diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt index 7d359c133..a871ac250 100644 --- a/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt +++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -8,6 +28,7 @@ import com.vitorpamplona.amethyst.service.Nip96Retriever import com.vitorpamplona.amethyst.service.Nip96Uploader import com.vitorpamplona.amethyst.ui.actions.ImageDownloader import com.vitorpamplona.quartz.crypto.KeyPair +import java.util.Base64 import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertTrue import junit.framework.TestCase.fail @@ -15,103 +36,107 @@ import kotlinx.coroutines.runBlocking import org.junit.Assert import org.junit.Test import org.junit.runner.RunWith -import java.util.Base64 @RunWith(AndroidJUnit4::class) class ImageUploadTesting { - val contentType = "image/gif" - val image = "R0lGODlhPQBEAPeoAJosM//AwO/AwHVYZ/z595kzAP/s7P+goOXMv8+fhw/v739/f+8PD98fH/8mJl+fn/9ZWb8/PzWlwv///6wWGbImAPgTEMImIN9gUFCEm/gDALULDN8PAD6atYdCTX9gUNKlj8wZAKUsAOzZz+UMAOsJAP/Z2ccMDA8PD/95eX5NWvsJCOVNQPtfX/8zM8+QePLl38MGBr8JCP+zs9myn/8GBqwpAP/GxgwJCPny78lzYLgjAJ8vAP9fX/+MjMUcAN8zM/9wcM8ZGcATEL+QePdZWf/29uc/P9cmJu9MTDImIN+/r7+/vz8/P8VNQGNugV8AAF9fX8swMNgTAFlDOICAgPNSUnNWSMQ5MBAQEJE3QPIGAM9AQMqGcG9vb6MhJsEdGM8vLx8fH98AANIWAMuQeL8fABkTEPPQ0OM5OSYdGFl5jo+Pj/+pqcsTE78wMFNGQLYmID4dGPvd3UBAQJmTkP+8vH9QUK+vr8ZWSHpzcJMmILdwcLOGcHRQUHxwcK9PT9DQ0O/v70w5MLypoG8wKOuwsP/g4P/Q0IcwKEswKMl8aJ9fX2xjdOtGRs/Pz+Dg4GImIP8gIH0sKEAwKKmTiKZ8aB/f39Wsl+LFt8dgUE9PT5x5aHBwcP+AgP+WltdgYMyZfyywz78AAAAAAAD///8AAP9mZv///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAKgALAAAAAA9AEQAAAj/AFEJHEiwoMGDCBMqXMiwocAbBww4nEhxoYkUpzJGrMixogkfGUNqlNixJEIDB0SqHGmyJSojM1bKZOmyop0gM3Oe2liTISKMOoPy7GnwY9CjIYcSRYm0aVKSLmE6nfq05QycVLPuhDrxBlCtYJUqNAq2bNWEBj6ZXRuyxZyDRtqwnXvkhACDV+euTeJm1Ki7A73qNWtFiF+/gA95Gly2CJLDhwEHMOUAAuOpLYDEgBxZ4GRTlC1fDnpkM+fOqD6DDj1aZpITp0dtGCDhr+fVuCu3zlg49ijaokTZTo27uG7Gjn2P+hI8+PDPERoUB318bWbfAJ5sUNFcuGRTYUqV/3ogfXp1rWlMc6awJjiAAd2fm4ogXjz56aypOoIde4OE5u/F9x199dlXnnGiHZWEYbGpsAEA3QXYnHwEFliKAgswgJ8LPeiUXGwedCAKABACCN+EA1pYIIYaFlcDhytd51sGAJbo3onOpajiihlO92KHGaUXGwWjUBChjSPiWJuOO/LYIm4v1tXfE6J4gCSJEZ7YgRYUNrkji9P55sF/ogxw5ZkSqIDaZBV6aSGYq/lGZplndkckZ98xoICbTcIJGQAZcNmdmUc210hs35nCyJ58fgmIKX5RQGOZowxaZwYA+JaoKQwswGijBV4C6SiTUmpphMspJx9unX4KaimjDv9aaXOEBteBqmuuxgEHoLX6Kqx+yXqqBANsgCtit4FWQAEkrNbpq7HSOmtwag5w57GrmlJBASEU18ADjUYb3ADTinIttsgSB1oJFfA63bduimuqKB1keqwUhoCSK374wbujvOSu4QG6UvxBRydcpKsav++Ca6G8A6Pr1x2kVMyHwsVxUALDq/krnrhPSOzXG1lUTIoffqGR7Goi2MAxbv6O2kEG56I7CSlRsEFKFVyovDJoIRTg7sugNRDGqCJzJgcKE0ywc0ELm6KBCCJo8DIPFeCWNGcyqNFE06ToAfV0HBRgxsvLThHn1oddQMrXj5DyAQgjEHSAJMWZwS3HPxT/QMbabI/iBCliMLEJKX2EEkomBAUCxRi42VDADxyTYDVogV+wSChqmKxEKCDAYFDFj4OmwbY7bDGdBhtrnTQYOigeChUmc1K3QTnAUfEgGFgAWt88hKA6aCRIXhxnQ1yg3BCayK44EWdkUQcBByEQChFXfCB776aQsG0BIlQgQgE8qO26X1h8cEUep8ngRBnOy74E9QgRgEAC8SvOfQkh7FDBDmS43PmGoIiKUUEGkMEC/PJHgxw0xH74yx/3XnaYRJgMB8obxQW6kL9QYEJ0FIFgByfIL7/IQAlvQwEpnAC7DtLNJCKUoO/w45c44GwCXiAFB/OXAATQryUxdN4LfFiwgjCNYg+kYMIEFkCKDs6PKAIJouyGWMS1FSKJOMRB/BoIxYJIUXFUxNwoIkEKPAgCBZSQHQ1A2EWDfDEUVLyADj5AChSIQW6gu10bE/JG2VnCZGfo4R4d0sdQoBAHhPjhIB94v/wRoRKQWGRHgrhGSQJxCS+0pCZbEhAAOw==" + val contentType = "image/gif" + val image = + "R0lGODlhPQBEAPeoAJosM//AwO/AwHVYZ/z595kzAP/s7P+goOXMv8+fhw/v739/f+8PD98fH/8mJl+fn/9ZWb8/PzWlwv///6wWGbImAPgTEMImIN9gUFCEm/gDALULDN8PAD6atYdCTX9gUNKlj8wZAKUsAOzZz+UMAOsJAP/Z2ccMDA8PD/95eX5NWvsJCOVNQPtfX/8zM8+QePLl38MGBr8JCP+zs9myn/8GBqwpAP/GxgwJCPny78lzYLgjAJ8vAP9fX/+MjMUcAN8zM/9wcM8ZGcATEL+QePdZWf/29uc/P9cmJu9MTDImIN+/r7+/vz8/P8VNQGNugV8AAF9fX8swMNgTAFlDOICAgPNSUnNWSMQ5MBAQEJE3QPIGAM9AQMqGcG9vb6MhJsEdGM8vLx8fH98AANIWAMuQeL8fABkTEPPQ0OM5OSYdGFl5jo+Pj/+pqcsTE78wMFNGQLYmID4dGPvd3UBAQJmTkP+8vH9QUK+vr8ZWSHpzcJMmILdwcLOGcHRQUHxwcK9PT9DQ0O/v70w5MLypoG8wKOuwsP/g4P/Q0IcwKEswKMl8aJ9fX2xjdOtGRs/Pz+Dg4GImIP8gIH0sKEAwKKmTiKZ8aB/f39Wsl+LFt8dgUE9PT5x5aHBwcP+AgP+WltdgYMyZfyywz78AAAAAAAD///8AAP9mZv///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAKgALAAAAAA9AEQAAAj/AFEJHEiwoMGDCBMqXMiwocAbBww4nEhxoYkUpzJGrMixogkfGUNqlNixJEIDB0SqHGmyJSojM1bKZOmyop0gM3Oe2liTISKMOoPy7GnwY9CjIYcSRYm0aVKSLmE6nfq05QycVLPuhDrxBlCtYJUqNAq2bNWEBj6ZXRuyxZyDRtqwnXvkhACDV+euTeJm1Ki7A73qNWtFiF+/gA95Gly2CJLDhwEHMOUAAuOpLYDEgBxZ4GRTlC1fDnpkM+fOqD6DDj1aZpITp0dtGCDhr+fVuCu3zlg49ijaokTZTo27uG7Gjn2P+hI8+PDPERoUB318bWbfAJ5sUNFcuGRTYUqV/3ogfXp1rWlMc6awJjiAAd2fm4ogXjz56aypOoIde4OE5u/F9x199dlXnnGiHZWEYbGpsAEA3QXYnHwEFliKAgswgJ8LPeiUXGwedCAKABACCN+EA1pYIIYaFlcDhytd51sGAJbo3onOpajiihlO92KHGaUXGwWjUBChjSPiWJuOO/LYIm4v1tXfE6J4gCSJEZ7YgRYUNrkji9P55sF/ogxw5ZkSqIDaZBV6aSGYq/lGZplndkckZ98xoICbTcIJGQAZcNmdmUc210hs35nCyJ58fgmIKX5RQGOZowxaZwYA+JaoKQwswGijBV4C6SiTUmpphMspJx9unX4KaimjDv9aaXOEBteBqmuuxgEHoLX6Kqx+yXqqBANsgCtit4FWQAEkrNbpq7HSOmtwag5w57GrmlJBASEU18ADjUYb3ADTinIttsgSB1oJFfA63bduimuqKB1keqwUhoCSK374wbujvOSu4QG6UvxBRydcpKsav++Ca6G8A6Pr1x2kVMyHwsVxUALDq/krnrhPSOzXG1lUTIoffqGR7Goi2MAxbv6O2kEG56I7CSlRsEFKFVyovDJoIRTg7sugNRDGqCJzJgcKE0ywc0ELm6KBCCJo8DIPFeCWNGcyqNFE06ToAfV0HBRgxsvLThHn1oddQMrXj5DyAQgjEHSAJMWZwS3HPxT/QMbabI/iBCliMLEJKX2EEkomBAUCxRi42VDADxyTYDVogV+wSChqmKxEKCDAYFDFj4OmwbY7bDGdBhtrnTQYOigeChUmc1K3QTnAUfEgGFgAWt88hKA6aCRIXhxnQ1yg3BCayK44EWdkUQcBByEQChFXfCB776aQsG0BIlQgQgE8qO26X1h8cEUep8ngRBnOy74E9QgRgEAC8SvOfQkh7FDBDmS43PmGoIiKUUEGkMEC/PJHgxw0xH74yx/3XnaYRJgMB8obxQW6kL9QYEJ0FIFgByfIL7/IQAlvQwEpnAC7DtLNJCKUoO/w45c44GwCXiAFB/OXAATQryUxdN4LfFiwgjCNYg+kYMIEFkCKDs6PKAIJouyGWMS1FSKJOMRB/BoIxYJIUXFUxNwoIkEKPAgCBZSQHQ1A2EWDfDEUVLyADj5AChSIQW6gu10bE/JG2VnCZGfo4R4d0sdQoBAHhPjhIB94v/wRoRKQWGRHgrhGSQJxCS+0pCZbEhAAOw==" - val contentTypePng = "image/png" - val imagePng = "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII=" + val contentTypePng = "image/png" + val imagePng = + "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII=" - private suspend fun testBase(server: Nip96MediaServers.ServerName) { - val serverInfo = Nip96Retriever().loadInfo( - server.baseUrl + private suspend fun testBase(server: Nip96MediaServers.ServerName) { + val serverInfo = + Nip96Retriever() + .loadInfo( + server.baseUrl, ) - val bytes = Base64.getDecoder().decode(imagePng) - val inputStream = bytes.inputStream() + val bytes = Base64.getDecoder().decode(imagePng) + val inputStream = bytes.inputStream() - val account = Account(KeyPair()) + val account = Account(KeyPair()) - val result = Nip96Uploader(account).uploadImage( - inputStream, - bytes.size.toLong(), - contentTypePng, - alt = null, - sensitiveContent = null, - serverInfo, - onProgress = { - } + val result = + Nip96Uploader(account) + .uploadImage( + inputStream, + bytes.size.toLong(), + contentTypePng, + alt = null, + sensitiveContent = null, + serverInfo, + onProgress = {}, ) - val url = result.tags!!.first() { it[0] == "url" }.get(1) - val size = result.tags!!.firstOrNull() { it[0] == "size" }?.get(1)?.ifBlank { null } - val dim = result.tags!!.firstOrNull() { it[0] == "dim" }?.get(1)?.ifBlank { null } - val hash = result.tags!!.firstOrNull() { it[0] == "x" }?.get(1)?.ifBlank { null } - val contentType = result.tags!!.first() { it[0] == "m" }.get(1) - val ox = result.tags!!.first() { it[0] == "ox" }.get(1) + val url = result.tags!!.first { it[0] == "url" }.get(1) + val size = result.tags!!.firstOrNull { it[0] == "size" }?.get(1)?.ifBlank { null } + val dim = result.tags!!.firstOrNull { it[0] == "dim" }?.get(1)?.ifBlank { null } + val hash = result.tags!!.firstOrNull { it[0] == "x" }?.get(1)?.ifBlank { null } + val contentType = result.tags!!.first { it[0] == "m" }.get(1) + val ox = result.tags!!.first { it[0] == "ox" }.get(1) - Assert.assertTrue(url.startsWith("http")) + Assert.assertTrue(url.startsWith("http")) - val imageData: ByteArray = ImageDownloader().waitAndGetImage(url) ?: run { - fail("Should not be null") - return + val imageData: ByteArray = + ImageDownloader().waitAndGetImage(url) + ?: run { + fail("Should not be null") + return } - FileHeader.prepare( - imageData, - contentTypePng, - null, - onReady = { - if (dim != null) { - assertEquals(dim, it.dim) - } - if (size != null) { - assertEquals(size, it.size.toString()) - } - if (hash != null) { - assertEquals(hash, it.hash) - } - }, - onError = { - fail("It should not fail") - } - ) + FileHeader.prepare( + imageData, + contentTypePng, + null, + onReady = { + if (dim != null) { + assertEquals(dim, it.dim) + } + if (size != null) { + assertEquals(size, it.size.toString()) + } + if (hash != null) { + assertEquals(hash, it.hash) + } + }, + onError = { fail("It should not fail") }, + ) - // delay(1000) + // delay(1000) - // assertTrue(Nip96Uploader(account).delete(ox, contentType, serverInfo)) - } + // assertTrue(Nip96Uploader(account).delete(ox, contentType, serverInfo)) + } - @Test() - fun testNostrCheck() = runBlocking { - testBase(Nip96MediaServers.ServerName("nostrcheck.me", "https://nostrcheck.me")) - } + @Test() + fun testNostrCheck() = runBlocking { + testBase(Nip96MediaServers.ServerName("nostrcheck.me", "https://nostrcheck.me")) + } - @Test() - fun testNostrage() = runBlocking { - testBase(Nip96MediaServers.ServerName("nostrage", "https://nostrage.com")) - } + @Test() + fun testNostrage() = runBlocking { + testBase(Nip96MediaServers.ServerName("nostrage", "https://nostrage.com")) + } - @Test() - fun testSove() = runBlocking { - testBase(Nip96MediaServers.ServerName("sove", "https://sove.rent")) - } + @Test() + fun testSove() = runBlocking { + testBase(Nip96MediaServers.ServerName("sove", "https://sove.rent")) + } - @Test() - fun testNostrBuild() = runBlocking { - testBase(Nip96MediaServers.ServerName("nostr.build", "https://nostr.build")) - } + @Test() + fun testNostrBuild() = runBlocking { + testBase(Nip96MediaServers.ServerName("nostr.build", "https://nostr.build")) + } - @Test() - fun testSovbit() = runBlocking { - testBase(Nip96MediaServers.ServerName("sovbit", "https://files.sovbit.host")) - } + @Test() + fun testSovbit() = runBlocking { + testBase(Nip96MediaServers.ServerName("sovbit", "https://files.sovbit.host")) + } - @Test() - fun testVoidCat() = runBlocking { - testBase(Nip96MediaServers.ServerName("void.cat", "https://void.cat")) - } + @Test() + fun testVoidCat() = runBlocking { + testBase(Nip96MediaServers.ServerName("void.cat", "https://void.cat")) + } } diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/RichTextParserTest.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/RichTextParserTest.kt index f9f8cf2c3..b7b0318f8 100644 --- a/app/src/androidTest/java/com/vitorpamplona/amethyst/RichTextParserTest.kt +++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/RichTextParserTest.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -10,14 +30,15 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class RichTextParserTest { - private val textToParse = """ + private val textToParse = + """ ๐Ÿ“ฐ 24 Hour NostrInspector Report ๐Ÿ•ต (TEXT ONLY VERSION) Generated Friday June 30 2023 03:59:01 UTC-6 (CST) Network statistics - New events witnessed (top 110 relays) + New events witnessed (top 110 relays) Kind, count, (% count), size, (% size) 1, 207.9K, (28.8%), 458.02MB, (9.2%) @@ -101,48 +122,48 @@ class RichTextParserTest { #hashtag, mentions today, days in top 30 - #bitcoin, 1.7K, 109 - #concussion, 1.1K, 25 - #press, 0.9K, 65 - #france, 492, 46 - #presse, 480, 42 - #covid19, 465, 65 - #nostr, 414, 109 - #zapathon, 386, 76 - #rssfeed, 309, 53 - #btc, 299, 109 - #news, 294, 91 - #zap, 283, 109 - #linux, 253, 88 - #respond, 246, 90 - #kompost, 240, 31 - #plebchain, 236, 109 - #gardenaward, 236, 31 - #start, 236, 31 - #unicef, 233, 32 - #coronavirus, 233, 33 - #bew, 229, 31 - #balkon, 229, 31 - #terrasse, 229, 31 - #braininjuryawareness, 229, 24 - #garten, 220, 21 - #smart, 220, 21 - #nsfw, 211, 85 - #protoncalendar, 206, 31 - #stacksats, 195, 99 - #nokyc, 179, 98 + #bitcoin, 1.7K, 109 + #concussion, 1.1K, 25 + #press, 0.9K, 65 + #france, 492, 46 + #presse, 480, 42 + #covid19, 465, 65 + #nostr, 414, 109 + #zapathon, 386, 76 + #rssfeed, 309, 53 + #btc, 299, 109 + #news, 294, 91 + #zap, 283, 109 + #linux, 253, 88 + #respond, 246, 90 + #kompost, 240, 31 + #plebchain, 236, 109 + #gardenaward, 236, 31 + #start, 236, 31 + #unicef, 233, 32 + #coronavirus, 233, 33 + #bew, 229, 31 + #balkon, 229, 31 + #terrasse, 229, 31 + #braininjuryawareness, 229, 24 + #garten, 220, 21 + #smart, 220, 21 + #nsfw, 211, 85 + #protoncalendar, 206, 31 + #stacksats, 195, 99 + #nokyc, 179, 98 Emoji sentiment today - โšก (1.6K) ๐Ÿ‘‰ (1.4K) ๐Ÿ‡ช๐Ÿ‡บ (1.2K) ๐Ÿซ‚ (1.2K) ๐Ÿ‡บ๐Ÿ‡ธ (1.1K) ๐Ÿ’œ (875) ๐Ÿง  (858) ๐Ÿ˜‚ (830) ๐Ÿ”ฅ (690) ๐Ÿคฃ (566) ๐Ÿค™ (525) โ˜• (444) ๐Ÿ‘‡ (443) ๐Ÿ™Œ๐Ÿป (425) โ˜€ (307) ๐Ÿ˜Ž (305) ๐Ÿฅณ (301) ๐Ÿค” (276) ๐ŸŒป (270) ๐Ÿงก (270) ๐Ÿฅ‡ (269) ๐Ÿ—“ (269) ๐Ÿ™ (268) ๐Ÿ† (267) ๐ŸŒฑ (264) ๐Ÿ“ฐ (230) ๐Ÿ‰ (221) ๐Ÿ˜ญ (220) ๐Ÿ’ฐ (219) ๐Ÿ”— (209) ๐Ÿ‘€ (201) ๐Ÿ˜… (199) โœจ (193) ๐Ÿ‡ท๐Ÿ‡บ (182) ๐Ÿ’ช (167) โœ… (164) ๐Ÿ’ค (163) ๐Ÿถ (151) ๐Ÿ‡จ๐Ÿ‡ญ (141) ๐Ÿ“ (137) ๐Ÿ˜ (136) ๐ŸŒž (136) ๐Ÿพ (136) โค (132) ๐Ÿ’ป (126) ๐Ÿš€ (125) ๐Ÿ‘ (125) ๐Ÿ‡ง๐Ÿ‡ท (125) ๐Ÿ˜Š (121) ๐Ÿ“š (120) โžก (120) ๐Ÿ‘ (118) ๐ŸŽ‰ (117) ๐ŸŽฎ (115) ๐Ÿคท (113) ๐Ÿ‘‹ (112) ๐Ÿ’ƒ (108) ๐Ÿ•บ๐Ÿป (106) ๐Ÿ’ก (104) ๐Ÿšจ (99) ๐Ÿ˜† (97) ๐Ÿ’ฏ (95) โš  (92) ๐Ÿ“ข (92) ๐Ÿค— (89) ๐Ÿ˜ด (87) ๐Ÿ” (83) ๐Ÿฐ (81) ๐Ÿ˜€ (79) ๐ŸŽŸ (78) โ› (78) ๐Ÿฆ (76) ๐Ÿ’ธ (76) โœŒ๐Ÿป (75) ๐Ÿค (73) ๐Ÿ‡ฌ๐Ÿ‡ง (73) ๐ŸŒฝ (70) ๐Ÿคก (69) ๐Ÿคฎ (69) โ— (66) ๐Ÿค (65) ๐Ÿ˜‰ (65) ๐Ÿ™‡ (65) ๐Ÿป (64) ๐ŸŒ (64) ๐Ÿ’• (63) ๐ŸŒธ (62) ๐Ÿ’ฌ (61) โ˜บ (61) ๐Ÿ‡ฆ๐Ÿ‡ท (59) ๐Ÿ‡ฎ๐Ÿ‡ฉ (57) ๐Ÿ˜ณ (57) ๐Ÿ˜„ (57) ๐ŸŽถ (57) ๐Ÿฅท๐Ÿป (56) ๐ŸŽต (56) ๐Ÿ˜ƒ (56) ๐Ÿ” (55) ๐Ÿ’ฅ (55) ๐ŸŽฒ (54) โœ (54) ๐Ÿ•’ (53) โฌ‡ (53) ๐Ÿ’™ (51) ๐Ÿ”’ (50) ๐Ÿ“ˆ (50) ๐Ÿช™ (50) ๐ŸŒง (50) ๐Ÿฅฐ (50) ๐Ÿ•ธ (50) ๐ŸŒ (50) ๐Ÿ’ญ (49) ๐ŸŒ™ (49) ๐Ÿ˜ (49) ๐Ÿ“ฑ (48) ๐ŸŒŸ (48) ๐Ÿคฉ (48) ๐Ÿ’” (47) ๐Ÿ”Œ (47) ๐Ÿ˜‹ (47) ๐ŸŽ– (47) ๐Ÿฃ (46) ๐Ÿ“ท (46) ๐Ÿ’ผ (45) โญ (45) ๐Ÿฅ” (45) ๐Ÿฅบ (45) ๐Ÿ‘Œ (44) ๐Ÿ‘ท๐Ÿผ (43) ๐Ÿ˜ฑ (43) ๐Ÿ“… (43) ๐Ÿค– (43) ๐Ÿ“ธ (42) ๐Ÿ“Š (42) ๐Ÿฆ‘ (40) ๐Ÿ’ต (40) ๐Ÿคฆ (39) โฃ (38) ๐Ÿ’Ž (38) ๐Ÿ–ค (38) ๐Ÿ“บ (37) ๐Ÿ‡ต๐Ÿ‡ฑ (37) ๐Ÿ‡ฏ๐Ÿ‡ต (36) ๐Ÿ”ง (36) ๐Ÿค˜ (36) ๐Ÿ’– (36) โ€ผ (35) ๐Ÿ˜ข (35) ๐Ÿ˜บ (34) ๐Ÿ”Š (34) ๐Ÿ˜ (34) ๐Ÿ‡ธ๐Ÿ‡ฐ (34) ๐Ÿƒ (34) ๐Ÿ‘ฉโ€๐Ÿ‘ง (34) โฐ (33) ๐Ÿ‘จโ€๐Ÿ’ป (33) ๐Ÿ‘‘ (33) ๐Ÿ‘ฅ (32) ๐Ÿ–ฅ (32) ๐Ÿ’จ (32) ๐Ÿ’— (31) ๐Ÿ‡ฒ๐Ÿ‡ฝ (31) ๐Ÿ“– (31) ๐Ÿšซ (31) ๐Ÿ‘Š๐Ÿป (31) ๐Ÿ˜ก (31) ๐ŸŒŽ (31) ๐Ÿ‘ (30) ๐Ÿ—ž (30) ๐Ÿ€ (30) ๐Ÿฝ (29) ๐Ÿธ (29) ๐Ÿฅš (29) ๐Ÿ’ฉ (29) โœŠ๐Ÿพ (29) ๐Ÿ˜ฎ (29) ๐ŸŒก (29) ๐Ÿ™ƒ (28) ๐Ÿ”” (28) ๐Ÿ‡ป๐Ÿ‡ช (28) ๐Ÿ’ฆ (28) ๐ŸŽฏ (28) ๐ŸŽจ (28) ๐Ÿ› (28) ๐Ÿ–ผ (27) โ˜๐Ÿป (27) ๐Ÿ›‘ (27) ๐Ÿ™„ (27) ๐Ÿง‘๐Ÿปโ€๐Ÿคโ€๐Ÿง‘๐Ÿฝ (27) ๐ŸŒˆ (27) ๐Ÿฅ‚ (26) ๐Ÿ‡ซ๐Ÿ‡ฎ (26) ๐ŸŽฅ (26) ๐Ÿ˜ฌ (26) ๐Ÿฅฒ (25) ๐Ÿฆพ (24) ๐Ÿคœ (24) ๐Ÿ™‚ (24) ๐Ÿ–• (24) ๐Ÿ˜ฉ (24) + โšก (1.6K) ๐Ÿ‘‰ (1.4K) ๐Ÿ‡ช๐Ÿ‡บ (1.2K) ๐Ÿซ‚ (1.2K) ๐Ÿ‡บ๐Ÿ‡ธ (1.1K) ๐Ÿ’œ (875) ๐Ÿง  (858) ๐Ÿ˜‚ (830) ๐Ÿ”ฅ (690) ๐Ÿคฃ (566) ๐Ÿค™ (525) โ˜• (444) ๐Ÿ‘‡ (443) ๐Ÿ™Œ๐Ÿป (425) โ˜€ (307) ๐Ÿ˜Ž (305) ๐Ÿฅณ (301) ๐Ÿค” (276) ๐ŸŒป (270) ๐Ÿงก (270) ๐Ÿฅ‡ (269) ๐Ÿ—“ (269) ๐Ÿ™ (268) ๐Ÿ† (267) ๐ŸŒฑ (264) ๐Ÿ“ฐ (230) ๐Ÿ‰ (221) ๐Ÿ˜ญ (220) ๐Ÿ’ฐ (219) ๐Ÿ”— (209) ๐Ÿ‘€ (201) ๐Ÿ˜… (199) โœจ (193) ๐Ÿ‡ท๐Ÿ‡บ (182) ๐Ÿ’ช (167) โœ… (164) ๐Ÿ’ค (163) ๐Ÿถ (151) ๐Ÿ‡จ๐Ÿ‡ญ (141) ๐Ÿ“ (137) ๐Ÿ˜ (136) ๐ŸŒž (136) ๐Ÿพ (136) โค (132) ๐Ÿ’ป (126) ๐Ÿš€ (125) ๐Ÿ‘ (125) ๐Ÿ‡ง๐Ÿ‡ท (125) ๐Ÿ˜Š (121) ๐Ÿ“š (120) โžก (120) ๐Ÿ‘ (118) ๐ŸŽ‰ (117) ๐ŸŽฎ (115) ๐Ÿคท (113) ๐Ÿ‘‹ (112) ๐Ÿ’ƒ (108) ๐Ÿ•บ๐Ÿป (106) ๐Ÿ’ก (104) ๐Ÿšจ (99) ๐Ÿ˜† (97) ๐Ÿ’ฏ (95) โš  (92) ๐Ÿ“ข (92) ๐Ÿค— (89) ๐Ÿ˜ด (87) ๐Ÿ” (83) ๐Ÿฐ (81) ๐Ÿ˜€ (79) ๐ŸŽŸ (78) โ› (78) ๐Ÿฆ (76) ๐Ÿ’ธ (76) โœŒ๐Ÿป (75) ๐Ÿค (73) ๐Ÿ‡ฌ๐Ÿ‡ง (73) ๐ŸŒฝ (70) ๐Ÿคก (69) ๐Ÿคฎ (69) โ— (66) ๐Ÿค (65) ๐Ÿ˜‰ (65) ๐Ÿ™‡ (65) ๐Ÿป (64) ๐ŸŒ (64) ๐Ÿ’• (63) ๐ŸŒธ (62) ๐Ÿ’ฌ (61) โ˜บ (61) ๐Ÿ‡ฆ๐Ÿ‡ท (59) ๐Ÿ‡ฎ๐Ÿ‡ฉ (57) ๐Ÿ˜ณ (57) ๐Ÿ˜„ (57) ๐ŸŽถ (57) ๐Ÿฅท๐Ÿป (56) ๐ŸŽต (56) ๐Ÿ˜ƒ (56) ๐Ÿ” (55) ๐Ÿ’ฅ (55) ๐ŸŽฒ (54) โœ (54) ๐Ÿ•’ (53) โฌ‡ (53) ๐Ÿ’™ (51) ๐Ÿ”’ (50) ๐Ÿ“ˆ (50) ๐Ÿช™ (50) ๐ŸŒง (50) ๐Ÿฅฐ (50) ๐Ÿ•ธ (50) ๐ŸŒ (50) ๐Ÿ’ญ (49) ๐ŸŒ™ (49) ๐Ÿ˜ (49) ๐Ÿ“ฑ (48) ๐ŸŒŸ (48) ๐Ÿคฉ (48) ๐Ÿ’” (47) ๐Ÿ”Œ (47) ๐Ÿ˜‹ (47) ๐ŸŽ– (47) ๐Ÿฃ (46) ๐Ÿ“ท (46) ๐Ÿ’ผ (45) โญ (45) ๐Ÿฅ” (45) ๐Ÿฅบ (45) ๐Ÿ‘Œ (44) ๐Ÿ‘ท๐Ÿผ (43) ๐Ÿ˜ฑ (43) ๐Ÿ“… (43) ๐Ÿค– (43) ๐Ÿ“ธ (42) ๐Ÿ“Š (42) ๐Ÿฆ‘ (40) ๐Ÿ’ต (40) ๐Ÿคฆ (39) โฃ (38) ๐Ÿ’Ž (38) ๐Ÿ–ค (38) ๐Ÿ“บ (37) ๐Ÿ‡ต๐Ÿ‡ฑ (37) ๐Ÿ‡ฏ๐Ÿ‡ต (36) ๐Ÿ”ง (36) ๐Ÿค˜ (36) ๐Ÿ’– (36) โ€ผ (35) ๐Ÿ˜ข (35) ๐Ÿ˜บ (34) ๐Ÿ”Š (34) ๐Ÿ˜ (34) ๐Ÿ‡ธ๐Ÿ‡ฐ (34) ๐Ÿƒ (34) ๐Ÿ‘ฉโ€๐Ÿ‘ง (34) โฐ (33) ๐Ÿ‘จโ€๐Ÿ’ป (33) ๐Ÿ‘‘ (33) ๐Ÿ‘ฅ (32) ๐Ÿ–ฅ (32) ๐Ÿ’จ (32) ๐Ÿ’— (31) ๐Ÿ‡ฒ๐Ÿ‡ฝ (31) ๐Ÿ“– (31) ๐Ÿšซ (31) ๐Ÿ‘Š๐Ÿป (31) ๐Ÿ˜ก (31) ๐ŸŒŽ (31) ๐Ÿ‘ (30) ๐Ÿ—ž (30) ๐Ÿ€ (30) ๐Ÿฝ (29) ๐Ÿธ (29) ๐Ÿฅš (29) ๐Ÿ’ฉ (29) โœŠ๐Ÿพ (29) ๐Ÿ˜ฎ (29) ๐ŸŒก (29) ๐Ÿ™ƒ (28) ๐Ÿ”” (28) ๐Ÿ‡ป๐Ÿ‡ช (28) ๐Ÿ’ฆ (28) ๐ŸŽฏ (28) ๐ŸŽจ (28) ๐Ÿ› (28) ๐Ÿ–ผ (27) โ˜๐Ÿป (27) ๐Ÿ›‘ (27) ๐Ÿ™„ (27) ๐Ÿง‘๐Ÿปโ€๐Ÿคโ€๐Ÿง‘๐Ÿฝ (27) ๐ŸŒˆ (27) ๐Ÿฅ‚ (26) ๐Ÿ‡ซ๐Ÿ‡ฎ (26) ๐ŸŽฅ (26) ๐Ÿ˜ฌ (26) ๐Ÿฅฒ (25) ๐Ÿฆพ (24) ๐Ÿคœ (24) ๐Ÿ™‚ (24) ๐Ÿ–• (24) ๐Ÿ˜ฉ (24) Zap economy - โšก41.7M sats (โ‚ฟ0.417) + โšก41.7M sats (โ‚ฟ0.417) 1,816 zappers & 920 zapped (unique pubkeys) ๐ŸŒฉ๏ธ 33,248 zaps, 1,253 sats per zap (avg) - Most followed + Most followed #1 30% jb55, jb55@jb55.com - 32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245 #2 19% Snowden, Snowden@Nostr-Check.com - 84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240 @@ -650,3520 +671,3555 @@ class RichTextParserTest { New events inspected today: 720.71K (4.85GB) Average events inspected per second: 8.34 Uptime: Server 99.93%, NostrInspector: 99.93% - Spam estimate: + Spam estimate: 74.12 % About the NostrInspector Report - โœ… The 24 Hour NostrInspector Report is generated by listening for new events on the top relays using the Nostr Protocol. The statistics report that + โœ… The 24 Hour NostrInspector Report is generated by listening for new events on the top relays using the Nostr Protocol. The statistics report that it generates includes de data layer as well as the social layer. ๐Ÿ’œ To support this free effort share, like, comment or zap. - ๐Ÿซ‚ Thank you ๐Ÿ™ + ๐Ÿซ‚ Thank you ๐Ÿ™ - ๐Ÿ•ต๏ธ @nostrin "The Nostr Inspector" + ๐Ÿ•ต๏ธ @nostrin "The Nostr Inspector" npub17m7f7q08k4x746s2v45eyvwppck32dcahw7uj2mu5txuswldgqkqw9zms7 - """.trimIndent() + """ + .trimIndent() - @Test - fun testTextToParse() { - val state = RichTextParser().parseText(textToParse, EmptyTagList) + @Test + fun testTextToParse() { + val state = RichTextParser().parseText(textToParse, EmptyTagList) + Assert.assertEquals( + "relay.shitforce.one, relayable.org, universe.nostrich.land, nos.lol, universe.nostrich.land?lang=zh, universe.nostrich.land?lang=en, relay.damus.io, relay.nostr.wirednet.jp, offchain.pub, nostr.rocks, relay.wellorder.net, nostr.oxtr.dev, universe.nostrich.land?lang=ja, relay.mostr.pub, nostr.bitcoiner.social, Nostr-Check.com, MR.Rabbit, Ancap.su, zapper.lol, smies.me, baller.hodl", + state.urlSet.joinToString(", "), + ) + + printStateForDebug(state) + + val expectedResult = + listOf( + "RegularText(๐Ÿ“ฐ 24 Hour NostrInspector Report ๐Ÿ•ต (TEXT ONLY VERSION))", + "RegularText()", + "RegularText(Generated Friday June 30 2023 03:59:01 UTC-6 (CST))", + "RegularText()", + "RegularText(Network statistics)", + "RegularText()", + "RegularText(New events witnessed (top 110 relays) )", + "RegularText()", + "RegularText(Kind, count, (% count), size, (% size))", + "RegularText(1, 207.9K, (28.8%), 458.02MB, (9.2%))", + "RegularText(7, 158.3K, (22%), 280.83MB, (5.7%))", + "RegularText(0, 84.1K, (11.7%), 192.89MB, (3.9%))", + "RegularText(9735, 57.2K, (7.9%), 353.16MB, (7.1%))", + "RegularText(3, 54.7K, (7.6%), 2.75GB, (56.7%))", + "RegularText(6, 31.6K, (4.4%), 111.27MB, (2.2%))", + "RegularText(4, 30.8K, (4.3%), 89.79MB, (1.8%))", + "RegularText(30000, 29.1K, (4%), 115.33MB, (2.3%))", + "RegularText(30078, 12.1K, (1.7%), 317.25MB, (6.4%))", + "RegularText(5, 11K, (1.5%), 16.86MB, (0.3%))", + "RegularText(10002, 8.6K, (1.2%), 16.59MB, (0.3%))", + "RegularText(1311, 7.7K, (1.1%), 12.71MB, (0.3%))", + "RegularText(1984, 6.3K, (0.9%), 10.93MB, (0.2%))", + "RegularText(9734, 3.7K, (0.5%), 10.88MB, (0.2%))", + "RegularText(30001, 3.1K, (0.4%), 66.91MB, (1.3%))", + "RegularText(1000, 2.8K, (0.4%), 13.43MB, (0.3%))", + "RegularText(20100, 1.4K, (0.2%), 2.32MB, (0%))", + "RegularText(42, 1.1K, (0.2%), 2.30MB, (0%))", + "RegularText(13194, 1K, (0.1%), 1.22MB, (0%))", + "RegularText(1063, 875, (0.1%), 1.96MB, (0%))", + "RegularText()", + "RegularText(New events by relay (top 50%))", + "RegularText()", + "RegularText(Events (%) Relay)", + "RegularText(33.4K)", + "RegularText((4.6%))", + "Link(relay.shitforce.one)", + "RegularText(32.9K)", + "RegularText((4.6%))", + "Link(relayable.org)", + "RegularText(26.6K)", + "RegularText((3.7%))", + "Link(universe.nostrich.land)", + "RegularText(22.8K)", + "RegularText((3.2%))", + "Link(nos.lol)", + "RegularText(22.7K)", + "RegularText((3.1%))", + "Link(universe.nostrich.land?lang=zh)", + "RegularText(22.5K)", + "RegularText((3.1%))", + "Link(universe.nostrich.land?lang=en)", + "RegularText(21.2K)", + "RegularText((2.9%))", + "Link(relay.damus.io)", + "RegularText(20.6K)", + "RegularText((2.9%))", + "Link(relay.nostr.wirednet.jp)", + "RegularText(20.1K)", + "RegularText((2.8%))", + "Link(offchain.pub)", + "RegularText(19.9K)", + "RegularText((2.8%))", + "Link(nostr.rocks)", + "RegularText(19.5K)", + "RegularText((2.7%))", + "Link(relay.wellorder.net)", + "RegularText(19.4K)", + "RegularText((2.7%))", + "Link(nostr.oxtr.dev)", + "RegularText(19K)", + "RegularText((2.6%))", + "Link(universe.nostrich.land?lang=ja)", + "RegularText(18.4K)", + "RegularText((2.6%))", + "Link(relay.mostr.pub)", + "RegularText(17.5K)", + "RegularText((2.4%))", + "Link(universe.nostrich.land?lang=zh)", + "RegularText(16.3K)", + "RegularText((2.3%))", + "Link(nostr.bitcoiner.social)", + "RegularText()", + "RegularText(30 day global new events)", + "RegularText()", + "RegularText(23-05-29 1M)", + "RegularText(23-05-30 861.9K)", + "RegularText(23-05-31 752.5K)", + "RegularText(23-06-01 0.9M)", + "RegularText(23-06-02 808.9K)", + "RegularText(23-06-03 683.8K)", + "RegularText(23-06-04 0.9M)", + "RegularText(23-06-05 890.6K)", + "RegularText(23-06-06 839.4K)", + "RegularText(23-06-07 827K)", + "RegularText(23-06-08 804.8K)", + "RegularText(23-06-09 736.7K)", + "RegularText(23-06-10 709.7K)", + "RegularText(23-06-11 772.2K)", + "RegularText(23-06-12 882K)", + "RegularText(23-06-13 794.9K)", + "RegularText(23-06-14 842.2K)", + "RegularText(23-06-15 812.1K)", + "RegularText(23-06-16 839.6K)", + "RegularText(23-06-17 730.2K)", + "RegularText(23-06-18 811.9K)", + "RegularText(23-06-19 721.9K)", + "RegularText(23-06-20 786.2K)", + "RegularText(23-06-21 756.6K)", + "RegularText(23-06-22 736K)", + "RegularText(23-06-23 723.5K)", + "RegularText(23-06-24 703.9K)", + "RegularText(23-06-25 734.9K)", + "RegularText(23-06-26 742.4K)", + "RegularText(23-06-27 707.8K)", + "RegularText(23-06-28 747.7K)", + "RegularText()", + "RegularText(Social Network Statistics)", + "RegularText()", + "RegularText(Top 30 hashtags found today)", + "RegularText()", + "HashTag(#hashtag,)", + "RegularText(mentions)", + "RegularText(today,)", + "RegularText(days)", + "RegularText(in)", + "RegularText(top)", + "RegularText(30)", + "RegularText()", + "HashTag(#bitcoin,)", + "RegularText(1.7K,)", + "RegularText(109)", + "HashTag(#concussion,)", + "RegularText(1.1K,)", + "RegularText(25)", + "HashTag(#press,)", + "RegularText(0.9K,)", + "RegularText(65)", + "HashTag(#france,)", + "RegularText(492,)", + "RegularText(46)", + "HashTag(#presse,)", + "RegularText(480,)", + "RegularText(42)", + "HashTag(#covid19,)", + "RegularText(465,)", + "RegularText(65)", + "HashTag(#nostr,)", + "RegularText(414,)", + "RegularText(109)", + "HashTag(#zapathon,)", + "RegularText(386,)", + "RegularText(76)", + "HashTag(#rssfeed,)", + "RegularText(309,)", + "RegularText(53)", + "HashTag(#btc,)", + "RegularText(299,)", + "RegularText(109)", + "HashTag(#news,)", + "RegularText(294,)", + "RegularText(91)", + "HashTag(#zap,)", + "RegularText(283,)", + "RegularText(109)", + "HashTag(#linux,)", + "RegularText(253,)", + "RegularText(88)", + "HashTag(#respond,)", + "RegularText(246,)", + "RegularText(90)", + "HashTag(#kompost,)", + "RegularText(240,)", + "RegularText(31)", + "HashTag(#plebchain,)", + "RegularText(236,)", + "RegularText(109)", + "HashTag(#gardenaward,)", + "RegularText(236,)", + "RegularText(31)", + "HashTag(#start,)", + "RegularText(236,)", + "RegularText(31)", + "HashTag(#unicef,)", + "RegularText(233,)", + "RegularText(32)", + "HashTag(#coronavirus,)", + "RegularText(233,)", + "RegularText(33)", + "HashTag(#bew,)", + "RegularText(229,)", + "RegularText(31)", + "HashTag(#balkon,)", + "RegularText(229,)", + "RegularText(31)", + "HashTag(#terrasse,)", + "RegularText(229,)", + "RegularText(31)", + "HashTag(#braininjuryawareness,)", + "RegularText(229,)", + "RegularText(24)", + "HashTag(#garten,)", + "RegularText(220,)", + "RegularText(21)", + "HashTag(#smart,)", + "RegularText(220,)", + "RegularText(21)", + "HashTag(#nsfw,)", + "RegularText(211,)", + "RegularText(85)", + "HashTag(#protoncalendar,)", + "RegularText(206,)", + "RegularText(31)", + "HashTag(#stacksats,)", + "RegularText(195,)", + "RegularText(99)", + "HashTag(#nokyc,)", + "RegularText(179,)", + "RegularText(98)", + "RegularText()", + "RegularText(Emoji sentiment today)", + "RegularText()", + "RegularText(โšก (1.6K) ๐Ÿ‘‰ (1.4K) ๐Ÿ‡ช๐Ÿ‡บ (1.2K) ๐Ÿซ‚ (1.2K) ๐Ÿ‡บ๐Ÿ‡ธ (1.1K) ๐Ÿ’œ (875) ๐Ÿง  (858) ๐Ÿ˜‚ (830) ๐Ÿ”ฅ (690) ๐Ÿคฃ (566) ๐Ÿค™ (525) โ˜• (444) ๐Ÿ‘‡ (443) ๐Ÿ™Œ๐Ÿป (425) โ˜€ (307) ๐Ÿ˜Ž (305) ๐Ÿฅณ (301) ๐Ÿค” (276) ๐ŸŒป (270) ๐Ÿงก (270) ๐Ÿฅ‡ (269) ๐Ÿ—“ (269) ๐Ÿ™ (268) ๐Ÿ† (267) ๐ŸŒฑ (264) ๐Ÿ“ฐ (230) ๐Ÿ‰ (221) ๐Ÿ˜ญ (220) ๐Ÿ’ฐ (219) ๐Ÿ”— (209) ๐Ÿ‘€ (201) ๐Ÿ˜… (199) โœจ (193) ๐Ÿ‡ท๐Ÿ‡บ (182) ๐Ÿ’ช (167) โœ… (164) ๐Ÿ’ค (163) ๐Ÿถ (151) ๐Ÿ‡จ๐Ÿ‡ญ (141) ๐Ÿ“ (137) ๐Ÿ˜ (136) ๐ŸŒž (136) ๐Ÿพ (136) โค (132) ๐Ÿ’ป (126) ๐Ÿš€ (125) ๐Ÿ‘ (125) ๐Ÿ‡ง๐Ÿ‡ท (125) ๐Ÿ˜Š (121) ๐Ÿ“š (120) โžก (120) ๐Ÿ‘ (118) ๐ŸŽ‰ (117) ๐ŸŽฎ (115) ๐Ÿคท (113) ๐Ÿ‘‹ (112) ๐Ÿ’ƒ (108) ๐Ÿ•บ๐Ÿป (106) ๐Ÿ’ก (104) ๐Ÿšจ (99) ๐Ÿ˜† (97) ๐Ÿ’ฏ (95) โš  (92) ๐Ÿ“ข (92) ๐Ÿค— (89) ๐Ÿ˜ด (87) ๐Ÿ” (83) ๐Ÿฐ (81) ๐Ÿ˜€ (79) ๐ŸŽŸ (78) โ› (78) ๐Ÿฆ (76) ๐Ÿ’ธ (76) โœŒ๐Ÿป (75) ๐Ÿค (73) ๐Ÿ‡ฌ๐Ÿ‡ง (73) ๐ŸŒฝ (70) ๐Ÿคก (69) ๐Ÿคฎ (69) โ— (66) ๐Ÿค (65) ๐Ÿ˜‰ (65) ๐Ÿ™‡ (65) ๐Ÿป (64) ๐ŸŒ (64) ๐Ÿ’• (63) ๐ŸŒธ (62) ๐Ÿ’ฌ (61) โ˜บ (61) ๐Ÿ‡ฆ๐Ÿ‡ท (59) ๐Ÿ‡ฎ๐Ÿ‡ฉ (57) ๐Ÿ˜ณ (57) ๐Ÿ˜„ (57) ๐ŸŽถ (57) ๐Ÿฅท๐Ÿป (56) ๐ŸŽต (56) ๐Ÿ˜ƒ (56) ๐Ÿ” (55) ๐Ÿ’ฅ (55) ๐ŸŽฒ (54) โœ (54) ๐Ÿ•’ (53) โฌ‡ (53) ๐Ÿ’™ (51) ๐Ÿ”’ (50) ๐Ÿ“ˆ (50) ๐Ÿช™ (50) ๐ŸŒง (50) ๐Ÿฅฐ (50) ๐Ÿ•ธ (50) ๐ŸŒ (50) ๐Ÿ’ญ (49) ๐ŸŒ™ (49) ๐Ÿ˜ (49) ๐Ÿ“ฑ (48) ๐ŸŒŸ (48) ๐Ÿคฉ (48) ๐Ÿ’” (47) ๐Ÿ”Œ (47) ๐Ÿ˜‹ (47) ๐ŸŽ– (47) ๐Ÿฃ (46) ๐Ÿ“ท (46) ๐Ÿ’ผ (45) โญ (45) ๐Ÿฅ” (45) ๐Ÿฅบ (45) ๐Ÿ‘Œ (44) ๐Ÿ‘ท๐Ÿผ (43) ๐Ÿ˜ฑ (43) ๐Ÿ“… (43) ๐Ÿค– (43) ๐Ÿ“ธ (42) ๐Ÿ“Š (42) ๐Ÿฆ‘ (40) ๐Ÿ’ต (40) ๐Ÿคฆ (39) โฃ (38) ๐Ÿ’Ž (38) ๐Ÿ–ค (38) ๐Ÿ“บ (37) ๐Ÿ‡ต๐Ÿ‡ฑ (37) ๐Ÿ‡ฏ๐Ÿ‡ต (36) ๐Ÿ”ง (36) ๐Ÿค˜ (36) ๐Ÿ’– (36) โ€ผ (35) ๐Ÿ˜ข (35) ๐Ÿ˜บ (34) ๐Ÿ”Š (34) ๐Ÿ˜ (34) ๐Ÿ‡ธ๐Ÿ‡ฐ (34) ๐Ÿƒ (34) ๐Ÿ‘ฉโ€๐Ÿ‘ง (34) โฐ (33) ๐Ÿ‘จโ€๐Ÿ’ป (33) ๐Ÿ‘‘ (33) ๐Ÿ‘ฅ (32) ๐Ÿ–ฅ (32) ๐Ÿ’จ (32) ๐Ÿ’— (31) ๐Ÿ‡ฒ๐Ÿ‡ฝ (31) ๐Ÿ“– (31) ๐Ÿšซ (31) ๐Ÿ‘Š๐Ÿป (31) ๐Ÿ˜ก (31) ๐ŸŒŽ (31) ๐Ÿ‘ (30) ๐Ÿ—ž (30) ๐Ÿ€ (30) ๐Ÿฝ (29) ๐Ÿธ (29) ๐Ÿฅš (29) ๐Ÿ’ฉ (29) โœŠ๐Ÿพ (29) ๐Ÿ˜ฎ (29) ๐ŸŒก (29) ๐Ÿ™ƒ (28) ๐Ÿ”” (28) ๐Ÿ‡ป๐Ÿ‡ช (28) ๐Ÿ’ฆ (28) ๐ŸŽฏ (28) ๐ŸŽจ (28) ๐Ÿ› (28) ๐Ÿ–ผ (27) โ˜๐Ÿป (27) ๐Ÿ›‘ (27) ๐Ÿ™„ (27) ๐Ÿง‘๐Ÿปโ€๐Ÿคโ€๐Ÿง‘๐Ÿฝ (27) ๐ŸŒˆ (27) ๐Ÿฅ‚ (26) ๐Ÿ‡ซ๐Ÿ‡ฎ (26) ๐ŸŽฅ (26) ๐Ÿ˜ฌ (26) ๐Ÿฅฒ (25) ๐Ÿฆพ (24) ๐Ÿคœ (24) ๐Ÿ™‚ (24) ๐Ÿ–• (24) ๐Ÿ˜ฉ (24) )", + "RegularText()", + "RegularText(Zap economy)", + "RegularText()", + "RegularText(โšก41.7M sats (โ‚ฟ0.417) )", + "RegularText(1,816 zappers & 920 zapped (unique pubkeys))", + "RegularText(๐ŸŒฉ๏ธ 33,248 zaps, 1,253 sats per zap (avg))", + "RegularText()", + "RegularText(Most followed )", + "RegularText()", + "HashTag(#1)", + "RegularText(30%)", + "RegularText(jb55,)", + "Email(jb55@jb55.com)", + "RegularText(-)", + "RegularText(32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245)", + "HashTag(#2)", + "RegularText(19%)", + "RegularText(Snowden,)", + "Email(Snowden@Nostr-Check.com)", + "RegularText(-)", + "RegularText(84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240)", + "HashTag(#3)", + "RegularText(18%)", + "RegularText(cameri,)", + "Email(cameri@elder.nostr.land)", + "RegularText(-)", + "RegularText(00000000827ffaa94bfea288c3dfce4422c794fbb96625b6b31e9049f729d700)", + "HashTag(#4)", + "RegularText(11%)", + "RegularText(Natalie,)", + "Email(natalie@NostrVerified.com)", + "RegularText(-)", + "RegularText(edcd20558f17d99327d841e4582f9b006331ac4010806efa020ef0d40078e6da)", + "HashTag(#5)", + "RegularText(11%)", + "RegularText(saifedean,)", + "RegularText()", + "RegularText(-)", + "RegularText(4379e76bfa76a80b8db9ea759211d90bb3e67b2202f8880cc4f5ffe2065061ad)", + "HashTag(#6)", + "RegularText(11%)", + "RegularText(alanbwt,)", + "Email(alanbwt@nostrplebs.com)", + "RegularText(-)", + "RegularText(1bd32a386a7be6f688b3dc7c480efc21cd946b43eac14ba4ba7834ac77a23e69)", + "HashTag(#7)", + "RegularText(10%)", + "RegularText(rick,)", + "Email(rick@no.str.cr)", + "RegularText(-)", + "RegularText(978c8f26ea9b3c58bfd4c8ddfde83741a6c2496fab72774109fe46819ca49708)", + "HashTag(#8)", + "RegularText(9%)", + "RegularText(shawn,)", + "Email(shawn@shawnyeager.com)", + "RegularText(-)", + "RegularText(c7eda660a6bc8270530e82b4a7712acdea2e31dc0a56f8dc955ac009efd97c86)", + "HashTag(#9)", + "RegularText(9%)", + "RegularText(0xtr,)", + "Email(0xtr@oxtr.dev)", + "RegularText(-)", + "RegularText(b2d670de53b27691c0c3400225b65c35a26d06093bcc41f48ffc71e0907f9d4a)", + "HashTag(#10)", + "RegularText(9%)", + "RegularText(stick,)", + "Email(pavol@rusnak.io)", + "RegularText(-)", + "RegularText(d7f0e3917c466f1e2233e9624fbd6d4bd1392dbcfcaf3574f457569d496cb731)", + "HashTag(#11)", + "RegularText(9%)", + "RegularText(caitlinlong,)", + "Email(caitlin@nostrverified.com)", + "RegularText(-)", + "RegularText(e1055729d51e037b3c14e8c56e2c79c22183385d94aadb32e5dc88092cd0fef4)", + "HashTag(#12)", + "RegularText(9%)", + "RegularText(ralf,)", + "Email(ralf@snort.social)", + "RegularText(-)", + "RegularText(c89cf36deea286da912d4145f7140c73495d77e2cfedfb652158daa7c771f2f8)", + "HashTag(#13)", + "RegularText(9%)", + "RegularText(StackSats,)", + "Email(stacksats@nostrplebs.com)", + "RegularText(-)", + "RegularText(b93049a6e2547a36a7692d90e4baa809012526175546a17337454def9ab69d30)", + "HashTag(#14)", + "RegularText(9%)", + "RegularText(MrHodl,)", + "Email(MrHodl@nostrpurple.com)", + "RegularText(-)", + "RegularText(29fbc05acee671fb579182ca33b0e41b455bb1f9564b90a3d8f2f39dee3f2779)", + "HashTag(#15)", + "RegularText(9%)", + "RegularText(mikedilger,)", + "Email(_@mikedilger.com)", + "RegularText(-)", + "RegularText(ee11a5dff40c19a555f41fe42b48f00e618c91225622ae37b6c2bb67b76c4e49)", + "HashTag(#16)", + "RegularText(9%)", + "RegularText(jascha,)", + "Email(jascha@relayable.org)", + "RegularText(-)", + "RegularText(2479739594ed5802a96703e5a870b515d986982474a71feae180e8ecffa302c6)", + "HashTag(#17)", + "RegularText(8%)", + "RegularText(Nakadaimon,)", + "Email(Nakadaimon@nostrplebs.com)", + "RegularText(-)", + "RegularText(803a613997a26e8714116f99aa1f98e8589cb6116e1aaa1fc9c389984fcd9bb8)", + "HashTag(#18)", + "RegularText(8%)", + "RegularText(KeithMukai,)", + "Email(KeithMukai@nostr.seedsigner.com)", + "RegularText(-)", + "RegularText(5b0e8da6fdfba663038690b37d216d8345a623cc33e111afd0f738ed7792bc54)", + "HashTag(#19)", + "RegularText(8%)", + "RegularText(TheGuySwann,)", + "Email(theguyswann@NostrVerified.com)", + "RegularText(-)", + "RegularText(b0b8fbd9578ac23e782d97a32b7b3a72cda0760761359bd65661d42752b4090a)", + "HashTag(#20)", + "RegularText(8%)", + "RegularText(dk,)", + "Email(dk@stacker.news)", + "RegularText(-)", + "RegularText(b708f7392f588406212c3882e7b3bc0d9b08d62f95fa170d099127ece2770e5e)", + "HashTag(#21)", + "RegularText(7%)", + "RegularText(zerohedge,)", + "Email(npub1z7eqn5603ltuxr77w70t3sasep8hyngzr6lxqpa9hfcqjwe9wmdqhw0qhv@nost.vip)", + "RegularText(-)", + "RegularText(17b209d34f8fd7c30fde779eb8c3b0c84f724d021ebe6007a5ba70093b2576da)", + "HashTag(#22)", + "RegularText(7%)", + "RegularText(miljan,)", + "Email(miljan@primal.net)", + "RegularText(-)", + "RegularText(d61f3bc5b3eb4400efdae6169a5c17cabf3246b514361de939ce4a1a0da6ef4a)", + "HashTag(#23)", + "RegularText(7%)", + "RegularText(jared,)", + "Email(jared@nostrplebs.com)", + "RegularText(-)", + "RegularText(92e3aac668edb25319edd1d87cadef0b189557fdd13b123d82a19d67fd211909)", + "HashTag(#24)", + "RegularText(7%)", + "RegularText(radii,)", + "Email(radii@orangepill.dev)", + "RegularText(-)", + "RegularText(acedd3597025cb13b84f9a89643645aeb61a3b4a3af8d7ac01f8553171bf17c5)", + "HashTag(#25)", + "RegularText(7%)", + "RegularText(katie,)", + "Email(_@katieannbaker.com)", + "RegularText(-)", + "RegularText(07eced8b63b883cedbd8520bdb3303bf9c2b37c2c7921ca5c59f64e0f79ad2a6)", + "HashTag(#26)", + "RegularText(7%)", + "RegularText(giacomozucco,)", + "Email(giacomozucco@BitcoinNostr.com)", + "RegularText(-)", + "RegularText(ef151c7a380f40a75d7d1493ac347b6777a9d9b5fa0aa3cddb47fc78fab69a8b)", + "HashTag(#27)", + "RegularText(7%)", + "RegularText(kr,)", + "Email(kr@stacker.news)", + "RegularText(-)", + "RegularText(08b80da85ba68ac031885ea555ab42bb42231fde9b690bbd0f48c128dfbf8009)", + "HashTag(#28)", + "RegularText(7%)", + "RegularText(phil,)", + "Email(phil@nostrpurple.com)", + "RegularText(-)", + "RegularText(e07773a92a610a28da20748fdd98bfb5af694b0cad085224801265594a98108a)", + "HashTag(#29)", + "RegularText(7%)", + "RegularText(angela,)", + "Email(angela@nostr.world)", + "RegularText(-)", + "RegularText(2b1964b885de3fcbb33777874d06b05c254fecd561511622ce86e3d1851949fa)", + "HashTag(#30)", + "RegularText(7%)", + "RegularText(mason)", + "RegularText(๐“„€)", + "RegularText(๐“…ฆ,)", + "Email(mason@lacosanostr.com)", + "RegularText(-)", + "RegularText(5ef92421b5df0ed97df6c1a98fc038ea7962a29e7f33a060f7a8ddeb9ee587e9)", + "HashTag(#31)", + "RegularText(7%)", + "RegularText(Lau,)", + "Email(lau@nostr.report)", + "RegularText(-)", + "RegularText(5a9c48c8f4782351135dd89c5d8930feb59cb70652ffd37d9167bf922f2d1069)", + "HashTag(#32)", + "RegularText(7%)", + "RegularText(Rex)", + "RegularText(Damascus)", + "RegularText(,)", + "Email(damascusrex@iris.to)", + "RegularText(-)", + "RegularText(50c5c98ccc31ca9f1ef56a547afc4cb48195fe5603d4f7874a221db965867c8e)", + "HashTag(#33)", + "RegularText(6%)", + "RegularText(nym,)", + "Email(nym@nostr.fan)", + "RegularText(-)", + "RegularText(9936a53def39d712f886ac7e2ed509ce223b534834dd29d95caba9f6bc01ef35)", + "HashTag(#34)", + "RegularText(6%)", + "RegularText(nico,)", + "Email(nico@nostrplebs.com)", + "RegularText(-)", + "RegularText(0000000033f569c7069cdec575ca000591a31831ebb68de20ed9fb783e3fc287)", + "HashTag(#35)", + "RegularText(6%)", + "RegularText(anna,)", + "Email(seekerdreamer1@stacker.news)", + "RegularText(-)", + "RegularText(6f2347c6fc4cbcc26d66e74247abadd4151592277b3048331f52aa3a5c244af9)", + "HashTag(#36)", + "RegularText(6%)", + "RegularText(TheSameCat,)", + "Email(thesamecat@iris.to)", + "RegularText(-)", + "RegularText(72f9755501e1a4464f7277d86120f67e7f7ec3a84ef6813cc7606bf5e0870ff3)", + "HashTag(#37)", + "RegularText(6%)", + "RegularText(nitesh_btc,)", + "Email(nitesh@noderunner.wtf)", + "RegularText(-)", + "RegularText(021d7ef7aafc034a8fefba4de07622d78fd369df1e5f9dd7d41dc2cffa74ae02)", + "HashTag(#38)", + "RegularText(6%)", + "RegularText(gpt3,)", + "Email(gpt3@jb55.com)", + "RegularText(-)", + "RegularText(5c10ed0678805156d39ef1ef6d46110fe1e7e590ae04986ccf48ba1299cb53e2)", + "HashTag(#39)", + "RegularText(6%)", + "RegularText(Byzantine,)", + "Email(byzantine@stacker.news)", + "RegularText(-)", + "RegularText(5d1d83de3ee5edde157071d5091a6d03ead8cce1d46bc585a9642abdd0db5aa0)", + "HashTag(#40)", + "RegularText(6%)", + "RegularText(wealththeory,)", + "Email(wealththeory@nostrplebs.com)", + "RegularText(-)", + "RegularText(3004d45a0ab6352c61a62586a57c50f11591416c29db1143367a4f0623b491ca)", + "HashTag(#41)", + "RegularText(6%)", + "RegularText(IshBit,)", + "Email(gug@nostrplebs.com)", + "RegularText(-)", + "RegularText(8e27ffb5c9bb8cdd0131ade6efa49d56d401b5424d9fdf9a63e074d527b0715c)", + "HashTag(#42)", + "RegularText(5%)", + "RegularText(Lana,)", + "Email(lana@b.tc)", + "RegularText(-)", + "RegularText(e8795f9f4821f63116572ed4998924c6f0e01682945bf7a3d9d6132f1c7dace7)", + "HashTag(#43)", + "RegularText(5%)", + "RegularText(Shevacai,)", + "Email(shevacai@nostrplebs.com)", + "RegularText(-)", + "RegularText(2f175fe4348f4da2da157e84d119b5165c84559158e64729ff00b16394718bbf)", + "HashTag(#44)", + "RegularText(5%)", + "RegularText(joe,)", + "Email(joe@nostrpurple.com)", + "RegularText(-)", + "RegularText(907a5a23635ea02be052c31f465b1982aefb756710ccc9f628aa31b70d2e262e)", + "HashTag(#45)", + "RegularText(5%)", + "RegularText(SimplestBitcoinBook,)", + "Email(simplestbitcoinbook@nostrplebs.com)", + "RegularText(-)", + "RegularText(6867d899ce6b677b89052602cfe04a165f26bb6a1a6390355f497f9ee5cb0796)", + "HashTag(#46)", + "RegularText(5%)", + "RegularText(knutsvanholm,)", + "Email(knutsvanholm@iris.to)", + "RegularText(-)", + "RegularText(92cbe5861cfc5213dd89f0a6f6084486f85e6f03cfeb70a13f455938116433b8)", + "HashTag(#47)", + "RegularText(5%)", + "RegularText(rajwinder,)", + "Email(rs@zbd.ai)", + "RegularText(-)", + "RegularText(1c9d368fc24e8549ce2d95eba63cb34b82b363f3036d90c12e5f13afe2981fba)", + "HashTag(#48)", + "RegularText(5%)", + "RegularText(Vlad,)", + "RegularText()", + "RegularText(-)", + "RegularText(50054d07e2cdf32b1035777bd9cf73992a4ae22f91c14a762efdaa5bf61f4755)", + "HashTag(#49)", + "RegularText(5%)", + "RegularText(GRANTGILLIAM,)", + "Email(GRANTGILLIAM@grantgilliam.com)", + "RegularText(-)", + "RegularText(874db6d2db7b39035fe7aac19e83a48257915e37d4f2a55cb4ca66be2d77aa88)", + "HashTag(#50)", + "RegularText(5%)", + "RegularText(LifeLoveLiberty,)", + "Email(lifeloveliberty@iris.to)", + "RegularText(-)", + "RegularText(c07a2ea48b6753d11ad29d622925cb48bab48a8f38e954e85aec46953a0752a2)", + "HashTag(#51)", + "RegularText(5%)", + "RegularText(hackernews,)", + "Email(npub1s9c53smfq925qx6fgkqgw8as2e99l2hmj32gz0hjjhe8q67fxdvs3ga9je@nost.vip)", + "RegularText(-)", + "RegularText(817148c3690155401b494580871fb0564a5faafb9454813ef295f2706bc93359)", + "HashTag(#52)", + "RegularText(5%)", + "RegularText(arbedout,)", + "Email(arbedout@granddecentral.com)", + "RegularText(-)", + "RegularText(a67e98faf32f2520ae574d84262534e7b94625ce0d4e14a50c97e362c06b770e)", + "HashTag(#53)", + "RegularText(5%)", + "RegularText(nobody,)", + "RegularText()", + "RegularText(-)", + "RegularText(5f735049528d831f544b49a585e6f058c1655dfaed9fc338374cd4f3a5a06bf7)", + "HashTag(#54)", + "RegularText(5%)", + "RegularText(glowleaf,)", + "Email(glowleaf@nostrplebs.com)", + "RegularText(-)", + "RegularText(34c0a53283bacd5cb6c45f9b057bea05dfb276333dcf14e9b167680b5d3638e4)", + "HashTag(#55)", + "RegularText(5%)", + "RegularText(Modus,)", + "Email(modus@lacosanostr.com)", + "RegularText(-)", + "RegularText(547fcc5c7e655fe7c83da5a812e6332f0a4779c87bf540d8e75a4edbbf36fe4a)", + "HashTag(#56)", + "RegularText(5%)", + "RegularText(Melvin)", + "RegularText(Carvalho)", + "RegularText(Old)", + "RegularText(Key)", + "RegularText(DO)", + "RegularText(NOT)", + "RegularText(USE,)", + "RegularText(USE)", + "Bech(npub1melv683fw6n2mvhl5h6dhqd8mqfv3wmxnz4qph83ua4dk4006ezsrt5c24,)", + "RegularText()", + "RegularText(-)", + "RegularText(ed1d0e1f743a7d19aa2dfb0162df73bacdbc699f67cc55bb91a98c35f7deac69)", + "HashTag(#57)", + "RegularText(5%)", + "RegularText(anil,)", + "Email(anil@bitcoinnostr.com)", + "RegularText(-)", + "RegularText(ade7a0c6acca095c5b36f88f20163bccda4d97b071c4acc8fe329dc724eec8fb)", + "HashTag(#58)", + "RegularText(4%)", + "RegularText(DocumentingBTC,)", + "Email(documentingbtc@uselessshit.co)", + "RegularText(-)", + "RegularText(641ac8fea1478c27839fb7a0850676c2873c22aa70c6216996862c98861b7e2f)", + "HashTag(#59)", + "RegularText(4%)", + "RegularText(wolfbearclaw,)", + "Email(wolfbearclaw@nostr.messagepush.io)", + "RegularText(-)", + "RegularText(0b963191ab21680a63307aedb50fd7b01392c9c6bef79cd0ceb6748afc5e7ffd)", + "HashTag(#60)", + "RegularText(4%)", + "RegularText(Amboss,)", + "Email(_@amboss.space)", + "RegularText(-)", + "RegularText(2af01e0d6bd1b9fbb9e3d43157d64590fb27dcfbcabe28784a5832e17befb87b)", + "HashTag(#61)", + "RegularText(4%)", + "RegularText(k3tan,)", + "Email(k3tan@k3tan.com)", + "RegularText(-)", + "RegularText(599c4f2380b0c1a9a18b7257e107cf9e6d8b4f8dea06c18c84538d311ff2b28c)", + "HashTag(#62)", + "RegularText(4%)", + "RegularText(wolzie)", + "RegularText(,)", + "Email(wolzie@BitcoinNostr.com)", + "RegularText(-)", + "RegularText(aabedc1f237853aeeb22bd985556036f262f8507842d64f3ecce01adbd7207e2)", + "HashTag(#63)", + "RegularText(4%)", + "RegularText(trey,)", + "Email(trey@nostrplebs.com)", + "RegularText(-)", + "RegularText(d5415a313d38461ff93a8c170f941b2cd4a66a5cfdbb093406960f6cb317849f)", + "HashTag(#64)", + "RegularText(4%)", + "RegularText(sillystev,)", + "RegularText()", + "RegularText(-)", + "RegularText(d541ef2e4830f2e1543c8bdc40128ceceb062b08c7e3f53d141552d5f5bc0cfc)", + "HashTag(#65)", + "RegularText(4%)", + "RegularText(sovereignmox,)", + "Email(woody@fountain.fm)", + "RegularText(-)", + "RegularText(1c4123b2431c60be030d641b4b68300eb464415405035b199428c0913b879c0c)", + "HashTag(#66)", + "RegularText(4%)", + "RegularText(CosmicDimension,)", + "Email(cosmicdimension@nostrplebs.com)", + "RegularText(-)", + "RegularText(4afec6c875e81dc28a760cc828345c0c5b61ec464ba20224148f9fd854a868ff)", + "HashTag(#67)", + "RegularText(4%)", + "RegularText(Mir,)", + "Email(mirbtc@getalby.com)", + "RegularText(-)", + "RegularText(234c45ff85a31c19bf7108a747fa7be9cd4af95c7d621e07080ca2d663bb47d2)", + "HashTag(#68)", + "RegularText(4%)", + "RegularText(Tacozilla,)", + "RegularText()", + "RegularText(-)", + "RegularText(5f70f80ddcf4f6a022467bd5196a1fdfc53d59f1e735a90443e7f7c980564c88)", + "HashTag(#69)", + "RegularText(4%)", + "RegularText(marks,)", + "Email(marks@nostrplebs.com)", + "RegularText(-)", + "RegularText(8ea485266b2285463b13bf835907161c22bb3da1e652b443db14f9cee6720a43)", + "HashTag(#70)", + "RegularText(4%)", + "RegularText(blacktomcat,)", + "Email(barrensatin40@walletofsatoshi.com)", + "RegularText(-)", + "RegularText(16b7e4b067cba8c86bda96a8d932e7593f398118d24bd8060da39ccfd7315f5c)", + "HashTag(#71)", + "RegularText(4%)", + "RegularText(Alex)", + "RegularText(Emidio,)", + "Email(alexemidio@alexemidio.github.io)", + "RegularText(-)", + "RegularText(4ba8e86d2d97896dc9337c3e500691893d7317572fd81f8b41ddda5d89d32de4)", + "HashTag(#72)", + "RegularText(4%)", + "RegularText(Jenn,)", + "Email(Jenn@mintgreen.co)", + "RegularText(-)", + "RegularText(e0f59d89047b868a188c5efd6b93dd8c16b65643b8718884dad8542386c60ddd)", + "HashTag(#73)", + "RegularText(4%)", + "RegularText(spacemonkey,)", + "Email(spacemonkey@nostrich.love)", + "RegularText(-)", + "RegularText(23b26fea28700cd1e2e3a8acca5c445c37ab89acaad549a36d50e9c0eb0f5806)", + "HashTag(#74)", + "RegularText(4%)", + "RegularText(ishak,)", + "Email(ishak@nostrplebs.com)", + "RegularText(-)", + "RegularText(052466631c6c0aed84171f83ef3c95cb81848d4dcdc1d1ee9dfdf75b850c1cb4)", + "HashTag(#75)", + "RegularText(4%)", + "RegularText(nakamoto_army,)", + "RegularText()", + "RegularText(-)", + "RegularText(62f6c5ff12fd24251f0bfb3b7eb1e512d7f1f577a1a97a595db01c66b52ad04f)", + "HashTag(#76)", + "RegularText(4%)", + "RegularText(GrassFedBitcoin,)", + "Email(GrassFedBitcoin@start9.com)", + "RegularText(-)", + "RegularText(74ffc51cc30150cf79b6cb316d3a15cf332ab29a38fec9eb484ab1551d6d1856)", + "HashTag(#77)", + "RegularText(4%)", + "RegularText(NinoHodls,)", + "Email(ninoholds@nostrplebs.com)", + "RegularText(-)", + "RegularText(43ccdbcb1e4dff7e3dea2a91b851ca0e22f50e3c560364a12b64b8c6587924f0)", + "HashTag(#78)", + "RegularText(4%)", + "RegularText(satcap,)", + "Email(satcap@nostr.satcap.io)", + "RegularText(-)", + "RegularText(11dfaa43ae0faa0a06d8c67f89759214c58b60a021521627bc76cb2d3ad0b2e8)", + "HashTag(#79)", + "RegularText(4%)", + "RegularText(DuneMessias,)", + "RegularText()", + "RegularText(-)", + "RegularText(96a578f6b504646de141ba90bec5651965aa01df0605928b3785a1372504e93d)", + "HashTag(#80)", + "RegularText(4%)", + "RegularText(Idaeus,)", + "RegularText()", + "RegularText(-)", + "RegularText(eb473e8fd55ced7af32abaf89578647ddba75e38a860b1c41682bbfb774f5579)", + "HashTag(#81)", + "RegularText(4%)", + "RegularText(tpmoreira,)", + "Email(tpmoreira@nostrplebs.com)", + "RegularText(-)", + "RegularText(f514ef7d18da12ecfce55c964add719ce00a1392c187f20ccb57d99290720e03)", + "HashTag(#82)", + "RegularText(4%)", + "RegularText(force2B,)", + "Email(force2b@nostrplebs.com)", + "RegularText(-)", + "RegularText(d411848a42a11ad2747c439b00fc881120a4121e04917d38bebd156212e2f4ad)", + "HashTag(#83)", + "RegularText(4%)", + "RegularText(Hendrix,)", + "Email(hendrix@nostrplebs.com)", + "RegularText(-)", + "RegularText(cbd92008e1fe949072cbea02e54228140c43d14d14519108b1d7a32d9102665b)", + "HashTag(#84)", + "RegularText(4%)", + "RegularText(TXMC,)", + "Email(TXMC@alphabetasoup.tv)", + "RegularText(-)", + "RegularText(37359e92ece5c6fc8d5755de008ceb6270808b814ddd517d38ebeab269836c96)", + "HashTag(#85)", + "RegularText(4%)", + "RegularText(norman188,)", + "RegularText()", + "RegularText(-)", + "RegularText(662a4476a9c15a5778f379ce41ceb2841ac72dfa1829b492d67796a8443ac2ca)", + "HashTag(#86)", + "RegularText(4%)", + "RegularText(pipleb,)", + "Email(pipleb@iris.to)", + "RegularText(-)", + "RegularText(3c4280ef3b792fa919b1964460d34ca6af93b83fa55f633a3b0eb8fde556235a)", + "HashTag(#87)", + "RegularText(4%)", + "RegularText(reallhex,)", + "Email(reallhex@terranostr.com)", + "RegularText(-)", + "RegularText(29630aed66aeec73b6519a11547f40ca15c3f6aa79907e640f1efcf5a2ee9dc8)", + "HashTag(#88)", + "RegularText(4%)", + "RegularText(374324โ€ฆef9f78,)", + "RegularText()", + "RegularText(-)", + "RegularText(3743244390be53473a7e3b3b8d04dce83f6c9514b81a997fb3b123c072ef9f78)", + "HashTag(#89)", + "RegularText(4%)", + "RegularText(Nostradamus,)", + "RegularText()", + "RegularText(-)", + "RegularText(7acce9b3da22ceedc511a15cb730c898235ab551623955314b003e9f33e8b10c)", + "HashTag(#90)", + "RegularText(4%)", + "RegularText(Nicโ‚ฟ,)", + "Email(nicb@nicb.me)", + "RegularText(-)", + "RegularText(000000002d4f4733f1ee417a405637fd0d81dbfbc6dbd8c0d1c95f04ec3db973)", + "HashTag(#91)", + "RegularText(4%)", + "RegularText(NabismoPrime,)", + "Email(NabismoPrime@BostonBTC.com)", + "RegularText(-)", + "RegularText(4503baa127bdfd0b054384dc5ba82cb0e2a8367cbdb0629179f00db1a34caacc)", + "HashTag(#92)", + "RegularText(4%)", + "RegularText(paco,)", + "Email(paco@iris.to)", + "RegularText(-)", + "RegularText(66bd8fed3590f2299ef0128f58d67879289e6a99a660e83ead94feab7606fd17)", + "HashTag(#93)", + "RegularText(3%)", + "RegularText(globalstatesmen,)", + "Email(globalstatesmen@nostrplebs.com)", + "RegularText(-)", + "RegularText(237506ca399e5b1b9ce89455fe960bc98dfab6a71936772a89c5145720b681f4)", + "HashTag(#94)", + "RegularText(3%)", + "RegularText(Nostryfied,)", + "Email(_@NostrNet.work)", + "RegularText(-)", + "RegularText(c2c20ec0a555959713ca4c404c4d2cc80e6cb906f5b64217070612a0cae29c62)", + "HashTag(#95)", + "RegularText(3%)", + "RegularText(crayonsmell,)", + "Email(crayonsmell@habel.net)", + "RegularText(-)", + "RegularText(3ef3be9db1e3f268f84e937ad73c68772a58c6ffcec1d42feeef5f214ad1eaf9)", + "HashTag(#96)", + "RegularText(3%)", + "RegularText(Toxikat27,)", + "Email(ToxiKat27@Bitcoiner.social)", + "RegularText(-)", + "RegularText(12cfc2ec5a39a39d02f921f77e701dbc175b6287f22ddf0247af39706967f1d9)", + "HashTag(#97)", + "RegularText(3%)", + "RegularText(James)", + "RegularText(Trageser,)", + "Email(jtrag@BitcoinNostr.com)", + "RegularText(-)", + "RegularText(d29bc58353389481e302569835661c95838bee076137533eb365bca752c38316)", + "HashTag(#98)", + "RegularText(3%)", + "RegularText(Joe)", + "RegularText(Martin)", + "RegularText(Music,)", + "Email(joemartinmusic@nostrplebs.com)", + "RegularText(-)", + "RegularText(28ca019b78b494c25a9da2d645975a8501c7e99b11302e5cbe748ee593fcb2cc)", + "HashTag(#99)", + "RegularText(3%)", + "RegularText(Fundamentals,)", + "Email(ph@nostrplebs.com)", + "RegularText(-)", + "RegularText(5677fa5b6b1cb6d5bee785d088a904cd08082552bf75df3e4302cea015a5d3e1)", + "HashTag(#100)", + "RegularText(3%)", + "RegularText(bb,)", + "RegularText()", + "RegularText(-)", + "RegularText(1f254ae909a36b0000c3b68f36b92aad168f4532725d7cd9b67f5b09088f2125)", + "HashTag(#101)", + "RegularText(3%)", + "RegularText(ๆŽๅญๆŸ’,)", + "RegularText()", + "RegularText(-)", + "RegularText(c70c8e55e0228c3ce171ae0d357452e386489f3a2d14e6deca174c2fbfc8da52)", + "HashTag(#102)", + "RegularText(3%)", + "RegularText(Horse)", + "RegularText(๐Ÿด,)", + "Email(horse@iris.to)", + "RegularText(-)", + "RegularText(e4d3420c0b77926cfbf107f9cb606238efaf5524af39ff1c86e6d6fdd1515a57)", + "HashTag(#103)", + "RegularText(3%)", + "RegularText(KP,)", + "Email(kp@no.str.cr)", + "RegularText(-)", + "RegularText(b2e777c827e20215e905ab90b6d81d5b84be5bf66c944ce34943540b462ea362)", + "HashTag(#104)", + "RegularText(3%)", + "RegularText(Azarakhsh,)", + "Email(rebornbitcoiner@getalby.com)", + "RegularText(-)", + "RegularText(c734992a115c2ad9b4df40dd7c14d153695b29081a995df39b4fc8e6f1dcfb14)", + "HashTag(#105)", + "RegularText(3%)", + "RegularText(Toshi,)", + "Email(toshi@nostr-check.com)", + "RegularText(-)", + "RegularText(79d434176b64745d2793cf307f20967e27912994f6e81632de18da3106c2cbb4)", + "HashTag(#106)", + "RegularText(3%)", + "RegularText(FreeBorn,)", + "Email(freeborn@nostrplebs.com)", + "RegularText(-)", + "RegularText(408e04e9a5b02ef6d82edb9ecb2cca1d5a3121cb26b0ca5e6511800a0269b069)", + "HashTag(#107)", + "RegularText(3%)", + "RegularText(blee,)", + "Email(blee@bitcoiner.social)", + "RegularText(-)", + "RegularText(69a0a0910b49a1dbfbc4e4f10df22b5806af5403a228267638f2e908c968228d)", + "HashTag(#108)", + "RegularText(3%)", + "RegularText(SatsTonight,)", + "Email(SatsTonight@BitcoinNostr.com)", + "RegularText(-)", + "RegularText(eb3b94533dafeb8ebd58a4947a3dce11d83a9931c622bdf30a4257d3347ee1bf)", + "HashTag(#109)", + "RegularText(3%)", + "SchemelessUrl(Nostr-Check.com,)", + "Email(freeverification@Nostr-Check.com)", + "RegularText(-)", + "RegularText(ddfbb06a722e51933cd37e4ecdb30b1864f262f9bb5bd6c2d95cbeefc728f096)", + "HashTag(#110)", + "RegularText(3%)", + "RegularText(cowmaster,)", + "Email(cowmaster@getalby.com)", + "RegularText(-)", + "RegularText(6af9411d742c74611e149d19037e7a2ba4d44bbceb429b209c451902b6740bb8)", + "HashTag(#111)", + "RegularText(3%)", + "RegularText(Hacker,)", + "Email(hacker818@iris.to)", + "RegularText(-)", + "RegularText(40e10350fed534e5226b73761925030134d9f85306ee1db5cfbd663118034e84)", + "HashTag(#112)", + "RegularText(3%)", + "RegularText(BitcasaHomes,)", + "Email(amandabitcasa@nostrplebs.com)", + "RegularText(-)", + "RegularText(f96a2a2552c08f99c30b9e2441d64ca4c6b3d761735e7cd74580bafe549326e0)", + "HashTag(#113)", + "RegularText(3%)", + "RegularText(footstr,)", + "RegularText()", + "RegularText(-)", + "RegularText(aa1aa6af6be3a2903e2fb18690d7df128a10eec0f3a015157daf371c688b4cff)", + "HashTag(#114)", + "RegularText(3%)", + "RegularText(tiago,)", + "Email(tiago@nostrplebs.com)", + "RegularText(-)", + "RegularText(780ab38a843423c61502550474b016e006f2b56f2f7d18e9cd02737e11113262)", + "HashTag(#115)", + "RegularText(3%)", + "RegularText(Sepehr,)", + "Email(sepehr@nostribe.com)", + "RegularText(-)", + "RegularText(3e294d2fd339bb16a5403a86e3664947dd408c4d87a0066524f8a573ae53ca8e)", + "HashTag(#116)", + "RegularText(3%)", + "RegularText(dhruv,)", + "RegularText()", + "RegularText(-)", + "RegularText(297bc16357b314be291c893755b25d66999c1525bbf3537fbc637a0c767f14bb)", + "HashTag(#117)", + "RegularText(3%)", + "RegularText(b310edโ€ฆ4f793a,)", + "RegularText()", + "RegularText(-)", + "RegularText(b310ed0a54a71ccf8a8368032dd3b4b83b7aca2840bb10a4d5e6ef4b6a4f793a)", + "HashTag(#118)", + "RegularText(3%)", + "RegularText(MichZ)", + "RegularText(๐Ÿง˜๐Ÿปโ€โ™€๏ธ,)", + "RegularText()", + "RegularText(-)", + "RegularText(9349d012686caab46f6bfefd2f4c361c52e14b1cde1cd027476e0ae6d3e98946)", + "HashTag(#119)", + "RegularText(3%)", + "RegularText(gfy,)", + "Email(gfy@stacker.news)", + "RegularText(-)", + "RegularText(01e4fc2adc0ff7a0465d3e70b3267d375ebe4292828fa3888f972313f3a1248e)", + "HashTag(#120)", + "RegularText(3%)", + "RegularText(Dude,)", + "RegularText()", + "RegularText(-)", + "RegularText(67cbb3d83800cc1af6f5d2821f1c911f033ea21e1269ff2ad613ab3ae099b1f3)", + "HashTag(#121)", + "RegularText(3%)", + "RegularText(HODL_MFER,)", + "RegularText()", + "RegularText(-)", + "RegularText(7c6a9e6231570a6773e608d1c0a709acb9c21193a5c2df9cebfa9e9db09411a3)", + "HashTag(#122)", + "RegularText(3%)", + "RegularText(renatarodrigues,)", + "RegularText()", + "RegularText(-)", + "RegularText(aa116590cf23dc761a8a9e38ff224a3d07db45c66be3035b9f87144bda0eeaa5)", + "HashTag(#123)", + "RegularText(3%)", + "RegularText(CryptoJournaal,)", + "Email(cryptojournaal@iris.to)", + "RegularText(-)", + "RegularText(fb649213b88e9927a5c8f470d7affe88441de995deaccf283bf60a78f771b825)", + "HashTag(#124)", + "RegularText(3%)", + "RegularText(Bon,)", + "Email(bon@nostrplebs.com)", + "RegularText(-)", + "RegularText(b2722dd1e13ff9b82ff2f432186019045fee39911d5652d6b4263562061af908)", + "HashTag(#125)", + "RegularText(3%)", + "RegularText(binarywatch,)", + "Email(bot@binarywatch.org)", + "RegularText(-)", + "RegularText(0095c837e8ed370de6505c2c631551af08c110853b519055d0cdf3d981da5ac3)", + "HashTag(#126)", + "RegularText(3%)", + "RegularText(Moritz,)", + "Email(moritz@getalby.com)", + "RegularText(-)", + "RegularText(0521db9531096dff700dcf410b01db47ab6598de7e5ef2c5a2bd7e1160315bf6)", + "HashTag(#127)", + "RegularText(3%)", + "RegularText(hodlish,)", + "Email(hodlish@Nostr-Check.com)", + "RegularText(-)", + "RegularText(3575a3a7a6b5236443d6af03606aa9297c3177a45cf5314b9fd57bff894ee3ae)", + "HashTag(#128)", + "RegularText(3%)", + "RegularText(HolgerHatGarKeineNode,)", + "Email(HolgerHatGarKeineNode@nip05.easify.de)", + "RegularText(-)", + "RegularText(0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033)", + "HashTag(#129)", + "RegularText(3%)", + "RegularText(joe,)", + "Email(joe@jaxo.github.io)", + "RegularText(-)", + "RegularText(6827ef2b75ee652dcc83958b83aea0bc6580705b56041a9ee70a4178e1046cdb)", + "HashTag(#130)", + "RegularText(3%)", + "RegularText(hahattpro,)", + "Email(hahattpro@iris.to)", + "RegularText(-)", + "RegularText(53ac90ebaef84b0439cdf4f1d955ff1f1e98febc04fb789eff4a08fe53316483)", + "HashTag(#131)", + "RegularText(3%)", + "RegularText(bensima,)", + "Email(bensima@simatime.com)", + "RegularText(-)", + "RegularText(2fa4b9ba71b6dab17c4723745bb7850dfdafcb6ae1a8642f76f9c64fa5f43436)", + "HashTag(#132)", + "RegularText(3%)", + "RegularText(satan,)", + "Email(satan@nostrcheck.me)", + "RegularText(-)", + "RegularText(d6b44ef322f6d67806ff06aaa9623b22ff5c2b0f0705c5e7a5a35684af9e5101)", + "HashTag(#133)", + "RegularText(3%)", + "RegularText(RadVladdy,)", + "Email(radvladdy@nostrplebs.com)", + "RegularText(-)", + "RegularText(7933ea1abdb329139b4eb37157649229b41d0ae445907238b07926182f717924)", + "HashTag(#134)", + "RegularText(3%)", + "RegularText(horacio,)", + "RegularText()", + "RegularText(-)", + "RegularText(f61abb9886e1f4cd5d20419c197d5d7f3649addab24b6a32a2367124ca3194b4)", + "HashTag(#135)", + "RegularText(3%)", + "RegularText(yidneth,)", + "Email(yidneth@getalby.com)", + "RegularText(-)", + "RegularText(f28be20326c6779b2f8bfa75a865d0fa4af384e9c6c99dc6a803e542f9d2085e)", + "HashTag(#136)", + "RegularText(3%)", + "RegularText(JonO,)", + "RegularText()", + "RegularText(-)", + "RegularText(edecf91d15e03c921806ae6ebff86771c79e1641e899787e4d7689f68314d447)", + "HashTag(#137)", + "RegularText(3%)", + "RegularText(bellatrix,)", + "Email(bellatrix@iris.to)", + "RegularText(-)", + "RegularText(f9d7f0b271b5bb19ed400d8baeee1c22ac3a5be5cf20da55219c4929e523987a)", + "HashTag(#138)", + "RegularText(3%)", + "RegularText(SecureCoop,)", + "Email(securecoop@iris.to)", + "RegularText(-)", + "RegularText(d244e3cd0842d514a0725e0e0a00b712b7f2ed515a1d7ef362fd12c957b95549)", + "HashTag(#139)", + "RegularText(3%)", + "RegularText(charliesurf,)", + "Email(charliesurf@ln.tips)", + "RegularText(-)", + "RegularText(a396e36e962a991dac21731dd45da2ee3fd9265d65f9839c15847294ec991f1c)", + "HashTag(#140)", + "RegularText(3%)", + "RegularText(Bitcoin)", + "RegularText(ATM,)", + "Email(bitcoinatm@Nostr-Check.com)", + "RegularText(-)", + "RegularText(01a69fa5a7cbb4a185904bdc7cae6137ff353889bba95619c619debe9e3b8b09)", + "HashTag(#141)", + "RegularText(3%)", + "RegularText(lnstallone,)", + "Email(lnstallone@allmysats.com)", + "RegularText(-)", + "RegularText(84fe3febc748470ff1a363db8a375ffa1ff86603f2653d1c3c311ad0a70b5d0c)", + "HashTag(#142)", + "RegularText(3%)", + "RegularText(a652f6โ€ฆ9124f3,)", + "RegularText()", + "RegularText(-)", + "RegularText(a652f66df4ddb5280ff466b6ff444fbc310b8e83238660473d5ccffa9e9124f3)", + "HashTag(#143)", + "RegularText(3%)", + "RegularText(hmichellerose,)", + "RegularText()", + "RegularText(-)", + "RegularText(5b29255d5eaaaeb577552bf0d11030376f477d19a009c5f5a80ddc73d49359f6)", + "HashTag(#144)", + "RegularText(3%)", + "RegularText(L0la)", + "RegularText(L33tz,)", + "Email(L0laL33tz@cashu.me)", + "RegularText(-)", + "RegularText(d8a6ecf0c396eaa8f79a4497fe9b77dc977633451f3ca5c634e208659116647b)", + "HashTag(#145)", + "RegularText(3%)", + "RegularText(Lommy,)", + "Email(Lommy@nostrplebs.com)", + "RegularText(-)", + "RegularText(014b9837dabb358fc0f416ceb58f72c4e6ed8fc6d317f0578dd704fc879f16f8)", + "HashTag(#146)", + "RegularText(3%)", + "RegularText(jgmontoya,)", + "Email(jgmontoya@nostrplebs.com)", + "RegularText(-)", + "RegularText(9236f9ac521be2ee0a54f1cfffdf2df7f4982df4e6eb992867d733debcf95b35)", + "HashTag(#147)", + "RegularText(3%)", + "RegularText(bavarianledger,)", + "Email(bavarianledger@iris.to)", + "RegularText(-)", + "RegularText(f27c20bc6e64407f805a92c3190089060f9d85efa67ccc80b85f007c3323c221)", + "HashTag(#148)", + "RegularText(3%)", + "RegularText(operator,)", + "Email(operator@brb.io)", + "RegularText(-)", + "RegularText(3c1ba7d42c873c2f89caf1ca79b4ead6513385de53743fa6eb98c3705655695c)", + "HashTag(#149)", + "RegularText(3%)", + "RegularText(awaremoma,)", + "RegularText()", + "RegularText(-)", + "RegularText(44313b79dfc3303e3bd0c4aee0c872e96a84f23a2a45624b3ab630f24f43012f)", + "HashTag(#150)", + "RegularText(3%)", + "RegularText(Tรญo)", + "RegularText(Tito,)", + "Email(tiotito@nostriches.net)", + "RegularText(-)", + "RegularText(dc6e531596c52a218a6fae2e1ea359a1365d5eda02ec176c945ed06a9400ec72)", + "HashTag(#151)", + "RegularText(3%)", + "RegularText(javi,)", + "Email(javi@www.javiergonzalez.io)", + "RegularText(-)", + "RegularText(2eab634b27a78107c98599a982849b4f71c605316c8f4994861f83dc565df5c8)", + "HashTag(#152)", + "RegularText(3%)", + "RegularText(NathanCPerry,)", + "RegularText()", + "RegularText(-)", + "RegularText(cec9808bbb00bc9c3eab4c2f23e9440a5ea775201b65a18462bc77080e39e336)", + "HashTag(#153)", + "RegularText(3%)", + "RegularText(Jason)", + "RegularText(Hodlers)", + "RegularText(โ™พ๏ธ/2099999997690000๐Ÿด,)", + "Email(geekigai@nostrplebs.com)", + "RegularText(-)", + "RegularText(d162a53c3b0bfb5c3ebd787d7b08feab206b112362eca25aa291251cd70fe225)", + "HashTag(#154)", + "RegularText(3%)", + "SchemelessUrl(MR.Rabbit,)", + "Email(Mr.Rabbit@BitcoinNostr.com)", + "RegularText(-)", + "RegularText(42af69b2384071f31e55cb2d368c8a3351c8f2da03207e1fb6885991ac2522bf)", + "HashTag(#155)", + "RegularText(3%)", + "RegularText(kilicl,)", + "Email(kilicl@nostr-check.com)", + "RegularText(-)", + "RegularText(48a94f890f4dc3625b9926cdccded61e353ad1fe76600bc6acea44bdb9efceb7)", + "HashTag(#156)", + "RegularText(3%)", + "RegularText(retired,)", + "RegularText()", + "RegularText(-)", + "RegularText(82ba83731adcfe5a65ced992fde81efc756d10670c56a58cb8870210f859d3c1)", + "HashTag(#157)", + "RegularText(3%)", + "RegularText(Alex)", + "RegularText(Bit,)", + "Email(alexbit@nostrbr.online)", + "RegularText(-)", + "RegularText(9db334a465cc3f6107ed847eec0bc6c835e76ba50625f4c1900cbcb9df808d91)", + "HashTag(#158)", + "RegularText(3%)", + "RegularText(freeeedom21,)", + "Email(william@nostrplebs.com)", + "RegularText(-)", + "RegularText(fd254541619b6d4baa467412058321f70cf108d773adcda69083bd500e502033)", + "HashTag(#159)", + "RegularText(3%)", + "RegularText(OneEzra,)", + "Email(oneezra@nostrplebs.com)", + "RegularText(-)", + "RegularText(0078d4cb1652552475ba61ec439cd50c37c3a3a439853d830d7c9d338826ade2)", + "HashTag(#160)", + "RegularText(3%)", + "RegularText(lightsats,)", + "RegularText()", + "RegularText(-)", + "RegularText(88185e27e96cfcfc3c58c625cf70c4dba757f8d2e9ab7cab80f5012a343eb7d2)", + "HashTag(#161)", + "RegularText(3%)", + "RegularText(IceAndFireBTC,)", + "Email(iceandfirebtc@nostrplebs.com)", + "RegularText(-)", + "RegularText(edb50fd8286e36878f8dd9346c138598052e5d914f0c3c6072f12eb152f307d8)", + "HashTag(#162)", + "RegularText(3%)", + "RegularText(Nostr)", + "RegularText(Gang,)", + "Email(nostrgang@nostrplebs.com)", + "RegularText(-)", + "RegularText(91aeab23b5664edaa57dbe00b041ccb50544f89d7d956345bbd78b7dbaa48660)", + "HashTag(#163)", + "RegularText(3%)", + "RegularText(kexkey,)", + "RegularText()", + "RegularText(-)", + "RegularText(436456869bdd7fcb3aaaa91bed05173ea1510879004250b9f69b2c4370d58cf7)", + "HashTag(#164)", + "RegularText(3%)", + "RegularText(freebitcoin,)", + "Email(npub1vez5zekuzc3qk989q5gtly2zg9k2gz4l3wuplv5xs8y3se09yussg4vp7p@carteclip.com)", + "RegularText(-)", + "RegularText(66454166dc16220b14e50510bf9142416ca40abf8bb81fb28681c91865e52721)", + "HashTag(#165)", + "RegularText(3%)", + "RegularText(Sqvaznyak,)", + "Email(Sqvaznyak@uselessshit.co)", + "RegularText(-)", + "RegularText(056d6999f3283778d50aa85c25985716857cfeaffdbad92e73cf8aeaf394a5cd)", + "HashTag(#166)", + "RegularText(3%)", + "RegularText(koba,)", + "RegularText()", + "RegularText(-)", + "RegularText(b5926366f9ac01d8ed427c9bb4cdcb86b7b4a44aaad00d262ef436621e30ea5a)", + "HashTag(#167)", + "RegularText(3%)", + "RegularText(braj,)", + "Email(braj@nostrplebs.com)", + "RegularText(-)", + "RegularText(5921b801183f10b0143c2e48c22c8192fa38d27ac614a20251cac30ab729d3a5)", + "HashTag(#168)", + "RegularText(3%)", + "RegularText(Libertus,)", + "Email(libertus@getalby.com)", + "RegularText(-)", + "RegularText(2154d20dace7b28018621edf9c3a56ab842b901db0d9b02616dbed3d15fc5490)", + "HashTag(#169)", + "RegularText(3%)", + "RegularText(ZoeBoudreault,)", + "Email(ZoeBoudreault@id.nostrfy.me)", + "RegularText(-)", + "RegularText(3c43dc2a4c996832ae3a1830250d5f0917476783132969db4e14955b6e394047)", + "HashTag(#170)", + "RegularText(3%)", + "RegularText(Saiga,)", + "RegularText()", + "RegularText(-)", + "RegularText(8f5f3a60edc875315d9c1348d6ad5dddbca806d02400049632589cb32b3f0493)", + "HashTag(#171)", + "RegularText(3%)", + "RegularText(n,)", + "RegularText()", + "RegularText(-)", + "RegularText(aceff8abf70a60d7b378469ab80513c83c5d70a4f82872bac7bd619acbc71ff1)", + "HashTag(#172)", + "RegularText(3%)", + "RegularText(dnilso,)", + "Email(dnilso@iris.to)", + "RegularText(-)", + "RegularText(5ae325f930f53fad2a1a9ebefdb943bba1bef7b411e7712d2173bf3c38a49b17)", + "HashTag(#173)", + "RegularText(3%)", + "RegularText(Shroom,)", + "Email(shroom@nostrplebs.com)", + "RegularText(-)", + "RegularText(a4ee688a599c9493b8641cc61987ef42b7556ba1e79d35bca92a1dce186dac85)", + "HashTag(#174)", + "RegularText(3%)", + "RegularText(0a92e7โ€ฆbc2d3d,)", + "RegularText()", + "RegularText(-)", + "RegularText(0a92e765595bbf3368c44338479df5351cf5b0028215ba95e1c9e8de99bc2d3d)", + "HashTag(#175)", + "RegularText(3%)", + "RegularText(olegaba,)", + "Email(olegaba@olegaba.com)", + "RegularText(-)", + "RegularText(7fb2a29bd1a41d9a8ca43a19a7dcf3a8522f1bc09b4086253539190e9c29c51a)", + "HashTag(#176)", + "RegularText(3%)", + "RegularText(CJButcher,)", + "RegularText()", + "RegularText(-)", + "RegularText(15fdc4596019e2b9b702ae229d5c7a17d9527226f8cf5526006908901612b200)", + "HashTag(#177)", + "RegularText(3%)", + "RegularText(wasabi-pea,)", + "Email(wasabi@nostrplebs.com)", + "RegularText(-)", + "RegularText(abe1c8a87aca21e9b6a32a8c2fae5acbaf3212a01d9ccc13a80981c853e8fa02)", + "HashTag(#178)", + "RegularText(3%)", + "RegularText(045a6fโ€ฆf32334,)", + "RegularText()", + "RegularText(-)", + "RegularText(045a6fa0da5d278ac1c3aee79df23b7372ea03ee4da04ad4b8db9a5967f32334)", + "HashTag(#179)", + "RegularText(3%)", + "RegularText(Artur,)", + "Email(artur@getalby.com)", + "RegularText(-)", + "RegularText(762a3c15c6fa90911bf13d50fc3a29f1663dc1f04b4397a89eef604f622ecd60)", + "HashTag(#180)", + "RegularText(3%)", + "RegularText(ihsanmd๐Ÿ’€,)", + "Email(ihsanmd@getalby.com)", + "RegularText(-)", + "RegularText(d030bd233a1347e510c372b1878e00204b228072814361451623707896435da9)", + "HashTag(#181)", + "RegularText(2%)", + "RegularText(Satoshee,)", + "Email(satoshee@vida.page)", + "RegularText(-)", + "RegularText(0e88aac7368d5f2582437826042b3fb3a26a126f3d857618c6b6652a9f5bfa0a)", + "HashTag(#182)", + "RegularText(2%)", + "RegularText(39ed0aโ€ฆ60271a,)", + "RegularText()", + "RegularText(-)", + "RegularText(39ed0aea2338477103e0b5a820532ded27dbfe4f203e7270392d55f63e60271a)", + "HashTag(#183)", + "RegularText(2%)", + "SchemelessUrl(Ancap.su,)", + "Email(ancapsu@getalby.com)", + "RegularText(-)", + "RegularText(2fe5292a2df25047a392fceead75458875c775c31cc28f4be04cef3e8db15291)", + "HashTag(#184)", + "RegularText(2%)", + "RegularText(NiceAction,)", + "Email(niceaction@www.niceaction.com)", + "RegularText(-)", + "RegularText(32891ace6802507077035ba6064f7e1db29667002165b9bf5c1c9b3f84e2303c)", + "HashTag(#185)", + "RegularText(2%)", + "RegularText(seak,)", + "Email(seak@nostrplebs.com)", + "RegularText(-)", + "RegularText(d70f1bca430a2158f0e4c88b158ae18efffe8a91d436edbeee27acf2d9012cf5)", + "HashTag(#186)", + "RegularText(2%)", + "RegularText(twochickshomestead,)", + "RegularText()", + "RegularText(-)", + "RegularText(5bf5ab367f45b01b1cac72d73703fb30c704f3dbd5d376396fc0b6f39cac456b)", + "HashTag(#187)", + "RegularText(2%)", + "RegularText(Andy,)", + "Email(andy@nodeless.io)", + "RegularText(-)", + "RegularText(08cd52a46ab37a9894b3333785c2ff50e068d1b01fb03d702608da83e9817d82)", + "HashTag(#188)", + "RegularText(2%)", + "RegularText(coinbitstwitterfollows,)", + "RegularText()", + "RegularText(-)", + "RegularText(1341010418f272ed6db469d77dffdf1d946dd0701e33bdc84bb72269cef5bfed)", + "HashTag(#189)", + "RegularText(2%)", + "RegularText(Annonymal,)", + "RegularText()", + "RegularText(-)", + "RegularText(5c7794d47115a1b133a19673d57346ca494d367379458d8e98bf24a498abc46b)", + "HashTag(#190)", + "RegularText(2%)", + "RegularText(lindsey,)", + "RegularText()", + "RegularText(-)", + "RegularText(f81d7cbdfe99ff2b11932fb4cdcd94f18e629e3fedafcd25ee0a4ddc0967f0f9)", + "HashTag(#191)", + "RegularText(2%)", + "RegularText(pinkyjay,)", + "Email(pinkyjay@nostrplebs.com)", + "RegularText(-)", + "RegularText(b0dbac368a5ac474bc19ab11a0b3fd4260cf56b40c60944c4a331b8ad8ced926)", + "HashTag(#192)", + "RegularText(2%)", + "RegularText(criptobastardo,)", + "Email(criptobastardo@nostrplebs.com)", + "RegularText(-)", + "RegularText(311262ac14efb7011f23223b662aa1f18b3bb7c238206cb1c07424f051a11cce)", + "HashTag(#193)", + "RegularText(2%)", + "RegularText(lacosanostr,)", + "Email(lacosanostr@lacosanostr.com)", + "RegularText(-)", + "RegularText(6ce2001e7f070fade19d4817006747e4164089886a0faca950a6b0ab2a3b58b2)", + "HashTag(#194)", + "RegularText(2%)", + "RegularText(teeJem,)", + "Email(teejem@nostrplebs.com)", + "RegularText(-)", + "RegularText(36f7bc3a3f40b11095f546a86b11ff1babc7ca7111c8498d6b6950cfc7663694)", + "HashTag(#195)", + "RegularText(2%)", + "RegularText(BiancaBtcArt,)", + "RegularText()", + "RegularText(-)", + "RegularText(1f2c17bd3bcaf12f9c7e78fe798eeea59c1b22e1ee036694d5dc2886ddfa35d7)", + "HashTag(#196)", + "RegularText(2%)", + "RegularText(ruto,)", + "RegularText()", + "RegularText(-)", + "RegularText(2888961a564e080dfe35ad8fc6517b920d2fcd2b7830c73f7c3f9f2abae90ea9)", + "HashTag(#197)", + "RegularText(2%)", + "RegularText(Pocketcows,)", + "RegularText()", + "RegularText(-)", + "RegularText(e462fd4f25682164bdb7c51fc1b2cd3c7e6ddba13a1d7094b06f6f4fe47f9ae3)", + "HashTag(#198)", + "RegularText(2%)", + "RegularText(mewj,)", + "Email(mewj@elder.nostr.land)", + "RegularText(-)", + "RegularText(489ac583fc30cfbee0095dd736ec46468faa8b187e311fda6269c4e18284ed0c)", + "HashTag(#199)", + "RegularText(2%)", + "RegularText(nostr,)", + "RegularText()", + "RegularText(-)", + "RegularText(2bd053345e10aed28bd0e97c311aab3470f6d7f405dc588b056bce1e3797d2f0)", + "HashTag(#200)", + "RegularText(2%)", + "RegularText(Bobolo,)", + "RegularText()", + "RegularText(-)", + "RegularText(ca7799f00a9d792f9bba6947b32e3142e6c6c4733e52906cbaf92a2961216b46)", + "HashTag(#201)", + "RegularText(2%)", + "RegularText(InsolentBitcoin,)", + "RegularText()", + "RegularText(-)", + "RegularText(6484df04c9403a64c3039f5f00d24ac0535f497cdfa1f187bc6a2d34cf017b97)", + "HashTag(#202)", + "RegularText(2%)", + "RegularText(Monero)", + "RegularText(Directory,)", + "RegularText()", + "RegularText(-)", + "RegularText(1abdef52155dc52a21a2ac9ed19e444317f6cf83500df139fbe73c2a7ac78e2a)", + "HashTag(#203)", + "RegularText(2%)", + "RegularText(thetonewrecker,)", + "Email(thetonewrecker@nostrplebs.com)", + "RegularText(-)", + "RegularText(3762d3159bfd9d8acb56677eec9a6f8a5a05ea86636186ca6ed6714a69975fed)", + "HashTag(#204)", + "RegularText(2%)", + "RegularText(yodatravels,)", + "Email(yodatravels@iris.to)", + "RegularText(-)", + "RegularText(67eb726f7bb8e316418cd46cfa170d580345e51adbc186f8f7aa0d4380579350)", + "HashTag(#205)", + "RegularText(2%)", + "RegularText(Bitcoin)", + "RegularText(Bandit,)", + "Email(bitcoin69@iris.to)", + "RegularText(-)", + "RegularText(907842aa7b5d00054473d261e814c011c5d8e13bf8a585cc76121b1e6c51900f)", + "HashTag(#206)", + "RegularText(2%)", + "RegularText(Zzar,)", + "Email(Zzar@nostrplebs.com)", + "RegularText(-)", + "RegularText(ca1dd2422cb94874c1666c9c76b7961bbaea432632643f7a2dc9d4d2bfb35db9)", + "HashTag(#207)", + "RegularText(2%)", + "RegularText(vidalBidi,)", + "Email(vidalbidi@getalby.com)", + "RegularText(-)", + "RegularText(0c28a25357c76ac5ac3714eddc25d81fe98134df13351ab526fc2479cc306e65)", + "HashTag(#208)", + "RegularText(2%)", + "RegularText(994e89โ€ฆf75447,)", + "RegularText()", + "RegularText(-)", + "RegularText(994e892582261fd933af25bcc9672f2fbd5e769e3d1c889ecd292a7a92f75447)", + "HashTag(#209)", + "RegularText(2%)", + "RegularText(juangalt,)", + "Email(juangalt@current.ninja)", + "RegularText(-)", + "RegularText(372da077d6353430f343d5853d85311b3fd27018d5a83b8c1b397b92518ec7ac)", + "HashTag(#210)", + "RegularText(2%)", + "RegularText(Dean,)", + "Email(dean@nostrplebs.com)", + "RegularText(-)", + "RegularText(83f018060171dfee116b077f0f455472b6b6de59abf4730994022bf6f27d16be)", + "HashTag(#211)", + "RegularText(2%)", + "RegularText(alexli,)", + "Email(alex2@nostrverified.com)", + "RegularText(-)", + "RegularText(8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934)", + "HashTag(#212)", + "RegularText(2%)", + "RegularText(Khidthungban,)", + "RegularText()", + "RegularText(-)", + "RegularText(8d5cf93afb8d9ef1d08acee4e7147348d0c573bf7e5f57886a8a9a137cbe890c)", + "HashTag(#213)", + "RegularText(2%)", + "RegularText(Trooper,)", + "Email(trooper@iris.to)", + "RegularText(-)", + "RegularText(2c8d81a4e5cd9a99caba73f14c087ca7c05e554bb9988a900ccd76dbd828407d)", + "HashTag(#214)", + "RegularText(2%)", + "RegularText(Satscoinsv,)", + "RegularText(โšก๏ธsatscoinsv@getalby.com)", + "RegularText(-)", + "RegularText(80db64657ea0358c5332c5cca01565eeddd4b8799688b1c46d3cb2d7c966671f)", + "HashTag(#215)", + "RegularText(2%)", + "RegularText(AARBTC,)", + "Email(aarbtc@iris.to)", + "RegularText(-)", + "RegularText(6d23993803386c313b7d4dcdfffdbe4e1be706c2f0c89cb5afaa542bf2be1b90)", + "HashTag(#216)", + "RegularText(2%)", + "RegularText(yogsite,)", + "Email(_@gue.yogsite.com)", + "RegularText(-)", + "RegularText(d3ab705ec57f3ea963fc7c467bddc7b17bf01b85acc4fbb14eed87df794a116c)", + "HashTag(#217)", + "RegularText(2%)", + "RegularText(NostrMemes,)", + "Email(nostrmemes@iris.to)", + "RegularText(-)", + "RegularText(6399694ca3b8c40d8be9762f50c9c420bf0bd73fb7d7d244a195814c9ab8fb7e)", + "HashTag(#218)", + "RegularText(2%)", + "RegularText(btcpavao,)", + "Email(btcpavao@iris.to)", + "RegularText(-)", + "RegularText(1a8ed3216bd2b81768363b4326e1ae270a7cd6fe570bafeda2dc070f34f3aedc)", + "HashTag(#219)", + "RegularText(2%)", + "RegularText(Anonymous,)", + "Email(Anonymous@BitcoinNostr.com)", + "RegularText(-)", + "RegularText(ac076f8f80ee4a49f22c2ce258dcfe6e105de0bf029a048fa3a8de4b51c1b957)", + "HashTag(#220)", + "RegularText(2%)", + "RegularText(zoltanAB,)", + "Email(zoltanab@iris.to)", + "RegularText(-)", + "RegularText(42aafd1217089d68c757671a251507a194587dd3adfc3a3a76bb1e38a78a3453)", + "HashTag(#221)", + "RegularText(2%)", + "RegularText(katsu,)", + "Email(katsu@onsats.org)", + "RegularText(-)", + "RegularText(76f64475795661961801389aeaa7869a005735266c9e3df9bc93d127fad04154)", + "HashTag(#222)", + "RegularText(2%)", + "RegularText(bryan,)", + "Email(bryan@nonni.io)", + "RegularText(-)", + "RegularText(9ddf6fe3a194d330a6c6e278a432ae1309e52cc08587254b337d0f491f7ff642)", + "HashTag(#223)", + "RegularText(2%)", + "RegularText(pedromvpg,)", + "Email(pedromvpg@pedromvpg.com)", + "RegularText(-)", + "RegularText(8cd2d0f8310f7009e94f50231870756cb39ba68f37506044910e2f71482b1788)", + "HashTag(#224)", + "RegularText(2%)", + "RegularText(Nellie,)", + "Email(sonicstudio@getalby.com)", + "RegularText(-)", + "RegularText(37fbbf7707e70a8a7787e5b1b75f3e977e70aab4f41ddf7b3c0f38caedd875d4)", + "HashTag(#225)", + "RegularText(2%)", + "RegularText(nicknash,)", + "RegularText()", + "RegularText(-)", + "RegularText(636b4e6f5a594893c544b49a5742f0a90f109b70d659585e0427a1c0361c0b09)", + "HashTag(#226)", + "RegularText(2%)", + "RegularText(dlegal,)", + "Email(kounsellor@nostrplebs.com)", + "RegularText(-)", + "RegularText(201e51e71a753af3699cf684d7f4113c59a73c4b7bd26ef3f4c187a6173fbf06)", + "HashTag(#227)", + "RegularText(2%)", + "RegularText(BitcoinLake,)", + "RegularText()", + "RegularText(-)", + "RegularText(5babddf98277e3db6c88ae1d322bc63fd637764370e1d5e4fe5226104d82034f)", + "HashTag(#228)", + "RegularText(2%)", + "RegularText(BitcoinKeegan,)", + "RegularText()", + "RegularText(-)", + "RegularText(b457120b6cfb2589d48718f2ab71362dd0db43e13266771725129d35cc602dbe)", + "HashTag(#229)", + "RegularText(2%)", + "RegularText(KatieRoss,)", + "Email(katieross@nostrplebs.com)", + "RegularText(-)", + "RegularText(90f09238f3514f249e2b333e6119eef49697020f956fd7b6732ce118dd1b53cb)", + "HashTag(#230)", + "RegularText(2%)", + "RegularText(efcfa6โ€ฆe3f485,)", + "RegularText()", + "RegularText(-)", + "RegularText(efcfa63ac0324e37fb138c2b9dbbf9372f64ec857c923c5c1f713d3592e3f485)", + "HashTag(#231)", + "RegularText(2%)", + "RegularText(bc9e89โ€ฆb519d3,)", + "RegularText()", + "RegularText(-)", + "RegularText(bc9e89110e6e7ec5540b8ad0467d8a39554a7527c27e7af4cd45b2b8c4b519d3)", + "HashTag(#232)", + "RegularText(2%)", + "RegularText(Ilj,)", + "Email(iamlj@iris.to)", + "RegularText(-)", + "RegularText(fa3e7bcc5e588a8111ffb9d9eb8bf62c87d8a0ef6e1e5e0c74311b61f6ced8e7)", + "HashTag(#233)", + "RegularText(2%)", + "RegularText(ayelen,)", + "RegularText()", + "RegularText(-)", + "RegularText(1c31ccda2709fc6cf5db0a0b0873613e25646c4a944779dfb5e8d6cbbcd2ee1c)", + "HashTag(#234)", + "RegularText(2%)", + "RegularText(zach,)", + "Email(Zach@BitcoinNostr.com)", + "RegularText(-)", + "RegularText(d99211aeeb643695ee1aad0517696bbc822e2fb443afe2dc9dadc0ca50b040e2)", + "HashTag(#235)", + "RegularText(2%)", + "RegularText(Yi,)", + "RegularText()", + "RegularText(-)", + "RegularText(248caad2f8392c7f72502da41ee62bbe256ea66fb365e395c988198660562ff7)", + "HashTag(#236)", + "RegularText(2%)", + "RegularText(Amouranth,)", + "Email(amouranth@nostrcheck.me)", + "RegularText(-)", + "RegularText(be5aa097ad9f4d872c70e432ad8c09565ee7dc1aee24a50b683ddca771b14901)", + "HashTag(#237)", + "RegularText(2%)", + "RegularText(hss5qy,)", + "Email(hss5qy@getalby.com)", + "RegularText(-)", + "RegularText(bc21401161327647e0bbd31f2dec1be168ef7fa5d05689fca0d063b114ed9b46)", + "HashTag(#238)", + "RegularText(2%)", + "RegularText(dpc,)", + "Email(dpcpw@iris.to)", + "RegularText(-)", + "RegularText(274611b4728b0c40be1cf180d8f3427d7d3eebc55645d869a002e8b657f8cd61)", + "HashTag(#239)", + "RegularText(2%)", + "RegularText(pred,)", + "RegularText()", + "RegularText(-)", + "RegularText(3946adbb2fc7c95f75356d8f3952c8e2705ee2431f8bd33f5cae0f9ede0298e2)", + "HashTag(#240)", + "RegularText(2%)", + "RegularText(jamesgore,)", + "RegularText()", + "RegularText(-)", + "RegularText(a94921403ac0ccf1a150ccac3679b11adcb3c3bb78b490452db43a8b6964a5c7)", + "HashTag(#241)", + "RegularText(2%)", + "RegularText(bitcoinfinity,)", + "Email(bitcoinfinity@nostrplebs.com)", + "RegularText(-)", + "RegularText(afbda6a942f975ddf8728bda3e6e5c9e440f067fcde719c6f57512f0f7ed4bf2)", + "HashTag(#242)", + "RegularText(2%)", + "RegularText(tonyseries,)", + "Email(TonySeries@BitcoinNostr.com)", + "RegularText(-)", + "RegularText(ba5a614a48719361f515f6efa62c3e213da4bcddbb78dafd3121daa839192275)", + "HashTag(#243)", + "RegularText(2%)", + "RegularText(kuobano,)", + "Email(kuobano@nostrplebs.com)", + "RegularText(-)", + "RegularText(3f6d0bbb073839671f4c7f1e23452c6c3080f6c5f4cbc2f56c17e2b57ee01442)", + "HashTag(#244)", + "RegularText(2%)", + "RegularText(kitakripto,)", + "Email(kitakripto@BitcoinNostr.com)", + "RegularText(-)", + "RegularText(0b11a45bf4ff7f000886b2227e43404d212bf585f71514d54ae5ae685f4c8fbb)", + "HashTag(#245)", + "RegularText(2%)", + "RegularText(Bashy,)", + "Email(_@localhost.re)", + "RegularText(-)", + "RegularText(566516663d91d4fef824eaeccbf9c2631a8d8a2efee8048ca5ee6095e6e5c843)", + "HashTag(#246)", + "RegularText(2%)", + "RegularText(alxc,)", + "Email(alxc@uselessshit.co)", + "RegularText(-)", + "RegularText(c13cb9426a4f85aff08019d246d1240a6cbf49ab9525a06d54fb496b9a3592b0)", + "HashTag(#247)", + "RegularText(2%)", + "RegularText(Kukryr,)", + "Email(kukryr@orangepill.dev)", + "RegularText(-)", + "RegularText(3f03ab6555d2e36ba970d83b8dfe1a9c09d1b89048cf7db0c85d40850f406e54)", + "HashTag(#248)", + "RegularText(2%)", + "RegularText(Saidah,)", + "Email(saidah@nostrplebs.com)", + "RegularText(-)", + "RegularText(909efa6667b28627f107764ce3c28895c46fffd1811b7415dcab03f48c44b597)", + "HashTag(#249)", + "RegularText(2%)", + "RegularText(micmad,)", + "RegularText(miceliomad@miceliomad.github.io/nostr/)", + "RegularText(-)", + "RegularText(cd806edcf8ff40ea94fa574ea9cd97da16e5beb2b85aac6e1d648b8388504343)", + "HashTag(#250)", + "RegularText(2%)", + "RegularText(Zack)", + "RegularText(Wynne,)", + "RegularText()", + "RegularText(-)", + "RegularText(9156e62c7d2f49a91b55effec6c111d3fb343e9de6ff05650e7fd89a039a9dce)", + "HashTag(#251)", + "RegularText(2%)", + "RegularText(Sharon21M,)", + "Email(sharon21m@nostr.fan)", + "RegularText(-)", + "RegularText(66b5c5be6cec2b4a124c532e97d8342f8d763d6b507caced9185168603751f25)", + "HashTag(#252)", + "RegularText(2%)", + "RegularText(bitcoinheirodomanto,)", + "RegularText()", + "RegularText(-)", + "RegularText(93d16b6fcd11199cc113e28976999ff94137ded02ddf6b84bf671daf9358c54a)", + "HashTag(#253)", + "RegularText(2%)", + "RegularText(tyler,)", + "RegularText()", + "RegularText(-)", + "RegularText(272fe1597e8d938b9a7ae5eb23aa50c5048aabbf68f27a428afe3aecd08192da)", + "HashTag(#254)", + "RegularText(2%)", + "RegularText(DMN,)", + "Email(dmn@noderunners.org)", + "RegularText(-)", + "RegularText(176d6e6ceef73b3c66e1cb1ed19b9f2473eaa514678159bc41361b3f29ddb065)", + "HashTag(#255)", + "RegularText(2%)", + "RegularText(Nela@Nostrica2023,)", + "Email(nela_at_nostrica2023@Nostr-Check.com)", + "RegularText(-)", + "RegularText(4b0bcab460adda31fad5a326fb0c04f6ec821fb24be85dbdc03c04cc0e12fc07)", + "HashTag(#256)", + "RegularText(2%)", + "RegularText(xbolo,)", + "Email(xbologg@nanostr.deno.dev)", + "RegularText(-)", + "RegularText(7aabf4a15df15074deeffdb597e6be54be4a211cbd6303436cb1ccea6c9cf87b)", + "HashTag(#257)", + "RegularText(2%)", + "RegularText(btcurenas,)", + "Email(btcurenas@nostr.fan)", + "RegularText(-)", + "RegularText(206a1264c89e8f29355e792782e83ca62331ca3d70169327cb315171b4a7ce2c)", + "HashTag(#258)", + "RegularText(2%)", + "RegularText(amaluenda,)", + "Email(amaluenda@getalby.com)", + "RegularText(-)", + "RegularText(129a80a580a0cb88d5eae9d3924d7bb8a29e0c03ef9fb723091de69c22eaaff8)", + "HashTag(#259)", + "RegularText(2%)", + "RegularText(DeveRoSt,)", + "RegularText()", + "RegularText(-)", + "RegularText(f838b6a03d8d0127a9a98e87c0142b528916a4336ba537e14131a2f513becc17)", + "HashTag(#260)", + "RegularText(2%)", + "RegularText(phoenixpyro,)", + "RegularText()", + "RegularText(-)", + "RegularText(5122cee9af93a36be4bb9b08ee7897ef88fe446c0a5d2f8db60da9faa0f72f27)", + "HashTag(#261)", + "RegularText(2%)", + "RegularText(Queen)", + "RegularText(โ‚ฟ,)", + "Email(queenb@nostrplebs.com)", + "RegularText(-)", + "RegularText(735e573b24b78138e86c96aaf37cf47547d6287c9acbd4eda173e01826b6647a)", + "HashTag(#262)", + "RegularText(2%)", + "RegularText(L.,)", + "Email(ezekiel@Nostr-Check.com)", + "RegularText(-)", + "RegularText(83663cd936892679cbd1ccdf22e017cb9fee11aef494713192c93ad6a155e287)", + "HashTag(#263)", + "RegularText(2%)", + "RegularText(dolu)", + "RegularText((compromised),)", + "RegularText()", + "RegularText(-)", + "RegularText(e668a111aa647e63ef587c17fb0e2513d5c2859cd8d389563c7640ffea1fc216)", + "HashTag(#264)", + "RegularText(2%)", + "RegularText(Marakesh)", + "RegularText(๐“…ฆ,)", + "Email(marakesh@getalby.com)", + "RegularText(-)", + "RegularText(dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491)", + "HashTag(#265)", + "RegularText(2%)", + "RegularText(Storm,)", + "Email(storm@reddirtmining.io)", + "RegularText(-)", + "RegularText(eaba072268fbb5409bdd2e8199e2878cf5d0b51ce3493122d03d7c69585d17f2)", + "HashTag(#266)", + "RegularText(2%)", + "RegularText(fiore,)", + "RegularText()", + "RegularText(-)", + "RegularText(155fd584b69fea049a428935cef11c093b6b80ca067fe4362eab0564d0774f10)", + "HashTag(#267)", + "RegularText(2%)", + "RegularText(.b.o.n.e.s.,)", + "Email(_b_o_n_e_s_@stacker.news)", + "RegularText(-)", + "RegularText(b91257b518ee7226972fc7b726e96d8a63477750a1b40589e36a090735a4f92f)", + "HashTag(#268)", + "RegularText(2%)", + "RegularText(btchodl,)", + "Email(bdichdbd@stacker.news)", + "RegularText(-)", + "RegularText(d3ca4d0144b7608eceb214734a098d50dd6c728eb72e47b0e5b1e04480db1009)", + "HashTag(#269)", + "RegularText(2%)", + "RegularText(Rosie,)", + "RegularText()", + "RegularText(-)", + "RegularText(caf0d967570ab0702c3402d50c4ab12dc6855ea062519b1ac048708cb663b0c8)", + "HashTag(#270)", + "RegularText(2%)", + "RegularText(j9,)", + "Email(j9@nostrplebs.com)", + "RegularText(-)", + "RegularText(c2797c4c633d3005d60a469d154b85766277454b648252d927660d41ecec4163)", + "HashTag(#271)", + "RegularText(2%)", + "RegularText(nokyctranslate,)", + "Email(nokyctranslate@iris.to)", + "RegularText(-)", + "RegularText(794366f1f67b7bc5604fd47e21a27e6fcbff7ec7e7a72c6d4c386d50fd5d2f04)", + "HashTag(#272)", + "RegularText(2%)", + "RegularText(Neomobius,)", + "Email(Neomobius_at_mstdn.jp@mostr.pub)", + "RegularText(-)", + "RegularText(9134bd35097c03abdcd9d61819aa8948880b6e49fc548d8a751b719dced7f7da)", + "HashTag(#273)", + "RegularText(2%)", + "RegularText(dojomaster,)", + "RegularText()", + "RegularText(-)", + "RegularText(30be56daec34e8b319d730f2c2f1cba28ef076660be33d7811dd385698a9cb40)", + "HashTag(#274)", + "RegularText(2%)", + "RegularText(paddepadde)", + "RegularText(โšก๏ธ,)", + "Email(paddepadde@getcurrent.io)", + "RegularText(-)", + "RegularText(430169631f2f0682c60cebb4f902d68f0c71c498fd1711fd982f052cf1fd4279)", + "HashTag(#275)", + "RegularText(2%)", + "RegularText(Val,)", + "Email(val@nostrplebs.com)", + "RegularText(-)", + "RegularText(e2004cb6f21a23878f0000131363e557638e47a804bcfc200103dd653fc9b7dc)", + "HashTag(#276)", + "RegularText(2%)", + "RegularText(Nickfost_,)", + "RegularText()", + "RegularText(-)", + "RegularText(a3e4cba409d392a81521d8714578948979557c8b2d56994b2026a06f6b7e97d2)", + "HashTag(#277)", + "RegularText(2%)", + "RegularText(dishwasher_iot,)", + "Email(dishwasher_iot@wlvs.space)", + "RegularText(-)", + "RegularText(5c6c25b7ef18d8633e97512159954e1aa22809c6b763e94b9f91071836d00217)", + "HashTag(#278)", + "RegularText(2%)", + "RegularText(๐•ฌ๐–“๐–”๐–“๐–ž๐–’๐–”๐–š๐–˜,)", + "Link(zapper.lol)", + "RegularText(-)", + "RegularText(96aceca84aa381eeda084167dd317e1bf7a45d874cd14147f0a9e0df86fb44c2)", + "HashTag(#279)", + "RegularText(2%)", + "RegularText(Peter,)", + "RegularText()", + "RegularText(-)", + "RegularText(b649ca5743312176174cbe76cf81d3eec493b21a52b822b6aa12bd4473da0d01)", + "HashTag(#280)", + "RegularText(2%)", + "RegularText(justin,)", + "Email(1@justinrezvani.com)", + "RegularText(-)", + "RegularText(84d535055542132100ea22e96e33349844422e6e698cc98bd8fb5eae08d76752)", + "HashTag(#281)", + "RegularText(2%)", + "RegularText(vikeymehta,)", + "RegularText()", + "RegularText(-)", + "RegularText(1a3d05e13fa38543b3d45f31c638e94e113b35c0e1db7371cdfa69861e150830)", + "HashTag(#282)", + "RegularText(2%)", + "RegularText(sshh,)", + "Email(sshh@nostrplebs.com)", + "RegularText(-)", + "RegularText(b0f86106d59d2ce292a4d89e70ff4057d7adf4b1b42bb913f37ceb9159bb2aea)", + "HashTag(#283)", + "RegularText(2%)", + "RegularText(Red_Eye_Jedi,)", + "RegularText()", + "RegularText(-)", + "RegularText(3603dbbea53ee52ab34e0f96a8d42aa55486cf5e2e05483533613e97274155f5)", + "HashTag(#284)", + "RegularText(2%)", + "RegularText(jim,)", + "Email(mk05@iris.to)", + "RegularText(-)", + "RegularText(2ed67b778522bfa0245ee57306dea40d6fd9b023db5fff43e2de0419cfe2164e)", + "HashTag(#285)", + "RegularText(2%)", + "RegularText(pniraj007,)", + "RegularText()", + "RegularText(-)", + "RegularText(99f7ba6cfb2fcd60853446b45cec2a467f65faa3245a95513bcf372eec4fbb0e)", + "HashTag(#286)", + "RegularText(2%)", + "RegularText(b676ebโ€ฆ7c389b,)", + "RegularText()", + "RegularText(-)", + "RegularText(b676ebe5ebd490523dda7db35407b7370974b4df25be32335f0652a1f07c389b)", + "HashTag(#287)", + "RegularText(2%)", + "RegularText(herald,)", + "Email(herald@bitcoin-herald.org)", + "RegularText(-)", + "RegularText(7e7224cfe0af5aaf9131af8f3e9d34ff615ff91ce2694640f1f1fee5d8febb7d)", + "HashTag(#288)", + "RegularText(2%)", + "RegularText(Giuseppe)", + "RegularText(Atorino,)", + "Email(nostr@pos.btcpayserver.it)", + "RegularText(-)", + "RegularText(e6eaf2368767307b45fcbea2d96dcb34a93af8877147203fadc10b8f741b71c9)", + "HashTag(#289)", + "RegularText(2%)", + "RegularText(a8b7b0โ€ฆd90ac2,)", + "RegularText()", + "RegularText(-)", + "RegularText(a8b7b07222485f8b845961dd4ca4d8b63c575e060b4d9386e32463e513d90ac2)", + "HashTag(#290)", + "RegularText(2%)", + "RegularText(genosonic,)", + "RegularText()", + "RegularText(-)", + "RegularText(05ffbdf4b71930d0e93ae0caa8f34bcfb5100cfba71f07b9fad4d8b5a80e4df3)", + "HashTag(#291)", + "RegularText(2%)", + "RegularText(JohnnyG,)", + "Email(thumpgofast@NostrVerified.com)", + "RegularText(-)", + "RegularText(241d6b169d62fa3d673fccf66ab62d49c0a1147ab6ab81f7a526d890e1d68a2b)", + "HashTag(#292)", + "RegularText(2%)", + "RegularText(neoop,)", + "Email(neo@elder.nostr.land)", + "RegularText(-)", + "RegularText(ea64386dba380b76c86f671f2f3c5b2a93febe8d3e2e968ac26f33569da36f87)", + "HashTag(#293)", + "RegularText(2%)", + "RegularText(Alchemist,)", + "Email(alchemist@electronalchemy.com)", + "RegularText(-)", + "RegularText(734aac327175cb770b9aa75c8816156ea439a79c6f87a16801248c1c793a8bfc)", + "HashTag(#294)", + "RegularText(2%)", + "RegularText(timp,)", + "Email(timp@iris.to)", + "RegularText(-)", + "RegularText(24cf74e1125833e9752b4843e2887dedddf6910896e6e82a2def68c8527d0814)", + "HashTag(#295)", + "RegularText(2%)", + "RegularText(ken,)", + "Email(ken@BitcoinNostr.com)", + "RegularText(-)", + "RegularText(3505b759f075da83e9d503530d3238361b1603c28e0ee309d928174e87341713)", + "HashTag(#296)", + "RegularText(2%)", + "RegularText(Shea,)", + "RegularText()", + "RegularText(-)", + "RegularText(8dc289f2b5896057e23edc6b806407dc09162147164f4cae1d00dcb1bcd3f084)", + "HashTag(#297)", + "RegularText(2%)", + "RegularText(Devcat,)", + "RegularText()", + "RegularText(-)", + "RegularText(7f1052e59569dee4c6587507c69032af5d6883d2aa659a55bbfe1cb2e8233daf)", + "HashTag(#298)", + "RegularText(2%)", + "RegularText(173a2eโ€ฆ36436a,)", + "RegularText()", + "RegularText(-)", + "RegularText(173a2e04860656e9bab4a62cd5ec2b46ac8814e240c183e47b6badf7b936436a)", + "HashTag(#299)", + "RegularText(2%)", + "RegularText(Irebus,)", + "Email(irebus@nostr.red)", + "RegularText(-)", + "RegularText(1aaaa8e2a2094e2fdd70def09eae4e329ceb01a6a29473cb0b5e0c118f85bd35)", + "HashTag(#300)", + "RegularText(2%)", + "RegularText(b720b6โ€ฆe48a8f,)", + "RegularText()", + "RegularText(-)", + "RegularText(b720b63c47b3292dcb3339782c612462a7a42c9eece06d609a49cf951de48a8f)", + "HashTag(#301)", + "RegularText(2%)", + "RegularText(theflywheel,)", + "RegularText()", + "RegularText(-)", + "RegularText(57dcc9ed500a26a465ddb12c51de05963d4dec8a596708629558495c4acacab3)", + "HashTag(#302)", + "RegularText(2%)", + "RegularText(223597โ€ฆ002c18,)", + "RegularText()", + "RegularText(-)", + "RegularText(22359794c50e2945aa768ee500ffb2ddb388696ad078a350ae570152ff002c18)", + "HashTag(#303)", + "RegularText(2%)", + "RegularText(gratitude,)", + "RegularText()", + "RegularText(-)", + "RegularText(4686358c60bae7694e8b39dad26d1c834d5dd27726a56e2501fc06dec6942be1)", + "HashTag(#304)", + "RegularText(2%)", + "RegularText(stim4444,)", + "Email(stim4444@no.str.cr)", + "RegularText(-)", + "RegularText(0aeaec333bf9a0638de51ea837590ca64522ec590ed160ce87cb6e30d10df537)", + "HashTag(#305)", + "RegularText(2%)", + "RegularText(756240โ€ฆ265fc2,)", + "RegularText()", + "RegularText(-)", + "RegularText(756240d3be0d553b0cd174b3499cffa37fbe8394ee06b9ab50652e314c265fc2)", + "HashTag(#306)", + "RegularText(2%)", + "RegularText(4d38edโ€ฆd26aad,)", + "RegularText()", + "RegularText(-)", + "RegularText(4d38ed26a6d1080806534818a668c71381bcb04bc4ca1083d9d9572977d26aad)", + "HashTag(#307)", + "RegularText(2%)", + "RegularText(Kwinten,)", + "RegularText()", + "RegularText(-)", + "RegularText(c29da265739bc3886c76d84b0a351849fa45a31a64fcb72f47c600ab2623f90c)", + "HashTag(#308)", + "RegularText(2%)", + "RegularText(b36506โ€ฆ7ca32c,)", + "RegularText()", + "RegularText(-)", + "RegularText(b365069ada41fc7190f8b11e8342f7f66f9777eaaa9882722d0be863c27ca32c)", + "HashTag(#309)", + "RegularText(2%)", + "RegularText(Cole)", + "RegularText(Albon,)", + "RegularText()", + "RegularText(-)", + "RegularText(c3ff9a851ca965ed266ba54c9263f680be91e2465628c64bab6a5992521d5c5d)", + "HashTag(#310)", + "RegularText(2%)", + "RegularText(Onecoin,)", + "RegularText()", + "RegularText(-)", + "RegularText(b23ce47262373574d6653fad2da09db1fb20bb2919f3e697b8edd1966fffd8ec)", + "HashTag(#311)", + "RegularText(2%)", + "RegularText(Disabled,)", + "RegularText()", + "RegularText(-)", + "RegularText(7d706eaefb905ea9b3af885879fb5911b50b39db539c319438703373424204ec)", + "HashTag(#312)", + "RegularText(2%)", + "RegularText(xdamman,)", + "RegularText()", + "RegularText(-)", + "RegularText(340254e011abda2e82585cbfee4f91b3f07549a6c468fe009bf3ec7665a2e31b)", + "HashTag(#313)", + "RegularText(2%)", + "RegularText(jmrichner,)", + "RegularText()", + "RegularText(-)", + "RegularText(797750041d1366a80d45e130c831f0562b5f7266662b07acef50dd541bfa2535)", + "HashTag(#314)", + "RegularText(2%)", + "RegularText(pentoshi,)", + "RegularText()", + "RegularText(-)", + "RegularText(db6ad1e2a4cbbacbbdf79377a9ebb2fc30eb417ce9b061003771cb40b8e00d56)", + "HashTag(#315)", + "RegularText(2%)", + "RegularText(35453dโ€ฆ45d10b,)", + "RegularText()", + "RegularText(-)", + "RegularText(35453d2e49a0282c4dd694e5a364bf29600a9b5443e4712cfc86a0495345d10b)", + "HashTag(#316)", + "RegularText(2%)", + "RegularText(LayerLNW,)", + "Email(layerlnw@nostr.fan)", + "RegularText(-)", + "RegularText(33c9edf7ade19188685997136e6ffb4ed89939178fa5f2259428de1cd3301380)", + "HashTag(#317)", + "RegularText(2%)", + "RegularText(Bitcoincouch,)", + "RegularText()", + "RegularText(-)", + "RegularText(fbd3c6eb5ef06e82583d3b533663ba86036462a02e686881d8cb2de5aaa9fa4a)", + "HashTag(#318)", + "RegularText(2%)", + "RegularText(BritishHodl,)", + "RegularText()", + "RegularText(-)", + "RegularText(22fb17c6657bb317be84421335ef6b0f9f1777617aa220cf27dc06fb5788f438)", + "HashTag(#319)", + "RegularText(2%)", + "RegularText(enhickman,)", + "Email(enhickman@enhickman.net)", + "RegularText(-)", + "RegularText(0cf08d280aa5fcfaf340c269abcf66357526fdc90b94b3e9ff6d347a41f090b7)", + "HashTag(#320)", + "RegularText(2%)", + "RegularText(4d6e72โ€ฆ219298,)", + "RegularText()", + "RegularText(-)", + "RegularText(4d6e72aba0e8a033c973acd7e42f915d5fa1708be7229d477869e91136219298)", + "HashTag(#321)", + "RegularText(2%)", + "RegularText(f75326โ€ฆaf65e0,)", + "RegularText()", + "RegularText(-)", + "RegularText(f7532615471b029a34e41e080b2af4bad2b80f8105c008378d0095991eaf65e0)", + "HashTag(#322)", + "RegularText(2%)", + "RegularText(LiveFreeBTC,)", + "Email(LiveFreeBTC@livefreebtc.org)", + "RegularText(-)", + "RegularText(49f586188679af2b31e8d62ff153baf767fd0c586939f4142ef65606d236ff75)", + "HashTag(#323)", + "RegularText(2%)", + "RegularText(aptx4869,)", + "Email(aptx4869@aptx4869.app)", + "RegularText(-)", + "RegularText(64aaa73189af814977ff5dedbbab022df030f1d7df3e6307aceb1fddb30df847)", + "HashTag(#324)", + "RegularText(2%)", + "RegularText(khalil,)", + "Email(khalil@klouche.com)", + "RegularText(-)", + "RegularText(5a03bdb5448b440428d8459d4afe9b553e705737ef8cd7a0d25569ccead4d6ce)", + "HashTag(#325)", + "RegularText(2%)", + "RegularText(nsec1wnppl0xqw2lysecymwmz3hgxuzk60dgyur6mqtgexln20qp4xv9sugxghg,)", + "Email(nsec@ittybitty.tips)", + "RegularText(-)", + "RegularText(f1ea91eeab7988ed00e3253d5d50c66837433995348d7d97f968a0ceb81e0929)", + "HashTag(#326)", + "RegularText(2%)", + "RegularText(BTC_P2P,)", + "RegularText()", + "RegularText(-)", + "RegularText(ecf468164bd743b75683db3870ce01cb9a1d4b8ec203ed26de50f96255bbc75a)", + "HashTag(#327)", + "RegularText(2%)", + "RegularText(Big)", + "RegularText(FISH,)", + "Email(bigfish@iris.to)", + "RegularText(-)", + "RegularText(963100cf40967a70cdea802c6b4b97956cf8c5e3b09e492b24a847d4c535a794)", + "HashTag(#328)", + "RegularText(2%)", + "RegularText(9e93fbโ€ฆ2483b6,)", + "RegularText()", + "RegularText(-)", + "RegularText(9e93fb0012a6177faddf2fd324fb61eafbe8b142b31c5e89fd85bfafd12483b6)", + "HashTag(#329)", + "RegularText(2%)", + "RegularText(Mynameis,)", + "RegularText()", + "RegularText(-)", + "RegularText(6bec23b4a17da33d0a2f44e258371e869ff124775e8e38b9581dcd49c8d1d4a6)", + "HashTag(#330)", + "RegularText(2%)", + "RegularText(3f2342โ€ฆd689b8,)", + "RegularText()", + "RegularText(-)", + "RegularText(3f23426af245168f8112e441c046ecdb29aca56a6d33d21e276b8ac00bd689b8)", + "HashTag(#331)", + "RegularText(2%)", + "RegularText(865c92โ€ฆ136ced,)", + "RegularText()", + "RegularText(-)", + "RegularText(865c92a207a156a2d48404694a2eed5ceca5c163b7a845b86a6c75e142136ced)", + "HashTag(#332)", + "RegularText(2%)", + "RegularText(95d4d6โ€ฆfe1673,)", + "RegularText()", + "RegularText(-)", + "RegularText(95d4d60e643f283cef8d70ab7a9c09ab5a85924f97e11b22cf99779c4ffe1673)", + "HashTag(#333)", + "RegularText(2%)", + "RegularText(verse,)", + "RegularText()", + "RegularText(-)", + "RegularText(0ff7a93751d37ffcca05579c59ac69053d8d0c6f2c57ed9101ba8758eebc0d6b)", + "HashTag(#334)", + "RegularText(2%)", + "RegularText(oldschool,)", + "Email(oldschool@iris.to)", + "RegularText(-)", + "RegularText(19dba8f974322c7345d3b491925896d19e7f432a4f41223c5daf96e31fae338d)", + "HashTag(#335)", + "RegularText(2%)", + "RegularText(Danton๐Ÿ‡จ๐Ÿ‡ญ,)", + "Email(danton@nostrplebs.com)", + "RegularText(-)", + "RegularText(dbe693bc2d16c52e18e75f2cb76401cb7d74132cc956f7315ea5ebee1adfc966)", + "HashTag(#336)", + "RegularText(2%)", + "RegularText(BitcoinZavior,)", + "Email(bitcoinzavior@nostrplebs.com)", + "RegularText(-)", + "RegularText(c6e86c9b95ef289600800b855b9a6ca42019cc9453937020289d8b3e01dab865)", + "HashTag(#337)", + "RegularText(2%)", + "RegularText(BitcoinSermons,)", + "Email(BitcoinSermons@BitcoinNostr.com)", + "RegularText(-)", + "RegularText(615f40fae8f2e08da81b5c76a0143cb04b4e9e044bf6047efe15c56c7cc1a6b2)", + "HashTag(#338)", + "RegularText(2%)", + "RegularText(skreep,)", + "RegularText()", + "RegularText(-)", + "RegularText(a4992688b449c2bdd6fa9c39a880d7fe27d5f5e3e9fd4c47d65d824588fd660f)", + "HashTag(#339)", + "RegularText(2%)", + "RegularText(db830bโ€ฆ4bb85c,)", + "RegularText()", + "RegularText(-)", + "RegularText(db830b864876a0f3109ae3447e43715711250d53f310092052aabb5bdc4bb85c)", + "HashTag(#340)", + "RegularText(2%)", + "RegularText(UKNW22LINUX,)", + "Email(uknwlinux@plebs.place)", + "RegularText(-)", + "RegularText(ab1ef3f15fc29b3da324eb401122382ceb5ea9c61adaad498192879fd9a5d057)", + "HashTag(#341)", + "RegularText(2%)", + "RegularText(Satoshism,)", + "Email(satoshism@nostrplebs.com)", + "RegularText(-)", + "RegularText(e262ed3a22ad8c478b077ef5d7c56b2c3c7a530519ed696ed2e57c65e147fbcb)", + "HashTag(#342)", + "RegularText(2%)", + "RegularText(William,)", + "RegularText()", + "RegularText(-)", + "RegularText(8c55174d8fc29d4da650b273fdd18ad4dda478faa4b0ea14726d81ac6c7bef48)", + "HashTag(#343)", + "RegularText(2%)", + "RegularText(thebitcoinyogi,)", + "Email(jon@nostrplebs.com)", + "RegularText(-)", + "RegularText(59c2e15ad7bc0b5c97b8438b2763a5c409ff76ab985ab5f1f47c4bcdd25e6e8d)", + "HashTag(#344)", + "RegularText(2%)", + "RegularText(vake,)", + "RegularText()", + "RegularText(-)", + "RegularText(547f45b91c1e6b4137917cde4fa1da867c8cdfe43d0f646c836a622769795a14)", + "HashTag(#345)", + "RegularText(2%)", + "RegularText(hobozakki,)", + "Email(hobozakki@nostrplebs.com)", + "RegularText(-)", + "RegularText(29e31c4103b85fab499132fa71870bd5446de8f7e2ac040ec0372aa61ae22f98)", + "HashTag(#346)", + "RegularText(2%)", + "RegularText(SirGalahodl,)", + "Email(sirgalahodl@satstream.me)", + "RegularText(-)", + "RegularText(25ee676190e2b6145ad8dd137630eca55fc503dde715ce8af4c171815d018797)", + "HashTag(#347)", + "RegularText(2%)", + "RegularText(1f6c76โ€ฆebb9c9,)", + "RegularText()", + "RegularText(-)", + "RegularText(1f6c76ddbab213cdd43db2695b1474605639862302c7cfae35362be8caebb9c9)", + "HashTag(#348)", + "RegularText(2%)", + "RegularText(greencandleit,)", + "RegularText()", + "RegularText(-)", + "RegularText(3d4b358b50d20c3e4d855f273ff06c49bc6b3f6e62c42aed44f278742fd579da)", + "HashTag(#349)", + "RegularText(2%)", + "RegularText(ichigo,)", + "RegularText()", + "RegularText(-)", + "RegularText(477e0b3c0c6029e31562b39650efa8f871d52e3ab09145d72e99b9b74dd384d7)", + "HashTag(#350)", + "RegularText(2%)", + "RegularText(Niko,)", + "RegularText()", + "RegularText(-)", + "RegularText(636fdb4de194bca39ab30ab5793a38b8d15c1b1c0a968d04f7fe14eb1a6a8c42)", + "HashTag(#351)", + "RegularText(2%)", + "RegularText(afa,)", + "Email(victor@lnmarkets.com)", + "RegularText(-)", + "RegularText(8f6945b4726112826ac6abd56ec041c87d8bdc4ec02e86bb388a97481f372b97)", + "HashTag(#352)", + "RegularText(2%)", + "RegularText(BushBrook,)", + "RegularText()", + "RegularText(-)", + "RegularText(a39fd86ed75c654550bf813430877819beb77a3b670e01a9680a84a844db9620)", + "HashTag(#353)", + "RegularText(2%)", + "RegularText(naoise,)", + "RegularText()", + "RegularText(-)", + "RegularText(c4a9caef93e93f484274c04cd981d1de1424902451aca2f5602bd0835fe4393d)", + "HashTag(#354)", + "RegularText(2%)", + "SchemelessUrl(smies.me,)", + "Email(jacksmies@iris.to)", + "RegularText(-)", + "RegularText(cdecbc48e35a351582e3e030fd8cf5d5f44681613d2949353d9c6644d32d451f)", + "HashTag(#355)", + "RegularText(2%)", + "RegularText(Chemaclass,)", + "Email(chemaclass@snort.social)", + "RegularText(-)", + "RegularText(c5d4815c26e18e2c178133004a6ddba9a96a5f7af795a3ab606d11aa1055146a)", + "HashTag(#356)", + "RegularText(2%)", + "RegularText(BTCingularity,)", + "RegularText()", + "RegularText(-)", + "RegularText(aa1f96f685d0ac3e28a52feb87a20399a91afb3ac3137afeb7698dfcc99bc454)", + "HashTag(#357)", + "RegularText(2%)", + "RegularText(the_man,)", + "RegularText()", + "RegularText(-)", + "RegularText(dad77f3814964b5cdcd120a3a8d7b40c6218d413ae6328801b9929ed90123687)", + "HashTag(#358)", + "RegularText(2%)", + "RegularText(jayson,)", + "Email(jayson@tautic.com)", + "RegularText(-)", + "RegularText(7be5d241f3cc10922545e31aeb8d5735be2bc3230480e038c7fd503e7349a2cc)", + "HashTag(#359)", + "RegularText(2%)", + "RegularText(jesterhodl,)", + "Email(jesterhodl@jesterhodl.com)", + "RegularText(-)", + "RegularText(3c285d830bf433135ae61c721b750ce11ae5b2e187712d7a171afa7cda649e50)", + "HashTag(#360)", + "RegularText(2%)", + "RegularText(06d694โ€ฆc3ab96,)", + "RegularText()", + "RegularText(-)", + "RegularText(06d6946fd1ff1fba6ac530e0b5683db4c73cdc11d6c42324246e10f4f2c3ab96)", + "HashTag(#361)", + "RegularText(2%)", + "RegularText(sardin,)", + "RegularText()", + "RegularText(-)", + "RegularText(f26470570bcb67a18a90890dbe02d565eadc6c955912977c64c99d4b9a7fd29f)", + "HashTag(#362)", + "RegularText(2%)", + "RegularText(Bitcoin_Gamer_21,)", + "Email(Bitcoin_Gamer_21@bitcoin-21.org)", + "RegularText(-)", + "RegularText(021df4103ede2cdc32de4058d4bdb29ffcbfd13070f05c4688f6974bd9a67176)", + "HashTag(#363)", + "RegularText(2%)", + "RegularText(water-bot,)", + "Email(water-bot@gourcetools.github.io)", + "RegularText(-)", + "RegularText(000000dd7a2e54c77a521237a516eefb1d41df39047a9c64882d05bc84c9d666)", + "HashTag(#364)", + "RegularText(1%)", + "RegularText(ondorevillager,)", + "RegularText()", + "RegularText(-)", + "RegularText(5d7b460173010efd682c0d7bc8cc36ca9bf7dcc7990288f642c04b8e05713c83)", + "HashTag(#365)", + "RegularText(1%)", + "RegularText(Tomfantasia,)", + "RegularText()", + "RegularText(-)", + "RegularText(d856af932000c292ad723dee490ebcf908a1031b486dea05267ee50b473349b2)", + "HashTag(#366)", + "RegularText(1%)", + "RegularText(W3crypto,)", + "Email(w3crypto@iris.to)", + "RegularText(-)", + "RegularText(d001bca923ab56b1c759fc9471fbe6baadac50aeba7d963155772ac7b6779027)", + "HashTag(#367)", + "RegularText(1%)", + "RegularText(bradjpn,)", + "RegularText()", + "RegularText(-)", + "RegularText(c4da3be8e10fa86128530885d18e455900cccff39d7a24c4a6ac12b0284f62b3)", + "HashTag(#368)", + "RegularText(1%)", + "RegularText(@discretelog,)", + "RegularText()", + "RegularText(-)", + "RegularText(03e4804b4a28c051f43185d6bf5b4643cb3f0d9632c4394b60a2ffad0f852340)", + "HashTag(#369)", + "RegularText(1%)", + "RegularText(makaveli,)", + "Email(makaveli@nostrplebs.com)", + "RegularText(-)", + "RegularText(570469cbc969ea6c7e94c41c6496a2951f52d3399011992bf45f4b2216d99119)", + "HashTag(#370)", + "RegularText(1%)", + "RegularText(JamieAnders,)", + "Email(jamieanders@ln.tips)", + "RegularText(-)", + "RegularText(7601e743ad432d78471ac57178402a57cd3f3a92fb208be7de788af2d6a57669)", + "HashTag(#371)", + "RegularText(1%)", + "RegularText(LightningVentures,)", + "RegularText()", + "RegularText(-)", + "RegularText(37de18e08cdc01ce7ced1808b241ec0b4a69e754d576ce0e08f0cf3375bb0a6b)", + "HashTag(#372)", + "RegularText(1%)", + "RegularText(Colorado)", + "RegularText(Craig,)", + "Email(cball@nostrplebs.com)", + "RegularText(-)", + "RegularText(a2c20d6856545b145bc76cdfaffd04ddad4e58d73b2352dcc5de86aa4ba38e7b)", + "HashTag(#373)", + "RegularText(1%)", + "RegularText(21fadbโ€ฆ3d8f6f,)", + "RegularText()", + "RegularText(-)", + "RegularText(21fadb45755a5f41d1b84ecf4610657dd9336d24419d61efffb947aeec3d8f6f)", + "HashTag(#374)", + "RegularText(1%)", + "RegularText(castaway,)", + "RegularText()", + "RegularText(-)", + "RegularText(0cbde76a61cc539059f7da7b4fb19c0197f9f781674d307b52264cbb0144c739)", + "HashTag(#375)", + "RegularText(1%)", + "RegularText(chames,)", + "RegularText()", + "RegularText(-)", + "RegularText(a721f4370afd51fcbc7e2a685f24a454f14fea84448e1c2aa4a9a94b89f3ea7d)", + "HashTag(#376)", + "RegularText(1%)", + "RegularText(laura,)", + "Email(laura@nostrich.zone)", + "RegularText(-)", + "RegularText(ac2250f83aaa7c4a8503f9c15c0cc11ac992315e5ac3e634541223a8deb6c09c)", + "HashTag(#377)", + "RegularText(1%)", + "RegularText(Kaz,)", + "Email(kaz@reddirtmining.io)", + "RegularText(-)", + "RegularText(826d71153f4938c43b930f90cc3130f33430d1e069d43a2f705f9538450b9369)", + "HashTag(#378)", + "RegularText(1%)", + "RegularText(Verismus,)", + "Email(verismus@nostrplebs.com)", + "RegularText(-)", + "RegularText(9e79aed207461f0d5ebc2c8b94e6875e2a6d5dd15990f8ea3ad2540786d07528)", + "HashTag(#379)", + "RegularText(1%)", + "RegularText(cafc4fโ€ฆ107e85,)", + "RegularText()", + "RegularText(-)", + "RegularText(cafc4fbaa558e466bba6c667fcf14506728ff70975f2817c8e5b6fb062107e85)", + "HashTag(#380)", + "RegularText(1%)", + "RegularText(bitpetro,)", + "Email(bitpetro@nostrplebs.com)", + "RegularText(-)", + "RegularText(22470b963e71fa04e1f330ce55f66ff9783c7a9c4851b903d332a59f2327891e)", + "HashTag(#381)", + "RegularText(1%)", + "RegularText(nossence,)", + "Email(nossence@nossence.xyz)", + "RegularText(-)", + "RegularText(56899e6a55c14771a45a88cb90a802623a0e3211ea1447057e2c9871796ce57c)", + "HashTag(#382)", + "RegularText(1%)", + "RegularText(The)", + "RegularText(Progressive)", + "RegularText(Bitcoiner,)", + "RegularText()", + "RegularText(-)", + "RegularText(4870d5500a121e5187544a3e6e5c2fee1d0a03e1b85073f27edb710b110d6208)", + "HashTag(#383)", + "RegularText(1%)", + "RegularText(orangepillstacker,)", + "RegularText()", + "RegularText(-)", + "RegularText(affe861d3e4c42bb956a35d8f9d2c76a99ba16581f3d0dbf762d807e1de8e234)", + "HashTag(#384)", + "RegularText(1%)", + "RegularText(Nostrdamus,)", + "Email(manbearpig@nostrplebs.com)", + "RegularText(-)", + "RegularText(84a42d3efa48018e187027e2bbdd013285a27d8faf970f83a35691d7e2e1a310)", + "HashTag(#385)", + "RegularText(1%)", + "RegularText(JohnSmith,)", + "Email(johnsmith@nostrplebs.com)", + "RegularText(-)", + "RegularText(7c939a7211f1b818567d10b7e65bb03e2830420acf3d6f4f65a7320e2e66d97e)", + "HashTag(#386)", + "RegularText(1%)", + "RegularText(Matty,)", + "RegularText()", + "RegularText(-)", + "RegularText(1cb599e80e7933a7144bbebfb39168c6ee75a27bacd6d8a67e80c442a32a52a8)", + "HashTag(#387)", + "RegularText(1%)", + "RegularText(epodrulz,)", + "Email(bitcoin@bitcoinedu.com)", + "RegularText(-)", + "RegularText(a249234ba07c832c8ee99915f145c02838245499589a6ab8a7461f2ef3eec748)", + "HashTag(#388)", + "RegularText(1%)", + "RegularText(paul,)", + "RegularText()", + "RegularText(-)", + "RegularText(52b9e1aca3df269710568d1caa051abf40fbdf8c2489afb8d2b7cdb1d1d0ce6f)", + "HashTag(#389)", + "RegularText(1%)", + "RegularText(0ec37aโ€ฆba5855,)", + "RegularText()", + "RegularText(-)", + "RegularText(0ec37a784c894b8c8f96a0ccb6055d4ce7b8420482bc41d00e235723a9ba5855)", + "HashTag(#390)", + "RegularText(1%)", + "RegularText(jor,)", + "Email(knggolf@nostrplebs.com)", + "RegularText(-)", + "RegularText(7c765d407d3a9d5ea117cb8b8699628560787fc084a0c76afaa449bfbd121d84)", + "HashTag(#391)", + "RegularText(1%)", + "RegularText(Nighthaven,)", + "Email(nighthaven@iris.to)", + "RegularText(-)", + "RegularText(510e0096e4e622e9f2877af7e7af979ac2fdf50702b9cd77021658344d1a682c)", + "HashTag(#392)", + "RegularText(1%)", + "RegularText(00f454โ€ฆ929254,)", + "RegularText()", + "RegularText(-)", + "RegularText(00f45459dcd6c6e04706ddafd03a9f52a28833efc04b3ff0a66b89146b929254)", + "HashTag(#393)", + "RegularText(1%)", + "RegularText(XBT_fi,)", + "Email(xbt_fi@iris.to)", + "RegularText(-)", + "RegularText(6e1bee4bdfc34056ffcde2c0685ae6468867aedd0843ed5d0cfcde41f64bfda8)", + "HashTag(#394)", + "RegularText(1%)", + "RegularText(e9f332โ€ฆ6474aa,)", + "RegularText()", + "RegularText(-)", + "RegularText(e9f33272af64080287624176253ed2b468d17cec5f2a3d927a3ee36c356474aa)", + "HashTag(#395)", + "RegularText(1%)", + "RegularText(ulrichard,)", + "RegularText()", + "RegularText(-)", + "RegularText(cd0ea239c10e2dbe12e5171537ff0b8619747bfcd8dcf939f4bceed340b38c87)", + "HashTag(#396)", + "RegularText(1%)", + "RegularText(54ff28โ€ฆd7090d,)", + "RegularText()", + "RegularText(-)", + "RegularText(54ff28f1abbceddea50cf35cac69e5df32b982c3e872d40aa9ec035431d7090d)", + "HashTag(#397)", + "RegularText(1%)", + "RegularText(GeneralCarlosQ17,)", + "Email(gencarlosq17@iris.to)", + "RegularText(-)", + "RegularText(b13cc2d0b7b70ba41c13f09cc78dc6ce7f72049b1fe59a8194a237e23e37216e)", + "HashTag(#398)", + "RegularText(1%)", + "RegularText(BitcoinIslandPH,)", + "RegularText()", + "RegularText(-)", + "RegularText(b4ab403c8215e0606f11be21670126a501d85ea2027b6d15bf4b54c3236d0994)", + "HashTag(#399)", + "RegularText(1%)", + "RegularText(rotciv,)", + "Email(rotciv@plebs.place)", + "RegularText(-)", + "RegularText(b70c9bfb254b6072804212643beb077b6ba941609ed40515d9b10961d7767899)", + "HashTag(#400)", + "RegularText(1%)", + "RegularText(Alfa,)", + "RegularText()", + "RegularText(-)", + "RegularText(0575bc052fed6c729a0ab828efa45da77e28685da91bdfebc7a7640cb0728d12)", + "HashTag(#401)", + "RegularText(1%)", + "RegularText(ben_dewaal,)", + "RegularText()", + "RegularText(-)", + "RegularText(aac02781318dfc8c3d7ed0978ef9a7e8154a6b8ae6c910b3a52b42fd56875002)", + "HashTag(#402)", + "RegularText(1%)", + "RegularText(cguida,)", + "RegularText()", + "RegularText(-)", + "RegularText(2895c330c23f383196c0ef988de6da83b83b4583ed5f9c1edb0a559cecd1f900)", + "HashTag(#403)", + "RegularText(1%)", + "RegularText(nout,)", + "RegularText()", + "RegularText(-)", + "RegularText(52cb4b34775fa781b6a964bda0432dbcdfede7a59bf8dfc279cbff0ad8fb09ff)", + "HashTag(#404)", + "RegularText(1%)", + "RegularText(Merlin,)", + "Email(Merlin@bitcoinnostr.com)", + "RegularText(-)", + "RegularText(76dd32f31619b8e35e9f32e015224b633a0df8be8d5613c25b8838a370407698)", + "HashTag(#405)", + "RegularText(1%)", + "RegularText(millymischiefx,)", + "RegularText()", + "RegularText(-)", + "RegularText(868d9200af6e6fe1604a28d587b30c2712100b0edab76982551d56ebc6ae061f)", + "HashTag(#406)", + "RegularText(1%)", + "RegularText(yegorpetrov(alternative),)", + "Email(yeg0rpetrov@iris.to)", + "RegularText(-)", + "RegularText(2650f1f87e1dc974ffcc7b5813a234f6f1b1c92d56732f7db4fef986c80a31f7)", + "HashTag(#407)", + "RegularText(1%)", + "RegularText(baloo,)", + "Email(baloo@nostrpurple.com)", + "RegularText(-)", + "RegularText(c49f8402ef410fce5a1e5b2e6da06f351804988dd2e0bad18ae17a50fc76c221)", + "HashTag(#408)", + "RegularText(1%)", + "RegularText(jamesgospodyn,)", + "Email(jamesgospodyn@nostr.theorangepillapp.com)", + "RegularText(-)", + "RegularText(11edfa8182cf3d843ef36aa2fa270137d1aee9e4f0cd2add67707c8fc5ff2a0d)", + "HashTag(#409)", + "RegularText(1%)", + "RegularText(Mysterious_Minx,)", + "RegularText()", + "RegularText(-)", + "RegularText(381dbcc7138eab9a71e814c57837c9d623f4036ec0240ef302330684ffc8b38f)", + "HashTag(#410)", + "RegularText(1%)", + "RegularText(878bf5โ€ฆf7cb86,)", + "RegularText()", + "RegularText(-)", + "RegularText(878bf5d63ed5b13d2dac3f463e1bd73d0502bd3462ebf2ea3a0825ca11f7cb86)", + "HashTag(#411)", + "RegularText(1%)", + "RegularText(carl,)", + "Email(carl@armadalabs.studio)", + "RegularText(-)", + "RegularText(cd1197bede3b3c0cdc7412d076228e3f48b5b66e88760f53142e91485d128e07)", + "HashTag(#412)", + "RegularText(1%)", + "RegularText(NIMBUS,)", + "RegularText()", + "RegularText(-)", + "RegularText(c48a8ced6dfcc450056bb069b4007607c68a3e93cf3ae6e62b75bf3509f78178)", + "HashTag(#413)", + "RegularText(1%)", + "RegularText(btcportal,)", + "Email(btcportal@nostrplebs.com)", + "RegularText(-)", + "RegularText(9fc1e0ef750dba8cdb3b360b8a00ccad6dcef6b7ad7644f628e952ed8b7eebfb)", + "HashTag(#414)", + "RegularText(1%)", + "RegularText(9652baโ€ฆccd3f1,)", + "RegularText()", + "RegularText(-)", + "RegularText(9652ba74b6981f69a3ffad088aa0f16c8af7fe38a72e5d82176878acdcccd3f1)", + "HashTag(#415)", + "RegularText(1%)", + "RegularText(mjbonham,)", + "Email(mjb@nostrplebs.com)", + "RegularText(-)", + "RegularText(802afdddebfb60a516b39d649ea35401749622e394f85a687674907c4588dc7a)", + "HashTag(#416)", + "RegularText(1%)", + "RegularText(โŒœJanโŒ,)", + "RegularText()", + "RegularText(-)", + "RegularText(fca142a3a900fed71d831aa0aa9c21bb86a5917a9e1183659857b684f25ae1ce)", + "HashTag(#417)", + "RegularText(1%)", + "RegularText(DontTraceMeBruh,)", + "RegularText()", + "RegularText(-)", + "RegularText(3fef59378dce7726d3ef35d4699f57becf76d3be0a13187677126a66c9ade3b8)", + "HashTag(#418)", + "RegularText(1%)", + "RegularText(9a73c0โ€ฆ1707f2,)", + "RegularText()", + "RegularText(-)", + "RegularText(9a73c0ecd5049ae38b50d0d9eaaabd49390cdd08c3d3d666d0d8476c411707f2)", + "HashTag(#419)", + "RegularText(1%)", + "RegularText(esbewolkt,)", + "Email(esbewolkt@nostr.fan)", + "RegularText(-)", + "RegularText(50ea483ddffeeed3231c6f41fddfe8fb71f891fa736de46e3e06f748bbdeb307)", + "HashTag(#420)", + "RegularText(1%)", + "RegularText(morningstar,)", + "RegularText()", + "RegularText(-)", + "RegularText(82671c61fa007b0f70496dec2420238efd3df2f76cdaf6c1f810def8ce95ba45)", + "HashTag(#421)", + "RegularText(1%)", + "RegularText(Sweedgraffixx,)", + "RegularText()", + "RegularText(-)", + "RegularText(ee5f4a67cb434317dd7b931d9d23cb2978ab728a008e4c4dcca9cc781d3ae576)", + "HashTag(#422)", + "RegularText(1%)", + "RegularText(878492โ€ฆ165b4f,)", + "RegularText()", + "RegularText(-)", + "RegularText(878492807168be8dfbae71d721a9b7f6833a9928fcf9acc3274dfdb113165b4f)", + "HashTag(#423)", + "RegularText(1%)", + "RegularText(koukos,)", + "Email(koukos@iris.to)", + "RegularText(-)", + "RegularText(4260122b8a141e888413082dea2d93568488bae4726358e9e6b7da741852dfc8)", + "HashTag(#424)", + "RegularText(1%)", + "RegularText(nopara73,)", + "RegularText()", + "RegularText(-)", + "RegularText(001892e9b48b430d7e37c27051ff7bf414cbc52a7f48f451d857409ce7839dde)", + "HashTag(#425)", + "RegularText(1%)", + "RegularText(BeโšกBANK,)", + "RegularText()", + "RegularText(-)", + "RegularText(fbfb3855d50c37866af00484a6476680ae1e2ff04ceb9dd8936465f70d39150b)", + "HashTag(#426)", + "RegularText(1%)", + "RegularText(davekrock,)", + "Email(davekrock@NostrVerified.com)", + "RegularText(-)", + "RegularText(e26b5f261cb29354def8a8ba6af49b137e3144388a81ef78eed8e77cfb18fd44)", + "HashTag(#427)", + "RegularText(1%)", + "RegularText(BitcoinLoveLife,)", + "Email(Bitcoinlovelife@BitcoinNostr.com)", + "RegularText(-)", + "RegularText(3c08d854ef6c86b1dc11159fdabc09209eaeba01790ce96690c55787daf3c415)", + "HashTag(#428)", + "RegularText(1%)", + "RegularText(Steam,)", + "RegularText()", + "RegularText(-)", + "RegularText(111a1ae50a7e30a465126b0ab10c3eac6ddaa3cca016a4117470e6715a2dfdef)", + "HashTag(#429)", + "RegularText(1%)", + "RegularText(xolag,)", + "Email(xolagl2@getalby.com)", + "RegularText(-)", + "RegularText(fb64b9c3386a9ababaf8c4f80b47c071c4a38f7b8acdc4dafb009875a64f8c37)", + "HashTag(#430)", + "RegularText(1%)", + "RegularText(relay9may,)", + "RegularText()", + "RegularText(-)", + "RegularText(1e7fd2177d20c97f326cda699551f085b8e7f93650b48b6e87a0bebcdfeebc8b)", + "HashTag(#431)", + "RegularText(1%)", + "RegularText(f2c817โ€ฆ8a2f3b,)", + "RegularText()", + "RegularText(-)", + "RegularText(f2c817a3bbf07517a38beac228a12e3460d18f1ec2ed928d2e6d2e67308a2f3b)", + "HashTag(#432)", + "RegularText(1%)", + "RegularText(remoney,)", + "Email(remoney@nostrplebs.com)", + "RegularText(-)", + "RegularText(3939a929101b17f4782171b5e0e49996fbe2215b226bd847bd76be3c2de80e9a)", + "HashTag(#433)", + "RegularText(1%)", + "RegularText(387eb9โ€ฆa6f87f,)", + "RegularText()", + "RegularText(-)", + "RegularText(387eb9a5c4f43e40e6abd1f6fe953477464ae5830d104e325f362209c2a6f87f)", + "HashTag(#434)", + "RegularText(1%)", + "RegularText(846b76โ€ฆ539eca,)", + "RegularText()", + "RegularText(-)", + "RegularText(846b763b1234c5652f1e327e59570dcb6535d2d20589c67c2a9a90b323539eca)", + "HashTag(#435)", + "RegularText(1%)", + "RegularText(Shawn)", + "RegularText(C.,)", + "RegularText()", + "RegularText(-)", + "RegularText(83ea7cb5a3ab517f24eb2948b23f39466dd5f200fd4e6951fed43ba34e9a4a83)", + "HashTag(#436)", + "RegularText(1%)", + "RegularText(roberto,)", + "Email(roberto@bitcoiner.chat)", + "RegularText(-)", + "RegularText(319a588a77cd798b358724234b534bff3f3c294b4f6512bde94d070da93237c9)", + "HashTag(#437)", + "RegularText(1%)", + "RegularText(LazyNinja,)", + "Email(cryptolazyninja@stacker.news)", + "RegularText(-)", + "RegularText(ff444d454bc6ba2c16abdfd843124e6ad494297cf424fa81fb0604a24ee188e2)", + "HashTag(#438)", + "RegularText(1%)", + "RegularText(e5ae7bโ€ฆc8b2ef,)", + "RegularText()", + "RegularText(-)", + "RegularText(e5ae7b9cc5177675654400db194878601ee8ff5c355acb85daa50f7551c8b2ef)", + "HashTag(#439)", + "RegularText(1%)", + "RegularText(kimymt,)", + "Email(kimymt@getalby.com)", + "RegularText(-)", + "RegularText(3009318aa9544a2caf401ece529fd772e26cdd7e60349ec175423b302dafd521)", + "HashTag(#440)", + "RegularText(1%)", + "RegularText(z_hq,)", + "RegularText()", + "RegularText(-)", + "RegularText(215e2d416a8663d5b2e44f30d6c46750db7254cdbd2cf87fea4c1549d97486d4)", + "HashTag(#441)", + "RegularText(1%)", + "RegularText(Reza,)", + "RegularText()", + "RegularText(-)", + "RegularText(e7c0d1e42929695b972e90e88fb2210b3567af45206aac51fff85ba011f79093)", + "HashTag(#442)", + "RegularText(1%)", + "RegularText(benderlogic,)", + "Email(benderlogic@rogue.earth)", + "RegularText(-)", + "RegularText(d656ffcaf523f15899db0ea3289d04d00528714651d624814695cabe9cb34114)", + "HashTag(#443)", + "RegularText(1%)", + "RegularText(maestro,)", + "Email(MAESTRO@BitcoinNostr.com)", + "RegularText(-)", + "RegularText(8c3e08bbc47297021be7e6e2c59dab237fab9056b3a5302a8cd2fc2959037466)", + "HashTag(#444)", + "RegularText(1%)", + "RegularText(travis,)", + "Email(travis@west.report)", + "RegularText(-)", + "RegularText(3dc0b75592823507f5f625f889d36ba2607487550b4f38335a603eda010f2bc2)", + "HashTag(#445)", + "RegularText(1%)", + "RegularText(Coffee)", + "RegularText(Lover,)", + "Email(coffeelover@nostrplebs.com)", + "RegularText(-)", + "RegularText(9ecbaa6dc307291c3cf205c8a79ad8174411874cf244ca06f58a5a73e491222c)", + "HashTag(#446)", + "RegularText(1%)", + "RegularText(shadowysuperstore,)", + "Email(shadowysuperstore@shadowysuperstore.com)", + "RegularText(-)", + "RegularText(7abbf3067536c6b70fbc8ac1965e485dce6ebb3d5c125aac248bc0fe906c6818)", + "HashTag(#447)", + "RegularText(1%)", + "RegularText(bhaskar,)", + "RegularText()", + "RegularText(-)", + "RegularText(5beb5d04939db36498e0736003771294317c1c018953d18433276a042bf9a39d)", + "HashTag(#448)", + "RegularText(1%)", + "RegularText(kylum)", + "RegularText(๐ŸŸฃ,)", + "RegularText()", + "RegularText(-)", + "RegularText(e651489d08a27970aac55b222b8a3ea5f3c00419f2976a3cf4006f3add2b6f3c)", + "HashTag(#449)", + "RegularText(1%)", + "RegularText(็‰น็ซ‹็‹ฌ่กŒ็š„ๆŽๅ‘˜ๅค–,)", + "Email(npub1wg2dsjnh0g7phheq23v288k0mj8x75fffmq7rghtkhv53027hnassf4w8t@nost.vip)", + "RegularText(-)", + "RegularText(7214d84a777a3c1bdf205458a39ecfdc8e6f51294ec1e1a2ebb5d948bd5ebcfb)", + "HashTag(#450)", + "RegularText(1%)", + "RegularText(eynhaender,)", + "Email(eynhaender@nostrplebs.com)", + "RegularText(-)", + "RegularText(a21babb54929f10164ca8f8fcca5138d25a892c32fabc8df7d732b8b52b68d82)", + "HashTag(#451)", + "RegularText(1%)", + "RegularText(8340fdโ€ฆ8c7a30,)", + "RegularText()", + "RegularText(-)", + "RegularText(8340fd16fb4414765af8f59192ed68814920e7d33522709de2457490c28c7a30)", + "HashTag(#452)", + "RegularText(1%)", + "RegularText(B1ackSwan,)", + "Email(b1ackswan@nostrplebs.com)", + "RegularText(-)", + "RegularText(1f695a6883cef577dcebf9c60041111772a64e3490cb299c3b97fc81ad3901f4)", + "HashTag(#453)", + "RegularText(1%)", + "RegularText(91dac4โ€ฆ599398,)", + "RegularText()", + "RegularText(-)", + "RegularText(91dac44e3f9d0e3b839aaf7fd81e6c19cf2ce02356fca5096af9e92f58599398)", + "HashTag(#454)", + "RegularText(1%)", + "RegularText(356e99โ€ฆfc3ba8,)", + "RegularText()", + "RegularText(-)", + "RegularText(356e99a0f75e973c0512873cbdce0385df39712653020af825556ceb4afc3ba8)", + "HashTag(#455)", + "RegularText(1%)", + "RegularText(mcdean,)", + "RegularText()", + "RegularText(-)", + "RegularText(54def063abe1657a22cc886eaba75f6636845c601efe9ad56709b4cb3dcc62f1)", + "HashTag(#456)", + "RegularText(1%)", + "RegularText(mrbitcoin,)", + "Email(mrbitc0in@nostrplebs.com)", + "RegularText(-)", + "RegularText(da41332116804e9c4396f6dbb77ec9ad338197993e9d8af18f332e53dcc1bfeb)", + "HashTag(#457)", + "RegularText(1%)", + "RegularText(Jedi,)", + "Email(jedi@nostrplebs.com)", + "RegularText(-)", + "RegularText(246498aa79542482499086f9ab0134750a23047dad0cca38b696750f9ed8072c)", + "HashTag(#458)", + "RegularText(1%)", + "RegularText(CloudNull,)", + "Email(cloudnull@nostrplebs.com)", + "RegularText(-)", + "RegularText(5f53baca8cb88a18320a032957bf0b6f8dc8b33db007310b0e2f573edf2703a3)", + "HashTag(#459)", + "RegularText(1%)", + "RegularText(Mrwh0,)", + "Email(Mrwh0@Mrwh0.github.io)", + "RegularText(-)", + "RegularText(d8dd77e3dff24bd8c2da9b4c4fb321f5f99e8713bad40dd748ab59656b5ed27d)", + "HashTag(#460)", + "RegularText(1%)", + "RegularText(shinohai,)", + "Email(shinohai@iris.to)", + "RegularText(-)", + "RegularText(4bc7982c4ee4078b2ada5340ae673f18d3b6a664b1f97e8d6799e6074cb5c39d)", + "HashTag(#461)", + "RegularText(1%)", + "RegularText(awoi,)", + "Email(awoi@iris.to)", + "RegularText(-)", + "RegularText(edc083016d344679566ae8205b362530ecbafc6e064e224a0c2df1850cecfb4a)", + "HashTag(#462)", + "RegularText(1%)", + "RegularText(TheShopRat,)", + "RegularText()", + "RegularText(-)", + "RegularText(8362e77d9fd268720a15840af33fd9ab5cdf13fabc66f0910111580960cd297a)", + "HashTag(#463)", + "RegularText(1%)", + "RegularText(Dajjal,)", + "RegularText()", + "RegularText(-)", + "RegularText(614aee83d7eaffc7bc6bbf02feda0cc53e7f97eeceac08a897c4cea3c023b804)", + "HashTag(#464)", + "RegularText(1%)", + "RegularText(felipe,)", + "RegularText()", + "RegularText(-)", + "RegularText(0ee8894f1f663fd76b682c16e6a92db0fe14ada98db35b4a4cfa5f9068be0b3a)", + "HashTag(#465)", + "RegularText(1%)", + "RegularText(crypt0-j3sus,)", + "RegularText()", + "RegularText(-)", + "RegularText(9a7b7cbe37b2caa703062c51b207eb6ec4c42d06bfa909d979aa2d5005ac3d65)", + "HashTag(#466)", + "RegularText(1%)", + "RegularText(Just)", + "RegularText(J,)", + "Email(jcope101@nostrplebs.com)", + "RegularText(-)", + "RegularText(5f6f376733b1a8682a0f330e07b6a6064d738fdd8159db6c8df44c6c9419ff88)", + "HashTag(#467)", + "RegularText(1%)", + "RegularText(mmasnick,)", + "RegularText()", + "RegularText(-)", + "RegularText(4d53de27a24feb84d6383962e350219fc09e572c22a17c542545a69cd35b067f)", + "HashTag(#468)", + "RegularText(1%)", + "RegularText(Murmur,)", + "Email(murmur@nostrplebs.com)", + "RegularText(-)", + "RegularText(f7e84b92a5457546894daedaff9abd66f3d289f92435d6ac068a33cb170b01a4)", + "HashTag(#469)", + "RegularText(1%)", + "RegularText(JD,)", + "RegularText()", + "RegularText(-)", + "RegularText(1a9ba80629e2f8f77340ac13e67fdb4fcc66f4bb4124f9beff6a8c75e4ce29b0)", + "HashTag(#470)", + "RegularText(1%)", + "RegularText(dario,)", + "Email(dario@nostrplebs.com)", + "RegularText(-)", + "RegularText(d9987652d3cbb2c0fa39b6305cc0f2d03ca987afc1e56bc97a81c79e138152a8)", + "HashTag(#471)", + "RegularText(1%)", + "RegularText(leonwankum,)", + "RegularText(@leonawankum@BitcoinNostr.com)", + "RegularText()", + "RegularText(-)", + "RegularText(652d58acafa105af8475c0fe8029a52e7ddbc337b2bd9c98bb17a111dc4cde60)", + "HashTag(#472)", + "RegularText(1%)", + "RegularText(phil,)", + "Email(phil@iris.to)", + "RegularText(-)", + "RegularText(8352b55a828a60bb0e86b0ac9ef1928999ebe636c905dcbe0cd3c0f95c61b83b)", + "HashTag(#473)", + "RegularText(1%)", + "RegularText(hkmccullough,)", + "Email(thatirdude@nostrplebs.com)", + "RegularText(-)", + "RegularText(836059a05aeb8498dd53a0d422e04aced6b4b71eb3621d312626c46715d259d8)", + "HashTag(#474)", + "RegularText(1%)", + "RegularText(BitBox,)", + "RegularText()", + "RegularText(-)", + "RegularText(5a3de28ffd09d7506cff0a2672dbdb1f836307bcff0217cc144f48e19eea3fff)", + "HashTag(#475)", + "RegularText(1%)", + "RegularText(5eff6cโ€ฆ60bd07,)", + "RegularText()", + "RegularText(-)", + "RegularText(5eff6c1205c9db582863978b5b2e9c9aa73a57e6c1df526fddc2b9996060bd07)", + "HashTag(#476)", + "RegularText(1%)", + "RegularText(nobody,)", + "RegularText()", + "RegularText(-)", + "RegularText(2e472c6d072c0bcc28f1b260e0fc309f1f919667d238f4e703f8f1db0f0eb424)", + "HashTag(#477)", + "RegularText(1%)", + "RegularText(K_hole,)", + "Email(K_hole@ketamine.com)", + "RegularText(-)", + "RegularText(5ac74532e23b7573f8f6f3248fe5174c0b7230aec0b653c0ec8f11d540209fd7)", + "HashTag(#478)", + "RegularText(1%)", + "RegularText(bitcoinIllustrated,)", + "RegularText()", + "RegularText(-)", + "RegularText(90fb6b9607bba40686fe70aad74a07e5af96d152778f3a09fcda5967dcb0daba)", + "HashTag(#479)", + "RegularText(1%)", + "RegularText(kingfisher,)", + "RegularText()", + "RegularText(-)", + "RegularText(33d4c61d7354e1d5872e26218eda73170646d12a8e7b9cb6d3069a7058ebabfd)", + "HashTag(#480)", + "RegularText(1%)", + "RegularText(cfc11eโ€ฆb4f6e4,)", + "RegularText()", + "RegularText(-)", + "RegularText(cfc11ef4b31e2ab18261a71b79097c60199f532605a0c3aa73ad36acc6b4f6e4)", + "HashTag(#481)", + "RegularText(1%)", + "RegularText(d06848โ€ฆ2f86b3,)", + "RegularText()", + "RegularText(-)", + "RegularText(d06848a9ea53f9e9c15cafaf41b1729d6d7b84083cfbac2c76a0506dd72f86b3)", + "HashTag(#482)", + "RegularText(1%)", + "RegularText(nostrceo,)", + "RegularText()", + "RegularText(-)", + "RegularText(3159e1a148ca235cb55365a2ffde608b17e84c4c3bff6ed309f3e320307d5ab3)", + "HashTag(#483)", + "RegularText(1%)", + "RegularText(Lokuyow2,)", + "Email(2@lokuyow.github.io)", + "RegularText(-)", + "RegularText(f5f02030cb4b22ed15c3d7cc35ae616e6ce6bb3fa537f6e9e91aaa274b9cd716)", + "HashTag(#484)", + "RegularText(1%)", + "RegularText(fatushi,)", + "RegularText()", + "RegularText(-)", + "RegularText(49a458319060806221990e90e6bf2b1654201f08a40828d1a5d215a85f449df0)", + "HashTag(#485)", + "RegularText(1%)", + "RegularText(Omnia,)", + "RegularText()", + "RegularText(-)", + "RegularText(026d2251aa211684ef63e7a28e21c611c087bb3131a9c90b11dff6c16d68ce77)", + "HashTag(#486)", + "RegularText(1%)", + "RegularText(joey,)", + "RegularText()", + "RegularText(-)", + "RegularText(5f8a5bbf8d26104547a3942e82d7a5159554b3a5a3bc1275c47674b5e8c4c1d7)", + "HashTag(#487)", + "RegularText(1%)", + "RegularText(Hazey,)", + "Email(hazey@iris.to)", + "RegularText(-)", + "RegularText(800e0fe3d8638ce3f75a56ed865df9d96fc9d9cd2f75550df0d7f5c1d8468b0b)", + "HashTag(#488)", + "RegularText(1%)", + "RegularText(Milad)", + "RegularText(Younis,)", + "RegularText()", + "RegularText(-)", + "RegularText(64c24e0991f9bb6f59f9da486ba29242bc562b09ce051882f7b3bcc7fd055227)", + "HashTag(#489)", + "RegularText(1%)", + "RegularText(jlgalley,)", + "RegularText()", + "RegularText(-)", + "RegularText(920535dd1487975ccc75ed82b7b4753260ec4041dcf9ce24657623164f6586e3)", + "HashTag(#490)", + "RegularText(1%)", + "RegularText(paulgallo28,)", + "RegularText()", + "RegularText(-)", + "RegularText(690af9eed15cc3a7439c39b228bf194da134f75d64f40114a41d77bff6a60699)", + "HashTag(#491)", + "RegularText(1%)", + "RegularText(HeineNon,)", + "Email(HeineNon@tomottodx.github.io)", + "RegularText(-)", + "RegularText(64c66c231ea1c25ebd66b14fe4a0b1b39a6928d6824ad43e035f54aa667bc650)", + "HashTag(#492)", + "RegularText(1%)", + "RegularText(a9b9adโ€ฆ2b9f4c,)", + "RegularText()", + "RegularText(-)", + "RegularText(a9b9ad000e2ada08326bbcc1836effcdfa4e64b9c937e406fe5912dc562b9f4c)", + "HashTag(#493)", + "RegularText(1%)", + "RegularText(legxxi,)", + "RegularText()", + "RegularText(-)", + "RegularText(8476d0dcdb53f1cc67efc8d33f40104394da2d33e61369a8a8ade288036977c6)", + "HashTag(#494)", + "RegularText(1%)", + "RegularText(99f1b7โ€ฆ559c31,)", + "RegularText()", + "RegularText(-)", + "RegularText(99f1b7b39201d0e142f9ec3c8101b6be0eee8a389d16d53667ca4f57b1559c31)", + "HashTag(#495)", + "RegularText(1%)", + "RegularText(mbz,)", + "RegularText()", + "RegularText(-)", + "RegularText(e5195850d4fed08183f0b274ca30777094daad67be235a5cd15548b9b0341031)", + "HashTag(#496)", + "RegularText(1%)", + "RegularText(Titan,)", + "Email(titan@nostrplebs.com)", + "RegularText(-)", + "RegularText(672b1637bd65b6206c7a603158c2ecee15599648e10dd15a82f2fcb4e47735bf)", + "HashTag(#497)", + "RegularText(1%)", + "RegularText(Highlandhodl,)", + "RegularText()", + "RegularText(-)", + "RegularText(f0c74190cd05d85d843cdc5f355afe0fbac6d30d18da91243d6cae30a69713f7)", + "HashTag(#498)", + "RegularText(1%)", + "RegularText(CodeWarrior,)", + "RegularText()", + "RegularText(-)", + "RegularText(21a7014db2ba17acc8bbb9496645084866b46e1ba0062a80513afda405450183)", + "HashTag(#499)", + "RegularText(1%)", + "SchemelessUrl(baller.hodl,)", + "RegularText()", + "RegularText(-)", + "RegularText(d8150dc0631f834a004f231f0747d5ec8409b1a9214d246f675dfef39807a224)", + "HashTag(#500)", + "RegularText(1%)", + "RegularText(Now)", + "RegularText(Playing)", + "RegularText(on)", + "RegularText(GMโ‚ฟ,)", + "RegularText()", + "RegularText(-)", + "RegularText(9c6907de72e59daf5272103a34649bf7ca01050a68f402955520fc53dba9730d)", + "RegularText()", + "RegularText(Inspector monitor)", + "RegularText()", + "RegularText(New events inspected today: 720.71K (4.85GB))", + "RegularText(Average events inspected per second: 8.34)", + "RegularText(Uptime: Server 99.93%, NostrInspector: 99.93%)", + "RegularText(Spam estimate: )", + "RegularText(74.12 %)", + "RegularText()", + "RegularText(About the NostrInspector Report)", + "RegularText()", + "RegularText(โœ… The 24 Hour NostrInspector Report is generated by listening for new events on the top relays using the Nostr Protocol. The statistics report that )", + "RegularText(it generates includes de data layer as well as the social layer.)", + "RegularText(๐Ÿ’œ To support this free effort share, like, comment or zap.)", + "RegularText(๐Ÿซ‚ Thank you ๐Ÿ™ )", + "RegularText()", + "RegularText(๐Ÿ•ต๏ธ @nostrin \"The Nostr Inspector\" )", + "Bech(npub17m7f7q08k4x746s2v45eyvwppck32dcahw7uj2mu5txuswldgqkqw9zms7)", + ) + + state.paragraphs + .map { it.words } + .flatten() + .forEachIndexed { index, seg -> Assert.assertEquals( - "relay.shitforce.one, relayable.org, universe.nostrich.land, nos.lol, universe.nostrich.land?lang=zh, universe.nostrich.land?lang=en, relay.damus.io, relay.nostr.wirednet.jp, offchain.pub, nostr.rocks, relay.wellorder.net, nostr.oxtr.dev, universe.nostrich.land?lang=ja, relay.mostr.pub, nostr.bitcoiner.social, Nostr-Check.com, MR.Rabbit, Ancap.su, zapper.lol, smies.me, baller.hodl", - state.urlSet.joinToString(", ") + expectedResult[index], + "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", ) + } - printStateForDebug(state) + Assert.assertTrue(state.imagesForPager.isEmpty()) + Assert.assertTrue(state.imageList.isEmpty()) + Assert.assertTrue(state.customEmoji.isEmpty()) + Assert.assertEquals(651, state.paragraphs.size) + } - val expectedResult = listOf( - "RegularText(๐Ÿ“ฐ 24 Hour NostrInspector Report ๐Ÿ•ต (TEXT ONLY VERSION))", - "RegularText()", - "RegularText(Generated Friday June 30 2023 03:59:01 UTC-6 (CST))", - "RegularText()", - "RegularText(Network statistics)", - "RegularText()", - "RegularText(New events witnessed (top 110 relays) )", - "RegularText()", - "RegularText(Kind, count, (% count), size, (% size))", - "RegularText(1, 207.9K, (28.8%), 458.02MB, (9.2%))", - "RegularText(7, 158.3K, (22%), 280.83MB, (5.7%))", - "RegularText(0, 84.1K, (11.7%), 192.89MB, (3.9%))", - "RegularText(9735, 57.2K, (7.9%), 353.16MB, (7.1%))", - "RegularText(3, 54.7K, (7.6%), 2.75GB, (56.7%))", - "RegularText(6, 31.6K, (4.4%), 111.27MB, (2.2%))", - "RegularText(4, 30.8K, (4.3%), 89.79MB, (1.8%))", - "RegularText(30000, 29.1K, (4%), 115.33MB, (2.3%))", - "RegularText(30078, 12.1K, (1.7%), 317.25MB, (6.4%))", - "RegularText(5, 11K, (1.5%), 16.86MB, (0.3%))", - "RegularText(10002, 8.6K, (1.2%), 16.59MB, (0.3%))", - "RegularText(1311, 7.7K, (1.1%), 12.71MB, (0.3%))", - "RegularText(1984, 6.3K, (0.9%), 10.93MB, (0.2%))", - "RegularText(9734, 3.7K, (0.5%), 10.88MB, (0.2%))", - "RegularText(30001, 3.1K, (0.4%), 66.91MB, (1.3%))", - "RegularText(1000, 2.8K, (0.4%), 13.43MB, (0.3%))", - "RegularText(20100, 1.4K, (0.2%), 2.32MB, (0%))", - "RegularText(42, 1.1K, (0.2%), 2.30MB, (0%))", - "RegularText(13194, 1K, (0.1%), 1.22MB, (0%))", - "RegularText(1063, 875, (0.1%), 1.96MB, (0%))", - "RegularText()", - "RegularText(New events by relay (top 50%))", - "RegularText()", - "RegularText(Events (%) Relay)", - "RegularText(33.4K)", - "RegularText((4.6%))", - "Link(relay.shitforce.one)", - "RegularText(32.9K)", - "RegularText((4.6%))", - "Link(relayable.org)", - "RegularText(26.6K)", - "RegularText((3.7%))", - "Link(universe.nostrich.land)", - "RegularText(22.8K)", - "RegularText((3.2%))", - "Link(nos.lol)", - "RegularText(22.7K)", - "RegularText((3.1%))", - "Link(universe.nostrich.land?lang=zh)", - "RegularText(22.5K)", - "RegularText((3.1%))", - "Link(universe.nostrich.land?lang=en)", - "RegularText(21.2K)", - "RegularText((2.9%))", - "Link(relay.damus.io)", - "RegularText(20.6K)", - "RegularText((2.9%))", - "Link(relay.nostr.wirednet.jp)", - "RegularText(20.1K)", - "RegularText((2.8%))", - "Link(offchain.pub)", - "RegularText(19.9K)", - "RegularText((2.8%))", - "Link(nostr.rocks)", - "RegularText(19.5K)", - "RegularText((2.7%))", - "Link(relay.wellorder.net)", - "RegularText(19.4K)", - "RegularText((2.7%))", - "Link(nostr.oxtr.dev)", - "RegularText(19K)", - "RegularText((2.6%))", - "Link(universe.nostrich.land?lang=ja)", - "RegularText(18.4K)", - "RegularText((2.6%))", - "Link(relay.mostr.pub)", - "RegularText(17.5K)", - "RegularText((2.4%))", - "Link(universe.nostrich.land?lang=zh)", - "RegularText(16.3K)", - "RegularText((2.3%))", - "Link(nostr.bitcoiner.social)", - "RegularText()", - "RegularText(30 day global new events)", - "RegularText()", - "RegularText(23-05-29 1M)", - "RegularText(23-05-30 861.9K)", - "RegularText(23-05-31 752.5K)", - "RegularText(23-06-01 0.9M)", - "RegularText(23-06-02 808.9K)", - "RegularText(23-06-03 683.8K)", - "RegularText(23-06-04 0.9M)", - "RegularText(23-06-05 890.6K)", - "RegularText(23-06-06 839.4K)", - "RegularText(23-06-07 827K)", - "RegularText(23-06-08 804.8K)", - "RegularText(23-06-09 736.7K)", - "RegularText(23-06-10 709.7K)", - "RegularText(23-06-11 772.2K)", - "RegularText(23-06-12 882K)", - "RegularText(23-06-13 794.9K)", - "RegularText(23-06-14 842.2K)", - "RegularText(23-06-15 812.1K)", - "RegularText(23-06-16 839.6K)", - "RegularText(23-06-17 730.2K)", - "RegularText(23-06-18 811.9K)", - "RegularText(23-06-19 721.9K)", - "RegularText(23-06-20 786.2K)", - "RegularText(23-06-21 756.6K)", - "RegularText(23-06-22 736K)", - "RegularText(23-06-23 723.5K)", - "RegularText(23-06-24 703.9K)", - "RegularText(23-06-25 734.9K)", - "RegularText(23-06-26 742.4K)", - "RegularText(23-06-27 707.8K)", - "RegularText(23-06-28 747.7K)", - "RegularText()", - "RegularText(Social Network Statistics)", - "RegularText()", - "RegularText(Top 30 hashtags found today)", - "RegularText()", - "HashTag(#hashtag,)", - "RegularText(mentions)", - "RegularText(today,)", - "RegularText(days)", - "RegularText(in)", - "RegularText(top)", - "RegularText(30)", - "RegularText()", - "HashTag(#bitcoin,)", - "RegularText(1.7K,)", - "RegularText(109)", - "HashTag(#concussion,)", - "RegularText(1.1K,)", - "RegularText(25)", - "HashTag(#press,)", - "RegularText(0.9K,)", - "RegularText(65)", - "HashTag(#france,)", - "RegularText(492,)", - "RegularText(46)", - "HashTag(#presse,)", - "RegularText(480,)", - "RegularText(42)", - "HashTag(#covid19,)", - "RegularText(465,)", - "RegularText(65)", - "HashTag(#nostr,)", - "RegularText(414,)", - "RegularText(109)", - "HashTag(#zapathon,)", - "RegularText(386,)", - "RegularText(76)", - "HashTag(#rssfeed,)", - "RegularText(309,)", - "RegularText(53)", - "HashTag(#btc,)", - "RegularText(299,)", - "RegularText(109)", - "HashTag(#news,)", - "RegularText(294,)", - "RegularText(91)", - "HashTag(#zap,)", - "RegularText(283,)", - "RegularText(109)", - "HashTag(#linux,)", - "RegularText(253,)", - "RegularText(88)", - "HashTag(#respond,)", - "RegularText(246,)", - "RegularText(90)", - "HashTag(#kompost,)", - "RegularText(240,)", - "RegularText(31)", - "HashTag(#plebchain,)", - "RegularText(236,)", - "RegularText(109)", - "HashTag(#gardenaward,)", - "RegularText(236,)", - "RegularText(31)", - "HashTag(#start,)", - "RegularText(236,)", - "RegularText(31)", - "HashTag(#unicef,)", - "RegularText(233,)", - "RegularText(32)", - "HashTag(#coronavirus,)", - "RegularText(233,)", - "RegularText(33)", - "HashTag(#bew,)", - "RegularText(229,)", - "RegularText(31)", - "HashTag(#balkon,)", - "RegularText(229,)", - "RegularText(31)", - "HashTag(#terrasse,)", - "RegularText(229,)", - "RegularText(31)", - "HashTag(#braininjuryawareness,)", - "RegularText(229,)", - "RegularText(24)", - "HashTag(#garten,)", - "RegularText(220,)", - "RegularText(21)", - "HashTag(#smart,)", - "RegularText(220,)", - "RegularText(21)", - "HashTag(#nsfw,)", - "RegularText(211,)", - "RegularText(85)", - "HashTag(#protoncalendar,)", - "RegularText(206,)", - "RegularText(31)", - "HashTag(#stacksats,)", - "RegularText(195,)", - "RegularText(99)", - "HashTag(#nokyc,)", - "RegularText(179,)", - "RegularText(98)", - "RegularText()", - "RegularText(Emoji sentiment today)", - "RegularText()", - "RegularText(โšก (1.6K) ๐Ÿ‘‰ (1.4K) ๐Ÿ‡ช๐Ÿ‡บ (1.2K) ๐Ÿซ‚ (1.2K) ๐Ÿ‡บ๐Ÿ‡ธ (1.1K) ๐Ÿ’œ (875) ๐Ÿง  (858) ๐Ÿ˜‚ (830) ๐Ÿ”ฅ (690) ๐Ÿคฃ (566) ๐Ÿค™ (525) โ˜• (444) ๐Ÿ‘‡ (443) ๐Ÿ™Œ๐Ÿป (425) โ˜€ (307) ๐Ÿ˜Ž (305) ๐Ÿฅณ (301) ๐Ÿค” (276) ๐ŸŒป (270) ๐Ÿงก (270) ๐Ÿฅ‡ (269) ๐Ÿ—“ (269) ๐Ÿ™ (268) ๐Ÿ† (267) ๐ŸŒฑ (264) ๐Ÿ“ฐ (230) ๐Ÿ‰ (221) ๐Ÿ˜ญ (220) ๐Ÿ’ฐ (219) ๐Ÿ”— (209) ๐Ÿ‘€ (201) ๐Ÿ˜… (199) โœจ (193) ๐Ÿ‡ท๐Ÿ‡บ (182) ๐Ÿ’ช (167) โœ… (164) ๐Ÿ’ค (163) ๐Ÿถ (151) ๐Ÿ‡จ๐Ÿ‡ญ (141) ๐Ÿ“ (137) ๐Ÿ˜ (136) ๐ŸŒž (136) ๐Ÿพ (136) โค (132) ๐Ÿ’ป (126) ๐Ÿš€ (125) ๐Ÿ‘ (125) ๐Ÿ‡ง๐Ÿ‡ท (125) ๐Ÿ˜Š (121) ๐Ÿ“š (120) โžก (120) ๐Ÿ‘ (118) ๐ŸŽ‰ (117) ๐ŸŽฎ (115) ๐Ÿคท (113) ๐Ÿ‘‹ (112) ๐Ÿ’ƒ (108) ๐Ÿ•บ๐Ÿป (106) ๐Ÿ’ก (104) ๐Ÿšจ (99) ๐Ÿ˜† (97) ๐Ÿ’ฏ (95) โš  (92) ๐Ÿ“ข (92) ๐Ÿค— (89) ๐Ÿ˜ด (87) ๐Ÿ” (83) ๐Ÿฐ (81) ๐Ÿ˜€ (79) ๐ŸŽŸ (78) โ› (78) ๐Ÿฆ (76) ๐Ÿ’ธ (76) โœŒ๐Ÿป (75) ๐Ÿค (73) ๐Ÿ‡ฌ๐Ÿ‡ง (73) ๐ŸŒฝ (70) ๐Ÿคก (69) ๐Ÿคฎ (69) โ— (66) ๐Ÿค (65) ๐Ÿ˜‰ (65) ๐Ÿ™‡ (65) ๐Ÿป (64) ๐ŸŒ (64) ๐Ÿ’• (63) ๐ŸŒธ (62) ๐Ÿ’ฌ (61) โ˜บ (61) ๐Ÿ‡ฆ๐Ÿ‡ท (59) ๐Ÿ‡ฎ๐Ÿ‡ฉ (57) ๐Ÿ˜ณ (57) ๐Ÿ˜„ (57) ๐ŸŽถ (57) ๐Ÿฅท๐Ÿป (56) ๐ŸŽต (56) ๐Ÿ˜ƒ (56) ๐Ÿ” (55) ๐Ÿ’ฅ (55) ๐ŸŽฒ (54) โœ (54) ๐Ÿ•’ (53) โฌ‡ (53) ๐Ÿ’™ (51) ๐Ÿ”’ (50) ๐Ÿ“ˆ (50) ๐Ÿช™ (50) ๐ŸŒง (50) ๐Ÿฅฐ (50) ๐Ÿ•ธ (50) ๐ŸŒ (50) ๐Ÿ’ญ (49) ๐ŸŒ™ (49) ๐Ÿ˜ (49) ๐Ÿ“ฑ (48) ๐ŸŒŸ (48) ๐Ÿคฉ (48) ๐Ÿ’” (47) ๐Ÿ”Œ (47) ๐Ÿ˜‹ (47) ๐ŸŽ– (47) ๐Ÿฃ (46) ๐Ÿ“ท (46) ๐Ÿ’ผ (45) โญ (45) ๐Ÿฅ” (45) ๐Ÿฅบ (45) ๐Ÿ‘Œ (44) ๐Ÿ‘ท๐Ÿผ (43) ๐Ÿ˜ฑ (43) ๐Ÿ“… (43) ๐Ÿค– (43) ๐Ÿ“ธ (42) ๐Ÿ“Š (42) ๐Ÿฆ‘ (40) ๐Ÿ’ต (40) ๐Ÿคฆ (39) โฃ (38) ๐Ÿ’Ž (38) ๐Ÿ–ค (38) ๐Ÿ“บ (37) ๐Ÿ‡ต๐Ÿ‡ฑ (37) ๐Ÿ‡ฏ๐Ÿ‡ต (36) ๐Ÿ”ง (36) ๐Ÿค˜ (36) ๐Ÿ’– (36) โ€ผ (35) ๐Ÿ˜ข (35) ๐Ÿ˜บ (34) ๐Ÿ”Š (34) ๐Ÿ˜ (34) ๐Ÿ‡ธ๐Ÿ‡ฐ (34) ๐Ÿƒ (34) ๐Ÿ‘ฉโ€๐Ÿ‘ง (34) โฐ (33) ๐Ÿ‘จโ€๐Ÿ’ป (33) ๐Ÿ‘‘ (33) ๐Ÿ‘ฅ (32) ๐Ÿ–ฅ (32) ๐Ÿ’จ (32) ๐Ÿ’— (31) ๐Ÿ‡ฒ๐Ÿ‡ฝ (31) ๐Ÿ“– (31) ๐Ÿšซ (31) ๐Ÿ‘Š๐Ÿป (31) ๐Ÿ˜ก (31) ๐ŸŒŽ (31) ๐Ÿ‘ (30) ๐Ÿ—ž (30) ๐Ÿ€ (30) ๐Ÿฝ (29) ๐Ÿธ (29) ๐Ÿฅš (29) ๐Ÿ’ฉ (29) โœŠ๐Ÿพ (29) ๐Ÿ˜ฎ (29) ๐ŸŒก (29) ๐Ÿ™ƒ (28) ๐Ÿ”” (28) ๐Ÿ‡ป๐Ÿ‡ช (28) ๐Ÿ’ฆ (28) ๐ŸŽฏ (28) ๐ŸŽจ (28) ๐Ÿ› (28) ๐Ÿ–ผ (27) โ˜๐Ÿป (27) ๐Ÿ›‘ (27) ๐Ÿ™„ (27) ๐Ÿง‘๐Ÿปโ€๐Ÿคโ€๐Ÿง‘๐Ÿฝ (27) ๐ŸŒˆ (27) ๐Ÿฅ‚ (26) ๐Ÿ‡ซ๐Ÿ‡ฎ (26) ๐ŸŽฅ (26) ๐Ÿ˜ฌ (26) ๐Ÿฅฒ (25) ๐Ÿฆพ (24) ๐Ÿคœ (24) ๐Ÿ™‚ (24) ๐Ÿ–• (24) ๐Ÿ˜ฉ (24) )", - "RegularText()", - "RegularText(Zap economy)", - "RegularText()", - "RegularText(โšก41.7M sats (โ‚ฟ0.417) )", - "RegularText(1,816 zappers & 920 zapped (unique pubkeys))", - "RegularText(๐ŸŒฉ๏ธ 33,248 zaps, 1,253 sats per zap (avg))", - "RegularText()", - "RegularText(Most followed )", - "RegularText()", - "HashTag(#1)", - "RegularText(30%)", - "RegularText(jb55,)", - "Email(jb55@jb55.com)", - "RegularText(-)", - "RegularText(32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245)", - "HashTag(#2)", - "RegularText(19%)", - "RegularText(Snowden,)", - "Email(Snowden@Nostr-Check.com)", - "RegularText(-)", - "RegularText(84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240)", - "HashTag(#3)", - "RegularText(18%)", - "RegularText(cameri,)", - "Email(cameri@elder.nostr.land)", - "RegularText(-)", - "RegularText(00000000827ffaa94bfea288c3dfce4422c794fbb96625b6b31e9049f729d700)", - "HashTag(#4)", - "RegularText(11%)", - "RegularText(Natalie,)", - "Email(natalie@NostrVerified.com)", - "RegularText(-)", - "RegularText(edcd20558f17d99327d841e4582f9b006331ac4010806efa020ef0d40078e6da)", - "HashTag(#5)", - "RegularText(11%)", - "RegularText(saifedean,)", - "RegularText()", - "RegularText(-)", - "RegularText(4379e76bfa76a80b8db9ea759211d90bb3e67b2202f8880cc4f5ffe2065061ad)", - "HashTag(#6)", - "RegularText(11%)", - "RegularText(alanbwt,)", - "Email(alanbwt@nostrplebs.com)", - "RegularText(-)", - "RegularText(1bd32a386a7be6f688b3dc7c480efc21cd946b43eac14ba4ba7834ac77a23e69)", - "HashTag(#7)", - "RegularText(10%)", - "RegularText(rick,)", - "Email(rick@no.str.cr)", - "RegularText(-)", - "RegularText(978c8f26ea9b3c58bfd4c8ddfde83741a6c2496fab72774109fe46819ca49708)", - "HashTag(#8)", - "RegularText(9%)", - "RegularText(shawn,)", - "Email(shawn@shawnyeager.com)", - "RegularText(-)", - "RegularText(c7eda660a6bc8270530e82b4a7712acdea2e31dc0a56f8dc955ac009efd97c86)", - "HashTag(#9)", - "RegularText(9%)", - "RegularText(0xtr,)", - "Email(0xtr@oxtr.dev)", - "RegularText(-)", - "RegularText(b2d670de53b27691c0c3400225b65c35a26d06093bcc41f48ffc71e0907f9d4a)", - "HashTag(#10)", - "RegularText(9%)", - "RegularText(stick,)", - "Email(pavol@rusnak.io)", - "RegularText(-)", - "RegularText(d7f0e3917c466f1e2233e9624fbd6d4bd1392dbcfcaf3574f457569d496cb731)", - "HashTag(#11)", - "RegularText(9%)", - "RegularText(caitlinlong,)", - "Email(caitlin@nostrverified.com)", - "RegularText(-)", - "RegularText(e1055729d51e037b3c14e8c56e2c79c22183385d94aadb32e5dc88092cd0fef4)", - "HashTag(#12)", - "RegularText(9%)", - "RegularText(ralf,)", - "Email(ralf@snort.social)", - "RegularText(-)", - "RegularText(c89cf36deea286da912d4145f7140c73495d77e2cfedfb652158daa7c771f2f8)", - "HashTag(#13)", - "RegularText(9%)", - "RegularText(StackSats,)", - "Email(stacksats@nostrplebs.com)", - "RegularText(-)", - "RegularText(b93049a6e2547a36a7692d90e4baa809012526175546a17337454def9ab69d30)", - "HashTag(#14)", - "RegularText(9%)", - "RegularText(MrHodl,)", - "Email(MrHodl@nostrpurple.com)", - "RegularText(-)", - "RegularText(29fbc05acee671fb579182ca33b0e41b455bb1f9564b90a3d8f2f39dee3f2779)", - "HashTag(#15)", - "RegularText(9%)", - "RegularText(mikedilger,)", - "Email(_@mikedilger.com)", - "RegularText(-)", - "RegularText(ee11a5dff40c19a555f41fe42b48f00e618c91225622ae37b6c2bb67b76c4e49)", - "HashTag(#16)", - "RegularText(9%)", - "RegularText(jascha,)", - "Email(jascha@relayable.org)", - "RegularText(-)", - "RegularText(2479739594ed5802a96703e5a870b515d986982474a71feae180e8ecffa302c6)", - "HashTag(#17)", - "RegularText(8%)", - "RegularText(Nakadaimon,)", - "Email(Nakadaimon@nostrplebs.com)", - "RegularText(-)", - "RegularText(803a613997a26e8714116f99aa1f98e8589cb6116e1aaa1fc9c389984fcd9bb8)", - "HashTag(#18)", - "RegularText(8%)", - "RegularText(KeithMukai,)", - "Email(KeithMukai@nostr.seedsigner.com)", - "RegularText(-)", - "RegularText(5b0e8da6fdfba663038690b37d216d8345a623cc33e111afd0f738ed7792bc54)", - "HashTag(#19)", - "RegularText(8%)", - "RegularText(TheGuySwann,)", - "Email(theguyswann@NostrVerified.com)", - "RegularText(-)", - "RegularText(b0b8fbd9578ac23e782d97a32b7b3a72cda0760761359bd65661d42752b4090a)", - "HashTag(#20)", - "RegularText(8%)", - "RegularText(dk,)", - "Email(dk@stacker.news)", - "RegularText(-)", - "RegularText(b708f7392f588406212c3882e7b3bc0d9b08d62f95fa170d099127ece2770e5e)", - "HashTag(#21)", - "RegularText(7%)", - "RegularText(zerohedge,)", - "Email(npub1z7eqn5603ltuxr77w70t3sasep8hyngzr6lxqpa9hfcqjwe9wmdqhw0qhv@nost.vip)", - "RegularText(-)", - "RegularText(17b209d34f8fd7c30fde779eb8c3b0c84f724d021ebe6007a5ba70093b2576da)", - "HashTag(#22)", - "RegularText(7%)", - "RegularText(miljan,)", - "Email(miljan@primal.net)", - "RegularText(-)", - "RegularText(d61f3bc5b3eb4400efdae6169a5c17cabf3246b514361de939ce4a1a0da6ef4a)", - "HashTag(#23)", - "RegularText(7%)", - "RegularText(jared,)", - "Email(jared@nostrplebs.com)", - "RegularText(-)", - "RegularText(92e3aac668edb25319edd1d87cadef0b189557fdd13b123d82a19d67fd211909)", - "HashTag(#24)", - "RegularText(7%)", - "RegularText(radii,)", - "Email(radii@orangepill.dev)", - "RegularText(-)", - "RegularText(acedd3597025cb13b84f9a89643645aeb61a3b4a3af8d7ac01f8553171bf17c5)", - "HashTag(#25)", - "RegularText(7%)", - "RegularText(katie,)", - "Email(_@katieannbaker.com)", - "RegularText(-)", - "RegularText(07eced8b63b883cedbd8520bdb3303bf9c2b37c2c7921ca5c59f64e0f79ad2a6)", - "HashTag(#26)", - "RegularText(7%)", - "RegularText(giacomozucco,)", - "Email(giacomozucco@BitcoinNostr.com)", - "RegularText(-)", - "RegularText(ef151c7a380f40a75d7d1493ac347b6777a9d9b5fa0aa3cddb47fc78fab69a8b)", - "HashTag(#27)", - "RegularText(7%)", - "RegularText(kr,)", - "Email(kr@stacker.news)", - "RegularText(-)", - "RegularText(08b80da85ba68ac031885ea555ab42bb42231fde9b690bbd0f48c128dfbf8009)", - "HashTag(#28)", - "RegularText(7%)", - "RegularText(phil,)", - "Email(phil@nostrpurple.com)", - "RegularText(-)", - "RegularText(e07773a92a610a28da20748fdd98bfb5af694b0cad085224801265594a98108a)", - "HashTag(#29)", - "RegularText(7%)", - "RegularText(angela,)", - "Email(angela@nostr.world)", - "RegularText(-)", - "RegularText(2b1964b885de3fcbb33777874d06b05c254fecd561511622ce86e3d1851949fa)", - "HashTag(#30)", - "RegularText(7%)", - "RegularText(mason)", - "RegularText(๐“„€)", - "RegularText(๐“…ฆ,)", - "Email(mason@lacosanostr.com)", - "RegularText(-)", - "RegularText(5ef92421b5df0ed97df6c1a98fc038ea7962a29e7f33a060f7a8ddeb9ee587e9)", - "HashTag(#31)", - "RegularText(7%)", - "RegularText(Lau,)", - "Email(lau@nostr.report)", - "RegularText(-)", - "RegularText(5a9c48c8f4782351135dd89c5d8930feb59cb70652ffd37d9167bf922f2d1069)", - "HashTag(#32)", - "RegularText(7%)", - "RegularText(Rex)", - "RegularText(Damascus)", - "RegularText(,)", - "Email(damascusrex@iris.to)", - "RegularText(-)", - "RegularText(50c5c98ccc31ca9f1ef56a547afc4cb48195fe5603d4f7874a221db965867c8e)", - "HashTag(#33)", - "RegularText(6%)", - "RegularText(nym,)", - "Email(nym@nostr.fan)", - "RegularText(-)", - "RegularText(9936a53def39d712f886ac7e2ed509ce223b534834dd29d95caba9f6bc01ef35)", - "HashTag(#34)", - "RegularText(6%)", - "RegularText(nico,)", - "Email(nico@nostrplebs.com)", - "RegularText(-)", - "RegularText(0000000033f569c7069cdec575ca000591a31831ebb68de20ed9fb783e3fc287)", - "HashTag(#35)", - "RegularText(6%)", - "RegularText(anna,)", - "Email(seekerdreamer1@stacker.news)", - "RegularText(-)", - "RegularText(6f2347c6fc4cbcc26d66e74247abadd4151592277b3048331f52aa3a5c244af9)", - "HashTag(#36)", - "RegularText(6%)", - "RegularText(TheSameCat,)", - "Email(thesamecat@iris.to)", - "RegularText(-)", - "RegularText(72f9755501e1a4464f7277d86120f67e7f7ec3a84ef6813cc7606bf5e0870ff3)", - "HashTag(#37)", - "RegularText(6%)", - "RegularText(nitesh_btc,)", - "Email(nitesh@noderunner.wtf)", - "RegularText(-)", - "RegularText(021d7ef7aafc034a8fefba4de07622d78fd369df1e5f9dd7d41dc2cffa74ae02)", - "HashTag(#38)", - "RegularText(6%)", - "RegularText(gpt3,)", - "Email(gpt3@jb55.com)", - "RegularText(-)", - "RegularText(5c10ed0678805156d39ef1ef6d46110fe1e7e590ae04986ccf48ba1299cb53e2)", - "HashTag(#39)", - "RegularText(6%)", - "RegularText(Byzantine,)", - "Email(byzantine@stacker.news)", - "RegularText(-)", - "RegularText(5d1d83de3ee5edde157071d5091a6d03ead8cce1d46bc585a9642abdd0db5aa0)", - "HashTag(#40)", - "RegularText(6%)", - "RegularText(wealththeory,)", - "Email(wealththeory@nostrplebs.com)", - "RegularText(-)", - "RegularText(3004d45a0ab6352c61a62586a57c50f11591416c29db1143367a4f0623b491ca)", - "HashTag(#41)", - "RegularText(6%)", - "RegularText(IshBit,)", - "Email(gug@nostrplebs.com)", - "RegularText(-)", - "RegularText(8e27ffb5c9bb8cdd0131ade6efa49d56d401b5424d9fdf9a63e074d527b0715c)", - "HashTag(#42)", - "RegularText(5%)", - "RegularText(Lana,)", - "Email(lana@b.tc)", - "RegularText(-)", - "RegularText(e8795f9f4821f63116572ed4998924c6f0e01682945bf7a3d9d6132f1c7dace7)", - "HashTag(#43)", - "RegularText(5%)", - "RegularText(Shevacai,)", - "Email(shevacai@nostrplebs.com)", - "RegularText(-)", - "RegularText(2f175fe4348f4da2da157e84d119b5165c84559158e64729ff00b16394718bbf)", - "HashTag(#44)", - "RegularText(5%)", - "RegularText(joe,)", - "Email(joe@nostrpurple.com)", - "RegularText(-)", - "RegularText(907a5a23635ea02be052c31f465b1982aefb756710ccc9f628aa31b70d2e262e)", - "HashTag(#45)", - "RegularText(5%)", - "RegularText(SimplestBitcoinBook,)", - "Email(simplestbitcoinbook@nostrplebs.com)", - "RegularText(-)", - "RegularText(6867d899ce6b677b89052602cfe04a165f26bb6a1a6390355f497f9ee5cb0796)", - "HashTag(#46)", - "RegularText(5%)", - "RegularText(knutsvanholm,)", - "Email(knutsvanholm@iris.to)", - "RegularText(-)", - "RegularText(92cbe5861cfc5213dd89f0a6f6084486f85e6f03cfeb70a13f455938116433b8)", - "HashTag(#47)", - "RegularText(5%)", - "RegularText(rajwinder,)", - "Email(rs@zbd.ai)", - "RegularText(-)", - "RegularText(1c9d368fc24e8549ce2d95eba63cb34b82b363f3036d90c12e5f13afe2981fba)", - "HashTag(#48)", - "RegularText(5%)", - "RegularText(Vlad,)", - "RegularText()", - "RegularText(-)", - "RegularText(50054d07e2cdf32b1035777bd9cf73992a4ae22f91c14a762efdaa5bf61f4755)", - "HashTag(#49)", - "RegularText(5%)", - "RegularText(GRANTGILLIAM,)", - "Email(GRANTGILLIAM@grantgilliam.com)", - "RegularText(-)", - "RegularText(874db6d2db7b39035fe7aac19e83a48257915e37d4f2a55cb4ca66be2d77aa88)", - "HashTag(#50)", - "RegularText(5%)", - "RegularText(LifeLoveLiberty,)", - "Email(lifeloveliberty@iris.to)", - "RegularText(-)", - "RegularText(c07a2ea48b6753d11ad29d622925cb48bab48a8f38e954e85aec46953a0752a2)", - "HashTag(#51)", - "RegularText(5%)", - "RegularText(hackernews,)", - "Email(npub1s9c53smfq925qx6fgkqgw8as2e99l2hmj32gz0hjjhe8q67fxdvs3ga9je@nost.vip)", - "RegularText(-)", - "RegularText(817148c3690155401b494580871fb0564a5faafb9454813ef295f2706bc93359)", - "HashTag(#52)", - "RegularText(5%)", - "RegularText(arbedout,)", - "Email(arbedout@granddecentral.com)", - "RegularText(-)", - "RegularText(a67e98faf32f2520ae574d84262534e7b94625ce0d4e14a50c97e362c06b770e)", - "HashTag(#53)", - "RegularText(5%)", - "RegularText(nobody,)", - "RegularText()", - "RegularText(-)", - "RegularText(5f735049528d831f544b49a585e6f058c1655dfaed9fc338374cd4f3a5a06bf7)", - "HashTag(#54)", - "RegularText(5%)", - "RegularText(glowleaf,)", - "Email(glowleaf@nostrplebs.com)", - "RegularText(-)", - "RegularText(34c0a53283bacd5cb6c45f9b057bea05dfb276333dcf14e9b167680b5d3638e4)", - "HashTag(#55)", - "RegularText(5%)", - "RegularText(Modus,)", - "Email(modus@lacosanostr.com)", - "RegularText(-)", - "RegularText(547fcc5c7e655fe7c83da5a812e6332f0a4779c87bf540d8e75a4edbbf36fe4a)", - "HashTag(#56)", - "RegularText(5%)", - "RegularText(Melvin)", - "RegularText(Carvalho)", - "RegularText(Old)", - "RegularText(Key)", - "RegularText(DO)", - "RegularText(NOT)", - "RegularText(USE,)", - "RegularText(USE)", - "Bech(npub1melv683fw6n2mvhl5h6dhqd8mqfv3wmxnz4qph83ua4dk4006ezsrt5c24,)", - "RegularText()", - "RegularText(-)", - "RegularText(ed1d0e1f743a7d19aa2dfb0162df73bacdbc699f67cc55bb91a98c35f7deac69)", - "HashTag(#57)", - "RegularText(5%)", - "RegularText(anil,)", - "Email(anil@bitcoinnostr.com)", - "RegularText(-)", - "RegularText(ade7a0c6acca095c5b36f88f20163bccda4d97b071c4acc8fe329dc724eec8fb)", - "HashTag(#58)", - "RegularText(4%)", - "RegularText(DocumentingBTC,)", - "Email(documentingbtc@uselessshit.co)", - "RegularText(-)", - "RegularText(641ac8fea1478c27839fb7a0850676c2873c22aa70c6216996862c98861b7e2f)", - "HashTag(#59)", - "RegularText(4%)", - "RegularText(wolfbearclaw,)", - "Email(wolfbearclaw@nostr.messagepush.io)", - "RegularText(-)", - "RegularText(0b963191ab21680a63307aedb50fd7b01392c9c6bef79cd0ceb6748afc5e7ffd)", - "HashTag(#60)", - "RegularText(4%)", - "RegularText(Amboss,)", - "Email(_@amboss.space)", - "RegularText(-)", - "RegularText(2af01e0d6bd1b9fbb9e3d43157d64590fb27dcfbcabe28784a5832e17befb87b)", - "HashTag(#61)", - "RegularText(4%)", - "RegularText(k3tan,)", - "Email(k3tan@k3tan.com)", - "RegularText(-)", - "RegularText(599c4f2380b0c1a9a18b7257e107cf9e6d8b4f8dea06c18c84538d311ff2b28c)", - "HashTag(#62)", - "RegularText(4%)", - "RegularText(wolzie)", - "RegularText(,)", - "Email(wolzie@BitcoinNostr.com)", - "RegularText(-)", - "RegularText(aabedc1f237853aeeb22bd985556036f262f8507842d64f3ecce01adbd7207e2)", - "HashTag(#63)", - "RegularText(4%)", - "RegularText(trey,)", - "Email(trey@nostrplebs.com)", - "RegularText(-)", - "RegularText(d5415a313d38461ff93a8c170f941b2cd4a66a5cfdbb093406960f6cb317849f)", - "HashTag(#64)", - "RegularText(4%)", - "RegularText(sillystev,)", - "RegularText()", - "RegularText(-)", - "RegularText(d541ef2e4830f2e1543c8bdc40128ceceb062b08c7e3f53d141552d5f5bc0cfc)", - "HashTag(#65)", - "RegularText(4%)", - "RegularText(sovereignmox,)", - "Email(woody@fountain.fm)", - "RegularText(-)", - "RegularText(1c4123b2431c60be030d641b4b68300eb464415405035b199428c0913b879c0c)", - "HashTag(#66)", - "RegularText(4%)", - "RegularText(CosmicDimension,)", - "Email(cosmicdimension@nostrplebs.com)", - "RegularText(-)", - "RegularText(4afec6c875e81dc28a760cc828345c0c5b61ec464ba20224148f9fd854a868ff)", - "HashTag(#67)", - "RegularText(4%)", - "RegularText(Mir,)", - "Email(mirbtc@getalby.com)", - "RegularText(-)", - "RegularText(234c45ff85a31c19bf7108a747fa7be9cd4af95c7d621e07080ca2d663bb47d2)", - "HashTag(#68)", - "RegularText(4%)", - "RegularText(Tacozilla,)", - "RegularText()", - "RegularText(-)", - "RegularText(5f70f80ddcf4f6a022467bd5196a1fdfc53d59f1e735a90443e7f7c980564c88)", - "HashTag(#69)", - "RegularText(4%)", - "RegularText(marks,)", - "Email(marks@nostrplebs.com)", - "RegularText(-)", - "RegularText(8ea485266b2285463b13bf835907161c22bb3da1e652b443db14f9cee6720a43)", - "HashTag(#70)", - "RegularText(4%)", - "RegularText(blacktomcat,)", - "Email(barrensatin40@walletofsatoshi.com)", - "RegularText(-)", - "RegularText(16b7e4b067cba8c86bda96a8d932e7593f398118d24bd8060da39ccfd7315f5c)", - "HashTag(#71)", - "RegularText(4%)", - "RegularText(Alex)", - "RegularText(Emidio,)", - "Email(alexemidio@alexemidio.github.io)", - "RegularText(-)", - "RegularText(4ba8e86d2d97896dc9337c3e500691893d7317572fd81f8b41ddda5d89d32de4)", - "HashTag(#72)", - "RegularText(4%)", - "RegularText(Jenn,)", - "Email(Jenn@mintgreen.co)", - "RegularText(-)", - "RegularText(e0f59d89047b868a188c5efd6b93dd8c16b65643b8718884dad8542386c60ddd)", - "HashTag(#73)", - "RegularText(4%)", - "RegularText(spacemonkey,)", - "Email(spacemonkey@nostrich.love)", - "RegularText(-)", - "RegularText(23b26fea28700cd1e2e3a8acca5c445c37ab89acaad549a36d50e9c0eb0f5806)", - "HashTag(#74)", - "RegularText(4%)", - "RegularText(ishak,)", - "Email(ishak@nostrplebs.com)", - "RegularText(-)", - "RegularText(052466631c6c0aed84171f83ef3c95cb81848d4dcdc1d1ee9dfdf75b850c1cb4)", - "HashTag(#75)", - "RegularText(4%)", - "RegularText(nakamoto_army,)", - "RegularText()", - "RegularText(-)", - "RegularText(62f6c5ff12fd24251f0bfb3b7eb1e512d7f1f577a1a97a595db01c66b52ad04f)", - "HashTag(#76)", - "RegularText(4%)", - "RegularText(GrassFedBitcoin,)", - "Email(GrassFedBitcoin@start9.com)", - "RegularText(-)", - "RegularText(74ffc51cc30150cf79b6cb316d3a15cf332ab29a38fec9eb484ab1551d6d1856)", - "HashTag(#77)", - "RegularText(4%)", - "RegularText(NinoHodls,)", - "Email(ninoholds@nostrplebs.com)", - "RegularText(-)", - "RegularText(43ccdbcb1e4dff7e3dea2a91b851ca0e22f50e3c560364a12b64b8c6587924f0)", - "HashTag(#78)", - "RegularText(4%)", - "RegularText(satcap,)", - "Email(satcap@nostr.satcap.io)", - "RegularText(-)", - "RegularText(11dfaa43ae0faa0a06d8c67f89759214c58b60a021521627bc76cb2d3ad0b2e8)", - "HashTag(#79)", - "RegularText(4%)", - "RegularText(DuneMessias,)", - "RegularText()", - "RegularText(-)", - "RegularText(96a578f6b504646de141ba90bec5651965aa01df0605928b3785a1372504e93d)", - "HashTag(#80)", - "RegularText(4%)", - "RegularText(Idaeus,)", - "RegularText()", - "RegularText(-)", - "RegularText(eb473e8fd55ced7af32abaf89578647ddba75e38a860b1c41682bbfb774f5579)", - "HashTag(#81)", - "RegularText(4%)", - "RegularText(tpmoreira,)", - "Email(tpmoreira@nostrplebs.com)", - "RegularText(-)", - "RegularText(f514ef7d18da12ecfce55c964add719ce00a1392c187f20ccb57d99290720e03)", - "HashTag(#82)", - "RegularText(4%)", - "RegularText(force2B,)", - "Email(force2b@nostrplebs.com)", - "RegularText(-)", - "RegularText(d411848a42a11ad2747c439b00fc881120a4121e04917d38bebd156212e2f4ad)", - "HashTag(#83)", - "RegularText(4%)", - "RegularText(Hendrix,)", - "Email(hendrix@nostrplebs.com)", - "RegularText(-)", - "RegularText(cbd92008e1fe949072cbea02e54228140c43d14d14519108b1d7a32d9102665b)", - "HashTag(#84)", - "RegularText(4%)", - "RegularText(TXMC,)", - "Email(TXMC@alphabetasoup.tv)", - "RegularText(-)", - "RegularText(37359e92ece5c6fc8d5755de008ceb6270808b814ddd517d38ebeab269836c96)", - "HashTag(#85)", - "RegularText(4%)", - "RegularText(norman188,)", - "RegularText()", - "RegularText(-)", - "RegularText(662a4476a9c15a5778f379ce41ceb2841ac72dfa1829b492d67796a8443ac2ca)", - "HashTag(#86)", - "RegularText(4%)", - "RegularText(pipleb,)", - "Email(pipleb@iris.to)", - "RegularText(-)", - "RegularText(3c4280ef3b792fa919b1964460d34ca6af93b83fa55f633a3b0eb8fde556235a)", - "HashTag(#87)", - "RegularText(4%)", - "RegularText(reallhex,)", - "Email(reallhex@terranostr.com)", - "RegularText(-)", - "RegularText(29630aed66aeec73b6519a11547f40ca15c3f6aa79907e640f1efcf5a2ee9dc8)", - "HashTag(#88)", - "RegularText(4%)", - "RegularText(374324โ€ฆef9f78,)", - "RegularText()", - "RegularText(-)", - "RegularText(3743244390be53473a7e3b3b8d04dce83f6c9514b81a997fb3b123c072ef9f78)", - "HashTag(#89)", - "RegularText(4%)", - "RegularText(Nostradamus,)", - "RegularText()", - "RegularText(-)", - "RegularText(7acce9b3da22ceedc511a15cb730c898235ab551623955314b003e9f33e8b10c)", - "HashTag(#90)", - "RegularText(4%)", - "RegularText(Nicโ‚ฟ,)", - "Email(nicb@nicb.me)", - "RegularText(-)", - "RegularText(000000002d4f4733f1ee417a405637fd0d81dbfbc6dbd8c0d1c95f04ec3db973)", - "HashTag(#91)", - "RegularText(4%)", - "RegularText(NabismoPrime,)", - "Email(NabismoPrime@BostonBTC.com)", - "RegularText(-)", - "RegularText(4503baa127bdfd0b054384dc5ba82cb0e2a8367cbdb0629179f00db1a34caacc)", - "HashTag(#92)", - "RegularText(4%)", - "RegularText(paco,)", - "Email(paco@iris.to)", - "RegularText(-)", - "RegularText(66bd8fed3590f2299ef0128f58d67879289e6a99a660e83ead94feab7606fd17)", - "HashTag(#93)", - "RegularText(3%)", - "RegularText(globalstatesmen,)", - "Email(globalstatesmen@nostrplebs.com)", - "RegularText(-)", - "RegularText(237506ca399e5b1b9ce89455fe960bc98dfab6a71936772a89c5145720b681f4)", - "HashTag(#94)", - "RegularText(3%)", - "RegularText(Nostryfied,)", - "Email(_@NostrNet.work)", - "RegularText(-)", - "RegularText(c2c20ec0a555959713ca4c404c4d2cc80e6cb906f5b64217070612a0cae29c62)", - "HashTag(#95)", - "RegularText(3%)", - "RegularText(crayonsmell,)", - "Email(crayonsmell@habel.net)", - "RegularText(-)", - "RegularText(3ef3be9db1e3f268f84e937ad73c68772a58c6ffcec1d42feeef5f214ad1eaf9)", - "HashTag(#96)", - "RegularText(3%)", - "RegularText(Toxikat27,)", - "Email(ToxiKat27@Bitcoiner.social)", - "RegularText(-)", - "RegularText(12cfc2ec5a39a39d02f921f77e701dbc175b6287f22ddf0247af39706967f1d9)", - "HashTag(#97)", - "RegularText(3%)", - "RegularText(James)", - "RegularText(Trageser,)", - "Email(jtrag@BitcoinNostr.com)", - "RegularText(-)", - "RegularText(d29bc58353389481e302569835661c95838bee076137533eb365bca752c38316)", - "HashTag(#98)", - "RegularText(3%)", - "RegularText(Joe)", - "RegularText(Martin)", - "RegularText(Music,)", - "Email(joemartinmusic@nostrplebs.com)", - "RegularText(-)", - "RegularText(28ca019b78b494c25a9da2d645975a8501c7e99b11302e5cbe748ee593fcb2cc)", - "HashTag(#99)", - "RegularText(3%)", - "RegularText(Fundamentals,)", - "Email(ph@nostrplebs.com)", - "RegularText(-)", - "RegularText(5677fa5b6b1cb6d5bee785d088a904cd08082552bf75df3e4302cea015a5d3e1)", - "HashTag(#100)", - "RegularText(3%)", - "RegularText(bb,)", - "RegularText()", - "RegularText(-)", - "RegularText(1f254ae909a36b0000c3b68f36b92aad168f4532725d7cd9b67f5b09088f2125)", - "HashTag(#101)", - "RegularText(3%)", - "RegularText(ๆŽๅญๆŸ’,)", - "RegularText()", - "RegularText(-)", - "RegularText(c70c8e55e0228c3ce171ae0d357452e386489f3a2d14e6deca174c2fbfc8da52)", - "HashTag(#102)", - "RegularText(3%)", - "RegularText(Horse)", - "RegularText(๐Ÿด,)", - "Email(horse@iris.to)", - "RegularText(-)", - "RegularText(e4d3420c0b77926cfbf107f9cb606238efaf5524af39ff1c86e6d6fdd1515a57)", - "HashTag(#103)", - "RegularText(3%)", - "RegularText(KP,)", - "Email(kp@no.str.cr)", - "RegularText(-)", - "RegularText(b2e777c827e20215e905ab90b6d81d5b84be5bf66c944ce34943540b462ea362)", - "HashTag(#104)", - "RegularText(3%)", - "RegularText(Azarakhsh,)", - "Email(rebornbitcoiner@getalby.com)", - "RegularText(-)", - "RegularText(c734992a115c2ad9b4df40dd7c14d153695b29081a995df39b4fc8e6f1dcfb14)", - "HashTag(#105)", - "RegularText(3%)", - "RegularText(Toshi,)", - "Email(toshi@nostr-check.com)", - "RegularText(-)", - "RegularText(79d434176b64745d2793cf307f20967e27912994f6e81632de18da3106c2cbb4)", - "HashTag(#106)", - "RegularText(3%)", - "RegularText(FreeBorn,)", - "Email(freeborn@nostrplebs.com)", - "RegularText(-)", - "RegularText(408e04e9a5b02ef6d82edb9ecb2cca1d5a3121cb26b0ca5e6511800a0269b069)", - "HashTag(#107)", - "RegularText(3%)", - "RegularText(blee,)", - "Email(blee@bitcoiner.social)", - "RegularText(-)", - "RegularText(69a0a0910b49a1dbfbc4e4f10df22b5806af5403a228267638f2e908c968228d)", - "HashTag(#108)", - "RegularText(3%)", - "RegularText(SatsTonight,)", - "Email(SatsTonight@BitcoinNostr.com)", - "RegularText(-)", - "RegularText(eb3b94533dafeb8ebd58a4947a3dce11d83a9931c622bdf30a4257d3347ee1bf)", - "HashTag(#109)", - "RegularText(3%)", - "SchemelessUrl(Nostr-Check.com,)", - "Email(freeverification@Nostr-Check.com)", - "RegularText(-)", - "RegularText(ddfbb06a722e51933cd37e4ecdb30b1864f262f9bb5bd6c2d95cbeefc728f096)", - "HashTag(#110)", - "RegularText(3%)", - "RegularText(cowmaster,)", - "Email(cowmaster@getalby.com)", - "RegularText(-)", - "RegularText(6af9411d742c74611e149d19037e7a2ba4d44bbceb429b209c451902b6740bb8)", - "HashTag(#111)", - "RegularText(3%)", - "RegularText(Hacker,)", - "Email(hacker818@iris.to)", - "RegularText(-)", - "RegularText(40e10350fed534e5226b73761925030134d9f85306ee1db5cfbd663118034e84)", - "HashTag(#112)", - "RegularText(3%)", - "RegularText(BitcasaHomes,)", - "Email(amandabitcasa@nostrplebs.com)", - "RegularText(-)", - "RegularText(f96a2a2552c08f99c30b9e2441d64ca4c6b3d761735e7cd74580bafe549326e0)", - "HashTag(#113)", - "RegularText(3%)", - "RegularText(footstr,)", - "RegularText()", - "RegularText(-)", - "RegularText(aa1aa6af6be3a2903e2fb18690d7df128a10eec0f3a015157daf371c688b4cff)", - "HashTag(#114)", - "RegularText(3%)", - "RegularText(tiago,)", - "Email(tiago@nostrplebs.com)", - "RegularText(-)", - "RegularText(780ab38a843423c61502550474b016e006f2b56f2f7d18e9cd02737e11113262)", - "HashTag(#115)", - "RegularText(3%)", - "RegularText(Sepehr,)", - "Email(sepehr@nostribe.com)", - "RegularText(-)", - "RegularText(3e294d2fd339bb16a5403a86e3664947dd408c4d87a0066524f8a573ae53ca8e)", - "HashTag(#116)", - "RegularText(3%)", - "RegularText(dhruv,)", - "RegularText()", - "RegularText(-)", - "RegularText(297bc16357b314be291c893755b25d66999c1525bbf3537fbc637a0c767f14bb)", - "HashTag(#117)", - "RegularText(3%)", - "RegularText(b310edโ€ฆ4f793a,)", - "RegularText()", - "RegularText(-)", - "RegularText(b310ed0a54a71ccf8a8368032dd3b4b83b7aca2840bb10a4d5e6ef4b6a4f793a)", - "HashTag(#118)", - "RegularText(3%)", - "RegularText(MichZ)", - "RegularText(๐Ÿง˜๐Ÿปโ€โ™€๏ธ,)", - "RegularText()", - "RegularText(-)", - "RegularText(9349d012686caab46f6bfefd2f4c361c52e14b1cde1cd027476e0ae6d3e98946)", - "HashTag(#119)", - "RegularText(3%)", - "RegularText(gfy,)", - "Email(gfy@stacker.news)", - "RegularText(-)", - "RegularText(01e4fc2adc0ff7a0465d3e70b3267d375ebe4292828fa3888f972313f3a1248e)", - "HashTag(#120)", - "RegularText(3%)", - "RegularText(Dude,)", - "RegularText()", - "RegularText(-)", - "RegularText(67cbb3d83800cc1af6f5d2821f1c911f033ea21e1269ff2ad613ab3ae099b1f3)", - "HashTag(#121)", - "RegularText(3%)", - "RegularText(HODL_MFER,)", - "RegularText()", - "RegularText(-)", - "RegularText(7c6a9e6231570a6773e608d1c0a709acb9c21193a5c2df9cebfa9e9db09411a3)", - "HashTag(#122)", - "RegularText(3%)", - "RegularText(renatarodrigues,)", - "RegularText()", - "RegularText(-)", - "RegularText(aa116590cf23dc761a8a9e38ff224a3d07db45c66be3035b9f87144bda0eeaa5)", - "HashTag(#123)", - "RegularText(3%)", - "RegularText(CryptoJournaal,)", - "Email(cryptojournaal@iris.to)", - "RegularText(-)", - "RegularText(fb649213b88e9927a5c8f470d7affe88441de995deaccf283bf60a78f771b825)", - "HashTag(#124)", - "RegularText(3%)", - "RegularText(Bon,)", - "Email(bon@nostrplebs.com)", - "RegularText(-)", - "RegularText(b2722dd1e13ff9b82ff2f432186019045fee39911d5652d6b4263562061af908)", - "HashTag(#125)", - "RegularText(3%)", - "RegularText(binarywatch,)", - "Email(bot@binarywatch.org)", - "RegularText(-)", - "RegularText(0095c837e8ed370de6505c2c631551af08c110853b519055d0cdf3d981da5ac3)", - "HashTag(#126)", - "RegularText(3%)", - "RegularText(Moritz,)", - "Email(moritz@getalby.com)", - "RegularText(-)", - "RegularText(0521db9531096dff700dcf410b01db47ab6598de7e5ef2c5a2bd7e1160315bf6)", - "HashTag(#127)", - "RegularText(3%)", - "RegularText(hodlish,)", - "Email(hodlish@Nostr-Check.com)", - "RegularText(-)", - "RegularText(3575a3a7a6b5236443d6af03606aa9297c3177a45cf5314b9fd57bff894ee3ae)", - "HashTag(#128)", - "RegularText(3%)", - "RegularText(HolgerHatGarKeineNode,)", - "Email(HolgerHatGarKeineNode@nip05.easify.de)", - "RegularText(-)", - "RegularText(0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033)", - "HashTag(#129)", - "RegularText(3%)", - "RegularText(joe,)", - "Email(joe@jaxo.github.io)", - "RegularText(-)", - "RegularText(6827ef2b75ee652dcc83958b83aea0bc6580705b56041a9ee70a4178e1046cdb)", - "HashTag(#130)", - "RegularText(3%)", - "RegularText(hahattpro,)", - "Email(hahattpro@iris.to)", - "RegularText(-)", - "RegularText(53ac90ebaef84b0439cdf4f1d955ff1f1e98febc04fb789eff4a08fe53316483)", - "HashTag(#131)", - "RegularText(3%)", - "RegularText(bensima,)", - "Email(bensima@simatime.com)", - "RegularText(-)", - "RegularText(2fa4b9ba71b6dab17c4723745bb7850dfdafcb6ae1a8642f76f9c64fa5f43436)", - "HashTag(#132)", - "RegularText(3%)", - "RegularText(satan,)", - "Email(satan@nostrcheck.me)", - "RegularText(-)", - "RegularText(d6b44ef322f6d67806ff06aaa9623b22ff5c2b0f0705c5e7a5a35684af9e5101)", - "HashTag(#133)", - "RegularText(3%)", - "RegularText(RadVladdy,)", - "Email(radvladdy@nostrplebs.com)", - "RegularText(-)", - "RegularText(7933ea1abdb329139b4eb37157649229b41d0ae445907238b07926182f717924)", - "HashTag(#134)", - "RegularText(3%)", - "RegularText(horacio,)", - "RegularText()", - "RegularText(-)", - "RegularText(f61abb9886e1f4cd5d20419c197d5d7f3649addab24b6a32a2367124ca3194b4)", - "HashTag(#135)", - "RegularText(3%)", - "RegularText(yidneth,)", - "Email(yidneth@getalby.com)", - "RegularText(-)", - "RegularText(f28be20326c6779b2f8bfa75a865d0fa4af384e9c6c99dc6a803e542f9d2085e)", - "HashTag(#136)", - "RegularText(3%)", - "RegularText(JonO,)", - "RegularText()", - "RegularText(-)", - "RegularText(edecf91d15e03c921806ae6ebff86771c79e1641e899787e4d7689f68314d447)", - "HashTag(#137)", - "RegularText(3%)", - "RegularText(bellatrix,)", - "Email(bellatrix@iris.to)", - "RegularText(-)", - "RegularText(f9d7f0b271b5bb19ed400d8baeee1c22ac3a5be5cf20da55219c4929e523987a)", - "HashTag(#138)", - "RegularText(3%)", - "RegularText(SecureCoop,)", - "Email(securecoop@iris.to)", - "RegularText(-)", - "RegularText(d244e3cd0842d514a0725e0e0a00b712b7f2ed515a1d7ef362fd12c957b95549)", - "HashTag(#139)", - "RegularText(3%)", - "RegularText(charliesurf,)", - "Email(charliesurf@ln.tips)", - "RegularText(-)", - "RegularText(a396e36e962a991dac21731dd45da2ee3fd9265d65f9839c15847294ec991f1c)", - "HashTag(#140)", - "RegularText(3%)", - "RegularText(Bitcoin)", - "RegularText(ATM,)", - "Email(bitcoinatm@Nostr-Check.com)", - "RegularText(-)", - "RegularText(01a69fa5a7cbb4a185904bdc7cae6137ff353889bba95619c619debe9e3b8b09)", - "HashTag(#141)", - "RegularText(3%)", - "RegularText(lnstallone,)", - "Email(lnstallone@allmysats.com)", - "RegularText(-)", - "RegularText(84fe3febc748470ff1a363db8a375ffa1ff86603f2653d1c3c311ad0a70b5d0c)", - "HashTag(#142)", - "RegularText(3%)", - "RegularText(a652f6โ€ฆ9124f3,)", - "RegularText()", - "RegularText(-)", - "RegularText(a652f66df4ddb5280ff466b6ff444fbc310b8e83238660473d5ccffa9e9124f3)", - "HashTag(#143)", - "RegularText(3%)", - "RegularText(hmichellerose,)", - "RegularText()", - "RegularText(-)", - "RegularText(5b29255d5eaaaeb577552bf0d11030376f477d19a009c5f5a80ddc73d49359f6)", - "HashTag(#144)", - "RegularText(3%)", - "RegularText(L0la)", - "RegularText(L33tz,)", - "Email(L0laL33tz@cashu.me)", - "RegularText(-)", - "RegularText(d8a6ecf0c396eaa8f79a4497fe9b77dc977633451f3ca5c634e208659116647b)", - "HashTag(#145)", - "RegularText(3%)", - "RegularText(Lommy,)", - "Email(Lommy@nostrplebs.com)", - "RegularText(-)", - "RegularText(014b9837dabb358fc0f416ceb58f72c4e6ed8fc6d317f0578dd704fc879f16f8)", - "HashTag(#146)", - "RegularText(3%)", - "RegularText(jgmontoya,)", - "Email(jgmontoya@nostrplebs.com)", - "RegularText(-)", - "RegularText(9236f9ac521be2ee0a54f1cfffdf2df7f4982df4e6eb992867d733debcf95b35)", - "HashTag(#147)", - "RegularText(3%)", - "RegularText(bavarianledger,)", - "Email(bavarianledger@iris.to)", - "RegularText(-)", - "RegularText(f27c20bc6e64407f805a92c3190089060f9d85efa67ccc80b85f007c3323c221)", - "HashTag(#148)", - "RegularText(3%)", - "RegularText(operator,)", - "Email(operator@brb.io)", - "RegularText(-)", - "RegularText(3c1ba7d42c873c2f89caf1ca79b4ead6513385de53743fa6eb98c3705655695c)", - "HashTag(#149)", - "RegularText(3%)", - "RegularText(awaremoma,)", - "RegularText()", - "RegularText(-)", - "RegularText(44313b79dfc3303e3bd0c4aee0c872e96a84f23a2a45624b3ab630f24f43012f)", - "HashTag(#150)", - "RegularText(3%)", - "RegularText(Tรญo)", - "RegularText(Tito,)", - "Email(tiotito@nostriches.net)", - "RegularText(-)", - "RegularText(dc6e531596c52a218a6fae2e1ea359a1365d5eda02ec176c945ed06a9400ec72)", - "HashTag(#151)", - "RegularText(3%)", - "RegularText(javi,)", - "Email(javi@www.javiergonzalez.io)", - "RegularText(-)", - "RegularText(2eab634b27a78107c98599a982849b4f71c605316c8f4994861f83dc565df5c8)", - "HashTag(#152)", - "RegularText(3%)", - "RegularText(NathanCPerry,)", - "RegularText()", - "RegularText(-)", - "RegularText(cec9808bbb00bc9c3eab4c2f23e9440a5ea775201b65a18462bc77080e39e336)", - "HashTag(#153)", - "RegularText(3%)", - "RegularText(Jason)", - "RegularText(Hodlers)", - "RegularText(โ™พ๏ธ/2099999997690000๐Ÿด,)", - "Email(geekigai@nostrplebs.com)", - "RegularText(-)", - "RegularText(d162a53c3b0bfb5c3ebd787d7b08feab206b112362eca25aa291251cd70fe225)", - "HashTag(#154)", - "RegularText(3%)", - "SchemelessUrl(MR.Rabbit,)", - "Email(Mr.Rabbit@BitcoinNostr.com)", - "RegularText(-)", - "RegularText(42af69b2384071f31e55cb2d368c8a3351c8f2da03207e1fb6885991ac2522bf)", - "HashTag(#155)", - "RegularText(3%)", - "RegularText(kilicl,)", - "Email(kilicl@nostr-check.com)", - "RegularText(-)", - "RegularText(48a94f890f4dc3625b9926cdccded61e353ad1fe76600bc6acea44bdb9efceb7)", - "HashTag(#156)", - "RegularText(3%)", - "RegularText(retired,)", - "RegularText()", - "RegularText(-)", - "RegularText(82ba83731adcfe5a65ced992fde81efc756d10670c56a58cb8870210f859d3c1)", - "HashTag(#157)", - "RegularText(3%)", - "RegularText(Alex)", - "RegularText(Bit,)", - "Email(alexbit@nostrbr.online)", - "RegularText(-)", - "RegularText(9db334a465cc3f6107ed847eec0bc6c835e76ba50625f4c1900cbcb9df808d91)", - "HashTag(#158)", - "RegularText(3%)", - "RegularText(freeeedom21,)", - "Email(william@nostrplebs.com)", - "RegularText(-)", - "RegularText(fd254541619b6d4baa467412058321f70cf108d773adcda69083bd500e502033)", - "HashTag(#159)", - "RegularText(3%)", - "RegularText(OneEzra,)", - "Email(oneezra@nostrplebs.com)", - "RegularText(-)", - "RegularText(0078d4cb1652552475ba61ec439cd50c37c3a3a439853d830d7c9d338826ade2)", - "HashTag(#160)", - "RegularText(3%)", - "RegularText(lightsats,)", - "RegularText()", - "RegularText(-)", - "RegularText(88185e27e96cfcfc3c58c625cf70c4dba757f8d2e9ab7cab80f5012a343eb7d2)", - "HashTag(#161)", - "RegularText(3%)", - "RegularText(IceAndFireBTC,)", - "Email(iceandfirebtc@nostrplebs.com)", - "RegularText(-)", - "RegularText(edb50fd8286e36878f8dd9346c138598052e5d914f0c3c6072f12eb152f307d8)", - "HashTag(#162)", - "RegularText(3%)", - "RegularText(Nostr)", - "RegularText(Gang,)", - "Email(nostrgang@nostrplebs.com)", - "RegularText(-)", - "RegularText(91aeab23b5664edaa57dbe00b041ccb50544f89d7d956345bbd78b7dbaa48660)", - "HashTag(#163)", - "RegularText(3%)", - "RegularText(kexkey,)", - "RegularText()", - "RegularText(-)", - "RegularText(436456869bdd7fcb3aaaa91bed05173ea1510879004250b9f69b2c4370d58cf7)", - "HashTag(#164)", - "RegularText(3%)", - "RegularText(freebitcoin,)", - "Email(npub1vez5zekuzc3qk989q5gtly2zg9k2gz4l3wuplv5xs8y3se09yussg4vp7p@carteclip.com)", - "RegularText(-)", - "RegularText(66454166dc16220b14e50510bf9142416ca40abf8bb81fb28681c91865e52721)", - "HashTag(#165)", - "RegularText(3%)", - "RegularText(Sqvaznyak,)", - "Email(Sqvaznyak@uselessshit.co)", - "RegularText(-)", - "RegularText(056d6999f3283778d50aa85c25985716857cfeaffdbad92e73cf8aeaf394a5cd)", - "HashTag(#166)", - "RegularText(3%)", - "RegularText(koba,)", - "RegularText()", - "RegularText(-)", - "RegularText(b5926366f9ac01d8ed427c9bb4cdcb86b7b4a44aaad00d262ef436621e30ea5a)", - "HashTag(#167)", - "RegularText(3%)", - "RegularText(braj,)", - "Email(braj@nostrplebs.com)", - "RegularText(-)", - "RegularText(5921b801183f10b0143c2e48c22c8192fa38d27ac614a20251cac30ab729d3a5)", - "HashTag(#168)", - "RegularText(3%)", - "RegularText(Libertus,)", - "Email(libertus@getalby.com)", - "RegularText(-)", - "RegularText(2154d20dace7b28018621edf9c3a56ab842b901db0d9b02616dbed3d15fc5490)", - "HashTag(#169)", - "RegularText(3%)", - "RegularText(ZoeBoudreault,)", - "Email(ZoeBoudreault@id.nostrfy.me)", - "RegularText(-)", - "RegularText(3c43dc2a4c996832ae3a1830250d5f0917476783132969db4e14955b6e394047)", - "HashTag(#170)", - "RegularText(3%)", - "RegularText(Saiga,)", - "RegularText()", - "RegularText(-)", - "RegularText(8f5f3a60edc875315d9c1348d6ad5dddbca806d02400049632589cb32b3f0493)", - "HashTag(#171)", - "RegularText(3%)", - "RegularText(n,)", - "RegularText()", - "RegularText(-)", - "RegularText(aceff8abf70a60d7b378469ab80513c83c5d70a4f82872bac7bd619acbc71ff1)", - "HashTag(#172)", - "RegularText(3%)", - "RegularText(dnilso,)", - "Email(dnilso@iris.to)", - "RegularText(-)", - "RegularText(5ae325f930f53fad2a1a9ebefdb943bba1bef7b411e7712d2173bf3c38a49b17)", - "HashTag(#173)", - "RegularText(3%)", - "RegularText(Shroom,)", - "Email(shroom@nostrplebs.com)", - "RegularText(-)", - "RegularText(a4ee688a599c9493b8641cc61987ef42b7556ba1e79d35bca92a1dce186dac85)", - "HashTag(#174)", - "RegularText(3%)", - "RegularText(0a92e7โ€ฆbc2d3d,)", - "RegularText()", - "RegularText(-)", - "RegularText(0a92e765595bbf3368c44338479df5351cf5b0028215ba95e1c9e8de99bc2d3d)", - "HashTag(#175)", - "RegularText(3%)", - "RegularText(olegaba,)", - "Email(olegaba@olegaba.com)", - "RegularText(-)", - "RegularText(7fb2a29bd1a41d9a8ca43a19a7dcf3a8522f1bc09b4086253539190e9c29c51a)", - "HashTag(#176)", - "RegularText(3%)", - "RegularText(CJButcher,)", - "RegularText()", - "RegularText(-)", - "RegularText(15fdc4596019e2b9b702ae229d5c7a17d9527226f8cf5526006908901612b200)", - "HashTag(#177)", - "RegularText(3%)", - "RegularText(wasabi-pea,)", - "Email(wasabi@nostrplebs.com)", - "RegularText(-)", - "RegularText(abe1c8a87aca21e9b6a32a8c2fae5acbaf3212a01d9ccc13a80981c853e8fa02)", - "HashTag(#178)", - "RegularText(3%)", - "RegularText(045a6fโ€ฆf32334,)", - "RegularText()", - "RegularText(-)", - "RegularText(045a6fa0da5d278ac1c3aee79df23b7372ea03ee4da04ad4b8db9a5967f32334)", - "HashTag(#179)", - "RegularText(3%)", - "RegularText(Artur,)", - "Email(artur@getalby.com)", - "RegularText(-)", - "RegularText(762a3c15c6fa90911bf13d50fc3a29f1663dc1f04b4397a89eef604f622ecd60)", - "HashTag(#180)", - "RegularText(3%)", - "RegularText(ihsanmd๐Ÿ’€,)", - "Email(ihsanmd@getalby.com)", - "RegularText(-)", - "RegularText(d030bd233a1347e510c372b1878e00204b228072814361451623707896435da9)", - "HashTag(#181)", - "RegularText(2%)", - "RegularText(Satoshee,)", - "Email(satoshee@vida.page)", - "RegularText(-)", - "RegularText(0e88aac7368d5f2582437826042b3fb3a26a126f3d857618c6b6652a9f5bfa0a)", - "HashTag(#182)", - "RegularText(2%)", - "RegularText(39ed0aโ€ฆ60271a,)", - "RegularText()", - "RegularText(-)", - "RegularText(39ed0aea2338477103e0b5a820532ded27dbfe4f203e7270392d55f63e60271a)", - "HashTag(#183)", - "RegularText(2%)", - "SchemelessUrl(Ancap.su,)", - "Email(ancapsu@getalby.com)", - "RegularText(-)", - "RegularText(2fe5292a2df25047a392fceead75458875c775c31cc28f4be04cef3e8db15291)", - "HashTag(#184)", - "RegularText(2%)", - "RegularText(NiceAction,)", - "Email(niceaction@www.niceaction.com)", - "RegularText(-)", - "RegularText(32891ace6802507077035ba6064f7e1db29667002165b9bf5c1c9b3f84e2303c)", - "HashTag(#185)", - "RegularText(2%)", - "RegularText(seak,)", - "Email(seak@nostrplebs.com)", - "RegularText(-)", - "RegularText(d70f1bca430a2158f0e4c88b158ae18efffe8a91d436edbeee27acf2d9012cf5)", - "HashTag(#186)", - "RegularText(2%)", - "RegularText(twochickshomestead,)", - "RegularText()", - "RegularText(-)", - "RegularText(5bf5ab367f45b01b1cac72d73703fb30c704f3dbd5d376396fc0b6f39cac456b)", - "HashTag(#187)", - "RegularText(2%)", - "RegularText(Andy,)", - "Email(andy@nodeless.io)", - "RegularText(-)", - "RegularText(08cd52a46ab37a9894b3333785c2ff50e068d1b01fb03d702608da83e9817d82)", - "HashTag(#188)", - "RegularText(2%)", - "RegularText(coinbitstwitterfollows,)", - "RegularText()", - "RegularText(-)", - "RegularText(1341010418f272ed6db469d77dffdf1d946dd0701e33bdc84bb72269cef5bfed)", - "HashTag(#189)", - "RegularText(2%)", - "RegularText(Annonymal,)", - "RegularText()", - "RegularText(-)", - "RegularText(5c7794d47115a1b133a19673d57346ca494d367379458d8e98bf24a498abc46b)", - "HashTag(#190)", - "RegularText(2%)", - "RegularText(lindsey,)", - "RegularText()", - "RegularText(-)", - "RegularText(f81d7cbdfe99ff2b11932fb4cdcd94f18e629e3fedafcd25ee0a4ddc0967f0f9)", - "HashTag(#191)", - "RegularText(2%)", - "RegularText(pinkyjay,)", - "Email(pinkyjay@nostrplebs.com)", - "RegularText(-)", - "RegularText(b0dbac368a5ac474bc19ab11a0b3fd4260cf56b40c60944c4a331b8ad8ced926)", - "HashTag(#192)", - "RegularText(2%)", - "RegularText(criptobastardo,)", - "Email(criptobastardo@nostrplebs.com)", - "RegularText(-)", - "RegularText(311262ac14efb7011f23223b662aa1f18b3bb7c238206cb1c07424f051a11cce)", - "HashTag(#193)", - "RegularText(2%)", - "RegularText(lacosanostr,)", - "Email(lacosanostr@lacosanostr.com)", - "RegularText(-)", - "RegularText(6ce2001e7f070fade19d4817006747e4164089886a0faca950a6b0ab2a3b58b2)", - "HashTag(#194)", - "RegularText(2%)", - "RegularText(teeJem,)", - "Email(teejem@nostrplebs.com)", - "RegularText(-)", - "RegularText(36f7bc3a3f40b11095f546a86b11ff1babc7ca7111c8498d6b6950cfc7663694)", - "HashTag(#195)", - "RegularText(2%)", - "RegularText(BiancaBtcArt,)", - "RegularText()", - "RegularText(-)", - "RegularText(1f2c17bd3bcaf12f9c7e78fe798eeea59c1b22e1ee036694d5dc2886ddfa35d7)", - "HashTag(#196)", - "RegularText(2%)", - "RegularText(ruto,)", - "RegularText()", - "RegularText(-)", - "RegularText(2888961a564e080dfe35ad8fc6517b920d2fcd2b7830c73f7c3f9f2abae90ea9)", - "HashTag(#197)", - "RegularText(2%)", - "RegularText(Pocketcows,)", - "RegularText()", - "RegularText(-)", - "RegularText(e462fd4f25682164bdb7c51fc1b2cd3c7e6ddba13a1d7094b06f6f4fe47f9ae3)", - "HashTag(#198)", - "RegularText(2%)", - "RegularText(mewj,)", - "Email(mewj@elder.nostr.land)", - "RegularText(-)", - "RegularText(489ac583fc30cfbee0095dd736ec46468faa8b187e311fda6269c4e18284ed0c)", - "HashTag(#199)", - "RegularText(2%)", - "RegularText(nostr,)", - "RegularText()", - "RegularText(-)", - "RegularText(2bd053345e10aed28bd0e97c311aab3470f6d7f405dc588b056bce1e3797d2f0)", - "HashTag(#200)", - "RegularText(2%)", - "RegularText(Bobolo,)", - "RegularText()", - "RegularText(-)", - "RegularText(ca7799f00a9d792f9bba6947b32e3142e6c6c4733e52906cbaf92a2961216b46)", - "HashTag(#201)", - "RegularText(2%)", - "RegularText(InsolentBitcoin,)", - "RegularText()", - "RegularText(-)", - "RegularText(6484df04c9403a64c3039f5f00d24ac0535f497cdfa1f187bc6a2d34cf017b97)", - "HashTag(#202)", - "RegularText(2%)", - "RegularText(Monero)", - "RegularText(Directory,)", - "RegularText()", - "RegularText(-)", - "RegularText(1abdef52155dc52a21a2ac9ed19e444317f6cf83500df139fbe73c2a7ac78e2a)", - "HashTag(#203)", - "RegularText(2%)", - "RegularText(thetonewrecker,)", - "Email(thetonewrecker@nostrplebs.com)", - "RegularText(-)", - "RegularText(3762d3159bfd9d8acb56677eec9a6f8a5a05ea86636186ca6ed6714a69975fed)", - "HashTag(#204)", - "RegularText(2%)", - "RegularText(yodatravels,)", - "Email(yodatravels@iris.to)", - "RegularText(-)", - "RegularText(67eb726f7bb8e316418cd46cfa170d580345e51adbc186f8f7aa0d4380579350)", - "HashTag(#205)", - "RegularText(2%)", - "RegularText(Bitcoin)", - "RegularText(Bandit,)", - "Email(bitcoin69@iris.to)", - "RegularText(-)", - "RegularText(907842aa7b5d00054473d261e814c011c5d8e13bf8a585cc76121b1e6c51900f)", - "HashTag(#206)", - "RegularText(2%)", - "RegularText(Zzar,)", - "Email(Zzar@nostrplebs.com)", - "RegularText(-)", - "RegularText(ca1dd2422cb94874c1666c9c76b7961bbaea432632643f7a2dc9d4d2bfb35db9)", - "HashTag(#207)", - "RegularText(2%)", - "RegularText(vidalBidi,)", - "Email(vidalbidi@getalby.com)", - "RegularText(-)", - "RegularText(0c28a25357c76ac5ac3714eddc25d81fe98134df13351ab526fc2479cc306e65)", - "HashTag(#208)", - "RegularText(2%)", - "RegularText(994e89โ€ฆf75447,)", - "RegularText()", - "RegularText(-)", - "RegularText(994e892582261fd933af25bcc9672f2fbd5e769e3d1c889ecd292a7a92f75447)", - "HashTag(#209)", - "RegularText(2%)", - "RegularText(juangalt,)", - "Email(juangalt@current.ninja)", - "RegularText(-)", - "RegularText(372da077d6353430f343d5853d85311b3fd27018d5a83b8c1b397b92518ec7ac)", - "HashTag(#210)", - "RegularText(2%)", - "RegularText(Dean,)", - "Email(dean@nostrplebs.com)", - "RegularText(-)", - "RegularText(83f018060171dfee116b077f0f455472b6b6de59abf4730994022bf6f27d16be)", - "HashTag(#211)", - "RegularText(2%)", - "RegularText(alexli,)", - "Email(alex2@nostrverified.com)", - "RegularText(-)", - "RegularText(8083df6081d91b42bcf1042215e4bfc894af893cd07ea472e801bc0794da3934)", - "HashTag(#212)", - "RegularText(2%)", - "RegularText(Khidthungban,)", - "RegularText()", - "RegularText(-)", - "RegularText(8d5cf93afb8d9ef1d08acee4e7147348d0c573bf7e5f57886a8a9a137cbe890c)", - "HashTag(#213)", - "RegularText(2%)", - "RegularText(Trooper,)", - "Email(trooper@iris.to)", - "RegularText(-)", - "RegularText(2c8d81a4e5cd9a99caba73f14c087ca7c05e554bb9988a900ccd76dbd828407d)", - "HashTag(#214)", - "RegularText(2%)", - "RegularText(Satscoinsv,)", - "RegularText(โšก๏ธsatscoinsv@getalby.com)", - "RegularText(-)", - "RegularText(80db64657ea0358c5332c5cca01565eeddd4b8799688b1c46d3cb2d7c966671f)", - "HashTag(#215)", - "RegularText(2%)", - "RegularText(AARBTC,)", - "Email(aarbtc@iris.to)", - "RegularText(-)", - "RegularText(6d23993803386c313b7d4dcdfffdbe4e1be706c2f0c89cb5afaa542bf2be1b90)", - "HashTag(#216)", - "RegularText(2%)", - "RegularText(yogsite,)", - "Email(_@gue.yogsite.com)", - "RegularText(-)", - "RegularText(d3ab705ec57f3ea963fc7c467bddc7b17bf01b85acc4fbb14eed87df794a116c)", - "HashTag(#217)", - "RegularText(2%)", - "RegularText(NostrMemes,)", - "Email(nostrmemes@iris.to)", - "RegularText(-)", - "RegularText(6399694ca3b8c40d8be9762f50c9c420bf0bd73fb7d7d244a195814c9ab8fb7e)", - "HashTag(#218)", - "RegularText(2%)", - "RegularText(btcpavao,)", - "Email(btcpavao@iris.to)", - "RegularText(-)", - "RegularText(1a8ed3216bd2b81768363b4326e1ae270a7cd6fe570bafeda2dc070f34f3aedc)", - "HashTag(#219)", - "RegularText(2%)", - "RegularText(Anonymous,)", - "Email(Anonymous@BitcoinNostr.com)", - "RegularText(-)", - "RegularText(ac076f8f80ee4a49f22c2ce258dcfe6e105de0bf029a048fa3a8de4b51c1b957)", - "HashTag(#220)", - "RegularText(2%)", - "RegularText(zoltanAB,)", - "Email(zoltanab@iris.to)", - "RegularText(-)", - "RegularText(42aafd1217089d68c757671a251507a194587dd3adfc3a3a76bb1e38a78a3453)", - "HashTag(#221)", - "RegularText(2%)", - "RegularText(katsu,)", - "Email(katsu@onsats.org)", - "RegularText(-)", - "RegularText(76f64475795661961801389aeaa7869a005735266c9e3df9bc93d127fad04154)", - "HashTag(#222)", - "RegularText(2%)", - "RegularText(bryan,)", - "Email(bryan@nonni.io)", - "RegularText(-)", - "RegularText(9ddf6fe3a194d330a6c6e278a432ae1309e52cc08587254b337d0f491f7ff642)", - "HashTag(#223)", - "RegularText(2%)", - "RegularText(pedromvpg,)", - "Email(pedromvpg@pedromvpg.com)", - "RegularText(-)", - "RegularText(8cd2d0f8310f7009e94f50231870756cb39ba68f37506044910e2f71482b1788)", - "HashTag(#224)", - "RegularText(2%)", - "RegularText(Nellie,)", - "Email(sonicstudio@getalby.com)", - "RegularText(-)", - "RegularText(37fbbf7707e70a8a7787e5b1b75f3e977e70aab4f41ddf7b3c0f38caedd875d4)", - "HashTag(#225)", - "RegularText(2%)", - "RegularText(nicknash,)", - "RegularText()", - "RegularText(-)", - "RegularText(636b4e6f5a594893c544b49a5742f0a90f109b70d659585e0427a1c0361c0b09)", - "HashTag(#226)", - "RegularText(2%)", - "RegularText(dlegal,)", - "Email(kounsellor@nostrplebs.com)", - "RegularText(-)", - "RegularText(201e51e71a753af3699cf684d7f4113c59a73c4b7bd26ef3f4c187a6173fbf06)", - "HashTag(#227)", - "RegularText(2%)", - "RegularText(BitcoinLake,)", - "RegularText()", - "RegularText(-)", - "RegularText(5babddf98277e3db6c88ae1d322bc63fd637764370e1d5e4fe5226104d82034f)", - "HashTag(#228)", - "RegularText(2%)", - "RegularText(BitcoinKeegan,)", - "RegularText()", - "RegularText(-)", - "RegularText(b457120b6cfb2589d48718f2ab71362dd0db43e13266771725129d35cc602dbe)", - "HashTag(#229)", - "RegularText(2%)", - "RegularText(KatieRoss,)", - "Email(katieross@nostrplebs.com)", - "RegularText(-)", - "RegularText(90f09238f3514f249e2b333e6119eef49697020f956fd7b6732ce118dd1b53cb)", - "HashTag(#230)", - "RegularText(2%)", - "RegularText(efcfa6โ€ฆe3f485,)", - "RegularText()", - "RegularText(-)", - "RegularText(efcfa63ac0324e37fb138c2b9dbbf9372f64ec857c923c5c1f713d3592e3f485)", - "HashTag(#231)", - "RegularText(2%)", - "RegularText(bc9e89โ€ฆb519d3,)", - "RegularText()", - "RegularText(-)", - "RegularText(bc9e89110e6e7ec5540b8ad0467d8a39554a7527c27e7af4cd45b2b8c4b519d3)", - "HashTag(#232)", - "RegularText(2%)", - "RegularText(Ilj,)", - "Email(iamlj@iris.to)", - "RegularText(-)", - "RegularText(fa3e7bcc5e588a8111ffb9d9eb8bf62c87d8a0ef6e1e5e0c74311b61f6ced8e7)", - "HashTag(#233)", - "RegularText(2%)", - "RegularText(ayelen,)", - "RegularText()", - "RegularText(-)", - "RegularText(1c31ccda2709fc6cf5db0a0b0873613e25646c4a944779dfb5e8d6cbbcd2ee1c)", - "HashTag(#234)", - "RegularText(2%)", - "RegularText(zach,)", - "Email(Zach@BitcoinNostr.com)", - "RegularText(-)", - "RegularText(d99211aeeb643695ee1aad0517696bbc822e2fb443afe2dc9dadc0ca50b040e2)", - "HashTag(#235)", - "RegularText(2%)", - "RegularText(Yi,)", - "RegularText()", - "RegularText(-)", - "RegularText(248caad2f8392c7f72502da41ee62bbe256ea66fb365e395c988198660562ff7)", - "HashTag(#236)", - "RegularText(2%)", - "RegularText(Amouranth,)", - "Email(amouranth@nostrcheck.me)", - "RegularText(-)", - "RegularText(be5aa097ad9f4d872c70e432ad8c09565ee7dc1aee24a50b683ddca771b14901)", - "HashTag(#237)", - "RegularText(2%)", - "RegularText(hss5qy,)", - "Email(hss5qy@getalby.com)", - "RegularText(-)", - "RegularText(bc21401161327647e0bbd31f2dec1be168ef7fa5d05689fca0d063b114ed9b46)", - "HashTag(#238)", - "RegularText(2%)", - "RegularText(dpc,)", - "Email(dpcpw@iris.to)", - "RegularText(-)", - "RegularText(274611b4728b0c40be1cf180d8f3427d7d3eebc55645d869a002e8b657f8cd61)", - "HashTag(#239)", - "RegularText(2%)", - "RegularText(pred,)", - "RegularText()", - "RegularText(-)", - "RegularText(3946adbb2fc7c95f75356d8f3952c8e2705ee2431f8bd33f5cae0f9ede0298e2)", - "HashTag(#240)", - "RegularText(2%)", - "RegularText(jamesgore,)", - "RegularText()", - "RegularText(-)", - "RegularText(a94921403ac0ccf1a150ccac3679b11adcb3c3bb78b490452db43a8b6964a5c7)", - "HashTag(#241)", - "RegularText(2%)", - "RegularText(bitcoinfinity,)", - "Email(bitcoinfinity@nostrplebs.com)", - "RegularText(-)", - "RegularText(afbda6a942f975ddf8728bda3e6e5c9e440f067fcde719c6f57512f0f7ed4bf2)", - "HashTag(#242)", - "RegularText(2%)", - "RegularText(tonyseries,)", - "Email(TonySeries@BitcoinNostr.com)", - "RegularText(-)", - "RegularText(ba5a614a48719361f515f6efa62c3e213da4bcddbb78dafd3121daa839192275)", - "HashTag(#243)", - "RegularText(2%)", - "RegularText(kuobano,)", - "Email(kuobano@nostrplebs.com)", - "RegularText(-)", - "RegularText(3f6d0bbb073839671f4c7f1e23452c6c3080f6c5f4cbc2f56c17e2b57ee01442)", - "HashTag(#244)", - "RegularText(2%)", - "RegularText(kitakripto,)", - "Email(kitakripto@BitcoinNostr.com)", - "RegularText(-)", - "RegularText(0b11a45bf4ff7f000886b2227e43404d212bf585f71514d54ae5ae685f4c8fbb)", - "HashTag(#245)", - "RegularText(2%)", - "RegularText(Bashy,)", - "Email(_@localhost.re)", - "RegularText(-)", - "RegularText(566516663d91d4fef824eaeccbf9c2631a8d8a2efee8048ca5ee6095e6e5c843)", - "HashTag(#246)", - "RegularText(2%)", - "RegularText(alxc,)", - "Email(alxc@uselessshit.co)", - "RegularText(-)", - "RegularText(c13cb9426a4f85aff08019d246d1240a6cbf49ab9525a06d54fb496b9a3592b0)", - "HashTag(#247)", - "RegularText(2%)", - "RegularText(Kukryr,)", - "Email(kukryr@orangepill.dev)", - "RegularText(-)", - "RegularText(3f03ab6555d2e36ba970d83b8dfe1a9c09d1b89048cf7db0c85d40850f406e54)", - "HashTag(#248)", - "RegularText(2%)", - "RegularText(Saidah,)", - "Email(saidah@nostrplebs.com)", - "RegularText(-)", - "RegularText(909efa6667b28627f107764ce3c28895c46fffd1811b7415dcab03f48c44b597)", - "HashTag(#249)", - "RegularText(2%)", - "RegularText(micmad,)", - "RegularText(miceliomad@miceliomad.github.io/nostr/)", - "RegularText(-)", - "RegularText(cd806edcf8ff40ea94fa574ea9cd97da16e5beb2b85aac6e1d648b8388504343)", - "HashTag(#250)", - "RegularText(2%)", - "RegularText(Zack)", - "RegularText(Wynne,)", - "RegularText()", - "RegularText(-)", - "RegularText(9156e62c7d2f49a91b55effec6c111d3fb343e9de6ff05650e7fd89a039a9dce)", - "HashTag(#251)", - "RegularText(2%)", - "RegularText(Sharon21M,)", - "Email(sharon21m@nostr.fan)", - "RegularText(-)", - "RegularText(66b5c5be6cec2b4a124c532e97d8342f8d763d6b507caced9185168603751f25)", - "HashTag(#252)", - "RegularText(2%)", - "RegularText(bitcoinheirodomanto,)", - "RegularText()", - "RegularText(-)", - "RegularText(93d16b6fcd11199cc113e28976999ff94137ded02ddf6b84bf671daf9358c54a)", - "HashTag(#253)", - "RegularText(2%)", - "RegularText(tyler,)", - "RegularText()", - "RegularText(-)", - "RegularText(272fe1597e8d938b9a7ae5eb23aa50c5048aabbf68f27a428afe3aecd08192da)", - "HashTag(#254)", - "RegularText(2%)", - "RegularText(DMN,)", - "Email(dmn@noderunners.org)", - "RegularText(-)", - "RegularText(176d6e6ceef73b3c66e1cb1ed19b9f2473eaa514678159bc41361b3f29ddb065)", - "HashTag(#255)", - "RegularText(2%)", - "RegularText(Nela@Nostrica2023,)", - "Email(nela_at_nostrica2023@Nostr-Check.com)", - "RegularText(-)", - "RegularText(4b0bcab460adda31fad5a326fb0c04f6ec821fb24be85dbdc03c04cc0e12fc07)", - "HashTag(#256)", - "RegularText(2%)", - "RegularText(xbolo,)", - "Email(xbologg@nanostr.deno.dev)", - "RegularText(-)", - "RegularText(7aabf4a15df15074deeffdb597e6be54be4a211cbd6303436cb1ccea6c9cf87b)", - "HashTag(#257)", - "RegularText(2%)", - "RegularText(btcurenas,)", - "Email(btcurenas@nostr.fan)", - "RegularText(-)", - "RegularText(206a1264c89e8f29355e792782e83ca62331ca3d70169327cb315171b4a7ce2c)", - "HashTag(#258)", - "RegularText(2%)", - "RegularText(amaluenda,)", - "Email(amaluenda@getalby.com)", - "RegularText(-)", - "RegularText(129a80a580a0cb88d5eae9d3924d7bb8a29e0c03ef9fb723091de69c22eaaff8)", - "HashTag(#259)", - "RegularText(2%)", - "RegularText(DeveRoSt,)", - "RegularText()", - "RegularText(-)", - "RegularText(f838b6a03d8d0127a9a98e87c0142b528916a4336ba537e14131a2f513becc17)", - "HashTag(#260)", - "RegularText(2%)", - "RegularText(phoenixpyro,)", - "RegularText()", - "RegularText(-)", - "RegularText(5122cee9af93a36be4bb9b08ee7897ef88fe446c0a5d2f8db60da9faa0f72f27)", - "HashTag(#261)", - "RegularText(2%)", - "RegularText(Queen)", - "RegularText(โ‚ฟ,)", - "Email(queenb@nostrplebs.com)", - "RegularText(-)", - "RegularText(735e573b24b78138e86c96aaf37cf47547d6287c9acbd4eda173e01826b6647a)", - "HashTag(#262)", - "RegularText(2%)", - "RegularText(L.,)", - "Email(ezekiel@Nostr-Check.com)", - "RegularText(-)", - "RegularText(83663cd936892679cbd1ccdf22e017cb9fee11aef494713192c93ad6a155e287)", - "HashTag(#263)", - "RegularText(2%)", - "RegularText(dolu)", - "RegularText((compromised),)", - "RegularText()", - "RegularText(-)", - "RegularText(e668a111aa647e63ef587c17fb0e2513d5c2859cd8d389563c7640ffea1fc216)", - "HashTag(#264)", - "RegularText(2%)", - "RegularText(Marakesh)", - "RegularText(๐“…ฆ,)", - "Email(marakesh@getalby.com)", - "RegularText(-)", - "RegularText(dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491)", - "HashTag(#265)", - "RegularText(2%)", - "RegularText(Storm,)", - "Email(storm@reddirtmining.io)", - "RegularText(-)", - "RegularText(eaba072268fbb5409bdd2e8199e2878cf5d0b51ce3493122d03d7c69585d17f2)", - "HashTag(#266)", - "RegularText(2%)", - "RegularText(fiore,)", - "RegularText()", - "RegularText(-)", - "RegularText(155fd584b69fea049a428935cef11c093b6b80ca067fe4362eab0564d0774f10)", - "HashTag(#267)", - "RegularText(2%)", - "RegularText(.b.o.n.e.s.,)", - "Email(_b_o_n_e_s_@stacker.news)", - "RegularText(-)", - "RegularText(b91257b518ee7226972fc7b726e96d8a63477750a1b40589e36a090735a4f92f)", - "HashTag(#268)", - "RegularText(2%)", - "RegularText(btchodl,)", - "Email(bdichdbd@stacker.news)", - "RegularText(-)", - "RegularText(d3ca4d0144b7608eceb214734a098d50dd6c728eb72e47b0e5b1e04480db1009)", - "HashTag(#269)", - "RegularText(2%)", - "RegularText(Rosie,)", - "RegularText()", - "RegularText(-)", - "RegularText(caf0d967570ab0702c3402d50c4ab12dc6855ea062519b1ac048708cb663b0c8)", - "HashTag(#270)", - "RegularText(2%)", - "RegularText(j9,)", - "Email(j9@nostrplebs.com)", - "RegularText(-)", - "RegularText(c2797c4c633d3005d60a469d154b85766277454b648252d927660d41ecec4163)", - "HashTag(#271)", - "RegularText(2%)", - "RegularText(nokyctranslate,)", - "Email(nokyctranslate@iris.to)", - "RegularText(-)", - "RegularText(794366f1f67b7bc5604fd47e21a27e6fcbff7ec7e7a72c6d4c386d50fd5d2f04)", - "HashTag(#272)", - "RegularText(2%)", - "RegularText(Neomobius,)", - "Email(Neomobius_at_mstdn.jp@mostr.pub)", - "RegularText(-)", - "RegularText(9134bd35097c03abdcd9d61819aa8948880b6e49fc548d8a751b719dced7f7da)", - "HashTag(#273)", - "RegularText(2%)", - "RegularText(dojomaster,)", - "RegularText()", - "RegularText(-)", - "RegularText(30be56daec34e8b319d730f2c2f1cba28ef076660be33d7811dd385698a9cb40)", - "HashTag(#274)", - "RegularText(2%)", - "RegularText(paddepadde)", - "RegularText(โšก๏ธ,)", - "Email(paddepadde@getcurrent.io)", - "RegularText(-)", - "RegularText(430169631f2f0682c60cebb4f902d68f0c71c498fd1711fd982f052cf1fd4279)", - "HashTag(#275)", - "RegularText(2%)", - "RegularText(Val,)", - "Email(val@nostrplebs.com)", - "RegularText(-)", - "RegularText(e2004cb6f21a23878f0000131363e557638e47a804bcfc200103dd653fc9b7dc)", - "HashTag(#276)", - "RegularText(2%)", - "RegularText(Nickfost_,)", - "RegularText()", - "RegularText(-)", - "RegularText(a3e4cba409d392a81521d8714578948979557c8b2d56994b2026a06f6b7e97d2)", - "HashTag(#277)", - "RegularText(2%)", - "RegularText(dishwasher_iot,)", - "Email(dishwasher_iot@wlvs.space)", - "RegularText(-)", - "RegularText(5c6c25b7ef18d8633e97512159954e1aa22809c6b763e94b9f91071836d00217)", - "HashTag(#278)", - "RegularText(2%)", - "RegularText(๐•ฌ๐–“๐–”๐–“๐–ž๐–’๐–”๐–š๐–˜,)", - "Link(zapper.lol)", - "RegularText(-)", - "RegularText(96aceca84aa381eeda084167dd317e1bf7a45d874cd14147f0a9e0df86fb44c2)", - "HashTag(#279)", - "RegularText(2%)", - "RegularText(Peter,)", - "RegularText()", - "RegularText(-)", - "RegularText(b649ca5743312176174cbe76cf81d3eec493b21a52b822b6aa12bd4473da0d01)", - "HashTag(#280)", - "RegularText(2%)", - "RegularText(justin,)", - "Email(1@justinrezvani.com)", - "RegularText(-)", - "RegularText(84d535055542132100ea22e96e33349844422e6e698cc98bd8fb5eae08d76752)", - "HashTag(#281)", - "RegularText(2%)", - "RegularText(vikeymehta,)", - "RegularText()", - "RegularText(-)", - "RegularText(1a3d05e13fa38543b3d45f31c638e94e113b35c0e1db7371cdfa69861e150830)", - "HashTag(#282)", - "RegularText(2%)", - "RegularText(sshh,)", - "Email(sshh@nostrplebs.com)", - "RegularText(-)", - "RegularText(b0f86106d59d2ce292a4d89e70ff4057d7adf4b1b42bb913f37ceb9159bb2aea)", - "HashTag(#283)", - "RegularText(2%)", - "RegularText(Red_Eye_Jedi,)", - "RegularText()", - "RegularText(-)", - "RegularText(3603dbbea53ee52ab34e0f96a8d42aa55486cf5e2e05483533613e97274155f5)", - "HashTag(#284)", - "RegularText(2%)", - "RegularText(jim,)", - "Email(mk05@iris.to)", - "RegularText(-)", - "RegularText(2ed67b778522bfa0245ee57306dea40d6fd9b023db5fff43e2de0419cfe2164e)", - "HashTag(#285)", - "RegularText(2%)", - "RegularText(pniraj007,)", - "RegularText()", - "RegularText(-)", - "RegularText(99f7ba6cfb2fcd60853446b45cec2a467f65faa3245a95513bcf372eec4fbb0e)", - "HashTag(#286)", - "RegularText(2%)", - "RegularText(b676ebโ€ฆ7c389b,)", - "RegularText()", - "RegularText(-)", - "RegularText(b676ebe5ebd490523dda7db35407b7370974b4df25be32335f0652a1f07c389b)", - "HashTag(#287)", - "RegularText(2%)", - "RegularText(herald,)", - "Email(herald@bitcoin-herald.org)", - "RegularText(-)", - "RegularText(7e7224cfe0af5aaf9131af8f3e9d34ff615ff91ce2694640f1f1fee5d8febb7d)", - "HashTag(#288)", - "RegularText(2%)", - "RegularText(Giuseppe)", - "RegularText(Atorino,)", - "Email(nostr@pos.btcpayserver.it)", - "RegularText(-)", - "RegularText(e6eaf2368767307b45fcbea2d96dcb34a93af8877147203fadc10b8f741b71c9)", - "HashTag(#289)", - "RegularText(2%)", - "RegularText(a8b7b0โ€ฆd90ac2,)", - "RegularText()", - "RegularText(-)", - "RegularText(a8b7b07222485f8b845961dd4ca4d8b63c575e060b4d9386e32463e513d90ac2)", - "HashTag(#290)", - "RegularText(2%)", - "RegularText(genosonic,)", - "RegularText()", - "RegularText(-)", - "RegularText(05ffbdf4b71930d0e93ae0caa8f34bcfb5100cfba71f07b9fad4d8b5a80e4df3)", - "HashTag(#291)", - "RegularText(2%)", - "RegularText(JohnnyG,)", - "Email(thumpgofast@NostrVerified.com)", - "RegularText(-)", - "RegularText(241d6b169d62fa3d673fccf66ab62d49c0a1147ab6ab81f7a526d890e1d68a2b)", - "HashTag(#292)", - "RegularText(2%)", - "RegularText(neoop,)", - "Email(neo@elder.nostr.land)", - "RegularText(-)", - "RegularText(ea64386dba380b76c86f671f2f3c5b2a93febe8d3e2e968ac26f33569da36f87)", - "HashTag(#293)", - "RegularText(2%)", - "RegularText(Alchemist,)", - "Email(alchemist@electronalchemy.com)", - "RegularText(-)", - "RegularText(734aac327175cb770b9aa75c8816156ea439a79c6f87a16801248c1c793a8bfc)", - "HashTag(#294)", - "RegularText(2%)", - "RegularText(timp,)", - "Email(timp@iris.to)", - "RegularText(-)", - "RegularText(24cf74e1125833e9752b4843e2887dedddf6910896e6e82a2def68c8527d0814)", - "HashTag(#295)", - "RegularText(2%)", - "RegularText(ken,)", - "Email(ken@BitcoinNostr.com)", - "RegularText(-)", - "RegularText(3505b759f075da83e9d503530d3238361b1603c28e0ee309d928174e87341713)", - "HashTag(#296)", - "RegularText(2%)", - "RegularText(Shea,)", - "RegularText()", - "RegularText(-)", - "RegularText(8dc289f2b5896057e23edc6b806407dc09162147164f4cae1d00dcb1bcd3f084)", - "HashTag(#297)", - "RegularText(2%)", - "RegularText(Devcat,)", - "RegularText()", - "RegularText(-)", - "RegularText(7f1052e59569dee4c6587507c69032af5d6883d2aa659a55bbfe1cb2e8233daf)", - "HashTag(#298)", - "RegularText(2%)", - "RegularText(173a2eโ€ฆ36436a,)", - "RegularText()", - "RegularText(-)", - "RegularText(173a2e04860656e9bab4a62cd5ec2b46ac8814e240c183e47b6badf7b936436a)", - "HashTag(#299)", - "RegularText(2%)", - "RegularText(Irebus,)", - "Email(irebus@nostr.red)", - "RegularText(-)", - "RegularText(1aaaa8e2a2094e2fdd70def09eae4e329ceb01a6a29473cb0b5e0c118f85bd35)", - "HashTag(#300)", - "RegularText(2%)", - "RegularText(b720b6โ€ฆe48a8f,)", - "RegularText()", - "RegularText(-)", - "RegularText(b720b63c47b3292dcb3339782c612462a7a42c9eece06d609a49cf951de48a8f)", - "HashTag(#301)", - "RegularText(2%)", - "RegularText(theflywheel,)", - "RegularText()", - "RegularText(-)", - "RegularText(57dcc9ed500a26a465ddb12c51de05963d4dec8a596708629558495c4acacab3)", - "HashTag(#302)", - "RegularText(2%)", - "RegularText(223597โ€ฆ002c18,)", - "RegularText()", - "RegularText(-)", - "RegularText(22359794c50e2945aa768ee500ffb2ddb388696ad078a350ae570152ff002c18)", - "HashTag(#303)", - "RegularText(2%)", - "RegularText(gratitude,)", - "RegularText()", - "RegularText(-)", - "RegularText(4686358c60bae7694e8b39dad26d1c834d5dd27726a56e2501fc06dec6942be1)", - "HashTag(#304)", - "RegularText(2%)", - "RegularText(stim4444,)", - "Email(stim4444@no.str.cr)", - "RegularText(-)", - "RegularText(0aeaec333bf9a0638de51ea837590ca64522ec590ed160ce87cb6e30d10df537)", - "HashTag(#305)", - "RegularText(2%)", - "RegularText(756240โ€ฆ265fc2,)", - "RegularText()", - "RegularText(-)", - "RegularText(756240d3be0d553b0cd174b3499cffa37fbe8394ee06b9ab50652e314c265fc2)", - "HashTag(#306)", - "RegularText(2%)", - "RegularText(4d38edโ€ฆd26aad,)", - "RegularText()", - "RegularText(-)", - "RegularText(4d38ed26a6d1080806534818a668c71381bcb04bc4ca1083d9d9572977d26aad)", - "HashTag(#307)", - "RegularText(2%)", - "RegularText(Kwinten,)", - "RegularText()", - "RegularText(-)", - "RegularText(c29da265739bc3886c76d84b0a351849fa45a31a64fcb72f47c600ab2623f90c)", - "HashTag(#308)", - "RegularText(2%)", - "RegularText(b36506โ€ฆ7ca32c,)", - "RegularText()", - "RegularText(-)", - "RegularText(b365069ada41fc7190f8b11e8342f7f66f9777eaaa9882722d0be863c27ca32c)", - "HashTag(#309)", - "RegularText(2%)", - "RegularText(Cole)", - "RegularText(Albon,)", - "RegularText()", - "RegularText(-)", - "RegularText(c3ff9a851ca965ed266ba54c9263f680be91e2465628c64bab6a5992521d5c5d)", - "HashTag(#310)", - "RegularText(2%)", - "RegularText(Onecoin,)", - "RegularText()", - "RegularText(-)", - "RegularText(b23ce47262373574d6653fad2da09db1fb20bb2919f3e697b8edd1966fffd8ec)", - "HashTag(#311)", - "RegularText(2%)", - "RegularText(Disabled,)", - "RegularText()", - "RegularText(-)", - "RegularText(7d706eaefb905ea9b3af885879fb5911b50b39db539c319438703373424204ec)", - "HashTag(#312)", - "RegularText(2%)", - "RegularText(xdamman,)", - "RegularText()", - "RegularText(-)", - "RegularText(340254e011abda2e82585cbfee4f91b3f07549a6c468fe009bf3ec7665a2e31b)", - "HashTag(#313)", - "RegularText(2%)", - "RegularText(jmrichner,)", - "RegularText()", - "RegularText(-)", - "RegularText(797750041d1366a80d45e130c831f0562b5f7266662b07acef50dd541bfa2535)", - "HashTag(#314)", - "RegularText(2%)", - "RegularText(pentoshi,)", - "RegularText()", - "RegularText(-)", - "RegularText(db6ad1e2a4cbbacbbdf79377a9ebb2fc30eb417ce9b061003771cb40b8e00d56)", - "HashTag(#315)", - "RegularText(2%)", - "RegularText(35453dโ€ฆ45d10b,)", - "RegularText()", - "RegularText(-)", - "RegularText(35453d2e49a0282c4dd694e5a364bf29600a9b5443e4712cfc86a0495345d10b)", - "HashTag(#316)", - "RegularText(2%)", - "RegularText(LayerLNW,)", - "Email(layerlnw@nostr.fan)", - "RegularText(-)", - "RegularText(33c9edf7ade19188685997136e6ffb4ed89939178fa5f2259428de1cd3301380)", - "HashTag(#317)", - "RegularText(2%)", - "RegularText(Bitcoincouch,)", - "RegularText()", - "RegularText(-)", - "RegularText(fbd3c6eb5ef06e82583d3b533663ba86036462a02e686881d8cb2de5aaa9fa4a)", - "HashTag(#318)", - "RegularText(2%)", - "RegularText(BritishHodl,)", - "RegularText()", - "RegularText(-)", - "RegularText(22fb17c6657bb317be84421335ef6b0f9f1777617aa220cf27dc06fb5788f438)", - "HashTag(#319)", - "RegularText(2%)", - "RegularText(enhickman,)", - "Email(enhickman@enhickman.net)", - "RegularText(-)", - "RegularText(0cf08d280aa5fcfaf340c269abcf66357526fdc90b94b3e9ff6d347a41f090b7)", - "HashTag(#320)", - "RegularText(2%)", - "RegularText(4d6e72โ€ฆ219298,)", - "RegularText()", - "RegularText(-)", - "RegularText(4d6e72aba0e8a033c973acd7e42f915d5fa1708be7229d477869e91136219298)", - "HashTag(#321)", - "RegularText(2%)", - "RegularText(f75326โ€ฆaf65e0,)", - "RegularText()", - "RegularText(-)", - "RegularText(f7532615471b029a34e41e080b2af4bad2b80f8105c008378d0095991eaf65e0)", - "HashTag(#322)", - "RegularText(2%)", - "RegularText(LiveFreeBTC,)", - "Email(LiveFreeBTC@livefreebtc.org)", - "RegularText(-)", - "RegularText(49f586188679af2b31e8d62ff153baf767fd0c586939f4142ef65606d236ff75)", - "HashTag(#323)", - "RegularText(2%)", - "RegularText(aptx4869,)", - "Email(aptx4869@aptx4869.app)", - "RegularText(-)", - "RegularText(64aaa73189af814977ff5dedbbab022df030f1d7df3e6307aceb1fddb30df847)", - "HashTag(#324)", - "RegularText(2%)", - "RegularText(khalil,)", - "Email(khalil@klouche.com)", - "RegularText(-)", - "RegularText(5a03bdb5448b440428d8459d4afe9b553e705737ef8cd7a0d25569ccead4d6ce)", - "HashTag(#325)", - "RegularText(2%)", - "RegularText(nsec1wnppl0xqw2lysecymwmz3hgxuzk60dgyur6mqtgexln20qp4xv9sugxghg,)", - "Email(nsec@ittybitty.tips)", - "RegularText(-)", - "RegularText(f1ea91eeab7988ed00e3253d5d50c66837433995348d7d97f968a0ceb81e0929)", - "HashTag(#326)", - "RegularText(2%)", - "RegularText(BTC_P2P,)", - "RegularText()", - "RegularText(-)", - "RegularText(ecf468164bd743b75683db3870ce01cb9a1d4b8ec203ed26de50f96255bbc75a)", - "HashTag(#327)", - "RegularText(2%)", - "RegularText(Big)", - "RegularText(FISH,)", - "Email(bigfish@iris.to)", - "RegularText(-)", - "RegularText(963100cf40967a70cdea802c6b4b97956cf8c5e3b09e492b24a847d4c535a794)", - "HashTag(#328)", - "RegularText(2%)", - "RegularText(9e93fbโ€ฆ2483b6,)", - "RegularText()", - "RegularText(-)", - "RegularText(9e93fb0012a6177faddf2fd324fb61eafbe8b142b31c5e89fd85bfafd12483b6)", - "HashTag(#329)", - "RegularText(2%)", - "RegularText(Mynameis,)", - "RegularText()", - "RegularText(-)", - "RegularText(6bec23b4a17da33d0a2f44e258371e869ff124775e8e38b9581dcd49c8d1d4a6)", - "HashTag(#330)", - "RegularText(2%)", - "RegularText(3f2342โ€ฆd689b8,)", - "RegularText()", - "RegularText(-)", - "RegularText(3f23426af245168f8112e441c046ecdb29aca56a6d33d21e276b8ac00bd689b8)", - "HashTag(#331)", - "RegularText(2%)", - "RegularText(865c92โ€ฆ136ced,)", - "RegularText()", - "RegularText(-)", - "RegularText(865c92a207a156a2d48404694a2eed5ceca5c163b7a845b86a6c75e142136ced)", - "HashTag(#332)", - "RegularText(2%)", - "RegularText(95d4d6โ€ฆfe1673,)", - "RegularText()", - "RegularText(-)", - "RegularText(95d4d60e643f283cef8d70ab7a9c09ab5a85924f97e11b22cf99779c4ffe1673)", - "HashTag(#333)", - "RegularText(2%)", - "RegularText(verse,)", - "RegularText()", - "RegularText(-)", - "RegularText(0ff7a93751d37ffcca05579c59ac69053d8d0c6f2c57ed9101ba8758eebc0d6b)", - "HashTag(#334)", - "RegularText(2%)", - "RegularText(oldschool,)", - "Email(oldschool@iris.to)", - "RegularText(-)", - "RegularText(19dba8f974322c7345d3b491925896d19e7f432a4f41223c5daf96e31fae338d)", - "HashTag(#335)", - "RegularText(2%)", - "RegularText(Danton๐Ÿ‡จ๐Ÿ‡ญ,)", - "Email(danton@nostrplebs.com)", - "RegularText(-)", - "RegularText(dbe693bc2d16c52e18e75f2cb76401cb7d74132cc956f7315ea5ebee1adfc966)", - "HashTag(#336)", - "RegularText(2%)", - "RegularText(BitcoinZavior,)", - "Email(bitcoinzavior@nostrplebs.com)", - "RegularText(-)", - "RegularText(c6e86c9b95ef289600800b855b9a6ca42019cc9453937020289d8b3e01dab865)", - "HashTag(#337)", - "RegularText(2%)", - "RegularText(BitcoinSermons,)", - "Email(BitcoinSermons@BitcoinNostr.com)", - "RegularText(-)", - "RegularText(615f40fae8f2e08da81b5c76a0143cb04b4e9e044bf6047efe15c56c7cc1a6b2)", - "HashTag(#338)", - "RegularText(2%)", - "RegularText(skreep,)", - "RegularText()", - "RegularText(-)", - "RegularText(a4992688b449c2bdd6fa9c39a880d7fe27d5f5e3e9fd4c47d65d824588fd660f)", - "HashTag(#339)", - "RegularText(2%)", - "RegularText(db830bโ€ฆ4bb85c,)", - "RegularText()", - "RegularText(-)", - "RegularText(db830b864876a0f3109ae3447e43715711250d53f310092052aabb5bdc4bb85c)", - "HashTag(#340)", - "RegularText(2%)", - "RegularText(UKNW22LINUX,)", - "Email(uknwlinux@plebs.place)", - "RegularText(-)", - "RegularText(ab1ef3f15fc29b3da324eb401122382ceb5ea9c61adaad498192879fd9a5d057)", - "HashTag(#341)", - "RegularText(2%)", - "RegularText(Satoshism,)", - "Email(satoshism@nostrplebs.com)", - "RegularText(-)", - "RegularText(e262ed3a22ad8c478b077ef5d7c56b2c3c7a530519ed696ed2e57c65e147fbcb)", - "HashTag(#342)", - "RegularText(2%)", - "RegularText(William,)", - "RegularText()", - "RegularText(-)", - "RegularText(8c55174d8fc29d4da650b273fdd18ad4dda478faa4b0ea14726d81ac6c7bef48)", - "HashTag(#343)", - "RegularText(2%)", - "RegularText(thebitcoinyogi,)", - "Email(jon@nostrplebs.com)", - "RegularText(-)", - "RegularText(59c2e15ad7bc0b5c97b8438b2763a5c409ff76ab985ab5f1f47c4bcdd25e6e8d)", - "HashTag(#344)", - "RegularText(2%)", - "RegularText(vake,)", - "RegularText()", - "RegularText(-)", - "RegularText(547f45b91c1e6b4137917cde4fa1da867c8cdfe43d0f646c836a622769795a14)", - "HashTag(#345)", - "RegularText(2%)", - "RegularText(hobozakki,)", - "Email(hobozakki@nostrplebs.com)", - "RegularText(-)", - "RegularText(29e31c4103b85fab499132fa71870bd5446de8f7e2ac040ec0372aa61ae22f98)", - "HashTag(#346)", - "RegularText(2%)", - "RegularText(SirGalahodl,)", - "Email(sirgalahodl@satstream.me)", - "RegularText(-)", - "RegularText(25ee676190e2b6145ad8dd137630eca55fc503dde715ce8af4c171815d018797)", - "HashTag(#347)", - "RegularText(2%)", - "RegularText(1f6c76โ€ฆebb9c9,)", - "RegularText()", - "RegularText(-)", - "RegularText(1f6c76ddbab213cdd43db2695b1474605639862302c7cfae35362be8caebb9c9)", - "HashTag(#348)", - "RegularText(2%)", - "RegularText(greencandleit,)", - "RegularText()", - "RegularText(-)", - "RegularText(3d4b358b50d20c3e4d855f273ff06c49bc6b3f6e62c42aed44f278742fd579da)", - "HashTag(#349)", - "RegularText(2%)", - "RegularText(ichigo,)", - "RegularText()", - "RegularText(-)", - "RegularText(477e0b3c0c6029e31562b39650efa8f871d52e3ab09145d72e99b9b74dd384d7)", - "HashTag(#350)", - "RegularText(2%)", - "RegularText(Niko,)", - "RegularText()", - "RegularText(-)", - "RegularText(636fdb4de194bca39ab30ab5793a38b8d15c1b1c0a968d04f7fe14eb1a6a8c42)", - "HashTag(#351)", - "RegularText(2%)", - "RegularText(afa,)", - "Email(victor@lnmarkets.com)", - "RegularText(-)", - "RegularText(8f6945b4726112826ac6abd56ec041c87d8bdc4ec02e86bb388a97481f372b97)", - "HashTag(#352)", - "RegularText(2%)", - "RegularText(BushBrook,)", - "RegularText()", - "RegularText(-)", - "RegularText(a39fd86ed75c654550bf813430877819beb77a3b670e01a9680a84a844db9620)", - "HashTag(#353)", - "RegularText(2%)", - "RegularText(naoise,)", - "RegularText()", - "RegularText(-)", - "RegularText(c4a9caef93e93f484274c04cd981d1de1424902451aca2f5602bd0835fe4393d)", - "HashTag(#354)", - "RegularText(2%)", - "SchemelessUrl(smies.me,)", - "Email(jacksmies@iris.to)", - "RegularText(-)", - "RegularText(cdecbc48e35a351582e3e030fd8cf5d5f44681613d2949353d9c6644d32d451f)", - "HashTag(#355)", - "RegularText(2%)", - "RegularText(Chemaclass,)", - "Email(chemaclass@snort.social)", - "RegularText(-)", - "RegularText(c5d4815c26e18e2c178133004a6ddba9a96a5f7af795a3ab606d11aa1055146a)", - "HashTag(#356)", - "RegularText(2%)", - "RegularText(BTCingularity,)", - "RegularText()", - "RegularText(-)", - "RegularText(aa1f96f685d0ac3e28a52feb87a20399a91afb3ac3137afeb7698dfcc99bc454)", - "HashTag(#357)", - "RegularText(2%)", - "RegularText(the_man,)", - "RegularText()", - "RegularText(-)", - "RegularText(dad77f3814964b5cdcd120a3a8d7b40c6218d413ae6328801b9929ed90123687)", - "HashTag(#358)", - "RegularText(2%)", - "RegularText(jayson,)", - "Email(jayson@tautic.com)", - "RegularText(-)", - "RegularText(7be5d241f3cc10922545e31aeb8d5735be2bc3230480e038c7fd503e7349a2cc)", - "HashTag(#359)", - "RegularText(2%)", - "RegularText(jesterhodl,)", - "Email(jesterhodl@jesterhodl.com)", - "RegularText(-)", - "RegularText(3c285d830bf433135ae61c721b750ce11ae5b2e187712d7a171afa7cda649e50)", - "HashTag(#360)", - "RegularText(2%)", - "RegularText(06d694โ€ฆc3ab96,)", - "RegularText()", - "RegularText(-)", - "RegularText(06d6946fd1ff1fba6ac530e0b5683db4c73cdc11d6c42324246e10f4f2c3ab96)", - "HashTag(#361)", - "RegularText(2%)", - "RegularText(sardin,)", - "RegularText()", - "RegularText(-)", - "RegularText(f26470570bcb67a18a90890dbe02d565eadc6c955912977c64c99d4b9a7fd29f)", - "HashTag(#362)", - "RegularText(2%)", - "RegularText(Bitcoin_Gamer_21,)", - "Email(Bitcoin_Gamer_21@bitcoin-21.org)", - "RegularText(-)", - "RegularText(021df4103ede2cdc32de4058d4bdb29ffcbfd13070f05c4688f6974bd9a67176)", - "HashTag(#363)", - "RegularText(2%)", - "RegularText(water-bot,)", - "Email(water-bot@gourcetools.github.io)", - "RegularText(-)", - "RegularText(000000dd7a2e54c77a521237a516eefb1d41df39047a9c64882d05bc84c9d666)", - "HashTag(#364)", - "RegularText(1%)", - "RegularText(ondorevillager,)", - "RegularText()", - "RegularText(-)", - "RegularText(5d7b460173010efd682c0d7bc8cc36ca9bf7dcc7990288f642c04b8e05713c83)", - "HashTag(#365)", - "RegularText(1%)", - "RegularText(Tomfantasia,)", - "RegularText()", - "RegularText(-)", - "RegularText(d856af932000c292ad723dee490ebcf908a1031b486dea05267ee50b473349b2)", - "HashTag(#366)", - "RegularText(1%)", - "RegularText(W3crypto,)", - "Email(w3crypto@iris.to)", - "RegularText(-)", - "RegularText(d001bca923ab56b1c759fc9471fbe6baadac50aeba7d963155772ac7b6779027)", - "HashTag(#367)", - "RegularText(1%)", - "RegularText(bradjpn,)", - "RegularText()", - "RegularText(-)", - "RegularText(c4da3be8e10fa86128530885d18e455900cccff39d7a24c4a6ac12b0284f62b3)", - "HashTag(#368)", - "RegularText(1%)", - "RegularText(@discretelog,)", - "RegularText()", - "RegularText(-)", - "RegularText(03e4804b4a28c051f43185d6bf5b4643cb3f0d9632c4394b60a2ffad0f852340)", - "HashTag(#369)", - "RegularText(1%)", - "RegularText(makaveli,)", - "Email(makaveli@nostrplebs.com)", - "RegularText(-)", - "RegularText(570469cbc969ea6c7e94c41c6496a2951f52d3399011992bf45f4b2216d99119)", - "HashTag(#370)", - "RegularText(1%)", - "RegularText(JamieAnders,)", - "Email(jamieanders@ln.tips)", - "RegularText(-)", - "RegularText(7601e743ad432d78471ac57178402a57cd3f3a92fb208be7de788af2d6a57669)", - "HashTag(#371)", - "RegularText(1%)", - "RegularText(LightningVentures,)", - "RegularText()", - "RegularText(-)", - "RegularText(37de18e08cdc01ce7ced1808b241ec0b4a69e754d576ce0e08f0cf3375bb0a6b)", - "HashTag(#372)", - "RegularText(1%)", - "RegularText(Colorado)", - "RegularText(Craig,)", - "Email(cball@nostrplebs.com)", - "RegularText(-)", - "RegularText(a2c20d6856545b145bc76cdfaffd04ddad4e58d73b2352dcc5de86aa4ba38e7b)", - "HashTag(#373)", - "RegularText(1%)", - "RegularText(21fadbโ€ฆ3d8f6f,)", - "RegularText()", - "RegularText(-)", - "RegularText(21fadb45755a5f41d1b84ecf4610657dd9336d24419d61efffb947aeec3d8f6f)", - "HashTag(#374)", - "RegularText(1%)", - "RegularText(castaway,)", - "RegularText()", - "RegularText(-)", - "RegularText(0cbde76a61cc539059f7da7b4fb19c0197f9f781674d307b52264cbb0144c739)", - "HashTag(#375)", - "RegularText(1%)", - "RegularText(chames,)", - "RegularText()", - "RegularText(-)", - "RegularText(a721f4370afd51fcbc7e2a685f24a454f14fea84448e1c2aa4a9a94b89f3ea7d)", - "HashTag(#376)", - "RegularText(1%)", - "RegularText(laura,)", - "Email(laura@nostrich.zone)", - "RegularText(-)", - "RegularText(ac2250f83aaa7c4a8503f9c15c0cc11ac992315e5ac3e634541223a8deb6c09c)", - "HashTag(#377)", - "RegularText(1%)", - "RegularText(Kaz,)", - "Email(kaz@reddirtmining.io)", - "RegularText(-)", - "RegularText(826d71153f4938c43b930f90cc3130f33430d1e069d43a2f705f9538450b9369)", - "HashTag(#378)", - "RegularText(1%)", - "RegularText(Verismus,)", - "Email(verismus@nostrplebs.com)", - "RegularText(-)", - "RegularText(9e79aed207461f0d5ebc2c8b94e6875e2a6d5dd15990f8ea3ad2540786d07528)", - "HashTag(#379)", - "RegularText(1%)", - "RegularText(cafc4fโ€ฆ107e85,)", - "RegularText()", - "RegularText(-)", - "RegularText(cafc4fbaa558e466bba6c667fcf14506728ff70975f2817c8e5b6fb062107e85)", - "HashTag(#380)", - "RegularText(1%)", - "RegularText(bitpetro,)", - "Email(bitpetro@nostrplebs.com)", - "RegularText(-)", - "RegularText(22470b963e71fa04e1f330ce55f66ff9783c7a9c4851b903d332a59f2327891e)", - "HashTag(#381)", - "RegularText(1%)", - "RegularText(nossence,)", - "Email(nossence@nossence.xyz)", - "RegularText(-)", - "RegularText(56899e6a55c14771a45a88cb90a802623a0e3211ea1447057e2c9871796ce57c)", - "HashTag(#382)", - "RegularText(1%)", - "RegularText(The)", - "RegularText(Progressive)", - "RegularText(Bitcoiner,)", - "RegularText()", - "RegularText(-)", - "RegularText(4870d5500a121e5187544a3e6e5c2fee1d0a03e1b85073f27edb710b110d6208)", - "HashTag(#383)", - "RegularText(1%)", - "RegularText(orangepillstacker,)", - "RegularText()", - "RegularText(-)", - "RegularText(affe861d3e4c42bb956a35d8f9d2c76a99ba16581f3d0dbf762d807e1de8e234)", - "HashTag(#384)", - "RegularText(1%)", - "RegularText(Nostrdamus,)", - "Email(manbearpig@nostrplebs.com)", - "RegularText(-)", - "RegularText(84a42d3efa48018e187027e2bbdd013285a27d8faf970f83a35691d7e2e1a310)", - "HashTag(#385)", - "RegularText(1%)", - "RegularText(JohnSmith,)", - "Email(johnsmith@nostrplebs.com)", - "RegularText(-)", - "RegularText(7c939a7211f1b818567d10b7e65bb03e2830420acf3d6f4f65a7320e2e66d97e)", - "HashTag(#386)", - "RegularText(1%)", - "RegularText(Matty,)", - "RegularText()", - "RegularText(-)", - "RegularText(1cb599e80e7933a7144bbebfb39168c6ee75a27bacd6d8a67e80c442a32a52a8)", - "HashTag(#387)", - "RegularText(1%)", - "RegularText(epodrulz,)", - "Email(bitcoin@bitcoinedu.com)", - "RegularText(-)", - "RegularText(a249234ba07c832c8ee99915f145c02838245499589a6ab8a7461f2ef3eec748)", - "HashTag(#388)", - "RegularText(1%)", - "RegularText(paul,)", - "RegularText()", - "RegularText(-)", - "RegularText(52b9e1aca3df269710568d1caa051abf40fbdf8c2489afb8d2b7cdb1d1d0ce6f)", - "HashTag(#389)", - "RegularText(1%)", - "RegularText(0ec37aโ€ฆba5855,)", - "RegularText()", - "RegularText(-)", - "RegularText(0ec37a784c894b8c8f96a0ccb6055d4ce7b8420482bc41d00e235723a9ba5855)", - "HashTag(#390)", - "RegularText(1%)", - "RegularText(jor,)", - "Email(knggolf@nostrplebs.com)", - "RegularText(-)", - "RegularText(7c765d407d3a9d5ea117cb8b8699628560787fc084a0c76afaa449bfbd121d84)", - "HashTag(#391)", - "RegularText(1%)", - "RegularText(Nighthaven,)", - "Email(nighthaven@iris.to)", - "RegularText(-)", - "RegularText(510e0096e4e622e9f2877af7e7af979ac2fdf50702b9cd77021658344d1a682c)", - "HashTag(#392)", - "RegularText(1%)", - "RegularText(00f454โ€ฆ929254,)", - "RegularText()", - "RegularText(-)", - "RegularText(00f45459dcd6c6e04706ddafd03a9f52a28833efc04b3ff0a66b89146b929254)", - "HashTag(#393)", - "RegularText(1%)", - "RegularText(XBT_fi,)", - "Email(xbt_fi@iris.to)", - "RegularText(-)", - "RegularText(6e1bee4bdfc34056ffcde2c0685ae6468867aedd0843ed5d0cfcde41f64bfda8)", - "HashTag(#394)", - "RegularText(1%)", - "RegularText(e9f332โ€ฆ6474aa,)", - "RegularText()", - "RegularText(-)", - "RegularText(e9f33272af64080287624176253ed2b468d17cec5f2a3d927a3ee36c356474aa)", - "HashTag(#395)", - "RegularText(1%)", - "RegularText(ulrichard,)", - "RegularText()", - "RegularText(-)", - "RegularText(cd0ea239c10e2dbe12e5171537ff0b8619747bfcd8dcf939f4bceed340b38c87)", - "HashTag(#396)", - "RegularText(1%)", - "RegularText(54ff28โ€ฆd7090d,)", - "RegularText()", - "RegularText(-)", - "RegularText(54ff28f1abbceddea50cf35cac69e5df32b982c3e872d40aa9ec035431d7090d)", - "HashTag(#397)", - "RegularText(1%)", - "RegularText(GeneralCarlosQ17,)", - "Email(gencarlosq17@iris.to)", - "RegularText(-)", - "RegularText(b13cc2d0b7b70ba41c13f09cc78dc6ce7f72049b1fe59a8194a237e23e37216e)", - "HashTag(#398)", - "RegularText(1%)", - "RegularText(BitcoinIslandPH,)", - "RegularText()", - "RegularText(-)", - "RegularText(b4ab403c8215e0606f11be21670126a501d85ea2027b6d15bf4b54c3236d0994)", - "HashTag(#399)", - "RegularText(1%)", - "RegularText(rotciv,)", - "Email(rotciv@plebs.place)", - "RegularText(-)", - "RegularText(b70c9bfb254b6072804212643beb077b6ba941609ed40515d9b10961d7767899)", - "HashTag(#400)", - "RegularText(1%)", - "RegularText(Alfa,)", - "RegularText()", - "RegularText(-)", - "RegularText(0575bc052fed6c729a0ab828efa45da77e28685da91bdfebc7a7640cb0728d12)", - "HashTag(#401)", - "RegularText(1%)", - "RegularText(ben_dewaal,)", - "RegularText()", - "RegularText(-)", - "RegularText(aac02781318dfc8c3d7ed0978ef9a7e8154a6b8ae6c910b3a52b42fd56875002)", - "HashTag(#402)", - "RegularText(1%)", - "RegularText(cguida,)", - "RegularText()", - "RegularText(-)", - "RegularText(2895c330c23f383196c0ef988de6da83b83b4583ed5f9c1edb0a559cecd1f900)", - "HashTag(#403)", - "RegularText(1%)", - "RegularText(nout,)", - "RegularText()", - "RegularText(-)", - "RegularText(52cb4b34775fa781b6a964bda0432dbcdfede7a59bf8dfc279cbff0ad8fb09ff)", - "HashTag(#404)", - "RegularText(1%)", - "RegularText(Merlin,)", - "Email(Merlin@bitcoinnostr.com)", - "RegularText(-)", - "RegularText(76dd32f31619b8e35e9f32e015224b633a0df8be8d5613c25b8838a370407698)", - "HashTag(#405)", - "RegularText(1%)", - "RegularText(millymischiefx,)", - "RegularText()", - "RegularText(-)", - "RegularText(868d9200af6e6fe1604a28d587b30c2712100b0edab76982551d56ebc6ae061f)", - "HashTag(#406)", - "RegularText(1%)", - "RegularText(yegorpetrov(alternative),)", - "Email(yeg0rpetrov@iris.to)", - "RegularText(-)", - "RegularText(2650f1f87e1dc974ffcc7b5813a234f6f1b1c92d56732f7db4fef986c80a31f7)", - "HashTag(#407)", - "RegularText(1%)", - "RegularText(baloo,)", - "Email(baloo@nostrpurple.com)", - "RegularText(-)", - "RegularText(c49f8402ef410fce5a1e5b2e6da06f351804988dd2e0bad18ae17a50fc76c221)", - "HashTag(#408)", - "RegularText(1%)", - "RegularText(jamesgospodyn,)", - "Email(jamesgospodyn@nostr.theorangepillapp.com)", - "RegularText(-)", - "RegularText(11edfa8182cf3d843ef36aa2fa270137d1aee9e4f0cd2add67707c8fc5ff2a0d)", - "HashTag(#409)", - "RegularText(1%)", - "RegularText(Mysterious_Minx,)", - "RegularText()", - "RegularText(-)", - "RegularText(381dbcc7138eab9a71e814c57837c9d623f4036ec0240ef302330684ffc8b38f)", - "HashTag(#410)", - "RegularText(1%)", - "RegularText(878bf5โ€ฆf7cb86,)", - "RegularText()", - "RegularText(-)", - "RegularText(878bf5d63ed5b13d2dac3f463e1bd73d0502bd3462ebf2ea3a0825ca11f7cb86)", - "HashTag(#411)", - "RegularText(1%)", - "RegularText(carl,)", - "Email(carl@armadalabs.studio)", - "RegularText(-)", - "RegularText(cd1197bede3b3c0cdc7412d076228e3f48b5b66e88760f53142e91485d128e07)", - "HashTag(#412)", - "RegularText(1%)", - "RegularText(NIMBUS,)", - "RegularText()", - "RegularText(-)", - "RegularText(c48a8ced6dfcc450056bb069b4007607c68a3e93cf3ae6e62b75bf3509f78178)", - "HashTag(#413)", - "RegularText(1%)", - "RegularText(btcportal,)", - "Email(btcportal@nostrplebs.com)", - "RegularText(-)", - "RegularText(9fc1e0ef750dba8cdb3b360b8a00ccad6dcef6b7ad7644f628e952ed8b7eebfb)", - "HashTag(#414)", - "RegularText(1%)", - "RegularText(9652baโ€ฆccd3f1,)", - "RegularText()", - "RegularText(-)", - "RegularText(9652ba74b6981f69a3ffad088aa0f16c8af7fe38a72e5d82176878acdcccd3f1)", - "HashTag(#415)", - "RegularText(1%)", - "RegularText(mjbonham,)", - "Email(mjb@nostrplebs.com)", - "RegularText(-)", - "RegularText(802afdddebfb60a516b39d649ea35401749622e394f85a687674907c4588dc7a)", - "HashTag(#416)", - "RegularText(1%)", - "RegularText(โŒœJanโŒ,)", - "RegularText()", - "RegularText(-)", - "RegularText(fca142a3a900fed71d831aa0aa9c21bb86a5917a9e1183659857b684f25ae1ce)", - "HashTag(#417)", - "RegularText(1%)", - "RegularText(DontTraceMeBruh,)", - "RegularText()", - "RegularText(-)", - "RegularText(3fef59378dce7726d3ef35d4699f57becf76d3be0a13187677126a66c9ade3b8)", - "HashTag(#418)", - "RegularText(1%)", - "RegularText(9a73c0โ€ฆ1707f2,)", - "RegularText()", - "RegularText(-)", - "RegularText(9a73c0ecd5049ae38b50d0d9eaaabd49390cdd08c3d3d666d0d8476c411707f2)", - "HashTag(#419)", - "RegularText(1%)", - "RegularText(esbewolkt,)", - "Email(esbewolkt@nostr.fan)", - "RegularText(-)", - "RegularText(50ea483ddffeeed3231c6f41fddfe8fb71f891fa736de46e3e06f748bbdeb307)", - "HashTag(#420)", - "RegularText(1%)", - "RegularText(morningstar,)", - "RegularText()", - "RegularText(-)", - "RegularText(82671c61fa007b0f70496dec2420238efd3df2f76cdaf6c1f810def8ce95ba45)", - "HashTag(#421)", - "RegularText(1%)", - "RegularText(Sweedgraffixx,)", - "RegularText()", - "RegularText(-)", - "RegularText(ee5f4a67cb434317dd7b931d9d23cb2978ab728a008e4c4dcca9cc781d3ae576)", - "HashTag(#422)", - "RegularText(1%)", - "RegularText(878492โ€ฆ165b4f,)", - "RegularText()", - "RegularText(-)", - "RegularText(878492807168be8dfbae71d721a9b7f6833a9928fcf9acc3274dfdb113165b4f)", - "HashTag(#423)", - "RegularText(1%)", - "RegularText(koukos,)", - "Email(koukos@iris.to)", - "RegularText(-)", - "RegularText(4260122b8a141e888413082dea2d93568488bae4726358e9e6b7da741852dfc8)", - "HashTag(#424)", - "RegularText(1%)", - "RegularText(nopara73,)", - "RegularText()", - "RegularText(-)", - "RegularText(001892e9b48b430d7e37c27051ff7bf414cbc52a7f48f451d857409ce7839dde)", - "HashTag(#425)", - "RegularText(1%)", - "RegularText(BeโšกBANK,)", - "RegularText()", - "RegularText(-)", - "RegularText(fbfb3855d50c37866af00484a6476680ae1e2ff04ceb9dd8936465f70d39150b)", - "HashTag(#426)", - "RegularText(1%)", - "RegularText(davekrock,)", - "Email(davekrock@NostrVerified.com)", - "RegularText(-)", - "RegularText(e26b5f261cb29354def8a8ba6af49b137e3144388a81ef78eed8e77cfb18fd44)", - "HashTag(#427)", - "RegularText(1%)", - "RegularText(BitcoinLoveLife,)", - "Email(Bitcoinlovelife@BitcoinNostr.com)", - "RegularText(-)", - "RegularText(3c08d854ef6c86b1dc11159fdabc09209eaeba01790ce96690c55787daf3c415)", - "HashTag(#428)", - "RegularText(1%)", - "RegularText(Steam,)", - "RegularText()", - "RegularText(-)", - "RegularText(111a1ae50a7e30a465126b0ab10c3eac6ddaa3cca016a4117470e6715a2dfdef)", - "HashTag(#429)", - "RegularText(1%)", - "RegularText(xolag,)", - "Email(xolagl2@getalby.com)", - "RegularText(-)", - "RegularText(fb64b9c3386a9ababaf8c4f80b47c071c4a38f7b8acdc4dafb009875a64f8c37)", - "HashTag(#430)", - "RegularText(1%)", - "RegularText(relay9may,)", - "RegularText()", - "RegularText(-)", - "RegularText(1e7fd2177d20c97f326cda699551f085b8e7f93650b48b6e87a0bebcdfeebc8b)", - "HashTag(#431)", - "RegularText(1%)", - "RegularText(f2c817โ€ฆ8a2f3b,)", - "RegularText()", - "RegularText(-)", - "RegularText(f2c817a3bbf07517a38beac228a12e3460d18f1ec2ed928d2e6d2e67308a2f3b)", - "HashTag(#432)", - "RegularText(1%)", - "RegularText(remoney,)", - "Email(remoney@nostrplebs.com)", - "RegularText(-)", - "RegularText(3939a929101b17f4782171b5e0e49996fbe2215b226bd847bd76be3c2de80e9a)", - "HashTag(#433)", - "RegularText(1%)", - "RegularText(387eb9โ€ฆa6f87f,)", - "RegularText()", - "RegularText(-)", - "RegularText(387eb9a5c4f43e40e6abd1f6fe953477464ae5830d104e325f362209c2a6f87f)", - "HashTag(#434)", - "RegularText(1%)", - "RegularText(846b76โ€ฆ539eca,)", - "RegularText()", - "RegularText(-)", - "RegularText(846b763b1234c5652f1e327e59570dcb6535d2d20589c67c2a9a90b323539eca)", - "HashTag(#435)", - "RegularText(1%)", - "RegularText(Shawn)", - "RegularText(C.,)", - "RegularText()", - "RegularText(-)", - "RegularText(83ea7cb5a3ab517f24eb2948b23f39466dd5f200fd4e6951fed43ba34e9a4a83)", - "HashTag(#436)", - "RegularText(1%)", - "RegularText(roberto,)", - "Email(roberto@bitcoiner.chat)", - "RegularText(-)", - "RegularText(319a588a77cd798b358724234b534bff3f3c294b4f6512bde94d070da93237c9)", - "HashTag(#437)", - "RegularText(1%)", - "RegularText(LazyNinja,)", - "Email(cryptolazyninja@stacker.news)", - "RegularText(-)", - "RegularText(ff444d454bc6ba2c16abdfd843124e6ad494297cf424fa81fb0604a24ee188e2)", - "HashTag(#438)", - "RegularText(1%)", - "RegularText(e5ae7bโ€ฆc8b2ef,)", - "RegularText()", - "RegularText(-)", - "RegularText(e5ae7b9cc5177675654400db194878601ee8ff5c355acb85daa50f7551c8b2ef)", - "HashTag(#439)", - "RegularText(1%)", - "RegularText(kimymt,)", - "Email(kimymt@getalby.com)", - "RegularText(-)", - "RegularText(3009318aa9544a2caf401ece529fd772e26cdd7e60349ec175423b302dafd521)", - "HashTag(#440)", - "RegularText(1%)", - "RegularText(z_hq,)", - "RegularText()", - "RegularText(-)", - "RegularText(215e2d416a8663d5b2e44f30d6c46750db7254cdbd2cf87fea4c1549d97486d4)", - "HashTag(#441)", - "RegularText(1%)", - "RegularText(Reza,)", - "RegularText()", - "RegularText(-)", - "RegularText(e7c0d1e42929695b972e90e88fb2210b3567af45206aac51fff85ba011f79093)", - "HashTag(#442)", - "RegularText(1%)", - "RegularText(benderlogic,)", - "Email(benderlogic@rogue.earth)", - "RegularText(-)", - "RegularText(d656ffcaf523f15899db0ea3289d04d00528714651d624814695cabe9cb34114)", - "HashTag(#443)", - "RegularText(1%)", - "RegularText(maestro,)", - "Email(MAESTRO@BitcoinNostr.com)", - "RegularText(-)", - "RegularText(8c3e08bbc47297021be7e6e2c59dab237fab9056b3a5302a8cd2fc2959037466)", - "HashTag(#444)", - "RegularText(1%)", - "RegularText(travis,)", - "Email(travis@west.report)", - "RegularText(-)", - "RegularText(3dc0b75592823507f5f625f889d36ba2607487550b4f38335a603eda010f2bc2)", - "HashTag(#445)", - "RegularText(1%)", - "RegularText(Coffee)", - "RegularText(Lover,)", - "Email(coffeelover@nostrplebs.com)", - "RegularText(-)", - "RegularText(9ecbaa6dc307291c3cf205c8a79ad8174411874cf244ca06f58a5a73e491222c)", - "HashTag(#446)", - "RegularText(1%)", - "RegularText(shadowysuperstore,)", - "Email(shadowysuperstore@shadowysuperstore.com)", - "RegularText(-)", - "RegularText(7abbf3067536c6b70fbc8ac1965e485dce6ebb3d5c125aac248bc0fe906c6818)", - "HashTag(#447)", - "RegularText(1%)", - "RegularText(bhaskar,)", - "RegularText()", - "RegularText(-)", - "RegularText(5beb5d04939db36498e0736003771294317c1c018953d18433276a042bf9a39d)", - "HashTag(#448)", - "RegularText(1%)", - "RegularText(kylum)", - "RegularText(๐ŸŸฃ,)", - "RegularText()", - "RegularText(-)", - "RegularText(e651489d08a27970aac55b222b8a3ea5f3c00419f2976a3cf4006f3add2b6f3c)", - "HashTag(#449)", - "RegularText(1%)", - "RegularText(็‰น็ซ‹็‹ฌ่กŒ็š„ๆŽๅ‘˜ๅค–,)", - "Email(npub1wg2dsjnh0g7phheq23v288k0mj8x75fffmq7rghtkhv53027hnassf4w8t@nost.vip)", - "RegularText(-)", - "RegularText(7214d84a777a3c1bdf205458a39ecfdc8e6f51294ec1e1a2ebb5d948bd5ebcfb)", - "HashTag(#450)", - "RegularText(1%)", - "RegularText(eynhaender,)", - "Email(eynhaender@nostrplebs.com)", - "RegularText(-)", - "RegularText(a21babb54929f10164ca8f8fcca5138d25a892c32fabc8df7d732b8b52b68d82)", - "HashTag(#451)", - "RegularText(1%)", - "RegularText(8340fdโ€ฆ8c7a30,)", - "RegularText()", - "RegularText(-)", - "RegularText(8340fd16fb4414765af8f59192ed68814920e7d33522709de2457490c28c7a30)", - "HashTag(#452)", - "RegularText(1%)", - "RegularText(B1ackSwan,)", - "Email(b1ackswan@nostrplebs.com)", - "RegularText(-)", - "RegularText(1f695a6883cef577dcebf9c60041111772a64e3490cb299c3b97fc81ad3901f4)", - "HashTag(#453)", - "RegularText(1%)", - "RegularText(91dac4โ€ฆ599398,)", - "RegularText()", - "RegularText(-)", - "RegularText(91dac44e3f9d0e3b839aaf7fd81e6c19cf2ce02356fca5096af9e92f58599398)", - "HashTag(#454)", - "RegularText(1%)", - "RegularText(356e99โ€ฆfc3ba8,)", - "RegularText()", - "RegularText(-)", - "RegularText(356e99a0f75e973c0512873cbdce0385df39712653020af825556ceb4afc3ba8)", - "HashTag(#455)", - "RegularText(1%)", - "RegularText(mcdean,)", - "RegularText()", - "RegularText(-)", - "RegularText(54def063abe1657a22cc886eaba75f6636845c601efe9ad56709b4cb3dcc62f1)", - "HashTag(#456)", - "RegularText(1%)", - "RegularText(mrbitcoin,)", - "Email(mrbitc0in@nostrplebs.com)", - "RegularText(-)", - "RegularText(da41332116804e9c4396f6dbb77ec9ad338197993e9d8af18f332e53dcc1bfeb)", - "HashTag(#457)", - "RegularText(1%)", - "RegularText(Jedi,)", - "Email(jedi@nostrplebs.com)", - "RegularText(-)", - "RegularText(246498aa79542482499086f9ab0134750a23047dad0cca38b696750f9ed8072c)", - "HashTag(#458)", - "RegularText(1%)", - "RegularText(CloudNull,)", - "Email(cloudnull@nostrplebs.com)", - "RegularText(-)", - "RegularText(5f53baca8cb88a18320a032957bf0b6f8dc8b33db007310b0e2f573edf2703a3)", - "HashTag(#459)", - "RegularText(1%)", - "RegularText(Mrwh0,)", - "Email(Mrwh0@Mrwh0.github.io)", - "RegularText(-)", - "RegularText(d8dd77e3dff24bd8c2da9b4c4fb321f5f99e8713bad40dd748ab59656b5ed27d)", - "HashTag(#460)", - "RegularText(1%)", - "RegularText(shinohai,)", - "Email(shinohai@iris.to)", - "RegularText(-)", - "RegularText(4bc7982c4ee4078b2ada5340ae673f18d3b6a664b1f97e8d6799e6074cb5c39d)", - "HashTag(#461)", - "RegularText(1%)", - "RegularText(awoi,)", - "Email(awoi@iris.to)", - "RegularText(-)", - "RegularText(edc083016d344679566ae8205b362530ecbafc6e064e224a0c2df1850cecfb4a)", - "HashTag(#462)", - "RegularText(1%)", - "RegularText(TheShopRat,)", - "RegularText()", - "RegularText(-)", - "RegularText(8362e77d9fd268720a15840af33fd9ab5cdf13fabc66f0910111580960cd297a)", - "HashTag(#463)", - "RegularText(1%)", - "RegularText(Dajjal,)", - "RegularText()", - "RegularText(-)", - "RegularText(614aee83d7eaffc7bc6bbf02feda0cc53e7f97eeceac08a897c4cea3c023b804)", - "HashTag(#464)", - "RegularText(1%)", - "RegularText(felipe,)", - "RegularText()", - "RegularText(-)", - "RegularText(0ee8894f1f663fd76b682c16e6a92db0fe14ada98db35b4a4cfa5f9068be0b3a)", - "HashTag(#465)", - "RegularText(1%)", - "RegularText(crypt0-j3sus,)", - "RegularText()", - "RegularText(-)", - "RegularText(9a7b7cbe37b2caa703062c51b207eb6ec4c42d06bfa909d979aa2d5005ac3d65)", - "HashTag(#466)", - "RegularText(1%)", - "RegularText(Just)", - "RegularText(J,)", - "Email(jcope101@nostrplebs.com)", - "RegularText(-)", - "RegularText(5f6f376733b1a8682a0f330e07b6a6064d738fdd8159db6c8df44c6c9419ff88)", - "HashTag(#467)", - "RegularText(1%)", - "RegularText(mmasnick,)", - "RegularText()", - "RegularText(-)", - "RegularText(4d53de27a24feb84d6383962e350219fc09e572c22a17c542545a69cd35b067f)", - "HashTag(#468)", - "RegularText(1%)", - "RegularText(Murmur,)", - "Email(murmur@nostrplebs.com)", - "RegularText(-)", - "RegularText(f7e84b92a5457546894daedaff9abd66f3d289f92435d6ac068a33cb170b01a4)", - "HashTag(#469)", - "RegularText(1%)", - "RegularText(JD,)", - "RegularText()", - "RegularText(-)", - "RegularText(1a9ba80629e2f8f77340ac13e67fdb4fcc66f4bb4124f9beff6a8c75e4ce29b0)", - "HashTag(#470)", - "RegularText(1%)", - "RegularText(dario,)", - "Email(dario@nostrplebs.com)", - "RegularText(-)", - "RegularText(d9987652d3cbb2c0fa39b6305cc0f2d03ca987afc1e56bc97a81c79e138152a8)", - "HashTag(#471)", - "RegularText(1%)", - "RegularText(leonwankum,)", - "RegularText(@leonawankum@BitcoinNostr.com)", - "RegularText()", - "RegularText(-)", - "RegularText(652d58acafa105af8475c0fe8029a52e7ddbc337b2bd9c98bb17a111dc4cde60)", - "HashTag(#472)", - "RegularText(1%)", - "RegularText(phil,)", - "Email(phil@iris.to)", - "RegularText(-)", - "RegularText(8352b55a828a60bb0e86b0ac9ef1928999ebe636c905dcbe0cd3c0f95c61b83b)", - "HashTag(#473)", - "RegularText(1%)", - "RegularText(hkmccullough,)", - "Email(thatirdude@nostrplebs.com)", - "RegularText(-)", - "RegularText(836059a05aeb8498dd53a0d422e04aced6b4b71eb3621d312626c46715d259d8)", - "HashTag(#474)", - "RegularText(1%)", - "RegularText(BitBox,)", - "RegularText()", - "RegularText(-)", - "RegularText(5a3de28ffd09d7506cff0a2672dbdb1f836307bcff0217cc144f48e19eea3fff)", - "HashTag(#475)", - "RegularText(1%)", - "RegularText(5eff6cโ€ฆ60bd07,)", - "RegularText()", - "RegularText(-)", - "RegularText(5eff6c1205c9db582863978b5b2e9c9aa73a57e6c1df526fddc2b9996060bd07)", - "HashTag(#476)", - "RegularText(1%)", - "RegularText(nobody,)", - "RegularText()", - "RegularText(-)", - "RegularText(2e472c6d072c0bcc28f1b260e0fc309f1f919667d238f4e703f8f1db0f0eb424)", - "HashTag(#477)", - "RegularText(1%)", - "RegularText(K_hole,)", - "Email(K_hole@ketamine.com)", - "RegularText(-)", - "RegularText(5ac74532e23b7573f8f6f3248fe5174c0b7230aec0b653c0ec8f11d540209fd7)", - "HashTag(#478)", - "RegularText(1%)", - "RegularText(bitcoinIllustrated,)", - "RegularText()", - "RegularText(-)", - "RegularText(90fb6b9607bba40686fe70aad74a07e5af96d152778f3a09fcda5967dcb0daba)", - "HashTag(#479)", - "RegularText(1%)", - "RegularText(kingfisher,)", - "RegularText()", - "RegularText(-)", - "RegularText(33d4c61d7354e1d5872e26218eda73170646d12a8e7b9cb6d3069a7058ebabfd)", - "HashTag(#480)", - "RegularText(1%)", - "RegularText(cfc11eโ€ฆb4f6e4,)", - "RegularText()", - "RegularText(-)", - "RegularText(cfc11ef4b31e2ab18261a71b79097c60199f532605a0c3aa73ad36acc6b4f6e4)", - "HashTag(#481)", - "RegularText(1%)", - "RegularText(d06848โ€ฆ2f86b3,)", - "RegularText()", - "RegularText(-)", - "RegularText(d06848a9ea53f9e9c15cafaf41b1729d6d7b84083cfbac2c76a0506dd72f86b3)", - "HashTag(#482)", - "RegularText(1%)", - "RegularText(nostrceo,)", - "RegularText()", - "RegularText(-)", - "RegularText(3159e1a148ca235cb55365a2ffde608b17e84c4c3bff6ed309f3e320307d5ab3)", - "HashTag(#483)", - "RegularText(1%)", - "RegularText(Lokuyow2,)", - "Email(2@lokuyow.github.io)", - "RegularText(-)", - "RegularText(f5f02030cb4b22ed15c3d7cc35ae616e6ce6bb3fa537f6e9e91aaa274b9cd716)", - "HashTag(#484)", - "RegularText(1%)", - "RegularText(fatushi,)", - "RegularText()", - "RegularText(-)", - "RegularText(49a458319060806221990e90e6bf2b1654201f08a40828d1a5d215a85f449df0)", - "HashTag(#485)", - "RegularText(1%)", - "RegularText(Omnia,)", - "RegularText()", - "RegularText(-)", - "RegularText(026d2251aa211684ef63e7a28e21c611c087bb3131a9c90b11dff6c16d68ce77)", - "HashTag(#486)", - "RegularText(1%)", - "RegularText(joey,)", - "RegularText()", - "RegularText(-)", - "RegularText(5f8a5bbf8d26104547a3942e82d7a5159554b3a5a3bc1275c47674b5e8c4c1d7)", - "HashTag(#487)", - "RegularText(1%)", - "RegularText(Hazey,)", - "Email(hazey@iris.to)", - "RegularText(-)", - "RegularText(800e0fe3d8638ce3f75a56ed865df9d96fc9d9cd2f75550df0d7f5c1d8468b0b)", - "HashTag(#488)", - "RegularText(1%)", - "RegularText(Milad)", - "RegularText(Younis,)", - "RegularText()", - "RegularText(-)", - "RegularText(64c24e0991f9bb6f59f9da486ba29242bc562b09ce051882f7b3bcc7fd055227)", - "HashTag(#489)", - "RegularText(1%)", - "RegularText(jlgalley,)", - "RegularText()", - "RegularText(-)", - "RegularText(920535dd1487975ccc75ed82b7b4753260ec4041dcf9ce24657623164f6586e3)", - "HashTag(#490)", - "RegularText(1%)", - "RegularText(paulgallo28,)", - "RegularText()", - "RegularText(-)", - "RegularText(690af9eed15cc3a7439c39b228bf194da134f75d64f40114a41d77bff6a60699)", - "HashTag(#491)", - "RegularText(1%)", - "RegularText(HeineNon,)", - "Email(HeineNon@tomottodx.github.io)", - "RegularText(-)", - "RegularText(64c66c231ea1c25ebd66b14fe4a0b1b39a6928d6824ad43e035f54aa667bc650)", - "HashTag(#492)", - "RegularText(1%)", - "RegularText(a9b9adโ€ฆ2b9f4c,)", - "RegularText()", - "RegularText(-)", - "RegularText(a9b9ad000e2ada08326bbcc1836effcdfa4e64b9c937e406fe5912dc562b9f4c)", - "HashTag(#493)", - "RegularText(1%)", - "RegularText(legxxi,)", - "RegularText()", - "RegularText(-)", - "RegularText(8476d0dcdb53f1cc67efc8d33f40104394da2d33e61369a8a8ade288036977c6)", - "HashTag(#494)", - "RegularText(1%)", - "RegularText(99f1b7โ€ฆ559c31,)", - "RegularText()", - "RegularText(-)", - "RegularText(99f1b7b39201d0e142f9ec3c8101b6be0eee8a389d16d53667ca4f57b1559c31)", - "HashTag(#495)", - "RegularText(1%)", - "RegularText(mbz,)", - "RegularText()", - "RegularText(-)", - "RegularText(e5195850d4fed08183f0b274ca30777094daad67be235a5cd15548b9b0341031)", - "HashTag(#496)", - "RegularText(1%)", - "RegularText(Titan,)", - "Email(titan@nostrplebs.com)", - "RegularText(-)", - "RegularText(672b1637bd65b6206c7a603158c2ecee15599648e10dd15a82f2fcb4e47735bf)", - "HashTag(#497)", - "RegularText(1%)", - "RegularText(Highlandhodl,)", - "RegularText()", - "RegularText(-)", - "RegularText(f0c74190cd05d85d843cdc5f355afe0fbac6d30d18da91243d6cae30a69713f7)", - "HashTag(#498)", - "RegularText(1%)", - "RegularText(CodeWarrior,)", - "RegularText()", - "RegularText(-)", - "RegularText(21a7014db2ba17acc8bbb9496645084866b46e1ba0062a80513afda405450183)", - "HashTag(#499)", - "RegularText(1%)", - "SchemelessUrl(baller.hodl,)", - "RegularText()", - "RegularText(-)", - "RegularText(d8150dc0631f834a004f231f0747d5ec8409b1a9214d246f675dfef39807a224)", - "HashTag(#500)", - "RegularText(1%)", - "RegularText(Now)", - "RegularText(Playing)", - "RegularText(on)", - "RegularText(GMโ‚ฟ,)", - "RegularText()", - "RegularText(-)", - "RegularText(9c6907de72e59daf5272103a34649bf7ca01050a68f402955520fc53dba9730d)", - "RegularText()", - "RegularText(Inspector monitor)", - "RegularText()", - "RegularText(New events inspected today: 720.71K (4.85GB))", - "RegularText(Average events inspected per second: 8.34)", - "RegularText(Uptime: Server 99.93%, NostrInspector: 99.93%)", - "RegularText(Spam estimate: )", - "RegularText(74.12 %)", - "RegularText()", - "RegularText(About the NostrInspector Report)", - "RegularText()", - "RegularText(โœ… The 24 Hour NostrInspector Report is generated by listening for new events on the top relays using the Nostr Protocol. The statistics report that )", - "RegularText(it generates includes de data layer as well as the social layer.)", - "RegularText(๐Ÿ’œ To support this free effort share, like, comment or zap.)", - "RegularText(๐Ÿซ‚ Thank you ๐Ÿ™ )", - "RegularText()", - "RegularText(๐Ÿ•ต๏ธ @nostrin \"The Nostr Inspector\" )", - "Bech(npub17m7f7q08k4x746s2v45eyvwppck32dcahw7uj2mu5txuswldgqkqw9zms7)" - ) + @Test + fun testShortTextToParse() { + val state = RichTextParser().parseText("Hi, how are you doing? ", EmptyTagList) + Assert.assertTrue(state.urlSet.isEmpty()) + Assert.assertTrue(state.imagesForPager.isEmpty()) + Assert.assertTrue(state.imageList.isEmpty()) + Assert.assertTrue(state.customEmoji.isEmpty()) + Assert.assertEquals( + "Hi, how are you doing? ", + state.paragraphs.firstOrNull()?.words?.firstOrNull()?.segmentText, + ) + } - state.paragraphs.map { it.words }.flatten().forEachIndexed { index, seg -> - Assert.assertEquals( - expectedResult[index], - "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})" - ) - } + @Test + fun testShortNewLinesTextToParse() { + val state = RichTextParser().parseText("\nHi, \nhow\n\n\n are you doing? \n", EmptyTagList) + Assert.assertTrue(state.urlSet.isEmpty()) + Assert.assertTrue(state.imagesForPager.isEmpty()) + Assert.assertTrue(state.imageList.isEmpty()) + Assert.assertTrue(state.customEmoji.isEmpty()) + Assert.assertEquals( + "\nHi, \nhow\n\n\n are you doing? \n", + state.paragraphs.joinToString("\n") { it.words.joinToString(" ") { it.segmentText } }, + ) + } - Assert.assertTrue(state.imagesForPager.isEmpty()) - Assert.assertTrue(state.imageList.isEmpty()) - Assert.assertTrue(state.customEmoji.isEmpty()) - Assert.assertEquals(651, state.paragraphs.size) - } + @Test + fun testMultiLine() { + val text = + """ + Did you know you can embed #Nostr live streams into #Nostr long-form posts? Sounds like an obvious thing, but it's only supported by nostr:npub1048qg5p6kfnpth2l98kq3dffg097tutm4npsz2exygx25ge2k9xqf5x3nf at the moment. - @Test - fun testShortTextToParse() { - val state = RichTextParser().parseText("Hi, how are you doing? ", EmptyTagList) - Assert.assertTrue(state.urlSet.isEmpty()) - Assert.assertTrue(state.imagesForPager.isEmpty()) - Assert.assertTrue(state.imageList.isEmpty()) - Assert.assertTrue(state.customEmoji.isEmpty()) - Assert.assertEquals("Hi, how are you doing? ", state.paragraphs.firstOrNull()?.words?.firstOrNull()?.segmentText) - } + See how it can be done here: https://lnshort.it/live-stream-embeds/ - @Test - fun testShortNewLinesTextToParse() { - val state = RichTextParser().parseText("\nHi, \nhow\n\n\n are you doing? \n", EmptyTagList) - Assert.assertTrue(state.urlSet.isEmpty()) - Assert.assertTrue(state.imagesForPager.isEmpty()) - Assert.assertTrue(state.imageList.isEmpty()) - Assert.assertTrue(state.customEmoji.isEmpty()) + https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg + """ + .trimIndent() + + val state = RichTextParser().parseText(text, EmptyTagList) + Assert.assertEquals("https://lnshort.it/live-stream-embeds/", state.urlSet.firstOrNull()) + Assert.assertEquals( + "https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg", + state.imagesForPager.keys.firstOrNull(), + ) + Assert.assertEquals( + "https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg", + state.imageList.firstOrNull()?.url, + ) + Assert.assertTrue(state.customEmoji.isEmpty()) + + printStateForDebug(state) + + val expectedResult = + listOf( + "RegularText(Did)", + "RegularText(you)", + "RegularText(know)", + "RegularText(you)", + "RegularText(can)", + "RegularText(embed)", + "HashTag(#Nostr)", + "RegularText(live)", + "RegularText(streams)", + "RegularText(into)", + "HashTag(#Nostr)", + "RegularText(long-form)", + "RegularText(posts?)", + "RegularText(Sounds)", + "RegularText(like)", + "RegularText(an)", + "RegularText(obvious)", + "RegularText(thing,)", + "RegularText(but)", + "RegularText(it's)", + "RegularText(only)", + "RegularText(supported)", + "RegularText(by)", + "Bech(nostr:npub1048qg5p6kfnpth2l98kq3dffg097tutm4npsz2exygx25ge2k9xqf5x3nf)", + "RegularText(at)", + "RegularText(the)", + "RegularText(moment.)", + "RegularText()", + "RegularText(See)", + "RegularText(how)", + "RegularText(it)", + "RegularText(can)", + "RegularText(be)", + "RegularText(done)", + "RegularText(here:)", + "Link(https://lnshort.it/live-stream-embeds/)", + "RegularText()", + "Image(https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg)", + ) + + state.paragraphs + .map { it.words } + .flatten() + .forEachIndexed { index, seg -> Assert.assertEquals( - "\nHi, \nhow\n\n\n are you doing? \n", - state.paragraphs.joinToString("\n") { it.words.joinToString(" ") { it.segmentText } } + expectedResult[index], + "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", ) - } + } + } - @Test - fun testMultiLine() { - val text = """Did you know you can embed #Nostr live streams into #Nostr long-form posts? Sounds like an obvious thing, but it's only supported by nostr:npub1048qg5p6kfnpth2l98kq3dffg097tutm4npsz2exygx25ge2k9xqf5x3nf at the moment. + @Test + fun testNewLineAfterImage() { + val text = + "Thatโ€™s it ! Thatโ€™s the #note https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg " -See how it can be done here: https://lnshort.it/live-stream-embeds/ + val state = RichTextParser().parseText(text, EmptyTagList) -https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg - """.trimIndent() + printStateForDebug(state) - val state = RichTextParser().parseText(text, EmptyTagList) - Assert.assertEquals("https://lnshort.it/live-stream-embeds/", state.urlSet.firstOrNull()) - Assert.assertEquals("https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg", state.imagesForPager.keys.firstOrNull()) - Assert.assertEquals("https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg", state.imageList.firstOrNull()?.url) - Assert.assertTrue(state.customEmoji.isEmpty()) + val expectedResult = + listOf( + "RegularText(Thatโ€™s)", + "RegularText(it)", + "RegularText(!)", + "RegularText(Thatโ€™s)", + "RegularText(the)", + "HashTag(#note)", + "Image(https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg)", + ) - printStateForDebug(state) - - val expectedResult = listOf( - "RegularText(Did)", - "RegularText(you)", - "RegularText(know)", - "RegularText(you)", - "RegularText(can)", - "RegularText(embed)", - "HashTag(#Nostr)", - "RegularText(live)", - "RegularText(streams)", - "RegularText(into)", - "HashTag(#Nostr)", - "RegularText(long-form)", - "RegularText(posts?)", - "RegularText(Sounds)", - "RegularText(like)", - "RegularText(an)", - "RegularText(obvious)", - "RegularText(thing,)", - "RegularText(but)", - "RegularText(it's)", - "RegularText(only)", - "RegularText(supported)", - "RegularText(by)", - "Bech(nostr:npub1048qg5p6kfnpth2l98kq3dffg097tutm4npsz2exygx25ge2k9xqf5x3nf)", - "RegularText(at)", - "RegularText(the)", - "RegularText(moment.)", - "RegularText()", - "RegularText(See)", - "RegularText(how)", - "RegularText(it)", - "RegularText(can)", - "RegularText(be)", - "RegularText(done)", - "RegularText(here:)", - "Link(https://lnshort.it/live-stream-embeds/)", - "RegularText()", - "Image(https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg)" + state.paragraphs + .map { it.words } + .flatten() + .forEachIndexed { index, seg -> + Assert.assertEquals( + expectedResult[index], + "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", ) + } + } - state.paragraphs.map { it.words }.flatten().forEachIndexed { index, seg -> - Assert.assertEquals( - expectedResult[index], - "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})" - ) - } - } + @Test + fun testSapceAfterImage() { + val text = + "Thatโ€™s it! https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg Thatโ€™s the #note" - @Test - fun testNewLineAfterImage() { - val text = "Thatโ€™s it ! Thatโ€™s the #note https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg " + val state = RichTextParser().parseText(text, EmptyTagList) - val state = RichTextParser().parseText(text, EmptyTagList) + printStateForDebug(state) - printStateForDebug(state) + val expectedResult = + listOf( + "RegularText(Thatโ€™s)", + "RegularText(it!)", + "Image(https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg)", + "RegularText(Thatโ€™s)", + "RegularText(the)", + "HashTag(#note)", + ) - val expectedResult = listOf( - "RegularText(Thatโ€™s)", - "RegularText(it)", - "RegularText(!)", - "RegularText(Thatโ€™s)", - "RegularText(the)", - "HashTag(#note)", - "Image(https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg)" + state.paragraphs + .map { it.words } + .flatten() + .forEachIndexed { index, seg -> + Assert.assertEquals( + expectedResult[index], + "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", ) + } + } - state.paragraphs.map { it.words }.flatten().forEachIndexed { index, seg -> - Assert.assertEquals( - expectedResult[index], - "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})" - ) - } - } + @Test + fun testUrlsEndingInPeriod() { + val text = "Thatโ€™s it! http://vitorpamplona.com/. Thatโ€™s the note" - @Test - fun testSapceAfterImage() { - val text = "Thatโ€™s it! https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg Thatโ€™s the #note" + val state = RichTextParser().parseText(text, EmptyTagList) - val state = RichTextParser().parseText(text, EmptyTagList) + printStateForDebug(state) - printStateForDebug(state) + val expectedResult = + listOf( + "RegularText(Thatโ€™s)", + "RegularText(it!)", + "Link(http://vitorpamplona.com/.)", + "RegularText(Thatโ€™s)", + "RegularText(the)", + "RegularText(note)", + ) - val expectedResult = listOf( - "RegularText(Thatโ€™s)", - "RegularText(it!)", - "Image(https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg)", - "RegularText(Thatโ€™s)", - "RegularText(the)", - "HashTag(#note)" + state.paragraphs + .map { it.words } + .flatten() + .forEachIndexed { index, seg -> + Assert.assertEquals( + expectedResult[index], + "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", ) + } + } - state.paragraphs.map { it.words }.flatten().forEachIndexed { index, seg -> - Assert.assertEquals( - expectedResult[index], - "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})" - ) - } - } - - @Test - fun testUrlsEndingInPeriod() { - val text = "Thatโ€™s it! http://vitorpamplona.com/. Thatโ€™s the note" - - val state = RichTextParser().parseText(text, EmptyTagList) - - printStateForDebug(state) - - val expectedResult = listOf( - "RegularText(Thatโ€™s)", - "RegularText(it!)", - "Link(http://vitorpamplona.com/.)", - "RegularText(Thatโ€™s)", - "RegularText(the)", - "RegularText(note)" + private fun printStateForDebug(state: RichTextViewerState) { + state.paragraphs.forEach { paragraph -> + paragraph.words.forEach { seg -> + println( + "\"${ + seg.javaClass.simpleName.replace( + "Segment", + "", + ) + }(${seg.segmentText.replace("\n", "\\n").replace("\"", "\\")})\",", ) - - state.paragraphs.map { it.words }.flatten().forEachIndexed { index, seg -> - Assert.assertEquals( - expectedResult[index], - "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})" - ) - } - } - - private fun printStateForDebug(state: RichTextViewerState) { - state.paragraphs.forEach { paragraph -> - paragraph.words.forEach { seg -> - println( - "\"${ - seg.javaClass.simpleName.replace( - "Segment", - "" - ) - }(${seg.segmentText.replace("\n", "\\n").replace("\"", "\\")})\"," - ) - } - } + } } + } } diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/ThreadAssemblerTest.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/ThreadAssemblerTest.kt index 0c6c09f9e..bf89e5385 100644 --- a/app/src/androidTest/java/com/vitorpamplona/amethyst/ThreadAssemblerTest.kt +++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/ThreadAssemblerTest.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -18,7 +38,8 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class ThreadAssemblerTest { - val db = """ + val db = + """ [ {"id":"741f5367a9415f4d6f19c0f57a1e4647c8ed8309b53b0da2d82fc4ebfba03b2c","pubkey":"d85c99afd244911e0aaf800cbea4221df557f06f8a4ff2cbe84b24e0b9e728fc","created_at":1684674845,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"Getting my head around this","sig":"069874040bac26a219777fc0f90b8f4df71e38c30e3e6a953d53222499d8e0c5a8f32c6b4204d14eb335bb654f01c5610372d9dc00062284b8e0f2bb98c7ed85"}, {"id":"22323fc72b4c37f93ea21f6069684339ce5f63111161c81f2aa3de4a21bfe83b","pubkey":"73c7f6d5bb599bb7d7cee84c72e89dbd549df53da522ed6c7611055cc0db64bc","created_at":1683571810,"kind":1,"tags":[["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["e","532808e4d60f5f82b95aeaa3ed2e930a0c5973dccb0ede68b28b1931db91440f"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599"]],"content":"test","sig":"f6d3bcf0f8e07d06720a2527f95910ee5a66aa889dd9261514921a155bbc3e9a3c7d721f5a2ec948cd795183f85dbe80f2ee2e36663f9d1de33fc03274ccb9d9"}, @@ -58,9 +79,11 @@ class ThreadAssemblerTest { {"id":"512962dbada5fd5015fc727a107d5c3f569662de67eab8e5da5a8065012cf11e","pubkey":"7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194","created_at":1688194800,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"],["e","87a5bd25aa084cefb3357fc9c2a5b327254fab35fdd7b2d4bd0acddc63d0abe8","","reply"]],"content":"another one","sig":"b4bc4d8206a08de0a918043129b27c8863d17f60e4f58b4d2b535326625870d32640b04f247eb9eb5df1d62fe98c8e1b0341bdd119655553488300ec9d5b4036"}, {"id":"ce6e32e3e17b6901d2cc70b60f3743e24f885bb6e9da6d88cff516079eac1883","pubkey":"726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11","created_at":1688234921,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"],["e","512962dbada5fd5015fc727a107d5c3f569662de67eab8e5da5a8065012cf11e","","reply"]],"content":"yet another one, testing 0.0.3","sig":"13bd41c4029c6a7ee41cb03d12e831e9e9b6e14d43a61c78a72657070b45385ba2ce98e8121049fbcbdb7b2dd777c36c7867ba70b5e6a4798a9b866910ae5b62"} ] - """.trimIndent() + """ + .trimIndent() - val header = """ + val header = + """ { "content": "Not too long ago, I tried to paint a picture of what\na [vision for a value-enabled web][vew]\ncould look like. Now, only a couple of months later,\nall this stuff is being built. On nostr, and on lightning. Orange and\npurple, a match made in heaven.\n\nIt goes without saying that I'm beyond delighted. What a time to be alive!\n\n## nostr\n\nHere's the thing that nostr got right, and it's the same thing that\nBitcoin got right: information is easy to spread and hard to stifle.[^fn-stifle]\nInformation can be copied quickly and perfectly, which is, I believe,\nthe underlying reason for its desire to be free.\n\n[^fn-stifle]: That's a [Satoshi quote][stifle], of course: \"Bitcoin's solution is to use a peer-to-peer network to check for double-spending. In a nutshell, the network works like a distributed timestamp server, stamping the first transaction to spend a coin. It takes advantage of the nature of information being easy to spread but hard to stifle.\"\n\n[stifle]: https://satoshi.nakamotoinstitute.org/posts/p2pfoundation/1/\n\nEasy to spread, hard to stifle. That's the base reality of the nature\nof information. As always, the smart thing is to work with nature, not\nagainst it.[^1] That's what's beautiful about the orange coin and\nthe purple ostrich: both manage to work with the peculiarities of\ninformation, not against them. Both realize that information can and should be\ncopied, as it can be perfectly read and easily spread, always. Both understand\nthat resistance to censorship comes from writing to many places, making the cost\nof deletion prohibitive.\n\n> Information does not just want to be free,\n> it longs to be free. Information expands to fill the available\n> storage space. Information is Rumor's younger, stronger cousin;\n> Information is fleeter of foot, has more eyes, knows more, and\n> understands less than Rumor.\n>\n> Eric Hughes, [A Cypherpunk's Manifesto][manifesto]\n\n[manifesto]: https://nakamotoinstitute.org/static/docs/cypherpunk-manifesto.txt\n\nNostr is quickly establishing itself as a base layer for information exchange,\none that is identity-native and value-enabled. It is distinctly different from\nsystems that came before it, just like Bitcoin is distinctly different from\nmonies that came before it.\n\nAs of today, the focus of nostr is mostly on short text notes, the so-called\n\"type 1\" events more commonly known as *tweets*.[^fn-kinds] However, as you should be aware\nby now, nostr is way more than just an alternative to twitter. It is a new\nparadigm. Change the note kind from `1` to `30023` and you don't have an\nalternative to Twitter, but a replacement for Medium, Substack, and all the\nother long-form platforms. I believe that special-purpose clients that focus on\ncertain content types will emerge over time, just like we have seen the\nemergence of special-purpose platforms in the Web 2.0 era. This time, however,\nthe network effects are cumulative, not separate. A new paradigm.\n\nLet me now turn to one such special-purpose client, a nostr-based reading app.\n\n[^fn-kinds]: Refer to the various NIPs to discover the multitude of [event kinds][kinds] defined by the protocol.\n\n[kinds]: https://github.com/nostr-protocol/nips#event-kinds\n[nip23]: https://github.com/nostr-protocol/nips/blob/master/23.md\n\n## Reading\n\nI'm constantly surprised that, even though most people do read a lot\nonline, very few people seem to have a reading workflow or reading\ntools.\n\nWhy that is is anyone's guess, but maybe the added value of such tools\nis not readily apparent. You can just read the stuff right there, on the\nad-ridden, dead-ugly site, right? Why should you sign up for another\nsite, use another app, or bind yourself to another closed platform?\n\nThat's a fair point, but the success of Medium and Substack shows that\nthere is an appetite for clean reading and writing, as well as providing\navenues for authors to get paid for their writing (and a willingness of\nreaders to support said authors, just because).\n\nThe problem is, of course, that all of these platforms areย *platforms*,\nwhich is to say, walled gardens that imprison readers and writers alike.\nWorse than that: they are fiat platforms, which means that\npermissionless value-flows are not only absent from their DNA, they are\noutright impossible.[^2]\n\nNostr fixes this.\n\n![Nostriches like to read, or so I've heard](https://dergigi.com/assets/images/bitcoin/2023-04-04-purple-text-orange-highlights/nostrich-reading-a-newspaper.jpg)\n\nThe beauty of nostr is that it is not a platform. It's a protocol,\nwhich means that you don't have to sign up for it---you can create an\nidentity yourself. You don't have to ask for permission; you just *do*,\nwithout having to rely on the benevolence of whatever dictator is in\ncharge of the platform right now.\n\nNostr isย *not*ย a platform, and yet, powerful tools and services can be\nbuilt and monetized on top of it. This is good for users, good for\nservice providers, and good for the network(s) at large. Win-win-win.\n\nSo what am I talking about, exactly? How can nostr improve everyone's\nreading (and writing) experience?\n\nAllow me to paint a (rough) picture of what I have in mind. Nostr\nalready supports private and public bookmarks, so let's start from\nthere.\n\nImagine a special-purpose client that scans all your bookmarks for long-form\ncontent.[^fn-urls] Everything that you marked to be read later is shown in an orderly\nfashion, which is to say searchable, sortable, filterable, and displayed without\ndistractions. Voilร , you have yourself a reading app. That's, in essence, how\nPocket, Readwise, and other reading apps work. But all these apps are walled\ngardens without much interoperability and without direct monetization.\n\n[^fn-urls]: In the nostr world long-form content is simply markdown as defined in [NIP-23][nip23], but it could also be a link to an article or PDF, which in turn could get [converted into markdown][readability] and posted as an event to a special relay.\n\n[readability]: https://github.com/mozilla/readability\n\nBitcoin fixes the direct monetization part.[^fn-v4v] Nostr fixes the interoperability part.\n\n[^fn-v4v]: ...because Bitcoin makes [V4V][busking] practical. (Paywalls are not the way.)\n\nAlright, we got ourselves a boring reading app. Great. Now, imagine that\nusers are able to highlight passages. These highlights, just like\nbookmarks now, could be private or public. When shared publicly,\nsomething interesting emerges: an overlay on existing content, a lens on\nthe written Web. In other words:ย *swarm highlights*.\n\nImagine a visual overlay of all public highlights, automatically shining\na light on what the swarm of readers found most useful, insightful,\nfunny, etc.\n\n![Swarm Highlights](https://dergigi.com/assets/images/bitcoin/2023-04-04-purple-text-orange-highlights/highlights.png)\n\nFurther, imagine the possibility of sharing these highlights as aย \"type 1\"ย event\nwith one click, automatically tagging the highlighter(s)---as well as the\nauthor, of course---so that eventual sat-flows can be split and forwarded\nautomatically.\n\n![Automated value splits](https://dergigi.com/assets/images/bitcoin/2023-04-04-purple-text-orange-highlights/sat-flows.png)\n\nVoilร , you have a system that allows for value to flow back to those who\nprovide it, be it authors, editors, curators, or readers that willingly\nslog through the information jungle to share and highlight the best\nstuff (which is a form of curation, of course).\n\nZaps make nostr a defacto address book[^fn-pp] of payment information, which is\nto say lightning addresses, as of now. Thanks to [nostr wallet connect][nwc] (among\nother developments), sending sats ~~will soon be~~ is already as\nfrictionless as leaving a like.\n\n[^fn-pp]: The Yellow Pages are dead, long live [The Purple Pages](http://purplepag.es/)!\n\nValue-for-value and participatory payment flows are something that\ntraditional reading apps desperately lack, be it Pocket, Instapaper,\nReadwise, or the simple reading mode that is part of every browser.\n\nA neat side-effect of a more structured way to share passages of text is\nthat it enables semi-structured discussions around said\npassages---which could be another useful overlay inside\nspecial-purpose clients, providing context and further insights.[^5]\n\nFurther, imagine the option of seamlessly switching from text-on-screen\nto text-to-speech, allowing the user to stream sats if desired, as\nPodcasting 2.0 clients already do.[^3]\n\nImagine user-built curations of the best articles of the week, bundled\nneatly for your reading pleasure, incentivized by a small value split\nthat allows the curator to participate in the flow of sats.\n\nYou get the idea.\n\nI'm sure that the various implementation details will be hashed out,\nbut as I see it, 90% of the stuff is already there. Maybe we'll need\nanother NIP or two, but I don't see a reason why this can't be\nbuilt---and, more importantly: I don't see a reason why it wouldn't\nbe sustainable for everyone involved.\n\nMost puzzle pieces are already there, and the rest of them can probably\nbe implemented by custom event types. From the point of view of nostr,\nmost everything is an event: bookmarks are events, highlights are\nevents, marking something as read is an event, and sharing an excerpt or\na highlight is an event. Public actions are out in the open, private\nactions are encrypted, the data is not in a silo, and everyone wins.\nEspecially the users, those who are at the edge of the network and\nusually lose out on the value generated.\n\nIn this case, the reading case, the users are mostly \"consumers\" of\ncontent. What changes from the producing perspective, the perspective of\nthe writer?\n\n## Writing\n\nBack to the one thing that nostr got right: information is easy to\nspread but hard to stifle. In addition to that, digital information can\nbe copied perfectly, which is why it shouldn't matter where stuff is\npublished in the first place.\n\nAllow me to repeat this point in all caps, for emphasis:ย **IT SHOULD NOT\nMATTER WHERE INFORMATION IS PUBLISHED**, and, maybe even more\nimportantly, it shouldn't matter if it is published in a hundred\ndifferent places at once.[^fn-torrents]\n\nWhat matters is trust and accuracy, which is to say, digital signatures\nand reputation. To translate this to nostr speak: because every event is\nsigned by default, as long as you trust the person behind the signature,\nit doesn't matter from which relay the information is fetched.\n\nThis is already true (or mostly true) on the regular web. Whether you\nread the internet archive version of an article or the version that is\npublished by an online magazine, the version on the author's website,\nor the version read by some guy that has read more about Bitcoin than\nanyone else you know[^fn-guy]---it's all the same, essentially. What matters\nis the information itself.\n\n[^fn-guy]: There is only one such guy, as we all know, and it's this Guy: nostr:npub1h8nk2346qezka5cpm8jjh3yl5j88pf4ly2ptu7s6uu55wcfqy0wq36rpev\n\nPractically speaking, the source of truth in a hypernostrized world is---you\nguessed it---an event. An event signed by the author, which allows for\nthe information to be wrapped in a tamper-proof manner, which in turn\nallows the information to spread far and wide---without it being\nhosted in one place.\n\nThe first clients that focus on long-form content already exist, and I expect\nmore clients to pop up over time.[^4] As mentioned before, one could easily\nimagine [prism-like value splits][prism] seamlessly integrated into these\nclients, splitting zaps automatically to compensate writers, editors,\nproofreaders, and illustrators in a V4V fashion. Further, one could imagine\nvarious compute-intensive services built into these special-purpose clients,\nsuch as GPT Ghostwriters, or writing aids such as Grammarly and the like. All\nthese services could be seamlessly paid for in sats, without the requirement of\nany sign-ups or the gathering of any user data. That's the beauty of [money\nproper][rediscovery].\n\n![A clean and simple reading and writing interface](https://dergigi.com/assets/images/bitcoin/2023-04-04-purple-text-orange-highlights/nostr-reader-and-writer.png)\n\nPlagiarism is one issue that needs to be dealt with, of course. Humans\nare greedy, and some humans are assholes. Neither bitcoin nor nostr\nfixes this. However, while plagiarism detection is not necessarily\ntrivial, it is also not impossible, especially if most texts are\npublished on nostr first. Nostr-based publishing tools allow for\nOpenTimestamp attestations thanks\ntoย [NIP-03](https://github.com/nostr-protocol/nips/blob/master/03.md),\nwhich in turn allows for plagiarism detection based on \"first seen\"\nlookups.\n\nThat's just one way to deal with the problem, of course. In any case,\nI'm confident that we'll figure it out.\n\n## Value\n\nI believe that in the open ~~attention~~ information economy we find\nourselves in, value will mostly derive from effective curation,\ndissemination, and transmission of information, *not* the exclusive\nownership of it.\n\nAlthough it is still early days,\ntheย [statistics](https://stats.podcastindex.org/v4v)ย around Podcasting\n2.0 andย [nostr zaps](https://zaplife.lol/)ย clearly show that (a) people\nare willing to monetarily reward content they care about, and (b) the\nwillingness to send sats *increases* as friction *decreases*.\n\nThe ingenious thing about boostagrams and zaps is that they are direct\nand visible, which is to say, public and interactive. They are neither\nregular transactions nor simple donations---they are something else\nentirely. An unforgable value signal, a special form of gratitude and\nappreciation.\n\nContrast that with a link to Paypal or Patreon: impersonal, slow,\nindirect, and friction-laden. It's the opposite of a super-charged\ninteraction.\n\nWhile today's information jungle increasingly presents itself in the\nform of (short) videos and (long-form) audio, I believe that we will see\na renaissance of the written word, especially if we manage to move away\nfrom an economy built around attention, towards an economy built upon\nvalue and insight.\n\nThe orange future now has a purple hue, and I believe that it will be as\nbright as ever. We just have a lot of building to do.\n\n---\n\n## Further Reading\n\n- [A Vision for a Value-Enabled Web][vew]\n- [The Freedom of Value][busking]\n- [The Rediscovery of Money][prism]\n- [Lightning Prisms][rediscovery]\n\n[vew]: https://dergigi.com/vew\n[prism]: https://dergigi.com/prism\n[rediscovery]: https://dergigi.com/rediscovery\n[busking]: https://dergigi.com/busking\n\n## NIPs and Resources\n\n- [Nostr Resources][nr]\n- [value4value.info](https://value4value.info/)\n- [nips.be](https://nips.be/)\n- [NIP-23: Long-form content](https://github.com/nostr-protocol/nips/blob/master/23.md)\n- [NIP-57: Event-specific zap markers](https://github.com/nostr-protocol/nips/blob/master/57.md)\n- [NIP-47: Nostr Wallet Connect](https://github.com/getAlby/nips/blob/master/47.md)\n- [NIP-03: OpenTimestamps attestations for events](https://github.com/nostr-protocol/nips/blob/master/03.md)\n\nOriginally published on [dergigi.com](https://dergigi.com/reader)\n\n---\n\n[^1]: Paywalls work against this nature, which is why I consider them misguided at best and incredibly retarded at worst.\n\n[^2]: Fiat doesn't work for the [value-enabled web][vew], as fiat rails can never be open and permissionless. Digital fiat is never money. It is---and always will be---[credit][rediscovery].\n\n[^3]: Whether the recipient is a text-to-speech service provider or a human narrator doesn't even matter too much, sats will flow just the same.\n\n[^4]: [BlogStack](https://blogstack.io/) and [Habla](https://habla.news/) being two of them.\n\n[^5]: Use a URI as the discussion base (instead of a highlight), and you got yourself a [Disqus](https://disqus.com/) in purple feathers!\n\n[^fn-torrents]: That's what torrents got right, and [ipfs] for that matter.\n\n[nr]: https://nostr-resources.com\n[nwc]: https://nwc.getalby.com/\n[ipfs]: https://fiatjaf.com/d5031e5b.html\n", "created_at": 1680614039, @@ -88,72 +111,81 @@ class ThreadAssemblerTest { ["p","b9e76546ba06456ed301d9e52bc49fa48e70a6bf2282be7a1ae72947612023dc"] ] } - """.trimIndent() + """ + .trimIndent() - @Test - fun threadOrderTest() = runBlocking { - val eventArray = Event.mapper.readValue>(db) as List + Event.fromJson(header) + @Test + fun threadOrderTest() = runBlocking { + val eventArray = + Event.mapper.readValue>(db) as List + Event.fromJson(header) - var counter = 0 - eventArray.forEach { - TestCase.assertTrue("${it.id} failed signature check", it.hasValidSignature()) - LocalCache.verifyAndConsume(it, null) - counter++ - } - - val naddr = ATag(30023, "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93", "1680612926599", null) - - val account = Account(KeyPair()) - withContext(Dispatchers.Main) { - val user = account.userProfile().live() - } - - val filter = ThreadFeedFilter(account, naddr.toTag()) - val calculatedFeed = filter.feed() - - val expecteedOrder = listOf( - "30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599", - "e15b386824fbfdcbf1b50b8860f03062cef534a3ea5339cc837536fb2a58465e", - "ba9a8a1a8afb0b53fb5d4fa3f5130fe557a8d8d56fac7af9ad3443531d2a2933", - "b0425132a3dd4142a0f78986166aaa28021cc8fb440c95c321a95afce3d5e056", - "e9bb4e2d56bd2be2952570bd52b102c23444a62ada5b78ba086f086d9147651a", - "4d7b21c462f3fbf27a1882dbaaec4e99e5a5d18a2c18d1f2d1f0736684ad157c", - "9cdbced750e6b1e1274b7df47cb433f565414dd08c897167eba761eadee841cc", - "5799aac7a9b06f3cae3d3791b79df29d14173515cfdbf34398aab73ed4a44121", - "741f5367a9415f4d6f19c0f57a1e4647c8ed8309b53b0da2d82fc4ebfba03b2c", - "22323fc72b4c37f93ea21f6069684339ce5f63111161c81f2aa3de4a21bfe83b", - "14525fcaae530a029f782fd361dd0cd66634c3e23020bd19e66fe11c1e254e32", - "98ae0d6d10e494ed0bf70feb577e8225c6a6732c7af3d29f88bfe1b87d4439e6", - "36e262e71b7e8bcae946f69885b8c3614e318e82864437342cf50e8b9ab7229d", - "37725c2924ca267d66c2c27c2dae65550c07e7b883034cf1ea69671883430642", - "8ba54cfb6375270e8ae97a7e0992c1a0dbaa4cd46af8309d67a839e86789fde6", - "53410bc6d47e87f3f18ecbc93c716b5a6ef8ee3805516b2ff4d155154a685b7c", - "a3b3825af621727f9af3bd77392fe38c04d71658024916af7fe4c5867ef73eaa", - "e383476cb1ce5accde11d4b1338424fa32c3724cf96e6214af8e5e852981728a", - "e2d8aaed336d3c0f73a9ca46a89fdb2da62a6d172936a91b0067a68797b3bcb8", - "b92e4d6a5d0e8d1d2d2421044b84a4d11f2188261c55145d782b1b6bf0995009", - "da13e14cc8bcc243e0373dde14533d3829b8b621e214ca3c99c90f3dd9e11b8a", - "b5234d90a1543ba60765c57ac3fc7140129a4ac28bbd013531ec9b85e256ea55", - "c5ad64b1b72776a068c39f4549d089032432814a146849eba0650d1b329fb285", - "fc4e4a1230b002e4ae08251a0b26107de7f518800188b7a504f5de62d8b07996", - "6a58f8315af5badb1bdaeb5489417b94621a4d8e192ae2fedcca0c5dcf0c9cd4", - "6e9bb03c7c40d67fec0d0bb872548ec207ba0ac4533efa137d7bcaca9fb4b191", - "e2ae784b239cac4bad38136e4bd758b87dd261b659ef460450064bf9073edcb3", - "674c62f84afdc045bc3623ea132d90afdfe4b64249807f65302231115af5406d", - "d54761f672669ea4f4b7592f3b0a30ee28de340b0a7e46b91af94e66905171c9", - "00813a18ac9084cd0948c27027a980e34039a3011f30279a8b52ad87da5a3031", - "87a5bd25aa084cefb3357fc9c2a5b327254fab35fdd7b2d4bd0acddc63d0abe8", - "512962dbada5fd5015fc727a107d5c3f569662de67eab8e5da5a8065012cf11e", - "ce6e32e3e17b6901d2cc70b60f3743e24f885bb6e9da6d88cff516079eac1883", - "7a4a2419824669f07081abe2132f8cc0027efbce066ccdf187c897bb7ffa5dc3", - "45d4fc726f2cc5b524be862c14fdadc1a24b25b8c6c011eedf2d2909589263e7", - "d4a0b4f08d98d82a04292654ec132723cc2cf3fa24ffb6c0833426cb9372f4d5", - "8cdc4676aca93bbafcfbe6784f9b2df54e8ca20fbe69ba55fda487736bfdb7f6", - "7a18dda355525d468b31bba4fa947cba98cc19048d4a3099d5e9ba045d878c26" - ) - - for (i in expecteedOrder.indices) { - assertEquals(expecteedOrder[i], calculatedFeed[i].idHex) - } + var counter = 0 + eventArray.forEach { + TestCase.assertTrue("${it.id} failed signature check", it.hasValidSignature()) + LocalCache.verifyAndConsume(it, null) + counter++ } + + val naddr = + ATag( + 30023, + "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93", + "1680612926599", + null, + ) + + val account = Account(KeyPair()) + withContext(Dispatchers.Main) { + val user = account.userProfile().live() + } + + val filter = ThreadFeedFilter(account, naddr.toTag()) + val calculatedFeed = filter.feed() + + val expecteedOrder = + listOf( + "30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599", + "e15b386824fbfdcbf1b50b8860f03062cef534a3ea5339cc837536fb2a58465e", + "ba9a8a1a8afb0b53fb5d4fa3f5130fe557a8d8d56fac7af9ad3443531d2a2933", + "b0425132a3dd4142a0f78986166aaa28021cc8fb440c95c321a95afce3d5e056", + "e9bb4e2d56bd2be2952570bd52b102c23444a62ada5b78ba086f086d9147651a", + "4d7b21c462f3fbf27a1882dbaaec4e99e5a5d18a2c18d1f2d1f0736684ad157c", + "9cdbced750e6b1e1274b7df47cb433f565414dd08c897167eba761eadee841cc", + "5799aac7a9b06f3cae3d3791b79df29d14173515cfdbf34398aab73ed4a44121", + "741f5367a9415f4d6f19c0f57a1e4647c8ed8309b53b0da2d82fc4ebfba03b2c", + "22323fc72b4c37f93ea21f6069684339ce5f63111161c81f2aa3de4a21bfe83b", + "14525fcaae530a029f782fd361dd0cd66634c3e23020bd19e66fe11c1e254e32", + "98ae0d6d10e494ed0bf70feb577e8225c6a6732c7af3d29f88bfe1b87d4439e6", + "36e262e71b7e8bcae946f69885b8c3614e318e82864437342cf50e8b9ab7229d", + "37725c2924ca267d66c2c27c2dae65550c07e7b883034cf1ea69671883430642", + "8ba54cfb6375270e8ae97a7e0992c1a0dbaa4cd46af8309d67a839e86789fde6", + "53410bc6d47e87f3f18ecbc93c716b5a6ef8ee3805516b2ff4d155154a685b7c", + "a3b3825af621727f9af3bd77392fe38c04d71658024916af7fe4c5867ef73eaa", + "e383476cb1ce5accde11d4b1338424fa32c3724cf96e6214af8e5e852981728a", + "e2d8aaed336d3c0f73a9ca46a89fdb2da62a6d172936a91b0067a68797b3bcb8", + "b92e4d6a5d0e8d1d2d2421044b84a4d11f2188261c55145d782b1b6bf0995009", + "da13e14cc8bcc243e0373dde14533d3829b8b621e214ca3c99c90f3dd9e11b8a", + "b5234d90a1543ba60765c57ac3fc7140129a4ac28bbd013531ec9b85e256ea55", + "c5ad64b1b72776a068c39f4549d089032432814a146849eba0650d1b329fb285", + "fc4e4a1230b002e4ae08251a0b26107de7f518800188b7a504f5de62d8b07996", + "6a58f8315af5badb1bdaeb5489417b94621a4d8e192ae2fedcca0c5dcf0c9cd4", + "6e9bb03c7c40d67fec0d0bb872548ec207ba0ac4533efa137d7bcaca9fb4b191", + "e2ae784b239cac4bad38136e4bd758b87dd261b659ef460450064bf9073edcb3", + "674c62f84afdc045bc3623ea132d90afdfe4b64249807f65302231115af5406d", + "d54761f672669ea4f4b7592f3b0a30ee28de340b0a7e46b91af94e66905171c9", + "00813a18ac9084cd0948c27027a980e34039a3011f30279a8b52ad87da5a3031", + "87a5bd25aa084cefb3357fc9c2a5b327254fab35fdd7b2d4bd0acddc63d0abe8", + "512962dbada5fd5015fc727a107d5c3f569662de67eab8e5da5a8065012cf11e", + "ce6e32e3e17b6901d2cc70b60f3743e24f885bb6e9da6d88cff516079eac1883", + "7a4a2419824669f07081abe2132f8cc0027efbce066ccdf187c897bb7ffa5dc3", + "45d4fc726f2cc5b524be862c14fdadc1a24b25b8c6c011eedf2d2909589263e7", + "d4a0b4f08d98d82a04292654ec132723cc2cf3fa24ffb6c0833426cb9372f4d5", + "8cdc4676aca93bbafcfbe6784f9b2df54e8ca20fbe69ba55fda487736bfdb7f6", + "7a18dda355525d468b31bba4fa947cba98cc19048d4a3099d5e9ba045d878c26", + ) + + for (i in expecteedOrder.indices) { + assertEquals(expecteedOrder[i], calculatedFeed[i].idHex) + } + } } diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt index 68eae0777..dc83ecab2 100644 --- a/app/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt +++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst import androidx.compose.ui.graphics.Color @@ -20,86 +40,92 @@ import org.junit.runner.RunWith */ @RunWith(AndroidJUnit4::class) class UrlUserTagTransformationTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.vitorpamplona.amethyst", appContext.packageName.removeSuffix(".debug")) - } + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.vitorpamplona.amethyst", appContext.packageName.removeSuffix(".debug")) + } - @Test - fun transformationText() { - val user = LocalCache.getOrCreateUser( - decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z") - .toHexKey() - ) - user.info = UserMetadata() - user.info?.displayName = "Vitor Pamplona" + @Test + fun transformationText() { + val user = + LocalCache.getOrCreateUser( + decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z") + .toHexKey(), + ) + user.info = UserMetadata() + user.info?.displayName = "Vitor Pamplona" - val transformedText = buildAnnotatedStringWithUrlHighlighting( - AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"), - Color.Red - ) + val transformedText = + buildAnnotatedStringWithUrlHighlighting( + AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"), + Color.Red, + ) - assertEquals("New Hey @Vitor Pamplona", transformedText.text.text) + assertEquals("New Hey @Vitor Pamplona", transformedText.text.text) - assertEquals(0, transformedText.offsetMapping.originalToTransformed(0)) // Before N - assertEquals(4, transformedText.offsetMapping.originalToTransformed(4)) // Before H - assertEquals(8, transformedText.offsetMapping.originalToTransformed(8)) // Before @ - assertEquals(8, transformedText.offsetMapping.originalToTransformed(9)) // Before n - assertEquals(8, transformedText.offsetMapping.originalToTransformed(10)) // Before p - assertEquals(9, transformedText.offsetMapping.originalToTransformed(11)) // Before u - assertEquals(9, transformedText.offsetMapping.originalToTransformed(12)) // Before b - assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) // Before 1 + assertEquals(0, transformedText.offsetMapping.originalToTransformed(0)) // Before N + assertEquals(4, transformedText.offsetMapping.originalToTransformed(4)) // Before H + assertEquals(8, transformedText.offsetMapping.originalToTransformed(8)) // Before @ + assertEquals(8, transformedText.offsetMapping.originalToTransformed(9)) // Before n + assertEquals(8, transformedText.offsetMapping.originalToTransformed(10)) // Before p + assertEquals(9, transformedText.offsetMapping.originalToTransformed(11)) // Before u + assertEquals(9, transformedText.offsetMapping.originalToTransformed(12)) // Before b + assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) // Before 1 - assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) - assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) + assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) + assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) - assertEquals(0, transformedText.offsetMapping.transformedToOriginal(0)) - assertEquals(4, transformedText.offsetMapping.transformedToOriginal(4)) - assertEquals(8, transformedText.offsetMapping.transformedToOriginal(8)) - assertEquals(12, transformedText.offsetMapping.transformedToOriginal(9)) + assertEquals(0, transformedText.offsetMapping.transformedToOriginal(0)) + assertEquals(4, transformedText.offsetMapping.transformedToOriginal(4)) + assertEquals(8, transformedText.offsetMapping.transformedToOriginal(8)) + assertEquals(12, transformedText.offsetMapping.transformedToOriginal(9)) - assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) - assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) - } + assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) + assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) + } - @Test - fun transformationTextTwoKeys() { - val user = LocalCache.getOrCreateUser( - decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z") - .toHexKey() - ) - user.info = UserMetadata() - user.info?.displayName = "Vitor Pamplona" + @Test + fun transformationTextTwoKeys() { + val user = + LocalCache.getOrCreateUser( + decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z") + .toHexKey(), + ) + user.info = UserMetadata() + user.info?.displayName = "Vitor Pamplona" - val transformedText = buildAnnotatedStringWithUrlHighlighting( - AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z and @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"), - Color.Red - ) + val transformedText = + buildAnnotatedStringWithUrlHighlighting( + AnnotatedString( + "New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z and @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", + ), + Color.Red, + ) - assertEquals("New Hey @Vitor Pamplona and @Vitor Pamplona", transformedText.text.text) + assertEquals("New Hey @Vitor Pamplona and @Vitor Pamplona", transformedText.text.text) - assertEquals(9, transformedText.offsetMapping.originalToTransformed(11)) - assertEquals(9, transformedText.offsetMapping.originalToTransformed(12)) - assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) + assertEquals(9, transformedText.offsetMapping.originalToTransformed(11)) + assertEquals(9, transformedText.offsetMapping.originalToTransformed(12)) + assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) - assertEquals(23, transformedText.offsetMapping.originalToTransformed(70)) // Before 5 - assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) // Before z - assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) // Before - assertEquals(24, transformedText.offsetMapping.originalToTransformed(73)) // Before a - assertEquals(25, transformedText.offsetMapping.originalToTransformed(74)) // Before n - assertEquals(26, transformedText.offsetMapping.originalToTransformed(75)) // Before d - assertEquals(27, transformedText.offsetMapping.originalToTransformed(76)) // Before - assertEquals(28, transformedText.offsetMapping.originalToTransformed(77)) // Before @ - assertEquals(28, transformedText.offsetMapping.originalToTransformed(78)) // Before n + assertEquals(23, transformedText.offsetMapping.originalToTransformed(70)) // Before 5 + assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) // Before z + assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) // Before + assertEquals(24, transformedText.offsetMapping.originalToTransformed(73)) // Before a + assertEquals(25, transformedText.offsetMapping.originalToTransformed(74)) // Before n + assertEquals(26, transformedText.offsetMapping.originalToTransformed(75)) // Before d + assertEquals(27, transformedText.offsetMapping.originalToTransformed(76)) // Before + assertEquals(28, transformedText.offsetMapping.originalToTransformed(77)) // Before @ + assertEquals(28, transformedText.offsetMapping.originalToTransformed(78)) // Before n - assertEquals(68, transformedText.offsetMapping.transformedToOriginal(22)) // Before a - assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) // Before - assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) // Before a - assertEquals(74, transformedText.offsetMapping.transformedToOriginal(25)) // Before n - assertEquals(75, transformedText.offsetMapping.transformedToOriginal(26)) // Before d - assertEquals(76, transformedText.offsetMapping.transformedToOriginal(27)) // Before - assertEquals(77, transformedText.offsetMapping.transformedToOriginal(28)) // Before @ - } + assertEquals(68, transformedText.offsetMapping.transformedToOriginal(22)) // Before a + assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) // Before + assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) // Before a + assertEquals(74, transformedText.offsetMapping.transformedToOriginal(25)) // Before n + assertEquals(75, transformedText.offsetMapping.transformedToOriginal(26)) // Before d + assertEquals(76, transformedText.offsetMapping.transformedToOriginal(27)) // Before + assertEquals(77, transformedText.offsetMapping.transformedToOriginal(28)) // Before @ + } } diff --git a/app/src/androidTestPlay/java/com/vitorpamplona/amethyst/TranslationsTest.kt b/app/src/androidTestPlay/java/com/vitorpamplona/amethyst/TranslationsTest.kt index 86739bd07..1337c7078 100644 --- a/app/src/androidTestPlay/java/com/vitorpamplona/amethyst/TranslationsTest.kt +++ b/app/src/androidTestPlay/java/com/vitorpamplona/amethyst/TranslationsTest.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -10,102 +30,136 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class TranslationsTest { + fun translateTo( + text: String, + translateTo: String, + ): String? { + val task = LanguageTranslatorService.autoTranslate(text, emptySet(), translateTo) + return Tasks.await(task).result + } - fun translateTo(text: String, translateTo: String): String? { - val task = LanguageTranslatorService.autoTranslate(text, emptySet(), translateTo) - return Tasks.await(task).result - } + fun assertTranslate( + expected: String, + input: String, + translateTo: String, + ) { + assertEquals(null, expected, translateTo(input, translateTo)) + } - fun assertTranslate(expected: String, input: String, translateTo: String) { - assertEquals(null, expected, translateTo(input, translateTo)) - } + fun assertTranslateContains( + expected: String, + input: String, + translateTo: String, + ) { + val translated = translateTo(input, translateTo)!! + assertTrue("'$translated' does not contain '$expected'", translated.contains(expected)) + } - fun assertTranslateContains(expected: String, input: String, translateTo: String) { - val translated = translateTo(input, translateTo)!! - assertTrue("'$translated' does not contain '$expected'", translated.contains(expected)) - } + @Test + fun testTranslation() { + assertTranslate("Olรก mundo", "Hello World", "pt") + } - @Test - fun testTranslation() { - assertTranslate("Olรก mundo", "Hello World", "pt") - } + @Test + fun testTranslationName() { + assertTranslate("Olรก Vitor, como vocรช estรก?", "Hello Vitor, how are you doing?", "pt") + } - @Test - fun testTranslationName() { - assertTranslate("Olรก Vitor, como vocรช estรก?", "Hello Vitor, how are you doing?", "pt") - } + @Test + fun testTranslationTag() { + assertTranslate("Vocรช jรก viu isso, #[0]", "Have you seen this, #[0]", "pt") + } - @Test - fun testTranslationTag() { - assertTranslate("Vocรช jรก viu isso, #[0]", "Have you seen this, #[0]", "pt") - } + @Test + fun testTranslationUrl() { + assertTranslateContains("https://t.me/mygroup", "Have you seen this https://t.me/mygroup", "pt") + assertTranslateContains("http://bananas.com", "Have you seen this http://bananas.com", "pt") + assertTranslateContains( + "http://bananas.com/myimage.jpg", + "Have you seen this http://bananas.com/myimage.jpg", + "pt", + ) + assertTranslateContains( + "http://bananas.com?search=true&image=myimage.jpg", + "Have you seen this http://bananas.com?search=true&image=myimage.jpg", + "pt", + ) + assertTranslate("https://i.imgur.com/EZ3QPsw.jpg", "https://i.imgur.com/EZ3QPsw.jpg", "pt") + assertTranslate("https://HaveYouSeenThis.com", "https://HaveYouSeenThis.com", "pt") + assertTranslate("https://haveyouseenthis.com", "https://haveyouseenthis.com", "pt") + assertTranslate( + "https://i.imgur.com/asdEZ3QPsw.jpg", + "https://i.imgur.com/asdEZ3QPsw.jpg", + "pt", + ) + assertTranslateContains( + "https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg", + "Hi there! \n How are you doing? \n https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg", + "pt", + ) + } - @Test - fun testTranslationUrl() { - assertTranslateContains("https://t.me/mygroup", "Have you seen this https://t.me/mygroup", "pt") - assertTranslateContains("http://bananas.com", "Have you seen this http://bananas.com", "pt") - assertTranslateContains("http://bananas.com/myimage.jpg", "Have you seen this http://bananas.com/myimage.jpg", "pt") - assertTranslateContains("http://bananas.com?search=true&image=myimage.jpg", "Have you seen this http://bananas.com?search=true&image=myimage.jpg", "pt") - assertTranslate("https://i.imgur.com/EZ3QPsw.jpg", "https://i.imgur.com/EZ3QPsw.jpg", "pt") - assertTranslate("https://HaveYouSeenThis.com", "https://HaveYouSeenThis.com", "pt") - assertTranslate("https://haveyouseenthis.com", "https://haveyouseenthis.com", "pt") - assertTranslate("https://i.imgur.com/asdEZ3QPsw.jpg", "https://i.imgur.com/asdEZ3QPsw.jpg", "pt") - assertTranslateContains("https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg", "Hi there! \n How are you doing? \n https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg", "pt") - } + @Test + fun testChineseWithUrlDetector() { + assertTranslate( + "I entered your home page is very carton, perhaps your attention or other data is too much, and the homepage of others is not so carton. From aMethyst client", + "ๆˆ‘่ฟ›ๅ…ฅไฝ ็š„ไธป้กตๅพˆๅก้กฟ๏ผŒไนŸ่ฎธๆ˜ฏไฝ ็š„ๅ…ณๆณจไบบๆ•ฐๆˆ–่€…ๅ…ถไป–ๆ•ฐๆฎๅคชๅคšไบ†๏ผŒๅ…ถไป–ไบบไธป้กตๆฒกๆœ‰่ฟ™ไนˆๅก้กฟใ€‚ๆฅ่‡ชamethystๅฎขๆˆท็ซฏ", + "en", + ) + } - @Test - fun testChineseWithUrlDetector() { - assertTranslate("I entered your home page is very carton, perhaps your attention or other data is too much, and the homepage of others is not so carton. From aMethyst client", "ๆˆ‘่ฟ›ๅ…ฅไฝ ็š„ไธป้กตๅพˆๅก้กฟ๏ผŒไนŸ่ฎธๆ˜ฏไฝ ็š„ๅ…ณๆณจไบบๆ•ฐๆˆ–่€…ๅ…ถไป–ๆ•ฐๆฎๅคชๅคšไบ†๏ผŒๅ…ถไป–ไบบไธป้กตๆฒกๆœ‰่ฟ™ไนˆๅก้กฟใ€‚ๆฅ่‡ชamethystๅฎขๆˆท็ซฏ", "en") - } + @Test + fun testTranslationEmail() { + assertTranslateContains( + "vitor@amethyst.social", + "Have you seen this vitor@amethyst.social", + "pt", + ) + } - @Test - fun testTranslationEmail() { - assertTranslateContains("vitor@amethyst.social", "Have you seen this vitor@amethyst.social", "pt") - } + @Test + fun testTranslationLnInvoice() { + assertTranslateContains( + "lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn", + "Have you seen this: lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn I think I have to pay", + "pt", + ) - @Test - fun testTranslationLnInvoice() { - assertTranslateContains( - "lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn", - "Have you seen this: lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn I think I have to pay", - "pt" - ) + assertTranslateContains( + "lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v", + "Test lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v", + "pt", + ) + } - assertTranslateContains( - "lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v", - "Test lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v", - "pt" - ) - } + @Test + fun testNostrEvents() { + assertTranslateContains( + "nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy", + "sure, nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy", + "en", + ) + } - @Test - fun testNostrEvents() { - assertTranslateContains( - "nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy", - "sure, nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy", - "en" - ) - } + @Test + fun testJapaneseTranslationsOfUrl() { + assertTranslateContains( + "https://youtu.be/wMYFmCDy_Eg", + "ใ†ใกใฎไผš็คพใฎๅฐใ•ใ„ๅ…ˆ่ผฉใฎ่ฉฑ ็ฌฌ1่ฉฑใ€Œใ†ใกใฎไผš็คพใฎๅ…ˆ่ผฉใฏๅฐใ•ใใฆๅฏๆ„›ใ„ใ€\n" + + "\n" + + "https://youtu.be/wMYFmCDy_Eg\n" + + "\n" + + "ๅ…ˆ่ผฉใŒใ†ใ–ใ„ๅพŒ่ผฉใฎ่ฉฑใจไผผใŸใ‚ˆใ†ใช่ฉฑใ‹ใจๆ€ใฃใŸใ‘ใฉใ€ใ‚‚ใฃใจใ‚ชใ‚ฟใ‚ฏใฎๅฆ„ๆƒณใ‚ใ‚‹ใ‚ใ‚‹็š„ใชใ‚‚ใฎใ‚’่ฉฐใ‚่พผใ‚“ใ ใ‚„ใคใ ใ€‚ใƒฏใƒผใƒ‰ใจใ‹ใ‚ทใƒใƒฅใ‚จใƒผใ‚ทใƒงใƒณใจใ‹ใ€ใƒ’ใƒญใ‚คใƒณใฎใ‚ตใ‚คใ‚บๆ„Ÿใจใ‹ใ€‚็Ÿฅใ‚‰ใ‚“ใ‘ใฉ", + "en", + ) + } - @Test - fun testJapaneseTranslationsOfUrl() { - assertTranslateContains( - "https://youtu.be/wMYFmCDy_Eg", - "ใ†ใกใฎไผš็คพใฎๅฐใ•ใ„ๅ…ˆ่ผฉใฎ่ฉฑ ็ฌฌ1่ฉฑใ€Œใ†ใกใฎไผš็คพใฎๅ…ˆ่ผฉใฏๅฐใ•ใใฆๅฏๆ„›ใ„ใ€\n" + - "\n" + - "https://youtu.be/wMYFmCDy_Eg\n" + - "\n" + - "ๅ…ˆ่ผฉใŒใ†ใ–ใ„ๅพŒ่ผฉใฎ่ฉฑใจไผผใŸใ‚ˆใ†ใช่ฉฑใ‹ใจๆ€ใฃใŸใ‘ใฉใ€ใ‚‚ใฃใจใ‚ชใ‚ฟใ‚ฏใฎๅฆ„ๆƒณใ‚ใ‚‹ใ‚ใ‚‹็š„ใชใ‚‚ใฎใ‚’่ฉฐใ‚่พผใ‚“ใ ใ‚„ใคใ ใ€‚ใƒฏใƒผใƒ‰ใจใ‹ใ‚ทใƒใƒฅใ‚จใƒผใ‚ทใƒงใƒณใจใ‹ใ€ใƒ’ใƒญใ‚คใƒณใฎใ‚ตใ‚คใ‚บๆ„Ÿใจใ‹ใ€‚็Ÿฅใ‚‰ใ‚“ใ‘ใฉ", - "en" - ) - } - - @Test - fun testEmoji() { - assertTranslateContains( - "https://cdn.nostr.build/i/df3783dcdf7dd289ba02ba538dc039c8fe1d4db055e580b81604ed88c6af4ee0.jpg", - "\uD83E\uDD23 https://cdn.nostr.build/i/df3783dcdf7dd289ba02ba538dc039c8fe1d4db055e580b81604ed88c6af4ee0.jpg ", - "pt" - ) - } + @Test + fun testEmoji() { + assertTranslateContains( + "https://cdn.nostr.build/i/df3783dcdf7dd289ba02ba538dc039c8fe1d4db055e580b81604ed88c6af4ee0.jpg", + "\uD83E\uDD23 https://cdn.nostr.build/i/df3783dcdf7dd289ba02ba538dc039c8fe1d4db055e580b81604ed88c6af4ee0.jpg ", + "pt", + ) + } } diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt index 8e4c33967..bc008efd5 100644 --- a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt +++ b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt @@ -1,5 +1,25 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service.lang object LanguageTranslatorService { - fun clear() {} + fun clear() {} } diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushDistributorHandler.kt b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushDistributorHandler.kt index 0219c6c55..f8e8e3fc8 100644 --- a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushDistributorHandler.kt +++ b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushDistributorHandler.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service.notifications import android.content.Context @@ -8,69 +28,80 @@ import com.vitorpamplona.amethyst.Amethyst import org.unifiedpush.android.connector.UnifiedPush interface PushDistributorActions { - fun getSavedDistributor(): String - fun getInstalledDistributors(): List - fun saveDistributor(distributor: String) - fun removeSavedDistributor() + fun getSavedDistributor(): String + + fun getInstalledDistributors(): List + + fun saveDistributor(distributor: String) + + fun removeSavedDistributor() } + object PushDistributorHandler : PushDistributorActions { - private val appContext = Amethyst.instance.applicationContext - private val unifiedPush: UnifiedPush = UnifiedPush + private val appContext = Amethyst.instance.applicationContext + private val unifiedPush: UnifiedPush = UnifiedPush - private var endpointInternal = "" - val endpoint = endpointInternal + private var endpointInternal = "" + val endpoint = endpointInternal - fun getSavedEndpoint() = endpoint - fun setEndpoint(newEndpoint: String) { - endpointInternal = newEndpoint - Log.d("PushHandler", "New endpoint saved : $endpointInternal") - } + fun getSavedEndpoint() = endpoint - fun removeEndpoint() { - endpointInternal = "" - } + fun setEndpoint(newEndpoint: String) { + endpointInternal = newEndpoint + Log.d("PushHandler", "New endpoint saved : $endpointInternal") + } - override fun getSavedDistributor(): String { - return unifiedPush.getDistributor(appContext) - } + fun removeEndpoint() { + endpointInternal = "" + } - fun savedDistributorExists(): Boolean = getSavedDistributor().isNotEmpty() + override fun getSavedDistributor(): String { + return unifiedPush.getDistributor(appContext) + } - override fun getInstalledDistributors(): List { - return unifiedPush.getDistributors(appContext) - } + fun savedDistributorExists(): Boolean = getSavedDistributor().isNotEmpty() - fun formattedDistributorNames(): List { - val distributorsArray = getInstalledDistributors().toTypedArray() - val distributorsNameArray = distributorsArray.map { - try { - val ai = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - appContext.packageManager.getApplicationInfo( - it, - PackageManager.ApplicationInfoFlags.of( - PackageManager.GET_META_DATA.toLong() - ) - ) - } else { - appContext.packageManager.getApplicationInfo(it, 0) - } - appContext.packageManager.getApplicationLabel(ai) - } catch (e: PackageManager.NameNotFoundException) { - it - } as String - }.toTypedArray() - return distributorsNameArray.toList() - } + override fun getInstalledDistributors(): List { + return unifiedPush.getDistributors(appContext) + } - override fun saveDistributor(distributor: String) { - unifiedPush.saveDistributor(appContext, distributor) - unifiedPush.registerApp(appContext) - } + fun formattedDistributorNames(): List { + val distributorsArray = getInstalledDistributors().toTypedArray() + val distributorsNameArray = + distributorsArray + .map { + try { + val ai = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + appContext.packageManager.getApplicationInfo( + it, + PackageManager.ApplicationInfoFlags.of( + PackageManager.GET_META_DATA.toLong(), + ), + ) + } else { + appContext.packageManager.getApplicationInfo(it, 0) + } + appContext.packageManager.getApplicationLabel(ai) + } catch (e: PackageManager.NameNotFoundException) { + it + } + as String + } + .toTypedArray() + return distributorsNameArray.toList() + } - override fun removeSavedDistributor() { - unifiedPush.safeRemoveDistributor(appContext) - } - fun forceRemoveDistributor(context: Context) { - unifiedPush.forceRemoveDistributor(context) - } + override fun saveDistributor(distributor: String) { + unifiedPush.saveDistributor(appContext, distributor) + unifiedPush.registerApp(appContext) + } + + override fun removeSavedDistributor() { + unifiedPush.safeRemoveDistributor(appContext) + } + + fun forceRemoveDistributor(context: Context) { + unifiedPush.forceRemoveDistributor(context) + } } diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt index 0263c6fe4..0953db0a8 100644 --- a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt +++ b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service.notifications import android.app.NotificationManager @@ -20,73 +40,92 @@ import kotlinx.coroutines.launch import org.unifiedpush.android.connector.MessagingReceiver class PushMessageReceiver : MessagingReceiver() { + companion object { private val TAG = "Amethyst-OSSPushReceiver" - private val appContext = Amethyst.instance.applicationContext - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val eventCache = LruCache(100) - private val pushHandler = PushDistributorHandler + } - override fun onMessage(context: Context, message: ByteArray, instance: String) { - val messageStr = String(message) - Log.d(TAG, "New message ${message.decodeToString()} for Instance: $instance") - scope.launch { - try { - parseMessage(messageStr)?.let { - receiveIfNew(it) - } - } catch (e: Exception) { - Log.d(TAG, "Message could not be parsed: ${e.message}") - } - } - } + private val appContext = Amethyst.instance.applicationContext + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val eventCache = LruCache(100) + private val pushHandler = PushDistributorHandler - private suspend fun parseMessage(message: String): GiftWrapEvent? { - (Event.fromJson(message) as? GiftWrapEvent)?.let { - return it - } - return null + override fun onMessage( + context: Context, + message: ByteArray, + instance: String, + ) { + val messageStr = String(message) + Log.d(TAG, "New message ${message.decodeToString()} for Instance: $instance") + scope.launch { + try { + parseMessage(messageStr)?.let { receiveIfNew(it) } + } catch (e: Exception) { + Log.d(TAG, "Message could not be parsed: ${e.message}") + } } + } - private suspend fun receiveIfNew(event: GiftWrapEvent) { - if (eventCache.get(event.id) == null) { - eventCache.put(event.id, event.id) - EventNotificationConsumer(appContext).consume(event) - } + private suspend fun parseMessage(message: String): GiftWrapEvent? { + (Event.fromJson(message) as? GiftWrapEvent)?.let { + return it } + return null + } - override fun onNewEndpoint(context: Context, endpoint: String, instance: String) { - Log.d(TAG, "New endpoint provided:- $endpoint for Instance: $instance") - val sanitizedEndpoint = endpoint.dropLast(5) - pushHandler.setEndpoint(sanitizedEndpoint) - scope.launch(Dispatchers.IO) { - RegisterAccounts(LocalPreferences.allSavedAccounts()).go(sanitizedEndpoint) - notificationManager().getOrCreateZapChannel(appContext) - notificationManager().getOrCreateDMChannel(appContext) - } + private suspend fun receiveIfNew(event: GiftWrapEvent) { + if (eventCache.get(event.id) == null) { + eventCache.put(event.id, event.id) + EventNotificationConsumer(appContext).consume(event) } + } - override fun onReceive(context: Context, intent: Intent) { - val intentData = intent.dataString - val intentAction = intent.action.toString() - Log.d(TAG, "Intent Data:- $intentData Intent Action: $intentAction") - super.onReceive(context, intent) + override fun onNewEndpoint( + context: Context, + endpoint: String, + instance: String, + ) { + Log.d(TAG, "New endpoint provided:- $endpoint for Instance: $instance") + val sanitizedEndpoint = endpoint.dropLast(5) + pushHandler.setEndpoint(sanitizedEndpoint) + scope.launch(Dispatchers.IO) { + RegisterAccounts(LocalPreferences.allSavedAccounts()).go(sanitizedEndpoint) + notificationManager().getOrCreateZapChannel(appContext) + notificationManager().getOrCreateDMChannel(appContext) } + } - override fun onRegistrationFailed(context: Context, instance: String) { - Log.d(TAG, "Registration failed for Instance: $instance") - scope.cancel() - pushHandler.forceRemoveDistributor(context) - } + override fun onReceive( + context: Context, + intent: Intent, + ) { + val intentData = intent.dataString + val intentAction = intent.action.toString() + Log.d(TAG, "Intent Data:- $intentData Intent Action: $intentAction") + super.onReceive(context, intent) + } - override fun onUnregistered(context: Context, instance: String) { - val removedEndpoint = pushHandler.endpoint - Log.d(TAG, "Endpoint: $removedEndpoint removed for Instance: $instance") - Log.d(TAG, "App is unregistered. ") - pushHandler.forceRemoveDistributor(context) - pushHandler.removeEndpoint() - } + override fun onRegistrationFailed( + context: Context, + instance: String, + ) { + Log.d(TAG, "Registration failed for Instance: $instance") + scope.cancel() + pushHandler.forceRemoveDistributor(context) + } - fun notificationManager(): NotificationManager { - return ContextCompat.getSystemService(appContext, NotificationManager::class.java) as NotificationManager - } + override fun onUnregistered( + context: Context, + instance: String, + ) { + val removedEndpoint = pushHandler.endpoint + Log.d(TAG, "Endpoint: $removedEndpoint removed for Instance: $instance") + Log.d(TAG, "App is unregistered. ") + pushHandler.forceRemoveDistributor(context) + pushHandler.removeEndpoint() + } + + fun notificationManager(): NotificationManager { + return ContextCompat.getSystemService(appContext, NotificationManager::class.java) + as NotificationManager + } } diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt index 6e153d28f..72fb737bc 100644 --- a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt +++ b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt @@ -1,21 +1,42 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service.notifications import android.util.Log import com.vitorpamplona.amethyst.AccountInfo object PushNotificationUtils { - var hasInit: Boolean = false - private val pushHandler = PushDistributorHandler - suspend fun init(accounts: List) { - if (hasInit || pushHandler.savedDistributorExists()) { - return - } - try { - if (pushHandler.savedDistributorExists()) { - RegisterAccounts(accounts).go(pushHandler.getSavedEndpoint()) - } - } catch (e: Exception) { - Log.d("Amethyst-OSSPushUtils", "Failed to get endpoint.") - } + var hasInit: Boolean = false + private val pushHandler = PushDistributorHandler + + suspend fun init(accounts: List) { + if (hasInit || pushHandler.savedDistributorExists()) { + return } + try { + if (pushHandler.savedDistributorExists()) { + RegisterAccounts(accounts).go(pushHandler.getSavedEndpoint()) + } + } catch (e: Exception) { + Log.d("Amethyst-OSSPushUtils", "Failed to get endpoint.") + } + } } diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt b/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt index bb7c67e57..39153244d 100644 --- a/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt +++ b/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import android.util.Log @@ -44,145 +64,143 @@ import kotlinx.collections.immutable.toImmutableList @OptIn(ExperimentalPermissionsApi::class) @Composable fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesViewModel) { - val notificationPermissionState = CheckifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel) + val notificationPermissionState = + CheckifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel) - if (notificationPermissionState.status.isGranted) { - if (!sharedPreferencesViewModel.sharedPrefs.dontShowPushNotificationSelector) { - val context = LocalContext.current - var distributorPresent by remember { - mutableStateOf(PushDistributorHandler.savedDistributorExists()) - } - if (!distributorPresent) { - LoadDistributors() { currentDistributor, list, readableListWithExplainer -> - if (readableListWithExplainer.size > 1) { - SpinnerSelectionDialog( - title = stringResource(id = R.string.select_push_server), - options = readableListWithExplainer, - onSelect = { index -> - if (list[index] == "None") { - PushDistributorHandler.forceRemoveDistributor(context) - sharedPreferencesViewModel.dontAskForNotificationPermissions() - sharedPreferencesViewModel.dontShowPushNotificationSelector() - } else { - val fullDistributorName = list[index] - PushDistributorHandler.saveDistributor(fullDistributorName) - } - distributorPresent = true - Log.d("Amethyst", "NotificationScreen: Distributor registered.") - }, - onDismiss = { - distributorPresent = true - Log.d("Amethyst", "NotificationScreen: Distributor dialog dismissed.") - } - ) - } else { - AlertDialog( - onDismissRequest = { - distributorPresent = true - }, - title = { - Text(stringResource(R.string.push_server_install_app)) - }, - text = { - Material3RichText( - style = RichTextStyle().resolveDefaults() - ) { - Markdown( - content = stringResource(R.string.push_server_install_app_description) - ) - } - }, - confirmButton = { - Row( - modifier = Modifier - .padding(all = 8.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - TextButton(onClick = { - distributorPresent = true - sharedPreferencesViewModel.dontShowPushNotificationSelector() - }) { - Text(stringResource(R.string.quick_action_dont_show_again_button)) - } - Button(onClick = { - distributorPresent = true - }, contentPadding = PaddingValues(horizontal = 16.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null - ) - Spacer(Modifier.width(8.dp)) - Text(stringResource(R.string.error_dialog_button_ok)) - } - } - } - } - ) - } + if (notificationPermissionState.status.isGranted) { + if (!sharedPreferencesViewModel.sharedPrefs.dontShowPushNotificationSelector) { + val context = LocalContext.current + var distributorPresent by remember { + mutableStateOf(PushDistributorHandler.savedDistributorExists()) + } + if (!distributorPresent) { + LoadDistributors { currentDistributor, list, readableListWithExplainer -> + if (readableListWithExplainer.size > 1) { + SpinnerSelectionDialog( + title = stringResource(id = R.string.select_push_server), + options = readableListWithExplainer, + onSelect = { index -> + if (list[index] == "None") { + PushDistributorHandler.forceRemoveDistributor(context) + sharedPreferencesViewModel.dontAskForNotificationPermissions() + sharedPreferencesViewModel.dontShowPushNotificationSelector() + } else { + val fullDistributorName = list[index] + PushDistributorHandler.saveDistributor(fullDistributorName) } - } else { - val currentDistributor = PushDistributorHandler.getSavedDistributor() - PushDistributorHandler.saveDistributor(currentDistributor) - } + distributorPresent = true + Log.d("Amethyst", "NotificationScreen: Distributor registered.") + }, + onDismiss = { + distributorPresent = true + Log.d("Amethyst", "NotificationScreen: Distributor dialog dismissed.") + }, + ) + } else { + AlertDialog( + onDismissRequest = { distributorPresent = true }, + title = { Text(stringResource(R.string.push_server_install_app)) }, + text = { + Material3RichText( + style = RichTextStyle().resolveDefaults(), + ) { + Markdown( + content = stringResource(R.string.push_server_install_app_description), + ) + } + }, + confirmButton = { + Row( + modifier = Modifier.padding(all = 8.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + TextButton( + onClick = { + distributorPresent = true + sharedPreferencesViewModel.dontShowPushNotificationSelector() + }, + ) { + Text(stringResource(R.string.quick_action_dont_show_again_button)) + } + Button( + onClick = { distributorPresent = true }, + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.error_dialog_button_ok)) + } + } + } + }, + ) + } } + } else { + val currentDistributor = PushDistributorHandler.getSavedDistributor() + PushDistributorHandler.saveDistributor(currentDistributor) + } } + } } @Composable fun LoadDistributors( - onInner: @Composable (String, ImmutableList, ImmutableList) -> Unit + onInner: @Composable (String, ImmutableList, ImmutableList) -> Unit ) { - val currentDistributor = PushDistributorHandler.getSavedDistributor().ifBlank { null } ?: "None" + val currentDistributor = PushDistributorHandler.getSavedDistributor().ifBlank { null } ?: "None" - val list = remember { - PushDistributorHandler.getInstalledDistributors().plus("None").toImmutableList() - } + val list = remember { + PushDistributorHandler.getInstalledDistributors().plus("None").toImmutableList() + } - val readableListWithExplainer = - PushDistributorHandler.formattedDistributorNames() - .mapIndexed { index, name -> - TitleExplainer( - name, - stringResource(id = R.string.push_server_uses_app_explainer, list[index]) - ) - } - .plus( - TitleExplainer( - stringResource(id = R.string.push_server_none), - stringResource(id = R.string.push_server_none_explainer) - ) - ) - .toImmutableList() + val readableListWithExplainer = + PushDistributorHandler.formattedDistributorNames() + .mapIndexed { index, name -> + TitleExplainer( + name, + stringResource(id = R.string.push_server_uses_app_explainer, list[index]), + ) + } + .plus( + TitleExplainer( + stringResource(id = R.string.push_server_none), + stringResource(id = R.string.push_server_none_explainer), + ), + ) + .toImmutableList() - onInner( - currentDistributor, - list, - readableListWithExplainer - ) + onInner( + currentDistributor, + list, + readableListWithExplainer, + ) } @Composable fun PushNotificationSettingsRow(sharedPreferencesViewModel: SharedPreferencesViewModel) { - val context = LocalContext.current + val context = LocalContext.current - LoadDistributors() { currentDistributor, list, readableListWithExplainer -> - SettingsRow( - R.string.push_server_title, - R.string.push_server_explainer, - selectedItens = readableListWithExplainer, - selectedIndex = list.indexOf(currentDistributor) - ) { index -> - if (list[index] == "None") { - sharedPreferencesViewModel.dontAskForNotificationPermissions() - sharedPreferencesViewModel.dontShowPushNotificationSelector() - PushDistributorHandler.forceRemoveDistributor(context) - } else { - PushDistributorHandler.saveDistributor(list[index]) - } - } + LoadDistributors { currentDistributor, list, readableListWithExplainer -> + SettingsRow( + R.string.push_server_title, + R.string.push_server_explainer, + selectedItens = readableListWithExplainer, + selectedIndex = list.indexOf(currentDistributor), + ) { index -> + if (list[index] == "None") { + sharedPreferencesViewModel.dontAskForNotificationPermissions() + sharedPreferencesViewModel.dontShowPushNotificationSelector() + PushDistributorHandler.forceRemoveDistributor(context) + } else { + PushDistributorHandler.saveDistributor(list[index]) + } } + } } diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index 5c7855ed1..b91df7aeb 100644 --- a/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import androidx.compose.runtime.Composable @@ -9,19 +29,20 @@ import com.vitorpamplona.quartz.events.ImmutableListOfLists @Composable fun TranslatableRichTextViewer( - content: String, - canPreview: Boolean, - modifier: Modifier = Modifier, - tags: ImmutableListOfLists, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit -) = ExpandableRichTextViewer( + content: String, + canPreview: Boolean, + modifier: Modifier = Modifier, + tags: ImmutableListOfLists, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) = + ExpandableRichTextViewer( content, canPreview, modifier, tags, backgroundColor, accountViewModel, - nav -) + nav, + ) diff --git a/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefresh.kt b/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefresh.kt index 2f8cf6729..0454af354 100644 --- a/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefresh.kt +++ b/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefresh.kt @@ -1,19 +1,23 @@ -/* - * Copyright 2022 The Android Open Source Project +/** + * Copyright (c) 2023 Vitor Pamplona * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: * - * http://www.apache.org/licenses/LICENSE-2.0 + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - package androidx.compose.material3.pullrefresh import androidx.compose.ui.Modifier @@ -32,25 +36,25 @@ import androidx.compose.ui.unit.Velocity * Note that this modifier must be added above a scrolling container, such as a lazy column, in * order to receive scroll events. For example: * - * @sample androidx.compose.material.samples.PullRefreshSample - * - * @param state The [PullRefreshState] associated with this pull-to-refresh component. - * The state will be updated by this modifier. + * @param state The [PullRefreshState] associated with this pull-to-refresh component. The state + * will be updated by this modifier. * @param enabled If not enabled, all scroll delta and fling velocity will be ignored. + * @sample androidx.compose.material.samples.PullRefreshSample */ -// TODO(b/244423199): Move pullRefresh into its own material library similar to material-ripple. fun Modifier.pullRefresh( - state: PullRefreshState, - enabled: Boolean = true -) = inspectable( - inspectorInfo = debugInspectorInfo { + state: PullRefreshState, + enabled: Boolean = true, +) = + inspectable( + inspectorInfo = + debugInspectorInfo { name = "pullRefresh" properties["state"] = state properties["enabled"] = enabled - } -) { + }, + ) { Modifier.pullRefresh(state::onPull, state::onRelease, enabled) -} + } /** * A nested scroll modifier that provides [onPull] and [onRelease] callbacks to aid building custom @@ -59,62 +63,64 @@ fun Modifier.pullRefresh( * Note that this modifier must be added above a scrolling container, such as a lazy column, in * order to receive scroll events. For example: * - * @sample androidx.compose.material.samples.CustomPullRefreshSample - * * @param onPull Callback for dispatching vertical scroll delta, takes float pullDelta as argument. - * Positive delta (pulling down) is dispatched only if the child does not consume it (i.e. pulling - * down despite being at the top of a scrollable component), whereas negative delta (swiping up) is - * dispatched first (in case it is needed to push the indicator back up), and then the unconsumed - * delta is passed on to the child. The callback returns how much delta was consumed. - * @param onRelease Callback for when drag is released, takes float flingVelocity as argument. - * The callback returns how much velocity was consumed - in most cases this should only consume - * velocity if pull refresh has been dragged already and the velocity is positive (the fling is - * downwards), as an upwards fling should typically still scroll a scrollable component beneath the - * pullRefresh. This is invoked before any remaining velocity is passed to the child. + * Positive delta (pulling down) is dispatched only if the child does not consume it (i.e. pulling + * down despite being at the top of a scrollable component), whereas negative delta (swiping up) + * is dispatched first (in case it is needed to push the indicator back up), and then the + * unconsumed delta is passed on to the child. The callback returns how much delta was consumed. + * @param onRelease Callback for when drag is released, takes float flingVelocity as argument. The + * callback returns how much velocity was consumed - in most cases this should only consume + * velocity if pull refresh has been dragged already and the velocity is positive (the fling is + * downwards), as an upwards fling should typically still scroll a scrollable component beneath + * the pullRefresh. This is invoked before any remaining velocity is passed to the child. * @param enabled If not enabled, all scroll delta and fling velocity will be ignored and neither - * [onPull] nor [onRelease] will be invoked. + * [onPull] nor [onRelease] will be invoked. + * @sample androidx.compose.material.samples.CustomPullRefreshSample */ fun Modifier.pullRefresh( - onPull: (pullDelta: Float) -> Float, - onRelease: suspend (flingVelocity: Float) -> Float, - enabled: Boolean = true -) = inspectable( - inspectorInfo = debugInspectorInfo { + onPull: (pullDelta: Float) -> Float, + onRelease: suspend (flingVelocity: Float) -> Float, + enabled: Boolean = true, +) = + inspectable( + inspectorInfo = + debugInspectorInfo { name = "pullRefresh" properties["onPull"] = onPull properties["onRelease"] = onRelease properties["enabled"] = enabled - } -) { + }, + ) { Modifier.nestedScroll(PullRefreshNestedScrollConnection(onPull, onRelease, enabled)) -} + } private class PullRefreshNestedScrollConnection( - private val onPull: (pullDelta: Float) -> Float, - private val onRelease: suspend (flingVelocity: Float) -> Float, - private val enabled: Boolean + private val onPull: (pullDelta: Float) -> Float, + private val onRelease: suspend (flingVelocity: Float) -> Float, + private val enabled: Boolean, ) : NestedScrollConnection { - - override fun onPreScroll( - available: Offset, - source: NestedScrollSource - ): Offset = when { - !enabled -> Offset.Zero - source == Drag && available.y < 0 -> Offset(0f, onPull(available.y)) // Swiping up - else -> Offset.Zero + override fun onPreScroll( + available: Offset, + source: NestedScrollSource, + ): Offset = + when { + !enabled -> Offset.Zero + source == Drag && available.y < 0 -> Offset(0f, onPull(available.y)) // Swiping up + else -> Offset.Zero } - override fun onPostScroll( - consumed: Offset, - available: Offset, - source: NestedScrollSource - ): Offset = when { - !enabled -> Offset.Zero - source == Drag && available.y > 0 -> Offset(0f, onPull(available.y)) // Pulling down - else -> Offset.Zero + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset = + when { + !enabled -> Offset.Zero + source == Drag && available.y > 0 -> Offset(0f, onPull(available.y)) // Pulling down + else -> Offset.Zero } - override suspend fun onPreFling(available: Velocity): Velocity { - return Velocity(0f, onRelease(available.y)) - } + override suspend fun onPreFling(available: Velocity): Velocity { + return Velocity(0f, onRelease(available.y)) + } } diff --git a/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicator.kt b/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicator.kt index e351e5f56..f62eaed9e 100644 --- a/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicator.kt +++ b/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicator.kt @@ -1,19 +1,23 @@ -/* - * Copyright 2022 The Android Open Source Project +/** + * Copyright (c) 2023 Vitor Pamplona * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: * - * http://www.apache.org/licenses/LICENSE-2.0 + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - package androidx.compose.material3.pullrefresh import androidx.compose.animation.Crossfade @@ -56,173 +60,162 @@ import kotlin.math.pow /** * The default indicator for Compose pull-to-refresh, based on Android's SwipeRefreshLayout. * - * @sample androidx.compose.material.samples.PullRefreshSample - * * @param refreshing A boolean representing whether a refresh is occurring. * @param state The [PullRefreshState] which controls where and how the indicator will be drawn. * @param modifier Modifiers for the indicator. * @param backgroundColor The color of the indicator's background. * @param contentColor The color of the indicator's arc and arrow. * @param scale A boolean controlling whether the indicator's size scales with pull progress or not. + * @sample androidx.compose.material.samples.PullRefreshSample */ -// TODO(b/244423199): Consider whether the state parameter should be replaced with lambdas to -// enable people to use this indicator with custom pull-to-refresh components. @Composable fun PullRefreshIndicator( - refreshing: Boolean, - state: PullRefreshState, - modifier: Modifier = Modifier, - backgroundColor: Color = MaterialTheme.colorScheme.surface, - contentColor: Color = contentColorFor(backgroundColor), - scale: Boolean = false + refreshing: Boolean, + state: PullRefreshState, + modifier: Modifier = Modifier, + backgroundColor: Color = MaterialTheme.colorScheme.surface, + contentColor: Color = contentColorFor(backgroundColor), + scale: Boolean = false, ) { - val showElevation by remember(refreshing, state) { - derivedStateOf { refreshing || state.position > 0.5f } - } + val showElevation by + remember(refreshing, state) { derivedStateOf { refreshing || state.position > 0.5f } } - Surface( - modifier = modifier - .size(IndicatorSize) - .pullRefreshIndicatorTransform(state, scale), - shape = SpinnerShape, - color = backgroundColor, - shadowElevation = if (showElevation) Elevation else 0.dp - ) { - Crossfade( - targetState = refreshing, - animationSpec = tween(durationMillis = CrossfadeDurationMs) - ) { refreshing -> - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - val spinnerSize = (ArcRadius + StrokeWidth).times(2) + Surface( + modifier = modifier.size(IndicatorSize).pullRefreshIndicatorTransform(state, scale), + shape = SpinnerShape, + color = backgroundColor, + shadowElevation = if (showElevation) Elevation else 0.dp, + ) { + Crossfade( + targetState = refreshing, + animationSpec = tween(durationMillis = CROSSFADE_DURATION_MS), + ) { refreshing -> + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + val spinnerSize = (ArcRadius + StrokeWidth).times(2) - if (refreshing) { - CircularProgressIndicator( - color = contentColor, - strokeWidth = StrokeWidth, - modifier = Modifier.size(spinnerSize) - ) - } else { - CircularArrowIndicator(state, contentColor, Modifier.size(spinnerSize)) - } - } + if (refreshing) { + CircularProgressIndicator( + color = contentColor, + strokeWidth = StrokeWidth, + modifier = Modifier.size(spinnerSize), + ) + } else { + CircularArrowIndicator(state, contentColor, Modifier.size(spinnerSize)) } + } } + } } -/** - * Modifier.size MUST be specified. - */ +/** Modifier.size MUST be specified. */ @Composable private fun CircularArrowIndicator( - state: PullRefreshState, - color: Color, - modifier: Modifier + state: PullRefreshState, + color: Color, + modifier: Modifier, ) { - val path = remember { Path().apply { fillType = PathFillType.EvenOdd } } + val path = remember { Path().apply { fillType = PathFillType.EvenOdd } } - val targetAlpha by remember(state) { - derivedStateOf { - if (state.progress >= 1f) MaxAlpha else MinAlpha - } - } - - val alphaState = animateFloatAsState(targetValue = targetAlpha, animationSpec = AlphaTween) - - // Empty semantics for tests - Canvas(modifier.semantics {}) { - val values = ArrowValues(state.progress) - val alpha = alphaState.value - - rotate(degrees = values.rotation) { - val arcRadius = ArcRadius.toPx() + StrokeWidth.toPx() / 2f - val arcBounds = Rect( - size.center.x - arcRadius, - size.center.y - arcRadius, - size.center.x + arcRadius, - size.center.y + arcRadius - ) - drawArc( - color = color, - alpha = alpha, - startAngle = values.startAngle, - sweepAngle = values.endAngle - values.startAngle, - useCenter = false, - topLeft = arcBounds.topLeft, - size = arcBounds.size, - style = Stroke( - width = StrokeWidth.toPx(), - cap = StrokeCap.Square - ) - ) - drawArrow(path, arcBounds, color, alpha, values) - } + val targetAlpha by + remember(state) { derivedStateOf { if (state.progress >= 1f) MAX_ALPHA else MIN_ALPHA } } + + val alphaState = animateFloatAsState(targetValue = targetAlpha, animationSpec = AlphaTween) + + // Empty semantics for tests + Canvas(modifier.semantics {}) { + val values = ArrowValues(state.progress) + val alpha = alphaState.value + + rotate(degrees = values.rotation) { + val arcRadius = ArcRadius.toPx() + StrokeWidth.toPx() / 2f + val arcBounds = + Rect( + size.center.x - arcRadius, + size.center.y - arcRadius, + size.center.x + arcRadius, + size.center.y + arcRadius, + ) + drawArc( + color = color, + alpha = alpha, + startAngle = values.startAngle, + sweepAngle = values.endAngle - values.startAngle, + useCenter = false, + topLeft = arcBounds.topLeft, + size = arcBounds.size, + style = + Stroke( + width = StrokeWidth.toPx(), + cap = StrokeCap.Square, + ), + ) + drawArrow(path, arcBounds, color, alpha, values) } + } } @Immutable private class ArrowValues( - val rotation: Float, - val startAngle: Float, - val endAngle: Float, - val scale: Float + val rotation: Float, + val startAngle: Float, + val endAngle: Float, + val scale: Float, ) private fun ArrowValues(progress: Float): ArrowValues { - // Discard first 40% of progress. Scale remaining progress to full range between 0 and 100%. - val adjustedPercent = max(min(1f, progress) - 0.4f, 0f) * 5 / 3 - // How far beyond the threshold pull has gone, as a percentage of the threshold. - val overshootPercent = abs(progress) - 1.0f - // Limit the overshoot to 200%. Linear between 0 and 200. - val linearTension = overshootPercent.coerceIn(0f, 2f) - // Non-linear tension. Increases with linearTension, but at a decreasing rate. - val tensionPercent = linearTension - linearTension.pow(2) / 4 + // Discard first 40% of progress. Scale remaining progress to full range between 0 and 100%. + val adjustedPercent = max(min(1f, progress) - 0.4f, 0f) * 5 / 3 + // How far beyond the threshold pull has gone, as a percentage of the threshold. + val overshootPercent = abs(progress) - 1.0f + // Limit the overshoot to 200%. Linear between 0 and 200. + val linearTension = overshootPercent.coerceIn(0f, 2f) + // Non-linear tension. Increases with linearTension, but at a decreasing rate. + val tensionPercent = linearTension - linearTension.pow(2) / 4 - // Calculations based on SwipeRefreshLayout specification. - val endTrim = adjustedPercent * MaxProgressArc - val rotation = (-0.25f + 0.4f * adjustedPercent + tensionPercent) * 0.5f - val startAngle = rotation * 360 - val endAngle = (rotation + endTrim) * 360 - val scale = min(1f, adjustedPercent) + // Calculations based on SwipeRefreshLayout specification. + val endTrim = adjustedPercent * MAX_PROGRESS_ARC + val rotation = (-0.25f + 0.4f * adjustedPercent + tensionPercent) * 0.5f + val startAngle = rotation * 360 + val endAngle = (rotation + endTrim) * 360 + val scale = min(1f, adjustedPercent) - return ArrowValues(rotation, startAngle, endAngle, scale) + return ArrowValues(rotation, startAngle, endAngle, scale) } private fun DrawScope.drawArrow( - arrow: Path, - bounds: Rect, - color: Color, - alpha: Float, - values: ArrowValues + arrow: Path, + bounds: Rect, + color: Color, + alpha: Float, + values: ArrowValues, ) { - arrow.reset() - arrow.moveTo(0f, 0f) // Move to left corner - arrow.lineTo(x = ArrowWidth.toPx() * values.scale, y = 0f) // Line to right corner + arrow.reset() + arrow.moveTo(0f, 0f) // Move to left corner + arrow.lineTo(x = ArrowWidth.toPx() * values.scale, y = 0f) // Line to right corner - // Line to tip of arrow - arrow.lineTo( - x = ArrowWidth.toPx() * values.scale / 2, - y = ArrowHeight.toPx() * values.scale - ) + // Line to tip of arrow + arrow.lineTo( + x = ArrowWidth.toPx() * values.scale / 2, + y = ArrowHeight.toPx() * values.scale, + ) - val radius = min(bounds.width, bounds.height) / 2f - val inset = ArrowWidth.toPx() * values.scale / 2f - arrow.translate( - Offset( - x = radius + bounds.center.x - inset, - y = bounds.center.y + StrokeWidth.toPx() / 2f - ) - ) - arrow.close() - rotate(degrees = values.endAngle) { - drawPath(path = arrow, color = color, alpha = alpha) - } + val radius = min(bounds.width, bounds.height) / 2f + val inset = ArrowWidth.toPx() * values.scale / 2f + arrow.translate( + Offset( + x = radius + bounds.center.x - inset, + y = bounds.center.y + StrokeWidth.toPx() / 2f, + ), + ) + arrow.close() + rotate(degrees = values.endAngle) { drawPath(path = arrow, color = color, alpha = alpha) } } -private const val CrossfadeDurationMs = 100 -private const val MaxProgressArc = 0.8f +private const val CROSSFADE_DURATION_MS = 100 +private const val MAX_PROGRESS_ARC = 0.8f private val IndicatorSize = 40.dp private val SpinnerShape = CircleShape @@ -233,6 +226,6 @@ private val ArrowHeight = 5.dp private val Elevation = 6.dp // Values taken from SwipeRefreshLayout -private const val MinAlpha = 0.3f -private const val MaxAlpha = 1f +private const val MIN_ALPHA = 0.3f +private const val MAX_ALPHA = 1f private val AlphaTween = tween(300, easing = LinearEasing) diff --git a/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicatorTransform.kt b/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicatorTransform.kt index 6377d759e..8a0bbeefb 100644 --- a/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicatorTransform.kt +++ b/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicatorTransform.kt @@ -1,19 +1,23 @@ -/* - * Copyright 2022 The Android Open Source Project +/** + * Copyright (c) 2023 Vitor Pamplona * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: * - * http://www.apache.org/licenses/LICENSE-2.0 + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - package androidx.compose.material3.pullrefresh import androidx.compose.animation.core.LinearOutSlowInEasing @@ -25,51 +29,50 @@ import androidx.compose.ui.platform.debugInspectorInfo import androidx.compose.ui.platform.inspectable /** - * A modifier for translating the position and scaling the size of a pull-to-refresh indicator - * based on the given [PullRefreshState]. - * - * @sample androidx.compose.material.samples.PullRefreshIndicatorTransformSample + * A modifier for translating the position and scaling the size of a pull-to-refresh indicator based + * on the given [PullRefreshState]. * * @param state The [PullRefreshState] which determines the position of the indicator. * @param scale A boolean controlling whether the indicator's size scales with pull progress or not. + * @sample androidx.compose.material.samples.PullRefreshIndicatorTransformSample */ -// TODO: Consider whether the state parameter should be replaced with lambdas. fun Modifier.pullRefreshIndicatorTransform( - state: PullRefreshState, - scale: Boolean = false -) = inspectable( - inspectorInfo = debugInspectorInfo { + state: PullRefreshState, + scale: Boolean = false, +) = + inspectable( + inspectorInfo = + debugInspectorInfo { name = "pullRefreshIndicatorTransform" properties["state"] = state properties["scale"] = scale - } -) { + }, + ) { Modifier - // Essentially we only want to clip the at the top, so the indicator will not appear when - // the position is 0. It is preferable to clip the indicator as opposed to the layout that - // contains the indicator, as this would also end up clipping shadows drawn by items in a - // list for example - so we leave the clipping to the scrolling container. We use MAX_VALUE - // for the other dimensions to allow for more room for elevation / arbitrary indicators - we - // only ever really want to clip at the top edge. - .drawWithContent { - clipRect( - top = 0f, - left = -Float.MAX_VALUE, - right = Float.MAX_VALUE, - bottom = Float.MAX_VALUE - ) { - this@drawWithContent.drawContent() - } + // Essentially we only want to clip the at the top, so the indicator will not appear when + // the position is 0. It is preferable to clip the indicator as opposed to the layout that + // contains the indicator, as this would also end up clipping shadows drawn by items in a + // list for example - so we leave the clipping to the scrolling container. We use MAX_VALUE + // for the other dimensions to allow for more room for elevation / arbitrary indicators - we + // only ever really want to clip at the top edge. + .drawWithContent { + clipRect( + top = 0f, + left = -Float.MAX_VALUE, + right = Float.MAX_VALUE, + bottom = Float.MAX_VALUE, + ) { + this@drawWithContent.drawContent() } - .graphicsLayer { - translationY = state.position - size.height + } + .graphicsLayer { + translationY = state.position - size.height - if (scale && !state.refreshing) { - val scaleFraction = LinearOutSlowInEasing - .transform(state.position / state.threshold) - .coerceIn(0f, 1f) - scaleX = scaleFraction - scaleY = scaleFraction - } + if (scale && !state.refreshing) { + val scaleFraction = + LinearOutSlowInEasing.transform(state.position / state.threshold).coerceIn(0f, 1f) + scaleX = scaleFraction + scaleY = scaleFraction } -} + } + } diff --git a/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshState.kt b/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshState.kt index 86383eb70..97687d053 100644 --- a/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshState.kt +++ b/app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshState.kt @@ -1,19 +1,23 @@ -/* - * Copyright 2022 The Android Open Source Project +/** + * Copyright (c) 2023 Vitor Pamplona * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: * - * http://www.apache.org/licenses/LICENSE-2.0 + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - package androidx.compose.material3.pullrefresh import androidx.compose.animation.core.animate @@ -31,55 +35,53 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch import kotlin.math.abs import kotlin.math.pow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch /** * Creates a [PullRefreshState] that is remembered across compositions. * * Changes to [refreshing] will result in [PullRefreshState] being updated. * - * @sample androidx.compose.material.samples.PullRefreshSample - * * @param refreshing A boolean representing whether a refresh is currently occurring. * @param onRefresh The function to be called to trigger a refresh. - * @param refreshThreshold The threshold below which, if a release - * occurs, [onRefresh] will be called. + * @param refreshThreshold The threshold below which, if a release occurs, [onRefresh] will be + * called. * @param refreshingOffset The offset at which the indicator will be drawn while refreshing. This - * offset corresponds to the position of the bottom of the indicator. + * offset corresponds to the position of the bottom of the indicator. + * @sample androidx.compose.material.samples.PullRefreshSample */ @Composable fun rememberPullRefreshState( - refreshing: Boolean, - onRefresh: () -> Unit, - refreshThreshold: Dp = PullRefreshDefaults.RefreshThreshold, - refreshingOffset: Dp = PullRefreshDefaults.RefreshingOffset + refreshing: Boolean, + onRefresh: () -> Unit, + refreshThreshold: Dp = PullRefreshDefaults.RefreshThreshold, + refreshingOffset: Dp = PullRefreshDefaults.RefreshingOffset, ): PullRefreshState { - require(refreshThreshold > 0.dp) { "The refresh trigger must be greater than zero!" } + require(refreshThreshold > 0.dp) { "The refresh trigger must be greater than zero!" } - val scope = rememberCoroutineScope() - val onRefreshState = rememberUpdatedState(onRefresh) - val thresholdPx: Float - val refreshingOffsetPx: Float + val scope = rememberCoroutineScope() + val onRefreshState = rememberUpdatedState(onRefresh) + val thresholdPx: Float + val refreshingOffsetPx: Float - with(LocalDensity.current) { - thresholdPx = refreshThreshold.toPx() - refreshingOffsetPx = refreshingOffset.toPx() - } + with(LocalDensity.current) { + thresholdPx = refreshThreshold.toPx() + refreshingOffsetPx = refreshingOffset.toPx() + } - val state = remember(scope) { - PullRefreshState(scope, onRefreshState, refreshingOffsetPx, thresholdPx) - } + val state = + remember(scope) { PullRefreshState(scope, onRefreshState, refreshingOffsetPx, thresholdPx) } - SideEffect { - state.setRefreshing(refreshing) - state.setThreshold(thresholdPx) - state.setRefreshingOffset(refreshingOffsetPx) - } + SideEffect { + state.setRefreshing(refreshing) + state.setThreshold(thresholdPx) + state.setRefreshingOffset(refreshingOffsetPx) + } - return state + return state } /** @@ -95,134 +97,138 @@ fun rememberPullRefreshState( * * Should be created using [rememberPullRefreshState]. */ -class PullRefreshState internal constructor( - private val animationScope: CoroutineScope, - private val onRefreshState: State<() -> Unit>, - refreshingOffset: Float, - threshold: Float +class PullRefreshState +internal constructor( + private val animationScope: CoroutineScope, + private val onRefreshState: State<() -> Unit>, + refreshingOffset: Float, + threshold: Float, ) { - /** - * A float representing how far the user has pulled as a percentage of the refreshThreshold. - * - * If the component has not been pulled at all, progress is zero. If the pull has reached - * halfway to the threshold, progress is 0.5f. A value greater than 1 indicates that pull has - * gone beyond the refreshThreshold - e.g. a value of 2f indicates that the user has pulled to - * two times the refreshThreshold. - */ - val progress get() = adjustedDistancePulled / threshold + /** + * A float representing how far the user has pulled as a percentage of the refreshThreshold. + * + * If the component has not been pulled at all, progress is zero. If the pull has reached halfway + * to the threshold, progress is 0.5f. A value greater than 1 indicates that pull has gone beyond + * the refreshThreshold - e.g. a value of 2f indicates that the user has pulled to two times the + * refreshThreshold. + */ + val progress + get() = adjustedDistancePulled / threshold - internal val refreshing get() = _refreshing - internal val position get() = _position - internal val threshold get() = _threshold + val refreshing + get() = _refreshing - private val adjustedDistancePulled by derivedStateOf { distancePulled * DragMultiplier } + val position + get() = _position - private var _refreshing by mutableStateOf(false) - private var _position by mutableStateOf(0f) - private var distancePulled by mutableStateOf(0f) - private var _threshold by mutableStateOf(threshold) - private var _refreshingOffset by mutableStateOf(refreshingOffset) + val threshold + get() = _threshold - internal fun onPull(pullDelta: Float): Float { - if (_refreshing) return 0f // Already refreshing, do nothing. + private val adjustedDistancePulled by derivedStateOf { distancePulled * DRAG_MULTIPLIER } - val newOffset = (distancePulled + pullDelta).coerceAtLeast(0f) - val dragConsumed = newOffset - distancePulled - distancePulled = newOffset - _position = calculateIndicatorPosition() - return dragConsumed + private var _refreshing by mutableStateOf(false) + private var _position by mutableStateOf(0f) + private var distancePulled by mutableStateOf(0f) + private var _threshold by mutableStateOf(threshold) + private var refreshingOffsetState by mutableStateOf(refreshingOffset) + + internal fun onPull(pullDelta: Float): Float { + if (_refreshing) return 0f // Already refreshing, do nothing. + + val newOffset = (distancePulled + pullDelta).coerceAtLeast(0f) + val dragConsumed = newOffset - distancePulled + distancePulled = newOffset + _position = calculateIndicatorPosition() + return dragConsumed + } + + internal fun onRelease(velocity: Float): Float { + if (refreshing) return 0f // Already refreshing, do nothing + + if (adjustedDistancePulled > threshold) { + onRefreshState.value() + } + animateIndicatorTo(0f) + val consumed = + when { + // We are flinging without having dragged the pull refresh (for example a fling inside + // a list) - don't consume + distancePulled == 0f -> 0f + // If the velocity is negative, the fling is upwards, and we don't want to prevent the + // the list from scrolling + velocity < 0f -> 0f + // We are showing the indicator, and the fling is downwards - consume everything + else -> velocity + } + distancePulled = 0f + return consumed + } + + internal fun setRefreshing(refreshing: Boolean) { + if (_refreshing != refreshing) { + _refreshing = refreshing + distancePulled = 0f + animateIndicatorTo(if (refreshing) refreshingOffsetState else 0f) + } + } + + internal fun setThreshold(threshold: Float) { + _threshold = threshold + } + + internal fun setRefreshingOffset(refreshingOffset: Float) { + if (refreshingOffsetState != refreshingOffset) { + refreshingOffsetState = refreshingOffset + if (refreshing) animateIndicatorTo(refreshingOffset) + } + } + + // Make sure to cancel any existing animations when we launch a new one. We use this instead of + // Animatable as calling snapTo() on every drag delta has a one frame delay, and some extra + // overhead of running through the animation pipeline instead of directly mutating the state. + private val mutatorMutex = MutatorMutex() + + private fun animateIndicatorTo(offset: Float) = + animationScope.launch { + mutatorMutex.mutate { + animate(initialValue = _position, targetValue = offset) { value, _ -> _position = value } + } } - internal fun onRelease(velocity: Float): Float { - if (refreshing) return 0f // Already refreshing, do nothing - - if (adjustedDistancePulled > threshold) { - onRefreshState.value() - } - animateIndicatorTo(0f) - val consumed = when { - // We are flinging without having dragged the pull refresh (for example a fling inside - // a list) - don't consume - distancePulled == 0f -> 0f - // If the velocity is negative, the fling is upwards, and we don't want to prevent the - // the list from scrolling - velocity < 0f -> 0f - // We are showing the indicator, and the fling is downwards - consume everything - else -> velocity - } - distancePulled = 0f - return consumed - } - - internal fun setRefreshing(refreshing: Boolean) { - if (_refreshing != refreshing) { - _refreshing = refreshing - distancePulled = 0f - animateIndicatorTo(if (refreshing) _refreshingOffset else 0f) - } - } - - internal fun setThreshold(threshold: Float) { - _threshold = threshold - } - - internal fun setRefreshingOffset(refreshingOffset: Float) { - if (_refreshingOffset != refreshingOffset) { - _refreshingOffset = refreshingOffset - if (refreshing) animateIndicatorTo(refreshingOffset) - } - } - - // Make sure to cancel any existing animations when we launch a new one. We use this instead of - // Animatable as calling snapTo() on every drag delta has a one frame delay, and some extra - // overhead of running through the animation pipeline instead of directly mutating the state. - private val mutatorMutex = MutatorMutex() - - private fun animateIndicatorTo(offset: Float) = animationScope.launch { - mutatorMutex.mutate { - animate(initialValue = _position, targetValue = offset) { value, _ -> - _position = value - } - } - } - - private fun calculateIndicatorPosition(): Float = when { - // If drag hasn't gone past the threshold, the position is the adjustedDistancePulled. - adjustedDistancePulled <= threshold -> adjustedDistancePulled - else -> { - // How far beyond the threshold pull has gone, as a percentage of the threshold. - val overshootPercent = abs(progress) - 1.0f - // Limit the overshoot to 200%. Linear between 0 and 200. - val linearTension = overshootPercent.coerceIn(0f, 2f) - // Non-linear tension. Increases with linearTension, but at a decreasing rate. - val tensionPercent = linearTension - linearTension.pow(2) / 4 - // The additional offset beyond the threshold. - val extraOffset = threshold * tensionPercent - threshold + extraOffset - } + private fun calculateIndicatorPosition(): Float = + when { + // If drag hasn't gone past the threshold, the position is the adjustedDistancePulled. + adjustedDistancePulled <= threshold -> adjustedDistancePulled + else -> { + // How far beyond the threshold pull has gone, as a percentage of the threshold. + val overshootPercent = abs(progress) - 1.0f + // Limit the overshoot to 200%. Linear between 0 and 200. + val linearTension = overshootPercent.coerceIn(0f, 2f) + // Non-linear tension. Increases with linearTension, but at a decreasing rate. + val tensionPercent = linearTension - linearTension.pow(2) / 4 + // The additional offset beyond the threshold. + val extraOffset = threshold * tensionPercent + threshold + extraOffset + } } } -/** - * Default parameter values for [rememberPullRefreshState]. - */ +/** Default parameter values for [rememberPullRefreshState]. */ object PullRefreshDefaults { - /** - * If the indicator is below this threshold offset when it is released, a refresh - * will be triggered. - */ - val RefreshThreshold = 80.dp + /** + * If the indicator is below this threshold offset when it is released, a refresh will be + * triggered. + */ + val RefreshThreshold = 80.dp - /** - * The offset at which the indicator should be rendered whilst a refresh is occurring. - */ - val RefreshingOffset = 56.dp + /** The offset at which the indicator should be rendered whilst a refresh is occurring. */ + val RefreshingOffset = 56.dp } /** - * The distance pulled is multiplied by this value to give us the adjusted distance pulled, which - * is used in calculating the indicator position (when the adjusted distance pulled is less than - * the refresh threshold, it is the indicator position, otherwise the indicator position is - * derived from the progress). + * The distance pulled is multiplied by this value to give us the adjusted distance pulled, which is + * used in calculating the indicator position (when the adjusted distance pulled is less than the + * refresh threshold, it is the indicator position, otherwise the indicator position is derived from + * the progress). */ -private const val DragMultiplier = 0.5f +private const val DRAG_MULTIPLIER = 0.5f diff --git a/app/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/app/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index 3e15e6e78..dcf63a76e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -1,85 +1,100 @@ -package com.vitorpamplona.amethyst - -import android.app.Application -import android.content.Context -import android.os.StrictMode -import android.os.StrictMode.ThreadPolicy -import android.os.StrictMode.VmPolicy -import android.util.Log -import coil.ImageLoader -import coil.disk.DiskCache -import com.vitorpamplona.amethyst.service.playback.VideoCache -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import java.io.File -import kotlin.time.measureTimedValue - -class Amethyst : Application() { - val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - override fun onTerminate() { - super.onTerminate() - applicationIOScope.cancel() - } - - val videoCache: VideoCache by lazy { - val newCache = VideoCache() - newCache.initFileCache(this) - newCache - } - - private val imageCache: DiskCache by lazy { - DiskCache.Builder() - .directory(applicationContext.safeCacheDir.resolve("image_cache")) - .maxSizePercent(0.2) - .maximumMaxSizeBytes(500L * 1024 * 1024) // 250MB - .build() - } - - override fun onCreate() { - super.onCreate() - instance = this - - if (BuildConfig.DEBUG) { - StrictMode.setThreadPolicy( - ThreadPolicy.Builder() - .detectAll() - .penaltyLog() - .build() - ) - StrictMode.setVmPolicy( - VmPolicy.Builder() - .detectAll() - .penaltyLog() - .build() - ) - } - - GlobalScope.launch(Dispatchers.IO) { - val (value, elapsed) = measureTimedValue { - // initializes the video cache in a thread - videoCache - } - Log.d("Rendering Metrics", "VideoCache initialized in $elapsed") - } - } - - fun imageLoaderBuilder(): ImageLoader.Builder { - return ImageLoader.Builder(applicationContext).diskCache { imageCache } - } - - companion object { - lateinit var instance: Amethyst - private set - } -} - -internal val Context.safeCacheDir: File - get() { - val cacheDir = checkNotNull(cacheDir) { "cacheDir == null" } - return cacheDir.apply { mkdirs() } - } +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst + +import android.app.Application +import android.content.Context +import android.os.StrictMode +import android.os.StrictMode.ThreadPolicy +import android.os.StrictMode.VmPolicy +import android.util.Log +import coil.ImageLoader +import coil.disk.DiskCache +import com.vitorpamplona.amethyst.service.playback.VideoCache +import java.io.File +import kotlin.time.measureTimedValue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +class Amethyst : Application() { + val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + override fun onTerminate() { + super.onTerminate() + applicationIOScope.cancel() + } + + val videoCache: VideoCache by lazy { + val newCache = VideoCache() + newCache.initFileCache(this) + newCache + } + + private val imageCache: DiskCache by lazy { + DiskCache.Builder() + .directory(applicationContext.safeCacheDir.resolve("image_cache")) + .maxSizePercent(0.2) + .maximumMaxSizeBytes(500L * 1024 * 1024) // 250MB + .build() + } + + override fun onCreate() { + super.onCreate() + instance = this + + if (BuildConfig.DEBUG) { + StrictMode.setThreadPolicy( + ThreadPolicy.Builder().detectAll().penaltyLog().build(), + ) + StrictMode.setVmPolicy( + VmPolicy.Builder().detectAll().penaltyLog().build(), + ) + } + + GlobalScope.launch(Dispatchers.IO) { + val (value, elapsed) = + measureTimedValue { + // initializes the video cache in a thread + videoCache + } + Log.d("Rendering Metrics", "VideoCache initialized in $elapsed") + } + } + + fun imageLoaderBuilder(): ImageLoader.Builder { + return ImageLoader.Builder(applicationContext).diskCache { imageCache } + } + + companion object { + lateinit var instance: Amethyst + private set + } +} + +internal val Context.safeCacheDir: File + get() { + val cacheDir = checkNotNull(cacheDir) { "cacheDir == null" } + return cacheDir.apply { mkdirs() } + } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt b/app/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt index 0ab47841c..e243c751b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt @@ -1,30 +1,51 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey object EncryptedStorage { - private const val PREFERENCES_NAME = "secret_keeper" + private const val PREFERENCES_NAME = "secret_keeper" - // returns the preferences for each account or a global file if null. - fun prefsFileName(npub: String? = null): String { - return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub" - } + // returns the preferences for each account or a global file if null. + fun prefsFileName(npub: String? = null): String { + return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub" + } - fun preferences(npub: String? = null): EncryptedSharedPreferences { - val context = Amethyst.instance - val masterKey: MasterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() + fun preferences(npub: String? = null): EncryptedSharedPreferences { + val context = Amethyst.instance + val masterKey: MasterKey = + MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() - val preferencesName = prefsFileName(npub) + val preferencesName = prefsFileName(npub) - return EncryptedSharedPreferences.create( - context, - preferencesName, - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) as EncryptedSharedPreferences - } + return EncryptedSharedPreferences.create( + context, + preferencesName, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) as EncryptedSharedPreferences + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 50f1685e1..844598014 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst import android.annotation.SuppressLint @@ -33,13 +53,13 @@ import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.signers.ExternalSignerLauncher import com.vitorpamplona.quartz.signers.NostrSignerExternal import com.vitorpamplona.quartz.signers.NostrSignerInternal +import java.io.File +import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import java.io.File -import java.util.Locale // Release mode (!BuildConfig.DEBUG) always uses encrypted preferences // To use plaintext SharedPreferences for debugging, set this to true @@ -49,498 +69,583 @@ private const val DEBUG_PREFERENCES_NAME = "debug_prefs" @Immutable data class AccountInfo( - val npub: String, - val hasPrivKey: Boolean, - val loggedInWithExternalSigner: Boolean + val npub: String, + val hasPrivKey: Boolean, + val loggedInWithExternalSigner: Boolean, ) private object PrefKeys { - const val CURRENT_ACCOUNT = "currently_logged_in_account" - const val SAVED_ACCOUNTS = "all_saved_accounts" - const val NOSTR_PRIVKEY = "nostr_privkey" - const val NOSTR_PUBKEY = "nostr_pubkey" - const val RELAYS = "relays" - const val DONT_TRANSLATE_FROM = "dontTranslateFrom" - const val LANGUAGE_PREFS = "languagePreferences" - const val TRANSLATE_TO = "translateTo" - const val ZAP_AMOUNTS = "zapAmounts" - const val REACTION_CHOICES = "reactionChoices" - const val DEFAULT_ZAPTYPE = "defaultZapType" - const val DEFAULT_FILE_SERVER = "defaultFileServer" - const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList" - const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList" - const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList" - const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList" - const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer" - const val LATEST_CONTACT_LIST = "latestContactList" - const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog" - const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog" - const val HIDE_NIP_24_WARNING_DIALOG = "hide_nip24_warning_dialog" - const val USE_PROXY = "use_proxy" - const val PROXY_PORT = "proxy_port" - const val SHOW_SENSITIVE_CONTENT = "show_sensitive_content" - const val WARN_ABOUT_REPORTS = "warn_about_reports" - const val FILTER_SPAM_FROM_STRANGERS = "filter_spam_from_strangers" - const val LAST_READ_PER_ROUTE = "last_read_route_per_route" - const val AUTOMATICALLY_SHOW_IMAGES = "automatically_show_images" - const val AUTOMATICALLY_START_PLAYBACK = "automatically_start_playback" - const val THEME = "theme" - const val PREFERRED_LANGUAGE = "preferred_Language" - const val AUTOMATICALLY_LOAD_URL_PREVIEW = "automatically_load_url_preview" - const val AUTOMATICALLY_HIDE_NAV_BARS = "automatically_hide_nav_bars" - const val LOGIN_WITH_EXTERNAL_SIGNER = "login_with_external_signer" - const val AUTOMATICALLY_SHOW_PROFILE_PICTURE = "automatically_show_profile_picture" - const val SIGNER_PACKAGE_NAME = "signer_package_name" + const val CURRENT_ACCOUNT = "currently_logged_in_account" + const val SAVED_ACCOUNTS = "all_saved_accounts" + const val NOSTR_PRIVKEY = "nostr_privkey" + const val NOSTR_PUBKEY = "nostr_pubkey" + const val RELAYS = "relays" + const val DONT_TRANSLATE_FROM = "dontTranslateFrom" + const val LANGUAGE_PREFS = "languagePreferences" + const val TRANSLATE_TO = "translateTo" + const val ZAP_AMOUNTS = "zapAmounts" + const val REACTION_CHOICES = "reactionChoices" + const val DEFAULT_ZAPTYPE = "defaultZapType" + const val DEFAULT_FILE_SERVER = "defaultFileServer" + const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList" + const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList" + const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList" + const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList" + const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer" + const val LATEST_CONTACT_LIST = "latestContactList" + const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog" + const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog" + const val HIDE_NIP_24_WARNING_DIALOG = "hide_nip24_warning_dialog" + const val USE_PROXY = "use_proxy" + const val PROXY_PORT = "proxy_port" + const val SHOW_SENSITIVE_CONTENT = "show_sensitive_content" + const val WARN_ABOUT_REPORTS = "warn_about_reports" + const val FILTER_SPAM_FROM_STRANGERS = "filter_spam_from_strangers" + const val LAST_READ_PER_ROUTE = "last_read_route_per_route" + const val AUTOMATICALLY_SHOW_IMAGES = "automatically_show_images" + const val AUTOMATICALLY_START_PLAYBACK = "automatically_start_playback" + const val THEME = "theme" + const val PREFERRED_LANGUAGE = "preferred_Language" + const val AUTOMATICALLY_LOAD_URL_PREVIEW = "automatically_load_url_preview" + const val AUTOMATICALLY_HIDE_NAV_BARS = "automatically_hide_nav_bars" + const val LOGIN_WITH_EXTERNAL_SIGNER = "login_with_external_signer" + const val AUTOMATICALLY_SHOW_PROFILE_PICTURE = "automatically_show_profile_picture" + const val SIGNER_PACKAGE_NAME = "signer_package_name" - const val ALL_ACCOUNT_INFO = "all_saved_accounts_info" - const val SHARED_SETTINGS = "shared_settings" + const val ALL_ACCOUNT_INFO = "all_saved_accounts_info" + const val SHARED_SETTINGS = "shared_settings" } object LocalPreferences { - private const val comma = "," + private const val COMMA = "," - private var _currentAccount: String? = null - private var _savedAccounts: List? = null - private var _cachedAccounts: MutableMap = mutableMapOf() + private var currentAccount: String? = null + private var savedAccounts: List? = null + private var cachedAccounts: MutableMap = mutableMapOf() - suspend fun currentAccount(): String? { - if (_currentAccount == null) { - _currentAccount = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null) - } - return _currentAccount + suspend fun currentAccount(): String? { + if (currentAccount == null) { + currentAccount = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null) } + return currentAccount + } - private suspend fun updateCurrentAccount(npub: String) { - if (_currentAccount != npub) { - _currentAccount = npub + private suspend fun updateCurrentAccount(npub: String) { + if (currentAccount != npub) { + currentAccount = npub - encryptedPreferences().edit().apply { - putString(PrefKeys.CURRENT_ACCOUNT, npub) - }.apply() - } + encryptedPreferences().edit().apply { putString(PrefKeys.CURRENT_ACCOUNT, npub) }.apply() } + } - private fun savedAccounts(): List { - if (_savedAccounts == null) { - with(encryptedPreferences()) { - val newSystemOfAccounts = getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let { - Event.mapper.readValue>(it) - } + private fun savedAccounts(): List { + if (savedAccounts == null) { + with(encryptedPreferences()) { + val newSystemOfAccounts = + getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let { + Event.mapper.readValue>(it) + } - if (newSystemOfAccounts != null && newSystemOfAccounts.isNotEmpty()) { - _savedAccounts = newSystemOfAccounts - } else { - val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf() - - val migrated = oldAccounts.map { npub -> - AccountInfo( - npub, - encryptedPreferences(npub).getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false), - (encryptedPreferences(npub).getString(PrefKeys.NOSTR_PRIVKEY, "") ?: "").isNotBlank() - ) - } - - _savedAccounts = migrated - } - } - } - return _savedAccounts!! - } - - private suspend fun updateSavedAccounts(accounts: List) = withContext(Dispatchers.IO) { - if (_savedAccounts != accounts) { - _savedAccounts = accounts - - encryptedPreferences().edit().apply { - putString(PrefKeys.ALL_ACCOUNT_INFO, Event.mapper.writeValueAsString(accounts)) - }.apply() - } - } - - private val prefsDirPath: String - get() = "${Amethyst.instance.filesDir.parent}/shared_prefs/" - - private suspend fun addAccount(accInfo: AccountInfo) { - val accounts = savedAccounts().filter { it.npub != accInfo.npub }.plus(accInfo) - updateSavedAccounts(accounts) - } - - private suspend fun setCurrentAccount(account: Account) = withContext(Dispatchers.IO) { - val npub = account.userProfile().pubkeyNpub() - val accInfo = AccountInfo( - npub, - account.isWriteable(), - account.signer is NostrSignerExternal - ) - updateCurrentAccount(npub) - addAccount(accInfo) - } - - suspend fun switchToAccount(accountInfo: AccountInfo) = withContext(Dispatchers.IO) { - updateCurrentAccount(accountInfo.npub) - } - - /** - * Removes the account from the app level shared preferences - */ - private suspend fun removeAccount(accountInfo: AccountInfo) { - val accounts = savedAccounts().filter { it.npub != accountInfo.npub } - updateSavedAccounts(accounts) - } - - /** - * Deletes the npub-specific shared preference file - */ - private fun deleteUserPreferenceFile(npub: String) { - checkNotInMainThread() - - val prefsDir = File(prefsDirPath) - prefsDir.list()?.forEach { - if (it.contains(npub)) { - File(prefsDir, it).delete() - } - } - } - - private fun encryptedPreferences(npub: String? = null): SharedPreferences { - checkNotInMainThread() - - return if (BuildConfig.DEBUG && DEBUG_PLAINTEXT_PREFERENCES) { - val preferenceFile = if (npub == null) DEBUG_PREFERENCES_NAME else "${DEBUG_PREFERENCES_NAME}_$npub" - Amethyst.instance.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE) + if (newSystemOfAccounts != null && newSystemOfAccounts.isNotEmpty()) { + savedAccounts = newSystemOfAccounts } else { - return EncryptedStorage.preferences(npub) - } - } + val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(COMMA) ?: listOf() - /** - * Clears the preferences for a given npub, deletes the preferences xml file, - * and switches the user to the first account in the list if it exists - * - * We need to use `commit()` to write changes to disk and release the file - * lock so that it can be deleted. If we use `apply()` there is a race - * condition and the file will probably not be deleted - */ - @SuppressLint("ApplySharedPref") - suspend fun updatePrefsForLogout(accountInfo: AccountInfo) = withContext(Dispatchers.IO) { - val userPrefs = encryptedPreferences(accountInfo.npub) - userPrefs.edit().clear().commit() - removeAccount(accountInfo) - deleteUserPreferenceFile(accountInfo.npub) - - if (savedAccounts().isEmpty()) { - encryptedPreferences().edit().clear().apply() - } else if (currentAccount() == accountInfo.npub) { - updateCurrentAccount(savedAccounts().elementAt(0).npub) - } - } - - suspend fun updatePrefsForLogin(account: Account) { - setCurrentAccount(account) - saveToEncryptedStorage(account) - } - - fun allSavedAccounts(): List { - return savedAccounts() - } - - suspend fun saveToEncryptedStorage(account: Account) = withContext(Dispatchers.IO) { - checkNotInMainThread() - - val prefs = encryptedPreferences(account.userProfile().pubkeyNpub()) - prefs.edit().apply { - putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, account.signer is NostrSignerExternal) - if (account.signer is NostrSignerExternal) { - remove(PrefKeys.NOSTR_PRIVKEY) - putString(PrefKeys.SIGNER_PACKAGE_NAME, account.signer.launcher.signerPackageName) - } else { - account.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) } + val migrated = + oldAccounts.map { npub -> + AccountInfo( + npub, + encryptedPreferences(npub).getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false), + (encryptedPreferences(npub).getString(PrefKeys.NOSTR_PRIVKEY, "") ?: "") + .isNotBlank(), + ) } - account.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) } - putString(PrefKeys.RELAYS, Event.mapper.writeValueAsString(account.localRelays)) - putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom) - putString(PrefKeys.LANGUAGE_PREFS, Event.mapper.writeValueAsString(account.languagePreferences)) - putString(PrefKeys.TRANSLATE_TO, account.translateTo) - putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(account.zapAmountChoices)) - putString(PrefKeys.REACTION_CHOICES, Event.mapper.writeValueAsString(account.reactionChoices)) - putString(PrefKeys.DEFAULT_ZAPTYPE, account.defaultZapType.name) - putString(PrefKeys.DEFAULT_FILE_SERVER, Event.mapper.writeValueAsString(account.defaultFileServer)) - putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList.value) - putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList.value) - putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, account.defaultNotificationFollowList.value) - putString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, account.defaultDiscoveryFollowList.value) - putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, Event.mapper.writeValueAsString(account.zapPaymentRequest)) - putString(PrefKeys.LATEST_CONTACT_LIST, Event.mapper.writeValueAsString(account.backupContactList)) - putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog) - putBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, account.hideNIP24WarningDialog) - putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog) - putBoolean(PrefKeys.USE_PROXY, account.proxy != null) - putInt(PrefKeys.PROXY_PORT, account.proxyPort) - putBoolean(PrefKeys.WARN_ABOUT_REPORTS, account.warnAboutPostsWithReports) - putBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, account.filterSpamFromStrangers) - putString(PrefKeys.LAST_READ_PER_ROUTE, Event.mapper.writeValueAsString(account.lastReadPerRoute)) - if (account.showSensitiveContent == null) { - remove(PrefKeys.SHOW_SENSITIVE_CONTENT) - } else { - putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent!!) - } - }.apply() + savedAccounts = migrated + } + } + } + return savedAccounts!! + } + + private suspend fun updateSavedAccounts(accounts: List) = + withContext(Dispatchers.IO) { + if (savedAccounts != accounts) { + savedAccounts = accounts + + encryptedPreferences() + .edit() + .apply { putString(PrefKeys.ALL_ACCOUNT_INFO, Event.mapper.writeValueAsString(accounts)) } + .apply() + } } - suspend fun loadCurrentAccountFromEncryptedStorage(): Account? { - return currentAccount()?.let { - loadCurrentAccountFromEncryptedStorage(it) - } + private val prefsDirPath: String + get() = "${Amethyst.instance.filesDir.parent}/shared_prefs/" + + private suspend fun addAccount(accInfo: AccountInfo) { + val accounts = savedAccounts().filter { it.npub != accInfo.npub }.plus(accInfo) + updateSavedAccounts(accounts) + } + + private suspend fun setCurrentAccount(account: Account) = + withContext(Dispatchers.IO) { + val npub = account.userProfile().pubkeyNpub() + val accInfo = + AccountInfo( + npub, + account.isWriteable(), + account.signer is NostrSignerExternal, + ) + updateCurrentAccount(npub) + addAccount(accInfo) } - suspend fun migrateOldSharedSettings(): Settings? { - val prefs = encryptedPreferences() - loadOldSharedSettings(prefs)?.let { - saveSharedSettings(it, prefs) - return it + suspend fun switchToAccount(accountInfo: AccountInfo) = + withContext(Dispatchers.IO) { updateCurrentAccount(accountInfo.npub) } + + /** Removes the account from the app level shared preferences */ + private suspend fun removeAccount(accountInfo: AccountInfo) { + val accounts = savedAccounts().filter { it.npub != accountInfo.npub } + updateSavedAccounts(accounts) + } + + /** Deletes the npub-specific shared preference file */ + private fun deleteUserPreferenceFile(npub: String) { + checkNotInMainThread() + + val prefsDir = File(prefsDirPath) + prefsDir.list()?.forEach { + if (it.contains(npub)) { + File(prefsDir, it).delete() + } + } + } + + private fun encryptedPreferences(npub: String? = null): SharedPreferences { + checkNotInMainThread() + + return if (BuildConfig.DEBUG && DEBUG_PLAINTEXT_PREFERENCES) { + val preferenceFile = + if (npub == null) DEBUG_PREFERENCES_NAME else "${DEBUG_PREFERENCES_NAME}_$npub" + Amethyst.instance.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE) + } else { + return EncryptedStorage.preferences(npub) + } + } + + /** + * Clears the preferences for a given npub, deletes the preferences xml file, and switches the + * user to the first account in the list if it exists + * + * We need to use `commit()` to write changes to disk and release the file lock so that it can be + * deleted. If we use `apply()` there is a race condition and the file will probably not be + * deleted + */ + @SuppressLint("ApplySharedPref") + suspend fun updatePrefsForLogout(accountInfo: AccountInfo) = + withContext(Dispatchers.IO) { + val userPrefs = encryptedPreferences(accountInfo.npub) + userPrefs.edit().clear().commit() + removeAccount(accountInfo) + deleteUserPreferenceFile(accountInfo.npub) + + if (savedAccounts().isEmpty()) { + encryptedPreferences().edit().clear().apply() + } else if (currentAccount() == accountInfo.npub) { + updateCurrentAccount(savedAccounts().elementAt(0).npub) + } + } + + suspend fun updatePrefsForLogin(account: Account) { + setCurrentAccount(account) + saveToEncryptedStorage(account) + } + + fun allSavedAccounts(): List { + return savedAccounts() + } + + suspend fun saveToEncryptedStorage(account: Account) = + withContext(Dispatchers.IO) { + checkNotInMainThread() + + val prefs = encryptedPreferences(account.userProfile().pubkeyNpub()) + prefs + .edit() + .apply { + putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, account.signer is NostrSignerExternal) + if (account.signer is NostrSignerExternal) { + remove(PrefKeys.NOSTR_PRIVKEY) + putString(PrefKeys.SIGNER_PACKAGE_NAME, account.signer.launcher.signerPackageName) + } else { + account.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) } + } + account.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) } + putString(PrefKeys.RELAYS, Event.mapper.writeValueAsString(account.localRelays)) + putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom) + putString( + PrefKeys.LANGUAGE_PREFS, + Event.mapper.writeValueAsString(account.languagePreferences), + ) + putString(PrefKeys.TRANSLATE_TO, account.translateTo) + putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(account.zapAmountChoices)) + putString( + PrefKeys.REACTION_CHOICES, + Event.mapper.writeValueAsString(account.reactionChoices), + ) + putString(PrefKeys.DEFAULT_ZAPTYPE, account.defaultZapType.name) + putString( + PrefKeys.DEFAULT_FILE_SERVER, + Event.mapper.writeValueAsString(account.defaultFileServer), + ) + putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList.value) + putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList.value) + putString( + PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, + account.defaultNotificationFollowList.value, + ) + putString( + PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, + account.defaultDiscoveryFollowList.value, + ) + putString( + PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, + Event.mapper.writeValueAsString(account.zapPaymentRequest), + ) + putString( + PrefKeys.LATEST_CONTACT_LIST, + Event.mapper.writeValueAsString(account.backupContactList), + ) + putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog) + putBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, account.hideNIP24WarningDialog) + putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog) + putBoolean(PrefKeys.USE_PROXY, account.proxy != null) + putInt(PrefKeys.PROXY_PORT, account.proxyPort) + putBoolean(PrefKeys.WARN_ABOUT_REPORTS, account.warnAboutPostsWithReports) + putBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, account.filterSpamFromStrangers) + putString( + PrefKeys.LAST_READ_PER_ROUTE, + Event.mapper.writeValueAsString(account.lastReadPerRoute), + ) + + if (account.showSensitiveContent == null) { + remove(PrefKeys.SHOW_SENSITIVE_CONTENT) + } else { + putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent!!) + } } + .apply() + } + + suspend fun loadCurrentAccountFromEncryptedStorage(): Account? { + return currentAccount()?.let { loadCurrentAccountFromEncryptedStorage(it) } + } + + suspend fun migrateOldSharedSettings(): Settings? { + val prefs = encryptedPreferences() + loadOldSharedSettings(prefs)?.let { + saveSharedSettings(it, prefs) + return it + } + return null + } + + suspend fun saveSharedSettings( + sharedSettings: Settings, + prefs: SharedPreferences = encryptedPreferences(), + ) { + with(prefs.edit()) { + putString(PrefKeys.SHARED_SETTINGS, Event.mapper.writeValueAsString(sharedSettings)) + apply() + } + } + + suspend fun loadSharedSettings(prefs: SharedPreferences = encryptedPreferences()): Settings? { + with(prefs) { + return try { + getString(PrefKeys.SHARED_SETTINGS, "{}")?.let { Event.mapper.readValue(it) } + } catch (e: Throwable) { + Log.w( + "LocalPreferences", + "Unable to decode shared preferences: ${getString(PrefKeys.SHARED_SETTINGS, null)}", + e, + ) + e.printStackTrace() + null + } + } + } + + @Deprecated("Turned into a single JSON object") + suspend fun loadOldSharedSettings(prefs: SharedPreferences = encryptedPreferences()): Settings? { + with(prefs) { + if (!contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) { return null - } + } - suspend fun saveSharedSettings(sharedSettings: Settings, prefs: SharedPreferences = encryptedPreferences()) { - with(prefs.edit()) { - putString(PrefKeys.SHARED_SETTINGS, Event.mapper.writeValueAsString(sharedSettings)) - apply() + val automaticallyShowImages = + if (contains(PrefKeys.AUTOMATICALLY_SHOW_IMAGES)) { + parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, false)) + } else { + ConnectivityType.ALWAYS } - } - suspend fun loadSharedSettings(prefs: SharedPreferences = encryptedPreferences()): Settings? { - with(prefs) { - return try { - getString(PrefKeys.SHARED_SETTINGS, "{}")?.let { - Event.mapper.readValue(it) - } - } catch (e: Throwable) { - Log.w("LocalPreferences", "Unable to decode shared preferences: ${getString(PrefKeys.SHARED_SETTINGS, null)}", e) - e.printStackTrace() - null - } + val automaticallyStartPlayback = + if (contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) { + parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, false)) + } else { + ConnectivityType.ALWAYS } + val automaticallyShowUrlPreview = + if (contains(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW)) { + parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, false)) + } else { + ConnectivityType.ALWAYS + } + val automaticallyHideNavigationBars = + if (contains(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS)) { + parseBooleanType(getBoolean(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS, false)) + } else { + BooleanType.ALWAYS + } + + val automaticallyShowProfilePictures = + if (contains(PrefKeys.AUTOMATICALLY_SHOW_PROFILE_PICTURE)) { + parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_SHOW_PROFILE_PICTURE, false)) + } else { + ConnectivityType.ALWAYS + } + + val themeType = + if (contains(PrefKeys.THEME)) { + parseThemeType(getInt(PrefKeys.THEME, ThemeType.SYSTEM.screenCode)) + } else { + ThemeType.SYSTEM + } + + return Settings( + themeType, + getString(PrefKeys.PREFERRED_LANGUAGE, null)?.ifBlank { null }, + automaticallyShowImages, + automaticallyStartPlayback, + automaticallyShowUrlPreview, + automaticallyHideNavigationBars, + automaticallyShowProfilePictures, + false, + false, + ) + } + } + + val mutex = Mutex() + + suspend fun loadCurrentAccountFromEncryptedStorage(npub: String): Account? = + withContext(Dispatchers.IO) { + mutex.withLock { + if (cachedAccounts.containsKey(npub)) { + return@withContext cachedAccounts.get(npub) + } + + val account = innerLoadCurrentAccountFromEncryptedStorage(npub) + account?.registerObservers() + + cachedAccounts.put(npub, account) + + return@withContext account + } } - @Deprecated("Turned into a single JSON object") - suspend fun loadOldSharedSettings(prefs: SharedPreferences = encryptedPreferences()): Settings? { - with(prefs) { - if (!contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) { - return null - } + suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): Account? = + withContext(Dispatchers.IO) { + checkNotInMainThread() - val automaticallyShowImages = if (contains(PrefKeys.AUTOMATICALLY_SHOW_IMAGES)) { - parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, false)) - } else { - ConnectivityType.ALWAYS - } + return@withContext with(encryptedPreferences(npub)) { + val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return@with null + val loginWithExternalSigner = getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false) + val privKey = if (loginWithExternalSigner) null else getString(PrefKeys.NOSTR_PRIVKEY, null) - val automaticallyStartPlayback = if (contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) { - parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, false)) - } else { - ConnectivityType.ALWAYS - } - val automaticallyShowUrlPreview = if (contains(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW)) { - parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, false)) - } else { - ConnectivityType.ALWAYS - } - val automaticallyHideNavigationBars = if (contains(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS)) { - parseBooleanType(getBoolean(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS, false)) - } else { - BooleanType.ALWAYS - } + val localRelays = + getString(PrefKeys.RELAYS, "[]")?.let { + println("LocalRelays: $it") + Event.mapper.readValue?>(it) + } + ?: setOf() - val automaticallyShowProfilePictures = if (contains(PrefKeys.AUTOMATICALLY_SHOW_PROFILE_PICTURE)) { - parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_SHOW_PROFILE_PICTURE, false)) - } else { - ConnectivityType.ALWAYS - } + val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf() + val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language + val defaultHomeFollowList = + getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS + val defaultStoriesFollowList = + getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS + val defaultNotificationFollowList = + getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS + val defaultDiscoveryFollowList = + getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS - val themeType = if (contains(PrefKeys.THEME)) { - parseThemeType(getInt(PrefKeys.THEME, ThemeType.SYSTEM.screenCode)) - } else { - ThemeType.SYSTEM - } + val zapAmountChoices = + getString(PrefKeys.ZAP_AMOUNTS, "[]") + ?.let { Event.mapper.readValue?>(it) } + ?.ifEmpty { DefaultZapAmounts } + ?: DefaultZapAmounts - return Settings( - themeType, - getString(PrefKeys.PREFERRED_LANGUAGE, null)?.ifBlank { null }, - automaticallyShowImages, - automaticallyStartPlayback, - automaticallyShowUrlPreview, - automaticallyHideNavigationBars, - automaticallyShowProfilePictures, - false, - false + val reactionChoices = + getString(PrefKeys.REACTION_CHOICES, "[]") + ?.let { Event.mapper.readValue?>(it) } + ?.ifEmpty { DefaultReactions } + ?: DefaultReactions + + val defaultZapType = + getString(PrefKeys.DEFAULT_ZAPTYPE, "")?.let { serverName -> + LnZapEvent.ZapType.values().firstOrNull { it.name == serverName } + } + ?: LnZapEvent.ZapType.PUBLIC + + val defaultFileServer = + try { + getString(PrefKeys.DEFAULT_FILE_SERVER, "")?.let { serverName -> + Event.mapper.readValue(serverName) + } + ?: Nip96MediaServers.DEFAULT[0] + } catch (e: Exception) { + Log.w("LocalPreferences", "Failed to decode saved File Server", e) + e.printStackTrace() + Nip96MediaServers.DEFAULT[0] + } + + val zapPaymentRequestServer = + try { + getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let { + Event.mapper.readValue(it) + } + } catch (e: Throwable) { + Log.w( + "LocalPreferences", + "Error Decoding Zap Payment Request Server ${getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)}", + e, ) - } - } + e.printStackTrace() + null + } - val mutex = Mutex() - suspend fun loadCurrentAccountFromEncryptedStorage(npub: String): Account? = withContext(Dispatchers.IO) { - mutex.withLock { - if (_cachedAccounts.containsKey(npub)) { - return@withContext _cachedAccounts.get(npub) - } - - val account = innerLoadCurrentAccountFromEncryptedStorage(npub) - account?.registerObservers() - - _cachedAccounts.put(npub, account) - - return@withContext account - } - } - - suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): Account? = withContext(Dispatchers.IO) { - checkNotInMainThread() - - return@withContext with(encryptedPreferences(npub)) { - val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return@with null - val loginWithExternalSigner = getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false) - val privKey = if (loginWithExternalSigner) null else getString(PrefKeys.NOSTR_PRIVKEY, null) - - val localRelays = getString(PrefKeys.RELAYS, "[]")?.let { - println("LocalRelays: $it") - Event.mapper.readValue?>(it) - } ?: setOf() - - val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf() - val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language - val defaultHomeFollowList = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS - val defaultStoriesFollowList = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS - val defaultNotificationFollowList = getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS - val defaultDiscoveryFollowList = getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS - - val zapAmountChoices = getString(PrefKeys.ZAP_AMOUNTS, "[]")?.let { - Event.mapper.readValue?>(it) - }?.ifEmpty { DefaultZapAmounts } ?: DefaultZapAmounts - - val reactionChoices = getString(PrefKeys.REACTION_CHOICES, "[]")?.let { - Event.mapper.readValue?>(it) - }?.ifEmpty { DefaultReactions } ?: DefaultReactions - - val defaultZapType = getString(PrefKeys.DEFAULT_ZAPTYPE, "")?.let { serverName -> - LnZapEvent.ZapType.values().firstOrNull() { it.name == serverName } - } ?: LnZapEvent.ZapType.PUBLIC - - val defaultFileServer = try { - getString(PrefKeys.DEFAULT_FILE_SERVER, "")?.let { serverName -> - Event.mapper.readValue(serverName) - } ?: Nip96MediaServers.DEFAULT[0] - } catch (e: Exception) { - Log.w("LocalPreferences", "Failed to decode saved File Server", e) - e.printStackTrace() - Nip96MediaServers.DEFAULT[0] - } - - val zapPaymentRequestServer = try { - getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let { - Event.mapper.readValue(it) - } - } catch (e: Throwable) { - Log.w("LocalPreferences", "Error Decoding Zap Payment Request Server ${getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)}", e) - e.printStackTrace() + val latestContactList = + try { + getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let { + println("Decoding Contact List: " + it) + if (it != null) { + Event.fromJson(it) as ContactListEvent? + } else { null + } } - - val latestContactList = try { - getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let { - println("Decoding Contact List: " + it) - if (it != null) { - Event.fromJson(it) as ContactListEvent? - } else { - null - } - } - } catch (e: Throwable) { - Log.w("LocalPreferences", "Error Decoding Contact List ${getString(PrefKeys.LATEST_CONTACT_LIST, null)}", e) - null - } - - val languagePreferences = try { - getString(PrefKeys.LANGUAGE_PREFS, null)?.let { - Event.mapper.readValue?>(it) - } ?: mapOf() - } catch (e: Throwable) { - Log.w("LocalPreferences", "Error Decoding Language Preferences ${getString(PrefKeys.LANGUAGE_PREFS, null)}", e) - e.printStackTrace() - mapOf() - } - - val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) - val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) - val hideNIP24WarningDialog = getBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, false) - val useProxy = getBoolean(PrefKeys.USE_PROXY, false) - val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050) - val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort) - - val showSensitiveContent = if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) { - getBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, false) - } else { - null - } - val filterSpam = getBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, true) - val warnAboutReports = getBoolean(PrefKeys.WARN_ABOUT_REPORTS, true) - - val lastReadPerRoute = try { - getString(PrefKeys.LAST_READ_PER_ROUTE, null)?.let { - Event.mapper.readValue?>(it) - } ?: mapOf() - } catch (e: Throwable) { - Log.w("LocalPreferences", "Error Decoding Last Read per route ${getString(PrefKeys.LAST_READ_PER_ROUTE, null)}", e) - e.printStackTrace() - mapOf() - } - - val keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()) - val signer = if (loginWithExternalSigner) { - val packageName = getString(PrefKeys.SIGNER_PACKAGE_NAME, null) ?: "com.greenart7c3.nostrsigner" - NostrSignerExternal(pubKey, ExternalSignerLauncher(pubKey.hexToByteArray().toNpub(), packageName)) - } else { - NostrSignerInternal(keyPair) - } - - val account = Account( - keyPair = keyPair, - signer = signer, - localRelays = localRelays, - dontTranslateFrom = dontTranslateFrom, - languagePreferences = languagePreferences, - translateTo = translateTo, - zapAmountChoices = zapAmountChoices, - reactionChoices = reactionChoices, - defaultZapType = defaultZapType, - defaultFileServer = defaultFileServer, - defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList), - defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList), - defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList), - defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList), - zapPaymentRequest = zapPaymentRequestServer, - hideDeleteRequestDialog = hideDeleteRequestDialog, - hideBlockAlertDialog = hideBlockAlertDialog, - hideNIP24WarningDialog = hideNIP24WarningDialog, - backupContactList = latestContactList, - proxy = proxy, - proxyPort = proxyPort, - showSensitiveContent = showSensitiveContent, - warnAboutPostsWithReports = warnAboutReports, - filterSpamFromStrangers = filterSpam, - lastReadPerRoute = lastReadPerRoute + } catch (e: Throwable) { + Log.w( + "LocalPreferences", + "Error Decoding Contact List ${getString(PrefKeys.LATEST_CONTACT_LIST, null)}", + e, ) + null + } - // Loads from DB - account.userProfile() - - withContext(Dispatchers.Main) { - // Loads Live Objects - account.userProfile().live() + val languagePreferences = + try { + getString(PrefKeys.LANGUAGE_PREFS, null)?.let { + Event.mapper.readValue?>(it) } + ?: mapOf() + } catch (e: Throwable) { + Log.w( + "LocalPreferences", + "Error Decoding Language Preferences ${getString(PrefKeys.LANGUAGE_PREFS, null)}", + e, + ) + e.printStackTrace() + mapOf() + } - return@with account + val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) + val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) + val hideNIP24WarningDialog = getBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, false) + val useProxy = getBoolean(PrefKeys.USE_PROXY, false) + val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050) + val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort) + + val showSensitiveContent = + if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) { + getBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, false) + } else { + null + } + val filterSpam = getBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, true) + val warnAboutReports = getBoolean(PrefKeys.WARN_ABOUT_REPORTS, true) + + val lastReadPerRoute = + try { + getString(PrefKeys.LAST_READ_PER_ROUTE, null)?.let { + Event.mapper.readValue?>(it) + } + ?: mapOf() + } catch (e: Throwable) { + Log.w( + "LocalPreferences", + "Error Decoding Last Read per route ${getString(PrefKeys.LAST_READ_PER_ROUTE, null)}", + e, + ) + e.printStackTrace() + mapOf() + } + + val keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()) + val signer = + if (loginWithExternalSigner) { + val packageName = + getString(PrefKeys.SIGNER_PACKAGE_NAME, null) ?: "com.greenart7c3.nostrsigner" + NostrSignerExternal( + pubKey, + ExternalSignerLauncher(pubKey.hexToByteArray().toNpub(), packageName), + ) + } else { + NostrSignerInternal(keyPair) + } + + val account = + Account( + keyPair = keyPair, + signer = signer, + localRelays = localRelays, + dontTranslateFrom = dontTranslateFrom, + languagePreferences = languagePreferences, + translateTo = translateTo, + zapAmountChoices = zapAmountChoices, + reactionChoices = reactionChoices, + defaultZapType = defaultZapType, + defaultFileServer = defaultFileServer, + defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList), + defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList), + defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList), + defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList), + zapPaymentRequest = zapPaymentRequestServer, + hideDeleteRequestDialog = hideDeleteRequestDialog, + hideBlockAlertDialog = hideBlockAlertDialog, + hideNIP24WarningDialog = hideNIP24WarningDialog, + backupContactList = latestContactList, + proxy = proxy, + proxyPort = proxyPort, + showSensitiveContent = showSensitiveContent, + warnAboutPostsWithReports = warnAboutReports, + filterSpamFromStrangers = filterSpam, + lastReadPerRoute = lastReadPerRoute, + ) + + // Loads from DB + account.userProfile() + + withContext(Dispatchers.Main) { + // Loads Live Objects + account.userProfile().live() } + + return@with account + } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt index 123381c36..233081208 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst import android.os.Build @@ -42,180 +62,188 @@ import kotlinx.coroutines.launch @Stable class ServiceManager { - private var isStarted: Boolean = false // to not open amber in a loop trying to use auth relays and registering for notifications - private var account: Account? = null + private var isStarted: Boolean = + false // to not open amber in a loop trying to use auth relays and registering for notifications + private var account: Account? = null - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private var collectorJob: Job? = null + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var collectorJob: Job? = null - private fun start(account: Account) { - this.account = account + private fun start(account: Account) { + this.account = account + start() + } + + private fun start() { + Log.d("ServiceManager", "Pre Starting Relay Services $isStarted $account") + if (isStarted && account != null) { + return + } + Log.d("ServiceManager", "Starting Relay Services") + + val myAccount = account + + // Resets Proxy Use + HttpClient.start(account?.proxy) + LocalCache.antiSpam.active = account?.filterSpamFromStrangers ?: true + Coil.setImageLoader { + Amethyst.instance + .imageLoaderBuilder() + .components { + if (Build.VERSION.SDK_INT >= 28) { + add(ImageDecoderDecoder.Factory()) + } else { + add(GifDecoder.Factory()) + } + add(SvgDecoder.Factory()) + } // .logger(DebugLogger()) + .okHttpClient { HttpClient.getHttpClient() } + .precision(Precision.INEXACT) + .respectCacheHeaders(false) + .build() + } + + if (myAccount != null) { + val relaySet = myAccount.activeRelays() ?: myAccount.convertLocalRelays() + Log.d("Relay", "Service Manager Connect Connecting ${relaySet.size}") + Client.reconnect(relaySet) + + collectorJob?.cancel() + collectorJob = null + collectorJob = + scope.launch { + myAccount.userProfile().flow().relays.stateFlow.collect { + if (isStarted) { + val newRelaySet = myAccount.activeRelays() ?: myAccount.convertLocalRelays() + Client.reconnect(newRelaySet, onlyIfChanged = true) + } + } + } + + // start services + NostrAccountDataSource.account = myAccount + NostrAccountDataSource.otherAccounts = + LocalPreferences.allSavedAccounts().mapNotNull { + try { + it.npub.bechToBytes().toHexKey() + } catch (e: Exception) { + null + } + } + NostrHomeDataSource.account = myAccount + NostrChatroomListDataSource.account = myAccount + NostrVideoDataSource.account = myAccount + NostrDiscoveryDataSource.account = myAccount + + // Notification Elements + NostrHomeDataSource.start() + NostrAccountDataSource.start() + GlobalScope.launch(Dispatchers.IO) { + delay(3000) + NostrChatroomListDataSource.start() + NostrDiscoveryDataSource.start() + NostrVideoDataSource.start() + } + + // More Info Data Sources + NostrSingleEventDataSource.start() + NostrSingleChannelDataSource.start() + NostrSingleUserDataSource.start() + isStarted = true + } + } + + private fun pause() { + Log.d("ServiceManager", "Pausing Relay Services") + + collectorJob?.cancel() + collectorJob = null + + NostrAccountDataSource.stopSync() + NostrHomeDataSource.stopSync() + NostrChannelDataSource.stopSync() + NostrChatroomDataSource.stopSync() + NostrChatroomListDataSource.stopSync() + NostrDiscoveryDataSource.stopSync() + + NostrCommunityDataSource.stopSync() + NostrHashtagDataSource.stopSync() + NostrGeohashDataSource.stopSync() + NostrSearchEventOrUserDataSource.stopSync() + NostrSingleChannelDataSource.stopSync() + NostrSingleEventDataSource.stopSync() + NostrSingleUserDataSource.stopSync() + NostrThreadDataSource.stopSync() + NostrUserProfileDataSource.stopSync() + NostrVideoDataSource.stopSync() + + Client.reconnect(null) + isStarted = false + } + + fun cleanObservers() { + LocalCache.cleanObservers() + } + + fun trimMemory() { + LocalCache.cleanObservers() + + val accounts = + LocalPreferences.allSavedAccounts().mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet() + + account?.let { + LocalCache.pruneOldAndHiddenMessages(it) + NostrChatroomDataSource.clearEOSEs(it) + + LocalCache.pruneHiddenMessages(it) + LocalCache.pruneContactLists(accounts) + LocalCache.pruneRepliesAndReactions(accounts) + LocalCache.prunePastVersionsOfReplaceables() + LocalCache.pruneExpiredEvents() + } + } + + // This method keeps the pause/start in a Syncronized block to + // avoid concurrent pauses and starts. + @Synchronized + fun forceRestart( + account: Account? = null, + start: Boolean = true, + pause: Boolean = true, + ) { + if (pause) { + pause() + } + + if (start) { + if (account != null) { + start(account) + } else { start() + } } + } - private fun start() { - Log.d("ServiceManager", "Pre Starting Relay Services $isStarted $account") - if (isStarted && account != null) { - return - } - Log.d("ServiceManager", "Starting Relay Services") - - val myAccount = account - - // Resets Proxy Use - HttpClient.start(account?.proxy) - LocalCache.antiSpam.active = account?.filterSpamFromStrangers ?: true - Coil.setImageLoader { - Amethyst.instance.imageLoaderBuilder().components { - if (Build.VERSION.SDK_INT >= 28) { - add(ImageDecoderDecoder.Factory()) - } else { - add(GifDecoder.Factory()) - } - add(SvgDecoder.Factory()) - } // .logger(DebugLogger()) - .okHttpClient { HttpClient.getHttpClient() } - .precision(Precision.INEXACT) - .respectCacheHeaders(false) - .build() - } - - if (myAccount != null) { - val relaySet = myAccount.activeRelays() ?: myAccount.convertLocalRelays() - Log.d("Relay", "Service Manager Connect Connecting ${relaySet.size}") - Client.reconnect(relaySet) - - collectorJob?.cancel() - collectorJob = null - collectorJob = scope.launch { - myAccount.userProfile().flow().relays.stateFlow.collect { - if (isStarted) { - val newRelaySet = myAccount.activeRelays() ?: myAccount.convertLocalRelays() - Client.reconnect(newRelaySet, onlyIfChanged = true) - } - } - } - - // start services - NostrAccountDataSource.account = myAccount - NostrAccountDataSource.otherAccounts = LocalPreferences.allSavedAccounts().mapNotNull { - try { - it.npub.bechToBytes().toHexKey() - } catch (e: Exception) { - null - } - } - NostrHomeDataSource.account = myAccount - NostrChatroomListDataSource.account = myAccount - NostrVideoDataSource.account = myAccount - NostrDiscoveryDataSource.account = myAccount - - // Notification Elements - NostrHomeDataSource.start() - NostrAccountDataSource.start() - GlobalScope.launch(Dispatchers.IO) { - delay(3000) - NostrChatroomListDataSource.start() - NostrDiscoveryDataSource.start() - NostrVideoDataSource.start() - } - - // More Info Data Sources - NostrSingleEventDataSource.start() - NostrSingleChannelDataSource.start() - NostrSingleUserDataSource.start() - isStarted = true - } + fun restartIfDifferentAccount(account: Account) { + if (this.account != account) { + forceRestart(account, true, true) } + } - private fun pause() { - Log.d("ServiceManager", "Pausing Relay Services") + fun forceRestart() { + forceRestart(null, true, true) + } - collectorJob?.cancel() - collectorJob = null + fun justStart() { + forceRestart(null, true, false) + } - NostrAccountDataSource.stopSync() - NostrHomeDataSource.stopSync() - NostrChannelDataSource.stopSync() - NostrChatroomDataSource.stopSync() - NostrChatroomListDataSource.stopSync() - NostrDiscoveryDataSource.stopSync() + fun pauseForGood() { + forceRestart(null, false, true) + } - NostrCommunityDataSource.stopSync() - NostrHashtagDataSource.stopSync() - NostrGeohashDataSource.stopSync() - NostrSearchEventOrUserDataSource.stopSync() - NostrSingleChannelDataSource.stopSync() - NostrSingleEventDataSource.stopSync() - NostrSingleUserDataSource.stopSync() - NostrThreadDataSource.stopSync() - NostrUserProfileDataSource.stopSync() - NostrVideoDataSource.stopSync() - - Client.reconnect(null) - isStarted = false - } - - fun cleanObservers() { - LocalCache.cleanObservers() - } - - fun trimMemory() { - LocalCache.cleanObservers() - - val accounts = LocalPreferences.allSavedAccounts().mapNotNull { - decodePublicKeyAsHexOrNull(it.npub) - }.toSet() - - account?.let { - LocalCache.pruneOldAndHiddenMessages(it) - NostrChatroomDataSource.clearEOSEs(it) - - LocalCache.pruneHiddenMessages(it) - LocalCache.pruneContactLists(accounts) - LocalCache.pruneRepliesAndReactions(accounts) - LocalCache.prunePastVersionsOfReplaceables() - LocalCache.pruneExpiredEvents() - } - } - - // This method keeps the pause/start in a Syncronized block to - // avoid concurrent pauses and starts. - @Synchronized - fun forceRestart(account: Account? = null, start: Boolean = true, pause: Boolean = true) { - if (pause) { - pause() - } - - if (start) { - if (account != null) { - start(account) - } else { - start() - } - } - } - - fun restartIfDifferentAccount(account: Account) { - if (this.account != account) { - forceRestart(account, true, true) - } - } - - fun forceRestart() { - forceRestart(null, true, true) - } - - fun justStart() { - forceRestart(null, true, false) - } - - fun pauseForGood() { - forceRestart(null, false, true) - } - - fun pauseForGoodAndClearAccount() { - account = null - forceRestart(null, false, true) - } + fun pauseForGoodAndClearAccount() { + account = null + forceRestart(null, false, true) + } } 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 c9f76fc8c..13fcf7876 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1,2141 +1,2359 @@ -package com.vitorpamplona.amethyst.model - -import android.content.res.Resources -import android.util.Log -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.Stable -import androidx.core.os.ConfigurationCompat -import androidx.lifecycle.LiveData -import androidx.lifecycle.asFlow -import androidx.lifecycle.asLiveData -import androidx.lifecycle.liveData -import androidx.lifecycle.switchMap -import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.service.FileHeader -import com.vitorpamplona.amethyst.service.Nip96MediaServers -import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource -import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.amethyst.service.relays.Client -import com.vitorpamplona.amethyst.service.relays.Constants -import com.vitorpamplona.amethyst.service.relays.FeedType -import com.vitorpamplona.amethyst.service.relays.Relay -import com.vitorpamplona.amethyst.ui.components.BundledUpdate -import com.vitorpamplona.quartz.crypto.KeyPair -import com.vitorpamplona.quartz.encoders.ATag -import com.vitorpamplona.quartz.encoders.HexKey -import com.vitorpamplona.quartz.encoders.hexToByteArray -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.events.BookmarkListEvent -import com.vitorpamplona.quartz.events.ChannelCreateEvent -import com.vitorpamplona.quartz.events.ChannelMessageEvent -import com.vitorpamplona.quartz.events.ChannelMetadataEvent -import com.vitorpamplona.quartz.events.ChatMessageEvent -import com.vitorpamplona.quartz.events.ClassifiedsEvent -import com.vitorpamplona.quartz.events.Contact -import com.vitorpamplona.quartz.events.ContactListEvent -import com.vitorpamplona.quartz.events.DeletionEvent -import com.vitorpamplona.quartz.events.EmojiPackEvent -import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent -import com.vitorpamplona.quartz.events.EmojiUrl -import com.vitorpamplona.quartz.events.Event -import com.vitorpamplona.quartz.events.FileHeaderEvent -import com.vitorpamplona.quartz.events.FileServersEvent -import com.vitorpamplona.quartz.events.FileStorageEvent -import com.vitorpamplona.quartz.events.FileStorageHeaderEvent -import com.vitorpamplona.quartz.events.GeneralListEvent -import com.vitorpamplona.quartz.events.GenericRepostEvent -import com.vitorpamplona.quartz.events.GiftWrapEvent -import com.vitorpamplona.quartz.events.HTTPAuthorizationEvent -import com.vitorpamplona.quartz.events.IdentityClaim -import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.events.LnZapEvent -import com.vitorpamplona.quartz.events.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent -import com.vitorpamplona.quartz.events.LnZapRequestEvent -import com.vitorpamplona.quartz.events.MetadataEvent -import com.vitorpamplona.quartz.events.MuteListEvent -import com.vitorpamplona.quartz.events.NIP24Factory -import com.vitorpamplona.quartz.events.PeopleListEvent -import com.vitorpamplona.quartz.events.PollNoteEvent -import com.vitorpamplona.quartz.events.Price -import com.vitorpamplona.quartz.events.PrivateDmEvent -import com.vitorpamplona.quartz.events.ReactionEvent -import com.vitorpamplona.quartz.events.RelayAuthEvent -import com.vitorpamplona.quartz.events.ReportEvent -import com.vitorpamplona.quartz.events.RepostEvent -import com.vitorpamplona.quartz.events.Response -import com.vitorpamplona.quartz.events.SealedGossipEvent -import com.vitorpamplona.quartz.events.StatusEvent -import com.vitorpamplona.quartz.events.TextNoteEvent -import com.vitorpamplona.quartz.events.WrappedEvent -import com.vitorpamplona.quartz.events.ZapSplitSetup -import com.vitorpamplona.quartz.signers.NostrSigner -import com.vitorpamplona.quartz.signers.NostrSignerExternal -import com.vitorpamplona.quartz.signers.NostrSignerInternal -import com.vitorpamplona.quartz.utils.DualCase -import kotlinx.collections.immutable.ImmutableSet -import kotlinx.collections.immutable.persistentSetOf -import kotlinx.collections.immutable.toImmutableSet -import kotlinx.collections.immutable.toPersistentSet -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combineTransform -import kotlinx.coroutines.flow.flattenMerge -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.transformLatest -import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull -import java.math.BigDecimal -import java.net.Proxy -import java.util.Locale -import java.util.UUID -import kotlin.coroutines.resume - -val DefaultChannels = setOf( - "25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", // -> Anigma's Nostr - "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5" // -> Amethyst's Group -) - -val DefaultReactions = listOf("\uD83D\uDE80", "\uD83E\uDEC2", "\uD83D\uDC40", "\uD83D\uDE02", "\uD83C\uDF89", "\uD83E\uDD14", "\uD83D\uDE31") - -val DefaultZapAmounts = listOf(500L, 1000L, 5000L) - -fun getLanguagesSpokenByUser(): Set { - val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration()) - val codedList = mutableSetOf() - for (i in 0 until languageList.size()) { - languageList.get(i)?.let { codedList.add(it.language) } - } - return codedList -} - -val GLOBAL_FOLLOWS = " Global " // This has spaces to avoid mixing with a potential NIP-51 list with the same name. -val KIND3_FOLLOWS = " All Follows " // This has spaces to avoid mixing with a potential NIP-51 list with the same name. - -@OptIn(DelicateCoroutinesApi::class) -@Stable -class Account( - val keyPair: KeyPair, - val signer: NostrSigner = NostrSignerInternal(keyPair), - - var localRelays: Set = Constants.defaultRelays.toSet(), - var dontTranslateFrom: Set = getLanguagesSpokenByUser(), - var languagePreferences: Map = mapOf(), - var translateTo: String = Locale.getDefault().language, - var zapAmountChoices: List = DefaultZapAmounts, - var reactionChoices: List = DefaultReactions, - var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, - var defaultFileServer: Nip96MediaServers.ServerName = Nip96MediaServers.DEFAULT[0], - var defaultHomeFollowList: MutableStateFlow = MutableStateFlow(KIND3_FOLLOWS), - var defaultStoriesFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), - var defaultNotificationFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), - var defaultDiscoveryFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), - var zapPaymentRequest: Nip47URI? = null, - var hideDeleteRequestDialog: Boolean = false, - var hideBlockAlertDialog: Boolean = false, - var hideNIP24WarningDialog: Boolean = false, - var backupContactList: ContactListEvent? = null, - var proxy: Proxy? = null, - var proxyPort: Int = 9050, - var showSensitiveContent: Boolean? = null, - var warnAboutPostsWithReports: Boolean = true, - var filterSpamFromStrangers: Boolean = true, - var lastReadPerRoute: Map = mapOf() -) { - // Uses a single scope for the entire application. - val scope = Amethyst.instance.applicationIOScope - - var transientHiddenUsers: ImmutableSet = persistentSetOf() - - data class PaymentRequest( - val relayUrl: String, - val description: String - ) - var transientPaymentRequestDismissals: Set = emptySet() - val transientPaymentRequests: MutableStateFlow> = MutableStateFlow(emptySet()) - - // Observers line up here. - val live: AccountLiveData = AccountLiveData(this) - val liveLanguages: AccountLiveData = AccountLiveData(this) - val saveable: AccountLiveData = AccountLiveData(this) - - @Immutable - data class LiveFollowLists( - val users: ImmutableSet = persistentSetOf(), - val hashtags: ImmutableSet = persistentSetOf(), - val geotags: ImmutableSet = persistentSetOf(), - val communities: ImmutableSet = persistentSetOf() - ) - - @OptIn(ExperimentalCoroutinesApi::class) - val liveKind3Follows: StateFlow by lazy { - userProfile().live().follows.asFlow().transformLatest { - emit( - LiveFollowLists( - userProfile().cachedFollowingKeySet().toImmutableSet(), - userProfile().cachedFollowingTagSet().toImmutableSet(), - userProfile().cachedFollowingGeohashSet().toImmutableSet(), - userProfile().cachedFollowingCommunitiesSet().toImmutableSet() - ) - ) - }.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists()) - } - - @OptIn(ExperimentalCoroutinesApi::class) - private val liveHomeList: StateFlow by lazy { - defaultHomeFollowList.transformLatest { - LocalCache.checkGetOrCreateAddressableNote(it)?.flow()?.metadata?.stateFlow?.let { - emit(it) - } - }.flattenMerge() - .stateIn(scope, SharingStarted.Eagerly, null) - } - - val liveHomeFollowLists: StateFlow by lazy { - combineTransform(defaultHomeFollowList, liveKind3Follows, liveHomeList) { listName, kind3Follows, peopleListFollows -> - if (listName == GLOBAL_FOLLOWS) { - emit(null) - } else if (listName == KIND3_FOLLOWS) { - emit(kind3Follows) - } else { - val result = withTimeoutOrNull(1000) { - suspendCancellableCoroutine { continuation -> - decryptLiveFollows(peopleListFollows) { - continuation.resume(it) - } - } - } - result?.let { - emit(it) - } ?: run { - emit(LiveFollowLists()) - } - } - }.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists()) - } - - @OptIn(ExperimentalCoroutinesApi::class) - private val liveNotificationList: StateFlow by lazy { - defaultNotificationFollowList.transformLatest { - LocalCache.checkGetOrCreateAddressableNote(it)?.flow()?.metadata?.stateFlow?.let { - emit(it) - } - }.flattenMerge() - .stateIn(scope, SharingStarted.Eagerly, null) - } - - val liveNotificationFollowLists: StateFlow by lazy { - combineTransform(defaultNotificationFollowList, liveKind3Follows, liveNotificationList) { listName, kind3Follows, peopleListFollows -> - if (listName == GLOBAL_FOLLOWS) { - emit(null) - } else if (listName == KIND3_FOLLOWS) { - emit(kind3Follows) - } else { - val result = withTimeoutOrNull(1000) { - suspendCancellableCoroutine { continuation -> - decryptLiveFollows(peopleListFollows) { - continuation.resume(it) - } - } - } - result?.let { - emit(it) - } ?: run { - emit(LiveFollowLists()) - } - } - }.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists()) - } - - @OptIn(ExperimentalCoroutinesApi::class) - private val liveStoriesList: StateFlow by lazy { - defaultStoriesFollowList.transformLatest { - LocalCache.checkGetOrCreateAddressableNote(it)?.flow()?.metadata?.stateFlow?.let { - emit(it) - } - }.flattenMerge() - .stateIn(scope, SharingStarted.Eagerly, null) - } - - val liveStoriesFollowLists: StateFlow by lazy { - combineTransform(defaultStoriesFollowList, liveKind3Follows, liveStoriesList) { listName, kind3Follows, peopleListFollows -> - if (listName == GLOBAL_FOLLOWS) { - emit(null) - } else if (listName == KIND3_FOLLOWS) { - emit(kind3Follows) - } else { - val result = withTimeoutOrNull(1000) { - suspendCancellableCoroutine { continuation -> - decryptLiveFollows(peopleListFollows) { - continuation.resume(it) - } - } - } - result?.let { - emit(it) - } ?: run { - emit(LiveFollowLists()) - } - } - }.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists()) - } - - @OptIn(ExperimentalCoroutinesApi::class) - private val liveDiscoveryList: StateFlow by lazy { - defaultDiscoveryFollowList.transformLatest { - LocalCache.checkGetOrCreateAddressableNote(it)?.flow()?.metadata?.stateFlow?.let { - emit(it) - } - }.flattenMerge() - .stateIn(scope, SharingStarted.Eagerly, null) - } - - val liveDiscoveryFollowLists: StateFlow by lazy { - combineTransform(defaultDiscoveryFollowList, liveKind3Follows, liveDiscoveryList) { listName, kind3Follows, peopleListFollows -> - if (listName == GLOBAL_FOLLOWS) { - emit(null) - } else if (listName == KIND3_FOLLOWS) { - emit(kind3Follows) - } else { - val result = withTimeoutOrNull(1000) { - suspendCancellableCoroutine { continuation -> - decryptLiveFollows(peopleListFollows) { - continuation.resume(it) - } - } - } - result?.let { - emit(it) - } ?: run { - emit(LiveFollowLists()) - } - } - }.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists()) - } - - private fun decryptLiveFollows(peopleListFollows: NoteState?, onReady: (LiveFollowLists) -> Unit) { - val listEvent = (peopleListFollows?.note?.event as? GeneralListEvent) - listEvent?.privateTags(signer) { privateTagList -> - onReady( - LiveFollowLists( - users = (listEvent.bookmarkedPeople() + listEvent.filterUsers(privateTagList)).toImmutableSet(), - hashtags = (listEvent.hashtags() + listEvent.filterHashtags(privateTagList)).toImmutableSet(), - geotags = (listEvent.geohashes() + listEvent.filterGeohashes(privateTagList)).toImmutableSet(), - communities = (listEvent.taggedAddresses() + listEvent.filterAddresses(privateTagList)).map { it.toTag() }.toImmutableSet() - ) - ) - } - } - - @Immutable - data class LiveHiddenUsers( - val hiddenUsers: ImmutableSet, - val spammers: ImmutableSet, - val hiddenWords: ImmutableSet, - val hiddenWordsCase: List, - val showSensitiveContent: Boolean? - ) - - val flowHiddenUsers: StateFlow by lazy { - combineTransform( - live.asFlow(), - getBlockListNote().flow().metadata.stateFlow, - getMuteListNote().flow().metadata.stateFlow - ) { localLive, blockList, muteList -> - checkNotInMainThread() - - val resultBlockList = - (blockList.note.event as? PeopleListEvent)?.let { - withTimeoutOrNull(1000) { - suspendCancellableCoroutine { continuation -> - it.publicAndPrivateUsersAndWords(signer) { - continuation.resume(it) - } - } - } - } ?: PeopleListEvent.UsersAndWords() - - val resultMuteList = - (muteList.note.event as? MuteListEvent)?.let { - withTimeoutOrNull(1000) { - suspendCancellableCoroutine { continuation -> - it.publicAndPrivateUsersAndWords(signer) { - continuation.resume(it) - } - } - } - } ?: PeopleListEvent.UsersAndWords() - - val hiddenWords = resultBlockList.words + resultMuteList.words - - emit( - LiveHiddenUsers( - hiddenUsers = (resultBlockList.users + resultMuteList.users).toPersistentSet(), - hiddenWords = hiddenWords.toPersistentSet(), - hiddenWordsCase = hiddenWords.map { DualCase(it.lowercase(), it.uppercase()) }, - spammers = localLive.account.transientHiddenUsers, - showSensitiveContent = localLive.account.showSensitiveContent - ) - ) - }.stateIn( - scope, - SharingStarted.Eagerly, - LiveHiddenUsers( - hiddenUsers = persistentSetOf(), - hiddenWords = persistentSetOf(), - hiddenWordsCase = emptyList(), - spammers = transientHiddenUsers, - showSensitiveContent = showSensitiveContent - ) - ) - } - - val liveHiddenUsers = flowHiddenUsers.asLiveData() - - val decryptBookmarks: LiveData by lazy { - userProfile().live().innerBookmarks.switchMap { userState -> - liveData(Dispatchers.IO) { - userState.user.latestBookmarkList?.privateTags(signer) { - scope.launch(Dispatchers.IO) { - userState.user.latestBookmarkList?.let { - emit(it) - } - } - } - } - } - } - - fun addPaymentRequestIfNew(paymentRequest: PaymentRequest) { - if (!this.transientPaymentRequests.value.contains(paymentRequest) && - !this.transientPaymentRequestDismissals.contains(paymentRequest) - ) { - this.transientPaymentRequests.value = transientPaymentRequests.value + paymentRequest - } - } - - fun dismissPaymentRequest(request: PaymentRequest) { - if (this.transientPaymentRequests.value.contains(request)) { - this.transientPaymentRequests.value = transientPaymentRequests.value - request - this.transientPaymentRequestDismissals = transientPaymentRequestDismissals + request - } - } - - var userProfileCache: User? = null - - fun updateOptOutOptions(warnReports: Boolean, filterSpam: Boolean) { - warnAboutPostsWithReports = warnReports - filterSpamFromStrangers = filterSpam - LocalCache.antiSpam.active = filterSpamFromStrangers - if (!filterSpamFromStrangers) { - transientHiddenUsers = persistentSetOf() - } - live.invalidateData() - saveable.invalidateData() - } - - fun userProfile(): User { - return userProfileCache ?: run { - val myUser: User = LocalCache.getOrCreateUser(keyPair.pubKey.toHexKey()) - userProfileCache = myUser - myUser - } - } - - fun isWriteable(): Boolean { - return keyPair.privKey != null || signer is NostrSignerExternal - } - - fun sendNewRelayList(relays: Map) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null && contactList.tags.isNotEmpty()) { - ContactListEvent.updateRelayList( - earlierVersion = contactList, - relayUse = relays, - signer = signer - ) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } else { - ContactListEvent.createFromScratch( - followUsers = listOf(), - followTags = listOf(), - followGeohashes = listOf(), - followCommunities = listOf(), - followEvents = DefaultChannels.toList(), - relayUse = relays, - signer = signer - ) { - // Keep this local to avoid erasing a good contact list. - // Client.send(it) - LocalCache.justConsume(it, null) - } - } - } - - suspend fun sendNewUserMetadata(toString: String, newName: String, identities: List) { - if (!isWriteable()) return - - MetadataEvent.create(toString, newName, identities, signer) { - Client.send(it) - LocalCache.justConsume(it, null) - } - - return - } - - fun reactionTo(note: Note, reaction: String): List { - return note.reactedBy(userProfile(), reaction) - } - - fun hasBoosted(note: Note): Boolean { - return boostsTo(note).isNotEmpty() - } - - fun boostsTo(note: Note): List { - return note.boostedBy(userProfile()) - } - - fun hasReacted(note: Note, reaction: String): Boolean { - return note.hasReacted(userProfile(), reaction) - } - - suspend fun reactTo(note: Note, reaction: String) { - if (!isWriteable()) return - - if (hasReacted(note, reaction)) { - // has already liked this note - return - } - - if (note.event is ChatMessageEvent) { - val event = note.event as ChatMessageEvent - val users = event.recipientsPubKey().plus(event.pubKey).toSet().toList() - - if (reaction.startsWith(":")) { - val emojiUrl = EmojiUrl.decode(reaction) - if (emojiUrl != null) { - note.event?.let { - NIP24Factory().createReactionWithinGroup( - emojiUrl = emojiUrl, - originalNote = it, - to = users, - signer = signer - ) { - broadcastPrivately(it) - } - } - - return - } - } - - note.event?.let { - NIP24Factory().createReactionWithinGroup( - content = reaction, - originalNote = it, - to = users, - signer = signer - ) { - broadcastPrivately(it) - } - } - return - } else { - if (reaction.startsWith(":")) { - val emojiUrl = EmojiUrl.decode(reaction) - if (emojiUrl != null) { - note.event?.let { - ReactionEvent.create(emojiUrl, it, signer) { - Client.send(it) - LocalCache.consume(it) - } - } - - return - } - } - - note.event?.let { - ReactionEvent.create(reaction, it, signer) { - Client.send(it) - LocalCache.consume(it) - } - } - } - } - - fun createZapRequestFor(note: Note, pollOption: Int?, message: String = "", zapType: LnZapEvent.ZapType, toUser: User?, onReady: (LnZapRequestEvent) -> Unit) { - if (!isWriteable()) return - - note.event?.let { event -> - LnZapRequestEvent.create( - event, - userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } - ?: localRelays.map { it.url }.toSet(), - signer, - pollOption, - message, - zapType, - toUser?.pubkeyHex, - onReady = onReady - ) - } - } - - fun hasWalletConnectSetup(): Boolean { - return zapPaymentRequest != null - } - - fun isNIP47Author(pubkeyHex: String?): Boolean { - return (getNIP47Signer().pubKey == pubkeyHex) - } - - fun getNIP47Signer(): NostrSigner { - return zapPaymentRequest?.secret?.hexToByteArray()?.let { NostrSignerInternal(KeyPair(it)) } ?: signer - } - - fun decryptZapPaymentResponseEvent(zapResponseEvent: LnZapPaymentResponseEvent, onReady: (Response) -> Unit) { - val myNip47 = zapPaymentRequest ?: return - - val signer = myNip47.secret?.hexToByteArray()?.let { NostrSignerInternal(KeyPair(it)) } ?: signer - - zapResponseEvent.response(signer, onReady) - } - - fun calculateIfNoteWasZappedByAccount(zappedNote: Note?, onWasZapped: () -> Unit) { - zappedNote?.isZappedBy(userProfile(), this, onWasZapped) - } - - fun calculateZappedAmount(zappedNote: Note?, onReady: (BigDecimal) -> Unit) { - zappedNote?.zappedAmountWithNWCPayments(getNIP47Signer(), onReady) - } - - fun sendZapPaymentRequestFor(bolt11: String, zappedNote: Note?, onResponse: (Response?) -> Unit) { - if (!isWriteable()) return - - zapPaymentRequest?.let { nip47 -> - val signer = nip47.secret?.hexToByteArray()?.let { NostrSignerInternal(KeyPair(it)) } ?: signer - - LnZapPaymentRequestEvent.create(bolt11, nip47.pubKeyHex, signer) { event -> - val wcListener = NostrLnZapPaymentResponseDataSource( - fromServiceHex = nip47.pubKeyHex, - toUserHex = event.pubKey, - replyingToHex = event.id, - authSigner = signer - ) - wcListener.start() - - LocalCache.consume(event, zappedNote) { - it.response(signer) { - onResponse(it) - } - } - - Client.send(event, nip47.relayUri, wcListener.feedTypes) { - wcListener.destroy() - } - } - } - } - - fun createZapRequestFor(userPubKeyHex: String, message: String = "", zapType: LnZapEvent.ZapType, onReady: (LnZapRequestEvent) -> Unit) { - LnZapRequestEvent.create( - userPubKeyHex, - userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } - ?: localRelays.map { it.url }.toSet(), - signer, - message, - zapType, - onReady = onReady - ) - } - - suspend fun report(note: Note, type: ReportEvent.ReportType, content: String = "") { - if (!isWriteable()) return - - if (note.hasReacted(userProfile(), "โš ๏ธ")) { - // has already liked this note - return - } - - note.event?.let { - ReactionEvent.createWarning(it, signer) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } - - note.event?.let { - ReportEvent.create(it, type, signer, content) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } - } - - suspend fun report(user: User, type: ReportEvent.ReportType) { - if (!isWriteable()) return - - if (user.hasReport(userProfile(), type)) { - // has already reported this note - return - } - - ReportEvent.create(user.pubkeyHex, type, signer) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } - - suspend fun delete(note: Note) { - return delete(listOf(note)) - } - - suspend fun delete(notes: List) { - if (!isWriteable()) return - - val myNotes = notes.filter { it.author == userProfile() }.mapNotNull { - it.event?.id() - } - - if (myNotes.isNotEmpty()) { - DeletionEvent.create(myNotes, signer) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } - } - - fun createHTTPAuthorization(url: String, method: String, body: ByteArray? = null, onReady: (HTTPAuthorizationEvent) -> Unit) { - if (!isWriteable()) return - - HTTPAuthorizationEvent.create(url, method, body, signer, onReady = onReady) - } - - suspend fun boost(note: Note) { - if (!isWriteable()) return - - if (note.hasBoostedInTheLast5Minutes(userProfile())) { - // has already bosted in the past 5mins - return - } - - note.event?.let { - if (it.kind() == 1) { - RepostEvent.create(it, signer) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } else { - GenericRepostEvent.create(it, signer) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } - } - } - - fun broadcast(note: Note) { - note.event?.let { - if (it is WrappedEvent && it.host != null) { - it.host?.let { hostEvent -> - Client.send(hostEvent) - } - } else { - Client.send(it) - } - } - } - - fun follow(user: User) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null) { - ContactListEvent.followUser(contactList, user.pubkeyHex, signer) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } else { - ContactListEvent.createFromScratch( - followUsers = listOf(Contact(user.pubkeyHex, null)), - followTags = emptyList(), - followGeohashes = emptyList(), - followCommunities = emptyList(), - followEvents = DefaultChannels.toList(), - relayUse = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }, - signer = signer - ) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } - } - - fun follow(channel: Channel) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null) { - ContactListEvent.followEvent(contactList, channel.idHex, signer) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } else { - ContactListEvent.createFromScratch( - followUsers = emptyList(), - followTags = emptyList(), - followGeohashes = emptyList(), - followCommunities = emptyList(), - followEvents = DefaultChannels.toList().plus(channel.idHex), - relayUse = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }, - signer = signer - ) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } - } - - fun follow(community: AddressableNote) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null) { - ContactListEvent.followAddressableEvent(contactList, community.address, signer) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } else { - val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } - ContactListEvent.createFromScratch( - followUsers = emptyList(), - followTags = emptyList(), - followGeohashes = emptyList(), - followCommunities = listOf(community.address), - followEvents = DefaultChannels.toList(), - relayUse = relays, - signer = signer - ) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } - } - - fun followHashtag(tag: String) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null) { - ContactListEvent.followHashtag( - contactList, - tag, - signer - ) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } else { - ContactListEvent.createFromScratch( - followUsers = emptyList(), - followTags = listOf(tag), - followGeohashes = emptyList(), - followCommunities = emptyList(), - followEvents = DefaultChannels.toList(), - relayUse = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }, - signer = signer - ) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } - } - - fun followGeohash(geohash: String) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null) { - ContactListEvent.followGeohash( - contactList, - geohash, - signer, - onReady = this::onNewEventCreated - ) - } else { - ContactListEvent.createFromScratch( - followUsers = emptyList(), - followTags = emptyList(), - followGeohashes = listOf(geohash), - followCommunities = emptyList(), - followEvents = DefaultChannels.toList(), - relayUse = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }, - signer = signer, - onReady = this::onNewEventCreated - ) - } - } - - fun onNewEventCreated(event: Event) { - Client.send(event) - LocalCache.justConsume(event, null) - } - - fun unfollow(user: User) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null && contactList.tags.isNotEmpty()) { - ContactListEvent.unfollowUser( - contactList, - user.pubkeyHex, - signer, - onReady = this::onNewEventCreated - ) - } - } - - suspend fun unfollowHashtag(tag: String) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null && contactList.tags.isNotEmpty()) { - ContactListEvent.unfollowHashtag( - contactList, - tag, - signer, - onReady = this::onNewEventCreated - ) - } - } - - suspend fun unfollowGeohash(geohash: String) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null && contactList.tags.isNotEmpty()) { - ContactListEvent.unfollowGeohash( - contactList, - geohash, - signer, - onReady = this::onNewEventCreated - ) - } - } - - suspend fun unfollow(channel: Channel) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null && contactList.tags.isNotEmpty()) { - ContactListEvent.unfollowEvent( - contactList, - channel.idHex, - signer, - onReady = this::onNewEventCreated - ) - } - } - - suspend fun unfollow(community: AddressableNote) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null && contactList.tags.isNotEmpty()) { - ContactListEvent.unfollowAddressableEvent( - contactList, - community.address, - signer, - onReady = this::onNewEventCreated - ) - } - } - - fun createNip95( - byteArray: ByteArray, - headerInfo: FileHeader, - alt: String?, - sensitiveContent: Boolean, - onReady: (Pair) -> Unit - ) { - if (!isWriteable()) return - - FileStorageEvent.create( - mimeType = headerInfo.mimeType ?: "", - data = byteArray, - signer = signer - ) { data -> - FileStorageHeaderEvent.create( - data, - mimeType = headerInfo.mimeType, - hash = headerInfo.hash, - size = headerInfo.size.toString(), - dimensions = headerInfo.dim, - blurhash = headerInfo.blurHash, - alt = alt, - sensitiveContent = sensitiveContent, - signer = signer - ) { signedEvent -> - onReady( - Pair(data, signedEvent) - ) - } - } - } - - fun consumeAndSendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent, relayList: List? = null): Note? { - if (!isWriteable()) return null - - Client.send(data, relayList = relayList) - LocalCache.consume(data, null) - - Client.send(signedEvent, relayList = relayList) - LocalCache.consume(signedEvent, null) - - return LocalCache.notes[signedEvent.id] - } - - fun consumeNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent): Note? { - LocalCache.consume(data, null) - LocalCache.consume(signedEvent, null) - - return LocalCache.notes[signedEvent.id] - } - - fun sendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent, relayList: List? = null) { - Client.send(data, relayList = relayList) - Client.send(signedEvent, relayList = relayList) - } - - fun sendHeader(signedEvent: FileHeaderEvent, relayList: List? = null, onReady: (Note) -> Unit) { - Client.send(signedEvent, relayList = relayList) - LocalCache.consume(signedEvent, null) - - LocalCache.notes[signedEvent.id]?.let { - onReady(it) - } - } - - fun createHeader( - imageUrl: String, - magnetUri: String?, - headerInfo: FileHeader, - alt: String?, - sensitiveContent: Boolean, - originalHash: String? = null, - onReady: (FileHeaderEvent) -> Unit - ) { - if (!isWriteable()) return - - FileHeaderEvent.create( - url = imageUrl, - magnetUri = magnetUri, - mimeType = headerInfo.mimeType, - hash = headerInfo.hash, - size = headerInfo.size.toString(), - dimensions = headerInfo.dim, - blurhash = headerInfo.blurHash, - alt = alt, - originalHash = originalHash, - sensitiveContent = sensitiveContent, - signer = signer - ) { event -> - onReady(event) - } - } - - fun sendHeader( - imageUrl: String, - magnetUri: String?, - headerInfo: FileHeader, - alt: String?, - sensitiveContent: Boolean, - originalHash: String? = null, - relayList: List? = null, - onReady: (Note) -> Unit - ) { - if (!isWriteable()) return - - FileHeaderEvent.create( - url = imageUrl, - magnetUri = magnetUri, - mimeType = headerInfo.mimeType, - hash = headerInfo.hash, - size = headerInfo.size.toString(), - dimensions = headerInfo.dim, - blurhash = headerInfo.blurHash, - alt = alt, - originalHash = originalHash, - sensitiveContent = sensitiveContent, - signer = signer - ) { event -> - sendHeader(event, relayList = relayList, onReady) - } - } - - fun sendClassifieds( - title: String, - price: Price, - condition: ClassifiedsEvent.CONDITION, - location: String, - category: String, - message: String, - replyTo: List?, - mentions: List?, - directMentions: Set, - zapReceiver: List? = null, - wantsToMarkAsSensitive: Boolean, - zapRaiserAmount: Long? = null, - relayList: List? = null, - geohash: String? = null, - nip94attachments: List? = null - ) { - if (!isWriteable()) return - - val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex } - val mentionsHex = mentions?.map { it.pubkeyHex } - val addresses = replyTo?.mapNotNull { it.address() } - - ClassifiedsEvent.create( - dTag = UUID.randomUUID().toString(), - title = title, - price = price, - condition = condition, - summary = message, - image = null, - location = location, - category = category, - message = message, - replyTos = repliesToHex, - mentions = mentionsHex, - addresses = addresses, - zapReceiver = zapReceiver, - markAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = zapRaiserAmount, - directMentions = directMentions, - geohash = geohash, - nip94attachments = nip94attachments, - signer = signer - ) { - Client.send(it, relayList = relayList) - LocalCache.justConsume(it, null) - - replyTo?.forEach { - it.event?.let { - Client.send(it, relayList = relayList) - } - } - addresses?.forEach { - LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let { - Client.send(it, relayList = relayList) - } - } - } - } - - fun sendPost( - message: String, - replyTo: List?, - mentions: List?, - tags: List? = null, - zapReceiver: List? = null, - wantsToMarkAsSensitive: Boolean, - zapRaiserAmount: Long? = null, - replyingTo: String?, - root: String?, - directMentions: Set, - relayList: List? = null, - geohash: String? = null, - nip94attachments: List? = null - ) { - if (!isWriteable()) return - - val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex } - val mentionsHex = mentions?.map { it.pubkeyHex } - val addresses = replyTo?.mapNotNull { it.address() } - - TextNoteEvent.create( - msg = message, - replyTos = repliesToHex, - mentions = mentionsHex, - addresses = addresses, - extraTags = tags, - zapReceiver = zapReceiver, - markAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = zapRaiserAmount, - replyingTo = replyingTo, - root = root, - directMentions = directMentions, - geohash = geohash, - nip94attachments = nip94attachments, - signer = signer - ) { - Client.send(it, relayList = relayList) - LocalCache.justConsume(it, null) - - // broadcast replied notes - replyingTo?.let { - LocalCache.getNoteIfExists(replyingTo)?.event?.let { - Client.send(it, relayList = relayList) - } - } - replyTo?.forEach { - it.event?.let { - Client.send(it, relayList = relayList) - } - } - addresses?.forEach { - LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let { - Client.send(it, relayList = relayList) - } - } - } - } - - fun sendPoll( - message: String, - replyTo: List?, - mentions: List?, - pollOptions: Map, - valueMaximum: Int?, - valueMinimum: Int?, - consensusThreshold: Int?, - closedAt: Int?, - zapReceiver: List? = null, - wantsToMarkAsSensitive: Boolean, - zapRaiserAmount: Long? = null, - relayList: List? = null, - geohash: String? = null, - nip94attachments: List? = null - ) { - if (!isWriteable()) return - - val repliesToHex = replyTo?.map { it.idHex } - val mentionsHex = mentions?.map { it.pubkeyHex } - val addresses = replyTo?.mapNotNull { it.address() } - - PollNoteEvent.create( - msg = message, - replyTos = repliesToHex, - mentions = mentionsHex, - addresses = addresses, - signer = signer, - pollOptions = pollOptions, - valueMaximum = valueMaximum, - valueMinimum = valueMinimum, - consensusThreshold = consensusThreshold, - closedAt = closedAt, - zapReceiver = zapReceiver, - markAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = zapRaiserAmount, - geohash = geohash, - nip94attachments = nip94attachments - ) { - Client.send(it, relayList = relayList) - LocalCache.justConsume(it, null) - - // Rebroadcast replies and tags to the current relay set - replyTo?.forEach { - it.event?.let { - Client.send(it, relayList = relayList) - } - } - addresses?.forEach { - LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let { - Client.send(it, relayList = relayList) - } - } - } - } - - fun sendChannelMessage(message: String, toChannel: String, replyTo: List?, mentions: List?, zapReceiver: List? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null, nip94attachments: List? = null) { - if (!isWriteable()) return - - val repliesToHex = replyTo?.map { it.idHex } - val mentionsHex = mentions?.map { it.pubkeyHex } - - ChannelMessageEvent.create( - message = message, - channel = toChannel, - replyTos = repliesToHex, - mentions = mentionsHex, - zapReceiver = zapReceiver, - markAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = zapRaiserAmount, - geohash = geohash, - nip94attachments = nip94attachments, - signer = signer - ) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } - - fun sendLiveMessage(message: String, toChannel: ATag, replyTo: List?, mentions: List?, zapReceiver: List? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null, nip94attachments: List? = null) { - if (!isWriteable()) return - - // val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } - val repliesToHex = replyTo?.map { it.idHex } - val mentionsHex = mentions?.map { it.pubkeyHex } - - LiveActivitiesChatMessageEvent.create( - message = message, - activity = toChannel, - replyTos = repliesToHex, - mentions = mentionsHex, - zapReceiver = zapReceiver, - markAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = zapRaiserAmount, - geohash = geohash, - nip94attachments = nip94attachments, - signer = signer - ) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } - - fun sendPrivateMessage(message: String, toUser: User, replyingTo: Note? = null, mentions: List?, zapReceiver: List? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) { - sendPrivateMessage(message, toUser.pubkeyHex, replyingTo, mentions, zapReceiver, wantsToMarkAsSensitive, zapRaiserAmount, geohash) - } - - fun sendPrivateMessage(message: String, toUser: HexKey, replyingTo: Note? = null, mentions: List?, zapReceiver: List? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) { - if (!isWriteable()) return - - val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } - val mentionsHex = mentions?.map { it.pubkeyHex } - - PrivateDmEvent.create( - recipientPubKey = toUser, - publishedRecipientPubKey = toUser, - msg = message, - replyTos = repliesToHex, - mentions = mentionsHex, - zapReceiver = zapReceiver, - markAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = zapRaiserAmount, - geohash = geohash, - signer = signer, - advertiseNip18 = false - ) { - Client.send(it) - LocalCache.consume(it, null) - } - } - - fun sendNIP24PrivateMessage( - message: String, - toUsers: List, - subject: String? = null, - replyingTo: Note? = null, - mentions: List?, - zapReceiver: List? = null, - wantsToMarkAsSensitive: Boolean, - zapRaiserAmount: Long? = null, - geohash: String? = null - ) { - if (!isWriteable()) return - - val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } - val mentionsHex = mentions?.map { it.pubkeyHex } - - NIP24Factory().createMsgNIP24( - msg = message, - to = toUsers, - subject = subject, - replyTos = repliesToHex, - mentions = mentionsHex, - zapReceiver = zapReceiver, - markAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = zapRaiserAmount, - geohash = geohash, - signer = signer - ) { - broadcastPrivately(it) - } - } - - fun broadcastPrivately(signedEvents: NIP24Factory.Result) { - val mine = signedEvents.wraps.filter { - (it.recipientPubKey() == signer.pubKey) - } - - mine.forEach { giftWrap -> - giftWrap.cachedGift(signer) { gift -> - if (gift is SealedGossipEvent) { - gift.cachedGossip(signer) { gossip -> - LocalCache.justConsume(gossip, null) - } - } else { - LocalCache.justConsume(gift, null) - } - } - - LocalCache.consume(giftWrap, null) - } - - val id = mine.firstOrNull()?.id - val mineNote = if (id == null) null else LocalCache.getNoteIfExists(id) - - signedEvents.wraps.forEach { - // Creates an alias - if (mineNote != null && it.recipientPubKey() != keyPair.pubKey.toHexKey()) { - LocalCache.getOrAddAliasNote(it.id, mineNote) - } - - Client.send(it) - } - } - - fun sendCreateNewChannel(name: String, about: String, picture: String) { - if (!isWriteable()) return - - ChannelCreateEvent.create( - name = name, - about = about, - picture = picture, - signer = signer - ) { - Client.send(it) - LocalCache.justConsume(it, null) - - LocalCache.getChannelIfExists(it.id)?.let { - follow(it) - } - } - } - - fun updateStatus(oldStatus: AddressableNote, newStatus: String) { - if (!isWriteable()) return - val oldEvent = oldStatus.event as? StatusEvent ?: return - - StatusEvent.update(oldEvent, newStatus, signer) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } - - fun createStatus(newStatus: String) { - if (!isWriteable()) return - - StatusEvent.create(newStatus, "general", expiration = null, signer) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } - - fun deleteStatus(oldStatus: AddressableNote) { - if (!isWriteable()) return - val oldEvent = oldStatus.event as? StatusEvent ?: return - - StatusEvent.clear(oldEvent, signer) { event -> - Client.send(event) - LocalCache.justConsume(event, null) - - DeletionEvent.create(listOf(event.id), signer) { event2 -> - Client.send(event2) - LocalCache.justConsume(event2, null) - } - } - } - - fun removeEmojiPack(usersEmojiList: Note, emojiList: Note) { - if (!isWriteable()) return - - val noteEvent = usersEmojiList.event - if (noteEvent !is EmojiPackSelectionEvent) return - val emojiListEvent = emojiList.event - if (emojiListEvent !is EmojiPackEvent) return - - EmojiPackSelectionEvent.create( - noteEvent.taggedAddresses().filter { it != emojiListEvent.address() }, - signer - ) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } - - fun addEmojiPack(usersEmojiList: Note, emojiList: Note) { - if (!isWriteable()) return - val emojiListEvent = emojiList.event - if (emojiListEvent !is EmojiPackEvent) return - - if (usersEmojiList.event == null) { - EmojiPackSelectionEvent.create( - listOf(emojiListEvent.address()), - signer - ) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } else { - val noteEvent = usersEmojiList.event - if (noteEvent !is EmojiPackSelectionEvent) return - - if (noteEvent.taggedAddresses().any { it == emojiListEvent.address() }) { - return - } - - EmojiPackSelectionEvent.create( - noteEvent.taggedAddresses().plus(emojiListEvent.address()), - signer - ) { - Client.send(it) - LocalCache.justConsume(it, null) - } - } - } - - fun addBookmark(note: Note, isPrivate: Boolean) { - if (!isWriteable()) return - - if (note is AddressableNote) { - BookmarkListEvent.addReplaceable( - userProfile().latestBookmarkList, - note.address, - isPrivate, - signer - ) { - Client.send(it) - LocalCache.consume(it) - } - } else { - BookmarkListEvent.addEvent( - userProfile().latestBookmarkList, - note.idHex, - isPrivate, - signer - ) { - Client.send(it) - LocalCache.consume(it) - } - } - } - - fun removeBookmark(note: Note, isPrivate: Boolean) { - if (!isWriteable()) return - - val bookmarks = userProfile().latestBookmarkList ?: return - - if (note is AddressableNote) { - BookmarkListEvent.removeReplaceable( - bookmarks, - note.address, - isPrivate, - signer - ) { - Client.send(it) - LocalCache.consume(it) - } - } else { - BookmarkListEvent.removeEvent( - bookmarks, - note.idHex, - isPrivate, - signer - ) { - Client.send(it) - LocalCache.consume(it) - } - } - } - - fun createAuthEvent(relay: Relay, challenge: String, onReady: (RelayAuthEvent) -> Unit) { - return createAuthEvent(relay.url, challenge, onReady = onReady) - } - - fun createAuthEvent(relayUrl: String, challenge: String, onReady: (RelayAuthEvent) -> Unit) { - if (!isWriteable()) return - - RelayAuthEvent.create(relayUrl, challenge, signer, onReady = onReady) - } - - fun isInPrivateBookmarks(note: Note, onReady: (Boolean) -> Unit) { - if (!isWriteable()) return - - if (note is AddressableNote) { - userProfile().latestBookmarkList?.privateTaggedAddresses(signer) { - onReady(it.contains(note.address)) - } - } else { - userProfile().latestBookmarkList?.privateTaggedEvents(signer) { - onReady(it.contains(note.idHex)) - } - } - } - - fun isInPublicBookmarks(note: Note): Boolean { - if (!isWriteable()) return false - - if (note is AddressableNote) { - return userProfile().latestBookmarkList?.taggedAddresses()?.contains(note.address) == true - } else { - return userProfile().latestBookmarkList?.taggedEvents()?.contains(note.idHex) == true - } - } - - fun getBlockListNote(): AddressableNote { - val aTag = ATag( - PeopleListEvent.kind, - userProfile().pubkeyHex, - PeopleListEvent.blockList, - null - ) - return LocalCache.getOrCreateAddressableNote(aTag) - } - - fun getMuteListNote(): AddressableNote { - val aTag = ATag( - MuteListEvent.kind, - userProfile().pubkeyHex, - "", - null - ) - return LocalCache.getOrCreateAddressableNote(aTag) - } - - fun getFileServersNote(): AddressableNote { - val aTag = ATag( - FileServersEvent.kind, - userProfile().pubkeyHex, - "", - null - ) - return LocalCache.getOrCreateAddressableNote(aTag) - } - - fun getBlockList(): PeopleListEvent? { - return getBlockListNote().event as? PeopleListEvent - } - - fun getMuteList(): MuteListEvent? { - return getMuteListNote().event as? MuteListEvent - } - - fun getFileServersList(): FileServersEvent? { - return getFileServersNote().event as? FileServersEvent - } - - fun hideWord(word: String) { - val muteList = getMuteList() - - if (muteList != null) { - MuteListEvent.addWord( - earlierVersion = muteList, - word = word, - isPrivate = true, - signer = signer - ) { - Client.send(it) - LocalCache.consume(it, null) - } - } else { - MuteListEvent.createListWithWord( - word = word, - isPrivate = true, - signer = signer - ) { - Client.send(it) - LocalCache.consume(it, null) - } - } - } - - fun showWord(word: String) { - val blockList = getBlockList() - - if (blockList != null) { - PeopleListEvent.removeWord( - earlierVersion = blockList, - word = word, - isPrivate = true, - signer = signer - ) { - Client.send(it) - LocalCache.consume(it, null) - } - } - - val muteList = getMuteList() - - if (muteList != null) { - MuteListEvent.removeWord( - earlierVersion = muteList, - word = word, - isPrivate = true, - signer = signer - ) { - Client.send(it) - LocalCache.consume(it, null) - } - } - } - - fun hideUser(pubkeyHex: String) { - val muteList = getMuteList() - - if (muteList != null) { - MuteListEvent.addUser( - earlierVersion = muteList, - pubKeyHex = pubkeyHex, - isPrivate = true, - signer = signer - ) { - Client.send(it) - LocalCache.consume(it, null) - } - } else { - MuteListEvent.createListWithUser( - pubKeyHex = pubkeyHex, - isPrivate = true, - signer = signer - ) { - Client.send(it) - LocalCache.consume(it, null) - } - } - } - - fun showUser(pubkeyHex: String) { - val blockList = getBlockList() - - if (blockList != null) { - PeopleListEvent.removeUser( - earlierVersion = blockList, - pubKeyHex = pubkeyHex, - isPrivate = true, - signer = signer - ) { - Client.send(it) - LocalCache.consume(it, null) - } - } - - val muteList = getMuteList() - - if (muteList != null) { - MuteListEvent.removeUser( - earlierVersion = muteList, - pubKeyHex = pubkeyHex, - isPrivate = true, - signer = signer - ) { - Client.send(it) - LocalCache.consume(it, null) - } - } - - transientHiddenUsers = (transientHiddenUsers - pubkeyHex).toImmutableSet() - live.invalidateData() - saveable.invalidateData() - } - - fun changeDefaultZapType(zapType: LnZapEvent.ZapType) { - defaultZapType = zapType - live.invalidateData() - saveable.invalidateData() - } - - fun changeDefaultFileServer(server: Nip96MediaServers.ServerName) { - defaultFileServer = server - live.invalidateData() - saveable.invalidateData() - } - - fun changeDefaultHomeFollowList(name: String) { - defaultHomeFollowList.tryEmit(name) - live.invalidateData() - saveable.invalidateData() - } - - fun changeDefaultStoriesFollowList(name: String) { - defaultStoriesFollowList.tryEmit(name) - live.invalidateData() - saveable.invalidateData() - } - - fun changeDefaultNotificationFollowList(name: String) { - defaultNotificationFollowList.tryEmit(name) - live.invalidateData() - saveable.invalidateData() - } - - fun changeDefaultDiscoveryFollowList(name: String) { - defaultDiscoveryFollowList.tryEmit(name) - live.invalidateData() - saveable.invalidateData() - } - - fun changeZapAmounts(newAmounts: List) { - zapAmountChoices = newAmounts - live.invalidateData() - saveable.invalidateData() - } - - fun changeReactionTypes(newTypes: List) { - reactionChoices = newTypes - live.invalidateData() - saveable.invalidateData() - } - - fun changeZapPaymentRequest(newServer: Nip47URI?) { - zapPaymentRequest = newServer - live.invalidateData() - saveable.invalidateData() - } - - fun selectedChatsFollowList(): Set { - val contactList = userProfile().latestContactList - return contactList?.taggedEvents()?.toSet() ?: DefaultChannels - } - - fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) { - if (!isWriteable()) return - - ChannelMetadataEvent.create( - name, - about, - picture, - originalChannelIdHex = channel.idHex, - signer = signer - ) { - Client.send(it) - LocalCache.justConsume(it, null) - - follow(channel) - } - } - - fun unwrap(event: GiftWrapEvent, onReady: (Event) -> Unit) { - if (!isWriteable()) return - - return event.cachedGift(signer, onReady) - } - - fun unseal(event: SealedGossipEvent, onReady: (Event) -> Unit) { - if (!isWriteable()) return - - return event.cachedGossip(signer, onReady) - } - - fun cachedDecryptContent(note: Note): String? { - val event = note.event - return if (event is PrivateDmEvent && isWriteable()) { - event.cachedContentFor(signer) - } else if (event is LnZapRequestEvent && event.isPrivateZap() && isWriteable()) { - event.cachedPrivateZap()?.content - } else { - event?.content() - } - } - - fun decryptContent(note: Note, onReady: (String) -> Unit) { - val event = note.event - if (event is PrivateDmEvent && isWriteable()) { - event.plainContent(signer, onReady) - } else if (event is LnZapRequestEvent) { - decryptZapContentAuthor(note) { - onReady(it.content) - } - } else { - event?.content()?.let { - onReady(it) - } - } - } - - fun decryptZapContentAuthor(note: Note, onReady: (Event) -> Unit) { - val event = note.event - if (event is LnZapRequestEvent) { - if (event.isPrivateZap()) { - if (isWriteable()) { - event.decryptPrivateZap(signer) { - onReady(it) - } - } - } else { - onReady(event) - } - } - } - - fun addDontTranslateFrom(languageCode: String) { - dontTranslateFrom = dontTranslateFrom.plus(languageCode) - liveLanguages.invalidateData() - - saveable.invalidateData() - } - - fun updateTranslateTo(languageCode: String) { - translateTo = languageCode - liveLanguages.invalidateData() - - saveable.invalidateData() - } - - fun prefer(source: String, target: String, preference: String) { - languagePreferences = languagePreferences + Pair("$source,$target", preference) - saveable.invalidateData() - } - - fun preferenceBetween(source: String, target: String): String? { - return languagePreferences.get("$source,$target") - } - - private fun updateContactListTo(newContactList: ContactListEvent?) { - if (newContactList == null || newContactList.tags.isEmpty()) return - - // Events might be different objects, we have to compare their ids. - if (backupContactList?.id != newContactList.id) { - backupContactList = newContactList - saveable.invalidateData() - } - } - - // Takes a User's relay list and adds the types of feeds they are active for. - fun activeRelays(): Array? { - var usersRelayList = userProfile().latestContactList?.relays()?.map { - val localFeedTypes = localRelays.firstOrNull() { localRelay -> localRelay.url == it.key }?.feedTypes - ?: Constants.defaultRelays.filter { defaultRelay -> defaultRelay.url == it.key }.firstOrNull()?.feedTypes - ?: FeedType.values().toSet() - - Relay(it.key, it.value.read, it.value.write, localFeedTypes) - } ?: return null - - // Ugly, but forces nostr.band as the only search-supporting relay today. - // TODO: Remove when search becomes more available. - val searchRelays = usersRelayList.filter { it.url.removeSuffix("/") in Constants.forcedRelaysForSearchSet } - val hasSearchRelay = usersRelayList.any { it.activeTypes.contains(FeedType.SEARCH) } - if (!hasSearchRelay && searchRelays.isEmpty()) { - usersRelayList = usersRelayList + Constants.forcedRelayForSearch.map { - Relay( - it.url, - it.read, - it.write, - it.feedTypes - ) - } - } - - return usersRelayList.toTypedArray() - } - - fun convertLocalRelays(): Array { - return localRelays.map { - Relay(it.url, it.read, it.write, it.feedTypes) - }.toTypedArray() - } - - fun activeGlobalRelays(): Array { - return (activeRelays() ?: convertLocalRelays()).filter { it.activeTypes.contains(FeedType.GLOBAL) } - .map { it.url } - .toTypedArray() - } - - fun activeWriteRelays(): List { - return (activeRelays() ?: convertLocalRelays()).filter { it.write } - } - - fun isAllHidden(users: Set): Boolean { - return users.all { isHidden(it) } - } - - fun isHidden(user: User) = isHidden(user.pubkeyHex) - - fun isHidden(userHex: String): Boolean { - return flowHiddenUsers.value.hiddenUsers.contains(userHex) || flowHiddenUsers.value.spammers.contains(userHex) - } - - fun followingKeySet(): Set { - return userProfile().cachedFollowingKeySet() - } - - fun followingTagSet(): Set { - return userProfile().cachedFollowingTagSet() - } - - fun isAcceptable(user: User): Boolean { - if (userProfile().pubkeyHex == user.pubkeyHex) { - return true - } - - if (user.pubkeyHex in followingKeySet()) { - return true - } - - if (!warnAboutPostsWithReports) { - return !isHidden(user) && // if user hasn't hided this author - user.reportsBy(userProfile()).isEmpty() // if user has not reported this post - } - return !isHidden(user) && // if user hasn't hided this author - user.reportsBy(userProfile()).isEmpty() && // if user has not reported this post - user.countReportAuthorsBy(followingKeySet()) < 5 - } - - private fun isAcceptableDirect(note: Note): Boolean { - if (!warnAboutPostsWithReports) { - return !note.hasReportsBy(userProfile()) - } - return !note.hasReportsBy(userProfile()) && // if user has not reported this post - note.countReportAuthorsBy(followingKeySet()) < 5 // if it has 5 reports by reliable users - } - - fun isFollowing(user: User): Boolean { - return user.pubkeyHex in followingKeySet() - } - - fun isFollowing(user: HexKey): Boolean { - return user in followingKeySet() - } - - fun isAcceptable(note: Note): Boolean { - return note.author?.let { isAcceptable(it) } ?: true && // if user hasn't hided this author - isAcceptableDirect(note) && - ( - (note.event !is RepostEvent && note.event !is GenericRepostEvent) || - (note.replyTo?.firstOrNull { isAcceptableDirect(it) } != null) - ) // is not a reaction about a blocked post - } - - fun getRelevantReports(note: Note): Set { - val followsPlusMe = userProfile().latestContactList?.verifiedFollowKeySetAndMe ?: emptySet() - - val innerReports = if (note.event is RepostEvent || note.event is GenericRepostEvent) { - note.replyTo?.map { getRelevantReports(it) }?.flatten() ?: emptyList() - } else { - emptyList() - } - - return ( - note.reportsBy(followsPlusMe) + - ( - note.author?.reportsBy(followsPlusMe) ?: emptyList() - ) + innerReports - ).toSet() - } - - fun saveRelayList(value: List) { - try { - localRelays = value.toSet() - return sendNewRelayList(value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }) - } finally { - saveable.invalidateData() - } - } - - fun setHideDeleteRequestDialog() { - hideDeleteRequestDialog = true - saveable.invalidateData() - } - - fun setHideNIP24WarningDialog() { - hideNIP24WarningDialog = true - saveable.invalidateData() - } - - fun setHideBlockAlertDialog() { - hideBlockAlertDialog = true - saveable.invalidateData() - } - - fun updateShowSensitiveContent(show: Boolean?) { - showSensitiveContent = show - saveable.invalidateData() - live.invalidateData() - } - - fun markAsRead(route: String, timestampInSecs: Long): Boolean { - val lastTime = lastReadPerRoute[route] - return if (lastTime == null || timestampInSecs > lastTime) { - lastReadPerRoute = lastReadPerRoute + Pair(route, timestampInSecs) - saveable.invalidateData() - true - } else { - false - } - } - - fun loadLastRead(route: String): Long { - return lastReadPerRoute[route] ?: 0 - } - - suspend fun registerObservers() = withContext(Dispatchers.Main) { - // saves contact list for the next time. - userProfile().live().follows.observeForever { - GlobalScope.launch(Dispatchers.IO) { - updateContactListTo(userProfile().latestContactList) - } - } - - // imports transient blocks due to spam. - LocalCache.antiSpam.liveSpam.observeForever { - GlobalScope.launch(Dispatchers.IO) { - it.cache.spamMessages.snapshot().values.forEach { - if (it.pubkeyHex !in transientHiddenUsers && it.duplicatedMessages.size >= 5) { - if (it.pubkeyHex != userProfile().pubkeyHex && it.pubkeyHex !in followingKeySet()) { - transientHiddenUsers = (transientHiddenUsers + it.pubkeyHex).toImmutableSet() - live.invalidateData() - } - } - } - } - } - } - - init { - Log.d("Init", "Account") - backupContactList?.let { - println("Loading saved contacts ${it.toJson()}") - - if (userProfile().latestContactList == null) { - GlobalScope.launch(Dispatchers.IO) { - LocalCache.consume(it) - } - } - } - } -} - -class AccountLiveData(private val account: Account) : LiveData(AccountState(account)) { - // Refreshes observers in batches. - private val bundler = BundledUpdate(300, Dispatchers.Default) - - fun invalidateData() { - bundler.invalidate() { - if (hasActiveObservers()) { - refresh() - } - } - } - - fun refresh() { - postValue(AccountState(account)) - } -} - -@Immutable -class AccountState(val account: Account) +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model + +import android.content.res.Resources +import android.util.Log +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.core.os.ConfigurationCompat +import androidx.lifecycle.LiveData +import androidx.lifecycle.asFlow +import androidx.lifecycle.asLiveData +import androidx.lifecycle.liveData +import androidx.lifecycle.switchMap +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.service.FileHeader +import com.vitorpamplona.amethyst.service.Nip96MediaServers +import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource +import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.amethyst.service.relays.Client +import com.vitorpamplona.amethyst.service.relays.Constants +import com.vitorpamplona.amethyst.service.relays.FeedType +import com.vitorpamplona.amethyst.service.relays.Relay +import com.vitorpamplona.amethyst.ui.components.BundledUpdate +import com.vitorpamplona.quartz.crypto.KeyPair +import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.hexToByteArray +import com.vitorpamplona.quartz.encoders.toHexKey +import com.vitorpamplona.quartz.events.BookmarkListEvent +import com.vitorpamplona.quartz.events.ChannelCreateEvent +import com.vitorpamplona.quartz.events.ChannelMessageEvent +import com.vitorpamplona.quartz.events.ChannelMetadataEvent +import com.vitorpamplona.quartz.events.ChatMessageEvent +import com.vitorpamplona.quartz.events.ClassifiedsEvent +import com.vitorpamplona.quartz.events.Contact +import com.vitorpamplona.quartz.events.ContactListEvent +import com.vitorpamplona.quartz.events.DeletionEvent +import com.vitorpamplona.quartz.events.EmojiPackEvent +import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent +import com.vitorpamplona.quartz.events.EmojiUrl +import com.vitorpamplona.quartz.events.Event +import com.vitorpamplona.quartz.events.FileHeaderEvent +import com.vitorpamplona.quartz.events.FileServersEvent +import com.vitorpamplona.quartz.events.FileStorageEvent +import com.vitorpamplona.quartz.events.FileStorageHeaderEvent +import com.vitorpamplona.quartz.events.GeneralListEvent +import com.vitorpamplona.quartz.events.GenericRepostEvent +import com.vitorpamplona.quartz.events.GiftWrapEvent +import com.vitorpamplona.quartz.events.HTTPAuthorizationEvent +import com.vitorpamplona.quartz.events.IdentityClaim +import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.events.LnZapEvent +import com.vitorpamplona.quartz.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.events.LnZapRequestEvent +import com.vitorpamplona.quartz.events.MetadataEvent +import com.vitorpamplona.quartz.events.MuteListEvent +import com.vitorpamplona.quartz.events.NIP24Factory +import com.vitorpamplona.quartz.events.PeopleListEvent +import com.vitorpamplona.quartz.events.PollNoteEvent +import com.vitorpamplona.quartz.events.Price +import com.vitorpamplona.quartz.events.PrivateDmEvent +import com.vitorpamplona.quartz.events.ReactionEvent +import com.vitorpamplona.quartz.events.RelayAuthEvent +import com.vitorpamplona.quartz.events.ReportEvent +import com.vitorpamplona.quartz.events.RepostEvent +import com.vitorpamplona.quartz.events.Response +import com.vitorpamplona.quartz.events.SealedGossipEvent +import com.vitorpamplona.quartz.events.StatusEvent +import com.vitorpamplona.quartz.events.TextNoteEvent +import com.vitorpamplona.quartz.events.WrappedEvent +import com.vitorpamplona.quartz.events.ZapSplitSetup +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.signers.NostrSignerExternal +import com.vitorpamplona.quartz.signers.NostrSignerInternal +import com.vitorpamplona.quartz.utils.DualCase +import java.math.BigDecimal +import java.net.Proxy +import java.util.Locale +import java.util.UUID +import kotlin.coroutines.resume +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toImmutableSet +import kotlinx.collections.immutable.toPersistentSet +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combineTransform +import kotlinx.coroutines.flow.flattenMerge +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull + +val DefaultChannels = + setOf( + // Anigma's Nostr + "25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", + // Amethyst's Group + "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", + ) + +val DefaultReactions = + listOf( + "\uD83D\uDE80", + "\uD83E\uDEC2", + "\uD83D\uDC40", + "\uD83D\uDE02", + "\uD83C\uDF89", + "\uD83E\uDD14", + "\uD83D\uDE31", + ) + +val DefaultZapAmounts = listOf(500L, 1000L, 5000L) + +fun getLanguagesSpokenByUser(): Set { + val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration()) + val codedList = mutableSetOf() + for (i in 0 until languageList.size()) { + languageList.get(i)?.let { codedList.add(it.language) } + } + return codedList +} + +val GLOBAL_FOLLOWS = + " Global " // This has spaces to avoid mixing with a potential NIP-51 list with the same name. +val KIND3_FOLLOWS = + " All Follows " // This has spaces to avoid mixing with a potential NIP-51 list with the same +// name. + +@OptIn(DelicateCoroutinesApi::class) +@Stable +class Account( + val keyPair: KeyPair, + val signer: NostrSigner = NostrSignerInternal(keyPair), + var localRelays: Set = Constants.defaultRelays.toSet(), + var dontTranslateFrom: Set = getLanguagesSpokenByUser(), + var languagePreferences: Map = mapOf(), + var translateTo: String = Locale.getDefault().language, + var zapAmountChoices: List = DefaultZapAmounts, + var reactionChoices: List = DefaultReactions, + var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, + var defaultFileServer: Nip96MediaServers.ServerName = Nip96MediaServers.DEFAULT[0], + var defaultHomeFollowList: MutableStateFlow = MutableStateFlow(KIND3_FOLLOWS), + var defaultStoriesFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), + var defaultNotificationFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), + var defaultDiscoveryFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), + var zapPaymentRequest: Nip47URI? = null, + var hideDeleteRequestDialog: Boolean = false, + var hideBlockAlertDialog: Boolean = false, + var hideNIP24WarningDialog: Boolean = false, + var backupContactList: ContactListEvent? = null, + var proxy: Proxy? = null, + var proxyPort: Int = 9050, + var showSensitiveContent: Boolean? = null, + var warnAboutPostsWithReports: Boolean = true, + var filterSpamFromStrangers: Boolean = true, + var lastReadPerRoute: Map = mapOf(), +) { + // Uses a single scope for the entire application. + val scope = Amethyst.instance.applicationIOScope + + var transientHiddenUsers: ImmutableSet = persistentSetOf() + + data class PaymentRequest( + val relayUrl: String, + val description: String, + ) + + var transientPaymentRequestDismissals: Set = emptySet() + val transientPaymentRequests: MutableStateFlow> = MutableStateFlow(emptySet()) + + // Observers line up here. + val live: AccountLiveData = AccountLiveData(this) + val liveLanguages: AccountLiveData = AccountLiveData(this) + val saveable: AccountLiveData = AccountLiveData(this) + + @Immutable + data class LiveFollowLists( + val users: ImmutableSet = persistentSetOf(), + val hashtags: ImmutableSet = persistentSetOf(), + val geotags: ImmutableSet = persistentSetOf(), + val communities: ImmutableSet = persistentSetOf(), + ) + + @OptIn(ExperimentalCoroutinesApi::class) + val liveKind3Follows: StateFlow by lazy { + userProfile() + .live() + .follows + .asFlow() + .transformLatest { + emit( + LiveFollowLists( + userProfile().cachedFollowingKeySet().toImmutableSet(), + userProfile().cachedFollowingTagSet().toImmutableSet(), + userProfile().cachedFollowingGeohashSet().toImmutableSet(), + userProfile().cachedFollowingCommunitiesSet().toImmutableSet(), + ), + ) + } + .stateIn(scope, SharingStarted.Eagerly, LiveFollowLists()) + } + + @OptIn(ExperimentalCoroutinesApi::class) + private val liveHomeList: StateFlow by lazy { + defaultHomeFollowList + .transformLatest { + LocalCache.checkGetOrCreateAddressableNote(it)?.flow()?.metadata?.stateFlow?.let { + emit(it) + } + } + .flattenMerge() + .stateIn(scope, SharingStarted.Eagerly, null) + } + + val liveHomeFollowLists: StateFlow by lazy { + combineTransform(defaultHomeFollowList, liveKind3Follows, liveHomeList) { + listName, + kind3Follows, + peopleListFollows, + -> + if (listName == GLOBAL_FOLLOWS) { + emit(null) + } else if (listName == KIND3_FOLLOWS) { + emit(kind3Follows) + } else { + val result = + withTimeoutOrNull(1000) { + suspendCancellableCoroutine { continuation -> + decryptLiveFollows(peopleListFollows) { continuation.resume(it) } + } + } + result?.let { emit(it) } ?: run { emit(LiveFollowLists()) } + } + } + .stateIn(scope, SharingStarted.Eagerly, LiveFollowLists()) + } + + @OptIn(ExperimentalCoroutinesApi::class) + private val liveNotificationList: StateFlow by lazy { + defaultNotificationFollowList + .transformLatest { + LocalCache.checkGetOrCreateAddressableNote(it)?.flow()?.metadata?.stateFlow?.let { + emit(it) + } + } + .flattenMerge() + .stateIn(scope, SharingStarted.Eagerly, null) + } + + val liveNotificationFollowLists: StateFlow by lazy { + combineTransform(defaultNotificationFollowList, liveKind3Follows, liveNotificationList) { + listName, + kind3Follows, + peopleListFollows, + -> + if (listName == GLOBAL_FOLLOWS) { + emit(null) + } else if (listName == KIND3_FOLLOWS) { + emit(kind3Follows) + } else { + val result = + withTimeoutOrNull(1000) { + suspendCancellableCoroutine { continuation -> + decryptLiveFollows(peopleListFollows) { continuation.resume(it) } + } + } + result?.let { emit(it) } ?: run { emit(LiveFollowLists()) } + } + } + .stateIn(scope, SharingStarted.Eagerly, LiveFollowLists()) + } + + @OptIn(ExperimentalCoroutinesApi::class) + private val liveStoriesList: StateFlow by lazy { + defaultStoriesFollowList + .transformLatest { + LocalCache.checkGetOrCreateAddressableNote(it)?.flow()?.metadata?.stateFlow?.let { + emit(it) + } + } + .flattenMerge() + .stateIn(scope, SharingStarted.Eagerly, null) + } + + val liveStoriesFollowLists: StateFlow by lazy { + combineTransform(defaultStoriesFollowList, liveKind3Follows, liveStoriesList) { + listName, + kind3Follows, + peopleListFollows, + -> + if (listName == GLOBAL_FOLLOWS) { + emit(null) + } else if (listName == KIND3_FOLLOWS) { + emit(kind3Follows) + } else { + val result = + withTimeoutOrNull(1000) { + suspendCancellableCoroutine { continuation -> + decryptLiveFollows(peopleListFollows) { continuation.resume(it) } + } + } + result?.let { emit(it) } ?: run { emit(LiveFollowLists()) } + } + } + .stateIn(scope, SharingStarted.Eagerly, LiveFollowLists()) + } + + @OptIn(ExperimentalCoroutinesApi::class) + private val liveDiscoveryList: StateFlow by lazy { + defaultDiscoveryFollowList + .transformLatest { + LocalCache.checkGetOrCreateAddressableNote(it)?.flow()?.metadata?.stateFlow?.let { + emit(it) + } + } + .flattenMerge() + .stateIn(scope, SharingStarted.Eagerly, null) + } + + val liveDiscoveryFollowLists: StateFlow by lazy { + combineTransform(defaultDiscoveryFollowList, liveKind3Follows, liveDiscoveryList) { + listName, + kind3Follows, + peopleListFollows, + -> + if (listName == GLOBAL_FOLLOWS) { + emit(null) + } else if (listName == KIND3_FOLLOWS) { + emit(kind3Follows) + } else { + val result = + withTimeoutOrNull(1000) { + suspendCancellableCoroutine { continuation -> + decryptLiveFollows(peopleListFollows) { continuation.resume(it) } + } + } + result?.let { emit(it) } ?: run { emit(LiveFollowLists()) } + } + } + .stateIn(scope, SharingStarted.Eagerly, LiveFollowLists()) + } + + private fun decryptLiveFollows( + peopleListFollows: NoteState?, + onReady: (LiveFollowLists) -> Unit, + ) { + val listEvent = (peopleListFollows?.note?.event as? GeneralListEvent) + listEvent?.privateTags(signer) { privateTagList -> + onReady( + LiveFollowLists( + users = + (listEvent.bookmarkedPeople() + listEvent.filterUsers(privateTagList)).toImmutableSet(), + hashtags = + (listEvent.hashtags() + listEvent.filterHashtags(privateTagList)).toImmutableSet(), + geotags = + (listEvent.geohashes() + listEvent.filterGeohashes(privateTagList)).toImmutableSet(), + communities = + (listEvent.taggedAddresses() + listEvent.filterAddresses(privateTagList)) + .map { it.toTag() } + .toImmutableSet(), + ), + ) + } + } + + @Immutable + data class LiveHiddenUsers( + val hiddenUsers: ImmutableSet, + val spammers: ImmutableSet, + val hiddenWords: ImmutableSet, + val hiddenWordsCase: List, + val showSensitiveContent: Boolean?, + ) + + val flowHiddenUsers: StateFlow by lazy { + combineTransform( + live.asFlow(), + getBlockListNote().flow().metadata.stateFlow, + getMuteListNote().flow().metadata.stateFlow, + ) { localLive, blockList, muteList -> + checkNotInMainThread() + + val resultBlockList = + (blockList.note.event as? PeopleListEvent)?.let { + withTimeoutOrNull(1000) { + suspendCancellableCoroutine { continuation -> + it.publicAndPrivateUsersAndWords(signer) { continuation.resume(it) } + } + } + } + ?: PeopleListEvent.UsersAndWords() + + val resultMuteList = + (muteList.note.event as? MuteListEvent)?.let { + withTimeoutOrNull(1000) { + suspendCancellableCoroutine { continuation -> + it.publicAndPrivateUsersAndWords(signer) { continuation.resume(it) } + } + } + } + ?: PeopleListEvent.UsersAndWords() + + val hiddenWords = resultBlockList.words + resultMuteList.words + + emit( + LiveHiddenUsers( + hiddenUsers = (resultBlockList.users + resultMuteList.users).toPersistentSet(), + hiddenWords = hiddenWords.toPersistentSet(), + hiddenWordsCase = hiddenWords.map { DualCase(it.lowercase(), it.uppercase()) }, + spammers = localLive.account.transientHiddenUsers, + showSensitiveContent = localLive.account.showSensitiveContent, + ), + ) + } + .stateIn( + scope, + SharingStarted.Eagerly, + LiveHiddenUsers( + hiddenUsers = persistentSetOf(), + hiddenWords = persistentSetOf(), + hiddenWordsCase = emptyList(), + spammers = transientHiddenUsers, + showSensitiveContent = showSensitiveContent, + ), + ) + } + + val liveHiddenUsers = flowHiddenUsers.asLiveData() + + val decryptBookmarks: LiveData by lazy { + userProfile().live().innerBookmarks.switchMap { userState -> + liveData(Dispatchers.IO) { + userState.user.latestBookmarkList?.privateTags(signer) { + scope.launch(Dispatchers.IO) { userState.user.latestBookmarkList?.let { emit(it) } } + } + } + } + } + + fun addPaymentRequestIfNew(paymentRequest: PaymentRequest) { + if ( + !this.transientPaymentRequests.value.contains(paymentRequest) && + !this.transientPaymentRequestDismissals.contains(paymentRequest) + ) { + this.transientPaymentRequests.value = transientPaymentRequests.value + paymentRequest + } + } + + fun dismissPaymentRequest(request: PaymentRequest) { + if (this.transientPaymentRequests.value.contains(request)) { + this.transientPaymentRequests.value = transientPaymentRequests.value - request + this.transientPaymentRequestDismissals = transientPaymentRequestDismissals + request + } + } + + var userProfileCache: User? = null + + fun updateOptOutOptions( + warnReports: Boolean, + filterSpam: Boolean, + ) { + warnAboutPostsWithReports = warnReports + filterSpamFromStrangers = filterSpam + LocalCache.antiSpam.active = filterSpamFromStrangers + if (!filterSpamFromStrangers) { + transientHiddenUsers = persistentSetOf() + } + live.invalidateData() + saveable.invalidateData() + } + + fun userProfile(): User { + return userProfileCache + ?: run { + val myUser: User = LocalCache.getOrCreateUser(keyPair.pubKey.toHexKey()) + userProfileCache = myUser + myUser + } + } + + fun isWriteable(): Boolean { + return keyPair.privKey != null || signer is NostrSignerExternal + } + + fun sendNewRelayList(relays: Map) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + + if (contactList != null && contactList.tags.isNotEmpty()) { + ContactListEvent.updateRelayList( + earlierVersion = contactList, + relayUse = relays, + signer = signer, + ) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } else { + ContactListEvent.createFromScratch( + followUsers = listOf(), + followTags = listOf(), + followGeohashes = listOf(), + followCommunities = listOf(), + followEvents = DefaultChannels.toList(), + relayUse = relays, + signer = signer, + ) { + // Keep this local to avoid erasing a good contact list. + // Client.send(it) + LocalCache.justConsume(it, null) + } + } + } + + suspend fun sendNewUserMetadata( + toString: String, + newName: String, + identities: List, + ) { + if (!isWriteable()) return + + MetadataEvent.create(toString, newName, identities, signer) { + Client.send(it) + LocalCache.justConsume(it, null) + } + + return + } + + fun reactionTo( + note: Note, + reaction: String, + ): List { + return note.reactedBy(userProfile(), reaction) + } + + fun hasBoosted(note: Note): Boolean { + return boostsTo(note).isNotEmpty() + } + + fun boostsTo(note: Note): List { + return note.boostedBy(userProfile()) + } + + fun hasReacted( + note: Note, + reaction: String, + ): Boolean { + return note.hasReacted(userProfile(), reaction) + } + + suspend fun reactTo( + note: Note, + reaction: String, + ) { + if (!isWriteable()) return + + if (hasReacted(note, reaction)) { + // has already liked this note + return + } + + if (note.event is ChatMessageEvent) { + val event = note.event as ChatMessageEvent + val users = event.recipientsPubKey().plus(event.pubKey).toSet().toList() + + if (reaction.startsWith(":")) { + val emojiUrl = EmojiUrl.decode(reaction) + if (emojiUrl != null) { + note.event?.let { + NIP24Factory().createReactionWithinGroup( + emojiUrl = emojiUrl, + originalNote = it, + to = users, + signer = signer, + ) { + broadcastPrivately(it) + } + } + + return + } + } + + note.event?.let { + NIP24Factory().createReactionWithinGroup( + content = reaction, + originalNote = it, + to = users, + signer = signer, + ) { + broadcastPrivately(it) + } + } + return + } else { + if (reaction.startsWith(":")) { + val emojiUrl = EmojiUrl.decode(reaction) + if (emojiUrl != null) { + note.event?.let { + ReactionEvent.create(emojiUrl, it, signer) { + Client.send(it) + LocalCache.consume(it) + } + } + + return + } + } + + note.event?.let { + ReactionEvent.create(reaction, it, signer) { + Client.send(it) + LocalCache.consume(it) + } + } + } + } + + fun createZapRequestFor( + note: Note, + pollOption: Int?, + message: String = "", + zapType: LnZapEvent.ZapType, + toUser: User?, + onReady: (LnZapRequestEvent) -> Unit, + ) { + if (!isWriteable()) return + + note.event?.let { event -> + LnZapRequestEvent.create( + event, + userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } + ?: localRelays.map { it.url }.toSet(), + signer, + pollOption, + message, + zapType, + toUser?.pubkeyHex, + onReady = onReady, + ) + } + } + + fun hasWalletConnectSetup(): Boolean { + return zapPaymentRequest != null + } + + fun isNIP47Author(pubkeyHex: String?): Boolean { + return (getNIP47Signer().pubKey == pubkeyHex) + } + + fun getNIP47Signer(): NostrSigner { + return zapPaymentRequest?.secret?.hexToByteArray()?.let { NostrSignerInternal(KeyPair(it)) } + ?: signer + } + + fun decryptZapPaymentResponseEvent( + zapResponseEvent: LnZapPaymentResponseEvent, + onReady: (Response) -> Unit, + ) { + val myNip47 = zapPaymentRequest ?: return + + val signer = + myNip47.secret?.hexToByteArray()?.let { NostrSignerInternal(KeyPair(it)) } ?: signer + + zapResponseEvent.response(signer, onReady) + } + + fun calculateIfNoteWasZappedByAccount( + zappedNote: Note?, + onWasZapped: () -> Unit, + ) { + zappedNote?.isZappedBy(userProfile(), this, onWasZapped) + } + + fun calculateZappedAmount( + zappedNote: Note?, + onReady: (BigDecimal) -> Unit, + ) { + zappedNote?.zappedAmountWithNWCPayments(getNIP47Signer(), onReady) + } + + fun sendZapPaymentRequestFor( + bolt11: String, + zappedNote: Note?, + onResponse: (Response?) -> Unit, + ) { + if (!isWriteable()) return + + zapPaymentRequest?.let { nip47 -> + val signer = + nip47.secret?.hexToByteArray()?.let { NostrSignerInternal(KeyPair(it)) } ?: signer + + LnZapPaymentRequestEvent.create(bolt11, nip47.pubKeyHex, signer) { event -> + val wcListener = + NostrLnZapPaymentResponseDataSource( + fromServiceHex = nip47.pubKeyHex, + toUserHex = event.pubKey, + replyingToHex = event.id, + authSigner = signer, + ) + wcListener.start() + + LocalCache.consume(event, zappedNote) { it.response(signer) { onResponse(it) } } + + Client.send(event, nip47.relayUri, wcListener.feedTypes) { wcListener.destroy() } + } + } + } + + fun createZapRequestFor( + userPubKeyHex: String, + message: String = "", + zapType: LnZapEvent.ZapType, + onReady: (LnZapRequestEvent) -> Unit, + ) { + LnZapRequestEvent.create( + userPubKeyHex, + userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } + ?: localRelays.map { it.url }.toSet(), + signer, + message, + zapType, + onReady = onReady, + ) + } + + suspend fun report( + note: Note, + type: ReportEvent.ReportType, + content: String = "", + ) { + if (!isWriteable()) return + + if (note.hasReacted(userProfile(), "โš ๏ธ")) { + // has already liked this note + return + } + + note.event?.let { + ReactionEvent.createWarning(it, signer) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + + note.event?.let { + ReportEvent.create(it, type, signer, content) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + } + + suspend fun report( + user: User, + type: ReportEvent.ReportType, + ) { + if (!isWriteable()) return + + if (user.hasReport(userProfile(), type)) { + // has already reported this note + return + } + + ReportEvent.create(user.pubkeyHex, type, signer) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + + suspend fun delete(note: Note) { + return delete(listOf(note)) + } + + suspend fun delete(notes: List) { + if (!isWriteable()) return + + val myNotes = notes.filter { it.author == userProfile() }.mapNotNull { it.event?.id() } + + if (myNotes.isNotEmpty()) { + DeletionEvent.create(myNotes, signer) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + } + + fun createHTTPAuthorization( + url: String, + method: String, + body: ByteArray? = null, + onReady: (HTTPAuthorizationEvent) -> Unit, + ) { + if (!isWriteable()) return + + HTTPAuthorizationEvent.create(url, method, body, signer, onReady = onReady) + } + + suspend fun boost(note: Note) { + if (!isWriteable()) return + + if (note.hasBoostedInTheLast5Minutes(userProfile())) { + // has already bosted in the past 5mins + return + } + + note.event?.let { + if (it.kind() == 1) { + RepostEvent.create(it, signer) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } else { + GenericRepostEvent.create(it, signer) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + } + } + + fun broadcast(note: Note) { + note.event?.let { + if (it is WrappedEvent && it.host != null) { + it.host?.let { hostEvent -> Client.send(hostEvent) } + } else { + Client.send(it) + } + } + } + + fun follow(user: User) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + + if (contactList != null) { + ContactListEvent.followUser(contactList, user.pubkeyHex, signer) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } else { + ContactListEvent.createFromScratch( + followUsers = listOf(Contact(user.pubkeyHex, null)), + followTags = emptyList(), + followGeohashes = emptyList(), + followCommunities = emptyList(), + followEvents = DefaultChannels.toList(), + relayUse = + Constants.defaultRelays.associate { + it.url to ContactListEvent.ReadWrite(it.read, it.write) + }, + signer = signer, + ) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + } + + fun follow(channel: Channel) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + + if (contactList != null) { + ContactListEvent.followEvent(contactList, channel.idHex, signer) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } else { + ContactListEvent.createFromScratch( + followUsers = emptyList(), + followTags = emptyList(), + followGeohashes = emptyList(), + followCommunities = emptyList(), + followEvents = DefaultChannels.toList().plus(channel.idHex), + relayUse = + Constants.defaultRelays.associate { + it.url to ContactListEvent.ReadWrite(it.read, it.write) + }, + signer = signer, + ) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + } + + fun follow(community: AddressableNote) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + + if (contactList != null) { + ContactListEvent.followAddressableEvent(contactList, community.address, signer) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } else { + val relays = + Constants.defaultRelays.associate { + it.url to ContactListEvent.ReadWrite(it.read, it.write) + } + ContactListEvent.createFromScratch( + followUsers = emptyList(), + followTags = emptyList(), + followGeohashes = emptyList(), + followCommunities = listOf(community.address), + followEvents = DefaultChannels.toList(), + relayUse = relays, + signer = signer, + ) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + } + + fun followHashtag(tag: String) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + + if (contactList != null) { + ContactListEvent.followHashtag( + contactList, + tag, + signer, + ) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } else { + ContactListEvent.createFromScratch( + followUsers = emptyList(), + followTags = listOf(tag), + followGeohashes = emptyList(), + followCommunities = emptyList(), + followEvents = DefaultChannels.toList(), + relayUse = + Constants.defaultRelays.associate { + it.url to ContactListEvent.ReadWrite(it.read, it.write) + }, + signer = signer, + ) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + } + + fun followGeohash(geohash: String) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + + if (contactList != null) { + ContactListEvent.followGeohash( + contactList, + geohash, + signer, + onReady = this::onNewEventCreated, + ) + } else { + ContactListEvent.createFromScratch( + followUsers = emptyList(), + followTags = emptyList(), + followGeohashes = listOf(geohash), + followCommunities = emptyList(), + followEvents = DefaultChannels.toList(), + relayUse = + Constants.defaultRelays.associate { + it.url to ContactListEvent.ReadWrite(it.read, it.write) + }, + signer = signer, + onReady = this::onNewEventCreated, + ) + } + } + + fun onNewEventCreated(event: Event) { + Client.send(event) + LocalCache.justConsume(event, null) + } + + fun unfollow(user: User) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + + if (contactList != null && contactList.tags.isNotEmpty()) { + ContactListEvent.unfollowUser( + contactList, + user.pubkeyHex, + signer, + onReady = this::onNewEventCreated, + ) + } + } + + suspend fun unfollowHashtag(tag: String) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + + if (contactList != null && contactList.tags.isNotEmpty()) { + ContactListEvent.unfollowHashtag( + contactList, + tag, + signer, + onReady = this::onNewEventCreated, + ) + } + } + + suspend fun unfollowGeohash(geohash: String) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + + if (contactList != null && contactList.tags.isNotEmpty()) { + ContactListEvent.unfollowGeohash( + contactList, + geohash, + signer, + onReady = this::onNewEventCreated, + ) + } + } + + suspend fun unfollow(channel: Channel) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + + if (contactList != null && contactList.tags.isNotEmpty()) { + ContactListEvent.unfollowEvent( + contactList, + channel.idHex, + signer, + onReady = this::onNewEventCreated, + ) + } + } + + suspend fun unfollow(community: AddressableNote) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + + if (contactList != null && contactList.tags.isNotEmpty()) { + ContactListEvent.unfollowAddressableEvent( + contactList, + community.address, + signer, + onReady = this::onNewEventCreated, + ) + } + } + + fun createNip95( + byteArray: ByteArray, + headerInfo: FileHeader, + alt: String?, + sensitiveContent: Boolean, + onReady: (Pair) -> Unit, + ) { + if (!isWriteable()) return + + FileStorageEvent.create( + mimeType = headerInfo.mimeType ?: "", + data = byteArray, + signer = signer, + ) { data -> + FileStorageHeaderEvent.create( + data, + mimeType = headerInfo.mimeType, + hash = headerInfo.hash, + size = headerInfo.size.toString(), + dimensions = headerInfo.dim, + blurhash = headerInfo.blurHash, + alt = alt, + sensitiveContent = sensitiveContent, + signer = signer, + ) { signedEvent -> + onReady( + Pair(data, signedEvent), + ) + } + } + } + + fun consumeAndSendNip95( + data: FileStorageEvent, + signedEvent: FileStorageHeaderEvent, + relayList: List? = null, + ): Note? { + if (!isWriteable()) return null + + Client.send(data, relayList = relayList) + LocalCache.consume(data, null) + + Client.send(signedEvent, relayList = relayList) + LocalCache.consume(signedEvent, null) + + return LocalCache.notes[signedEvent.id] + } + + fun consumeNip95( + data: FileStorageEvent, + signedEvent: FileStorageHeaderEvent, + ): Note? { + LocalCache.consume(data, null) + LocalCache.consume(signedEvent, null) + + return LocalCache.notes[signedEvent.id] + } + + fun sendNip95( + data: FileStorageEvent, + signedEvent: FileStorageHeaderEvent, + relayList: List? = null, + ) { + Client.send(data, relayList = relayList) + Client.send(signedEvent, relayList = relayList) + } + + fun sendHeader( + signedEvent: FileHeaderEvent, + relayList: List? = null, + onReady: (Note) -> Unit, + ) { + Client.send(signedEvent, relayList = relayList) + LocalCache.consume(signedEvent, null) + + LocalCache.notes[signedEvent.id]?.let { onReady(it) } + } + + fun createHeader( + imageUrl: String, + magnetUri: String?, + headerInfo: FileHeader, + alt: String?, + sensitiveContent: Boolean, + originalHash: String? = null, + onReady: (FileHeaderEvent) -> Unit, + ) { + if (!isWriteable()) return + + FileHeaderEvent.create( + url = imageUrl, + magnetUri = magnetUri, + mimeType = headerInfo.mimeType, + hash = headerInfo.hash, + size = headerInfo.size.toString(), + dimensions = headerInfo.dim, + blurhash = headerInfo.blurHash, + alt = alt, + originalHash = originalHash, + sensitiveContent = sensitiveContent, + signer = signer, + ) { event -> + onReady(event) + } + } + + fun sendHeader( + imageUrl: String, + magnetUri: String?, + headerInfo: FileHeader, + alt: String?, + sensitiveContent: Boolean, + originalHash: String? = null, + relayList: List? = null, + onReady: (Note) -> Unit, + ) { + if (!isWriteable()) return + + FileHeaderEvent.create( + url = imageUrl, + magnetUri = magnetUri, + mimeType = headerInfo.mimeType, + hash = headerInfo.hash, + size = headerInfo.size.toString(), + dimensions = headerInfo.dim, + blurhash = headerInfo.blurHash, + alt = alt, + originalHash = originalHash, + sensitiveContent = sensitiveContent, + signer = signer, + ) { event -> + sendHeader(event, relayList = relayList, onReady) + } + } + + fun sendClassifieds( + title: String, + price: Price, + condition: ClassifiedsEvent.CONDITION, + location: String, + category: String, + message: String, + replyTo: List?, + mentions: List?, + directMentions: Set, + zapReceiver: List? = null, + wantsToMarkAsSensitive: Boolean, + zapRaiserAmount: Long? = null, + relayList: List? = null, + geohash: String? = null, + nip94attachments: List? = null, + ) { + if (!isWriteable()) return + + val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex } + val mentionsHex = mentions?.map { it.pubkeyHex } + val addresses = replyTo?.mapNotNull { it.address() } + + ClassifiedsEvent.create( + dTag = UUID.randomUUID().toString(), + title = title, + price = price, + condition = condition, + summary = message, + image = null, + location = location, + category = category, + message = message, + replyTos = repliesToHex, + mentions = mentionsHex, + addresses = addresses, + zapReceiver = zapReceiver, + markAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = zapRaiserAmount, + directMentions = directMentions, + geohash = geohash, + nip94attachments = nip94attachments, + signer = signer, + ) { + Client.send(it, relayList = relayList) + LocalCache.justConsume(it, null) + + replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } } + addresses?.forEach { + LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let { + Client.send(it, relayList = relayList) + } + } + } + } + + fun sendPost( + message: String, + replyTo: List?, + mentions: List?, + tags: List? = null, + zapReceiver: List? = null, + wantsToMarkAsSensitive: Boolean, + zapRaiserAmount: Long? = null, + replyingTo: String?, + root: String?, + directMentions: Set, + relayList: List? = null, + geohash: String? = null, + nip94attachments: List? = null, + ) { + if (!isWriteable()) return + + val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex } + val mentionsHex = mentions?.map { it.pubkeyHex } + val addresses = replyTo?.mapNotNull { it.address() } + + TextNoteEvent.create( + msg = message, + replyTos = repliesToHex, + mentions = mentionsHex, + addresses = addresses, + extraTags = tags, + zapReceiver = zapReceiver, + markAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = zapRaiserAmount, + replyingTo = replyingTo, + root = root, + directMentions = directMentions, + geohash = geohash, + nip94attachments = nip94attachments, + signer = signer, + ) { + Client.send(it, relayList = relayList) + LocalCache.justConsume(it, null) + + // broadcast replied notes + replyingTo?.let { + LocalCache.getNoteIfExists(replyingTo)?.event?.let { + Client.send(it, relayList = relayList) + } + } + replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } } + addresses?.forEach { + LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let { + Client.send(it, relayList = relayList) + } + } + } + } + + fun sendPoll( + message: String, + replyTo: List?, + mentions: List?, + pollOptions: Map, + valueMaximum: Int?, + valueMinimum: Int?, + consensusThreshold: Int?, + closedAt: Int?, + zapReceiver: List? = null, + wantsToMarkAsSensitive: Boolean, + zapRaiserAmount: Long? = null, + relayList: List? = null, + geohash: String? = null, + nip94attachments: List? = null, + ) { + if (!isWriteable()) return + + val repliesToHex = replyTo?.map { it.idHex } + val mentionsHex = mentions?.map { it.pubkeyHex } + val addresses = replyTo?.mapNotNull { it.address() } + + PollNoteEvent.create( + msg = message, + replyTos = repliesToHex, + mentions = mentionsHex, + addresses = addresses, + signer = signer, + pollOptions = pollOptions, + valueMaximum = valueMaximum, + valueMinimum = valueMinimum, + consensusThreshold = consensusThreshold, + closedAt = closedAt, + zapReceiver = zapReceiver, + markAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = zapRaiserAmount, + geohash = geohash, + nip94attachments = nip94attachments, + ) { + Client.send(it, relayList = relayList) + LocalCache.justConsume(it, null) + + // Rebroadcast replies and tags to the current relay set + replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } } + addresses?.forEach { + LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let { + Client.send(it, relayList = relayList) + } + } + } + } + + fun sendChannelMessage( + message: String, + toChannel: String, + replyTo: List?, + mentions: List?, + zapReceiver: List? = null, + wantsToMarkAsSensitive: Boolean, + zapRaiserAmount: Long? = null, + geohash: String? = null, + nip94attachments: List? = null, + ) { + if (!isWriteable()) return + + val repliesToHex = replyTo?.map { it.idHex } + val mentionsHex = mentions?.map { it.pubkeyHex } + + ChannelMessageEvent.create( + message = message, + channel = toChannel, + replyTos = repliesToHex, + mentions = mentionsHex, + zapReceiver = zapReceiver, + markAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = zapRaiserAmount, + geohash = geohash, + nip94attachments = nip94attachments, + signer = signer, + ) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + + fun sendLiveMessage( + message: String, + toChannel: ATag, + replyTo: List?, + mentions: List?, + zapReceiver: List? = null, + wantsToMarkAsSensitive: Boolean, + zapRaiserAmount: Long? = null, + geohash: String? = null, + nip94attachments: List? = null, + ) { + if (!isWriteable()) return + + // val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } + val repliesToHex = replyTo?.map { it.idHex } + val mentionsHex = mentions?.map { it.pubkeyHex } + + LiveActivitiesChatMessageEvent.create( + message = message, + activity = toChannel, + replyTos = repliesToHex, + mentions = mentionsHex, + zapReceiver = zapReceiver, + markAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = zapRaiserAmount, + geohash = geohash, + nip94attachments = nip94attachments, + signer = signer, + ) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + + fun sendPrivateMessage( + message: String, + toUser: User, + replyingTo: Note? = null, + mentions: List?, + zapReceiver: List? = null, + wantsToMarkAsSensitive: Boolean, + zapRaiserAmount: Long? = null, + geohash: String? = null, + ) { + sendPrivateMessage( + message, + toUser.pubkeyHex, + replyingTo, + mentions, + zapReceiver, + wantsToMarkAsSensitive, + zapRaiserAmount, + geohash, + ) + } + + fun sendPrivateMessage( + message: String, + toUser: HexKey, + replyingTo: Note? = null, + mentions: List?, + zapReceiver: List? = null, + wantsToMarkAsSensitive: Boolean, + zapRaiserAmount: Long? = null, + geohash: String? = null, + ) { + if (!isWriteable()) return + + val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } + val mentionsHex = mentions?.map { it.pubkeyHex } + + PrivateDmEvent.create( + recipientPubKey = toUser, + publishedRecipientPubKey = toUser, + msg = message, + replyTos = repliesToHex, + mentions = mentionsHex, + zapReceiver = zapReceiver, + markAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = zapRaiserAmount, + geohash = geohash, + signer = signer, + advertiseNip18 = false, + ) { + Client.send(it) + LocalCache.consume(it, null) + } + } + + fun sendNIP24PrivateMessage( + message: String, + toUsers: List, + subject: String? = null, + replyingTo: Note? = null, + mentions: List?, + zapReceiver: List? = null, + wantsToMarkAsSensitive: Boolean, + zapRaiserAmount: Long? = null, + geohash: String? = null, + ) { + if (!isWriteable()) return + + val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } + val mentionsHex = mentions?.map { it.pubkeyHex } + + NIP24Factory().createMsgNIP24( + msg = message, + to = toUsers, + subject = subject, + replyTos = repliesToHex, + mentions = mentionsHex, + zapReceiver = zapReceiver, + markAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = zapRaiserAmount, + geohash = geohash, + signer = signer, + ) { + broadcastPrivately(it) + } + } + + fun broadcastPrivately(signedEvents: NIP24Factory.Result) { + val mine = signedEvents.wraps.filter { (it.recipientPubKey() == signer.pubKey) } + + mine.forEach { giftWrap -> + giftWrap.cachedGift(signer) { gift -> + if (gift is SealedGossipEvent) { + gift.cachedGossip(signer) { gossip -> LocalCache.justConsume(gossip, null) } + } else { + LocalCache.justConsume(gift, null) + } + } + + LocalCache.consume(giftWrap, null) + } + + val id = mine.firstOrNull()?.id + val mineNote = if (id == null) null else LocalCache.getNoteIfExists(id) + + signedEvents.wraps.forEach { + // Creates an alias + if (mineNote != null && it.recipientPubKey() != keyPair.pubKey.toHexKey()) { + LocalCache.getOrAddAliasNote(it.id, mineNote) + } + + Client.send(it) + } + } + + fun sendCreateNewChannel( + name: String, + about: String, + picture: String, + ) { + if (!isWriteable()) return + + ChannelCreateEvent.create( + name = name, + about = about, + picture = picture, + signer = signer, + ) { + Client.send(it) + LocalCache.justConsume(it, null) + + LocalCache.getChannelIfExists(it.id)?.let { follow(it) } + } + } + + fun updateStatus( + oldStatus: AddressableNote, + newStatus: String, + ) { + if (!isWriteable()) return + val oldEvent = oldStatus.event as? StatusEvent ?: return + + StatusEvent.update(oldEvent, newStatus, signer) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + + fun createStatus(newStatus: String) { + if (!isWriteable()) return + + StatusEvent.create(newStatus, "general", expiration = null, signer) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + + fun deleteStatus(oldStatus: AddressableNote) { + if (!isWriteable()) return + val oldEvent = oldStatus.event as? StatusEvent ?: return + + StatusEvent.clear(oldEvent, signer) { event -> + Client.send(event) + LocalCache.justConsume(event, null) + + DeletionEvent.create(listOf(event.id), signer) { event2 -> + Client.send(event2) + LocalCache.justConsume(event2, null) + } + } + } + + fun removeEmojiPack( + usersEmojiList: Note, + emojiList: Note, + ) { + if (!isWriteable()) return + + val noteEvent = usersEmojiList.event + if (noteEvent !is EmojiPackSelectionEvent) return + val emojiListEvent = emojiList.event + if (emojiListEvent !is EmojiPackEvent) return + + EmojiPackSelectionEvent.create( + noteEvent.taggedAddresses().filter { it != emojiListEvent.address() }, + signer, + ) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + + fun addEmojiPack( + usersEmojiList: Note, + emojiList: Note, + ) { + if (!isWriteable()) return + val emojiListEvent = emojiList.event + if (emojiListEvent !is EmojiPackEvent) return + + if (usersEmojiList.event == null) { + EmojiPackSelectionEvent.create( + listOf(emojiListEvent.address()), + signer, + ) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } else { + val noteEvent = usersEmojiList.event + if (noteEvent !is EmojiPackSelectionEvent) return + + if (noteEvent.taggedAddresses().any { it == emojiListEvent.address() }) { + return + } + + EmojiPackSelectionEvent.create( + noteEvent.taggedAddresses().plus(emojiListEvent.address()), + signer, + ) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + } + + fun addBookmark( + note: Note, + isPrivate: Boolean, + ) { + if (!isWriteable()) return + + if (note is AddressableNote) { + BookmarkListEvent.addReplaceable( + userProfile().latestBookmarkList, + note.address, + isPrivate, + signer, + ) { + Client.send(it) + LocalCache.consume(it) + } + } else { + BookmarkListEvent.addEvent( + userProfile().latestBookmarkList, + note.idHex, + isPrivate, + signer, + ) { + Client.send(it) + LocalCache.consume(it) + } + } + } + + fun removeBookmark( + note: Note, + isPrivate: Boolean, + ) { + if (!isWriteable()) return + + val bookmarks = userProfile().latestBookmarkList ?: return + + if (note is AddressableNote) { + BookmarkListEvent.removeReplaceable( + bookmarks, + note.address, + isPrivate, + signer, + ) { + Client.send(it) + LocalCache.consume(it) + } + } else { + BookmarkListEvent.removeEvent( + bookmarks, + note.idHex, + isPrivate, + signer, + ) { + Client.send(it) + LocalCache.consume(it) + } + } + } + + fun createAuthEvent( + relay: Relay, + challenge: String, + onReady: (RelayAuthEvent) -> Unit, + ) { + return createAuthEvent(relay.url, challenge, onReady = onReady) + } + + fun createAuthEvent( + relayUrl: String, + challenge: String, + onReady: (RelayAuthEvent) -> Unit, + ) { + if (!isWriteable()) return + + RelayAuthEvent.create(relayUrl, challenge, signer, onReady = onReady) + } + + fun isInPrivateBookmarks( + note: Note, + onReady: (Boolean) -> Unit, + ) { + if (!isWriteable()) return + + if (note is AddressableNote) { + userProfile().latestBookmarkList?.privateTaggedAddresses(signer) { + onReady(it.contains(note.address)) + } + } else { + userProfile().latestBookmarkList?.privateTaggedEvents(signer) { + onReady(it.contains(note.idHex)) + } + } + } + + fun isInPublicBookmarks(note: Note): Boolean { + if (!isWriteable()) return false + + if (note is AddressableNote) { + return userProfile().latestBookmarkList?.taggedAddresses()?.contains(note.address) == true + } else { + return userProfile().latestBookmarkList?.taggedEvents()?.contains(note.idHex) == true + } + } + + fun getBlockListNote(): AddressableNote { + val aTag = + ATag( + PeopleListEvent.KIND, + userProfile().pubkeyHex, + PeopleListEvent.BLOCK_LIST_D_TAG, + null, + ) + return LocalCache.getOrCreateAddressableNote(aTag) + } + + fun getMuteListNote(): AddressableNote { + val aTag = + ATag( + MuteListEvent.KIND, + userProfile().pubkeyHex, + "", + null, + ) + return LocalCache.getOrCreateAddressableNote(aTag) + } + + fun getFileServersNote(): AddressableNote { + val aTag = + ATag( + FileServersEvent.KIND, + userProfile().pubkeyHex, + "", + null, + ) + return LocalCache.getOrCreateAddressableNote(aTag) + } + + fun getBlockList(): PeopleListEvent? { + return getBlockListNote().event as? PeopleListEvent + } + + fun getMuteList(): MuteListEvent? { + return getMuteListNote().event as? MuteListEvent + } + + fun getFileServersList(): FileServersEvent? { + return getFileServersNote().event as? FileServersEvent + } + + fun hideWord(word: String) { + val muteList = getMuteList() + + if (muteList != null) { + MuteListEvent.addWord( + earlierVersion = muteList, + word = word, + isPrivate = true, + signer = signer, + ) { + Client.send(it) + LocalCache.consume(it, null) + } + } else { + MuteListEvent.createListWithWord( + word = word, + isPrivate = true, + signer = signer, + ) { + Client.send(it) + LocalCache.consume(it, null) + } + } + } + + fun showWord(word: String) { + val blockList = getBlockList() + + if (blockList != null) { + PeopleListEvent.removeWord( + earlierVersion = blockList, + word = word, + isPrivate = true, + signer = signer, + ) { + Client.send(it) + LocalCache.consume(it, null) + } + } + + val muteList = getMuteList() + + if (muteList != null) { + MuteListEvent.removeWord( + earlierVersion = muteList, + word = word, + isPrivate = true, + signer = signer, + ) { + Client.send(it) + LocalCache.consume(it, null) + } + } + } + + fun hideUser(pubkeyHex: String) { + val muteList = getMuteList() + + if (muteList != null) { + MuteListEvent.addUser( + earlierVersion = muteList, + pubKeyHex = pubkeyHex, + isPrivate = true, + signer = signer, + ) { + Client.send(it) + LocalCache.consume(it, null) + } + } else { + MuteListEvent.createListWithUser( + pubKeyHex = pubkeyHex, + isPrivate = true, + signer = signer, + ) { + Client.send(it) + LocalCache.consume(it, null) + } + } + } + + fun showUser(pubkeyHex: String) { + val blockList = getBlockList() + + if (blockList != null) { + PeopleListEvent.removeUser( + earlierVersion = blockList, + pubKeyHex = pubkeyHex, + isPrivate = true, + signer = signer, + ) { + Client.send(it) + LocalCache.consume(it, null) + } + } + + val muteList = getMuteList() + + if (muteList != null) { + MuteListEvent.removeUser( + earlierVersion = muteList, + pubKeyHex = pubkeyHex, + isPrivate = true, + signer = signer, + ) { + Client.send(it) + LocalCache.consume(it, null) + } + } + + transientHiddenUsers = (transientHiddenUsers - pubkeyHex).toImmutableSet() + live.invalidateData() + saveable.invalidateData() + } + + fun changeDefaultZapType(zapType: LnZapEvent.ZapType) { + defaultZapType = zapType + live.invalidateData() + saveable.invalidateData() + } + + fun changeDefaultFileServer(server: Nip96MediaServers.ServerName) { + defaultFileServer = server + live.invalidateData() + saveable.invalidateData() + } + + fun changeDefaultHomeFollowList(name: String) { + defaultHomeFollowList.tryEmit(name) + live.invalidateData() + saveable.invalidateData() + } + + fun changeDefaultStoriesFollowList(name: String) { + defaultStoriesFollowList.tryEmit(name) + live.invalidateData() + saveable.invalidateData() + } + + fun changeDefaultNotificationFollowList(name: String) { + defaultNotificationFollowList.tryEmit(name) + live.invalidateData() + saveable.invalidateData() + } + + fun changeDefaultDiscoveryFollowList(name: String) { + defaultDiscoveryFollowList.tryEmit(name) + live.invalidateData() + saveable.invalidateData() + } + + fun changeZapAmounts(newAmounts: List) { + zapAmountChoices = newAmounts + live.invalidateData() + saveable.invalidateData() + } + + fun changeReactionTypes(newTypes: List) { + reactionChoices = newTypes + live.invalidateData() + saveable.invalidateData() + } + + fun changeZapPaymentRequest(newServer: Nip47URI?) { + zapPaymentRequest = newServer + live.invalidateData() + saveable.invalidateData() + } + + fun selectedChatsFollowList(): Set { + val contactList = userProfile().latestContactList + return contactList?.taggedEvents()?.toSet() ?: DefaultChannels + } + + fun sendChangeChannel( + name: String, + about: String, + picture: String, + channel: Channel, + ) { + if (!isWriteable()) return + + ChannelMetadataEvent.create( + name, + about, + picture, + originalChannelIdHex = channel.idHex, + signer = signer, + ) { + Client.send(it) + LocalCache.justConsume(it, null) + + follow(channel) + } + } + + fun unwrap( + event: GiftWrapEvent, + onReady: (Event) -> Unit, + ) { + if (!isWriteable()) return + + return event.cachedGift(signer, onReady) + } + + fun unseal( + event: SealedGossipEvent, + onReady: (Event) -> Unit, + ) { + if (!isWriteable()) return + + return event.cachedGossip(signer, onReady) + } + + fun cachedDecryptContent(note: Note): String? { + val event = note.event + return if (event is PrivateDmEvent && isWriteable()) { + event.cachedContentFor(signer) + } else if (event is LnZapRequestEvent && event.isPrivateZap() && isWriteable()) { + event.cachedPrivateZap()?.content + } else { + event?.content() + } + } + + fun decryptContent( + note: Note, + onReady: (String) -> Unit, + ) { + val event = note.event + if (event is PrivateDmEvent && isWriteable()) { + event.plainContent(signer, onReady) + } else if (event is LnZapRequestEvent) { + decryptZapContentAuthor(note) { onReady(it.content) } + } else { + event?.content()?.let { onReady(it) } + } + } + + fun decryptZapContentAuthor( + note: Note, + onReady: (Event) -> Unit, + ) { + val event = note.event + if (event is LnZapRequestEvent) { + if (event.isPrivateZap()) { + if (isWriteable()) { + event.decryptPrivateZap(signer) { onReady(it) } + } + } else { + onReady(event) + } + } + } + + fun addDontTranslateFrom(languageCode: String) { + dontTranslateFrom = dontTranslateFrom.plus(languageCode) + liveLanguages.invalidateData() + + saveable.invalidateData() + } + + fun updateTranslateTo(languageCode: String) { + translateTo = languageCode + liveLanguages.invalidateData() + + saveable.invalidateData() + } + + fun prefer( + source: String, + target: String, + preference: String, + ) { + languagePreferences = languagePreferences + Pair("$source,$target", preference) + saveable.invalidateData() + } + + fun preferenceBetween( + source: String, + target: String, + ): String? { + return languagePreferences.get("$source,$target") + } + + private fun updateContactListTo(newContactList: ContactListEvent?) { + if (newContactList == null || newContactList.tags.isEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupContactList?.id != newContactList.id) { + backupContactList = newContactList + saveable.invalidateData() + } + } + + // Takes a User's relay list and adds the types of feeds they are active for. + fun activeRelays(): Array? { + var usersRelayList = + userProfile().latestContactList?.relays()?.map { + val localFeedTypes = + localRelays.firstOrNull { localRelay -> localRelay.url == it.key }?.feedTypes + ?: Constants.defaultRelays + .filter { defaultRelay -> defaultRelay.url == it.key } + .firstOrNull() + ?.feedTypes + ?: FeedType.values().toSet() + + Relay(it.key, it.value.read, it.value.write, localFeedTypes) + } + ?: return null + + // Ugly, but forces nostr.band as the only search-supporting relay today. + // TODO: Remove when search becomes more available. + val searchRelays = + usersRelayList.filter { it.url.removeSuffix("/") in Constants.forcedRelaysForSearchSet } + val hasSearchRelay = usersRelayList.any { it.activeTypes.contains(FeedType.SEARCH) } + if (!hasSearchRelay && searchRelays.isEmpty()) { + usersRelayList = + usersRelayList + + Constants.forcedRelayForSearch.map { + Relay( + it.url, + it.read, + it.write, + it.feedTypes, + ) + } + } + + return usersRelayList.toTypedArray() + } + + fun convertLocalRelays(): Array { + return localRelays.map { Relay(it.url, it.read, it.write, it.feedTypes) }.toTypedArray() + } + + fun activeGlobalRelays(): Array { + return (activeRelays() ?: convertLocalRelays()) + .filter { it.activeTypes.contains(FeedType.GLOBAL) } + .map { it.url } + .toTypedArray() + } + + fun activeWriteRelays(): List { + return (activeRelays() ?: convertLocalRelays()).filter { it.write } + } + + fun isAllHidden(users: Set): Boolean { + return users.all { isHidden(it) } + } + + fun isHidden(user: User) = isHidden(user.pubkeyHex) + + fun isHidden(userHex: String): Boolean { + return flowHiddenUsers.value.hiddenUsers.contains(userHex) || + flowHiddenUsers.value.spammers.contains(userHex) + } + + fun followingKeySet(): Set { + return userProfile().cachedFollowingKeySet() + } + + fun followingTagSet(): Set { + return userProfile().cachedFollowingTagSet() + } + + fun isAcceptable(user: User): Boolean { + if (userProfile().pubkeyHex == user.pubkeyHex) { + return true + } + + if (user.pubkeyHex in followingKeySet()) { + return true + } + + if (!warnAboutPostsWithReports) { + return !isHidden(user) && // if user hasn't hided this author + user.reportsBy(userProfile()).isEmpty() // if user has not reported this post + } + return !isHidden(user) && // if user hasn't hided this author + user.reportsBy(userProfile()).isEmpty() && // if user has not reported this post + user.countReportAuthorsBy(followingKeySet()) < 5 + } + + private fun isAcceptableDirect(note: Note): Boolean { + if (!warnAboutPostsWithReports) { + return !note.hasReportsBy(userProfile()) + } + return !note.hasReportsBy(userProfile()) && // if user has not reported this post + note.countReportAuthorsBy(followingKeySet()) < 5 // if it has 5 reports by reliable users + } + + fun isFollowing(user: User): Boolean { + return user.pubkeyHex in followingKeySet() + } + + fun isFollowing(user: HexKey): Boolean { + return user in followingKeySet() + } + + fun isAcceptable(note: Note): Boolean { + return note.author?.let { isAcceptable(it) } ?: true && // if user hasn't hided this author + isAcceptableDirect(note) && + ((note.event !is RepostEvent && note.event !is GenericRepostEvent) || + (note.replyTo?.firstOrNull { isAcceptableDirect(it) } != + null)) // is not a reaction about a blocked post + } + + fun getRelevantReports(note: Note): Set { + val followsPlusMe = userProfile().latestContactList?.verifiedFollowKeySetAndMe ?: emptySet() + + val innerReports = + if (note.event is RepostEvent || note.event is GenericRepostEvent) { + note.replyTo?.map { getRelevantReports(it) }?.flatten() ?: emptyList() + } else { + emptyList() + } + + return (note.reportsBy(followsPlusMe) + + (note.author?.reportsBy(followsPlusMe) ?: emptyList()) + + innerReports) + .toSet() + } + + fun saveRelayList(value: List) { + try { + localRelays = value.toSet() + return sendNewRelayList( + value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }, + ) + } finally { + saveable.invalidateData() + } + } + + fun setHideDeleteRequestDialog() { + hideDeleteRequestDialog = true + saveable.invalidateData() + } + + fun setHideNIP24WarningDialog() { + hideNIP24WarningDialog = true + saveable.invalidateData() + } + + fun setHideBlockAlertDialog() { + hideBlockAlertDialog = true + saveable.invalidateData() + } + + fun updateShowSensitiveContent(show: Boolean?) { + showSensitiveContent = show + saveable.invalidateData() + live.invalidateData() + } + + fun markAsRead( + route: String, + timestampInSecs: Long, + ): Boolean { + val lastTime = lastReadPerRoute[route] + return if (lastTime == null || timestampInSecs > lastTime) { + lastReadPerRoute = lastReadPerRoute + Pair(route, timestampInSecs) + saveable.invalidateData() + true + } else { + false + } + } + + fun loadLastRead(route: String): Long { + return lastReadPerRoute[route] ?: 0 + } + + suspend fun registerObservers() = + withContext(Dispatchers.Main) { + // saves contact list for the next time. + userProfile().live().follows.observeForever { + GlobalScope.launch(Dispatchers.IO) { updateContactListTo(userProfile().latestContactList) } + } + + // imports transient blocks due to spam. + LocalCache.antiSpam.liveSpam.observeForever { + GlobalScope.launch(Dispatchers.IO) { + it.cache.spamMessages.snapshot().values.forEach { + if (it.pubkeyHex !in transientHiddenUsers && it.duplicatedMessages.size >= 5) { + if (it.pubkeyHex != userProfile().pubkeyHex && it.pubkeyHex !in followingKeySet()) { + transientHiddenUsers = (transientHiddenUsers + it.pubkeyHex).toImmutableSet() + live.invalidateData() + } + } + } + } + } + } + + init { + Log.d("Init", "Account") + backupContactList?.let { + println("Loading saved contacts ${it.toJson()}") + + if (userProfile().latestContactList == null) { + GlobalScope.launch(Dispatchers.IO) { LocalCache.consume(it) } + } + } + } +} + +class AccountLiveData(private val account: Account) : + LiveData(AccountState(account)) { + // Refreshes observers in batches. + private val bundler = BundledUpdate(300, Dispatchers.Default) + + fun invalidateData() { + bundler.invalidate { + if (hasActiveObservers()) { + refresh() + } + } + } + + fun refresh() { + postValue(AccountState(account)) + } +} + +@Immutable class AccountState(val account: Account) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt index b0c51b986..44ec79075 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.model import android.util.Log @@ -15,79 +35,94 @@ import kotlinx.coroutines.Dispatchers data class Spammer(val pubkeyHex: HexKey, var duplicatedMessages: Set) class AntiSpamFilter { - val recentMessages = LruCache(1000) - val spamMessages = LruCache(1000) + val recentMessages = LruCache(1000) + val spamMessages = LruCache(1000) - var active: Boolean = true + var active: Boolean = true - fun isSpam(event: Event, relay: Relay?): Boolean { - checkNotInMainThread() + fun isSpam( + event: Event, + relay: Relay?, + ): Boolean { + checkNotInMainThread() - if (!active) return false + if (!active) return false - val idHex = event.id + val idHex = event.id - // if short message, ok - // The idea here is to avoid considering repeated "GM" messages spam. - if (event.content.length < 50) return false + // if short message, ok + // The idea here is to avoid considering repeated "GM" messages spam. + if (event.content.length < 50) return false - // if the message is actually short but because it cites a user/event, the nostr: string is really long, make it ok. - // The idea here is to avoid considering repeated "@Bot, command" messages spam, while still blocking repeated "lnbc..." invoices or fishing urls - if (event.content.length < 180 && Nip19.nip19regex.matcher(event.content).find()) return false + // if the message is actually short but because it cites a user/event, the nostr: string is + // really long, make it ok. + // The idea here is to avoid considering repeated "@Bot, command" messages spam, while still + // blocking repeated "lnbc..." invoices or fishing urls + if (event.content.length < 180 && Nip19.nip19regex.matcher(event.content).find()) return false - // double list strategy: - // if duplicated, it goes into spam. 1000 spam messages are saved into the spam list. + // double list strategy: + // if duplicated, it goes into spam. 1000 spam messages are saved into the spam list. - // Considers tags so that same replies to different people don't count. - val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode() + // Considers tags so that same replies to different people don't count. + val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode() - if ((recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null) { - Log.w("Potential SPAM Message for sharing", "${Nip19.createNEvent(event.id, event.pubKey, event.kind, null)}") - Log.w("Potential SPAM Message", "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}") + if ( + (recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null + ) { + Log.w( + "Potential SPAM Message for sharing", + "${Nip19.createNEvent(event.id, event.pubKey, event.kind, null)}", + ) + Log.w( + "Potential SPAM Message", + "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}", + ) - // Log down offenders - logOffender(hash, event) + // Log down offenders + logOffender(hash, event) - liveSpam.invalidateData() + liveSpam.invalidateData() - return true - } - - recentMessages.put(hash, idHex) - - return false + return true } - @Synchronized - private fun logOffender(hashCode: Int, event: Event) { - if (spamMessages.get(hashCode) == null) { - spamMessages.put(hashCode, Spammer(event.pubKey, setOf(recentMessages[hashCode], event.id))) - } else { - val spammer = spamMessages.get(hashCode) - spammer.duplicatedMessages = spammer.duplicatedMessages + event.id - } - } + recentMessages.put(hash, idHex) - val liveSpam: AntiSpamLiveData = AntiSpamLiveData(this) + return false + } + + @Synchronized + private fun logOffender( + hashCode: Int, + event: Event, + ) { + if (spamMessages.get(hashCode) == null) { + spamMessages.put(hashCode, Spammer(event.pubKey, setOf(recentMessages[hashCode], event.id))) + } else { + val spammer = spamMessages.get(hashCode) + spammer.duplicatedMessages = spammer.duplicatedMessages + event.id + } + } + + val liveSpam: AntiSpamLiveData = AntiSpamLiveData(this) } @Stable class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData(AntiSpamState(cache)) { + // Refreshes observers in batches. + private val bundler = BundledUpdate(300, Dispatchers.IO) - // Refreshes observers in batches. - private val bundler = BundledUpdate(300, Dispatchers.IO) + fun invalidateData() { + checkNotInMainThread() - fun invalidateData() { - checkNotInMainThread() + bundler.invalidate { + checkNotInMainThread() - bundler.invalidate() { - checkNotInMainThread() - - if (hasActiveObservers()) { - postValue(AntiSpamState(cache)) - } - } + if (hasActiveObservers()) { + postValue(AntiSpamState(cache)) + } } + } } class AntiSpamState(val cache: AntiSpamFilter) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt index e37331872..6da9d0a02 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable @@ -12,162 +32,176 @@ import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.toNote import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent -import kotlinx.coroutines.Dispatchers import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.Dispatchers @Stable class PublicChatChannel(idHex: String) : Channel(idHex) { - var info = ChannelCreateEvent.ChannelData(null, null, null) + var info = ChannelCreateEvent.ChannelData(null, null, null) - fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) { - this.info = channelInfo - super.updateChannelInfo(creator, updatedAt) - } + fun updateChannelInfo( + creator: User, + channelInfo: ChannelCreateEvent.ChannelData, + updatedAt: Long, + ) { + this.info = channelInfo + super.updateChannelInfo(creator, updatedAt) + } - override fun toBestDisplayName(): String { - return info.name ?: super.toBestDisplayName() - } + override fun toBestDisplayName(): String { + return info.name ?: super.toBestDisplayName() + } - override fun summary(): String? { - return info.about - } + override fun summary(): String? { + return info.about + } - override fun profilePicture(): String? { - if (info.picture.isNullOrBlank()) return super.profilePicture() - return info.picture ?: super.profilePicture() - } + override fun profilePicture(): String? { + if (info.picture.isNullOrBlank()) return super.profilePicture() + return info.picture ?: super.profilePicture() + } - override fun anyNameStartsWith(prefix: String): Boolean { - return listOfNotNull(info.name, info.about) - .filter { it.contains(prefix, true) }.isNotEmpty() - } + override fun anyNameStartsWith(prefix: String): Boolean { + return listOfNotNull(info.name, info.about).filter { it.contains(prefix, true) }.isNotEmpty() + } } @Stable class LiveActivitiesChannel(val address: ATag) : Channel(address.toTag()) { - var info: LiveActivitiesEvent? = null + var info: LiveActivitiesEvent? = null - override fun idNote() = address.toNAddr() - override fun idDisplayNote() = idNote().toShortenHex() - fun address() = address + override fun idNote() = address.toNAddr() - fun updateChannelInfo(creator: User, channelInfo: LiveActivitiesEvent, updatedAt: Long) { - this.info = channelInfo - super.updateChannelInfo(creator, updatedAt) - } + override fun idDisplayNote() = idNote().toShortenHex() - override fun toBestDisplayName(): String { - return info?.title() ?: super.toBestDisplayName() - } + fun address() = address - override fun summary(): String? { - return info?.summary() - } + fun updateChannelInfo( + creator: User, + channelInfo: LiveActivitiesEvent, + updatedAt: Long, + ) { + this.info = channelInfo + super.updateChannelInfo(creator, updatedAt) + } - override fun profilePicture(): String? { - return info?.image()?.ifBlank { null } - } + override fun toBestDisplayName(): String { + return info?.title() ?: super.toBestDisplayName() + } - override fun anyNameStartsWith(prefix: String): Boolean { - return listOfNotNull(info?.title(), info?.summary()) - .filter { it.contains(prefix, true) }.isNotEmpty() - } + override fun summary(): String? { + return info?.summary() + } + + override fun profilePicture(): String? { + return info?.image()?.ifBlank { null } + } + + override fun anyNameStartsWith(prefix: String): Boolean { + return listOfNotNull(info?.title(), info?.summary()) + .filter { it.contains(prefix, true) } + .isNotEmpty() + } } @Stable abstract class Channel(val idHex: String) { - var creator: User? = null + var creator: User? = null - var updatedMetadataAt: Long = 0 + var updatedMetadataAt: Long = 0 - val notes = ConcurrentHashMap() + val notes = ConcurrentHashMap() - open fun id() = Hex.decode(idHex) - open fun idNote() = id().toNote() - open fun idDisplayNote() = idNote().toShortenHex() + open fun id() = Hex.decode(idHex) - open fun toBestDisplayName(): String { - return idDisplayNote() - } + open fun idNote() = id().toNote() - open fun summary(): String? { - return null - } + open fun idDisplayNote() = idNote().toShortenHex() - open fun creatorName(): String? { - return creator?.toBestDisplayName() - } + open fun toBestDisplayName(): String { + return idDisplayNote() + } - open fun profilePicture(): String? { - return creator?.profilePicture() - } + open fun summary(): String? { + return null + } - open fun updateChannelInfo(creator: User, updatedAt: Long) { - this.creator = creator - this.updatedMetadataAt = updatedAt + open fun creatorName(): String? { + return creator?.toBestDisplayName() + } - live.invalidateData() - } + open fun profilePicture(): String? { + return creator?.profilePicture() + } - fun addNote(note: Note) { - notes[note.idHex] = note - } + open fun updateChannelInfo( + creator: User, + updatedAt: Long, + ) { + this.creator = creator + this.updatedMetadataAt = updatedAt - fun removeNote(note: Note) { - notes.remove(note.idHex) - } + live.invalidateData() + } - fun removeNote(noteHex: String) { - notes.remove(noteHex) - } + fun addNote(note: Note) { + notes[note.idHex] = note + } - abstract fun anyNameStartsWith(prefix: String): Boolean + fun removeNote(note: Note) { + notes.remove(note.idHex) + } - // Observers line up here. - val live: ChannelLiveData = ChannelLiveData(this) + fun removeNote(noteHex: String) { + notes.remove(noteHex) + } - fun pruneOldAndHiddenMessages(account: Account): Set { - val important = notes.values - .filter { it.author?.let { it1 -> account.isHidden(it1) } == false } - .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - .reversed() - .take(1000) - .toSet() + abstract fun anyNameStartsWith(prefix: String): Boolean - val toBeRemoved = notes.values.filter { it !in important }.toSet() + // Observers line up here. + val live: ChannelLiveData = ChannelLiveData(this) - toBeRemoved.forEach { - notes.remove(it.idHex) - } + fun pruneOldAndHiddenMessages(account: Account): Set { + val important = + notes.values + .filter { it.author?.let { it1 -> account.isHidden(it1) } == false } + .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) + .reversed() + .take(1000) + .toSet() - return toBeRemoved - } + val toBeRemoved = notes.values.filter { it !in important }.toSet() + + toBeRemoved.forEach { notes.remove(it.idHex) } + + return toBeRemoved + } } class ChannelLiveData(val channel: Channel) : LiveData(ChannelState(channel)) { - // Refreshes observers in batches. - private val bundler = BundledUpdate(300, Dispatchers.IO) + // Refreshes observers in batches. + private val bundler = BundledUpdate(300, Dispatchers.IO) - fun invalidateData() { - checkNotInMainThread() + fun invalidateData() { + checkNotInMainThread() - bundler.invalidate() { - checkNotInMainThread() - if (hasActiveObservers()) { - postValue(ChannelState(channel)) - } - } + bundler.invalidate { + checkNotInMainThread() + if (hasActiveObservers()) { + postValue(ChannelState(channel)) + } } + } - override fun onActive() { - super.onActive() - NostrSingleChannelDataSource.add(channel) - } + override fun onActive() { + super.onActive() + NostrSingleChannelDataSource.add(channel) + } - override fun onInactive() { - super.onInactive() - NostrSingleChannelDataSource.remove(channel) - } + override fun onInactive() { + super.onInactive() + NostrSingleChannelDataSource.remove(channel) + } } class ChannelState(val channel: Channel) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Chatroom.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Chatroom.kt index e23b02956..18c7c1f7f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Chatroom.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Chatroom.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable @@ -7,57 +27,62 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Stable class Chatroom() { - var roomMessages: Set = setOf() - var subject: String? = null - var subjectCreatedAt: Long? = null + var roomMessages: Set = setOf() + var subject: String? = null + var subjectCreatedAt: Long? = null - @Synchronized - fun addMessageSync(msg: Note) { - checkNotInMainThread() + @Synchronized + fun addMessageSync(msg: Note) { + checkNotInMainThread() - if (msg !in roomMessages) { - roomMessages = roomMessages + msg + if (msg !in roomMessages) { + roomMessages = roomMessages + msg - val newSubject = msg.event?.subject() + val newSubject = msg.event?.subject() - if (newSubject != null && (msg.createdAt() ?: 0) > (subjectCreatedAt ?: 0)) { - subject = newSubject - subjectCreatedAt = msg.createdAt() - } + if (newSubject != null && (msg.createdAt() ?: 0) > (subjectCreatedAt ?: 0)) { + subject = newSubject + subjectCreatedAt = msg.createdAt() + } + } + } + + @Synchronized + fun removeMessageSync(msg: Note) { + checkNotInMainThread() + + if (msg !in roomMessages) { + roomMessages = roomMessages + msg + + roomMessages + .filter { it.event?.subject() != null } + .sortedBy { it.createdAt() } + .lastOrNull() + ?.let { + subject = it.event?.subject() + subjectCreatedAt = it.createdAt() } } + } - @Synchronized - fun removeMessageSync(msg: Note) { - checkNotInMainThread() + fun senderIntersects(keySet: Set): Boolean { + return roomMessages.any { it.author?.pubkeyHex in keySet } + } - if (msg !in roomMessages) { - roomMessages = roomMessages + msg + fun pruneMessagesToTheLatestOnly(): Set { + val sorted = roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - roomMessages.filter { it.event?.subject() != null }.sortedBy { it.createdAt() }.lastOrNull()?.let { - subject = it.event?.subject() - subjectCreatedAt = it.createdAt() - } - } - } + val toKeep = + if ((sorted.firstOrNull()?.createdAt() ?: 0) > TimeUtils.oneWeekAgo()) { + // Recent messages, keep last 100 + sorted.take(100).toSet() + } else { + // Old messages, keep the last one. + sorted.take(1).toSet() + } + sorted.filter { it.liveSet?.isInUse() ?: false } - fun senderIntersects(keySet: Set): Boolean { - return roomMessages.any { it.author?.pubkeyHex in keySet } - } - - fun pruneMessagesToTheLatestOnly(): Set { - val sorted = roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - - val toKeep = if ((sorted.firstOrNull()?.createdAt() ?: 0) > TimeUtils.oneWeekAgo()) { - // Recent messages, keep last 100 - sorted.take(100).toSet() - } else { - // Old messages, keep the last one. - sorted.take(1).toSet() - } + sorted.filter { it.liveSet?.isInUse() ?: false } - - val toRemove = roomMessages.minus(toKeep) - roomMessages = toKeep - return toRemove - } + val toRemove = roomMessages.minus(toKeep) + roomMessages = toKeep + return toRemove + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt index 384ef71d1..ff580a27c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt @@ -1,34 +1,171 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.model + import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Immutable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -fun checkForHashtagWithIcon(tag: String, primary: Color): HashtagIcon? { - return when (tag.lowercase()) { - "bitcoin", "btc", "timechain", "bitcoiner", "bitcoiners" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp)) - "nostr", "nostrich", "nostriches", "thenostr" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp)) - "lightning", "lightningnetwork" -> HashtagIcon(R.drawable.ht_lightning, "Lightning", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp)) - "zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> HashtagIcon(R.drawable.zap, "Zap", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp)) - "amethyst" -> HashtagIcon(R.drawable.amethyst, "Amethyst", Color.Unspecified, Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp)) - "onyx" -> HashtagIcon(R.drawable.black_heart, "Onyx", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp)) - "cashu", "ecash", "nut", "nuts", "deeznuts" -> HashtagIcon(R.drawable.cashu, "Cashu", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp)) - "plebs", "pleb", "plebchain" -> HashtagIcon(R.drawable.plebs, "Pleb", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp)) - "coffee", "coffeechain", "cafe" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp)) - "skullofsatoshi" -> HashtagIcon(R.drawable.skull, "SkullofSatoshi", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp)) - "grownostr", "gardening", "garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp)) - "footstr" -> HashtagIcon(R.drawable.footstr, "Footstr", Color.Unspecified, Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp)) - "tunestr", "music", "nowplaying" -> HashtagIcon(R.drawable.tunestr, "Tunestr", primary, Modifier.padding(0.dp, 3.dp, 0.dp, 1.dp)) - "weed", "weedstr", "420", "cannabis", "marijuana" -> HashtagIcon(R.drawable.weed, "Weed", Color.Unspecified, Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp)) - else -> null - } + +fun checkForHashtagWithIcon( + tag: String, + primary: Color, +): HashtagIcon? { + return when (tag.lowercase()) { + "bitcoin", + "btc", + "timechain", + "bitcoiner", + "bitcoiners", -> + HashtagIcon( + R.drawable.ht_btc, + "Bitcoin", + Color.Unspecified, + Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp), + ) + "nostr", + "nostrich", + "nostriches", + "thenostr", -> + HashtagIcon( + R.drawable.ht_nostr, + "Nostr", + Color.Unspecified, + Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp), + ) + "lightning", + "lightningnetwork", -> + HashtagIcon( + R.drawable.ht_lightning, + "Lightning", + Color.Unspecified, + Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp), + ) + "zap", + "zaps", + "zapper", + "zappers", + "zapping", + "zapped", + "zapathon", + "zapraiser", + "zaplife", + "zapchain", -> + HashtagIcon( + R.drawable.zap, + "Zap", + Color.Unspecified, + Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp), + ) + "amethyst" -> + HashtagIcon( + R.drawable.amethyst, + "Amethyst", + Color.Unspecified, + Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp), + ) + "onyx" -> + HashtagIcon( + R.drawable.black_heart, + "Onyx", + Color.Unspecified, + Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp), + ) + "cashu", + "ecash", + "nut", + "nuts", + "deeznuts", -> + HashtagIcon( + R.drawable.cashu, + "Cashu", + Color.Unspecified, + Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp), + ) + "plebs", + "pleb", + "plebchain", -> + HashtagIcon( + R.drawable.plebs, + "Pleb", + Color.Unspecified, + Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp), + ) + "coffee", + "coffeechain", + "cafe", -> + HashtagIcon( + R.drawable.coffee, + "Coffee", + Color.Unspecified, + Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp), + ) + "skullofsatoshi" -> + HashtagIcon( + R.drawable.skull, + "SkullofSatoshi", + Color.Unspecified, + Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp), + ) + "grownostr", + "gardening", + "garden", -> + HashtagIcon( + R.drawable.grownostr, + "GrowNostr", + Color.Unspecified, + Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp), + ) + "footstr" -> + HashtagIcon( + R.drawable.footstr, + "Footstr", + Color.Unspecified, + Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp), + ) + "tunestr", + "music", + "nowplaying", -> + HashtagIcon(R.drawable.tunestr, "Tunestr", primary, Modifier.padding(0.dp, 3.dp, 0.dp, 1.dp)) + "weed", + "weedstr", + "420", + "cannabis", + "marijuana", -> + HashtagIcon( + R.drawable.weed, + "Weed", + Color.Unspecified, + Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp), + ) + else -> null + } } @Immutable class HashtagIcon( - val icon: Int, - val description: String, - val color: Color, - val modifier: Modifier + val icon: Int, + val description: String, + val color: Color, + val modifier: Modifier, ) 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 b523c02da..52feee0ec 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.model import android.util.Log @@ -74,6 +94,13 @@ import com.vitorpamplona.quartz.events.StatusEvent import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.VideoHorizontalEvent import com.vitorpamplona.quartz.events.VideoVerticalEvent +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.concurrent.ConcurrentHashMap import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableList @@ -82,1545 +109,1724 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow -import java.io.File -import java.io.FileOutputStream -import java.io.IOException -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.util.concurrent.ConcurrentHashMap object LocalCache { - val antiSpam = AntiSpamFilter() + val antiSpam = AntiSpamFilter() - val users = ConcurrentHashMap(5000) - val notes = ConcurrentHashMap(5000) - val channels = ConcurrentHashMap() - val addressables = ConcurrentHashMap(100) + val users = ConcurrentHashMap(5000) + val notes = ConcurrentHashMap(5000) + val channels = ConcurrentHashMap() + val addressables = ConcurrentHashMap(100) - val awaitingPaymentRequests = - ConcurrentHashMap Unit>>(10) + val awaitingPaymentRequests = + ConcurrentHashMap Unit>>(10) - fun checkGetOrCreateUser(key: String): User? { - // checkNotInMainThread() + fun checkGetOrCreateUser(key: String): User? { + // checkNotInMainThread() - if (isValidHex(key)) { - return getOrCreateUser(key) + if (isValidHex(key)) { + return getOrCreateUser(key) + } + return null + } + + fun getOrCreateUser(key: HexKey): User { + // checkNotInMainThread() + + return users[key] + ?: run { + require(isValidHex(key = key)) { "$key is not a valid hex" } + + val newObject = User(key) + users.putIfAbsent(key, newObject) ?: newObject + } + } + + fun getUserIfExists(key: String): User? { + if (key.isEmpty()) return null + return users[key] + } + + fun getAddressableNoteIfExists(key: String): AddressableNote? { + return addressables[key] + } + + fun getNoteIfExists(key: String): Note? { + return addressables[key] ?: notes[key] + } + + fun getChannelIfExists(key: String): Channel? { + return channels[key] + } + + fun checkGetOrCreateNote(key: String): Note? { + checkNotInMainThread() + + if (ATag.isATag(key)) { + return checkGetOrCreateAddressableNote(key) + } + if (isValidHex(key)) { + val note = getOrCreateNote(key) + val noteEvent = note.event + return if (noteEvent is AddressableEvent) { + // upgrade to the latest + val newNote = checkGetOrCreateAddressableNote(noteEvent.address().toTag()) + + if (newNote != null && noteEvent is Event && newNote.event == null) { + val author = note.author ?: getOrCreateUser(noteEvent.pubKey) + newNote.loadEvent(noteEvent as Event, author, emptyList()) + note.moveAllReferencesTo(newNote) } - return null + + newNote + } else { + note + } + } + return null + } + + fun getOrAddAliasNote( + idHex: String, + note: Note, + ): Note { + checkNotInMainThread() + + return notes.get(idHex) + ?: run { + require(isValidHex(idHex)) { "$idHex is not a valid hex" } + + notes.putIfAbsent(idHex, note) ?: note + } + } + + fun getOrCreateNote(idHex: String): Note { + checkNotInMainThread() + + return notes.get(idHex) + ?: run { + require(isValidHex(idHex)) { "$idHex is not a valid hex" } + + val newObject = Note(idHex) + notes.putIfAbsent(idHex, newObject) ?: newObject + } + } + + fun checkGetOrCreateChannel(key: String): Channel? { + checkNotInMainThread() + + if (isValidHex(key)) { + return getOrCreateChannel(key) { PublicChatChannel(key) } + } + val aTag = ATag.parse(key, null) + if (aTag != null) { + return getOrCreateChannel(aTag.toTag()) { LiveActivitiesChannel(aTag) } + } + return null + } + + private fun isValidHex(key: String): Boolean { + if (key.isBlank()) return false + if (key.contains(":")) return false + + return HexValidator.isHex(key) + } + + fun getOrCreateChannel( + key: String, + channelFactory: (String) -> Channel, + ): Channel { + checkNotInMainThread() + + return channels[key] + ?: run { + val newObject = channelFactory(key) + channels.putIfAbsent(key, newObject) ?: newObject + } + } + + fun checkGetOrCreateAddressableNote(key: String): AddressableNote? { + return try { + val addr = ATag.parse(key, null) // relay doesn't matter for the index. + if (addr != null) { + getOrCreateAddressableNote(addr) + } else { + null + } + } catch (e: IllegalArgumentException) { + Log.e("LocalCache", "Invalid Key to create channel: $key", e) + null + } + } + + fun getOrCreateAddressableNoteInternal(key: ATag): AddressableNote { + // checkNotInMainThread() + + // we can't use naddr here because naddr might include relay info and + // the preferred relay should not be part of the index. + return addressables[key.toTag()] + ?: run { + val newObject = AddressableNote(key) + addressables.putIfAbsent(key.toTag(), newObject) ?: newObject + } + } + + fun getOrCreateAddressableNote(key: ATag): AddressableNote { + val note = getOrCreateAddressableNoteInternal(key) + // Loads the user outside a Syncronized block to avoid blocking + if (note.author == null) { + note.author = checkGetOrCreateUser(key.pubKeyHex) + } + return note + } + + fun consume(event: MetadataEvent) { + // new event + val oldUser = getOrCreateUser(event.pubKey) + if (oldUser.info == null || event.createdAt > oldUser.info!!.updatedMetadataAt) { + val newUserMetadata = event.contactMetaData() + if (newUserMetadata != null) { + oldUser.updateUserInfo(newUserMetadata, event) + } + // Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex} ${oldUser.toBestDisplayName()}") + } else { + // Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} + // ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}") + } + } + + fun consume(event: ContactListEvent) { + val user = getOrCreateUser(event.pubKey) + + // avoids processing empty contact lists. + if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !event.tags.isEmpty()) { + user.updateContactList(event) + // Log.d("CL", "Consumed contact list ${user.toNostrUri()} ${event.relays()?.size}") + } + } + + fun consume(event: BookmarkListEvent) { + val user = getOrCreateUser(event.pubKey) + if (user.latestBookmarkList == null || event.createdAt > user.latestBookmarkList!!.createdAt) { + if (event.dTag() == "bookmark") { + user.updateBookmark(event) + } + // Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex} ${oldUser.toBestDisplayName()}") + } else { + // Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} + // ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}") + } + } + + fun formattedDateTime(timestamp: Long): String { + return Instant.ofEpochSecond(timestamp) + .atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a")) + } + + fun consume( + event: TextNoteEvent, + relay: Relay? = null, + ) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) } - fun getOrCreateUser(key: HexKey): User { - // checkNotInMainThread() + // Already processed this event. + if (note.event != null) return - return users[key] ?: run { - require(isValidHex(key = key)) { - "$key is not a valid hex" - } - - val newObject = User(key) - users.putIfAbsent(key, newObject) ?: newObject - } + if (antiSpam.isSpam(event, relay)) { + relay?.let { it.spamCounter++ } + return } - fun getUserIfExists(key: String): User? { - if (key.isEmpty()) return null - return users[key] + val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } + + note.loadEvent(event, author, replyTo) + + // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} + // ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}") + + // Counts the replies + replyTo.forEach { it.addReply(note) } + + refreshObservers(note) + } + + fun consume( + event: LongTextNoteEvent, + relay: Relay?, + ) { + val version = getOrCreateNote(event.id) + val note = getOrCreateAddressableNote(event.address()) + val author = getOrCreateUser(event.pubKey) + + if (version.event == null) { + version.loadEvent(event, author, emptyList()) + version.moveAllReferencesTo(note) } - fun getAddressableNoteIfExists(key: String): AddressableNote? { - return addressables[key] + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) } - fun getNoteIfExists(key: String): Note? { - return addressables[key] ?: notes[key] + // Already processed this event. + if (note.event?.id() == event.id()) return + + if (antiSpam.isSpam(event, relay)) { + relay?.let { it.spamCounter++ } + return } - fun getChannelIfExists(key: String): Channel? { - return channels[key] + val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } + + if (event.createdAt > (note.createdAt() ?: 0)) { + note.loadEvent(event, author, replyTo) + + refreshObservers(note) + } + } + + fun consume( + event: PollNoteEvent, + relay: Relay? = null, + ) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) } - fun checkGetOrCreateNote(key: String): Note? { - checkNotInMainThread() + // Already processed this event. + if (note.event != null) return - if (ATag.isATag(key)) { - return checkGetOrCreateAddressableNote(key) - } - if (isValidHex(key)) { - val note = getOrCreateNote(key) - val noteEvent = note.event - return if (noteEvent is AddressableEvent) { - // upgrade to the latest - val newNote = checkGetOrCreateAddressableNote(noteEvent.address().toTag()) - - if (newNote != null && noteEvent is Event && newNote.event == null) { - val author = note.author ?: getOrCreateUser(noteEvent.pubKey) - newNote.loadEvent(noteEvent as Event, author, emptyList()) - note.moveAllReferencesTo(newNote) - } - - newNote - } else { - note - } - } - return null + if (antiSpam.isSpam(event, relay)) { + relay?.let { it.spamCounter++ } + return } - fun getOrAddAliasNote(idHex: String, note: Note): Note { - checkNotInMainThread() + val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } - return notes.get(idHex) ?: run { - require(isValidHex(idHex)) { - "$idHex is not a valid hex" - } + note.loadEvent(event, author, replyTo) - notes.putIfAbsent(idHex, note) ?: note - } + // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} + // ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}") + + // Counts the replies + replyTo.forEach { it.addReply(note) } + + refreshObservers(note) + } + + private fun consume( + event: LiveActivitiesEvent, + relay: Relay?, + ) { + val version = getOrCreateNote(event.id) + val note = getOrCreateAddressableNote(event.address()) + val author = getOrCreateUser(event.pubKey) + + if (version.event == null) { + version.loadEvent(event, author, emptyList()) + version.moveAllReferencesTo(note) } - fun getOrCreateNote(idHex: String): Note { - checkNotInMainThread() + if (note.event?.id() == event.id()) return - return notes.get(idHex) ?: run { - require(isValidHex(idHex)) { - "$idHex is not a valid hex" - } + if (event.createdAt > (note.createdAt() ?: 0)) { + note.loadEvent(event, author, emptyList()) - val newObject = Note(idHex) - notes.putIfAbsent(idHex, newObject) ?: newObject - } + val channel = + getOrCreateChannel(note.idHex) { LiveActivitiesChannel(note.address) } + as? LiveActivitiesChannel + + val creator = event.host()?.ifBlank { null }?.let { checkGetOrCreateUser(it) } ?: author + + channel?.updateChannelInfo(creator, event, event.createdAt) + + refreshObservers(note) + } + } + + fun consume( + event: MuteListEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + fun consume( + event: FileServersEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + fun consume( + event: PeopleListEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + private fun consume( + event: AdvertisedRelayListEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + private fun consume( + event: CommunityDefinitionEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + fun consume( + event: EmojiPackSelectionEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + private fun consume( + event: EmojiPackEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + private fun consume( + event: ClassifiedsEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + private fun consume( + event: PinListEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + private fun consume( + event: RelaySetEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + private fun consume( + event: AudioTrackEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + private fun consume( + event: VideoVerticalEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + private fun consume( + event: VideoHorizontalEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + fun consume( + event: StatusEvent, + relay: Relay?, + ) { + val version = getOrCreateNote(event.id) + val note = getOrCreateAddressableNote(event.address()) + val author = getOrCreateUser(event.pubKey) + + if (version.event == null) { + version.loadEvent(event, author, emptyList()) + version.moveAllReferencesTo(note) } - fun checkGetOrCreateChannel(key: String): Channel? { - checkNotInMainThread() + // Already processed this event. + if (note.event?.id() == event.id()) return - if (isValidHex(key)) { - return getOrCreateChannel(key) { - PublicChatChannel(key) - } - } - val aTag = ATag.parse(key, null) - if (aTag != null) { - return getOrCreateChannel(aTag.toTag()) { - LiveActivitiesChannel(aTag) - } - } - return null + if (event.createdAt > (note.createdAt() ?: 0)) { + note.loadEvent(event, author, emptyList()) + + author.liveSet?.innerStatuses?.invalidateData() + + refreshObservers(note) + } + } + + fun consume( + event: BadgeDefinitionEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + fun consume(event: BadgeProfilesEvent) { + val version = getOrCreateNote(event.id) + val note = getOrCreateAddressableNote(event.address()) + val author = getOrCreateUser(event.pubKey) + + if (version.event == null) { + version.loadEvent(event, author, emptyList()) + version.moveAllReferencesTo(note) } - private fun isValidHex(key: String): Boolean { - if (key.isBlank()) return false - if (key.contains(":")) return false + // Already processed this event. + if (note.event?.id() == event.id()) return - return HexValidator.isHex(key) + val replyTo = + event.badgeAwardEvents().mapNotNull { checkGetOrCreateNote(it) } + + event.badgeAwardDefinitions().map { getOrCreateAddressableNote(it) } + + if (event.createdAt > (note.createdAt() ?: 0)) { + note.loadEvent(event, author, replyTo) + + refreshObservers(note) + } + } + + fun consume(event: BadgeAwardEvent) { + val note = getOrCreateNote(event.id) + + // Already processed this event. + if (note.event != null) return + + // Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} + // ${formattedDateTime(event.createdAt)}") + + val author = getOrCreateUser(event.pubKey) + val awardDefinition = event.awardDefinition().map { getOrCreateAddressableNote(it) } + + note.loadEvent(event, author, awardDefinition) + + // Replies of an Badge Definition are Award Events + awardDefinition.forEach { it.addReply(note) } + + refreshObservers(note) + } + + private fun comsume( + event: NNSEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + fun consume( + event: AppDefinitionEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + private fun consume( + event: CalendarEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + private fun consume( + event: CalendarDateSlotEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + private fun consume( + event: CalendarTimeSlotEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + private fun consume( + event: CalendarRSVPEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + private fun consumeBaseReplaceable( + event: BaseAddressableEvent, + relay: Relay?, + ) { + val version = getOrCreateNote(event.id) + val note = getOrCreateAddressableNote(event.address()) + val author = getOrCreateUser(event.pubKey) + + if (version.event == null) { + version.loadEvent(event, author, emptyList()) + version.moveAllReferencesTo(note) } - fun getOrCreateChannel(key: String, channelFactory: (String) -> Channel): Channel { - checkNotInMainThread() - - return channels[key] ?: run { - val newObject = channelFactory(key) - channels.putIfAbsent(key, newObject) ?: newObject - } + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) } - fun checkGetOrCreateAddressableNote(key: String): AddressableNote? { - return try { - val addr = ATag.parse(key, null) // relay doesn't matter for the index. - if (addr != null) { - getOrCreateAddressableNote(addr) - } else { - null - } - } catch (e: IllegalArgumentException) { - Log.e("LocalCache", "Invalid Key to create channel: $key", e) - null - } + // Already processed this event. + if (note.event?.id() == event.id()) return + + if (event.createdAt > (note.createdAt() ?: 0)) { + note.loadEvent(event, author, emptyList()) + + refreshObservers(note) + } + } + + fun consume( + event: AppRecommendationEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + @Suppress("UNUSED_PARAMETER") + fun consume(event: RecommendRelayEvent) { + // // Log.d("RR", event.toJson()) + } + + fun consume( + event: PrivateDmEvent, + relay: Relay?, + ): Note { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) } - fun getOrCreateAddressableNoteInternal(key: ATag): AddressableNote { - // checkNotInMainThread() + // Already processed this event. + if (note.event != null) return note - // we can't use naddr here because naddr might include relay info and - // the preferred relay should not be part of the index. - return addressables[key.toTag()] ?: run { - val newObject = AddressableNote(key) - addressables.putIfAbsent(key.toTag(), newObject) ?: newObject - } + val recipient = event.verifiedRecipientPubKey()?.let { getOrCreateUser(it) } + + // Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}") + + val repliesTo = event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } + + note.loadEvent(event, author, repliesTo) + + if (recipient != null) { + author.addMessage(recipient, note) + recipient.addMessage(author, note) } - fun getOrCreateAddressableNote(key: ATag): AddressableNote { - val note = getOrCreateAddressableNoteInternal(key) - // Loads the user outside a Syncronized block to avoid blocking - if (note.author == null) { - note.author = checkGetOrCreateUser(key.pubKeyHex) - } - return note - } + refreshObservers(note) - fun consume(event: MetadataEvent) { - // new event - val oldUser = getOrCreateUser(event.pubKey) - if (oldUser.info == null || event.createdAt > oldUser.info!!.updatedMetadataAt) { - val newUserMetadata = event.contactMetaData() - if (newUserMetadata != null) { - oldUser.updateUserInfo(newUserMetadata, event) - } - // Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex} ${oldUser.toBestDisplayName()}") - } else { - // Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}") - } - } + return note + } - fun consume(event: ContactListEvent) { - val user = getOrCreateUser(event.pubKey) + fun consume(event: DeletionEvent) { + var deletedAtLeastOne = false - // avoids processing empty contact lists. - if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !event.tags.isEmpty()) { - user.updateContactList(event) - // Log.d("CL", "Consumed contact list ${user.toNostrUri()} ${event.relays()?.size}") - } - } + event + .deleteEvents() + .mapNotNull { notes[it] } + .forEach { deleteNote -> + // must be the same author + if (deleteNote.author?.pubkeyHex == event.pubKey) { + // reverts the add + val mentions = + deleteNote.event + ?.tags() + ?.filter { it.firstOrNull() == "p" } + ?.mapNotNull { it.getOrNull(1) } + ?.mapNotNull { checkGetOrCreateUser(it) } - fun consume(event: BookmarkListEvent) { - val user = getOrCreateUser(event.pubKey) - if (user.latestBookmarkList == null || event.createdAt > user.latestBookmarkList!!.createdAt) { - if (event.dTag() == "bookmark") { - user.updateBookmark(event) - } - // Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex} ${oldUser.toBestDisplayName()}") - } else { - // Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}") - } - } + mentions?.forEach { user -> user.removeReport(deleteNote) } - fun formattedDateTime(timestamp: Long): String { - return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault()) - .format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a")) - } + // Counts the replies + deleteNote.replyTo?.forEach { masterNote -> + masterNote.removeReply(deleteNote) + masterNote.removeBoost(deleteNote) + masterNote.removeReaction(deleteNote) + masterNote.removeZap(deleteNote) + masterNote.removeZapPayment(deleteNote) + masterNote.removeReport(deleteNote) + } - fun consume(event: TextNoteEvent, relay: Relay? = null) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) + deleteNote.channelHex()?.let { channels[it]?.removeNote(deleteNote) } - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } + (deleteNote.event as? LiveActivitiesChatMessageEvent)?.activity()?.let { + channels[it.toTag()]?.removeNote(deleteNote) + } - // Already processed this event. - if (note.event != null) return - - if (antiSpam.isSpam(event, relay)) { - relay?.let { - it.spamCounter++ - } - return - } - - val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } - - note.loadEvent(event, author, replyTo) - - // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}") - - // Counts the replies - replyTo.forEach { - it.addReply(note) - } - - refreshObservers(note) - } - - fun consume(event: LongTextNoteEvent, relay: Relay?) { - val version = getOrCreateNote(event.id) - val note = getOrCreateAddressableNote(event.address()) - val author = getOrCreateUser(event.pubKey) - - if (version.event == null) { - version.loadEvent(event, author, emptyList()) - version.moveAllReferencesTo(note) - } - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event?.id() == event.id()) return - - if (antiSpam.isSpam(event, relay)) { - relay?.let { - it.spamCounter++ - } - return - } - - val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } - - if (event.createdAt > (note.createdAt() ?: 0)) { - note.loadEvent(event, author, replyTo) - - refreshObservers(note) - } - } - - fun consume(event: PollNoteEvent, relay: Relay? = null) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - if (antiSpam.isSpam(event, relay)) { - relay?.let { - it.spamCounter++ - } - return - } - - val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } - - note.loadEvent(event, author, replyTo) - - // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}") - - // Counts the replies - replyTo.forEach { - it.addReply(note) - } - - refreshObservers(note) - } - - private fun consume(event: LiveActivitiesEvent, relay: Relay?) { - val version = getOrCreateNote(event.id) - val note = getOrCreateAddressableNote(event.address()) - val author = getOrCreateUser(event.pubKey) - - if (version.event == null) { - version.loadEvent(event, author, emptyList()) - version.moveAllReferencesTo(note) - } - - if (note.event?.id() == event.id()) return - - if (event.createdAt > (note.createdAt() ?: 0)) { - note.loadEvent(event, author, emptyList()) - - val channel = getOrCreateChannel(note.idHex) { - LiveActivitiesChannel(note.address) - } as? LiveActivitiesChannel - - val creator = event.host()?.ifBlank { null }?.let { + if (deleteNote.event is PrivateDmEvent) { + val author = deleteNote.author + val recipient = + (deleteNote.event as? PrivateDmEvent)?.verifiedRecipientPubKey()?.let { checkGetOrCreateUser(it) - } ?: author + } - channel?.updateChannelInfo(creator, event, event.createdAt) - - refreshObservers(note) - } - } - - fun consume(event: MuteListEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) } - fun consume(event: FileServersEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) } - fun consume(event: PeopleListEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) } - private fun consume(event: AdvertisedRelayListEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) } - private fun consume(event: CommunityDefinitionEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) } - fun consume(event: EmojiPackSelectionEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) } - private fun consume(event: EmojiPackEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) } - private fun consume(event: ClassifiedsEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) } - private fun consume(event: PinListEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) } - private fun consume(event: RelaySetEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) } - private fun consume(event: AudioTrackEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) } - private fun consume(event: VideoVerticalEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) } - private fun consume(event: VideoHorizontalEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) } - - fun consume(event: StatusEvent, relay: Relay?) { - val version = getOrCreateNote(event.id) - val note = getOrCreateAddressableNote(event.address()) - val author = getOrCreateUser(event.pubKey) - - if (version.event == null) { - version.loadEvent(event, author, emptyList()) - version.moveAllReferencesTo(note) - } - - // Already processed this event. - if (note.event?.id() == event.id()) return - - if (event.createdAt > (note.createdAt() ?: 0)) { - note.loadEvent(event, author, emptyList()) - - author.liveSet?.innerStatuses?.invalidateData() - - refreshObservers(note) - } - } - - fun consume(event: BadgeDefinitionEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) } - - fun consume(event: BadgeProfilesEvent) { - val version = getOrCreateNote(event.id) - val note = getOrCreateAddressableNote(event.address()) - val author = getOrCreateUser(event.pubKey) - - if (version.event == null) { - version.loadEvent(event, author, emptyList()) - version.moveAllReferencesTo(note) - } - - // Already processed this event. - if (note.event?.id() == event.id()) return - - val replyTo = event.badgeAwardEvents().mapNotNull { checkGetOrCreateNote(it) } + - event.badgeAwardDefinitions().map { getOrCreateAddressableNote(it) } - - if (event.createdAt > (note.createdAt() ?: 0)) { - note.loadEvent(event, author, replyTo) - - refreshObservers(note) - } - } - - fun consume(event: BadgeAwardEvent) { - val note = getOrCreateNote(event.id) - - // Already processed this event. - if (note.event != null) return - - // Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") - - val author = getOrCreateUser(event.pubKey) - val awardDefinition = event.awardDefinition().map { getOrCreateAddressableNote(it) } - - note.loadEvent(event, author, awardDefinition) - - // Replies of an Badge Definition are Award Events - awardDefinition.forEach { - it.addReply(note) - } - - refreshObservers(note) - } - - private fun comsume(event: NNSEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) } - fun consume(event: AppDefinitionEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) } - private fun consume(event: CalendarEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) } - private fun consume(event: CalendarDateSlotEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) } - private fun consume(event: CalendarTimeSlotEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) } - private fun consume(event: CalendarRSVPEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) } - - private fun consumeBaseReplaceable(event: BaseAddressableEvent, relay: Relay?) { - val version = getOrCreateNote(event.id) - val note = getOrCreateAddressableNote(event.address()) - val author = getOrCreateUser(event.pubKey) - - if (version.event == null) { - version.loadEvent(event, author, emptyList()) - version.moveAllReferencesTo(note) - } - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event?.id() == event.id()) return - - if (event.createdAt > (note.createdAt() ?: 0)) { - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) - } - } - - fun consume(event: AppRecommendationEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) } - - @Suppress("UNUSED_PARAMETER") - fun consume(event: RecommendRelayEvent) { -// // Log.d("RR", event.toJson()) - } - - fun consume(event: PrivateDmEvent, relay: Relay?): Note { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return note - - val recipient = event.verifiedRecipientPubKey()?.let { getOrCreateUser(it) } - - // Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}") - - val repliesTo = event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } - - note.loadEvent(event, author, repliesTo) - - if (recipient != null) { - author.addMessage(recipient, note) - recipient.addMessage(author, note) - } - - refreshObservers(note) - - return note - } - - fun consume(event: DeletionEvent) { - var deletedAtLeastOne = false - - event.deleteEvents().mapNotNull { notes[it] }.forEach { deleteNote -> - // must be the same author - if (deleteNote.author?.pubkeyHex == event.pubKey) { - // reverts the add - val mentions = deleteNote.event?.tags()?.filter { it.firstOrNull() == "p" } - ?.mapNotNull { it.getOrNull(1) }?.mapNotNull { checkGetOrCreateUser(it) } - - mentions?.forEach { user -> - user.removeReport(deleteNote) - } - - // Counts the replies - deleteNote.replyTo?.forEach { masterNote -> - masterNote.removeReply(deleteNote) - masterNote.removeBoost(deleteNote) - masterNote.removeReaction(deleteNote) - masterNote.removeZap(deleteNote) - masterNote.removeZapPayment(deleteNote) - masterNote.removeReport(deleteNote) - } - - deleteNote.channelHex()?.let { - channels[it]?.removeNote(deleteNote) - } - - (deleteNote.event as? LiveActivitiesChatMessageEvent)?.activity()?.let { - channels[it.toTag()]?.removeNote(deleteNote) - } - - if (deleteNote.event is PrivateDmEvent) { - val author = deleteNote.author - val recipient = (deleteNote.event as? PrivateDmEvent)?.verifiedRecipientPubKey()?.let { checkGetOrCreateUser(it) } - - if (recipient != null && author != null) { - author.removeMessage(recipient, deleteNote) - recipient.removeMessage(author, deleteNote) - } - } - - notes.remove(deleteNote.idHex) - - deletedAtLeastOne = true + if (recipient != null && author != null) { + author.removeMessage(recipient, deleteNote) + recipient.removeMessage(author, deleteNote) } - } + } - if (deletedAtLeastOne) { - // refreshObservers() + notes.remove(deleteNote.idHex) + + deletedAtLeastOne = true } + } + + if (deletedAtLeastOne) { + // refreshObservers() + } + } + + fun consume(event: RepostEvent) { + val note = getOrCreateNote(event.id) + + // Already processed this event. + if (note.event != null) return + + // Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} + // ${formattedDateTime(event.createdAt)}") + + val author = getOrCreateUser(event.pubKey) + val repliesTo = + event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } + + event.taggedAddresses().map { getOrCreateAddressableNote(it) } + + note.loadEvent(event, author, repliesTo) + + // Counts the replies + repliesTo.forEach { it.addBoost(note) } + + refreshObservers(note) + } + + fun consume(event: GenericRepostEvent) { + val note = getOrCreateNote(event.id) + + // Already processed this event. + if (note.event != null) return + + // Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} + // ${formattedDateTime(event.createdAt)}") + + val author = getOrCreateUser(event.pubKey) + val repliesTo = + event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } + + event.taggedAddresses().map { getOrCreateAddressableNote(it) } + + note.loadEvent(event, author, repliesTo) + + // Counts the replies + repliesTo.forEach { it.addBoost(note) } + + refreshObservers(note) + } + + fun consume(event: CommunityPostApprovalEvent) { + val note = getOrCreateNote(event.id) + + // Already processed this event. + if (note.event != null) return + + // Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} + // ${formattedDateTime(event.createdAt)}") + + val author = getOrCreateUser(event.pubKey) + + val communities = event.communities() + val eventsApproved = event.approvedEvents().mapNotNull { checkGetOrCreateNote(it) } + + val repliesTo = communities.map { getOrCreateAddressableNote(it) } + + note.loadEvent(event, author, eventsApproved) + + // Counts the replies + repliesTo.forEach { it.addBoost(note) } + + refreshObservers(note) + } + + fun consume(event: ReactionEvent) { + val note = getOrCreateNote(event.id) + + // Already processed this event. + if (note.event != null) return + + val author = getOrCreateUser(event.pubKey) + val repliesTo = + event.originalPost().mapNotNull { checkGetOrCreateNote(it) } + + event.taggedAddresses().map { getOrCreateAddressableNote(it) } + + note.loadEvent(event, author, repliesTo) + + // Log.d("RE", "New Reaction ${event.content} (${notes.size},${users.size}) + // ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") + + repliesTo.forEach { it.addReaction(note) } + + refreshObservers(note) + } + + fun consume( + event: ReportEvent, + relay: Relay?, + ) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) } - fun consume(event: RepostEvent) { - val note = getOrCreateNote(event.id) + // Already processed this event. + if (note.event != null) return - // Already processed this event. - if (note.event != null) return + val mentions = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.key) } + val repliesTo = + event.reportedPost().mapNotNull { checkGetOrCreateNote(it.key) } + + event.taggedAddresses().map { getOrCreateAddressableNote(it) } - // Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") + note.loadEvent(event, author, repliesTo) - val author = getOrCreateUser(event.pubKey) - val repliesTo = event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } + - event.taggedAddresses().map { getOrCreateAddressableNote(it) } + // Log.d("RP", "New Report ${event.content} by ${note.author?.toBestDisplayName()} + // ${formattedDateTime(event.createdAt)}") + // Adds notifications to users. + if (repliesTo.isEmpty()) { + mentions.forEach { it.addReport(note) } + } else { + repliesTo.forEach { it.addReport(note) } - note.loadEvent(event, author, repliesTo) - - // Counts the replies - repliesTo.forEach { - it.addBoost(note) - } - - refreshObservers(note) + mentions.forEach { + // doesn't add to reports, but triggers recounts + it.liveSet?.innerReports?.invalidateData() + } } - fun consume(event: GenericRepostEvent) { - val note = getOrCreateNote(event.id) + refreshObservers(note) + } - // Already processed this event. - if (note.event != null) return + fun consume(event: ChannelCreateEvent) { + // Log.d("MT", "New Event ${event.content} ${event.id.toHex()}") + val oldChannel = getOrCreateChannel(event.id) { PublicChatChannel(it) } + val author = getOrCreateUser(event.pubKey) - // Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") + val note = getOrCreateNote(event.id) + if (note.event == null) { + oldChannel.addNote(note) + note.loadEvent(event, author, emptyList()) - val author = getOrCreateUser(event.pubKey) - val repliesTo = event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } + - event.taggedAddresses().map { getOrCreateAddressableNote(it) } - - note.loadEvent(event, author, repliesTo) - - // Counts the replies - repliesTo.forEach { - it.addBoost(note) - } - - refreshObservers(note) + refreshObservers(note) } - fun consume(event: CommunityPostApprovalEvent) { - val note = getOrCreateNote(event.id) + if (event.createdAt <= oldChannel.updatedMetadataAt) { + return // older data, does nothing + } + if (oldChannel.creator == null || oldChannel.creator == author) { + if (oldChannel is PublicChatChannel) { + oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt) + } + } + } - // Already processed this event. - if (note.event != null) return + fun consume(event: ChannelMetadataEvent) { + val channelId = event.channel() + // Log.d("MT", "New PublicChatMetadata ${event.channelInfo()}") + if (channelId.isNullOrBlank()) return - // Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") + // new event + val oldChannel = checkGetOrCreateChannel(channelId) ?: return - val author = getOrCreateUser(event.pubKey) - - val communities = event.communities() - val eventsApproved = event.approvedEvents().mapNotNull { checkGetOrCreateNote(it) } - - val repliesTo = communities.map { getOrCreateAddressableNote(it) } - - note.loadEvent(event, author, eventsApproved) - - // Counts the replies - repliesTo.forEach { - it.addBoost(note) - } - - refreshObservers(note) + val author = getOrCreateUser(event.pubKey) + if (event.createdAt > oldChannel.updatedMetadataAt) { + if (oldChannel is PublicChatChannel) { + oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt) + } + } else { + // Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} + // ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}") } - fun consume(event: ReactionEvent) { - val note = getOrCreateNote(event.id) + val note = getOrCreateNote(event.id) + if (note.event == null) { + oldChannel.addNote(note) + note.loadEvent(event, author, emptyList()) - // Already processed this event. - if (note.event != null) return + refreshObservers(note) + } + } - val author = getOrCreateUser(event.pubKey) - val repliesTo = event.originalPost().mapNotNull { checkGetOrCreateNote(it) } + - event.taggedAddresses().map { getOrCreateAddressableNote(it) } + fun consume( + event: ChannelMessageEvent, + relay: Relay?, + ) { + val channelId = event.channel() - note.loadEvent(event, author, repliesTo) + if (channelId.isNullOrBlank()) return - // Log.d("RE", "New Reaction ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") + val channel = checkGetOrCreateChannel(channelId) ?: return - repliesTo.forEach { - it.addReaction(note) - } + val note = getOrCreateNote(event.id) + channel.addNote(note) - refreshObservers(note) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) } - fun consume(event: ReportEvent, relay: Relay?) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) + // Already processed this event. + if (note.event != null) return - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - val mentions = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.key) } - val repliesTo = event.reportedPost().mapNotNull { checkGetOrCreateNote(it.key) } + - event.taggedAddresses().map { getOrCreateAddressableNote(it) } - - note.loadEvent(event, author, repliesTo) - - // Log.d("RP", "New Report ${event.content} by ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") - // Adds notifications to users. - if (repliesTo.isEmpty()) { - mentions.forEach { - it.addReport(note) - } - } else { - repliesTo.forEach { - it.addReport(note) - } - - mentions.forEach { - // doesn't add to reports, but triggers recounts - it.liveSet?.innerReports?.invalidateData() - } - } - - refreshObservers(note) + if (antiSpam.isSpam(event, relay)) { + relay?.let { it.spamCounter++ } + return } - fun consume(event: ChannelCreateEvent) { - // Log.d("MT", "New Event ${event.content} ${event.id.toHex()}") - val oldChannel = getOrCreateChannel(event.id) { - PublicChatChannel(it) - } - val author = getOrCreateUser(event.pubKey) + val replyTo = + event + .tagsWithoutCitations() + .filter { it != event.channel() } + .mapNotNull { checkGetOrCreateNote(it) } - val note = getOrCreateNote(event.id) - if (note.event == null) { - oldChannel.addNote(note) - note.loadEvent(event, author, emptyList()) + note.loadEvent(event, author, replyTo) - refreshObservers(note) - } + // Log.d("CM", "New Chat Note (${note.author?.toBestDisplayName()} ${note.event?.content()} + // ${formattedDateTime(event.createdAt)}") - if (event.createdAt <= oldChannel.updatedMetadataAt) { - return // older data, does nothing - } - if (oldChannel.creator == null || oldChannel.creator == author) { - if (oldChannel is PublicChatChannel) { - oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt) - } - } + // Counts the replies + replyTo.forEach { it.addReply(note) } + + refreshObservers(note) + } + + fun consume( + event: LiveActivitiesChatMessageEvent, + relay: Relay?, + ) { + val activityId = event.activity() ?: return + + val channel = getOrCreateChannel(activityId.toTag()) { LiveActivitiesChannel(activityId) } + + val note = getOrCreateNote(event.id) + channel.addNote(note) + + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) } - fun consume(event: ChannelMetadataEvent) { - val channelId = event.channel() - // Log.d("MT", "New PublicChatMetadata ${event.channelInfo()}") - if (channelId.isNullOrBlank()) return + // Already processed this event. + if (note.event != null) return - // new event - val oldChannel = checkGetOrCreateChannel(channelId) ?: return - - val author = getOrCreateUser(event.pubKey) - if (event.createdAt > oldChannel.updatedMetadataAt) { - if (oldChannel is PublicChatChannel) { - oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt) - } - } else { - // Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}") - } - - val note = getOrCreateNote(event.id) - if (note.event == null) { - oldChannel.addNote(note) - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) - } + if (antiSpam.isSpam(event, relay)) { + relay?.let { it.spamCounter++ } + return } - fun consume(event: ChannelMessageEvent, relay: Relay?) { - val channelId = event.channel() + val replyTo = + event + .tagsWithoutCitations() + .filter { it != event.activity()?.toTag() } + .mapNotNull { checkGetOrCreateNote(it) } - if (channelId.isNullOrBlank()) return + note.loadEvent(event, author, replyTo) - val channel = checkGetOrCreateChannel(channelId) ?: return + // Counts the replies + replyTo.forEach { it.addReply(note) } - val note = getOrCreateNote(event.id) - channel.addNote(note) + refreshObservers(note) + } - val author = getOrCreateUser(event.pubKey) + @Suppress("UNUSED_PARAMETER") fun consume(event: ChannelHideMessageEvent) {} - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } + @Suppress("UNUSED_PARAMETER") fun consume(event: ChannelMuteUserEvent) {} - // Already processed this event. - if (note.event != null) return + fun consume(event: LnZapEvent) { + val note = getOrCreateNote(event.id) + // Already processed this event. + if (note.event != null) return - if (antiSpam.isSpam(event, relay)) { - relay?.let { - it.spamCounter++ - } - return - } + val zapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) } - val replyTo = event.tagsWithoutCitations() - .filter { it != event.channel() } - .mapNotNull { checkGetOrCreateNote(it) } - - note.loadEvent(event, author, replyTo) - - // Log.d("CM", "New Chat Note (${note.author?.toBestDisplayName()} ${note.event?.content()} ${formattedDateTime(event.createdAt)}") - - // Counts the replies - replyTo.forEach { - it.addReply(note) - } - - refreshObservers(note) + if (zapRequest == null || zapRequest.event !is LnZapRequestEvent) { + Log.e("ZP", "Zap Request not found. Unable to process Zap {${event.toJson()}}") + return } - fun consume(event: LiveActivitiesChatMessageEvent, relay: Relay?) { - val activityId = event.activity() ?: return - - val channel = getOrCreateChannel(activityId.toTag()) { - LiveActivitiesChannel(activityId) + val author = getOrCreateUser(event.pubKey) + val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) } + val repliesTo = + event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } + + event.taggedAddresses().map { getOrCreateAddressableNote(it) } + + ((zapRequest.event as? LnZapRequestEvent)?.taggedAddresses()?.map { + getOrCreateAddressableNote(it) } + ?: emptySet()) - val note = getOrCreateNote(event.id) - channel.addNote(note) + note.loadEvent(event, author, repliesTo) - val author = getOrCreateUser(event.pubKey) + // Log.d("ZP", "New ZapEvent ${event.content} (${notes.size},${users.size}) + // ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } + repliesTo.forEach { it.addZap(zapRequest, note) } + mentions.forEach { it.addZap(zapRequest, note) } - // Already processed this event. - if (note.event != null) return + refreshObservers(note) + } - if (antiSpam.isSpam(event, relay)) { - relay?.let { - it.spamCounter++ - } - return - } + fun consume(event: LnZapRequestEvent) { + val note = getOrCreateNote(event.id) - val replyTo = event.tagsWithoutCitations() - .filter { it != event.activity()?.toTag() } - .mapNotNull { checkGetOrCreateNote(it) } + // Already processed this event. + if (note.event != null) return - note.loadEvent(event, author, replyTo) + val author = getOrCreateUser(event.pubKey) + val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) } + val repliesTo = + event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } + + event.taggedAddresses().map { getOrCreateAddressableNote(it) } - // Counts the replies - replyTo.forEach { - it.addReply(note) - } + note.loadEvent(event, author, repliesTo) - refreshObservers(note) + // Log.d("ZP", "New Zap Request ${event.content} (${notes.size},${users.size}) + // ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") + + repliesTo.forEach { it.addZap(note, null) } + mentions.forEach { it.addZap(note, null) } + + refreshObservers(note) + } + + fun consume( + event: AudioHeaderEvent, + relay: Relay?, + ) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) } - @Suppress("UNUSED_PARAMETER") - fun consume(event: ChannelHideMessageEvent) { + // Already processed this event. + if (note.event != null) return + + note.loadEvent(event, author, emptyList()) + + refreshObservers(note) + } + + fun consume( + event: FileHeaderEvent, + relay: Relay?, + ) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) } - @Suppress("UNUSED_PARAMETER") - fun consume(event: ChannelMuteUserEvent) { + // Already processed this event. + if (note.event != null) return + + note.loadEvent(event, author, emptyList()) + + refreshObservers(note) + } + + fun consume( + event: FileStorageHeaderEvent, + relay: Relay?, + ) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) } - fun consume(event: LnZapEvent) { - val note = getOrCreateNote(event.id) - // Already processed this event. - if (note.event != null) return + // Already processed this event. + if (note.event != null) return - val zapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) } + note.loadEvent(event, author, emptyList()) - if (zapRequest == null || zapRequest.event !is LnZapRequestEvent) { - Log.e("ZP", "Zap Request not found. Unable to process Zap {${event.toJson()}}") - return - } + refreshObservers(note) + } - val author = getOrCreateUser(event.pubKey) - val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) } - val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } + - event.taggedAddresses().map { getOrCreateAddressableNote(it) } + - ( - (zapRequest.event as? LnZapRequestEvent)?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptySet() - ) + fun consume( + event: HighlightEvent, + relay: Relay?, + ) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) - note.loadEvent(event, author, repliesTo) - - // Log.d("ZP", "New ZapEvent ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") - - repliesTo.forEach { - it.addZap(zapRequest, note) - } - mentions.forEach { - it.addZap(zapRequest, note) - } - - refreshObservers(note) + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) } - fun consume(event: LnZapRequestEvent) { - val note = getOrCreateNote(event.id) + // Already processed this event. + if (note.event != null) return - // Already processed this event. - if (note.event != null) return + note.loadEvent(event, author, emptyList()) - val author = getOrCreateUser(event.pubKey) - val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) } - val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } + - event.taggedAddresses().map { getOrCreateAddressableNote(it) } + refreshObservers(note) + } - note.loadEvent(event, author, repliesTo) + fun consume( + event: FileStorageEvent, + relay: Relay?, + ) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) - // Log.d("ZP", "New Zap Request ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") - - repliesTo.forEach { - it.addZap(note, null) - } - mentions.forEach { - it.addZap(note, null) - } - - refreshObservers(note) + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) } - fun consume(event: AudioHeaderEvent, relay: Relay?) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } - - // Already processed this event. - if (note.event != null) return - - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) + try { + val cachePath = File(Amethyst.instance.applicationContext.cacheDir, "NIP95") + cachePath.mkdirs() + val file = File(cachePath, event.id) + if (!file.exists()) { + val stream = FileOutputStream(file) + stream.write(event.decode()) + stream.close() + Log.i( + "FileStorageEvent", + "NIP95 File received from ${relay?.url} and saved to disk as $file", + ) + } + } catch (e: IOException) { + Log.e("FileStorageEvent", "FileStorageEvent save to disk error: " + event.id, e) } - fun consume(event: FileHeaderEvent, relay: Relay?) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) + // Already processed this event. + if (note.event != null) return - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } + // this is an invalid event. But we don't need to keep the data in memory. + val eventNoData = + FileStorageEvent(event.id, event.pubKey, event.createdAt, event.tags, "", event.sig) - // Already processed this event. - if (note.event != null) return + note.loadEvent(eventNoData, author, emptyList()) - note.loadEvent(event, author, emptyList()) + refreshObservers(note) + } - refreshObservers(note) + private fun consume( + event: ChatMessageEvent, + relay: Relay?, + ) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) } - fun consume(event: FileStorageHeaderEvent, relay: Relay?) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) + // Already processed this event. + if (note.event != null) return - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } + val recipientsHex = event.recipientsPubKey().plus(event.pubKey).toSet() + val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet() - // Already processed this event. - if (note.event != null) return + // Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}") - note.loadEvent(event, author, emptyList()) + val repliesTo = event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } - refreshObservers(note) + note.loadEvent(event, author, repliesTo) + + if (recipients.isNotEmpty()) { + recipients.forEach { + val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex) + + val authorGroup = + if (groupMinusRecipient.isEmpty()) { + // note to self + ChatroomKey(persistentSetOf(it.pubkeyHex)) + } else { + ChatroomKey(groupMinusRecipient.toImmutableSet()) + } + + it.addMessage(authorGroup, note) + } } - fun consume(event: HighlightEvent, relay: Relay?) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) + refreshObservers(note) + } - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } + private fun consume( + event: SealedGossipEvent, + relay: Relay?, + ) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) - // Already processed this event. - if (note.event != null) return - - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) } - fun consume(event: FileStorageEvent, relay: Relay?) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) + // Already processed this event. + if (note.event != null) return - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } + note.loadEvent(event, author, emptyList()) - try { - val cachePath = File(Amethyst.instance.applicationContext.cacheDir, "NIP95") - cachePath.mkdirs() - val file = File(cachePath, event.id) - if (!file.exists()) { - val stream = FileOutputStream(file) - stream.write(event.decode()) - stream.close() - Log.i("FileStorageEvent", "NIP95 File received from ${relay?.url} and saved to disk as $file") - } - } catch (e: IOException) { - Log.e("FileStorageEvent", "FileStorageEvent save to disk error: " + event.id, e) - } + refreshObservers(note) + } - // Already processed this event. - if (note.event != null) return + fun consume( + event: GiftWrapEvent, + relay: Relay?, + ) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) - // this is an invalid event. But we don't need to keep the data in memory. - val eventNoData = FileStorageEvent(event.id, event.pubKey, event.createdAt, event.tags, "", event.sig) - - note.loadEvent(eventNoData, author, emptyList()) - - refreshObservers(note) + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) } - private fun consume(event: ChatMessageEvent, relay: Relay?) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) + // Already processed this event. + if (note.event != null) return - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } + note.loadEvent(event, author, emptyList()) - // Already processed this event. - if (note.event != null) return + refreshObservers(note) + } - val recipientsHex = event.recipientsPubKey().plus(event.pubKey).toSet() - val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet() + fun consume(event: LnZapPaymentRequestEvent) { + // Does nothing without a response callback. + } - // Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}") + fun consume( + event: LnZapPaymentRequestEvent, + zappedNote: Note?, + onResponse: (LnZapPaymentResponseEvent) -> Unit, + ) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) - val repliesTo = event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } + // Already processed this event. + if (note.event != null) return - note.loadEvent(event, author, repliesTo) + note.loadEvent(event, author, emptyList()) - if (recipients.isNotEmpty()) { - recipients.forEach { - val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex) + zappedNote?.addZapPayment(note, null) - val authorGroup = if (groupMinusRecipient.isEmpty()) { - // note to self - ChatroomKey(persistentSetOf(it.pubkeyHex)) - } else { - ChatroomKey(groupMinusRecipient.toImmutableSet()) - } + awaitingPaymentRequests.put(event.id, Pair(zappedNote, onResponse)) - it.addMessage(authorGroup, note) - } - } + refreshObservers(note) + } - refreshObservers(note) + fun consume(event: LnZapPaymentResponseEvent) { + val requestId = event.requestId() + val pair = awaitingPaymentRequests[requestId] ?: return + + val (zappedNote, responseCallback) = pair + + val requestNote = requestId?.let { checkGetOrCreateNote(requestId) } + + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + // Already processed this event. + if (note.event != null) return + + note.loadEvent(event, author, emptyList()) + + requestNote?.let { request -> zappedNote?.addZapPayment(request, note) } + + if (responseCallback != null) { + responseCallback(event) + } + } + + fun findUsersStartingWith(username: String): List { + checkNotInMainThread() + + val key = decodePublicKeyAsHexOrNull(username) + + if (key != null && users[key] != null) { + return listOfNotNull(users[key]) } - private fun consume(event: SealedGossipEvent, relay: Relay?) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) + return users.values.filter { + (it.anyNameStartsWith(username)) || + it.pubkeyHex.startsWith(username, true) || + it.pubkeyNpub().startsWith(username, true) + } + } - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } + fun findNotesStartingWith(text: String): List { + checkNotInMainThread() - // Already processed this event. - if (note.event != null) return + val key = + try { + Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey() + } catch (e: Exception) { + null + } - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) + if (key != null && (notes[key] ?: addressables[key]) != null) { + return listOfNotNull(notes[key] ?: addressables[key]) } - fun consume(event: GiftWrapEvent, relay: Relay?) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) + return notes.values.filter { + (it.event !is GenericRepostEvent && + it.event !is RepostEvent && + it.event !is CommunityPostApprovalEvent && + it.event !is ReactionEvent && + it.event !is GiftWrapEvent && + it.event !is LnZapEvent && + it.event !is LnZapRequestEvent) && + (it.event?.content()?.contains(text, true) + ?: false || + it.event?.matchTag1With(text) ?: false || + it.idHex.startsWith(text, true) || + it.idNote().startsWith(text, true)) + } + + addressables.values.filter { + (it.event !is GenericRepostEvent && + it.event !is RepostEvent && + it.event !is CommunityPostApprovalEvent && + it.event !is ReactionEvent && + it.event !is GiftWrapEvent && + it.event !is LnZapEvent && + it.event !is LnZapRequestEvent) && + (it.event?.content()?.contains(text, true) + ?: false || it.event?.matchTag1With(text) ?: false || it.idHex.startsWith(text, true)) + } + } - if (relay != null) { - author.addRelayBeingUsed(relay, event.createdAt) - note.addRelay(relay) - } + fun findChannelsStartingWith(text: String): List { + checkNotInMainThread() - // Already processed this event. - if (note.event != null) return + val key = + try { + Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey() + } catch (e: Exception) { + null + } - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) + if (key != null && channels[key] != null) { + return listOfNotNull(channels[key]) } - fun consume(event: LnZapPaymentRequestEvent) { - // Does nothing without a response callback. + return channels.values.filter { + it.anyNameStartsWith(text) || + it.idHex.startsWith(text, true) || + it.idNote().startsWith(text, true) + } + } + + suspend fun findStatusesForUser(user: User): ImmutableList { + checkNotInMainThread() + + return addressables + .filter { + val noteEvent = it.value.event + (noteEvent is StatusEvent && + noteEvent.pubKey == user.pubkeyHex && + !noteEvent.isExpired() && + noteEvent.content.isNotBlank()) + } + .values + .sortedWith(compareBy({ it.event?.expiration() ?: it.event?.createdAt() }, { it.idHex })) + .reversed() + .toImmutableList() + } + + fun cleanObservers() { + notes.forEach { it.value.clearLive() } + + addressables.forEach { it.value.clearLive() } + + users.forEach { it.value.clearLive() } + } + + fun pruneOldAndHiddenMessages(account: Account) { + checkNotInMainThread() + + channels.forEach { it -> + val toBeRemoved = it.value.pruneOldAndHiddenMessages(account) + + val childrenToBeRemoved = mutableListOf() + + toBeRemoved.forEach { + removeFromCache(it) + + childrenToBeRemoved.addAll(it.removeAllChildNotes()) + } + + removeFromCache(childrenToBeRemoved) + + if (toBeRemoved.size > 100 || it.value.notes.size > 100) { + println( + "PRUNE: ${toBeRemoved.size} messages removed from ${it.value.toBestDisplayName()}. ${it.value.notes.size} kept", + ) + } } - fun consume(event: LnZapPaymentRequestEvent, zappedNote: Note?, onResponse: (LnZapPaymentResponseEvent) -> Unit) { - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - // Already processed this event. - if (note.event != null) return - - note.loadEvent(event, author, emptyList()) - - zappedNote?.addZapPayment(note, null) - - awaitingPaymentRequests.put(event.id, Pair(zappedNote, onResponse)) - - refreshObservers(note) - } - - fun consume(event: LnZapPaymentResponseEvent) { - val requestId = event.requestId() - val pair = awaitingPaymentRequests[requestId] ?: return - - val (zappedNote, responseCallback) = pair - - val requestNote = requestId?.let { checkGetOrCreateNote(requestId) } - - val note = getOrCreateNote(event.id) - val author = getOrCreateUser(event.pubKey) - - // Already processed this event. - if (note.event != null) return - - note.loadEvent(event, author, emptyList()) - - requestNote?.let { request -> - zappedNote?.addZapPayment(request, note) - } - - if (responseCallback != null) { - responseCallback(event) - } - } - - fun findUsersStartingWith(username: String): List { - checkNotInMainThread() - - val key = decodePublicKeyAsHexOrNull(username) - - if (key != null && users[key] != null) { - return listOfNotNull(users[key]) - } - - return users.values.filter { - (it.anyNameStartsWith(username)) || - it.pubkeyHex.startsWith(username, true) || - it.pubkeyNpub().startsWith(username, true) - } - } - - fun findNotesStartingWith(text: String): List { - checkNotInMainThread() - - val key = try { - Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey() - } catch (e: Exception) { - null - } - - if (key != null && (notes[key] ?: addressables[key]) != null) { - return listOfNotNull(notes[key] ?: addressables[key]) - } - - return notes.values.filter { - (it.event !is GenericRepostEvent && it.event !is RepostEvent && it.event !is CommunityPostApprovalEvent && it.event !is ReactionEvent && it.event !is GiftWrapEvent && it.event !is LnZapEvent && it.event !is LnZapRequestEvent) && - ( - it.event?.content()?.contains(text, true) ?: false || - it.event?.matchTag1With(text) ?: false || - it.idHex.startsWith(text, true) || - it.idNote().startsWith(text, true) - ) - } + addressables.values.filter { - (it.event !is GenericRepostEvent && it.event !is RepostEvent && it.event !is CommunityPostApprovalEvent && it.event !is ReactionEvent && it.event !is GiftWrapEvent && it.event !is LnZapEvent && it.event !is LnZapRequestEvent) && - ( - it.event?.content()?.contains(text, true) ?: false || - it.event?.matchTag1With(text) ?: false || - it.idHex.startsWith(text, true) - ) - } - } - - fun findChannelsStartingWith(text: String): List { - checkNotInMainThread() - - val key = try { - Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey() - } catch (e: Exception) { - null - } - - if (key != null && channels[key] != null) { - return listOfNotNull(channels[key]) - } - - return channels.values.filter { - it.anyNameStartsWith(text) || - it.idHex.startsWith(text, true) || - it.idNote().startsWith(text, true) - } - } - - suspend fun findStatusesForUser(user: User): ImmutableList { - checkNotInMainThread() - - return addressables.filter { - val noteEvent = it.value.event - (noteEvent is StatusEvent && noteEvent.pubKey == user.pubkeyHex && !noteEvent.isExpired() && noteEvent.content.isNotBlank()) - }.values - .sortedWith(compareBy({ it.event?.expiration() ?: it.event?.createdAt() }, { it.idHex })) - .reversed() - .toImmutableList() - } - - fun cleanObservers() { - notes.forEach { - it.value.clearLive() - } - - addressables.forEach { - it.value.clearLive() - } - - users.forEach { - it.value.clearLive() - } - } - - fun pruneOldAndHiddenMessages(account: Account) { - checkNotInMainThread() - - channels.forEach { it -> - val toBeRemoved = it.value.pruneOldAndHiddenMessages(account) - - val childrenToBeRemoved = mutableListOf() - - toBeRemoved.forEach { - removeFromCache(it) - - childrenToBeRemoved.addAll(it.removeAllChildNotes()) - } - - removeFromCache(childrenToBeRemoved) - - if (toBeRemoved.size > 100 || it.value.notes.size > 100) { - println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.toBestDisplayName()}. ${it.value.notes.size} kept") - } - } - - users.forEach { userPair -> - userPair.value.privateChatrooms.values.map { - val toBeRemoved = it.pruneMessagesToTheLatestOnly() - - val childrenToBeRemoved = mutableListOf() - - toBeRemoved.forEach { - removeFromCache(it) - - childrenToBeRemoved.addAll(it.removeAllChildNotes()) - } - - removeFromCache(childrenToBeRemoved) - - if (toBeRemoved.size > 1) { - println("PRUNE: ${toBeRemoved.size} private messages with ${userPair.value.toBestDisplayName()} removed. ${it.roomMessages.size} kept") - } - } - } - } - - fun prunePastVersionsOfReplaceables() { - val toBeRemoved = notes.filter { - val noteEvent = it.value.event - if (noteEvent is AddressableEvent) { - noteEvent.createdAt() < (addressables[noteEvent.address().toTag()]?.event?.createdAt() ?: 0) - } else { - false - } - }.values + users.forEach { userPair -> + userPair.value.privateChatrooms.values.map { + val toBeRemoved = it.pruneMessagesToTheLatestOnly() val childrenToBeRemoved = mutableListOf() toBeRemoved.forEach { - val newerVersion = addressables[(it.event as? AddressableEvent)?.address()?.toTag()] - if (newerVersion != null) { - it.moveAllReferencesTo(newerVersion) - } + removeFromCache(it) - removeFromCache(it) - childrenToBeRemoved.addAll(it.removeAllChildNotes()) + childrenToBeRemoved.addAll(it.removeAllChildNotes()) } removeFromCache(childrenToBeRemoved) if (toBeRemoved.size > 1) { - println("PRUNE: ${toBeRemoved.size} old version of addressables removed.") + println( + "PRUNE: ${toBeRemoved.size} private messages with ${userPair.value.toBestDisplayName()} removed. ${it.roomMessages.size} kept", + ) } + } } + } - fun pruneRepliesAndReactions(accounts: Set) { - checkNotInMainThread() - - val toBeRemoved = notes.filter { - ( - (it.value.event is TextNoteEvent && !it.value.isNewThread()) || - it.value.event is ReactionEvent || it.value.event is LnZapEvent || it.value.event is LnZapRequestEvent || - it.value.event is ReportEvent || it.value.event is GenericRepostEvent - ) && - it.value.replyTo?.any { it.liveSet?.isInUse() == true } != true && - it.value.liveSet?.isInUse() != true && // don't delete if observing. - it.value.author?.pubkeyHex !in accounts && // don't delete if it is the logged in account - it.value.event?.isTaggedUsers(accounts) != true // don't delete if it's a notification to the logged in user - }.values - - val childrenToBeRemoved = mutableListOf() - - toBeRemoved.forEach { - removeFromCache(it) - childrenToBeRemoved.addAll(it.removeAllChildNotes()) - } - - removeFromCache(childrenToBeRemoved) - - toBeRemoved.forEach { - it.replyTo?.forEach { masterNote -> - masterNote.clearEOSE() // allows reloading of these events - } - } - - if (toBeRemoved.size > 1) { - println("PRUNE: ${toBeRemoved.size} thread replies removed.") - } - } - - private fun removeFromCache(note: Note) { - note.replyTo?.forEach { masterNote -> - masterNote.removeReply(note) - masterNote.removeBoost(note) - masterNote.removeReaction(note) - masterNote.removeZap(note) - masterNote.removeReport(note) - masterNote.clearEOSE() // allows reloading of these events if needed - } - - val noteEvent = note.event - - if (noteEvent is LnZapEvent) { - noteEvent.zappedAuthor().forEach { - val author = getUserIfExists(it) - author?.removeZap(note) - author?.clearEOSE() - } - } - if (noteEvent is LnZapRequestEvent) { - noteEvent.zappedAuthor().mapNotNull { - val author = getUserIfExists(it) - author?.removeZap(note) - author?.clearEOSE() - } - } - if (noteEvent is ReportEvent) { - noteEvent.reportedAuthor().mapNotNull { - val author = getUserIfExists(it.key) - author?.removeReport(note) - author?.clearEOSE() - } - } - - notes.remove(note.idHex) - } - - fun removeFromCache(nextToBeRemoved: List) { - nextToBeRemoved.forEach { note -> - removeFromCache(note) - } - } - - fun pruneExpiredEvents() { - checkNotInMainThread() - - val toBeRemoved = notes.filter { - it.value.event?.isExpired() == true - }.values - - val childrenToBeRemoved = mutableListOf() - - toBeRemoved.forEach { - removeFromCache(it) - childrenToBeRemoved.addAll(it.removeAllChildNotes()) - } - - removeFromCache(childrenToBeRemoved) - - if (toBeRemoved.size > 1) { - println("PRUNE: ${toBeRemoved.size} thread replies removed.") - } - } - - fun pruneHiddenMessages(account: Account) { - checkNotInMainThread() - - val childrenToBeRemoved = mutableListOf() - - val toBeRemoved = account.liveHiddenUsers.value?.hiddenUsers?.map { userHex -> - ( - notes.values.filter { - it.event?.pubKey() == userHex - } + addressables.values.filter { - it.event?.pubKey() == userHex - } - ).toSet() - }?.flatten() ?: emptyList() - - toBeRemoved.forEach { - removeFromCache(it) - childrenToBeRemoved.addAll(it.removeAllChildNotes()) - } - - removeFromCache(childrenToBeRemoved) - - println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden") - } - - fun pruneContactLists(loggedIn: Set) { - checkNotInMainThread() - - var removingContactList = 0 - users.values.forEach { - if (it.pubkeyHex !in loggedIn && - (it.liveSet == null || it.liveSet?.isInUse() == false) && - it.latestContactList != null - ) { - it.latestContactList = null - removingContactList++ - } - } - - println("PRUNE: $removingContactList contact lists") - } - - // Observers line up here. - val live: LocalCacheLiveData = LocalCacheLiveData() - - private fun refreshObservers(newNote: Note) { - live.invalidateData(newNote) - } - - fun verifyAndConsume(event: Event, relay: Relay?) { - if (justVerify(event)) { - justConsume(event, relay) - } - } - - fun justVerify(event: Event): Boolean { - checkNotInMainThread() - - return if (!event.hasValidSignature()) { - try { - event.checkSignature() - } catch (e: Exception) { - Log.w("Event failed retest ${event.kind}", (e.message ?: "") + event.toJson()) - } + fun prunePastVersionsOfReplaceables() { + val toBeRemoved = + notes + .filter { + val noteEvent = it.value.event + if (noteEvent is AddressableEvent) { + noteEvent.createdAt() < + (addressables[noteEvent.address().toTag()]?.event?.createdAt() ?: 0) + } else { false - } else { - true + } } + .values + + val childrenToBeRemoved = mutableListOf() + + toBeRemoved.forEach { + val newerVersion = addressables[(it.event as? AddressableEvent)?.address()?.toTag()] + if (newerVersion != null) { + it.moveAllReferencesTo(newerVersion) + } + + removeFromCache(it) + childrenToBeRemoved.addAll(it.removeAllChildNotes()) } - fun justConsume(event: Event, relay: Relay?) { - checkNotInMainThread() + removeFromCache(childrenToBeRemoved) - try { - when (event) { - is AdvertisedRelayListEvent -> consume(event, relay) - is AppDefinitionEvent -> consume(event, relay) - is AppRecommendationEvent -> consume(event, relay) - is AudioHeaderEvent -> consume(event, relay) - is AudioTrackEvent -> consume(event, relay) - is BadgeAwardEvent -> consume(event) - is BadgeDefinitionEvent -> consume(event, relay) - is BadgeProfilesEvent -> consume(event) - is BookmarkListEvent -> consume(event) - is CalendarEvent -> consume(event, relay) - is CalendarDateSlotEvent -> consume(event, relay) - is CalendarTimeSlotEvent -> consume(event, relay) - is CalendarRSVPEvent -> consume(event, relay) - is ChannelCreateEvent -> consume(event) - is ChannelHideMessageEvent -> consume(event) - is ChannelMessageEvent -> consume(event, relay) - is ChannelMetadataEvent -> consume(event) - is ChannelMuteUserEvent -> consume(event) - is ChatMessageEvent -> consume(event, relay) - is ClassifiedsEvent -> consume(event, relay) - is CommunityDefinitionEvent -> consume(event, relay) - is CommunityPostApprovalEvent -> { - event.containedPost()?.let { - verifyAndConsume(it, relay) - } - consume(event) - } - is ContactListEvent -> consume(event) - is DeletionEvent -> consume(event) - is EmojiPackEvent -> consume(event, relay) - is EmojiPackSelectionEvent -> consume(event, relay) - is SealedGossipEvent -> consume(event, relay) - - is FileHeaderEvent -> consume(event, relay) - is FileServersEvent -> consume(event, relay) - is FileStorageEvent -> consume(event, relay) - is FileStorageHeaderEvent -> consume(event, relay) - is GiftWrapEvent -> consume(event, relay) - is HighlightEvent -> consume(event, relay) - is LiveActivitiesEvent -> consume(event, relay) - is LiveActivitiesChatMessageEvent -> consume(event, relay) - is LnZapEvent -> { - event.zapRequest?.let { - // must have a valid request - verifyAndConsume(it, relay) - consume(event) - } - } - is LnZapRequestEvent -> consume(event) - is LnZapPaymentRequestEvent -> consume(event) - is LnZapPaymentResponseEvent -> consume(event) - is LongTextNoteEvent -> consume(event, relay) - is MetadataEvent -> consume(event) - is MuteListEvent -> consume(event, relay) - is NNSEvent -> comsume(event, relay) - is PrivateDmEvent -> consume(event, relay) - is PinListEvent -> consume(event, relay) - is PeopleListEvent -> consume(event, relay) - is PollNoteEvent -> consume(event, relay) - is ReactionEvent -> consume(event) - is RecommendRelayEvent -> consume(event) - is RelaySetEvent -> consume(event, relay) - is ReportEvent -> consume(event, relay) - is RepostEvent -> { - event.containedPost()?.let { - verifyAndConsume(it, relay) - } - consume(event) - } - is GenericRepostEvent -> { - event.containedPost()?.let { - verifyAndConsume(it, relay) - } - consume(event) - } - is StatusEvent -> consume(event, relay) - is TextNoteEvent -> consume(event, relay) - is VideoHorizontalEvent -> consume(event, relay) - is VideoVerticalEvent -> consume(event, relay) - else -> { - Log.w("Event Not Supported", event.toJson()) - } - } - } catch (e: Exception) { - e.printStackTrace() - } + if (toBeRemoved.size > 1) { + println("PRUNE: ${toBeRemoved.size} old version of addressables removed.") } + } + + fun pruneRepliesAndReactions(accounts: Set) { + checkNotInMainThread() + + val toBeRemoved = + notes + .filter { + ((it.value.event is TextNoteEvent && !it.value.isNewThread()) || + it.value.event is ReactionEvent || + it.value.event is LnZapEvent || + it.value.event is LnZapRequestEvent || + it.value.event is ReportEvent || + it.value.event is GenericRepostEvent) && + it.value.replyTo?.any { it.liveSet?.isInUse() == true } != true && + it.value.liveSet?.isInUse() != true && // don't delete if observing. + it.value.author?.pubkeyHex !in + accounts && // don't delete if it is the logged in account + it.value.event?.isTaggedUsers(accounts) != + true // don't delete if it's a notification to the logged in user + } + .values + + val childrenToBeRemoved = mutableListOf() + + toBeRemoved.forEach { + removeFromCache(it) + childrenToBeRemoved.addAll(it.removeAllChildNotes()) + } + + removeFromCache(childrenToBeRemoved) + + toBeRemoved.forEach { + it.replyTo?.forEach { masterNote -> + masterNote.clearEOSE() // allows reloading of these events + } + } + + if (toBeRemoved.size > 1) { + println("PRUNE: ${toBeRemoved.size} thread replies removed.") + } + } + + private fun removeFromCache(note: Note) { + note.replyTo?.forEach { masterNote -> + masterNote.removeReply(note) + masterNote.removeBoost(note) + masterNote.removeReaction(note) + masterNote.removeZap(note) + masterNote.removeReport(note) + masterNote.clearEOSE() // allows reloading of these events if needed + } + + val noteEvent = note.event + + if (noteEvent is LnZapEvent) { + noteEvent.zappedAuthor().forEach { + val author = getUserIfExists(it) + author?.removeZap(note) + author?.clearEOSE() + } + } + if (noteEvent is LnZapRequestEvent) { + noteEvent.zappedAuthor().mapNotNull { + val author = getUserIfExists(it) + author?.removeZap(note) + author?.clearEOSE() + } + } + if (noteEvent is ReportEvent) { + noteEvent.reportedAuthor().mapNotNull { + val author = getUserIfExists(it.key) + author?.removeReport(note) + author?.clearEOSE() + } + } + + notes.remove(note.idHex) + } + + fun removeFromCache(nextToBeRemoved: List) { + nextToBeRemoved.forEach { note -> removeFromCache(note) } + } + + fun pruneExpiredEvents() { + checkNotInMainThread() + + val toBeRemoved = notes.filter { it.value.event?.isExpired() == true }.values + + val childrenToBeRemoved = mutableListOf() + + toBeRemoved.forEach { + removeFromCache(it) + childrenToBeRemoved.addAll(it.removeAllChildNotes()) + } + + removeFromCache(childrenToBeRemoved) + + if (toBeRemoved.size > 1) { + println("PRUNE: ${toBeRemoved.size} thread replies removed.") + } + } + + fun pruneHiddenMessages(account: Account) { + checkNotInMainThread() + + val childrenToBeRemoved = mutableListOf() + + val toBeRemoved = + account.liveHiddenUsers.value + ?.hiddenUsers + ?.map { userHex -> + (notes.values.filter { it.event?.pubKey() == userHex } + + addressables.values.filter { it.event?.pubKey() == userHex }) + .toSet() + } + ?.flatten() + ?: emptyList() + + toBeRemoved.forEach { + removeFromCache(it) + childrenToBeRemoved.addAll(it.removeAllChildNotes()) + } + + removeFromCache(childrenToBeRemoved) + + println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden") + } + + fun pruneContactLists(loggedIn: Set) { + checkNotInMainThread() + + var removingContactList = 0 + users.values.forEach { + if ( + it.pubkeyHex !in loggedIn && + (it.liveSet == null || it.liveSet?.isInUse() == false) && + it.latestContactList != null + ) { + it.latestContactList = null + removingContactList++ + } + } + + println("PRUNE: $removingContactList contact lists") + } + + // Observers line up here. + val live: LocalCacheLiveData = LocalCacheLiveData() + + private fun refreshObservers(newNote: Note) { + live.invalidateData(newNote) + } + + fun verifyAndConsume( + event: Event, + relay: Relay?, + ) { + if (justVerify(event)) { + justConsume(event, relay) + } + } + + fun justVerify(event: Event): Boolean { + checkNotInMainThread() + + return if (!event.hasValidSignature()) { + try { + event.checkSignature() + } catch (e: Exception) { + Log.w("Event failed retest ${event.kind}", (e.message ?: "") + event.toJson()) + } + false + } else { + true + } + } + + fun justConsume( + event: Event, + relay: Relay?, + ) { + checkNotInMainThread() + + try { + when (event) { + is AdvertisedRelayListEvent -> consume(event, relay) + is AppDefinitionEvent -> consume(event, relay) + is AppRecommendationEvent -> consume(event, relay) + is AudioHeaderEvent -> consume(event, relay) + is AudioTrackEvent -> consume(event, relay) + is BadgeAwardEvent -> consume(event) + is BadgeDefinitionEvent -> consume(event, relay) + is BadgeProfilesEvent -> consume(event) + is BookmarkListEvent -> consume(event) + is CalendarEvent -> consume(event, relay) + is CalendarDateSlotEvent -> consume(event, relay) + is CalendarTimeSlotEvent -> consume(event, relay) + is CalendarRSVPEvent -> consume(event, relay) + is ChannelCreateEvent -> consume(event) + is ChannelHideMessageEvent -> consume(event) + is ChannelMessageEvent -> consume(event, relay) + is ChannelMetadataEvent -> consume(event) + is ChannelMuteUserEvent -> consume(event) + is ChatMessageEvent -> consume(event, relay) + is ClassifiedsEvent -> consume(event, relay) + is CommunityDefinitionEvent -> consume(event, relay) + is CommunityPostApprovalEvent -> { + event.containedPost()?.let { verifyAndConsume(it, relay) } + consume(event) + } + is ContactListEvent -> consume(event) + is DeletionEvent -> consume(event) + is EmojiPackEvent -> consume(event, relay) + is EmojiPackSelectionEvent -> consume(event, relay) + is SealedGossipEvent -> consume(event, relay) + is FileHeaderEvent -> consume(event, relay) + is FileServersEvent -> consume(event, relay) + is FileStorageEvent -> consume(event, relay) + is FileStorageHeaderEvent -> consume(event, relay) + is GiftWrapEvent -> consume(event, relay) + is HighlightEvent -> consume(event, relay) + is LiveActivitiesEvent -> consume(event, relay) + is LiveActivitiesChatMessageEvent -> consume(event, relay) + is LnZapEvent -> { + event.zapRequest?.let { + // must have a valid request + verifyAndConsume(it, relay) + consume(event) + } + } + is LnZapRequestEvent -> consume(event) + is LnZapPaymentRequestEvent -> consume(event) + is LnZapPaymentResponseEvent -> consume(event) + is LongTextNoteEvent -> consume(event, relay) + is MetadataEvent -> consume(event) + is MuteListEvent -> consume(event, relay) + is NNSEvent -> comsume(event, relay) + is PrivateDmEvent -> consume(event, relay) + is PinListEvent -> consume(event, relay) + is PeopleListEvent -> consume(event, relay) + is PollNoteEvent -> consume(event, relay) + is ReactionEvent -> consume(event) + is RecommendRelayEvent -> consume(event) + is RelaySetEvent -> consume(event, relay) + is ReportEvent -> consume(event, relay) + is RepostEvent -> { + event.containedPost()?.let { verifyAndConsume(it, relay) } + consume(event) + } + is GenericRepostEvent -> { + event.containedPost()?.let { verifyAndConsume(it, relay) } + consume(event) + } + is StatusEvent -> consume(event, relay) + is TextNoteEvent -> consume(event, relay) + is VideoHorizontalEvent -> consume(event, relay) + is VideoVerticalEvent -> consume(event, relay) + else -> { + Log.w("Event Not Supported", event.toJson()) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } } @Stable class LocalCacheLiveData { - private val _newEventBundles = MutableSharedFlow>(0, 10, BufferOverflow.DROP_OLDEST) - val newEventBundles = _newEventBundles.asSharedFlow() // read-only public view + private val _newEventBundles = MutableSharedFlow>(0, 10, BufferOverflow.DROP_OLDEST) + val newEventBundles = _newEventBundles.asSharedFlow() // read-only public view - // Refreshes observers in batches. - private val bundler = BundledInsert(1000, Dispatchers.IO) + // Refreshes observers in batches. + private val bundler = BundledInsert(1000, Dispatchers.IO) - fun invalidateData(newNote: Note) { - bundler.invalidateList(newNote) { bundledNewNotes -> - _newEventBundles.emit(bundledNewNotes) - } - } + fun invalidateData(newNote: Note) { + bundler.invalidateList(newNote) { bundledNewNotes -> _newEventBundles.emit(bundledNewNotes) } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Nip47URI.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Nip47URI.kt index 887e4df00..e76c2fffb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Nip47URI.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Nip47URI.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.model import com.vitorpamplona.quartz.encoders.HexKey diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index 5caec0bab..af650623b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.model import android.util.LruCache @@ -43,947 +63,1014 @@ import com.vitorpamplona.quartz.events.WrappedEvent import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.containsAny -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow import java.math.BigDecimal import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow @Stable class AddressableNote(val address: ATag) : Note(address.toTag()) { - override fun idNote() = address.toNAddr() - override fun toNEvent() = address.toNAddr() - override fun idDisplayNote() = idNote().toShortenHex() - override fun address() = address - override fun createdAt(): Long? { - if (event == null) return null + override fun idNote() = address.toNAddr() - val publishedAt = (event as? LongTextNoteEvent)?.publishedAt() ?: Long.MAX_VALUE - val lastCreatedAt = event?.createdAt() ?: Long.MAX_VALUE + override fun toNEvent() = address.toNAddr() - return minOf(publishedAt, lastCreatedAt) - } + override fun idDisplayNote() = idNote().toShortenHex() - fun dTag(): String? { - return (event as? AddressableEvent)?.dTag() - } + override fun address() = address + + override fun createdAt(): Long? { + if (event == null) return null + + val publishedAt = (event as? LongTextNoteEvent)?.publishedAt() ?: Long.MAX_VALUE + val lastCreatedAt = event?.createdAt() ?: Long.MAX_VALUE + + return minOf(publishedAt, lastCreatedAt) + } + + fun dTag(): String? { + return (event as? AddressableEvent)?.dTag() + } } @Stable open class Note(val idHex: String) { - // These fields are only available after the Text Note event is received. - // They are immutable after that. - var event: EventInterface? = null - var author: User? = null - var replyTo: List? = null + // These fields are only available after the Text Note event is received. + // They are immutable after that. + var event: EventInterface? = null + var author: User? = null + var replyTo: List? = null - // These fields are updated every time an event related to this note is received. - var replies = listOf() - private set - var reactions = mapOf>() - private set - var boosts = listOf() - private set - var reports = mapOf>() - private set - var zaps = mapOf() - private set - var zapsAmount: BigDecimal = BigDecimal.ZERO + // These fields are updated every time an event related to this note is received. + var replies = listOf() + private set - var zapPayments = mapOf() - private set + var reactions = mapOf>() + private set - var relays = listOf() - private set + var boosts = listOf() + private set - var lastReactionsDownloadTime: Map = emptyMap() + var reports = mapOf>() + private set - fun id() = Hex.decode(idHex) - open fun idNote() = id().toNote() + var zaps = mapOf() + private set - open fun toNEvent(): String { - val myEvent = event - return if (myEvent is WrappedEvent) { - val host = myEvent.host - if (host != null) { - Nip19.createNEvent( - host.id, - host.pubKey, - host.kind(), - relays.firstOrNull()?.url - ) - } else { - Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url) - } - } else { - Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url) - } - } + var zapsAmount: BigDecimal = BigDecimal.ZERO - fun toNostrUri(): String { - return "nostr:${toNEvent()}" - } + var zapPayments = mapOf() + private set - open fun idDisplayNote() = idNote().toShortenHex() + var relays = listOf() + private set - fun channelHex(): HexKey? { - return if (event is ChannelMessageEvent || - event is ChannelMetadataEvent || - event is ChannelCreateEvent || - event is LiveActivitiesChatMessageEvent || - event is LiveActivitiesEvent - ) { - (event as? ChannelMessageEvent)?.channel() - ?: (event as? ChannelMetadataEvent)?.channel() - ?: (event as? ChannelCreateEvent)?.id - ?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag() - ?: (event as? LiveActivitiesEvent)?.address()?.toTag() - } else { - null - } - } + var lastReactionsDownloadTime: Map = emptyMap() - open fun address(): ATag? = null + fun id() = Hex.decode(idHex) - open fun createdAt() = event?.createdAt() + open fun idNote() = id().toNote() - fun loadEvent(event: Event, author: User, replyTo: List) { - if (this.event?.id() != event.id()) { - this.event = event - this.author = author - this.replyTo = replyTo - - liveSet?.innerMetadata?.invalidateData() - flowSet?.metadata?.invalidateData() - } - } - - fun formattedDateTime(timestamp: Long): String { - return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault()) - .format(DateTimeFormatter.ofPattern("uuuu-MM-dd-HH:mm:ss")) - } - - data class LevelSignature(val signature: String, val createdAt: Long?, val author: User?) - - /** - * This method caches signatures during each execution to avoid recalculation in longer threads - */ - fun replyLevelSignature( - eventsToConsider: Set, - cachedSignatures: MutableMap, - account: User, - accountFollowingSet: Set, - now: Long - ): LevelSignature { - val replyTo = replyTo - if (event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()) { - return LevelSignature( - signature = "/" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) + ";", - createdAt = createdAt(), - author = author - ) - } - - val parent = ( - replyTo - .filter { it.idHex in eventsToConsider } // This forces the signature to be based on a branch, avoiding two roots - .map { - cachedSignatures[it] ?: it.replyLevelSignature( - eventsToConsider, - cachedSignatures, - account, - accountFollowingSet, - now - ).apply { cachedSignatures.put(it, this) } - } - .maxByOrNull { it.signature.length } - ) - - val parentSignature = parent?.signature?.removeSuffix(";") ?: "" - - val threadOrder = if (parent?.author == author && createdAt() != null) { - // author of the thread first, in **ascending** order - "9" + formattedDateTime((parent?.createdAt ?: 0) + (now - (createdAt() ?: 0))) + idHex.substring(0, 8) - } else if (author?.pubkeyHex == account.pubkeyHex) { - "8" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // my replies - } else if (author?.pubkeyHex in accountFollowingSet) { - "7" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // my follows replies. - } else { - "0" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // everyone else. - } - - val mySignature = LevelSignature( - signature = parentSignature + "/" + threadOrder + ";", - createdAt = createdAt(), - author = author + open fun toNEvent(): String { + val myEvent = event + return if (myEvent is WrappedEvent) { + val host = myEvent.host + if (host != null) { + Nip19.createNEvent( + host.id, + host.pubKey, + host.kind(), + relays.firstOrNull()?.url, ) + } else { + Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url) + } + } else { + Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url) + } + } - cachedSignatures[this] = mySignature - return mySignature + fun toNostrUri(): String { + return "nostr:${toNEvent()}" + } + + open fun idDisplayNote() = idNote().toShortenHex() + + fun channelHex(): HexKey? { + return if ( + event is ChannelMessageEvent || + event is ChannelMetadataEvent || + event is ChannelCreateEvent || + event is LiveActivitiesChatMessageEvent || + event is LiveActivitiesEvent + ) { + (event as? ChannelMessageEvent)?.channel() + ?: (event as? ChannelMetadataEvent)?.channel() ?: (event as? ChannelCreateEvent)?.id + ?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag() + ?: (event as? LiveActivitiesEvent)?.address()?.toTag() + } else { + null + } + } + + open fun address(): ATag? = null + + open fun createdAt() = event?.createdAt() + + fun loadEvent( + event: Event, + author: User, + replyTo: List, + ) { + if (this.event?.id() != event.id()) { + this.event = event + this.author = author + this.replyTo = replyTo + + liveSet?.innerMetadata?.invalidateData() + flowSet?.metadata?.invalidateData() + } + } + + fun formattedDateTime(timestamp: Long): String { + return Instant.ofEpochSecond(timestamp) + .atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("uuuu-MM-dd-HH:mm:ss")) + } + + data class LevelSignature(val signature: String, val createdAt: Long?, val author: User?) + + /** + * This method caches signatures during each execution to avoid recalculation in longer threads + */ + fun replyLevelSignature( + eventsToConsider: Set, + cachedSignatures: MutableMap, + account: User, + accountFollowingSet: Set, + now: Long, + ): LevelSignature { + val replyTo = replyTo + if ( + event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty() + ) { + return LevelSignature( + signature = "/" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) + ";", + createdAt = createdAt(), + author = author, + ) } - fun replyLevel(cachedLevels: MutableMap = mutableMapOf()): Int { - val replyTo = replyTo - if (event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()) { - return 0 + val parent = + (replyTo + .filter { + it.idHex in eventsToConsider + } // This forces the signature to be based on a branch, avoiding two roots + .map { + cachedSignatures[it] + ?: it + .replyLevelSignature( + eventsToConsider, + cachedSignatures, + account, + accountFollowingSet, + now, + ) + .apply { cachedSignatures.put(it, this) } } + .maxByOrNull { it.signature.length }) - return replyTo.maxOf { - cachedLevels[it] ?: it.replyLevel(cachedLevels).apply { cachedLevels.put(it, this) } - } + 1 + val parentSignature = parent?.signature?.removeSuffix(";") ?: "" + + val threadOrder = + if (parent?.author == author && createdAt() != null) { + // author of the thread first, in **ascending** order + "9" + + formattedDateTime((parent?.createdAt ?: 0) + (now - (createdAt() ?: 0))) + + idHex.substring(0, 8) + } else if (author?.pubkeyHex == account.pubkeyHex) { + "8" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // my replies + } else if (author?.pubkeyHex in accountFollowingSet) { + "7" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // my follows replies. + } else { + "0" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // everyone else. + } + + val mySignature = + LevelSignature( + signature = parentSignature + "/" + threadOrder + ";", + createdAt = createdAt(), + author = author, + ) + + cachedSignatures[this] = mySignature + return mySignature + } + + fun replyLevel(cachedLevels: MutableMap = mutableMapOf()): Int { + val replyTo = replyTo + if ( + event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty() + ) { + return 0 } - fun addReply(note: Note) { - if (note !in replies) { - replies = replies + note - liveSet?.innerReplies?.invalidateData() + return replyTo.maxOf { + cachedLevels[it] ?: it.replyLevel(cachedLevels).apply { cachedLevels.put(it, this) } + } + 1 + } + + fun addReply(note: Note) { + if (note !in replies) { + replies = replies + note + liveSet?.innerReplies?.invalidateData() + } + } + + fun removeReply(note: Note) { + if (note in replies) { + replies = replies - note + liveSet?.innerReplies?.invalidateData() + } + } + + fun removeBoost(note: Note) { + if (note in boosts) { + boosts = boosts - note + liveSet?.innerBoosts?.invalidateData() + } + } + + fun removeAllChildNotes(): List { + val toBeRemoved = + replies + + reactions.values.flatten() + + boosts + + reports.values.flatten() + + zaps.keys + + zaps.values.filterNotNull() + + zapPayments.keys + + zapPayments.values.filterNotNull() + + replies = listOf() + reactions = mapOf>() + boosts = listOf() + reports = mapOf>() + zaps = mapOf() + zapPayments = mapOf() + zapsAmount = BigDecimal.ZERO + relays = listOf() + lastReactionsDownloadTime = emptyMap() + + liveSet?.innerReplies?.invalidateData() + liveSet?.innerReactions?.invalidateData() + liveSet?.innerBoosts?.invalidateData() + liveSet?.innerReports?.invalidateData() + liveSet?.innerZaps?.invalidateData() + + return toBeRemoved + } + + fun removeReaction(note: Note) { + val tags = note.event?.tags() ?: emptyArray() + val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+" + + if (reactions[reaction]?.contains(note) == true) { + reactions[reaction]?.let { + if (note in it) { + val newList = it.minus(note) + if (newList.isEmpty()) { + reactions = reactions.minus(reaction) + } else { + reactions = reactions + Pair(reaction, newList) + } + + liveSet?.innerReactions?.invalidateData() } + } } + } - fun removeReply(note: Note) { - if (note in replies) { - replies = replies - note - liveSet?.innerReplies?.invalidateData() - } - } + fun removeReport(deleteNote: Note) { + val author = deleteNote.author ?: return - fun removeBoost(note: Note) { - if (note in boosts) { - boosts = boosts - note - liveSet?.innerBoosts?.invalidateData() - } - } - - fun removeAllChildNotes(): List { - val toBeRemoved = replies + - reactions.values.flatten() + - boosts + - reports.values.flatten() + - zaps.keys + - zaps.values.filterNotNull() + - zapPayments.keys + - zapPayments.values.filterNotNull() - - replies = listOf() - reactions = mapOf>() - boosts = listOf() - reports = mapOf>() - zaps = mapOf() - zapPayments = mapOf() - zapsAmount = BigDecimal.ZERO - relays = listOf() - lastReactionsDownloadTime = emptyMap() - - liveSet?.innerReplies?.invalidateData() - liveSet?.innerReactions?.invalidateData() - liveSet?.innerBoosts?.invalidateData() + if (reports[author]?.contains(deleteNote) == true) { + reports[author]?.let { + reports = reports + Pair(author, it.minus(deleteNote)) liveSet?.innerReports?.invalidateData() + } + } + } + + fun removeZap(note: Note) { + if (zaps[note] != null) { + zaps = zaps.minus(note) + updateZapTotal() + liveSet?.innerZaps?.invalidateData() + } else if (zaps.containsValue(note)) { + zaps = zaps.filterValues { it != note } + updateZapTotal() + liveSet?.innerZaps?.invalidateData() + } + } + + fun removeZapPayment(note: Note) { + if (zapPayments.containsKey(note)) { + zapPayments = zapPayments.minus(note) + liveSet?.innerZaps?.invalidateData() + } else if (zapPayments.containsValue(note)) { + zapPayments = zapPayments.filterValues { it != note } + liveSet?.innerZaps?.invalidateData() + } + } + + fun addBoost(note: Note) { + if (note !in boosts) { + boosts = boosts + note + liveSet?.innerBoosts?.invalidateData() + } + } + + @Synchronized + private fun innerAddZap( + zapRequest: Note, + zap: Note?, + ): Boolean { + if (zaps[zapRequest] == null) { + zaps = zaps + Pair(zapRequest, zap) + return true + } + + return false + } + + fun addZap( + zapRequest: Note, + zap: Note?, + ) { + checkNotInMainThread() + + if (zaps[zapRequest] == null) { + val inserted = innerAddZap(zapRequest, zap) + if (inserted) { + updateZapTotal() liveSet?.innerZaps?.invalidateData() + } + } + } - return toBeRemoved + @Synchronized + private fun innerAddZapPayment( + zapPaymentRequest: Note, + zapPayment: Note?, + ): Boolean { + if (zapPayments[zapPaymentRequest] == null) { + zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment) + return true } - fun removeReaction(note: Note) { - val tags = note.event?.tags() ?: emptyArray() - val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+" + return false + } - if (reactions[reaction]?.contains(note) == true) { - reactions[reaction]?.let { - if (note in it) { - val newList = it.minus(note) - if (newList.isEmpty()) { - reactions = reactions.minus(reaction) - } else { - reactions = reactions + Pair(reaction, newList) - } + fun addZapPayment( + zapPaymentRequest: Note, + zapPayment: Note?, + ) { + checkNotInMainThread() + if (zapPayments[zapPaymentRequest] == null) { + val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment) + if (inserted) { + liveSet?.innerZaps?.invalidateData() + } + } + } - liveSet?.innerReactions?.invalidateData() - } - } - } + fun addReaction(note: Note) { + val tags = note.event?.tags() ?: emptyArray() + val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+" + + val listOfAuthors = reactions[reaction] + if (listOfAuthors == null) { + reactions = reactions + Pair(reaction, listOf(note)) + liveSet?.innerReactions?.invalidateData() + } else if (!listOfAuthors.contains(note)) { + reactions = reactions + Pair(reaction, listOfAuthors + note) + liveSet?.innerReactions?.invalidateData() + } + } + + fun addReport(note: Note) { + val author = note.author ?: return + + val reportsByAuthor = reports[author] + + if (reportsByAuthor == null) { + reports = reports + Pair(author, listOf(note)) + liveSet?.innerReports?.invalidateData() + } else if (!reportsByAuthor.contains(note)) { + reports = reports + Pair(author, reportsByAuthor + note) + liveSet?.innerReports?.invalidateData() + } + } + + @Synchronized + fun addRelaySync(briefInfo: RelayBriefInfoCache.RelayBriefInfo) { + if (briefInfo !in relays) { + relays = relays + briefInfo + } + } + + fun addRelay(relay: Relay) { + if (relay.brief !in relays) { + addRelaySync(relay.brief) + liveSet?.innerRelays?.invalidateData() + } + } + + private fun recursiveIsPaidByCalculation( + account: Account, + remainingZapPayments: List>, + onWasZappedByAuthor: () -> Unit, + ) { + if (remainingZapPayments.isEmpty()) { + return } - fun removeReport(deleteNote: Note) { - val author = deleteNote.author ?: return + val next = remainingZapPayments.first() - if (reports[author]?.contains(deleteNote) == true) { - reports[author]?.let { - reports = reports + Pair(author, it.minus(deleteNote)) - liveSet?.innerReports?.invalidateData() - } - } - } - - fun removeZap(note: Note) { - if (zaps[note] != null) { - zaps = zaps.minus(note) - updateZapTotal() - liveSet?.innerZaps?.invalidateData() - } else if (zaps.containsValue(note)) { - zaps = zaps.filterValues { it != note } - updateZapTotal() - liveSet?.innerZaps?.invalidateData() - } - } - - fun removeZapPayment(note: Note) { - if (zapPayments.containsKey(note)) { - zapPayments = zapPayments.minus(note) - liveSet?.innerZaps?.invalidateData() - } else if (zapPayments.containsValue(note)) { - zapPayments = zapPayments.filterValues { it != note } - liveSet?.innerZaps?.invalidateData() - } - } - - fun addBoost(note: Note) { - if (note !in boosts) { - boosts = boosts + note - liveSet?.innerBoosts?.invalidateData() - } - } - - @Synchronized - private fun innerAddZap(zapRequest: Note, zap: Note?): Boolean { - if (zaps[zapRequest] == null) { - zaps = zaps + Pair(zapRequest, zap) - return true - } - - return false - } - - fun addZap(zapRequest: Note, zap: Note?) { - checkNotInMainThread() - - if (zaps[zapRequest] == null) { - val inserted = innerAddZap(zapRequest, zap) - if (inserted) { - updateZapTotal() - liveSet?.innerZaps?.invalidateData() - } - } - } - - @Synchronized - private fun innerAddZapPayment(zapPaymentRequest: Note, zapPayment: Note?): Boolean { - if (zapPayments[zapPaymentRequest] == null) { - zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment) - return true - } - - return false - } - - fun addZapPayment(zapPaymentRequest: Note, zapPayment: Note?) { - checkNotInMainThread() - if (zapPayments[zapPaymentRequest] == null) { - val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment) - if (inserted) { - liveSet?.innerZaps?.invalidateData() - } - } - } - - fun addReaction(note: Note) { - val tags = note.event?.tags() ?: emptyArray() - val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+" - - val listOfAuthors = reactions[reaction] - if (listOfAuthors == null) { - reactions = reactions + Pair(reaction, listOf(note)) - liveSet?.innerReactions?.invalidateData() - } else if (!listOfAuthors.contains(note)) { - reactions = reactions + Pair(reaction, listOfAuthors + note) - liveSet?.innerReactions?.invalidateData() - } - } - - fun addReport(note: Note) { - val author = note.author ?: return - - val reportsByAuthor = reports[author] - - if (reportsByAuthor == null) { - reports = reports + Pair(author, listOf(note)) - liveSet?.innerReports?.invalidateData() - } else if (!reportsByAuthor.contains(note)) { - reports = reports + Pair(author, reportsByAuthor + note) - liveSet?.innerReports?.invalidateData() - } - } - - @Synchronized - fun addRelaySync(briefInfo: RelayBriefInfoCache.RelayBriefInfo) { - if (briefInfo !in relays) { - relays = relays + briefInfo - } - } - - fun addRelay(relay: Relay) { - if (relay.brief !in relays) { - addRelaySync(relay.brief) - liveSet?.innerRelays?.invalidateData() - } - } - - private fun recursiveIsPaidByCalculation( - account: Account, - remainingZapPayments: List>, - onWasZappedByAuthor: () -> Unit - ) { - if (remainingZapPayments.isEmpty()) { - return - } - - val next = remainingZapPayments.first() - - val zapResponseEvent = next.second?.event as? LnZapPaymentResponseEvent - if (zapResponseEvent != null) { - account.decryptZapPaymentResponseEvent(zapResponseEvent) { response -> - if (response is PayInvoiceSuccessResponse && account.isNIP47Author(zapResponseEvent.requestAuthor())) { - onWasZappedByAuthor() - } else { - recursiveIsPaidByCalculation( - account, - remainingZapPayments.minus(next), - onWasZappedByAuthor - ) - } - } - } - } - - private fun recursiveIsZappedByCalculation( - option: Int?, - user: User, - account: Account, - remainingZapEvents: List>, - onWasZappedByAuthor: () -> Unit - ) { - if (remainingZapEvents.isEmpty()) { - return - } - - val next = remainingZapEvents.first() - - if (next.first.author?.pubkeyHex == user.pubkeyHex) { - onWasZappedByAuthor() + val zapResponseEvent = next.second?.event as? LnZapPaymentResponseEvent + if (zapResponseEvent != null) { + account.decryptZapPaymentResponseEvent(zapResponseEvent) { response -> + if ( + response is PayInvoiceSuccessResponse && + account.isNIP47Author(zapResponseEvent.requestAuthor()) + ) { + onWasZappedByAuthor() } else { - account.decryptZapContentAuthor(next.first) { - if (it.pubKey == user.pubkeyHex && (option == null || option == (it as? LnZapEvent)?.zappedPollOption())) { - onWasZappedByAuthor() - } else { - recursiveIsZappedByCalculation(option, user, account, remainingZapEvents.minus(next), onWasZappedByAuthor) - } + recursiveIsPaidByCalculation( + account, + remainingZapPayments.minus(next), + onWasZappedByAuthor, + ) + } + } + } + } + + private fun recursiveIsZappedByCalculation( + option: Int?, + user: User, + account: Account, + remainingZapEvents: List>, + onWasZappedByAuthor: () -> Unit, + ) { + if (remainingZapEvents.isEmpty()) { + return + } + + val next = remainingZapEvents.first() + + if (next.first.author?.pubkeyHex == user.pubkeyHex) { + onWasZappedByAuthor() + } else { + account.decryptZapContentAuthor(next.first) { + if ( + it.pubKey == user.pubkeyHex && + (option == null || option == (it as? LnZapEvent)?.zappedPollOption()) + ) { + onWasZappedByAuthor() + } else { + recursiveIsZappedByCalculation( + option, + user, + account, + remainingZapEvents.minus(next), + onWasZappedByAuthor, + ) + } + } + } + } + + fun isZappedBy( + user: User, + account: Account, + onWasZappedByAuthor: () -> Unit, + ) { + recursiveIsZappedByCalculation(null, user, account, zaps.toList(), onWasZappedByAuthor) + if (account.userProfile() == user) { + recursiveIsPaidByCalculation(account, zapPayments.toList(), onWasZappedByAuthor) + } + } + + fun isZappedBy( + option: Int?, + user: User, + account: Account, + onWasZappedByAuthor: () -> Unit, + ) { + recursiveIsZappedByCalculation(option, user, account, zaps.toList(), onWasZappedByAuthor) + } + + fun getReactionBy(user: User): String? { + return reactions.firstNotNullOfOrNull { + if (it.value.any { it.author?.pubkeyHex == user.pubkeyHex }) { + it.key + } else { + null + } + } + } + + fun isBoostedBy(user: User): Boolean { + return boosts.any { it.author?.pubkeyHex == user.pubkeyHex } + } + + fun hasReportsBy(user: User): Boolean { + return reports[user]?.isNotEmpty() ?: false + } + + fun countReportAuthorsBy(users: Set): Int { + return reports.count { it.key.pubkeyHex in users } + } + + fun reportsBy(users: Set): List { + return reports + .mapNotNull { + if (it.key.pubkeyHex in users) { + it.value + } else { + null + } + } + .flatten() + } + + private fun updateZapTotal() { + var sumOfAmounts = BigDecimal.ZERO + + // Regular Zap Receipts + zaps.forEach { + val noteEvent = it?.value?.event + if (noteEvent is LnZapEvent) { + sumOfAmounts += noteEvent.amount ?: BigDecimal.ZERO + } + } + + zapsAmount = sumOfAmounts + } + + private fun recursiveZappedAmountCalculation( + invoiceSet: LinkedHashSet, + remainingZapPayments: List>, + signer: NostrSigner, + output: BigDecimal, + onReady: (BigDecimal) -> Unit, + ) { + if (remainingZapPayments.isEmpty()) { + onReady(output) + return + } + + val next = remainingZapPayments.first() + + (next.second?.event as? LnZapPaymentResponseEvent)?.response(signer) { noteEvent -> + if (noteEvent is PayInvoiceSuccessResponse) { + (next.first.event as? LnZapPaymentRequestEvent)?.lnInvoice(signer) { invoice -> + val amount = + try { + LnInvoiceUtil.getAmountInSats(invoice) + } catch (e: java.lang.Exception) { + null } - } - } - fun isZappedBy(user: User, account: Account, onWasZappedByAuthor: () -> Unit) { - recursiveIsZappedByCalculation(null, user, account, zaps.toList(), onWasZappedByAuthor) - if (account.userProfile() == user) { - recursiveIsPaidByCalculation(account, zapPayments.toList(), onWasZappedByAuthor) - } - } + var newAmount = output - fun isZappedBy(option: Int?, user: User, account: Account, onWasZappedByAuthor: () -> Unit) { - recursiveIsZappedByCalculation(option, user, account, zaps.toList(), onWasZappedByAuthor) - } + if (amount != null && !invoiceSet.contains(invoice)) { + invoiceSet.add(invoice) + newAmount += amount + } - fun getReactionBy(user: User): String? { - return reactions.firstNotNullOfOrNull { - if (it.value.any { it.author?.pubkeyHex == user.pubkeyHex }) { - it.key - } else { - null - } - } - } - - fun isBoostedBy(user: User): Boolean { - return boosts.any { it.author?.pubkeyHex == user.pubkeyHex } - } - - fun hasReportsBy(user: User): Boolean { - return reports[user]?.isNotEmpty() ?: false - } - - fun countReportAuthorsBy(users: Set): Int { - return reports.count { it.key.pubkeyHex in users } - } - - fun reportsBy(users: Set): List { - return reports.mapNotNull { - if (it.key.pubkeyHex in users) { - it.value - } else { - null - } - }.flatten() - } - - private fun updateZapTotal() { - var sumOfAmounts = BigDecimal.ZERO - - // Regular Zap Receipts - zaps.forEach { - val noteEvent = it?.value?.event - if (noteEvent is LnZapEvent) { - sumOfAmounts += noteEvent.amount ?: BigDecimal.ZERO - } - } - - zapsAmount = sumOfAmounts - } - - private fun recursiveZappedAmountCalculation( - invoiceSet: LinkedHashSet, - remainingZapPayments: List>, - signer: NostrSigner, - output: BigDecimal, - onReady: (BigDecimal) -> Unit - ) { - if (remainingZapPayments.isEmpty()) { - onReady(output) - return - } - - val next = remainingZapPayments.first() - - (next.second?.event as? LnZapPaymentResponseEvent)?.response(signer) { noteEvent -> - if (noteEvent is PayInvoiceSuccessResponse) { - (next.first.event as? LnZapPaymentRequestEvent)?.lnInvoice(signer) { invoice -> - val amount = try { - LnInvoiceUtil.getAmountInSats(invoice) - } catch (e: java.lang.Exception) { - null - } - - var newAmount = output - - if (amount != null && !invoiceSet.contains(invoice)) { - invoiceSet.add(invoice) - newAmount += amount - } - - recursiveZappedAmountCalculation( - invoiceSet, - remainingZapPayments.minus(next), - signer, - newAmount, - onReady - ) - } - } - } - } - - fun zappedAmountWithNWCPayments(signer: NostrSigner, onReady: (BigDecimal) -> Unit) { - if (zapPayments.isEmpty()) { - onReady(zapsAmount) - } - - val invoiceSet = LinkedHashSet(zaps.size + zapPayments.size) - zaps.forEach { - (it.value?.event as? LnZapEvent)?.lnInvoice()?.let { - invoiceSet.add(it) - } - } - - recursiveZappedAmountCalculation( + recursiveZappedAmountCalculation( invoiceSet, - zapPayments.toList(), + remainingZapPayments.minus(next), signer, - zapsAmount, - onReady - ) - } - - fun hasPledgeBy(user: User): Boolean { - return replies - .filter { it.event?.isTaggedHash("bounty-added-reward") ?: false } - .any { - val pledgeValue = try { - BigDecimal(it.event?.content()) - } catch (e: Exception) { - null - // do nothing if it can't convert to bigdecimal - } - - pledgeValue != null && it.author == user - } - } - - fun pledgedAmountByOthers(): BigDecimal { - return replies - .filter { it.event?.isTaggedHash("bounty-added-reward") ?: false } - .mapNotNull { - try { - BigDecimal(it.event?.content()) - } catch (e: Exception) { - null - // do nothing if it can't convert to bigdecimal - } - } - .sumOf { it } - } - - fun hasAnyReports(): Boolean { - val dayAgo = TimeUtils.oneDayAgo() - return reports.isNotEmpty() || - ( - author?.reports?.any { - it.value.firstOrNull { (it.createdAt() ?: 0) > dayAgo } != null - } ?: false - ) - } - - fun isNewThread(): Boolean { - return ( - event is RepostEvent || - event is GenericRepostEvent || - replyTo == null || - replyTo?.size == 0 - ) && - event !is ChannelMessageEvent && - event !is LiveActivitiesChatMessageEvent - } - - fun hasZapped(loggedIn: User): Boolean { - return zaps.any { it.key.author == loggedIn } - } - - fun hasReacted(loggedIn: User, content: String): Boolean { - return reactedBy(loggedIn, content).isNotEmpty() - } - - fun reactedBy(loggedIn: User, content: String): List { - return reactions[content]?.filter { it.author == loggedIn } ?: emptyList() - } - - fun reactedBy(loggedIn: User): List { - return reactions.filter { it.value.any { it.author == loggedIn } }.mapNotNull { it.key } - } - - fun hasBoostedInTheLast5Minutes(loggedIn: User): Boolean { - return boosts.firstOrNull { it.author == loggedIn && (it.createdAt() ?: 0) > TimeUtils.fiveMinutesAgo() } != null // 5 minute protection - } - - fun boostedBy(loggedIn: User): List { - return boosts.filter { it.author == loggedIn } - } - - fun moveAllReferencesTo(note: AddressableNote) { - // migrates these comments to a new version - replies.forEach { - note.addReply(it) - it.replyTo = it.replyTo?.updated(this, note) - } - reactions.forEach { - it.value.forEach { - note.addReaction(it) - it.replyTo = it.replyTo?.updated(this, note) - } - } - boosts.forEach { - note.addBoost(it) - it.replyTo = it.replyTo?.updated(this, note) - } - reports.forEach { - it.value.forEach { - note.addReport(it) - it.replyTo = it.replyTo?.updated(this, note) - } - } - zaps.forEach { - note.addZap(it.key, it.value) - it.key.replyTo = it.key.replyTo?.updated(this, note) - it.value?.replyTo = it.value?.replyTo?.updated(this, note) + newAmount, + onReady, + ) } + } + } + } - replyTo = null - replies = emptyList() - reactions = emptyMap() - boosts = emptyList() - reports = emptyMap() - zaps = emptyMap() - zapsAmount = BigDecimal.ZERO + fun zappedAmountWithNWCPayments( + signer: NostrSigner, + onReady: (BigDecimal) -> Unit, + ) { + if (zapPayments.isEmpty()) { + onReady(zapsAmount) } - fun clearEOSE() { - lastReactionsDownloadTime = emptyMap() + val invoiceSet = LinkedHashSet(zaps.size + zapPayments.size) + zaps.forEach { (it.value?.event as? LnZapEvent)?.lnInvoice()?.let { invoiceSet.add(it) } } + + recursiveZappedAmountCalculation( + invoiceSet, + zapPayments.toList(), + signer, + zapsAmount, + onReady, + ) + } + + fun hasPledgeBy(user: User): Boolean { + return replies + .filter { it.event?.isTaggedHash("bounty-added-reward") ?: false } + .any { + val pledgeValue = + try { + BigDecimal(it.event?.content()) + } catch (e: Exception) { + null + // do nothing if it can't convert to bigdecimal + } + + pledgeValue != null && it.author == user + } + } + + fun pledgedAmountByOthers(): BigDecimal { + return replies + .filter { it.event?.isTaggedHash("bounty-added-reward") ?: false } + .mapNotNull { + try { + BigDecimal(it.event?.content()) + } catch (e: Exception) { + null + // do nothing if it can't convert to bigdecimal + } + } + .sumOf { it } + } + + fun hasAnyReports(): Boolean { + val dayAgo = TimeUtils.oneDayAgo() + return reports.isNotEmpty() || + (author?.reports?.any { it.value.firstOrNull { (it.createdAt() ?: 0) > dayAgo } != null } + ?: false) + } + + fun isNewThread(): Boolean { + return (event is RepostEvent || + event is GenericRepostEvent || + replyTo == null || + replyTo?.size == 0) && + event !is ChannelMessageEvent && + event !is LiveActivitiesChatMessageEvent + } + + fun hasZapped(loggedIn: User): Boolean { + return zaps.any { it.key.author == loggedIn } + } + + fun hasReacted( + loggedIn: User, + content: String, + ): Boolean { + return reactedBy(loggedIn, content).isNotEmpty() + } + + fun reactedBy( + loggedIn: User, + content: String, + ): List { + return reactions[content]?.filter { it.author == loggedIn } ?: emptyList() + } + + fun reactedBy(loggedIn: User): List { + return reactions.filter { it.value.any { it.author == loggedIn } }.mapNotNull { it.key } + } + + fun hasBoostedInTheLast5Minutes(loggedIn: User): Boolean { + return boosts.firstOrNull { + it.author == loggedIn && (it.createdAt() ?: 0) > TimeUtils.fiveMinutesAgo() + } != null // 5 minute protection + } + + fun boostedBy(loggedIn: User): List { + return boosts.filter { it.author == loggedIn } + } + + fun moveAllReferencesTo(note: AddressableNote) { + // migrates these comments to a new version + replies.forEach { + note.addReply(it) + it.replyTo = it.replyTo?.updated(this, note) + } + reactions.forEach { + it.value.forEach { + note.addReaction(it) + it.replyTo = it.replyTo?.updated(this, note) + } + } + boosts.forEach { + note.addBoost(it) + it.replyTo = it.replyTo?.updated(this, note) + } + reports.forEach { + it.value.forEach { + note.addReport(it) + it.replyTo = it.replyTo?.updated(this, note) + } + } + zaps.forEach { + note.addZap(it.key, it.value) + it.key.replyTo = it.key.replyTo?.updated(this, note) + it.value?.replyTo = it.value?.replyTo?.updated(this, note) } - fun isHiddenFor(accountChoices: Account.LiveHiddenUsers): Boolean { - val thisEvent = event ?: return false + replyTo = null + replies = emptyList() + reactions = emptyMap() + boosts = emptyList() + reports = emptyMap() + zaps = emptyMap() + zapsAmount = BigDecimal.ZERO + } - val isBoostedNoteHidden = if (thisEvent is GenericRepostEvent || thisEvent is RepostEvent || thisEvent is CommunityPostApprovalEvent) { - replyTo?.lastOrNull()?.isHiddenFor(accountChoices) ?: false - } else { - false + fun clearEOSE() { + lastReactionsDownloadTime = emptyMap() + } + + fun isHiddenFor(accountChoices: Account.LiveHiddenUsers): Boolean { + val thisEvent = event ?: return false + + val isBoostedNoteHidden = + if ( + thisEvent is GenericRepostEvent || + thisEvent is RepostEvent || + thisEvent is CommunityPostApprovalEvent + ) { + replyTo?.lastOrNull()?.isHiddenFor(accountChoices) ?: false + } else { + false + } + + val isHiddenByWord = + if (thisEvent is BaseTextNoteEvent) { + accountChoices.hiddenWords.any { + thisEvent.content.containsAny(accountChoices.hiddenWordsCase) } + } else { + false + } - val isHiddenByWord = if (thisEvent is BaseTextNoteEvent) { - accountChoices.hiddenWords.any { - thisEvent.content.containsAny(accountChoices.hiddenWordsCase) - } - } else { - false - } + val isSensitive = thisEvent.isSensitive() + return isBoostedNoteHidden || + isHiddenByWord || + accountChoices.hiddenUsers.contains(author?.pubkeyHex) || + accountChoices.spammers.contains(author?.pubkeyHex) || + (isSensitive && accountChoices.showSensitiveContent == false) + } - val isSensitive = thisEvent.isSensitive() - return isBoostedNoteHidden || isHiddenByWord || - accountChoices.hiddenUsers.contains(author?.pubkeyHex) || - accountChoices.spammers.contains(author?.pubkeyHex) || - (isSensitive && accountChoices.showSensitiveContent == false) + var liveSet: NoteLiveSet? = null + var flowSet: NoteFlowSet? = null + + @Synchronized + fun createOrDestroyLiveSync(create: Boolean) { + if (create) { + if (liveSet == null) { + liveSet = NoteLiveSet(this) + } + } else { + if (liveSet != null && liveSet?.isInUse() == false) { + liveSet?.destroy() + liveSet = null + } } + } - var liveSet: NoteLiveSet? = null - var flowSet: NoteFlowSet? = null - - @Synchronized - fun createOrDestroyLiveSync(create: Boolean) { - if (create) { - if (liveSet == null) { - liveSet = NoteLiveSet(this) - } - } else { - if (liveSet != null && liveSet?.isInUse() == false) { - liveSet?.destroy() - liveSet = null - } - } + fun live(): NoteLiveSet { + if (liveSet == null) { + createOrDestroyLiveSync(true) } + return liveSet!! + } - fun live(): NoteLiveSet { - if (liveSet == null) { - createOrDestroyLiveSync(true) - } - return liveSet!! + fun clearLive() { + if (liveSet != null && liveSet?.isInUse() == false) { + createOrDestroyLiveSync(false) } + } - fun clearLive() { - if (liveSet != null && liveSet?.isInUse() == false) { - createOrDestroyLiveSync(false) - } + @Synchronized + fun createOrDestroyFlowSync(create: Boolean) { + if (create) { + if (flowSet == null) { + flowSet = NoteFlowSet(this) + } + } else { + if (flowSet != null && flowSet?.isInUse() == false) { + flowSet?.destroy() + flowSet = null + } } + } - @Synchronized - fun createOrDestroyFlowSync(create: Boolean) { - if (create) { - if (flowSet == null) { - flowSet = NoteFlowSet(this) - } - } else { - if (flowSet != null && flowSet?.isInUse() == false) { - flowSet?.destroy() - flowSet = null - } - } + fun flow(): NoteFlowSet { + if (flowSet == null) { + createOrDestroyFlowSync(true) } + return flowSet!! + } - fun flow(): NoteFlowSet { - if (flowSet == null) { - createOrDestroyFlowSync(true) - } - return flowSet!! - } - - fun clearFlow() { - if (flowSet != null && flowSet?.isInUse() == false) { - createOrDestroyFlowSync(false) - } + fun clearFlow() { + if (flowSet != null && flowSet?.isInUse() == false) { + createOrDestroyFlowSync(false) } + } } @Stable class NoteFlowSet(u: Note) { - // Observers line up here. - val metadata = NoteBundledRefresherFlow(u) + // Observers line up here. + val metadata = NoteBundledRefresherFlow(u) - fun isInUse(): Boolean { - return metadata.stateFlow.subscriptionCount.value > 0 - } + fun isInUse(): Boolean { + return metadata.stateFlow.subscriptionCount.value > 0 + } - fun destroy() { - metadata.destroy() - } + fun destroy() { + metadata.destroy() + } } @Stable class NoteLiveSet(u: Note) { - // Observers line up here. - val innerMetadata = NoteBundledRefresherLiveData(u) - val innerReactions = NoteBundledRefresherLiveData(u) - val innerBoosts = NoteBundledRefresherLiveData(u) - val innerReplies = NoteBundledRefresherLiveData(u) - val innerReports = NoteBundledRefresherLiveData(u) - val innerRelays = NoteBundledRefresherLiveData(u) - val innerZaps = NoteBundledRefresherLiveData(u) + // Observers line up here. + val innerMetadata = NoteBundledRefresherLiveData(u) + val innerReactions = NoteBundledRefresherLiveData(u) + val innerBoosts = NoteBundledRefresherLiveData(u) + val innerReplies = NoteBundledRefresherLiveData(u) + val innerReports = NoteBundledRefresherLiveData(u) + val innerRelays = NoteBundledRefresherLiveData(u) + val innerZaps = NoteBundledRefresherLiveData(u) - val metadata = innerMetadata.map { it } - val reactions = innerReactions.map { it } - val boosts = innerBoosts.map { it } - val replies = innerReplies.map { it } - val reports = innerReports.map { it } - val relays = innerRelays.map { it } - val zaps = innerZaps.map { it } + val metadata = innerMetadata.map { it } + val reactions = innerReactions.map { it } + val boosts = innerBoosts.map { it } + val replies = innerReplies.map { it } + val reports = innerReports.map { it } + val relays = innerRelays.map { it } + val zaps = innerZaps.map { it } - val authorChanges = innerMetadata.map { - it.note.author - }.distinctUntilChanged() + val authorChanges = innerMetadata.map { it.note.author }.distinctUntilChanged() - val hasEvent = innerMetadata.map { - it.note.event != null - }.distinctUntilChanged() + val hasEvent = innerMetadata.map { it.note.event != null }.distinctUntilChanged() - val hasReactions = innerZaps.combineWith(innerBoosts, innerReactions) { zapState, boostState, reactionState -> - zapState?.note?.zaps?.isNotEmpty() ?: false || - boostState?.note?.boosts?.isNotEmpty() ?: false || - reactionState?.note?.reactions?.isNotEmpty() ?: false - }.distinctUntilChanged() + val hasReactions = + innerZaps + .combineWith(innerBoosts, innerReactions) { zapState, boostState, reactionState -> + zapState?.note?.zaps?.isNotEmpty() + ?: false || + boostState?.note?.boosts?.isNotEmpty() ?: false || + reactionState?.note?.reactions?.isNotEmpty() ?: false + } + .distinctUntilChanged() - val replyCount = innerReplies.map { - it.note.replies.size - }.distinctUntilChanged() + val replyCount = innerReplies.map { it.note.replies.size }.distinctUntilChanged() - val reactionCount = innerReactions.map { + val reactionCount = + innerReactions + .map { var total = 0 it.note.reactions.forEach { total += it.value.size } total - }.distinctUntilChanged() + } + .distinctUntilChanged() - val boostCount = innerBoosts.map { - it.note.boosts.size - }.distinctUntilChanged() + val boostCount = innerBoosts.map { it.note.boosts.size }.distinctUntilChanged() - val relayInfo = innerRelays.map { - it.note.relays - } + val relayInfo = innerRelays.map { it.note.relays } - val content = innerMetadata.map { - it.note.event?.content() ?: "" - } + val content = innerMetadata.map { it.note.event?.content() ?: "" } - fun isInUse(): Boolean { - return metadata.hasObservers() || - reactions.hasObservers() || - boosts.hasObservers() || - replies.hasObservers() || - reports.hasObservers() || - relays.hasObservers() || - zaps.hasObservers() || - authorChanges.hasObservers() || - hasEvent.hasObservers() || - hasReactions.hasObservers() || - replyCount.hasObservers() || - reactionCount.hasObservers() || - boostCount.hasObservers() - } + fun isInUse(): Boolean { + return metadata.hasObservers() || + reactions.hasObservers() || + boosts.hasObservers() || + replies.hasObservers() || + reports.hasObservers() || + relays.hasObservers() || + zaps.hasObservers() || + authorChanges.hasObservers() || + hasEvent.hasObservers() || + hasReactions.hasObservers() || + replyCount.hasObservers() || + reactionCount.hasObservers() || + boostCount.hasObservers() + } - fun destroy() { - innerMetadata.destroy() - innerReactions.destroy() - innerBoosts.destroy() - innerReplies.destroy() - innerReports.destroy() - innerRelays.destroy() - innerZaps.destroy() - } + fun destroy() { + innerMetadata.destroy() + innerReactions.destroy() + innerBoosts.destroy() + innerReplies.destroy() + innerReports.destroy() + innerRelays.destroy() + innerZaps.destroy() + } } @Stable class NoteBundledRefresherFlow(val note: Note) { - // Refreshes observers in batches. - private val bundler = BundledUpdate(500, Dispatchers.IO) - val stateFlow = MutableStateFlow(NoteState(note)) + // Refreshes observers in batches. + private val bundler = BundledUpdate(500, Dispatchers.IO) + val stateFlow = MutableStateFlow(NoteState(note)) - fun destroy() { - bundler.cancel() - } - - fun invalidateData() { - checkNotInMainThread() - - bundler.invalidate() { - checkNotInMainThread() - - stateFlow.emit(NoteState(note)) - } + fun destroy() { + bundler.cancel() + } + + fun invalidateData() { + checkNotInMainThread() + + bundler.invalidate { + checkNotInMainThread() + + stateFlow.emit(NoteState(note)) } + } } @Stable class NoteBundledRefresherLiveData(val note: Note) : LiveData(NoteState(note)) { - // Refreshes observers in batches. - private val bundler = BundledUpdate(500, Dispatchers.IO) + // Refreshes observers in batches. + private val bundler = BundledUpdate(500, Dispatchers.IO) - fun destroy() { - bundler.cancel() + fun destroy() { + bundler.cancel() + } + + fun invalidateData() { + checkNotInMainThread() + + bundler.invalidate { + checkNotInMainThread() + + postValue(NoteState(note)) } + } - fun invalidateData() { - checkNotInMainThread() - - bundler.invalidate() { - checkNotInMainThread() - - postValue(NoteState(note)) - } - } - - fun map( - transform: (NoteState) -> Y - ): NoteLoadingLiveData { - val initialValue = this.value?.let { transform(it) } - val result = NoteLoadingLiveData(note, initialValue) - result.addSource(this) { x -> result.value = transform(x) } - return result - } + fun map(transform: (NoteState) -> Y): NoteLoadingLiveData { + val initialValue = this.value?.let { transform(it) } + val result = NoteLoadingLiveData(note, initialValue) + result.addSource(this) { x -> result.value = transform(x) } + return result + } } @Stable class NoteLoadingLiveData(val note: Note, initialValue: Y?) : MediatorLiveData(initialValue) { - override fun onActive() { - super.onActive() - if (note is AddressableNote) { - NostrSingleEventDataSource.addAddress(note) - } else { - NostrSingleEventDataSource.add(note) - } + override fun onActive() { + super.onActive() + if (note is AddressableNote) { + NostrSingleEventDataSource.addAddress(note) + } else { + NostrSingleEventDataSource.add(note) } + } - override fun onInactive() { - super.onInactive() - if (note is AddressableNote) { - NostrSingleEventDataSource.removeAddress(note) - } else { - NostrSingleEventDataSource.remove(note) - } + override fun onInactive() { + super.onInactive() + if (note is AddressableNote) { + NostrSingleEventDataSource.removeAddress(note) + } else { + NostrSingleEventDataSource.remove(note) } + } } -@Immutable -class NoteState(val note: Note) +@Immutable class NoteState(val note: Note) object RelayBriefInfoCache { - val cache = LruCache(50) + val cache = LruCache(50) - @Immutable - data class RelayBriefInfo( - val url: String, - val displayUrl: String = url.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/").intern(), - val favIcon: String = "https://$displayUrl/favicon.ico".intern() - ) + @Immutable + data class RelayBriefInfo( + val url: String, + val displayUrl: String = + url.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/").intern(), + val favIcon: String = "https://$displayUrl/favicon.ico".intern(), + ) - fun get(url: String): RelayBriefInfo { - val info = cache[url] - if (info != null) return info + fun get(url: String): RelayBriefInfo { + val info = cache[url] + if (info != null) return info - val newInfo = RelayBriefInfo(url) - cache.put(url, newInfo) - return newInfo - } + val newInfo = RelayBriefInfo(url) + cache.put(url, newInfo) + return newInfo + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/ParticipantListBuilder.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/ParticipantListBuilder.kt index 544dc9cfe..260ff7d6c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/ParticipantListBuilder.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/ParticipantListBuilder.kt @@ -1,77 +1,116 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.model import com.vitorpamplona.quartz.encoders.HexKey class ParticipantListBuilder { - private fun addFollowsThatDirectlyParticipateOnToSet(baseNote: Note, followingSet: Set?, set: MutableSet) { - baseNote.author?.let { author -> - if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author) - } - - // Breaks these searchers down to avoid the memory use of creating multiple lists - baseNote.replies.forEach { reply -> - reply.author?.let { author -> - if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author) - } - } - - baseNote.boosts.forEach { boost -> - boost.author?.let { author -> - if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author) - } - } - - baseNote.zaps.forEach { zapPair -> - zapPair.key.author?.let { author -> - if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author) - } - } - - baseNote.reactions.forEach { reactionSet -> - reactionSet.value.forEach { reaction -> - reaction.author?.let { author -> - if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author) - } - } - } + private fun addFollowsThatDirectlyParticipateOnToSet( + baseNote: Note, + followingSet: Set?, + set: MutableSet, + ) { + baseNote.author?.let { author -> + if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) { + set.add(author) + } } - fun followsThatParticipateOnDirect(baseNote: Note?, followingSet: Set?): Set { - if (baseNote == null) return mutableSetOf() - - val set = mutableSetOf() - addFollowsThatDirectlyParticipateOnToSet(baseNote, followingSet, set) - return set + // Breaks these searchers down to avoid the memory use of creating multiple lists + baseNote.replies.forEach { reply -> + reply.author?.let { author -> + if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) { + set.add(author) + } + } } - fun followsThatParticipateOn(baseNote: Note?, followingSet: Set?): Set { - if (baseNote == null) return mutableSetOf() - - val mySet = mutableSetOf() - addFollowsThatDirectlyParticipateOnToSet(baseNote, followingSet, mySet) - - baseNote.replies.forEach { - addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet) + baseNote.boosts.forEach { boost -> + boost.author?.let { author -> + if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) { + set.add(author) } - - baseNote.boosts.forEach { - it.replyTo?.forEach { - addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet) - } - } - - LocalCache.getChannelIfExists(baseNote.idHex)?.notes?.values?.forEach { - addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet) - } - - return mySet + } } - fun countFollowsThatParticipateOn(baseNote: Note?, followingSet: Set?): Int { - if (baseNote == null) return 0 - - val list = followsThatParticipateOn(baseNote, followingSet) - - return list.size + baseNote.zaps.forEach { zapPair -> + zapPair.key.author?.let { author -> + if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) { + set.add(author) + } + } } + + baseNote.reactions.forEach { reactionSet -> + reactionSet.value.forEach { reaction -> + reaction.author?.let { author -> + if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) { + set.add(author) + } + } + } + } + } + + fun followsThatParticipateOnDirect( + baseNote: Note?, + followingSet: Set?, + ): Set { + if (baseNote == null) return mutableSetOf() + + val set = mutableSetOf() + addFollowsThatDirectlyParticipateOnToSet(baseNote, followingSet, set) + return set + } + + fun followsThatParticipateOn( + baseNote: Note?, + followingSet: Set?, + ): Set { + if (baseNote == null) return mutableSetOf() + + val mySet = mutableSetOf() + addFollowsThatDirectlyParticipateOnToSet(baseNote, followingSet, mySet) + + baseNote.replies.forEach { addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet) } + + baseNote.boosts.forEach { + it.replyTo?.forEach { addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet) } + } + + LocalCache.getChannelIfExists(baseNote.idHex)?.notes?.values?.forEach { + addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet) + } + + return mySet + } + + fun countFollowsThatParticipateOn( + baseNote: Note?, + followingSet: Set?, + ): Int { + if (baseNote == null) return 0 + + val list = followsThatParticipateOn(baseNote, followingSet) + + return list.size + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/RelayInformation.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/RelayInformation.kt index 81e38a037..be9f35b22 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/RelayInformation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/RelayInformation.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable @@ -6,55 +26,57 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper @Stable class RelayInformation( - val id: String?, - val name: String?, - val description: String?, - val pubkey: String?, - val contact: String?, - val supported_nips: List?, - val supported_nip_extensions: List?, - val software: String?, - val version: String?, - val limitation: RelayInformationLimitation?, - val relay_countries: List?, - val language_tags: List?, - val tags: List?, - val posting_policy: String?, - val payments_url: String?, - val fees: RelayInformationFees? + val id: String?, + val name: String?, + val description: String?, + val pubkey: String?, + val contact: String?, + val supported_nips: List?, + val supported_nip_extensions: List?, + val software: String?, + val version: String?, + val limitation: RelayInformationLimitation?, + val relay_countries: List?, + val language_tags: List?, + val tags: List?, + val posting_policy: String?, + val payments_url: String?, + val fees: RelayInformationFees?, ) { - companion object { - val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + companion object { + val mapper = + jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - fun fromJson(json: String): RelayInformation = mapper.readValue(json, RelayInformation::class.java) - } + fun fromJson(json: String): RelayInformation = + mapper.readValue(json, RelayInformation::class.java) + } } @Stable class RelayInformationFee( - val amount: Int?, - val unit: String?, - val period: Int?, - val kinds: List? + val amount: Int?, + val unit: String?, + val period: Int?, + val kinds: List?, ) class RelayInformationFees( - val admission: List?, - val subscription: List?, - val publication: List?, - val retention: List? + val admission: List?, + val subscription: List?, + val publication: List?, + val retention: List?, ) class RelayInformationLimitation( - val max_message_length: Int?, - val max_subscriptions: Int?, - val max_filters: Int?, - val max_limit: Int?, - val max_subid_length: Int?, - val min_prefix: Int?, - val max_event_tags: Int?, - val max_content_length: Int?, - val min_pow_difficulty: Int?, - val auth_required: Boolean?, - val payment_required: Boolean? + val max_message_length: Int?, + val max_subscriptions: Int?, + val max_filters: Int?, + val max_limit: Int?, + val max_subid_length: Int?, + val min_prefix: Int?, + val max_event_tags: Int?, + val max_content_length: Int?, + val min_pow_difficulty: Int?, + val auth_required: Boolean?, + val payment_required: Boolean?, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/RelaySetupInfo.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/RelaySetupInfo.kt index 79f36917d..413266bb8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/RelaySetupInfo.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/RelaySetupInfo.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Immutable @@ -5,15 +25,15 @@ import com.vitorpamplona.amethyst.service.relays.FeedType @Immutable data class RelaySetupInfo( - val url: String, - val read: Boolean, - val write: Boolean, - val errorCount: Int = 0, - val downloadCountInBytes: Int = 0, - val uploadCountInBytes: Int = 0, - val spamCount: Int = 0, - val feedTypes: Set, - val paidRelay: Boolean = false + val url: String, + val read: Boolean, + val write: Boolean, + val errorCount: Int = 0, + val downloadCountInBytes: Int = 0, + val uploadCountInBytes: Int = 0, + val spamCount: Int = 0, + val feedTypes: Set, + val paidRelay: Boolean = false, ) { - val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url) + val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Settings.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Settings.kt index 881a9a965..2dc0ece29 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Settings.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Settings.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable @@ -5,83 +25,83 @@ import com.vitorpamplona.amethyst.R @Stable data class Settings( - val theme: ThemeType = ThemeType.SYSTEM, - val preferredLanguage: String? = null, - val automaticallyShowImages: ConnectivityType = ConnectivityType.ALWAYS, - val automaticallyStartPlayback: ConnectivityType = ConnectivityType.ALWAYS, - val automaticallyShowUrlPreview: ConnectivityType = ConnectivityType.ALWAYS, - val automaticallyHideNavigationBars: BooleanType = BooleanType.ALWAYS, - val automaticallyShowProfilePictures: ConnectivityType = ConnectivityType.ALWAYS, - val dontShowPushNotificationSelector: Boolean = false, - val dontAskForNotificationPermissions: Boolean = false + val theme: ThemeType = ThemeType.SYSTEM, + val preferredLanguage: String? = null, + val automaticallyShowImages: ConnectivityType = ConnectivityType.ALWAYS, + val automaticallyStartPlayback: ConnectivityType = ConnectivityType.ALWAYS, + val automaticallyShowUrlPreview: ConnectivityType = ConnectivityType.ALWAYS, + val automaticallyHideNavigationBars: BooleanType = BooleanType.ALWAYS, + val automaticallyShowProfilePictures: ConnectivityType = ConnectivityType.ALWAYS, + val dontShowPushNotificationSelector: Boolean = false, + val dontAskForNotificationPermissions: Boolean = false, ) enum class ThemeType(val screenCode: Int, val resourceId: Int) { - SYSTEM(0, R.string.system), - LIGHT(1, R.string.light), - DARK(2, R.string.dark) + SYSTEM(0, R.string.system), + LIGHT(1, R.string.light), + DARK(2, R.string.dark), } fun parseThemeType(code: Int?): ThemeType { - return when (code) { - ThemeType.SYSTEM.screenCode -> ThemeType.SYSTEM - ThemeType.LIGHT.screenCode -> ThemeType.LIGHT - ThemeType.DARK.screenCode -> ThemeType.DARK - else -> { - ThemeType.SYSTEM - } + return when (code) { + ThemeType.SYSTEM.screenCode -> ThemeType.SYSTEM + ThemeType.LIGHT.screenCode -> ThemeType.LIGHT + ThemeType.DARK.screenCode -> ThemeType.DARK + else -> { + ThemeType.SYSTEM } + } } enum class ConnectivityType(val prefCode: Boolean?, val screenCode: Int, val resourceId: Int) { - ALWAYS(null, 0, R.string.connectivity_type_always), - WIFI_ONLY(true, 1, R.string.connectivity_type_wifi_only), - NEVER(false, 2, R.string.connectivity_type_never) + ALWAYS(null, 0, R.string.connectivity_type_always), + WIFI_ONLY(true, 1, R.string.connectivity_type_wifi_only), + NEVER(false, 2, R.string.connectivity_type_never), } fun parseConnectivityType(code: Boolean?): ConnectivityType { - return when (code) { - ConnectivityType.ALWAYS.prefCode -> ConnectivityType.ALWAYS - ConnectivityType.WIFI_ONLY.prefCode -> ConnectivityType.WIFI_ONLY - ConnectivityType.NEVER.prefCode -> ConnectivityType.NEVER - else -> { - ConnectivityType.ALWAYS - } + return when (code) { + ConnectivityType.ALWAYS.prefCode -> ConnectivityType.ALWAYS + ConnectivityType.WIFI_ONLY.prefCode -> ConnectivityType.WIFI_ONLY + ConnectivityType.NEVER.prefCode -> ConnectivityType.NEVER + else -> { + ConnectivityType.ALWAYS } + } } fun parseConnectivityType(screenCode: Int): ConnectivityType { - return when (screenCode) { - ConnectivityType.ALWAYS.screenCode -> ConnectivityType.ALWAYS - ConnectivityType.WIFI_ONLY.screenCode -> ConnectivityType.WIFI_ONLY - ConnectivityType.NEVER.screenCode -> ConnectivityType.NEVER - else -> { - ConnectivityType.ALWAYS - } + return when (screenCode) { + ConnectivityType.ALWAYS.screenCode -> ConnectivityType.ALWAYS + ConnectivityType.WIFI_ONLY.screenCode -> ConnectivityType.WIFI_ONLY + ConnectivityType.NEVER.screenCode -> ConnectivityType.NEVER + else -> { + ConnectivityType.ALWAYS } + } } enum class BooleanType(val prefCode: Boolean?, val screenCode: Int, val reourceId: Int) { - ALWAYS(null, 0, R.string.connectivity_type_always), - NEVER(false, 1, R.string.connectivity_type_never) + ALWAYS(null, 0, R.string.connectivity_type_always), + NEVER(false, 1, R.string.connectivity_type_never), } fun parseBooleanType(code: Boolean?): BooleanType { - return when (code) { - BooleanType.ALWAYS.prefCode -> BooleanType.ALWAYS - BooleanType.NEVER.prefCode -> BooleanType.NEVER - else -> { - BooleanType.ALWAYS - } + return when (code) { + BooleanType.ALWAYS.prefCode -> BooleanType.ALWAYS + BooleanType.NEVER.prefCode -> BooleanType.NEVER + else -> { + BooleanType.ALWAYS } + } } fun parseBooleanType(screenCode: Int): BooleanType { - return when (screenCode) { - BooleanType.ALWAYS.screenCode -> BooleanType.ALWAYS - BooleanType.NEVER.screenCode -> BooleanType.NEVER - else -> { - BooleanType.ALWAYS - } + return when (screenCode) { + BooleanType.ALWAYS.screenCode -> BooleanType.ALWAYS + BooleanType.NEVER.screenCode -> BooleanType.NEVER + else -> { + BooleanType.ALWAYS } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt index ea5c04904..440f2a861 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.model import com.vitorpamplona.amethyst.service.checkNotInMainThread @@ -6,77 +26,86 @@ import com.vitorpamplona.quartz.events.RepostEvent import kotlin.time.measureTimedValue class ThreadAssembler { + private fun searchRoot( + note: Note, + testedNotes: MutableSet = mutableSetOf(), + ): Note? { + if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note - private fun searchRoot(note: Note, testedNotes: MutableSet = mutableSetOf()): Note? { - if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note + if (note.event is RepostEvent || note.event is GenericRepostEvent) return note - if (note.event is RepostEvent || note.event is GenericRepostEvent) return note + testedNotes.add(note) - testedNotes.add(note) - - val markedAsRoot = note.event?.tags()?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1) - if (markedAsRoot != null) { - // Check to ssee if there is an error in the tag and the root has replies - if (LocalCache.getNoteIfExists(markedAsRoot)?.replyTo?.isEmpty() == true) { - return LocalCache.checkGetOrCreateNote(markedAsRoot) - } - } - - val hasNoReplyTo = note.replyTo?.reversed()?.firstOrNull { it.replyTo?.isEmpty() == true } - if (hasNoReplyTo != null) return hasNoReplyTo - - // recursive - val roots = note.replyTo?.map { - if (it !in testedNotes) { - searchRoot(it, testedNotes) - } else { - null - } - }?.filterNotNull() - - if (roots != null && roots.isNotEmpty()) { - return roots[0] - } - - return null + val markedAsRoot = + note.event + ?.tags() + ?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" } + ?.getOrNull(1) + if (markedAsRoot != null) { + // Check to ssee if there is an error in the tag and the root has replies + if (LocalCache.getNoteIfExists(markedAsRoot)?.replyTo?.isEmpty() == true) { + return LocalCache.checkGetOrCreateNote(markedAsRoot) + } } - fun findThreadFor(noteId: String): Set { - checkNotInMainThread() + val hasNoReplyTo = note.replyTo?.reversed()?.firstOrNull { it.replyTo?.isEmpty() == true } + if (hasNoReplyTo != null) return hasNoReplyTo - val (result, elapsed) = measureTimedValue { - val note = LocalCache.checkGetOrCreateNote(noteId) ?: return emptySet() - - if (note.event != null) { - val thread = mutableSetOf() - - val threadRoot = searchRoot(note, thread) ?: note - - loadDown(threadRoot, thread) - // adds the replies of the note in case the search for Root - // did not added them. - note.replies.forEach { - loadDown(it, thread) - } - - thread.toSet() - } else { - setOf(note) - } + // recursive + val roots = + note.replyTo + ?.map { + if (it !in testedNotes) { + searchRoot(it, testedNotes) + } else { + null + } } + ?.filterNotNull() - println("Model Refresh: Thread loaded in $elapsed") - - return result + if (roots != null && roots.isNotEmpty()) { + return roots[0] } - fun loadDown(note: Note, thread: MutableSet) { - if (note !in thread) { - thread.add(note) + return null + } - note.replies.forEach { - loadDown(it, thread) - } + fun findThreadFor(noteId: String): Set { + checkNotInMainThread() + + val (result, elapsed) = + measureTimedValue { + val note = LocalCache.checkGetOrCreateNote(noteId) ?: return emptySet() + + if (note.event != null) { + val thread = mutableSetOf() + + val threadRoot = searchRoot(note, thread) ?: note + + loadDown(threadRoot, thread) + // adds the replies of the note in case the search for Root + // did not added them. + note.replies.forEach { loadDown(it, thread) } + + thread.toSet() + } else { + setOf(note) } + } + + println("Model Refresh: Thread loaded in $elapsed") + + return result + } + + fun loadDown( + note: Note, + thread: MutableSet, + ) { + if (note !in thread) { + thread.add(note) + + note.replies.forEach { loadDown(it, thread) } } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt index 783b708eb..9a77ef5a2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.model import android.util.LruCache @@ -11,50 +31,55 @@ import kotlinx.coroutines.withContext @Stable object UrlCachedPreviewer { - var cache = LruCache(100) - private set + var cache = LruCache(100) + private set - suspend fun previewInfo( - url: String, - onReady: suspend (UrlPreviewState) -> Unit - ) = withContext(Dispatchers.IO) { - cache[url]?.let { - onReady(it) - return@withContext - } + suspend fun previewInfo( + url: String, + onReady: suspend (UrlPreviewState) -> Unit, + ) = + withContext(Dispatchers.IO) { + cache[url]?.let { + onReady(it) + return@withContext + } - BahaUrlPreview( - url, - object : IUrlPreviewCallback { - override suspend fun onComplete(urlInfo: UrlInfoItem) = withContext(Dispatchers.IO) { - cache[url]?.let { - if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) { - onReady(it) - return@withContext - } - } - - val state = if (urlInfo.fetchComplete() && urlInfo.url == url) { - UrlPreviewState.Loaded(urlInfo) - } else { - UrlPreviewState.Empty - } - - cache.put(url, state) - onReady(state) + BahaUrlPreview( + url, + object : IUrlPreviewCallback { + override suspend fun onComplete(urlInfo: UrlInfoItem) = + withContext(Dispatchers.IO) { + cache[url]?.let { + if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) { + onReady(it) + return@withContext + } } - override suspend fun onFailed(throwable: Throwable) = withContext(Dispatchers.IO) { - cache[url]?.let { - onReady(it) - return@withContext - } + val state = + if (urlInfo.fetchComplete() && urlInfo.url == url) { + UrlPreviewState.Loaded(urlInfo) + } else { + UrlPreviewState.Empty + } - val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview") - cache.put(url, state) - onReady(state) + cache.put(url, state) + onReady(state) + } + + override suspend fun onFailed(throwable: Throwable) = + withContext(Dispatchers.IO) { + cache[url]?.let { + onReady(it) + return@withContext } - } - ).fetchUrlPreview() + + val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview") + cache.put(url, state) + onReady(state) + } + }, + ) + .fetchUrlPreview() } } 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 7d5cbfe5a..951cbe02a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Immutable @@ -23,552 +43,572 @@ import com.vitorpamplona.quartz.events.MetadataEvent import com.vitorpamplona.quartz.events.ReportEvent import com.vitorpamplona.quartz.events.UserMetadata import com.vitorpamplona.quartz.events.toImmutableListOfLists +import java.math.BigDecimal import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow -import java.math.BigDecimal @Stable class User(val pubkeyHex: String) { - var info: UserMetadata? = null + var info: UserMetadata? = null - var latestContactList: ContactListEvent? = null - var latestBookmarkList: BookmarkListEvent? = null + var latestContactList: ContactListEvent? = null + var latestBookmarkList: BookmarkListEvent? = null - var reports = mapOf>() - private set + var reports = mapOf>() + private set - var latestEOSEs: Map = emptyMap() + var latestEOSEs: Map = emptyMap() - var zaps = mapOf() - private set + var zaps = mapOf() + private set - var relaysBeingUsed = mapOf() - private set + var relaysBeingUsed = mapOf() + private set - var privateChatrooms = mapOf() - private set + var privateChatrooms = mapOf() + private set - fun pubkey() = Hex.decode(pubkeyHex) - fun pubkeyNpub() = pubkey().toNpub() + fun pubkey() = Hex.decode(pubkeyHex) - fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex() + fun pubkeyNpub() = pubkey().toNpub() - fun toNostrUri() = "nostr:${pubkeyNpub()}" + fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex() - override fun toString(): String = pubkeyHex + fun toNostrUri() = "nostr:${pubkeyNpub()}" - fun toBestShortFirstName(): String { - val fullName = bestDisplayName() ?: bestUsername() ?: return pubkeyDisplayHex() + override fun toString(): String = pubkeyHex - val names = fullName.split(' ') + fun toBestShortFirstName(): String { + val fullName = bestDisplayName() ?: bestUsername() ?: return pubkeyDisplayHex() - val firstName = if (names[0].length <= 3) { - // too short. Remove Dr. - "${names[0]} ${names.getOrNull(1) ?: ""}" + val names = fullName.split(' ') + + val firstName = + if (names[0].length <= 3) { + // too short. Remove Dr. + "${names[0]} ${names.getOrNull(1) ?: ""}" + } else { + names[0] + } + + return firstName + } + + fun toBestDisplayName(): String { + return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex() + } + + fun bestUsername(): String? { + return info?.name?.ifBlank { null } ?: info?.username?.ifBlank { null } + } + + fun bestDisplayName(): String? { + return info?.displayName?.ifBlank { null } + } + + fun nip05(): String? { + return info?.nip05?.ifBlank { null } + } + + fun profilePicture(): String? { + if (info?.picture.isNullOrBlank()) info?.picture = null + return info?.picture + } + + fun updateBookmark(event: BookmarkListEvent) { + if (event.id == latestBookmarkList?.id) return + + latestBookmarkList = event + liveSet?.innerBookmarks?.invalidateData() + } + + fun clearEOSE() { + latestEOSEs = emptyMap() + } + + fun updateContactList(event: ContactListEvent) { + if (event.id == latestContactList?.id) return + + val oldContactListEvent = latestContactList + latestContactList = event + + // Update following of the current user + liveSet?.innerFollows?.invalidateData() + + // Update Followers of the past user list + // Update Followers of the new contact list + (oldContactListEvent)?.unverifiedFollowKeySet()?.forEach { + LocalCache.users[it]?.liveSet?.innerFollowers?.invalidateData() + } + (latestContactList)?.unverifiedFollowKeySet()?.forEach { + LocalCache.users[it]?.liveSet?.innerFollowers?.invalidateData() + } + + liveSet?.innerRelays?.invalidateData() + flowSet?.relays?.invalidateData() + } + + fun addReport(note: Note) { + val author = note.author ?: return + + val reportsBy = reports[author] + if (reportsBy == null) { + reports = reports + Pair(author, setOf(note)) + liveSet?.innerReports?.invalidateData() + } else if (!reportsBy.contains(note)) { + reports = reports + Pair(author, reportsBy + note) + liveSet?.innerReports?.invalidateData() + } + } + + fun removeReport(deleteNote: Note) { + val author = deleteNote.author ?: return + + if (reports[author]?.contains(deleteNote) == true) { + reports[author]?.let { + reports = reports + Pair(author, it.minus(deleteNote)) + liveSet?.innerReports?.invalidateData() + } + } + } + + fun addZap( + zapRequest: Note, + zap: Note?, + ) { + if (zaps[zapRequest] == null) { + zaps = zaps + Pair(zapRequest, zap) + liveSet?.innerZaps?.invalidateData() + } + } + + fun removeZap(zapRequestOrZapEvent: Note) { + if (zaps.containsKey(zapRequestOrZapEvent)) { + zaps = zaps.minus(zapRequestOrZapEvent) + liveSet?.innerZaps?.invalidateData() + } else if (zaps.containsValue(zapRequestOrZapEvent)) { + zaps = zaps.filter { it.value != zapRequestOrZapEvent } + liveSet?.innerZaps?.invalidateData() + } + } + + fun zappedAmount(): BigDecimal { + var amount = BigDecimal.ZERO + zaps.forEach { + val itemValue = (it.value?.event as? LnZapEvent)?.amount + if (itemValue != null) { + amount += itemValue + } + } + + return amount + } + + fun reportsBy(user: User): Set { + return reports[user] ?: emptySet() + } + + fun countReportAuthorsBy(users: Set): Int { + return reports.count { it.key.pubkeyHex in users } + } + + fun reportsBy(users: Set): List { + return reports + .mapNotNull { + if (it.key.pubkeyHex in users) { + it.value } else { - names[0] + null } + } + .flatten() + } - return firstName + @Synchronized + private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom { + checkNotInMainThread() + + return privateChatrooms[key] + ?: run { + val privateChatroom = Chatroom() + privateChatrooms = privateChatrooms + Pair(key, privateChatroom) + privateChatroom + } + } + + private fun getOrCreatePrivateChatroom(user: User): Chatroom { + val key = ChatroomKey(persistentSetOf(user.pubkeyHex)) + return getOrCreatePrivateChatroom(key) + } + + private fun getOrCreatePrivateChatroom(key: ChatroomKey): Chatroom { + return privateChatrooms[key] ?: getOrCreatePrivateChatroomSync(key) + } + + fun addMessage( + room: ChatroomKey, + msg: Note, + ) { + val privateChatroom = getOrCreatePrivateChatroom(room) + if (msg !in privateChatroom.roomMessages) { + privateChatroom.addMessageSync(msg) + liveSet?.innerMessages?.invalidateData() + } + } + + fun addMessage( + user: User, + msg: Note, + ) { + val privateChatroom = getOrCreatePrivateChatroom(user) + if (msg !in privateChatroom.roomMessages) { + privateChatroom.addMessageSync(msg) + liveSet?.innerMessages?.invalidateData() + } + } + + fun createChatroom(withKey: ChatroomKey) { + getOrCreatePrivateChatroom(withKey) + } + + fun removeMessage( + user: User, + msg: Note, + ) { + checkNotInMainThread() + + val privateChatroom = getOrCreatePrivateChatroom(user) + if (msg in privateChatroom.roomMessages) { + privateChatroom.removeMessageSync(msg) + liveSet?.innerMessages?.invalidateData() + } + } + + fun addRelayBeingUsed( + relay: Relay, + eventTime: Long, + ) { + val here = relaysBeingUsed[relay.url] + if (here == null) { + relaysBeingUsed = relaysBeingUsed + Pair(relay.url, RelayInfo(relay.url, eventTime, 1)) + } else { + if (eventTime > here.lastEvent) { + here.lastEvent = eventTime + } + here.counter++ } - fun toBestDisplayName(): String { - return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex() - } + liveSet?.innerRelayInfo?.invalidateData() + } - fun bestUsername(): String? { - return info?.name?.ifBlank { null } ?: info?.username?.ifBlank { null } - } + fun updateUserInfo( + newUserInfo: UserMetadata, + latestMetadata: MetadataEvent, + ) { + info = newUserInfo + info?.latestMetadata = latestMetadata + info?.updatedMetadataAt = latestMetadata.createdAt + info?.tags = latestMetadata.tags.toImmutableListOfLists() - fun bestDisplayName(): String? { - return info?.displayName?.ifBlank { null } ?: info?.display_name?.ifBlank { null } - } - - fun nip05(): String? { - return info?.nip05?.ifBlank { null } - } - - fun profilePicture(): String? { - if (info?.picture.isNullOrBlank()) info?.picture = null - return info?.picture - } - - fun updateBookmark(event: BookmarkListEvent) { - if (event.id == latestBookmarkList?.id) return - - latestBookmarkList = event - liveSet?.innerBookmarks?.invalidateData() - } - - fun clearEOSE() { - latestEOSEs = emptyMap() - } - - fun updateContactList(event: ContactListEvent) { - if (event.id == latestContactList?.id) return - - val oldContactListEvent = latestContactList - latestContactList = event - - // Update following of the current user - liveSet?.innerFollows?.invalidateData() - - // Update Followers of the past user list - // Update Followers of the new contact list - (oldContactListEvent)?.unverifiedFollowKeySet()?.forEach { - LocalCache.users[it]?.liveSet?.innerFollowers?.invalidateData() + if (newUserInfo.lud16.isNullOrBlank()) { + info?.lud06?.let { + if (it.lowercase().startsWith("lnurl")) { + info?.lud16 = Lud06().toLud16(it) } - (latestContactList)?.unverifiedFollowKeySet()?.forEach { - LocalCache.users[it]?.liveSet?.innerFollowers?.invalidateData() - } - - liveSet?.innerRelays?.invalidateData() - flowSet?.relays?.invalidateData() + } } - fun addReport(note: Note) { - val author = note.author ?: return + liveSet?.innerMetadata?.invalidateData() + } - val reportsBy = reports[author] - if (reportsBy == null) { - reports = reports + Pair(author, setOf(note)) - liveSet?.innerReports?.invalidateData() - } else if (!reportsBy.contains(note)) { - reports = reports + Pair(author, reportsBy + note) - liveSet?.innerReports?.invalidateData() - } + fun isFollowing(user: User): Boolean { + return latestContactList?.isTaggedUser(user.pubkeyHex) ?: false + } + + fun isFollowingHashtag(tag: String): Boolean { + return latestContactList?.isTaggedHash(tag) ?: false + } + + fun isFollowingHashtagCached(tag: String): Boolean { + return latestContactList?.verifiedFollowTagSet?.let { + return tag.lowercase() in it } + ?: false + } - fun removeReport(deleteNote: Note) { - val author = deleteNote.author ?: return - - if (reports[author]?.contains(deleteNote) == true) { - reports[author]?.let { - reports = reports + Pair(author, it.minus(deleteNote)) - liveSet?.innerReports?.invalidateData() - } - } + fun isFollowingGeohashCached(geoTag: String): Boolean { + return latestContactList?.verifiedFollowGeohashSet?.let { + return geoTag.lowercase() in it } + ?: false + } - fun addZap(zapRequest: Note, zap: Note?) { - if (zaps[zapRequest] == null) { - zaps = zaps + Pair(zapRequest, zap) - liveSet?.innerZaps?.invalidateData() - } + fun isFollowingCached(user: User): Boolean { + return latestContactList?.verifiedFollowKeySet?.let { + return user.pubkeyHex in it } + ?: false + } - fun removeZap(zapRequestOrZapEvent: Note) { - if (zaps.containsKey(zapRequestOrZapEvent)) { - zaps = zaps.minus(zapRequestOrZapEvent) - liveSet?.innerZaps?.invalidateData() - } else if (zaps.containsValue(zapRequestOrZapEvent)) { - zaps = zaps.filter { it.value != zapRequestOrZapEvent } - liveSet?.innerZaps?.invalidateData() - } + fun isFollowingCached(userHex: String): Boolean { + return latestContactList?.verifiedFollowKeySet?.let { + return userHex in it } + ?: false + } - fun zappedAmount(): BigDecimal { - var amount = BigDecimal.ZERO - zaps.forEach { - val itemValue = (it.value?.event as? LnZapEvent)?.amount - if (itemValue != null) { - amount += itemValue - } - } + fun transientFollowCount(): Int? { + return latestContactList?.unverifiedFollowKeySet()?.size + } - return amount + suspend fun transientFollowerCount(): Int { + return LocalCache.users.count { it.value.latestContactList?.isTaggedUser(pubkeyHex) ?: false } + } + + fun cachedFollowingKeySet(): Set { + return latestContactList?.verifiedFollowKeySet ?: emptySet() + } + + fun cachedFollowingTagSet(): Set { + return latestContactList?.verifiedFollowTagSet ?: emptySet() + } + + fun cachedFollowingGeohashSet(): Set { + return latestContactList?.verifiedFollowGeohashSet ?: emptySet() + } + + fun cachedFollowingCommunitiesSet(): Set { + return latestContactList?.verifiedFollowCommunitySet ?: emptySet() + } + + fun cachedFollowCount(): Int? { + return latestContactList?.verifiedFollowKeySet?.size + } + + suspend fun cachedFollowerCount(): Int { + return LocalCache.users.count { it.value.latestContactList?.isTaggedUser(pubkeyHex) ?: false } + } + + fun hasSentMessagesTo(key: ChatroomKey?): Boolean { + val messagesToUser = privateChatrooms[key] ?: return false + + return messagesToUser.roomMessages.any { this.pubkeyHex == it.author?.pubkeyHex } + } + + fun hasReport( + loggedIn: User, + type: ReportEvent.ReportType, + ): Boolean { + return reports[loggedIn]?.firstOrNull { + it.event is ReportEvent && + (it.event as ReportEvent).reportedAuthor().any { it.reportType == type } + } != null + } + + fun anyNameStartsWith(username: String): Boolean { + return info?.anyNameStartsWith(username) ?: false + } + + var liveSet: UserLiveSet? = null + var flowSet: UserFlowSet? = null + + fun live(): UserLiveSet { + if (liveSet == null) { + createOrDestroyLiveSync(true) } + return liveSet!! + } - fun reportsBy(user: User): Set { - return reports[user] ?: emptySet() + fun clearLive() { + if (liveSet != null && liveSet?.isInUse() == false) { + createOrDestroyLiveSync(false) } + } - fun countReportAuthorsBy(users: Set): Int { - return reports.count { it.key.pubkeyHex in users } + @Synchronized + fun createOrDestroyLiveSync(create: Boolean) { + if (create) { + if (liveSet == null) { + liveSet = UserLiveSet(this) + } + } else { + if (liveSet != null && liveSet?.isInUse() == false) { + liveSet?.destroy() + liveSet = null + } } + } - fun reportsBy(users: Set): List { - return reports.mapNotNull { - if (it.key.pubkeyHex in users) { - it.value - } else { - null - } - }.flatten() + @Synchronized + fun createOrDestroyFlowSync(create: Boolean) { + if (create) { + if (flowSet == null) { + flowSet = UserFlowSet(this) + } + } else { + if (flowSet != null && flowSet?.isInUse() == false) { + flowSet?.destroy() + flowSet = null + } } + } - @Synchronized - private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom { - checkNotInMainThread() - - return privateChatrooms[key] ?: run { - val privateChatroom = Chatroom() - privateChatrooms = privateChatrooms + Pair(key, privateChatroom) - privateChatroom - } + fun flow(): UserFlowSet { + if (flowSet == null) { + createOrDestroyFlowSync(true) } + return flowSet!! + } - private fun getOrCreatePrivateChatroom(user: User): Chatroom { - val key = ChatroomKey(persistentSetOf(user.pubkeyHex)) - return getOrCreatePrivateChatroom(key) - } - - private fun getOrCreatePrivateChatroom(key: ChatroomKey): Chatroom { - return privateChatrooms[key] ?: getOrCreatePrivateChatroomSync(key) - } - - fun addMessage(room: ChatroomKey, msg: Note) { - val privateChatroom = getOrCreatePrivateChatroom(room) - if (msg !in privateChatroom.roomMessages) { - privateChatroom.addMessageSync(msg) - liveSet?.innerMessages?.invalidateData() - } - } - - fun addMessage(user: User, msg: Note) { - val privateChatroom = getOrCreatePrivateChatroom(user) - if (msg !in privateChatroom.roomMessages) { - privateChatroom.addMessageSync(msg) - liveSet?.innerMessages?.invalidateData() - } - } - - fun createChatroom(withKey: ChatroomKey) { - getOrCreatePrivateChatroom(withKey) - } - - fun removeMessage(user: User, msg: Note) { - checkNotInMainThread() - - val privateChatroom = getOrCreatePrivateChatroom(user) - if (msg in privateChatroom.roomMessages) { - privateChatroom.removeMessageSync(msg) - liveSet?.innerMessages?.invalidateData() - } - } - - fun addRelayBeingUsed(relay: Relay, eventTime: Long) { - val here = relaysBeingUsed[relay.url] - if (here == null) { - relaysBeingUsed = relaysBeingUsed + Pair(relay.url, RelayInfo(relay.url, eventTime, 1)) - } else { - if (eventTime > here.lastEvent) { - here.lastEvent = eventTime - } - here.counter++ - } - - liveSet?.innerRelayInfo?.invalidateData() - } - - fun updateUserInfo(newUserInfo: UserMetadata, latestMetadata: MetadataEvent) { - info = newUserInfo - info?.latestMetadata = latestMetadata - info?.updatedMetadataAt = latestMetadata.createdAt - info?.tags = latestMetadata.tags.toImmutableListOfLists() - - if (newUserInfo.lud16.isNullOrBlank()) { - info?.lud06?.let { - if (it.lowercase().startsWith("lnurl")) { - info?.lud16 = Lud06().toLud16(it) - } - } - } - - liveSet?.innerMetadata?.invalidateData() - } - - fun isFollowing(user: User): Boolean { - return latestContactList?.isTaggedUser(user.pubkeyHex) ?: false - } - - fun isFollowingHashtag(tag: String): Boolean { - return latestContactList?.isTaggedHash(tag) ?: false - } - - fun isFollowingHashtagCached(tag: String): Boolean { - return latestContactList?.verifiedFollowTagSet?.let { - return tag.lowercase() in it - } ?: false - } - - fun isFollowingGeohashCached(geoTag: String): Boolean { - return latestContactList?.verifiedFollowGeohashSet?.let { - return geoTag.lowercase() in it - } ?: false - } - - fun isFollowingCached(user: User): Boolean { - return latestContactList?.verifiedFollowKeySet?.let { - return user.pubkeyHex in it - } ?: false - } - - fun isFollowingCached(userHex: String): Boolean { - return latestContactList?.verifiedFollowKeySet?.let { - return userHex in it - } ?: false - } - - fun transientFollowCount(): Int? { - return latestContactList?.unverifiedFollowKeySet()?.size - } - - suspend fun transientFollowerCount(): Int { - return LocalCache.users.count { it.value.latestContactList?.isTaggedUser(pubkeyHex) ?: false } - } - - fun cachedFollowingKeySet(): Set { - return latestContactList?.verifiedFollowKeySet ?: emptySet() - } - - fun cachedFollowingTagSet(): Set { - return latestContactList?.verifiedFollowTagSet ?: emptySet() - } - - fun cachedFollowingGeohashSet(): Set { - return latestContactList?.verifiedFollowGeohashSet ?: emptySet() - } - - fun cachedFollowingCommunitiesSet(): Set { - return latestContactList?.verifiedFollowCommunitySet ?: emptySet() - } - - fun cachedFollowCount(): Int? { - return latestContactList?.verifiedFollowKeySet?.size - } - - suspend fun cachedFollowerCount(): Int { - return LocalCache.users.count { it.value.latestContactList?.isTaggedUser(pubkeyHex) ?: false } - } - - fun hasSentMessagesTo(key: ChatroomKey?): Boolean { - val messagesToUser = privateChatrooms[key] ?: return false - - return messagesToUser.roomMessages.any { this.pubkeyHex == it.author?.pubkeyHex } - } - - fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean { - return reports[loggedIn]?.firstOrNull() { - it.event is ReportEvent && (it.event as ReportEvent).reportedAuthor().any { it.reportType == type } - } != null - } - - fun anyNameStartsWith(username: String): Boolean { - return info?.anyNameStartsWith(username) ?: false - } - - var liveSet: UserLiveSet? = null - var flowSet: UserFlowSet? = null - - fun live(): UserLiveSet { - if (liveSet == null) { - createOrDestroyLiveSync(true) - } - return liveSet!! - } - - fun clearLive() { - if (liveSet != null && liveSet?.isInUse() == false) { - createOrDestroyLiveSync(false) - } - } - - @Synchronized - fun createOrDestroyLiveSync(create: Boolean) { - if (create) { - if (liveSet == null) { - liveSet = UserLiveSet(this) - } - } else { - if (liveSet != null && liveSet?.isInUse() == false) { - liveSet?.destroy() - liveSet = null - } - } - } - - @Synchronized - fun createOrDestroyFlowSync(create: Boolean) { - if (create) { - if (flowSet == null) { - flowSet = UserFlowSet(this) - } - } else { - if (flowSet != null && flowSet?.isInUse() == false) { - flowSet?.destroy() - flowSet = null - } - } - } - - fun flow(): UserFlowSet { - if (flowSet == null) { - createOrDestroyFlowSync(true) - } - return flowSet!! - } - - fun clearFlow() { - if (flowSet != null && flowSet?.isInUse() == false) { - createOrDestroyFlowSync(false) - } + fun clearFlow() { + if (flowSet != null && flowSet?.isInUse() == false) { + createOrDestroyFlowSync(false) } + } } @Stable class UserFlowSet(u: User) { - // Observers line up here. - val relays = UserBundledRefresherFlow(u) + // Observers line up here. + val relays = UserBundledRefresherFlow(u) - fun isInUse(): Boolean { - return relays.stateFlow.subscriptionCount.value > 0 - } + fun isInUse(): Boolean { + return relays.stateFlow.subscriptionCount.value > 0 + } - fun destroy() { - relays.destroy() - } + fun destroy() { + relays.destroy() + } } @Stable class UserLiveSet(u: User) { - val innerMetadata = UserBundledRefresherLiveData(u) + val innerMetadata = UserBundledRefresherLiveData(u) - // UI Observers line up here. - val innerFollows = UserBundledRefresherLiveData(u) - val innerFollowers = UserBundledRefresherLiveData(u) - val innerReports = UserBundledRefresherLiveData(u) - val innerMessages = UserBundledRefresherLiveData(u) - val innerRelays = UserBundledRefresherLiveData(u) - val innerRelayInfo = UserBundledRefresherLiveData(u) - val innerZaps = UserBundledRefresherLiveData(u) - val innerBookmarks = UserBundledRefresherLiveData(u) - val innerStatuses = UserBundledRefresherLiveData(u) + // UI Observers line up here. + val innerFollows = UserBundledRefresherLiveData(u) + val innerFollowers = UserBundledRefresherLiveData(u) + val innerReports = UserBundledRefresherLiveData(u) + val innerMessages = UserBundledRefresherLiveData(u) + val innerRelays = UserBundledRefresherLiveData(u) + val innerRelayInfo = UserBundledRefresherLiveData(u) + val innerZaps = UserBundledRefresherLiveData(u) + val innerBookmarks = UserBundledRefresherLiveData(u) + val innerStatuses = UserBundledRefresherLiveData(u) - // UI Observers line up here. - val metadata = innerMetadata.map { it } - val follows = innerFollows.map { it } - val followers = innerFollowers.map { it } - val reports = innerReports.map { it } - val messages = innerMessages.map { it } - val relays = innerRelays.map { it } - val relayInfo = innerRelayInfo.map { it } - val zaps = innerZaps.map { it } - val bookmarks = innerBookmarks.map { - it - } - val statuses = innerStatuses.map { it } + // UI Observers line up here. + val metadata = innerMetadata.map { it } + val follows = innerFollows.map { it } + val followers = innerFollowers.map { it } + val reports = innerReports.map { it } + val messages = innerMessages.map { it } + val relays = innerRelays.map { it } + val relayInfo = innerRelayInfo.map { it } + val zaps = innerZaps.map { it } + val bookmarks = innerBookmarks.map { it } + val statuses = innerStatuses.map { it } - val profilePictureChanges = innerMetadata.map { - it.user.profilePicture() - }.distinctUntilChanged() + val profilePictureChanges = innerMetadata.map { it.user.profilePicture() }.distinctUntilChanged() - val nip05Changes = innerMetadata.map { - it.user.nip05() - }.distinctUntilChanged() + val nip05Changes = innerMetadata.map { it.user.nip05() }.distinctUntilChanged() - val userMetadataInfo = innerMetadata.map { - it.user.info - }.distinctUntilChanged() + val userMetadataInfo = innerMetadata.map { it.user.info }.distinctUntilChanged() - fun isInUse(): Boolean { - return metadata.hasObservers() || - follows.hasObservers() || - followers.hasObservers() || - reports.hasObservers() || - messages.hasObservers() || - relays.hasObservers() || - relayInfo.hasObservers() || - zaps.hasObservers() || - bookmarks.hasObservers() || - statuses.hasObservers() || - profilePictureChanges.hasObservers() || - nip05Changes.hasObservers() || - userMetadataInfo.hasObservers() - } + fun isInUse(): Boolean { + return metadata.hasObservers() || + follows.hasObservers() || + followers.hasObservers() || + reports.hasObservers() || + messages.hasObservers() || + relays.hasObservers() || + relayInfo.hasObservers() || + zaps.hasObservers() || + bookmarks.hasObservers() || + statuses.hasObservers() || + profilePictureChanges.hasObservers() || + nip05Changes.hasObservers() || + userMetadataInfo.hasObservers() + } - fun destroy() { - innerMetadata.destroy() - innerFollows.destroy() - innerFollowers.destroy() - innerReports.destroy() - innerMessages.destroy() - innerRelays.destroy() - innerRelayInfo.destroy() - innerZaps.destroy() - innerBookmarks.destroy() - innerStatuses.destroy() - } + fun destroy() { + innerMetadata.destroy() + innerFollows.destroy() + innerFollowers.destroy() + innerReports.destroy() + innerMessages.destroy() + innerRelays.destroy() + innerRelayInfo.destroy() + innerZaps.destroy() + innerBookmarks.destroy() + innerStatuses.destroy() + } } @Immutable data class RelayInfo( - val url: String, - var lastEvent: Long, - var counter: Long + val url: String, + var lastEvent: Long, + var counter: Long, ) class UserBundledRefresherLiveData(val user: User) : LiveData(UserState(user)) { - // Refreshes observers in batches. - private val bundler = BundledUpdate(500, Dispatchers.IO) + // Refreshes observers in batches. + private val bundler = BundledUpdate(500, Dispatchers.IO) - fun destroy() { - bundler.cancel() + fun destroy() { + bundler.cancel() + } + + fun invalidateData() { + checkNotInMainThread() + + bundler.invalidate { + checkNotInMainThread() + + postValue(UserState(user)) } + } - fun invalidateData() { - checkNotInMainThread() - - bundler.invalidate() { - checkNotInMainThread() - - postValue(UserState(user)) - } - } - - fun map( - transform: (UserState) -> Y - ): UserLoadingLiveData { - val initialValue = this.value?.let { transform(it) } - val result = UserLoadingLiveData(user, initialValue) - result.addSource(this) { x -> result.value = transform(x) } - return result - } + fun map(transform: (UserState) -> Y): UserLoadingLiveData { + val initialValue = this.value?.let { transform(it) } + val result = UserLoadingLiveData(user, initialValue) + result.addSource(this) { x -> result.value = transform(x) } + return result + } } @Stable class UserBundledRefresherFlow(val user: User) { - // Refreshes observers in batches. - private val bundler = BundledUpdate(500, Dispatchers.IO) - val stateFlow = MutableStateFlow(UserState(user)) + // Refreshes observers in batches. + private val bundler = BundledUpdate(500, Dispatchers.IO) + val stateFlow = MutableStateFlow(UserState(user)) - fun destroy() { - bundler.cancel() - } - - fun invalidateData() { - checkNotInMainThread() - - bundler.invalidate() { - checkNotInMainThread() - - stateFlow.emit(UserState(user)) - } + fun destroy() { + bundler.cancel() + } + + fun invalidateData() { + checkNotInMainThread() + + bundler.invalidate { + checkNotInMainThread() + + stateFlow.emit(UserState(user)) } + } } class UserLoadingLiveData(val user: User, initialValue: Y?) : MediatorLiveData(initialValue) { - override fun onActive() { - super.onActive() - NostrSingleUserDataSource.add(user) - } + override fun onActive() { + super.onActive() + NostrSingleUserDataSource.add(user) + } - override fun onInactive() { - super.onInactive() - NostrSingleUserDataSource.remove(user) - } + override fun onInactive() { + super.onInactive() + NostrSingleUserDataSource.remove(user) + } } -@Immutable -class UserState(val user: User) +@Immutable class UserState(val user: User) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt index 8efb55dc2..943600c68 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import android.graphics.Bitmap @@ -7,199 +27,296 @@ 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() + private val cacheCosinesY = HashMap() - // 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() - private val cacheCosinesY = HashMap() + /** + * 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() + } - /** - * 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 } - /** - * 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() + } - 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? { + checkNotInMainThread() + + if (blurHash == null || blurHash.length < 6) { + return null } - - /** - * 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? { - checkNotInMainThread() - - 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) + 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 } - - 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) + 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 { - ((v + 0.055f) / 1.055f).pow(2.4f) + 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 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 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 signedPow2(value: Float) = value.pow(2f).withSign(value) - - private fun composeBitmap( - width: Int, - height: Int, - numCompX: Int, - numCompY: Int, - colors: Array, - 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', '#', '$', '%', '*', '+', ',', - '-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~' + 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, ) - .mapIndexed { i, c -> c to i } - .toMap() + } + + private fun signedPow2(value: Float) = value.pow(2f).withSign(value) + + private fun composeBitmap( + width: Int, + height: Int, + numCompX: Int, + numCompY: Int, + colors: Array, + 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() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt index 86c4137c2..dcf177cb0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import android.content.Context @@ -17,52 +37,58 @@ import kotlin.math.roundToInt @Stable class BlurHashFetcher( - private val options: Options, - private val data: Uri + private val options: Options, + private val data: Uri, ) : Fetcher { + override suspend fun fetch(): FetchResult { + checkNotInMainThread() - override suspend fun fetch(): FetchResult { - checkNotInMainThread() + val encodedHash = data.toString().removePrefix("bluehash:") + val hash = URLDecoder.decode(encodedHash, "utf-8") - val encodedHash = data.toString().removePrefix("bluehash:") - val hash = URLDecoder.decode(encodedHash, "utf-8") + val aspectRatio = BlurHashDecoder.aspectRatio(hash) ?: 1.0f - val aspectRatio = BlurHashDecoder.aspectRatio(hash) ?: 1.0f + val preferredWidth = 100 - val preferredWidth = 100 + val bitmap = + BlurHashDecoder.decode( + hash, + preferredWidth, + (preferredWidth * (1 / aspectRatio)).roundToInt(), + ) - 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 - ) + if (bitmap == null) { + throw Exception("Unable to convert Bluehash $hash") } - object Factory : Fetcher.Factory { - override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher { - return BlurHashFetcher(options, data) - } + return DrawableResult( + drawable = bitmap.toDrawable(options.context.resources), + isSampled = false, + dataSource = DataSource.MEMORY, + ) + } + + object Factory : Fetcher.Factory { + 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") + fun imageRequest( + context: Context, + message: String, + ): ImageRequest { + val encodedMessage = URLEncoder.encode(message, "utf-8") - return ImageRequest - .Builder(context) - .data("bluehash:$encodedMessage") - .fetcherFactory(BlurHashFetcher.Factory) - .build() - } + return ImageRequest.Builder(context) + .data("bluehash:$encodedMessage") + .fetcherFactory(BlurHashFetcher.Factory) + .build() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt index 97e5372eb..c34368283 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import android.util.Log @@ -15,6 +35,7 @@ import com.vitorpamplona.amethyst.ui.components.removeQueryParamsForExtensionCom import com.vitorpamplona.amethyst.ui.components.tagIndex import com.vitorpamplona.amethyst.ui.components.videoExtensions import com.vitorpamplona.quartz.events.ImmutableListOfLists +import java.util.regex.Pattern import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.ImmutableSet @@ -22,295 +43,316 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toImmutableSet -import java.util.regex.Pattern @Immutable data class RichTextViewerState( - val urlSet: ImmutableSet, - val imagesForPager: ImmutableMap, - val imageList: ImmutableList, - val customEmoji: ImmutableMap, - val paragraphs: ImmutableList + val urlSet: ImmutableSet, + val imagesForPager: ImmutableMap, + val imageList: ImmutableList, + val customEmoji: ImmutableMap, + val paragraphs: ImmutableList, ) data class ParagraphState(val words: ImmutableList, val isRTL: Boolean) object CachedRichTextParser { - val richTextCache = LruCache(200) + val richTextCache = LruCache(200) - fun parseText(content: String, tags: ImmutableListOfLists): RichTextViewerState { - return if (richTextCache[content] != null) { - richTextCache[content] - } else { - val newUrls = RichTextParser().parseText(content, tags) - richTextCache.put(content, newUrls) - newUrls - } + fun parseText( + content: String, + tags: ImmutableListOfLists, + ): RichTextViewerState { + return if (richTextCache[content] != null) { + richTextCache[content] + } else { + val newUrls = RichTextParser().parseText(content, tags) + richTextCache.put(content, newUrls) + newUrls } + } } // Group 1 = url, group 4 additional chars -// val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)") +// val noProtocolUrlValidator = +// Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)") // Android9 seems to have an issue starting this regex. -val noProtocolUrlValidator = try { - Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+[^\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}])*\\/?)(.*)") -} catch (e: Exception) { - Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)") -} +val noProtocolUrlValidator = + try { + Pattern.compile( + "(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+[^\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}])*\\/?)(.*)", + ) + } catch (e: Exception) { + Pattern.compile( + "(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)", + ) + } -val HTTPRegex = "^((http|https)://)?([A-Za-z0-9-_]+(\\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?".toRegex(RegexOption.IGNORE_CASE) +val HTTPRegex = + "^((http|https)://)?([A-Za-z0-9-_]+(\\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?" + .toRegex(RegexOption.IGNORE_CASE) class RichTextParser() { - fun parseMediaUrl(fullUrl: String): ZoomableUrlContent? { - val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl) - return if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - val frags = Nip44UrlParser().parse(fullUrl) - ZoomableUrlImage( - url = fullUrl, - description = frags["alt"], - hash = frags["x"], - blurhash = frags["blurhash"], - dim = frags["dim"] - ) - } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { - val frags = Nip44UrlParser().parse(fullUrl) - ZoomableUrlVideo( - url = fullUrl, - description = frags["alt"], - hash = frags["x"], - blurhash = frags["blurhash"], - dim = frags["dim"] - ) + fun parseMediaUrl(fullUrl: String): ZoomableUrlContent? { + val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl) + return if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { + val frags = Nip44UrlParser().parse(fullUrl) + ZoomableUrlImage( + url = fullUrl, + description = frags["alt"], + hash = frags["x"], + blurhash = frags["blurhash"], + dim = frags["dim"], + ) + } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { + val frags = Nip44UrlParser().parse(fullUrl) + ZoomableUrlVideo( + url = fullUrl, + description = frags["alt"], + hash = frags["x"], + blurhash = frags["blurhash"], + dim = frags["dim"], + ) + } else { + null + } + } + + fun parseText( + content: String, + tags: ImmutableListOfLists, + ): RichTextViewerState { + val urls = UrlDetector(content, UrlDetectorOptions.Default).detect() + + val urlSet = + urls.mapNotNullTo(LinkedHashSet(urls.size)) { + // removes e-mails + if (Patterns.EMAIL_ADDRESS.matcher(it.originalUrl).matches()) { + null + } else if (isNumber(it.originalUrl)) { + null + } else if (it.originalUrl.contains("ใ€‚")) { + null } else { + if (HTTPRegex.matches(it.originalUrl)) { + it.originalUrl + } else { null + } } - } + } - fun parseText( - content: String, - tags: ImmutableListOfLists - ): RichTextViewerState { - val urls = UrlDetector(content, UrlDetectorOptions.Default).detect() + val imagesForPager = + urlSet.mapNotNull { fullUrl -> parseMediaUrl(fullUrl) }.associateBy { it.url } + val imageList = imagesForPager.values.toList() - val urlSet = urls.mapNotNullTo(LinkedHashSet(urls.size)) { - // removes e-mails - if (Patterns.EMAIL_ADDRESS.matcher(it.originalUrl).matches()) { - null - } else if (isNumber(it.originalUrl)) { - null - } else if (it.originalUrl.contains("ใ€‚")) { - null - } else { - if (HTTPRegex.matches(it.originalUrl)) { - it.originalUrl - } else { - null - } - } + val emojiMap = + tags.lists.filter { it.size > 2 && it[0] == "emoji" }.associate { ":${it[1]}:" to it[2] } + + val segments = findTextSegments(content, imagesForPager.keys, urlSet, emojiMap, tags) + + return RichTextViewerState( + urlSet.toImmutableSet(), + imagesForPager.toImmutableMap(), + imageList.toImmutableList(), + emojiMap.toImmutableMap(), + segments, + ) + } + + private fun findTextSegments( + content: String, + images: Set, + urls: Set, + emojis: Map, + tags: ImmutableListOfLists, + ): ImmutableList { + var paragraphSegments = persistentListOf() + + content.split('\n').forEach { paragraph -> + var segments = persistentListOf() + var isDirty = false + + val isRTL = isArabic(paragraph) + + val wordList = paragraph.trimEnd().split(' ') + wordList.forEach { word -> + val wordSegment = wordIdentifier(word, images, urls, emojis, tags) + if (wordSegment !is RegularTextSegment) { + isDirty = true } + segments = segments.add(wordSegment) + } - val imagesForPager = urlSet.mapNotNull { fullUrl -> - parseMediaUrl(fullUrl) - }.associateBy { it.url } - val imageList = imagesForPager.values.toList() - - val emojiMap = - tags.lists.filter { it.size > 2 && it[0] == "emoji" }.associate { ":${it[1]}:" to it[2] } - - val segments = findTextSegments(content, imagesForPager.keys, urlSet, emojiMap, tags) - - return RichTextViewerState( - urlSet.toImmutableSet(), - imagesForPager.toImmutableMap(), - imageList.toImmutableList(), - emojiMap.toImmutableMap(), - segments - ) - } - - private fun findTextSegments(content: String, images: Set, urls: Set, emojis: Map, tags: ImmutableListOfLists): ImmutableList { - var paragraphSegments = persistentListOf() - - content.split('\n').forEach { paragraph -> - var segments = persistentListOf() - var isDirty = false - - val isRTL = isArabic(paragraph) - - val wordList = paragraph.trimEnd().split(' ') - wordList.forEach { word -> - val wordSegment = wordIdentifier(word, images, urls, emojis, tags) - if (wordSegment !is RegularTextSegment) { - isDirty = true - } - segments = segments.add(wordSegment) - } - - val newSegments = if (isDirty) { - ParagraphState(segments, isRTL) - } else { - ParagraphState(persistentListOf(RegularTextSegment(paragraph)), isRTL) - } - - paragraphSegments = paragraphSegments.add(newSegments) - } - - return paragraphSegments - } - - fun isNumber(word: String): Boolean { - return numberPattern.matcher(word).matches() - } - - fun isDate(word: String): Boolean { - return shortDatePattern.matcher(word).matches() || longDatePattern.matcher(word).matches() - } - - private fun isArabic(text: String): Boolean { - return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' } - } - - private fun wordIdentifier(word: String, images: Set, urls: Set, emojis: Map, tags: ImmutableListOfLists): Segment { - val emailMatcher = Patterns.EMAIL_ADDRESS.matcher(word) - val phoneMatcher = Patterns.PHONE.matcher(word) - val schemelessMatcher = noProtocolUrlValidator.matcher(word) - - return if (word.isEmpty()) { - RegularTextSegment(word) - } else if (images.contains(word)) { - ImageSegment(word) - } else if (urls.contains(word)) { - LinkSegment(word) - } else if (emojis.any { word.contains(it.key) }) { - EmojiSegment(word) - } else if (word.startsWith("lnbc", true)) { - InvoiceSegment(word) - } else if (word.startsWith("lnurl", true)) { - WithdrawSegment(word) - } else if (word.startsWith("cashuA", true)) { - CashuSegment(word) - } else if (emailMatcher.matches()) { - EmailSegment(word) - } else if (word.length in 7..14 && !isDate(word) && phoneMatcher.matches()) { - PhoneSegment(word) - } else if (startsWithNIP19Scheme(word)) { - BechSegment(word) - } else if (word.startsWith("#")) { - parseHash(word, tags) - } else if (word.contains(".") && schemelessMatcher.find()) { - val url = schemelessMatcher.group(1) // url - val additionalChars = schemelessMatcher.group(4) // additional chars - val pattern = """^([A-Za-z0-9-_]+(\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\?[^#]*)?(#.*)?""".toRegex(RegexOption.IGNORE_CASE) - if (pattern.find(word) != null) { - SchemelessUrlSegment(word, url, additionalChars) - } else { - RegularTextSegment(word) - } + val newSegments = + if (isDirty) { + ParagraphState(segments, isRTL) } else { - RegularTextSegment(word) + ParagraphState(persistentListOf(RegularTextSegment(paragraph)), isRTL) } + + paragraphSegments = paragraphSegments.add(newSegments) } - private fun parseHash(word: String, tags: ImmutableListOfLists): Segment { - // First #[n] + return paragraphSegments + } - val matcher = tagIndex.matcher(word) - try { - if (matcher.find()) { - val index = matcher.group(1)?.toInt() - val suffix = matcher.group(2) + fun isNumber(word: String): Boolean { + return numberPattern.matcher(word).matches() + } - if (index != null && index >= 0 && index < tags.lists.size) { - val tag = tags.lists[index] + fun isDate(word: String): Boolean { + return shortDatePattern.matcher(word).matches() || longDatePattern.matcher(word).matches() + } - if (tag.size > 1) { - if (tag[0] == "p") { - return HashIndexUserSegment(word, tag[1], suffix) - } else if (tag[0] == "e" || tag[0] == "a") { - return HashIndexEventSegment(word, tag[1], suffix) - } - } - } + private fun isArabic(text: String): Boolean { + return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' } + } + + private fun wordIdentifier( + word: String, + images: Set, + urls: Set, + emojis: Map, + tags: ImmutableListOfLists, + ): Segment { + val emailMatcher = Patterns.EMAIL_ADDRESS.matcher(word) + val phoneMatcher = Patterns.PHONE.matcher(word) + val schemelessMatcher = noProtocolUrlValidator.matcher(word) + + return if (word.isEmpty()) { + RegularTextSegment(word) + } else if (images.contains(word)) { + ImageSegment(word) + } else if (urls.contains(word)) { + LinkSegment(word) + } else if (emojis.any { word.contains(it.key) }) { + EmojiSegment(word) + } else if (word.startsWith("lnbc", true)) { + InvoiceSegment(word) + } else if (word.startsWith("lnurl", true)) { + WithdrawSegment(word) + } else if (word.startsWith("cashuA", true)) { + CashuSegment(word) + } else if (emailMatcher.matches()) { + EmailSegment(word) + } else if (word.length in 7..14 && !isDate(word) && phoneMatcher.matches()) { + PhoneSegment(word) + } else if (startsWithNIP19Scheme(word)) { + BechSegment(word) + } else if (word.startsWith("#")) { + parseHash(word, tags) + } else if (word.contains(".") && schemelessMatcher.find()) { + val url = schemelessMatcher.group(1) // url + val additionalChars = schemelessMatcher.group(4) // additional chars + val pattern = + """^([A-Za-z0-9-_]+(\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\?[^#]*)?(#.*)?""" + .toRegex(RegexOption.IGNORE_CASE) + if (pattern.find(word) != null) { + SchemelessUrlSegment(word, url, additionalChars) + } else { + RegularTextSegment(word) + } + } else { + RegularTextSegment(word) + } + } + + private fun parseHash( + word: String, + tags: ImmutableListOfLists, + ): Segment { + // First #[n] + + val matcher = tagIndex.matcher(word) + try { + if (matcher.find()) { + val index = matcher.group(1)?.toInt() + val suffix = matcher.group(2) + + if (index != null && index >= 0 && index < tags.lists.size) { + val tag = tags.lists[index] + + if (tag.size > 1) { + if (tag[0] == "p") { + return HashIndexUserSegment(word, tag[1], suffix) + } else if (tag[0] == "e" || tag[0] == "a") { + return HashIndexEventSegment(word, tag[1], suffix) } - } catch (e: Exception) { - Log.w("Tag Parser", "Couldn't link tag $word", e) + } } - - // Second #Amethyst - val hashtagMatcher = hashTagsPattern.matcher(word) - - try { - if (hashtagMatcher.find()) { - val hashtag = hashtagMatcher.group(1) - if (hashtag != null) { - return HashTagSegment(word, hashtag, hashtagMatcher.group(2)) - } - } - } catch (e: Exception) { - Log.e("Hashtag Parser", "Couldn't link hashtag $word", e) - } - - return RegularTextSegment(word) + } + } catch (e: Exception) { + Log.w("Tag Parser", "Couldn't link tag $word", e) } - companion object { - val longDatePattern: Pattern = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$") - val shortDatePattern: Pattern = Pattern.compile("^\\d{2}-\\d{2}-\\d{2}$") - val numberPattern: Pattern = Pattern.compile("^(-?[\\d.]+)([a-zA-Z%]*)$") + // Second #Amethyst + val hashtagMatcher = hashTagsPattern.matcher(word) + + try { + if (hashtagMatcher.find()) { + val hashtag = hashtagMatcher.group(1) + if (hashtag != null) { + return HashTagSegment(word, hashtag, hashtagMatcher.group(2)) + } + } + } catch (e: Exception) { + Log.e("Hashtag Parser", "Couldn't link hashtag $word", e) } + + return RegularTextSegment(word) + } + + companion object { + val longDatePattern: Pattern = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$") + val shortDatePattern: Pattern = Pattern.compile("^\\d{2}-\\d{2}-\\d{2}$") + val numberPattern: Pattern = Pattern.compile("^(-?[\\d.]+)([a-zA-Z%]*)$") + } } -@Immutable -open class Segment(val segmentText: String) +@Immutable open class Segment(val segmentText: String) + +@Immutable class ImageSegment(segment: String) : Segment(segment) + +@Immutable class LinkSegment(segment: String) : Segment(segment) + +@Immutable class EmojiSegment(segment: String) : Segment(segment) + +@Immutable class InvoiceSegment(segment: String) : Segment(segment) + +@Immutable class WithdrawSegment(segment: String) : Segment(segment) + +@Immutable class CashuSegment(segment: String) : Segment(segment) + +@Immutable class EmailSegment(segment: String) : Segment(segment) + +@Immutable class PhoneSegment(segment: String) : Segment(segment) + +@Immutable class BechSegment(segment: String) : Segment(segment) @Immutable -class ImageSegment(segment: String) : Segment(segment) +open class HashIndexSegment(segment: String, val hex: String, val extras: String?) : + Segment(segment) @Immutable -class LinkSegment(segment: String) : Segment(segment) +class HashIndexUserSegment(segment: String, hex: String, extras: String?) : + HashIndexSegment(segment, hex, extras) @Immutable -class EmojiSegment(segment: String) : Segment(segment) - -@Immutable -class InvoiceSegment(segment: String) : Segment(segment) - -@Immutable -class WithdrawSegment(segment: String) : Segment(segment) - -@Immutable -class CashuSegment(segment: String) : Segment(segment) - -@Immutable -class EmailSegment(segment: String) : Segment(segment) - -@Immutable -class PhoneSegment(segment: String) : Segment(segment) - -@Immutable -class BechSegment(segment: String) : Segment(segment) - -@Immutable -open class HashIndexSegment(segment: String, val hex: String, val extras: String?) : Segment(segment) - -@Immutable -class HashIndexUserSegment(segment: String, hex: String, extras: String?) : HashIndexSegment(segment, hex, extras) - -@Immutable -class HashIndexEventSegment(segment: String, hex: String, extras: String?) : HashIndexSegment(segment, hex, extras) +class HashIndexEventSegment(segment: String, hex: String, extras: String?) : + HashIndexSegment(segment, hex, extras) @Immutable class HashTagSegment(segment: String, val hashtag: String, val extras: String?) : Segment(segment) @Immutable -class SchemelessUrlSegment(segment: String, val url: String, val extras: String?) : Segment(segment) +class SchemelessUrlSegment(segment: String, val url: String, val extras: String?) : + Segment(segment) -@Immutable -class RegularTextSegment(segment: String) : Segment(segment) +@Immutable class RegularTextSegment(segment: String) : Segment(segment) fun startsWithNIP19Scheme(word: String): Boolean { - val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@") + val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@") - return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it) } + return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt index d4b41afa4..8a97bf34b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import android.content.Context @@ -8,174 +28,193 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.quartz.events.Event +import java.util.Base64 import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody -import java.util.Base64 @Immutable data class CashuToken( - val token: String, - val mint: String, - val totalAmount: Long, - val proofs: JsonNode + val token: String, + val mint: String, + val totalAmount: Long, + val proofs: JsonNode, ) class CashuProcessor { - fun parse(cashuToken: String): GenericLoadable { - checkNotInMainThread() + fun parse(cashuToken: String): GenericLoadable { + checkNotInMainThread() - try { - val base64token = cashuToken.replace("cashuA", "") - val cashu = jacksonObjectMapper().readTree(String(Base64.getDecoder().decode(base64token))) - val token = cashu.get("token").get(0) - val proofs = token.get("proofs") - val mint = token.get("mint").asText() + try { + val base64token = cashuToken.replace("cashuA", "") + val cashu = jacksonObjectMapper().readTree(String(Base64.getDecoder().decode(base64token))) + val token = cashu.get("token").get(0) + val proofs = token.get("proofs") + val mint = token.get("mint").asText() - var totalAmount = 0L - for (proof in proofs) { - totalAmount += proof.get("amount").asLong() - } + var totalAmount = 0L + for (proof in proofs) { + totalAmount += proof.get("amount").asLong() + } - return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, proofs)) - } catch (e: Exception) { - return GenericLoadable.Error("Could not parse this cashu token") - } + return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, proofs)) + } catch (e: Exception) { + return GenericLoadable.Error("Could not parse this cashu token") } + } - suspend fun melt(token: CashuToken, lud16: String, onSuccess: (String, String) -> Unit, onError: (String, String) -> Unit, context: Context) { - checkNotInMainThread() + suspend fun melt( + token: CashuToken, + lud16: String, + onSuccess: (String, String) -> Unit, + onError: (String, String) -> Unit, + context: Context, + ) { + checkNotInMainThread() - runCatching { - LightningAddressResolver().lnAddressInvoice( - lnaddress = lud16, - milliSats = token.totalAmount * 1000, // Make invoice and leave room for fees - message = "Calculate Fees for Cashu", - onSuccess = { baseInvoice -> - feeCalculator( - token.mint, - baseInvoice, - onSuccess = { fees -> - LightningAddressResolver().lnAddressInvoice( - lnaddress = lud16, - milliSats = (token.totalAmount - fees) * 1000, // Make invoice and leave room for fees - message = "Redeem Cashu", - onSuccess = { invoice -> - meltInvoice(token, invoice, fees, onSuccess, onError, context) - }, - onProgress = { - }, - onError = onError, - context = context - ) - }, - onError = onError, - context - ) - }, - onProgress = { - }, - onError = onError, - context = context + runCatching { + LightningAddressResolver() + .lnAddressInvoice( + lnaddress = lud16, + // Make invoice and leave room for fees + milliSats = token.totalAmount * 1000, + message = "Calculate Fees for Cashu", + onSuccess = { baseInvoice -> + feeCalculator( + token.mint, + baseInvoice, + onSuccess = { fees -> + LightningAddressResolver() + .lnAddressInvoice( + lnaddress = lud16, + // Make invoice and leave room for fees + milliSats = (token.totalAmount - fees) * 1000, + message = "Redeem Cashu", + onSuccess = { invoice -> + meltInvoice(token, invoice, fees, onSuccess, onError, context) + }, + onProgress = {}, + onError = onError, + context = context, + ) + }, + onError = onError, + context, ) - } + }, + onProgress = {}, + onError = onError, + context = context, + ) } + } - fun feeCalculator( - mintAddress: String, - invoice: String, - onSuccess: (Int) -> Unit, - onError: (String, String) -> Unit, - context: Context - ) { - checkNotInMainThread() + fun feeCalculator( + mintAddress: String, + invoice: String, + onSuccess: (Int) -> Unit, + onError: (String, String) -> Unit, + context: Context, + ) { + checkNotInMainThread() - try { - val client = HttpClient.getHttpClient() - val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint + try { + val client = HttpClient.getHttpClient() + val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint - val factory = Event.mapper.nodeFactory + val factory = Event.mapper.nodeFactory - val jsonObject = factory.objectNode() - jsonObject.put("pr", invoice) + val jsonObject = factory.objectNode() + jsonObject.put("pr", invoice) - val mediaType = "application/json; charset=utf-8".toMediaType() - val requestBody = jsonObject.toString().toRequestBody(mediaType) - val request = Request.Builder() - .url(url) - .post(requestBody) - .build() + val mediaType = "application/json; charset=utf-8".toMediaType() + val requestBody = jsonObject.toString().toRequestBody(mediaType) + val request = Request.Builder().url(url).post(requestBody).build() - client.newCall(request).execute().use { - val body = it.body.string() - val tree = jacksonObjectMapper().readTree(body) + client.newCall(request).execute().use { + val body = it.body.string() + val tree = jacksonObjectMapper().readTree(body) - val feeCost = tree?.get("fee")?.asInt() + val feeCost = tree?.get("fee")?.asInt() - if (feeCost != null) { - onSuccess( - feeCost - ) - } else { - val msg = tree?.get("detail")?.asText()?.split('.')?.getOrNull(0)?.ifBlank { null } - onError( - context.getString(R.string.cashu_failed_redemption), - if (msg != null) { - context.getString(R.string.cashu_failed_redemption_explainer_error_msg, msg) - } else { - context.getString(R.string.cashu_failed_redemption_explainer_error_msg) - } - ) - } - } - } catch (e: Exception) { - onError(context.getString(R.string.cashu_successful_redemption), context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message)) + if (feeCost != null) { + onSuccess( + feeCost, + ) + } else { + val msg = tree?.get("detail")?.asText()?.split('.')?.getOrNull(0)?.ifBlank { null } + onError( + context.getString(R.string.cashu_failed_redemption), + if (msg != null) { + context.getString(R.string.cashu_failed_redemption_explainer_error_msg, msg) + } else { + context.getString(R.string.cashu_failed_redemption_explainer_error_msg) + }, + ) } + } + } catch (e: Exception) { + onError( + context.getString(R.string.cashu_successful_redemption), + context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message), + ) } + } - private fun meltInvoice(token: CashuToken, invoice: String, fees: Int, onSuccess: (String, String) -> Unit, onError: (String, String) -> Unit, context: Context) { - try { - val client = HttpClient.getHttpClient() - val url = token.mint + "/melt" // Melt cashu tokens at Mint + private fun meltInvoice( + token: CashuToken, + invoice: String, + fees: Int, + onSuccess: (String, String) -> Unit, + onError: (String, String) -> Unit, + context: Context, + ) { + try { + val client = HttpClient.getHttpClient() + val url = token.mint + "/melt" // Melt cashu tokens at Mint - val factory = Event.mapper.nodeFactory + val factory = Event.mapper.nodeFactory - val jsonObject = factory.objectNode() - jsonObject.put("proofs", token.proofs) - jsonObject.put("pr", invoice) + val jsonObject = factory.objectNode() + jsonObject.put("proofs", token.proofs) + jsonObject.put("pr", invoice) - val mediaType = "application/json; charset=utf-8".toMediaType() - val requestBody = jsonObject.toString().toRequestBody(mediaType) - val request = Request.Builder() - .url(url) - .post(requestBody) - .build() + val mediaType = "application/json; charset=utf-8".toMediaType() + val requestBody = jsonObject.toString().toRequestBody(mediaType) + val request = Request.Builder().url(url).post(requestBody).build() - client.newCall(request).execute().use { - val body = it.body.string() - val tree = jacksonObjectMapper().readTree(body) + client.newCall(request).execute().use { + val body = it.body.string() + val tree = jacksonObjectMapper().readTree(body) - val successful = tree?.get("paid")?.asText() == "true" + val successful = tree?.get("paid")?.asText() == "true" - if (successful) { - onSuccess( - context.getString(R.string.cashu_successful_redemption), - context.getString(R.string.cashu_successful_redemption_explainer, token.totalAmount.toString(), fees.toString()) - ) - } else { - val msg = tree?.get("detail")?.asText()?.split('.')?.getOrNull(0)?.ifBlank { null } - onError( - context.getString(R.string.cashu_failed_redemption), - if (msg != null) { - context.getString(R.string.cashu_failed_redemption_explainer_error_msg, msg) - } else { - context.getString(R.string.cashu_failed_redemption_explainer_error_msg) - } - ) - } - } - } catch (e: Exception) { - onError(context.getString(R.string.cashu_successful_redemption), context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message)) + if (successful) { + onSuccess( + context.getString(R.string.cashu_successful_redemption), + context.getString( + R.string.cashu_successful_redemption_explainer, + token.totalAmount.toString(), + fees.toString(), + ), + ) + } else { + val msg = tree?.get("detail")?.asText()?.split('.')?.getOrNull(0)?.ifBlank { null } + onError( + context.getString(R.string.cashu_failed_redemption), + if (msg != null) { + context.getString(R.string.cashu_failed_redemption_explainer_error_msg, msg) + } else { + context.getString(R.string.cashu_failed_redemption_explainer_error_msg) + }, + ) } + } + } catch (e: Exception) { + onError( + context.getString(R.string.cashu_successful_redemption), + context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message), + ) } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/EmojiUtils.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/EmojiUtils.kt index 85f2e059b..8a83c6bcf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/EmojiUtils.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/EmojiUtils.kt @@ -1,107 +1,128 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import com.vitorpamplona.quartz.events.ImmutableListOfLists fun String.isUTF16Char(pos: Int): Boolean { - return Character.charCount(this.codePointAt(pos)) == 2 + return Character.charCount(this.codePointAt(pos)) == 2 } fun String.firstFullCharOld(): String { - return when (this.length) { - 0, 1 -> return this - 2, 3 -> return if (isUTF16Char(0)) this.take(2) else this.take(1) - else -> { - val first = isUTF16Char(0) - val second = isUTF16Char(2) - if (first && second) { - this.take(4) - } else if (first) { - this.take(2) - } else { - this.take(1) - } - } + return when (this.length) { + 0, + 1, -> return this + 2, + 3, -> return if (isUTF16Char(0)) this.take(2) else this.take(1) + else -> { + val first = isUTF16Char(0) + val second = isUTF16Char(2) + if (first && second) { + this.take(4) + } else if (first) { + this.take(2) + } else { + this.take(1) + } } + } } fun String.firstFullChar(): String { - var isInJoin = false - var hasHadSecondChance = false - var start = 0 - var previousCharLength = 0 - var next: Int - var codePoint: Int + var isInJoin = false + var hasHadSecondChance = false + var start = 0 + var previousCharLength = 0 + var next: Int + var codePoint: Int - var i = 0 + var i = 0 - while (i < this.length) { - codePoint = codePointAt(i) + while (i < this.length) { + codePoint = codePointAt(i) - // Skips if it starts with the join char 0x200D - if (codePoint == 0x200D && previousCharLength == 0) { - next = offsetByCodePoints(i, 1) - start = next + // Skips if it starts with the join char 0x200D + if (codePoint == 0x200D && previousCharLength == 0) { + next = offsetByCodePoints(i, 1) + start = next + } else { + // If join, searches for the next char + if (codePoint == 0xFE0F) {} else if (codePoint == 0x200D) { + isInJoin = true + } else { + // stops when two chars are not joined together + if (previousCharLength > 0 && !isInJoin) { + if (Character.charCount(codePoint) == 1 || hasHadSecondChance) { + break + } else { + hasHadSecondChance = true + } } else { - // If join, searches for the next char - if (codePoint == 0xFE0F) { - } else if (codePoint == 0x200D) { - isInJoin = true - } else { - // stops when two chars are not joined together - if (previousCharLength > 0 && !isInJoin) { - if (Character.charCount(codePoint) == 1 || hasHadSecondChance) { - break - } else { - hasHadSecondChance = true - } - } else { - hasHadSecondChance = false - } - - isInJoin = false - } - - // next char to evaluate - next = offsetByCodePoints(i, 1) - previousCharLength += (next - i) + hasHadSecondChance = false } - i = next + isInJoin = false + } + + // next char to evaluate + next = offsetByCodePoints(i, 1) + previousCharLength += (next - i) } - // if ends in join, then seachers backwards until a char is found. - if (isInJoin) { - i = previousCharLength - 1 - while (i > 0) { - if (this[i].code == 0x200D) { - previousCharLength -= 1 - } else { - break - } + i = next + } - i -= 1 - } + // if ends in join, then seachers backwards until a char is found. + if (isInJoin) { + i = previousCharLength - 1 + while (i > 0) { + if (this[i].code == 0x200D) { + previousCharLength -= 1 + } else { + break + } + + i -= 1 } + } - return substring(start, start + previousCharLength) + return substring(start, start + previousCharLength) } fun String.firstFullCharOrEmoji(tags: ImmutableListOfLists): String { - if (length <= 2) { - return firstFullChar() - } - - if (this[0] == ':') { - // makes sure an emoji exists - val emojiParts = this.split(":", limit = 3) - if (emojiParts.size >= 2) { - val emojiName = emojiParts[1] - val emojiUrl = tags.lists.firstOrNull() { it.size > 1 && it[1] == emojiName }?.getOrNull(2) - if (emojiUrl != null) { - return ":$emojiName:$emojiUrl" - } - } - } - + if (length <= 2) { return firstFullChar() + } + + if (this[0] == ':') { + // makes sure an emoji exists + val emojiParts = this.split(":", limit = 3) + if (emojiParts.size >= 2) { + val emojiName = emojiParts[1] + val emojiUrl = tags.lists.firstOrNull { it.size > 1 && it[1] == emojiName }?.getOrNull(2) + if (emojiUrl != null) { + return ":$emojiName:$emojiUrl" + } + } + } + + return firstFullChar() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt index 7f42f671b..2e126c033 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import android.graphics.Bitmap @@ -16,191 +36,229 @@ import java.io.IOException import kotlin.math.roundToInt class FileHeader( - val mimeType: String?, - val hash: String, - val size: Int, - val dim: String?, - val blurHash: String? + val mimeType: String?, + val hash: String, + val size: Int, + val dim: String?, + val blurHash: String?, ) { - companion object { - suspend fun prepare( - fileUrl: String, - mimeType: String?, - dimPrecomputed: String?, - onReady: (FileHeader) -> Unit, - onError: (String?) -> Unit - ) { - try { - val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl) + companion object { + suspend fun prepare( + fileUrl: String, + mimeType: String?, + dimPrecomputed: String?, + onReady: (FileHeader) -> Unit, + onError: (String?) -> Unit, + ) { + try { + val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl) - if (imageData != null) { - prepare(imageData, mimeType, dimPrecomputed, onReady, onError) - } else { - onError(null) - } - } catch (e: Exception) { - Log.e("ImageDownload", "Couldn't download image from server: ${e.message}") - onError(e.message) - } - } - - fun prepare( - data: ByteArray, - mimeType: String?, - dimPrecomputed: String?, - onReady: (FileHeader) -> Unit, - onError: (String?) -> Unit - ) { - try { - val hash = CryptoUtils.sha256(data).toHexKey() - val size = data.size - - val (blurHash, dim) = if (mimeType?.startsWith("image/") == true) { - val opt = BitmapFactory.Options() - opt.inPreferredConfig = Bitmap.Config.ARGB_8888 - val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt) - - val intArray = IntArray(mBitmap.width * mBitmap.height) - mBitmap.getPixels( - intArray, - 0, - mBitmap.width, - 0, - 0, - mBitmap.width, - mBitmap.height - ) - - val dim = "${mBitmap.width}x${mBitmap.height}" - - val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat() - - if (aspectRatio > 1) { - Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 9, (9 * (1 / aspectRatio)).roundToInt()), dim) - } else if (aspectRatio < 1) { - Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, (9 * aspectRatio).roundToInt(), 9), dim) - } else { - Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim) - } - } else if (mimeType?.startsWith("video/") == true) { - val mediaMetadataRetriever = MediaMetadataRetriever() - mediaMetadataRetriever.setDataSource(ByteArrayMediaDataSource(data)) - - val newDim = mediaMetadataRetriever.prepareDimFromVideo() ?: dimPrecomputed - - val blurhash = mediaMetadataRetriever.getThumbnail()?.let { thumbnail -> - val aspectRatio = (thumbnail.width).toFloat() / (thumbnail.height).toFloat() - - val intArray = IntArray(thumbnail.width * thumbnail.height) - thumbnail.getPixels( - intArray, - 0, - thumbnail.width, - 0, - 0, - thumbnail.width, - thumbnail.height - ) - - if (aspectRatio > 1) { - BlurHash.encode(intArray, thumbnail.width, thumbnail.height, 9, (9 * (1 / aspectRatio)).roundToInt()) - } else if (aspectRatio < 1) { - BlurHash.encode(intArray, thumbnail.width, thumbnail.height, (9 * aspectRatio).roundToInt(), 9) - } else { - BlurHash.encode(intArray, thumbnail.width, thumbnail.height, 4, 4) - } - } - - if (newDim != "0x0") { - Pair(blurhash, newDim) - } else { - Pair(blurhash, null) - } - } else { - Pair(null, null) - } - - onReady(FileHeader(mimeType, hash, size, dim, blurHash)) - } catch (e: Exception) { - Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}") - onError(e.message) - } + if (imageData != null) { + prepare(imageData, mimeType, dimPrecomputed, onReady, onError) + } else { + onError(null) } + } catch (e: Exception) { + Log.e("ImageDownload", "Couldn't download image from server: ${e.message}") + onError(e.message) + } } + + fun prepare( + data: ByteArray, + mimeType: String?, + dimPrecomputed: String?, + onReady: (FileHeader) -> Unit, + onError: (String?) -> Unit, + ) { + try { + val hash = CryptoUtils.sha256(data).toHexKey() + val size = data.size + + val (blurHash, dim) = + if (mimeType?.startsWith("image/") == true) { + val opt = BitmapFactory.Options() + opt.inPreferredConfig = Bitmap.Config.ARGB_8888 + val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt) + + val intArray = IntArray(mBitmap.width * mBitmap.height) + mBitmap.getPixels( + intArray, + 0, + mBitmap.width, + 0, + 0, + mBitmap.width, + mBitmap.height, + ) + + val dim = "${mBitmap.width}x${mBitmap.height}" + + val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat() + + if (aspectRatio > 1) { + Pair( + BlurHash.encode( + intArray, + mBitmap.width, + mBitmap.height, + 9, + (9 * (1 / aspectRatio)).roundToInt(), + ), + dim, + ) + } else if (aspectRatio < 1) { + Pair( + BlurHash.encode( + intArray, + mBitmap.width, + mBitmap.height, + (9 * aspectRatio).roundToInt(), + 9, + ), + dim, + ) + } else { + Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim) + } + } else if (mimeType?.startsWith("video/") == true) { + val mediaMetadataRetriever = MediaMetadataRetriever() + mediaMetadataRetriever.setDataSource(ByteArrayMediaDataSource(data)) + + val newDim = mediaMetadataRetriever.prepareDimFromVideo() ?: dimPrecomputed + + val blurhash = + mediaMetadataRetriever.getThumbnail()?.let { thumbnail -> + val aspectRatio = (thumbnail.width).toFloat() / (thumbnail.height).toFloat() + + val intArray = IntArray(thumbnail.width * thumbnail.height) + thumbnail.getPixels( + intArray, + 0, + thumbnail.width, + 0, + 0, + thumbnail.width, + thumbnail.height, + ) + + if (aspectRatio > 1) { + BlurHash.encode( + intArray, + thumbnail.width, + thumbnail.height, + 9, + (9 * (1 / aspectRatio)).roundToInt(), + ) + } else if (aspectRatio < 1) { + BlurHash.encode( + intArray, + thumbnail.width, + thumbnail.height, + (9 * aspectRatio).roundToInt(), + 9, + ) + } else { + BlurHash.encode(intArray, thumbnail.width, thumbnail.height, 4, 4) + } + } + + if (newDim != "0x0") { + Pair(blurhash, newDim) + } else { + Pair(blurhash, null) + } + } else { + Pair(null, null) + } + + onReady(FileHeader(mimeType, hash, size, dim, blurHash)) + } catch (e: Exception) { + Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}") + onError(e.message) + } + } + } } fun MediaMetadataRetriever.getThumbnail(): Bitmap? { - val raw: ByteArray? = getEmbeddedPicture() - if (raw != null) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - return ImageDecoder.decodeBitmap(ImageDecoder.createSource(raw)) - } + val raw: ByteArray? = getEmbeddedPicture() + if (raw != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + return ImageDecoder.decodeBitmap(ImageDecoder.createSource(raw)) } + } - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - val params = BitmapParams() - params.preferredConfig = Bitmap.Config.ARGB_8888 + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val params = BitmapParams() + params.preferredConfig = Bitmap.Config.ARGB_8888 - // Fall back to middle of video - // Note: METADATA_KEY_DURATION unit is in ms, not us. - val thumbnailTimeUs: Long = (extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0) * 1000 / 2 + // Fall back to middle of video + // Note: METADATA_KEY_DURATION unit is in ms, not us. + val thumbnailTimeUs: Long = + (extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0) * 1000 / 2 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - getFrameAtTime(thumbnailTimeUs, MediaMetadataRetriever.OPTION_CLOSEST_SYNC, params) - } else { - null - } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + getFrameAtTime(thumbnailTimeUs, MediaMetadataRetriever.OPTION_CLOSEST_SYNC, params) } else { - null + null } + } else { + null + } } fun MediaMetadataRetriever.prepareDimFromVideo(): String? { - val width = prepareVideoWidth() ?: return null - val height = prepareVideoHeight() ?: return null + val width = prepareVideoWidth() ?: return null + val height = prepareVideoHeight() ?: return null - return "${width}x$height" + return "${width}x$height" } fun MediaMetadataRetriever.prepareVideoWidth(): Int? { - val widthData = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) - return if (widthData.isNullOrEmpty()) { - null - } else { - widthData.toInt() - } + val widthData = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) + return if (widthData.isNullOrEmpty()) { + null + } else { + widthData.toInt() + } } fun MediaMetadataRetriever.prepareVideoHeight(): Int? { - val heightData = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) - return if (heightData.isNullOrEmpty()) { - null - } else { - heightData.toInt() - } + val heightData = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) + return if (heightData.isNullOrEmpty()) { + null + } else { + heightData.toInt() + } } class ByteArrayMediaDataSource(var imageData: ByteArray) : MediaDataSource() { - override fun getSize(): Long { - return imageData.size.toLong() + override fun getSize(): Long { + return imageData.size.toLong() + } + + @Throws(IOException::class) + override fun readAt( + position: Long, + buffer: ByteArray, + offset: Int, + size: Int, + ): Int { + if (position >= imageData.size) { + return -1 } + val newSize = + if (position + size > imageData.size) { + size - ((position.toInt() + size) - imageData.size) + } else { + size + } - @Throws(IOException::class) - override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int { - if (position >= imageData.size) { - return -1 - } - val newSize = if (position + size > imageData.size) { - size - ((position.toInt() + size) - imageData.size) - } else { - size - } + imageData.copyInto(buffer, offset, position.toInt(), position.toInt() + newSize) - imageData.copyInto(buffer, offset, position.toInt(), position.toInt() + newSize) + return newSize + } - return newSize - } - - @Throws(IOException::class) - override fun close() {} + @Throws(IOException::class) override fun close() {} } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/HttpClient.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/HttpClient.kt index d8eca104c..40b731b5e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/HttpClient.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/HttpClient.kt @@ -1,95 +1,119 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import android.util.Log import com.vitorpamplona.amethyst.BuildConfig -import okhttp3.Interceptor -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response import java.io.IOException import java.net.InetSocketAddress import java.net.Proxy import java.time.Duration import kotlin.properties.Delegates +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response object HttpClient { - val DEFAULT_TIMEOUT_ON_WIFI = Duration.ofSeconds(10L) - val DEFAULT_TIMEOUT_ON_MOBILE = Duration.ofSeconds(30L) + val DEFAULT_TIMEOUT_ON_WIFI = Duration.ofSeconds(10L) + val DEFAULT_TIMEOUT_ON_MOBILE = Duration.ofSeconds(30L) - var proxyChangeListeners = ArrayList<() -> Unit>() - var defaultTimeout = DEFAULT_TIMEOUT_ON_WIFI + var proxyChangeListeners = ArrayList<() -> Unit>() + var defaultTimeout = DEFAULT_TIMEOUT_ON_WIFI - var defaultHttpClient: OkHttpClient? = null + var defaultHttpClient: OkHttpClient? = null - // fires off every time value of the property changes - private var internalProxy: Proxy? by Delegates.observable(null) { _, oldValue, newValue -> - if (oldValue != newValue) { - proxyChangeListeners.forEach { - it() - } - } + // fires off every time value of the property changes + private var internalProxy: Proxy? by + Delegates.observable(null) { _, oldValue, newValue -> + if (oldValue != newValue) { + proxyChangeListeners.forEach { it() } + } } - fun start(proxy: Proxy?) { - if (internalProxy != proxy) { - this.internalProxy = proxy - this.defaultHttpClient = getHttpClient() - } + fun start(proxy: Proxy?) { + if (internalProxy != proxy) { + this.internalProxy = proxy + this.defaultHttpClient = getHttpClient() } + } - fun changeTimeouts(timeout: Duration) { - Log.d("HttpClient", "Changing timeout to: $timeout") - if (this.defaultTimeout.seconds != timeout.seconds) { - this.defaultTimeout = timeout - this.defaultHttpClient = getHttpClient() - } + fun changeTimeouts(timeout: Duration) { + Log.d("HttpClient", "Changing timeout to: $timeout") + if (this.defaultTimeout.seconds != timeout.seconds) { + this.defaultTimeout = timeout + this.defaultHttpClient = getHttpClient() } + } - fun getHttpClient(timeout: Duration): OkHttpClient { - val seconds = if (internalProxy != null) timeout.seconds * 2 else timeout.seconds - val duration = Duration.ofSeconds(seconds) - return OkHttpClient.Builder() - .proxy(internalProxy) - .readTimeout(duration) - .connectTimeout(duration) - .writeTimeout(duration) - .addInterceptor(DefaultContentTypeInterceptor()) - .followRedirects(true) - .followSslRedirects(true) - .build() - } + fun getHttpClient(timeout: Duration): OkHttpClient { + val seconds = if (internalProxy != null) timeout.seconds * 2 else timeout.seconds + val duration = Duration.ofSeconds(seconds) + return OkHttpClient.Builder() + .proxy(internalProxy) + .readTimeout(duration) + .connectTimeout(duration) + .writeTimeout(duration) + .addInterceptor(DefaultContentTypeInterceptor()) + .followRedirects(true) + .followSslRedirects(true) + .build() + } - class DefaultContentTypeInterceptor : Interceptor { - @Throws(IOException::class) - override fun intercept(chain: Interceptor.Chain): Response { - val originalRequest: Request = chain.request() - val requestWithUserAgent: Request = originalRequest - .newBuilder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") - .build() - return chain.proceed(requestWithUserAgent) - } + class DefaultContentTypeInterceptor : Interceptor { + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest: Request = chain.request() + val requestWithUserAgent: Request = + originalRequest + .newBuilder() + .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .build() + return chain.proceed(requestWithUserAgent) } + } - fun getHttpClientForRelays(): OkHttpClient { - if (this.defaultHttpClient == null) { - this.defaultHttpClient = getHttpClient(defaultTimeout) - } - return defaultHttpClient!! + fun getHttpClientForRelays(): OkHttpClient { + if (this.defaultHttpClient == null) { + this.defaultHttpClient = getHttpClient(defaultTimeout) } + return defaultHttpClient!! + } - fun getHttpClient(): OkHttpClient { - if (this.defaultHttpClient == null) { - this.defaultHttpClient = getHttpClient(defaultTimeout) - } - return defaultHttpClient!! + fun getHttpClient(): OkHttpClient { + if (this.defaultHttpClient == null) { + this.defaultHttpClient = getHttpClient(defaultTimeout) } + return defaultHttpClient!! + } - fun getProxy(): Proxy? { - return internalProxy - } + fun getProxy(): Proxy? { + return internalProxy + } - fun initProxy(useProxy: Boolean, hostname: String, port: Int): Proxy? { - return if (useProxy) Proxy(Proxy.Type.SOCKS, InetSocketAddress(hostname, port)) else null - } + fun initProxy( + useProxy: Boolean, + hostname: String, + port: Int, + ): Proxy? { + return if (useProxy) Proxy(Proxy.Type.SOCKS, InetSocketAddress(hostname, port)) else null + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/LocationUtil.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/LocationUtil.kt index 43f9bb39d..739af3b5b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/LocationUtil.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/LocationUtil.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import android.annotation.SuppressLint @@ -12,68 +32,81 @@ import androidx.compose.runtime.mutableStateOf import kotlinx.coroutines.flow.MutableStateFlow class LocationUtil(context: Context) { - companion object { - const val MIN_TIME: Long = 1000L - const val MIN_DISTANCE: Float = 0.0f + companion object { + const val MIN_TIME: Long = 1000L + const val MIN_DISTANCE: Float = 0.0f + } + + private val locationManager = + context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + private var locationListener: LocationListener? = null + + val locationStateFlow = MutableStateFlow(Location(LocationManager.NETWORK_PROVIDER)) + val providerState = mutableStateOf(false) + val isStart: MutableState = mutableStateOf(false) + + private val locHandlerThread = HandlerThread("LocationUtil Thread") + + init { + locHandlerThread.start() + } + + @SuppressLint("MissingPermission") + fun start( + minTimeMs: Long = MIN_TIME, + minDistanceM: Float = MIN_DISTANCE, + ) { + locationListener().let { + locationListener = it + locationManager.requestLocationUpdates( + LocationManager.NETWORK_PROVIDER, + minTimeMs, + minDistanceM, + it, + locHandlerThread.looper, + ) } + providerState.value = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) + isStart.value = true + } - private val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager - private var locationListener: LocationListener? = null + fun stop() { + locationListener?.let { locationManager.removeUpdates(it) } + isStart.value = false + } - val locationStateFlow = MutableStateFlow(Location(LocationManager.NETWORK_PROVIDER)) - val providerState = mutableStateOf(false) - val isStart: MutableState = mutableStateOf(false) + private fun locationListener() = + object : LocationListener { + override fun onLocationChanged(location: Location) { + locationStateFlow.value = location + } - private val locHandlerThread = HandlerThread("LocationUtil Thread") + override fun onProviderEnabled(provider: String) { + providerState.value = true + } - init { - locHandlerThread.start() - } - - @SuppressLint("MissingPermission") - fun start(minTimeMs: Long = MIN_TIME, minDistanceM: Float = MIN_DISTANCE) { - locationListener().let { - locationListener = it - locationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, minTimeMs, minDistanceM, it, locHandlerThread.looper) - } - providerState.value = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) - isStart.value = true - } - - fun stop() { - locationListener?.let { - locationManager.removeUpdates(it) - } - isStart.value = false - } - - private fun locationListener() = object : LocationListener { - override fun onLocationChanged(location: Location) { - locationStateFlow.value = location - } - - override fun onProviderEnabled(provider: String) { - providerState.value = true - } - - override fun onProviderDisabled(provider: String) { - providerState.value = false - } + override fun onProviderDisabled(provider: String) { + providerState.value = false + } } } class ReverseGeoLocationUtil { - suspend fun execute( - location: Location, - context: Context - ): String? { - return try { - Geocoder(context).getFromLocation(location.latitude, location.longitude, 1)?.firstOrNull()?.let { address -> - listOfNotNull(address.locality ?: address.subAdminArea, address.countryCode).joinToString(", ") - } - } catch (e: Exception) { - e.printStackTrace() - return null + suspend fun execute( + location: Location, + context: Context, + ): String? { + return try { + Geocoder(context) + .getFromLocation(location.latitude, location.longitude, 1) + ?.firstOrNull() + ?.let { address -> + listOfNotNull(address.locality ?: address.subAdminArea, address.countryCode) + .joinToString(", ") } + } catch (e: Exception) { + e.printStackTrace() + return null } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/MainThreadChecker.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/MainThreadChecker.kt index 36542b8d7..24b175a24 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/MainThreadChecker.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/MainThreadChecker.kt @@ -1,10 +1,32 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import android.os.Looper import com.vitorpamplona.amethyst.BuildConfig fun checkNotInMainThread() { - if (BuildConfig.DEBUG && isMainThread()) throw OnMainThreadException("It should not be in the MainThread") + if (BuildConfig.DEBUG && isMainThread()) { + throw OnMainThreadException("It should not be in the MainThread") + } } fun isMainThread() = Looper.myLooper() == Looper.getMainLooper() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt index 75c243692..9a7b176df 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper @@ -10,95 +30,121 @@ import okhttp3.Request import okhttp3.Response class Nip05NostrAddressVerifier() { - fun assembleUrl(nip05address: String): String? { - val parts = nip05address.trim().split("@") + fun assembleUrl(nip05address: String): String? { + val parts = nip05address.trim().split("@") - if (parts.size == 2) { - return "https://${parts[1]}/.well-known/nostr.json?name=${parts[0]}" - } - if (parts.size == 1) { - return "https://${parts[0]}/.well-known/nostr.json?name=_" - } - - return null + if (parts.size == 2) { + return "https://${parts[1]}/.well-known/nostr.json?name=${parts[0]}" + } + if (parts.size == 1) { + return "https://${parts[0]}/.well-known/nostr.json?name=_" } - suspend fun fetchNip05Json(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) = withContext(Dispatchers.IO) { - checkNotInMainThread() + return null + } - val url = assembleUrl(nip05) + suspend fun fetchNip05Json( + nip05: String, + onSuccess: (String) -> Unit, + onError: (String) -> Unit, + ) = + withContext(Dispatchers.IO) { + checkNotInMainThread() - if (url == null) { - onError("Could not assemble url from Nip05: \"${nip05}\". Check the user's setup") - return@withContext - } + val url = assembleUrl(nip05) - try { - val request = Request.Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") - .url(url) - .build() + if (url == null) { + onError("Could not assemble url from Nip05: \"${nip05}\". Check the user's setup") + return@withContext + } - HttpClient.getHttpClient().newCall(request).enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - checkNotInMainThread() + try { + val request = + Request.Builder() + .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .url(url) + .build() - response.use { - if (it.isSuccessful) { - onSuccess(it.body.string()) - } else { - onError("Could not resolve $nip05. Error: ${it.code}. Check if the server is up and if the address $nip05 is correct") - } - } - } - - override fun onFailure(call: Call, e: java.io.IOException) { - onError("Could not resolve $url. Check if the server is up and if the address $nip05 is correct") - e.printStackTrace() - } - }) - } catch (e: java.lang.Exception) { - onError("Could not resolve '$url': ${e.message}") - } - } - - suspend fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) { - // check fails on tests - checkNotInMainThread() - - val mapper = jacksonObjectMapper() - - fetchNip05Json( - nip05, - onSuccess = { + HttpClient.getHttpClient() + .newCall(request) + .enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { checkNotInMainThread() - // NIP05 usernames are case insensitive, but JSON properties are not - // converts the json to lowercase and then tries to access the username via a - // lowercase version of the username. - val nip05url = try { - mapper.readTree(it.lowercase()) - } catch (t: Throwable) { - onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup") - null + response.use { + if (it.isSuccessful) { + onSuccess(it.body.string()) + } else { + onError( + "Could not resolve $nip05. Error: ${it.code}. Check if the server is up and if the address $nip05 is correct", + ) + } } + } - val parts = nip05.split("@") - val user = if (parts.size == 2) { - parts[0].lowercase() - } else { - "_" - } - - val hexKey = nip05url?.get("names")?.get(user)?.asText() - - if (hexKey == null) { - onError("Username not found in the NIP05 JSON") - } else { - onSuccess(hexKey) - } + override fun onFailure( + call: Call, + e: java.io.IOException, + ) { + onError( + "Could not resolve $url. Check if the server is up and if the address $nip05 is correct", + ) + e.printStackTrace() + } }, - onError = onError - ) + ) + } catch (e: java.lang.Exception) { + onError("Could not resolve '$url': ${e.message}") + } } + + suspend fun verifyNip05( + nip05: String, + onSuccess: (String) -> Unit, + onError: (String) -> Unit, + ) { + // check fails on tests + checkNotInMainThread() + + val mapper = jacksonObjectMapper() + + fetchNip05Json( + nip05, + onSuccess = { + checkNotInMainThread() + + // NIP05 usernames are case insensitive, but JSON properties are not + // converts the json to lowercase and then tries to access the username via a + // lowercase version of the username. + val nip05url = + try { + mapper.readTree(it.lowercase()) + } catch (t: Throwable) { + onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup") + null + } + + val parts = nip05.split("@") + val user = + if (parts.size == 2) { + parts[0].lowercase() + } else { + "_" + } + + val hexKey = nip05url?.get("names")?.get(user)?.asText() + + if (hexKey == null) { + onError("Username not found in the NIP05 JSON") + } else { + onSuccess(hexKey) + } + }, + onError = onError, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt index 58e88e207..d3984b4e4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt @@ -1,103 +1,129 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import android.util.Log import android.util.LruCache import com.vitorpamplona.amethyst.model.RelayInformation +import java.io.IOException import okhttp3.Call import okhttp3.Callback import okhttp3.Request import okhttp3.Response -import java.io.IOException object Nip11CachedRetriever { - val relayInformationDocumentCache = LruCache(100) - val retriever = Nip11Retriever() + val relayInformationDocumentCache = LruCache(100) + val retriever = Nip11Retriever() - suspend fun loadRelayInfo( - dirtyUrl: String, - onInfo: (RelayInformation) -> Unit, - onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit - ) { - val url = retriever.cleanUrl(dirtyUrl) - val doc = relayInformationDocumentCache.get(url) + suspend fun loadRelayInfo( + dirtyUrl: String, + onInfo: (RelayInformation) -> Unit, + onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit, + ) { + val url = retriever.cleanUrl(dirtyUrl) + val doc = relayInformationDocumentCache.get(url) - if (doc != null) { - onInfo(doc) - } else { - Nip11Retriever().loadRelayInfo( - url, - dirtyUrl, - onInfo = { - relayInformationDocumentCache.put(url, it) - onInfo(it) - }, - onError - ) - } + if (doc != null) { + onInfo(doc) + } else { + Nip11Retriever() + .loadRelayInfo( + url, + dirtyUrl, + onInfo = { + relayInformationDocumentCache.put(url, it) + onInfo(it) + }, + onError, + ) } + } } class Nip11Retriever { - enum class ErrorCode { - FAIL_TO_ASSEMBLE_URL, - FAIL_TO_REACH_SERVER, - FAIL_TO_PARSE_RESULT, - FAIL_WITH_HTTP_STATUS + enum class ErrorCode { + FAIL_TO_ASSEMBLE_URL, + FAIL_TO_REACH_SERVER, + FAIL_TO_PARSE_RESULT, + FAIL_WITH_HTTP_STATUS, + } + + fun cleanUrl(dirtyUrl: String): String { + return if (dirtyUrl.contains("://")) { + dirtyUrl.replace("wss://", "https://").replace("ws://", "http://") + } else { + "https://$dirtyUrl" } + } - fun cleanUrl(dirtyUrl: String): String { - return if (dirtyUrl.contains("://")) { - dirtyUrl - .replace("wss://", "https://") - .replace("ws://", "http://") - } else { - "https://$dirtyUrl" - } - } - - suspend fun loadRelayInfo( - url: String, - dirtyUrl: String, - onInfo: (RelayInformation) -> Unit, - onError: (String, ErrorCode, String?) -> Unit - ) { - try { - val request: Request = Request - .Builder() - .header("Accept", "application/nostr+json") - .url(url) - .build() - - HttpClient.getHttpClient() - .newCall(request) - .enqueue( - object : Callback { - override fun onResponse(call: Call, response: Response) { - checkNotInMainThread() - response.use { - val body = it.body.string() - try { - if (it.isSuccessful) { - onInfo(RelayInformation.fromJson(body)) - } else { - onError(dirtyUrl, ErrorCode.FAIL_WITH_HTTP_STATUS, it.code.toString()) - } - } catch (e: Exception) { - Log.e("RelayInfoFail", "Resulting Message from Relay $dirtyUrl in not parseable: $body", e) - onError(dirtyUrl, ErrorCode.FAIL_TO_PARSE_RESULT, e.message) - } - } - } - - override fun onFailure(call: Call, e: IOException) { - Log.e("RelayInfoFail", "$dirtyUrl unavailable", e) - onError(dirtyUrl, ErrorCode.FAIL_TO_REACH_SERVER, e.message) - } - } - ) - } catch (e: Exception) { - Log.e("RelayInfoFail", "Invalid URL $dirtyUrl", e) - onError(dirtyUrl, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message) - } + suspend fun loadRelayInfo( + url: String, + dirtyUrl: String, + onInfo: (RelayInformation) -> Unit, + onError: (String, ErrorCode, String?) -> Unit, + ) { + try { + val request: Request = + Request.Builder().header("Accept", "application/nostr+json").url(url).build() + + HttpClient.getHttpClient() + .newCall(request) + .enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + checkNotInMainThread() + response.use { + val body = it.body.string() + try { + if (it.isSuccessful) { + onInfo(RelayInformation.fromJson(body)) + } else { + onError(dirtyUrl, ErrorCode.FAIL_WITH_HTTP_STATUS, it.code.toString()) + } + } catch (e: Exception) { + Log.e( + "RelayInfoFail", + "Resulting Message from Relay $dirtyUrl in not parseable: $body", + e, + ) + onError(dirtyUrl, ErrorCode.FAIL_TO_PARSE_RESULT, e.message) + } + } + } + + override fun onFailure( + call: Call, + e: IOException, + ) { + Log.e("RelayInfoFail", "$dirtyUrl unavailable", e) + onError(dirtyUrl, ErrorCode.FAIL_TO_REACH_SERVER, e.message) + } + }, + ) + } catch (e: Exception) { + Log.e("RelayInfoFail", "Invalid URL $dirtyUrl", e) + onError(dirtyUrl, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message) } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip30CustomEmoji.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip30CustomEmoji.kt index ad280ea2c..6fb340578 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip30CustomEmoji.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip30CustomEmoji.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import androidx.compose.runtime.Immutable @@ -5,30 +25,31 @@ import java.util.regex.Pattern @Immutable class Nip30CustomEmoji { - val customEmojiPattern: Pattern = Pattern.compile("\\:([A-Za-z0-9_\\-]+)\\:", Pattern.CASE_INSENSITIVE) + val customEmojiPattern: Pattern = + Pattern.compile("\\:([A-Za-z0-9_\\-]+)\\:", Pattern.CASE_INSENSITIVE) - fun buildArray(input: String): List { - val matcher = customEmojiPattern.matcher(input) - val list = mutableListOf() - while (matcher.find()) { - list.add(matcher.group()) - } - - if (list.isEmpty()) { - return listOf(input) - } - - val regularChars = input.split(customEmojiPattern.toRegex()) - - val finalList = mutableListOf() - var index = 0 - for (e in regularChars) { - finalList.add(e) - if (index < list.size) { - finalList.add(list[index]) - } - index++ - } - return finalList + fun buildArray(input: String): List { + val matcher = customEmojiPattern.matcher(input) + val list = mutableListOf() + while (matcher.find()) { + list.add(matcher.group()) } + + if (list.isEmpty()) { + return listOf(input) + } + + val regularChars = input.split(customEmojiPattern.toRegex()) + + val finalList = mutableListOf() + var index = 0 + for (e in regularChars) { + finalList.add(e) + if (index < list.size) { + finalList.add(list[index]) + } + index++ + } + return finalList + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip44UrlParser.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip44UrlParser.kt index edfe4c371..57dd508f3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip44UrlParser.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip44UrlParser.kt @@ -1,24 +1,44 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import java.net.URI import java.net.URLDecoder class Nip44UrlParser { - fun parse(url: String): Map { - return try { - fragments(URI(url)) - } catch (e: Exception) { - emptyMap() - } + fun parse(url: String): Map { + return try { + fragments(URI(url)) + } catch (e: Exception) { + emptyMap() } + } - private fun fragments(uri: URI): Map { - if (uri.rawFragment == null) return emptyMap() - return uri.rawFragment.split('&').associate { keyValuePair -> - val parts = keyValuePair.split('=') - val name = parts.firstOrNull() ?: "" - val value = parts.getOrNull(1)?.let { URLDecoder.decode(it, "UTF-8") } ?: "" - Pair(name, value) - } + private fun fragments(uri: URI): Map { + if (uri.rawFragment == null) return emptyMap() + return uri.rawFragment.split('&').associate { keyValuePair -> + val parts = keyValuePair.split('=') + val name = parts.firstOrNull() ?: "" + val value = parts.getOrNull(1)?.let { URLDecoder.decode(it, "UTF-8") } ?: "" + Pair(name, value) } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47WalletConnectParser.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47WalletConnectParser.kt index f7f9fce59..0fd5ea8fe 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47WalletConnectParser.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47WalletConnectParser.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.note import android.net.Uri @@ -7,26 +27,27 @@ import com.vitorpamplona.quartz.encoders.toHexKey // Rename to the corect nip number when ready. object Nip47WalletConnectParser { - fun parse(uri: String): Nip47URI { - // nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22Example%22%7D + fun parse(uri: String): Nip47URI { + // nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22Example%22%7D - val url = Uri.parse(uri) + val url = Uri.parse(uri) - if (url.scheme != "nostrwalletconnect" && url.scheme != "nostr+walletconnect") { - throw IllegalArgumentException("Not a Wallet Connect QR Code") - } - - val pubkey = url.host ?: throw IllegalArgumentException("Hostname cannot be null") - - val pubkeyHex = try { - decodePublicKey(pubkey).toHexKey() - } catch (e: Exception) { - throw IllegalArgumentException("Hostname is not a valid Nostr Pubkey") - } - - val relay = url.getQueryParameter("relay") - val secret = url.getQueryParameter("secret") - - return Nip47URI(pubkeyHex, relay, secret) + if (url.scheme != "nostrwalletconnect" && url.scheme != "nostr+walletconnect") { + throw IllegalArgumentException("Not a Wallet Connect QR Code") } + + val pubkey = url.host ?: throw IllegalArgumentException("Hostname cannot be null") + + val pubkeyHex = + try { + decodePublicKey(pubkey).toHexKey() + } catch (e: Exception) { + throw IllegalArgumentException("Hostname is not a valid Nostr Pubkey") + } + + val relay = url.getQueryParameter("relay") + val secret = url.getQueryParameter("secret") + + return Nip47URI(pubkeyHex, relay, secret) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip96MediaServers.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip96MediaServers.kt index 9b57344b4..f10aaa2db 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip96MediaServers.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip96MediaServers.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import android.util.Log @@ -7,86 +27,87 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import okhttp3.Request object Nip96MediaServers { - val DEFAULT = - listOf( - ServerName("Nostr.Build", "https://nostr.build"), - ServerName("NostrCheck.me", "https://nostrcheck.me"), - ServerName("Nostrage", "https://nostrage.com"), - ServerName("Sove", "https://sove.rent"), - ServerName("Sovbit", "https://files.sovbit.host"), - ServerName("Void.cat", "https://void.cat") - ) + val DEFAULT = + listOf( + ServerName("Nostr.Build", "https://nostr.build"), + ServerName("NostrCheck.me", "https://nostrcheck.me"), + ServerName("Nostrage", "https://nostrage.com"), + ServerName("Sove", "https://sove.rent"), + ServerName("Sovbit", "https://files.sovbit.host"), + ServerName("Void.cat", "https://void.cat"), + ) - data class ServerName(val name: String, val baseUrl: String) + data class ServerName(val name: String, val baseUrl: String) - val cache: MutableMap = mutableMapOf() + val cache: MutableMap = mutableMapOf() - suspend fun load(url: String): Nip96Retriever.ServerInfo { - val cached = cache[url] - if (cached != null) return cached + suspend fun load(url: String): Nip96Retriever.ServerInfo { + val cached = cache[url] + if (cached != null) return cached - val fetched = Nip96Retriever().loadInfo(url) - cache[url] = fetched - return fetched - } + val fetched = Nip96Retriever().loadInfo(url) + cache[url] = fetched + return fetched + } } class Nip96Retriever { - data class ServerInfo( - @JsonProperty("api_url") val apiUrl: String, - @JsonProperty("download_url") val downloadUrl: String? = null, - @JsonProperty("delegated_to_url") val delegatedToUrl: String? = null, - @JsonProperty("supported_nips") val supportedNips: ArrayList = arrayListOf(), - @JsonProperty("tos_url") val tosUrl: String? = null, - @JsonProperty("content_types") val contentTypes: ArrayList = arrayListOf(), - @JsonProperty("plans") val plans: Map = mapOf() - ) + data class ServerInfo( + @JsonProperty("api_url") val apiUrl: String, + @JsonProperty("download_url") val downloadUrl: String? = null, + @JsonProperty("delegated_to_url") val delegatedToUrl: String? = null, + @JsonProperty("supported_nips") val supportedNips: ArrayList = arrayListOf(), + @JsonProperty("tos_url") val tosUrl: String? = null, + @JsonProperty("content_types") val contentTypes: ArrayList = arrayListOf(), + @JsonProperty("plans") val plans: Map = mapOf(), + ) - data class Plan( - @JsonProperty("name") val name: String? = null, - @JsonProperty("is_nip98_required") val isNip98Required: Boolean? = null, - @JsonProperty("url") val url: String? = null, - @JsonProperty("max_byte_size") val maxByteSize: Long? = null, - @JsonProperty("file_expiration") val fileExpiration: ArrayList = arrayListOf(), - @JsonProperty("media_transformations") val mediaTransformations: Map> = emptyMap() - ) + data class Plan( + @JsonProperty("name") val name: String? = null, + @JsonProperty("is_nip98_required") val isNip98Required: Boolean? = null, + @JsonProperty("url") val url: String? = null, + @JsonProperty("max_byte_size") val maxByteSize: Long? = null, + @JsonProperty("file_expiration") val fileExpiration: ArrayList = arrayListOf(), + @JsonProperty("media_transformations") + val mediaTransformations: Map> = emptyMap(), + ) - fun parse(body: String): ServerInfo { - val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - return mapper.readValue(body, ServerInfo::class.java) - } - - suspend fun loadInfo( - baseUrl: String - ): ServerInfo { - checkNotInMainThread() - - val request: Request = Request - .Builder() - .header("Accept", "application/nostr+json") - .url(baseUrl.removeSuffix("/") + "/.well-known/nostr/nip96.json") - .build() - - HttpClient.getHttpClient() - .newCall(request) - .execute().use { response -> - checkNotInMainThread() - response.use { - val body = it.body.string() - try { - if (it.isSuccessful) { - return parse(body) - } else { - throw RuntimeException("Resulting Message from $baseUrl is an error: ${response.code} ${response.message}") - } - } catch (e: Exception) { - Log.e("RelayInfoFail", "Resulting Message from $baseUrl in not parseable: $body", e) - throw e - } - } - } + fun parse(body: String): ServerInfo { + val mapper = + jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + return mapper.readValue(body, ServerInfo::class.java) + } + + suspend fun loadInfo(baseUrl: String): ServerInfo { + checkNotInMainThread() + + val request: Request = + Request.Builder() + .header("Accept", "application/nostr+json") + .url(baseUrl.removeSuffix("/") + "/.well-known/nostr/nip96.json") + .build() + + HttpClient.getHttpClient().newCall(request).execute().use { response -> + checkNotInMainThread() + response.use { + val body = it.body.string() + try { + if (it.isSuccessful) { + return parse(body) + } else { + throw RuntimeException( + "Resulting Message from $baseUrl is an error: ${response.code} ${response.message}", + ) + } + } catch (e: Exception) { + Log.e("RelayInfoFail", "Resulting Message from $baseUrl in not parseable: $body", e) + throw e + } + } } + } } typealias PlanName = String + typealias MimeType = String diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt index 0956ddeb8..8a5d6fa4b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import android.content.ContentResolver @@ -10,6 +30,9 @@ import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.model.Account +import java.io.InputStream +import java.util.Base64 +import kotlin.coroutines.resume import kotlinx.coroutines.delay import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeoutOrNull @@ -19,261 +42,276 @@ import okhttp3.Request import okhttp3.RequestBody import okio.BufferedSink import okio.source -import java.io.InputStream -import java.util.Base64 -import kotlin.coroutines.resume val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') fun randomChars() = List(16) { charPool.random() }.joinToString("") class Nip96Uploader(val account: Account?) { - - suspend fun uploadImage( - uri: Uri, - contentType: String?, - size: Long?, - alt: String?, - sensitiveContent: String?, - server: Nip96MediaServers.ServerName, - contentResolver: ContentResolver, - onProgress: (percentage: Float) -> Unit - ): PartialEvent { - val serverInfo = Nip96Retriever().loadInfo( - server.baseUrl + suspend fun uploadImage( + uri: Uri, + contentType: String?, + size: Long?, + alt: String?, + sensitiveContent: String?, + server: Nip96MediaServers.ServerName, + contentResolver: ContentResolver, + onProgress: (percentage: Float) -> Unit, + ): PartialEvent { + val serverInfo = + Nip96Retriever() + .loadInfo( + server.baseUrl, ) - return uploadImage(uri, contentType, size, alt, sensitiveContent, serverInfo, contentResolver, onProgress) - } - - suspend fun uploadImage( - uri: Uri, - contentType: String?, - size: Long?, - alt: String?, - sensitiveContent: String?, - server: Nip96Retriever.ServerInfo, - contentResolver: ContentResolver, - onProgress: (percentage: Float) -> Unit - ): PartialEvent { - checkNotInMainThread() - - val myContentType = contentType ?: contentResolver.getType(uri) - val imageInputStream = contentResolver.openInputStream(uri) - - val length = size ?: contentResolver.query(uri, null, null, null, null)?.use { - it.moveToFirst() - val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE) - it.getLong(sizeIndex) - } ?: kotlin.runCatching { - uri.toFile().length() - }.getOrNull() ?: 0 - - checkNotNull(imageInputStream) { - "Can't open the image input stream" - } - - return uploadImage(imageInputStream, length, myContentType, alt, sensitiveContent, server, onProgress) - } - - suspend fun uploadImage( - inputStream: InputStream, - length: Long, - contentType: String?, - alt: String?, - sensitiveContent: String?, - server: Nip96Retriever.ServerInfo, - onProgress: (percentage: Float) -> Unit - ): PartialEvent { - checkNotInMainThread() - - val fileName = randomChars() - val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" - - val client = HttpClient.getHttpClient() - val requestBody: RequestBody - val requestBuilder = Request.Builder() - - requestBody = MultipartBody.Builder() - .setType(MultipartBody.FORM) - .addFormDataPart("expiration", "") - .addFormDataPart("size", length.toString()) - .also { body -> - alt?.let { body.addFormDataPart("alt", it) } - sensitiveContent?.let { body.addFormDataPart("content-warning", it) } - contentType?.let { body.addFormDataPart("content_type", it) } - } - .addFormDataPart( - "file", - "$fileName.$extension", - - object : RequestBody() { - override fun contentType() = contentType?.toMediaType() - - override fun contentLength() = length - - override fun writeTo(sink: BufferedSink) { - inputStream.source().use(sink::writeAll) - } - } - ) - .build() - - nip98Header(server.apiUrl)?.let { - requestBuilder.addHeader("Authorization", it) - } - - requestBuilder - .addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") - .url(server.apiUrl) - .post(requestBody) - - val request = requestBuilder.build() - - client.newCall(request).execute().use { response -> - if (response.isSuccessful) { - response.body.use { body -> - val str = body.string() - val result = parseResults(str) - - if (!result.processingUrl.isNullOrBlank()) { - return waitProcessing(result, server, onProgress) - } else if (result.status == "success" && result.nip94Event != null) { - return result.nip94Event - } else { - throw RuntimeException("Failed to upload with message: ${result.message}") - } - } - } else { - throw RuntimeException("Error Uploading image: ${response.code}") - } - } - } - - suspend fun delete( - hash: String, - contentType: String?, - server: Nip96Retriever.ServerInfo - ): Boolean { - val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" - - val client = HttpClient.getHttpClient() - - val requestBuilder = Request.Builder() - - nip98Header(server.apiUrl)?.let { - requestBuilder.addHeader("Authorization", it) - } - - println(server.apiUrl.removeSuffix("/") + "/$hash.$extension") - - val request = requestBuilder - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") - .url(server.apiUrl.removeSuffix("/") + "/$hash.$extension") - .delete() - .build() - - client.newCall(request).execute().use { response -> - if (response.isSuccessful) { - response.body.use { body -> - val str = body.string() - val result = parseDeleteResults(str) - return result.status == "success" - } - } else { - throw RuntimeException("Error Uploading image: ${response.code}") - } - } - } - - private suspend fun waitProcessing( - result: Nip96Result, - server: Nip96Retriever.ServerInfo, - onProgress: (percentage: Float) -> Unit - ): PartialEvent { - val client = HttpClient.getHttpClient() - var currentResult = result - - while (!result.processingUrl.isNullOrBlank() && (currentResult.percentage ?: 100) < 100) { - onProgress((currentResult.percentage ?: 100) / 100f) - - val request: Request = Request.Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") - .url(result.processingUrl) - .build() - - client.newCall(request).execute().use { - if (it.isSuccessful) { - it.body.use { - currentResult = parseResults(it.string()) - } - } - } - - delay(500) - } - onProgress((currentResult.percentage ?: 100) / 100f) - - val nip94 = currentResult.nip94Event - - if (nip94 != null) { - return nip94 - } else { - throw RuntimeException("Error waiting for processing. Final result is unavailable") - } - } - - suspend fun nip98Header(url: String): String? { - return withTimeoutOrNull(5000) { - suspendCancellableCoroutine { continuation -> - nip98Header(url, "POST") { authorizationToken -> - continuation.resume(authorizationToken) - } - } - } - } - - fun nip98Header(url: String, method: String, file: ByteArray? = null, onReady: (String?) -> Unit) { - val myAccount = account - - if (myAccount == null) { - onReady(null) - return - } - - myAccount.createHTTPAuthorization(url, method, file) { - val encodedNIP98Event = Base64.getEncoder().encodeToString(it.toJson().toByteArray()) - onReady("Nostr $encodedNIP98Event") - } - } - - data class DeleteResult( - val status: String?, - val message: String? + return uploadImage( + uri, + contentType, + size, + alt, + sensitiveContent, + serverInfo, + contentResolver, + onProgress, ) + } - private fun parseDeleteResults(body: String): DeleteResult { - val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - return mapper.readValue(body, DeleteResult::class.java) + suspend fun uploadImage( + uri: Uri, + contentType: String?, + size: Long?, + alt: String?, + sensitiveContent: String?, + server: Nip96Retriever.ServerInfo, + contentResolver: ContentResolver, + onProgress: (percentage: Float) -> Unit, + ): PartialEvent { + checkNotInMainThread() + + val myContentType = contentType ?: contentResolver.getType(uri) + val imageInputStream = contentResolver.openInputStream(uri) + + val length = + size + ?: contentResolver.query(uri, null, null, null, null)?.use { + it.moveToFirst() + val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE) + it.getLong(sizeIndex) + } + ?: kotlin.runCatching { uri.toFile().length() }.getOrNull() ?: 0 + + checkNotNull(imageInputStream) { "Can't open the image input stream" } + + return uploadImage( + imageInputStream, + length, + myContentType, + alt, + sensitiveContent, + server, + onProgress, + ) + } + + suspend fun uploadImage( + inputStream: InputStream, + length: Long, + contentType: String?, + alt: String?, + sensitiveContent: String?, + server: Nip96Retriever.ServerInfo, + onProgress: (percentage: Float) -> Unit, + ): PartialEvent { + checkNotInMainThread() + + val fileName = randomChars() + val extension = + contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" + + val client = HttpClient.getHttpClient() + val requestBody: RequestBody + val requestBuilder = Request.Builder() + + requestBody = + MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("expiration", "") + .addFormDataPart("size", length.toString()) + .also { body -> + alt?.let { body.addFormDataPart("alt", it) } + sensitiveContent?.let { body.addFormDataPart("content-warning", it) } + contentType?.let { body.addFormDataPart("content_type", it) } + } + .addFormDataPart( + "file", + "$fileName.$extension", + object : RequestBody() { + override fun contentType() = contentType?.toMediaType() + + override fun contentLength() = length + + override fun writeTo(sink: BufferedSink) { + inputStream.source().use(sink::writeAll) + } + }, + ) + .build() + + nip98Header(server.apiUrl)?.let { requestBuilder.addHeader("Authorization", it) } + + requestBuilder + .addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .url(server.apiUrl) + .post(requestBody) + + val request = requestBuilder.build() + + client.newCall(request).execute().use { response -> + if (response.isSuccessful) { + response.body.use { body -> + val str = body.string() + val result = parseResults(str) + + if (!result.processingUrl.isNullOrBlank()) { + return waitProcessing(result, server, onProgress) + } else if (result.status == "success" && result.nip94Event != null) { + return result.nip94Event + } else { + throw RuntimeException("Failed to upload with message: ${result.message}") + } + } + } else { + throw RuntimeException("Error Uploading image: ${response.code}") + } + } + } + + suspend fun delete( + hash: String, + contentType: String?, + server: Nip96Retriever.ServerInfo, + ): Boolean { + val extension = + contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" + + val client = HttpClient.getHttpClient() + + val requestBuilder = Request.Builder() + + nip98Header(server.apiUrl)?.let { requestBuilder.addHeader("Authorization", it) } + + println(server.apiUrl.removeSuffix("/") + "/$hash.$extension") + + val request = + requestBuilder + .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .url(server.apiUrl.removeSuffix("/") + "/$hash.$extension") + .delete() + .build() + + client.newCall(request).execute().use { response -> + if (response.isSuccessful) { + response.body.use { body -> + val str = body.string() + val result = parseDeleteResults(str) + return result.status == "success" + } + } else { + throw RuntimeException("Error Uploading image: ${response.code}") + } + } + } + + private suspend fun waitProcessing( + result: Nip96Result, + server: Nip96Retriever.ServerInfo, + onProgress: (percentage: Float) -> Unit, + ): PartialEvent { + val client = HttpClient.getHttpClient() + var currentResult = result + + while (!result.processingUrl.isNullOrBlank() && (currentResult.percentage ?: 100) < 100) { + onProgress((currentResult.percentage ?: 100) / 100f) + + val request: Request = + Request.Builder() + .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .url(result.processingUrl) + .build() + + client.newCall(request).execute().use { + if (it.isSuccessful) { + it.body.use { currentResult = parseResults(it.string()) } + } + } + + delay(500) + } + onProgress((currentResult.percentage ?: 100) / 100f) + + val nip94 = currentResult.nip94Event + + if (nip94 != null) { + return nip94 + } else { + throw RuntimeException("Error waiting for processing. Final result is unavailable") + } + } + + suspend fun nip98Header(url: String): String? { + return withTimeoutOrNull(5000) { + suspendCancellableCoroutine { continuation -> + nip98Header(url, "POST") { authorizationToken -> continuation.resume(authorizationToken) } + } + } + } + + fun nip98Header( + url: String, + method: String, + file: ByteArray? = null, + onReady: (String?) -> Unit, + ) { + val myAccount = account + + if (myAccount == null) { + onReady(null) + return } - data class Nip96Result( - val status: String? = null, - val message: String? = null, - @JsonProperty("processing_url") - val processingUrl: String? = null, - val percentage: Int? = null, - @JsonProperty("nip94_event") - val nip94Event: PartialEvent? = null - ) - - class PartialEvent( - val tags: Array>? = null, - val content: String? = null - ) - - private fun parseResults(body: String): Nip96Result { - val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - return mapper.readValue(body, Nip96Result::class.java) + myAccount.createHTTPAuthorization(url, method, file) { + val encodedNIP98Event = Base64.getEncoder().encodeToString(it.toJson().toByteArray()) + onReady("Nostr $encodedNIP98Event") } + } + + data class DeleteResult( + val status: String?, + val message: String?, + ) + + private fun parseDeleteResults(body: String): DeleteResult { + val mapper = + jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + return mapper.readValue(body, DeleteResult::class.java) + } + + data class Nip96Result( + val status: String? = null, + val message: String? = null, + @JsonProperty("processing_url") val processingUrl: String? = null, + val percentage: Int? = null, + @JsonProperty("nip94_event") val nip94Event: PartialEvent? = null, + ) + + class PartialEvent( + val tags: Array>? = null, + val content: String? = null, + ) + + private fun parseResults(body: String): Nip96Result { + val mapper = + jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + return mapper.readValue(body, Nip96Result::class.java) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt index 7a1763afe..a42454ace 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.Account @@ -38,241 +58,287 @@ import com.vitorpamplona.quartz.utils.TimeUtils // TODO: Migrate this to a property of AccountVi object NostrAccountDataSource : NostrDataSource("AccountData") { - lateinit var account: Account - var otherAccounts = listOf() + lateinit var account: Account + var otherAccounts = listOf() - val latestEOSEs = EOSEAccount() - val hasLoadedTheBasics = mutableMapOf() + val latestEOSEs = EOSEAccount() + val hasLoadedTheBasics = mutableMapOf() - fun createAccountContactListFilter(): TypedFilter { - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - kinds = listOf(ContactListEvent.kind), - authors = listOf(account.userProfile().pubkeyHex), - limit = 1 - ) - ) - } + fun createAccountContactListFilter(): TypedFilter { + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(ContactListEvent.KIND), + authors = listOf(account.userProfile().pubkeyHex), + limit = 1, + ), + ) + } - fun createAccountMetadataFilter(): TypedFilter { - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - kinds = listOf(MetadataEvent.kind), - authors = listOf(account.userProfile().pubkeyHex), - limit = 1 - ) - ) - } + fun createAccountMetadataFilter(): TypedFilter { + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(MetadataEvent.KIND), + authors = listOf(account.userProfile().pubkeyHex), + limit = 1, + ), + ) + } - fun createAccountRelayListFilter(): TypedFilter { - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - kinds = listOf(AdvertisedRelayListEvent.kind, StatusEvent.kind), - authors = listOf(account.userProfile().pubkeyHex), - limit = 5 - ) - ) - } + fun createAccountRelayListFilter(): TypedFilter { + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(AdvertisedRelayListEvent.KIND, StatusEvent.KIND), + authors = listOf(account.userProfile().pubkeyHex), + limit = 5, + ), + ) + } - fun createOtherAccountsBaseFilter(): TypedFilter? { - if (otherAccounts.isEmpty()) return null - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - kinds = listOf(MetadataEvent.kind, ContactListEvent.kind, AdvertisedRelayListEvent.kind, MuteListEvent.kind, PeopleListEvent.kind), - authors = otherAccounts.filter { it != account.userProfile().pubkeyHex }, - limit = 100 - ) - ) - } + fun createOtherAccountsBaseFilter(): TypedFilter? { + if (otherAccounts.isEmpty()) return null + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = + listOf( + MetadataEvent.KIND, + ContactListEvent.KIND, + AdvertisedRelayListEvent.KIND, + MuteListEvent.KIND, + PeopleListEvent.KIND, + ), + authors = otherAccounts.filter { it != account.userProfile().pubkeyHex }, + limit = 100, + ), + ) + } - fun createAccountAcceptedAwardsFilter(): TypedFilter { - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - kinds = listOf(BadgeProfilesEvent.kind, EmojiPackSelectionEvent.kind), - authors = listOf(account.userProfile().pubkeyHex), - limit = 10 - ) - ) - } + fun createAccountAcceptedAwardsFilter(): TypedFilter { + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(BadgeProfilesEvent.KIND, EmojiPackSelectionEvent.KIND), + authors = listOf(account.userProfile().pubkeyHex), + limit = 10, + ), + ) + } - fun createAccountBookmarkListFilter(): TypedFilter { - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind, MuteListEvent.kind), - authors = listOf(account.userProfile().pubkeyHex), - limit = 100 - ) - ) - } + fun createAccountBookmarkListFilter(): TypedFilter { + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, MuteListEvent.KIND), + authors = listOf(account.userProfile().pubkeyHex), + limit = 100, + ), + ) + } - fun createAccountReportsFilter(): TypedFilter { - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - kinds = listOf(ReportEvent.kind), - authors = listOf(account.userProfile().pubkeyHex), - since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList.value)?.relayList - ) - ) - } + fun createAccountReportsFilter(): TypedFilter { + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(ReportEvent.KIND), + authors = listOf(account.userProfile().pubkeyHex), + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultNotificationFollowList.value) + ?.relayList, + ), + ) + } - fun createAccountLastPostsListFilter(): TypedFilter { - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - authors = listOf(account.userProfile().pubkeyHex), - limit = 400 - ) - ) - } + fun createAccountLastPostsListFilter(): TypedFilter { + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + authors = listOf(account.userProfile().pubkeyHex), + limit = 400, + ), + ) + } - fun createNotificationFilter(): TypedFilter { - val since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList.value)?.relayList - ?: account.activeRelays()?.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) } - ?: account.convertLocalRelays().associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) } + fun createNotificationFilter(): TypedFilter { + val since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultNotificationFollowList.value) + ?.relayList + ?: account.activeRelays()?.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) } + ?: account.convertLocalRelays().associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) } - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - kinds = listOf( - TextNoteEvent.kind, - PollNoteEvent.kind, - ReactionEvent.kind, - RepostEvent.kind, - GenericRepostEvent.kind, - ReportEvent.kind, - LnZapEvent.kind, - LnZapPaymentResponseEvent.kind, - ChannelMessageEvent.kind, - BadgeAwardEvent.kind - ), - tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), - limit = 4000, - since = since - ) - ) - } + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = + listOf( + TextNoteEvent.KIND, + PollNoteEvent.KIND, + ReactionEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + ReportEvent.KIND, + LnZapEvent.KIND, + LnZapPaymentResponseEvent.KIND, + ChannelMessageEvent.KIND, + BadgeAwardEvent.KIND, + ), + tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), + limit = 4000, + since = since, + ), + ) + } - fun createGiftWrapsToMeFilter() = TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - kinds = listOf(GiftWrapEvent.kind), - tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)) - ) + fun createGiftWrapsToMeFilter() = + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(GiftWrapEvent.KIND), + tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), + ), ) - val accountChannel = requestNewChannel { time, relayUrl -> - if (hasLoadedTheBasics[account.userProfile()] != null) { - latestEOSEs.addOrUpdate(account.userProfile(), account.defaultNotificationFollowList.value, relayUrl, time) - } else { - hasLoadedTheBasics[account.userProfile()] = true + val accountChannel = requestNewChannel { time, relayUrl -> + if (hasLoadedTheBasics[account.userProfile()] != null) { + latestEOSEs.addOrUpdate( + account.userProfile(), + account.defaultNotificationFollowList.value, + relayUrl, + time, + ) + } else { + hasLoadedTheBasics[account.userProfile()] = true - invalidateFilters() - } + invalidateFilters() } + } - override fun consume(event: Event, relay: Relay) { - checkNotInMainThread() + override fun consume( + event: Event, + relay: Relay, + ) { + checkNotInMainThread() - if (LocalCache.justVerify(event)) { - if (event is GiftWrapEvent) { - // Avoid decrypting over and over again if the event already exist. - if (LocalCache.getNoteIfExists(event.id) != null) return + if (LocalCache.justVerify(event)) { + if (event is GiftWrapEvent) { + // Avoid decrypting over and over again if the event already exist. + if (LocalCache.getNoteIfExists(event.id) != null) return - event.cachedGift(account.signer) { - this.consume(it, relay) - } - } + event.cachedGift(account.signer) { this.consume(it, relay) } + } - if (event is SealedGossipEvent) { - // Avoid decrypting over and over again if the event already exist. - if (LocalCache.getNoteIfExists(event.id) != null) return + if (event is SealedGossipEvent) { + // Avoid decrypting over and over again if the event already exist. + if (LocalCache.getNoteIfExists(event.id) != null) return - event.cachedGossip(account.signer) { - LocalCache.justConsume(it, relay) - } - } else { - LocalCache.justConsume(event, relay) - } - } + event.cachedGossip(account.signer) { LocalCache.justConsume(it, relay) } + } else { + LocalCache.justConsume(event, relay) + } } + } - override fun markAsSeenOnRelay(eventId: String, relay: Relay) { - checkNotInMainThread() + override fun markAsSeenOnRelay( + eventId: String, + relay: Relay, + ) { + checkNotInMainThread() - super.markAsSeenOnRelay(eventId, relay) + super.markAsSeenOnRelay(eventId, relay) - val note = LocalCache.getNoteIfExists(eventId) ?: return - val privKey = account.keyPair.privKey ?: return + val note = LocalCache.getNoteIfExists(eventId) ?: return + val privKey = account.keyPair.privKey ?: return - val noteEvent = note.event ?: return - markInnerAsSeenOnRelay(noteEvent, privKey, relay) + val noteEvent = note.event ?: return + markInnerAsSeenOnRelay(noteEvent, privKey, relay) + } + + private fun markInnerAsSeenOnRelay( + noteEvent: EventInterface, + privKey: ByteArray, + relay: Relay, + ) { + LocalCache.getNoteIfExists(noteEvent.id())?.addRelay(relay) + + if (noteEvent is GiftWrapEvent) { + noteEvent.cachedGift(account.signer) { gift -> markInnerAsSeenOnRelay(gift, privKey, relay) } + } else if (noteEvent is SealedGossipEvent) { + noteEvent.cachedGossip(account.signer) { rumor -> + markInnerAsSeenOnRelay(rumor, privKey, relay) + } } + } - private fun markInnerAsSeenOnRelay(noteEvent: EventInterface, privKey: ByteArray, relay: Relay) { - LocalCache.getNoteIfExists(noteEvent.id())?.addRelay(relay) - - if (noteEvent is GiftWrapEvent) { - noteEvent.cachedGift(account.signer) { gift -> - markInnerAsSeenOnRelay(gift, privKey, relay) - } - } else if (noteEvent is SealedGossipEvent) { - noteEvent.cachedGossip(account.signer) { rumor -> - markInnerAsSeenOnRelay(rumor, privKey, relay) - } - } + override fun updateChannelFilters() { + return if (hasLoadedTheBasics[account.userProfile()] != null) { + // gets everything about the user logged in + accountChannel.typedFilters = + listOfNotNull( + createAccountMetadataFilter(), + createAccountContactListFilter(), + createAccountRelayListFilter(), + createNotificationFilter(), + createGiftWrapsToMeFilter(), + createAccountReportsFilter(), + createAccountAcceptedAwardsFilter(), + createAccountBookmarkListFilter(), + createAccountLastPostsListFilter(), + createOtherAccountsBaseFilter(), + ) + .ifEmpty { null } + } else { + // just the basics. + accountChannel.typedFilters = + listOf( + createAccountMetadataFilter(), + createAccountContactListFilter(), + createAccountRelayListFilter(), + createAccountBookmarkListFilter(), + ) + .ifEmpty { null } } + } - override fun updateChannelFilters() { - return if (hasLoadedTheBasics[account.userProfile()] != null) { - // gets everything about the user logged in - accountChannel.typedFilters = listOfNotNull( - createAccountMetadataFilter(), - createAccountContactListFilter(), - createAccountRelayListFilter(), - createNotificationFilter(), - createGiftWrapsToMeFilter(), - createAccountReportsFilter(), - createAccountAcceptedAwardsFilter(), - createAccountBookmarkListFilter(), - createAccountLastPostsListFilter(), - createOtherAccountsBaseFilter() - ).ifEmpty { null } - } else { - // just the basics. - accountChannel.typedFilters = listOf( - createAccountMetadataFilter(), - createAccountContactListFilter(), - createAccountRelayListFilter(), - createAccountBookmarkListFilter() - ).ifEmpty { null } - } + override fun auth( + relay: Relay, + challenge: String, + ) { + super.auth(relay, challenge) + + if (this::account.isInitialized) { + account.createAuthEvent(relay, challenge) { + Client.send( + it, + relay.url, + ) + } } + } - override fun auth(relay: Relay, challenge: String) { - super.auth(relay, challenge) + override fun notify( + relay: Relay, + description: String, + ) { + super.notify(relay, description) - if (this::account.isInitialized) { - account.createAuthEvent(relay, challenge) { - Client.send( - it, - relay.url - ) - } - } - } - - override fun notify(relay: Relay, description: String) { - super.notify(relay, description) - - if (this::account.isInitialized) { - account.addPaymentRequestIfNew(Account.PaymentRequest(relay.url, description)) - } + if (this::account.isInitialized) { + account.addPaymentRequestIfNew(Account.PaymentRequest(relay.url, description)) } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt index 84e79dd3e..21bd9482e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.Account @@ -11,80 +31,89 @@ import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent object NostrChannelDataSource : NostrDataSource("ChatroomFeed") { - var account: Account? = null - var channel: Channel? = null + var account: Account? = null + var channel: Channel? = null - fun loadMessagesBetween(account: Account, channel: Channel) { - this.account = account - this.channel = channel - resetFilters() + fun loadMessagesBetween( + account: Account, + channel: Channel, + ) { + this.account = account + this.channel = channel + resetFilters() + } + + fun clear() { + account = null + channel = null + } + + fun createMessagesByMeToChannelFilter(): TypedFilter? { + val myAccount = account ?: return null + + if (channel is PublicChatChannel) { + // Brings on messages by the user from all other relays. + // Since we ship with write to public, read from private only + // this guarantees that messages from the author do not disappear. + return TypedFilter( + types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH), + filter = + JsonFilter( + kinds = listOf(ChannelMessageEvent.KIND), + authors = listOf(myAccount.userProfile().pubkeyHex), + limit = 50, + ), + ) + } else if (channel is LiveActivitiesChannel) { + // Brings on messages by the user from all other relays. + // Since we ship with write to public, read from private only + // this guarantees that messages from the author do not disappear. + return TypedFilter( + types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH), + filter = + JsonFilter( + kinds = listOf(LiveActivitiesChatMessageEvent.KIND), + authors = listOf(myAccount.userProfile().pubkeyHex), + limit = 50, + ), + ) } + return null + } - fun clear() { - account = null - channel = null + fun createMessagesToChannelFilter(): TypedFilter? { + if (channel is PublicChatChannel) { + return TypedFilter( + types = setOf(FeedType.PUBLIC_CHATS), + filter = + JsonFilter( + kinds = listOf(ChannelMessageEvent.KIND), + tags = mapOf("e" to listOfNotNull(channel?.idHex)), + limit = 200, + ), + ) + } else if (channel is LiveActivitiesChannel) { + return TypedFilter( + types = setOf(FeedType.PUBLIC_CHATS), + filter = + JsonFilter( + kinds = listOf(LiveActivitiesChatMessageEvent.KIND), + tags = mapOf("a" to listOfNotNull(channel?.idHex)), + limit = 200, + ), + ) } + return null + } - fun createMessagesByMeToChannelFilter(): TypedFilter? { - val myAccount = account ?: return null + val messagesChannel = requestNewChannel() - if (channel is PublicChatChannel) { - // Brings on messages by the user from all other relays. - // Since we ship with write to public, read from private only - // this guarantees that messages from the author do not disappear. - return TypedFilter( - types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH), - filter = JsonFilter( - kinds = listOf(ChannelMessageEvent.kind), - authors = listOf(myAccount.userProfile().pubkeyHex), - limit = 50 - ) - ) - } else if (channel is LiveActivitiesChannel) { - // Brings on messages by the user from all other relays. - // Since we ship with write to public, read from private only - // this guarantees that messages from the author do not disappear. - return TypedFilter( - types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH), - filter = JsonFilter( - kinds = listOf(LiveActivitiesChatMessageEvent.kind), - authors = listOf(myAccount.userProfile().pubkeyHex), - limit = 50 - ) - ) - } - return null - } - - fun createMessagesToChannelFilter(): TypedFilter? { - if (channel is PublicChatChannel) { - return TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = JsonFilter( - kinds = listOf(ChannelMessageEvent.kind), - tags = mapOf("e" to listOfNotNull(channel?.idHex)), - limit = 200 - ) - ) - } else if (channel is LiveActivitiesChannel) { - return TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = JsonFilter( - kinds = listOf(LiveActivitiesChatMessageEvent.kind), - tags = mapOf("a" to listOfNotNull(channel?.idHex)), - limit = 200 - ) - ) - } - return null - } - - val messagesChannel = requestNewChannel() - - override fun updateChannelFilters() { - messagesChannel.typedFilters = listOfNotNull( - createMessagesToChannelFilter(), - createMessagesByMeToChannelFilter() - ).ifEmpty { null } - } + override fun updateChannelFilters() { + messagesChannel.typedFilters = + listOfNotNull( + createMessagesToChannelFilter(), + createMessagesByMeToChannelFilter(), + ) + .ifEmpty { null } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt index 1f796113c..a7cbac4c7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.Account @@ -9,65 +29,80 @@ import com.vitorpamplona.quartz.events.ChatroomKey import com.vitorpamplona.quartz.events.PrivateDmEvent object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") { - lateinit var account: Account - private var withRoom: ChatroomKey? = null + lateinit var account: Account + private var withRoom: ChatroomKey? = null - private val latestEOSEs = EOSEAccount() + private val latestEOSEs = EOSEAccount() - fun loadMessagesBetween(accountIn: Account, withRoom: ChatroomKey) { - this.account = accountIn - this.withRoom = withRoom - resetFilters() + fun loadMessagesBetween( + accountIn: Account, + withRoom: ChatroomKey, + ) { + this.account = accountIn + this.withRoom = withRoom + resetFilters() + } + + fun createMessagesToMeFilter(): TypedFilter? { + val myPeer = withRoom + + return if (myPeer != null) { + TypedFilter( + types = setOf(FeedType.PRIVATE_DMS), + filter = + JsonFilter( + kinds = listOf(PrivateDmEvent.KIND), + authors = myPeer.users.map { it }, + tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(withRoom.hashCode().toString()) + ?.relayList, + ), + ) + } else { + null } + } - fun createMessagesToMeFilter(): TypedFilter? { - val myPeer = withRoom + fun createMessagesFromMeFilter(): TypedFilter? { + val myPeer = withRoom - return if (myPeer != null) { - TypedFilter( - types = setOf(FeedType.PRIVATE_DMS), - filter = JsonFilter( - kinds = listOf(PrivateDmEvent.kind), - authors = myPeer.users.map { it }, - tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), - since = latestEOSEs.users[account.userProfile()]?.followList?.get(withRoom.hashCode().toString())?.relayList - ) - ) - } else { - null - } + return if (myPeer != null) { + TypedFilter( + types = setOf(FeedType.PRIVATE_DMS), + filter = + JsonFilter( + kinds = listOf(PrivateDmEvent.KIND), + authors = listOf(account.userProfile().pubkeyHex), + tags = mapOf("p" to myPeer.users.map { it }), + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(withRoom.hashCode().toString()) + ?.relayList, + ), + ) + } else { + null } + } - fun createMessagesFromMeFilter(): TypedFilter? { - val myPeer = withRoom + fun clearEOSEs(account: Account) { + latestEOSEs.removeDataFor(account.userProfile()) + } - return if (myPeer != null) { - TypedFilter( - types = setOf(FeedType.PRIVATE_DMS), - filter = JsonFilter( - kinds = listOf(PrivateDmEvent.kind), - authors = listOf(account.userProfile().pubkeyHex), - tags = mapOf("p" to myPeer.users.map { it }), - since = latestEOSEs.users[account.userProfile()]?.followList?.get(withRoom.hashCode().toString())?.relayList - ) - ) - } else { - null - } - } + val inandoutChannel = requestNewChannel { time, relayUrl -> + latestEOSEs.addOrUpdate(account.userProfile(), withRoom.hashCode().toString(), relayUrl, time) + } - fun clearEOSEs(account: Account) { - latestEOSEs.removeDataFor(account.userProfile()) - } - - val inandoutChannel = requestNewChannel { time, relayUrl -> - latestEOSEs.addOrUpdate(account.userProfile(), withRoom.hashCode().toString(), relayUrl, time) - } - - override fun updateChannelFilters() { - inandoutChannel.typedFilters = listOfNotNull( - createMessagesToMeFilter(), - createMessagesFromMeFilter() - ).ifEmpty { null } - } + override fun updateChannelFilters() { + inandoutChannel.typedFilters = + listOfNotNull( + createMessagesToMeFilter(), + createMessagesFromMeFilter(), + ) + .ifEmpty { null } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt index 4df78423a..0f0833d8f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.Account @@ -12,103 +32,124 @@ import com.vitorpamplona.quartz.events.ChannelMetadataEvent import com.vitorpamplona.quartz.events.PrivateDmEvent object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") { - lateinit var account: Account + lateinit var account: Account - val latestEOSEs = EOSEAccount() - val chatRoomList = "ChatroomList" + val latestEOSEs = EOSEAccount() + val chatRoomList = "ChatroomList" - fun createMessagesToMeFilter() = TypedFilter( - types = setOf(FeedType.PRIVATE_DMS), - filter = JsonFilter( - kinds = listOf(PrivateDmEvent.kind), - tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), - since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList - ) + fun createMessagesToMeFilter() = + TypedFilter( + types = setOf(FeedType.PRIVATE_DMS), + filter = + JsonFilter( + kinds = listOf(PrivateDmEvent.KIND), + tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), + since = + latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList, + ), ) - fun createMessagesFromMeFilter() = TypedFilter( - types = setOf(FeedType.PRIVATE_DMS), - filter = JsonFilter( - kinds = listOf(PrivateDmEvent.kind), - authors = listOf(account.userProfile().pubkeyHex), - since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList - ) + fun createMessagesFromMeFilter() = + TypedFilter( + types = setOf(FeedType.PRIVATE_DMS), + filter = + JsonFilter( + kinds = listOf(PrivateDmEvent.KIND), + authors = listOf(account.userProfile().pubkeyHex), + since = + latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList, + ), ) - fun createChannelsCreatedbyMeFilter() = TypedFilter( + fun createChannelsCreatedbyMeFilter() = + TypedFilter( + types = setOf(FeedType.PUBLIC_CHATS), + filter = + JsonFilter( + kinds = listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND), + authors = listOf(account.userProfile().pubkeyHex), + since = + latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList, + ), + ) + + fun createMyChannelsFilter(): TypedFilter? { + val followingEvents = account.selectedChatsFollowList() + + if (followingEvents.isEmpty()) return null + + return TypedFilter( + // Metadata comes from any relay + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(ChannelCreateEvent.KIND), + ids = followingEvents.toList(), + since = + latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList, + ), + ) + } + + fun createLastChannelInfoFilter(): List? { + val followingEvents = account.selectedChatsFollowList() + + if (followingEvents.isEmpty()) return null + + return followingEvents.map { + TypedFilter( + // Metadata comes from any relay + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(ChannelMetadataEvent.KIND), + tags = mapOf("e" to listOf(it)), + limit = 1, + ), + ) + } + } + + fun createLastMessageOfEachChannelFilter(): List? { + val followingEvents = account.selectedChatsFollowList() + + if (followingEvents.isEmpty()) return null + + return followingEvents.map { + TypedFilter( types = setOf(FeedType.PUBLIC_CHATS), - filter = JsonFilter( - kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind), - authors = listOf(account.userProfile().pubkeyHex), - since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList + filter = + JsonFilter( + kinds = listOf(ChannelMessageEvent.KIND), + tags = mapOf("e" to listOf(it)), + since = + latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList, + // Remember to consider spam that is being removed from the UI + limit = 50, + ), + ) + } + } + + val chatroomListChannel = requestNewChannel { time, relayUrl -> + latestEOSEs.addOrUpdate(account.userProfile(), chatRoomList, relayUrl, time) + } + + override fun updateChannelFilters() { + val list = + listOfNotNull( + createMessagesToMeFilter(), + createMessagesFromMeFilter(), + createMyChannelsFilter(), + ) + + chatroomListChannel.typedFilters = + listOfNotNull( + list, + createLastChannelInfoFilter(), + createLastMessageOfEachChannelFilter(), ) - ) - - fun createMyChannelsFilter(): TypedFilter? { - val followingEvents = account.selectedChatsFollowList() - - if (followingEvents.isEmpty()) return null - - return TypedFilter( - types = COMMON_FEED_TYPES, // Metadata comes from any relay - filter = JsonFilter( - kinds = listOf(ChannelCreateEvent.kind), - ids = followingEvents.toList(), - since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList - ) - ) - } - - fun createLastChannelInfoFilter(): List? { - val followingEvents = account.selectedChatsFollowList() - - if (followingEvents.isEmpty()) return null - - return followingEvents.map { - TypedFilter( - types = COMMON_FEED_TYPES, // Metadata comes from any relay - filter = JsonFilter( - kinds = listOf(ChannelMetadataEvent.kind), - tags = mapOf("e" to listOf(it)), - limit = 1 - ) - ) - } - } - - fun createLastMessageOfEachChannelFilter(): List? { - val followingEvents = account.selectedChatsFollowList() - - if (followingEvents.isEmpty()) return null - - return followingEvents.map { - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = JsonFilter( - kinds = listOf(ChannelMessageEvent.kind), - tags = mapOf("e" to listOf(it)), - since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList, - limit = 50 // Remember to consider spam that is being removed from the UI - ) - ) - } - } - - val chatroomListChannel = requestNewChannel { time, relayUrl -> - latestEOSEs.addOrUpdate(account.userProfile(), chatRoomList, relayUrl, time) - } - - override fun updateChannelFilters() { - val list = listOfNotNull( - createMessagesToMeFilter(), - createMessagesFromMeFilter(), - createMyChannelsFilter() - ) - - chatroomListChannel.typedFilters = listOfNotNull( - list, - createLastChannelInfoFilter(), - createLastMessageOfEachChannelFilter() - ).flatten().ifEmpty { null } - } + .flatten() + .ifEmpty { null } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrCommunityDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrCommunityDataSource.kt index 6e8384a29..cd1bc4c74 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrCommunityDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrCommunityDataSource.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.AddressableNote @@ -8,34 +28,40 @@ import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent object NostrCommunityDataSource : NostrDataSource("SingleCommunityFeed") { - private var communityToWatch: AddressableNote? = null + private var communityToWatch: AddressableNote? = null - private fun createLoadCommunityFilter(): TypedFilter? { - val myCommunityToWatch = communityToWatch ?: return null + private fun createLoadCommunityFilter(): TypedFilter? { + val myCommunityToWatch = communityToWatch ?: return null - val community = myCommunityToWatch.event as? CommunityDefinitionEvent ?: return null + val community = myCommunityToWatch.event as? CommunityDefinitionEvent ?: return null - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - authors = community.moderators().map { it.key }.plus(listOfNotNull(myCommunityToWatch.author?.pubkeyHex)), - tags = mapOf( - "a" to listOf(myCommunityToWatch.address.toTag()) - ), - kinds = listOf(CommunityPostApprovalEvent.kind), - limit = 500 - ) - ) - } + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + authors = + community + .moderators() + .map { it.key } + .plus(listOfNotNull(myCommunityToWatch.author?.pubkeyHex)), + tags = + mapOf( + "a" to listOf(myCommunityToWatch.address.toTag()), + ), + kinds = listOf(CommunityPostApprovalEvent.KIND), + limit = 500, + ), + ) + } - val loadCommunityChannel = requestNewChannel() + val loadCommunityChannel = requestNewChannel() - override fun updateChannelFilters() { - loadCommunityChannel.typedFilters = listOfNotNull(createLoadCommunityFilter()).ifEmpty { null } - } + override fun updateChannelFilters() { + loadCommunityChannel.typedFilters = listOfNotNull(createLoadCommunityFilter()).ifEmpty { null } + } - fun loadCommunity(note: AddressableNote?) { - communityToWatch = note - invalidateFilters() - } + fun loadCommunity(note: AddressableNote?) { + communityToWatch = note + invalidateFilters() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt index 1c31fbd1c..6e2bbb477 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import android.util.Log @@ -8,243 +28,302 @@ import com.vitorpamplona.amethyst.service.relays.Subscription import com.vitorpamplona.amethyst.ui.components.BundledUpdate import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.utils.TimeUtils +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch -import java.util.UUID -import java.util.concurrent.atomic.AtomicBoolean abstract class NostrDataSource(val debugName: String) { - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private var subscriptions = mapOf() - data class Counter(var counter: Int) + private var subscriptions = mapOf() - private var eventCounter = mapOf() - var changingFilters = AtomicBoolean() + data class Counter(var counter: Int) - private var active: Boolean = false + private var eventCounter = mapOf() + var changingFilters = AtomicBoolean() - fun printCounter() { - eventCounter.forEach { - Log.d("STATE DUMP ${this.javaClass.simpleName}", "Received Events ${it.key}: ${it.value.counter}") - } + private var active: Boolean = false + + fun printCounter() { + eventCounter.forEach { + Log.d( + "STATE DUMP ${this.javaClass.simpleName}", + "Received Events ${it.key}: ${it.value.counter}", + ) } + } - private val clientListener = object : Client.Listener() { - override fun onEvent(event: Event, subscriptionId: String, relay: Relay, afterEOSE: Boolean) { - if (subscriptions.containsKey(subscriptionId)) { - val key = "$debugName $subscriptionId ${event.kind}" - val keyValue = eventCounter.get(key) - if (keyValue != null) { - keyValue.counter++ - } else { - eventCounter = eventCounter + Pair(key, Counter(1)) - } + private val clientListener = + object : Client.Listener() { + override fun onEvent( + event: Event, + subscriptionId: String, + relay: Relay, + afterEOSE: Boolean, + ) { + if (subscriptions.containsKey(subscriptionId)) { + val key = "$debugName $subscriptionId ${event.kind}" + val keyValue = eventCounter.get(key) + if (keyValue != null) { + keyValue.counter++ + } else { + eventCounter = eventCounter + Pair(key, Counter(1)) + } - // Log.d(this@NostrDataSource.javaClass.simpleName, "Relay ${relay.url}: ${event.kind}") + // Log.d(this@NostrDataSource.javaClass.simpleName, "Relay ${relay.url}: ${event.kind}") - consume(event, relay) - if (afterEOSE) { - markAsEOSE(subscriptionId, relay) - } - } + consume(event, relay) + if (afterEOSE) { + markAsEOSE(subscriptionId, relay) + } } + } - override fun onError(error: Error, subscriptionId: String, relay: Relay) { - // if (subscriptions.containsKey(subscriptionId)) { - // Log.e( - // this@NostrDataSource.javaClass.simpleName, - // "Relay OnError ${relay.url}: ${error.message}" - // ) - // } - } + override fun onError( + error: Error, + subscriptionId: String, + relay: Relay, + ) { + // if (subscriptions.containsKey(subscriptionId)) { + // Log.e( + // this@NostrDataSource.javaClass.simpleName, + // "Relay OnError ${relay.url}: ${error.message}" + // ) + // } + } - override fun onRelayStateChange(type: Relay.StateType, relay: Relay, subscriptionId: String?) { - // if (subscriptions.containsKey(subscriptionId)) { - // Log.d(this@NostrDataSource.javaClass.simpleName, "Relay ${relay.url} ${subscriptionId} ${type.name}") - // } + override fun onRelayStateChange( + type: Relay.StateType, + relay: Relay, + subscriptionId: String?, + ) { + // if (subscriptions.containsKey(subscriptionId)) { + // Log.d(this@NostrDataSource.javaClass.simpleName, "Relay ${relay.url} ${subscriptionId} + // ${type.name}") + // } - if (type == Relay.StateType.EOSE && subscriptionId != null && subscriptions.containsKey(subscriptionId)) { - markAsEOSE(subscriptionId, relay) - } - } - - override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) { - if (success) { - markAsSeenOnRelay(eventId, relay) - } - } - - override fun onAuth(relay: Relay, challenge: String) { - auth(relay, challenge) - } - - override fun onNotify( - relay: Relay, - description: String + if ( + type == Relay.StateType.EOSE && + subscriptionId != null && + subscriptions.containsKey(subscriptionId) ) { - notify(relay, description) + markAsEOSE(subscriptionId, relay) } - } + } - init { - Log.d(this.javaClass.simpleName, "${this.javaClass.simpleName} Subscribe") - Client.subscribe(clientListener) - } - - fun destroy() { - // makes sure to run - Log.d(this.javaClass.simpleName, "${this.javaClass.simpleName} Unsubscribe") - stop() - Client.unsubscribe(clientListener) - scope.cancel() - bundler.cancel() - } - - open fun start() { - println("DataSource: ${this.javaClass.simpleName} Start") - active = true - resetFilters() - } - - open fun stop() { - active = false - println("DataSource: ${this.javaClass.simpleName} Stop") - - GlobalScope.launch(Dispatchers.IO) { - subscriptions.values.forEach { subscription -> - Client.close(subscription.id) - subscription.typedFilters = null - } + override fun onSendResponse( + eventId: String, + success: Boolean, + message: String, + relay: Relay, + ) { + if (success) { + markAsSeenOnRelay(eventId, relay) } + } + + override fun onAuth( + relay: Relay, + challenge: String, + ) { + auth(relay, challenge) + } + + override fun onNotify( + relay: Relay, + description: String, + ) { + notify(relay, description) + } } - open fun stopSync() { - active = false - println("DataSource: ${this.javaClass.simpleName} Stop") + init { + Log.d(this.javaClass.simpleName, "${this.javaClass.simpleName} Subscribe") + Client.subscribe(clientListener) + } - subscriptions.values.forEach { subscription -> - Client.close(subscription.id) - subscription.typedFilters = null - } - } + fun destroy() { + // makes sure to run + Log.d(this.javaClass.simpleName, "${this.javaClass.simpleName} Unsubscribe") + stop() + Client.unsubscribe(clientListener) + scope.cancel() + bundler.cancel() + } - fun requestNewChannel(onEOSE: ((Long, String) -> Unit)? = null): Subscription { - val newSubscription = Subscription(UUID.randomUUID().toString().substring(0, 4), onEOSE) - subscriptions = subscriptions + Pair(newSubscription.id, newSubscription) - return newSubscription - } + open fun start() { + println("DataSource: ${this.javaClass.simpleName} Start") + active = true + resetFilters() + } - fun dismissChannel(subscription: Subscription) { + open fun stop() { + active = false + println("DataSource: ${this.javaClass.simpleName} Stop") + + GlobalScope.launch(Dispatchers.IO) { + subscriptions.values.forEach { subscription -> Client.close(subscription.id) - subscriptions = subscriptions.minus(subscription.id) + subscription.typedFilters = null + } } + } - // Refreshes observers in batches. - private val bundler = BundledUpdate(300, Dispatchers.IO) + open fun stopSync() { + active = false + println("DataSource: ${this.javaClass.simpleName} Stop") - fun invalidateFilters() { - scope.launch(Dispatchers.IO) { - bundler.invalidate() { - // println("DataSource: ${this.javaClass.simpleName} InvalidateFilters") + subscriptions.values.forEach { subscription -> + Client.close(subscription.id) + subscription.typedFilters = null + } + } - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - resetFiltersSuspend() - } + fun requestNewChannel(onEOSE: ((Long, String) -> Unit)? = null): Subscription { + val newSubscription = Subscription(UUID.randomUUID().toString().substring(0, 4), onEOSE) + subscriptions = subscriptions + Pair(newSubscription.id, newSubscription) + return newSubscription + } + + fun dismissChannel(subscription: Subscription) { + Client.close(subscription.id) + subscriptions = subscriptions.minus(subscription.id) + } + + // Refreshes observers in batches. + private val bundler = BundledUpdate(300, Dispatchers.IO) + + fun invalidateFilters() { + scope.launch(Dispatchers.IO) { + bundler.invalidate { + // println("DataSource: ${this.javaClass.simpleName} InvalidateFilters") + + // adds the time to perform the refresh into this delay + // holding off new updates in case of heavy refresh routines. + resetFiltersSuspend() + } + } + } + + fun resetFilters() { + scope.launch(Dispatchers.IO) { resetFiltersSuspend() } + } + + fun resetFiltersSuspend() { + println("DataSource: ${this.javaClass.simpleName} resetFiltersSuspend $active") + checkNotInMainThread() + + // saves the channels that are currently active + val activeSubscriptions = subscriptions.values.filter { it.typedFilters != null } + // saves the current content to only update if it changes + val currentFilters = activeSubscriptions.associate { it.id to it.toJson() } + + changingFilters.getAndSet(true) + + updateChannelFilters() + + // Makes sure to only send an updated filter when it actually changes. + subscriptions.values.forEach { updatedSubscription -> + val updatedSubscriptionNewFilters = updatedSubscription.typedFilters + + val isActive = Client.isActive(updatedSubscription.id) + + if (!isActive && updatedSubscriptionNewFilters != null) { + // Filter was removed from the active list + if (active) { + Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) } - } - - fun resetFilters() { - scope.launch(Dispatchers.IO) { - resetFiltersSuspend() - } - } - - fun resetFiltersSuspend() { - println("DataSource: ${this.javaClass.simpleName} resetFiltersSuspend $active") - checkNotInMainThread() - - // saves the channels that are currently active - val activeSubscriptions = subscriptions.values.filter { it.typedFilters != null } - // saves the current content to only update if it changes - val currentFilters = activeSubscriptions.associate { it.id to it.toJson() } - - changingFilters.getAndSet(true) - - updateChannelFilters() - - // Makes sure to only send an updated filter when it actually changes. - subscriptions.values.forEach { updatedSubscription -> - val updatedSubscriptionNewFilters = updatedSubscription.typedFilters - - val isActive = Client.isActive(updatedSubscription.id) - - if (!isActive && updatedSubscriptionNewFilters != null) { - // Filter was removed from the active list - if (active) { - Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) - } + } else { + if (currentFilters.containsKey(updatedSubscription.id)) { + if (updatedSubscriptionNewFilters == null) { + // was active and is not active anymore, just close. + Client.close(updatedSubscription.id) + } else { + // was active and is still active, check if it has changed. + if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) { + Client.close(updatedSubscription.id) + if (active) { + Log.d( + this@NostrDataSource.javaClass.simpleName, + "Update Filter 1 ${updatedSubscription.id} ${Client.isSubscribed(clientListener)}", + ) + Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) + } } else { - if (currentFilters.containsKey(updatedSubscription.id)) { - if (updatedSubscriptionNewFilters == null) { - // was active and is not active anymore, just close. - Client.close(updatedSubscription.id) - } else { - // was active and is still active, check if it has changed. - if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) { - Client.close(updatedSubscription.id) - if (active) { - Log.d(this@NostrDataSource.javaClass.simpleName, "Update Filter 1 ${updatedSubscription.id} ${Client.isSubscribed(clientListener)}") - Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) - } - } else { - // hasn't changed, does nothing. - if (active) { - Log.d(this@NostrDataSource.javaClass.simpleName, "Update Filter 2 ${updatedSubscription.id} ${Client.isSubscribed(clientListener)}") - Client.sendFilterOnlyIfDisconnected(updatedSubscription.id, updatedSubscriptionNewFilters) - } - } - } - } else { - if (updatedSubscriptionNewFilters == null) { - // was not active and is still not active, does nothing - } else { - // was not active and becomes active, sends the filter. - if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) { - if (active) { - Log.d(this@NostrDataSource.javaClass.simpleName, "Update Filter 3 ${updatedSubscription.id} ${Client.isSubscribed(clientListener)}") - Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) - } - } - } - } + // hasn't changed, does nothing. + if (active) { + Log.d( + this@NostrDataSource.javaClass.simpleName, + "Update Filter 2 ${updatedSubscription.id} ${Client.isSubscribed(clientListener)}", + ) + Client.sendFilterOnlyIfDisconnected( + updatedSubscription.id, + updatedSubscriptionNewFilters, + ) + } } + } + } else { + if (updatedSubscriptionNewFilters == null) { + // was not active and is still not active, does nothing + } else { + // was not active and becomes active, sends the filter. + if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) { + if (active) { + Log.d( + this@NostrDataSource.javaClass.simpleName, + "Update Filter 3 ${updatedSubscription.id} ${Client.isSubscribed(clientListener)}", + ) + Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) + } + } + } } - - changingFilters.getAndSet(false) + } } - open fun consume(event: Event, relay: Relay) { - LocalCache.verifyAndConsume(event, relay) - } + changingFilters.getAndSet(false) + } - open fun markAsSeenOnRelay(eventId: String, relay: Relay) { - LocalCache.getNoteIfExists(eventId)?.addRelay(relay) - } + open fun consume( + event: Event, + relay: Relay, + ) { + LocalCache.verifyAndConsume(event, relay) + } - open fun markAsEOSE(subscriptionId: String, relay: Relay) { - subscriptions[subscriptionId]?.updateEOSE( - TimeUtils.oneMinuteAgo(), // in case people's clock is slighly off. - relay.url - ) - } + open fun markAsSeenOnRelay( + eventId: String, + relay: Relay, + ) { + LocalCache.getNoteIfExists(eventId)?.addRelay(relay) + } - abstract fun updateChannelFilters() - open fun auth(relay: Relay, challenge: String) = Unit - open fun notify(relay: Relay, description: String) = Unit + open fun markAsEOSE( + subscriptionId: String, + relay: Relay, + ) { + subscriptions[subscriptionId]?.updateEOSE( + // in case people's clock is slighly off. + TimeUtils.oneMinuteAgo(), + relay.url, + ) + } + + abstract fun updateChannelFilters() + + open fun auth( + relay: Relay, + challenge: String, + ) = Unit + + open fun notify( + relay: Relay, + description: String, + ) = Unit } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt index a88dcc92b..14f3f8d53 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.Amethyst @@ -19,287 +39,384 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") { - lateinit var account: Account + lateinit var account: Account - val scope = Amethyst.instance.applicationIOScope - val latestEOSEs = EOSEAccount() + val scope = Amethyst.instance.applicationIOScope + val latestEOSEs = EOSEAccount() - var job: Job? = null + var job: Job? = null - override fun start() { - job?.cancel() - job = scope.launch(Dispatchers.IO) { - account.liveDiscoveryFollowLists.collect { - if (this@NostrDiscoveryDataSource::account.isInitialized) { - invalidateFilters() - } - } + override fun start() { + job?.cancel() + job = + scope.launch(Dispatchers.IO) { + account.liveDiscoveryFollowLists.collect { + if (this@NostrDiscoveryDataSource::account.isInitialized) { + invalidateFilters() + } } - super.start() - } + } + super.start() + } - override fun stop() { - super.stop() - job?.cancel() - } + override fun stop() { + super.stop() + job?.cancel() + } - fun createMarketplaceFilter(): List { - val follows = account.liveDiscoveryFollowLists.value?.users?.toList() - val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList() - val geohashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList() + fun createMarketplaceFilter(): List { + val follows = account.liveDiscoveryFollowLists.value?.users?.toList() + val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList() + val geohashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList() - return listOfNotNull( - TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = JsonFilter( - authors = follows, - kinds = listOf(ClassifiedsEvent.kind), - limit = 300, - since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList - ) + return listOfNotNull( + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + JsonFilter( + authors = follows, + kinds = listOf(ClassifiedsEvent.KIND), + limit = 300, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultDiscoveryFollowList.value) + ?.relayList, + ), + ), + hashToLoad?.let { + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + JsonFilter( + kinds = listOf(ClassifiedsEvent.KIND), + tags = + mapOf( + "t" to + it + .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } + .flatten(), + ), + limit = 300, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultDiscoveryFollowList.value) + ?.relayList, ), - hashToLoad?.let { - TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = JsonFilter( - kinds = listOf(ClassifiedsEvent.kind), - tags = mapOf( - "t" to it.map { - listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) - }.flatten() - ), - limit = 300, - since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList - ) - ) - }, - geohashToLoad?.let { - TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = JsonFilter( - kinds = listOf(ClassifiedsEvent.kind), - tags = mapOf( - "g" to it.map { - listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) - }.flatten() - ), - limit = 300, - since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList - ) - ) - } ) - } - - fun createLiveStreamFilter(): List { - val follows = account.liveDiscoveryFollowLists.value?.users?.toList() - - return listOfNotNull( - TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = JsonFilter( - authors = follows, - kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind), - limit = 300, - since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList - ) + }, + geohashToLoad?.let { + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + JsonFilter( + kinds = listOf(ClassifiedsEvent.KIND), + tags = + mapOf( + "g" to + it + .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } + .flatten(), + ), + limit = 300, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultDiscoveryFollowList.value) + ?.relayList, ), - follows?.let { - TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = JsonFilter( - tags = mapOf("p" to it), - kinds = listOf(LiveActivitiesEvent.kind), - limit = 100, - since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList - ) - ) - } ) - } + }, + ) + } - fun createPublicChatFilter(): List { - val follows = account.liveDiscoveryFollowLists.value?.users?.toList() - val followChats = account.selectedChatsFollowList().toList() + fun createLiveStreamFilter(): List { + val follows = account.liveDiscoveryFollowLists.value?.users?.toList() - return listOfNotNull( - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = JsonFilter( - authors = follows, - kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind), - limit = 300, - since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList - ) + return listOfNotNull( + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + JsonFilter( + authors = follows, + kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), + limit = 300, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultDiscoveryFollowList.value) + ?.relayList, + ), + ), + follows?.let { + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + JsonFilter( + tags = mapOf("p" to it), + kinds = listOf(LiveActivitiesEvent.KIND), + limit = 100, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultDiscoveryFollowList.value) + ?.relayList, ), - if (followChats.isNotEmpty()) { - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = JsonFilter( - ids = followChats, - kinds = listOf(ChannelCreateEvent.kind), - limit = 300, - since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList - ) - ) - } else { - null - } ) - } + }, + ) + } - fun createCommunitiesFilter(): TypedFilter { - val follows = account.liveDiscoveryFollowLists.value?.users?.toList() + fun createPublicChatFilter(): List { + val follows = account.liveDiscoveryFollowLists.value?.users?.toList() + val followChats = account.selectedChatsFollowList().toList() - return TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = JsonFilter( - authors = follows, - kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind), - limit = 300, - since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList - ) + return listOfNotNull( + TypedFilter( + types = setOf(FeedType.PUBLIC_CHATS), + filter = + JsonFilter( + authors = follows, + kinds = + listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND), + limit = 300, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultDiscoveryFollowList.value) + ?.relayList, + ), + ), + if (followChats.isNotEmpty()) { + TypedFilter( + types = setOf(FeedType.PUBLIC_CHATS), + filter = + JsonFilter( + ids = followChats, + kinds = listOf(ChannelCreateEvent.KIND), + limit = 300, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultDiscoveryFollowList.value) + ?.relayList, + ), ) - } + } else { + null + }, + ) + } - fun createLiveStreamTagsFilter(): TypedFilter? { - val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList() + fun createCommunitiesFilter(): TypedFilter { + val follows = account.liveDiscoveryFollowLists.value?.users?.toList() - if (hashToLoad.isNullOrEmpty()) return null + return TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + JsonFilter( + authors = follows, + kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), + limit = 300, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultDiscoveryFollowList.value) + ?.relayList, + ), + ) + } - return TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = JsonFilter( - kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind), - tags = mapOf( - "t" to hashToLoad.map { - listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) - }.flatten() - ), - limit = 300, - since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList - ) + fun createLiveStreamTagsFilter(): TypedFilter? { + val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList() + + if (hashToLoad.isNullOrEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + JsonFilter( + kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), + tags = + mapOf( + "t" to + hashToLoad + .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } + .flatten(), + ), + limit = 300, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultDiscoveryFollowList.value) + ?.relayList, + ), + ) + } + + fun createLiveStreamGeohashesFilter(): TypedFilter? { + val hashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList() + + if (hashToLoad.isNullOrEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + JsonFilter( + kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), + tags = + mapOf( + "g" to + hashToLoad + .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } + .flatten(), + ), + limit = 300, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultDiscoveryFollowList.value) + ?.relayList, + ), + ) + } + + fun createPublicChatsTagsFilter(): TypedFilter? { + val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList() + + if (hashToLoad.isNullOrEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.PUBLIC_CHATS), + filter = + JsonFilter( + kinds = + listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND), + tags = + mapOf( + "t" to + hashToLoad + .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } + .flatten(), + ), + limit = 300, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultDiscoveryFollowList.value) + ?.relayList, + ), + ) + } + + fun createPublicChatsGeohashesFilter(): TypedFilter? { + val hashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList() + + if (hashToLoad.isNullOrEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.PUBLIC_CHATS), + filter = + JsonFilter( + kinds = + listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND), + tags = + mapOf( + "g" to + hashToLoad + .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } + .flatten(), + ), + limit = 300, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultDiscoveryFollowList.value) + ?.relayList, + ), + ) + } + + fun createCommunitiesTagsFilter(): TypedFilter? { + val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList() + + if (hashToLoad.isNullOrEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + JsonFilter( + kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), + tags = + mapOf( + "t" to + hashToLoad + .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } + .flatten(), + ), + limit = 300, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultDiscoveryFollowList.value) + ?.relayList, + ), + ) + } + + fun createCommunitiesGeohashesFilter(): TypedFilter? { + val hashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList() + + if (hashToLoad.isNullOrEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + JsonFilter( + kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), + tags = + mapOf( + "g" to + hashToLoad + .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } + .flatten(), + ), + limit = 300, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultDiscoveryFollowList.value) + ?.relayList, + ), + ) + } + + val discoveryFeedChannel = requestNewChannel { time, relayUrl -> + latestEOSEs.addOrUpdate( + account.userProfile(), + account.defaultDiscoveryFollowList.value, + relayUrl, + time, + ) + } + + override fun updateChannelFilters() { + discoveryFeedChannel.typedFilters = + createLiveStreamFilter() + .plus(createPublicChatFilter()) + .plus(createMarketplaceFilter()) + .plus( + listOfNotNull( + createLiveStreamTagsFilter(), + createLiveStreamGeohashesFilter(), + createCommunitiesFilter(), + createCommunitiesTagsFilter(), + createCommunitiesGeohashesFilter(), + createPublicChatsTagsFilter(), + createPublicChatsGeohashesFilter(), + ), ) - } - - fun createLiveStreamGeohashesFilter(): TypedFilter? { - val hashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList() - - if (hashToLoad.isNullOrEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = JsonFilter( - kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind), - tags = mapOf( - "g" to hashToLoad.map { - listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) - }.flatten() - ), - limit = 300, - since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList - ) - ) - } - - fun createPublicChatsTagsFilter(): TypedFilter? { - val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList() - - if (hashToLoad.isNullOrEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = JsonFilter( - kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind), - tags = mapOf( - "t" to hashToLoad.map { - listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) - }.flatten() - ), - limit = 300, - since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList - ) - ) - } - - fun createPublicChatsGeohashesFilter(): TypedFilter? { - val hashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList() - - if (hashToLoad.isNullOrEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = JsonFilter( - kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind), - tags = mapOf( - "g" to hashToLoad.map { - listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) - }.flatten() - ), - limit = 300, - since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList - ) - ) - } - - fun createCommunitiesTagsFilter(): TypedFilter? { - val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList() - - if (hashToLoad.isNullOrEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = JsonFilter( - kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind), - tags = mapOf( - "t" to hashToLoad.map { - listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) - }.flatten() - ), - limit = 300, - since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList - ) - ) - } - - fun createCommunitiesGeohashesFilter(): TypedFilter? { - val hashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList() - - if (hashToLoad.isNullOrEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = JsonFilter( - kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind), - tags = mapOf( - "g" to hashToLoad.map { - listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) - }.flatten() - ), - limit = 300, - since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList - ) - ) - } - - val discoveryFeedChannel = requestNewChannel() { time, relayUrl -> - latestEOSEs.addOrUpdate(account.userProfile(), account.defaultDiscoveryFollowList.value, relayUrl, time) - } - - override fun updateChannelFilters() { - discoveryFeedChannel.typedFilters = createLiveStreamFilter() - .plus(createPublicChatFilter()) - .plus(createMarketplaceFilter()) - .plus( - listOfNotNull( - createLiveStreamTagsFilter(), - createLiveStreamGeohashesFilter(), - createCommunitiesFilter(), - createCommunitiesTagsFilter(), - createCommunitiesGeohashesFilter(), - createPublicChatsTagsFilter(), - createPublicChatsGeohashesFilter() - ) - ).ifEmpty { null } - } + .ifEmpty { null } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt index 8ff6d369d..0c0da05c3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES @@ -14,34 +34,48 @@ import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.TextNoteEvent object NostrGeohashDataSource : NostrDataSource("SingleGeoHashFeed") { - private var geohashToWatch: String? = null + private var geohashToWatch: String? = null - fun createLoadHashtagFilter(): TypedFilter? { - val hashToLoad = geohashToWatch ?: return null + fun createLoadHashtagFilter(): TypedFilter? { + val hashToLoad = geohashToWatch ?: return null - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - tags = mapOf( - "g" to listOf( - hashToLoad - ) + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + tags = + mapOf( + "g" to + listOf( + hashToLoad, ), - kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, LiveActivitiesChatMessageEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, AudioHeaderEvent.kind), - limit = 200 - ) - ) - } + ), + kinds = + listOf( + TextNoteEvent.KIND, + ChannelMessageEvent.KIND, + LongTextNoteEvent.KIND, + PollNoteEvent.KIND, + LiveActivitiesChatMessageEvent.KIND, + ClassifiedsEvent.KIND, + HighlightEvent.KIND, + AudioTrackEvent.KIND, + AudioHeaderEvent.KIND, + ), + limit = 200, + ), + ) + } - val loadGeohashChannel = requestNewChannel() + val loadGeohashChannel = requestNewChannel() - override fun updateChannelFilters() { - loadGeohashChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null } - } + override fun updateChannelFilters() { + loadGeohashChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null } + } - fun loadHashtag(tag: String?) { - geohashToWatch = tag + fun loadHashtag(tag: String?) { + geohashToWatch = tag - invalidateFilters() - } + invalidateFilters() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt index 7a382577f..62ea28487 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES @@ -14,37 +34,51 @@ import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.TextNoteEvent object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") { - private var hashtagToWatch: String? = null + private var hashtagToWatch: String? = null - fun createLoadHashtagFilter(): TypedFilter? { - val hashToLoad = hashtagToWatch ?: return null + fun createLoadHashtagFilter(): TypedFilter? { + val hashToLoad = hashtagToWatch ?: return null - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - tags = mapOf( - "t" to listOf( - hashToLoad, - hashToLoad.lowercase(), - hashToLoad.uppercase(), - hashToLoad.capitalize() - ) + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + tags = + mapOf( + "t" to + listOf( + hashToLoad, + hashToLoad.lowercase(), + hashToLoad.uppercase(), + hashToLoad.capitalize(), ), - kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, LiveActivitiesChatMessageEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, AudioHeaderEvent.kind), - limit = 200 - ) - ) - } + ), + kinds = + listOf( + TextNoteEvent.KIND, + ChannelMessageEvent.KIND, + LongTextNoteEvent.KIND, + PollNoteEvent.KIND, + LiveActivitiesChatMessageEvent.KIND, + ClassifiedsEvent.KIND, + HighlightEvent.KIND, + AudioTrackEvent.KIND, + AudioHeaderEvent.KIND, + ), + limit = 200, + ), + ) + } - val loadHashtagChannel = requestNewChannel() + val loadHashtagChannel = requestNewChannel() - override fun updateChannelFilters() { - loadHashtagChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null } - } + override fun updateChannelFilters() { + loadHashtagChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null } + } - fun loadHashtag(tag: String?) { - hashtagToWatch = tag + fun loadHashtag(tag: String?) { + hashtagToWatch = tag - invalidateFilters() - } + invalidateFilters() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt index d54d9a38b..577c0c072 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.Amethyst @@ -25,139 +45,190 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext object NostrHomeDataSource : NostrDataSource("HomeFeed") { - lateinit var account: Account + lateinit var account: Account - val scope = Amethyst.instance.applicationIOScope - val latestEOSEs = EOSEAccount() + val scope = Amethyst.instance.applicationIOScope + val latestEOSEs = EOSEAccount() - var job: Job? = null + var job: Job? = null - override fun start() { - job?.cancel() - job = scope.launch(Dispatchers.IO) { - // creates cache on main - withContext(Dispatchers.Main) { - account.userProfile().live() - } - account.liveHomeFollowLists.collect { - if (this@NostrHomeDataSource::account.isInitialized) { - invalidateFilters() - } - } + override fun start() { + job?.cancel() + job = + scope.launch(Dispatchers.IO) { + // creates cache on main + withContext(Dispatchers.Main) { account.userProfile().live() } + account.liveHomeFollowLists.collect { + if (this@NostrHomeDataSource::account.isInitialized) { + invalidateFilters() + } } - super.start() - } + } + super.start() + } - override fun stop() { - super.stop() - job?.cancel() - } + override fun stop() { + super.stop() + job?.cancel() + } - fun createFollowAccountsFilter(): TypedFilter { - val follows = account.liveHomeFollowLists.value?.users - val followSet = follows?.plus(account.userProfile().pubkeyHex)?.toList()?.ifEmpty { null } + fun createFollowAccountsFilter(): TypedFilter { + val follows = account.liveHomeFollowLists.value?.users + val followSet = follows?.plus(account.userProfile().pubkeyHex)?.toList()?.ifEmpty { null } - return TypedFilter( - types = setOf(FeedType.FOLLOWS), - filter = JsonFilter( - kinds = listOf( - TextNoteEvent.kind, - RepostEvent.kind, - GenericRepostEvent.kind, - ClassifiedsEvent.kind, - LongTextNoteEvent.kind, - PollNoteEvent.kind, - HighlightEvent.kind, - AudioTrackEvent.kind, - AudioHeaderEvent.kind, - PinListEvent.kind, - LiveActivitiesChatMessageEvent.kind, - LiveActivitiesEvent.kind - ), - authors = followSet, - limit = 400, - since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList.value)?.relayList - ) + return TypedFilter( + types = setOf(FeedType.FOLLOWS), + filter = + JsonFilter( + kinds = + listOf( + TextNoteEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + ClassifiedsEvent.KIND, + LongTextNoteEvent.KIND, + PollNoteEvent.KIND, + HighlightEvent.KIND, + AudioTrackEvent.KIND, + AudioHeaderEvent.KIND, + PinListEvent.KIND, + LiveActivitiesChatMessageEvent.KIND, + LiveActivitiesEvent.KIND, + ), + authors = followSet, + limit = 400, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultHomeFollowList.value) + ?.relayList, + ), + ) + } + + fun createFollowTagsFilter(): TypedFilter? { + val hashToLoad = account.liveHomeFollowLists.value?.hashtags ?: return null + + if (hashToLoad.isEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.FOLLOWS), + filter = + JsonFilter( + kinds = + listOf( + TextNoteEvent.KIND, + LongTextNoteEvent.KIND, + ClassifiedsEvent.KIND, + HighlightEvent.KIND, + AudioHeaderEvent.KIND, + AudioTrackEvent.KIND, + PinListEvent.KIND, + ), + tags = + mapOf( + "t" to + hashToLoad + .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } + .flatten(), + ), + limit = 100, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultHomeFollowList.value) + ?.relayList, + ), + ) + } + + fun createFollowGeohashesFilter(): TypedFilter? { + val hashToLoad = account.liveHomeFollowLists.value?.geotags ?: return null + + if (hashToLoad.isEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.FOLLOWS), + filter = + JsonFilter( + kinds = + listOf( + TextNoteEvent.KIND, + LongTextNoteEvent.KIND, + ClassifiedsEvent.KIND, + HighlightEvent.KIND, + AudioHeaderEvent.KIND, + AudioTrackEvent.KIND, + PinListEvent.KIND, + ), + tags = + mapOf( + "g" to + hashToLoad + .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } + .flatten(), + ), + limit = 100, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultHomeFollowList.value) + ?.relayList, + ), + ) + } + + fun createFollowCommunitiesFilter(): TypedFilter? { + val communitiesToLoad = account.liveHomeFollowLists.value?.communities ?: return null + + if (communitiesToLoad.isEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.FOLLOWS), + filter = + JsonFilter( + kinds = + listOf( + TextNoteEvent.KIND, + LongTextNoteEvent.KIND, + ClassifiedsEvent.KIND, + HighlightEvent.KIND, + AudioHeaderEvent.KIND, + AudioTrackEvent.KIND, + PinListEvent.KIND, + CommunityPostApprovalEvent.KIND, + ), + tags = + mapOf( + "a" to communitiesToLoad.toList(), + ), + limit = 100, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultHomeFollowList.value) + ?.relayList, + ), + ) + } + + val followAccountChannel = requestNewChannel { time, relayUrl -> + latestEOSEs.addOrUpdate( + account.userProfile(), + account.defaultHomeFollowList.value, + relayUrl, + time, + ) + } + + override fun updateChannelFilters() { + followAccountChannel.typedFilters = + listOfNotNull( + createFollowAccountsFilter(), + createFollowCommunitiesFilter(), + createFollowTagsFilter(), + createFollowGeohashesFilter(), ) - } - - fun createFollowTagsFilter(): TypedFilter? { - val hashToLoad = account.liveHomeFollowLists.value?.hashtags ?: return null - - if (hashToLoad.isEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.FOLLOWS), - filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioHeaderEvent.kind, AudioTrackEvent.kind, PinListEvent.kind), - tags = mapOf( - "t" to hashToLoad.map { - listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) - }.flatten() - ), - limit = 100, - since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList.value)?.relayList - ) - ) - } - - fun createFollowGeohashesFilter(): TypedFilter? { - val hashToLoad = account.liveHomeFollowLists.value?.geotags ?: return null - - if (hashToLoad.isEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.FOLLOWS), - filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioHeaderEvent.kind, AudioTrackEvent.kind, PinListEvent.kind), - tags = mapOf( - "g" to hashToLoad.map { - listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) - }.flatten() - ), - limit = 100, - since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList.value)?.relayList - ) - ) - } - - fun createFollowCommunitiesFilter(): TypedFilter? { - val communitiesToLoad = account.liveHomeFollowLists.value?.communities ?: return null - - if (communitiesToLoad.isEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.FOLLOWS), - filter = JsonFilter( - kinds = listOf( - TextNoteEvent.kind, - LongTextNoteEvent.kind, - ClassifiedsEvent.kind, - HighlightEvent.kind, - AudioHeaderEvent.kind, - AudioTrackEvent.kind, - PinListEvent.kind, - CommunityPostApprovalEvent.kind - ), - tags = mapOf( - "a" to communitiesToLoad.toList() - ), - limit = 100, - since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList.value)?.relayList - ) - ) - } - - val followAccountChannel = requestNewChannel { time, relayUrl -> - latestEOSEs.addOrUpdate(account.userProfile(), account.defaultHomeFollowList.value, relayUrl, time) - } - - override fun updateChannelFilters() { - followAccountChannel.typedFilters = listOfNotNull( - createFollowAccountsFilter(), - createFollowCommunitiesFilter(), - createFollowTagsFilter(), - createFollowGeohashesFilter() - ).ifEmpty { null } - } + .ifEmpty { null } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt index 7fb698619..f325165e6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.service.relays.Client @@ -10,46 +30,50 @@ import com.vitorpamplona.quartz.events.RelayAuthEvent import com.vitorpamplona.quartz.signers.NostrSigner class NostrLnZapPaymentResponseDataSource( - private val fromServiceHex: String, - private val toUserHex: String, - private val replyingToHex: String, - private val authSigner: NostrSigner + private val fromServiceHex: String, + private val toUserHex: String, + private val replyingToHex: String, + private val authSigner: NostrSigner, ) : NostrDataSource("LnZapPaymentResponseFeed") { + val feedTypes = setOf(FeedType.WALLET_CONNECT) - val feedTypes = setOf(FeedType.WALLET_CONNECT) + private fun createWalletConnectServiceWatcher(): TypedFilter { + // downloads all the reactions to a given event. + return TypedFilter( + types = feedTypes, + filter = + JsonFilter( + kinds = listOf(LnZapPaymentResponseEvent.KIND), + authors = listOf(fromServiceHex), + tags = + mapOf( + "e" to listOf(replyingToHex), + "p" to listOf(toUserHex), + ), + limit = 1, + ), + ) + } - private fun createWalletConnectServiceWatcher(): TypedFilter { - // downloads all the reactions to a given event. - return TypedFilter( - types = feedTypes, - filter = JsonFilter( - kinds = listOf(LnZapPaymentResponseEvent.kind), - authors = listOf(fromServiceHex), - tags = mapOf( - "e" to listOf(replyingToHex), - "p" to listOf(toUserHex) - ), - limit = 1 - ) - ) - } - - val channel = requestNewChannel() - - override fun updateChannelFilters() { - val wc = createWalletConnectServiceWatcher() - - channel.typedFilters = listOfNotNull(wc).ifEmpty { null } - } - - override fun auth(relay: Relay, challenge: String) { - super.auth(relay, challenge) - - RelayAuthEvent.create(relay.url, challenge, authSigner) { - Client.send( - it, - relay.url - ) - } + val channel = requestNewChannel() + + override fun updateChannelFilters() { + val wc = createWalletConnectServiceWatcher() + + channel.typedFilters = listOfNotNull(wc).ifEmpty { null } + } + + override fun auth( + relay: Relay, + challenge: String, + ) { + super.auth(relay, challenge) + + RelayAuthEvent.create(relay.url, challenge, authSigner) { + Client.send( + it, + relay.url, + ) } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt index 9bfaebdf2..7c8a799ab 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES @@ -28,105 +48,120 @@ import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.TextNoteEvent object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") { - private var searchString: String? = null + private var searchString: String? = null - private fun createAnythingWithIDFilter(): List? { - val mySearchString = searchString - if (mySearchString.isNullOrBlank()) { - return null - } + private fun createAnythingWithIDFilter(): List? { + val mySearchString = searchString + if (mySearchString.isNullOrBlank()) { + return null + } - val hexToWatch = try { - val isAStraightHex = if (HexValidator.isHex(mySearchString)) { - Hex.decode(mySearchString).toHexKey() - } else { - null - } - - Nip19.uriToRoute(mySearchString)?.hex ?: isAStraightHex - } catch (e: Exception) { + val hexToWatch = + try { + val isAStraightHex = + if (HexValidator.isHex(mySearchString)) { + Hex.decode(mySearchString).toHexKey() + } else { null - } + } - // downloads all the reactions to a given event. - return listOfNotNull( - hexToWatch?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - ids = listOfNotNull(hexToWatch) - ) - ) - }, - hexToWatch?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - kinds = listOf(MetadataEvent.kind), - authors = listOfNotNull(hexToWatch) - ) - ) - }, - TypedFilter( - types = setOf(FeedType.SEARCH), - filter = JsonFilter( - kinds = listOf(MetadataEvent.kind), - search = mySearchString, - limit = 100 - ) + Nip19.uriToRoute(mySearchString)?.hex ?: isAStraightHex + } catch (e: Exception) { + null + } + + // downloads all the reactions to a given event. + return listOfNotNull( + hexToWatch?.let { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + ids = listOfNotNull(hexToWatch), ), - TypedFilter( - types = setOf(FeedType.SEARCH), - filter = JsonFilter( - kinds = listOf( - TextNoteEvent.kind, LongTextNoteEvent.kind, BadgeDefinitionEvent.kind, - PeopleListEvent.kind, BookmarkListEvent.kind, AudioHeaderEvent.kind, - AudioTrackEvent.kind, PinListEvent.kind, PollNoteEvent.kind, - ChannelCreateEvent.kind - ), - search = mySearchString, - limit = 100 - ) - ), - TypedFilter( - types = setOf(FeedType.SEARCH), - filter = JsonFilter( - kinds = listOf( - ChannelMetadataEvent.kind, - ClassifiedsEvent.kind, - CommunityDefinitionEvent.kind, - EmojiPackEvent.kind, - HighlightEvent.kind, - LiveActivitiesEvent.kind, - PollNoteEvent.kind, - NNSEvent.kind - ), - search = mySearchString, - limit = 100 - ) - ) ) - } + }, + hexToWatch?.let { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(MetadataEvent.KIND), + authors = listOfNotNull(hexToWatch), + ), + ) + }, + TypedFilter( + types = setOf(FeedType.SEARCH), + filter = + JsonFilter( + kinds = listOf(MetadataEvent.KIND), + search = mySearchString, + limit = 100, + ), + ), + TypedFilter( + types = setOf(FeedType.SEARCH), + filter = + JsonFilter( + kinds = + listOf( + TextNoteEvent.KIND, + LongTextNoteEvent.KIND, + BadgeDefinitionEvent.KIND, + PeopleListEvent.KIND, + BookmarkListEvent.KIND, + AudioHeaderEvent.KIND, + AudioTrackEvent.KIND, + PinListEvent.KIND, + PollNoteEvent.KIND, + ChannelCreateEvent.KIND, + ), + search = mySearchString, + limit = 100, + ), + ), + TypedFilter( + types = setOf(FeedType.SEARCH), + filter = + JsonFilter( + kinds = + listOf( + ChannelMetadataEvent.KIND, + ClassifiedsEvent.KIND, + CommunityDefinitionEvent.KIND, + EmojiPackEvent.KIND, + HighlightEvent.KIND, + LiveActivitiesEvent.KIND, + PollNoteEvent.KIND, + NNSEvent.KIND, + ), + search = mySearchString, + limit = 100, + ), + ), + ) + } - val searchChannel = requestNewChannel() + val searchChannel = requestNewChannel() - override fun updateChannelFilters() { - searchChannel.typedFilters = createAnythingWithIDFilter() - } + override fun updateChannelFilters() { + searchChannel.typedFilters = createAnythingWithIDFilter() + } - fun search(searchString: String) { - if (this.searchString != searchString) { - println("DataSource: ${this.javaClass.simpleName} Search for $searchString") - this.searchString = searchString - invalidateFilters() - } + fun search(searchString: String) { + if (this.searchString != searchString) { + println("DataSource: ${this.javaClass.simpleName} Search for $searchString") + this.searchString = searchString + invalidateFilters() } + } - fun clear() { - if (searchString != null) { - println("DataSource: ${this.javaClass.simpleName} Clear") - searchString = null - invalidateFilters() - } + fun clear() { + if (searchString != null) { + println("DataSource: ${this.javaClass.simpleName} Clear") + searchString = null + invalidateFilters() } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt index b72da4fe8..cb09c9cb3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.Channel @@ -11,94 +31,95 @@ import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") { - private var channelsToWatch = setOf() + private var channelsToWatch = setOf() - private fun createMetadataChangeFilter(): TypedFilter? { - val reactionsToWatch = channelsToWatch.filter { it is PublicChatChannel }.map { it.idHex } + private fun createMetadataChangeFilter(): TypedFilter? { + val reactionsToWatch = channelsToWatch.filter { it is PublicChatChannel }.map { it.idHex } - if (reactionsToWatch.isEmpty()) { - return null - } + if (reactionsToWatch.isEmpty()) { + return null + } - // downloads all the reactions to a given event. - return TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = JsonFilter( - kinds = listOf(ChannelMetadataEvent.kind), - tags = mapOf("e" to reactionsToWatch) - ) + // downloads all the reactions to a given event. + return TypedFilter( + types = setOf(FeedType.PUBLIC_CHATS), + filter = + JsonFilter( + kinds = listOf(ChannelMetadataEvent.KIND), + tags = mapOf("e" to reactionsToWatch), + ), + ) + } + + fun createLoadEventsIfNotLoadedFilter(): TypedFilter? { + val directEventsToLoad = + channelsToWatch.filter { it.notes.isEmpty() && it is PublicChatChannel } + + val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet() + + if (interestedEvents.isEmpty()) { + return null + } + + // downloads linked events to this event. + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(ChannelCreateEvent.KIND), + ids = interestedEvents.toList(), + ), + ) + } + + fun createLoadStreamingIfNotLoadedFilter(): List? { + val directEventsToLoad = + channelsToWatch.filterIsInstance().filter { it.info == null } + + val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet() + + if (interestedEvents.isEmpty()) { + return null + } + + // downloads linked events to this event. + return directEventsToLoad.map { + it.address().let { aTag -> + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(aTag.kind), + tags = mapOf("d" to listOf(aTag.dTag)), + authors = listOf(aTag.pubKeyHex), + ), ) + } } + } - fun createLoadEventsIfNotLoadedFilter(): TypedFilter? { - val directEventsToLoad = channelsToWatch - .filter { it.notes.isEmpty() && it is PublicChatChannel } + val singleChannelChannel = requestNewChannel() - val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet() + override fun updateChannelFilters() { + val reactions = createMetadataChangeFilter() + val missing = createLoadEventsIfNotLoadedFilter() + val missingStreaming = createLoadStreamingIfNotLoadedFilter() - if (interestedEvents.isEmpty()) { - return null - } + singleChannelChannel.typedFilters = + ((listOfNotNull(reactions, missing)) + (missingStreaming ?: emptyList())).ifEmpty { null } + } - // downloads linked events to this event. - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - kinds = listOf(ChannelCreateEvent.kind), - ids = interestedEvents.toList() - ) - ) + fun add(eventId: Channel) { + if (eventId !in channelsToWatch) { + channelsToWatch = channelsToWatch.plus(eventId) + invalidateFilters() } + } - fun createLoadStreamingIfNotLoadedFilter(): List? { - val directEventsToLoad = channelsToWatch - .filterIsInstance() - .filter { it.info == null } - - val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet() - - if (interestedEvents.isEmpty()) { - return null - } - - // downloads linked events to this event. - return directEventsToLoad.map { - it.address().let { aTag -> - TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - kinds = listOf(aTag.kind), - tags = mapOf("d" to listOf(aTag.dTag)), - authors = listOf(aTag.pubKeyHex) - ) - ) - } - } - } - - val singleChannelChannel = requestNewChannel() - - override fun updateChannelFilters() { - val reactions = createMetadataChangeFilter() - val missing = createLoadEventsIfNotLoadedFilter() - val missingStreaming = createLoadStreamingIfNotLoadedFilter() - - singleChannelChannel.typedFilters = ( - (listOfNotNull(reactions, missing)) + (missingStreaming ?: emptyList()) - ).ifEmpty { null } - } - - fun add(eventId: Channel) { - if (eventId !in channelsToWatch) { - channelsToWatch = channelsToWatch.plus(eventId) - invalidateFilters() - } - } - - fun remove(eventId: Channel) { - if (eventId in channelsToWatch) { - channelsToWatch = channelsToWatch.minus(eventId) - invalidateFilters() - } + fun remove(eventId: Channel) { + if (eventId in channelsToWatch) { + channelsToWatch = channelsToWatch.minus(eventId) + invalidateFilters() } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt index 05c1652fe..461b5b603 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.AddressableNote @@ -18,235 +38,242 @@ import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.TextNoteEvent object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { - private var eventsToWatch = setOf() - private var addressesToWatch = setOf() + private var eventsToWatch = setOf() + private var addressesToWatch = setOf() - private fun createReactionsToWatchInAddressFilter(): List? { - val addressesToWatch = - ( - eventsToWatch.filter { it.address() != null } + - addressesToWatch.filter { it.address() != null } - ).toSet() + private fun createReactionsToWatchInAddressFilter(): List? { + val addressesToWatch = + (eventsToWatch.filter { it.address() != null } + + addressesToWatch.filter { it.address() != null }) + .toSet() - if (addressesToWatch.isEmpty()) { - return null - } - - return groupByEOSEPresence(addressesToWatch).map { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - kinds = listOf( - TextNoteEvent.kind, - ReactionEvent.kind, - RepostEvent.kind, - GenericRepostEvent.kind, - ReportEvent.kind, - LnZapEvent.kind, - PollNoteEvent.kind, - CommunityPostApprovalEvent.kind, - LiveActivitiesChatMessageEvent.kind - ), - tags = mapOf("a" to it.mapNotNull { it.address()?.toTag() }), - since = findMinimumEOSEs(it), - limit = 1000 // Max amount of "replies" to download on a specific event. - ) - ) - } + if (addressesToWatch.isEmpty()) { + return null } - private fun createAddressFilter(): List? { - val addressesToWatch = addressesToWatch.filter { it.event == null } + return groupByEOSEPresence(addressesToWatch).map { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = + listOf( + TextNoteEvent.KIND, + ReactionEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + ReportEvent.KIND, + LnZapEvent.KIND, + PollNoteEvent.KIND, + CommunityPostApprovalEvent.KIND, + LiveActivitiesChatMessageEvent.KIND, + ), + tags = mapOf("a" to it.mapNotNull { it.address()?.toTag() }), + since = findMinimumEOSEs(it), + // Max amount of "replies" to download on a specific event. + limit = 1000, + ), + ) + } + } - if (addressesToWatch.isEmpty()) { - return null - } + private fun createAddressFilter(): List? { + val addressesToWatch = addressesToWatch.filter { it.event == null } - return addressesToWatch.mapNotNull { - it.address()?.let { aTag -> - if (aTag.kind < 25000 && aTag.dTag.isBlank()) { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - kinds = listOf(aTag.kind), - authors = listOf(aTag.pubKeyHex), - limit = 5 - ) - ) - } else { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - kinds = listOf(aTag.kind), - tags = mapOf("d" to listOf(aTag.dTag)), - authors = listOf(aTag.pubKeyHex), - limit = 5 - ) - ) - } - } - } + if (addressesToWatch.isEmpty()) { + return null } - private fun createRepliesAndReactionsFilter(): List? { - if (eventsToWatch.isEmpty()) { - return null + return addressesToWatch.mapNotNull { + it.address()?.let { aTag -> + if (aTag.kind < 25000 && aTag.dTag.isBlank()) { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(aTag.kind), + authors = listOf(aTag.pubKeyHex), + limit = 5, + ), + ) + } else { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(aTag.kind), + tags = mapOf("d" to listOf(aTag.dTag)), + authors = listOf(aTag.pubKeyHex), + limit = 5, + ), + ) } + } + } + } - return groupByEOSEPresence(eventsToWatch).map { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - kinds = listOf( - TextNoteEvent.kind, - ReactionEvent.kind, - RepostEvent.kind, - GenericRepostEvent.kind, - ReportEvent.kind, - LnZapEvent.kind, - PollNoteEvent.kind - ), - tags = mapOf("e" to it.map { it.idHex }), - since = findMinimumEOSEs(it), - limit = 1000 // Max amount of "replies" to download on a specific event. - ) - ) - } + private fun createRepliesAndReactionsFilter(): List? { + if (eventsToWatch.isEmpty()) { + return null } - fun createLoadEventsIfNotLoadedFilter(): List? { - val directEventsToLoad = eventsToWatch - .filter { it.event == null } + return groupByEOSEPresence(eventsToWatch).map { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = + listOf( + TextNoteEvent.KIND, + ReactionEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + ReportEvent.KIND, + LnZapEvent.KIND, + PollNoteEvent.KIND, + ), + tags = mapOf("e" to it.map { it.idHex }), + since = findMinimumEOSEs(it), + // Max amount of "replies" to download on a specific event. + limit = 1000, + ), + ) + } + } - val threadingEventsToLoad = eventsToWatch - .mapNotNull { it.replyTo } - .flatten() - .filter { it !is AddressableNote && it.event == null } + fun createLoadEventsIfNotLoadedFilter(): List? { + val directEventsToLoad = eventsToWatch.filter { it.event == null } - val interestedEvents = - (directEventsToLoad + threadingEventsToLoad) - .map { it.idHex }.toSet() + val threadingEventsToLoad = + eventsToWatch + .mapNotNull { it.replyTo } + .flatten() + .filter { it !is AddressableNote && it.event == null } - if (interestedEvents.isEmpty()) { - return null - } + val interestedEvents = (directEventsToLoad + threadingEventsToLoad).map { it.idHex }.toSet() - // downloads linked events to this event. - return listOf( - TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - ids = interestedEvents.toList() - ) - ) - ) + if (interestedEvents.isEmpty()) { + return null } - val singleEventChannel = requestNewChannel { time, relayUrl -> - // Ignores EOSE if it is in the middle of a filter change. - if (changingFilters.get()) return@requestNewChannel + // downloads linked events to this event. + return listOf( + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + ids = interestedEvents.toList(), + ), + ), + ) + } - checkNotInMainThread() + val singleEventChannel = requestNewChannel { time, relayUrl -> + // Ignores EOSE if it is in the middle of a filter change. + if (changingFilters.get()) return@requestNewChannel - eventsToWatch.forEach { - val eose = it.lastReactionsDownloadTime[relayUrl] - if (eose == null) { - it.lastReactionsDownloadTime = it.lastReactionsDownloadTime + Pair(relayUrl, EOSETime(time)) - } else { - eose.time = time - } - } + checkNotInMainThread() - addressesToWatch.forEach { - val eose = it.lastReactionsDownloadTime[relayUrl] - if (eose == null) { - it.lastReactionsDownloadTime = it.lastReactionsDownloadTime + Pair(relayUrl, EOSETime(time)) - } else { - eose.time = time - } - } - - // Many relays operate with limits in the amount of filters. - // As information comes, the filters will be rotated to get more data. - invalidateFilters() + eventsToWatch.forEach { + val eose = it.lastReactionsDownloadTime[relayUrl] + if (eose == null) { + it.lastReactionsDownloadTime = it.lastReactionsDownloadTime + Pair(relayUrl, EOSETime(time)) + } else { + eose.time = time + } } - override fun updateChannelFilters() { - val reactions = createRepliesAndReactionsFilter() - val missing = createLoadEventsIfNotLoadedFilter() - val addresses = createAddressFilter() - val addressReactions = createReactionsToWatchInAddressFilter() - - singleEventChannel.typedFilters = listOfNotNull(missing, addresses, reactions, addressReactions).flatten().ifEmpty { null } + addressesToWatch.forEach { + val eose = it.lastReactionsDownloadTime[relayUrl] + if (eose == null) { + it.lastReactionsDownloadTime = it.lastReactionsDownloadTime + Pair(relayUrl, EOSETime(time)) + } else { + eose.time = time + } } - fun add(eventId: Note) { - if (!eventsToWatch.contains(eventId)) { - eventsToWatch = eventsToWatch.plus(eventId) - invalidateFilters() - } - } + // Many relays operate with limits in the amount of filters. + // As information comes, the filters will be rotated to get more data. + invalidateFilters() + } - fun remove(eventId: Note) { - if (eventsToWatch.contains(eventId)) { - eventsToWatch = eventsToWatch.minus(eventId) - invalidateFilters() - } - } + override fun updateChannelFilters() { + val reactions = createRepliesAndReactionsFilter() + val missing = createLoadEventsIfNotLoadedFilter() + val addresses = createAddressFilter() + val addressReactions = createReactionsToWatchInAddressFilter() - fun addAddress(addressableNote: Note) { - if (!addressesToWatch.contains(addressableNote)) { - addressesToWatch = addressesToWatch.plus(addressableNote) - invalidateFilters() - } - } + singleEventChannel.typedFilters = + listOfNotNull(missing, addresses, reactions, addressReactions).flatten().ifEmpty { null } + } - fun removeAddress(addressableNote: Note) { - if (addressesToWatch.contains(addressableNote)) { - addressesToWatch = addressesToWatch.minus(addressableNote) - invalidateFilters() - } + fun add(eventId: Note) { + if (!eventsToWatch.contains(eventId)) { + eventsToWatch = eventsToWatch.plus(eventId) + invalidateFilters() } + } + + fun remove(eventId: Note) { + if (eventsToWatch.contains(eventId)) { + eventsToWatch = eventsToWatch.minus(eventId) + invalidateFilters() + } + } + + fun addAddress(addressableNote: Note) { + if (!addressesToWatch.contains(addressableNote)) { + addressesToWatch = addressesToWatch.plus(addressableNote) + invalidateFilters() + } + } + + fun removeAddress(addressableNote: Note) { + if (addressesToWatch.contains(addressableNote)) { + addressesToWatch = addressesToWatch.minus(addressableNote) + invalidateFilters() + } + } } fun groupByEOSEPresence(notes: Set): Collection> { - return notes.groupBy { it.lastReactionsDownloadTime.keys.sorted().joinToString(",") }.values + return notes.groupBy { it.lastReactionsDownloadTime.keys.sorted().joinToString(",") }.values } fun groupByEOSEPresence(users: Iterable): Collection> { - return users.groupBy { it.latestEOSEs.keys.sorted().joinToString(",") }.values + return users.groupBy { it.latestEOSEs.keys.sorted().joinToString(",") }.values } fun findMinimumEOSEs(notes: List): Map { - val minLatestEOSEs = mutableMapOf() + val minLatestEOSEs = mutableMapOf() - notes.forEach { - it.lastReactionsDownloadTime.forEach { - val minEose = minLatestEOSEs[it.key] - if (minEose == null) { - minLatestEOSEs.put(it.key, EOSETime(it.value.time)) - } else if (it.value.time < minEose.time) { - minEose.time = it.value.time - } - } + notes.forEach { + it.lastReactionsDownloadTime.forEach { + val minEose = minLatestEOSEs[it.key] + if (minEose == null) { + minLatestEOSEs.put(it.key, EOSETime(it.value.time)) + } else if (it.value.time < minEose.time) { + minEose.time = it.value.time + } } + } - return minLatestEOSEs + return minLatestEOSEs } fun findMinimumEOSEsForUsers(users: List): Map { - val minLatestEOSEs = mutableMapOf() + val minLatestEOSEs = mutableMapOf() - users.forEach { - it.latestEOSEs.forEach { - val minEose = minLatestEOSEs[it.key] - if (minEose == null) { - minLatestEOSEs.put(it.key, EOSETime(it.value.time)) - } else if (it.value.time < minEose.time) { - minEose.time = it.value.time - } - } + users.forEach { + it.latestEOSEs.forEach { + val minEose = minLatestEOSEs[it.key] + if (minEose == null) { + minLatestEOSEs.put(it.key, EOSETime(it.value.time)) + } else if (it.value.time < minEose.time) { + minEose.time = it.value.time + } } + } - return minLatestEOSEs + return minLatestEOSEs } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt index 152350619..bec73e2e1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.User @@ -10,92 +30,100 @@ import com.vitorpamplona.quartz.events.ReportEvent import com.vitorpamplona.quartz.events.StatusEvent object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") { - var usersToWatch = setOf() + var usersToWatch = setOf() - fun createUserMetadataFilter(): List? { - if (usersToWatch.isEmpty()) return null + fun createUserMetadataFilter(): List? { + if (usersToWatch.isEmpty()) return null - val firstTimers = usersToWatch.filter { it.info?.latestMetadata == null }.map { it.pubkeyHex } + val firstTimers = usersToWatch.filter { it.info?.latestMetadata == null }.map { it.pubkeyHex } - if (firstTimers.isEmpty()) return null + if (firstTimers.isEmpty()) return null - return listOf( - TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - kinds = listOf(MetadataEvent.kind), - authors = firstTimers - ) - ) + return listOf( + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(MetadataEvent.KIND), + authors = firstTimers, + ), + ), + ) + } + + fun createUserMetadataStatusReportFilter(): List? { + if (usersToWatch.isEmpty()) return null + + val secondTimers = usersToWatch.filter { it.info?.latestMetadata != null } + + if (secondTimers.isEmpty()) return null + + return groupByEOSEPresence(secondTimers) + .map { group -> + val groupIds = group.map { it.pubkeyHex } + val minEOSEs = findMinimumEOSEsForUsers(group) + listOf( + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(MetadataEvent.KIND, StatusEvent.KIND), + authors = groupIds, + since = minEOSEs, + ), + ), + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(ReportEvent.KIND), + tags = mapOf("p" to groupIds), + since = minEOSEs, + ), + ), ) - } + } + .flatten() + } - fun createUserMetadataStatusReportFilter(): List? { - if (usersToWatch.isEmpty()) return null + val userChannel = requestNewChannel { time, relayUrl -> + checkNotInMainThread() - val secondTimers = usersToWatch.filter { it.info?.latestMetadata != null } - - if (secondTimers.isEmpty()) return null - - return groupByEOSEPresence(secondTimers).map { group -> - val groupIds = group.map { it.pubkeyHex } - val minEOSEs = findMinimumEOSEsForUsers(group) - listOf( - TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - kinds = listOf(MetadataEvent.kind, StatusEvent.kind), - authors = groupIds, - since = minEOSEs - ) - ), - TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - kinds = listOf(ReportEvent.kind), - tags = mapOf("p" to groupIds), - since = minEOSEs - ) - ) - ) - }.flatten() - } - - val userChannel = requestNewChannel() { time, relayUrl -> - checkNotInMainThread() - - usersToWatch.forEach { - if (it.info?.latestMetadata != null) { - val eose = it.latestEOSEs[relayUrl] - if (eose == null) { - it.latestEOSEs = it.latestEOSEs + Pair(relayUrl, EOSETime(time)) - } else { - eose.time = time - } - } + usersToWatch.forEach { + if (it.info?.latestMetadata != null) { + val eose = it.latestEOSEs[relayUrl] + if (eose == null) { + it.latestEOSEs = it.latestEOSEs + Pair(relayUrl, EOSETime(time)) + } else { + eose.time = time } + } } + } - override fun updateChannelFilters() { - checkNotInMainThread() + override fun updateChannelFilters() { + checkNotInMainThread() - userChannel.typedFilters = listOfNotNull( - createUserMetadataFilter(), - createUserMetadataStatusReportFilter() - ).flatten().ifEmpty { null } + userChannel.typedFilters = + listOfNotNull( + createUserMetadataFilter(), + createUserMetadataStatusReportFilter(), + ) + .flatten() + .ifEmpty { null } + } + + fun add(user: User) { + if (!usersToWatch.contains(user)) { + usersToWatch = usersToWatch.plus(user) + invalidateFilters() } + } - fun add(user: User) { - if (!usersToWatch.contains(user)) { - usersToWatch = usersToWatch.plus(user) - invalidateFilters() - } - } - - fun remove(user: User) { - if (usersToWatch.contains(user)) { - usersToWatch = usersToWatch.minus(user) - invalidateFilters() - } + fun remove(user: User) { + if (usersToWatch.contains(user)) { + usersToWatch = usersToWatch.minus(user) + invalidateFilters() } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt index 7f73ac5a2..81a8031ca 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.ThreadAssembler @@ -6,42 +26,47 @@ import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") { - private var eventToWatch: String? = null + private var eventToWatch: String? = null - fun createLoadEventsIfNotLoadedFilter(): TypedFilter? { - val threadToLoad = eventToWatch ?: return null + fun createLoadEventsIfNotLoadedFilter(): TypedFilter? { + val threadToLoad = eventToWatch ?: return null - val eventsToLoad = ThreadAssembler().findThreadFor(threadToLoad) - .filter { it.event == null } - .map { it.idHex } - .toSet() - .ifEmpty { null } ?: return null + val eventsToLoad = + ThreadAssembler() + .findThreadFor(threadToLoad) + .filter { it.event == null } + .map { it.idHex } + .toSet() + .ifEmpty { null } + ?: return null - if (eventsToLoad.isEmpty()) return null + if (eventsToLoad.isEmpty()) return null - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - ids = eventsToLoad.toList() - ) - ) - } - - val loadEventsChannel = requestNewChannel() { _, _ -> - // Many relays operate with limits in the amount of filters. - // As information comes, the filters will be rotated to get more data. - invalidateFilters() - } - - override fun updateChannelFilters() { - loadEventsChannel.typedFilters = listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null } - } - - fun loadThread(noteId: String?) { - if (eventToWatch != noteId) { - eventToWatch = noteId - - invalidateFilters() - } + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + ids = eventsToLoad.toList(), + ), + ) + } + + val loadEventsChannel = requestNewChannel { _, _ -> + // Many relays operate with limits in the amount of filters. + // As information comes, the filters will be rotated to get more data. + invalidateFilters() + } + + override fun updateChannelFilters() { + loadEventsChannel.typedFilters = + listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null } + } + + fun loadThread(noteId: String?) { + if (eventToWatch != noteId) { + eventToWatch = noteId + + invalidateFilters() } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt index d3c261a04..d1107a3fe 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.User @@ -23,120 +43,140 @@ import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.TextNoteEvent object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { - var user: User? = null + var user: User? = null - fun loadUserProfile(user: User?) { - this.user = user + fun loadUserProfile(user: User?) { + this.user = user + } + + fun createUserInfoFilter() = + user?.let { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(MetadataEvent.KIND), + authors = listOf(it.pubkeyHex), + limit = 1, + ), + ) } - fun createUserInfoFilter() = user?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - kinds = listOf(MetadataEvent.kind), - authors = listOf(it.pubkeyHex), - limit = 1 - ) + fun createUserPostsFilter() = + user?.let { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = + listOf( + TextNoteEvent.KIND, + GenericRepostEvent.KIND, + RepostEvent.KIND, + LongTextNoteEvent.KIND, + AudioTrackEvent.KIND, + AudioHeaderEvent.KIND, + PinListEvent.KIND, + PollNoteEvent.KIND, + HighlightEvent.KIND, + ), + authors = listOf(it.pubkeyHex), + limit = 200, + ), + ) + } + + fun createUserReceivedZapsFilter() = + user?.let { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(LnZapEvent.KIND), + tags = mapOf("p" to listOf(it.pubkeyHex)), + ), + ) + } + + fun createFollowFilter() = + user?.let { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(ContactListEvent.KIND), + authors = listOf(it.pubkeyHex), + limit = 1, + ), + ) + } + + fun createFollowersFilter() = + user?.let { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(ContactListEvent.KIND), + tags = mapOf("p" to listOf(it.pubkeyHex)), + ), + ) + } + + fun createAcceptedAwardsFilter() = + user?.let { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(BadgeProfilesEvent.KIND), + authors = listOf(it.pubkeyHex), + limit = 1, + ), + ) + } + + fun createBookmarksFilter() = + user?.let { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = + listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, AppRecommendationEvent.KIND), + authors = listOf(it.pubkeyHex), + limit = 100, + ), + ) + } + + fun createReceivedAwardsFilter() = + user?.let { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(BadgeAwardEvent.KIND), + tags = mapOf("p" to listOf(it.pubkeyHex)), + limit = 20, + ), + ) + } + + val userInfoChannel = requestNewChannel() + + override fun updateChannelFilters() { + userInfoChannel.typedFilters = + listOfNotNull( + createUserInfoFilter(), + createUserPostsFilter(), + createFollowFilter(), + createFollowersFilter(), + createUserReceivedZapsFilter(), + createAcceptedAwardsFilter(), + createReceivedAwardsFilter(), + createBookmarksFilter(), ) - } - - fun createUserPostsFilter() = user?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - kinds = listOf( - TextNoteEvent.kind, - GenericRepostEvent.kind, - RepostEvent.kind, - LongTextNoteEvent.kind, - AudioTrackEvent.kind, - AudioHeaderEvent.kind, - PinListEvent.kind, - PollNoteEvent.kind, - HighlightEvent.kind - ), - authors = listOf(it.pubkeyHex), - limit = 200 - ) - ) - } - - fun createUserReceivedZapsFilter() = user?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - kinds = listOf(LnZapEvent.kind), - tags = mapOf("p" to listOf(it.pubkeyHex)) - ) - ) - } - - fun createFollowFilter() = user?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - kinds = listOf(ContactListEvent.kind), - authors = listOf(it.pubkeyHex), - limit = 1 - ) - ) - } - - fun createFollowersFilter() = user?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - kinds = listOf(ContactListEvent.kind), - tags = mapOf("p" to listOf(it.pubkeyHex)) - ) - ) - } - - fun createAcceptedAwardsFilter() = user?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - kinds = listOf(BadgeProfilesEvent.kind), - authors = listOf(it.pubkeyHex), - limit = 1 - ) - ) - } - - fun createBookmarksFilter() = user?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind, AppRecommendationEvent.kind), - authors = listOf(it.pubkeyHex), - limit = 100 - ) - ) - } - - fun createReceivedAwardsFilter() = user?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - kinds = listOf(BadgeAwardEvent.kind), - tags = mapOf("p" to listOf(it.pubkeyHex)), - limit = 20 - ) - ) - } - - val userInfoChannel = requestNewChannel() - - override fun updateChannelFilters() { - userInfoChannel.typedFilters = listOfNotNull( - createUserInfoFilter(), - createUserPostsFilter(), - createFollowFilter(), - createFollowersFilter(), - createUserReceivedZapsFilter(), - createAcceptedAwardsFilter(), - createReceivedAwardsFilter(), - createBookmarksFilter() - ).ifEmpty { null } - } + .ifEmpty { null } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt index 8b7327900..e256667eb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.Amethyst @@ -13,93 +33,120 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch object NostrVideoDataSource : NostrDataSource("VideoFeed") { - lateinit var account: Account + lateinit var account: Account - val scope = Amethyst.instance.applicationIOScope - val latestEOSEs = EOSEAccount() + val scope = Amethyst.instance.applicationIOScope + val latestEOSEs = EOSEAccount() - var job: Job? = null + var job: Job? = null - override fun start() { - job?.cancel() - job = scope.launch(Dispatchers.IO) { - account.liveStoriesFollowLists.collect { - if (this@NostrVideoDataSource::account.isInitialized) { - invalidateFilters() - } - } + override fun start() { + job?.cancel() + job = + scope.launch(Dispatchers.IO) { + account.liveStoriesFollowLists.collect { + if (this@NostrVideoDataSource::account.isInitialized) { + invalidateFilters() + } } - super.start() - } + } + super.start() + } - override fun stop() { - super.stop() - job?.cancel() - } + override fun stop() { + super.stop() + job?.cancel() + } - fun createContextualFilter(): TypedFilter { - val follows = account.liveStoriesFollowLists.value?.users?.toList() + fun createContextualFilter(): TypedFilter { + val follows = account.liveStoriesFollowLists.value?.users?.toList() - return TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = JsonFilter( - authors = follows, - kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind), - limit = 200, - since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList.value)?.relayList - ) + return TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + JsonFilter( + authors = follows, + kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND), + limit = 200, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultStoriesFollowList.value) + ?.relayList, + ), + ) + } + + fun createFollowTagsFilter(): TypedFilter? { + val hashToLoad = account.liveStoriesFollowLists.value?.hashtags?.toList() ?: return null + + if (hashToLoad.isEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + JsonFilter( + kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND), + tags = + mapOf( + "t" to + hashToLoad + .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } + .flatten(), + ), + limit = 100, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultStoriesFollowList.value) + ?.relayList, + ), + ) + } + + fun createFollowGeohashesFilter(): TypedFilter? { + val hashToLoad = account.liveStoriesFollowLists.value?.geotags?.toList() ?: return null + + if (hashToLoad.isEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + JsonFilter( + kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND), + tags = + mapOf( + "g" to + hashToLoad + .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } + .flatten(), + ), + limit = 100, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultStoriesFollowList.value) + ?.relayList, + ), + ) + } + + val videoFeedChannel = requestNewChannel { time, relayUrl -> + latestEOSEs.addOrUpdate( + account.userProfile(), + account.defaultStoriesFollowList.value, + relayUrl, + time, + ) + } + + override fun updateChannelFilters() { + videoFeedChannel.typedFilters = + listOfNotNull( + createContextualFilter(), + createFollowTagsFilter(), + createFollowGeohashesFilter(), ) - } - - fun createFollowTagsFilter(): TypedFilter? { - val hashToLoad = account.liveStoriesFollowLists.value?.hashtags?.toList() ?: return null - - if (hashToLoad.isEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = JsonFilter( - kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind), - tags = mapOf( - "t" to hashToLoad.map { - listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) - }.flatten() - ), - limit = 100, - since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList.value)?.relayList - ) - ) - } - - fun createFollowGeohashesFilter(): TypedFilter? { - val hashToLoad = account.liveStoriesFollowLists.value?.geotags?.toList() ?: return null - - if (hashToLoad.isNullOrEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = JsonFilter( - kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind), - tags = mapOf( - "g" to hashToLoad.map { - listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) - }.flatten() - ), - limit = 100, - since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList.value)?.relayList - ) - ) - } - - val videoFeedChannel = requestNewChannel() { time, relayUrl -> - latestEOSEs.addOrUpdate(account.userProfile(), account.defaultStoriesFollowList.value, relayUrl, time) - } - - override fun updateChannelFilters() { - videoFeedChannel.typedFilters = listOfNotNull( - createContextualFilter(), - createFollowTagsFilter(), - createFollowGeohashesFilter() - ).ifEmpty { null } - } + .ifEmpty { null } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt index 87ff1bdbe..906274093 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import android.util.Log @@ -6,48 +26,49 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.BuildConfig import okhttp3.Request -@Immutable -data class OnlineCheckResult(val timeInMs: Long, val online: Boolean) +@Immutable data class OnlineCheckResult(val timeInMs: Long, val online: Boolean) object OnlineChecker { - val checkOnlineCache = LruCache(100) - val fiveMinutes = 1000 * 60 * 5 + val checkOnlineCache = LruCache(100) + val fiveMinutes = 1000 * 60 * 5 - fun isOnlineCached(url: String?): Boolean { - if (url.isNullOrBlank()) return false - if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) { - return checkOnlineCache.get(url).online - } - return false + fun isOnlineCached(url: String?): Boolean { + if (url.isNullOrBlank()) return false + if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) { + return checkOnlineCache.get(url).online + } + return false + } + + fun isOnline(url: String?): Boolean { + checkNotInMainThread() + + if (url.isNullOrBlank()) return false + if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) { + return checkOnlineCache.get(url).online } - fun isOnline(url: String?): Boolean { - checkNotInMainThread() + Log.d("OnlineChecker", "isOnline $url") - if (url.isNullOrBlank()) return false - if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) { - return checkOnlineCache.get(url).online - } - - Log.d("OnlineChecker", "isOnline $url") - - return try { - val request = Request.Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") - .url(url) - .get() - .build() - - val result = HttpClient.getHttpClient().newCall(request).execute().use { - checkNotInMainThread() - it.isSuccessful - } - checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result)) - result - } catch (e: Exception) { - checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), false)) - Log.e("LiveActivities", "Failed to check streaming url $url", e) - false + return try { + val request = + Request.Builder() + .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .url(url) + .get() + .build() + + val result = + HttpClient.getHttpClient().newCall(request).execute().use { + checkNotInMainThread() + it.isSuccessful } + checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result)) + result + } catch (e: Exception) { + checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), false)) + Log.e("LiveActivities", "Failed to check streaming url $url", e) + false } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/PackageUtils.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/PackageUtils.kt index f6dd82972..a870604dd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/PackageUtils.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/PackageUtils.kt @@ -1,17 +1,42 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import android.content.Context object PackageUtils { - private fun isPackageInstalled(context: Context, target: String): Boolean { - return context.packageManager.getInstalledApplications(0).find { info -> info.packageName == target } != null - } + private fun isPackageInstalled( + context: Context, + target: String, + ): Boolean { + return context.packageManager.getInstalledApplications(0).find { info -> + info.packageName == target + } != null + } - fun isOrbotInstalled(context: Context): Boolean { - return isPackageInstalled(context, "org.torproject.android") - } + fun isOrbotInstalled(context: Context): Boolean { + return isPackageInstalled(context, "org.torproject.android") + } - fun isAmberInstalled(context: Context): Boolean { - return isPackageInstalled(context, "com.greenart7c3.nostrsigner") - } + fun isAmberInstalled(context: Context): Boolean { + return isPackageInstalled(context, "com.greenart7c3.nostrsigner") + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index 8cf3476c3..484ecca18 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import android.content.Context @@ -12,230 +32,230 @@ import com.vitorpamplona.quartz.events.LiveActivitiesEvent import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse import com.vitorpamplona.quartz.events.ZapSplitSetup +import kotlin.math.round import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlin.math.round class ZapPaymentHandler(val account: Account) { + @Immutable + data class Payable( + val info: ZapSplitSetup, + val user: User?, + val amountMilliSats: Long, + val invoice: String, + ) - @Immutable - data class Payable( - val info: ZapSplitSetup, - val user: User?, - val amountMilliSats: Long, - val invoice: String - ) + suspend fun zap( + note: Note, + amountMilliSats: Long, + pollOption: Int?, + message: String, + context: Context, + onError: (String, String) -> Unit, + onProgress: (percent: Float) -> Unit, + onPayViaIntent: (ImmutableList) -> Unit, + zapType: LnZapEvent.ZapType, + ) = + withContext(Dispatchers.IO) { + val zapSplitSetup = note.event?.zapSplitSetup() - suspend fun zap( - note: Note, - amountMilliSats: Long, - pollOption: Int?, - message: String, - context: Context, - onError: (String, String) -> Unit, - onProgress: (percent: Float) -> Unit, - onPayViaIntent: (ImmutableList) -> Unit, - zapType: LnZapEvent.ZapType - ) = withContext(Dispatchers.IO) { - val zapSplitSetup = note.event?.zapSplitSetup() + val noteEvent = note.event - val noteEvent = note.event - - val zapsToSend = if (!zapSplitSetup.isNullOrEmpty()) { - zapSplitSetup + val zapsToSend = + if (!zapSplitSetup.isNullOrEmpty()) { + zapSplitSetup } else if (noteEvent is LiveActivitiesEvent && noteEvent.hasHost()) { - noteEvent.hosts().map { - ZapSplitSetup(it, null, weight = 1.0, false) - } + noteEvent.hosts().map { ZapSplitSetup(it, null, weight = 1.0, false) } } else { - val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim() + val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim() - if (lud16.isNullOrBlank()) { - onError( - context.getString(R.string.missing_lud16), - context.getString(R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats) - ) - return@withContext - } + if (lud16.isNullOrBlank()) { + onError( + context.getString(R.string.missing_lud16), + context.getString( + R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats, + ), + ) + return@withContext + } - listOf(ZapSplitSetup(lud16, null, weight = 1.0, true)) + listOf(ZapSplitSetup(lud16, null, weight = 1.0, true)) } - val totalWeight = zapsToSend.sumOf { it.weight } + val totalWeight = zapsToSend.sumOf { it.weight } - val invoicesToPayOnIntent = mutableListOf() + val invoicesToPayOnIntent = mutableListOf() - zapsToSend.forEachIndexed { index, value -> - val outerProgressMin = index / zapsToSend.size.toFloat() - val outerProgressMax = (index + 1) / zapsToSend.size.toFloat() + zapsToSend.forEachIndexed { index, value -> + val outerProgressMin = index / zapsToSend.size.toFloat() + val outerProgressMax = (index + 1) / zapsToSend.size.toFloat() - val zapValue = - round((amountMilliSats * value.weight / totalWeight) / 1000f).toLong() * 1000 + val zapValue = round((amountMilliSats * value.weight / totalWeight) / 1000f).toLong() * 1000 - if (value.isLnAddress) { - innerZap( - lud16 = value.lnAddressOrPubKeyHex, - note = note, - amount = zapValue, - pollOption = pollOption, - message = message, - context = context, - onError = onError, - onProgress = { - onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin) - }, - zapType = zapType, - onPayInvoiceThroughIntent = { - invoicesToPayOnIntent.add( - Payable( - info = value, - user = null, - amountMilliSats = zapValue, - invoice = it - ) - ) - } + if (value.isLnAddress) { + innerZap( + lud16 = value.lnAddressOrPubKeyHex, + note = note, + amount = zapValue, + pollOption = pollOption, + message = message, + context = context, + onError = onError, + onProgress = { + onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin) + }, + zapType = zapType, + onPayInvoiceThroughIntent = { + invoicesToPayOnIntent.add( + Payable( + info = value, + user = null, + amountMilliSats = zapValue, + invoice = it, + ), + ) + }, + ) + } else { + val user = LocalCache.getUserIfExists(value.lnAddressOrPubKeyHex) + val lud16 = user?.info?.lnAddress() + + if (lud16 != null) { + innerZap( + lud16 = lud16, + note = note, + amount = zapValue, + pollOption = pollOption, + message = message, + context = context, + onError = onError, + onProgress = { + onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin) + }, + zapType = zapType, + overrideUser = user, + onPayInvoiceThroughIntent = { + invoicesToPayOnIntent.add( + Payable( + info = value, + user = user, + amountMilliSats = zapValue, + invoice = it, + ), ) - } else { - val user = LocalCache.getUserIfExists(value.lnAddressOrPubKeyHex) - val lud16 = user?.info?.lnAddress() - - if (lud16 != null) { - innerZap( - lud16 = lud16, - note = note, - amount = zapValue, - pollOption = pollOption, - message = message, - context = context, - onError = onError, - onProgress = { - onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin) - }, - zapType = zapType, - overrideUser = user, - onPayInvoiceThroughIntent = { - invoicesToPayOnIntent.add( - Payable( - info = value, - user = user, - amountMilliSats = zapValue, - invoice = it - ) - ) - } - ) - } else { - onError( - context.getString( - R.string.missing_lud16 - ), - context.getString( - R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats, - user?.toBestDisplayName() ?: value.lnAddressOrPubKeyHex - ) - ) - } - } + }, + ) + } else { + onError( + context.getString( + R.string.missing_lud16, + ), + context.getString( + R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats, + user?.toBestDisplayName() ?: value.lnAddressOrPubKeyHex, + ), + ) + } } + } - if (invoicesToPayOnIntent.isNotEmpty()) { + if (invoicesToPayOnIntent.isNotEmpty()) { + onPayViaIntent(invoicesToPayOnIntent.toImmutableList()) + onProgress(1f) + } else { + launch(Dispatchers.IO) { + // Awaits for the event to come back to LocalCache. + var count = 0 + while (invoicesToPayOnIntent.size < zapsToSend.size || count < 4) { + count++ + Thread.sleep(5000) + } + if (invoicesToPayOnIntent.isNotEmpty()) { onPayViaIntent(invoicesToPayOnIntent.toImmutableList()) onProgress(1f) - } else { - launch(Dispatchers.IO) { - // Awaits for the event to come back to LocalCache. - var count = 0 - while (invoicesToPayOnIntent.size < zapsToSend.size || count < 4) { - count++ - Thread.sleep(5000) - } - if (invoicesToPayOnIntent.isNotEmpty()) { - onPayViaIntent(invoicesToPayOnIntent.toImmutableList()) - onProgress(1f) - } else { - onProgress(1f) - } - } + } else { + onProgress(1f) + } } + } } - private fun prepareZapRequestIfNeeded( - note: Note, - pollOption: Int?, - message: String, - zapType: LnZapEvent.ZapType, - overrideUser: User? = null, - onReady: (String?) -> Unit - ) { - if (zapType != LnZapEvent.ZapType.NONZAP) { - account.createZapRequestFor(note, pollOption, message, zapType, overrideUser) { zapRequest -> - onReady(zapRequest.toJson()) - } - } else { - onReady(null) - } + private fun prepareZapRequestIfNeeded( + note: Note, + pollOption: Int?, + message: String, + zapType: LnZapEvent.ZapType, + overrideUser: User? = null, + onReady: (String?) -> Unit, + ) { + if (zapType != LnZapEvent.ZapType.NONZAP) { + account.createZapRequestFor(note, pollOption, message, zapType, overrideUser) { zapRequest -> + onReady(zapRequest.toJson()) + } + } else { + onReady(null) } + } - private suspend fun innerZap( - lud16: String, - note: Note, - amount: Long, - pollOption: Int?, - message: String, - context: Context, - onError: (String, String) -> Unit, - onProgress: (percent: Float) -> Unit, - onPayInvoiceThroughIntent: (String) -> Unit, - zapType: LnZapEvent.ZapType, - overrideUser: User? = null - ) { - onProgress(0.05f) + private suspend fun innerZap( + lud16: String, + note: Note, + amount: Long, + pollOption: Int?, + message: String, + context: Context, + onError: (String, String) -> Unit, + onProgress: (percent: Float) -> Unit, + onPayInvoiceThroughIntent: (String) -> Unit, + zapType: LnZapEvent.ZapType, + overrideUser: User? = null, + ) { + onProgress(0.05f) - prepareZapRequestIfNeeded(note, pollOption, message, zapType, overrideUser) { zapRequestJson -> - onProgress(0.10f) + prepareZapRequestIfNeeded(note, pollOption, message, zapType, overrideUser) { zapRequestJson -> + onProgress(0.10f) - LightningAddressResolver().lnAddressInvoice( - lud16, - amount, - message, - zapRequestJson, - onSuccess = { - onProgress(0.7f) - if (account.hasWalletConnectSetup()) { - account.sendZapPaymentRequestFor( - bolt11 = it, - note, - onResponse = { response -> - if (response is PayInvoiceErrorResponse) { - onProgress(0.0f) - onError( - context.getString(R.string.error_dialog_pay_invoice_error), - context.getString( - R.string.wallet_connect_pay_invoice_error_error, - response.error?.message - ?: response.error?.code?.toString() - ?: "Error parsing error message" - ) - ) - } else { - onProgress(1f) - } - } - ) - onProgress(0.8f) - } else { - onPayInvoiceThroughIntent(it) - onProgress(0f) - } + LightningAddressResolver() + .lnAddressInvoice( + lud16, + amount, + message, + zapRequestJson, + onSuccess = { + onProgress(0.7f) + if (account.hasWalletConnectSetup()) { + account.sendZapPaymentRequestFor( + bolt11 = it, + note, + onResponse = { response -> + if (response is PayInvoiceErrorResponse) { + onProgress(0.0f) + onError( + context.getString(R.string.error_dialog_pay_invoice_error), + context.getString( + R.string.wallet_connect_pay_invoice_error_error, + response.error?.message + ?: response.error?.code?.toString() ?: "Error parsing error message", + ), + ) + } else { + onProgress(1f) + } }, - onError = onError, - onProgress = onProgress, - context = context - ) - } + ) + onProgress(0.8f) + } else { + onPayInvoiceThroughIntent(it) + onProgress(0f) + } + }, + onError = onError, + onProgress = onProgress, + context = context, + ) } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt index 30979061a..6582e193a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service.lnurl import android.content.Context @@ -9,232 +29,263 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.quartz.encoders.LnInvoiceUtil import com.vitorpamplona.quartz.encoders.Lud06 import com.vitorpamplona.quartz.encoders.toLnUrl -import okhttp3.Request import java.math.BigDecimal import java.math.RoundingMode import java.net.URLEncoder +import okhttp3.Request class LightningAddressResolver() { - val client = HttpClient.getHttpClient() + val client = HttpClient.getHttpClient() - fun assembleUrl(lnaddress: String): String? { - val parts = lnaddress.split("@") + fun assembleUrl(lnaddress: String): String? { + val parts = lnaddress.split("@") - if (parts.size == 2) { - return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}" - } - - if (lnaddress.lowercase().startsWith("lnurl")) { - return Lud06().toLnUrlp(lnaddress) - } - - return null + if (parts.size == 2) { + return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}" } - private fun fetchLightningAddressJson( - lnaddress: String, - onSuccess: (String) -> Unit, - onError: (String, String) -> Unit, - context: Context - ) { - checkNotInMainThread() + if (lnaddress.lowercase().startsWith("lnurl")) { + return Lud06().toLnUrlp(lnaddress) + } - val url = assembleUrl(lnaddress) + return null + } - if (url == null) { + private fun fetchLightningAddressJson( + lnaddress: String, + onSuccess: (String) -> Unit, + onError: (String, String) -> Unit, + context: Context, + ) { + checkNotInMainThread() + + val url = assembleUrl(lnaddress) + + if (url == null) { + onError( + context.getString(R.string.error_unable_to_fetch_invoice), + context.getString( + R.string.could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup, + lnaddress, + ), + ) + return + } + + try { + val request: Request = + Request.Builder() + .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .url(url) + .build() + + client.newCall(request).execute().use { + if (it.isSuccessful) { + onSuccess(it.body.string()) + } else { + onError( + context.getString(R.string.error_unable_to_fetch_invoice), + context.getString( + R.string + .the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct, + url, + lnaddress, + it.code.toString(), + ), + ) + } + } + } catch (e: Exception) { + e.printStackTrace() + onError( + context.getString(R.string.error_unable_to_fetch_invoice), + context.getString( + R.string + .could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct, + url, + lnaddress, + ), + ) + } + } + + fun fetchLightningInvoice( + lnCallback: String, + milliSats: Long, + message: String, + nostrRequest: String? = null, + onSuccess: (String) -> Unit, + onError: (String, String) -> Unit, + context: Context, + ) { + checkNotInMainThread() + + val encodedMessage = URLEncoder.encode(message, "utf-8") + + val urlBinder = if (lnCallback.contains("?")) "&" else "?" + var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage" + + if (nostrRequest != null) { + val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8") + url += "&nostr=$encodedNostrRequest" + } + + val request: Request = + Request.Builder() + .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .url(url) + .build() + + client.newCall(request).execute().use { + if (it.isSuccessful) { + onSuccess(it.body.string()) + } else { + onError( + context.getString(R.string.error_unable_to_fetch_invoice), + context.getString(R.string.could_not_fetch_invoice_from, lnCallback), + ) + } + } + } + + fun lnAddressToLnUrl( + lnaddress: String, + onSuccess: (String) -> Unit, + onError: (String, String) -> Unit, + context: Context, + ) { + fetchLightningAddressJson( + lnaddress, + onSuccess = { onSuccess(it.toByteArray().toLnUrl()) }, + onError = onError, + context = context, + ) + } + + fun lnAddressInvoice( + lnaddress: String, + milliSats: Long, + message: String, + nostrRequest: String? = null, + onSuccess: (String) -> Unit, + onError: (String, String) -> Unit, + onProgress: (percent: Float) -> Unit, + context: Context, + ) { + val mapper = jacksonObjectMapper() + + fetchLightningAddressJson( + lnaddress, + onSuccess = { lnAddressJson -> + onProgress(0.4f) + + val lnurlp = + try { + mapper.readTree(lnAddressJson) + } catch (t: Throwable) { onError( - context.getString(R.string.error_unable_to_fetch_invoice), - context.getString( - R.string.could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup, - lnaddress - ) + context.getString(R.string.error_unable_to_fetch_invoice), + context.getString( + R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup, + ), ) - return + null + } + + val callback = lnurlp?.get("callback")?.asText() + + if (callback == null) { + onError( + context.getString(R.string.error_unable_to_fetch_invoice), + context.getString( + R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration, + ), + ) } - try { - val request: Request = Request.Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") - .url(url) - .build() + val allowsNostr = lnurlp?.get("allowsNostr")?.asBoolean() ?: false - client.newCall(request).execute().use { - if (it.isSuccessful) { - onSuccess(it.body.string()) - } else { - onError( - context.getString(R.string.error_unable_to_fetch_invoice), - context.getString( - R.string.the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct, - url, - lnaddress, - it.code.toString() - ) - ) - } - } - } catch (e: Exception) { - e.printStackTrace() - onError( - context.getString(R.string.error_unable_to_fetch_invoice), - context.getString( - R.string.could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct, - url, - lnaddress - ) - ) - } - } - - fun fetchLightningInvoice( - lnCallback: String, - milliSats: Long, - message: String, - nostrRequest: String? = null, - onSuccess: (String) -> Unit, - onError: (String, String) -> Unit, - context: Context - ) { - checkNotInMainThread() - - val encodedMessage = URLEncoder.encode(message, "utf-8") - - val urlBinder = if (lnCallback.contains("?")) "&" else "?" - var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage" - - if (nostrRequest != null) { - val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8") - url += "&nostr=$encodedNostrRequest" - } - - val request: Request = Request.Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") - .url(url) - .build() - - client.newCall(request).execute().use { - if (it.isSuccessful) { - onSuccess(it.body.string()) - } else { - onError( - context.getString(R.string.error_unable_to_fetch_invoice), - context.getString(R.string.could_not_fetch_invoice_from, lnCallback) - ) - } - } - } - - fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String, String) -> Unit, context: Context) { - fetchLightningAddressJson( - lnaddress, + callback?.let { cb -> + fetchLightningInvoice( + cb, + milliSats, + message, + if (allowsNostr) nostrRequest else null, onSuccess = { - onSuccess(it.toByteArray().toLnUrl()) - }, - onError = onError, - context = context - ) - } + onProgress(0.6f) - fun lnAddressInvoice( - lnaddress: String, - milliSats: Long, - message: String, - nostrRequest: String? = null, - onSuccess: (String) -> Unit, - onError: (String, String) -> Unit, - onProgress: (percent: Float) -> Unit, - context: Context - ) { - val mapper = jacksonObjectMapper() - - fetchLightningAddressJson( - lnaddress, - onSuccess = { lnAddressJson -> - onProgress(0.4f) - - val lnurlp = try { - mapper.readTree(lnAddressJson) + val lnInvoice = + try { + mapper.readTree(it) } catch (t: Throwable) { - onError( - context.getString(R.string.error_unable_to_fetch_invoice), - context.getString(R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup) - ) - null + onError( + context.getString(R.string.error_unable_to_fetch_invoice), + context.getString( + R.string + .error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup, + ), + ) + null } - val callback = lnurlp?.get("callback")?.asText() - - if (callback == null) { + lnInvoice + ?.get("pr") + ?.asText() + ?.ifBlank { null } + ?.let { pr -> + // Forces LN Invoice amount to be the requested amount. + val expectedAmountInSats = + BigDecimal(milliSats).divide(BigDecimal(1000), RoundingMode.HALF_UP).toLong() + val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr) + if (invoiceAmount.toLong() == expectedAmountInSats) { + onProgress(0.7f) + onSuccess(pr) + } else { + onProgress(0.0f) onError( - context.getString(R.string.error_unable_to_fetch_invoice), - context.getString(R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration) + context.getString(R.string.error_unable_to_fetch_invoice), + context.getString( + R.string.incorrect_invoice_amount_sats_from_it_should_have_been, + invoiceAmount.toLong().toString(), + lnaddress, + expectedAmountInSats.toString(), + ), ) + } } - - val allowsNostr = lnurlp?.get("allowsNostr")?.asBoolean() ?: false - - callback?.let { cb -> - fetchLightningInvoice( - cb, - milliSats, - message, - if (allowsNostr) nostrRequest else null, - onSuccess = { - onProgress(0.6f) - - val lnInvoice = try { - mapper.readTree(it) - } catch (t: Throwable) { - onError( - context.getString(R.string.error_unable_to_fetch_invoice), - context.getString(R.string.error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup) - ) - null - } - - lnInvoice?.get("pr")?.asText()?.ifBlank { null }?.let { pr -> - // Forces LN Invoice amount to be the requested amount. - val expectedAmountInSats = BigDecimal(milliSats).divide(BigDecimal(1000), RoundingMode.HALF_UP).toLong() - val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr) - if (invoiceAmount.toLong() == expectedAmountInSats) { - onProgress(0.7f) - onSuccess(pr) - } else { - onProgress(0.0f) - onError( - context.getString(R.string.error_unable_to_fetch_invoice), - context.getString( - R.string.incorrect_invoice_amount_sats_from_it_should_have_been, - invoiceAmount.toLong().toString(), - lnaddress, - expectedAmountInSats.toString() - ) - ) - } - } ?: lnInvoice?.get("reason")?.asText()?.ifBlank { null }?.let { reason -> - onProgress(0.0f) - onError( - context.getString(R.string.error_unable_to_fetch_invoice), - context.getString( - R.string.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error, - reason - ) - ) - } ?: run { - onProgress(0.0f) - onError( - context.getString(R.string.error_unable_to_fetch_invoice), - context.getString(R.string.unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json) - ) - } - }, - onError = onError, - context + ?: lnInvoice + ?.get("reason") + ?.asText() + ?.ifBlank { null } + ?.let { reason -> + onProgress(0.0f) + onError( + context.getString(R.string.error_unable_to_fetch_invoice), + context.getString( + R.string + .unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error, + reason, + ), ) + } + ?: run { + onProgress(0.0f) + onError( + context.getString(R.string.error_unable_to_fetch_invoice), + context.getString( + R.string + .unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json, + ), + ) } }, onError = onError, - context - ) - } + context, + ) + } + }, + onError = onError, + context, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/zaps/UserZaps.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/zaps/UserZaps.kt index 23b2740f3..a2e0d5d1d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/zaps/UserZaps.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/zaps/UserZaps.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events.zaps import com.vitorpamplona.amethyst.model.Note @@ -5,18 +25,12 @@ import com.vitorpamplona.amethyst.ui.screen.ZapReqResponse import com.vitorpamplona.quartz.events.LnZapEventInterface object UserZaps { - fun forProfileFeed(zaps: Map?): List { - if (zaps == null) return emptyList() + fun forProfileFeed(zaps: Map?): List { + if (zaps == null) return emptyList() - return ( - zaps - .mapNotNull { entry -> - entry.value?.let { - ZapReqResponse(entry.key, it) - } - } - .sortedBy { (it.zapEvent.event as? LnZapEventInterface)?.amount() } - .reversed() - ) - } + return (zaps + .mapNotNull { entry -> entry.value?.let { ZapReqResponse(entry.key, it) } } + .sortedBy { (it.zapEvent.event as? LnZapEventInterface)?.amount() } + .reversed()) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 52be47216..a5dbb29d0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service.notifications import android.app.NotificationManager @@ -20,165 +40,203 @@ import com.vitorpamplona.quartz.events.LnZapRequestEvent import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.SealedGossipEvent import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.collections.immutable.persistentSetOf import java.math.BigDecimal +import kotlinx.collections.immutable.persistentSetOf class EventNotificationConsumer(private val applicationContext: Context) { - suspend fun consume(event: GiftWrapEvent) { - if (!LocalCache.justVerify(event)) return - if (!notificationManager().areNotificationsEnabled()) return + suspend fun consume(event: GiftWrapEvent) { + if (!LocalCache.justVerify(event)) return + if (!notificationManager().areNotificationsEnabled()) return - // PushNotification Wraps don't include a receiver. - // Test with all logged in accounts - LocalPreferences.allSavedAccounts().forEach { - if (it.hasPrivKey || it.loggedInWithExternalSigner) { - LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)?.let { acc -> - consumeIfMatchesAccount(event, acc) - } - } + // PushNotification Wraps don't include a receiver. + // Test with all logged in accounts + LocalPreferences.allSavedAccounts().forEach { + if (it.hasPrivKey || it.loggedInWithExternalSigner) { + LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)?.let { acc -> + consumeIfMatchesAccount(event, acc) } + } } + } - private suspend fun consumeIfMatchesAccount(pushWrappedEvent: GiftWrapEvent, account: Account) { - pushWrappedEvent.cachedGift(account.signer) { notificationEvent -> - LocalCache.justConsume(notificationEvent, null) + private suspend fun consumeIfMatchesAccount( + pushWrappedEvent: GiftWrapEvent, + account: Account, + ) { + pushWrappedEvent.cachedGift(account.signer) { notificationEvent -> + LocalCache.justConsume(notificationEvent, null) - unwrapAndConsume(notificationEvent, account) { innerEvent -> - if (innerEvent is PrivateDmEvent) { - notify(innerEvent, account) - } else if (innerEvent is LnZapEvent) { - notify(innerEvent, account) - } else if (innerEvent is ChatMessageEvent) { - notify(innerEvent, account) - } - } + unwrapAndConsume(notificationEvent, account) { innerEvent -> + if (innerEvent is PrivateDmEvent) { + notify(innerEvent, account) + } else if (innerEvent is LnZapEvent) { + notify(innerEvent, account) + } else if (innerEvent is ChatMessageEvent) { + notify(innerEvent, account) } + } } + } - private fun unwrapAndConsume(event: Event, account: Account, onReady: (Event) -> Unit) { - if (!LocalCache.justVerify(event)) return + private fun unwrapAndConsume( + event: Event, + account: Account, + onReady: (Event) -> Unit, + ) { + if (!LocalCache.justVerify(event)) return - when (event) { - is GiftWrapEvent -> { - event.cachedGift(account.signer) { - unwrapAndConsume(it, account, onReady) - } - } - is SealedGossipEvent -> { - event.cachedGossip(account.signer) { - // this is not verifiable - LocalCache.justConsume(it, null) - onReady(it) - } - } - else -> { - LocalCache.justConsume(event, null) - onReady(event) - } + when (event) { + is GiftWrapEvent -> { + event.cachedGift(account.signer) { unwrapAndConsume(it, account, onReady) } + } + is SealedGossipEvent -> { + event.cachedGossip(account.signer) { + // this is not verifiable + LocalCache.justConsume(it, null) + onReady(it) } + } + else -> { + LocalCache.justConsume(event, null) + onReady(event) + } } + } - private fun notify(event: ChatMessageEvent, acc: Account) { - if (event.createdAt > TimeUtils.fiveMinutesAgo() && // old event being re-broadcasted - event.pubKey != acc.userProfile().pubkeyHex - ) { // from the user + private fun notify( + event: ChatMessageEvent, + acc: Account, + ) { + if ( + event.createdAt > TimeUtils.fiveMinutesAgo() && // old event being re-broadcasted + event.pubKey != acc.userProfile().pubkeyHex + ) { // from the user - val chatNote = LocalCache.notes[event.id] ?: return - val chatRoom = event.chatroomKey(acc.keyPair.pubKey.toHexKey()) + val chatNote = LocalCache.notes[event.id] ?: return + val chatRoom = event.chatroomKey(acc.keyPair.pubKey.toHexKey()) - val followingKeySet = acc.followingKeySet() + val followingKeySet = acc.followingKeySet() - val isKnownRoom = ( - acc.userProfile().privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true || - acc.userProfile().hasSentMessagesTo(chatRoom) - ) && !acc.isAllHidden(chatRoom.users) + val isKnownRoom = + (acc.userProfile().privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true || + acc.userProfile().hasSentMessagesTo(chatRoom)) && !acc.isAllHidden(chatRoom.users) - if (isKnownRoom) { - val content = chatNote.event?.content() ?: "" - val user = chatNote.author?.toBestDisplayName() ?: "" - val userPicture = chatNote.author?.profilePicture() - val noteUri = chatNote.toNEvent() - notificationManager().sendDMNotification( - event.id, - content, - user, - userPicture, - noteUri, - applicationContext - ) - } + if (isKnownRoom) { + val content = chatNote.event?.content() ?: "" + val user = chatNote.author?.toBestDisplayName() ?: "" + val userPicture = chatNote.author?.profilePicture() + val noteUri = chatNote.toNEvent() + notificationManager() + .sendDMNotification( + event.id, + content, + user, + userPicture, + noteUri, + applicationContext, + ) + } + } + } + + private fun notify( + event: PrivateDmEvent, + acc: Account, + ) { + val note = LocalCache.notes[event.id] ?: return + + // old event being re-broadcast + if (event.createdAt < TimeUtils.fiveMinutesAgo()) return + + if (acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) { + val followingKeySet = acc.followingKeySet() + + val knownChatrooms = + acc + .userProfile() + .privateChatrooms + .keys + .filter { + (acc.userProfile().privateChatrooms[it]?.senderIntersects(followingKeySet) == true || + acc.userProfile().hasSentMessagesTo(it)) && !acc.isAllHidden(it.users) + } + .toSet() + + note.author?.let { + if (ChatroomKey(persistentSetOf(it.pubkeyHex)) in knownChatrooms) { + acc.decryptContent(note) { content -> + val user = note.author?.toBestDisplayName() ?: "" + val userPicture = note.author?.profilePicture() + val noteUri = note.toNEvent() + notificationManager() + .sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext) + } } + } } + } - private fun notify(event: PrivateDmEvent, acc: Account) { - val note = LocalCache.notes[event.id] ?: return + private fun notify( + event: LnZapEvent, + acc: Account, + ) { + val noteZapEvent = LocalCache.notes[event.id] ?: return - // old event being re-broadcast - if (event.createdAt < TimeUtils.fiveMinutesAgo()) return + // old event being re-broadcast + if (event.createdAt < TimeUtils.fiveMinutesAgo()) return - if (acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) { - val followingKeySet = acc.followingKeySet() + val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return + val noteZapped = + event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return - val knownChatrooms = acc.userProfile().privateChatrooms.keys.filter { - ( - acc.userProfile().privateChatrooms[it]?.senderIntersects(followingKeySet) == true || - acc.userProfile().hasSentMessagesTo(it) - ) && !acc.isAllHidden(it.users) - }.toSet() + if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return - note.author?.let { - if (ChatroomKey(persistentSetOf(it.pubkeyHex)) in knownChatrooms) { - acc.decryptContent(note) { content -> - val user = note.author?.toBestDisplayName() ?: "" - val userPicture = note.author?.profilePicture() - val noteUri = note.toNEvent() - notificationManager().sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext) - } - } + if (acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) { + val amount = showAmount(event.amount) + (noteZapRequest.event as? LnZapRequestEvent)?.let { event -> + acc.decryptZapContentAuthor(noteZapRequest) { + val author = LocalCache.getOrCreateUser(it.pubKey) + val senderInfo = Pair(author, it.content.ifBlank { null }) + + acc.decryptContent(noteZapped) { + val zappedContent = it.split("\n").get(0) + + val user = senderInfo.first.toBestDisplayName() + var title = + applicationContext.getString(R.string.app_notification_zaps_channel_message, amount) + senderInfo.second?.ifBlank { null }?.let { title += " ($it)" } + var content = + applicationContext.getString( + R.string.app_notification_zaps_channel_message_from, + user, + ) + zappedContent?.let { + content += + " " + + applicationContext.getString( + R.string.app_notification_zaps_channel_message_for, + zappedContent, + ) } + val userPicture = senderInfo?.first?.profilePicture() + val noteUri = "nostr:Notifications" + notificationManager() + .sendZapNotification( + event.id, + content, + title, + userPicture, + noteUri, + applicationContext, + ) + } } + } } + } - private fun notify(event: LnZapEvent, acc: Account) { - val noteZapEvent = LocalCache.notes[event.id] ?: return - - // old event being re-broadcast - if (event.createdAt < TimeUtils.fiveMinutesAgo()) return - - val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return - val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return - - if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return - - if (acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) { - val amount = showAmount(event.amount) - (noteZapRequest.event as? LnZapRequestEvent)?.let { event -> - acc.decryptZapContentAuthor(noteZapRequest) { - val author = LocalCache.getOrCreateUser(it.pubKey) - val senderInfo = Pair(author, it.content.ifBlank { null }) - - acc.decryptContent(noteZapped) { - val zappedContent = it.split("\n").get(0) - - val user = senderInfo.first.toBestDisplayName() - var title = applicationContext.getString(R.string.app_notification_zaps_channel_message, amount) - senderInfo.second?.ifBlank { null }?.let { - title += " ($it)" - } - var content = applicationContext.getString(R.string.app_notification_zaps_channel_message_from, user) - zappedContent?.let { - content += " " + applicationContext.getString(R.string.app_notification_zaps_channel_message_for, zappedContent) - } - val userPicture = senderInfo?.first?.profilePicture() - val noteUri = "nostr:Notifications" - notificationManager().sendZapNotification(event.id, content, title, userPicture, noteUri, applicationContext) - } - } - } - } - } - - fun notificationManager(): NotificationManager { - return ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) as NotificationManager - } + fun notificationManager(): NotificationManager { + return ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) + as NotificationManager + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index 1e7d69120..ec8262f06 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service.notifications import android.app.NotificationChannel @@ -16,189 +36,213 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.MainActivity object NotificationUtils { - private var dmChannel: NotificationChannel? = null - private var zapChannel: NotificationChannel? = null - private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION" - private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION" + private var dmChannel: NotificationChannel? = null + private var zapChannel: NotificationChannel? = null + private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION" + private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION" - fun NotificationManager.getOrCreateDMChannel(applicationContext: Context): NotificationChannel { - if (dmChannel != null) return dmChannel!! + fun NotificationManager.getOrCreateDMChannel(applicationContext: Context): NotificationChannel { + if (dmChannel != null) return dmChannel!! - dmChannel = NotificationChannel( - applicationContext.getString(R.string.app_notification_dms_channel_id), - applicationContext.getString(R.string.app_notification_dms_channel_name), - NotificationManager.IMPORTANCE_DEFAULT - ).apply { - description = applicationContext.getString(R.string.app_notification_dms_channel_description) - } - - // Register the channel with the system - val notificationManager: NotificationManager = - applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - notificationManager.createNotificationChannel(dmChannel!!) - - return dmChannel!! - } - - fun NotificationManager.getOrCreateZapChannel(applicationContext: Context): NotificationChannel { - if (zapChannel != null) return zapChannel!! - - zapChannel = NotificationChannel( - applicationContext.getString(R.string.app_notification_zaps_channel_id), - applicationContext.getString(R.string.app_notification_zaps_channel_name), - NotificationManager.IMPORTANCE_DEFAULT - ).apply { - description = applicationContext.getString(R.string.app_notification_zaps_channel_description) - } - - // Register the channel with the system - val notificationManager: NotificationManager = - applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - notificationManager.createNotificationChannel(zapChannel!!) - - return zapChannel!! - } - - fun NotificationManager.sendZapNotification( - id: String, - messageBody: String, - messageTitle: String, - pictureUrl: String?, - uri: String, - applicationContext: Context - ) { - val zapChannel = getOrCreateZapChannel(applicationContext) - val channelId = applicationContext.getString(R.string.app_notification_zaps_channel_id) - - sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, ZAP_GROUP_KEY, applicationContext) - } - - fun NotificationManager.sendDMNotification( - id: String, - messageBody: String, - messageTitle: String, - pictureUrl: String?, - uri: String, - applicationContext: Context - ) { - val dmChannel = getOrCreateDMChannel(applicationContext) - val channelId = applicationContext.getString(R.string.app_notification_dms_channel_id) - - sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, DM_GROUP_KEY, applicationContext) - } - - fun NotificationManager.sendNotification( - id: String, - messageBody: String, - messageTitle: String, - pictureUrl: String?, - uri: String, - channelId: String, - notificationGroupKey: String, - applicationContext: Context - ) { - if (pictureUrl != null) { - val request = ImageRequest.Builder(applicationContext) - .data(pictureUrl) - .build() - - val imageLoader = ImageLoader(applicationContext) - val imageResult = imageLoader.executeBlocking(request) - sendNotification( - id = id, - messageBody = messageBody, - messageTitle = messageTitle, - picture = imageResult.drawable as? BitmapDrawable, - uri = uri, - channelId, - notificationGroupKey, - applicationContext = applicationContext - ) - } else { - sendNotification( - id = id, - messageBody = messageBody, - messageTitle = messageTitle, - picture = null, - uri = uri, - channelId, - notificationGroupKey, - applicationContext = applicationContext - ) - } - } - - private fun NotificationManager.sendNotification( - id: String, - messageBody: String, - messageTitle: String, - picture: BitmapDrawable?, - uri: String, - channelId: String, - notificationGroupKey: String, - applicationContext: Context - ) { - val notId = id.hashCode() - - // dont notify twice - val notifications: Array = getActiveNotifications() - for (notification in notifications) { - if (notification.id == notId) { - return - } - } - - val contentIntent = Intent(applicationContext, MainActivity::class.java).apply { - data = Uri.parse(uri) - } - - val contentPendingIntent = PendingIntent.getActivity( - applicationContext, - notId, - contentIntent, - PendingIntent.FLAG_MUTABLE + dmChannel = + NotificationChannel( + applicationContext.getString(R.string.app_notification_dms_channel_id), + applicationContext.getString(R.string.app_notification_dms_channel_name), + NotificationManager.IMPORTANCE_DEFAULT, ) + .apply { + description = + applicationContext.getString(R.string.app_notification_dms_channel_description) + } - // Build the notification - val builderPublic = NotificationCompat.Builder( - applicationContext, - channelId + // Register the channel with the system + val notificationManager: NotificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + notificationManager.createNotificationChannel(dmChannel!!) + + return dmChannel!! + } + + fun NotificationManager.getOrCreateZapChannel(applicationContext: Context): NotificationChannel { + if (zapChannel != null) return zapChannel!! + + zapChannel = + NotificationChannel( + applicationContext.getString(R.string.app_notification_zaps_channel_id), + applicationContext.getString(R.string.app_notification_zaps_channel_name), + NotificationManager.IMPORTANCE_DEFAULT, ) - .setSmallIcon(R.drawable.amethyst) - .setContentTitle(messageTitle) - .setContentText(applicationContext.getString(R.string.app_notification_private_message)) - .setLargeIcon(picture?.bitmap) - // .setGroup(messageTitle) - // .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we activate this - .setContentIntent(contentPendingIntent) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setAutoCancel(true) + .apply { + description = + applicationContext.getString(R.string.app_notification_zaps_channel_description) + } - // Build the notification - val builder = NotificationCompat.Builder( - applicationContext, - channelId - ) - .setSmallIcon(R.drawable.amethyst) - .setContentTitle(messageTitle) - .setContentText(messageBody) - .setLargeIcon(picture?.bitmap) - // .setGroup(messageTitle) - // .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we activate this - .setContentIntent(contentPendingIntent) - .setPublicVersion(builderPublic.build()) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setAutoCancel(true) + // Register the channel with the system + val notificationManager: NotificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notify(notId, builder.build()) + notificationManager.createNotificationChannel(zapChannel!!) + + return zapChannel!! + } + + fun NotificationManager.sendZapNotification( + id: String, + messageBody: String, + messageTitle: String, + pictureUrl: String?, + uri: String, + applicationContext: Context, + ) { + val zapChannel = getOrCreateZapChannel(applicationContext) + val channelId = applicationContext.getString(R.string.app_notification_zaps_channel_id) + + sendNotification( + id, + messageBody, + messageTitle, + pictureUrl, + uri, + channelId, + ZAP_GROUP_KEY, + applicationContext, + ) + } + + fun NotificationManager.sendDMNotification( + id: String, + messageBody: String, + messageTitle: String, + pictureUrl: String?, + uri: String, + applicationContext: Context, + ) { + val dmChannel = getOrCreateDMChannel(applicationContext) + val channelId = applicationContext.getString(R.string.app_notification_dms_channel_id) + + sendNotification( + id, + messageBody, + messageTitle, + pictureUrl, + uri, + channelId, + DM_GROUP_KEY, + applicationContext, + ) + } + + fun NotificationManager.sendNotification( + id: String, + messageBody: String, + messageTitle: String, + pictureUrl: String?, + uri: String, + channelId: String, + notificationGroupKey: String, + applicationContext: Context, + ) { + if (pictureUrl != null) { + val request = ImageRequest.Builder(applicationContext).data(pictureUrl).build() + + val imageLoader = ImageLoader(applicationContext) + val imageResult = imageLoader.executeBlocking(request) + sendNotification( + id = id, + messageBody = messageBody, + messageTitle = messageTitle, + picture = imageResult.drawable as? BitmapDrawable, + uri = uri, + channelId, + notificationGroupKey, + applicationContext = applicationContext, + ) + } else { + sendNotification( + id = id, + messageBody = messageBody, + messageTitle = messageTitle, + picture = null, + uri = uri, + channelId, + notificationGroupKey, + applicationContext = applicationContext, + ) + } + } + + private fun NotificationManager.sendNotification( + id: String, + messageBody: String, + messageTitle: String, + picture: BitmapDrawable?, + uri: String, + channelId: String, + notificationGroupKey: String, + applicationContext: Context, + ) { + val notId = id.hashCode() + + // dont notify twice + val notifications: Array = getActiveNotifications() + for (notification in notifications) { + if (notification.id == notId) { + return + } } - /** - * Cancels all notifications. - */ - fun NotificationManager.cancelNotifications() { - cancelAll() - } + val contentIntent = + Intent(applicationContext, MainActivity::class.java).apply { data = Uri.parse(uri) } + + val contentPendingIntent = + PendingIntent.getActivity( + applicationContext, + notId, + contentIntent, + PendingIntent.FLAG_MUTABLE, + ) + + // Build the notification + val builderPublic = + NotificationCompat.Builder( + applicationContext, + channelId, + ) + .setSmallIcon(R.drawable.amethyst) + .setContentTitle(messageTitle) + .setContentText(applicationContext.getString(R.string.app_notification_private_message)) + .setLargeIcon(picture?.bitmap) + // .setGroup(messageTitle) + // .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we + // activate this + .setContentIntent(contentPendingIntent) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + + // Build the notification + val builder = + NotificationCompat.Builder( + applicationContext, + channelId, + ) + .setSmallIcon(R.drawable.amethyst) + .setContentTitle(messageTitle) + .setContentText(messageBody) + .setLargeIcon(picture?.bitmap) + // .setGroup(messageTitle) + // .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we + // activate this + .setContentIntent(contentPendingIntent) + .setPublicVersion(builderPublic.build()) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + + notify(notId, builder.build()) + } + + /** Cancels all notifications. */ + fun NotificationManager.cancelNotifications() { + cancelAll() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt index 226276f1c..420d3a54d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service.notifications import android.util.Log @@ -14,94 +34,96 @@ import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody class RegisterAccounts( - private val accounts: List + private val accounts: List, ) { - private fun recursiveAuthCreation( - notificationToken: String, - remainingTos: List>, - output: MutableList, - onReady: (List) -> Unit - ) { - if (remainingTos.isEmpty()) { - onReady(output) - return - } - - val next = remainingTos.first() - - next.first.createAuthEvent(next.second, notificationToken) { - output.add(it) - recursiveAuthCreation(notificationToken, remainingTos.filter { next != it }, output, onReady) - } + private fun recursiveAuthCreation( + notificationToken: String, + remainingTos: List>, + output: MutableList, + onReady: (List) -> Unit, + ) { + if (remainingTos.isEmpty()) { + onReady(output) + return } - // creates proof that it controls all accounts - private suspend fun signEventsToProveControlOfAccounts( - accounts: List, - notificationToken: String, - onReady: (List) -> Unit - ) { - val readyToSend = accounts.mapNotNull { - val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub) - if (acc != null && acc.isWriteable()) { - val readRelays = acc.userProfile().latestContactList?.relays() ?: acc.backupContactList?.relays() + val next = remainingTos.first() - val relayToUse = readRelays?.firstNotNullOfOrNull { if (it.value.read) it.key else null } - if (relayToUse != null) { - Pair(acc, relayToUse) - } else { - null - } - } else { - null - } - } - - val listOfAuthEvents = mutableListOf() - recursiveAuthCreation( - notificationToken, - readyToSend, - listOfAuthEvents, - onReady - ) + next.first.createAuthEvent(next.second, notificationToken) { + output.add(it) + recursiveAuthCreation(notificationToken, remainingTos.filter { next != it }, output, onReady) } + } - fun postRegistrationEvent(events: List) { - try { - val jsonObject = """{ + // creates proof that it controls all accounts + private suspend fun signEventsToProveControlOfAccounts( + accounts: List, + notificationToken: String, + onReady: (List) -> Unit, + ) { + val readyToSend = + accounts.mapNotNull { + val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub) + if (acc != null && acc.isWriteable()) { + val readRelays = + acc.userProfile().latestContactList?.relays() ?: acc.backupContactList?.relays() + + val relayToUse = readRelays?.firstNotNullOfOrNull { if (it.value.read) it.key else null } + if (relayToUse != null) { + Pair(acc, relayToUse) + } else { + null + } + } else { + null + } + } + + val listOfAuthEvents = mutableListOf() + recursiveAuthCreation( + notificationToken, + readyToSend, + listOfAuthEvents, + onReady, + ) + } + + fun postRegistrationEvent(events: List) { + try { + val jsonObject = + """{ "events": [ ${events.joinToString(", ") { it.toJson() }} ] } """ - val mediaType = "application/json; charset=utf-8".toMediaType() - val body = jsonObject.toRequestBody(mediaType) + val mediaType = "application/json; charset=utf-8".toMediaType() + val body = jsonObject.toRequestBody(mediaType) - val request = Request.Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") - .url("https://push.amethyst.social/register") - .post(body) - .build() + val request = + Request.Builder() + .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .url("https://push.amethyst.social/register") + .post(body) + .build() - val client = HttpClient.getHttpClient() + val client = HttpClient.getHttpClient() - val isSucess = client.newCall(request).execute().use { - it.isSuccessful - } - } catch (e: java.lang.Exception) { - val tag = if (BuildConfig.FLAVOR == "play") { - "FirebaseMsgService" - } else { - "UnifiedPushService" - } - Log.e(tag, "Unable to register with push server", e) + val isSucess = client.newCall(request).execute().use { it.isSuccessful } + } catch (e: java.lang.Exception) { + val tag = + if (BuildConfig.FLAVOR == "play") { + "FirebaseMsgService" + } else { + "UnifiedPushService" } + Log.e(tag, "Unable to register with push server", e) } + } - suspend fun go(notificationToken: String) = withContext(Dispatchers.IO) { - signEventsToProveControlOfAccounts(accounts, notificationToken) { - postRegistrationEvent(it) - } + suspend fun go(notificationToken: String) = + withContext(Dispatchers.IO) { + signEventsToProveControlOfAccounts(accounts, notificationToken) { postRegistrationEvent(it) } - PushNotificationUtils.hasInit = true + PushNotificationUtils.hasInit = true } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/playback/MultiPlayerPlaybackManager.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/playback/MultiPlayerPlaybackManager.kt index f57c577bc..e40e4421c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/playback/MultiPlayerPlaybackManager.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/playback/MultiPlayerPlaybackManager.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service.playback import android.app.PendingIntent @@ -12,127 +32,138 @@ import androidx.media3.common.Player.STATE_READY import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.MediaSession import com.vitorpamplona.amethyst.ui.MainActivity +import kotlin.math.abs import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch -import kotlin.math.abs class MultiPlayerPlaybackManager( - private val dataSourceFactory: androidx.media3.exoplayer.source.MediaSource.Factory? = null, - private val cachedPositions: VideoViewedPositionCache + private val dataSourceFactory: androidx.media3.exoplayer.source.MediaSource.Factory? = null, + private val cachedPositions: VideoViewedPositionCache, ) { - // protects from LruCache killing playing sessions - private val playingMap = mutableMapOf() + // protects from LruCache killing playing sessions + private val playingMap = mutableMapOf() - private val cache = - object : LruCache(10) { // up to 10 videos in the screen at the same time - override fun entryRemoved( - evicted: Boolean, - key: String?, - oldValue: MediaSession?, - newValue: MediaSession? - ) { - super.entryRemoved(evicted, key, oldValue, newValue) + private val cache = + object : LruCache(10) { // up to 10 videos in the screen at the same time + override fun entryRemoved( + evicted: Boolean, + key: String?, + oldValue: MediaSession?, + newValue: MediaSession?, + ) { + super.entryRemoved(evicted, key, oldValue, newValue) - if (!playingMap.contains(key)) { - oldValue?.let { - it.player.release() - it.release() - } + if (!playingMap.contains(key)) { + oldValue?.let { + it.player.release() + it.release() + } + } + } + } + + private fun getCallbackIntent( + callbackUri: String, + applicationContext: Context, + ): PendingIntent { + return PendingIntent.getActivity( + applicationContext, + 0, + Intent(Intent.ACTION_VIEW, callbackUri.toUri(), applicationContext, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + } + + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + fun getMediaSession( + id: String, + uri: String, + callbackUri: String?, + context: Context, + applicationContext: Context, + ): MediaSession { + val existingSession = playingMap.get(id) ?: cache.get(id) + if (existingSession != null) return existingSession + + val player = + ExoPlayer.Builder(context).run { + dataSourceFactory?.let { setMediaSourceFactory(it) } + build() + } + + player.apply { + repeatMode = Player.REPEAT_MODE_ALL + videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT + volume = 0f + } + + val mediaSession = + MediaSession.Builder(context, player).run { + callbackUri?.let { setSessionActivity(getCallbackIntent(it, applicationContext)) } + setId(id) + build() + } + + player.addListener( + object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + if (isPlaying) { + player.setWakeMode(C.WAKE_MODE_NETWORK) + playingMap.put(id, mediaSession) + } else { + player.setWakeMode(C.WAKE_MODE_NONE) + cachedPositions.add(uri, player.currentPosition) + cache.put(id, mediaSession) + playingMap.remove(id, mediaSession) + } + } + + override fun onPlaybackStateChanged(playbackState: Int) { + when (playbackState) { + STATE_IDLE -> { + // only saves if it wqs playing + if (abs(player.currentPosition) > 1) { + cachedPositions.add(uri, player.currentPosition) + } + } + STATE_READY -> { + cachedPositions.get(uri)?.let { lastPosition -> + if (abs(player.currentPosition - lastPosition) > 5 * 60) { + player.seekTo(lastPosition) } + } } + else -> { + // only saves if it wqs playing + if (abs(player.currentPosition) > 1) { + cachedPositions.add(uri, player.currentPosition) + } + } + } } + }, + ) - private fun getCallbackIntent(callbackUri: String, applicationContext: Context): PendingIntent { - return PendingIntent.getActivity( - applicationContext, - 0, - Intent(Intent.ACTION_VIEW, callbackUri.toUri(), applicationContext, MainActivity::class.java), - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) + cache.put(id, mediaSession) + + return mediaSession + } + + @OptIn(DelicateCoroutinesApi::class) + fun releaseAppPlayers() { + GlobalScope.launch(Dispatchers.Main) { + cache.evictAll() + playingMap.forEach { + it.value.player.release() + it.value.release() + } + playingMap.clear() } + } - @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) - fun getMediaSession(id: String, uri: String, callbackUri: String?, context: Context, applicationContext: Context): MediaSession { - val existingSession = playingMap.get(id) ?: cache.get(id) - if (existingSession != null) return existingSession - - val player = ExoPlayer.Builder(context).run { - dataSourceFactory?.let { setMediaSourceFactory(it) } - build() - } - - player.apply { - repeatMode = Player.REPEAT_MODE_ALL - videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT - volume = 0f - } - - val mediaSession = MediaSession.Builder(context, player).run { - callbackUri?.let { - setSessionActivity(getCallbackIntent(it, applicationContext)) - } - setId(id) - build() - } - - player.addListener(object : Player.Listener { - override fun onIsPlayingChanged(isPlaying: Boolean) { - if (isPlaying) { - player.setWakeMode(C.WAKE_MODE_NETWORK) - playingMap.put(id, mediaSession) - } else { - player.setWakeMode(C.WAKE_MODE_NONE) - cachedPositions.add(uri, player.currentPosition) - cache.put(id, mediaSession) - playingMap.remove(id, mediaSession) - } - } - - override fun onPlaybackStateChanged(playbackState: Int) { - when (playbackState) { - STATE_IDLE -> { - // only saves if it wqs playing - if (abs(player.currentPosition) > 1) { - cachedPositions.add(uri, player.currentPosition) - } - } - STATE_READY -> { - cachedPositions.get(uri)?.let { lastPosition -> - if (abs(player.currentPosition - lastPosition) > 5 * 60) { - player.seekTo(lastPosition) - } - } - } - else -> { - // only saves if it wqs playing - if (abs(player.currentPosition) > 1) { - cachedPositions.add(uri, player.currentPosition) - } - } - } - } - }) - - cache.put(id, mediaSession) - - return mediaSession - } - - @OptIn(DelicateCoroutinesApi::class) - fun releaseAppPlayers() { - GlobalScope.launch(Dispatchers.Main) { - cache.evictAll() - playingMap.forEach { - it.value.player.release() - it.value.release() - } - playingMap.clear() - } - } - - fun playingContent(): Collection { - return playingMap.values - } + fun playingContent(): Collection { + return playingMap.values + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackClientController.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackClientController.kt index aeb19ea3e..bd66234a1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackClientController.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackClientController.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service.playback import android.content.ComponentName @@ -10,47 +30,45 @@ import androidx.media3.session.SessionToken import com.google.common.util.concurrent.MoreExecutors object PlaybackClientController { - val cache = LruCache(1) + val cache = LruCache(1) - @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) - fun prepareController( - controllerID: String, - videoUri: String, - callbackUri: String?, - context: Context, - onReady: (MediaController) -> Unit - ) { - try { - // creating a bundle object - // creating a bundle object - val bundle = Bundle() - bundle.putString("id", controllerID) - bundle.putString("uri", videoUri) - bundle.putString("callbackUri", callbackUri) + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + fun prepareController( + controllerID: String, + videoUri: String, + callbackUri: String?, + context: Context, + onReady: (MediaController) -> Unit, + ) { + try { + // creating a bundle object + // creating a bundle object + val bundle = Bundle() + bundle.putString("id", controllerID) + bundle.putString("uri", videoUri) + bundle.putString("callbackUri", callbackUri) - var session = cache.get(context.hashCode()) - if (session == null) { - session = SessionToken(context, ComponentName(context, PlaybackService::class.java)) - cache.put(context.hashCode(), session) - } + var session = cache.get(context.hashCode()) + if (session == null) { + session = SessionToken(context, ComponentName(context, PlaybackService::class.java)) + cache.put(context.hashCode(), session) + } - val controllerFuture = MediaController - .Builder(context, session) - .setConnectionHints(bundle) - .buildAsync() + val controllerFuture = + MediaController.Builder(context, session).setConnectionHints(bundle).buildAsync() - controllerFuture.addListener( - { - try { - onReady(controllerFuture.get()) - } catch (e: Exception) { - Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e) - } - }, - MoreExecutors.directExecutor() - ) - } catch (e: Exception) { + controllerFuture.addListener( + { + try { + onReady(controllerFuture.get()) + } catch (e: Exception) { Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e) - } + } + }, + MoreExecutors.directExecutor(), + ) + } catch (e: Exception) { + Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e) } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackService.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackService.kt index 2660b318a..d2416e279 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackService.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackService.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service.playback import android.content.Intent @@ -15,144 +35,161 @@ import com.vitorpamplona.amethyst.service.HttpClient @UnstableApi // Extend MediaSessionService class PlaybackService : MediaSessionService() { - private var videoViewedPositionCache = VideoViewedPositionCache() + private var videoViewedPositionCache = VideoViewedPositionCache() - private var managerHls: MultiPlayerPlaybackManager? = null - private var managerProgressive: MultiPlayerPlaybackManager? = null - private var managerLocal: MultiPlayerPlaybackManager? = null + private var managerHls: MultiPlayerPlaybackManager? = null + private var managerProgressive: MultiPlayerPlaybackManager? = null + private var managerLocal: MultiPlayerPlaybackManager? = null - fun newHslDataSource(): MediaSource.Factory { - return HlsMediaSource.Factory(OkHttpDataSource.Factory(HttpClient.getHttpClient())) + fun newHslDataSource(): MediaSource.Factory { + return HlsMediaSource.Factory(OkHttpDataSource.Factory(HttpClient.getHttpClient())) + } + + fun newProgressiveDataSource(): MediaSource.Factory { + return ProgressiveMediaSource.Factory( + (applicationContext as Amethyst).videoCache.get(HttpClient.getHttpClient()), + ) + } + + fun lazyHlsDS(): MultiPlayerPlaybackManager { + managerHls?.let { + return it } - fun newProgressiveDataSource(): MediaSource.Factory { - return ProgressiveMediaSource.Factory( - (applicationContext as Amethyst).videoCache.get(HttpClient.getHttpClient()) - ) + val newInstance = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache) + managerHls = newInstance + return newInstance + } + + fun lazyProgressiveDS(): MultiPlayerPlaybackManager { + managerProgressive?.let { + return it } - fun lazyHlsDS(): MultiPlayerPlaybackManager { - managerHls?.let { return it } + val newInstance = + MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache) + managerProgressive = newInstance + return newInstance + } - val newInstance = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache) - managerHls = newInstance - return newInstance + fun lazyLocalDS(): MultiPlayerPlaybackManager { + managerLocal?.let { + return it } - fun lazyProgressiveDS(): MultiPlayerPlaybackManager { - managerProgressive?.let { return it } + val newInstance = MultiPlayerPlaybackManager(cachedPositions = videoViewedPositionCache) + managerLocal = newInstance + return newInstance + } - val newInstance = MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache) - managerProgressive = newInstance - return newInstance + // Create your Player and MediaSession in the onCreate lifecycle event + @OptIn(UnstableApi::class) + override fun onCreate() { + super.onCreate() + + Log.d("Lifetime Event", "PlaybackService.onCreate") + + // Stop all videos and recreates all managers when the proxy changes. + HttpClient.proxyChangeListeners.add(this@PlaybackService::onProxyUpdated) + } + + private fun onProxyUpdated() { + val toDestroyHls = managerHls + val toDestroyProgressive = managerProgressive + + managerHls = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache) + managerProgressive = + MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache) + + toDestroyHls?.releaseAppPlayers() + toDestroyProgressive?.releaseAppPlayers() + } + + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + + Log.d("Lifetime Event", "onTaskRemoved") + } + + override fun onDestroy() { + Log.d("Lifetime Event", "PlaybackService.onDestroy") + + HttpClient.proxyChangeListeners.remove(this@PlaybackService::onProxyUpdated) + + managerHls?.releaseAppPlayers() + managerLocal?.releaseAppPlayers() + managerProgressive?.releaseAppPlayers() + + super.onDestroy() + } + + fun getAppropriateMediaSessionManager(fileName: String): MultiPlayerPlaybackManager? { + return if (fileName.startsWith("file")) { + lazyLocalDS() + } else if (fileName.endsWith("m3u8")) { + lazyHlsDS() + } else { + lazyProgressiveDS() } + } - fun lazyLocalDS(): MultiPlayerPlaybackManager { - managerLocal?.let { return it } + override fun onUpdateNotification( + session: MediaSession, + startInForegroundRequired: Boolean, + ) { + // Updates any new player ready + super.onUpdateNotification(session, startInForegroundRequired) - val newInstance = MultiPlayerPlaybackManager(cachedPositions = videoViewedPositionCache) - managerLocal = newInstance - return newInstance + // Overrides the notification with any player actually playing + managerHls?.playingContent()?.forEach { + if (it.player.isPlaying) { + super.onUpdateNotification(it, startInForegroundRequired) + } } - - // Create your Player and MediaSession in the onCreate lifecycle event - @OptIn(UnstableApi::class) - override fun onCreate() { - super.onCreate() - - Log.d("Lifetime Event", "PlaybackService.onCreate") - - // Stop all videos and recreates all managers when the proxy changes. - HttpClient.proxyChangeListeners.add(this@PlaybackService::onProxyUpdated) - } - - private fun onProxyUpdated() { - val toDestroyHls = managerHls - val toDestroyProgressive = managerProgressive - - managerHls = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache) - managerProgressive = MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache) - - toDestroyHls?.releaseAppPlayers() - toDestroyProgressive?.releaseAppPlayers() - } - - override fun onTaskRemoved(rootIntent: Intent?) { - super.onTaskRemoved(rootIntent) - - Log.d("Lifetime Event", "onTaskRemoved") - } - - override fun onDestroy() { - Log.d("Lifetime Event", "PlaybackService.onDestroy") - - HttpClient.proxyChangeListeners.remove(this@PlaybackService::onProxyUpdated) - - managerHls?.releaseAppPlayers() - managerLocal?.releaseAppPlayers() - managerProgressive?.releaseAppPlayers() - - super.onDestroy() - } - - fun getAppropriateMediaSessionManager(fileName: String): MultiPlayerPlaybackManager? { - return if (fileName.startsWith("file")) { - lazyLocalDS() - } else if (fileName.endsWith("m3u8")) { - lazyHlsDS() - } else { - lazyProgressiveDS() - } - } - - override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) { - // Updates any new player ready + managerLocal?.playingContent()?.forEach { + if (it.player.isPlaying) { super.onUpdateNotification(session, startInForegroundRequired) - - // Overrides the notification with any player actually playing - managerHls?.playingContent()?.forEach { - if (it.player.isPlaying) { - super.onUpdateNotification(it, startInForegroundRequired) - } - } - managerLocal?.playingContent()?.forEach { - if (it.player.isPlaying) { - super.onUpdateNotification(session, startInForegroundRequired) - } - } - managerProgressive?.playingContent()?.forEach { - if (it.player.isPlaying) { - super.onUpdateNotification(session, startInForegroundRequired) - } - } - - // Overrides again with playing with audio - managerHls?.playingContent()?.forEach { - if (it.player.isPlaying && it.player.volume > 0) { - super.onUpdateNotification(it, startInForegroundRequired) - } - } - managerLocal?.playingContent()?.forEach { - if (it.player.isPlaying && it.player.volume > 0) { - super.onUpdateNotification(session, startInForegroundRequired) - } - } - managerProgressive?.playingContent()?.forEach { - if (it.player.isPlaying && it.player.volume > 0) { - super.onUpdateNotification(session, startInForegroundRequired) - } - } + } + } + managerProgressive?.playingContent()?.forEach { + if (it.player.isPlaying) { + super.onUpdateNotification(session, startInForegroundRequired) + } } - // Return a MediaSession to link with the MediaController that is making - // this request. - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { - val id = controllerInfo.connectionHints.getString("id") ?: return null - val uri = controllerInfo.connectionHints.getString("uri") ?: return null - val callbackUri = controllerInfo.connectionHints.getString("callbackUri") - - val manager = getAppropriateMediaSessionManager(uri) - - return manager?.getMediaSession(id, uri, callbackUri, context = this, applicationContext = applicationContext) + // Overrides again with playing with audio + managerHls?.playingContent()?.forEach { + if (it.player.isPlaying && it.player.volume > 0) { + super.onUpdateNotification(it, startInForegroundRequired) + } } + managerLocal?.playingContent()?.forEach { + if (it.player.isPlaying && it.player.volume > 0) { + super.onUpdateNotification(session, startInForegroundRequired) + } + } + managerProgressive?.playingContent()?.forEach { + if (it.player.isPlaying && it.player.volume > 0) { + super.onUpdateNotification(session, startInForegroundRequired) + } + } + } + + // Return a MediaSession to link with the MediaController that is making + // this request. + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { + val id = controllerInfo.connectionHints.getString("id") ?: return null + val uri = controllerInfo.connectionHints.getString("uri") ?: return null + val callbackUri = controllerInfo.connectionHints.getString("callbackUri") + + val manager = getAppropriateMediaSessionManager(uri) + + return manager?.getMediaSession( + id, + uri, + callbackUri, + context = this, + applicationContext = applicationContext, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoCache.kt index 5a6ce0cf2..14bff8581 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoCache.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service.playback import android.annotation.SuppressLint @@ -7,46 +27,47 @@ import androidx.media3.datasource.cache.CacheDataSource import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor import androidx.media3.datasource.cache.SimpleCache import androidx.media3.datasource.okhttp.OkHttpDataSource -import okhttp3.OkHttpClient import java.io.File +import okhttp3.OkHttpClient @SuppressLint("UnsafeOptInUsageError") class VideoCache { + var exoPlayerCacheSize: Long = 150 * 1024 * 1024 // 90MB - var exoPlayerCacheSize: Long = 150 * 1024 * 1024 // 90MB + var leastRecentlyUsedCacheEvictor = LeastRecentlyUsedCacheEvictor(exoPlayerCacheSize) - var leastRecentlyUsedCacheEvictor = LeastRecentlyUsedCacheEvictor(exoPlayerCacheSize) + lateinit var exoDatabaseProvider: StandaloneDatabaseProvider + lateinit var simpleCache: SimpleCache - lateinit var exoDatabaseProvider: StandaloneDatabaseProvider - lateinit var simpleCache: SimpleCache + lateinit var cacheDataSourceFactory: CacheDataSource.Factory - lateinit var cacheDataSourceFactory: CacheDataSource.Factory + @Synchronized + fun initFileCache(context: Context) { + exoDatabaseProvider = StandaloneDatabaseProvider(context) - @Synchronized - fun initFileCache(context: Context) { - exoDatabaseProvider = StandaloneDatabaseProvider(context) + simpleCache = + SimpleCache( + File(context.cacheDir, "exoplayer"), + leastRecentlyUsedCacheEvictor, + exoDatabaseProvider, + ) + } - simpleCache = SimpleCache( - File(context.cacheDir, "exoplayer"), - leastRecentlyUsedCacheEvictor, - exoDatabaseProvider + // This method should be called when proxy setting changes. + fun renewCacheFactory(client: OkHttpClient) { + cacheDataSourceFactory = + CacheDataSource.Factory() + .setCache(simpleCache) + .setUpstreamDataSourceFactory( + OkHttpDataSource.Factory(client), ) - } + .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) + } - // This method should be called when proxy setting changes. - fun renewCacheFactory(client: OkHttpClient) { - cacheDataSourceFactory = CacheDataSource.Factory() - .setCache(simpleCache) - .setUpstreamDataSourceFactory( - OkHttpDataSource.Factory(client) - ) - .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) - } + fun get(client: OkHttpClient): CacheDataSource.Factory { + // Renews the factory because OkHttpMight have changed. + renewCacheFactory(client) - fun get(client: OkHttpClient): CacheDataSource.Factory { - // Renews the factory because OkHttpMight have changed. - renewCacheFactory(client) - - return cacheDataSourceFactory - } + return cacheDataSourceFactory + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoViewedPositionCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoViewedPositionCache.kt index 569e131fe..e0984c0fb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoViewedPositionCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoViewedPositionCache.kt @@ -1,15 +1,38 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service.playback import android.util.LruCache class VideoViewedPositionCache { - val cachedPosition = LruCache(100) + val cachedPosition = LruCache(100) - fun add(uri: String, position: Long) { - cachedPosition.put(uri, position) - } + fun add( + uri: String, + position: Long, + ) { + cachedPosition.put(uri, position) + } - fun get(uri: String): Long? { - return cachedPosition.get(uri) - } + fun get(uri: String): Long? { + return cachedPosition.get(uri) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/previews/BahaUrlPreview.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/previews/BahaUrlPreview.kt index b7af42bfb..01b448577 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/previews/BahaUrlPreview.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/previews/BahaUrlPreview.kt @@ -1,22 +1,43 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service.previews import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) { - suspend fun fetchUrlPreview(timeOut: Int = 30000) = withContext(Dispatchers.IO) { - try { - fetch(timeOut) - } catch (t: Throwable) { - callback?.onFailed(t) - } + suspend fun fetchUrlPreview(timeOut: Int = 30000) = + withContext(Dispatchers.IO) { + try { + fetch(timeOut) + } catch (t: Throwable) { + callback?.onFailed(t) + } } - private suspend fun fetch(timeOut: Int = 30000) { - callback?.onComplete(getDocument(url, timeOut)) - } + private suspend fun fetch(timeOut: Int = 30000) { + callback?.onComplete(getDocument(url, timeOut)) + } - fun cleanUp() { - callback = null - } + fun cleanUp() { + callback = null + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/previews/IUrlPreviewCallback.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/previews/IUrlPreviewCallback.kt index 9e676d98a..5f333a34c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/previews/IUrlPreviewCallback.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/previews/IUrlPreviewCallback.kt @@ -1,6 +1,27 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service.previews interface IUrlPreviewCallback { - suspend fun onComplete(urlInfo: UrlInfoItem) - suspend fun onFailed(throwable: Throwable) + suspend fun onComplete(urlInfo: UrlInfoItem) + + suspend fun onFailed(throwable: Throwable) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlInfoItem.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlInfoItem.kt index f96e5a164..d89f5734b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlInfoItem.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlInfoItem.kt @@ -1,30 +1,50 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service.previews import androidx.compose.runtime.Immutable -import okhttp3.MediaType import java.net.URL +import okhttp3.MediaType @Immutable class UrlInfoItem( - val url: String = "", - val title: String = "", - val description: String = "", - val image: String = "", - val mimeType: MediaType + val url: String = "", + val title: String = "", + val description: String = "", + val image: String = "", + val mimeType: MediaType, ) { - val verifiedUrl = kotlin.runCatching { URL(url) }.getOrNull() - val imageUrlFullPath = - if (image.startsWith("/")) { - URL(verifiedUrl, image).toString() - } else { - image - } - - fun fetchComplete(): Boolean { - return url.isNotEmpty() && image.isNotEmpty() + val verifiedUrl = kotlin.runCatching { URL(url) }.getOrNull() + val imageUrlFullPath = + if (image.startsWith("/")) { + URL(verifiedUrl, image).toString() + } else { + image } - fun allFetchComplete(): Boolean { - return title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty() - } + fun fetchComplete(): Boolean { + return url.isNotEmpty() && image.isNotEmpty() + } + + fun allFetchComplete(): Boolean { + return title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreviewUtils.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreviewUtils.kt index fdd4ef681..9594bba51 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreviewUtils.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreviewUtils.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service.previews import com.vitorpamplona.amethyst.service.HttpClient @@ -14,112 +34,149 @@ private const val ATTRIBUTE_VALUE_PROPERTY = "property" private const val ATTRIBUTE_VALUE_NAME = "name" private const val ATTRIBUTE_VALUE_ITEMPROP = "itemprop" -/*for if (title.isEmpty()) { - title = it.attr(CONTENT) - } - in META_X_DESCRIPTION -> if (description.isEmpty()) { - description = it.attr(CONTENT) - } - in META_X_IMAGE -> if (image.isEmpty()) { - image = it.attr(CONTENT) - } - } + metaTags.forEach { + when (it.attr(ATTRIBUTE_VALUE_PROPERTY)) { + in META_X_TITLE -> + if (title.isEmpty()) { + title = it.attr(CONTENT) + } + in META_X_DESCRIPTION -> + if (description.isEmpty()) { + description = it.attr(CONTENT) + } + in META_X_IMAGE -> + if (image.isEmpty()) { + image = it.attr(CONTENT) + } + } - when (it.attr(ATTRIBUTE_VALUE_NAME)) { - in META_X_TITLE -> if (title.isEmpty()) { - title = it.attr(CONTENT) - } - in META_X_DESCRIPTION -> if (description.isEmpty()) { - description = it.attr(CONTENT) - } - in META_X_IMAGE -> if (image.isEmpty()) { - image = it.attr(CONTENT) - } - } + when (it.attr(ATTRIBUTE_VALUE_NAME)) { + in META_X_TITLE -> + if (title.isEmpty()) { + title = it.attr(CONTENT) + } + in META_X_DESCRIPTION -> + if (description.isEmpty()) { + description = it.attr(CONTENT) + } + in META_X_IMAGE -> + if (image.isEmpty()) { + image = it.attr(CONTENT) + } + } - when (it.attr(ATTRIBUTE_VALUE_ITEMPROP)) { - in META_X_TITLE -> if (title.isEmpty()) { - title = it.attr(CONTENT) - } - in META_X_DESCRIPTION -> if (description.isEmpty()) { - description = it.attr(CONTENT) - } - in META_X_IMAGE -> if (image.isEmpty()) { - image = it.attr(CONTENT) - } - } + when (it.attr(ATTRIBUTE_VALUE_ITEMPROP)) { + in META_X_TITLE -> + if (title.isEmpty()) { + title = it.attr(CONTENT) + } + in META_X_DESCRIPTION -> + if (description.isEmpty()) { + description = it.attr(CONTENT) + } + in META_X_IMAGE -> + if (image.isEmpty()) { + image = it.attr(CONTENT) + } + } - if (title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()) { - return@withContext UrlInfoItem(url, title, description, image, type) - } - } + if (title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()) { return@withContext UrlInfoItem(url, title, description, image, type) + } } + return@withContext UrlInfoItem(url, title, description, image, type) + } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt index b800b8c6e..d89311996 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt @@ -1,257 +1,316 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service.relays import android.util.Log import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.EventInterface +import java.util.UUID import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import java.util.UUID /** * The Nostr Client manages multiple personae the user may switch between. Events are received and - * published through multiple relays. - * Events are stored with their respective persona. + * published through multiple relays. Events are stored with their respective persona. */ object Client : RelayPool.Listener { - private var listeners = setOf() - private var relays = emptyArray() - private var subscriptions = mapOf>() + private var listeners = setOf() + private var relays = emptyArray() + private var subscriptions = mapOf>() - @Synchronized - fun reconnect(relays: Array?, onlyIfChanged: Boolean = false) { - Log.d("Relay", "Relay Pool Reconnecting to ${relays?.size} relays") - checkNotInMainThread() + @Synchronized + fun reconnect( + relays: Array?, + onlyIfChanged: Boolean = false, + ) { + Log.d("Relay", "Relay Pool Reconnecting to ${relays?.size} relays") + checkNotInMainThread() - if (onlyIfChanged) { - if (!isSameRelaySetConfig(relays)) { - if (this.relays.isNotEmpty()) { - RelayPool.disconnect() - RelayPool.unregister(this) - RelayPool.unloadRelays() - } - - if (relays != null) { - RelayPool.register(this) - RelayPool.loadRelays(relays.toList()) - RelayPool.requestAndWatch() - this.relays = relays - } - } - } else { - if (this.relays.isNotEmpty()) { - RelayPool.disconnect() - RelayPool.unregister(this) - RelayPool.unloadRelays() - } - - if (relays != null) { - RelayPool.register(this) - RelayPool.loadRelays(relays.toList()) - RelayPool.requestAndWatch() - this.relays = relays - } - } - } - - fun isSameRelaySetConfig(newRelayConfig: Array?): Boolean { - if (relays.size != newRelayConfig?.size) return false - - relays.forEach { oldRelayInfo -> - val newRelayInfo = newRelayConfig.find { it.url == oldRelayInfo.url } ?: return false - - if (!oldRelayInfo.isSameRelayConfig(newRelayInfo)) return false + if (onlyIfChanged) { + if (!isSameRelaySetConfig(relays)) { + if (this.relays.isNotEmpty()) { + RelayPool.disconnect() + RelayPool.unregister(this) + RelayPool.unloadRelays() } - return true - } - - fun sendFilter( - subscriptionId: String = UUID.randomUUID().toString().substring(0..10), - filters: List = listOf() - ) { - checkNotInMainThread() - - subscriptions = subscriptions + Pair(subscriptionId, filters) - RelayPool.sendFilter(subscriptionId) - } - - fun sendFilterOnlyIfDisconnected( - subscriptionId: String = UUID.randomUUID().toString().substring(0..10), - filters: List = listOf() - ) { - checkNotInMainThread() - - subscriptions = subscriptions + Pair(subscriptionId, filters) - RelayPool.sendFilterOnlyIfDisconnected(subscriptionId) - } - - fun send( - signedEvent: EventInterface, - relay: String? = null, - feedTypes: Set? = null, - relayList: List? = null, - onDone: (() -> Unit)? = null - ) { - checkNotInMainThread() - - if (relayList != null) { - RelayPool.sendToSelectedRelays(relayList, signedEvent) - } else if (relay == null) { - RelayPool.send(signedEvent) - } else { - val useConnectedRelayIfPresent = RelayPool.getRelays(relay) - - if (useConnectedRelayIfPresent.isNotEmpty()) { - useConnectedRelayIfPresent.forEach { - it.send(signedEvent) - } - } else { - /** temporary connection */ - newSporadicRelay( - relay, - feedTypes, - onConnected = { relay -> - relay.send(signedEvent) - }, - onDone = onDone - ) - } + if (relays != null) { + RelayPool.register(this) + RelayPool.loadRelays(relays.toList()) + RelayPool.requestAndWatch() + this.relays = relays } + } + } else { + if (this.relays.isNotEmpty()) { + RelayPool.disconnect() + RelayPool.unregister(this) + RelayPool.unloadRelays() + } + + if (relays != null) { + RelayPool.register(this) + RelayPool.loadRelays(relays.toList()) + RelayPool.requestAndWatch() + this.relays = relays + } + } + } + + fun isSameRelaySetConfig(newRelayConfig: Array?): Boolean { + if (relays.size != newRelayConfig?.size) return false + + relays.forEach { oldRelayInfo -> + val newRelayInfo = newRelayConfig.find { it.url == oldRelayInfo.url } ?: return false + + if (!oldRelayInfo.isSameRelayConfig(newRelayInfo)) return false } - @OptIn(DelicateCoroutinesApi::class) - private fun newSporadicRelay(url: String, feedTypes: Set?, onConnected: (Relay) -> Unit, onDone: (() -> Unit)?) { - val relay = Relay(url, true, true, feedTypes ?: emptySet()) - RelayPool.addRelay(relay) + return true + } - relay.connectAndRun { - allSubscriptions().forEach { - relay.sendFilter(requestId = it) - } + fun sendFilter( + subscriptionId: String = UUID.randomUUID().toString().substring(0..10), + filters: List = listOf(), + ) { + checkNotInMainThread() - onConnected(relay) + subscriptions = subscriptions + Pair(subscriptionId, filters) + RelayPool.sendFilter(subscriptionId) + } - GlobalScope.launch(Dispatchers.IO) { - delay(60000) // waits for a reply - relay.disconnect() - RelayPool.removeRelay(relay) + fun sendFilterOnlyIfDisconnected( + subscriptionId: String = UUID.randomUUID().toString().substring(0..10), + filters: List = listOf(), + ) { + checkNotInMainThread() - if (onDone != null) { - onDone() - } - } + subscriptions = subscriptions + Pair(subscriptionId, filters) + RelayPool.sendFilterOnlyIfDisconnected(subscriptionId) + } + + fun send( + signedEvent: EventInterface, + relay: String? = null, + feedTypes: Set? = null, + relayList: List? = null, + onDone: (() -> Unit)? = null, + ) { + checkNotInMainThread() + + if (relayList != null) { + RelayPool.sendToSelectedRelays(relayList, signedEvent) + } else if (relay == null) { + RelayPool.send(signedEvent) + } else { + val useConnectedRelayIfPresent = RelayPool.getRelays(relay) + + if (useConnectedRelayIfPresent.isNotEmpty()) { + useConnectedRelayIfPresent.forEach { it.send(signedEvent) } + } else { + /** temporary connection */ + newSporadicRelay( + relay, + feedTypes, + onConnected = { relay -> relay.send(signedEvent) }, + onDone = onDone, + ) + } + } + } + + @OptIn(DelicateCoroutinesApi::class) + private fun newSporadicRelay( + url: String, + feedTypes: Set?, + onConnected: (Relay) -> Unit, + onDone: (() -> Unit)?, + ) { + val relay = Relay(url, true, true, feedTypes ?: emptySet()) + RelayPool.addRelay(relay) + + relay.connectAndRun { + allSubscriptions().forEach { relay.sendFilter(requestId = it) } + + onConnected(relay) + + GlobalScope.launch(Dispatchers.IO) { + delay(60000) // waits for a reply + relay.disconnect() + RelayPool.removeRelay(relay) + + if (onDone != null) { + onDone() } + } } + } - fun close(subscriptionId: String) { - RelayPool.close(subscriptionId) - subscriptions = subscriptions.minus(subscriptionId) + fun close(subscriptionId: String) { + RelayPool.close(subscriptionId) + subscriptions = subscriptions.minus(subscriptionId) + } + + fun isActive(subscriptionId: String): Boolean { + return subscriptions.contains(subscriptionId) + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onEvent( + event: Event, + subscriptionId: String, + relay: Relay, + afterEOSE: Boolean, + ) { + // Releases the Web thread for the new payload. + // May need to add a processing queue if processing new events become too costly. + GlobalScope.launch(Dispatchers.Default) { + listeners.forEach { it.onEvent(event, subscriptionId, relay, afterEOSE) } } + } - fun isActive(subscriptionId: String): Boolean { - return subscriptions.contains(subscriptionId) + @OptIn(DelicateCoroutinesApi::class) + override fun onError( + error: Error, + subscriptionId: String, + relay: Relay, + ) { + // Releases the Web thread for the new payload. + // May need to add a processing queue if processing new events become too costly. + GlobalScope.launch(Dispatchers.Default) { + listeners.forEach { it.onError(error, subscriptionId, relay) } } + } - @OptIn(DelicateCoroutinesApi::class) - override fun onEvent(event: Event, subscriptionId: String, relay: Relay, afterEOSE: Boolean) { - // Releases the Web thread for the new payload. - // May need to add a processing queue if processing new events become too costly. - GlobalScope.launch(Dispatchers.Default) { - listeners.forEach { it.onEvent(event, subscriptionId, relay, afterEOSE) } - } + @OptIn(DelicateCoroutinesApi::class) + override fun onRelayStateChange( + type: Relay.StateType, + relay: Relay, + channel: String?, + ) { + // Releases the Web thread for the new payload. + // May need to add a processing queue if processing new events become too costly. + GlobalScope.launch(Dispatchers.Default) { + listeners.forEach { it.onRelayStateChange(type, relay, channel) } } + } - @OptIn(DelicateCoroutinesApi::class) - override fun onError(error: Error, subscriptionId: String, relay: Relay) { - // Releases the Web thread for the new payload. - // May need to add a processing queue if processing new events become too costly. - GlobalScope.launch(Dispatchers.Default) { - listeners.forEach { it.onError(error, subscriptionId, relay) } - } + @OptIn(DelicateCoroutinesApi::class) + override fun onSendResponse( + eventId: String, + success: Boolean, + message: String, + relay: Relay, + ) { + // Releases the Web thread for the new payload. + // May need to add a processing queue if processing new events become too costly. + GlobalScope.launch(Dispatchers.Default) { + listeners.forEach { it.onSendResponse(eventId, success, message, relay) } } + } - @OptIn(DelicateCoroutinesApi::class) - override fun onRelayStateChange(type: Relay.StateType, relay: Relay, channel: String?) { - // Releases the Web thread for the new payload. - // May need to add a processing queue if processing new events become too costly. - GlobalScope.launch(Dispatchers.Default) { - listeners.forEach { it.onRelayStateChange(type, relay, channel) } - } + @OptIn(DelicateCoroutinesApi::class) + override fun onAuth( + relay: Relay, + challenge: String, + ) { + // Releases the Web thread for the new payload. + // May need to add a processing queue if processing new events become too costly. + GlobalScope.launch(Dispatchers.Default) { listeners.forEach { it.onAuth(relay, challenge) } } + } + + override fun onNotify( + relay: Relay, + description: String, + ) { + // Releases the Web thread for the new payload. + // May need to add a processing queue if processing new events become too costly. + GlobalScope.launch(Dispatchers.Default) { + listeners.forEach { it.onNotify(relay, description) } } + } - @OptIn(DelicateCoroutinesApi::class) - override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) { - // Releases the Web thread for the new payload. - // May need to add a processing queue if processing new events become too costly. - GlobalScope.launch(Dispatchers.Default) { - listeners.forEach { it.onSendResponse(eventId, success, message, relay) } - } - } + fun subscribe(listener: Listener) { + listeners = listeners.plus(listener) + } - @OptIn(DelicateCoroutinesApi::class) - override fun onAuth(relay: Relay, challenge: String) { - // Releases the Web thread for the new payload. - // May need to add a processing queue if processing new events become too costly. - GlobalScope.launch(Dispatchers.Default) { - listeners.forEach { it.onAuth(relay, challenge) } - } - } + fun isSubscribed(listener: Listener): Boolean { + return listeners.contains(listener) + } - override fun onNotify(relay: Relay, description: String) { - // Releases the Web thread for the new payload. - // May need to add a processing queue if processing new events become too costly. - GlobalScope.launch(Dispatchers.Default) { - listeners.forEach { it.onNotify(relay, description) } - } - } + fun unsubscribe(listener: Listener) { + listeners = listeners.minus(listener) + } - fun subscribe(listener: Listener) { - listeners = listeners.plus(listener) - } + fun allSubscriptions(): Set { + return subscriptions.keys + } - fun isSubscribed(listener: Listener): Boolean { - return listeners.contains(listener) - } + fun getSubscriptionFilters(subId: String): List { + return subscriptions[subId] ?: emptyList() + } - fun unsubscribe(listener: Listener) { - listeners = listeners.minus(listener) - } + abstract class Listener { + /** A new message was received */ + open fun onEvent( + event: Event, + subscriptionId: String, + relay: Relay, + afterEOSE: Boolean, + ) = Unit - fun allSubscriptions(): Set { - return subscriptions.keys - } + /** A new or repeat message was received */ + open fun onError( + error: Error, + subscriptionId: String, + relay: Relay, + ) = Unit - fun getSubscriptionFilters(subId: String): List { - return subscriptions[subId] ?: emptyList() - } + /** Connected to or disconnected from a relay */ + open fun onRelayStateChange( + type: Relay.StateType, + relay: Relay, + channel: String?, + ) = Unit - abstract class Listener { - /** - * A new message was received - */ - open fun onEvent(event: Event, subscriptionId: String, relay: Relay, afterEOSE: Boolean) = Unit + /** When an relay saves or rejects a new event. */ + open fun onSendResponse( + eventId: String, + success: Boolean, + message: String, + relay: Relay, + ) = Unit - /** - * A new or repeat message was received - */ - open fun onError(error: Error, subscriptionId: String, relay: Relay) = Unit + open fun onAuth( + relay: Relay, + challenge: String, + ) = Unit - /** - * Connected to or disconnected from a relay - */ - open fun onRelayStateChange(type: Relay.StateType, relay: Relay, channel: String?) = Unit - - /** - * When an relay saves or rejects a new event. - */ - open fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) = Unit - - open fun onAuth(relay: Relay, challenge: String) = Unit - - open fun onNotify(relay: Relay, description: String) = Unit - } + open fun onNotify( + relay: Relay, + description: String, + ) = Unit + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Constants.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Constants.kt index 0c4fff114..409968b71 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Constants.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Constants.kt @@ -1,63 +1,178 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service.relays import com.vitorpamplona.amethyst.model.RelaySetupInfo object Constants { - val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS) - val activeTypesChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS) - val activeTypesGlobalChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL) - val activeTypesSearch = setOf(FeedType.SEARCH) + val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS) + val activeTypesChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS) + val activeTypesGlobalChats = + setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL) + val activeTypesSearch = setOf(FeedType.SEARCH) - fun convertDefaultRelays(): Array { - return defaultRelays.map { - Relay(it.url, it.read, it.write, it.feedTypes) - }.toTypedArray() - } + fun convertDefaultRelays(): Array { + return defaultRelays.map { Relay(it.url, it.read, it.write, it.feedTypes) }.toTypedArray() + } - val defaultRelays = arrayOf( - // Free relays for only DMs and Follows due to the amount of spam - RelaySetupInfo("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes), - - // Chats - RelaySetupInfo("wss://nostr.bitcoiner.social", read = true, write = true, feedTypes = activeTypesChats), - RelaySetupInfo("wss://relay.nostr.bg", read = true, write = true, feedTypes = activeTypesChats), - RelaySetupInfo("wss://nostr.oxtr.dev", read = true, write = true, feedTypes = activeTypesChats), - RelaySetupInfo("wss://nostr-pub.wellorder.net", read = true, write = true, feedTypes = activeTypesChats), - RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypesChats), - RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypesChats), - - // Less Reliable - // NewRelayListViewModel.Relay("wss://nostr.orangepill.dev", read = true, write = true, feedTypes = activeTypes), - // NewRelayListViewModel.Relay("wss://nostr.onsats.org", read = true, write = true, feedTypes = activeTypes), - // NewRelayListViewModel.Relay("wss://nostr.sandwich.farm", read = true, write = true, feedTypes = activeTypes), - // NewRelayListViewModel.Relay("wss://relay.nostr.ch", read = true, write = true, feedTypes = activeTypes), - // NewRelayListViewModel.Relay("wss://nostr.zebedee.cloud", read = true, write = true, feedTypes = activeTypes), - // NewRelayListViewModel.Relay("wss://nostr.rocks", read = true, write = true, feedTypes = activeTypes), - // NewRelayListViewModel.Relay("wss://nostr.fmt.wiz.biz", read = true, write = true, feedTypes = activeTypes), - // NewRelayListViewModel.Relay("wss://brb.io", read = true, write = true, feedTypes = activeTypes), - - // Paid relays - RelaySetupInfo("wss://relay.snort.social", read = true, write = false, feedTypes = activeTypesGlobalChats), - RelaySetupInfo("wss://relay.nostr.com.au", read = true, write = false, feedTypes = activeTypesGlobalChats), - RelaySetupInfo("wss://eden.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats), - RelaySetupInfo("wss://nostr.milou.lol", read = true, write = false, feedTypes = activeTypesGlobalChats), - RelaySetupInfo("wss://puravida.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats), - RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesGlobalChats), - RelaySetupInfo("wss://nostr.inosta.cc", read = true, write = false, feedTypes = activeTypesGlobalChats), - RelaySetupInfo("wss://atlas.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats), - RelaySetupInfo("wss://relay.orangepill.dev", read = true, write = false, feedTypes = activeTypesGlobalChats), - RelaySetupInfo("wss://relay.nostrati.com", read = true, write = false, feedTypes = activeTypesGlobalChats), - - // Supporting NIP-50 - RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch), - RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch), - RelaySetupInfo("wss://relay.noswhere.com", read = true, write = false, feedTypes = activeTypesSearch) + val defaultRelays = + arrayOf( + // Free relays for only DMs and Follows due to the amount of spam + RelaySetupInfo("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes), + // Chats + RelaySetupInfo( + "wss://nostr.bitcoiner.social", + read = true, + write = true, + feedTypes = activeTypesChats, + ), + RelaySetupInfo( + "wss://relay.nostr.bg", + read = true, + write = true, + feedTypes = activeTypesChats, + ), + RelaySetupInfo( + "wss://nostr.oxtr.dev", + read = true, + write = true, + feedTypes = activeTypesChats, + ), + RelaySetupInfo( + "wss://nostr-pub.wellorder.net", + read = true, + write = true, + feedTypes = activeTypesChats, + ), + RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypesChats), + RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypesChats), + // Less Reliable + // NewRelayListViewModel.Relay("wss://nostr.orangepill.dev", read = true, write = true, + // feedTypes = activeTypes), + // NewRelayListViewModel.Relay("wss://nostr.onsats.org", read = true, write = true, feedTypes + // = activeTypes), + // NewRelayListViewModel.Relay("wss://nostr.sandwich.farm", read = true, write = true, + // feedTypes = activeTypes), + // NewRelayListViewModel.Relay("wss://relay.nostr.ch", read = true, write = true, feedTypes = + // activeTypes), + // NewRelayListViewModel.Relay("wss://nostr.zebedee.cloud", read = true, write = true, + // feedTypes = activeTypes), + // NewRelayListViewModel.Relay("wss://nostr.rocks", read = true, write = true, feedTypes = + // activeTypes), + // NewRelayListViewModel.Relay("wss://nostr.fmt.wiz.biz", read = true, write = true, feedTypes + // = activeTypes), + // NewRelayListViewModel.Relay("wss://brb.io", read = true, write = true, feedTypes = + // activeTypes), + // Paid relays + RelaySetupInfo( + "wss://relay.snort.social", + read = true, + write = false, + feedTypes = activeTypesGlobalChats, + ), + RelaySetupInfo( + "wss://relay.nostr.com.au", + read = true, + write = false, + feedTypes = activeTypesGlobalChats, + ), + RelaySetupInfo( + "wss://eden.nostr.land", + read = true, + write = false, + feedTypes = activeTypesGlobalChats, + ), + RelaySetupInfo( + "wss://nostr.milou.lol", + read = true, + write = false, + feedTypes = activeTypesGlobalChats, + ), + RelaySetupInfo( + "wss://puravida.nostr.land", + read = true, + write = false, + feedTypes = activeTypesGlobalChats, + ), + RelaySetupInfo( + "wss://nostr.wine", + read = true, + write = false, + feedTypes = activeTypesGlobalChats, + ), + RelaySetupInfo( + "wss://nostr.inosta.cc", + read = true, + write = false, + feedTypes = activeTypesGlobalChats, + ), + RelaySetupInfo( + "wss://atlas.nostr.land", + read = true, + write = false, + feedTypes = activeTypesGlobalChats, + ), + RelaySetupInfo( + "wss://relay.orangepill.dev", + read = true, + write = false, + feedTypes = activeTypesGlobalChats, + ), + RelaySetupInfo( + "wss://relay.nostrati.com", + read = true, + write = false, + feedTypes = activeTypesGlobalChats, + ), + // Supporting NIP-50 + RelaySetupInfo( + "wss://relay.nostr.band", + read = true, + write = false, + feedTypes = activeTypesSearch, + ), + RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch), + RelaySetupInfo( + "wss://relay.noswhere.com", + read = true, + write = false, + feedTypes = activeTypesSearch, + ), ) - val forcedRelayForSearch = arrayOf( - RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch), - RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch), - RelaySetupInfo("wss://relay.noswhere.com", read = true, write = false, feedTypes = activeTypesSearch) + val forcedRelayForSearch = + arrayOf( + RelaySetupInfo( + "wss://relay.nostr.band", + read = true, + write = false, + feedTypes = activeTypesSearch, + ), + RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch), + RelaySetupInfo( + "wss://relay.noswhere.com", + read = true, + write = false, + feedTypes = activeTypesSearch, + ), ) - val forcedRelaysForSearchSet = forcedRelayForSearch.map { it.url } + val forcedRelaysForSearchSet = forcedRelayForSearch.map { it.url } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt index 6c5d4bb1d..431d4edc7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt @@ -1,50 +1,82 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service.relays import com.vitorpamplona.amethyst.model.User class EOSETime(var time: Long) { - override fun toString(): String { - return time.toString() - } + override fun toString(): String { + return time.toString() + } } class EOSERelayList(var relayList: Map = emptyMap()) { - fun addOrUpdate(relayUrl: String, time: Long) { - val eose = relayList[relayUrl] - if (eose == null) { - relayList = relayList + Pair(relayUrl, EOSETime(time)) - } else { - eose.time = time - } + fun addOrUpdate( + relayUrl: String, + time: Long, + ) { + val eose = relayList[relayUrl] + if (eose == null) { + relayList = relayList + Pair(relayUrl, EOSETime(time)) + } else { + eose.time = time } + } } class EOSEFollowList(var followList: Map = emptyMap()) { - fun addOrUpdate(listCode: String, relayUrl: String, time: Long) { - val relayList = followList[listCode] - if (relayList == null) { - val newList = EOSERelayList() - newList.addOrUpdate(relayUrl, time) - followList = followList + mapOf(listCode to newList) - } else { - relayList.addOrUpdate(relayUrl, time) - } + fun addOrUpdate( + listCode: String, + relayUrl: String, + time: Long, + ) { + val relayList = followList[listCode] + if (relayList == null) { + val newList = EOSERelayList() + newList.addOrUpdate(relayUrl, time) + followList = followList + mapOf(listCode to newList) + } else { + relayList.addOrUpdate(relayUrl, time) } + } } class EOSEAccount(var users: Map = emptyMap()) { - fun addOrUpdate(user: User, listCode: String, relayUrl: String, time: Long) { - val followList = users[user] - if (followList == null) { - val newList = EOSEFollowList() - newList.addOrUpdate(listCode, relayUrl, time) - users = users + mapOf(user to newList) - } else { - followList.addOrUpdate(listCode, relayUrl, time) - } + fun addOrUpdate( + user: User, + listCode: String, + relayUrl: String, + time: Long, + ) { + val followList = users[user] + if (followList == null) { + val newList = EOSEFollowList() + newList.addOrUpdate(listCode, relayUrl, time) + users = users + mapOf(user to newList) + } else { + followList.addOrUpdate(listCode, relayUrl, time) } + } - fun removeDataFor(user: User) { - users = users.minus(user) - } + fun removeDataFor(user: User) { + users = users.minus(user) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/JsonFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/JsonFilter.kt index 16a97086d..91105f507 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/JsonFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/JsonFilter.kt @@ -1,75 +1,87 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service.relays import com.vitorpamplona.quartz.events.Event class JsonFilter( - val ids: List? = null, - val authors: List? = null, - val kinds: List? = null, - val tags: Map>? = null, - val since: Map? = null, - val until: Long? = null, - val limit: Int? = null, - val search: String? = null + val ids: List? = null, + val authors: List? = null, + val kinds: List? = null, + val tags: Map>? = null, + val since: Map? = null, + val until: Long? = null, + val limit: Int? = null, + val search: String? = null, ) { - - fun toJson(forRelay: String? = null): String { - val factory = Event.mapper.nodeFactory - val filter = factory.objectNode().apply { - ids?.run { - put( - "ids", - factory.arrayNode(ids.size).apply { - ids.forEach { add(it) } - } - ) - } - authors?.run { - put( - "authors", - factory.arrayNode(authors.size).apply { - authors.forEach { add(it) } - } - ) - } - kinds?.run { - put( - "kinds", - factory.arrayNode(kinds.size).apply { - kinds.forEach { add(it) } - } - ) - } - tags?.run { - entries.forEach { kv -> - put( - "#${kv.key}", - factory.arrayNode(kv.value.size).apply { - kv.value.forEach { add(it) } - } - ) - } - } - since?.run { - if (!isEmpty()) { - if (forRelay != null) { - val relaySince = get(forRelay) - if (relaySince != null) { - put("since", relaySince.time) - } - } else { - val jsonObjectSince = factory.objectNode() - entries.forEach { sincePairs -> - jsonObjectSince.put(sincePairs.key, "${sincePairs.value}") - } - put("since", jsonObjectSince) - } - } - } - until?.run { put("until", until) } - limit?.run { put("limit", limit) } - search?.run { put("search", search) } + fun toJson(forRelay: String? = null): String { + val factory = Event.mapper.nodeFactory + val filter = + factory.objectNode().apply { + ids?.run { + put( + "ids", + factory.arrayNode(ids.size).apply { ids.forEach { add(it) } }, + ) } - return Event.mapper.writeValueAsString(filter) - } + authors?.run { + put( + "authors", + factory.arrayNode(authors.size).apply { authors.forEach { add(it) } }, + ) + } + kinds?.run { + put( + "kinds", + factory.arrayNode(kinds.size).apply { kinds.forEach { add(it) } }, + ) + } + tags?.run { + entries.forEach { kv -> + put( + "#${kv.key}", + factory.arrayNode(kv.value.size).apply { kv.value.forEach { add(it) } }, + ) + } + } + since?.run { + if (!isEmpty()) { + if (forRelay != null) { + val relaySince = get(forRelay) + if (relaySince != null) { + put("since", relaySince.time) + } + } else { + val jsonObjectSince = factory.objectNode() + entries.forEach { sincePairs -> + jsonObjectSince.put(sincePairs.key, "${sincePairs.value}") + } + put("since", jsonObjectSince) + } + } + } + until?.run { put("until", until) } + limit?.run { put("limit", limit) } + search?.run { put("search", search) } + } + return Event.mapper.writeValueAsString(filter) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt index 3f419e3a4..f1585705f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service.relays import android.util.Log @@ -11,439 +31,501 @@ import com.vitorpamplona.quartz.events.EventInterface import com.vitorpamplona.quartz.events.RelayAuthEvent import com.vitorpamplona.quartz.events.bytesUsedInMemory import com.vitorpamplona.quartz.utils.TimeUtils +import java.lang.StringBuilder +import java.util.concurrent.atomic.AtomicBoolean import okhttp3.Request import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener -import java.lang.StringBuilder -import java.util.concurrent.atomic.AtomicBoolean enum class FeedType { - FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH, WALLET_CONNECT + FOLLOWS, + PUBLIC_CHATS, + PRIVATE_DMS, + GLOBAL, + SEARCH, + WALLET_CONNECT, } -val COMMON_FEED_TYPES = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL) +val COMMON_FEED_TYPES = + setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL) class Relay( - val url: String, - val read: Boolean = true, - val write: Boolean = true, - val activeTypes: Set = FeedType.values().toSet() + val url: String, + val read: Boolean = true, + val write: Boolean = true, + val activeTypes: Set = FeedType.values().toSet(), ) { - val brief = RelayBriefInfoCache.get(url) + val brief = RelayBriefInfoCache.get(url) - companion object { - // waits 3 minutes to reconnect once things fail - const val RECONNECTING_IN_SECONDS = 60 * 3 + companion object { + // waits 3 minutes to reconnect once things fail + const val RECONNECTING_IN_SECONDS = 60 * 3 + } + + private val httpClient = HttpClient.getHttpClientForRelays() + + private var listeners = setOf() + private var socket: WebSocket? = null + private var isReady: Boolean = false + private var usingCompression: Boolean = false + + var eventDownloadCounterInBytes = 0 + var eventUploadCounterInBytes = 0 + + var spamCounter = 0 + var errorCounter = 0 + var pingInMs: Long? = null + + var closingTimeInSeconds = 0L + + var afterEOSEPerSubscription = mutableMapOf() + + val authResponse = mutableMapOf() + + fun register(listener: Listener) { + listeners = listeners.plus(listener) + } + + fun unregister(listener: Listener) { + listeners = listeners.minus(listener) + } + + fun isConnected(): Boolean { + return socket != null + } + + fun connect() { + connectAndRun { + checkNotInMainThread() + + // Sends everything. + renewFilters() + } + } + + private var connectingBlock = AtomicBoolean() + + fun connectAndRun(onConnected: (Relay) -> Unit) { + Log.d("Relay", "Relay.connect $url") + // BRB is crashing OkHttp Deflater object :( + if (url.contains("brb.io")) return + + // If there is a connection, don't wait. + if (connectingBlock.getAndSet(true)) { + return } - private val httpClient = HttpClient.getHttpClientForRelays() + checkNotInMainThread() - private var listeners = setOf() - private var socket: WebSocket? = null - private var isReady: Boolean = false - private var usingCompression: Boolean = false + if (socket != null) return - var eventDownloadCounterInBytes = 0 - var eventUploadCounterInBytes = 0 + try { + val request = + Request.Builder() + .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .url(url.trim()) + .build() - var spamCounter = 0 - var errorCounter = 0 - var pingInMs: Long? = null + socket = httpClient.newWebSocket(request, RelayListener(onConnected)) + } catch (e: Exception) { + errorCounter++ + markConnectionAsClosed() + Log.e("Relay", "Relay Invalid $url") + e.printStackTrace() + } finally { + connectingBlock.set(false) + } + } - var closingTimeInSeconds = 0L + inner class RelayListener(val onConnected: (Relay) -> Unit) : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) { + checkNotInMainThread() + Log.d("Relay", "Connect onOpen $url $socket") - var afterEOSEPerSubscription = mutableMapOf() + markConnectionAsReady( + pingInMs = response.receivedResponseAtMillis - response.sentRequestAtMillis, + usingCompression = + response.headers.get("Sec-WebSocket-Extensions")?.contains("permessage-deflate") ?: false, + ) - val authResponse = mutableMapOf() + // Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url") + onConnected(this@Relay) - fun register(listener: Listener) { - listeners = listeners.plus(listener) + listeners.forEach { it.onRelayStateChange(this@Relay, StateType.CONNECT, null) } } - fun unregister(listener: Listener) { - listeners = listeners.minus(listener) + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + checkNotInMainThread() + + eventDownloadCounterInBytes += text.bytesUsedInMemory() + + try { + processNewRelayMessage(text) + } catch (t: Throwable) { + t.printStackTrace() + text.chunked(2000) { chunked -> + listeners.forEach { it.onError(this@Relay, "", Error("Problem with $chunked")) } + } + } } - fun isConnected(): Boolean { - return socket != null + override fun onClosing( + webSocket: WebSocket, + code: Int, + reason: String, + ) { + checkNotInMainThread() + + Log.w("Relay", "Relay onClosing $url: $reason") + + listeners.forEach { + it.onRelayStateChange( + this@Relay, + StateType.DISCONNECTING, + null, + ) + } } - fun connect() { - connectAndRun { - checkNotInMainThread() + override fun onClosed( + webSocket: WebSocket, + code: Int, + reason: String, + ) { + checkNotInMainThread() - // Sends everything. - renewFilters() + markConnectionAsClosed() + + Log.w("Relay", "Relay onClosed $url: $reason") + + listeners.forEach { it.onRelayStateChange(this@Relay, StateType.DISCONNECT, null) } + } + + override fun onFailure( + webSocket: WebSocket, + t: Throwable, + response: Response?, + ) { + checkNotInMainThread() + + errorCounter++ + + socket?.cancel() // 1000, "Normal close" + // Failures disconnect the relay. + markConnectionAsClosed() + + Log.w("Relay", "Relay onFailure $url, ${response?.message} $response") + t.printStackTrace() + listeners.forEach { + it.onError( + this@Relay, + "", + Error("WebSocket Failure. Response: $response. Exception: ${t.message}", t), + ) + } + } + } + + fun markConnectionAsReady( + pingInMs: Long, + usingCompression: Boolean, + ) { + this.resetEOSEStatuses() + this.isReady = true + this.pingInMs = pingInMs + this.usingCompression = usingCompression + } + + fun markConnectionAsClosed() { + this.socket = null + this.isReady = false + this.usingCompression = false + this.resetEOSEStatuses() + this.closingTimeInSeconds = TimeUtils.now() + } + + fun processNewRelayMessage(newMessage: String) { + val msgArray = Event.mapper.readTree(newMessage) + + when (val type = msgArray.get(0).asText()) { + "EVENT" -> { + val subscriptionId = msgArray.get(1).asText() + val event = Event.fromJson(msgArray.get(2)) + + // Log.w("Relay", "Relay onEVENT ${event.kind} $url, $subscriptionId ${msgArray.get(2)}") + listeners.forEach { + it.onEvent( + this@Relay, + subscriptionId, + event, + afterEOSEPerSubscription[subscriptionId] == true, + ) + } + } + "EOSE" -> + listeners.forEach { + val subscriptionId = msgArray.get(1).asText() + + afterEOSEPerSubscription[subscriptionId] = true + // Log.w("Relay", "Relay onEOSE $url $subscriptionId") + it.onRelayStateChange(this@Relay, StateType.EOSE, subscriptionId) + } + "NOTICE" -> + listeners.forEach { + val message = msgArray.get(1).asText() + Log.w("Relay", "Relay onNotice $url, $message") + + it.onError(this@Relay, message, Error("Relay sent notice: $message")) + } + "OK" -> + listeners.forEach { + val eventId = msgArray[1].asText() + val success = msgArray[2].asBoolean() + val message = if (msgArray.size() > 2) msgArray[3].asText() else "" + + if (authResponse.containsKey(eventId)) { + val wasAlreadyAuthenticated = authResponse.get(eventId) + authResponse.put(eventId, success) + if (wasAlreadyAuthenticated != true && success) { + renewFilters() + } + } + + Log.w("Relay", "Relay on OK $url, $eventId, $success, $message") + it.onSendResponse(this@Relay, eventId, success, message) + } + "AUTH" -> + listeners.forEach { + // Log.w("Relay", "Relay onAuth $url, ${msg[1].asString}") + it.onAuth(this@Relay, msgArray[1].asText()) + } + "NOTIFY" -> + listeners.forEach { + // Log.w("Relay", "Relay onNotify $url, ${msg[1].asString}") + it.onNotify(this@Relay, msgArray[1].asText()) + } + "CLOSED" -> listeners.forEach { Log.w("Relay", "Relay onClosed $url, $newMessage") } + else -> + listeners.forEach { + Log.w("Relay", "Unsupported message: $newMessage") + it.onError( + this@Relay, + "", + Error("Unknown type $type on channel. Msg was $newMessage"), + ) } } + } - private var connectingBlock = AtomicBoolean() + fun disconnect() { + Log.d("Relay", "Relay.disconnect $url") + checkNotInMainThread() - fun connectAndRun(onConnected: (Relay) -> Unit) { - Log.d("Relay", "Relay.connect $url") - // BRB is crashing OkHttp Deflater object :( - if (url.contains("brb.io")) return + closingTimeInSeconds = TimeUtils.now() + socket?.cancel() + socket = null + isReady = false + usingCompression = false + resetEOSEStatuses() + } - // If there is a connection, don't wait. - if (connectingBlock.getAndSet(true)) { - return + fun resetEOSEStatuses() { + afterEOSEPerSubscription = LinkedHashMap(afterEOSEPerSubscription.size) + } + + fun sendFilter(requestId: String) { + checkNotInMainThread() + + if (read) { + if (isConnected()) { + if (isReady) { + val filters = + Client.getSubscriptionFilters(requestId).filter { filter -> + activeTypes.any { it in filter.types } + } + if (filters.isNotEmpty()) { + val request = + filters.joinToStringLimited( + separator = ",", + limit = 20, + prefix = """["REQ","$requestId",""", + postfix = "]", + ) { + it.filter.toJson(url) + } + + // Log.d("Relay", "onFilterSent $url $requestId $request") + + socket?.send(request) + eventUploadCounterInBytes += request.bytesUsedInMemory() + resetEOSEStatuses() + } } - - checkNotInMainThread() - - if (socket != null) return - - try { - val request = Request.Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") - .url(url.trim()) - .build() - - socket = httpClient.newWebSocket(request, RelayListener(onConnected)) - } catch (e: Exception) { - errorCounter++ - markConnectionAsClosed() - Log.e("Relay", "Relay Invalid $url") - e.printStackTrace() - } finally { - connectingBlock.set(false) + } else { + // waits 60 seconds to reconnect after disconnected. + if (TimeUtils.now() > closingTimeInSeconds + RECONNECTING_IN_SECONDS) { + // sends all filters after connection is successful. + connect() } + } } + } - inner class RelayListener(val onConnected: (Relay) -> Unit) : WebSocketListener() { - override fun onOpen(webSocket: WebSocket, response: Response) { - checkNotInMainThread() - Log.d("Relay", "Connect onOpen $url $socket") - - markConnectionAsReady( - pingInMs = response.receivedResponseAtMillis - response.sentRequestAtMillis, - usingCompression = response.headers.get("Sec-WebSocket-Extensions")?.contains("permessage-deflate") ?: false - ) - - // Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url") - onConnected(this@Relay) - - listeners.forEach { it.onRelayStateChange(this@Relay, StateType.CONNECT, null) } - } - - override fun onMessage(webSocket: WebSocket, text: String) { - checkNotInMainThread() - - eventDownloadCounterInBytes += text.bytesUsedInMemory() - - try { - processNewRelayMessage(text) - } catch (t: Throwable) { - t.printStackTrace() - text.chunked(2000) { chunked -> - listeners.forEach { it.onError(this@Relay, "", Error("Problem with $chunked")) } - } - } - } - - override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { - checkNotInMainThread() - - Log.w("Relay", "Relay onClosing $url: $reason") - - listeners.forEach { - it.onRelayStateChange( - this@Relay, - StateType.DISCONNECTING, - null - ) - } - } - - override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { - checkNotInMainThread() - - markConnectionAsClosed() - - Log.w("Relay", "Relay onClosed $url: $reason") - - listeners.forEach { it.onRelayStateChange(this@Relay, StateType.DISCONNECT, null) } - } - - override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { - checkNotInMainThread() - - errorCounter++ - - socket?.cancel() // 1000, "Normal close" - // Failures disconnect the relay. - markConnectionAsClosed() - - Log.w("Relay", "Relay onFailure $url, ${response?.message} $response") - t.printStackTrace() - listeners.forEach { - it.onError(this@Relay, "", Error("WebSocket Failure. Response: $response. Exception: ${t.message}", t)) - } + fun Iterable.joinToStringLimited( + separator: CharSequence = ", ", + prefix: CharSequence = "", + postfix: CharSequence = "", + limit: Int = -1, + transform: ((T) -> CharSequence)? = null, + ): String { + val buffer = StringBuilder() + buffer.append(prefix) + var count = 0 + for (element in this) { + if (limit < 0 || count <= limit) { + if (++count > 1) buffer.append(separator) + when { + transform != null -> buffer.append(transform(element)) + element is CharSequence? -> buffer.append(element) + element is Char -> buffer.append(element) + else -> buffer.append(element.toString()) } + } else { + break + } } + buffer.append(postfix) + return buffer.toString() + } - fun markConnectionAsReady(pingInMs: Long, usingCompression: Boolean) { - this.resetEOSEStatuses() - this.isReady = true - this.pingInMs = pingInMs - this.usingCompression = usingCompression + fun sendFilterOnlyIfDisconnected(subscriptionId: String) { + checkNotInMainThread() + + if (socket == null) { + // waits 60 seconds to reconnect after disconnected. + if (TimeUtils.now() > closingTimeInSeconds + RECONNECTING_IN_SECONDS) { + // println("sendfilter Only if Disconnected ${url} ") + connect() + } } + } - fun markConnectionAsClosed() { - this.socket = null - this.isReady = false - this.usingCompression = false - this.resetEOSEStatuses() - this.closingTimeInSeconds = TimeUtils.now() - } + fun renewFilters() { + // Force update all filters after AUTH. + Client.allSubscriptions().forEach { sendFilter(requestId = it) } + } - fun processNewRelayMessage(newMessage: String) { - val msgArray = Event.mapper.readTree(newMessage) + fun send(signedEvent: EventInterface) { + checkNotInMainThread() - when (val type = msgArray.get(0).asText()) { - "EVENT" -> { - val subscriptionId = msgArray.get(1).asText() - val event = Event.fromJson(msgArray.get(2)) - - // Log.w("Relay", "Relay onEVENT ${event.kind} $url, $subscriptionId ${msgArray.get(2)}") - listeners.forEach { - it.onEvent(this@Relay, subscriptionId, event, afterEOSEPerSubscription[subscriptionId] == true) - } - } - "EOSE" -> listeners.forEach { - val subscriptionId = msgArray.get(1).asText() - - afterEOSEPerSubscription[subscriptionId] = true - // Log.w("Relay", "Relay onEOSE $url $subscriptionId") - it.onRelayStateChange(this@Relay, StateType.EOSE, subscriptionId) - } - "NOTICE" -> listeners.forEach { - val message = msgArray.get(1).asText() - Log.w("Relay", "Relay onNotice $url, $message") - - it.onError(this@Relay, message, Error("Relay sent notice: $message")) - } - "OK" -> listeners.forEach { - val eventId = msgArray[1].asText() - val success = msgArray[2].asBoolean() - val message = if (msgArray.size() > 2) msgArray[3].asText() else "" - - if (authResponse.containsKey(eventId)) { - val wasAlreadyAuthenticated = authResponse.get(eventId) - authResponse.put(eventId, success) - if (wasAlreadyAuthenticated != true && success) { - renewFilters() - } - } - - Log.w("Relay", "Relay on OK $url, $eventId, $success, $message") - it.onSendResponse(this@Relay, eventId, success, message) - } - "AUTH" -> listeners.forEach { - // Log.w("Relay", "Relay onAuth $url, ${msg[1].asString}") - it.onAuth(this@Relay, msgArray[1].asText()) - } - "NOTIFY" -> listeners.forEach { - // Log.w("Relay", "Relay onNotify $url, ${msg[1].asString}") - it.onNotify(this@Relay, msgArray[1].asText()) - } - "CLOSED" -> listeners.forEach { - Log.w("Relay", "Relay onClosed $url, $newMessage") - } - else -> listeners.forEach { - Log.w("Relay", "Unsupported message: $newMessage") - it.onError( - this@Relay, - "", - Error("Unknown type $type on channel. Msg was $newMessage") - ) - } - } - } - - fun disconnect() { - Log.d("Relay", "Relay.disconnect $url") - checkNotInMainThread() - - closingTimeInSeconds = TimeUtils.now() - socket?.cancel() - socket = null - isReady = false - usingCompression = false - resetEOSEStatuses() - } - - fun resetEOSEStatuses() { - afterEOSEPerSubscription = LinkedHashMap(afterEOSEPerSubscription.size) - } - - fun sendFilter(requestId: String) { - checkNotInMainThread() - - if (read) { - if (isConnected()) { - if (isReady) { - val filters = Client.getSubscriptionFilters(requestId).filter { filter -> - activeTypes.any { it in filter.types } - } - if (filters.isNotEmpty()) { - val request = filters.joinToStringLimited( - separator = ",", - limit = 20, - prefix = """["REQ","$requestId",""", - postfix = "]" - ) { it.filter.toJson(url) } - - // Log.d("Relay", "onFilterSent $url $requestId $request") - - socket?.send(request) - eventUploadCounterInBytes += request.bytesUsedInMemory() - resetEOSEStatuses() - } - } - } else { - // waits 60 seconds to reconnect after disconnected. - if (TimeUtils.now() > closingTimeInSeconds + RECONNECTING_IN_SECONDS) { - // sends all filters after connection is successful. - connect() - } - } - } - } - - fun Iterable.joinToStringLimited( - separator: CharSequence = ", ", - prefix: CharSequence = "", - postfix: CharSequence = "", - limit: Int = -1, - transform: ((T) -> CharSequence)? = null - ): String { - val buffer = StringBuilder() - buffer.append(prefix) - var count = 0 - for (element in this) { - if (limit < 0 || count <= limit) { - if (++count > 1) buffer.append(separator) - when { - transform != null -> buffer.append(transform(element)) - element is CharSequence? -> buffer.append(element) - element is Char -> buffer.append(element) - else -> buffer.append(element.toString()) - } - } else { - break - } - } - buffer.append(postfix) - return buffer.toString() - } - - fun sendFilterOnlyIfDisconnected(subscriptionId: String) { - checkNotInMainThread() - - if (socket == null) { - // waits 60 seconds to reconnect after disconnected. - if (TimeUtils.now() > closingTimeInSeconds + RECONNECTING_IN_SECONDS) { - // println("sendfilter Only if Disconnected ${url} ") - connect() - } - } - } - - fun renewFilters() { - // Force update all filters after AUTH. - Client.allSubscriptions().forEach { - sendFilter(requestId = it) - } - } - - fun send(signedEvent: EventInterface) { - checkNotInMainThread() - - if (signedEvent is RelayAuthEvent) { - authResponse.put(signedEvent.id, false) - // specific protocol for this event. - val event = """["AUTH",${signedEvent.toJson()}]""" + if (signedEvent is RelayAuthEvent) { + authResponse.put(signedEvent.id, false) + // specific protocol for this event. + val event = """["AUTH",${signedEvent.toJson()}]""" + socket?.send(event) + eventUploadCounterInBytes += event.bytesUsedInMemory() + } else { + if (write) { + val event = """["EVENT",${signedEvent.toJson()}]""" + if (isConnected()) { + if (isReady) { socket?.send(event) eventUploadCounterInBytes += event.bytesUsedInMemory() + } } else { - if (write) { - val event = """["EVENT",${signedEvent.toJson()}]""" - if (isConnected()) { - if (isReady) { - socket?.send(event) - eventUploadCounterInBytes += event.bytesUsedInMemory() - } - } else { - // sends all filters after connection is successful. - connectAndRun { - checkNotInMainThread() + // sends all filters after connection is successful. + connectAndRun { + checkNotInMainThread() - socket?.send(event) - eventUploadCounterInBytes += event.bytesUsedInMemory() + socket?.send(event) + eventUploadCounterInBytes += event.bytesUsedInMemory() - // Sends everything. - Client.allSubscriptions().forEach { - sendFilter(requestId = it) - } - } - } - } + // Sends everything. + Client.allSubscriptions().forEach { sendFilter(requestId = it) } + } } + } } + } - fun close(subscriptionId: String) { - checkNotInMainThread() + fun close(subscriptionId: String) { + checkNotInMainThread() - val msg = """["CLOSE","$subscriptionId"]""" - // Log.d("Relay", "Close Subscription $url $msg") - socket?.send(msg) - } + val msg = """["CLOSE","$subscriptionId"]""" + // Log.d("Relay", "Close Subscription $url $msg") + socket?.send(msg) + } - fun isSameRelayConfig(other: Relay): Boolean { - return url == other.url && - write == other.write && - read == other.read && - activeTypes == other.activeTypes - } + fun isSameRelayConfig(other: Relay): Boolean { + return url == other.url && + write == other.write && + read == other.read && + activeTypes == other.activeTypes + } - enum class StateType { - // Websocket connected - CONNECT, + enum class StateType { + // Websocket connected + CONNECT, - // Websocket disconnecting - DISCONNECTING, + // Websocket disconnecting + DISCONNECTING, - // Websocket disconnected - DISCONNECT, + // Websocket disconnected + DISCONNECT, - // End Of Stored Events - EOSE - } + // End Of Stored Events + EOSE, + } - interface Listener { - /** - * A new message was received - */ - fun onEvent(relay: Relay, subscriptionId: String, event: Event, afterEOSE: Boolean) + interface Listener { + /** A new message was received */ + fun onEvent( + relay: Relay, + subscriptionId: String, + event: Event, + afterEOSE: Boolean, + ) - fun onError(relay: Relay, subscriptionId: String, error: Error) + fun onError( + relay: Relay, + subscriptionId: String, + error: Error, + ) - fun onSendResponse(relay: Relay, eventId: String, success: Boolean, message: String) + fun onSendResponse( + relay: Relay, + eventId: String, + success: Boolean, + message: String, + ) - fun onAuth(relay: Relay, challenge: String) + fun onAuth( + relay: Relay, + challenge: String, + ) - /** - * Connected to or disconnected from a relay - * - * @param type is 0 for disconnect and 1 for connect - */ - fun onRelayStateChange(relay: Relay, type: StateType, channel: String?) + /** + * Connected to or disconnected from a relay + * + * @param type is 0 for disconnect and 1 for connect + */ + fun onRelayStateChange( + relay: Relay, + type: StateType, + channel: String?, + ) - /** - * Relay sent an invoice - */ - fun onNotify(relay: Relay, description: String) - } + /** Relay sent an invoice */ + fun onNotify( + relay: Relay, + description: String, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt index 696c6025a..0d40a2898 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service.relays import androidx.compose.runtime.Immutable @@ -13,146 +33,200 @@ import kotlinx.coroutines.flow.asSharedFlow * RelayPool manages the connection to multiple Relays and lets consumers deal with simple events. */ object RelayPool : Relay.Listener { - private var relays = listOf() - private var listeners = setOf() + private var relays = listOf() + private var listeners = setOf() - // Backing property to avoid flow emissions from other classes - private var _lastStatus = RelayPoolStatus(0, 0) - private val _statusFlow = MutableSharedFlow(1, 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) - val statusFlow: SharedFlow = _statusFlow.asSharedFlow() + // Backing property to avoid flow emissions from other classes + private var lastStatus = RelayPoolStatus(0, 0) + private val _statusFlow = + MutableSharedFlow(1, 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val statusFlow: SharedFlow = _statusFlow.asSharedFlow() - fun availableRelays(): Int { - return relays.size + fun availableRelays(): Int { + return relays.size + } + + fun connectedRelays(): Int { + return relays.count { it.isConnected() } + } + + fun getRelay(url: String): Relay? { + return relays.firstOrNull { it.url == url } + } + + fun getRelays(url: String): List { + return relays.filter { it.url == url } + } + + fun loadRelays(relayList: List) { + if (!relayList.isNullOrEmpty()) { + relayList.forEach { addRelay(it) } + } else { + Constants.convertDefaultRelays().forEach { addRelay(it) } } + } - fun connectedRelays(): Int { - return relays.count { it.isConnected() } + fun unloadRelays() { + relays.forEach { it.unregister(this) } + relays = listOf() + } + + fun requestAndWatch() { + checkNotInMainThread() + + relays.forEach { it.connect() } + } + + fun sendFilter(subscriptionId: String) { + relays.forEach { it.sendFilter(subscriptionId) } + } + + fun sendFilterOnlyIfDisconnected(subscriptionId: String) { + relays.forEach { it.sendFilterOnlyIfDisconnected(subscriptionId) } + } + + fun sendToSelectedRelays( + list: List, + signedEvent: EventInterface, + ) { + list.forEach { relay -> relays.filter { it.url == relay.url }.forEach { it.send(signedEvent) } } + } + + fun send(signedEvent: EventInterface) { + relays.forEach { it.send(signedEvent) } + } + + fun close(subscriptionId: String) { + relays.forEach { it.close(subscriptionId) } + } + + fun disconnect() { + relays.forEach { it.disconnect() } + } + + fun addRelay(relay: Relay) { + relay.register(this) + relays += relay + updateStatus() + } + + fun removeRelay(relay: Relay) { + relay.unregister(this) + relays = relays.minus(relay) + updateStatus() + } + + fun register(listener: Listener) { + listeners = listeners.plus(listener) + } + + fun unregister(listener: Listener) { + listeners = listeners.minus(listener) + } + + interface Listener { + fun onEvent( + event: Event, + subscriptionId: String, + relay: Relay, + afterEOSE: Boolean, + ) + + fun onError( + error: Error, + subscriptionId: String, + relay: Relay, + ) + + fun onRelayStateChange( + type: Relay.StateType, + relay: Relay, + channel: String?, + ) + + fun onSendResponse( + eventId: String, + success: Boolean, + message: String, + relay: Relay, + ) + + fun onAuth( + relay: Relay, + challenge: String, + ) + + fun onNotify( + relay: Relay, + description: String, + ) + } + + override fun onEvent( + relay: Relay, + subscriptionId: String, + event: Event, + afterEOSE: Boolean, + ) { + listeners.forEach { it.onEvent(event, subscriptionId, relay, afterEOSE) } + } + + override fun onError( + relay: Relay, + subscriptionId: String, + error: Error, + ) { + listeners.forEach { it.onError(error, subscriptionId, relay) } + updateStatus() + } + + override fun onRelayStateChange( + relay: Relay, + type: Relay.StateType, + channel: String?, + ) { + listeners.forEach { it.onRelayStateChange(type, relay, channel) } + if (type != Relay.StateType.EOSE) { + updateStatus() } + } - fun getRelay(url: String): Relay? { - return relays.firstOrNull() { it.url == url } - } - - fun getRelays(url: String): List { - return relays.filter { it.url == url } - } - - fun loadRelays(relayList: List) { - if (!relayList.isNullOrEmpty()) { - relayList.forEach { addRelay(it) } - } else { - Constants.convertDefaultRelays().forEach { addRelay(it) } - } - } - - fun unloadRelays() { - relays.forEach { it.unregister(this) } - relays = listOf() - } - - fun requestAndWatch() { - checkNotInMainThread() - - relays.forEach { it.connect() } - } - - fun sendFilter(subscriptionId: String) { - relays.forEach { it.sendFilter(subscriptionId) } - } - - fun sendFilterOnlyIfDisconnected(subscriptionId: String) { - relays.forEach { it.sendFilterOnlyIfDisconnected(subscriptionId) } - } - - fun sendToSelectedRelays(list: List, signedEvent: EventInterface) { - list.forEach { relay -> - relays.filter { it.url == relay.url }.forEach { it.send(signedEvent) } - } - } - - fun send(signedEvent: EventInterface) { - relays.forEach { it.send(signedEvent) } - } - - fun close(subscriptionId: String) { - relays.forEach { it.close(subscriptionId) } - } - - fun disconnect() { - relays.forEach { it.disconnect() } - } - - fun addRelay(relay: Relay) { - relay.register(this) - relays += relay - updateStatus() - } - - fun removeRelay(relay: Relay) { - relay.unregister(this) - relays = relays.minus(relay) - updateStatus() - } - - fun register(listener: Listener) { - listeners = listeners.plus(listener) - } - - fun unregister(listener: Listener) { - listeners = listeners.minus(listener) - } - - interface Listener { - fun onEvent(event: Event, subscriptionId: String, relay: Relay, afterEOSE: Boolean) - - fun onError(error: Error, subscriptionId: String, relay: Relay) - - fun onRelayStateChange(type: Relay.StateType, relay: Relay, channel: String?) - - fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) - - fun onAuth(relay: Relay, challenge: String) - - fun onNotify(relay: Relay, description: String) - } - - override fun onEvent(relay: Relay, subscriptionId: String, event: Event, afterEOSE: Boolean) { - listeners.forEach { it.onEvent(event, subscriptionId, relay, afterEOSE) } - } - - override fun onError(relay: Relay, subscriptionId: String, error: Error) { - listeners.forEach { it.onError(error, subscriptionId, relay) } - updateStatus() - } - - override fun onRelayStateChange(relay: Relay, type: Relay.StateType, channel: String?) { - listeners.forEach { it.onRelayStateChange(type, relay, channel) } - if (type != Relay.StateType.EOSE) { - updateStatus() - } - } - - override fun onSendResponse(relay: Relay, eventId: String, success: Boolean, message: String) { - listeners.forEach { it.onSendResponse(eventId, success, message, relay) } - } - - override fun onAuth(relay: Relay, challenge: String) { - listeners.forEach { it.onAuth(relay, challenge) } - } - - override fun onNotify(relay: Relay, description: String) { - listeners.forEach { it.onNotify(relay, description) } - } - - private fun updateStatus() { - val connected = connectedRelays() - val available = availableRelays() - if (_lastStatus.connected != connected || _lastStatus.available != available) { - _lastStatus = RelayPoolStatus(connected, available) - _statusFlow.tryEmit(_lastStatus) - } + override fun onSendResponse( + relay: Relay, + eventId: String, + success: Boolean, + message: String, + ) { + listeners.forEach { it.onSendResponse(eventId, success, message, relay) } + } + + override fun onAuth( + relay: Relay, + challenge: String, + ) { + listeners.forEach { it.onAuth(relay, challenge) } + } + + override fun onNotify( + relay: Relay, + description: String, + ) { + listeners.forEach { it.onNotify(relay, description) } + } + + private fun updateStatus() { + val connected = connectedRelays() + val available = availableRelays() + if (lastStatus.connected != connected || lastStatus.available != available) { + lastStatus = RelayPoolStatus(connected, available) + _statusFlow.tryEmit(lastStatus) } + } } @Immutable -data class RelayPoolStatus(val connected: Int, val available: Int, val isConnected: Boolean = connected > 0) +data class RelayPoolStatus( + val connected: Int, + val available: Int, + val isConnected: Boolean = connected > 0, +) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Subscription.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Subscription.kt index 50549afb4..d52bc7be9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Subscription.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Subscription.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service.relays import com.fasterxml.jackson.databind.JsonNode @@ -5,34 +25,35 @@ import com.vitorpamplona.quartz.events.Event import java.util.UUID data class Subscription( - val id: String = UUID.randomUUID().toString().substring(0, 4), - val onEOSE: ((Long, String) -> Unit)? = null + val id: String = UUID.randomUUID().toString().substring(0, 4), + val onEOSE: ((Long, String) -> Unit)? = null, ) { - var typedFilters: List? = null // Inactive when null + var typedFilters: List? = null // Inactive when null - fun updateEOSE(time: Long, relay: String) { - onEOSE?.let { it(time, relay) } - } - - fun toJson(): String { - return Event.mapper.writeValueAsString(toJsonObject()) - } - - fun toJsonObject(): JsonNode { - val factory = Event.mapper.nodeFactory - - return factory.objectNode().apply { - put("id", id) - typedFilters?.also { filters -> - put( - "typedFilters", - factory.arrayNode(filters.size).apply { - filters.forEach { filter -> - add(filter.toJsonObject()) - } - } - ) - } - } + fun updateEOSE( + time: Long, + relay: String, + ) { + onEOSE?.let { it(time, relay) } + } + + fun toJson(): String { + return Event.mapper.writeValueAsString(toJsonObject()) + } + + fun toJsonObject(): JsonNode { + val factory = Event.mapper.nodeFactory + + return factory.objectNode().apply { + put("id", id) + typedFilters?.also { filters -> + put( + "typedFilters", + factory.arrayNode(filters.size).apply { + filters.forEach { filter -> add(filter.toJsonObject()) } + }, + ) + } } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/TypedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/TypedFilter.kt index 75b11ad44..277256cc1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/TypedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/TypedFilter.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service.relays import com.fasterxml.jackson.databind.JsonNode @@ -5,79 +25,68 @@ import com.fasterxml.jackson.databind.node.ArrayNode import com.vitorpamplona.quartz.events.Event class TypedFilter( - val types: Set, - val filter: JsonFilter + val types: Set, + val filter: JsonFilter, ) { + fun toJson(): String { + return Event.mapper.writeValueAsString(toJsonObject()) + } - fun toJson(): String { - return Event.mapper.writeValueAsString(toJsonObject()) + fun toJsonObject(): JsonNode { + val factory = Event.mapper.nodeFactory + + return factory.objectNode().apply { + put("types", typesToJson(types)) + put("filter", filterToJson(filter)) } + } - fun toJsonObject(): JsonNode { - val factory = Event.mapper.nodeFactory + fun typesToJson(types: Set): ArrayNode { + val factory = Event.mapper.nodeFactory + return factory.arrayNode(types.size).apply { types.forEach { add(it.name.lowercase()) } } + } - return factory.objectNode().apply { - put("types", typesToJson(types)) - put("filter", filterToJson(filter)) - } - } - - fun typesToJson(types: Set): ArrayNode { - val factory = Event.mapper.nodeFactory - return factory.arrayNode(types.size).apply { - types.forEach { add(it.name.lowercase()) } - } - } - - fun filterToJson(filter: JsonFilter): JsonNode { - val factory = Event.mapper.nodeFactory - return factory.objectNode().apply { - filter.ids?.run { - put( - "ids", - factory.arrayNode(filter.ids.size).apply { - filter.ids.forEach { add(it) } - } - ) - } - filter.authors?.run { - put( - "authors", - factory.arrayNode(filter.authors.size).apply { - filter.authors.forEach { add(it) } - } - ) - } - filter.kinds?.run { - put( - "kinds", - factory.arrayNode(filter.kinds.size).apply { - filter.kinds.forEach { add(it) } - } - ) - } - filter.tags?.run { - entries.forEach { kv -> - put( - "#${kv.key}", - factory.arrayNode(kv.value.size).apply { - kv.value.forEach { add(it) } - } - ) - } - } - /* - Does not include since in the json comparison - filter.since?.run { - val jsonObjectSince = JsonObject() - entries.forEach { sincePairs -> - jsonObjectSince.addProperty(sincePairs.key, "${sincePairs.value}") - } - jsonObject.add("since", jsonObjectSince) - }*/ - filter.until?.run { put("until", filter.until) } - filter.limit?.run { put("limit", filter.limit) } - filter.search?.run { put("search", filter.search) } + fun filterToJson(filter: JsonFilter): JsonNode { + val factory = Event.mapper.nodeFactory + return factory.objectNode().apply { + filter.ids?.run { + put( + "ids", + factory.arrayNode(filter.ids.size).apply { filter.ids.forEach { add(it) } }, + ) + } + filter.authors?.run { + put( + "authors", + factory.arrayNode(filter.authors.size).apply { filter.authors.forEach { add(it) } }, + ) + } + filter.kinds?.run { + put( + "kinds", + factory.arrayNode(filter.kinds.size).apply { filter.kinds.forEach { add(it) } }, + ) + } + filter.tags?.run { + entries.forEach { kv -> + put( + "#${kv.key}", + factory.arrayNode(kv.value.size).apply { kv.value.forEach { add(it) } }, + ) } + } + /* + Does not include since in the json comparison + filter.since?.run { + val jsonObjectSince = JsonObject() + entries.forEach { sincePairs -> + jsonObjectSince.addProperty(sincePairs.key, "${sincePairs.value}") + } + jsonObject.add("since", jsonObjectSince) + }*/ + filter.until?.run { put("until", filter.until) } + filter.limit?.run { put("limit", filter.limit) } + filter.search?.run { put("search", filter.search) } } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechEngine.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechEngine.kt index 21b44bb26..52cad43ef 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechEngine.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechEngine.kt @@ -1,9 +1,23 @@ -/* - * Created by Ayaan on 02/02/23, 10:30 pm - * Copyright (c) 2023 . All rights reserved. - * Last modified 02/02/23, 9:56 pm +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - package com.vitorpamplona.amethyst.service.tts import android.content.Context @@ -13,7 +27,8 @@ import java.util.Locale const val DEF_SPEECH_AND_PITCH = 0.8f -fun getErrorText(errorCode: Int): String = when (errorCode) { +fun getErrorText(errorCode: Int): String = + when (errorCode) { TextToSpeech.ERROR -> "ERROR" TextToSpeech.ERROR_INVALID_REQUEST -> "ERROR_INVALID_REQUEST" TextToSpeech.ERROR_NETWORK -> "ERROR_NETWORK" @@ -22,143 +37,151 @@ fun getErrorText(errorCode: Int): String = when (errorCode) { TextToSpeech.ERROR_SYNTHESIS -> "ERROR_SYNTHESIS" TextToSpeech.ERROR_NOT_INSTALLED_YET -> "ERROR_NOT_INSTALLED_YET" else -> "UNKNOWN" -} + } class TextToSpeechEngine private constructor() { - private var tts: TextToSpeech? = null + private var tts: TextToSpeech? = null - private var defaultPitch = 0.8f - private var defaultSpeed = 0.8f - private var defLanguage = Locale.getDefault() - private var onStartListener: (() -> Unit)? = null - private var onDoneListener: (() -> Unit)? = null - private var onErrorListener: ((String) -> Unit)? = null - private var onHighlightListener: ((Int, Int) -> Unit)? = null - private var message: String? = null + private var defaultPitch = 0.8f + private var defaultSpeed = 0.8f + private var defLanguage = Locale.getDefault() + private var onStartListener: (() -> Unit)? = null + private var onDoneListener: (() -> Unit)? = null + private var onErrorListener: ((String) -> Unit)? = null + private var onHighlightListener: ((Int, Int) -> Unit)? = null + private var message: String? = null - companion object { - private var instance: TextToSpeechEngine? = null - fun getInstance(): TextToSpeechEngine { - if (instance == null) { - instance = TextToSpeechEngine() - } - return instance!! - } + companion object { + private var instance: TextToSpeechEngine? = null + + fun getInstance(): TextToSpeechEngine { + if (instance == null) { + instance = TextToSpeechEngine() + } + return instance!! } + } - fun initTTS(context: Context, message: String) { - tts = TextToSpeech(context) { - if (it == TextToSpeech.SUCCESS) { - tts?.let { - it.language = defLanguage - it.setPitch(defaultPitch) - it.setSpeechRate(defaultSpeed) - it.setListener( - onStart = { - onStartListener?.invoke() - }, - onError = { e -> - e?.let { error -> - onErrorListener?.invoke(error) - } - }, - onRange = { start, end -> - if (this@TextToSpeechEngine.message != null) { - onHighlightListener?.invoke(start, end) - } - }, - onDone = { - onStartListener?.invoke() - } - ) - speak(message) + fun initTTS( + context: Context, + message: String, + ) { + tts = + TextToSpeech(context) { + if (it == TextToSpeech.SUCCESS) { + tts?.let { + it.language = defLanguage + it.setPitch(defaultPitch) + it.setSpeechRate(defaultSpeed) + it.setListener( + onStart = { onStartListener?.invoke() }, + onError = { e -> e?.let { error -> onErrorListener?.invoke(error) } }, + onRange = { start, end -> + if (this@TextToSpeechEngine.message != null) { + onHighlightListener?.invoke(start, end) } - } else { - onErrorListener?.invoke(getErrorText(it)) - } + }, + onDone = { onStartListener?.invoke() }, + ) + speak(message) + } + } else { + onErrorListener?.invoke(getErrorText(it)) } - } + } + } - private fun speak(message: String): TextToSpeechEngine { - tts?.speak( - message, - TextToSpeech.QUEUE_FLUSH, - null, - TextToSpeech.ACTION_TTS_QUEUE_PROCESSING_COMPLETED - ) - return this - } + private fun speak(message: String): TextToSpeechEngine { + tts?.speak( + message, + TextToSpeech.QUEUE_FLUSH, + null, + TextToSpeech.ACTION_TTS_QUEUE_PROCESSING_COMPLETED, + ) + return this + } - fun setPitchAndSpeed(pitch: Float, speed: Float) { - defaultPitch = pitch - defaultSpeed = speed - } + fun setPitchAndSpeed( + pitch: Float, + speed: Float, + ) { + defaultPitch = pitch + defaultSpeed = speed + } - fun resetPitchAndSpeed() { - defaultPitch = DEF_SPEECH_AND_PITCH - defaultSpeed = DEF_SPEECH_AND_PITCH - } + fun resetPitchAndSpeed() { + defaultPitch = DEF_SPEECH_AND_PITCH + defaultSpeed = DEF_SPEECH_AND_PITCH + } - fun setLanguage(local: Locale): TextToSpeechEngine { - this.defLanguage = local - return this - } + fun setLanguage(local: Locale): TextToSpeechEngine { + this.defLanguage = local + return this + } - fun setHighlightedMessage(message: String) { - this.message = message - } + fun setHighlightedMessage(message: String) { + this.message = message + } - fun setOnStartListener(onStartListener: (() -> Unit)): TextToSpeechEngine { - this.onStartListener = onStartListener - return this - } + fun setOnStartListener(onStartListener: (() -> Unit)): TextToSpeechEngine { + this.onStartListener = onStartListener + return this + } - fun setOnCompletionListener(onDoneListener: () -> Unit): TextToSpeechEngine { - this.onDoneListener = onDoneListener - return this - } + fun setOnCompletionListener(onDoneListener: () -> Unit): TextToSpeechEngine { + this.onDoneListener = onDoneListener + return this + } - fun setOnErrorListener(onErrorListener: (String) -> Unit): TextToSpeechEngine { - this.onErrorListener = onErrorListener - return this - } + fun setOnErrorListener(onErrorListener: (String) -> Unit): TextToSpeechEngine { + this.onErrorListener = onErrorListener + return this + } - fun setOnHighlightListener(onHighlightListener: (Int, Int) -> Unit): TextToSpeechEngine { - this.onHighlightListener = onHighlightListener - return this - } + fun setOnHighlightListener(onHighlightListener: (Int, Int) -> Unit): TextToSpeechEngine { + this.onHighlightListener = onHighlightListener + return this + } - fun destroy() { - tts?.stop() - tts?.shutdown() - tts = null - instance = null - } + fun destroy() { + tts?.stop() + tts?.shutdown() + tts = null + instance = null + } } inline fun TextToSpeech.setListener( - crossinline onStart: (String?) -> Unit = {}, - crossinline onError: (String?) -> Unit = {}, - crossinline onRange: (Int, Int) -> Unit = { _, _ -> }, - crossinline onDone: (String?) -> Unit -) = this.apply { - setOnUtteranceProgressListener(object : UtteranceProgressListener() { + crossinline onStart: (String?) -> Unit = {}, + crossinline onError: (String?) -> Unit = {}, + crossinline onRange: (Int, Int) -> Unit = { _, _ -> }, + crossinline onDone: (String?) -> Unit, +) = + this.apply { + setOnUtteranceProgressListener( + object : UtteranceProgressListener() { override fun onStart(p0: String?) { - onStart.invoke(p0) + onStart.invoke(p0) } override fun onDone(p0: String?) { - onDone.invoke(p0) + onDone.invoke(p0) } @Deprecated("Deprecated in Java", ReplaceWith("onError.invoke(p0)")) override fun onError(p0: String?) { - onError.invoke(p0) + onError.invoke(p0) } - override fun onRangeStart(utteranceId: String?, start: Int, end: Int, frame: Int) { - super.onRangeStart(utteranceId, start, end, frame) - onRange.invoke(start, end) + override fun onRangeStart( + utteranceId: String?, + start: Int, + end: Int, + frame: Int, + ) { + super.onRangeStart(utteranceId, start, end, frame) + onRange.invoke(start, end) } - }) -} + }, + ) + } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechHelper.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechHelper.kt index 0526eefed..1a4274a55 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechHelper.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechHelper.kt @@ -1,9 +1,23 @@ -/* - * Created by Ayaan on 02/02/23, 10:30 pm - * Copyright (c) 2023 . All rights reserved. - * Last modified 02/02/23, 10:19 pm +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - package com.vitorpamplona.amethyst.service.tts import android.content.Context @@ -15,146 +29,149 @@ import java.lang.ref.WeakReference import java.util.Locale class TextToSpeechHelper private constructor(private val context: WeakReference) : - LifecycleEventObserver { - private val appContext - get() = context.get()!!.applicationContext + LifecycleEventObserver { + private val appContext + get() = context.get()!!.applicationContext - private var message: String? = null + private var message: String? = null - private var ttsEngine: TextToSpeechEngine? = null + private var ttsEngine: TextToSpeechEngine? = null - private var onStart: (() -> Unit)? = null + private var onStart: (() -> Unit)? = null - private var onDoneListener: (() -> Unit)? = null + private var onDoneListener: (() -> Unit)? = null - private var onErrorListener: ((String) -> Unit)? = null + private var onErrorListener: ((String) -> Unit)? = null - private var onHighlightListener: ((Pair) -> Unit)? = null + private var onHighlightListener: ((Pair) -> Unit)? = null - private var customActionForDestroy: (() -> Unit)? = null + private var customActionForDestroy: (() -> Unit)? = null - init { - Log.d("Init", "Init TTS") - initTTS() + init { + Log.d("Init", "Init TTS") + initTTS() + } + + fun registerLifecycle(owner: LifecycleOwner): TextToSpeechHelper { + owner.lifecycle.addObserver(this) + return this + } + + private fun initTTS() = + context.get()?.run { + ttsEngine = + TextToSpeechEngine.getInstance() + .setOnCompletionListener { onDoneListener?.invoke() } + .setOnErrorListener { onErrorListener?.invoke(it) } + .setOnStartListener { onStart?.invoke() } } - fun registerLifecycle(owner: LifecycleOwner): TextToSpeechHelper { - owner.lifecycle.addObserver(this) - return this + fun speak(message: String): TextToSpeechHelper { + if (ttsEngine == null) { + initTTS() } + this.message = message - private fun initTTS() = context.get()?.run { - ttsEngine = TextToSpeechEngine.getInstance() - .setOnCompletionListener { onDoneListener?.invoke() } - .setOnErrorListener { onErrorListener?.invoke(it) } - .setOnStartListener { onStart?.invoke() } + ttsEngine?.initTTS( + appContext, + message, + ) + return this + } + + /** + * This method will highlight the text in the textView + * + * @exception Exception("Message can't be null for highlighting !! Call speak() first") + */ + fun highlight(): TextToSpeechHelper { + if (message == null) { + throw Exception("Message can't be null for highlighting !! Call speak() first") } + ttsEngine?.setHighlightedMessage(message!!) + ttsEngine?.setOnHighlightListener { i, i2 -> onHighlightListener?.invoke(Pair(i, i2)) } + return this + } - fun speak(message: String): TextToSpeechHelper { - if (ttsEngine == null) { - initTTS() + fun removeHighlight(): TextToSpeechHelper { + message = null + onHighlightListener = null + return this + } + + fun destroy(action: (() -> Unit) = {}) { + ttsEngine?.destroy() + ttsEngine = null + action.invoke() + instance = null + } + + fun onStart(onStartListener: () -> Unit): TextToSpeechHelper { + this.onStart = onStartListener + return this + } + + fun onDone(onCompleteListener: () -> Unit): TextToSpeechHelper { + this.onDoneListener = onCompleteListener + return this + } + + fun onError(onErrorListener: (String) -> Unit): TextToSpeechHelper { + this.onErrorListener = onErrorListener + return this + } + + fun onHighlight(onHighlightListener: (Pair) -> Unit): TextToSpeechHelper { + this.onHighlightListener = onHighlightListener + return this + } + + fun setCustomActionForDestroy(action: () -> Unit): TextToSpeechHelper { + customActionForDestroy = action + return this + } + + fun setLanguage(locale: Locale): TextToSpeechHelper { + ttsEngine?.setLanguage(locale) + return this + } + + fun setPitchAndSpeed( + pitch: Float = DEF_SPEECH_AND_PITCH, + speed: Float = DEF_SPEECH_AND_PITCH, + ): TextToSpeechHelper { + ttsEngine?.setPitchAndSpeed(pitch, speed) + return this + } + + fun resetPitchAndSpeed(): TextToSpeechHelper { + ttsEngine?.resetPitchAndSpeed() + return this + } + + companion object { + private var instance: TextToSpeechHelper? = null + + fun getInstance(context: Context): TextToSpeechHelper { + synchronized(TextToSpeechHelper::class.java) { + if (instance == null) { + instance = TextToSpeechHelper(WeakReference(context)) } - this.message = message - - ttsEngine?.initTTS( - appContext, - message - ) - return this + return instance!! + } } + } - /** - * This method will highlight the text in the textView - * - * @exception Exception("Message can't be null for highlighting !! Call speak() first") - */ - fun highlight(): TextToSpeechHelper { - if (message == null) throw Exception("Message can't be null for highlighting !! Call speak() first") - ttsEngine?.setHighlightedMessage(message!!) - ttsEngine?.setOnHighlightListener { i, i2 -> - onHighlightListener?.invoke(Pair(i, i2)) - } - return this - } - - fun removeHighlight(): TextToSpeechHelper { - message = null - onHighlightListener = null - return this - } - - fun destroy( - action: (() -> Unit) = {} + override fun onStateChanged( + source: LifecycleOwner, + event: Lifecycle.Event, + ) { + if ( + event == Lifecycle.Event.ON_DESTROY || + event == Lifecycle.Event.ON_STOP || + event == Lifecycle.Event.ON_PAUSE ) { - ttsEngine?.destroy() - ttsEngine = null - action.invoke() - INSTANCE = null - } - - fun onStart(onStartListener: () -> Unit): TextToSpeechHelper { - this.onStart = onStartListener - return this - } - - fun onDone(onCompleteListener: () -> Unit): TextToSpeechHelper { - this.onDoneListener = onCompleteListener - return this - } - - fun onError(onErrorListener: (String) -> Unit): TextToSpeechHelper { - this.onErrorListener = onErrorListener - return this - } - - fun onHighlight(onHighlightListener: (Pair) -> Unit): TextToSpeechHelper { - this.onHighlightListener = onHighlightListener - return this - } - - fun setCustomActionForDestroy(action: () -> Unit): TextToSpeechHelper { - customActionForDestroy = action - return this - } - - fun setLanguage(locale: Locale): TextToSpeechHelper { - ttsEngine?.setLanguage(locale) - return this - } - - fun setPitchAndSpeed( - pitch: Float = DEF_SPEECH_AND_PITCH, - speed: Float = DEF_SPEECH_AND_PITCH - ): TextToSpeechHelper { - ttsEngine?.setPitchAndSpeed(pitch, speed) - return this - } - - fun resetPitchAndSpeed(): TextToSpeechHelper { - ttsEngine?.resetPitchAndSpeed() - return this - } - - companion object { - private var INSTANCE: TextToSpeechHelper? = null - fun getInstance(context: Context): TextToSpeechHelper { - synchronized(TextToSpeechHelper::class.java) { - if (INSTANCE == null) { - INSTANCE = TextToSpeechHelper(WeakReference(context)) - } - return INSTANCE!! - } - } - } - - override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { - if (event == Lifecycle.Event.ON_DESTROY || - event == Lifecycle.Event.ON_STOP || - event == Lifecycle.Event.ON_PAUSE - ) { - destroy { - customActionForDestroy?.invoke() - } - } + destroy { customActionForDestroy?.invoke() } } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index cf8f1d897..07ea5c803 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui import android.annotation.SuppressLint @@ -28,7 +48,7 @@ import com.vitorpamplona.amethyst.ServiceManager import com.vitorpamplona.amethyst.service.HttpClient import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils -import com.vitorpamplona.amethyst.ui.components.DefaultMutedSetting +import com.vitorpamplona.amethyst.ui.components.DEFAULT_MUTED_SETTING import com.vitorpamplona.amethyst.ui.components.keepPlayingMutex import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.navigation.debugState @@ -44,287 +64,284 @@ import com.vitorpamplona.quartz.events.ChannelMetadataEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent import com.vitorpamplona.quartz.events.PrivateDmEvent -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch import java.net.URLEncoder import java.nio.charset.StandardCharsets import java.util.Timer import kotlin.concurrent.schedule +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch class MainActivity : AppCompatActivity() { - private val isOnMobileDataState = mutableStateOf(false) - private val isOnWifiDataState = mutableStateOf(false) + private val isOnMobileDataState = mutableStateOf(false) + private val isOnWifiDataState = mutableStateOf(false) - // Service Manager is only active when the activity is active. - val serviceManager = ServiceManager() - private var shouldPauseService = true + // Service Manager is only active when the activity is active. + val serviceManager = ServiceManager() + private var shouldPauseService = true - @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) - @RequiresApi(Build.VERSION_CODES.R) - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) + @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) + @RequiresApi(Build.VERSION_CODES.R) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) - Log.d("Lifetime Event", "MainActivity.onCreate") + Log.d("Lifetime Event", "MainActivity.onCreate") - setContent { - val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel() + setContent { + val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel() - val displayFeatures = calculateDisplayFeatures(this) - val windowSizeClass = calculateWindowSizeClass(this) + val displayFeatures = calculateDisplayFeatures(this) + val windowSizeClass = calculateWindowSizeClass(this) - LaunchedEffect(key1 = sharedPreferencesViewModel) { - sharedPreferencesViewModel.init() - sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures) - } + LaunchedEffect(key1 = sharedPreferencesViewModel) { + sharedPreferencesViewModel.init() + sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures) + } - LaunchedEffect(isOnMobileDataState) { - sharedPreferencesViewModel.updateConnectivityStatusState(isOnMobileDataState) - } + LaunchedEffect(isOnMobileDataState) { + sharedPreferencesViewModel.updateConnectivityStatusState(isOnMobileDataState) + } - AmethystTheme(sharedPreferencesViewModel) { - // A surface container using the 'background' color from the theme - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - val accountStateViewModel: AccountStateViewModel = viewModel() - accountStateViewModel.serviceManager = serviceManager - - LaunchedEffect(key1 = Unit) { - accountStateViewModel.tryLoginExistingAccountAsync() - } - - AccountScreen(accountStateViewModel, sharedPreferencesViewModel) - } - } - } - } - - fun prepareToLaunchSigner() { - shouldPauseService = false - } - - @OptIn(DelicateCoroutinesApi::class) - override fun onResume() { - super.onResume() - - Log.d("Lifetime Event", "MainActivity.onResume") - - // starts muted every time - DefaultMutedSetting.value = true - - // Keep connection alive if it's calling the signer app - Log.d("shouldPauseService", "shouldPauseService onResume: $shouldPauseService") - if (shouldPauseService) { - GlobalScope.launch(Dispatchers.IO) { - serviceManager.justStart() - } - } - - GlobalScope.launch(Dispatchers.IO) { - PushNotificationUtils.init(LocalPreferences.allSavedAccounts()) - } - - val connectivityManager = (getSystemService(ConnectivityManager::class.java) as ConnectivityManager) - connectivityManager.registerDefaultNetworkCallback(networkCallback) - connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) - ?.let { updateNetworkCapabilities(it) } - - // resets state until next External Signer Call - Timer().schedule(350) { - shouldPauseService = true - } - } - - override fun onPause() { - Log.d("Lifetime Event", "MainActivity.onPause") - - LanguageTranslatorService.clear() - serviceManager.cleanObservers() - - // if (BuildConfig.DEBUG) { - GlobalScope.launch(Dispatchers.IO) { - debugState(this@MainActivity) - } - // } - - Log.d("shouldPauseService", "shouldPauseService onPause: $shouldPauseService") - if (shouldPauseService) { - GlobalScope.launch(Dispatchers.IO) { - serviceManager.pauseForGood() - } - } - - (getSystemService(ConnectivityManager::class.java) as ConnectivityManager) - .unregisterNetworkCallback(networkCallback) - - super.onPause() - } - - override fun onStart() { - super.onStart() - - Log.d("Lifetime Event", "MainActivity.onStart") - } - - override fun onStop() { - super.onStop() - - // Graph doesn't completely clear. - // GlobalScope.launch(Dispatchers.Default) { - // serviceManager.trimMemory() - // } - - Log.d("Lifetime Event", "MainActivity.onStop") - } - - override fun onDestroy() { - Log.d("Lifetime Event", "MainActivity.onDestroy") - - GlobalScope.launch(Dispatchers.Main) { - keepPlayingMutex?.stop() - keepPlayingMutex?.release() - keepPlayingMutex = null - } - - super.onDestroy() - } - - /** - * Release memory when the UI becomes hidden or when system resources become low. - * @param level the memory-related event that was raised. - */ - @OptIn(DelicateCoroutinesApi::class) - override fun onTrimMemory(level: Int) { - super.onTrimMemory(level) - println("Trim Memory $level") - GlobalScope.launch(Dispatchers.Default) { - serviceManager.trimMemory() - } - } - - fun updateNetworkCapabilities(networkCapabilities: NetworkCapabilities): Boolean { - val isOnMobileData = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) - val isOnWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) - - var changedNetwork = false - - if (isOnMobileDataState.value != isOnMobileData) { - isOnMobileDataState.value = isOnMobileData - - changedNetwork = true - } - - if (isOnWifiDataState.value != isOnWifi) { - isOnWifiDataState.value = isOnWifi - - changedNetwork = true - } - - if (changedNetwork) { - if (isOnMobileData) { - HttpClient.changeTimeouts(HttpClient.DEFAULT_TIMEOUT_ON_MOBILE) - } else { - HttpClient.changeTimeouts(HttpClient.DEFAULT_TIMEOUT_ON_WIFI) - } - } - - return changedNetwork - } - - @OptIn(DelicateCoroutinesApi::class) - private val networkCallback = object : ConnectivityManager.NetworkCallback() { - var lastNetwork: Network? = null - - override fun onAvailable(network: Network) { - super.onAvailable(network) - - Log.d("ServiceManager NetworkCallback", "onAvailable: $shouldPauseService") - if (shouldPauseService && lastNetwork != null && lastNetwork != network) { - GlobalScope.launch(Dispatchers.IO) { - serviceManager.forceRestart() - } - } - - lastNetwork = network - } - - // Network capabilities have changed for the network - override fun onCapabilitiesChanged( - network: Network, - networkCapabilities: NetworkCapabilities + AmethystTheme(sharedPreferencesViewModel) { + // A surface container using the 'background' color from the theme + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, ) { - super.onCapabilitiesChanged(network, networkCapabilities) + val accountStateViewModel: AccountStateViewModel = viewModel() + accountStateViewModel.serviceManager = serviceManager - GlobalScope.launch(Dispatchers.IO) { - Log.d("ServiceManager NetworkCallback", "onCapabilitiesChanged: ${network.networkHandle} hasMobileData ${isOnMobileDataState.value} hasWifi ${isOnWifiDataState.value}") - if (updateNetworkCapabilities(networkCapabilities) && shouldPauseService) { - serviceManager.forceRestart() - } - } + LaunchedEffect(key1 = Unit) { accountStateViewModel.tryLoginExistingAccountAsync() } + + AccountScreen(accountStateViewModel, sharedPreferencesViewModel) } + } + } + } + + fun prepareToLaunchSigner() { + shouldPauseService = false + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onResume() { + super.onResume() + + Log.d("Lifetime Event", "MainActivity.onResume") + + // starts muted every time + DEFAULT_MUTED_SETTING.value = true + + // Keep connection alive if it's calling the signer app + Log.d("shouldPauseService", "shouldPauseService onResume: $shouldPauseService") + if (shouldPauseService) { + GlobalScope.launch(Dispatchers.IO) { serviceManager.justStart() } + } + + GlobalScope.launch(Dispatchers.IO) { + PushNotificationUtils.init(LocalPreferences.allSavedAccounts()) + } + + val connectivityManager = + (getSystemService(ConnectivityManager::class.java) as ConnectivityManager) + connectivityManager.registerDefaultNetworkCallback(networkCallback) + connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)?.let { + updateNetworkCapabilities(it) + } + + // resets state until next External Signer Call + Timer().schedule(350) { shouldPauseService = true } + } + + override fun onPause() { + Log.d("Lifetime Event", "MainActivity.onPause") + + LanguageTranslatorService.clear() + serviceManager.cleanObservers() + + // if (BuildConfig.DEBUG) { + GlobalScope.launch(Dispatchers.IO) { debugState(this@MainActivity) } + // } + + Log.d("shouldPauseService", "shouldPauseService onPause: $shouldPauseService") + if (shouldPauseService) { + GlobalScope.launch(Dispatchers.IO) { serviceManager.pauseForGood() } + } + + (getSystemService(ConnectivityManager::class.java) as ConnectivityManager) + .unregisterNetworkCallback(networkCallback) + + super.onPause() + } + + override fun onStart() { + super.onStart() + + Log.d("Lifetime Event", "MainActivity.onStart") + } + + override fun onStop() { + super.onStop() + + // Graph doesn't completely clear. + // GlobalScope.launch(Dispatchers.Default) { + // serviceManager.trimMemory() + // } + + Log.d("Lifetime Event", "MainActivity.onStop") + } + + override fun onDestroy() { + Log.d("Lifetime Event", "MainActivity.onDestroy") + + GlobalScope.launch(Dispatchers.Main) { + keepPlayingMutex?.stop() + keepPlayingMutex?.release() + keepPlayingMutex = null + } + + super.onDestroy() + } + + /** + * Release memory when the UI becomes hidden or when system resources become low. + * + * @param level the memory-related event that was raised. + */ + @OptIn(DelicateCoroutinesApi::class) + override fun onTrimMemory(level: Int) { + super.onTrimMemory(level) + println("Trim Memory $level") + GlobalScope.launch(Dispatchers.Default) { serviceManager.trimMemory() } + } + + fun updateNetworkCapabilities(networkCapabilities: NetworkCapabilities): Boolean { + val isOnMobileData = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) + val isOnWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + + var changedNetwork = false + + if (isOnMobileDataState.value != isOnMobileData) { + isOnMobileDataState.value = isOnMobileData + + changedNetwork = true + } + + if (isOnWifiDataState.value != isOnWifi) { + isOnWifiDataState.value = isOnWifi + + changedNetwork = true + } + + if (changedNetwork) { + if (isOnMobileData) { + HttpClient.changeTimeouts(HttpClient.DEFAULT_TIMEOUT_ON_MOBILE) + } else { + HttpClient.changeTimeouts(HttpClient.DEFAULT_TIMEOUT_ON_WIFI) + } + } + + return changedNetwork + } + + @OptIn(DelicateCoroutinesApi::class) + private val networkCallback = + object : ConnectivityManager.NetworkCallback() { + var lastNetwork: Network? = null + + override fun onAvailable(network: Network) { + super.onAvailable(network) + + Log.d("ServiceManager NetworkCallback", "onAvailable: $shouldPauseService") + if (shouldPauseService && lastNetwork != null && lastNetwork != network) { + GlobalScope.launch(Dispatchers.IO) { serviceManager.forceRestart() } + } + + lastNetwork = network + } + + // Network capabilities have changed for the network + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities, + ) { + super.onCapabilitiesChanged(network, networkCapabilities) + + GlobalScope.launch(Dispatchers.IO) { + Log.d( + "ServiceManager NetworkCallback", + "onCapabilitiesChanged: ${network.networkHandle} hasMobileData ${isOnMobileDataState.value} hasWifi ${isOnWifiDataState.value}", + ) + if (updateNetworkCapabilities(networkCapabilities) && shouldPauseService) { + serviceManager.forceRestart() + } + } + } } } class GetMediaActivityResultContract : ActivityResultContracts.GetContent() { - - @SuppressLint("MissingSuperCall") - override fun createIntent(context: Context, input: String): Intent { - // Force only images and videos to be selectable - // Force OPEN Document because of the resulting URI must be passed to the - // Playback service and the picker's permissions only allow the activity to read the URI - return Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - // Force only images and videos to be selectable - type = "*/*" - putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*")) - } + @SuppressLint("MissingSuperCall") + override fun createIntent( + context: Context, + input: String, + ): Intent { + // Force only images and videos to be selectable + // Force OPEN Document because of the resulting URI must be passed to the + // Playback service and the picker's permissions only allow the activity to read the URI + return Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + // Force only images and videos to be selectable + type = "*/*" + putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*")) } + } } fun uriToRoute(uri: String?): String? { - return if (uri.equals("nostr:Notifications", true)) { - Route.Notification.route.replace("{scrollToTop}", "true") + return if (uri.equals("nostr:Notifications", true)) { + Route.Notification.route.replace("{scrollToTop}", "true") + } else { + if (uri?.startsWith("nostr:Hashtag?id=") == true) { + Route.Hashtag.route.replace("{id}", uri.removePrefix("nostr:Hashtag?id=")) } else { - if (uri?.startsWith("nostr:Hashtag?id=") == true) { - Route.Hashtag.route.replace("{id}", uri.removePrefix("nostr:Hashtag?id=")) - } else { - val nip19 = Nip19.uriToRoute(uri) - when (nip19?.type) { - Nip19.Type.USER -> "User/${nip19.hex}" - Nip19.Type.NOTE -> "Note/${nip19.hex}" - Nip19.Type.EVENT -> { - if (nip19.kind == PrivateDmEvent.kind) { - nip19.author?.let { - "RoomByAuthor/$it" - } - } else if (nip19.kind == ChannelMessageEvent.kind || nip19.kind == ChannelCreateEvent.kind || nip19.kind == ChannelMetadataEvent.kind) { - "Channel/${nip19.hex}" - } else { - "Event/${nip19.hex}" - } - } - - Nip19.Type.ADDRESS -> - if (nip19.kind == CommunityDefinitionEvent.kind) { - "Community/${nip19.hex}" - } else if (nip19.kind == LiveActivitiesEvent.kind) { - "Channel/${nip19.hex}" - } else { - "Event/${nip19.hex}" - } - else -> null - } - } ?: try { - uri?.let { - Nip47WalletConnectParser.parse(it) - val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString()) - Route.Home.base + "?nip47=" + encodedUri - } - } catch (e: Exception) { - null + val nip19 = Nip19.uriToRoute(uri) + when (nip19?.type) { + Nip19.Type.USER -> "User/${nip19.hex}" + Nip19.Type.NOTE -> "Note/${nip19.hex}" + Nip19.Type.EVENT -> { + if (nip19.kind == PrivateDmEvent.KIND) { + nip19.author?.let { "RoomByAuthor/$it" } + } else if ( + nip19.kind == ChannelMessageEvent.KIND || + nip19.kind == ChannelCreateEvent.KIND || + nip19.kind == ChannelMetadataEvent.KIND + ) { + "Channel/${nip19.hex}" + } else { + "Event/${nip19.hex}" + } } + Nip19.Type.ADDRESS -> + if (nip19.kind == CommunityDefinitionEvent.KIND) { + "Community/${nip19.hex}" + } else if (nip19.kind == LiveActivitiesEvent.KIND) { + "Channel/${nip19.hex}" + } else { + "Event/${nip19.hex}" + } + else -> null + } } + ?: try { + uri?.let { + Nip47WalletConnectParser.parse(it) + val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString()) + Route.Home.base + "?nip47=" + encodedUri + } + } catch (e: Exception) { + null + } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageDownloader.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageDownloader.kt index 7e2657522..72dae481c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageDownloader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageDownloader.kt @@ -1,47 +1,68 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.actions -import kotlinx.coroutines.delay import java.net.HttpURLConnection import java.net.URL +import kotlinx.coroutines.delay class ImageDownloader { - suspend fun waitAndGetImage(imageUrl: String): ByteArray? { - var imageData: ByteArray? = null - var tentatives = 0 + suspend fun waitAndGetImage(imageUrl: String): ByteArray? { + var imageData: ByteArray? = null + var tentatives = 0 - // Servers are usually not ready.. so tries to download it for 15 times/seconds. - while (imageData == null && tentatives < 15) { - imageData = try { - HttpURLConnection.setFollowRedirects(true) - var url = URL(imageUrl) - var huc = url.openConnection() as HttpURLConnection - huc.instanceFollowRedirects = true - var responseCode = huc.responseCode + // Servers are usually not ready.. so tries to download it for 15 times/seconds. + while (imageData == null && tentatives < 15) { + imageData = + try { + HttpURLConnection.setFollowRedirects(true) + var url = URL(imageUrl) + var huc = url.openConnection() as HttpURLConnection + huc.instanceFollowRedirects = true + var responseCode = huc.responseCode - if (responseCode in 300..400) { - val newUrl: String = huc.getHeaderField("Location") + if (responseCode in 300..400) { + val newUrl: String = huc.getHeaderField("Location") - // open the new connnection again - url = URL(newUrl) - huc = url.openConnection() as HttpURLConnection - responseCode = huc.responseCode - } + // open the new connnection again + url = URL(newUrl) + huc = url.openConnection() as HttpURLConnection + responseCode = huc.responseCode + } - if (responseCode in 200..300) { - huc.inputStream.use { it.readBytes() } - } else { - tentatives++ - delay(1000) + if (responseCode in 200..300) { + huc.inputStream.use { it.readBytes() } + } else { + tentatives++ + delay(1000) - null - } - } catch (e: Exception) { - tentatives++ - delay(1000) - null - } + null + } + } catch (e: Exception) { + tentatives++ + delay(1000) + null } - - return imageData } + + return imageData + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt index 4b54fe791..70e2037c9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.actions import android.content.ContentResolver @@ -11,6 +31,8 @@ import android.webkit.MimeTypeMap import androidx.annotation.RequiresApi import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.service.HttpClient +import java.io.File +import java.util.UUID import okhttp3.Call import okhttp3.Callback import okhttp3.Request @@ -20,169 +42,168 @@ import okio.IOException import okio.buffer import okio.sink import okio.source -import java.io.File -import java.util.UUID object ImageSaver { - /** - * Saves the image to the gallery. - * May require a storage permission. - * - * @see PICTURES_SUBDIRECTORY - */ - fun saveImage( - url: String, - context: Context, - onSuccess: () -> Any?, - onError: (Throwable) -> Any? - ) { - val client = HttpClient.getHttpClient() + /** + * Saves the image to the gallery. May require a storage permission. + * + * @see PICTURES_SUBDIRECTORY + */ + fun saveImage( + url: String, + context: Context, + onSuccess: () -> Any?, + onError: (Throwable) -> Any?, + ) { + val client = HttpClient.getHttpClient() - val request = Request.Builder() - .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") - .get() - .url(url) - .build() + val request = + Request.Builder() + .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .get() + .url(url) + .build() - client.newCall(request).enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) { - e.printStackTrace() - onError(e) - } - - override fun onResponse(call: Call, response: Response) { - try { - check(response.isSuccessful) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val contentType = response.header("Content-Type") - checkNotNull(contentType) { - "Can't find out the content type" - } - - saveContentQ( - displayName = File(url).nameWithoutExtension, - contentType = contentType, - contentSource = response.body.source(), - contentResolver = context.contentResolver - ) - } else { - saveContentDefault( - fileName = File(url).name, - contentSource = response.body.source(), - context = context - ) - } - onSuccess() - } catch (e: Exception) { - e.printStackTrace() - onError(e) - } - } - }) - } - - fun saveImage( - localFile: File, - mimeType: String?, - context: Context, - onSuccess: () -> Any?, - onError: (Throwable) -> Any? - ) { - try { - val extension = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" - val buffer = localFile.inputStream().source().buffer() - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - saveContentQ( - displayName = UUID.randomUUID().toString(), - contentType = mimeType ?: "", - contentSource = buffer, - contentResolver = context.contentResolver - ) - } else { - saveContentDefault( - fileName = UUID.randomUUID().toString() + ".$extension", - contentSource = buffer, - context = context - ) - } - onSuccess() - } catch (e: Exception) { + client + .newCall(request) + .enqueue( + object : Callback { + override fun onFailure( + call: Call, + e: IOException, + ) { e.printStackTrace() onError(e) - } - } + } - @RequiresApi(Build.VERSION_CODES.Q) - private fun saveContentQ( - displayName: String, - contentType: String, - contentSource: BufferedSource, - contentResolver: ContentResolver - ) { - val contentValues = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) - put(MediaStore.MediaColumns.MIME_TYPE, contentType) - put( - MediaStore.MediaColumns.RELATIVE_PATH, - Environment.DIRECTORY_PICTURES + File.separatorChar + PICTURES_SUBDIRECTORY - ) - } + override fun onResponse( + call: Call, + response: Response, + ) { + try { + check(response.isSuccessful) - val masterUri = if (contentType.startsWith("image")) { - MediaStore.Images.Media.EXTERNAL_CONTENT_URI - } else { - MediaStore.Video.Media.EXTERNAL_CONTENT_URI - } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val contentType = response.header("Content-Type") + checkNotNull(contentType) { "Can't find out the content type" } - val uri = - contentResolver.insert(masterUri, contentValues) - checkNotNull(uri) { - "Can't insert the new content" - } - - try { - val outputStream = contentResolver.openOutputStream(uri) - checkNotNull(outputStream) { - "Can't open the content output stream" + saveContentQ( + displayName = File(url).nameWithoutExtension, + contentType = contentType, + contentSource = response.body.source(), + contentResolver = context.contentResolver, + ) + } else { + saveContentDefault( + fileName = File(url).name, + contentSource = response.body.source(), + context = context, + ) + } + onSuccess() + } catch (e: Exception) { + e.printStackTrace() + onError(e) } + } + }, + ) + } - outputStream.use { - contentSource.readAll(it.sink()) - } - } catch (e: Exception) { - contentResolver.delete(uri, null, null) - throw e - } - } + fun saveImage( + localFile: File, + mimeType: String?, + context: Context, + onSuccess: () -> Any?, + onError: (Throwable) -> Any?, + ) { + try { + val extension = + mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" + val buffer = localFile.inputStream().source().buffer() - private fun saveContentDefault( - fileName: String, - contentSource: BufferedSource, - context: Context - ) { - val subdirectory = File( - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), - PICTURES_SUBDIRECTORY + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + saveContentQ( + displayName = UUID.randomUUID().toString(), + contentType = mimeType ?: "", + contentSource = buffer, + contentResolver = context.contentResolver, ) + } else { + saveContentDefault( + fileName = UUID.randomUUID().toString() + ".$extension", + contentSource = buffer, + context = context, + ) + } + onSuccess() + } catch (e: Exception) { + e.printStackTrace() + onError(e) + } + } - if (!subdirectory.exists()) { - subdirectory.mkdirs() - } + @RequiresApi(Build.VERSION_CODES.Q) + private fun saveContentQ( + displayName: String, + contentType: String, + contentSource: BufferedSource, + contentResolver: ContentResolver, + ) { + val contentValues = + ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) + put(MediaStore.MediaColumns.MIME_TYPE, contentType) + put( + MediaStore.MediaColumns.RELATIVE_PATH, + Environment.DIRECTORY_PICTURES + File.separatorChar + PICTURES_SUBDIRECTORY, + ) + } - val outputFile = File(subdirectory, fileName) + val masterUri = + if (contentType.startsWith("image")) { + MediaStore.Images.Media.EXTERNAL_CONTENT_URI + } else { + MediaStore.Video.Media.EXTERNAL_CONTENT_URI + } - outputFile - .outputStream() - .use { - contentSource.readAll(it.sink()) - } + val uri = contentResolver.insert(masterUri, contentValues) + checkNotNull(uri) { "Can't insert the new content" } - // Call the media scanner manually, so the image - // appears in the gallery faster. - MediaScannerConnection.scanFile(context, arrayOf(outputFile.toString()), null, null) + try { + val outputStream = contentResolver.openOutputStream(uri) + checkNotNull(outputStream) { "Can't open the content output stream" } + + outputStream.use { contentSource.readAll(it.sink()) } + } catch (e: Exception) { + contentResolver.delete(uri, null, null) + throw e + } + } + + private fun saveContentDefault( + fileName: String, + contentSource: BufferedSource, + context: Context, + ) { + val subdirectory = + File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), + PICTURES_SUBDIRECTORY, + ) + + if (!subdirectory.exists()) { + subdirectory.mkdirs() } - private const val PICTURES_SUBDIRECTORY = "Amethyst" + val outputFile = File(subdirectory, fileName) + + outputFile.outputStream().use { contentSource.readAll(it.sink()) } + + // Call the media scanner manually, so the image + // appears in the gallery faster. + MediaScannerConnection.scanFile(context, arrayOf(outputFile.toString()), null, null) + } + + private const val PICTURES_SUBDIRECTORY = "Amethyst" } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/InformationDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/InformationDialog.kt index a8323fd36..0c72422fb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/InformationDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/InformationDialog.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.actions import androidx.compose.foundation.layout.PaddingValues @@ -21,34 +41,32 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer @Composable fun InformationDialog( - title: String, - textContent: String, - buttonColors: ButtonColors = ButtonDefaults.buttonColors(), - onDismiss: () -> Unit + title: String, + textContent: String, + buttonColors: ButtonColors = ButtonDefaults.buttonColors(), + onDismiss: () -> Unit, ) { - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text(title) - }, - text = { - SelectionContainer { - Text(textContent) - } - }, - confirmButton = { - Button(onClick = onDismiss, colors = buttonColors, contentPadding = PaddingValues(horizontal = Size16dp)) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Outlined.Done, - contentDescription = null - ) - Spacer(StdHorzSpacer) - Text(stringResource(R.string.error_dialog_button_ok)) - } - } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { SelectionContainer { Text(textContent) } }, + confirmButton = { + Button( + onClick = onDismiss, + colors = buttonColors, + contentPadding = PaddingValues(horizontal = Size16dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Done, + contentDescription = null, + ) + Spacer(StdHorzSpacer) + Text(stringResource(R.string.error_dialog_button_ok)) } - ) + } + }, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt index 888b31dac..c19c9ec62 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.actions import androidx.compose.foundation.clickable @@ -80,353 +100,339 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @Composable -fun JoinUserOrChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val searchBarViewModel: SearchBarViewModel = viewModel( - key = "SearchBarViewModel", - factory = SearchBarViewModel.Factory( - accountViewModel.account - ) +fun JoinUserOrChannelView( + onClose: () -> Unit, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val searchBarViewModel: SearchBarViewModel = + viewModel( + key = "SearchBarViewModel", + factory = + SearchBarViewModel.Factory( + accountViewModel.account, + ), ) - JoinUserOrChannelView( - searchBarViewModel = searchBarViewModel, - onClose = onClose, - accountViewModel = accountViewModel, - nav = nav - ) + JoinUserOrChannelView( + searchBarViewModel = searchBarViewModel, + onClose = onClose, + accountViewModel = accountViewModel, + nav = nav, + ) } @Composable -fun JoinUserOrChannelView(searchBarViewModel: SearchBarViewModel, onClose: () -> Unit, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - Dialog( - onDismissRequest = { - NostrSearchEventOrUserDataSource.clear() - searchBarViewModel.clear() - onClose() - }, - properties = DialogProperties( - dismissOnClickOutside = false - ) - ) { - Surface() { - Column( - modifier = Modifier - .padding(10.dp) - .heightIn(min = 500.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - CloseButton(onPress = { - searchBarViewModel.clear() - NostrSearchEventOrUserDataSource.clear() - onClose() - }) +fun JoinUserOrChannelView( + searchBarViewModel: SearchBarViewModel, + onClose: () -> Unit, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + Dialog( + onDismissRequest = { + NostrSearchEventOrUserDataSource.clear() + searchBarViewModel.clear() + onClose() + }, + properties = + DialogProperties( + dismissOnClickOutside = false, + ), + ) { + Surface { + Column( + modifier = Modifier.padding(10.dp).heightIn(min = 500.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + CloseButton( + onPress = { + searchBarViewModel.clear() + NostrSearchEventOrUserDataSource.clear() + onClose() + }, + ) - Text( - text = stringResource(R.string.channel_list_join_conversation), - fontWeight = FontWeight.Bold - ) + Text( + text = stringResource(R.string.channel_list_join_conversation), + fontWeight = FontWeight.Bold, + ) - Text( - text = "", - color = MaterialTheme.colorScheme.placeholderText, - fontWeight = FontWeight.Bold - ) - } - - Spacer(modifier = Modifier.height(15.dp)) - - RenderSearch(searchBarViewModel, accountViewModel, nav) - } + Text( + text = "", + color = MaterialTheme.colorScheme.placeholderText, + fontWeight = FontWeight.Bold, + ) } + + Spacer(modifier = Modifier.height(15.dp)) + + RenderSearch(searchBarViewModel, accountViewModel, nav) + } } + } } @Composable private fun RenderSearch( - searchBarViewModel: SearchBarViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + searchBarViewModel: SearchBarViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val listState = rememberLazyListState() + val listState = rememberLazyListState() - val lifeCycleOwner = LocalLifecycleOwner.current + val lifeCycleOwner = LocalLifecycleOwner.current - // Create a channel for processing search queries. - val searchTextChanges = remember { - Channel(Channel.CONFLATED) + // Create a channel for processing search queries. + val searchTextChanges = remember { Channel(Channel.CONFLATED) } + + LaunchedEffect(Unit) { + launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { + checkNotInMainThread() + if (searchBarViewModel.isSearchingFun()) { + searchBarViewModel.invalidateData() + } + } } + } - LaunchedEffect(Unit) { - launch(Dispatchers.IO) { - LocalCache.live.newEventBundles.collect { - checkNotInMainThread() - if (searchBarViewModel.isSearchingFun()) { - searchBarViewModel.invalidateData() - } - } + LaunchedEffect(Unit) { + // Wait for text changes to stop for 300 ms before firing off search. + withContext(Dispatchers.IO) { + searchTextChanges + .receiveAsFlow() + .filter { it.isNotBlank() } + .distinctUntilChanged() + .debounce(300) + .collectLatest { + if (it.length >= 2) { + NostrSearchEventOrUserDataSource.search(it.trim()) + } + + searchBarViewModel.invalidateData() + + // makes sure to show the top of the search + launch(Dispatchers.Main) { listState.animateScrollToItem(0) } } } + } - LaunchedEffect(Unit) { - // Wait for text changes to stop for 300 ms before firing off search. - withContext(Dispatchers.IO) { - searchTextChanges.receiveAsFlow() - .filter { it.isNotBlank() } - .distinctUntilChanged() - .debounce(300) - .collectLatest { - if (it.length >= 2) { - NostrSearchEventOrUserDataSource.search(it.trim()) - } - - searchBarViewModel.invalidateData() - - // makes sure to show the top of the search - launch(Dispatchers.Main) { - listState.animateScrollToItem(0) - } - } - } + DisposableEffect(lifeCycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Join Start") + NostrSearchEventOrUserDataSource.start() + searchBarViewModel.invalidateData() + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Join Stop") + NostrSearchEventOrUserDataSource.clear() + NostrSearchEventOrUserDataSource.stop() + } } - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Join Start") - NostrSearchEventOrUserDataSource.start() - searchBarViewModel.invalidateData() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Join Stop") - NostrSearchEventOrUserDataSource.clear() - NostrSearchEventOrUserDataSource.stop() - } - } + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } + } - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { - lifeCycleOwner.lifecycle.removeObserver(observer) - } - } + // LAST ROW + SearchEditTextForJoin(searchBarViewModel, searchTextChanges) - // LAST ROW - SearchEditTextForJoin(searchBarViewModel, searchTextChanges) - - RenderSearchResults(searchBarViewModel, listState, accountViewModel, nav) + RenderSearchResults(searchBarViewModel, listState, accountViewModel, nav) } @OptIn(ExperimentalComposeUiApi::class) @Composable private fun SearchEditTextForJoin( - searchBarViewModel: SearchBarViewModel, - searchTextChanges: Channel + searchBarViewModel: SearchBarViewModel, + searchTextChanges: Channel, ) { - val scope = rememberCoroutineScope() + val scope = rememberCoroutineScope() - // initialize focus reference to be able to request focus programmatically - val focusRequester = remember { FocusRequester() } - val keyboardController = LocalSoftwareKeyboardController.current + // initialize focus reference to be able to request focus programmatically + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current - LaunchedEffect(Unit) { - launch { - delay(100) - focusRequester.requestFocus() - } + LaunchedEffect(Unit) { + launch { + delay(100) + focusRequester.requestFocus() } + } - Row( - modifier = Modifier - .padding(horizontal = 10.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.channel_list_user_or_group_id)) }, - value = searchBarViewModel.searchValue, - onValueChange = { - searchBarViewModel.updateSearchValue(it) - scope.launch(Dispatchers.IO) { - searchTextChanges.trySend(it) - } - }, - leadingIcon = { - SearchIcon(modifier = Size20Modifier, Color.Unspecified) - }, - modifier = Modifier - .weight(1f, true) - .defaultMinSize(minHeight = 20.dp) - .focusRequester(focusRequester) - .onFocusChanged { - if (it.isFocused) { - keyboardController?.show() - } - }, - placeholder = { - Text( - text = stringResource(R.string.channel_list_user_or_group_id_demo), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - trailingIcon = { - if (searchBarViewModel.isSearching) { - IconButton( - onClick = { - searchBarViewModel.clear() - NostrSearchEventOrUserDataSource.clear() - } - ) { - Icon( - imageVector = Icons.Default.Clear, - contentDescription = stringResource(R.string.clear) - ) - } - } + Row( + modifier = Modifier.padding(horizontal = 10.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.channel_list_user_or_group_id)) }, + value = searchBarViewModel.searchValue, + onValueChange = { + searchBarViewModel.updateSearchValue(it) + scope.launch(Dispatchers.IO) { searchTextChanges.trySend(it) } + }, + leadingIcon = { SearchIcon(modifier = Size20Modifier, Color.Unspecified) }, + modifier = + Modifier.weight(1f, true) + .defaultMinSize(minHeight = 20.dp) + .focusRequester(focusRequester) + .onFocusChanged { + if (it.isFocused) { + keyboardController?.show() } + }, + placeholder = { + Text( + text = stringResource(R.string.channel_list_user_or_group_id_demo), + color = MaterialTheme.colorScheme.placeholderText, ) - } + }, + trailingIcon = { + if (searchBarViewModel.isSearching) { + IconButton( + onClick = { + searchBarViewModel.clear() + NostrSearchEventOrUserDataSource.clear() + }, + ) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = stringResource(R.string.clear), + ) + } + } + }, + ) + } } @Composable private fun RenderSearchResults( - searchBarViewModel: SearchBarViewModel, - listState: LazyListState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + searchBarViewModel: SearchBarViewModel, + listState: LazyListState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - if (searchBarViewModel.isSearching) { - val users by searchBarViewModel.searchResultsUsers.collectAsStateWithLifecycle() - val channels by searchBarViewModel.searchResultsChannels.collectAsStateWithLifecycle() + if (searchBarViewModel.isSearching) { + val users by searchBarViewModel.searchResultsUsers.collectAsStateWithLifecycle() + val channels by searchBarViewModel.searchResultsChannels.collectAsStateWithLifecycle() - val automaticallyShowProfilePicture = remember { - accountViewModel.settings.showProfilePictures.value - } - - Row( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - .padding(vertical = 10.dp) - ) { - LazyColumn( - modifier = Modifier.fillMaxHeight(), - contentPadding = FeedPadding, - state = listState - ) { - itemsIndexed( - users, - key = { _, item -> "u" + item.pubkeyHex } - ) { _, item -> - UserComposeForChat(item, accountViewModel) { - accountViewModel.createChatRoomFor(item) { - nav("Room/$it") - } - - searchBarViewModel.clear() - } - } - - itemsIndexed( - channels, - key = { _, item -> "c" + item.idHex } - ) { _, item -> - RenderChannel(item, automaticallyShowProfilePicture) { - nav("Channel/${item.idHex}") - searchBarViewModel.clear() - } - } - } - } + val automaticallyShowProfilePicture = remember { + accountViewModel.settings.showProfilePictures.value } + + Row( + modifier = Modifier.fillMaxWidth().fillMaxHeight().padding(vertical = 10.dp), + ) { + LazyColumn( + modifier = Modifier.fillMaxHeight(), + contentPadding = FeedPadding, + state = listState, + ) { + itemsIndexed( + users, + key = { _, item -> "u" + item.pubkeyHex }, + ) { _, item -> + UserComposeForChat(item, accountViewModel) { + accountViewModel.createChatRoomFor(item) { nav("Room/$it") } + + searchBarViewModel.clear() + } + } + + itemsIndexed( + channels, + key = { _, item -> "c" + item.idHex }, + ) { _, item -> + RenderChannel(item, automaticallyShowProfilePicture) { + nav("Channel/${item.idHex}") + searchBarViewModel.clear() + } + } + } + } + } } @Composable private fun RenderChannel( - item: com.vitorpamplona.amethyst.model.Channel, - loadProfilePicture: Boolean, - onClick: () -> Unit + item: com.vitorpamplona.amethyst.model.Channel, + loadProfilePicture: Boolean, + onClick: () -> Unit, ) { - val hasNewMessages = remember { - mutableStateOf(false) - } + val hasNewMessages = remember { mutableStateOf(false) } - ChannelName( - channelIdHex = item.idHex, - channelPicture = item.profilePicture(), - channelTitle = { - Text( - item.toBestDisplayName(), - fontWeight = FontWeight.Bold - ) - }, - channelLastTime = null, - channelLastContent = item.summary(), - hasNewMessages, - onClick = onClick, - loadProfilePicture = loadProfilePicture - ) + ChannelName( + channelIdHex = item.idHex, + channelPicture = item.profilePicture(), + channelTitle = { + Text( + item.toBestDisplayName(), + fontWeight = FontWeight.Bold, + ) + }, + channelLastTime = null, + channelLastContent = item.summary(), + hasNewMessages, + onClick = onClick, + loadProfilePicture = loadProfilePicture, + ) } @Composable fun UserComposeForChat( - baseUser: User, - accountViewModel: AccountViewModel, - onClick: () -> Unit + baseUser: User, + accountViewModel: AccountViewModel, + onClick: () -> Unit, ) { - Column( - modifier = - Modifier.clickable( - onClick = onClick - ) + Column( + modifier = + Modifier.clickable( + onClick = onClick, + ), + ) { + Row( + modifier = + Modifier.padding( + start = 12.dp, + end = 12.dp, + top = 10.dp, + ), + verticalAlignment = Alignment.CenterVertically, ) { - Row( - modifier = Modifier - .padding( - start = 12.dp, - end = 12.dp, - top = 10.dp - ), - verticalAlignment = Alignment.CenterVertically - ) { - ClickableUserPicture(baseUser, Size55dp, accountViewModel) + ClickableUserPicture(baseUser, Size55dp, accountViewModel) - Column( - modifier = Modifier - .padding(start = 10.dp) - .weight(1f) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - UsernameDisplay(baseUser) - } + Column( + modifier = Modifier.padding(start = 10.dp).weight(1f), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseUser) } - DisplayUserAboutInfo(baseUser) - } - } - - Divider( - modifier = Modifier.padding(top = 10.dp), - thickness = DividerThickness - ) + DisplayUserAboutInfo(baseUser) + } } + + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = DividerThickness, + ) + } } @Composable private fun DisplayUserAboutInfo(baseUser: User) { - val baseUserState by baseUser.live().metadata.observeAsState() - val about by remember(baseUserState) { - derivedStateOf { - baseUserState?.user?.info?.about ?: "" - } - } + val baseUserState by baseUser.live().metadata.observeAsState() + val about by remember(baseUserState) { derivedStateOf { baseUserState?.user?.info?.about ?: "" } } - Text( - text = about, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Text( + text = about, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt index 5987a7cb4..407a092b8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.actions import androidx.compose.foundation.layout.Arrangement @@ -31,97 +51,102 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable -fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, channel: PublicChatChannel? = null) { - val postViewModel: NewChannelViewModel = viewModel() - postViewModel.load(accountViewModel.account, channel) +fun NewChannelView( + onClose: () -> Unit, + accountViewModel: AccountViewModel, + channel: PublicChatChannel? = null, +) { + val postViewModel: NewChannelViewModel = viewModel() + postViewModel.load(accountViewModel.account, channel) - Dialog( - onDismissRequest = { onClose() }, - properties = DialogProperties( - dismissOnClickOutside = false - ) - ) { - Surface() { - Column( - modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState()) - ) { - Row( - modifier = Modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - CloseButton(onPress = { - postViewModel.clear() - onClose() - }) + Dialog( + onDismissRequest = { onClose() }, + properties = + DialogProperties( + dismissOnClickOutside = false, + ), + ) { + Surface { + Column( + modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState()), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + CloseButton( + onPress = { + postViewModel.clear() + onClose() + }, + ) - PostButton( - onPost = { - postViewModel.create() - onClose() - }, - postViewModel.channelName.value.text.isNotBlank() - ) - } - - Spacer(modifier = Modifier.height(15.dp)) - - OutlinedTextField( - label = { Text(text = stringResource(R.string.channel_name)) }, - modifier = Modifier.fillMaxWidth(), - value = postViewModel.channelName.value, - onValueChange = { postViewModel.channelName.value = it }, - placeholder = { - Text( - text = stringResource(R.string.my_awesome_group), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences - ), - textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content) - ) - - Spacer(modifier = Modifier.height(15.dp)) - - OutlinedTextField( - label = { Text(text = stringResource(R.string.picture_url)) }, - modifier = Modifier.fillMaxWidth(), - value = postViewModel.channelPicture.value, - onValueChange = { postViewModel.channelPicture.value = it }, - placeholder = { - Text( - text = "http://mygroup.com/logo.jpg", - color = MaterialTheme.colorScheme.placeholderText - ) - } - ) - - Spacer(modifier = Modifier.height(15.dp)) - - OutlinedTextField( - label = { Text(text = stringResource(R.string.description)) }, - modifier = Modifier - .fillMaxWidth() - .height(100.dp), - value = postViewModel.channelDescription.value, - onValueChange = { postViewModel.channelDescription.value = it }, - placeholder = { - Text( - text = stringResource(R.string.about_us), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences - ), - textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - maxLines = 10 - - ) - } + PostButton( + onPost = { + postViewModel.create() + onClose() + }, + postViewModel.channelName.value.text.isNotBlank(), + ) } + + Spacer(modifier = Modifier.height(15.dp)) + + OutlinedTextField( + label = { Text(text = stringResource(R.string.channel_name)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.channelName.value, + onValueChange = { postViewModel.channelName.value = it }, + placeholder = { + Text( + text = stringResource(R.string.my_awesome_group), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + ) + + Spacer(modifier = Modifier.height(15.dp)) + + OutlinedTextField( + label = { Text(text = stringResource(R.string.picture_url)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.channelPicture.value, + onValueChange = { postViewModel.channelPicture.value = it }, + placeholder = { + Text( + text = "http://mygroup.com/logo.jpg", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + ) + + Spacer(modifier = Modifier.height(15.dp)) + + OutlinedTextField( + label = { Text(text = stringResource(R.string.description)) }, + modifier = Modifier.fillMaxWidth().height(100.dp), + value = postViewModel.channelDescription.value, + onValueChange = { postViewModel.channelDescription.value = it }, + placeholder = { + Text( + text = stringResource(R.string.about_us), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + maxLines = 10, + ) + } } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelViewModel.kt index 7ab71eb1f..5b6c171bd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelViewModel.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.actions import androidx.compose.runtime.mutableStateOf @@ -10,49 +30,52 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class NewChannelViewModel : ViewModel() { - private var account: Account? = null - private var originalChannel: PublicChatChannel? = null + private var account: Account? = null + private var originalChannel: PublicChatChannel? = null - val channelName = mutableStateOf(TextFieldValue()) - val channelPicture = mutableStateOf(TextFieldValue()) - val channelDescription = mutableStateOf(TextFieldValue()) + val channelName = mutableStateOf(TextFieldValue()) + val channelPicture = mutableStateOf(TextFieldValue()) + val channelDescription = mutableStateOf(TextFieldValue()) - fun load(account: Account, channel: PublicChatChannel?) { - this.account = account - if (channel != null) { - originalChannel = channel - channelName.value = TextFieldValue(channel.info.name ?: "") - channelPicture.value = TextFieldValue(channel.info.picture ?: "") - channelDescription.value = TextFieldValue(channel.info.about ?: "") + fun load( + account: Account, + channel: PublicChatChannel?, + ) { + this.account = account + if (channel != null) { + originalChannel = channel + channelName.value = TextFieldValue(channel.info.name ?: "") + channelPicture.value = TextFieldValue(channel.info.picture ?: "") + channelDescription.value = TextFieldValue(channel.info.about ?: "") + } + } + + fun create() { + viewModelScope.launch(Dispatchers.IO) { + account?.let { account -> + if (originalChannel == null) { + account.sendCreateNewChannel( + channelName.value.text, + channelDescription.value.text, + channelPicture.value.text, + ) + } else { + account.sendChangeChannel( + channelName.value.text, + channelDescription.value.text, + channelPicture.value.text, + originalChannel!!, + ) } - } + } - fun create() { - viewModelScope.launch(Dispatchers.IO) { - account?.let { account -> - if (originalChannel == null) { - account.sendCreateNewChannel( - channelName.value.text, - channelDescription.value.text, - channelPicture.value.text - ) - } else { - account.sendChangeChannel( - channelName.value.text, - channelDescription.value.text, - channelPicture.value.text, - originalChannel!! - ) - } - } - - clear() - } + clear() } + } - fun clear() { - channelName.value = TextFieldValue() - channelPicture.value = TextFieldValue() - channelDescription.value = TextFieldValue() - } + fun clear() { + channelName.value = TextFieldValue() + channelPicture.value = TextFieldValue() + channelDescription.value = TextFieldValue() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt index 5aa7e43cd..2c6e1b67e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.actions import android.content.Context @@ -20,253 +40,289 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch data class ServerOption( - val server: Nip96MediaServers.ServerName, - val isNip95: Boolean + val server: Nip96MediaServers.ServerName, + val isNip95: Boolean, ) @Stable open class NewMediaModel : ViewModel() { - var account: Account? = null + var account: Account? = null - var isUploadingImage by mutableStateOf(false) - var mediaType by mutableStateOf(null) + var isUploadingImage by mutableStateOf(false) + var mediaType by mutableStateOf(null) - var selectedServer by mutableStateOf(null) - var alt by mutableStateOf("") - var sensitiveContent by mutableStateOf(false) + var selectedServer by mutableStateOf(null) + var alt by mutableStateOf("") + var sensitiveContent by mutableStateOf(false) - // Images and Videos - var galleryUri by mutableStateOf(null) + // Images and Videos + var galleryUri by mutableStateOf(null) - var uploadingPercentage = mutableStateOf(0.0f) - var uploadingDescription = mutableStateOf(null) + var uploadingPercentage = mutableStateOf(0.0f) + var uploadingDescription = mutableStateOf(null) - var onceUploaded: () -> Unit = {} - var onError: (String) -> Unit = {} + var onceUploaded: () -> Unit = {} + var onError: (String) -> Unit = {} - open fun load(account: Account, uri: Uri, contentType: String?, onError: (String) -> Unit) { - this.account = account - this.galleryUri = uri - this.mediaType = contentType - this.selectedServer = ServerOption(defaultServer(), false) - this.onError = onError - } + open fun load( + account: Account, + uri: Uri, + contentType: String?, + onError: (String) -> Unit, + ) { + this.account = account + this.galleryUri = uri + this.mediaType = contentType + this.selectedServer = ServerOption(defaultServer(), false) + this.onError = onError + } - fun upload(context: Context, relayList: List? = null) { - isUploadingImage = true + fun upload( + context: Context, + relayList: List? = null, + ) { + isUploadingImage = true - val contentResolver = context.contentResolver - val myGalleryUri = galleryUri ?: return - val serverToUse = selectedServer ?: return + val contentResolver = context.contentResolver + val myGalleryUri = galleryUri ?: return + val serverToUse = selectedServer ?: return - val contentType = contentResolver.getType(myGalleryUri) + val contentType = contentResolver.getType(myGalleryUri) - viewModelScope.launch(Dispatchers.IO) { - uploadingPercentage.value = 0.1f - uploadingDescription.value = "Compress" - MediaCompressor().compress( - myGalleryUri, - contentType, - context.applicationContext, - onReady = { fileUri, contentType, size -> - if (serverToUse.isNip95) { - uploadingPercentage.value = 0.2f - uploadingDescription.value = "Loading" - contentResolver.openInputStream(fileUri)?.use { - createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent, relayList = relayList, context) - } - ?: run { - viewModelScope.launch { - onError(context.getString(R.string.could_not_open_the_compressed_file)) - isUploadingImage = false - uploadingPercentage.value = 0.00f - uploadingDescription.value = null - } - } - } else { - uploadingPercentage.value = 0.2f - uploadingDescription.value = "Uploading" - viewModelScope.launch(Dispatchers.IO) { - try { - val result = Nip96Uploader(account).uploadImage( - uri = fileUri, - contentType = contentType, - size = size, - alt = alt, - sensitiveContent = if (sensitiveContent) "" else null, - server = serverToUse.server, - contentResolver = contentResolver, - onProgress = { percent: Float -> - uploadingPercentage.value = 0.2f + (0.2f * percent) - } - ) - - createNIP94Record( - uploadingResult = result, - localContentType = contentType, - alt = alt, - sensitiveContent = sensitiveContent, - relayList = relayList, - context - ) - } catch (e: Exception) { - isUploadingImage = false - uploadingPercentage.value = 0.00f - uploadingDescription.value = null - onError(context.getString(R.string.failed_to_upload_media, e.message)) - } - } - } - }, - onError = { + viewModelScope.launch(Dispatchers.IO) { + uploadingPercentage.value = 0.1f + uploadingDescription.value = "Compress" + MediaCompressor() + .compress( + myGalleryUri, + contentType, + context.applicationContext, + onReady = { fileUri, contentType, size -> + if (serverToUse.isNip95) { + uploadingPercentage.value = 0.2f + uploadingDescription.value = "Loading" + contentResolver.openInputStream(fileUri)?.use { + createNIP95Record( + it.readBytes(), + contentType, + alt, + sensitiveContent, + relayList = relayList, + context, + ) + } + ?: run { + viewModelScope.launch { + onError(context.getString(R.string.could_not_open_the_compressed_file)) isUploadingImage = false uploadingPercentage.value = 0.00f uploadingDescription.value = null - onError(context.getString(R.string.error_when_compressing_media, it)) + } } - ) - } - } + } else { + uploadingPercentage.value = 0.2f + uploadingDescription.value = "Uploading" + viewModelScope.launch(Dispatchers.IO) { + try { + val result = + Nip96Uploader(account) + .uploadImage( + uri = fileUri, + contentType = contentType, + size = size, + alt = alt, + sensitiveContent = if (sensitiveContent) "" else null, + server = serverToUse.server, + contentResolver = contentResolver, + onProgress = { percent: Float -> + uploadingPercentage.value = 0.2f + (0.2f * percent) + }, + ) - open fun cancel() { - galleryUri = null - isUploadingImage = false - mediaType = null - uploadingDescription.value = null - uploadingPercentage.value = 0.0f - - alt = "" - selectedServer = ServerOption(defaultServer(), false) - } - - fun canPost(): Boolean { - return !isUploadingImage && galleryUri != null && selectedServer != null - } - - suspend fun createNIP94Record( - uploadingResult: Nip96Uploader.PartialEvent, - localContentType: String?, - alt: String, - sensitiveContent: Boolean, - relayList: List? = null, - context: Context - ) { - uploadingPercentage.value = 0.40f - uploadingDescription.value = "Server Processing" - // 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 originalHash = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "ox" }?.get(1)?.ifBlank { null } - val dim = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null } - val magnet = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "magnet" }?.get(1)?.ifBlank { null } - - if (imageUrl.isNullOrBlank()) { - Log.e("ImageDownload", "Couldn't download image from server") - cancel() - uploadingPercentage.value = 0.00f - uploadingDescription.value = null - isUploadingImage = false - onError(context.getString(R.string.server_did_not_provide_a_url_after_uploading)) - return - } - - uploadingDescription.value = "Downloading" - uploadingPercentage.value = 0.60f - - val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl) - - if (imageData != null) { - uploadingPercentage.value = 0.80f - uploadingDescription.value = "Hashing" - - FileHeader.prepare( - data = imageData, - mimeType = remoteMimeType ?: localContentType, - dimPrecomputed = dim, - onReady = { - uploadingPercentage.value = 0.90f - uploadingDescription.value = "Sending" - account?.sendHeader(imageUrl, magnet, it, alt, sensitiveContent, originalHash, relayList) { - uploadingPercentage.value = 1.00f - isUploadingImage = false - onceUploaded() - cancel() - } - }, - onError = { - cancel() - uploadingPercentage.value = 0.00f - uploadingDescription.value = null - isUploadingImage = false - onError(context.getString(R.string.could_not_prepare_local_file_to_upload, it)) + createNIP94Record( + uploadingResult = result, + localContentType = contentType, + alt = alt, + sensitiveContent = sensitiveContent, + relayList = relayList, + context, + ) + } catch (e: Exception) { + isUploadingImage = false + uploadingPercentage.value = 0.00f + uploadingDescription.value = null + onError(context.getString(R.string.failed_to_upload_media, e.message)) } - ) - } else { - Log.e("ImageDownload", "Couldn't download image from server") - cancel() - uploadingPercentage.value = 0.00f - uploadingDescription.value = null - isUploadingImage = false - onError(context.getString(R.string.could_not_download_from_the_server)) - } - } - - fun createNIP95Record( - bytes: ByteArray, - mimeType: String?, - alt: String, - sensitiveContent: Boolean, - relayList: List? = null, - context: Context - ) { - if (bytes.size > 80000) { - viewModelScope.launch { - onError("Media is too big for NIP-95") - isUploadingImage = false - uploadingPercentage.value = 0.00f - uploadingDescription.value = null + } } - return - } + }, + onError = { + isUploadingImage = false + uploadingPercentage.value = 0.00f + uploadingDescription.value = null + onError(context.getString(R.string.error_when_compressing_media, it)) + }, + ) + } + } - uploadingPercentage.value = 0.30f - uploadingDescription.value = "Hashing" + open fun cancel() { + galleryUri = null + isUploadingImage = false + mediaType = null + uploadingDescription.value = null + uploadingPercentage.value = 0.0f - viewModelScope.launch(Dispatchers.IO) { - FileHeader.prepare( - bytes, - mimeType, - null, - onReady = { - uploadingDescription.value = "Signing" - uploadingPercentage.value = 0.40f - account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 -> - uploadingDescription.value = "Sending" - uploadingPercentage.value = 0.60f - account?.consumeAndSendNip95(nip95.first, nip95.second, relayList) + alt = "" + selectedServer = ServerOption(defaultServer(), false) + } - uploadingPercentage.value = 1.00f - isUploadingImage = false - onceUploaded() - cancel() - } - }, - onError = { - uploadingDescription.value = null - uploadingPercentage.value = 0.00f - isUploadingImage = false - cancel() - onError(context.getString(R.string.could_not_prepare_local_file_to_upload, it)) - } - ) - } + fun canPost(): Boolean { + return !isUploadingImage && galleryUri != null && selectedServer != null + } + + suspend fun createNIP94Record( + uploadingResult: Nip96Uploader.PartialEvent, + localContentType: String?, + alt: String, + sensitiveContent: Boolean, + relayList: List? = null, + context: Context, + ) { + uploadingPercentage.value = 0.40f + uploadingDescription.value = "Server Processing" + // 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 originalHash = + uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "ox" }?.get(1)?.ifBlank { null } + val dim = + uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null } + val magnet = + uploadingResult.tags + ?.firstOrNull { it.size > 1 && it[0] == "magnet" } + ?.get(1) + ?.ifBlank { null } + + if (imageUrl.isNullOrBlank()) { + Log.e("ImageDownload", "Couldn't download image from server") + cancel() + uploadingPercentage.value = 0.00f + uploadingDescription.value = null + isUploadingImage = false + onError(context.getString(R.string.server_did_not_provide_a_url_after_uploading)) + return } - fun isImage() = mediaType?.startsWith("image") - fun isVideo() = mediaType?.startsWith("video") - fun defaultServer() = account?.defaultFileServer ?: Nip96MediaServers.DEFAULT[0] - fun onceUploaded(onceUploaded: () -> Unit) { - this.onceUploaded = onceUploaded + uploadingDescription.value = "Downloading" + uploadingPercentage.value = 0.60f + + val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl) + + if (imageData != null) { + uploadingPercentage.value = 0.80f + uploadingDescription.value = "Hashing" + + FileHeader.prepare( + data = imageData, + mimeType = remoteMimeType ?: localContentType, + dimPrecomputed = dim, + onReady = { + uploadingPercentage.value = 0.90f + uploadingDescription.value = "Sending" + account?.sendHeader( + imageUrl, + magnet, + it, + alt, + sensitiveContent, + originalHash, + relayList, + ) { + uploadingPercentage.value = 1.00f + isUploadingImage = false + onceUploaded() + cancel() + } + }, + onError = { + cancel() + uploadingPercentage.value = 0.00f + uploadingDescription.value = null + isUploadingImage = false + onError(context.getString(R.string.could_not_prepare_local_file_to_upload, it)) + }, + ) + } else { + Log.e("ImageDownload", "Couldn't download image from server") + cancel() + uploadingPercentage.value = 0.00f + uploadingDescription.value = null + isUploadingImage = false + onError(context.getString(R.string.could_not_download_from_the_server)) } + } + + fun createNIP95Record( + bytes: ByteArray, + mimeType: String?, + alt: String, + sensitiveContent: Boolean, + relayList: List? = null, + context: Context, + ) { + if (bytes.size > 80000) { + viewModelScope.launch { + onError("Media is too big for NIP-95") + isUploadingImage = false + uploadingPercentage.value = 0.00f + uploadingDescription.value = null + } + return + } + + uploadingPercentage.value = 0.30f + uploadingDescription.value = "Hashing" + + viewModelScope.launch(Dispatchers.IO) { + FileHeader.prepare( + bytes, + mimeType, + null, + onReady = { + uploadingDescription.value = "Signing" + uploadingPercentage.value = 0.40f + account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 -> + uploadingDescription.value = "Sending" + uploadingPercentage.value = 0.60f + account?.consumeAndSendNip95(nip95.first, nip95.second, relayList) + + uploadingPercentage.value = 1.00f + isUploadingImage = false + onceUploaded() + cancel() + } + }, + onError = { + uploadingDescription.value = null + uploadingPercentage.value = 0.00f + isUploadingImage = false + cancel() + onError(context.getString(R.string.could_not_prepare_local_file_to_upload, it)) + }, + ) + } + } + + fun isImage() = mediaType?.startsWith("image") + + fun isVideo() = mediaType?.startsWith("video") + + fun defaultServer() = account?.defaultFileServer ?: Nip96MediaServers.DEFAULT[0] + + fun onceUploaded(onceUploaded: () -> Unit) { + this.onceUploaded = onceUploaded + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt index cea93ab50..838ba5dac 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.actions import android.graphics.Bitmap @@ -56,237 +76,234 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @Composable -fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val account = accountViewModel.account - val resolver = LocalContext.current.contentResolver - val context = LocalContext.current +fun NewMediaView( + uri: Uri, + onClose: () -> Unit, + postViewModel: NewMediaModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val account = accountViewModel.account + val resolver = LocalContext.current.contentResolver + val context = LocalContext.current - val scroolState = rememberScrollState() + val scroolState = rememberScrollState() - LaunchedEffect(uri) { - val mediaType = resolver.getType(uri) ?: "" - postViewModel.load(account, uri, mediaType) { - accountViewModel.toast(context.getString(R.string.failed_to_upload_media_no_details), it) - } + LaunchedEffect(uri) { + val mediaType = resolver.getType(uri) ?: "" + postViewModel.load(account, uri, mediaType) { + accountViewModel.toast(context.getString(R.string.failed_to_upload_media_no_details), it) } + } - var showRelaysDialog by remember { - mutableStateOf(false) - } - var relayList = remember { - accountViewModel.account.activeWriteRelays().toImmutableList() - } + var showRelaysDialog by remember { mutableStateOf(false) } + var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() } - Dialog( - onDismissRequest = { onClose() }, - properties = DialogProperties( - usePlatformDefaultWidth = false, - dismissOnClickOutside = false, - decorFitsSystemWindows = false - ) + Dialog( + onDismissRequest = { onClose() }, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + decorFitsSystemWindows = false, + ), + ) { + Surface( + modifier = Modifier.fillMaxWidth(), ) { - Surface( - modifier = Modifier - .fillMaxWidth() + if (showRelaysDialog) { + RelaySelectionDialog( + preSelectedList = relayList, + onClose = { showRelaysDialog = false }, + onPost = { relayList = it }, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + Column( + modifier = + Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp) + .fillMaxWidth() + .fillMaxHeight() + .imePadding(), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - if (showRelaysDialog) { - RelaySelectionDialog( - preSelectedList = relayList, - onClose = { - showRelaysDialog = false - }, - onPost = { - relayList = it - }, - accountViewModel = accountViewModel, - nav = nav - ) - } + CloseButton( + onPress = { + postViewModel.cancel() + onClose() + }, + ) - Column( - modifier = Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp) - .fillMaxWidth() - .fillMaxHeight().imePadding() + Box { + IconButton( + modifier = Modifier.align(Alignment.Center), + onClick = { showRelaysDialog = true }, ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - CloseButton(onPress = { - postViewModel.cancel() - onClose() - }) - - Box { - IconButton( - modifier = Modifier.align(Alignment.Center), - onClick = { - showRelaysDialog = true - } - ) { - Icon( - painter = painterResource(R.drawable.relays), - contentDescription = null, - modifier = Modifier.height(25.dp), - tint = MaterialTheme.colorScheme.onBackground - ) - } - } - - PostButton( - onPost = { - onClose() - postViewModel.upload(context, relayList) - postViewModel.selectedServer?.let { - if (!it.isNip95) { - account.changeDefaultFileServer(it.server) - } - } - }, - isActive = postViewModel.canPost() - ) - } - - Row( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(scroolState) - ) { - ImageVideoPost(postViewModel, accountViewModel) - } - } + Icon( + painter = painterResource(R.drawable.relays), + contentDescription = null, + modifier = Modifier.height(25.dp), + tint = MaterialTheme.colorScheme.onBackground, + ) } + } + + PostButton( + onPost = { + onClose() + postViewModel.upload(context, relayList) + postViewModel.selectedServer?.let { + if (!it.isNip95) { + account.changeDefaultFileServer(it.server) + } + } + }, + isActive = postViewModel.canPost(), + ) } + + Row( + modifier = Modifier.fillMaxWidth().weight(1f), + ) { + Column( + modifier = Modifier.fillMaxWidth().verticalScroll(scroolState), + ) { + ImageVideoPost(postViewModel, accountViewModel) + } + } + } } + } } @Composable -fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewModel) { - val fileServers = Nip96MediaServers.DEFAULT.map { ServerOption(it, false) } + listOf( +fun ImageVideoPost( + postViewModel: NewMediaModel, + accountViewModel: AccountViewModel, +) { + val fileServers = + Nip96MediaServers.DEFAULT.map { ServerOption(it, false) } + + listOf( ServerOption( - Nip96MediaServers.ServerName( - "NIP95", - stringResource(id = R.string.upload_server_relays_nip95) - ), - true - ) - ) + Nip96MediaServers.ServerName( + "NIP95", + stringResource(id = R.string.upload_server_relays_nip95), + ), + true, + ), + ) - val fileServerOptions = remember { fileServers.map { TitleExplainer(it.server.name, it.server.baseUrl) }.toImmutableList() } - val resolver = LocalContext.current.contentResolver + val fileServerOptions = remember { + fileServers.map { TitleExplainer(it.server.name, it.server.baseUrl) }.toImmutableList() + } + val resolver = LocalContext.current.contentResolver - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.fillMaxWidth() + .padding(bottom = 10.dp) + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), + ) { + if (postViewModel.isImage() == true) { + AsyncImage( + model = postViewModel.galleryUri.toString(), + contentDescription = postViewModel.galleryUri.toString(), + contentScale = ContentScale.FillWidth, + modifier = + Modifier.padding(top = 4.dp) .fillMaxWidth() - .padding(bottom = 10.dp) - .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) - ) { - if (postViewModel.isImage() == true) { - AsyncImage( - model = postViewModel.galleryUri.toString(), - contentDescription = postViewModel.galleryUri.toString(), - contentScale = ContentScale.FillWidth, - modifier = Modifier - .padding(top = 4.dp) - .fillMaxWidth() - .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) - ) - } else if (postViewModel.isVideo() == true && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - var bitmap by remember { mutableStateOf(null) } + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), + ) + } else if (postViewModel.isVideo() == true && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + var bitmap by remember { mutableStateOf(null) } - LaunchedEffect(key1 = postViewModel.galleryUri) { - launch(Dispatchers.IO) { - postViewModel.galleryUri?.let { - try { - bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null) - } catch (e: Exception) { - Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e) - } - } - } - } - - bitmap?.let { - Image( - bitmap = it.asImageBitmap(), - contentDescription = "some useful description", - contentScale = ContentScale.FillWidth, - modifier = Modifier - .padding(top = 4.dp) - .fillMaxWidth() - ) - } - } else { - postViewModel.galleryUri?.let { - VideoView( - videoUri = it.toString(), - roundedCorner = false, - accountViewModel = accountViewModel - ) + LaunchedEffect(key1 = postViewModel.galleryUri) { + launch(Dispatchers.IO) { + postViewModel.galleryUri?.let { + try { + bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null) + } catch (e: Exception) { + Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e) } + } } - } + } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - TextSpinner( - label = stringResource(id = R.string.file_server), - placeholder = fileServers.firstOrNull { - it.server == accountViewModel.account.defaultFileServer - }?.server?.name ?: fileServers[0].server.name, - options = fileServerOptions, - onSelect = { - postViewModel.selectedServer = fileServers[it] - }, - modifier = Modifier - .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) - .weight(1f) + bitmap?.let { + Image( + bitmap = it.asImageBitmap(), + contentDescription = "some useful description", + contentScale = ContentScale.FillWidth, + modifier = Modifier.padding(top = 4.dp).fillMaxWidth(), ) + } + } else { + postViewModel.galleryUri?.let { + VideoView( + videoUri = it.toString(), + roundedCorner = false, + accountViewModel = accountViewModel, + ) + } } + } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - SettingSwitchItem( - checked = postViewModel.sensitiveContent, - onCheckedChange = { postViewModel.sensitiveContent = it }, - title = R.string.add_sensitive_content_label, - description = R.string.add_sensitive_content_description - ) - } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + TextSpinner( + label = stringResource(id = R.string.file_server), + placeholder = + fileServers + .firstOrNull { it.server == accountViewModel.account.defaultFileServer } + ?.server + ?.name + ?: fileServers[0].server.name, + options = fileServerOptions, + onSelect = { postViewModel.selectedServer = fileServers[it] }, + modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)).weight(1f), + ) + } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) - ) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.content_description)) }, - modifier = Modifier - .fillMaxWidth() - .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), - value = postViewModel.alt, - onValueChange = { postViewModel.alt = it }, - placeholder = { - Text( - text = stringResource(R.string.content_description_example), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + SettingSwitchItem( + checked = postViewModel.sensitiveContent, + onCheckedChange = { postViewModel.sensitiveContent = it }, + title = R.string.add_sensitive_content_label, + description = R.string.add_sensitive_content_description, + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.content_description)) }, + modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), + value = postViewModel.alt, + onValueChange = { postViewModel.alt = it }, + placeholder = { + Text( + text = stringResource(R.string.content_description_example), + color = MaterialTheme.colorScheme.placeholderText, ) - } + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt index 28aac09ca..7cc18bba5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.actions import androidx.compose.runtime.Immutable @@ -11,168 +31,182 @@ import com.vitorpamplona.quartz.encoders.bechToBytes import com.vitorpamplona.quartz.encoders.toNpub class NewMessageTagger( - var message: String, - var pTags: List? = null, - var eTags: List? = null, - var channelHex: String? = null, - var dao: Dao + var message: String, + var pTags: List? = null, + var eTags: List? = null, + var channelHex: String? = null, + var dao: Dao, ) { + val directMentions = mutableSetOf() - val directMentions = mutableSetOf() + fun addUserToMentions(user: User) { + directMentions.add(user.pubkeyHex) + pTags = if (pTags?.contains(user) == true) pTags else pTags?.plus(user) ?: listOf(user) + } - fun addUserToMentions(user: User) { - directMentions.add(user.pubkeyHex) - pTags = if (pTags?.contains(user) == true) pTags else pTags?.plus(user) ?: listOf(user) - } + fun addNoteToReplyTos(note: Note) { + directMentions.add(note.idHex) - fun addNoteToReplyTos(note: Note) { - directMentions.add(note.idHex) + note.author?.let { addUserToMentions(it) } + eTags = if (eTags?.contains(note) == true) eTags else eTags?.plus(note) ?: listOf(note) + } - note.author?.let { addUserToMentions(it) } - eTags = if (eTags?.contains(note) == true) eTags else eTags?.plus(note) ?: listOf(note) - } + fun tagIndex(user: User): Int { + // Postr Events assembles replies before mentions in the tag order + return (if (channelHex != null) 1 else 0) + (eTags?.size ?: 0) + (pTags?.indexOf(user) ?: 0) + } - fun tagIndex(user: User): Int { - // Postr Events assembles replies before mentions in the tag order - return (if (channelHex != null) 1 else 0) + (eTags?.size ?: 0) + (pTags?.indexOf(user) ?: 0) - } + fun tagIndex(note: Note): Int { + // Postr Events assembles replies before mentions in the tag order + return (if (channelHex != null) 1 else 0) + (eTags?.indexOf(note) ?: 0) + } - fun tagIndex(note: Note): Int { - // Postr Events assembles replies before mentions in the tag order - return (if (channelHex != null) 1 else 0) + (eTags?.indexOf(note) ?: 0) - } + suspend fun run() { + // adds all references to mentions and reply tos + message.split('\n').forEach { paragraph: String -> + paragraph.split(' ').forEach { word: String -> + val results = parseDirtyWordForKey(word) - suspend fun run() { - // adds all references to mentions and reply tos - message.split('\n').forEach { paragraph: String -> - paragraph.split(' ').forEach { word: String -> - val results = parseDirtyWordForKey(word) - - if (results?.key?.type == Nip19.Type.USER) { - addUserToMentions(dao.getOrCreateUser(results.key.hex)) - } else if (results?.key?.type == Nip19.Type.NOTE) { - addNoteToReplyTos(dao.getOrCreateNote(results.key.hex)) - } else if (results?.key?.type == Nip19.Type.EVENT) { - addNoteToReplyTos(dao.getOrCreateNote(results.key.hex)) - } else if (results?.key?.type == Nip19.Type.ADDRESS) { - val note = dao.checkGetOrCreateAddressableNote(results.key.hex) - if (note != null) { - addNoteToReplyTos(note) - } - } - } + if (results?.key?.type == Nip19.Type.USER) { + addUserToMentions(dao.getOrCreateUser(results.key.hex)) + } else if (results?.key?.type == Nip19.Type.NOTE) { + addNoteToReplyTos(dao.getOrCreateNote(results.key.hex)) + } else if (results?.key?.type == Nip19.Type.EVENT) { + addNoteToReplyTos(dao.getOrCreateNote(results.key.hex)) + } else if (results?.key?.type == Nip19.Type.ADDRESS) { + val note = dao.checkGetOrCreateAddressableNote(results.key.hex) + if (note != null) { + addNoteToReplyTos(note) + } } + } + } - // Tags the text in the correct order. - message = message.split('\n').map { paragraph: String -> - paragraph.split(' ').map { word: String -> - val results = parseDirtyWordForKey(word) - if (results?.key?.type == Nip19.Type.USER) { - val user = dao.getOrCreateUser(results.key.hex) + // Tags the text in the correct order. + message = + message + .split('\n') + .map { paragraph: String -> + paragraph + .split(' ') + .map { word: String -> + val results = parseDirtyWordForKey(word) + if (results?.key?.type == Nip19.Type.USER) { + val user = dao.getOrCreateUser(results.key.hex) - getNostrAddress(user.pubkeyNpub(), results.restOfWord) - } else if (results?.key?.type == Nip19.Type.NOTE) { - val note = dao.getOrCreateNote(results.key.hex) + getNostrAddress(user.pubkeyNpub(), results.restOfWord) + } else if (results?.key?.type == Nip19.Type.NOTE) { + val note = dao.getOrCreateNote(results.key.hex) - getNostrAddress(note.toNEvent(), results.restOfWord) - } else if (results?.key?.type == Nip19.Type.EVENT) { - val note = dao.getOrCreateNote(results.key.hex) + getNostrAddress(note.toNEvent(), results.restOfWord) + } else if (results?.key?.type == Nip19.Type.EVENT) { + val note = dao.getOrCreateNote(results.key.hex) - getNostrAddress(note.toNEvent(), results.restOfWord) - } else if (results?.key?.type == Nip19.Type.ADDRESS) { - val note = dao.checkGetOrCreateAddressableNote(results.key.hex) - if (note != null) { - getNostrAddress(note.idNote(), results.restOfWord) - } else { - word - } + getNostrAddress(note.toNEvent(), results.restOfWord) + } else if (results?.key?.type == Nip19.Type.ADDRESS) { + val note = dao.checkGetOrCreateAddressableNote(results.key.hex) + if (note != null) { + getNostrAddress(note.idNote(), results.restOfWord) } else { - word + word } - }.joinToString(" ") - }.joinToString("\n") - } - - fun getNostrAddress(bechAddress: String, restOfTheWord: String): String { - return if (restOfTheWord.isEmpty()) { - "nostr:$bechAddress" - } else { - if (Bech32.alphabet.contains(restOfTheWord.get(0), true)) { - "nostr:$bechAddress $restOfTheWord" - } else { - "nostr:${bechAddress}$restOfTheWord" + } else { + word + } } + .joinToString(" ") } + .joinToString("\n") + } + + fun getNostrAddress( + bechAddress: String, + restOfTheWord: String, + ): String { + return if (restOfTheWord.isEmpty()) { + "nostr:$bechAddress" + } else { + if (Bech32.ALPHABET.contains(restOfTheWord.get(0), true)) { + "nostr:$bechAddress $restOfTheWord" + } else { + "nostr:${bechAddress}$restOfTheWord" + } + } + } + + @Immutable data class DirtyKeyInfo(val key: Nip19.Return, val restOfWord: String) + + fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? { + var key = mightBeAKey + if (key.startsWith("nostr:", true)) { + key = key.substring("nostr:".length) } - @Immutable - data class DirtyKeyInfo(val key: Nip19.Return, val restOfWord: String) + key = key.removePrefix("@") - fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? { - var key = mightBeAKey - if (key.startsWith("nostr:", true)) { - key = key.substring("nostr:".length) + try { + if (key.startsWith("nsec1", true)) { + if (key.length < 63) { + return null } - key = key.removePrefix("@") + val keyB32 = key.substring(0, 63) + val restOfWord = key.substring(63) + // Converts to npub + val pubkey = + Nip19.uriToRoute(KeyPair(privKey = keyB32.bechToBytes()).pubKey.toNpub()) ?: return null - try { - if (key.startsWith("nsec1", true)) { - if (key.length < 63) { - return null - } - - val keyB32 = key.substring(0, 63) - val restOfWord = key.substring(63) - // Converts to npub - val pubkey = Nip19.uriToRoute(KeyPair(privKey = keyB32.bechToBytes()).pubKey.toNpub()) ?: return null - - return DirtyKeyInfo(pubkey, restOfWord) - } else if (key.startsWith("npub1", true)) { - if (key.length < 63) { - return null - } - - val keyB32 = key.substring(0, 63) - val restOfWord = key.substring(63) - - val pubkey = Nip19.uriToRoute(keyB32) ?: return null - - return DirtyKeyInfo(pubkey, restOfWord) - } else if (key.startsWith("note1", true)) { - if (key.length < 63) { - return null - } - - val keyB32 = key.substring(0, 63) - val restOfWord = key.substring(63) - - val noteId = Nip19.uriToRoute(keyB32) ?: return null - - return DirtyKeyInfo(noteId, restOfWord) - } else if (key.startsWith("nprofile", true)) { - val pubkeyRelay = Nip19.uriToRoute(key) ?: return null - - return DirtyKeyInfo(pubkeyRelay, pubkeyRelay.additionalChars) - } else if (key.startsWith("nevent1", true)) { - val noteRelayId = Nip19.uriToRoute(key) ?: return null - - return DirtyKeyInfo(noteRelayId, noteRelayId.additionalChars) - } else if (key.startsWith("naddr1", true)) { - val address = Nip19.uriToRoute(key) ?: return null - - return DirtyKeyInfo(address, address.additionalChars) // no way to know when they address ends and dirt begins - } - } catch (e: Exception) { - e.printStackTrace() + return DirtyKeyInfo(pubkey, restOfWord) + } else if (key.startsWith("npub1", true)) { + if (key.length < 63) { + return null } - return null + val keyB32 = key.substring(0, 63) + val restOfWord = key.substring(63) + + val pubkey = Nip19.uriToRoute(keyB32) ?: return null + + return DirtyKeyInfo(pubkey, restOfWord) + } else if (key.startsWith("note1", true)) { + if (key.length < 63) { + return null + } + + val keyB32 = key.substring(0, 63) + val restOfWord = key.substring(63) + + val noteId = Nip19.uriToRoute(keyB32) ?: return null + + return DirtyKeyInfo(noteId, restOfWord) + } else if (key.startsWith("nprofile", true)) { + val pubkeyRelay = Nip19.uriToRoute(key) ?: return null + + return DirtyKeyInfo(pubkeyRelay, pubkeyRelay.additionalChars) + } else if (key.startsWith("nevent1", true)) { + val noteRelayId = Nip19.uriToRoute(key) ?: return null + + return DirtyKeyInfo(noteRelayId, noteRelayId.additionalChars) + } else if (key.startsWith("naddr1", true)) { + val address = Nip19.uriToRoute(key) ?: return null + + return DirtyKeyInfo( + address, + address.additionalChars, + ) // no way to know when they address ends and dirt begins + } + } catch (e: Exception) { + e.printStackTrace() } + + return null + } } interface Dao { - suspend fun getOrCreateUser(hex: String): User - suspend fun getOrCreateNote(hex: String): Note - suspend fun checkGetOrCreateAddressableNote(hex: String): Note? + suspend fun getOrCreateUser(hex: String): User + + suspend fun getOrCreateNote(hex: String): Note + + suspend fun checkGetOrCreateAddressableNote(hex: String): Note? } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt index 61a4e5029..6224716ee 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import androidx.compose.foundation.layout.Arrangement @@ -26,55 +46,61 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable fun NewPollClosing(pollViewModel: NewPostViewModel) { - var text by rememberSaveable { mutableStateOf("") } + var text by rememberSaveable { mutableStateOf("") } - pollViewModel.isValidClosedAt.value = true - if (text.isNotEmpty()) { - try { - val int = text.toInt() - if (int < 0) { - pollViewModel.isValidClosedAt.value = false - } else { pollViewModel.closedAt = int } - } catch (e: Exception) { pollViewModel.isValidClosedAt.value = false } + pollViewModel.isValidClosedAt.value = true + if (text.isNotEmpty()) { + try { + val int = text.toInt() + if (int < 0) { + pollViewModel.isValidClosedAt.value = false + } else { + pollViewModel.closedAt = int + } + } catch (e: Exception) { + pollViewModel.isValidClosedAt.value = false } + } - val colorInValid = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.error, - unfocusedBorderColor = Color.Red + val colorInValid = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.error, + unfocusedBorderColor = Color.Red, ) - val colorValid = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText + val colorValid = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText, ) - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - OutlinedTextField( - value = text, - onValueChange = { text = it }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.width(150.dp), - colors = if (pollViewModel.isValidClosedAt.value) colorValid else colorInValid, - label = { - Text( - text = stringResource(R.string.poll_closing_time), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - placeholder = { - Text( - text = stringResource(R.string.poll_closing_time_days), - color = MaterialTheme.colorScheme.placeholderText - ) - } + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.width(150.dp), + colors = if (pollViewModel.isValidClosedAt.value) colorValid else colorInValid, + label = { + Text( + text = stringResource(R.string.poll_closing_time), + color = MaterialTheme.colorScheme.placeholderText, ) - } + }, + placeholder = { + Text( + text = stringResource(R.string.poll_closing_time_days), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + ) + } } @Preview @Composable fun NewPollClosingPreview() { - NewPollClosing(NewPostViewModel()) + NewPollClosing(NewPostViewModel()) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt index 571e11276..11af117e2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import androidx.compose.foundation.layout.Arrangement @@ -26,55 +46,61 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable fun NewPollConsensusThreshold(pollViewModel: NewPostViewModel) { - var text by rememberSaveable { mutableStateOf("") } + var text by rememberSaveable { mutableStateOf("") } - pollViewModel.isValidConsensusThreshold.value = true - if (text.isNotEmpty()) { - try { - val int = text.toInt() - if (int < 0 || int > 100) { - pollViewModel.isValidConsensusThreshold.value = false - } else { pollViewModel.consensusThreshold = int } - } catch (e: Exception) { pollViewModel.isValidConsensusThreshold.value = false } + pollViewModel.isValidConsensusThreshold.value = true + if (text.isNotEmpty()) { + try { + val int = text.toInt() + if (int < 0 || int > 100) { + pollViewModel.isValidConsensusThreshold.value = false + } else { + pollViewModel.consensusThreshold = int + } + } catch (e: Exception) { + pollViewModel.isValidConsensusThreshold.value = false } + } - val colorInValid = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.error, - unfocusedBorderColor = Color.Red + val colorInValid = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.error, + unfocusedBorderColor = Color.Red, ) - val colorValid = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText + val colorValid = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText, ) - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - OutlinedTextField( - value = text, - onValueChange = { text = it }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.width(150.dp), - colors = if (pollViewModel.isValidConsensusThreshold.value) colorValid else colorInValid, - label = { - Text( - text = stringResource(R.string.poll_consensus_threshold), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - placeholder = { - Text( - text = stringResource(R.string.poll_consensus_threshold_percent), - color = MaterialTheme.colorScheme.placeholderText - ) - } + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.width(150.dp), + colors = if (pollViewModel.isValidConsensusThreshold.value) colorValid else colorInValid, + label = { + Text( + text = stringResource(R.string.poll_consensus_threshold), + color = MaterialTheme.colorScheme.placeholderText, ) - } + }, + placeholder = { + Text( + text = stringResource(R.string.poll_consensus_threshold_percent), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + ) + } } @Preview @Composable fun NewPollConsensusThresholdPreview() { - NewPollConsensusThreshold(NewPostViewModel()) + NewPollConsensusThreshold(NewPostViewModel()) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt index 64ffd0a50..58bd075f6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.actions import androidx.compose.foundation.layout.Row @@ -18,48 +38,51 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable -fun NewPollOption(pollViewModel: NewPostViewModel, optionIndex: Int) { - Row { - val deleteIcon: @Composable (() -> Unit) = { - IconButton( - onClick = { - pollViewModel.pollOptions.remove(optionIndex) - } - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = stringResource(R.string.clear) - ) - } - } - - OutlinedTextField( - modifier = Modifier.weight(1F), - value = pollViewModel.pollOptions[optionIndex] ?: "", - onValueChange = { pollViewModel.pollOptions[optionIndex] = it }, - label = { - Text( - text = stringResource(R.string.poll_option_index).format(optionIndex + 1), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - placeholder = { - Text( - text = stringResource(R.string.poll_option_description), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences - ), - // colors = if (pollViewModel.pollOptions[optionIndex]?.isNotEmpty() == true) colorValid else colorInValid, - trailingIcon = if (optionIndex > 1) deleteIcon else null +fun NewPollOption( + pollViewModel: NewPostViewModel, + optionIndex: Int, +) { + Row { + val deleteIcon: @Composable (() -> Unit) = { + IconButton( + onClick = { pollViewModel.pollOptions.remove(optionIndex) }, + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.clear), ) + } } + + OutlinedTextField( + modifier = Modifier.weight(1F), + value = pollViewModel.pollOptions[optionIndex] ?: "", + onValueChange = { pollViewModel.pollOptions[optionIndex] = it }, + label = { + Text( + text = stringResource(R.string.poll_option_index).format(optionIndex + 1), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + placeholder = { + Text( + text = stringResource(R.string.poll_option_description), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + // colors = if (pollViewModel.pollOptions[optionIndex]?.isNotEmpty() == true) colorValid else + // colorInValid, + trailingIcon = if (optionIndex > 1) deleteIcon else null, + ) + } } @Preview @Composable fun NewPollOptionPreview() { - NewPollOption(NewPostViewModel(), 0) + NewPollOption(NewPostViewModel(), 0) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt index 93c7a1f7e..d9398411c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import androidx.compose.foundation.layout.fillMaxWidth @@ -29,55 +49,53 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText @OptIn(ExperimentalComposeUiApi::class) @Composable fun NewPollPrimaryDescription(pollViewModel: NewPostViewModel) { - // initialize focus reference to be able to request focus programmatically - val focusRequester = remember { FocusRequester() } - val keyboardController = LocalSoftwareKeyboardController.current + // initialize focus reference to be able to request focus programmatically + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current - var isInputValid = true - if (pollViewModel.message.text.isEmpty()) { - isInputValid = false - } + var isInputValid = true + if (pollViewModel.message.text.isEmpty()) { + isInputValid = false + } - val colorInValid = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.error, - unfocusedBorderColor = Color.Red + val colorInValid = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.error, + unfocusedBorderColor = Color.Red, ) - val colorValid = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText + val colorValid = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText, ) - OutlinedTextField( - value = pollViewModel.message, - onValueChange = { - pollViewModel.updateMessage(it) - }, - label = { - Text( - text = stringResource(R.string.poll_primary_description), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences - ), - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - .focusRequester(focusRequester) - .onFocusChanged { - if (it.isFocused) { - keyboardController?.show() - } - }, - placeholder = { - Text( - text = stringResource(R.string.poll_primary_description), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - colors = if (isInputValid) colorValid else colorInValid, - visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), - textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content) - ) + OutlinedTextField( + value = pollViewModel.message, + onValueChange = { pollViewModel.updateMessage(it) }, + label = { + Text( + text = stringResource(R.string.poll_primary_description), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + modifier = + Modifier.fillMaxWidth().padding(top = 8.dp).focusRequester(focusRequester).onFocusChanged { + if (it.isFocused) { + keyboardController?.show() + } + }, + placeholder = { + Text( + text = stringResource(R.string.poll_primary_description), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + colors = if (isInputValid) colorValid else colorInValid, + visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt index 0782d409b..f53b41630 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import androidx.compose.foundation.layout.fillMaxWidth @@ -13,32 +33,33 @@ import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable -fun NewPollRecipientsField(pollViewModel: NewPostViewModel, account: Account) { - // if no recipients, add user's pubkey - if (pollViewModel.zapRecipients.isEmpty()) { - pollViewModel.zapRecipients.add(account.userProfile().pubkeyHex) - } +fun NewPollRecipientsField( + pollViewModel: NewPostViewModel, + account: Account, +) { + // if no recipients, add user's pubkey + if (pollViewModel.zapRecipients.isEmpty()) { + pollViewModel.zapRecipients.add(account.userProfile().pubkeyHex) + } - // TODO allow add multiple recipients and check input validity + // TODO allow add multiple recipients and check input validity - OutlinedTextField( - modifier = Modifier - .fillMaxWidth(), - value = pollViewModel.zapRecipients[0], - onValueChange = { /* TODO */ }, - enabled = false, // TODO enable add recipients - label = { - Text( - text = stringResource(R.string.poll_zap_recipients), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - placeholder = { - Text( - text = stringResource(R.string.poll_zap_recipients), - color = MaterialTheme.colorScheme.placeholderText - ) - } - - ) + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = pollViewModel.zapRecipients[0], + onValueChange = { /* TODO */}, + enabled = false, + label = { + Text( + text = stringResource(R.string.poll_zap_recipients), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + placeholder = { + Text( + text = stringResource(R.string.poll_zap_recipients), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt index 3223cb01f..4e766fc37 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.actions import androidx.compose.foundation.layout.Arrangement @@ -24,80 +44,82 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) { - val colorInValid = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.error, - unfocusedBorderColor = Color.Red + val colorInValid = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.error, + unfocusedBorderColor = Color.Red, ) - val colorValid = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText + val colorValid = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText, ) - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - OutlinedTextField( - value = pollViewModel.valueMinimum?.toString() ?: "", - onValueChange = { pollViewModel.updateMinZapAmountForPoll(it) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.weight(1f), - colors = if (pollViewModel.isValidvalueMinimum.value) colorValid else colorInValid, - label = { - Text( - text = stringResource(R.string.poll_zap_value_min), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - placeholder = { - Text( - text = stringResource(R.string.sats), - color = MaterialTheme.colorScheme.placeholderText - ) - } - ) - - Spacer(modifier = DoubleHorzSpacer) - - OutlinedTextField( - value = pollViewModel.valueMaximum?.toString() ?: "", - onValueChange = { pollViewModel.updateMaxZapAmountForPoll(it) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.weight(1f), - colors = if (pollViewModel.isValidvalueMaximum.value) colorValid else colorInValid, - label = { - Text( - text = stringResource(R.string.poll_zap_value_max), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - placeholder = { - Text( - text = stringResource(R.string.sats), - color = MaterialTheme.colorScheme.placeholderText - ) - } - ) - } - - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + OutlinedTextField( + value = pollViewModel.valueMinimum?.toString() ?: "", + onValueChange = { pollViewModel.updateMinZapAmountForPoll(it) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + colors = if (pollViewModel.isValidvalueMinimum.value) colorValid else colorInValid, + label = { Text( - text = stringResource(R.string.poll_zap_value_min_max_explainer), - color = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.padding(vertical = 10.dp) + text = stringResource(R.string.poll_zap_value_min), + color = MaterialTheme.colorScheme.placeholderText, ) - } + }, + placeholder = { + Text( + text = stringResource(R.string.sats), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + ) + + Spacer(modifier = DoubleHorzSpacer) + + OutlinedTextField( + value = pollViewModel.valueMaximum?.toString() ?: "", + onValueChange = { pollViewModel.updateMaxZapAmountForPoll(it) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + colors = if (pollViewModel.isValidvalueMaximum.value) colorValid else colorInValid, + label = { + Text( + text = stringResource(R.string.poll_zap_value_max), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + placeholder = { + Text( + text = stringResource(R.string.sats), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + ) + } + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.poll_zap_value_min_max_explainer), + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(vertical = 10.dp), + ) + } } @Preview @Composable fun NewPollVoteValueRangePreview() { - Column( - modifier = Modifier.fillMaxWidth() - ) { - NewPollVoteValueRange(NewPostViewModel()) - } + Column( + modifier = Modifier.fillMaxWidth(), + ) { + NewPollVoteValueRange(NewPostViewModel()) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index a3929bb94..158c12fb5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.actions import android.Manifest @@ -157,6 +177,7 @@ import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.toImmutableListOfLists +import java.lang.Math.round import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers @@ -164,1686 +185,1624 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.lang.Math.round @OptIn(ExperimentalMaterial3Api::class) @Composable fun NewPostView( - onClose: () -> Unit, - baseReplyTo: Note? = null, - quote: Note? = null, - enableMessageInterface: Boolean = false, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + onClose: () -> Unit, + baseReplyTo: Note? = null, + quote: Note? = null, + enableMessageInterface: Boolean = false, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val postViewModel: NewPostViewModel = viewModel() - postViewModel.wantsDirectMessage = enableMessageInterface + val postViewModel: NewPostViewModel = viewModel() + postViewModel.wantsDirectMessage = enableMessageInterface - val context = LocalContext.current + val context = LocalContext.current - val scrollState = rememberScrollState() - val scope = rememberCoroutineScope() - var showRelaysDialog by remember { - mutableStateOf(false) + val scrollState = rememberScrollState() + val scope = rememberCoroutineScope() + var showRelaysDialog by remember { mutableStateOf(false) } + var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() } + + LaunchedEffect(Unit) { + postViewModel.load(accountViewModel, baseReplyTo, quote) + + launch(Dispatchers.IO) { + postViewModel.imageUploadingError.collect { error -> + withContext(Dispatchers.Main) { Toast.makeText(context, error, Toast.LENGTH_SHORT).show() } + } } - var relayList = remember { - accountViewModel.account.activeWriteRelays().toImmutableList() + } + + DisposableEffect(Unit) { + NostrSearchEventOrUserDataSource.start() + + onDispose { + NostrSearchEventOrUserDataSource.clear() + NostrSearchEventOrUserDataSource.stop() + } + } + + Dialog( + onDismissRequest = { onClose() }, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + decorFitsSystemWindows = false, + ), + ) { + if (showRelaysDialog) { + RelaySelectionDialog( + preSelectedList = relayList, + onClose = { showRelaysDialog = false }, + onPost = { relayList = it }, + accountViewModel = accountViewModel, + nav = nav, + ) } - LaunchedEffect(Unit) { - postViewModel.load(accountViewModel, baseReplyTo, quote) - - launch(Dispatchers.IO) { - postViewModel.imageUploadingError.collect { error -> - withContext(Dispatchers.Main) { - Toast.makeText(context, error, Toast.LENGTH_SHORT).show() - } - } - } - } - - DisposableEffect(Unit) { - NostrSearchEventOrUserDataSource.start() - - onDispose { - NostrSearchEventOrUserDataSource.clear() - NostrSearchEventOrUserDataSource.stop() - } - } - - Dialog( - onDismissRequest = { onClose() }, - properties = DialogProperties( - usePlatformDefaultWidth = false, - dismissOnClickOutside = false, - decorFitsSystemWindows = false - ) - ) { - if (showRelaysDialog) { - RelaySelectionDialog( - preSelectedList = relayList, - onClose = { - showRelaysDialog = false - }, - onPost = { - relayList = it - }, - accountViewModel = accountViewModel, - nav = nav - ) - } - - Scaffold( - topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Spacer(modifier = StdHorzSpacer) - - Box { - IconButton( - modifier = Modifier.align(Alignment.Center), - onClick = { - showRelaysDialog = true - } - ) { - Icon( - painter = painterResource(R.drawable.relays), - contentDescription = null, - modifier = Modifier.height(25.dp), - tint = MaterialTheme.colorScheme.onBackground - ) - } - } - PostButton( - onPost = { - postViewModel.sendPost(relayList = relayList) - scope.launch { - delay(100) - onClose() - } - }, - isActive = postViewModel.canPost() - ) - } - }, - navigationIcon = { - Row() { - Spacer(modifier = StdHorzSpacer) - CloseButton(onPress = { - postViewModel.cancel() - scope.launch { - delay(100) - onClose() - } - }) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface - ) - ) - } - ) { pad -> - Surface( - modifier = Modifier - .padding( - start = Size10dp, - top = pad.calculateTopPadding(), - end = Size10dp, - bottom = pad.calculateBottomPadding() - ) - .fillMaxSize() + Scaffold( + topBar = { + TopAppBar( + title = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Column( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() + Spacer(modifier = StdHorzSpacer) + + Box { + IconButton( + modifier = Modifier.align(Alignment.Center), + onClick = { showRelaysDialog = true }, ) { - Column( - modifier = Modifier - .imePadding() - .weight(1f) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(scrollState) - ) { - postViewModel.originalNote?.let { - Row(Modifier.heightIn(max = 200.dp)) { - NoteCompose( - baseNote = it, - makeItShort = true, - unPackReply = false, - isQuotedNote = true, - modifier = MaterialTheme.colorScheme.replyModifier, - accountViewModel = accountViewModel, - nav = nav - ) - Spacer(modifier = StdVertSpacer) - } - } - - Row() { - Notifying(postViewModel.pTags?.toImmutableList()) { - postViewModel.removeFromReplyList(it) - } - } - - if (enableMessageInterface) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp) - ) { - SendDirectMessageTo(postViewModel = postViewModel) - } - } - - if (postViewModel.wantsProduct) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp) - ) { - SellProduct(postViewModel = postViewModel) - } - } - - MessageField(postViewModel) - - if (postViewModel.wantsPoll) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp) - ) { - PollField(postViewModel) - } - } - - if (postViewModel.wantsToMarkAsSensitive) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding(vertical = Size5dp, horizontal = Size10dp) - ) { - ContentSensitivityExplainer(postViewModel) - } - } - - if (postViewModel.wantsToAddGeoHash) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding(vertical = Size5dp, horizontal = Size10dp) - ) { - LocationAsHash(postViewModel) - } - } - - if (postViewModel.wantsForwardZapTo) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(top = Size5dp, bottom = Size5dp, start = Size10dp) - ) { - FowardZapTo(postViewModel, accountViewModel) - } - } - - val url = postViewModel.contentToAddUrl - if (url != null) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) { - ImageVideoDescription( - url, - accountViewModel.account.defaultFileServer, - onAdd = { alt, server, sensitiveContent -> - postViewModel.upload(url, alt, sensitiveContent, false, server, context) - if (!server.isNip95) { - accountViewModel.account.changeDefaultFileServer(server.server) - } - }, - onCancel = { - postViewModel.contentToAddUrl = null - }, - onError = { - scope.launch { - postViewModel.imageUploadingError.emit(it) - } - }, - accountViewModel = accountViewModel - ) - } - } - - val user = postViewModel.account?.userProfile() - val lud16 = user?.info?.lnAddress() - - if (lud16 != null && postViewModel.wantsInvoice) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) { - Column(Modifier.fillMaxWidth()) { - InvoiceRequest( - lud16, - user.pubkeyHex, - accountViewModel.account, - stringResource(id = R.string.lightning_invoice), - stringResource(id = R.string.lightning_create_and_add_invoice), - onSuccess = { - postViewModel.message = TextFieldValue(postViewModel.message.text + "\n\n" + it) - postViewModel.wantsInvoice = false - }, - onClose = { - postViewModel.wantsInvoice = false - }, - onError = { title, message -> - accountViewModel.toast(title, message) - } - ) - } - } - } - - if (lud16 != null && postViewModel.wantsZapraiser) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) { - ZapRaiserRequest( - stringResource(id = R.string.zapraiser), - postViewModel - ) - } - } - - val myUrlPreview = postViewModel.urlPreview - if (myUrlPreview != null) { - Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) { - if (isValidURL(myUrlPreview)) { - val removedParamsFromUrl = removeQueryParamsForExtensionComparison(myUrlPreview) - if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - AsyncImage( - model = myUrlPreview, - contentDescription = myUrlPreview, - contentScale = ContentScale.FillWidth, - modifier = Modifier - .padding(top = 4.dp) - .fillMaxWidth() - .clip(shape = QuoteBorder) - .border( - 1.dp, - MaterialTheme.colorScheme.subtleBorder, - QuoteBorder - ) - ) - } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { - VideoView(myUrlPreview, roundedCorner = true, accountViewModel = accountViewModel) - } else { - LoadUrlPreview(myUrlPreview, myUrlPreview, accountViewModel) - } - } else if (startsWithNIP19Scheme(myUrlPreview)) { - val bgColor = MaterialTheme.colorScheme.background - val backgroundColor = remember { - mutableStateOf(bgColor) - } - - BechLink( - myUrlPreview, - true, - backgroundColor, - accountViewModel, - nav - ) - } else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) { - LoadUrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel) - } - } - } - } - } - - val userSuggestions = postViewModel.userSuggestions - if (userSuggestions.isNotEmpty()) { - LazyColumn( - contentPadding = PaddingValues( - top = 10.dp - ), - modifier = Modifier.heightIn(0.dp, 300.dp) - ) { - itemsIndexed( - userSuggestions, - key = { _, item -> item.pubkeyHex } - ) { _, item -> - UserLine(item, accountViewModel) { - postViewModel.autocompleteWithUser(item) - } - } - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .height(50.dp), - verticalAlignment = Alignment.CenterVertically - ) { - UploadFromGallery( - isUploading = postViewModel.isUploadingImage, - tint = MaterialTheme.colorScheme.onBackground, - modifier = Modifier - ) { - postViewModel.selectImage(it) - } - - if (postViewModel.canUsePoll) { - // These should be hashtag recommendations the user selects in the future. - // val hashtag = stringResource(R.string.poll_hashtag) - // postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag) - AddPollButton(postViewModel.wantsPoll) { - postViewModel.wantsPoll = !postViewModel.wantsPoll - if (postViewModel.wantsPoll) { - postViewModel.wantsProduct = false - } - } - } - - AddClassifiedsButton(postViewModel) { - postViewModel.wantsProduct = !postViewModel.wantsProduct - if (postViewModel.wantsProduct) { - postViewModel.wantsPoll = false - } - } - - if (postViewModel.canAddInvoice) { - AddLnInvoiceButton(postViewModel.wantsInvoice) { - postViewModel.wantsInvoice = !postViewModel.wantsInvoice - } - } - - if (postViewModel.canAddZapRaiser) { - AddZapraiserButton(postViewModel.wantsZapraiser) { - postViewModel.wantsZapraiser = !postViewModel.wantsZapraiser - } - } - - MarkAsSensitive(postViewModel) { - postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive - } - - AddGeoHash(postViewModel) { - postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash - } - - ForwardZapTo(postViewModel) { - postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo - } - } - } + Icon( + painter = painterResource(R.drawable.relays), + contentDescription = null, + modifier = Modifier.height(25.dp), + tint = MaterialTheme.colorScheme.onBackground, + ) } + } + PostButton( + onPost = { + postViewModel.sendPost(relayList = relayList) + scope.launch { + delay(100) + onClose() + } + }, + isActive = postViewModel.canPost(), + ) } + }, + navigationIcon = { + Row { + Spacer(modifier = StdHorzSpacer) + CloseButton( + onPress = { + postViewModel.cancel() + scope.launch { + delay(100) + onClose() + } + }, + ) + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + }, + ) { pad -> + Surface( + modifier = + Modifier.padding( + start = Size10dp, + top = pad.calculateTopPadding(), + end = Size10dp, + bottom = pad.calculateBottomPadding(), + ) + .fillMaxSize(), + ) { + Column( + modifier = Modifier.fillMaxWidth().fillMaxHeight(), + ) { + Column( + modifier = Modifier.imePadding().weight(1f), + ) { + Row( + modifier = Modifier.fillMaxWidth().weight(1f), + ) { + Column( + modifier = Modifier.fillMaxWidth().verticalScroll(scrollState), + ) { + postViewModel.originalNote?.let { + Row(Modifier.heightIn(max = 200.dp)) { + NoteCompose( + baseNote = it, + makeItShort = true, + unPackReply = false, + isQuotedNote = true, + modifier = MaterialTheme.colorScheme.replyModifier, + accountViewModel = accountViewModel, + nav = nav, + ) + Spacer(modifier = StdVertSpacer) + } + } + + Row { + Notifying(postViewModel.pTags?.toImmutableList()) { + postViewModel.removeFromReplyList(it) + } + } + + if (enableMessageInterface) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + SendDirectMessageTo(postViewModel = postViewModel) + } + } + + if (postViewModel.wantsProduct) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + SellProduct(postViewModel = postViewModel) + } + } + + MessageField(postViewModel) + + if (postViewModel.wantsPoll) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + PollField(postViewModel) + } + } + + if (postViewModel.wantsToMarkAsSensitive) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + ContentSensitivityExplainer(postViewModel) + } + } + + if (postViewModel.wantsToAddGeoHash) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + LocationAsHash(postViewModel) + } + } + + if (postViewModel.wantsForwardZapTo) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = Size5dp, bottom = Size5dp, start = Size10dp), + ) { + FowardZapTo(postViewModel, accountViewModel) + } + } + + val url = postViewModel.contentToAddUrl + if (url != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + ImageVideoDescription( + url, + accountViewModel.account.defaultFileServer, + onAdd = { alt, server, sensitiveContent -> + postViewModel.upload(url, alt, sensitiveContent, false, server, context) + if (!server.isNip95) { + accountViewModel.account.changeDefaultFileServer(server.server) + } + }, + onCancel = { postViewModel.contentToAddUrl = null }, + onError = { scope.launch { postViewModel.imageUploadingError.emit(it) } }, + accountViewModel = accountViewModel, + ) + } + } + + val user = postViewModel.account?.userProfile() + val lud16 = user?.info?.lnAddress() + + if (lud16 != null && postViewModel.wantsInvoice) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + Column(Modifier.fillMaxWidth()) { + InvoiceRequest( + lud16, + user.pubkeyHex, + accountViewModel.account, + stringResource(id = R.string.lightning_invoice), + stringResource(id = R.string.lightning_create_and_add_invoice), + onSuccess = { + postViewModel.message = + TextFieldValue(postViewModel.message.text + "\n\n" + it) + postViewModel.wantsInvoice = false + }, + onClose = { postViewModel.wantsInvoice = false }, + onError = { title, message -> accountViewModel.toast(title, message) }, + ) + } + } + } + + if (lud16 != null && postViewModel.wantsZapraiser) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + ZapRaiserRequest( + stringResource(id = R.string.zapraiser), + postViewModel, + ) + } + } + + val myUrlPreview = postViewModel.urlPreview + if (myUrlPreview != null) { + Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) { + if (isValidURL(myUrlPreview)) { + val removedParamsFromUrl = + removeQueryParamsForExtensionComparison(myUrlPreview) + if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { + AsyncImage( + model = myUrlPreview, + contentDescription = myUrlPreview, + contentScale = ContentScale.FillWidth, + modifier = + Modifier.padding(top = 4.dp) + .fillMaxWidth() + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ), + ) + } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { + VideoView( + myUrlPreview, + roundedCorner = true, + accountViewModel = accountViewModel, + ) + } else { + LoadUrlPreview(myUrlPreview, myUrlPreview, accountViewModel) + } + } else if (startsWithNIP19Scheme(myUrlPreview)) { + val bgColor = MaterialTheme.colorScheme.background + val backgroundColor = remember { mutableStateOf(bgColor) } + + BechLink( + myUrlPreview, + true, + backgroundColor, + accountViewModel, + nav, + ) + } else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) { + LoadUrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel) + } + } + } + } + } + + val userSuggestions = postViewModel.userSuggestions + if (userSuggestions.isNotEmpty()) { + LazyColumn( + contentPadding = + PaddingValues( + top = 10.dp, + ), + modifier = Modifier.heightIn(0.dp, 300.dp), + ) { + itemsIndexed( + userSuggestions, + key = { _, item -> item.pubkeyHex }, + ) { _, item -> + UserLine(item, accountViewModel) { postViewModel.autocompleteWithUser(item) } + } + } + } + + Row( + modifier = Modifier.fillMaxWidth().height(50.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + UploadFromGallery( + isUploading = postViewModel.isUploadingImage, + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier, + ) { + postViewModel.selectImage(it) + } + + if (postViewModel.canUsePoll) { + // These should be hashtag recommendations the user selects in the future. + // val hashtag = stringResource(R.string.poll_hashtag) + // postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag) + AddPollButton(postViewModel.wantsPoll) { + postViewModel.wantsPoll = !postViewModel.wantsPoll + if (postViewModel.wantsPoll) { + postViewModel.wantsProduct = false + } + } + } + + AddClassifiedsButton(postViewModel) { + postViewModel.wantsProduct = !postViewModel.wantsProduct + if (postViewModel.wantsProduct) { + postViewModel.wantsPoll = false + } + } + + if (postViewModel.canAddInvoice) { + AddLnInvoiceButton(postViewModel.wantsInvoice) { + postViewModel.wantsInvoice = !postViewModel.wantsInvoice + } + } + + if (postViewModel.canAddZapRaiser) { + AddZapraiserButton(postViewModel.wantsZapraiser) { + postViewModel.wantsZapraiser = !postViewModel.wantsZapraiser + } + } + + MarkAsSensitive(postViewModel) { + postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive + } + + AddGeoHash(postViewModel) { + postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash + } + + ForwardZapTo(postViewModel) { + postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo + } + } + } } + } } + } } @Composable private fun PollField(postViewModel: NewPostViewModel) { - Column( - modifier = Modifier.fillMaxWidth() - ) { - postViewModel.pollOptions.values.forEachIndexed { index, _ -> - NewPollOption(postViewModel, index) - } - - NewPollVoteValueRange(postViewModel) - - Button( - onClick = { - postViewModel.pollOptions[postViewModel.pollOptions.size] = - "" - }, - border = BorderStroke( - 1.dp, - MaterialTheme.colorScheme.placeholderText - ), - colors = ButtonDefaults.outlinedButtonColors( - containerColor = MaterialTheme.colorScheme.placeholderText - ) - ) { - Image( - painterResource(id = android.R.drawable.ic_input_add), - contentDescription = "Add poll option button", - modifier = Size18Modifier - ) - } + Column( + modifier = Modifier.fillMaxWidth(), + ) { + postViewModel.pollOptions.values.forEachIndexed { index, _ -> + NewPollOption(postViewModel, index) } + + NewPollVoteValueRange(postViewModel) + + Button( + onClick = { postViewModel.pollOptions[postViewModel.pollOptions.size] = "" }, + border = + BorderStroke( + 1.dp, + MaterialTheme.colorScheme.placeholderText, + ), + colors = + ButtonDefaults.outlinedButtonColors( + containerColor = MaterialTheme.colorScheme.placeholderText, + ), + ) { + Image( + painterResource(id = android.R.drawable.ic_input_add), + contentDescription = "Add poll option button", + modifier = Size18Modifier, + ) + } + } } @OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) @Composable -private fun MessageField( - postViewModel: NewPostViewModel -) { - val focusRequester = remember { FocusRequester() } - val keyboardController = LocalSoftwareKeyboardController.current +private fun MessageField(postViewModel: NewPostViewModel) { + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current - LaunchedEffect(Unit) { - launch { - delay(200) - focusRequester.requestFocus() - } + LaunchedEffect(Unit) { + launch { + delay(200) + focusRequester.requestFocus() } + } - OutlinedTextField( - value = postViewModel.message, - onValueChange = { - postViewModel.updateMessage(it) + OutlinedTextField( + value = postViewModel.message, + onValueChange = { postViewModel.updateMessage(it) }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + modifier = + Modifier.fillMaxWidth() + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(8.dp), + ) + .focusRequester(focusRequester) + .onFocusChanged { + if (it.isFocused) { + keyboardController?.show() + } }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences - ), - modifier = Modifier - .fillMaxWidth() - .border( - width = 1.dp, - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(8.dp) - ) - .focusRequester(focusRequester) - .onFocusChanged { - if (it.isFocused) { - keyboardController?.show() - } - }, - placeholder = { - Text( - text = if (postViewModel.wantsProduct) { - stringResource(R.string.description) - } else { - stringResource(R.string.what_s_on_your_mind) - }, - color = MaterialTheme.colorScheme.placeholderText - ) - }, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent - ), - visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), - textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content) - ) + placeholder = { + Text( + text = + if (postViewModel.wantsProduct) { + stringResource(R.string.description) + } else { + stringResource(R.string.what_s_on_your_mind) + }, + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + ), + visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + ) } @Composable fun ContentSensitivityExplainer(postViewModel: NewPostViewModel) { - Column( - modifier = Modifier.fillMaxWidth() + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 10.dp) - ) { - Box( - Modifier - .height(20.dp) - .width(25.dp) - ) { - Icon( - imageVector = Icons.Default.VisibilityOff, - contentDescription = stringResource(id = R.string.content_warning), - modifier = Modifier - .size(18.dp) - .align(Alignment.BottomStart), - tint = Color.Red - ) - Icon( - imageVector = Icons.Rounded.Warning, - contentDescription = stringResource(id = R.string.content_warning), - modifier = Modifier - .size(10.dp) - .align(Alignment.TopEnd), - tint = Color.Yellow - ) - } - - Text( - text = stringResource(R.string.add_sensitive_content_label), - fontSize = 20.sp, - fontWeight = FontWeight.W500, - modifier = Modifier.padding(start = 10.dp) - ) - } - - Divider() - - Text( - text = stringResource(R.string.add_sensitive_content_explainer), - color = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.padding(vertical = 10.dp) + Box( + Modifier.height(20.dp).width(25.dp), + ) { + Icon( + imageVector = Icons.Default.VisibilityOff, + contentDescription = stringResource(id = R.string.content_warning), + modifier = Modifier.size(18.dp).align(Alignment.BottomStart), + tint = Color.Red, ) + Icon( + imageVector = Icons.Rounded.Warning, + contentDescription = stringResource(id = R.string.content_warning), + modifier = Modifier.size(10.dp).align(Alignment.TopEnd), + tint = Color.Yellow, + ) + } + + Text( + text = stringResource(R.string.add_sensitive_content_label), + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.padding(start = 10.dp), + ) } + + Divider() + + Text( + text = stringResource(R.string.add_sensitive_content_explainer), + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(vertical = 10.dp), + ) + } } @Composable fun SendDirectMessageTo(postViewModel: NewPostViewModel) { - Column( - modifier = Modifier.fillMaxWidth() + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = stringResource(R.string.messages_new_message_to), - fontSize = Font14SP, - fontWeight = FontWeight.W500 - ) + Text( + text = stringResource(R.string.messages_new_message_to), + fontSize = Font14SP, + fontWeight = FontWeight.W500, + ) - MyTextField( - value = postViewModel.toUsers, - onValueChange = { - postViewModel.updateToUsers(it) - }, - modifier = Modifier.fillMaxWidth(), - placeholder = { - Text( - text = stringResource(R.string.messages_new_message_to_caption), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - visualTransformation = UrlUserTagTransformation( - MaterialTheme.colorScheme.primary - ), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = Color.Transparent, - focusedBorderColor = Color.Transparent - ) - ) - } - - Divider() - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - ) { - Text( - text = stringResource(R.string.messages_new_message_subject), - fontSize = Font14SP, - fontWeight = FontWeight.W500 - ) - - MyTextField( - value = postViewModel.subject, - onValueChange = { - postViewModel.updateSubject(it) - }, - modifier = Modifier.fillMaxWidth(), - placeholder = { - Text( - text = stringResource(R.string.messages_new_message_subject_caption), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - visualTransformation = UrlUserTagTransformation( - MaterialTheme.colorScheme.primary - ), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = Color.Transparent, - focusedBorderColor = Color.Transparent - ) - ) - } - - Divider() + MyTextField( + value = postViewModel.toUsers, + onValueChange = { postViewModel.updateToUsers(it) }, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = stringResource(R.string.messages_new_message_to_caption), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + visualTransformation = + UrlUserTagTransformation( + MaterialTheme.colorScheme.primary, + ), + colors = + OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = Color.Transparent, + ), + ) } + + Divider() + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.messages_new_message_subject), + fontSize = Font14SP, + fontWeight = FontWeight.W500, + ) + + MyTextField( + value = postViewModel.subject, + onValueChange = { postViewModel.updateSubject(it) }, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = stringResource(R.string.messages_new_message_subject_caption), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + visualTransformation = + UrlUserTagTransformation( + MaterialTheme.colorScheme.primary, + ), + colors = + OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = Color.Transparent, + ), + ) + } + + Divider() + } } @Composable fun SellProduct(postViewModel: NewPostViewModel) { - Column( - modifier = Modifier.fillMaxWidth() + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = stringResource(R.string.classifieds_title), - fontSize = Font14SP, - fontWeight = FontWeight.W500 - ) + Text( + text = stringResource(R.string.classifieds_title), + fontSize = Font14SP, + fontWeight = FontWeight.W500, + ) - MyTextField( - value = postViewModel.title, - onValueChange = { - postViewModel.title = it - }, - modifier = Modifier.fillMaxWidth(), - placeholder = { - Text( - text = stringResource(R.string.classifieds_title_placeholder), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - visualTransformation = UrlUserTagTransformation( - MaterialTheme.colorScheme.primary - ), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = Color.Transparent, - focusedBorderColor = Color.Transparent - ) - ) - } - - Divider() - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = stringResource(R.string.classifieds_price), - fontSize = Font14SP, - fontWeight = FontWeight.W500 - ) - - MyTextField( - modifier = Modifier.fillMaxWidth(), - value = postViewModel.price, - onValueChange = { - runCatching { - if (it.text.isEmpty()) { - postViewModel.price = TextFieldValue("") - } else if (it.text.toLongOrNull() != null) { - postViewModel.price = it - } - } - }, - placeholder = { - Text( - text = "1000", - color = MaterialTheme.colorScheme.placeholderText - ) - }, - keyboardOptions = KeyboardOptions.Default.copy( - keyboardType = KeyboardType.Number - ), - singleLine = true, - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = Color.Transparent, - focusedBorderColor = Color.Transparent - ) - ) - } - - Divider() - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = stringResource(R.string.classifieds_condition), - fontSize = Font14SP, - fontWeight = FontWeight.W500 - ) - - val conditionTypes = listOf( - Triple(ClassifiedsEvent.CONDITION.NEW, stringResource(id = R.string.classifieds_condition_new), stringResource(id = R.string.classifieds_condition_new_explainer)), - Triple(ClassifiedsEvent.CONDITION.USED_LIKE_NEW, stringResource(id = R.string.classifieds_condition_like_new), stringResource(id = R.string.classifieds_condition_like_new_explainer)), - Triple(ClassifiedsEvent.CONDITION.USED_GOOD, stringResource(id = R.string.classifieds_condition_good), stringResource(id = R.string.classifieds_condition_good_explainer)), - Triple(ClassifiedsEvent.CONDITION.USED_FAIR, stringResource(id = R.string.classifieds_condition_fair), stringResource(id = R.string.classifieds_condition_fair_explainer)) - ) - - val conditionOptions = remember { conditionTypes.map { TitleExplainer(it.second, it.third) }.toImmutableList() } - - TextSpinner( - placeholder = conditionTypes.filter { it.first == postViewModel.condition }.first().second, - options = conditionOptions, - onSelect = { - postViewModel.condition = conditionTypes[it].first - }, - modifier = Modifier - .weight(1f) - .padding(end = 5.dp, bottom = 1.dp) - ) { currentOption, modifier -> - MyTextField( - value = TextFieldValue(currentOption), - onValueChange = {}, - readOnly = true, - modifier = modifier, - singleLine = true, - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = Color.Transparent, - focusedBorderColor = Color.Transparent - ) - ) - } - } - - Divider() - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = stringResource(R.string.classifieds_category), - fontSize = Font14SP, - fontWeight = FontWeight.W500 - ) - - val categoryList = listOf( - R.string.classifieds_category_clothing, - R.string.classifieds_category_accessories, - R.string.classifieds_category_electronics, - R.string.classifieds_category_furniture, - R.string.classifieds_category_collectibles, - R.string.classifieds_category_books, - R.string.classifieds_category_pets, - R.string.classifieds_category_sports, - R.string.classifieds_category_fitness, - R.string.classifieds_category_art, - R.string.classifieds_category_crafts, - R.string.classifieds_category_home, - R.string.classifieds_category_office, - R.string.classifieds_category_food, - R.string.classifieds_category_misc, - R.string.classifieds_category_other - ) - - val categoryTypes = categoryList.map { - Triple(it, stringResource(id = it), null) - } - - val categoryOptions = remember { categoryTypes.map { TitleExplainer(it.second, null) }.toImmutableList() } - TextSpinner( - placeholder = categoryTypes.filter { it.second == postViewModel.category.text }.firstOrNull()?.second ?: "", - options = categoryOptions, - onSelect = { - postViewModel.category = TextFieldValue(categoryTypes[it].second) - }, - modifier = Modifier - .weight(1f) - .padding(end = 5.dp, bottom = 1.dp) - ) { currentOption, modifier -> - MyTextField( - value = TextFieldValue(currentOption), - onValueChange = {}, - readOnly = true, - modifier = modifier, - singleLine = true, - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = Color.Transparent, - focusedBorderColor = Color.Transparent - ) - ) - } - } - - Divider() - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = stringResource(R.string.classifieds_location), - fontSize = Font14SP, - fontWeight = FontWeight.W500 - ) - - MyTextField( - value = postViewModel.locationText, - onValueChange = { - postViewModel.locationText = it - }, - modifier = Modifier.fillMaxWidth(), - placeholder = { - Text( - text = stringResource(R.string.classifieds_location_placeholder), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - visualTransformation = UrlUserTagTransformation( - MaterialTheme.colorScheme.primary - ), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = Color.Transparent, - focusedBorderColor = Color.Transparent - ) - ) - } - - Divider() + MyTextField( + value = postViewModel.title, + onValueChange = { postViewModel.title = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = stringResource(R.string.classifieds_title_placeholder), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + visualTransformation = + UrlUserTagTransformation( + MaterialTheme.colorScheme.primary, + ), + colors = + OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = Color.Transparent, + ), + ) } + + Divider() + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.classifieds_price), + fontSize = Font14SP, + fontWeight = FontWeight.W500, + ) + + MyTextField( + modifier = Modifier.fillMaxWidth(), + value = postViewModel.price, + onValueChange = { + runCatching { + if (it.text.isEmpty()) { + postViewModel.price = TextFieldValue("") + } else if (it.text.toLongOrNull() != null) { + postViewModel.price = it + } + } + }, + placeholder = { + Text( + text = "1000", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Number, + ), + singleLine = true, + colors = + OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = Color.Transparent, + ), + ) + } + + Divider() + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.classifieds_condition), + fontSize = Font14SP, + fontWeight = FontWeight.W500, + ) + + val conditionTypes = + listOf( + Triple( + ClassifiedsEvent.CONDITION.NEW, + stringResource(id = R.string.classifieds_condition_new), + stringResource(id = R.string.classifieds_condition_new_explainer), + ), + Triple( + ClassifiedsEvent.CONDITION.USED_LIKE_NEW, + stringResource(id = R.string.classifieds_condition_like_new), + stringResource(id = R.string.classifieds_condition_like_new_explainer), + ), + Triple( + ClassifiedsEvent.CONDITION.USED_GOOD, + stringResource(id = R.string.classifieds_condition_good), + stringResource(id = R.string.classifieds_condition_good_explainer), + ), + Triple( + ClassifiedsEvent.CONDITION.USED_FAIR, + stringResource(id = R.string.classifieds_condition_fair), + stringResource(id = R.string.classifieds_condition_fair_explainer), + ), + ) + + val conditionOptions = remember { + conditionTypes.map { TitleExplainer(it.second, it.third) }.toImmutableList() + } + + TextSpinner( + placeholder = conditionTypes.filter { it.first == postViewModel.condition }.first().second, + options = conditionOptions, + onSelect = { postViewModel.condition = conditionTypes[it].first }, + modifier = Modifier.weight(1f).padding(end = 5.dp, bottom = 1.dp), + ) { currentOption, modifier -> + MyTextField( + value = TextFieldValue(currentOption), + onValueChange = {}, + readOnly = true, + modifier = modifier, + singleLine = true, + colors = + OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = Color.Transparent, + ), + ) + } + } + + Divider() + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.classifieds_category), + fontSize = Font14SP, + fontWeight = FontWeight.W500, + ) + + val categoryList = + listOf( + R.string.classifieds_category_clothing, + R.string.classifieds_category_accessories, + R.string.classifieds_category_electronics, + R.string.classifieds_category_furniture, + R.string.classifieds_category_collectibles, + R.string.classifieds_category_books, + R.string.classifieds_category_pets, + R.string.classifieds_category_sports, + R.string.classifieds_category_fitness, + R.string.classifieds_category_art, + R.string.classifieds_category_crafts, + R.string.classifieds_category_home, + R.string.classifieds_category_office, + R.string.classifieds_category_food, + R.string.classifieds_category_misc, + R.string.classifieds_category_other, + ) + + val categoryTypes = categoryList.map { Triple(it, stringResource(id = it), null) } + + val categoryOptions = remember { + categoryTypes.map { TitleExplainer(it.second, null) }.toImmutableList() + } + TextSpinner( + placeholder = + categoryTypes.filter { it.second == postViewModel.category.text }.firstOrNull()?.second + ?: "", + options = categoryOptions, + onSelect = { postViewModel.category = TextFieldValue(categoryTypes[it].second) }, + modifier = Modifier.weight(1f).padding(end = 5.dp, bottom = 1.dp), + ) { currentOption, modifier -> + MyTextField( + value = TextFieldValue(currentOption), + onValueChange = {}, + readOnly = true, + modifier = modifier, + singleLine = true, + colors = + OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = Color.Transparent, + ), + ) + } + } + + Divider() + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.classifieds_location), + fontSize = Font14SP, + fontWeight = FontWeight.W500, + ) + + MyTextField( + value = postViewModel.locationText, + onValueChange = { postViewModel.locationText = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = stringResource(R.string.classifieds_location_placeholder), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + visualTransformation = + UrlUserTagTransformation( + MaterialTheme.colorScheme.primary, + ), + colors = + OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = Color.Transparent, + ), + ) + } + + Divider() + } } @Composable -fun FowardZapTo(postViewModel: NewPostViewModel, accountViewModel: AccountViewModel) { - Column( - modifier = Modifier.fillMaxWidth() +fun FowardZapTo( + postViewModel: NewPostViewModel, + accountViewModel: AccountViewModel, +) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 10.dp) - ) { - Box( - Modifier - .height(20.dp) - .width(25.dp) - ) { - Icon( - imageVector = Icons.Outlined.Bolt, - contentDescription = stringResource(id = R.string.zaps), - modifier = Modifier - .size(20.dp) - .align(Alignment.CenterStart), - tint = BitcoinOrange - ) - Icon( - imageVector = Icons.Outlined.ArrowForwardIos, - contentDescription = stringResource(id = R.string.zaps), - modifier = Modifier - .size(13.dp) - .align(Alignment.CenterEnd), - tint = BitcoinOrange - ) - } - - Text( - text = stringResource(R.string.zap_split_title), - fontSize = 20.sp, - fontWeight = FontWeight.W500, - modifier = Modifier.padding(start = 10.dp) - ) - } - - Divider() - - Text( - text = stringResource(R.string.zap_split_explainer), - color = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.padding(vertical = 10.dp) + Box( + Modifier.height(20.dp).width(25.dp), + ) { + Icon( + imageVector = Icons.Outlined.Bolt, + contentDescription = stringResource(id = R.string.zaps), + modifier = Modifier.size(20.dp).align(Alignment.CenterStart), + tint = BitcoinOrange, ) - - postViewModel.forwardZapTo.items.forEachIndexed { index, splitItem -> - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size10dp)) { - BaseUserPicture(splitItem.key, Size55dp, accountViewModel = accountViewModel) - - Spacer(modifier = DoubleHorzSpacer) - - Column(modifier = Modifier.weight(1f)) { - UsernameDisplay(splitItem.key, showPlayButton = false) - Text( - text = String.format("%.0f%%", splitItem.percentage * 100), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - fontWeight = FontWeight.Bold, - fontSize = 18.sp - ) - } - - Spacer(modifier = DoubleHorzSpacer) - - Slider( - value = splitItem.percentage, - onValueChange = { sliderValue -> - val rounded = (round(sliderValue * 20)) / 20.0f - postViewModel.updateZapPercentage(index, rounded) - }, - modifier = Modifier - .weight(1.5f) - ) - } - } - - OutlinedTextField( - value = postViewModel.forwardZapToEditting, - onValueChange = { - postViewModel.updateZapForwardTo(it) - }, - label = { Text(text = stringResource(R.string.zap_split_search_and_add_user)) }, - modifier = Modifier.fillMaxWidth(), - placeholder = { - Text( - text = stringResource(R.string.zap_split_search_and_add_user_placeholder), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - singleLine = true, - visualTransformation = UrlUserTagTransformation( - MaterialTheme.colorScheme.primary - ), - textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content) + Icon( + imageVector = Icons.Outlined.ArrowForwardIos, + contentDescription = stringResource(id = R.string.zaps), + modifier = Modifier.size(13.dp).align(Alignment.CenterEnd), + tint = BitcoinOrange, ) + } + + Text( + text = stringResource(R.string.zap_split_title), + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.padding(start = 10.dp), + ) } + + Divider() + + Text( + text = stringResource(R.string.zap_split_explainer), + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(vertical = 10.dp), + ) + + postViewModel.forwardZapTo.items.forEachIndexed { index, splitItem -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = Size10dp), + ) { + BaseUserPicture(splitItem.key, Size55dp, accountViewModel = accountViewModel) + + Spacer(modifier = DoubleHorzSpacer) + + Column(modifier = Modifier.weight(1f)) { + UsernameDisplay(splitItem.key, showPlayButton = false) + Text( + text = String.format("%.0f%%", splitItem.percentage * 100), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ) + } + + Spacer(modifier = DoubleHorzSpacer) + + Slider( + value = splitItem.percentage, + onValueChange = { sliderValue -> + val rounded = (round(sliderValue * 20)) / 20.0f + postViewModel.updateZapPercentage(index, rounded) + }, + modifier = Modifier.weight(1.5f), + ) + } + } + + OutlinedTextField( + value = postViewModel.forwardZapToEditting, + onValueChange = { postViewModel.updateZapForwardTo(it) }, + label = { Text(text = stringResource(R.string.zap_split_search_and_add_user)) }, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = stringResource(R.string.zap_split_search_and_add_user_placeholder), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + visualTransformation = + UrlUserTagTransformation( + MaterialTheme.colorScheme.primary, + ), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + ) + } } @OptIn(ExperimentalPermissionsApi::class) @Composable fun LocationAsHash(postViewModel: NewPostViewModel) { - val context = LocalContext.current + val context = LocalContext.current - val locationPermissionState = rememberPermissionState( - Manifest.permission.ACCESS_COARSE_LOCATION + val locationPermissionState = + rememberPermissionState( + Manifest.permission.ACCESS_COARSE_LOCATION, ) - if (locationPermissionState.status.isGranted) { - var locationDescriptionFlow by remember(postViewModel) { - mutableStateOf?>(null) - } + if (locationPermissionState.status.isGranted) { + var locationDescriptionFlow by remember(postViewModel) { mutableStateOf?>(null) } - DisposableEffect(key1 = Unit) { - postViewModel.startLocation(context = context) - locationDescriptionFlow = postViewModel.location + DisposableEffect(key1 = Unit) { + postViewModel.startLocation(context = context) + locationDescriptionFlow = postViewModel.location - onDispose { - postViewModel.stopLocation() - } - } - - Column( - modifier = Modifier.fillMaxWidth() - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 10.dp) - ) { - Box( - Modifier - .height(20.dp) - .width(20.dp) - ) { - Icon( - imageVector = Icons.Default.LocationOn, - null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary - ) - } - - Text( - text = stringResource(R.string.geohash_title), - fontSize = 20.sp, - fontWeight = FontWeight.W500, - modifier = Modifier.padding(start = 10.dp) - ) - - locationDescriptionFlow?.let { geoLocation -> - DisplayLocationObserver(geoLocation) - } - } - - Divider() - - Text( - text = stringResource(R.string.geohash_explainer), - color = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.padding(vertical = 10.dp) - ) - } - } else { - LaunchedEffect(locationPermissionState) { - locationPermissionState.launchPermissionRequest() - } + onDispose { postViewModel.stopLocation() } } + + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + ) { + Box( + Modifier.height(20.dp).width(20.dp), + ) { + Icon( + imageVector = Icons.Default.LocationOn, + null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + + Text( + text = stringResource(R.string.geohash_title), + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.padding(start = 10.dp), + ) + + locationDescriptionFlow?.let { geoLocation -> DisplayLocationObserver(geoLocation) } + } + + Divider() + + Text( + text = stringResource(R.string.geohash_explainer), + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(vertical = 10.dp), + ) + } + } else { + LaunchedEffect(locationPermissionState) { locationPermissionState.launchPermissionRequest() } + } } @Composable fun DisplayLocationObserver(geoLocation: Flow) { - val location by geoLocation.collectAsStateWithLifecycle(null) + val location by geoLocation.collectAsStateWithLifecycle(null) - location?.let { - DisplayLocationInTitle(geohash = it) - } + location?.let { DisplayLocationInTitle(geohash = it) } } @Composable fun DisplayLocationInTitle(geohash: String) { - val context = LocalContext.current + val context = LocalContext.current - var cityName by remember(geohash) { - mutableStateOf(geohash) - } + var cityName by remember(geohash) { mutableStateOf(geohash) } - LaunchedEffect(key1 = geohash) { - launch(Dispatchers.IO) { - val newCityName = ReverseGeoLocationUtil().execute(geohash.toGeoHash().toLocation(), context)?.ifBlank { null } - - if (newCityName != null && newCityName != cityName) { - cityName = newCityName - } + LaunchedEffect(key1 = geohash) { + launch(Dispatchers.IO) { + val newCityName = + ReverseGeoLocationUtil().execute(geohash.toGeoHash().toLocation(), context)?.ifBlank { + null } - } - if (geohash != "s0000") { - Text( - text = cityName, - fontSize = 20.sp, - fontWeight = FontWeight.W500, - modifier = Modifier.padding(start = Size5dp) - ) - } else { - Spacer(modifier = StdHorzSpacer) - LoadingAnimation() + if (newCityName != null && newCityName != cityName) { + cityName = newCityName + } } + } + + if (geohash != "s0000") { + Text( + text = cityName, + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.padding(start = Size5dp), + ) + } else { + Spacer(modifier = StdHorzSpacer) + LoadingAnimation() + } } @OptIn(ExperimentalLayoutApi::class) @Composable -fun Notifying(baseMentions: ImmutableList?, onClick: (User) -> Unit) { - val mentions = baseMentions?.toSet() +fun Notifying( + baseMentions: ImmutableList?, + onClick: (User) -> Unit, +) { + val mentions = baseMentions?.toSet() - FlowRow(horizontalArrangement = Arrangement.spacedBy(5.dp)) { - if (!mentions.isNullOrEmpty()) { - Text( - stringResource(R.string.reply_notify), - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.align(CenterVertically) + FlowRow(horizontalArrangement = Arrangement.spacedBy(5.dp)) { + if (!mentions.isNullOrEmpty()) { + Text( + stringResource(R.string.reply_notify), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.align(CenterVertically), + ) + + mentions.forEachIndexed { idx, user -> + val innerUserState by user.live().metadata.observeAsState() + innerUserState?.user?.let { myUser -> + val tags = + remember(innerUserState) { myUser.info?.latestMetadata?.tags?.toImmutableListOfLists() } + + Button( + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.mediumImportanceLink, + ), + onClick = { onClick(myUser) }, + ) { + CreateTextWithEmoji( + text = remember(innerUserState) { "โœ– ${myUser.toBestDisplayName()}" }, + tags = tags, + color = Color.White, + textAlign = TextAlign.Center, ) - - mentions.forEachIndexed { idx, user -> - val innerUserState by user.live().metadata.observeAsState() - innerUserState?.user?.let { myUser -> - val tags = remember(innerUserState) { - myUser.info?.latestMetadata?.tags?.toImmutableListOfLists() - } - - Button( - shape = ButtonBorder, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.mediumImportanceLink - ), - onClick = { - onClick(myUser) - } - ) { - CreateTextWithEmoji( - text = remember(innerUserState) { "โœ– ${myUser.toBestDisplayName()}" }, - tags = tags, - color = Color.White, - textAlign = TextAlign.Center - ) - } - } - } + } } + } } + } } @Composable private fun AddPollButton( - isPollActive: Boolean, - onClick: () -> Unit + isPollActive: Boolean, + onClick: () -> Unit, ) { - IconButton( - onClick = { - onClick() - } - ) { - if (!isPollActive) { - PollIcon() - } else { - RegularPostIcon() - } + IconButton( + onClick = { onClick() }, + ) { + if (!isPollActive) { + PollIcon() + } else { + RegularPostIcon() } + } } @Composable private fun AddZapraiserButton( - isLnInvoiceActive: Boolean, - onClick: () -> Unit + isLnInvoiceActive: Boolean, + onClick: () -> Unit, ) { - IconButton( - onClick = { - onClick() - } + IconButton( + onClick = { onClick() }, + ) { + Box( + Modifier.height(20.dp).width(25.dp), ) { - Box( - Modifier - .height(20.dp) - .width(25.dp) - ) { - if (!isLnInvoiceActive) { - Icon( - imageVector = Icons.Default.ShowChart, - null, - modifier = Modifier - .size(20.dp) - .align(Alignment.TopStart), - tint = MaterialTheme.colorScheme.onBackground - ) - Icon( - imageVector = Icons.Default.Bolt, - contentDescription = stringResource(R.string.zaps), - modifier = Modifier - .size(13.dp) - .align(Alignment.BottomEnd), - tint = MaterialTheme.colorScheme.onBackground - ) - } else { - Icon( - imageVector = Icons.Default.ShowChart, - null, - modifier = Modifier - .size(20.dp) - .align(Alignment.TopStart), - tint = BitcoinOrange - ) - Icon( - imageVector = Icons.Default.Bolt, - contentDescription = stringResource(R.string.zaps), - modifier = Modifier - .size(13.dp) - .align(Alignment.BottomEnd), - tint = BitcoinOrange - ) - } - } + if (!isLnInvoiceActive) { + Icon( + imageVector = Icons.Default.ShowChart, + null, + modifier = Modifier.size(20.dp).align(Alignment.TopStart), + tint = MaterialTheme.colorScheme.onBackground, + ) + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = stringResource(R.string.zaps), + modifier = Modifier.size(13.dp).align(Alignment.BottomEnd), + tint = MaterialTheme.colorScheme.onBackground, + ) + } else { + Icon( + imageVector = Icons.Default.ShowChart, + null, + modifier = Modifier.size(20.dp).align(Alignment.TopStart), + tint = BitcoinOrange, + ) + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = stringResource(R.string.zaps), + modifier = Modifier.size(13.dp).align(Alignment.BottomEnd), + tint = BitcoinOrange, + ) + } } + } } @Composable -fun AddGeoHash(postViewModel: NewPostViewModel, onClick: () -> Unit) { - IconButton( - onClick = { - onClick() - } - ) { - if (!postViewModel.wantsToAddGeoHash) { - Icon( - imageVector = Icons.Default.LocationOff, - null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onBackground - ) - } else { - Icon( - imageVector = Icons.Default.LocationOn, - null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary - ) - } +fun AddGeoHash( + postViewModel: NewPostViewModel, + onClick: () -> Unit, +) { + IconButton( + onClick = { onClick() }, + ) { + if (!postViewModel.wantsToAddGeoHash) { + Icon( + imageVector = Icons.Default.LocationOff, + null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onBackground, + ) + } else { + Icon( + imageVector = Icons.Default.LocationOn, + null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) } + } } @Composable private fun AddLnInvoiceButton( - isLnInvoiceActive: Boolean, - onClick: () -> Unit + isLnInvoiceActive: Boolean, + onClick: () -> Unit, ) { - IconButton( - onClick = { - onClick() - } - ) { - if (!isLnInvoiceActive) { - Icon( - imageVector = Icons.Default.CurrencyBitcoin, - null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onBackground - ) - } else { - Icon( - imageVector = Icons.Default.CurrencyBitcoin, - null, - modifier = Modifier.size(20.dp), - tint = BitcoinOrange - ) - } + IconButton( + onClick = { onClick() }, + ) { + if (!isLnInvoiceActive) { + Icon( + imageVector = Icons.Default.CurrencyBitcoin, + null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onBackground, + ) + } else { + Icon( + imageVector = Icons.Default.CurrencyBitcoin, + null, + modifier = Modifier.size(20.dp), + tint = BitcoinOrange, + ) } + } } @Composable private fun ForwardZapTo( - postViewModel: NewPostViewModel, - onClick: () -> Unit + postViewModel: NewPostViewModel, + onClick: () -> Unit, ) { - IconButton( - onClick = { - onClick() - } + IconButton( + onClick = { onClick() }, + ) { + Box( + Modifier.height(20.dp).width(25.dp), ) { - Box( - Modifier - .height(20.dp) - .width(25.dp) - ) { - if (!postViewModel.wantsForwardZapTo) { - Icon( - imageVector = Icons.Default.Bolt, - contentDescription = stringResource(R.string.zaps), - modifier = Modifier - .size(20.dp) - .align(Alignment.CenterStart), - tint = MaterialTheme.colorScheme.onBackground - ) - Icon( - imageVector = Icons.Default.ArrowForwardIos, - contentDescription = stringResource(R.string.zaps), - modifier = Modifier - .size(13.dp) - .align(Alignment.CenterEnd), - tint = MaterialTheme.colorScheme.onBackground - ) - } else { - Icon( - imageVector = Icons.Outlined.Bolt, - contentDescription = stringResource(id = R.string.zaps), - modifier = Modifier - .size(20.dp) - .align(Alignment.CenterStart), - tint = BitcoinOrange - ) - Icon( - imageVector = Icons.Outlined.ArrowForwardIos, - contentDescription = stringResource(id = R.string.zaps), - modifier = Modifier - .size(13.dp) - .align(Alignment.CenterEnd), - tint = BitcoinOrange - ) - } - } + if (!postViewModel.wantsForwardZapTo) { + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = stringResource(R.string.zaps), + modifier = Modifier.size(20.dp).align(Alignment.CenterStart), + tint = MaterialTheme.colorScheme.onBackground, + ) + Icon( + imageVector = Icons.Default.ArrowForwardIos, + contentDescription = stringResource(R.string.zaps), + modifier = Modifier.size(13.dp).align(Alignment.CenterEnd), + tint = MaterialTheme.colorScheme.onBackground, + ) + } else { + Icon( + imageVector = Icons.Outlined.Bolt, + contentDescription = stringResource(id = R.string.zaps), + modifier = Modifier.size(20.dp).align(Alignment.CenterStart), + tint = BitcoinOrange, + ) + Icon( + imageVector = Icons.Outlined.ArrowForwardIos, + contentDescription = stringResource(id = R.string.zaps), + modifier = Modifier.size(13.dp).align(Alignment.CenterEnd), + tint = BitcoinOrange, + ) + } } + } } @Composable private fun AddClassifiedsButton( - postViewModel: NewPostViewModel, - onClick: () -> Unit + postViewModel: NewPostViewModel, + onClick: () -> Unit, ) { - IconButton( - onClick = { - onClick() - } - ) { - if (!postViewModel.wantsProduct) { - Icon( - imageVector = Icons.Default.Sell, - contentDescription = stringResource(R.string.classifieds), - modifier = Modifier - .size(20.dp), - tint = MaterialTheme.colorScheme.onBackground - ) - } else { - Icon( - imageVector = Icons.Default.Sell, - contentDescription = stringResource(id = R.string.classifieds), - modifier = Modifier - .size(20.dp), - tint = BitcoinOrange - ) - } + IconButton( + onClick = { onClick() }, + ) { + if (!postViewModel.wantsProduct) { + Icon( + imageVector = Icons.Default.Sell, + contentDescription = stringResource(R.string.classifieds), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onBackground, + ) + } else { + Icon( + imageVector = Icons.Default.Sell, + contentDescription = stringResource(id = R.string.classifieds), + modifier = Modifier.size(20.dp), + tint = BitcoinOrange, + ) } + } } @Composable private fun MarkAsSensitive( - postViewModel: NewPostViewModel, - onClick: () -> Unit + postViewModel: NewPostViewModel, + onClick: () -> Unit, ) { - IconButton( - onClick = { - onClick() - } + IconButton( + onClick = { onClick() }, + ) { + Box( + Modifier.height(20.dp).width(23.dp), ) { - Box( - Modifier - .height(20.dp) - .width(23.dp) - ) { - if (!postViewModel.wantsToMarkAsSensitive) { - Icon( - imageVector = Icons.Default.Visibility, - contentDescription = stringResource(R.string.content_warning), - modifier = Modifier - .size(18.dp) - .align(Alignment.BottomStart), - tint = MaterialTheme.colorScheme.onBackground - ) - Icon( - imageVector = Icons.Rounded.Warning, - contentDescription = stringResource(R.string.content_warning), - modifier = Modifier - .size(10.dp) - .align(Alignment.TopEnd), - tint = MaterialTheme.colorScheme.onBackground - ) - } else { - Icon( - imageVector = Icons.Default.VisibilityOff, - contentDescription = stringResource(id = R.string.content_warning), - modifier = Modifier - .size(18.dp) - .align(Alignment.BottomStart), - tint = Color.Red - ) - Icon( - imageVector = Icons.Rounded.Warning, - contentDescription = stringResource(id = R.string.content_warning), - modifier = Modifier - .size(10.dp) - .align(Alignment.TopEnd), - tint = Color.Yellow - ) - } - } + if (!postViewModel.wantsToMarkAsSensitive) { + Icon( + imageVector = Icons.Default.Visibility, + contentDescription = stringResource(R.string.content_warning), + modifier = Modifier.size(18.dp).align(Alignment.BottomStart), + tint = MaterialTheme.colorScheme.onBackground, + ) + Icon( + imageVector = Icons.Rounded.Warning, + contentDescription = stringResource(R.string.content_warning), + modifier = Modifier.size(10.dp).align(Alignment.TopEnd), + tint = MaterialTheme.colorScheme.onBackground, + ) + } else { + Icon( + imageVector = Icons.Default.VisibilityOff, + contentDescription = stringResource(id = R.string.content_warning), + modifier = Modifier.size(18.dp).align(Alignment.BottomStart), + tint = Color.Red, + ) + Icon( + imageVector = Icons.Rounded.Warning, + contentDescription = stringResource(id = R.string.content_warning), + modifier = Modifier.size(10.dp).align(Alignment.TopEnd), + tint = Color.Yellow, + ) + } } + } } @Composable fun CloseButton(onPress: () -> Unit) { - OutlinedButton( - onClick = onPress, - contentPadding = PaddingValues(horizontal = Size5dp) - ) { - CloseIcon() - } + OutlinedButton( + onClick = onPress, + contentPadding = PaddingValues(horizontal = Size5dp), + ) { + CloseIcon() + } } @Composable -fun PostButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier = Modifier) { - Button( - modifier = modifier, - enabled = isActive, - onClick = onPost - ) { - Text(text = stringResource(R.string.post)) - } +fun PostButton( + onPost: () -> Unit = {}, + isActive: Boolean, + modifier: Modifier = Modifier, +) { + Button( + modifier = modifier, + enabled = isActive, + onClick = onPost, + ) { + Text(text = stringResource(R.string.post)) + } } @Composable -fun SaveButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier = Modifier) { - Button( - enabled = isActive, - modifier = modifier, - onClick = onPost - ) { - Text(text = stringResource(R.string.save)) - } +fun SaveButton( + onPost: () -> Unit = {}, + isActive: Boolean, + modifier: Modifier = Modifier, +) { + Button( + enabled = isActive, + modifier = modifier, + onClick = onPost, + ) { + Text(text = stringResource(R.string.save)) + } } @Composable -fun CreateButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier = Modifier) { - Button( - modifier = modifier, - onClick = { - if (isActive) { - onPost() - } - }, - shape = ButtonBorder, - colors = ButtonDefaults.buttonColors( - containerColor = if (isActive) MaterialTheme.colorScheme.primary else Color.Gray - ) - ) { - Text(text = stringResource(R.string.create), color = Color.White) - } +fun CreateButton( + onPost: () -> Unit = {}, + isActive: Boolean, + modifier: Modifier = Modifier, +) { + Button( + modifier = modifier, + onClick = { + if (isActive) { + onPost() + } + }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = if (isActive) MaterialTheme.colorScheme.primary else Color.Gray, + ), + ) { + Text(text = stringResource(R.string.create), color = Color.White) + } } @Composable fun ImageVideoDescription( - uri: Uri, - defaultServer: Nip96MediaServers.ServerName, - onAdd: (String, ServerOption, Boolean) -> Unit, - onCancel: () -> Unit, - onError: (String) -> Unit, - accountViewModel: AccountViewModel + uri: Uri, + defaultServer: Nip96MediaServers.ServerName, + onAdd: (String, ServerOption, Boolean) -> Unit, + onCancel: () -> Unit, + onError: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - val resolver = LocalContext.current.contentResolver - val mediaType = resolver.getType(uri) ?: "" + val resolver = LocalContext.current.contentResolver + val mediaType = resolver.getType(uri) ?: "" - val isImage = mediaType.startsWith("image") - val isVideo = mediaType.startsWith("video") + val isImage = mediaType.startsWith("image") + val isVideo = mediaType.startsWith("video") - val fileServers = Nip96MediaServers.DEFAULT.map { ServerOption(it, false) } + listOf( + val fileServers = + Nip96MediaServers.DEFAULT.map { ServerOption(it, false) } + + listOf( ServerOption( - Nip96MediaServers.ServerName( - "NIP95", - stringResource(id = R.string.upload_server_relays_nip95) - ), - true - ) - ) + Nip96MediaServers.ServerName( + "NIP95", + stringResource(id = R.string.upload_server_relays_nip95), + ), + true, + ), + ) - val fileServerOptions = remember { fileServers.map { TitleExplainer(it.server.name, it.server.baseUrl) }.toImmutableList() } + val fileServerOptions = remember { + fileServers.map { TitleExplainer(it.server.name, it.server.baseUrl) }.toImmutableList() + } - var selectedServer by remember { mutableStateOf(ServerOption(defaultServer, false)) } - var message by remember { mutableStateOf("") } - var sensitiveContent by remember { mutableStateOf(false) } + var selectedServer by remember { mutableStateOf(ServerOption(defaultServer, false)) } + var message by remember { mutableStateOf("") } + var sensitiveContent by remember { mutableStateOf(false) } + Column( + modifier = + Modifier.fillMaxWidth() + .padding(start = 30.dp, end = 30.dp) + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ), + ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(start = 30.dp, end = 30.dp) - .clip(shape = QuoteBorder) - .border( - 1.dp, - MaterialTheme.colorScheme.subtleBorder, - QuoteBorder - ) + modifier = Modifier.fillMaxWidth().padding(30.dp), ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(30.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 10.dp) - ) { - Text( - text = stringResource( - if (isImage) { - R.string.content_description_add_image - } else { - if (isVideo) { - R.string.content_description_add_video - } else { - R.string.content_description_add_document - } - } - ), - fontSize = 20.sp, - fontWeight = FontWeight.W500, - modifier = Modifier - .padding(start = 10.dp) - .weight(1.0f) - .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) - ) - - IconButton( - modifier = Modifier - .size(30.dp) - .padding(end = 5.dp), - onClick = onCancel - ) { - CancelIcon() - } - } - - Divider() - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 10.dp) - .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) - ) { - if (mediaType.startsWith("image")) { - AsyncImage( - model = uri.toString(), - contentDescription = uri.toString(), - contentScale = ContentScale.FillWidth, - modifier = Modifier - .padding(top = 4.dp) - .fillMaxWidth() - .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) - ) - } else if (mediaType.startsWith("video") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - var bitmap by remember { mutableStateOf(null) } - - LaunchedEffect(key1 = uri) { - launch(Dispatchers.IO) { - try { - bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null) - } catch (e: Exception) { - onError("Unable to load thumbnail") - Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e) - } - } - } - - bitmap?.let { - Image( - bitmap = it.asImageBitmap(), - contentDescription = "some useful description", - contentScale = ContentScale.FillWidth, - modifier = Modifier - .padding(top = 4.dp) - .fillMaxWidth() - ) - } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + ) { + Text( + text = + stringResource( + if (isImage) { + R.string.content_description_add_image + } else { + if (isVideo) { + R.string.content_description_add_video } else { - VideoView(uri.toString(), roundedCorner = true, accountViewModel = accountViewModel) + R.string.content_description_add_document } - } + }, + ), + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = + Modifier.padding(start = 10.dp) + .weight(1.0f) + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), + ) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - TextSpinner( - label = stringResource(id = R.string.file_server), - placeholder = fileServers.firstOrNull { - it.server == accountViewModel.account.defaultFileServer - }?.server?.name ?: fileServers[0].server.name, - options = fileServerOptions, - onSelect = { - selectedServer = fileServers[it] - }, - modifier = Modifier - .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) - .weight(1f) - ) - } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - SettingSwitchItem( - checked = sensitiveContent, - onCheckedChange = { sensitiveContent = it }, - title = R.string.add_sensitive_content_label, - description = R.string.add_sensitive_content_description - ) - } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) - ) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.content_description)) }, - modifier = Modifier - .fillMaxWidth() - .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), - value = message, - onValueChange = { message = it }, - placeholder = { - Text( - text = stringResource(R.string.content_description_example), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences - ) - ) - } - - Button( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 10.dp), - onClick = { - onAdd(message, selectedServer, sensitiveContent) - }, - shape = QuoteBorder, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ) - ) { - Text(text = stringResource(R.string.add_content), color = Color.White, fontSize = 20.sp) - } + IconButton( + modifier = Modifier.size(30.dp).padding(end = 5.dp), + onClick = onCancel, + ) { + CancelIcon() } + } + + Divider() + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.fillMaxWidth() + .padding(bottom = 10.dp) + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), + ) { + if (mediaType.startsWith("image")) { + AsyncImage( + model = uri.toString(), + contentDescription = uri.toString(), + contentScale = ContentScale.FillWidth, + modifier = + Modifier.padding(top = 4.dp) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), + ) + } else if ( + mediaType.startsWith("video") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + ) { + var bitmap by remember { mutableStateOf(null) } + + LaunchedEffect(key1 = uri) { + launch(Dispatchers.IO) { + try { + bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null) + } catch (e: Exception) { + onError("Unable to load thumbnail") + Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e) + } + } + } + + bitmap?.let { + Image( + bitmap = it.asImageBitmap(), + contentDescription = "some useful description", + contentScale = ContentScale.FillWidth, + modifier = Modifier.padding(top = 4.dp).fillMaxWidth(), + ) + } + } else { + VideoView(uri.toString(), roundedCorner = true, accountViewModel = accountViewModel) + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + TextSpinner( + label = stringResource(id = R.string.file_server), + placeholder = + fileServers + .firstOrNull { it.server == accountViewModel.account.defaultFileServer } + ?.server + ?.name + ?: fileServers[0].server.name, + options = fileServerOptions, + onSelect = { selectedServer = fileServers[it] }, + modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)).weight(1f), + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + SettingSwitchItem( + checked = sensitiveContent, + onCheckedChange = { sensitiveContent = it }, + title = R.string.add_sensitive_content_label, + description = R.string.add_sensitive_content_description, + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.content_description)) }, + modifier = + Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), + value = message, + onValueChange = { message = it }, + placeholder = { + Text( + text = stringResource(R.string.content_description_example), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + ) + } + + Button( + modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), + onClick = { onAdd(message, selectedServer, sensitiveContent) }, + shape = QuoteBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text(text = stringResource(R.string.add_content), color = Color.White, fontSize = 20.sp) + } } + } } @Composable fun SettingSwitchItem( - modifier: Modifier = Modifier, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, - title: Int, - description: Int, - enabled: Boolean = true + modifier: Modifier = Modifier, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + title: Int, + description: Int, + enabled: Boolean = true, ) { - Row( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) - .toggleable( - value = checked, - enabled = enabled, - role = Role.Switch, - onValueChange = onCheckedChange - ), - verticalAlignment = Alignment.CenterVertically + Row( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .toggleable( + value = checked, + enabled = enabled, + role = Role.Switch, + onValueChange = onCheckedChange, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1.0f), + verticalArrangement = Arrangement.spacedBy(3.dp), ) { - Column( - modifier = Modifier.weight(1.0f), - verticalArrangement = Arrangement.spacedBy(3.dp) - ) { - Text( - text = stringResource(id = title), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = stringResource(id = description), - style = MaterialTheme.typography.bodySmall, - color = Color.Gray, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - - Switch( - checked = checked, - onCheckedChange = null, - enabled = enabled - ) + Text( + text = stringResource(id = title), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringResource(id = description), + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) } + + Switch( + checked = checked, + onCheckedChange = null, + enabled = enabled, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 710f86489..f14f218c0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.actions import android.content.Context @@ -43,6 +63,7 @@ import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.ZapSplitSetup import com.vitorpamplona.quartz.events.findURLs +import java.net.URLEncoder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.BufferOverflow @@ -50,838 +71,924 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.launch -import java.net.URLEncoder enum class UserSuggestionAnchor { - MAIN_MESSAGE, - FORWARD_ZAPS, - TO_USERS + MAIN_MESSAGE, + FORWARD_ZAPS, + TO_USERS, } @Stable open class NewPostViewModel() : ViewModel() { - var accountViewModel: AccountViewModel? = null - var account: Account? = null - var requiresNIP24: Boolean = false + var accountViewModel: AccountViewModel? = null + var account: Account? = null + var requiresNIP24: Boolean = false - var originalNote: Note? = null + var originalNote: Note? = null - var pTags by mutableStateOf?>(null) - var eTags by mutableStateOf?>(null) + var pTags by mutableStateOf?>(null) + var eTags by mutableStateOf?>(null) - var nip94attachments by mutableStateOf>(emptyList()) - var nip95attachments by mutableStateOf>>(emptyList()) + var nip94attachments by mutableStateOf>(emptyList()) + var nip95attachments by + mutableStateOf>>(emptyList()) - var message by mutableStateOf(TextFieldValue("")) - var urlPreview by mutableStateOf(null) - var isUploadingImage by mutableStateOf(false) - val imageUploadingError = MutableSharedFlow(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST) + var message by mutableStateOf(TextFieldValue("")) + var urlPreview by mutableStateOf(null) + var isUploadingImage by mutableStateOf(false) + val imageUploadingError = + MutableSharedFlow(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST) - var userSuggestions by mutableStateOf>(emptyList()) - var userSuggestionAnchor: TextRange? = null - var userSuggestionsMainMessage: UserSuggestionAnchor? = null + var userSuggestions by mutableStateOf>(emptyList()) + var userSuggestionAnchor: TextRange? = null + var userSuggestionsMainMessage: UserSuggestionAnchor? = null - // DMs - var wantsDirectMessage by mutableStateOf(false) - var toUsers by mutableStateOf(TextFieldValue("")) - var subject by mutableStateOf(TextFieldValue("")) + // DMs + var wantsDirectMessage by mutableStateOf(false) + var toUsers by mutableStateOf(TextFieldValue("")) + var subject by mutableStateOf(TextFieldValue("")) - // Images and Videos - var contentToAddUrl by mutableStateOf(null) + // Images and Videos + var contentToAddUrl by mutableStateOf(null) - // Polls - var canUsePoll by mutableStateOf(false) - var wantsPoll by mutableStateOf(false) - var zapRecipients = mutableStateListOf() - var pollOptions = newStateMapPollOptions() - var valueMaximum by mutableStateOf(null) - var valueMinimum by mutableStateOf(null) - var consensusThreshold: Int? = null - var closedAt: Int? = null + // Polls + var canUsePoll by mutableStateOf(false) + var wantsPoll by mutableStateOf(false) + var zapRecipients = mutableStateListOf() + var pollOptions = newStateMapPollOptions() + var valueMaximum by mutableStateOf(null) + var valueMinimum by mutableStateOf(null) + var consensusThreshold: Int? = null + var closedAt: Int? = null - var isValidRecipients = mutableStateOf(true) - var isValidvalueMaximum = mutableStateOf(true) - var isValidvalueMinimum = mutableStateOf(true) - var isValidConsensusThreshold = mutableStateOf(true) - var isValidClosedAt = mutableStateOf(true) + var isValidRecipients = mutableStateOf(true) + var isValidvalueMaximum = mutableStateOf(true) + var isValidvalueMinimum = mutableStateOf(true) + var isValidConsensusThreshold = mutableStateOf(true) + var isValidClosedAt = mutableStateOf(true) - // Classifieds - var wantsProduct by mutableStateOf(false) - var title by mutableStateOf(TextFieldValue("")) - var price by mutableStateOf(TextFieldValue("")) - var locationText by mutableStateOf(TextFieldValue("")) - var category by mutableStateOf(TextFieldValue("")) - var condition by mutableStateOf(ClassifiedsEvent.CONDITION.USED_LIKE_NEW) + // Classifieds + var wantsProduct by mutableStateOf(false) + var title by mutableStateOf(TextFieldValue("")) + var price by mutableStateOf(TextFieldValue("")) + var locationText by mutableStateOf(TextFieldValue("")) + var category by mutableStateOf(TextFieldValue("")) + var condition by + mutableStateOf(ClassifiedsEvent.CONDITION.USED_LIKE_NEW) - // Invoices - var canAddInvoice by mutableStateOf(false) - var wantsInvoice by mutableStateOf(false) + // Invoices + var canAddInvoice by mutableStateOf(false) + var wantsInvoice by mutableStateOf(false) - // Forward Zap to - var wantsForwardZapTo by mutableStateOf(false) - var forwardZapTo by mutableStateOf>(Split()) - var forwardZapToEditting by mutableStateOf(TextFieldValue("")) + // Forward Zap to + var wantsForwardZapTo by mutableStateOf(false) + var forwardZapTo by mutableStateOf>(Split()) + var forwardZapToEditting by mutableStateOf(TextFieldValue("")) - // NSFW, Sensitive - var wantsToMarkAsSensitive by mutableStateOf(false) + // NSFW, Sensitive + var wantsToMarkAsSensitive by mutableStateOf(false) - // GeoHash - var wantsToAddGeoHash by mutableStateOf(false) - var locUtil: LocationUtil? = null - var location: Flow? = null + // GeoHash + var wantsToAddGeoHash by mutableStateOf(false) + var locUtil: LocationUtil? = null + var location: Flow? = null - // ZapRaiser - var canAddZapRaiser by mutableStateOf(false) - var wantsZapraiser by mutableStateOf(false) - var zapRaiserAmount by mutableStateOf(null) + // ZapRaiser + var canAddZapRaiser by mutableStateOf(false) + var wantsZapraiser by mutableStateOf(false) + var zapRaiserAmount by mutableStateOf(null) - // NIP24 Wrapped DMs / Group messages - var nip24 by mutableStateOf(false) + // NIP24 Wrapped DMs / Group messages + var nip24 by mutableStateOf(false) - open fun load(accountViewModel: AccountViewModel, replyingTo: Note?, quote: Note?) { - this.accountViewModel = accountViewModel - this.account = accountViewModel.account + open fun load( + accountViewModel: AccountViewModel, + replyingTo: Note?, + quote: Note?, + ) { + this.accountViewModel = accountViewModel + this.account = accountViewModel.account - originalNote = replyingTo - replyingTo?.let { replyNote -> - if (replyNote.event is BaseTextNoteEvent) { - this.eTags = (replyNote.replyTo ?: emptyList()).plus(replyNote) - } else { - this.eTags = listOf(replyNote) - } + originalNote = replyingTo + replyingTo?.let { replyNote -> + if (replyNote.event is BaseTextNoteEvent) { + this.eTags = (replyNote.replyTo ?: emptyList()).plus(replyNote) + } else { + this.eTags = listOf(replyNote) + } - if (replyNote.event !is CommunityDefinitionEvent) { - replyNote.author?.let { replyUser -> - val currentMentions = (replyNote.event as? TextNoteEvent) - ?.mentions() - ?.map { LocalCache.getOrCreateUser(it) } ?: emptyList() + if (replyNote.event !is CommunityDefinitionEvent) { + replyNote.author?.let { replyUser -> + val currentMentions = + (replyNote.event as? TextNoteEvent)?.mentions()?.map { LocalCache.getOrCreateUser(it) } + ?: emptyList() - if (currentMentions.contains(replyUser)) { - this.pTags = currentMentions - } else { - this.pTags = currentMentions.plus(replyUser) - } - } - } - } ?: run { - eTags = null - pTags = null + if (currentMentions.contains(replyUser)) { + this.pTags = currentMentions + } else { + this.pTags = currentMentions.plus(replyUser) + } } - - quote?.let { - message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}") - urlPreview = findUrlInMessage() - } - - canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null - canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null - canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null - contentToAddUrl = null - - wantsForwardZapTo = false - wantsToMarkAsSensitive = false - wantsToAddGeoHash = false - wantsZapraiser = false - zapRaiserAmount = null - forwardZapTo = Split() - forwardZapToEditting = TextFieldValue("") + } } - - fun sendPost(relayList: List? = null) { - viewModelScope.launch(Dispatchers.IO) { - innerSendPost(relayList) - } - } - - suspend fun innerSendPost(relayList: List? = null) { - if (accountViewModel == null) { - cancel() - return - } - - val tagger = NewMessageTagger(message.text, pTags, eTags, originalNote?.channelHex(), accountViewModel!!) - tagger.run() - - val toUsersTagger = NewMessageTagger(toUsers.text, null, null, null, accountViewModel!!) - toUsersTagger.run() - val dmUsers = toUsersTagger.pTags - - val zapReceiver = if (wantsForwardZapTo) { - forwardZapTo.items.map { - ZapSplitSetup( - lnAddressOrPubKeyHex = it.key.pubkeyHex, - relay = it.key.relaysBeingUsed.keys.firstOrNull(), - weight = it.percentage.toDouble(), - isLnAddress = false - ) - } - } else { - null - } - - val geoLocation = locUtil?.locationStateFlow?.value - val geoHash = if (wantsToAddGeoHash && geoLocation != null) { - geoLocation.toGeoHash(GeohashPrecision.KM_5_X_5.digits).toString() - } else { - null - } - - val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount else null - - nip95attachments.forEach { - if (eTags?.contains(LocalCache.getNoteIfExists(it.second.id)) == true) { - account?.sendNip95(it.first, it.second, relayList) - } - } - - val urls = findURLs(tagger.message) - val usedAttachments = nip94attachments.filter { - it.urls().intersect(urls).isNotEmpty() - } - usedAttachments.forEach { - account?.sendHeader(it, relayList, { }) - } - - if (originalNote?.channelHex() != null) { - if (originalNote is AddressableEvent && originalNote?.address() != null) { - account?.sendLiveMessage(tagger.message, originalNote?.address()!!, tagger.eTags, tagger.pTags, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash, nip94attachments = usedAttachments) - } else { - account?.sendChannelMessage(tagger.message, tagger.channelHex!!, tagger.eTags, tagger.pTags, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash, nip94attachments = usedAttachments) - } - } else if (originalNote?.event is PrivateDmEvent) { - account?.sendPrivateMessage(tagger.message, originalNote!!.author!!, originalNote!!, tagger.pTags, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash) - } else if (originalNote?.event is ChatMessageEvent) { - val receivers = (originalNote?.event as ChatMessageEvent).recipientsPubKey().plus(originalNote?.author?.pubkeyHex).filterNotNull().toSet().toList() - - account?.sendNIP24PrivateMessage( - message = tagger.message, - toUsers = receivers, - subject = subject.text.ifBlank { null }, - replyingTo = originalNote!!, - mentions = tagger.pTags, - wantsToMarkAsSensitive = wantsToMarkAsSensitive, - zapReceiver = zapReceiver, - zapRaiserAmount = localZapRaiserAmount, - geohash = geoHash - ) - } else if (!dmUsers.isNullOrEmpty()) { - if (nip24 || dmUsers.size > 1) { - account?.sendNIP24PrivateMessage( - message = tagger.message, - toUsers = dmUsers.map { it.pubkeyHex }, - subject = subject.text.ifBlank { null }, - replyingTo = tagger.eTags?.firstOrNull(), - mentions = tagger.pTags, - wantsToMarkAsSensitive = wantsToMarkAsSensitive, - zapReceiver = zapReceiver, - zapRaiserAmount = localZapRaiserAmount, - geohash = geoHash - ) - } else { - account?.sendPrivateMessage( - message = tagger.message, - toUser = dmUsers.first().pubkeyHex, - replyingTo = originalNote, - mentions = tagger.pTags, - wantsToMarkAsSensitive = wantsToMarkAsSensitive, - zapReceiver = zapReceiver, - zapRaiserAmount = localZapRaiserAmount, - geohash = geoHash - ) - } - } else { - if (wantsPoll) { - account?.sendPoll( - tagger.message, - tagger.eTags, - tagger.pTags, - pollOptions, - valueMaximum, - valueMinimum, - consensusThreshold, - closedAt, - zapReceiver, - wantsToMarkAsSensitive, - localZapRaiserAmount, - relayList, - geoHash, - nip94attachments = usedAttachments - ) - } else if (wantsProduct) { - account?.sendClassifieds( - title = title.text, - price = Price(price.text, "SATS", null), - condition = condition, - message = tagger.message, - replyTo = tagger.eTags, - mentions = tagger.pTags, - location = locationText.text, - category = category.text, - directMentions = tagger.directMentions, - zapReceiver = zapReceiver, - wantsToMarkAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = localZapRaiserAmount, - relayList = relayList, - geohash = geoHash, - nip94attachments = usedAttachments - ) - } else { - // adds markers - val rootId = - (originalNote?.event as? TextNoteEvent)?.root() // if it has a marker as root - ?: originalNote?.replyTo?.firstOrNull { it.event != null && it.replyTo?.isEmpty() == true }?.idHex // if it has loaded events with zero replies in the reply list - ?: originalNote?.replyTo?.firstOrNull()?.idHex // old rules, first item is root. - ?: originalNote?.idHex - - val replyId = originalNote?.idHex - - account?.sendPost( - message = tagger.message, - replyTo = tagger.eTags, - mentions = tagger.pTags, - tags = null, - zapReceiver = zapReceiver, - wantsToMarkAsSensitive = wantsToMarkAsSensitive, - zapRaiserAmount = localZapRaiserAmount, - replyingTo = replyId, - root = rootId, - directMentions = tagger.directMentions, - relayList = relayList, - geohash = geoHash, - nip94attachments = usedAttachments - ) - } - } - - cancel() - } - - fun upload( - galleryUri: Uri, - alt: String?, - sensitiveContent: Boolean, - isPrivate: Boolean = false, - server: ServerOption, - context: Context - ) { - isUploadingImage = true - contentToAddUrl = null - - val contentResolver = context.contentResolver - val contentType = contentResolver.getType(galleryUri) - - viewModelScope.launch(Dispatchers.IO) { - MediaCompressor().compress( - galleryUri, - contentType, - context.applicationContext, - onReady = { fileUri, contentType, size -> - if (server.isNip95) { - contentResolver.openInputStream(fileUri)?.use { - createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent) - } - } else { - viewModelScope.launch(Dispatchers.IO) { - try { - val result = Nip96Uploader(account).uploadImage( - uri = fileUri, - contentType = contentType, - size = size, - alt = alt, - sensitiveContent = if (sensitiveContent) "" else null, - server = server.server, - contentResolver = contentResolver, - onProgress = { } - ) - - if (!isPrivate) { - createNIP94Record( - uploadingResult = result, - localContentType = contentType, - alt = alt, - sensitiveContent = sensitiveContent - ) - } else { - noNIP94( - uploadingResult = result, - localContentType = contentType, - alt = alt, - sensitiveContent = sensitiveContent - ) - } - } catch (e: Exception) { - Log.e( - "ImageUploader", - "Failed to upload ${e.message}", - e - ) - isUploadingImage = false - viewModelScope.launch { - imageUploadingError.emit("Failed to upload: ${e.message}") - } - } - } - } - }, - onError = { - isUploadingImage = false - viewModelScope.launch { - imageUploadingError.emit(it) - } - } - ) - } - } - - open fun cancel() { - message = TextFieldValue("") - toUsers = TextFieldValue("") - subject = TextFieldValue("") - - contentToAddUrl = null - urlPreview = null - isUploadingImage = false + ?: run { + eTags = null pTags = null + } - wantsDirectMessage = false + quote?.let { + message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}") + urlPreview = findUrlInMessage() + } - wantsPoll = false - zapRecipients = mutableStateListOf() - pollOptions = newStateMapPollOptions() - valueMaximum = null - valueMinimum = null - consensusThreshold = null - closedAt = null + canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null + canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null + canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null + contentToAddUrl = null - wantsInvoice = false - wantsZapraiser = false - zapRaiserAmount = null + wantsForwardZapTo = false + wantsToMarkAsSensitive = false + wantsToAddGeoHash = false + wantsZapraiser = false + zapRaiserAmount = null + forwardZapTo = Split() + forwardZapToEditting = TextFieldValue("") + } - wantsProduct = false - condition = ClassifiedsEvent.CONDITION.USED_LIKE_NEW - price = TextFieldValue("") + fun sendPost(relayList: List? = null) { + viewModelScope.launch(Dispatchers.IO) { innerSendPost(relayList) } + } - wantsForwardZapTo = false - wantsToMarkAsSensitive = false - wantsToAddGeoHash = false - forwardZapTo = Split() - forwardZapToEditting = TextFieldValue("") + suspend fun innerSendPost(relayList: List? = null) { + if (accountViewModel == null) { + cancel() + return + } - userSuggestions = emptyList() - userSuggestionAnchor = null - userSuggestionsMainMessage = null + val tagger = + NewMessageTagger(message.text, pTags, eTags, originalNote?.channelHex(), accountViewModel!!) + tagger.run() + val toUsersTagger = NewMessageTagger(toUsers.text, null, null, null, accountViewModel!!) + toUsersTagger.run() + val dmUsers = toUsersTagger.pTags + + val zapReceiver = + if (wantsForwardZapTo) { + forwardZapTo.items.map { + ZapSplitSetup( + lnAddressOrPubKeyHex = it.key.pubkeyHex, + relay = it.key.relaysBeingUsed.keys.firstOrNull(), + weight = it.percentage.toDouble(), + isLnAddress = false, + ) + } + } else { + null + } + + val geoLocation = locUtil?.locationStateFlow?.value + val geoHash = + if (wantsToAddGeoHash && geoLocation != null) { + geoLocation.toGeoHash(GeohashPrecision.KM_5_X_5.digits).toString() + } else { + null + } + + val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount else null + + nip95attachments.forEach { + if (eTags?.contains(LocalCache.getNoteIfExists(it.second.id)) == true) { + account?.sendNip95(it.first, it.second, relayList) + } + } + + val urls = findURLs(tagger.message) + val usedAttachments = nip94attachments.filter { it.urls().intersect(urls).isNotEmpty() } + usedAttachments.forEach { account?.sendHeader(it, relayList, {}) } + + if (originalNote?.channelHex() != null) { + if (originalNote is AddressableEvent && originalNote?.address() != null) { + account?.sendLiveMessage( + tagger.message, + originalNote?.address()!!, + tagger.eTags, + tagger.pTags, + zapReceiver, + wantsToMarkAsSensitive, + localZapRaiserAmount, + geoHash, + nip94attachments = usedAttachments, + ) + } else { + account?.sendChannelMessage( + tagger.message, + tagger.channelHex!!, + tagger.eTags, + tagger.pTags, + zapReceiver, + wantsToMarkAsSensitive, + localZapRaiserAmount, + geoHash, + nip94attachments = usedAttachments, + ) + } + } else if (originalNote?.event is PrivateDmEvent) { + account?.sendPrivateMessage( + tagger.message, + originalNote!!.author!!, + originalNote!!, + tagger.pTags, + zapReceiver, + wantsToMarkAsSensitive, + localZapRaiserAmount, + geoHash, + ) + } else if (originalNote?.event is ChatMessageEvent) { + val receivers = + (originalNote?.event as ChatMessageEvent) + .recipientsPubKey() + .plus(originalNote?.author?.pubkeyHex) + .filterNotNull() + .toSet() + .toList() + + account?.sendNIP24PrivateMessage( + message = tagger.message, + toUsers = receivers, + subject = subject.text.ifBlank { null }, + replyingTo = originalNote!!, + mentions = tagger.pTags, + wantsToMarkAsSensitive = wantsToMarkAsSensitive, + zapReceiver = zapReceiver, + zapRaiserAmount = localZapRaiserAmount, + geohash = geoHash, + ) + } else if (!dmUsers.isNullOrEmpty()) { + if (nip24 || dmUsers.size > 1) { + account?.sendNIP24PrivateMessage( + message = tagger.message, + toUsers = dmUsers.map { it.pubkeyHex }, + subject = subject.text.ifBlank { null }, + replyingTo = tagger.eTags?.firstOrNull(), + mentions = tagger.pTags, + wantsToMarkAsSensitive = wantsToMarkAsSensitive, + zapReceiver = zapReceiver, + zapRaiserAmount = localZapRaiserAmount, + geohash = geoHash, + ) + } else { + account?.sendPrivateMessage( + message = tagger.message, + toUser = dmUsers.first().pubkeyHex, + replyingTo = originalNote, + mentions = tagger.pTags, + wantsToMarkAsSensitive = wantsToMarkAsSensitive, + zapReceiver = zapReceiver, + zapRaiserAmount = localZapRaiserAmount, + geohash = geoHash, + ) + } + } else { + if (wantsPoll) { + account?.sendPoll( + tagger.message, + tagger.eTags, + tagger.pTags, + pollOptions, + valueMaximum, + valueMinimum, + consensusThreshold, + closedAt, + zapReceiver, + wantsToMarkAsSensitive, + localZapRaiserAmount, + relayList, + geoHash, + nip94attachments = usedAttachments, + ) + } else if (wantsProduct) { + account?.sendClassifieds( + title = title.text, + price = Price(price.text, "SATS", null), + condition = condition, + message = tagger.message, + replyTo = tagger.eTags, + mentions = tagger.pTags, + location = locationText.text, + category = category.text, + directMentions = tagger.directMentions, + zapReceiver = zapReceiver, + wantsToMarkAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = localZapRaiserAmount, + relayList = relayList, + geohash = geoHash, + nip94attachments = usedAttachments, + ) + } else { + // adds markers + val rootId = + (originalNote?.event as? TextNoteEvent)?.root() // if it has a marker as root + ?: originalNote + ?.replyTo + ?.firstOrNull { it.event != null && it.replyTo?.isEmpty() == true } + ?.idHex // if it has loaded events with zero replies in the reply list + ?: originalNote?.replyTo?.firstOrNull()?.idHex // old rules, first item is root. + ?: originalNote?.idHex + + val replyId = originalNote?.idHex + + account?.sendPost( + message = tagger.message, + replyTo = tagger.eTags, + mentions = tagger.pTags, + tags = null, + zapReceiver = zapReceiver, + wantsToMarkAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = localZapRaiserAmount, + replyingTo = replyId, + root = rootId, + directMentions = tagger.directMentions, + relayList = relayList, + geohash = geoHash, + nip94attachments = usedAttachments, + ) + } + } + + cancel() + } + + fun upload( + galleryUri: Uri, + alt: String?, + sensitiveContent: Boolean, + isPrivate: Boolean = false, + server: ServerOption, + context: Context, + ) { + isUploadingImage = true + contentToAddUrl = null + + val contentResolver = context.contentResolver + val contentType = contentResolver.getType(galleryUri) + + viewModelScope.launch(Dispatchers.IO) { + MediaCompressor() + .compress( + galleryUri, + contentType, + context.applicationContext, + onReady = { fileUri, contentType, size -> + if (server.isNip95) { + contentResolver.openInputStream(fileUri)?.use { + createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent) + } + } else { + viewModelScope.launch(Dispatchers.IO) { + try { + val result = + Nip96Uploader(account) + .uploadImage( + uri = fileUri, + contentType = contentType, + size = size, + alt = alt, + sensitiveContent = if (sensitiveContent) "" else null, + server = server.server, + contentResolver = contentResolver, + onProgress = {}, + ) + + if (!isPrivate) { + createNIP94Record( + uploadingResult = result, + localContentType = contentType, + alt = alt, + sensitiveContent = sensitiveContent, + ) + } else { + noNIP94( + uploadingResult = result, + localContentType = contentType, + alt = alt, + sensitiveContent = sensitiveContent, + ) + } + } catch (e: Exception) { + Log.e( + "ImageUploader", + "Failed to upload ${e.message}", + e, + ) + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Failed to upload: ${e.message}") + } + } + } + } + }, + onError = { + isUploadingImage = false + viewModelScope.launch { imageUploadingError.emit(it) } + }, + ) + } + } + + open fun cancel() { + message = TextFieldValue("") + toUsers = TextFieldValue("") + subject = TextFieldValue("") + + contentToAddUrl = null + urlPreview = null + isUploadingImage = false + pTags = null + + wantsDirectMessage = false + + wantsPoll = false + zapRecipients = mutableStateListOf() + pollOptions = newStateMapPollOptions() + valueMaximum = null + valueMinimum = null + consensusThreshold = null + closedAt = null + + wantsInvoice = false + wantsZapraiser = false + zapRaiserAmount = null + + wantsProduct = false + condition = ClassifiedsEvent.CONDITION.USED_LIKE_NEW + price = TextFieldValue("") + + wantsForwardZapTo = false + wantsToMarkAsSensitive = false + wantsToAddGeoHash = false + forwardZapTo = Split() + forwardZapToEditting = TextFieldValue("") + + userSuggestions = emptyList() + userSuggestionAnchor = null + userSuggestionsMainMessage = null + + NostrSearchEventOrUserDataSource.clear() + } + + open fun findUrlInMessage(): String? { + return message.text.split('\n').firstNotNullOfOrNull { paragraph -> + paragraph.split(' ').firstOrNull { word: String -> + isValidURL(word) || noProtocolUrlValidator.matcher(word).matches() + } + } + } + + open fun removeFromReplyList(userToRemove: User) { + pTags = pTags?.filter { it != userToRemove } + } + + open fun updateMessage(it: TextFieldValue) { + message = it + urlPreview = findUrlInMessage() + + if (it.selection.collapsed) { + val lastWord = + it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ") + userSuggestionAnchor = it.selection + userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE + if (lastWord.startsWith("@") && lastWord.length > 2) { + NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@")) + viewModelScope.launch(Dispatchers.IO) { + userSuggestions = + LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) + .sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })) + .reversed() + } + } else { NostrSearchEventOrUserDataSource.clear() + userSuggestions = emptyList() + } } + } - open fun findUrlInMessage(): String? { - return message.text.split('\n').firstNotNullOfOrNull { paragraph -> - paragraph.split(' ').firstOrNull { word: String -> - isValidURL(word) || noProtocolUrlValidator.matcher(word).matches() - } + open fun updateToUsers(it: TextFieldValue) { + toUsers = it + + if (it.selection.collapsed) { + val lastWord = + it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ") + userSuggestionAnchor = it.selection + userSuggestionsMainMessage = UserSuggestionAnchor.TO_USERS + if (lastWord.startsWith("@") && lastWord.length > 2) { + NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@")) + viewModelScope.launch(Dispatchers.IO) { + userSuggestions = + LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) + .sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })) + .reversed() } + } else { + NostrSearchEventOrUserDataSource.clear() + userSuggestions = emptyList() + } + } + } + + open fun updateSubject(it: TextFieldValue) { + subject = it + } + + open fun updateZapForwardTo(it: TextFieldValue) { + forwardZapToEditting = it + if (it.selection.collapsed) { + val lastWord = it.text + userSuggestionAnchor = it.selection + userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS + if (lastWord.length > 2) { + NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@")) + viewModelScope.launch(Dispatchers.IO) { + userSuggestions = + LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) + .sortedWith( + compareBy( + { account?.isFollowing(it) }, + { it.toBestDisplayName() }, + ), + ) + .reversed() + } + } else { + NostrSearchEventOrUserDataSource.clear() + userSuggestions = emptyList() + } + } + } + + open fun autocompleteWithUser(item: User) { + userSuggestionAnchor?.let { + if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { + val lastWord = + message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") + val lastWordStart = it.end - lastWord.length + val wordToInsert = "@${item.pubkeyNpub()}" + + message = + TextFieldValue( + message.text.replaceRange(lastWordStart, it.end, wordToInsert), + TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length), + ) + } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { + forwardZapTo.addItem(item) + forwardZapToEditting = TextFieldValue("") + /* + val lastWord = forwardZapToEditting.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") + val lastWordStart = it.end - lastWord.length + val wordToInsert = "@${item.pubkeyNpub()}" + forwardZapTo = item + + forwardZapToEditting = TextFieldValue( + forwardZapToEditting.text.replaceRange(lastWordStart, it.end, wordToInsert), + TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length) + )*/ + } else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) { + val lastWord = + toUsers.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") + val lastWordStart = it.end - lastWord.length + val wordToInsert = "@${item.pubkeyNpub()}" + + toUsers = + TextFieldValue( + toUsers.text.replaceRange(lastWordStart, it.end, wordToInsert), + TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length), + ) + } + + userSuggestionAnchor = null + userSuggestionsMainMessage = null + userSuggestions = emptyList() + } + } + + private fun newStateMapPollOptions(): SnapshotStateMap { + return mutableStateMapOf(Pair(0, ""), Pair(1, "")) + } + + fun canPost(): Boolean { + return message.text.isNotBlank() && + !isUploadingImage && + !wantsInvoice && + (!wantsZapraiser || zapRaiserAmount != null) && + (!wantsDirectMessage || !toUsers.text.isNullOrBlank()) && + (!wantsPoll || + (pollOptions.values.all { it.isNotEmpty() } && + isValidvalueMinimum.value && + isValidvalueMaximum.value)) && + (!wantsProduct || + (!title.text.isNullOrBlank() && + !price.text.isNullOrBlank() && + !category.text.isNullOrBlank())) && + contentToAddUrl == null + } + + fun includePollHashtagInMessage( + include: Boolean, + hashtag: String, + ) { + if (include) { + updateMessage(TextFieldValue(message.text + " $hashtag")) + } else { + updateMessage( + TextFieldValue( + message.text.replace(" $hashtag", "").replace(hashtag, ""), + ), + ) + } + } + + suspend fun createNIP94Record( + 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 originalHash = + uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "ox" }?.get(1)?.ifBlank { null } + val dim = + uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null } + val magnet = + uploadingResult.tags + ?.firstOrNull { it.size > 1 && it[0] == "magnet" } + ?.get(1) + ?.ifBlank { null } + + if (imageUrl.isNullOrBlank()) { + Log.e("ImageDownload", "Couldn't download image from server") + cancel() + isUploadingImage = false + viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") } + return } - open fun removeFromReplyList(userToRemove: User) { - pTags = pTags?.filter { it != userToRemove } + FileHeader.prepare( + fileUrl = imageUrl, + mimeType = remoteMimeType ?: localContentType, + dimPrecomputed = dim, + onReady = { header: FileHeader -> + account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { + event, + -> + isUploadingImage = false + nip94attachments = nip94attachments + event + 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") } + }, + ) + } + + 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 } - open fun updateMessage(it: TextFieldValue) { - message = it + 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") } + }, + ) + } - if (it.selection.collapsed) { - val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ") - userSuggestionAnchor = it.selection - userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE - if (lastWord.startsWith("@") && lastWord.length > 2) { - NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@")) - viewModelScope.launch(Dispatchers.IO) { - userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) - .sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })) - .reversed() - } - } else { - NostrSearchEventOrUserDataSource.clear() - userSuggestions = emptyList() - } - } - } - - open fun updateToUsers(it: TextFieldValue) { - toUsers = it - - if (it.selection.collapsed) { - val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ") - userSuggestionAnchor = it.selection - userSuggestionsMainMessage = UserSuggestionAnchor.TO_USERS - if (lastWord.startsWith("@") && lastWord.length > 2) { - NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@")) - viewModelScope.launch(Dispatchers.IO) { - userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) - .sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })) - .reversed() - } - } else { - NostrSearchEventOrUserDataSource.clear() - userSuggestions = emptyList() - } - } - } - - open fun updateSubject(it: TextFieldValue) { - subject = it - } - - open fun updateZapForwardTo(it: TextFieldValue) { - forwardZapToEditting = it - if (it.selection.collapsed) { - val lastWord = it.text - userSuggestionAnchor = it.selection - userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS - if (lastWord.length > 2) { - NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@")) - viewModelScope.launch(Dispatchers.IO) { - userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) - .sortedWith( - compareBy( - { account?.isFollowing(it) }, - { it.toBestDisplayName() } - ) - ).reversed() - } - } else { - NostrSearchEventOrUserDataSource.clear() - userSuggestions = emptyList() - } - } - } - - open fun autocompleteWithUser(item: User) { - userSuggestionAnchor?.let { - if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { - val lastWord = message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") - val lastWordStart = it.end - lastWord.length - val wordToInsert = "@${item.pubkeyNpub()}" - - message = TextFieldValue( - message.text.replaceRange(lastWordStart, it.end, wordToInsert), - TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length) - ) - } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { - forwardZapTo.addItem(item) - forwardZapToEditting = TextFieldValue("") - /* - val lastWord = forwardZapToEditting.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") - val lastWordStart = it.end - lastWord.length - val wordToInsert = "@${item.pubkeyNpub()}" - forwardZapTo = item - - forwardZapToEditting = TextFieldValue( - forwardZapToEditting.text.replaceRange(lastWordStart, it.end, wordToInsert), - TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length) - )*/ - } else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) { - val lastWord = toUsers.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") - val lastWordStart = it.end - lastWord.length - val wordToInsert = "@${item.pubkeyNpub()}" - - toUsers = TextFieldValue( - toUsers.text.replaceRange(lastWordStart, it.end, wordToInsert), - TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length) - ) - } - - userSuggestionAnchor = null - userSuggestionsMainMessage = null - userSuggestions = emptyList() - } - } - - private fun newStateMapPollOptions(): SnapshotStateMap { - return mutableStateMapOf(Pair(0, ""), Pair(1, "")) - } - - fun canPost(): Boolean { - return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice && - (!wantsZapraiser || zapRaiserAmount != null) && - (!wantsDirectMessage || !toUsers.text.isNullOrBlank()) && - (!wantsPoll || (pollOptions.values.all { it.isNotEmpty() } && isValidvalueMinimum.value && isValidvalueMaximum.value)) && - (!wantsProduct || (!title.text.isNullOrBlank() && !price.text.isNullOrBlank() && !category.text.isNullOrBlank())) && - contentToAddUrl == null - } - - fun includePollHashtagInMessage(include: Boolean, hashtag: String) { - if (include) { - updateMessage(TextFieldValue(message.text + " $hashtag")) - } else { - updateMessage( - TextFieldValue( - message.text.replace(" $hashtag", "") - .replace(hashtag, "") - ) - ) - } - } - - suspend fun createNIP94Record( - 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 originalHash = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "ox" }?.get(1)?.ifBlank { null } - val dim = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null } - val magnet = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "magnet" }?.get(1)?.ifBlank { null } - - if (imageUrl.isNullOrBlank()) { - Log.e("ImageDownload", "Couldn't download image from server") - cancel() - isUploadingImage = false - viewModelScope.launch { - imageUploadingError.emit("Failed to upload the image / video") - } - return - } - - FileHeader.prepare( - fileUrl = imageUrl, - mimeType = remoteMimeType ?: localContentType, - dimPrecomputed = dim, - onReady = { header: FileHeader -> - account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event -> - isUploadingImage = false - nip94attachments = nip94attachments + event - 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( + bytes: ByteArray, + mimeType: String?, + alt: String?, + sensitiveContent: Boolean, + ) { + if (bytes.size > 80000) { + viewModelScope.launch { + imageUploadingError.emit("Media is too big for NIP-95") + isUploadingImage = false + } + return } - 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 } + viewModelScope.launch(Dispatchers.IO) { + FileHeader.prepare( + bytes, + mimeType, + null, + onReady = { + account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 -> + nip95attachments = nip95attachments + nip95 + val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } - 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") - } - } - ) + note?.let { message = TextFieldValue(message.text + "\nnostr:" + it.toNEvent()) } + + 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("&") + fun selectImage(uri: Uri) { + contentToAddUrl = uri + } - return if (imageUrl.contains("#")) { - "$imageUrl&$extension" + @OptIn(ExperimentalCoroutinesApi::class) + fun startLocation(context: Context) { + locUtil = LocationUtil(context) + locUtil?.let { + location = + it.locationStateFlow.mapLatest { it.toGeoHash(GeohashPrecision.KM_5_X_5.digits).toString() } + } + viewModelScope.launch(Dispatchers.IO) { locUtil?.start() } + } + + fun stopLocation() { + viewModelScope.launch(Dispatchers.IO) { locUtil?.stop() } + location = null + locUtil = null + } + + override fun onCleared() { + super.onCleared() + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + viewModelScope.launch(Dispatchers.IO) { locUtil?.stop() } + location = null + locUtil = null + } + + fun toggleNIP04And24() { + if (requiresNIP24) { + nip24 = true + } else { + nip24 = !nip24 + } + } + + fun updateMinZapAmountForPoll(textMin: String) { + if (textMin.isNotEmpty()) { + try { + val int = textMin.toInt() + if (int < 1) { + valueMinimum = null } else { - "$imageUrl#$extension" + valueMinimum = int } + } catch (e: Exception) {} + } else { + valueMinimum = null } - fun createNIP95Record( - bytes: ByteArray, - mimeType: String?, - alt: String?, - sensitiveContent: Boolean - ) { - if (bytes.size > 80000) { - viewModelScope.launch { - imageUploadingError.emit("Media is too big for NIP-95") - isUploadingImage = false - } - return - } + checkMinMax() + } - viewModelScope.launch(Dispatchers.IO) { - FileHeader.prepare( - bytes, - mimeType, - null, - onReady = { - account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 -> - nip95attachments = nip95attachments + nip95 - val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } - - isUploadingImage = false - - note?.let { - message = TextFieldValue(message.text + "\nnostr:" + it.toNEvent()) - } - - urlPreview = findUrlInMessage() - } - }, - onError = { - isUploadingImage = false - viewModelScope.launch { - imageUploadingError.emit("Failed to upload the image / video") - } - } - ) - } - } - - fun selectImage(uri: Uri) { - contentToAddUrl = uri - } - - @OptIn(ExperimentalCoroutinesApi::class) - fun startLocation(context: Context) { - locUtil = LocationUtil(context) - locUtil?.let { - location = it.locationStateFlow.mapLatest { - it.toGeoHash(GeohashPrecision.KM_5_X_5.digits).toString() - } - } - viewModelScope.launch(Dispatchers.IO) { - locUtil?.start() - } - } - - fun stopLocation() { - viewModelScope.launch(Dispatchers.IO) { - locUtil?.stop() - } - location = null - locUtil = null - } - - override fun onCleared() { - super.onCleared() - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") - viewModelScope.launch(Dispatchers.IO) { - locUtil?.stop() - } - location = null - locUtil = null - } - - fun toggleNIP04And24() { - if (requiresNIP24) { - nip24 = true + fun updateMaxZapAmountForPoll(textMax: String) { + if (textMax.isNotEmpty()) { + try { + val int = textMax.toInt() + if (int < 1) { + valueMaximum = null } else { - nip24 = !nip24 + valueMaximum = int } + } catch (e: Exception) {} + } else { + valueMaximum = null } - fun updateMinZapAmountForPoll(textMin: String) { - if (textMin.isNotEmpty()) { - try { - val int = textMin.toInt() - if (int < 1) { - valueMinimum = null - } else { - valueMinimum = int - } - } catch (e: Exception) {} - } else { - valueMinimum = null - } + checkMinMax() + } - checkMinMax() + fun checkMinMax() { + if ((valueMinimum ?: 0) > (valueMaximum ?: Int.MAX_VALUE)) { + isValidvalueMinimum.value = false + isValidvalueMaximum.value = false + } else { + isValidvalueMinimum.value = true + isValidvalueMaximum.value = true } + } - fun updateMaxZapAmountForPoll(textMax: String) { - if (textMax.isNotEmpty()) { - try { - val int = textMax.toInt() - if (int < 1) { - valueMaximum = null - } else { - valueMaximum = int - } - } catch (e: Exception) {} - } else { - valueMaximum = null - } - - checkMinMax() - } - - fun checkMinMax() { - if ((valueMinimum ?: 0) > (valueMaximum ?: Int.MAX_VALUE)) { - isValidvalueMinimum.value = false - isValidvalueMaximum.value = false - } else { - isValidvalueMinimum.value = true - isValidvalueMaximum.value = true - } - } - - fun updateZapPercentage(index: Int, sliderValue: Float) { - forwardZapTo.updatePercentage(index, sliderValue) - } + fun updateZapPercentage( + index: Int, + sliderValue: Float, + ) { + forwardZapTo.updatePercentage(index, sliderValue) + } } enum class GeohashPrecision(val digits: Int) { - KM_5000_X_5000(1), // 5,000km ร— 5,000km - KM_1250_X_625(2), // 1,250km ร— 625km - KM_156_X_156(3), // 156km ร— 156km - KM_39_X_19(4), // 39.1km ร— 19.5km - KM_5_X_5(5), // 4.89km ร— 4.89km - - M_1000_X_600(6), // 1.22km ร— 0.61km - M_153_X_153(7), // 153m ร— 153m - M_38_X_19(8), // 38.2m ร— 19.1m - M_5_X_5(9), // 4.77m ร— 4.77m - - MM_1000_X_1000(10), // 1.19m ร— 0.596m - MM_149_X_149(11), // 149mm ร— 149mm - MM_37_X_18(12) // 37.2mm ร— 18.6mm + KM_5000_X_5000(1), // 5,000km ร— 5,000km + KM_1250_X_625(2), // 1,250km ร— 625km + KM_156_X_156(3), // 156km ร— 156km + KM_39_X_19(4), // 39.1km ร— 19.5km + KM_5_X_5(5), // 4.89km ร— 4.89km + M_1000_X_600(6), // 1.22km ร— 0.61km + M_153_X_153(7), // 153m ร— 153m + M_38_X_19(8), // 38.2m ร— 19.1m + M_5_X_5(9), // 4.77m ร— 4.77m + MM_1000_X_1000(10), // 1.19m ร— 0.596m + MM_149_X_149(11), // 149mm ร— 149mm + MM_37_X_18(12), // 37.2mm ร— 18.6mm } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt index 0fac35af1..85b6e1973 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.actions import android.widget.Toast @@ -81,795 +101,839 @@ import com.vitorpamplona.amethyst.ui.theme.allGoodColor import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.warningColor -import kotlinx.coroutines.launch import java.lang.Math.round +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable -fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, relayToAdd: String = "", nav: (String) -> Unit) { - val postViewModel: NewRelayListViewModel = viewModel() - val feedState by postViewModel.relays.collectAsStateWithLifecycle() +fun NewRelayListView( + onClose: () -> Unit, + accountViewModel: AccountViewModel, + relayToAdd: String = "", + nav: (String) -> Unit, +) { + val postViewModel: NewRelayListViewModel = viewModel() + val feedState by postViewModel.relays.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { - postViewModel.load(accountViewModel.account) - } + LaunchedEffect(Unit) { postViewModel.load(accountViewModel.account) } - Dialog( - onDismissRequest = onClose, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Scaffold( - topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Spacer(modifier = StdHorzSpacer) - - Button( - onClick = { - postViewModel.deleteAll() - defaultRelays.forEach { - postViewModel.addRelay(it) - } - postViewModel.loadRelayDocuments() - } - ) { - Text(stringResource(R.string.default_relays)) - } - - SaveButton( - onPost = { - postViewModel.create() - onClose() - }, - true - ) - } - }, - navigationIcon = { - Spacer(modifier = StdHorzSpacer) - CloseButton(onPress = { - postViewModel.clear() - onClose() - }) - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface - ) - ) - } - ) { pad -> - Column( - modifier = Modifier.padding( - 16.dp, - pad.calculateTopPadding(), - 16.dp, - pad.calculateBottomPadding() - ), - verticalArrangement = Arrangement.SpaceAround + Dialog( + onDismissRequest = onClose, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Scaffold( + topBar = { + TopAppBar( + title = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { - LazyColumn( - contentPadding = FeedPadding - ) { - itemsIndexed(feedState, key = { _, item -> item.url }) { index, item -> - ServerConfig( - item, - onToggleDownload = { postViewModel.toggleDownload(it) }, - onToggleUpload = { postViewModel.toggleUpload(it) }, + Spacer(modifier = StdHorzSpacer) - onToggleFollows = { postViewModel.toggleFollows(it) }, - onTogglePrivateDMs = { postViewModel.toggleMessages(it) }, - onTogglePublicChats = { postViewModel.togglePublicChats(it) }, - onToggleGlobal = { postViewModel.toggleGlobal(it) }, - onToggleSearch = { postViewModel.toggleSearch(it) }, + Button( + onClick = { + postViewModel.deleteAll() + defaultRelays.forEach { postViewModel.addRelay(it) } + postViewModel.loadRelayDocuments() + }, + ) { + Text(stringResource(R.string.default_relays)) + } - onDelete = { postViewModel.deleteRelay(it) }, - accountViewModel = accountViewModel, - nav = nav - ) - } - } - } - - Spacer(modifier = StdVertSpacer) - - EditableServerConfig(relayToAdd) { - postViewModel.addRelay(it) - } + SaveButton( + onPost = { + postViewModel.create() + onClose() + }, + true, + ) } + }, + navigationIcon = { + Spacer(modifier = StdHorzSpacer) + CloseButton( + onPress = { + postViewModel.clear() + onClose() + }, + ) + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + }, + ) { pad -> + Column( + modifier = + Modifier.padding( + 16.dp, + pad.calculateTopPadding(), + 16.dp, + pad.calculateBottomPadding(), + ), + verticalArrangement = Arrangement.SpaceAround, + ) { + Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { + LazyColumn( + contentPadding = FeedPadding, + ) { + itemsIndexed(feedState, key = { _, item -> item.url }) { index, item -> + ServerConfig( + item, + onToggleDownload = { postViewModel.toggleDownload(it) }, + onToggleUpload = { postViewModel.toggleUpload(it) }, + onToggleFollows = { postViewModel.toggleFollows(it) }, + onTogglePrivateDMs = { postViewModel.toggleMessages(it) }, + onTogglePublicChats = { postViewModel.togglePublicChats(it) }, + onToggleGlobal = { postViewModel.toggleGlobal(it) }, + onToggleSearch = { postViewModel.toggleSearch(it) }, + onDelete = { postViewModel.deleteRelay(it) }, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } } + + Spacer(modifier = StdVertSpacer) + + EditableServerConfig(relayToAdd) { postViewModel.addRelay(it) } + } } + } } @Composable fun ServerConfigHeader() { - Column(Modifier.fillMaxWidth()) { + Column(Modifier.fillMaxWidth()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = stringResource(R.string.relay_address), - modifier = Modifier.weight(1f), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - - Column(Modifier.weight(1.4f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Spacer(modifier = Modifier.size(30.dp)) - - Text( - text = stringResource(R.string.bytes), - maxLines = 1, - fontSize = Font14SP, - modifier = Modifier.weight(1.2f), - color = MaterialTheme.colorScheme.placeholderText - ) - - Spacer(modifier = Modifier.size(5.dp)) - - Text( - text = stringResource(id = R.string.bytes), - maxLines = 1, - fontSize = Font14SP, - modifier = Modifier.weight(1.2f), - color = MaterialTheme.colorScheme.placeholderText - ) - - Spacer(modifier = Modifier.size(5.dp)) - - Text( - text = stringResource(R.string.errors), - maxLines = 1, - fontSize = Font14SP, - modifier = Modifier.weight(1f), - color = MaterialTheme.colorScheme.placeholderText - ) - - Spacer(modifier = Modifier.size(5.dp)) - - Text( - text = stringResource(R.string.spam), - maxLines = 1, - fontSize = Font14SP, - modifier = Modifier.weight(1f), - color = MaterialTheme.colorScheme.placeholderText - ) - - Spacer(modifier = Modifier.size(2.dp)) - } - } + Text( + text = stringResource(R.string.relay_address), + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } + } - Divider( - thickness = DividerThickness - ) + Column(Modifier.weight(1.4f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(modifier = Modifier.size(30.dp)) + + Text( + text = stringResource(R.string.bytes), + maxLines = 1, + fontSize = Font14SP, + modifier = Modifier.weight(1.2f), + color = MaterialTheme.colorScheme.placeholderText, + ) + + Spacer(modifier = Modifier.size(5.dp)) + + Text( + text = stringResource(id = R.string.bytes), + maxLines = 1, + fontSize = Font14SP, + modifier = Modifier.weight(1.2f), + color = MaterialTheme.colorScheme.placeholderText, + ) + + Spacer(modifier = Modifier.size(5.dp)) + + Text( + text = stringResource(R.string.errors), + maxLines = 1, + fontSize = Font14SP, + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.placeholderText, + ) + + Spacer(modifier = Modifier.size(5.dp)) + + Text( + text = stringResource(R.string.spam), + maxLines = 1, + fontSize = Font14SP, + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.placeholderText, + ) + + Spacer(modifier = Modifier.size(2.dp)) + } + } } + + Divider( + thickness = DividerThickness, + ) + } } @Preview @Composable fun ServerConfigPreview() { - ServerConfigClickableLine( - loadProfilePicture = true, - item = RelaySetupInfo( - url = "nostr.mom", - read = true, - write = true, - errorCount = 23, - downloadCountInBytes = 10000, - uploadCountInBytes = 10000000, - spamCount = 10, - feedTypes = Constants.activeTypesGlobalChats, - paidRelay = true - ), - onDelete = {}, - onToggleDownload = {}, - onToggleUpload = {}, - onToggleFollows = {}, - onTogglePrivateDMs = {}, - onTogglePublicChats = {}, - onToggleGlobal = {}, - onToggleSearch = {}, - onClick = {} - ) + ServerConfigClickableLine( + loadProfilePicture = true, + item = + RelaySetupInfo( + url = "nostr.mom", + read = true, + write = true, + errorCount = 23, + downloadCountInBytes = 10000, + uploadCountInBytes = 10000000, + spamCount = 10, + feedTypes = Constants.activeTypesGlobalChats, + paidRelay = true, + ), + onDelete = {}, + onToggleDownload = {}, + onToggleUpload = {}, + onToggleFollows = {}, + onTogglePrivateDMs = {}, + onTogglePublicChats = {}, + onToggleGlobal = {}, + onToggleSearch = {}, + onClick = {}, + ) } @Composable fun ServerConfig( - item: RelaySetupInfo, - onToggleDownload: (RelaySetupInfo) -> Unit, - onToggleUpload: (RelaySetupInfo) -> Unit, - onToggleFollows: (RelaySetupInfo) -> Unit, - onTogglePrivateDMs: (RelaySetupInfo) -> Unit, - onTogglePublicChats: (RelaySetupInfo) -> Unit, - onToggleGlobal: (RelaySetupInfo) -> Unit, - onToggleSearch: (RelaySetupInfo) -> Unit, - - onDelete: (RelaySetupInfo) -> Unit, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + item: RelaySetupInfo, + onToggleDownload: (RelaySetupInfo) -> Unit, + onToggleUpload: (RelaySetupInfo) -> Unit, + onToggleFollows: (RelaySetupInfo) -> Unit, + onTogglePrivateDMs: (RelaySetupInfo) -> Unit, + onTogglePublicChats: (RelaySetupInfo) -> Unit, + onToggleGlobal: (RelaySetupInfo) -> Unit, + onToggleSearch: (RelaySetupInfo) -> Unit, + onDelete: (RelaySetupInfo) -> Unit, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) } - val context = LocalContext.current + var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) } + val context = LocalContext.current - relayInfo?.let { - RelayInformationDialog( - onClose = { relayInfo = null }, - relayInfo = it.relayInfo, - relayBriefInfo = it.relayBriefInfo, - accountViewModel = accountViewModel, - nav = nav - ) - } - - val automaticallyShowProfilePicture = remember { - accountViewModel.settings.showProfilePictures.value - } - - ServerConfigClickableLine( - item = item, - loadProfilePicture = automaticallyShowProfilePicture, - onToggleDownload = onToggleDownload, - onToggleUpload = onToggleUpload, - onToggleFollows = onToggleFollows, - onTogglePrivateDMs = onTogglePrivateDMs, - onTogglePublicChats = onTogglePublicChats, - onToggleGlobal = onToggleGlobal, - onToggleSearch = onToggleSearch, - onDelete = onDelete, - onClick = { - accountViewModel.retrieveRelayDocument( - item.url, - onInfo = { - relayInfo = RelayInfoDialog(RelayBriefInfoCache.RelayBriefInfo(item.url), it) - }, - onError = { url, errorCode, exceptionMessage -> - val msg = when (errorCode) { - Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage) - Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage) - Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage) - Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage) - } - - accountViewModel.toast( - context.getString(R.string.unable_to_download_relay_document), - msg - ) - } - ) - } + relayInfo?.let { + RelayInformationDialog( + onClose = { relayInfo = null }, + relayInfo = it.relayInfo, + relayBriefInfo = it.relayBriefInfo, + accountViewModel = accountViewModel, + nav = nav, ) + } + + val automaticallyShowProfilePicture = remember { + accountViewModel.settings.showProfilePictures.value + } + + ServerConfigClickableLine( + item = item, + loadProfilePicture = automaticallyShowProfilePicture, + onToggleDownload = onToggleDownload, + onToggleUpload = onToggleUpload, + onToggleFollows = onToggleFollows, + onTogglePrivateDMs = onTogglePrivateDMs, + onTogglePublicChats = onTogglePublicChats, + onToggleGlobal = onToggleGlobal, + onToggleSearch = onToggleSearch, + onDelete = onDelete, + onClick = { + accountViewModel.retrieveRelayDocument( + item.url, + onInfo = { relayInfo = RelayInfoDialog(RelayBriefInfoCache.RelayBriefInfo(item.url), it) }, + onError = { url, errorCode, exceptionMessage -> + val msg = + when (errorCode) { + Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> + context.getString( + R.string.relay_information_document_error_assemble_url, + url, + exceptionMessage, + ) + Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> + context.getString( + R.string.relay_information_document_error_assemble_url, + url, + exceptionMessage, + ) + Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> + context.getString( + R.string.relay_information_document_error_assemble_url, + url, + exceptionMessage, + ) + Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> + context.getString( + R.string.relay_information_document_error_assemble_url, + url, + exceptionMessage, + ) + } + + accountViewModel.toast( + context.getString(R.string.unable_to_download_relay_document), + msg, + ) + }, + ) + }, + ) } @Composable fun ServerConfigClickableLine( - item: RelaySetupInfo, - loadProfilePicture: Boolean, - onToggleDownload: (RelaySetupInfo) -> Unit, - onToggleUpload: (RelaySetupInfo) -> Unit, - onToggleFollows: (RelaySetupInfo) -> Unit, - onTogglePrivateDMs: (RelaySetupInfo) -> Unit, - onTogglePublicChats: (RelaySetupInfo) -> Unit, - onToggleGlobal: (RelaySetupInfo) -> Unit, - onToggleSearch: (RelaySetupInfo) -> Unit, - onDelete: (RelaySetupInfo) -> Unit, - onClick: () -> Unit + item: RelaySetupInfo, + loadProfilePicture: Boolean, + onToggleDownload: (RelaySetupInfo) -> Unit, + onToggleUpload: (RelaySetupInfo) -> Unit, + onToggleFollows: (RelaySetupInfo) -> Unit, + onTogglePrivateDMs: (RelaySetupInfo) -> Unit, + onTogglePublicChats: (RelaySetupInfo) -> Unit, + onToggleGlobal: (RelaySetupInfo) -> Unit, + onToggleSearch: (RelaySetupInfo) -> Unit, + onDelete: (RelaySetupInfo) -> Unit, + onClick: () -> Unit, ) { - Column(Modifier.fillMaxWidth()) { + Column(Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 5.dp), + ) { + Column(Modifier.clickable(onClick = onClick)) { + RenderRelayIcon( + item.briefInfo.displayUrl, + item.briefInfo.favIcon, + loadProfilePicture, + MaterialTheme.colorScheme.largeRelayIconModifier, + ) + } + + Spacer(modifier = HalfHorzPadding) + + Column(Modifier.weight(1f)) { + FirstLine(item, onClick, onDelete, ReactionRowHeightChat.fillMaxWidth()) + Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 5.dp) + verticalAlignment = Alignment.CenterVertically, + modifier = ReactionRowHeightChat.fillMaxWidth(), ) { - Column(Modifier.clickable(onClick = onClick)) { - RenderRelayIcon( - item.briefInfo.displayUrl, - item.briefInfo.favIcon, - loadProfilePicture, - MaterialTheme.colorScheme.largeRelayIconModifier - ) - } - - Spacer(modifier = HalfHorzPadding) - - Column(Modifier.weight(1f)) { - FirstLine(item, onClick, onDelete, ReactionRowHeightChat.fillMaxWidth()) - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = ReactionRowHeightChat.fillMaxWidth() - ) { - RenderActiveToggles( - item = item, - onToggleFollows = onToggleFollows, - onTogglePrivateDMs = onTogglePrivateDMs, - onTogglePublicChats = onTogglePublicChats, - onToggleGlobal = onToggleGlobal, - onToggleSearch = onToggleSearch - ) - } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = ReactionRowHeightChat.fillMaxWidth() - ) { - RenderStatusRow( - item = item, - onToggleDownload = onToggleDownload, - onToggleUpload = onToggleUpload, - modifier = HalfStartPadding.weight(1f) - ) - } - } + RenderActiveToggles( + item = item, + onToggleFollows = onToggleFollows, + onTogglePrivateDMs = onTogglePrivateDMs, + onTogglePublicChats = onTogglePublicChats, + onToggleGlobal = onToggleGlobal, + onToggleSearch = onToggleSearch, + ) } - Divider( - thickness = DividerThickness - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = ReactionRowHeightChat.fillMaxWidth(), + ) { + RenderStatusRow( + item = item, + onToggleDownload = onToggleDownload, + onToggleUpload = onToggleUpload, + modifier = HalfStartPadding.weight(1f), + ) + } + } } + + Divider( + thickness = DividerThickness, + ) + } } @Composable @OptIn(ExperimentalFoundationApi::class) private fun RenderStatusRow( - item: RelaySetupInfo, - onToggleDownload: (RelaySetupInfo) -> Unit, - onToggleUpload: (RelaySetupInfo) -> Unit, - modifier: Modifier + item: RelaySetupInfo, + onToggleDownload: (RelaySetupInfo) -> Unit, + onToggleUpload: (RelaySetupInfo) -> Unit, + modifier: Modifier, ) { - val scope = rememberCoroutineScope() - val context = LocalContext.current + val scope = rememberCoroutineScope() + val context = LocalContext.current - Icon( - imageVector = Icons.Default.Download, - contentDescription = stringResource(R.string.read_from_relay), - modifier = Modifier - .size(15.dp) - .combinedClickable( - onClick = { onToggleDownload(item) }, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - context.getString(R.string.read_from_relay), - Toast.LENGTH_SHORT - ) - .show() - } - } - ), - tint = if (item.read) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f - ) - } - ) + Icon( + imageVector = Icons.Default.Download, + contentDescription = stringResource(R.string.read_from_relay), + modifier = + Modifier.size(15.dp) + .combinedClickable( + onClick = { onToggleDownload(item) }, + onLongClick = { + scope.launch { + Toast.makeText( + context, + context.getString(R.string.read_from_relay), + Toast.LENGTH_SHORT, + ) + .show() + } + }, + ), + tint = + if (item.read) { + MaterialTheme.colorScheme.allGoodColor + } else { + MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.32f, + ) + }, + ) - Text( - text = countToHumanReadableBytes(item.downloadCountInBytes), - maxLines = 1, - fontSize = 12.sp, - modifier = modifier, - color = MaterialTheme.colorScheme.placeholderText - ) + Text( + text = countToHumanReadableBytes(item.downloadCountInBytes), + maxLines = 1, + fontSize = 12.sp, + modifier = modifier, + color = MaterialTheme.colorScheme.placeholderText, + ) - Icon( - imageVector = Icons.Default.Upload, - stringResource(R.string.write_to_relay), - modifier = Modifier - .size(15.dp) - .combinedClickable( - onClick = { onToggleUpload(item) }, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - context.getString(R.string.write_to_relay), - Toast.LENGTH_SHORT - ) - .show() - } - } - ), - tint = if (item.write) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f - ) - } - ) + Icon( + imageVector = Icons.Default.Upload, + stringResource(R.string.write_to_relay), + modifier = + Modifier.size(15.dp) + .combinedClickable( + onClick = { onToggleUpload(item) }, + onLongClick = { + scope.launch { + Toast.makeText( + context, + context.getString(R.string.write_to_relay), + Toast.LENGTH_SHORT, + ) + .show() + } + }, + ), + tint = + if (item.write) { + MaterialTheme.colorScheme.allGoodColor + } else { + MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.32f, + ) + }, + ) - Text( - text = countToHumanReadableBytes(item.uploadCountInBytes), - maxLines = 1, - fontSize = 12.sp, - modifier = modifier, - color = MaterialTheme.colorScheme.placeholderText - ) + Text( + text = countToHumanReadableBytes(item.uploadCountInBytes), + maxLines = 1, + fontSize = 12.sp, + modifier = modifier, + color = MaterialTheme.colorScheme.placeholderText, + ) - Icon( - imageVector = Icons.Default.SyncProblem, - stringResource(R.string.errors), - modifier = Modifier - .size(15.dp) - .combinedClickable( - onClick = { }, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - context.getString(R.string.errors), - Toast.LENGTH_SHORT - ) - .show() - } - } - ), - tint = if (item.errorCount > 0) MaterialTheme.colorScheme.warningColor else MaterialTheme.colorScheme.allGoodColor - ) + Icon( + imageVector = Icons.Default.SyncProblem, + stringResource(R.string.errors), + modifier = + Modifier.size(15.dp) + .combinedClickable( + onClick = {}, + onLongClick = { + scope.launch { + Toast.makeText( + context, + context.getString(R.string.errors), + Toast.LENGTH_SHORT, + ) + .show() + } + }, + ), + tint = + if (item.errorCount > 0) { + MaterialTheme.colorScheme.warningColor + } else { + MaterialTheme.colorScheme.allGoodColor + }, + ) - Text( - text = countToHumanReadable(item.errorCount, "errors"), - maxLines = 1, - fontSize = 12.sp, - modifier = modifier, - color = MaterialTheme.colorScheme.placeholderText - ) + Text( + text = countToHumanReadable(item.errorCount, "errors"), + maxLines = 1, + fontSize = 12.sp, + modifier = modifier, + color = MaterialTheme.colorScheme.placeholderText, + ) - Icon( - imageVector = Icons.Default.DeleteSweep, - stringResource(R.string.spam), - modifier = Modifier - .size(15.dp) - .combinedClickable( - onClick = { }, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - context.getString(R.string.spam), - Toast.LENGTH_SHORT - ) - .show() - } - } - ), - tint = if (item.spamCount > 0) MaterialTheme.colorScheme.warningColor else MaterialTheme.colorScheme.allGoodColor - ) + Icon( + imageVector = Icons.Default.DeleteSweep, + stringResource(R.string.spam), + modifier = + Modifier.size(15.dp) + .combinedClickable( + onClick = {}, + onLongClick = { + scope.launch { + Toast.makeText( + context, + context.getString(R.string.spam), + Toast.LENGTH_SHORT, + ) + .show() + } + }, + ), + tint = + if (item.spamCount > 0) { + MaterialTheme.colorScheme.warningColor + } else { + MaterialTheme.colorScheme.allGoodColor + }, + ) - Text( - text = countToHumanReadable(item.spamCount, "spam"), - maxLines = 1, - fontSize = 12.sp, - modifier = modifier, - color = MaterialTheme.colorScheme.placeholderText - ) + Text( + text = countToHumanReadable(item.spamCount, "spam"), + maxLines = 1, + fontSize = 12.sp, + modifier = modifier, + color = MaterialTheme.colorScheme.placeholderText, + ) } @Composable @OptIn(ExperimentalFoundationApi::class) private fun RenderActiveToggles( - item: RelaySetupInfo, - onToggleFollows: (RelaySetupInfo) -> Unit, - onTogglePrivateDMs: (RelaySetupInfo) -> Unit, - onTogglePublicChats: (RelaySetupInfo) -> Unit, - onToggleGlobal: (RelaySetupInfo) -> Unit, - onToggleSearch: (RelaySetupInfo) -> Unit + item: RelaySetupInfo, + onToggleFollows: (RelaySetupInfo) -> Unit, + onTogglePrivateDMs: (RelaySetupInfo) -> Unit, + onTogglePublicChats: (RelaySetupInfo) -> Unit, + onToggleGlobal: (RelaySetupInfo) -> Unit, + onToggleSearch: (RelaySetupInfo) -> Unit, ) { - val scope = rememberCoroutineScope() - val context = LocalContext.current + val scope = rememberCoroutineScope() + val context = LocalContext.current - Text( - text = stringResource(id = R.string.active_for), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.padding(start = 2.dp, end = 5.dp), - fontSize = 14.sp + Text( + text = stringResource(id = R.string.active_for), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(start = 2.dp, end = 5.dp), + fontSize = 14.sp, + ) + + IconButton( + modifier = Size30Modifier, + onClick = { onToggleFollows(item) }, + ) { + Icon( + painterResource(R.drawable.ic_home), + stringResource(R.string.home_feed), + modifier = + Modifier.padding(horizontal = 5.dp) + .size(15.dp) + .combinedClickable( + onClick = { onToggleFollows(item) }, + onLongClick = { + scope.launch { + Toast.makeText( + context, + context.getString(R.string.home_feed), + Toast.LENGTH_SHORT, + ) + .show() + } + }, + ), + tint = + if (item.feedTypes.contains(FeedType.FOLLOWS)) { + MaterialTheme.colorScheme.allGoodColor + } else { + MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.32f, + ) + }, ) + } + IconButton( + modifier = Size30Modifier, + onClick = { onTogglePrivateDMs(item) }, + ) { + Icon( + painterResource(R.drawable.ic_dm), + stringResource(R.string.private_message_feed), + modifier = + Modifier.padding(horizontal = 5.dp) + .size(15.dp) + .combinedClickable( + onClick = { onTogglePrivateDMs(item) }, + onLongClick = { + scope.launch { + Toast.makeText( + context, + context.getString(R.string.private_message_feed), + Toast.LENGTH_SHORT, + ) + .show() + } + }, + ), + tint = + if (item.feedTypes.contains(FeedType.PRIVATE_DMS)) { + MaterialTheme.colorScheme.allGoodColor + } else { + MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.32f, + ) + }, + ) + } + IconButton( + modifier = Size30Modifier, + onClick = { onTogglePublicChats(item) }, + ) { + Icon( + imageVector = Icons.Default.Groups, + contentDescription = stringResource(R.string.public_chat_feed), + modifier = + Modifier.padding(horizontal = 5.dp) + .size(15.dp) + .combinedClickable( + onClick = { onTogglePublicChats(item) }, + onLongClick = { + scope.launch { + Toast.makeText( + context, + context.getString(R.string.public_chat_feed), + Toast.LENGTH_SHORT, + ) + .show() + } + }, + ), + tint = + if (item.feedTypes.contains(FeedType.PUBLIC_CHATS)) { + MaterialTheme.colorScheme.allGoodColor + } else { + MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.32f, + ) + }, + ) + } + IconButton( + modifier = Size30Modifier, + onClick = { onToggleGlobal(item) }, + ) { + Icon( + imageVector = Icons.Default.Public, + stringResource(R.string.global_feed), + modifier = + Modifier.padding(horizontal = 5.dp) + .size(15.dp) + .combinedClickable( + onClick = { onToggleGlobal(item) }, + onLongClick = { + scope.launch { + Toast.makeText( + context, + context.getString(R.string.global_feed), + Toast.LENGTH_SHORT, + ) + .show() + } + }, + ), + tint = + if (item.feedTypes.contains(FeedType.GLOBAL)) { + MaterialTheme.colorScheme.allGoodColor + } else { + MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.32f, + ) + }, + ) + } - IconButton( - modifier = Size30Modifier, - onClick = { onToggleFollows(item) } - ) { - Icon( - painterResource(R.drawable.ic_home), - stringResource(R.string.home_feed), - modifier = Modifier - .padding(horizontal = 5.dp) - .size(15.dp) - .combinedClickable( - onClick = { onToggleFollows(item) }, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - context.getString(R.string.home_feed), - Toast.LENGTH_SHORT - ) - .show() - } - } - ), - tint = if (item.feedTypes.contains(FeedType.FOLLOWS)) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f - ) - } - ) - } - IconButton( - modifier = Size30Modifier, - onClick = { onTogglePrivateDMs(item) } - ) { - Icon( - painterResource(R.drawable.ic_dm), - stringResource(R.string.private_message_feed), - modifier = Modifier - .padding(horizontal = 5.dp) - .size(15.dp) - .combinedClickable( - onClick = { onTogglePrivateDMs(item) }, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - context.getString(R.string.private_message_feed), - Toast.LENGTH_SHORT - ) - .show() - } - } - ), - tint = if (item.feedTypes.contains(FeedType.PRIVATE_DMS)) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f - ) - } - ) - } - IconButton( - modifier = Size30Modifier, - onClick = { onTogglePublicChats(item) } - ) { - Icon( - imageVector = Icons.Default.Groups, - contentDescription = stringResource(R.string.public_chat_feed), - modifier = Modifier - .padding(horizontal = 5.dp) - .size(15.dp) - .combinedClickable( - onClick = { onTogglePublicChats(item) }, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - context.getString(R.string.public_chat_feed), - Toast.LENGTH_SHORT - ) - .show() - } - } - ), - tint = if (item.feedTypes.contains(FeedType.PUBLIC_CHATS)) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f - ) - } - ) - } - IconButton( - modifier = Size30Modifier, - onClick = { onToggleGlobal(item) } - ) { - Icon( - imageVector = Icons.Default.Public, - stringResource(R.string.global_feed), - modifier = Modifier - .padding(horizontal = 5.dp) - .size(15.dp) - .combinedClickable( - onClick = { onToggleGlobal(item) }, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - context.getString(R.string.global_feed), - Toast.LENGTH_SHORT - ) - .show() - } - } - ), - tint = if (item.feedTypes.contains(FeedType.GLOBAL)) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f - ) - } - ) - } - - IconButton( - modifier = Size30Modifier, - onClick = { onToggleSearch(item) } - ) { - Icon( - imageVector = Icons.Default.Search, - stringResource(R.string.search_feed), - modifier = Modifier - .padding(horizontal = 5.dp) - .size(15.dp) - .combinedClickable( - onClick = { onToggleSearch(item) }, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - context.getString(R.string.search_feed), - Toast.LENGTH_SHORT - ) - .show() - } - } - ), - tint = if (item.feedTypes.contains(FeedType.SEARCH)) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f - ) - } - ) - } + IconButton( + modifier = Size30Modifier, + onClick = { onToggleSearch(item) }, + ) { + Icon( + imageVector = Icons.Default.Search, + stringResource(R.string.search_feed), + modifier = + Modifier.padding(horizontal = 5.dp) + .size(15.dp) + .combinedClickable( + onClick = { onToggleSearch(item) }, + onLongClick = { + scope.launch { + Toast.makeText( + context, + context.getString(R.string.search_feed), + Toast.LENGTH_SHORT, + ) + .show() + } + }, + ), + tint = + if (item.feedTypes.contains(FeedType.SEARCH)) { + MaterialTheme.colorScheme.allGoodColor + } else { + MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.32f, + ) + }, + ) + } } @Composable private fun FirstLine( - item: RelaySetupInfo, - onClick: () -> Unit, - onDelete: (RelaySetupInfo) -> Unit, - modifier: Modifier + item: RelaySetupInfo, + onClick: () -> Unit, + onDelete: (RelaySetupInfo) -> Unit, + modifier: Modifier, ) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { - Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { - Text( - text = item.briefInfo.displayUrl, - modifier = Modifier.clickable(onClick = onClick), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { + Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { + Text( + text = item.briefInfo.displayUrl, + modifier = Modifier.clickable(onClick = onClick), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) - if (item.paidRelay) { - Icon( - imageVector = Icons.Default.Paid, - null, - modifier = Modifier - .padding(start = 5.dp, top = 1.dp) - .size(14.dp), - tint = MaterialTheme.colorScheme.allGoodColor - ) - } - } - - IconButton( - modifier = Modifier.size(30.dp), - onClick = { onDelete(item) } - ) { - Icon( - imageVector = Icons.Default.Cancel, - null, - modifier = Modifier - .padding(start = 10.dp) - .size(15.dp), - tint = WarningColor - ) - } + if (item.paidRelay) { + Icon( + imageVector = Icons.Default.Paid, + null, + modifier = Modifier.padding(start = 5.dp, top = 1.dp).size(14.dp), + tint = MaterialTheme.colorScheme.allGoodColor, + ) + } } + + IconButton( + modifier = Modifier.size(30.dp), + onClick = { onDelete(item) }, + ) { + Icon( + imageVector = Icons.Default.Cancel, + null, + modifier = Modifier.padding(start = 10.dp).size(15.dp), + tint = WarningColor, + ) + } + } } @Composable -fun EditableServerConfig(relayToAdd: String, onNewRelay: (RelaySetupInfo) -> Unit) { - var url by remember { mutableStateOf(relayToAdd) } - var read by remember { mutableStateOf(true) } - var write by remember { mutableStateOf(true) } +fun EditableServerConfig( + relayToAdd: String, + onNewRelay: (RelaySetupInfo) -> Unit, +) { + var url by remember { mutableStateOf(relayToAdd) } + var read by remember { mutableStateOf(true) } + var write by remember { mutableStateOf(true) } - Row(verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.add_a_relay)) }, - modifier = Modifier.weight(1f), - value = url, - onValueChange = { url = it }, - placeholder = { - Text( - text = "server.com", - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1 - ) - }, - singleLine = true + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.add_a_relay)) }, + modifier = Modifier.weight(1f), + value = url, + onValueChange = { url = it }, + placeholder = { + Text( + text = "server.com", + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, ) + }, + singleLine = true, + ) - IconButton(onClick = { read = !read }) { - Icon( - imageVector = Icons.Default.Download, - null, - modifier = Modifier - .size(Size35dp) - .padding(horizontal = 5.dp), - tint = if (read) MaterialTheme.colorScheme.allGoodColor else MaterialTheme.colorScheme.placeholderText - ) - } - - IconButton(onClick = { write = !write }) { - Icon( - imageVector = Icons.Default.Upload, - null, - modifier = Modifier - .size(Size35dp) - .padding(horizontal = 5.dp), - tint = if (write) MaterialTheme.colorScheme.allGoodColor else MaterialTheme.colorScheme.placeholderText - ) - } - - Button( - onClick = { - if (url.isNotBlank() && url != "/") { - var addedWSS = if (!url.startsWith("wss://") && !url.startsWith("ws://")) "wss://$url" else url - if (url.endsWith("/")) addedWSS = addedWSS.dropLast(1) - onNewRelay(RelaySetupInfo(addedWSS, read, write, feedTypes = FeedType.values().toSet())) - url = "" - write = true - read = true - } - }, - shape = ButtonBorder, - colors = ButtonDefaults - .buttonColors( - containerColor = if (url.isNotBlank()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.placeholderText - ) - ) { - Text(text = stringResource(id = R.string.add), color = Color.White) - } + IconButton(onClick = { read = !read }) { + Icon( + imageVector = Icons.Default.Download, + null, + modifier = Modifier.size(Size35dp).padding(horizontal = 5.dp), + tint = + if (read) { + MaterialTheme.colorScheme.allGoodColor + } else { + MaterialTheme.colorScheme.placeholderText + }, + ) } + + IconButton(onClick = { write = !write }) { + Icon( + imageVector = Icons.Default.Upload, + null, + modifier = Modifier.size(Size35dp).padding(horizontal = 5.dp), + tint = + if (write) { + MaterialTheme.colorScheme.allGoodColor + } else { + MaterialTheme.colorScheme.placeholderText + }, + ) + } + + Button( + onClick = { + if (url.isNotBlank() && url != "/") { + var addedWSS = + if (!url.startsWith("wss://") && !url.startsWith("ws://")) "wss://$url" else url + if (url.endsWith("/")) addedWSS = addedWSS.dropLast(1) + onNewRelay(RelaySetupInfo(addedWSS, read, write, feedTypes = FeedType.values().toSet())) + url = "" + write = true + read = true + } + }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = + if (url.isNotBlank()) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.placeholderText + }, + ), + ) { + Text(text = stringResource(id = R.string.add), color = Color.White) + } + } } -fun countToHumanReadableBytes(counter: Int) = when { +fun countToHumanReadableBytes(counter: Int) = + when { counter >= 1000000000 -> "${round(counter / 1000000000f)} GB" counter >= 1000000 -> "${round(counter / 1000000f)} MB" counter >= 1000 -> "${round(counter / 1000f)} KB" else -> "$counter" -} + } -fun countToHumanReadable(counter: Int, str: String) = when { +fun countToHumanReadable( + counter: Int, + str: String, +) = + when { counter >= 1000000000 -> "${round(counter / 1000000000f)}G $str" counter >= 1000000 -> "${round(counter / 1000000f)}M $str" counter >= 1000 -> "${round(counter / 1000f)}K $str" else -> "$counter $str" -} + } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListViewModel.kt index 6c00ffb05..0fe5da245 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListViewModel.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.actions import androidx.lifecycle.ViewModel @@ -17,168 +37,190 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class NewRelayListViewModel : ViewModel() { - private lateinit var account: Account + private lateinit var account: Account - private val _relays = MutableStateFlow>(emptyList()) - val relays = _relays.asStateFlow() + private val _relays = MutableStateFlow>(emptyList()) + val relays = _relays.asStateFlow() - fun load(account: Account) { - this.account = account + fun load(account: Account) { + this.account = account + clear() + loadRelayDocuments() + } + + fun create() { + relays.let { + viewModelScope.launch(Dispatchers.IO) { + account.saveRelayList(it.value) clear() - loadRelayDocuments() + } } + } - fun create() { - relays.let { - viewModelScope.launch(Dispatchers.IO) { - account.saveRelayList(it.value) - clear() + fun loadRelayDocuments() { + viewModelScope.launch(Dispatchers.IO) { + _relays.value.forEach { item -> + Nip11CachedRetriever.loadRelayInfo( + dirtyUrl = item.url, + onInfo = { togglePaidRelay(item, it.limitation?.payment_required ?: false) }, + onError = { url, errorCode, exceptionMessage -> }, + ) + } + } + } + + fun clear() { + _relays.update { + var relayFile = account.userProfile().latestContactList?.relays() + + if (relayFile != null) { + // Ugly, but forces nostr.band as the only search-supporting relay today. + // TODO: Remove when search becomes more available. + + val needsSearchRelay = + relayFile.none { it.key.removeSuffix("/") in Constants.forcedRelaysForSearchSet } && + relayFile.none { + account.localRelays + .filter { localRelay -> localRelay.url == it.key } + .firstOrNull() + ?.feedTypes + ?.contains(FeedType.SEARCH) + ?: false } - } - } - fun loadRelayDocuments() { - viewModelScope.launch(Dispatchers.IO) { - _relays.value.forEach { item -> - Nip11CachedRetriever.loadRelayInfo( - dirtyUrl = item.url, - onInfo = { - togglePaidRelay(item, it.limitation?.payment_required ?: false) - }, - onError = { url, errorCode, exceptionMessage -> - } + if (needsSearchRelay) { + relayFile = + relayFile + + Constants.forcedRelayForSearch.map { + Pair( + it.url, + ContactListEvent.ReadWrite(it.read, it.write), ) - } + } } + + relayFile + .map { + val liveRelay = RelayPool.getRelay(it.key) + val localInfoFeedTypes = + account.localRelays + .filter { localRelay -> localRelay.url == it.key } + .firstOrNull() + ?.feedTypes + ?: Constants.defaultRelays + .filter { defaultRelay -> defaultRelay.url == it.key } + .firstOrNull() + ?.feedTypes + ?: FeedType.values().toSet().toImmutableSet() + + val errorCounter = liveRelay?.errorCounter ?: 0 + val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0 + val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0 + val spamCounter = liveRelay?.spamCounter ?: 0 + + RelaySetupInfo( + it.key, + it.value.read, + it.value.write, + errorCounter, + eventDownloadCounter, + eventUploadCounter, + spamCounter, + localInfoFeedTypes, + ) + } + .sortedBy { it.downloadCountInBytes } + .reversed() + } else { + account.localRelays + .map { + val liveRelay = RelayPool.getRelay(it.url) + + val errorCounter = liveRelay?.errorCounter ?: 0 + val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0 + val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0 + val spamCounter = liveRelay?.spamCounter ?: 0 + + RelaySetupInfo( + it.url, + it.read, + it.write, + errorCounter, + eventDownloadCounter, + eventUploadCounter, + spamCounter, + it.feedTypes, + ) + } + .sortedBy { it.downloadCountInBytes } + .reversed() + } } + } - fun clear() { - _relays.update { - var relayFile = account.userProfile().latestContactList?.relays() + fun addRelay(relay: RelaySetupInfo) { + if (relays.value.any { it.url == relay.url }) return - if (relayFile != null) { - // Ugly, but forces nostr.band as the only search-supporting relay today. - // TODO: Remove when search becomes more available. + _relays.update { it.plus(relay) } + } - val needsSearchRelay = relayFile.none { - it.key.removeSuffix("/") in Constants.forcedRelaysForSearchSet - } && relayFile.none { - account.localRelays.filter { localRelay -> localRelay.url == it.key }.firstOrNull()?.feedTypes?.contains(FeedType.SEARCH) ?: false - } + fun deleteRelay(relay: RelaySetupInfo) { + _relays.update { it.minus(relay) } + } - if (needsSearchRelay) { - relayFile = relayFile + Constants.forcedRelayForSearch.map { - Pair( - it.url, - ContactListEvent.ReadWrite(it.read, it.write) - ) - } - } + fun deleteAll() { + _relays.update { relays -> emptyList() } + } - relayFile.map { - val liveRelay = RelayPool.getRelay(it.key) - val localInfoFeedTypes = account.localRelays.filter { localRelay -> localRelay.url == it.key }.firstOrNull()?.feedTypes - ?: Constants.defaultRelays.filter { defaultRelay -> defaultRelay.url == it.key }.firstOrNull()?.feedTypes - ?: FeedType.values().toSet().toImmutableSet() + fun toggleDownload(relay: RelaySetupInfo) { + _relays.update { it.updated(relay, relay.copy(read = !relay.read)) } + } - val errorCounter = liveRelay?.errorCounter ?: 0 - val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0 - val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0 - val spamCounter = liveRelay?.spamCounter ?: 0 + fun toggleUpload(relay: RelaySetupInfo) { + _relays.update { it.updated(relay, relay.copy(write = !relay.write)) } + } - RelaySetupInfo(it.key, it.value.read, it.value.write, errorCounter, eventDownloadCounter, eventUploadCounter, spamCounter, localInfoFeedTypes) - }.sortedBy { it.downloadCountInBytes }.reversed() - } else { - account.localRelays.map { - val liveRelay = RelayPool.getRelay(it.url) + fun toggleFollows(relay: RelaySetupInfo) { + val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.FOLLOWS) + _relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) } + } - val errorCounter = liveRelay?.errorCounter ?: 0 - val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0 - val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0 - val spamCounter = liveRelay?.spamCounter ?: 0 + fun toggleMessages(relay: RelaySetupInfo) { + val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PRIVATE_DMS) + _relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) } + } - RelaySetupInfo(it.url, it.read, it.write, errorCounter, eventDownloadCounter, eventUploadCounter, spamCounter, it.feedTypes) - }.sortedBy { it.downloadCountInBytes }.reversed() - } - } - } + fun togglePublicChats(relay: RelaySetupInfo) { + val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PUBLIC_CHATS) + _relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) } + } - fun addRelay(relay: RelaySetupInfo) { - if (relays.value.any { it.url == relay.url }) return + fun toggleGlobal(relay: RelaySetupInfo) { + val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.GLOBAL) + _relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) } + } - _relays.update { - it.plus(relay) - } - } + fun toggleSearch(relay: RelaySetupInfo) { + val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.SEARCH) + _relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) } + } - fun deleteRelay(relay: RelaySetupInfo) { - _relays.update { - it.minus(relay) - } - } - - fun deleteAll() { - _relays.update { relays -> - emptyList() - } - } - - fun toggleDownload(relay: RelaySetupInfo) { - _relays.update { - it.updated(relay, relay.copy(read = !relay.read)) - } - } - - fun toggleUpload(relay: RelaySetupInfo) { - _relays.update { - it.updated(relay, relay.copy(write = !relay.write)) - } - } - - fun toggleFollows(relay: RelaySetupInfo) { - val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.FOLLOWS) - _relays.update { - it.updated(relay, relay.copy(feedTypes = newTypes)) - } - } - - fun toggleMessages(relay: RelaySetupInfo) { - val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PRIVATE_DMS) - _relays.update { - it.updated(relay, relay.copy(feedTypes = newTypes)) - } - } - - fun togglePublicChats(relay: RelaySetupInfo) { - val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PUBLIC_CHATS) - _relays.update { - it.updated(relay, relay.copy(feedTypes = newTypes)) - } - } - - fun toggleGlobal(relay: RelaySetupInfo) { - val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.GLOBAL) - _relays.update { - it.updated(relay, relay.copy(feedTypes = newTypes)) - } - } - - fun toggleSearch(relay: RelaySetupInfo) { - val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.SEARCH) - _relays.update { - it.updated(relay, relay.copy(feedTypes = newTypes)) - } - } - - fun togglePaidRelay(relay: RelaySetupInfo, paid: Boolean) { - _relays.update { - it.updated(relay, relay.copy(paidRelay = paid)) - } - } + fun togglePaidRelay( + relay: RelaySetupInfo, + paid: Boolean, + ) { + _relays.update { it.updated(relay, relay.copy(paidRelay = paid)) } + } } -fun Iterable.updated(old: T, new: T): List = map { if (it == old) new else it } +fun Iterable.updated( + old: T, + new: T, +): List = map { if (it == old) new else it } -fun togglePresenceInSet(set: Set, item: T): Set { - return if (set.contains(item)) set.minus(item) else set.plus(item) +fun togglePresenceInSet( + set: Set, + item: T, +): Set { + return if (set.contains(item)) set.minus(item) else set.plus(item) } 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 index 0fb372d12..1d2ecd910 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.actions import android.widget.Toast @@ -34,254 +54,255 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @Composable -fun NewUserMetadataView(onClose: () -> Unit, account: Account) { - val postViewModel: NewUserMetadataViewModel = viewModel() - val context = LocalContext.current +fun NewUserMetadataView( + onClose: () -> Unit, + account: Account, +) { + val postViewModel: NewUserMetadataViewModel = viewModel() + val context = LocalContext.current - LaunchedEffect(Unit) { - postViewModel.load(account) + LaunchedEffect(Unit) { + postViewModel.load(account) - launch(Dispatchers.IO) { - postViewModel.imageUploadingError.collect { error -> - withContext(Dispatchers.Main) { - Toast.makeText(context, error, Toast.LENGTH_SHORT).show() - } - } - } + launch(Dispatchers.IO) { + postViewModel.imageUploadingError.collect { error -> + withContext(Dispatchers.Main) { Toast.makeText(context, error, Toast.LENGTH_SHORT).show() } + } } + } - 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(onPress = { - postViewModel.clear() - onClose() - }) + 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( + onPress = { + postViewModel.clear() + onClose() + }, + ) - SaveButton( - onPost = { - postViewModel.create() - onClose() - }, - true - ) - } - - Column( - modifier = Modifier - .padding(10.dp) - .verticalScroll(rememberScrollState()) - ) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.display_name)) }, - modifier = Modifier.fillMaxWidth(), - value = postViewModel.displayName.value, - onValueChange = { postViewModel.displayName.value = it }, - placeholder = { - Text( - text = stringResource(R.string.my_display_name), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences - ), - singleLine = true - ) - - 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.colorScheme.placeholderText - ) - }, - 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.colorScheme.placeholderText - ) - }, - leadingIcon = { - UploadFromGallery( - isUploading = postViewModel.isUploadingImageForPicture, - tint = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.padding(start = 5.dp) - ) { - postViewModel.uploadForPicture(it, context) - } - }, - 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.colorScheme.placeholderText - ) - }, - leadingIcon = { - UploadFromGallery( - isUploading = postViewModel.isUploadingImageForBanner, - tint = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.padding(start = 5.dp) - ) { - postViewModel.uploadForBanner(it, context) - } - }, - 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.colorScheme.placeholderText - ) - }, - 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.colorScheme.placeholderText - ) - }, - 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.colorScheme.placeholderText - ) - }, - 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.colorScheme.placeholderText - ) - } - ) - - 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.colorScheme.placeholderText - ) - } - ) - - 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.colorScheme.placeholderText - ) - } - ) - - 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.colorScheme.placeholderText - ) - } - ) - } - } + SaveButton( + onPost = { + postViewModel.create() + onClose() + }, + true, + ) } + + Column( + modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState()), + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.display_name)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.displayName.value, + onValueChange = { postViewModel.displayName.value = it }, + placeholder = { + Text( + text = stringResource(R.string.my_display_name), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + singleLine = true, + ) + + 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.colorScheme.placeholderText, + ) + }, + 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.colorScheme.placeholderText, + ) + }, + leadingIcon = { + UploadFromGallery( + isUploading = postViewModel.isUploadingImageForPicture, + tint = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(start = 5.dp), + ) { + postViewModel.uploadForPicture(it, context) + } + }, + 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.colorScheme.placeholderText, + ) + }, + leadingIcon = { + UploadFromGallery( + isUploading = postViewModel.isUploadingImageForBanner, + tint = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(start = 5.dp), + ) { + postViewModel.uploadForBanner(it, context) + } + }, + 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.colorScheme.placeholderText, + ) + }, + 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.colorScheme.placeholderText, + ) + }, + 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.colorScheme.placeholderText, + ) + }, + 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.colorScheme.placeholderText, + ) + }, + ) + + 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.colorScheme.placeholderText, + ) + }, + ) + + 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.colorScheme.placeholderText, + ) + }, + ) + + 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.colorScheme.placeholderText, + ) + }, + ) + } + } } + } } 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 index f19471f47..32a6d636c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.actions import android.content.Context @@ -15,213 +35,220 @@ import com.vitorpamplona.amethyst.ui.components.MediaCompressor import com.vitorpamplona.quartz.events.GitHubIdentity import com.vitorpamplona.quartz.events.MastodonIdentity import com.vitorpamplona.quartz.events.TwitterIdentity +import java.io.ByteArrayInputStream +import java.io.StringWriter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch -import java.io.ByteArrayInputStream -import java.io.StringWriter class NewUserMetadataViewModel : ViewModel() { - private lateinit var account: Account + private lateinit var account: Account - // val userName = mutableStateOf("") - val displayName = mutableStateOf("") - val about = mutableStateOf("") + // val userName = mutableStateOf("") + val displayName = mutableStateOf("") + val about = mutableStateOf("") - val picture = mutableStateOf("") - val banner = mutableStateOf("") + val picture = mutableStateOf("") + val banner = mutableStateOf("") - val website = mutableStateOf("") - val nip05 = mutableStateOf("") - val lnAddress = mutableStateOf("") - val lnURL = mutableStateOf("") + val website = mutableStateOf("") + val nip05 = mutableStateOf("") + val lnAddress = mutableStateOf("") + val lnURL = mutableStateOf("") - val twitter = mutableStateOf("") - val github = mutableStateOf("") - val mastodon = mutableStateOf("") + val twitter = mutableStateOf("") + val github = mutableStateOf("") + val mastodon = mutableStateOf("") - var isUploadingImageForPicture by mutableStateOf(false) - var isUploadingImageForBanner by mutableStateOf(false) - val imageUploadingError = MutableSharedFlow(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST) + var isUploadingImageForPicture by mutableStateOf(false) + var isUploadingImageForBanner by mutableStateOf(false) + val imageUploadingError = + MutableSharedFlow(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST) - fun load(account: Account) { - this.account = account + 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 ?: "" - lnURL.value = it.info?.lud06 ?: "" + 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 ?: "" + lnURL.value = it.info?.lud06 ?: "" - twitter.value = "" - github.value = "" - mastodon.value = "" + 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() - } - } + // 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() } + } + } + } + + fun create() { + // Tries to not delete any existing attribute that we do not work with. + val latest = account.userProfile().info?.latestMetadata + val currentJson = + if (latest != null) { + ObjectMapper() + .readTree( + ByteArrayInputStream(latest.content.toByteArray(Charsets.UTF_8)), + ) as ObjectNode + } else { + ObjectMapper().createObjectNode() + } + currentJson.put("name", displayName.value.trim()) + currentJson.put("display_name", displayName.value.trim()) + currentJson.put("picture", picture.value.trim()) + currentJson.put("banner", banner.value.trim()) + currentJson.put("website", website.value.trim()) + currentJson.put("about", about.value.trim()) + currentJson.put("nip05", nip05.value.trim()) + 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 } } - fun create() { - // Tries to not delete any existing attribute that we do not work with. - val latest = account.userProfile().info?.latestMetadata - val currentJson = if (latest != null) { - ObjectMapper().readTree( - ByteArrayInputStream(latest.content.toByteArray(Charsets.UTF_8)) - ) as ObjectNode - } else { - ObjectMapper().createObjectNode() - } - // currentJson.put("username", userName.value.trim()) - currentJson.put("name", displayName.value.trim()) - currentJson.put("display_name", displayName.value.trim()) - currentJson.put("displayName", displayName.value.trim()) - currentJson.put("picture", picture.value.trim()) - currentJson.put("banner", banner.value.trim()) - currentJson.put("website", website.value.trim()) - currentJson.put("about", about.value.trim()) - currentJson.put("nip05", nip05.value.trim()) - 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) - - viewModelScope.launch(Dispatchers.IO) { - account.sendNewUserMetadata(writer.buffer.toString(), displayName.value.trim(), newClaims) - } - clear() + if (github.value.isBlank()) { + // delete github + claims = claims.filter { it !is GitHubIdentity } } - fun clear() { - // userName.value = "" - displayName.value = "" - about.value = "" - picture.value = "" - banner.value = "" - website.value = "" - nip05.value = "" - lnAddress.value = "" - lnURL.value = "" - twitter.value = "" - github.value = "" - mastodon.value = "" + if (mastodon.value.isBlank()) { + // delete mastodon + claims = claims.filter { it !is MastodonIdentity } } - fun uploadForPicture(uri: Uri, context: Context) { - viewModelScope.launch(Dispatchers.IO) { - upload( - uri, - context, - onUploading = { - isUploadingImageForPicture = it - }, - onUploaded = { - picture.value = it - } - ) - } + // 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) + + viewModelScope.launch(Dispatchers.IO) { + account.sendNewUserMetadata(writer.buffer.toString(), displayName.value.trim(), newClaims) } + clear() + } - fun uploadForBanner(uri: Uri, context: Context) { - viewModelScope.launch(Dispatchers.IO) { - upload( - uri, - context, - onUploading = { - isUploadingImageForBanner = it - }, - onUploaded = { - banner.value = it - } - ) - } + fun clear() { + // userName.value = "" + displayName.value = "" + about.value = "" + picture.value = "" + banner.value = "" + website.value = "" + nip05.value = "" + lnAddress.value = "" + lnURL.value = "" + twitter.value = "" + github.value = "" + mastodon.value = "" + } + + fun uploadForPicture( + uri: Uri, + context: Context, + ) { + viewModelScope.launch(Dispatchers.IO) { + upload( + uri, + context, + onUploading = { isUploadingImageForPicture = it }, + onUploaded = { picture.value = it }, + ) } + } - private suspend fun upload(galleryUri: Uri, context: Context, onUploading: (Boolean) -> Unit, onUploaded: (String) -> Unit) { - onUploading(true) + fun uploadForBanner( + uri: Uri, + context: Context, + ) { + viewModelScope.launch(Dispatchers.IO) { + upload( + uri, + context, + onUploading = { isUploadingImageForBanner = it }, + onUploaded = { banner.value = it }, + ) + } + } - val contentResolver = context.contentResolver + private suspend fun upload( + galleryUri: Uri, + context: Context, + onUploading: (Boolean) -> Unit, + onUploaded: (String) -> Unit, + ) { + onUploading(true) - MediaCompressor().compress( - galleryUri, - contentResolver.getType(galleryUri), - context.applicationContext, - onReady = { fileUri, contentType, size -> - viewModelScope.launch(Dispatchers.IO) { - try { - val result = Nip96Uploader(account).uploadImage( - uri = fileUri, - contentType = contentType, - size = size, - alt = null, - sensitiveContent = null, - server = account.defaultFileServer, - contentResolver = contentResolver, - onProgress = { } - ) + val contentResolver = context.contentResolver - val url = result.tags?.firstOrNull() { it.size > 1 && it[0] == "url" }?.get(1) + MediaCompressor() + .compress( + galleryUri, + contentResolver.getType(galleryUri), + context.applicationContext, + onReady = { fileUri, contentType, size -> + viewModelScope.launch(Dispatchers.IO) { + try { + val result = + Nip96Uploader(account) + .uploadImage( + uri = fileUri, + contentType = contentType, + size = size, + alt = null, + sensitiveContent = null, + server = account.defaultFileServer, + contentResolver = contentResolver, + onProgress = {}, + ) - if (url != null) { - onUploading(false) - onUploaded(url) - } else { - onUploading(false) - viewModelScope.launch { - imageUploadingError.emit("Failed to upload the image / video") - } - } - } catch (e: Exception) { - onUploading(false) - viewModelScope.launch { - imageUploadingError.emit("Failed to upload the image / video") - } - } - } - }, - onError = { + val url = result.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1) + + if (url != null) { + onUploading(false) + onUploaded(url) + } else { onUploading(false) viewModelScope.launch { - imageUploadingError.emit(it) + imageUploadingError.emit("Failed to upload the image / video") } + } + } catch (e: Exception) { + onUploading(false) + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + } } - ) - } + } + }, + onError = { + onUploading(false) + viewModelScope.launch { imageUploadingError.emit(it) } + }, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NotifyRequestDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NotifyRequestDialog.kt index da71d0db5..4fb274088 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NotifyRequestDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NotifyRequestDialog.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.actions import androidx.compose.foundation.layout.PaddingValues @@ -28,47 +48,47 @@ import com.vitorpamplona.quartz.events.EmptyTagList @Composable fun NotifyRequestDialog( - title: String, - textContent: String, - buttonColors: ButtonColors = ButtonDefaults.buttonColors(), - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - onDismiss: () -> Unit + title: String, + textContent: String, + buttonColors: ButtonColors = ButtonDefaults.buttonColors(), + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + onDismiss: () -> Unit, ) { - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text(title) - }, - text = { - val defaultBackground = MaterialTheme.colorScheme.background - val background = remember { - mutableStateOf(defaultBackground) - } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + val defaultBackground = MaterialTheme.colorScheme.background + val background = remember { mutableStateOf(defaultBackground) } - TranslatableRichTextViewer( - textContent, - canPreview = true, - Modifier.fillMaxWidth(), - EmptyTagList, - background, - accountViewModel, - nav - ) - }, - confirmButton = { - Button(onClick = onDismiss, colors = buttonColors, contentPadding = PaddingValues(horizontal = Size16dp)) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Outlined.Done, - contentDescription = null - ) - Spacer(StdHorzSpacer) - Text(stringResource(R.string.error_dialog_button_ok)) - } - } + TranslatableRichTextViewer( + textContent, + canPreview = true, + Modifier.fillMaxWidth(), + EmptyTagList, + background, + accountViewModel, + nav, + ) + }, + confirmButton = { + Button( + onClick = onDismiss, + colors = buttonColors, + contentPadding = PaddingValues(horizontal = Size16dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Done, + contentDescription = null, + ) + Spacer(StdHorzSpacer) + Text(stringResource(R.string.error_dialog_button_ok)) } - ) + } + }, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelayInformationDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelayInformationDialog.kt index e764259cd..10acfb265 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelayInformationDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelayInformationDialog.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.actions import androidx.compose.animation.Crossfade @@ -43,267 +63,264 @@ import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier @OptIn(ExperimentalLayoutApi::class) @Composable fun RelayInformationDialog( - onClose: () -> Unit, - relayBriefInfo: RelayBriefInfoCache.RelayBriefInfo, - relayInfo: RelayInformation, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + onClose: () -> Unit, + relayBriefInfo: RelayBriefInfoCache.RelayBriefInfo, + relayInfo: RelayInformation, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val automaticallyShowProfilePicture = remember { - accountViewModel.settings.showProfilePictures.value - } + val automaticallyShowProfilePicture = remember { + accountViewModel.settings.showProfilePictures.value + } - Dialog( - onDismissRequest = { onClose() }, - properties = DialogProperties( - usePlatformDefaultWidth = false, - dismissOnClickOutside = false - ) - ) { - Surface { - val scrollState = rememberScrollState() + Dialog( + onDismissRequest = { onClose() }, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + ), + ) { + Surface { + val scrollState = rememberScrollState() - Column( - modifier = Modifier - .padding(10.dp) - .fillMaxSize() - .verticalScroll(scrollState) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - CloseButton(onPress = { - onClose() - }) - } - - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = StdPadding.fillMaxWidth()) { - Column() { - RenderRelayIcon( - relayBriefInfo.displayUrl, - relayBriefInfo.favIcon, - automaticallyShowProfilePicture, - MaterialTheme.colorScheme.largeRelayIconModifier - ) - } - - Spacer(modifier = DoubleHorzSpacer) - - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Row() { - Title(relayInfo.name?.trim() ?: "") - } - - Row() { - SubtitleContent(relayInfo.description?.trim() ?: "") - } - } - } - - Section(stringResource(R.string.owner)) - - relayInfo.pubkey?.let { - DisplayOwnerInformation(it, accountViewModel, nav) - } - - Section(stringResource(R.string.software)) - - DisplaySoftwareInformation(relayInfo) - - Section(stringResource(R.string.version)) - - SectionContent(relayInfo.version ?: "") - - Section(stringResource(R.string.contact)) - - Box(modifier = Modifier.padding(start = 10.dp)) { - relayInfo.contact?.let { - if (it.startsWith("https:")) { - ClickableUrl(urlText = it, url = it) - } else if (it.startsWith("mailto:") || it.contains('@')) { - ClickableEmail(it) - } else { - SectionContent(it) - } - } - } - - Section(stringResource(R.string.supports)) - - DisplaySupportedNips(relayInfo) - - relayInfo.fees?.admission?.let { - if (it.isNotEmpty()) { - Section(stringResource(R.string.admission_fees)) - - it.forEach { item -> - SectionContent("${item.amount?.div(1000) ?: 0} sats") - } - } - } - - relayInfo.payments_url?.let { - Section(stringResource(R.string.payments_url)) - - Box(modifier = Modifier.padding(start = 10.dp)) { - ClickableUrl( - urlText = it, - url = it - ) - } - } - - relayInfo.limitation?.let { - Section(stringResource(R.string.limitations)) - val authRequired = it.auth_required ?: false - val authRequiredText = if (authRequired) stringResource(R.string.yes) else stringResource(R.string.no) - val paymentRequired = it.payment_required ?: false - val paymentRequiredText = if (paymentRequired) stringResource(R.string.yes) else stringResource(R.string.no) - - Column { - SectionContent("${stringResource(R.string.message_length)}: ${it.max_message_length ?: 0}") - SectionContent("${stringResource(R.string.subscriptions)}: ${it.max_subscriptions ?: 0}") - SectionContent("${stringResource(R.string.filters)}: ${it.max_subscriptions ?: 0}") - SectionContent("${stringResource(R.string.subscription_id_length)}: ${it.max_subid_length ?: 0}") - SectionContent("${stringResource(R.string.minimum_prefix)}: ${it.min_prefix ?: 0}") - SectionContent("${stringResource(R.string.maximum_event_tags)}: ${it.max_event_tags ?: 0}") - SectionContent("${stringResource(R.string.content_length)}: ${it.max_content_length ?: 0}") - SectionContent("${stringResource(R.string.minimum_pow)}: ${it.min_pow_difficulty ?: 0}") - SectionContent("${stringResource(R.string.auth)}: $authRequiredText") - SectionContent("${stringResource(R.string.payment)}: $paymentRequiredText") - } - } - - relayInfo.relay_countries?.let { - Section(stringResource(R.string.countries)) - - FlowRow { - it.forEach { item -> - SectionContent(item) - } - } - } - - relayInfo.language_tags?.let { - Section(stringResource(R.string.languages)) - - FlowRow { - it.forEach { item -> - SectionContent(item) - } - } - } - - relayInfo.tags?.let { - Section(stringResource(R.string.tags)) - - FlowRow { - it.forEach { item -> - SectionContent(item) - } - } - } - - relayInfo.posting_policy?.let { - Section(stringResource(R.string.posting_policy)) - - Box(Modifier.padding(10.dp)) { - ClickableUrl( - it, - it - ) - } - } - } + Column( + modifier = Modifier.padding(10.dp).fillMaxSize().verticalScroll(scrollState), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + CloseButton(onPress = { onClose() }) } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = StdPadding.fillMaxWidth(), + ) { + Column { + RenderRelayIcon( + relayBriefInfo.displayUrl, + relayBriefInfo.favIcon, + automaticallyShowProfilePicture, + MaterialTheme.colorScheme.largeRelayIconModifier, + ) + } + + Spacer(modifier = DoubleHorzSpacer) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Row { Title(relayInfo.name?.trim() ?: "") } + + Row { SubtitleContent(relayInfo.description?.trim() ?: "") } + } + } + + Section(stringResource(R.string.owner)) + + relayInfo.pubkey?.let { DisplayOwnerInformation(it, accountViewModel, nav) } + + Section(stringResource(R.string.software)) + + DisplaySoftwareInformation(relayInfo) + + Section(stringResource(R.string.version)) + + SectionContent(relayInfo.version ?: "") + + Section(stringResource(R.string.contact)) + + Box(modifier = Modifier.padding(start = 10.dp)) { + relayInfo.contact?.let { + if (it.startsWith("https:")) { + ClickableUrl(urlText = it, url = it) + } else if (it.startsWith("mailto:") || it.contains('@')) { + ClickableEmail(it) + } else { + SectionContent(it) + } + } + } + + Section(stringResource(R.string.supports)) + + DisplaySupportedNips(relayInfo) + + relayInfo.fees?.admission?.let { + if (it.isNotEmpty()) { + Section(stringResource(R.string.admission_fees)) + + it.forEach { item -> SectionContent("${item.amount?.div(1000) ?: 0} sats") } + } + } + + relayInfo.payments_url?.let { + Section(stringResource(R.string.payments_url)) + + Box(modifier = Modifier.padding(start = 10.dp)) { + ClickableUrl( + urlText = it, + url = it, + ) + } + } + + relayInfo.limitation?.let { + Section(stringResource(R.string.limitations)) + val authRequired = it.auth_required ?: false + val authRequiredText = + if (authRequired) stringResource(R.string.yes) else stringResource(R.string.no) + val paymentRequired = it.payment_required ?: false + val paymentRequiredText = + if (paymentRequired) stringResource(R.string.yes) else stringResource(R.string.no) + + Column { + SectionContent( + "${stringResource(R.string.message_length)}: ${it.max_message_length ?: 0}", + ) + SectionContent( + "${stringResource(R.string.subscriptions)}: ${it.max_subscriptions ?: 0}", + ) + SectionContent("${stringResource(R.string.filters)}: ${it.max_subscriptions ?: 0}") + SectionContent( + "${stringResource(R.string.subscription_id_length)}: ${it.max_subid_length ?: 0}", + ) + SectionContent("${stringResource(R.string.minimum_prefix)}: ${it.min_prefix ?: 0}") + SectionContent( + "${stringResource(R.string.maximum_event_tags)}: ${it.max_event_tags ?: 0}", + ) + SectionContent( + "${stringResource(R.string.content_length)}: ${it.max_content_length ?: 0}", + ) + SectionContent("${stringResource(R.string.minimum_pow)}: ${it.min_pow_difficulty ?: 0}") + SectionContent("${stringResource(R.string.auth)}: $authRequiredText") + SectionContent("${stringResource(R.string.payment)}: $paymentRequiredText") + } + } + + relayInfo.relay_countries?.let { + Section(stringResource(R.string.countries)) + + FlowRow { it.forEach { item -> SectionContent(item) } } + } + + relayInfo.language_tags?.let { + Section(stringResource(R.string.languages)) + + FlowRow { it.forEach { item -> SectionContent(item) } } + } + + relayInfo.tags?.let { + Section(stringResource(R.string.tags)) + + FlowRow { it.forEach { item -> SectionContent(item) } } + } + + relayInfo.posting_policy?.let { + Section(stringResource(R.string.posting_policy)) + + Box(Modifier.padding(10.dp)) { + ClickableUrl( + it, + it, + ) + } + } + } } + } } @Composable @OptIn(ExperimentalLayoutApi::class) private fun DisplaySupportedNips(relayInfo: RelayInformation) { - FlowRow { - relayInfo.supported_nips?.forEach { item -> - val text = item.toString().padStart(2, '0') - Box(Modifier.padding(10.dp)) { - ClickableUrl( - urlText = text, - url = "https://github.com/nostr-protocol/nips/blob/master/$text.md" - ) - } - } - - relayInfo.supported_nip_extensions?.forEach { item -> - val text = item.padStart(2, '0') - Box(Modifier.padding(10.dp)) { - ClickableUrl( - urlText = text, - url = "https://github.com/nostr-protocol/nips/blob/master/$text.md" - ) - } - } + FlowRow { + relayInfo.supported_nips?.forEach { item -> + val text = item.toString().padStart(2, '0') + Box(Modifier.padding(10.dp)) { + ClickableUrl( + urlText = text, + url = "https://github.com/nostr-protocol/nips/blob/master/$text.md", + ) + } } + + relayInfo.supported_nip_extensions?.forEach { item -> + val text = item.padStart(2, '0') + Box(Modifier.padding(10.dp)) { + ClickableUrl( + urlText = text, + url = "https://github.com/nostr-protocol/nips/blob/master/$text.md", + ) + } + } + } } @Composable private fun DisplaySoftwareInformation(relayInfo: RelayInformation) { - val url = (relayInfo.software ?: "").replace("git+", "") - Box(modifier = Modifier.padding(start = 10.dp)) { - ClickableUrl( - urlText = url, - url = url - ) - } + val url = (relayInfo.software ?: "").replace("git+", "") + Box(modifier = Modifier.padding(start = 10.dp)) { + ClickableUrl( + urlText = url, + url = url, + ) + } } @Composable private fun DisplayOwnerInformation( - userHex: String, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + userHex: String, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - LoadUser(baseUserHex = userHex, accountViewModel) { - Crossfade(it) { - if (it != null) { - UserCompose(baseUser = it, accountViewModel = accountViewModel, showDiviser = false, nav = nav) - } - } + LoadUser(baseUserHex = userHex, accountViewModel) { + Crossfade(it) { + if (it != null) { + UserCompose( + baseUser = it, + accountViewModel = accountViewModel, + showDiviser = false, + nav = nav, + ) + } } + } } @Composable fun Title(text: String) { - Text( - text = text, - fontWeight = FontWeight.Bold, - fontSize = 24.sp - ) + Text( + text = text, + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + ) } @Composable fun SubtitleContent(text: String) { - Text( - text = text - ) + Text( + text = text, + ) } @Composable fun Section(text: String) { - Spacer(modifier = DoubleVertSpacer) - Text( - text = text, - fontWeight = FontWeight.Bold, - fontSize = 20.sp - ) - Spacer(modifier = DoubleVertSpacer) + Spacer(modifier = DoubleVertSpacer) + Text( + text = text, + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + ) + Spacer(modifier = DoubleVertSpacer) } @Composable fun SectionContent(text: String) { - Text( - modifier = Modifier.padding(start = 10.dp), - text = text - ) + Text( + modifier = Modifier.padding(start = 10.dp), + text = text, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelaySelectionDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelaySelectionDialog.kt index 9d4a8e80b..1ac87acbd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelaySelectionDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelaySelectionDialog.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.actions import androidx.compose.foundation.ExperimentalFoundationApi @@ -36,188 +56,198 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList data class RelayList( - val relay: Relay, - val relayInfo: RelayBriefInfoCache.RelayBriefInfo, - val isSelected: Boolean + val relay: Relay, + val relayInfo: RelayBriefInfoCache.RelayBriefInfo, + val isSelected: Boolean, ) data class RelayInfoDialog( - val relayBriefInfo: RelayBriefInfoCache.RelayBriefInfo, - val relayInfo: RelayInformation + val relayBriefInfo: RelayBriefInfoCache.RelayBriefInfo, + val relayInfo: RelayInformation, ) @Composable fun RelaySelectionDialog( - preSelectedList: ImmutableList, - onClose: () -> Unit, - onPost: (list: ImmutableList) -> Unit, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + preSelectedList: ImmutableList, + onClose: () -> Unit, + onPost: (list: ImmutableList) -> Unit, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val context = LocalContext.current + val context = LocalContext.current - var relays by remember { - mutableStateOf( - accountViewModel.account.activeWriteRelays().map { - RelayList( - relay = it, - relayInfo = RelayBriefInfoCache.RelayBriefInfo(it.url), - isSelected = preSelectedList.any { relay -> it.url == relay.url } - ) - } + var relays by remember { + mutableStateOf( + accountViewModel.account.activeWriteRelays().map { + RelayList( + relay = it, + relayInfo = RelayBriefInfoCache.RelayBriefInfo(it.url), + isSelected = preSelectedList.any { relay -> it.url == relay.url }, ) - } + }, + ) + } - val hasSelectedRelay by remember { - derivedStateOf { - relays.any { it.isSelected } - } - } + val hasSelectedRelay by remember { derivedStateOf { relays.any { it.isSelected } } } - var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) } + var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) } - relayInfo?.let { - RelayInformationDialog( - onClose = { - relayInfo = null - }, - relayInfo = it.relayInfo, - relayBriefInfo = it.relayBriefInfo, - accountViewModel = accountViewModel, - nav = nav - ) - } + relayInfo?.let { + RelayInformationDialog( + onClose = { relayInfo = null }, + relayInfo = it.relayInfo, + relayBriefInfo = it.relayBriefInfo, + accountViewModel = accountViewModel, + nav = nav, + ) + } - var selected by remember { - mutableStateOf(true) - } + var selected by remember { mutableStateOf(true) } - Dialog( - onDismissRequest = { onClose() }, - properties = DialogProperties( - usePlatformDefaultWidth = false, - dismissOnClickOutside = false, - decorFitsSystemWindows = false - ) + Dialog( + onDismissRequest = { onClose() }, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + decorFitsSystemWindows = false, + ), + ) { + Surface( + modifier = Modifier.fillMaxWidth().fillMaxHeight(), ) { - Surface( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() + Column( + modifier = + Modifier.fillMaxWidth().fillMaxHeight().padding(start = 10.dp, end = 10.dp, top = 10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Column( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - .padding(start = 10.dp, end = 10.dp, top = 10.dp) + CloseButton( + onPress = { onClose() }, + ) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - CloseButton( - onPress = { - onClose() - } - ) - - SaveButton( - onPost = { - val selectedRelays = relays.filter { it.isSelected } - onPost(selectedRelays.map { it.relay }.toImmutableList()) - onClose() - }, - isActive = hasSelectedRelay - ) - } - - RelaySwitch( - text = context.getString(R.string.select_deselect_all), - checked = selected, - onClick = { - selected = !selected - relays = relays.mapIndexed { _, item -> - item.copy(isSelected = selected) - } - } - ) - - LazyColumn( - contentPadding = FeedPadding - ) { - itemsIndexed( - relays, - key = { _, item -> item.relay.url } - ) { index, item -> - RelaySwitch( - text = item.relayInfo.displayUrl, - checked = item.isSelected, - onClick = { - relays = relays.mapIndexed { j, item -> - if (index == j) { - item.copy(isSelected = !item.isSelected) - } else { - item - } - } - }, - onLongPress = { - accountViewModel.retrieveRelayDocument( - item.relay.url, - onInfo = { - relayInfo = RelayInfoDialog( - RelayBriefInfoCache.RelayBriefInfo( - item.relay.url - ), - it - ) - }, - onError = { url, errorCode, exceptionMessage -> - val msg = when (errorCode) { - Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage) - Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage) - Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage) - Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage) - } - - accountViewModel.toast( - context.getString(R.string.unable_to_download_relay_document), - msg - ) - } - ) - } - ) - } - } - } + SaveButton( + onPost = { + val selectedRelays = relays.filter { it.isSelected } + onPost(selectedRelays.map { it.relay }.toImmutableList()) + onClose() + }, + isActive = hasSelectedRelay, + ) } + + RelaySwitch( + text = context.getString(R.string.select_deselect_all), + checked = selected, + onClick = { + selected = !selected + relays = relays.mapIndexed { _, item -> item.copy(isSelected = selected) } + }, + ) + + LazyColumn( + contentPadding = FeedPadding, + ) { + itemsIndexed( + relays, + key = { _, item -> item.relay.url }, + ) { index, item -> + RelaySwitch( + text = item.relayInfo.displayUrl, + checked = item.isSelected, + onClick = { + relays = + relays.mapIndexed { j, item -> + if (index == j) { + item.copy(isSelected = !item.isSelected) + } else { + item + } + } + }, + onLongPress = { + accountViewModel.retrieveRelayDocument( + item.relay.url, + onInfo = { + relayInfo = + RelayInfoDialog( + RelayBriefInfoCache.RelayBriefInfo( + item.relay.url, + ), + it, + ) + }, + onError = { url, errorCode, exceptionMessage -> + val msg = + when (errorCode) { + Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> + context.getString( + R.string.relay_information_document_error_assemble_url, + url, + exceptionMessage, + ) + Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> + context.getString( + R.string.relay_information_document_error_assemble_url, + url, + exceptionMessage, + ) + Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> + context.getString( + R.string.relay_information_document_error_assemble_url, + url, + exceptionMessage, + ) + Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> + context.getString( + R.string.relay_information_document_error_assemble_url, + url, + exceptionMessage, + ) + } + + accountViewModel.toast( + context.getString(R.string.unable_to_download_relay_document), + msg, + ) + }, + ) + }, + ) + } + } + } } + } } @OptIn(ExperimentalFoundationApi::class) @Composable -fun RelaySwitch(text: String, checked: Boolean, onClick: () -> Unit, onLongPress: () -> Unit = { }) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .combinedClickable( - onClick = onClick, - onLongClick = onLongPress - ) - ) { - Text( - modifier = Modifier.weight(1f), - text = text - ) - Switch( - checked = checked, - onCheckedChange = { - onClick() - } - ) - } +fun RelaySwitch( + text: String, + checked: Boolean, + onClick: () -> Unit, + onLongPress: () -> Unit = {}, +) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.combinedClickable( + onClick = onClick, + onLongClick = onLongPress, + ), + ) { + Text( + modifier = Modifier.weight(1f), + text = text, + ) + Switch( + checked = checked, + onCheckedChange = { onClick() }, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt index 1c0196c2d..4ea5f1b05 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.actions import android.Manifest @@ -14,121 +34,131 @@ import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.theme.ButtonBorder -import kotlinx.coroutines.launch import java.io.File +import kotlinx.coroutines.launch /** - * A button to save the remote image to the gallery. - * May require a storage permission. + * A button to save the remote image to the gallery. May require a storage permission. * * @param url URL of the image */ @OptIn(ExperimentalPermissionsApi::class) @Composable fun SaveToGallery(url: String) { - val localContext = LocalContext.current - val scope = rememberCoroutineScope() + val localContext = LocalContext.current + val scope = rememberCoroutineScope() - fun saveImage() { - ImageSaver.saveImage( - context = localContext, - url = url, - onSuccess = { - scope.launch { - Toast.makeText( - localContext, - localContext.getString(R.string.image_saved_to_the_gallery), - Toast.LENGTH_SHORT - ) - .show() - } - }, - onError = { - scope.launch { - Toast.makeText( - localContext, - localContext.getString(R.string.failed_to_save_the_image), - Toast.LENGTH_SHORT - ) - .show() - } - } - ) - } + fun saveImage() { + ImageSaver.saveImage( + context = localContext, + url = url, + onSuccess = { + scope.launch { + Toast.makeText( + localContext, + localContext.getString(R.string.image_saved_to_the_gallery), + Toast.LENGTH_SHORT, + ) + .show() + } + }, + onError = { + scope.launch { + Toast.makeText( + localContext, + localContext.getString(R.string.failed_to_save_the_image), + Toast.LENGTH_SHORT, + ) + .show() + } + }, + ) + } - val writeStoragePermissionState = rememberPermissionState( - Manifest.permission.WRITE_EXTERNAL_STORAGE + val writeStoragePermissionState = + rememberPermissionState( + Manifest.permission.WRITE_EXTERNAL_STORAGE, ) { isGranted -> - if (isGranted) { - saveImage() - } + if (isGranted) { + saveImage() + } } - OutlinedButton( - onClick = { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || writeStoragePermissionState.status.isGranted) { - saveImage() - } else { - writeStoragePermissionState.launchPermissionRequest() - } - } - ) { - Text(text = stringResource(id = R.string.save)) - } + OutlinedButton( + onClick = { + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || + writeStoragePermissionState.status.isGranted + ) { + saveImage() + } else { + writeStoragePermissionState.launchPermissionRequest() + } + }, + ) { + Text(text = stringResource(id = R.string.save)) + } } @OptIn(ExperimentalPermissionsApi::class) @Composable -fun SaveToGallery(localFile: File, mimeType: String?) { - val localContext = LocalContext.current - val scope = rememberCoroutineScope() +fun SaveToGallery( + localFile: File, + mimeType: String?, +) { + val localContext = LocalContext.current + val scope = rememberCoroutineScope() - fun saveImage() { - ImageSaver.saveImage( - context = localContext, - localFile = localFile, - mimeType = mimeType, - onSuccess = { - scope.launch { - Toast.makeText( - localContext, - localContext.getString(R.string.image_saved_to_the_gallery), - Toast.LENGTH_SHORT - ) - .show() - } - }, - onError = { - scope.launch { - Toast.makeText( - localContext, - localContext.getString(R.string.failed_to_save_the_image), - Toast.LENGTH_SHORT - ) - .show() - } - } - ) - } - - val writeStoragePermissionState = rememberPermissionState( - Manifest.permission.WRITE_EXTERNAL_STORAGE - ) { isGranted -> - if (isGranted) { - saveImage() + fun saveImage() { + ImageSaver.saveImage( + context = localContext, + localFile = localFile, + mimeType = mimeType, + onSuccess = { + scope.launch { + Toast.makeText( + localContext, + localContext.getString(R.string.image_saved_to_the_gallery), + Toast.LENGTH_SHORT, + ) + .show() } + }, + onError = { + scope.launch { + Toast.makeText( + localContext, + localContext.getString(R.string.failed_to_save_the_image), + Toast.LENGTH_SHORT, + ) + .show() + } + }, + ) + } + + val writeStoragePermissionState = + rememberPermissionState( + Manifest.permission.WRITE_EXTERNAL_STORAGE, + ) { isGranted -> + if (isGranted) { + saveImage() + } } - OutlinedButton( - onClick = { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || writeStoragePermissionState.status.isGranted) { - saveImage() - } else { - writeStoragePermissionState.launchPermissionRequest() - } - }, - shape = ButtonBorder - ) { - Text(text = stringResource(id = R.string.save)) - } + OutlinedButton( + onClick = { + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || + writeStoragePermissionState.status.isGranted + ) { + saveImage() + } else { + writeStoragePermissionState.launchPermissionRequest() + } + }, + shape = ButtonBorder, + ) { + Text(text = stringResource(id = R.string.save)) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt index 2277074a3..aa326c53e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.actions import android.net.Uri @@ -38,147 +58,145 @@ import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.GetMediaActivityResultContract +import java.util.concurrent.atomic.AtomicBoolean import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList -import java.util.concurrent.atomic.AtomicBoolean @OptIn(ExperimentalPermissionsApi::class) @Composable fun UploadFromGallery( - isUploading: Boolean, - tint: Color, - modifier: Modifier, - onImageChosen: (Uri) -> Unit + isUploading: Boolean, + tint: Color, + modifier: Modifier, + onImageChosen: (Uri) -> Unit, ) { - val cameraPermissionState = - rememberPermissionState( - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - android.Manifest.permission.READ_MEDIA_IMAGES - } else { - android.Manifest.permission.READ_EXTERNAL_STORAGE - } - ) + val cameraPermissionState = + rememberPermissionState( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + android.Manifest.permission.READ_MEDIA_IMAGES + } else { + android.Manifest.permission.READ_EXTERNAL_STORAGE + }, + ) - if (cameraPermissionState.status.isGranted) { - var showGallerySelect by remember { mutableStateOf(false) } - if (showGallerySelect) { - GallerySelect( - onImageUri = { uri -> - showGallerySelect = false - if (uri != null) { - onImageChosen(uri) - } - } - ) - } - - UploadBoxButton(isUploading, tint, modifier) { - showGallerySelect = true - } - } else { - UploadBoxButton(isUploading, tint, modifier) { - cameraPermissionState.launchPermissionRequest() - } + if (cameraPermissionState.status.isGranted) { + var showGallerySelect by remember { mutableStateOf(false) } + if (showGallerySelect) { + GallerySelect( + onImageUri = { uri -> + showGallerySelect = false + if (uri != null) { + onImageChosen(uri) + } + }, + ) } + + UploadBoxButton(isUploading, tint, modifier) { showGallerySelect = true } + } else { + UploadBoxButton(isUploading, tint, modifier) { cameraPermissionState.launchPermissionRequest() } + } } @Composable private fun UploadBoxButton( - isUploading: Boolean, - tint: Color, - modifier: Modifier, - onClick: () -> Unit + isUploading: Boolean, + tint: Color, + modifier: Modifier, + onClick: () -> Unit, ) { - Box() { - IconButton( - modifier = modifier.align(Alignment.Center), - enabled = !isUploading, - onClick = { - onClick() - } - ) { - if (!isUploading) { - Icon( - imageVector = Icons.Default.AddPhotoAlternate, - contentDescription = stringResource(id = R.string.upload_image), - modifier = Modifier.height(25.dp), - tint = tint - ) - } else { - LoadingAnimation() - } - } + Box { + IconButton( + modifier = modifier.align(Alignment.Center), + enabled = !isUploading, + onClick = { onClick() }, + ) { + if (!isUploading) { + Icon( + imageVector = Icons.Default.AddPhotoAlternate, + contentDescription = stringResource(id = R.string.upload_image), + modifier = Modifier.height(25.dp), + tint = tint, + ) + } else { + LoadingAnimation() + } } + } } -val DefaultAnimationColors = listOf( - Color(0xFF5851D8), - Color(0xFF833AB4), - Color(0xFFC13584), - Color(0xFFE1306C), - Color(0xFFFD1D1D), - Color(0xFFF56040), - Color(0xFFF77737), - Color(0xFFFCAF45), - Color(0xFFFFDC80), - Color(0xFF5851D8) -).toImmutableList() +val DefaultAnimationColors = + listOf( + Color(0xFF5851D8), + Color(0xFF833AB4), + Color(0xFFC13584), + Color(0xFFE1306C), + Color(0xFFFD1D1D), + Color(0xFFF56040), + Color(0xFFF77737), + Color(0xFFFCAF45), + Color(0xFFFFDC80), + Color(0xFF5851D8), + ) + .toImmutableList() @Composable fun LoadingAnimation( - indicatorSize: Dp = 20.dp, - circleColors: ImmutableList = DefaultAnimationColors, - animationDuration: Int = 1000 + indicatorSize: Dp = 20.dp, + circleColors: ImmutableList = DefaultAnimationColors, + animationDuration: Int = 1000, ) { - val infiniteTransition = rememberInfiniteTransition() + val infiniteTransition = rememberInfiniteTransition() - val rotateAnimation by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 360f, - animationSpec = infiniteRepeatable( - animation = tween( - durationMillis = animationDuration, - easing = LinearEasing - ) - ) - ) - - CircularProgressIndicator( - modifier = Modifier - .size(size = indicatorSize) - .rotate(degrees = rotateAnimation) - .border( - width = 4.dp, - brush = Brush.sweepGradient(circleColors), - shape = CircleShape + val rotateAnimation by + infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = + infiniteRepeatable( + animation = + tween( + durationMillis = animationDuration, + easing = LinearEasing, ), - progress = 1f, - strokeWidth = 1.dp, - color = MaterialTheme.colorScheme.background // Set background color + ), ) + + CircularProgressIndicator( + modifier = + Modifier.size(size = indicatorSize) + .rotate(degrees = rotateAnimation) + .border( + width = 4.dp, + brush = Brush.sweepGradient(circleColors), + shape = CircleShape, + ), + progress = 1f, + strokeWidth = 1.dp, + color = MaterialTheme.colorScheme.background, + ) } @Composable -fun GallerySelect( - onImageUri: (Uri?) -> Unit = { } -) { - var hasLaunched by remember { mutableStateOf(AtomicBoolean(false)) } - val launcher = rememberLauncherForActivityResult( - contract = GetMediaActivityResultContract(), - onResult = { uri: Uri? -> - onImageUri(uri) - hasLaunched.set(false) - } +fun GallerySelect(onImageUri: (Uri?) -> Unit = {}) { + var hasLaunched by remember { mutableStateOf(AtomicBoolean(false)) } + val launcher = + rememberLauncherForActivityResult( + contract = GetMediaActivityResultContract(), + onResult = { uri: Uri? -> + onImageUri(uri) + hasLaunched.set(false) + }, ) - @Composable - fun LaunchGallery() { - SideEffect { - if (!hasLaunched.getAndSet(true)) { - launcher.launch("*/*") - } - } + @Composable + fun LaunchGallery() { + SideEffect { + if (!hasLaunched.getAndSet(true)) { + launcher.launch("*/*") + } } + } - LaunchGallery() + LaunchGallery() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt index b8b19a994..5d8182f5b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.actions import android.util.Patterns @@ -18,129 +38,153 @@ import kotlin.math.roundToInt data class RangesChanges(val original: TextRange, val modified: TextRange) class UrlUserTagTransformation(val color: Color) : VisualTransformation { - override fun filter(text: AnnotatedString): TransformedText { - return buildAnnotatedStringWithUrlHighlighting(text, color) - } + override fun filter(text: AnnotatedString): TransformedText { + return buildAnnotatedStringWithUrlHighlighting(text, color) + } } -fun buildAnnotatedStringWithUrlHighlighting(text: AnnotatedString, color: Color): TransformedText { - val substitutions = mutableListOf() +fun buildAnnotatedStringWithUrlHighlighting( + text: AnnotatedString, + color: Color, +): TransformedText { + val substitutions = mutableListOf() - val newText = buildAnnotatedString { - val builderBefore = StringBuilder() // important to correctly measure Tag start and end - val builderAfter = StringBuilder() // important to correctly measure Tag start and end - append( - text.split('\n').map { paragraph: String -> - paragraph.split(' ').map { word: String -> - try { - if (word.startsWith("@npub") && word.length >= 64) { - val keyB32 = word.substring(0, 64) - val restOfWord = word.substring(64) + val newText = buildAnnotatedString { + val builderBefore = StringBuilder() // important to correctly measure Tag start and end + val builderAfter = StringBuilder() // important to correctly measure Tag start and end + append( + text + .split('\n') + .map { paragraph: String -> + paragraph + .split(' ') + .map { word: String -> + try { + if (word.startsWith("@npub") && word.length >= 64) { + val keyB32 = word.substring(0, 64) + val restOfWord = word.substring(64) - val startIndex = builderBefore.toString().length + val startIndex = builderBefore.toString().length - builderBefore.append("$keyB32$restOfWord ") // accounts for the \n at the end of each paragraph + builderBefore.append( + "$keyB32$restOfWord ", + ) // accounts for the \n at the end of each paragraph - val endIndex = startIndex + keyB32.length + val endIndex = startIndex + keyB32.length - val key = - decodePublicKey(keyB32.removePrefix("@")) - val user = LocalCache.getOrCreateUser(key.toHexKey()) + val key = decodePublicKey(keyB32.removePrefix("@")) + val user = LocalCache.getOrCreateUser(key.toHexKey()) - val newWord = "@${user.toBestDisplayName()}" - val startNew = builderAfter.toString().length + val newWord = "@${user.toBestDisplayName()}" + val startNew = builderAfter.toString().length - builderAfter.append("$newWord$restOfWord ") // accounts for the \n at the end of each paragraph + builderAfter.append( + "$newWord$restOfWord ", + ) // accounts for the \n at the end of each paragraph - substitutions.add( - RangesChanges( - TextRange(startIndex, endIndex), - TextRange(startNew, startNew + newWord.length) - ) - ) - newWord + restOfWord - } else if (Patterns.WEB_URL.matcher(word).matches()) { - val startIndex = builderBefore.toString().length - val endIndex = startIndex + word.length + substitutions.add( + RangesChanges( + TextRange(startIndex, endIndex), + TextRange(startNew, startNew + newWord.length), + ), + ) + newWord + restOfWord + } else if (Patterns.WEB_URL.matcher(word).matches()) { + val startIndex = builderBefore.toString().length + val endIndex = startIndex + word.length - val startNew = builderAfter.toString().length - val endNew = startNew + word.length + val startNew = builderAfter.toString().length + val endNew = startNew + word.length - substitutions.add( - RangesChanges( - TextRange(startIndex, endIndex), - TextRange(startNew, endNew) - ) - ) + substitutions.add( + RangesChanges( + TextRange(startIndex, endIndex), + TextRange(startNew, endNew), + ), + ) - builderBefore.append("$word ") - builderAfter.append("$word ") - word - } else { - builderBefore.append("$word ") - builderAfter.append("$word ") - word - } - } catch (e: Exception) { - // if it can't parse the key, don't try to change. - builderBefore.append("$word ") - builderAfter.append("$word ") - word - } - }.joinToString(" ") - }.joinToString("\n") - ) - - substitutions.forEach { - addStyle( - style = SpanStyle( - color = color, - textDecoration = TextDecoration.None - ), - start = it.modified.start, - end = it.modified.end - ) + builderBefore.append("$word ") + builderAfter.append("$word ") + word + } else { + builderBefore.append("$word ") + builderAfter.append("$word ") + word + } + } catch (e: Exception) { + // if it can't parse the key, don't try to change. + builderBefore.append("$word ") + builderAfter.append("$word ") + word + } + } + .joinToString(" ") } - } - - val numberOffsetTranslator = object : OffsetMapping { - override fun originalToTransformed(offset: Int): Int { - val inInsideRange = substitutions.filter { offset > it.original.start && offset < it.original.end }.firstOrNull() - - if (inInsideRange != null) { - val percentInRange = (offset - inInsideRange.original.start) / (inInsideRange.original.length.toFloat()) - return (inInsideRange.modified.start + inInsideRange.modified.length * percentInRange).roundToInt() - } - - val lastRangeThrough = substitutions.lastOrNull { offset >= it.original.end } - - if (lastRangeThrough != null) { - return lastRangeThrough.modified.end + (offset - lastRangeThrough.original.end) - } else { - return offset - } - } - - override fun transformedToOriginal(offset: Int): Int { - val inInsideRange = substitutions.filter { offset > it.modified.start && offset < it.modified.end }.firstOrNull() - - if (inInsideRange != null) { - val percentInRange = (offset - inInsideRange.modified.start) / (inInsideRange.modified.length.toFloat()) - return (inInsideRange.original.start + inInsideRange.original.length * percentInRange).roundToInt() - } - - val lastRangeThrough = substitutions.lastOrNull { offset >= it.modified.end } - - if (lastRangeThrough != null) { - return lastRangeThrough.original.end + (offset - lastRangeThrough.modified.end) - } else { - return offset - } - } - } - - return TransformedText( - newText, - numberOffsetTranslator + .joinToString("\n"), ) + + substitutions.forEach { + addStyle( + style = + SpanStyle( + color = color, + textDecoration = TextDecoration.None, + ), + start = it.modified.start, + end = it.modified.end, + ) + } + } + + val numberOffsetTranslator = + object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + val inInsideRange = + substitutions + .filter { offset > it.original.start && offset < it.original.end } + .firstOrNull() + + if (inInsideRange != null) { + val percentInRange = + (offset - inInsideRange.original.start) / (inInsideRange.original.length.toFloat()) + return (inInsideRange.modified.start + inInsideRange.modified.length * percentInRange) + .roundToInt() + } + + val lastRangeThrough = substitutions.lastOrNull { offset >= it.original.end } + + if (lastRangeThrough != null) { + return lastRangeThrough.modified.end + (offset - lastRangeThrough.original.end) + } else { + return offset + } + } + + override fun transformedToOriginal(offset: Int): Int { + val inInsideRange = + substitutions + .filter { offset > it.modified.start && offset < it.modified.end } + .firstOrNull() + + if (inInsideRange != null) { + val percentInRange = + (offset - inInsideRange.modified.start) / (inInsideRange.modified.length.toFloat()) + return (inInsideRange.original.start + inInsideRange.original.length * percentInRange) + .roundToInt() + } + + val lastRangeThrough = substitutions.lastOrNull { offset >= it.modified.end } + + if (lastRangeThrough != null) { + return lastRangeThrough.original.end + (offset - lastRangeThrough.modified.end) + } else { + return offset + } + } + } + + return TransformedText( + newText, + numberOffsetTranslator, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/ChannelFabColumn.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/ChannelFabColumn.kt index af59856ce..6effa5898 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/ChannelFabColumn.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/ChannelFabColumn.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.buttons import androidx.compose.foundation.layout.Column @@ -29,75 +49,84 @@ import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.Size55Modifier @Composable -fun ChannelFabColumn(accountViewModel: AccountViewModel, nav: (String) -> Unit) { - var isOpen by remember { - mutableStateOf(false) +fun ChannelFabColumn( + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + var isOpen by remember { mutableStateOf(false) } + + var wantsToSendNewMessage by remember { mutableStateOf(false) } + + var wantsToCreateChannel by remember { mutableStateOf(false) } + + if (wantsToCreateChannel) { + NewChannelView({ wantsToCreateChannel = false }, accountViewModel = accountViewModel) + } + + if (wantsToSendNewMessage) { + NewPostView( + { wantsToSendNewMessage = false }, + enableMessageInterface = true, + accountViewModel = accountViewModel, + nav = nav, + ) + // JoinUserOrChannelView({ wantsToJoinChannelOrUser = false }, accountViewModel = + // accountViewModel, nav = nav) + } + + Column { + if (isOpen) { + FloatingActionButton( + onClick = { + wantsToSendNewMessage = true + isOpen = false + }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Text( + text = stringResource(R.string.messages_new_message), + color = Color.White, + textAlign = TextAlign.Center, + fontSize = Font12SP, + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + + FloatingActionButton( + onClick = { + wantsToCreateChannel = true + isOpen = false + }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Text( + text = stringResource(R.string.messages_create_public_chat), + color = Color.White, + textAlign = TextAlign.Center, + fontSize = Font12SP, + ) + } + + Spacer(modifier = Modifier.height(20.dp)) } - var wantsToSendNewMessage by remember { - mutableStateOf(false) - } - - var wantsToCreateChannel by remember { - mutableStateOf(false) - } - - if (wantsToCreateChannel) { - NewChannelView({ wantsToCreateChannel = false }, accountViewModel = accountViewModel) - } - - if (wantsToSendNewMessage) { - NewPostView({ wantsToSendNewMessage = false }, enableMessageInterface = true, accountViewModel = accountViewModel, nav = nav) - // JoinUserOrChannelView({ wantsToJoinChannelOrUser = false }, accountViewModel = accountViewModel, nav = nav) - } - - Column() { - if (isOpen) { - FloatingActionButton( - onClick = { wantsToSendNewMessage = true; isOpen = false }, - modifier = Size55Modifier, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary - ) { - Text( - text = stringResource(R.string.messages_new_message), - color = Color.White, - textAlign = TextAlign.Center, - fontSize = Font12SP - ) - } - - Spacer(modifier = Modifier.height(20.dp)) - - FloatingActionButton( - onClick = { wantsToCreateChannel = true; isOpen = false }, - modifier = Size55Modifier, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary - ) { - Text( - text = stringResource(R.string.messages_create_public_chat), - color = Color.White, - textAlign = TextAlign.Center, - fontSize = Font12SP - ) - } - - Spacer(modifier = Modifier.height(20.dp)) - } - - FloatingActionButton( - onClick = { isOpen = !isOpen }, - modifier = Size55Modifier, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary - ) { - Icon( - imageVector = Icons.Outlined.Add, - contentDescription = stringResource(R.string.messages_create_public_chat), - modifier = Modifier.size(26.dp), - tint = Color.White - ) - } + FloatingActionButton( + onClick = { isOpen = !isOpen }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = stringResource(R.string.messages_create_public_chat), + modifier = Modifier.size(26.dp), + tint = Color.White, + ) } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewChannelButton.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewChannelButton.kt index d53c7d9cc..4e5c8512e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewChannelButton.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewChannelButton.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.buttons import androidx.compose.foundation.layout.size @@ -25,26 +45,25 @@ import com.vitorpamplona.amethyst.ui.theme.ZeroPadding @Composable fun NewChannelButton(accountViewModel: AccountViewModel) { - var wantsToPost by remember { - mutableStateOf(false) - } + var wantsToPost by remember { mutableStateOf(false) } - if (wantsToPost) { - NewChannelView({ wantsToPost = false }, accountViewModel = accountViewModel) - } + if (wantsToPost) { + NewChannelView({ wantsToPost = false }, accountViewModel = accountViewModel) + } - OutlinedButton( - onClick = { wantsToPost = true }, - modifier = Size55Modifier, - shape = CircleShape, - colors = ButtonDefaults.outlinedButtonColors(containerColor = MaterialTheme.colorScheme.primary), - contentPadding = ZeroPadding - ) { - Icon( - imageVector = Icons.Outlined.Add, - contentDescription = stringResource(R.string.new_channel), - modifier = Modifier.size(26.dp), - tint = Color.White - ) - } + OutlinedButton( + onClick = { wantsToPost = true }, + modifier = Size55Modifier, + shape = CircleShape, + colors = + ButtonDefaults.outlinedButtonColors(containerColor = MaterialTheme.colorScheme.primary), + contentPadding = ZeroPadding, + ) { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = stringResource(R.string.new_channel), + modifier = Modifier.size(26.dp), + tint = Color.White, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewCommunityNoteButton.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewCommunityNoteButton.kt index 802110e28..1cd6cc020 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewCommunityNoteButton.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewCommunityNoteButton.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.buttons import androidx.compose.foundation.layout.size @@ -22,35 +42,39 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Size55Modifier @Composable -fun NewCommunityNoteButton(communityIdHex: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - LoadNote(baseNoteHex = communityIdHex, accountViewModel) { - it?.let { - NewCommunityNoteButton(it, accountViewModel, nav) - } - } +fun NewCommunityNoteButton( + communityIdHex: String, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + LoadNote(baseNoteHex = communityIdHex, accountViewModel) { + it?.let { NewCommunityNoteButton(it, accountViewModel, nav) } + } } @Composable -fun NewCommunityNoteButton(note: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - var wantsToPost by remember { - mutableStateOf(false) - } +fun NewCommunityNoteButton( + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + var wantsToPost by remember { mutableStateOf(false) } - if (wantsToPost) { - NewPostView({ wantsToPost = false }, note, accountViewModel = accountViewModel, nav = nav) - } + if (wantsToPost) { + NewPostView({ wantsToPost = false }, note, accountViewModel = accountViewModel, nav = nav) + } - FloatingActionButton( - onClick = { wantsToPost = true }, - modifier = Size55Modifier, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary - ) { - Icon( - painter = painterResource(R.drawable.ic_compose), - null, - modifier = Modifier.size(26.dp), - tint = Color.White - ) - } + FloatingActionButton( + onClick = { wantsToPost = true }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + painter = painterResource(R.drawable.ic_compose), + null, + modifier = Modifier.size(26.dp), + tint = Color.White, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewImageButton.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewImageButton.kt index b9f167b61..20e11249b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewImageButton.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewImageButton.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.buttons import android.Manifest @@ -47,106 +67,102 @@ import kotlinx.coroutines.withContext @OptIn(ExperimentalPermissionsApi::class) @Composable -fun NewImageButton(accountViewModel: AccountViewModel, nav: (String) -> Unit, navScrollToTop: (Route, Boolean) -> Unit) { - var wantsToPost by remember { - mutableStateOf(false) +fun NewImageButton( + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + navScrollToTop: (Route, Boolean) -> Unit, +) { + var wantsToPost by remember { mutableStateOf(false) } + + var pickedURI by remember { mutableStateOf(null) } + + val scope = rememberCoroutineScope() + + val postViewModel: NewMediaModel = viewModel() + postViewModel.onceUploaded { + scope.launch(Dispatchers.Default) { + delay(500) + withContext(Dispatchers.Main) { navScrollToTop(Route.Video, true) } } + } - var pickedURI by remember { - mutableStateOf(null) - } - - val scope = rememberCoroutineScope() - - val postViewModel: NewMediaModel = viewModel() - postViewModel.onceUploaded { - scope.launch(Dispatchers.Default) { - delay(500) - withContext(Dispatchers.Main) { - navScrollToTop(Route.Video, true) - } - } - } - - if (wantsToPost) { - val cameraPermissionState = - rememberPermissionState( - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - Manifest.permission.READ_MEDIA_IMAGES - } else { - Manifest.permission.READ_EXTERNAL_STORAGE - } - ) - - if (cameraPermissionState.status.isGranted) { - var showGallerySelect by remember { mutableStateOf(false) } - if (showGallerySelect) { - GallerySelect( - onImageUri = { uri -> - wantsToPost = false - showGallerySelect = false - pickedURI = uri - } - ) - } - - showGallerySelect = true + if (wantsToPost) { + val cameraPermissionState = + rememberPermissionState( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Manifest.permission.READ_MEDIA_IMAGES } else { - LaunchedEffect(key1 = accountViewModel) { - cameraPermissionState.launchPermissionRequest() - } - } - } + Manifest.permission.READ_EXTERNAL_STORAGE + }, + ) - pickedURI?.let { - NewMediaView( - uri = it, - onClose = { pickedURI = null }, - postViewModel = postViewModel, - accountViewModel = accountViewModel, - nav = nav + if (cameraPermissionState.status.isGranted) { + var showGallerySelect by remember { mutableStateOf(false) } + if (showGallerySelect) { + GallerySelect( + onImageUri = { uri -> + wantsToPost = false + showGallerySelect = false + pickedURI = uri + }, ) - } + } - if (postViewModel.isUploadingImage) { - ShowProgress(postViewModel) + showGallerySelect = true } else { - FloatingActionButton( - onClick = { wantsToPost = true }, - modifier = Size55Modifier, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary - ) { - Icon( - painter = painterResource(R.drawable.ic_compose), - null, - modifier = Modifier.size(26.dp), - tint = Color.White - ) - } + LaunchedEffect(key1 = accountViewModel) { cameraPermissionState.launchPermissionRequest() } } + } + + pickedURI?.let { + NewMediaView( + uri = it, + onClose = { pickedURI = null }, + postViewModel = postViewModel, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (postViewModel.isUploadingImage) { + ShowProgress(postViewModel) + } else { + FloatingActionButton( + onClick = { wantsToPost = true }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + painter = painterResource(R.drawable.ic_compose), + null, + modifier = Modifier.size(26.dp), + tint = Color.White, + ) + } + } } @Composable private fun ShowProgress(postViewModel: NewMediaModel) { - Box(Modifier.size(55.dp), contentAlignment = Alignment.Center) { - CircularProgressIndicator( - progress = animateFloatAsState( - targetValue = postViewModel.uploadingPercentage.value, - animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec - ).value, - modifier = Size55Modifier - .clip(CircleShape) - .background(MaterialTheme.colorScheme.background), - strokeWidth = 5.dp - ) - postViewModel.uploadingDescription.value?.let { - Text( - it, - color = MaterialTheme.colorScheme.onSurface, - fontSize = 10.sp, - textAlign = TextAlign.Center - ) - } + Box(Modifier.size(55.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator( + progress = + animateFloatAsState( + targetValue = postViewModel.uploadingPercentage.value, + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, + ) + .value, + modifier = Size55Modifier.clip(CircleShape).background(MaterialTheme.colorScheme.background), + strokeWidth = 5.dp, + ) + postViewModel.uploadingDescription.value?.let { + Text( + it, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 10.sp, + textAlign = TextAlign.Center, + ) } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewNoteButton.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewNoteButton.kt index ae61e1b99..a729ea4ef 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewNoteButton.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewNoteButton.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.buttons import androidx.compose.foundation.layout.size @@ -20,26 +40,27 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Size55Modifier @Composable -fun NewNoteButton(accountViewModel: AccountViewModel, nav: (String) -> Unit) { - var wantsToPost by remember { - mutableStateOf(false) - } +fun NewNoteButton( + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + var wantsToPost by remember { mutableStateOf(false) } - if (wantsToPost) { - NewPostView({ wantsToPost = false }, accountViewModel = accountViewModel, nav = nav) - } + if (wantsToPost) { + NewPostView({ wantsToPost = false }, accountViewModel = accountViewModel, nav = nav) + } - FloatingActionButton( - onClick = { wantsToPost = true }, - modifier = Size55Modifier, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary - ) { - Icon( - painter = painterResource(R.drawable.ic_compose), - null, - modifier = Modifier.size(26.dp), - tint = Color.White - ) - } + FloatingActionButton( + onClick = { wantsToPost = true }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + painter = painterResource(R.drawable.ic_compose), + null, + modifier = Modifier.size(26.dp), + tint = Color.White, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt index 174bad10e..e9338a3c3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import androidx.compose.animation.core.AnimationSpec @@ -12,7 +32,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset @@ -39,130 +58,150 @@ private val MaxSpikePaddingDp: Dp = 12.dp private val MinSpikeRadiusDp: Dp = 0.dp private val MaxSpikeRadiusDp: Dp = 12.dp -private const val MinProgress: Float = 0F -private const val MaxProgress: Float = 1F +private const val MIN_PROGRESS: Float = 0F +private const val MAX_PROGRESS: Float = 1F -private const val MinSpikeHeight: Float = 1F -private const val DefaultGraphicsLayerAlpha: Float = 0.99F +private const val MIN_SPIKE_HEIGHT: Float = 1F +private const val DEFAULT_GRAPHICS_LAYER_ALPHA: Float = 0.99F -@OptIn(ExperimentalComposeUiApi::class) @Composable fun AudioWaveformReadOnly( - modifier: Modifier = Modifier, - style: DrawStyle = Fill, - waveformBrush: Brush = SolidColor(Color.White), - progressBrush: Brush = SolidColor(Color.Blue), - waveformAlignment: WaveformAlignment = WaveformAlignment.Center, - amplitudeType: AmplitudeType = AmplitudeType.Avg, - onProgressChangeFinished: (() -> Unit)? = null, - spikeAnimationSpec: AnimationSpec = tween(500), - spikeWidth: Dp = 3.dp, - spikeRadius: Dp = 2.dp, - spikePadding: Dp = 2.dp, - progress: Float = 0F, - amplitudes: List, - onProgressChange: (Float) -> Unit + modifier: Modifier = Modifier, + style: DrawStyle = Fill, + waveformBrush: Brush = SolidColor(Color.White), + progressBrush: Brush = SolidColor(Color.Blue), + waveformAlignment: WaveformAlignment = WaveformAlignment.Center, + amplitudeType: AmplitudeType = AmplitudeType.Avg, + onProgressChangeFinished: (() -> Unit)? = null, + spikeAnimationSpec: AnimationSpec = tween(500), + spikeWidth: Dp = 3.dp, + spikeRadius: Dp = 2.dp, + spikePadding: Dp = 2.dp, + progress: Float = 0F, + amplitudes: List, + onProgressChange: (Float) -> Unit, ) { - val backgroundColor = MaterialTheme.colorScheme.background - val _progress = remember(progress) { progress.coerceIn(MinProgress, MaxProgress) } - val _spikeWidth = remember(spikeWidth) { spikeWidth.coerceIn(MinSpikeWidthDp, MaxSpikeWidthDp) } - val _spikePadding = remember(spikePadding) { spikePadding.coerceIn(MinSpikePaddingDp, MaxSpikePaddingDp) } - val _spikeRadius = remember(spikeRadius) { spikeRadius.coerceIn(MinSpikeRadiusDp, MaxSpikeRadiusDp) } - val _spikeTotalWidth = remember(spikeWidth, spikePadding) { _spikeWidth + _spikePadding } - var canvasSize by remember { mutableStateOf(Size(0f, 0f)) } - var spikes by remember { mutableStateOf(0F) } - val spikesAmplitudes = remember(amplitudes, spikes, amplitudeType) { + val backgroundColor = MaterialTheme.colorScheme.background + val progressState = remember(progress) { progress.coerceIn(MIN_PROGRESS, MAX_PROGRESS) } + val spikeWidthState = + remember(spikeWidth) { spikeWidth.coerceIn(MinSpikeWidthDp, MaxSpikeWidthDp) } + val spikePaddingState = + remember(spikePadding) { spikePadding.coerceIn(MinSpikePaddingDp, MaxSpikePaddingDp) } + val spikeRadiusState = + remember(spikeRadius) { spikeRadius.coerceIn(MinSpikeRadiusDp, MaxSpikeRadiusDp) } + val spikeTotalWidthState = + remember(spikeWidth, spikePadding) { spikeWidthState + spikePaddingState } + var canvasSize by remember { mutableStateOf(Size(0f, 0f)) } + var spikes by remember { mutableStateOf(0F) } + val spikesAmplitudes = + remember(amplitudes, spikes, amplitudeType) { amplitudes.toDrawableAmplitudes( - amplitudeType = amplitudeType, - spikes = spikes.toInt(), - minHeight = MinSpikeHeight, - maxHeight = canvasSize.height.coerceAtLeast(MinSpikeHeight) + amplitudeType = amplitudeType, + spikes = spikes.toInt(), + minHeight = MIN_SPIKE_HEIGHT, + maxHeight = canvasSize.height.coerceAtLeast(MIN_SPIKE_HEIGHT), ) - }.map { animateFloatAsState(it, spikeAnimationSpec).value } - Canvas( - modifier = Modifier - .fillMaxWidth() - .requiredHeight(48.dp) - .graphicsLayer(alpha = DefaultGraphicsLayerAlpha) - .then(modifier) - ) { - canvasSize = size - spikes = size.width / _spikeTotalWidth.toPx() - spikesAmplitudes.forEachIndexed { index, amplitude -> - drawRoundRect( - brush = waveformBrush, - topLeft = Offset( - x = index * _spikeTotalWidth.toPx(), - y = when (waveformAlignment) { - WaveformAlignment.Top -> 0F - WaveformAlignment.Bottom -> size.height - amplitude - WaveformAlignment.Center -> size.height / 2F - amplitude / 2F - } - ), - size = Size( - width = _spikeWidth.toPx(), - height = amplitude - ), - cornerRadius = CornerRadius(_spikeRadius.toPx(), _spikeRadius.toPx()), - style = style - ) - drawRect( - brush = progressBrush, - size = Size( - width = _progress * size.width, - height = size.height - ), - blendMode = BlendMode.SrcAtop - ) - } + } + .map { animateFloatAsState(it, spikeAnimationSpec).value } + Canvas( + modifier = + Modifier.fillMaxWidth() + .requiredHeight(48.dp) + .graphicsLayer(alpha = DEFAULT_GRAPHICS_LAYER_ALPHA) + .then(modifier), + ) { + canvasSize = size + spikes = size.width / spikeTotalWidthState.toPx() + spikesAmplitudes.forEachIndexed { index, amplitude -> + drawRoundRect( + brush = waveformBrush, + topLeft = + Offset( + x = index * spikeTotalWidthState.toPx(), + y = + when (waveformAlignment) { + WaveformAlignment.Top -> 0F + WaveformAlignment.Bottom -> size.height - amplitude + WaveformAlignment.Center -> size.height / 2F - amplitude / 2F + }, + ), + size = + Size( + width = spikeWidthState.toPx(), + height = amplitude, + ), + cornerRadius = CornerRadius(spikeRadiusState.toPx(), spikeRadiusState.toPx()), + style = style, + ) + drawRect( + brush = progressBrush, + size = + Size( + width = progressState * size.width, + height = size.height, + ), + blendMode = BlendMode.SrcAtop, + ) } + } } private fun List.toDrawableAmplitudes( - amplitudeType: AmplitudeType, - spikes: Int, - minHeight: Float, - maxHeight: Float + amplitudeType: AmplitudeType, + spikes: Int, + minHeight: Float, + maxHeight: Float, ): List { - val amplitudes = map(Int::toFloat) - if (amplitudes.isEmpty() || spikes == 0) { - return List(spikes) { minHeight } - } - val transform = { data: List -> - when (amplitudeType) { - AmplitudeType.Avg -> data.average() - AmplitudeType.Max -> data.max() - AmplitudeType.Min -> data.min() - }.toFloat().coerceIn(minHeight, maxHeight) - } - return when { - spikes > amplitudes.count() -> amplitudes.fillToSize(spikes, transform) - else -> amplitudes.chunkToSize(spikes, transform) - }.normalize(minHeight, maxHeight) + val amplitudes = map(Int::toFloat) + if (amplitudes.isEmpty() || spikes == 0) { + return List(spikes) { minHeight } + } + val transform = { data: List -> + when (amplitudeType) { + AmplitudeType.Avg -> data.average() + AmplitudeType.Max -> data.max() + AmplitudeType.Min -> data.min() + } + .toFloat() + .coerceIn(minHeight, maxHeight) + } + return when { + spikes > amplitudes.count() -> amplitudes.fillToSize(spikes, transform) + else -> amplitudes.chunkToSize(spikes, transform) + }.normalize(minHeight, maxHeight) } -internal fun Iterable.fillToSize(size: Int, transform: (List) -> T): List { - val capacity = ceil(size.safeDiv(count())).roundToInt() - return map { data -> List(capacity) { data } }.flatten().chunkToSize(size, transform) +internal fun Iterable.fillToSize( + size: Int, + transform: (List) -> T, +): List { + val capacity = ceil(size.safeDiv(count())).roundToInt() + return map { data -> List(capacity) { data } }.flatten().chunkToSize(size, transform) } -internal fun Iterable.chunkToSize(size: Int, transform: (List) -> T): List { - val chunkSize = count() / size - val remainder = count() % size - val remainderIndex = ceil(count().safeDiv(remainder)).roundToInt() - val chunkIteration = filterIndexed { index, _ -> - remainderIndex == 0 || index % remainderIndex != 0 - }.chunked(chunkSize, transform) - return when (size) { - chunkIteration.count() -> chunkIteration - else -> chunkIteration.chunkToSize(size, transform) - } +internal fun Iterable.chunkToSize( + size: Int, + transform: (List) -> T, +): List { + val chunkSize = count() / size + val remainder = count() % size + val remainderIndex = ceil(count().safeDiv(remainder)).roundToInt() + val chunkIteration = + filterIndexed { index, _ -> remainderIndex == 0 || index % remainderIndex != 0 } + .chunked(chunkSize, transform) + return when (size) { + chunkIteration.count() -> chunkIteration + else -> chunkIteration.chunkToSize(size, transform) + } } -internal fun Iterable.normalize(min: Float, max: Float): List { - return map { (max - min) * ((it - min()) / (max() - min())) + min } +internal fun Iterable.normalize( + min: Float, + max: Float, +): List { + return map { (max - min) * ((it - min()) / (max() - min())) + min } } private fun Int.safeDiv(value: Int): Float { - return if (value == 0) return 0F else this / value.toFloat() + return if (value == 0) return 0F else this / value.toFloat() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt index 8c51616a2..fa2a0e6b5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt @@ -1,7 +1,29 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.service.checkNotInMainThread +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -11,96 +33,94 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.util.concurrent.LinkedBlockingQueue -import java.util.concurrent.atomic.AtomicBoolean -/** - * This class is designed to have a waiting time between two calls of invalidate - */ +/** This class is designed to have a waiting time between two calls of invalidate */ @Stable class BundledUpdate( - val delay: Long, - val dispatcher: CoroutineDispatcher = Dispatchers.Default + val delay: Long, + val dispatcher: CoroutineDispatcher = Dispatchers.Default, ) { - val scope = CoroutineScope(dispatcher + SupervisorJob()) + val scope = CoroutineScope(dispatcher + SupervisorJob()) - private var onlyOneInBlock = AtomicBoolean() - private var invalidatesAgain = false + private var onlyOneInBlock = AtomicBoolean() + private var invalidatesAgain = false - fun invalidate(ignoreIfDoing: Boolean = false, onUpdate: suspend () -> Unit) { - if (onlyOneInBlock.getAndSet(true)) { - if (!ignoreIfDoing) { - invalidatesAgain = true - } - return - } - - scope.launch(dispatcher) { - try { - onUpdate() - delay(delay) - if (invalidatesAgain) { - onUpdate() - } - } finally { - withContext(NonCancellable) { - invalidatesAgain = false - onlyOneInBlock.set(false) - } - } - } + fun invalidate( + ignoreIfDoing: Boolean = false, + onUpdate: suspend () -> Unit, + ) { + if (onlyOneInBlock.getAndSet(true)) { + if (!ignoreIfDoing) { + invalidatesAgain = true + } + return } - fun cancel() { - scope.cancel() + scope.launch(dispatcher) { + try { + onUpdate() + delay(delay) + if (invalidatesAgain) { + onUpdate() + } + } finally { + withContext(NonCancellable) { + invalidatesAgain = false + onlyOneInBlock.set(false) + } + } } + } + + fun cancel() { + scope.cancel() + } } -/** - * This class is designed to have a waiting time between two calls of invalidate - */ +/** This class is designed to have a waiting time between two calls of invalidate */ @Stable class BundledInsert( - val delay: Long, - val dispatcher: CoroutineDispatcher = Dispatchers.Default + val delay: Long, + val dispatcher: CoroutineDispatcher = Dispatchers.Default, ) { - val scope = CoroutineScope(dispatcher + SupervisorJob()) + val scope = CoroutineScope(dispatcher + SupervisorJob()) - private var onlyOneInBlock = AtomicBoolean() - private var queue = LinkedBlockingQueue() + private var onlyOneInBlock = AtomicBoolean() + private var queue = LinkedBlockingQueue() - fun invalidateList(newObject: T, onUpdate: suspend (Set) -> Unit) { - checkNotInMainThread() + fun invalidateList( + newObject: T, + onUpdate: suspend (Set) -> Unit, + ) { + checkNotInMainThread() - queue.put(newObject) - if (onlyOneInBlock.getAndSet(true)) { - return - } - - scope.launch(dispatcher) { - try { - val mySet = mutableSetOf() - queue.drainTo(mySet) - if (mySet.isNotEmpty()) { - onUpdate(mySet) - } - - delay(delay) - - val mySet2 = mutableSetOf() - queue.drainTo(mySet2) - if (mySet2.isNotEmpty()) { - onUpdate(mySet2) - } - } finally { - withContext(NonCancellable) { - onlyOneInBlock.set(false) - } - } - } + queue.put(newObject) + if (onlyOneInBlock.getAndSet(true)) { + return } - fun cancel() { - scope.cancel() + scope.launch(dispatcher) { + try { + val mySet = mutableSetOf() + queue.drainTo(mySet) + if (mySet.isNotEmpty()) { + onUpdate(mySet) + } + + delay(delay) + + val mySet2 = mutableSetOf() + queue.drainTo(mySet2) + if (mySet2.isNotEmpty()) { + onUpdate(mySet2) + } + } finally { + withContext(NonCancellable) { onlyOneInBlock.set(false) } + } } + } + + fun cancel() { + scope.cancel() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt index c728313ea..a81f9d368 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import android.content.Context @@ -67,301 +87,295 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @Composable -fun CashuPreview(cashutoken: String, accountViewModel: AccountViewModel) { - var cachuData by remember { - mutableStateOf>(GenericLoadable.Loading()) - } +fun CashuPreview( + cashutoken: String, + accountViewModel: AccountViewModel, +) { + var cachuData by remember { + mutableStateOf>(GenericLoadable.Loading()) + } - LaunchedEffect(key1 = cashutoken) { - launch(Dispatchers.IO) { - val newCachuData = CashuProcessor().parse(cashutoken) - launch(Dispatchers.Main) { - cachuData = newCachuData - } - } + LaunchedEffect(key1 = cashutoken) { + launch(Dispatchers.IO) { + val newCachuData = CashuProcessor().parse(cashutoken) + launch(Dispatchers.Main) { cachuData = newCachuData } } + } - Crossfade(targetState = cachuData, label = "CashuPreview(") { - when (it) { - is GenericLoadable.Loaded -> CashuPreview(it.loaded, accountViewModel) - is GenericLoadable.Error -> Text( - text = "$cashutoken ", - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) - ) - else -> {} - } + Crossfade(targetState = cachuData, label = "CashuPreview(") { + when (it) { + is GenericLoadable.Loaded -> CashuPreview(it.loaded, accountViewModel) + is GenericLoadable.Error -> + Text( + text = "$cashutoken ", + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + ) + else -> {} } + } } @Composable -fun CashuPreview(token: CashuToken, accountViewModel: AccountViewModel) { - CashuPreviewNew(token, accountViewModel::meltCashu, accountViewModel::toast) +fun CashuPreview( + token: CashuToken, + accountViewModel: AccountViewModel, +) { + CashuPreviewNew(token, accountViewModel::meltCashu, accountViewModel::toast) } @Composable @Preview() fun CashuPreviewPreview() { - val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel() + val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel() - sharedPreferencesViewModel.init() - sharedPreferencesViewModel.updateTheme(ThemeType.DARK) + sharedPreferencesViewModel.init() + sharedPreferencesViewModel.updateTheme(ThemeType.DARK) - AmethystTheme(sharedPrefsViewModel = sharedPreferencesViewModel) { - Column() { - CashuPreview( - token = CashuToken("token", "mint", 32400, TextNode("")), - melt = { token, context, onDone -> - }, - toast = { title, message -> - } - ) + AmethystTheme(sharedPrefsViewModel = sharedPreferencesViewModel) { + Column { + CashuPreview( + token = CashuToken("token", "mint", 32400, TextNode("")), + melt = { token, context, onDone -> }, + toast = { title, message -> }, + ) - CashuPreviewNew( - token = CashuToken("token", "mint", 32400, TextNode("")), - melt = { token, context, onDone -> - }, - toast = { title, message -> - } - ) - } + CashuPreviewNew( + token = CashuToken("token", "mint", 32400, TextNode("")), + melt = { token, context, onDone -> }, + toast = { title, message -> }, + ) } + } } @Composable fun CashuPreview( - token: CashuToken, - melt: (CashuToken, Context, (String, String) -> Unit) -> Unit, - toast: (String, String) -> Unit + token: CashuToken, + melt: (CashuToken, Context, (String, String) -> Unit) -> Unit, + toast: (String, String) -> Unit, ) { - val context = LocalContext.current - val clipboardManager = LocalClipboardManager.current + val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current + Column( + modifier = + Modifier.fillMaxWidth() + .padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 10.dp) + .clip(shape = QuoteBorder) + .border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder), + ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 10.dp) - .clip(shape = QuoteBorder) - .border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder) + modifier = Modifier.fillMaxWidth().padding(20.dp), ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + ) { + Icon( + painter = painterResource(R.drawable.cashu), + null, + modifier = Size20Modifier, + tint = Color.Unspecified, + ) + + Text( + text = stringResource(R.string.cashu), + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.padding(start = 10.dp), + ) + } + + Divider() + + Text( + text = "${token.totalAmount} ${stringResource(id = R.string.sats)}", + fontSize = 25.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), + ) + + Row( + modifier = Modifier.padding(top = 5.dp).fillMaxWidth(), + ) { + var isRedeeming by remember { mutableStateOf(false) } + + Button( + onClick = { + isRedeeming = true + melt(token, context) { title, message -> + toast(title, message) + isRedeeming = false + } + }, + shape = QuoteBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 10.dp) - ) { - Icon( - painter = painterResource(R.drawable.cashu), - null, - modifier = Size20Modifier, - tint = Color.Unspecified - ) + if (isRedeeming) { + LoadingAnimation() + } else { + ZapIcon(Size20Modifier, tint = Color.White) + } + Spacer(DoubleHorzSpacer) - Text( - text = stringResource(R.string.cashu), - fontSize = 20.sp, - fontWeight = FontWeight.W500, - modifier = Modifier.padding(start = 10.dp) - ) - } - - Divider() - - Text( - text = "${token.totalAmount} ${stringResource(id = R.string.sats)}", - fontSize = 25.sp, - fontWeight = FontWeight.W500, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 10.dp) - ) - - Row( - modifier = Modifier - .padding(top = 5.dp) - .fillMaxWidth() - ) { - var isRedeeming by remember { - mutableStateOf(false) - } - - Button( - onClick = { - isRedeeming = true - melt(token, context) { title, message -> - toast(title, message) - isRedeeming = false - } - }, - shape = QuoteBorder, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ) - ) { - if (isRedeeming) { - LoadingAnimation() - } else { - ZapIcon(Size20Modifier, tint = Color.White) - } - Spacer(DoubleHorzSpacer) - - Text( - stringResource(id = R.string.cashu_redeem_to_zap), - color = Color.White, - fontSize = 16.sp - ) - } - } - - Spacer(modifier = StdHorzSpacer) - Button( - onClick = { - try { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("cashu://${token.token}")) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - - startActivity(context, intent, null) - } catch (e: Exception) { - toast("Cashu", context.getString(R.string.cashu_no_wallet_found)) - } - }, - shape = QuoteBorder, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ) - ) { - CashuIcon(Size20Modifier) - Spacer(DoubleHorzSpacer) - Text(stringResource(id = R.string.cashu_redeem_to_cashu), color = Color.White, fontSize = 16.sp) - } - Spacer(modifier = StdHorzSpacer) - Button( - onClick = { - // Copying the token to clipboard - clipboardManager.setText(AnnotatedString(token.token)) - }, - shape = QuoteBorder, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ) - ) { - CopyIcon(Size20Modifier, Color.White) - Spacer(DoubleHorzSpacer) - Text(stringResource(id = R.string.cashu_copy_token), color = Color.White, fontSize = 16.sp) - } - Spacer(modifier = StdHorzSpacer) + Text( + stringResource(id = R.string.cashu_redeem_to_zap), + color = Color.White, + fontSize = 16.sp, + ) } + } + + Spacer(modifier = StdHorzSpacer) + Button( + onClick = { + try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("cashu://${token.token}")) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + + startActivity(context, intent, null) + } catch (e: Exception) { + toast("Cashu", context.getString(R.string.cashu_no_wallet_found)) + } + }, + shape = QuoteBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + CashuIcon(Size20Modifier) + Spacer(DoubleHorzSpacer) + Text( + stringResource(id = R.string.cashu_redeem_to_cashu), + color = Color.White, + fontSize = 16.sp, + ) + } + Spacer(modifier = StdHorzSpacer) + Button( + onClick = { + // Copying the token to clipboard + clipboardManager.setText(AnnotatedString(token.token)) + }, + shape = QuoteBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + CopyIcon(Size20Modifier, Color.White) + Spacer(DoubleHorzSpacer) + Text(stringResource(id = R.string.cashu_copy_token), color = Color.White, fontSize = 16.sp) + } + Spacer(modifier = StdHorzSpacer) } + } } @Composable fun CashuPreviewNew( - token: CashuToken, - melt: (CashuToken, Context, (String, String) -> Unit) -> Unit, - toast: (String, String) -> Unit + token: CashuToken, + melt: (CashuToken, Context, (String, String) -> Unit) -> Unit, + toast: (String, String) -> Unit, ) { - val context = LocalContext.current - val clipboardManager = LocalClipboardManager.current + val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current - Card( - modifier = Modifier - .fillMaxWidth() - .padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp) - .clip(shape = QuoteBorder) + Card( + modifier = + Modifier.fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp) + .clip(shape = QuoteBorder), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth().padding(10.dp), ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxWidth() - .padding(10.dp) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(R.drawable.cashu), + null, + modifier = Modifier.size(13.dp), + tint = Color.Unspecified, + ) + + Text( + text = stringResource(R.string.cashu), + fontSize = 12.sp, + modifier = Modifier.padding(start = 5.dp, bottom = 1.dp), + ) + } + + Text( + text = "${token.totalAmount} ${stringResource(id = R.string.sats)}", + fontSize = 20.sp, + ) + + Row(modifier = Modifier.padding(top = 5.dp)) { + var isRedeeming by remember { mutableStateOf(false) } + + FilledTonalButton( + onClick = { + isRedeeming = true + melt(token, context) { title, message -> + toast(title, message) + isRedeeming = false + } + }, + shape = SmallishBorder, ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - painter = painterResource(R.drawable.cashu), - null, - modifier = Modifier.size(13.dp), - tint = Color.Unspecified - ) + if (isRedeeming) { + LoadingAnimation() + } else { + ZapIcon(Size20Modifier, tint = MaterialTheme.colorScheme.onBackground) + } + Spacer(StdHorzSpacer) - Text( - text = stringResource(R.string.cashu), - fontSize = 12.sp, - modifier = Modifier.padding(start = 5.dp, bottom = 1.dp) - ) - } - - Text( - text = "${token.totalAmount} ${stringResource(id = R.string.sats)}", - fontSize = 20.sp - ) - - Row(modifier = Modifier.padding(top = 5.dp)) { - var isRedeeming by remember { - mutableStateOf(false) - } - - FilledTonalButton( - onClick = { - isRedeeming = true - melt(token, context) { title, message -> - toast(title, message) - isRedeeming = false - } - }, - shape = SmallishBorder - ) { - if (isRedeeming) { - LoadingAnimation() - } else { - ZapIcon(Size20Modifier, tint = MaterialTheme.colorScheme.onBackground) - } - Spacer(StdHorzSpacer) - - Text( - "Redeem", - color = MaterialTheme.colorScheme.onBackground, - fontSize = 16.sp - ) - } - - Spacer(modifier = StdHorzSpacer) - - FilledTonalButton( - onClick = { - try { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("cashu://${token.token}")) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - - startActivity(context, intent, null) - } catch (e: Exception) { - toast("Cashu", context.getString(R.string.cashu_no_wallet_found)) - } - }, - shape = SmallishBorder, - contentPadding = PaddingValues(0.dp) - ) { - OpenInNewIcon(Size18Modifier, tint = MaterialTheme.colorScheme.onBackground) - } - - Spacer(modifier = StdHorzSpacer) - - FilledTonalButton( - onClick = { - // Copying the token to clipboard - clipboardManager.setText(AnnotatedString(token.token)) - }, - shape = SmallishBorder, - contentPadding = PaddingValues(0.dp) - ) { - CopyIcon(Size18Modifier, tint = MaterialTheme.colorScheme.onBackground) - } - } + Text( + "Redeem", + color = MaterialTheme.colorScheme.onBackground, + fontSize = 16.sp, + ) } + + Spacer(modifier = StdHorzSpacer) + + FilledTonalButton( + onClick = { + try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("cashu://${token.token}")) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + + startActivity(context, intent, null) + } catch (e: Exception) { + toast("Cashu", context.getString(R.string.cashu_no_wallet_found)) + } + }, + shape = SmallishBorder, + contentPadding = PaddingValues(0.dp), + ) { + OpenInNewIcon(Size18Modifier, tint = MaterialTheme.colorScheme.onBackground) + } + + Spacer(modifier = StdHorzSpacer) + + FilledTonalButton( + onClick = { + // Copying the token to clipboard + clipboardManager.setText(AnnotatedString(token.token)) + }, + shape = SmallishBorder, + contentPadding = PaddingValues(0.dp), + ) { + CopyIcon(Size18Modifier, tint = MaterialTheme.colorScheme.onBackground) + } + } } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt index 6a9a17c99..bd44dfb1d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import android.content.ActivityNotFoundException @@ -13,28 +33,31 @@ import androidx.compose.ui.text.AnnotatedString @Composable fun ClickableEmail(email: String) { - val stripped = email.replaceFirst("mailto:", "") - val context = LocalContext.current + val stripped = email.replaceFirst("mailto:", "") + val context = LocalContext.current - ClickableText( - text = remember { AnnotatedString(stripped) }, - onClick = { runCatching { context.sendMail(stripped) } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary) - ) + ClickableText( + text = remember { AnnotatedString(stripped) }, + onClick = { runCatching { context.sendMail(stripped) } }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + ) } -fun Context.sendMail(to: String, subject: String? = null) { - try { - val intent = Intent(Intent.ACTION_SEND) - intent.type = "vnd.android.cursor.item/email" // or "message/rfc822" - intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(to)) - if (subject != null) { - intent.putExtra(Intent.EXTRA_SUBJECT, subject) - } - startActivity(intent) - } catch (e: ActivityNotFoundException) { - // TODO: Handle case where no email app is available - } catch (t: Throwable) { - // TODO: Handle potential other type of exceptions +fun Context.sendMail( + to: String, + subject: String? = null, +) { + try { + val intent = Intent(Intent.ACTION_SEND) + intent.type = "vnd.android.cursor.item/email" // or "message/rfc822" + intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(to)) + if (subject != null) { + intent.putExtra(Intent.EXTRA_SUBJECT, subject) } + startActivity(intent) + } catch (e: ActivityNotFoundException) { + // TODO: Handle case where no email app is available + } catch (t: Throwable) { + // TODO: Handle potential other type of exceptions + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableNoteTag.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableNoteTag.kt index 32a9e136c..75f4b2112 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableNoteTag.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableNoteTag.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import androidx.compose.foundation.text.ClickableText @@ -10,12 +30,12 @@ import com.vitorpamplona.amethyst.ui.note.toShortenHex @Composable fun ClickableNoteTag( - baseNote: Note, - nav: (String) -> Unit + baseNote: Note, + nav: (String) -> Unit, ) { - ClickableText( - text = AnnotatedString("@${baseNote.idNote().toShortenHex()}"), - onClick = { nav("Note/${baseNote.idHex}") }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary) - ) + ClickableText( + text = AnnotatedString("@${baseNote.idNote().toShortenHex()}"), + onClick = { nav("Note/${baseNote.idHex}") }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt index e4d298d40..52a2fc376 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import android.content.Context @@ -13,20 +33,20 @@ import androidx.compose.ui.text.AnnotatedString @Composable fun ClickablePhone(phone: String) { - val context = LocalContext.current + val context = LocalContext.current - ClickableText( - text = remember { AnnotatedString(phone) }, - onClick = { runCatching { context.dial(phone) } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary) - ) + ClickableText( + text = remember { AnnotatedString(phone) }, + onClick = { runCatching { context.dial(phone) } }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + ) } fun Context.dial(phone: String) { - try { - val intent = Intent(Intent.ACTION_DIAL, Uri.fromParts("tel", phone, null)) - startActivity(intent) - } catch (t: Throwable) { - // TODO: Handle potential exceptions - } + try { + val intent = Intent(Intent.ACTION_DIAL, Uri.fromParts("tel", phone, null)) + startActivity(intent) + } catch (t: Throwable) { + // TODO: Handle potential exceptions + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index ccb5d4219..d0fc842a9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import androidx.compose.foundation.gestures.detectTapGestures @@ -59,654 +79,649 @@ import kotlinx.coroutines.launch @Composable fun ClickableRoute( - nip19: Nip19.Return, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + nip19: Nip19.Return, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - when (nip19.type) { - Nip19.Type.USER -> { - DisplayUser(nip19, accountViewModel, nav) - } - Nip19.Type.ADDRESS -> { - DisplayAddress(nip19, accountViewModel, nav) - } - Nip19.Type.NOTE -> { - DisplayNote(nip19, accountViewModel, nav) - } - Nip19.Type.EVENT -> { - DisplayEvent(nip19, accountViewModel, nav) - } - else -> { - Text( - remember { - "@${nip19.hex}${nip19.additionalChars}" - } - ) - } + when (nip19.type) { + Nip19.Type.USER -> { + DisplayUser(nip19, accountViewModel, nav) } + Nip19.Type.ADDRESS -> { + DisplayAddress(nip19, accountViewModel, nav) + } + Nip19.Type.NOTE -> { + DisplayNote(nip19, accountViewModel, nav) + } + Nip19.Type.EVENT -> { + DisplayEvent(nip19, accountViewModel, nav) + } + else -> { + Text( + remember { "@${nip19.hex}${nip19.additionalChars}" }, + ) + } + } } @Composable private fun DisplayEvent( - nip19: Nip19.Return, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + nip19: Nip19.Return, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - LoadNote(nip19.hex, accountViewModel) { - if (it != null) { - DisplayNoteLink(it, nip19, accountViewModel, nav) - } else { - CreateClickableText( - clickablePart = remember(nip19) { "@${nip19.hex.toShortenHex()}" }, - suffix = nip19.additionalChars, - route = remember(nip19) { "Event/${nip19.hex}" }, - nav = nav - ) - } + LoadNote(nip19.hex, accountViewModel) { + if (it != null) { + DisplayNoteLink(it, nip19, accountViewModel, nav) + } else { + CreateClickableText( + clickablePart = remember(nip19) { "@${nip19.hex.toShortenHex()}" }, + suffix = nip19.additionalChars, + route = remember(nip19) { "Event/${nip19.hex}" }, + nav = nav, + ) } + } } @Composable private fun DisplayNote( - nip19: Nip19.Return, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + nip19: Nip19.Return, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - LoadNote(nip19.hex, accountViewModel = accountViewModel) { - if (it != null) { - DisplayNoteLink(it, nip19, accountViewModel, nav) - } else { - CreateClickableText( - clickablePart = remember(nip19) { "@${nip19.hex.toShortenHex()}" }, - suffix = nip19.additionalChars, - route = remember(nip19) { "Event/${nip19.hex}" }, - nav = nav - ) - } + LoadNote(nip19.hex, accountViewModel = accountViewModel) { + if (it != null) { + DisplayNoteLink(it, nip19, accountViewModel, nav) + } else { + CreateClickableText( + clickablePart = remember(nip19) { "@${nip19.hex.toShortenHex()}" }, + suffix = nip19.additionalChars, + route = remember(nip19) { "Event/${nip19.hex}" }, + nav = nav, + ) } + } } @Composable private fun DisplayNoteLink( - it: Note, - nip19: Nip19.Return, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + it: Note, + nip19: Nip19.Return, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteState by it.live().metadata.observeAsState() + val noteState by it.live().metadata.observeAsState() - val note = remember(noteState) { noteState?.note } ?: return + val note = remember(noteState) { noteState?.note } ?: return - val channelHex = remember(noteState) { note.channelHex() } - val noteIdDisplayNote = remember(noteState) { "@${note.idDisplayNote()}" } - val addedCharts = remember { "${nip19.additionalChars}" } + val channelHex = remember(noteState) { note.channelHex() } + val noteIdDisplayNote = remember(noteState) { "@${note.idDisplayNote()}" } + val addedCharts = remember { "${nip19.additionalChars}" } - if (note.event is ChannelCreateEvent || nip19.kind == ChannelCreateEvent.kind) { - CreateClickableText( - clickablePart = noteIdDisplayNote, - suffix = addedCharts, - route = remember(noteState) { "Channel/${nip19.hex}" }, - nav = nav - ) - } else if (note.event is PrivateDmEvent || nip19.kind == PrivateDmEvent.kind) { - CreateClickableText( - clickablePart = noteIdDisplayNote, - suffix = addedCharts, - route = remember(noteState) { - (note.author?.pubkeyHex ?: nip19.hex).let { - "RoomByAuthor/$it" - } - }, - nav = nav - ) - } else if (channelHex != null) { - LoadChannel(baseChannelHex = channelHex, accountViewModel) { baseChannel -> - val channelState by baseChannel.live.observeAsState() - val channelDisplayName by remember(channelState) { - derivedStateOf { - channelState?.channel?.toBestDisplayName() ?: noteIdDisplayNote - } - } - - CreateClickableText( - clickablePart = channelDisplayName, - suffix = addedCharts, - route = remember(noteState) { "Channel/${baseChannel.idHex}" }, - nav = nav - ) + if (note.event is ChannelCreateEvent || nip19.kind == ChannelCreateEvent.KIND) { + CreateClickableText( + clickablePart = noteIdDisplayNote, + suffix = addedCharts, + route = remember(noteState) { "Channel/${nip19.hex}" }, + nav = nav, + ) + } else if (note.event is PrivateDmEvent || nip19.kind == PrivateDmEvent.KIND) { + CreateClickableText( + clickablePart = noteIdDisplayNote, + suffix = addedCharts, + route = + remember(noteState) { (note.author?.pubkeyHex ?: nip19.hex).let { "RoomByAuthor/$it" } }, + nav = nav, + ) + } else if (channelHex != null) { + LoadChannel(baseChannelHex = channelHex, accountViewModel) { baseChannel -> + val channelState by baseChannel.live.observeAsState() + val channelDisplayName by + remember(channelState) { + derivedStateOf { channelState?.channel?.toBestDisplayName() ?: noteIdDisplayNote } } - } else { - CreateClickableText( - clickablePart = noteIdDisplayNote, - suffix = addedCharts, - route = remember(noteState) { "Event/${nip19.hex}" }, - nav = nav - ) + + CreateClickableText( + clickablePart = channelDisplayName, + suffix = addedCharts, + route = remember(noteState) { "Channel/${baseChannel.idHex}" }, + nav = nav, + ) } + } else { + CreateClickableText( + clickablePart = noteIdDisplayNote, + suffix = addedCharts, + route = remember(noteState) { "Event/${nip19.hex}" }, + nav = nav, + ) + } } @Composable private fun DisplayAddress( - nip19: Nip19.Return, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + nip19: Nip19.Return, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var noteBase by remember(nip19) { mutableStateOf(accountViewModel.getNoteIfExists(nip19.hex)) } + var noteBase by remember(nip19) { mutableStateOf(accountViewModel.getNoteIfExists(nip19.hex)) } - if (noteBase == null) { - LaunchedEffect(key1 = nip19.hex) { - accountViewModel.checkGetOrCreateAddressableNote(nip19.hex) { - noteBase = it - } - } + if (noteBase == null) { + LaunchedEffect(key1 = nip19.hex) { + accountViewModel.checkGetOrCreateAddressableNote(nip19.hex) { noteBase = it } } + } - noteBase?.let { - val noteState by it.live().metadata.observeAsState() + noteBase?.let { + val noteState by it.live().metadata.observeAsState() - val route = remember(noteState) { "Note/${nip19.hex}" } - val displayName = remember(noteState) { "@${noteState?.note?.idDisplayNote()}" } - val addedCharts = remember { "${nip19.additionalChars}" } + val route = remember(noteState) { "Note/${nip19.hex}" } + val displayName = remember(noteState) { "@${noteState?.note?.idDisplayNote()}" } + val addedCharts = remember { "${nip19.additionalChars}" } - CreateClickableText( - clickablePart = displayName, - suffix = addedCharts, - route = route, - nav = nav - ) - } + CreateClickableText( + clickablePart = displayName, + suffix = addedCharts, + route = route, + nav = nav, + ) + } - if (noteBase == null) { - Text( - remember { - "@${nip19.hex}${nip19.additionalChars}" - } - ) - } + if (noteBase == null) { + Text( + remember { "@${nip19.hex}${nip19.additionalChars}" }, + ) + } } @Composable private fun DisplayUser( - nip19: Nip19.Return, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + nip19: Nip19.Return, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var userBase by remember(nip19) { - mutableStateOf( - accountViewModel.getUserIfExists(nip19.hex) - ) + var userBase by + remember(nip19) { + mutableStateOf( + accountViewModel.getUserIfExists(nip19.hex), + ) } - if (userBase == null) { - LaunchedEffect(key1 = nip19.hex) { - accountViewModel.checkGetOrCreateUser(nip19.hex) { - userBase = it - } - } + if (userBase == null) { + LaunchedEffect(key1 = nip19.hex) { + accountViewModel.checkGetOrCreateUser(nip19.hex) { userBase = it } } + } - userBase?.let { - RenderUserAsClickableText(it, nip19, nav) - } + userBase?.let { RenderUserAsClickableText(it, nip19, nav) } - if (userBase == null) { - Text( - remember { - "@${nip19.hex}${nip19.additionalChars}" - } - ) - } + if (userBase == null) { + Text( + remember { "@${nip19.hex}${nip19.additionalChars}" }, + ) + } } @Composable private fun RenderUserAsClickableText( - baseUser: User, - nip19: Nip19.Return, - nav: (String) -> Unit + baseUser: User, + nip19: Nip19.Return, + nav: (String) -> Unit, ) { - val userState by baseUser.live().metadata.observeAsState() - val route = remember { "User/${baseUser.pubkeyHex}" } + val userState by baseUser.live().metadata.observeAsState() + val route = remember { "User/${baseUser.pubkeyHex}" } - val userDisplayName by remember(userState) { - derivedStateOf { - userState?.user?.toBestDisplayName() - } + val userDisplayName by + remember(userState) { derivedStateOf { userState?.user?.toBestDisplayName() } } + + val userTags by + remember(userState) { + derivedStateOf { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() } } - val userTags by remember(userState) { - derivedStateOf { - userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() - } - } + val addedCharts = remember(nip19) { "${nip19.additionalChars}" } - val addedCharts = remember(nip19) { - "${nip19.additionalChars}" - } - - userDisplayName?.let { - CreateClickableTextWithEmoji( - clickablePart = it, - suffix = addedCharts, - maxLines = 1, - route = route, - nav = nav, - tags = userTags - ) - } + userDisplayName?.let { + CreateClickableTextWithEmoji( + clickablePart = it, + suffix = addedCharts, + maxLines = 1, + route = route, + nav = nav, + tags = userTags, + ) + } } @Composable fun CreateClickableText( - clickablePart: String, - suffix: String?, - maxLines: Int = Int.MAX_VALUE, - overrideColor: Color? = null, - fontWeight: FontWeight = FontWeight.Normal, - route: String, - nav: (String) -> Unit + clickablePart: String, + suffix: String?, + maxLines: Int = Int.MAX_VALUE, + overrideColor: Color? = null, + fontWeight: FontWeight = FontWeight.Normal, + route: String, + nav: (String) -> Unit, ) { - val currentStyle = LocalTextStyle.current - val primaryColor = MaterialTheme.colorScheme.primary - val onBackgroundColor = MaterialTheme.colorScheme.onBackground + val currentStyle = LocalTextStyle.current + val primaryColor = MaterialTheme.colorScheme.primary + val onBackgroundColor = MaterialTheme.colorScheme.onBackground - val clickablePartStyle = remember(primaryColor, overrideColor) { - currentStyle.copy(color = overrideColor ?: primaryColor, fontWeight = fontWeight).toSpanStyle() + val clickablePartStyle = + remember(primaryColor, overrideColor) { + currentStyle + .copy(color = overrideColor ?: primaryColor, fontWeight = fontWeight) + .toSpanStyle() } - val nonClickablePartStyle = remember(onBackgroundColor, overrideColor) { - currentStyle.copy(color = overrideColor ?: onBackgroundColor, fontWeight = fontWeight).toSpanStyle() + val nonClickablePartStyle = + remember(onBackgroundColor, overrideColor) { + currentStyle + .copy(color = overrideColor ?: onBackgroundColor, fontWeight = fontWeight) + .toSpanStyle() } - val text = remember(clickablePartStyle, nonClickablePartStyle, clickablePart, suffix) { - buildAnnotatedString { - withStyle(clickablePartStyle) { - append(clickablePart) - } - if (!suffix.isNullOrBlank()) { - withStyle(nonClickablePartStyle) { - append(suffix) - } - } + val text = + remember(clickablePartStyle, nonClickablePartStyle, clickablePart, suffix) { + buildAnnotatedString { + withStyle(clickablePartStyle) { append(clickablePart) } + if (!suffix.isNullOrBlank()) { + withStyle(nonClickablePartStyle) { append(suffix) } } + } } - ClickableText( - text = text, - maxLines = maxLines, - onClick = { nav(route) } + ClickableText( + text = text, + maxLines = maxLines, + onClick = { nav(route) }, + ) +} + +@Composable +fun CreateTextWithEmoji( + text: String, + tags: ImmutableListOfLists?, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + fontWeight: FontWeight? = null, + fontSize: TextUnit = TextUnit.Unspecified, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = TextOverflow.Clip, + modifier: Modifier = Modifier, +) { + var emojiList by remember(text) { mutableStateOf>(persistentListOf()) } + + LaunchedEffect(key1 = text) { + launch(Dispatchers.Default) { + val emojis = + tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } + ?: emptyMap() + + if (emojis.isNotEmpty()) { + val newEmojiList = assembleAnnotatedList(text, emojis) + if (newEmojiList.isNotEmpty()) { + emojiList = newEmojiList.toImmutableList() + } + } + } + } + + val textColor = + color.takeOrElse { LocalTextStyle.current.color.takeOrElse { LocalContentColor.current } } + + if (emojiList.isEmpty()) { + Text( + text = text, + color = textColor, + textAlign = textAlign, + fontWeight = fontWeight, + fontSize = fontSize, + maxLines = maxLines, + overflow = overflow, + modifier = modifier, ) + } else { + val style = + LocalTextStyle.current + .merge( + TextStyle( + color = textColor, + textAlign = textAlign, + fontWeight = fontWeight, + fontSize = fontSize, + ), + ) + .toSpanStyle() + + InLineIconRenderer(emojiList, style, fontSize, maxLines, overflow, modifier) + } } @Composable fun CreateTextWithEmoji( - text: String, - tags: ImmutableListOfLists?, - color: Color = Color.Unspecified, - textAlign: TextAlign? = null, - fontWeight: FontWeight? = null, - fontSize: TextUnit = TextUnit.Unspecified, - maxLines: Int = Int.MAX_VALUE, - overflow: TextOverflow = TextOverflow.Clip, - modifier: Modifier = Modifier + text: String, + emojis: ImmutableMap, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + fontWeight: FontWeight? = null, + fontSize: TextUnit = TextUnit.Unspecified, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = TextOverflow.Clip, + modifier: Modifier = Modifier, ) { - var emojiList by remember(text) { mutableStateOf>(persistentListOf()) } + var emojiList by remember(text) { mutableStateOf>(persistentListOf()) } + if (emojis.isNotEmpty()) { LaunchedEffect(key1 = text) { - launch(Dispatchers.Default) { - val emojis = - tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap() - - if (emojis.isNotEmpty()) { - val newEmojiList = assembleAnnotatedList(text, emojis) - if (newEmojiList.isNotEmpty()) { - emojiList = newEmojiList.toImmutableList() - } - } + launch(Dispatchers.Default) { + val newEmojiList = assembleAnnotatedList(text, emojis) + if (newEmojiList.isNotEmpty()) { + emojiList = newEmojiList.toImmutableList() } + } } + } - val textColor = color.takeOrElse { - LocalTextStyle.current.color.takeOrElse { - LocalContentColor.current - } - } + val textColor = + color.takeOrElse { LocalTextStyle.current.color.takeOrElse { LocalContentColor.current } } - if (emojiList.isEmpty()) { - Text( - text = text, - color = textColor, - textAlign = textAlign, - fontWeight = fontWeight, - fontSize = fontSize, - maxLines = maxLines, - overflow = overflow, - modifier = modifier - ) - } else { - val style = LocalTextStyle.current.merge( + if (emojiList.isEmpty()) { + Text( + text = text, + color = textColor, + textAlign = textAlign, + fontWeight = fontWeight, + fontSize = fontSize, + maxLines = maxLines, + overflow = overflow, + modifier = modifier, + ) + } else { + val currentStyle = LocalTextStyle.current + val style = + remember(currentStyle) { + currentStyle + .merge( TextStyle( - color = textColor, - textAlign = textAlign, - fontWeight = fontWeight, - fontSize = fontSize - ) - ).toSpanStyle() + color = textColor, + textAlign = textAlign, + fontWeight = fontWeight, + fontSize = fontSize, + ), + ) + .toSpanStyle() + } - InLineIconRenderer(emojiList, style, fontSize, maxLines, overflow, modifier) - } -} - -@Composable -fun CreateTextWithEmoji( - text: String, - emojis: ImmutableMap, - color: Color = Color.Unspecified, - textAlign: TextAlign? = null, - fontWeight: FontWeight? = null, - fontSize: TextUnit = TextUnit.Unspecified, - maxLines: Int = Int.MAX_VALUE, - overflow: TextOverflow = TextOverflow.Clip, - modifier: Modifier = Modifier -) { - var emojiList by remember(text) { mutableStateOf>(persistentListOf()) } - - if (emojis.isNotEmpty()) { - LaunchedEffect(key1 = text) { - launch(Dispatchers.Default) { - val newEmojiList = assembleAnnotatedList(text, emojis) - if (newEmojiList.isNotEmpty()) { - emojiList = newEmojiList.toImmutableList() - } - } - } - } - - val textColor = color.takeOrElse { - LocalTextStyle.current.color.takeOrElse { - LocalContentColor.current - } - } - - if (emojiList.isEmpty()) { - Text( - text = text, - color = textColor, - textAlign = textAlign, - fontWeight = fontWeight, - fontSize = fontSize, - maxLines = maxLines, - overflow = overflow, - modifier = modifier - ) - } else { - val currentStyle = LocalTextStyle.current - val style = remember(currentStyle) { - currentStyle.merge( - TextStyle( - color = textColor, - textAlign = textAlign, - fontWeight = fontWeight, - fontSize = fontSize - ) - ).toSpanStyle() - } - - InLineIconRenderer(emojiList, style, fontSize, maxLines, overflow, modifier) - } + InLineIconRenderer(emojiList, style, fontSize, maxLines, overflow, modifier) + } } @Composable fun CreateClickableTextWithEmoji( - clickablePart: String, - maxLines: Int = Int.MAX_VALUE, - tags: ImmutableListOfLists?, - style: TextStyle, - onClick: (Int) -> Unit + clickablePart: String, + maxLines: Int = Int.MAX_VALUE, + tags: ImmutableListOfLists?, + style: TextStyle, + onClick: (Int) -> Unit, ) { - var emojiList by remember(clickablePart) { mutableStateOf>(persistentListOf()) } + var emojiList by + remember(clickablePart) { mutableStateOf>(persistentListOf()) } - LaunchedEffect(key1 = clickablePart) { - launch(Dispatchers.Default) { - val emojis = - tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap() + LaunchedEffect(key1 = clickablePart) { + launch(Dispatchers.Default) { + val emojis = + tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } + ?: emptyMap() - if (emojis.isNotEmpty()) { - val newEmojiList = assembleAnnotatedList(clickablePart, emojis) - if (newEmojiList.isNotEmpty()) { - emojiList = newEmojiList.toImmutableList() - } - } + if (emojis.isNotEmpty()) { + val newEmojiList = assembleAnnotatedList(clickablePart, emojis) + if (newEmojiList.isNotEmpty()) { + emojiList = newEmojiList.toImmutableList() } + } } + } - if (emojiList.isEmpty()) { - ClickableText( - text = AnnotatedString(clickablePart), - style = style, - maxLines = maxLines, - onClick = onClick - ) - } else { - ClickableInLineIconRenderer(emojiList, maxLines, style.toSpanStyle()) { - onClick(it) - } - } + if (emojiList.isEmpty()) { + ClickableText( + text = AnnotatedString(clickablePart), + style = style, + maxLines = maxLines, + onClick = onClick, + ) + } else { + ClickableInLineIconRenderer(emojiList, maxLines, style.toSpanStyle()) { onClick(it) } + } } @Immutable data class DoubleEmojiList( - val part1: ImmutableList, - val part2: ImmutableList + val part1: ImmutableList, + val part2: ImmutableList, ) @Composable fun CreateClickableTextWithEmoji( - clickablePart: String, - suffix: String?, - maxLines: Int = Int.MAX_VALUE, - overrideColor: Color? = null, - fontWeight: FontWeight = FontWeight.Normal, - route: String, - nav: (String) -> Unit, - tags: ImmutableListOfLists? + clickablePart: String, + suffix: String?, + maxLines: Int = Int.MAX_VALUE, + overrideColor: Color? = null, + fontWeight: FontWeight = FontWeight.Normal, + route: String, + nav: (String) -> Unit, + tags: ImmutableListOfLists?, ) { - var emojiLists by remember(clickablePart) { - mutableStateOf(null) + var emojiLists by remember(clickablePart) { mutableStateOf(null) } + + LaunchedEffect(key1 = clickablePart) { + launch(Dispatchers.Default) { + val emojis = + tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } + ?: emptyMap() + + if (emojis.isNotEmpty()) { + val newEmojiList1 = assembleAnnotatedList(clickablePart, emojis) + val newEmojiList2 = + suffix?.let { assembleAnnotatedList(it, emojis) } ?: emptyList() + + if (newEmojiList1.isNotEmpty() || newEmojiList2.isNotEmpty()) { + emojiLists = + DoubleEmojiList(newEmojiList1.toImmutableList(), newEmojiList2.toImmutableList()) + } + } + } + } + + if (emojiLists == null) { + CreateClickableText(clickablePart, suffix, maxLines, overrideColor, fontWeight, route, nav) + } else { + ClickableInLineIconRenderer( + emojiLists!!.part1, + maxLines, + LocalTextStyle.current + .copy(color = overrideColor ?: MaterialTheme.colorScheme.primary, fontWeight = fontWeight) + .toSpanStyle(), + ) { + nav(route) } - LaunchedEffect(key1 = clickablePart) { - launch(Dispatchers.Default) { - val emojis = - tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap() - - if (emojis.isNotEmpty()) { - val newEmojiList1 = assembleAnnotatedList(clickablePart, emojis) - val newEmojiList2 = suffix?.let { assembleAnnotatedList(it, emojis) } ?: emptyList() - - if (newEmojiList1.isNotEmpty() || newEmojiList2.isNotEmpty()) { - emojiLists = DoubleEmojiList(newEmojiList1.toImmutableList(), newEmojiList2.toImmutableList()) - } - } - } - } - - if (emojiLists == null) { - CreateClickableText(clickablePart, suffix, maxLines, overrideColor, fontWeight, route, nav) - } else { - ClickableInLineIconRenderer( - emojiLists!!.part1, - maxLines, - LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colorScheme.primary, fontWeight = fontWeight).toSpanStyle() - ) { - nav(route) - } - - InLineIconRenderer( - emojiLists!!.part2, - LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colorScheme.onBackground, fontWeight = fontWeight).toSpanStyle(), - maxLines = maxLines + InLineIconRenderer( + emojiLists!!.part2, + LocalTextStyle.current + .copy( + color = overrideColor ?: MaterialTheme.colorScheme.onBackground, + fontWeight = fontWeight, ) + .toSpanStyle(), + maxLines = maxLines, + ) + } +} + +suspend fun assembleAnnotatedList( + text: String, + emojis: Map, +): ImmutableList { + return Nip30CustomEmoji() + .buildArray(text) + .map { + val url = emojis[it] + if (url != null) { + ImageUrlType(url) + } else { + TextType(it) + } } + .toImmutableList() } -suspend fun assembleAnnotatedList(text: String, emojis: Map): ImmutableList { - return Nip30CustomEmoji().buildArray(text).map { - val url = emojis[it] - if (url != null) { - ImageUrlType(url) - } else { - TextType(it) - } - }.toImmutableList() -} +@Immutable open class Renderable() -@Immutable -open class Renderable() +@Immutable class TextType(val text: String) : Renderable() -@Immutable -class TextType(val text: String) : Renderable() - -@Immutable -class ImageUrlType(val url: String) : Renderable() +@Immutable class ImageUrlType(val url: String) : Renderable() @Composable fun ClickableInLineIconRenderer( - wordsInOrder: ImmutableList, - maxLines: Int = Int.MAX_VALUE, - style: SpanStyle, - onClick: (Int) -> Unit + wordsInOrder: ImmutableList, + maxLines: Int = Int.MAX_VALUE, + style: SpanStyle, + onClick: (Int) -> Unit, ) { - val placeholderSize = remember(style) { - if (style.fontSize == TextUnit.Unspecified) { - 22.sp - } else { - style.fontSize.times(1.1f) - } + val placeholderSize = + remember(style) { + if (style.fontSize == TextUnit.Unspecified) { + 22.sp + } else { + style.fontSize.times(1.1f) + } } - val inlineContent = wordsInOrder.mapIndexedNotNull { idx, value -> + val inlineContent = + wordsInOrder + .mapIndexedNotNull { idx, value -> if (value is ImageUrlType) { - Pair( - "inlineContent$idx", - InlineTextContent( - Placeholder( - width = placeholderSize, - height = placeholderSize, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center - ) - ) { - AsyncImage( - model = value.url, - contentDescription = null, - modifier = Modifier - .fillMaxSize() - .padding(1.dp) - ) - } - ) - } else { - null - } - }.associate { it.first to it.second } - - val annotatedText = buildAnnotatedString { - wordsInOrder.forEachIndexed { idx, value -> - withStyle( - style + Pair( + "inlineContent$idx", + InlineTextContent( + Placeholder( + width = placeholderSize, + height = placeholderSize, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ), ) { - if (value is TextType) { - append(value.text) - } else if (value is ImageUrlType) { - appendInlineContent("inlineContent$idx", "[icon]") - } - } + AsyncImage( + model = value.url, + contentDescription = null, + modifier = Modifier.fillMaxSize().padding(1.dp), + ) + }, + ) + } else { + null } + } + .associate { it.first to it.second } + + val annotatedText = buildAnnotatedString { + wordsInOrder.forEachIndexed { idx, value -> + withStyle( + style, + ) { + if (value is TextType) { + append(value.text) + } else if (value is ImageUrlType) { + appendInlineContent("inlineContent$idx", "[icon]") + } + } + } + } + + val layoutResult = remember { mutableStateOf(null) } + val pressIndicator = + Modifier.pointerInput(onClick) { + detectTapGestures { pos -> + layoutResult.value?.let { layoutResult -> onClick(layoutResult.getOffsetForPosition(pos)) } + } } - val layoutResult = remember { mutableStateOf(null) } - val pressIndicator = Modifier.pointerInput(onClick) { - detectTapGestures { pos -> - layoutResult.value?.let { layoutResult -> - onClick(layoutResult.getOffsetForPosition(pos)) - } - } - } - - BasicText( - text = annotatedText, - modifier = pressIndicator, - inlineContent = inlineContent, - maxLines = maxLines, - onTextLayout = { - layoutResult.value = it - } - ) + BasicText( + text = annotatedText, + modifier = pressIndicator, + inlineContent = inlineContent, + maxLines = maxLines, + onTextLayout = { layoutResult.value = it }, + ) } @Composable fun InLineIconRenderer( - wordsInOrder: ImmutableList, - style: SpanStyle, - fontSize: TextUnit = TextUnit.Unspecified, - maxLines: Int = Int.MAX_VALUE, - overflow: TextOverflow = TextOverflow.Clip, - modifier: Modifier = Modifier + wordsInOrder: ImmutableList, + style: SpanStyle, + fontSize: TextUnit = TextUnit.Unspecified, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = TextOverflow.Clip, + modifier: Modifier = Modifier, ) { - val placeholderSize = remember(fontSize) { - if (fontSize == TextUnit.Unspecified) { - 22.sp - } else { - fontSize.times(1.1f) - } + val placeholderSize = + remember(fontSize) { + if (fontSize == TextUnit.Unspecified) { + 22.sp + } else { + fontSize.times(1.1f) + } } - val inlineContent = wordsInOrder.mapIndexedNotNull { idx, value -> + val inlineContent = + wordsInOrder + .mapIndexedNotNull { idx, value -> if (value is ImageUrlType) { - Pair( - "inlineContent$idx", - InlineTextContent( - Placeholder( - width = placeholderSize, - height = placeholderSize, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center - ) - ) { - AsyncImage( - model = value.url, - contentDescription = null, - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 0.dp) - ) - } - ) + Pair( + "inlineContent$idx", + InlineTextContent( + Placeholder( + width = placeholderSize, + height = placeholderSize, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ), + ) { + AsyncImage( + model = value.url, + contentDescription = null, + modifier = Modifier.fillMaxSize().padding(horizontal = 0.dp), + ) + }, + ) } else { - null + null } - }.associate { it.first to it.second } + } + .associate { it.first to it.second } - val annotatedText = remember { - buildAnnotatedString { - wordsInOrder.forEachIndexed { idx, value -> - withStyle( - style - ) { - if (value is TextType) { - append(value.text) - } else if (value is ImageUrlType) { - appendInlineContent("inlineContent$idx", "[icon]") - } - } - } + val annotatedText = remember { + buildAnnotatedString { + wordsInOrder.forEachIndexed { idx, value -> + withStyle( + style, + ) { + if (value is TextType) { + append(value.text) + } else if (value is ImageUrlType) { + appendInlineContent("inlineContent$idx", "[icon]") + } } + } } + } - Text( - text = annotatedText, - inlineContent = inlineContent, - fontSize = fontSize, - maxLines = maxLines, - overflow = overflow, - modifier = modifier - ) + Text( + text = annotatedText, + inlineContent = inlineContent, + fontSize = fontSize, + maxLines = maxLines, + overflow = overflow, + modifier = modifier, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt index 7bb9ec1a5..353f539eb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import androidx.compose.foundation.text.ClickableText @@ -9,21 +29,22 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.AnnotatedString @Composable -fun ClickableUrl(urlText: String, url: String) { - val uri = LocalUriHandler.current +fun ClickableUrl( + urlText: String, + url: String, +) { + val uri = LocalUriHandler.current - val text = remember(urlText) { - AnnotatedString(urlText) - } + val text = remember(urlText) { AnnotatedString(urlText) } - ClickableText( - text = text, - onClick = { - runCatching { - val doubleCheckedUrl = if (url.contains("://")) url else "https://$url" - uri.openUri(doubleCheckedUrl) - } - }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary) - ) + ClickableText( + text = text, + onClick = { + runCatching { + val doubleCheckedUrl = if (url.contains("://")) url else "https://$url" + uri.openUri(doubleCheckedUrl) + } + }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUserTag.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUserTag.kt index f01fb4b05..fe4bbcb8e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUserTag.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUserTag.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import androidx.compose.foundation.text.ClickableText @@ -12,22 +32,19 @@ import com.vitorpamplona.amethyst.model.User @Composable fun ClickableUserTag( - user: User, - nav: (String) -> Unit + user: User, + nav: (String) -> Unit, ) { - val route = remember { - "User/${user.pubkeyHex}" - } + val route = remember { "User/${user.pubkeyHex}" } - val innerUserState by user.live().metadata.observeAsState() + val innerUserState by user.live().metadata.observeAsState() - val userName = remember(innerUserState) { - AnnotatedString("@${innerUserState?.user?.toBestDisplayName()}") - } + val userName = + remember(innerUserState) { AnnotatedString("@${innerUserState?.user?.toBestDisplayName()}") } - ClickableText( - text = userName, - onClick = { nav(route) }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary) - ) + ClickableText( + text = userName, + onClick = { nav(route) }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt index 74fefe14f..89d121e95 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import androidx.compose.animation.Crossfade @@ -23,51 +43,43 @@ import kotlinx.coroutines.launch @Composable fun MayBeWithdrawal(lnurlWord: String) { - var lnWithdrawal by remember { mutableStateOf(null) } + var lnWithdrawal by remember { mutableStateOf(null) } - LaunchedEffect(key1 = lnurlWord) { - launch(Dispatchers.IO) { - lnWithdrawal = LnWithdrawalUtil.findWithdrawal(lnurlWord) - } - } + LaunchedEffect(key1 = lnurlWord) { + launch(Dispatchers.IO) { lnWithdrawal = LnWithdrawalUtil.findWithdrawal(lnurlWord) } + } - Crossfade(targetState = lnWithdrawal) { - if (it != null) { - ClickableWithdrawal(withdrawalString = it) - } else { - Text( - text = lnurlWord, - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) - ) - } + Crossfade(targetState = lnWithdrawal) { + if (it != null) { + ClickableWithdrawal(withdrawalString = it) + } else { + Text( + text = lnurlWord, + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + ) } + } } @Composable fun ClickableWithdrawal(withdrawalString: String) { - val context = LocalContext.current + val context = LocalContext.current - val withdraw = remember(withdrawalString) { - AnnotatedString("$withdrawalString ") - } + val withdraw = remember(withdrawalString) { AnnotatedString("$withdrawalString ") } - var showErrorMessageDialog by remember { mutableStateOf(null) } + var showErrorMessageDialog by remember { mutableStateOf(null) } - if (showErrorMessageDialog != null) { - ErrorMessageDialog( - title = context.getString(R.string.error_dialog_pay_withdraw_error), - textContent = showErrorMessageDialog ?: "", - onDismiss = { showErrorMessageDialog = null } - ) - } - - ClickableText( - text = withdraw, - onClick = { - payViaIntent(withdrawalString, context) { - showErrorMessageDialog = it - } - }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary) + if (showErrorMessageDialog != null) { + ErrorMessageDialog( + title = context.getString(R.string.error_dialog_pay_withdraw_error), + textContent = showErrorMessageDialog ?: "", + onDismiss = { showErrorMessageDialog = null }, ) + } + + ClickableText( + text = withdraw, + onClick = { payViaIntent(withdrawalString, context) { showErrorMessageDialog = it } }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt index 2208d6a4a..3a5922547 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import androidx.compose.animation.Crossfade @@ -36,89 +56,92 @@ const val SHORTEN_AFTER_LINES = 10 @Composable fun ExpandableRichTextViewer( - content: String, - canPreview: Boolean, - modifier: Modifier, - tags: ImmutableListOfLists, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + content: String, + canPreview: Boolean, + modifier: Modifier, + tags: ImmutableListOfLists, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var showFullText by remember { mutableStateOf(false) } + var showFullText by remember { mutableStateOf(false) } - val whereToCut = remember(content) { - // Cuts the text in the first space or new line after SHORT_TEXT_LENGTH characters - val firstSpaceAfterCut = content.indexOf(' ', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it } - val firstNewLineAfterCut = content.indexOf('\n', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it } + val whereToCut = + remember(content) { + // Cuts the text in the first space or new line after SHORT_TEXT_LENGTH characters + val firstSpaceAfterCut = + content.indexOf(' ', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it } + val firstNewLineAfterCut = + content.indexOf('\n', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it } - // or after SHORTEN_AFTER_LINES lines - val numberOfLines = content.count { it == '\n' } + // or after SHORTEN_AFTER_LINES lines + val numberOfLines = content.count { it == '\n' } - var charactersInLines = minOf(firstSpaceAfterCut, firstNewLineAfterCut) + var charactersInLines = minOf(firstSpaceAfterCut, firstNewLineAfterCut) - if (numberOfLines > SHORTEN_AFTER_LINES) { - val shortContent = content.lines().take(SHORTEN_AFTER_LINES) - charactersInLines = 0 - for (line in shortContent) { - // +1 because new line character is omitted from .lines - charactersInLines += (line.length + 1) - } + if (numberOfLines > SHORTEN_AFTER_LINES) { + val shortContent = content.lines().take(SHORTEN_AFTER_LINES) + charactersInLines = 0 + for (line in shortContent) { + // +1 because new line character is omitted from .lines + charactersInLines += (line.length + 1) } + } - minOf(firstSpaceAfterCut, firstNewLineAfterCut, charactersInLines) + minOf(firstSpaceAfterCut, firstNewLineAfterCut, charactersInLines) } - val text by remember(content) { - derivedStateOf { - if (showFullText) { - content - } else { - content.take(whereToCut) - } + val text by + remember(content) { + derivedStateOf { + if (showFullText) { + content + } else { + content.take(whereToCut) } + } } - Box { - Crossfade(text, label = "ExpandableRichTextViewer") { - RichTextViewer( - it, - canPreview, - modifier.align(Alignment.TopStart), - tags, - backgroundColor, - accountViewModel, - nav - ) - } - - if (content.length > whereToCut && !showFullText) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .background(getGradient(backgroundColor)) - ) { - ShowMoreButton { - showFullText = !showFullText - } - } - } + Box { + Crossfade(text, label = "ExpandableRichTextViewer") { + RichTextViewer( + it, + canPreview, + modifier.align(Alignment.TopStart), + tags, + backgroundColor, + accountViewModel, + nav, + ) } + + if (content.length > whereToCut && !showFullText) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = + Modifier.align(Alignment.BottomCenter) + .fillMaxWidth() + .background(getGradient(backgroundColor)), + ) { + ShowMoreButton { showFullText = !showFullText } + } + } + } } @Composable fun ShowMoreButton(onClick: () -> Unit) { - Button( - modifier = Modifier.padding(top = 10.dp), - onClick = onClick, - shape = ButtonBorder, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondaryButtonBackground - ), - contentPadding = ButtonPadding - ) { - Text(text = stringResource(R.string.show_more), color = Color.White) - } + Button( + modifier = Modifier.padding(top = 10.dp), + onClick = onClick, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryButtonBackground, + ), + contentPadding = ButtonPadding, + ) { + Text(text = stringResource(R.string.show_more), color = Color.White) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/GenericLoadable.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/GenericLoadable.kt index 22a72d914..2bce3d281 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/GenericLoadable.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/GenericLoadable.kt @@ -1,19 +1,34 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import androidx.compose.runtime.Immutable @Immutable sealed class GenericLoadable { + @Immutable class Loading : GenericLoadable() - @Immutable - class Loading : GenericLoadable() + @Immutable class Loaded(val loaded: T) : GenericLoadable() - @Immutable - class Loaded(val loaded: T) : GenericLoadable() + @Immutable class Empty : GenericLoadable() - @Immutable - class Empty : GenericLoadable() - - @Immutable - class Error(val errorMessage: String) : GenericLoadable() + @Immutable class Error(val errorMessage: String) : GenericLoadable() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt index 1db8253f2..331525773 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import androidx.compose.animation.Crossfade @@ -38,128 +58,123 @@ import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.quartz.encoders.LnInvoiceUtil +import java.text.NumberFormat import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import java.text.NumberFormat -@Stable -data class InvoiceAmount(val invoice: String, val amount: String?) +@Stable data class InvoiceAmount(val invoice: String, val amount: String?) @Composable -fun LoadValueFromInvoice(lnbcWord: String, inner: @Composable (invoiceAmount: InvoiceAmount?) -> Unit) { - var lnInvoice by remember { mutableStateOf(null) } +fun LoadValueFromInvoice( + lnbcWord: String, + inner: @Composable (invoiceAmount: InvoiceAmount?) -> Unit, +) { + var lnInvoice by remember { mutableStateOf(null) } - LaunchedEffect(key1 = lnbcWord) { - launch(Dispatchers.IO) { - val myInvoice = LnInvoiceUtil.findInvoice(lnbcWord) - if (myInvoice != null) { - val myInvoiceAmount = try { - NumberFormat.getInstance().format(LnInvoiceUtil.getAmountInSats(myInvoice)) - } catch (e: Exception) { - e.printStackTrace() - null - } + LaunchedEffect(key1 = lnbcWord) { + launch(Dispatchers.IO) { + val myInvoice = LnInvoiceUtil.findInvoice(lnbcWord) + if (myInvoice != null) { + val myInvoiceAmount = + try { + NumberFormat.getInstance().format(LnInvoiceUtil.getAmountInSats(myInvoice)) + } catch (e: Exception) { + e.printStackTrace() + null + } - lnInvoice = InvoiceAmount(myInvoice, myInvoiceAmount) - } - } + lnInvoice = InvoiceAmount(myInvoice, myInvoiceAmount) + } } + } - inner(lnInvoice) + inner(lnInvoice) } @Composable fun MayBeInvoicePreview(lnbcWord: String) { - LoadValueFromInvoice(lnbcWord = lnbcWord) { invoiceAmount -> - Crossfade(targetState = invoiceAmount, label = "MayBeInvoicePreview") { - if (it != null) { - InvoicePreview(it.invoice, it.amount) - } else { - Text( - text = lnbcWord, - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) - ) - } - } + LoadValueFromInvoice(lnbcWord = lnbcWord) { invoiceAmount -> + Crossfade(targetState = invoiceAmount, label = "MayBeInvoicePreview") { + if (it != null) { + InvoicePreview(it.invoice, it.amount) + } else { + Text( + text = lnbcWord, + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + ) + } } + } } @Composable -fun InvoicePreview(lnInvoice: String, amount: String?) { - val context = LocalContext.current +fun InvoicePreview( + lnInvoice: String, + amount: String?, +) { + val context = LocalContext.current - var showErrorMessageDialog by remember { mutableStateOf(null) } + var showErrorMessageDialog by remember { mutableStateOf(null) } - if (showErrorMessageDialog != null) { - ErrorMessageDialog( - title = context.getString(R.string.error_dialog_pay_invoice_error), - textContent = showErrorMessageDialog ?: "", - onDismiss = { showErrorMessageDialog = null } - ) - } + if (showErrorMessageDialog != null) { + ErrorMessageDialog( + title = context.getString(R.string.error_dialog_pay_invoice_error), + textContent = showErrorMessageDialog ?: "", + onDismiss = { showErrorMessageDialog = null }, + ) + } + Column( + modifier = + Modifier.fillMaxWidth() + .padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 10.dp) + .clip(shape = QuoteBorder) + .border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder), + ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 10.dp) - .clip(shape = QuoteBorder) - .border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder) + modifier = Modifier.fillMaxWidth().padding(20.dp), ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 10.dp) - ) { - Icon( - painter = painterResource(R.drawable.lightning), - null, - modifier = Size20Modifier, - tint = Color.Unspecified - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + ) { + Icon( + painter = painterResource(R.drawable.lightning), + null, + modifier = Size20Modifier, + tint = Color.Unspecified, + ) - Text( - text = stringResource(R.string.lightning_invoice), - fontSize = 20.sp, - fontWeight = FontWeight.W500, - modifier = Modifier.padding(start = 10.dp) - ) - } + Text( + text = stringResource(R.string.lightning_invoice), + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.padding(start = 10.dp), + ) + } - Divider() + Divider() - amount?.let { - Text( - text = "$it ${stringResource(id = R.string.sats)}", - fontSize = 25.sp, - fontWeight = FontWeight.W500, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 10.dp) - ) - } + amount?.let { + Text( + text = "$it ${stringResource(id = R.string.sats)}", + fontSize = 25.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), + ) + } - Button( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 10.dp), - onClick = { - payViaIntent(lnInvoice, context) { - showErrorMessageDialog = it - } - }, - shape = QuoteBorder, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ) - ) { - Text(text = stringResource(R.string.pay), color = Color.White, fontSize = 20.sp) - } - } + Button( + modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), + onClick = { payViaIntent(lnInvoice, context) { showErrorMessageDialog = it } }, + shape = QuoteBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text(text = stringResource(R.string.pay), color = Color.White, fontSize = 20.sp) + } } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt index 172404ea9..7aeb9d459 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import androidx.compose.foundation.border @@ -45,153 +65,167 @@ import kotlinx.coroutines.launch @Composable fun InvoiceRequestCard( - lud16: String, - toUserPubKeyHex: String, - account: Account, - titleText: String? = null, - buttonText: String? = null, - onSuccess: (String) -> Unit, - onClose: () -> Unit, - onError: (String, String) -> Unit + lud16: String, + toUserPubKeyHex: String, + account: Account, + titleText: String? = null, + buttonText: String? = null, + onSuccess: (String) -> Unit, + onClose: () -> Unit, + onError: (String, String) -> Unit, ) { + Column( + modifier = + Modifier.fillMaxWidth() + .padding(start = 30.dp, end = 30.dp) + .clip(shape = QuoteBorder) + .border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder), + ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(start = 30.dp, end = 30.dp) - .clip(shape = QuoteBorder) - .border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder) + modifier = Modifier.fillMaxWidth().padding(30.dp), ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(30.dp) - ) { - InvoiceRequest(lud16, toUserPubKeyHex, account, titleText, buttonText, onSuccess, onClose, onError) - } + InvoiceRequest( + lud16, + toUserPubKeyHex, + account, + titleText, + buttonText, + onSuccess, + onClose, + onError, + ) } + } } @Composable fun InvoiceRequest( - lud16: String, - toUserPubKeyHex: String, - account: Account, - titleText: String? = null, - buttonText: String? = null, - onSuccess: (String) -> Unit, - onClose: () -> Unit, - onError: (String, String) -> Unit + lud16: String, + toUserPubKeyHex: String, + account: Account, + titleText: String? = null, + buttonText: String? = null, + onSuccess: (String) -> Unit, + onClose: () -> Unit, + onError: (String, String) -> Unit, ) { - val context = LocalContext.current - val scope = rememberCoroutineScope() + val context = LocalContext.current + val scope = rememberCoroutineScope() - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 10.dp) - ) { - Icon( - painter = painterResource(R.drawable.lightning), - null, - modifier = Size20Modifier, - tint = Color.Unspecified - ) - - Text( - text = titleText ?: stringResource(R.string.lightning_tips), - fontSize = 20.sp, - fontWeight = FontWeight.W500, - modifier = Modifier.padding(start = 10.dp) - ) - } - - Divider() - - var message by remember { mutableStateOf("") } - var amount by remember { mutableStateOf(1000L) } - - OutlinedTextField( - label = { Text(text = stringResource(R.string.note_to_receiver)) }, - modifier = Modifier.fillMaxWidth(), - value = message, - onValueChange = { message = it }, - placeholder = { - Text( - text = stringResource(R.string.thank_you_so_much), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences - ), - singleLine = true + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + ) { + Icon( + painter = painterResource(R.drawable.lightning), + null, + modifier = Size20Modifier, + tint = Color.Unspecified, ) - OutlinedTextField( - label = { Text(text = stringResource(R.string.amount_in_sats)) }, - modifier = Modifier.fillMaxWidth(), - value = amount.toString(), - onValueChange = { - runCatching { - if (it.isEmpty()) { - amount = 0 - } else { - amount = it.toLong() - } - } - }, - placeholder = { - Text( - text = "1000", - color = MaterialTheme.colorScheme.placeholderText - ) - }, - keyboardOptions = KeyboardOptions.Default.copy( - keyboardType = KeyboardType.Number - ), - singleLine = true + Text( + text = titleText ?: stringResource(R.string.lightning_tips), + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.padding(start = 10.dp), ) + } - Button( - modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), - onClick = { - scope.launch(Dispatchers.IO) { - if (account.defaultZapType == LnZapEvent.ZapType.NONZAP) { - LightningAddressResolver().lnAddressInvoice( - lud16, - amount * 1000, - message, - null, - onSuccess = onSuccess, - onError = onError, - onProgress = { - }, - context = context - ) - } else { - account.createZapRequestFor(toUserPubKeyHex, message, account.defaultZapType) { zapRequest -> - LocalCache.justConsume(zapRequest, null) - LightningAddressResolver().lnAddressInvoice( - lud16, - amount * 1000, - message, - zapRequest.toJson(), - onSuccess = onSuccess, - onError = onError, - onProgress = { - }, - context = context - ) - } - } - } - }, - shape = QuoteBorder, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ) - ) { - Text(text = buttonText ?: stringResource(R.string.send_sats), color = Color.White, fontSize = 20.sp) - } + Divider() + + var message by remember { mutableStateOf("") } + var amount by remember { mutableStateOf(1000L) } + + OutlinedTextField( + label = { Text(text = stringResource(R.string.note_to_receiver)) }, + modifier = Modifier.fillMaxWidth(), + value = message, + onValueChange = { message = it }, + placeholder = { + Text( + text = stringResource(R.string.thank_you_so_much), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + singleLine = true, + ) + + OutlinedTextField( + label = { Text(text = stringResource(R.string.amount_in_sats)) }, + modifier = Modifier.fillMaxWidth(), + value = amount.toString(), + onValueChange = { + runCatching { + if (it.isEmpty()) { + amount = 0 + } else { + amount = it.toLong() + } + } + }, + placeholder = { + Text( + text = "1000", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Number, + ), + singleLine = true, + ) + + Button( + modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), + onClick = { + scope.launch(Dispatchers.IO) { + if (account.defaultZapType == LnZapEvent.ZapType.NONZAP) { + LightningAddressResolver() + .lnAddressInvoice( + lud16, + amount * 1000, + message, + null, + onSuccess = onSuccess, + onError = onError, + onProgress = {}, + context = context, + ) + } else { + account.createZapRequestFor(toUserPubKeyHex, message, account.defaultZapType) { + zapRequest, + -> + LocalCache.justConsume(zapRequest, null) + LightningAddressResolver() + .lnAddressInvoice( + lud16, + amount * 1000, + message, + zapRequest.toJson(), + onSuccess = onSuccess, + onError = onError, + onProgress = {}, + context = context, + ) + } + } + } + }, + shape = QuoteBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text( + text = buttonText ?: stringResource(R.string.send_sats), + color = Color.White, + fontSize = 20.sp, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt index 7b6ca9068..c7464b15b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import androidx.compose.animation.Crossfade @@ -15,53 +35,62 @@ import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding import kotlinx.collections.immutable.persistentListOf @Composable -fun LoadUrlPreview(url: String, urlText: String, accountViewModel: AccountViewModel) { - val automaticallyShowUrlPreview = remember { - accountViewModel.settings.showUrlPreview.value +fun LoadUrlPreview( + url: String, + urlText: String, + accountViewModel: AccountViewModel, +) { + val automaticallyShowUrlPreview = remember { accountViewModel.settings.showUrlPreview.value } + + if (!automaticallyShowUrlPreview) { + ClickableUrl(urlText, url) + } else { + var urlPreviewState by + remember(url) { + mutableStateOf( + UrlCachedPreviewer.cache.get(url) ?: UrlPreviewState.Loading, + ) + } + + // Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are + // created). + if (urlPreviewState == UrlPreviewState.Loading) { + LaunchedEffect(url) { accountViewModel.urlPreview(url) { urlPreviewState = it } } } - if (!automaticallyShowUrlPreview) { - ClickableUrl(urlText, url) - } else { - var urlPreviewState by remember(url) { - mutableStateOf( - UrlCachedPreviewer.cache.get(url) ?: UrlPreviewState.Loading - ) - } - - // Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created). - if (urlPreviewState == UrlPreviewState.Loading) { - LaunchedEffect(url) { - accountViewModel.urlPreview(url) { - urlPreviewState = it - } + Crossfade( + targetState = urlPreviewState, + animationSpec = tween(durationMillis = 100), + label = "UrlPreview", + ) { state -> + when (state) { + is UrlPreviewState.Loaded -> { + if (state.previewInfo.mimeType.type == "image") { + Box(modifier = HalfVertPadding) { + ZoomableContentView( + ZoomableUrlImage(url), + persistentListOf(), + roundedCorner = true, + accountViewModel, + ) } - } - - Crossfade( - targetState = urlPreviewState, - animationSpec = tween(durationMillis = 100), - label = "UrlPreview" - ) { state -> - when (state) { - is UrlPreviewState.Loaded -> { - if (state.previewInfo.mimeType.type == "image") { - Box(modifier = HalfVertPadding) { - ZoomableContentView(ZoomableUrlImage(url), persistentListOf(), roundedCorner = true, accountViewModel) - } - } else if (state.previewInfo.mimeType.type == "video") { - Box(modifier = HalfVertPadding) { - ZoomableContentView(ZoomableUrlVideo(url), persistentListOf(), roundedCorner = true, accountViewModel) - } - } else { - UrlPreviewCard(url, state.previewInfo) - } - } - - else -> { - ClickableUrl(urlText, url) - } + } else if (state.previewInfo.mimeType.type == "video") { + Box(modifier = HalfVertPadding) { + ZoomableContentView( + ZoomableUrlVideo(url), + persistentListOf(), + roundedCorner = true, + accountViewModel, + ) } + } else { + UrlPreviewCard(url, state.previewInfo) + } } + else -> { + ClickableUrl(urlText, url) + } + } } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MarkdownParser.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MarkdownParser.kt index 3877cb7a8..9e5e507b2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MarkdownParser.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MarkdownParser.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import android.util.Log @@ -9,146 +29,156 @@ import com.vitorpamplona.quartz.encoders.Nip19 import com.vitorpamplona.quartz.events.ImmutableListOfLists class MarkdownParser { - private fun getDisplayNameAndNIP19FromTag(tag: String, tags: ImmutableListOfLists): Pair? { - val matcher = tagIndex.matcher(tag) - val (index, suffix) = try { - matcher.find() - Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "") - } catch (e: Exception) { - Log.w("Tag Parser", "Couldn't link tag $tag", e) - Pair(null, null) + private fun getDisplayNameAndNIP19FromTag( + tag: String, + tags: ImmutableListOfLists, + ): Pair? { + val matcher = tagIndex.matcher(tag) + val (index, suffix) = + try { + matcher.find() + Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "") + } catch (e: Exception) { + Log.w("Tag Parser", "Couldn't link tag $tag", e) + Pair(null, null) + } + + if (index != null && index >= 0 && index < tags.lists.size) { + val tag = tags.lists[index] + + if (tag.size > 1) { + if (tag[0] == "p") { + LocalCache.checkGetOrCreateUser(tag[1])?.let { + return Pair(it.toBestDisplayName(), it.pubkeyNpub()) + } + } else if (tag[0] == "e" || tag[0] == "a") { + LocalCache.checkGetOrCreateNote(tag[1])?.let { + return Pair(it.idDisplayNote(), it.toNEvent()) + } } - - if (index != null && index >= 0 && index < tags.lists.size) { - val tag = tags.lists[index] - - if (tag.size > 1) { - if (tag[0] == "p") { - LocalCache.checkGetOrCreateUser(tag[1])?.let { - return Pair(it.toBestDisplayName(), it.pubkeyNpub()) - } - } else if (tag[0] == "e" || tag[0] == "a") { - LocalCache.checkGetOrCreateNote(tag[1])?.let { - return Pair(it.idDisplayNote(), it.toNEvent()) - } - } - } - } - - return null + } } - private fun getDisplayNameFromNip19(nip19: Nip19.Return): Pair? { - if (nip19.type == Nip19.Type.USER) { - LocalCache.users[nip19.hex]?.let { - return Pair(it.toBestDisplayName(), it.pubkeyNpub()) - } - } else if (nip19.type == Nip19.Type.NOTE) { - LocalCache.notes[nip19.hex]?.let { - return Pair(it.idDisplayNote(), it.toNEvent()) - } - } else if (nip19.type == Nip19.Type.ADDRESS) { - LocalCache.addressables[nip19.hex]?.let { - return Pair(it.idDisplayNote(), it.toNEvent()) - } - } else if (nip19.type == Nip19.Type.EVENT) { - LocalCache.notes[nip19.hex]?.let { - return Pair(it.idDisplayNote(), it.toNEvent()) - } - } + return null + } - return null + private fun getDisplayNameFromNip19(nip19: Nip19.Return): Pair? { + if (nip19.type == Nip19.Type.USER) { + LocalCache.users[nip19.hex]?.let { + return Pair(it.toBestDisplayName(), it.pubkeyNpub()) + } + } else if (nip19.type == Nip19.Type.NOTE) { + LocalCache.notes[nip19.hex]?.let { + return Pair(it.idDisplayNote(), it.toNEvent()) + } + } else if (nip19.type == Nip19.Type.ADDRESS) { + LocalCache.addressables[nip19.hex]?.let { + return Pair(it.idDisplayNote(), it.toNEvent()) + } + } else if (nip19.type == Nip19.Type.EVENT) { + LocalCache.notes[nip19.hex]?.let { + return Pair(it.idDisplayNote(), it.toNEvent()) + } } - fun returnNIP19References(content: String, tags: ImmutableListOfLists?): List { - checkNotInMainThread() + return null + } - val listOfReferences = mutableListOf() - content.split('\n').forEach { paragraph -> - paragraph.split(' ').forEach { word: String -> - if (startsWithNIP19Scheme(word)) { - val parsedNip19 = Nip19.uriToRoute(word) - parsedNip19?.let { - listOfReferences.add(it) - } - } - } + fun returnNIP19References( + content: String, + tags: ImmutableListOfLists?, + ): List { + checkNotInMainThread() + + val listOfReferences = mutableListOf() + content.split('\n').forEach { paragraph -> + paragraph.split(' ').forEach { word: String -> + if (startsWithNIP19Scheme(word)) { + val parsedNip19 = Nip19.uriToRoute(word) + parsedNip19?.let { listOfReferences.add(it) } } - - tags?.lists?.forEach { - if (it[0] == "p" && it.size > 1) { - listOfReferences.add(Nip19.Return(Nip19.Type.USER, it[1], null, null, null, "")) - } else if (it[0] == "e" && it.size > 1) { - listOfReferences.add(Nip19.Return(Nip19.Type.NOTE, it[1], null, null, null, "")) - } else if (it[0] == "a" && it.size > 1) { - listOfReferences.add(Nip19.Return(Nip19.Type.ADDRESS, it[1], null, null, null, "")) - } - } - - return listOfReferences + } } - fun returnMarkdownWithSpecialContent(content: String, tags: ImmutableListOfLists?): String { - var returnContent = "" - content.split('\n').forEach { paragraph -> - paragraph.split(' ').forEach { word: String -> - if (isValidURL(word)) { - val removedParamsFromUrl = removeQueryParamsForExtensionComparison(word) - if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - returnContent += "![]($word) " - } else { - returnContent += "[$word]($word) " - } - } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) { - returnContent += "[$word](mailto:$word) " - } else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) { - returnContent += "[$word](tel:$word) " - } else if (startsWithNIP19Scheme(word)) { - val parsedNip19 = Nip19.uriToRoute(word) - returnContent += if (parsedNip19 !== null) { - val pair = getDisplayNameFromNip19(parsedNip19) - if (pair != null) { - val (displayName, nip19) = pair - "[$displayName](nostr:$nip19) " - } else { - "$word " - } - } else { - "$word " - } - } else if (word.startsWith("#")) { - if (tagIndex.matcher(word).matches() && tags != null) { - val pair = getDisplayNameAndNIP19FromTag(word, tags) - if (pair != null) { - returnContent += "[${pair.first}](nostr:${pair.second}) " - } else { - returnContent += "$word " - } - } else if (hashTagsPattern.matcher(word).matches()) { - val hashtagMatcher = hashTagsPattern.matcher(word) - - val (myTag, mySuffix) = try { - hashtagMatcher.find() - Pair(hashtagMatcher.group(1), hashtagMatcher.group(2)) - } catch (e: Exception) { - Log.e("Hashtag Parser", "Couldn't link hashtag $word", e) - Pair(null, null) - } - - if (myTag != null) { - returnContent += "[#$myTag](nostr:Hashtag?id=$myTag)$mySuffix " - } else { - returnContent += "$word " - } - } else { - returnContent += "$word " - } - } else { - returnContent += "$word " - } - } - returnContent += "\n" - } - return returnContent + tags?.lists?.forEach { + if (it[0] == "p" && it.size > 1) { + listOfReferences.add(Nip19.Return(Nip19.Type.USER, it[1], null, null, null, "")) + } else if (it[0] == "e" && it.size > 1) { + listOfReferences.add(Nip19.Return(Nip19.Type.NOTE, it[1], null, null, null, "")) + } else if (it[0] == "a" && it.size > 1) { + listOfReferences.add(Nip19.Return(Nip19.Type.ADDRESS, it[1], null, null, null, "")) + } } + + return listOfReferences + } + + fun returnMarkdownWithSpecialContent( + content: String, + tags: ImmutableListOfLists?, + ): String { + var returnContent = "" + content.split('\n').forEach { paragraph -> + paragraph.split(' ').forEach { word: String -> + if (isValidURL(word)) { + val removedParamsFromUrl = removeQueryParamsForExtensionComparison(word) + if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { + returnContent += "![]($word) " + } else { + returnContent += "[$word]($word) " + } + } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) { + returnContent += "[$word](mailto:$word) " + } else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) { + returnContent += "[$word](tel:$word) " + } else if (startsWithNIP19Scheme(word)) { + val parsedNip19 = Nip19.uriToRoute(word) + returnContent += + if (parsedNip19 !== null) { + val pair = getDisplayNameFromNip19(parsedNip19) + if (pair != null) { + val (displayName, nip19) = pair + "[$displayName](nostr:$nip19) " + } else { + "$word " + } + } else { + "$word " + } + } else if (word.startsWith("#")) { + if (tagIndex.matcher(word).matches() && tags != null) { + val pair = getDisplayNameAndNIP19FromTag(word, tags) + if (pair != null) { + returnContent += "[${pair.first}](nostr:${pair.second}) " + } else { + returnContent += "$word " + } + } else if (hashTagsPattern.matcher(word).matches()) { + val hashtagMatcher = hashTagsPattern.matcher(word) + + val (myTag, mySuffix) = + try { + hashtagMatcher.find() + Pair(hashtagMatcher.group(1), hashtagMatcher.group(2)) + } catch (e: Exception) { + Log.e("Hashtag Parser", "Couldn't link hashtag $word", e) + Pair(null, null) + } + + if (myTag != null) { + returnContent += "[#$myTag](nostr:Hashtag?id=$myTag)$mySuffix " + } else { + returnContent += "$word " + } + } else { + returnContent += "$word " + } + } else { + returnContent += "$word " + } + } + returnContent += "\n" + } + return returnContent + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MediaCompressor.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MediaCompressor.kt index 17396a8a2..e44f98914 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MediaCompressor.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MediaCompressor.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import android.content.Context @@ -18,101 +38,125 @@ import java.io.FileOutputStream import java.util.UUID class MediaCompressor { - suspend fun compress( - uri: Uri, - contentType: String?, - applicationContext: Context, - onReady: (Uri, String?, Long?) -> Unit, - onError: (String) -> Unit + suspend fun compress( + uri: Uri, + contentType: String?, + applicationContext: Context, + onReady: (Uri, String?, Long?) -> Unit, + onError: (String) -> Unit, + ) { + checkNotInMainThread() + + if (contentType?.startsWith("video", true) == true) { + VideoCompressor.start( + // => This is required + context = applicationContext, + // => Source can be provided as content uris + uris = listOf(uri), + isStreamable = false, + // THIS STORAGE + // sharedStorageConfiguration = SharedStorageConfiguration( + // saveAt = SaveLocation.movies, // => default is movies + // videoName = "compressed_video" // => required name + // ), + // OR AND NOT BOTH + appSpecificStorageConfiguration = AppSpecificStorageConfiguration(), + configureWith = + Configuration( + quality = VideoQuality.LOW, + // => required name + videoNames = listOf(UUID.randomUUID().toString()), + ), + listener = + object : CompressionListener { + override fun onProgress( + index: Int, + percent: Float, + ) {} + + override fun onStart(index: Int) { + // Compression start + } + + override fun onSuccess( + index: Int, + size: Long, + path: String?, + ) { + if (path != null) { + onReady(Uri.fromFile(File(path)), contentType, size) + } else { + onError("Compression Returned null") + } + } + + override fun onFailure( + index: Int, + failureMessage: String, + ) { + // keeps going with original video + onReady(uri, contentType, null) + } + + override fun onCancelled(index: Int) { + onError("Compression Cancelled") + } + }, + ) + } else if ( + contentType?.startsWith("image", true) == true && + !contentType.contains("gif") && + !contentType.contains("svg") ) { - checkNotInMainThread() + try { + val compressedImageFile = + Compressor.compress(applicationContext, from(uri, contentType, applicationContext)) { + default(width = 640, format = Bitmap.CompressFormat.JPEG) + } + onReady(compressedImageFile.toUri(), contentType, compressedImageFile.length()) + } catch (e: Exception) { + e.printStackTrace() + onReady(uri, contentType, null) + } + } else { + onReady(uri, contentType, null) + } + } - if (contentType?.startsWith("video", true) == true) { - VideoCompressor.start( - context = applicationContext, // => This is required - uris = listOf(uri), // => Source can be provided as content uris - isStreamable = false, - // THIS STORAGE - // sharedStorageConfiguration = SharedStorageConfiguration( - // saveAt = SaveLocation.movies, // => default is movies - // videoName = "compressed_video" // => required name - // ), - // OR AND NOT BOTH - appSpecificStorageConfiguration = AppSpecificStorageConfiguration(), - configureWith = Configuration( - quality = VideoQuality.LOW, - videoNames = listOf(UUID.randomUUID().toString()) // => required name - ), - listener = object : CompressionListener { - override fun onProgress(index: Int, percent: Float) { - } + fun from( + uri: Uri?, + contentType: String?, + context: Context, + ): File { + val extension = + contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" - override fun onStart(index: Int) { - // Compression start - } - - override fun onSuccess(index: Int, size: Long, path: String?) { - if (path != null) { - onReady(Uri.fromFile(File(path)), contentType, size) - } else { - onError("Compression Returned null") - } - } - - override fun onFailure(index: Int, failureMessage: String) { - // keeps going with original video - onReady(uri, contentType, null) - } - - override fun onCancelled(index: Int) { - onError("Compression Cancelled") - } - } - ) - } else if (contentType?.startsWith("image", true) == true && !contentType.contains("gif") && !contentType.contains("svg")) { - try { - val compressedImageFile = Compressor.compress(applicationContext, from(uri, contentType, applicationContext)) { - default(width = 640, format = Bitmap.CompressFormat.JPEG) - } - onReady(compressedImageFile.toUri(), contentType, compressedImageFile.length()) - } catch (e: Exception) { - e.printStackTrace() - onReady(uri, contentType, null) - } - } else { - onReady(uri, contentType, null) + val inputStream = context.contentResolver.openInputStream(uri!!) + val fileName: String = UUID.randomUUID().toString() + ".$extension" + val splitName: Array = splitFileName(fileName) + val tempFile = File.createTempFile(splitName[0], splitName[1]) + inputStream?.use { input -> + FileOutputStream(tempFile).use { output -> + val buffer = ByteArray(1024 * 50) + var read: Int = input.read(buffer) + while (read != -1) { + output.write(buffer, 0, read) + read = input.read(buffer) } + } } - fun from(uri: Uri?, contentType: String?, context: Context): File { - val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" + return tempFile + } - val inputStream = context.contentResolver.openInputStream(uri!!) - val fileName: String = UUID.randomUUID().toString() + ".$extension" - val splitName: Array = splitFileName(fileName) - val tempFile = File.createTempFile(splitName[0], splitName[1]) - inputStream?.use { input -> - FileOutputStream(tempFile).use { output -> - val buffer = ByteArray(1024 * 50) - var read: Int = input.read(buffer) - while (read != -1) { - output.write(buffer, 0, read) - read = input.read(buffer) - } - } - } - - return tempFile - } - - private fun splitFileName(fileName: String): Array { - var name = fileName - var extension = "" - val i = fileName.lastIndexOf(".") - if (i != -1) { - name = fileName.substring(0, i) - extension = fileName.substring(i) - } - return arrayOf(name, extension) + private fun splitFileName(fileName: String): Array { + var name = fileName + var extension = "" + val i = fileName.lastIndexOf(".") + if (i != -1) { + name = fileName.substring(0, i) + extension = fileName.substring(i) } + return arrayOf(name, extension) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 7997dcf73..2fbe773aa 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import androidx.compose.animation.Crossfade @@ -84,676 +104,700 @@ import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.amethyst.ui.uriToRoute import com.vitorpamplona.quartz.encoders.Nip19 import com.vitorpamplona.quartz.events.ImmutableListOfLists -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import java.net.MalformedURLException import java.net.URISyntaxException import java.net.URL import java.util.regex.Pattern +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg") val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8") 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 removeQueryParamsForExtensionComparison(fullUrl: String): String { - return if (fullUrl.contains("?")) { - fullUrl.split("?")[0].lowercase() - } else if (fullUrl.contains("#")) { - fullUrl.split("#")[0].lowercase() - } else { - fullUrl.lowercase() - } + return if (fullUrl.contains("?")) { + fullUrl.split("?")[0].lowercase() + } else if (fullUrl.contains("#")) { + fullUrl.split("#")[0].lowercase() + } else { + fullUrl.lowercase() + } } fun isImageOrVideoUrl(url: String): Boolean { - val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url) + val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url) - return imageExtensions.any { removedParamsFromUrl.endsWith(it) } || videoExtensions.any { removedParamsFromUrl.endsWith(it) } + return imageExtensions.any { removedParamsFromUrl.endsWith(it) } || + videoExtensions.any { removedParamsFromUrl.endsWith(it) } } fun isValidURL(url: String?): Boolean { - return try { - URL(url).toURI() - true - } catch (e: MalformedURLException) { - false - } catch (e: URISyntaxException) { - false - } + return try { + URL(url).toURI() + true + } catch (e: MalformedURLException) { + false + } catch (e: URISyntaxException) { + false + } } fun isMarkdown(content: String): Boolean { - return content.startsWith("> ") || - content.startsWith("# ") || - content.contains("##") || - content.contains("__") || - content.contains("```") || - content.contains("](") + return content.startsWith("> ") || + content.startsWith("# ") || + content.contains("##") || + content.contains("__") || + content.contains("```") || + content.contains("](") } @Composable fun RichTextViewer( - content: String, - canPreview: Boolean, - modifier: Modifier, - tags: ImmutableListOfLists, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + content: String, + canPreview: Boolean, + modifier: Modifier, + tags: ImmutableListOfLists, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - Column(modifier = modifier) { - if (remember(content) { isMarkdown(content) }) { - RenderContentAsMarkdown(content, tags, accountViewModel, nav) - } else { - RenderRegular(content, tags, canPreview, backgroundColor, accountViewModel, nav) - } + Column(modifier = modifier) { + if (remember(content) { isMarkdown(content) }) { + RenderContentAsMarkdown(content, tags, accountViewModel, nav) + } else { + RenderRegular(content, tags, canPreview, backgroundColor, accountViewModel, nav) } + } } @OptIn(ExperimentalLayoutApi::class) @Composable private fun RenderRegular( - content: String, - tags: ImmutableListOfLists, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + content: String, + tags: ImmutableListOfLists, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val state by remember(content) { - mutableStateOf(CachedRichTextParser.parseText(content, tags)) + val state by remember(content) { mutableStateOf(CachedRichTextParser.parseText(content, tags)) } + + val currentTextStyle = LocalTextStyle.current + val currentTextColor = LocalContentColor.current + + val textStyle = + remember(currentTextStyle, currentTextColor) { + currentTextStyle.copy( + lineHeight = 1.4.em, + color = currentTextStyle.color.takeOrElse { currentTextColor }, + ) } - val currentTextStyle = LocalTextStyle.current - val currentTextColor = LocalContentColor.current + val spaceWidth = measureSpaceWidth(textStyle) - val textStyle = remember(currentTextStyle, currentTextColor) { - currentTextStyle.copy( - lineHeight = 1.4.em, - color = currentTextStyle.color.takeOrElse { - currentTextColor - } - ) - } - - val spaceWidth = measureSpaceWidth(textStyle) - - Column() { - if (canPreview) { - // FlowRow doesn't work well with paragraphs. So we need to split them - state.paragraphs.forEach { paragraph -> - val direction = if (paragraph.isRTL) { - LayoutDirection.Rtl - } else { - LayoutDirection.Ltr - } - - CompositionLocalProvider(LocalLayoutDirection provides direction) { - FlowRow( - modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start), - horizontalArrangement = Arrangement.spacedBy(spaceWidth) - ) { - paragraph.words.forEach { word -> - RenderWordWithPreview( - word, - state, - backgroundColor, - textStyle, - accountViewModel, - nav - ) - } - } - } - } - } else { - // FlowRow doesn't work well with paragraphs. So we need to split them - state.paragraphs.forEach { paragraph -> - val direction = if (paragraph.isRTL) { - LayoutDirection.Rtl - } else { - LayoutDirection.Ltr - } - - CompositionLocalProvider(LocalLayoutDirection provides direction) { - FlowRow( - horizontalArrangement = Arrangement.spacedBy(spaceWidth), - modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start) - ) { - paragraph.words.forEach { word -> - RenderWordWithoutPreview( - word, - state, - backgroundColor, - textStyle, - accountViewModel, - nav - ) - } - } - } + Column { + if (canPreview) { + // FlowRow doesn't work well with paragraphs. So we need to split them + state.paragraphs.forEach { paragraph -> + val direction = + if (paragraph.isRTL) { + LayoutDirection.Rtl + } else { + LayoutDirection.Ltr + } + + CompositionLocalProvider(LocalLayoutDirection provides direction) { + FlowRow( + modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start), + horizontalArrangement = Arrangement.spacedBy(spaceWidth), + ) { + paragraph.words.forEach { word -> + RenderWordWithPreview( + word, + state, + backgroundColor, + textStyle, + accountViewModel, + nav, + ) } + } } + } + } else { + // FlowRow doesn't work well with paragraphs. So we need to split them + state.paragraphs.forEach { paragraph -> + val direction = + if (paragraph.isRTL) { + LayoutDirection.Rtl + } else { + LayoutDirection.Ltr + } + + CompositionLocalProvider(LocalLayoutDirection provides direction) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(spaceWidth), + modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start), + ) { + paragraph.words.forEach { word -> + RenderWordWithoutPreview( + word, + state, + backgroundColor, + textStyle, + accountViewModel, + nav, + ) + } + } + } + } } + } } @Composable fun measureSpaceWidth(textStyle: TextStyle): Dp { - val fontFamilyResolver = LocalFontFamilyResolver.current - val density = LocalDensity.current - val layoutDirection = LocalLayoutDirection.current + val fontFamilyResolver = LocalFontFamilyResolver.current + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current - return remember(fontFamilyResolver, density, layoutDirection, textStyle) { - val widthPx = TextMeasurer(fontFamilyResolver, density, layoutDirection, 1).measure(" ", textStyle).size.width - with(density) { - widthPx.toDp() - } - } + return remember(fontFamilyResolver, density, layoutDirection, textStyle) { + val widthPx = + TextMeasurer(fontFamilyResolver, density, layoutDirection, 1) + .measure(" ", textStyle) + .size + .width + with(density) { widthPx.toDp() } + } } @Composable private fun RenderWordWithoutPreview( - word: Segment, - state: RichTextViewerState, - backgroundColor: MutableState, - style: TextStyle, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + word: Segment, + state: RichTextViewerState, + backgroundColor: MutableState, + style: TextStyle, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - when (word) { - // Don't preview Images - is ImageSegment -> ClickableUrl(word.segmentText, word.segmentText) - is LinkSegment -> ClickableUrl(word.segmentText, word.segmentText) - is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) - // Don't offer to pay invoices - is InvoiceSegment -> NormalWord(word.segmentText, style) - // Don't offer to withdraw - is WithdrawSegment -> NormalWord(word.segmentText, style) - is CashuSegment -> NormalWord(word.segmentText, style) - is EmailSegment -> ClickableEmail(word.segmentText) - is PhoneSegment -> ClickablePhone(word.segmentText) - is BechSegment -> BechLink(word.segmentText, false, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, nav) - is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) - is HashIndexEventSegment -> TagLink(word, false, backgroundColor, accountViewModel, nav) - is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) - is RegularTextSegment -> NormalWord(word.segmentText, style) - } + when (word) { + // Don't preview Images + is ImageSegment -> ClickableUrl(word.segmentText, word.segmentText) + is LinkSegment -> ClickableUrl(word.segmentText, word.segmentText) + is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) + // Don't offer to pay invoices + is InvoiceSegment -> NormalWord(word.segmentText, style) + // Don't offer to withdraw + is WithdrawSegment -> NormalWord(word.segmentText, style) + is CashuSegment -> NormalWord(word.segmentText, style) + is EmailSegment -> ClickableEmail(word.segmentText) + is PhoneSegment -> ClickablePhone(word.segmentText) + is BechSegment -> BechLink(word.segmentText, false, backgroundColor, accountViewModel, nav) + is HashTagSegment -> HashTag(word, nav) + is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) + is HashIndexEventSegment -> TagLink(word, false, backgroundColor, accountViewModel, nav) + is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) + is RegularTextSegment -> NormalWord(word.segmentText, style) + } } @Composable private fun RenderWordWithPreview( - word: Segment, - state: RichTextViewerState, - backgroundColor: MutableState, - style: TextStyle, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + word: Segment, + state: RichTextViewerState, + backgroundColor: MutableState, + style: TextStyle, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - when (word) { - is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) - is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, accountViewModel) - is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) - is InvoiceSegment -> MayBeInvoicePreview(word.segmentText) - is WithdrawSegment -> MayBeWithdrawal(word.segmentText) - is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) - is EmailSegment -> ClickableEmail(word.segmentText) - is PhoneSegment -> ClickablePhone(word.segmentText) - is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, nav) - is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) - is HashIndexEventSegment -> TagLink(word, true, backgroundColor, accountViewModel, nav) - is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) - is RegularTextSegment -> NormalWord(word.segmentText, style) - } + when (word) { + is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) + is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, accountViewModel) + is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) + is InvoiceSegment -> MayBeInvoicePreview(word.segmentText) + is WithdrawSegment -> MayBeWithdrawal(word.segmentText) + is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) + is EmailSegment -> ClickableEmail(word.segmentText) + is PhoneSegment -> ClickablePhone(word.segmentText) + is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav) + is HashTagSegment -> HashTag(word, nav) + is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) + is HashIndexEventSegment -> TagLink(word, true, backgroundColor, accountViewModel, nav) + is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) + is RegularTextSegment -> NormalWord(word.segmentText, style) + } } @Composable private fun ZoomableContentView( - word: String, - state: RichTextViewerState, - accountViewModel: AccountViewModel + word: String, + state: RichTextViewerState, + accountViewModel: AccountViewModel, ) { - state.imagesForPager[word]?.let { - Box(modifier = HalfVertPadding) { - ZoomableContentView(it, state.imageList, roundedCorner = true, accountViewModel) - } + state.imagesForPager[word]?.let { + Box(modifier = HalfVertPadding) { + ZoomableContentView(it, state.imageList, roundedCorner = true, accountViewModel) } + } } @Composable -private fun NormalWord(word: String, style: TextStyle) { - BasicText( - text = word, - style = style - ) +private fun NormalWord( + word: String, + style: TextStyle, +) { + BasicText( + text = word, + style = style, + ) } @Composable private fun NoProtocolUrlRenderer(word: SchemelessUrlSegment) { - RenderUrl(word) + RenderUrl(word) } @Composable private fun RenderUrl(segment: SchemelessUrlSegment) { - ClickableUrl(segment.url, "https://${segment.url}") - segment.extras?.let { it1 -> Text(it1) } + ClickableUrl(segment.url, "https://${segment.url}") + segment.extras?.let { it1 -> Text(it1) } } @Composable -fun RenderCustomEmoji(word: String, state: RichTextViewerState) { - CreateTextWithEmoji( - text = word, - emojis = state.customEmoji - ) +fun RenderCustomEmoji( + word: String, + state: RichTextViewerState, +) { + CreateTextWithEmoji( + text = word, + emojis = state.customEmoji, + ) } -val markdownParseOptions = MarkdownParseOptions( +val markdownParseOptions = + MarkdownParseOptions( autolink = true, - isImage = { url -> - isImageOrVideoUrl(url) - } -) + isImage = { url -> isImageOrVideoUrl(url) }, + ) @Composable -private fun RenderContentAsMarkdown(content: String, tags: ImmutableListOfLists?, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val uri = LocalUriHandler.current - val onClick = remember { - { link: String -> - val route = uriToRoute(link) - if (route != null) { - nav(route) - } else { - runCatching { uri.openUri(link) } - } - Unit - } +private fun RenderContentAsMarkdown( + content: String, + tags: ImmutableListOfLists?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val uri = LocalUriHandler.current + val onClick = remember { + { link: String -> + val route = uriToRoute(link) + if (route != null) { + nav(route) + } else { + runCatching { uri.openUri(link) } + } + Unit } + } - ProvideTextStyle(MarkdownTextStyle) { - Material3RichText(style = MaterialTheme.colorScheme.markdownStyle) { - RefreshableContent(content, tags, accountViewModel) { - Markdown( - content = it, - markdownParseOptions = markdownParseOptions, - onLinkClicked = onClick, - onMediaCompose = { title, destination -> - ZoomableContentView( - content = remember(destination) { - RichTextParser().parseMediaUrl(destination) - ?: ZoomableUrlImage(url = destination) - }, - roundedCorner = true, - accountViewModel = accountViewModel - ) - } - ) - } - } + ProvideTextStyle(MarkdownTextStyle) { + Material3RichText(style = MaterialTheme.colorScheme.markdownStyle) { + RefreshableContent(content, tags, accountViewModel) { + Markdown( + content = it, + markdownParseOptions = markdownParseOptions, + onLinkClicked = onClick, + onMediaCompose = { title, destination -> + ZoomableContentView( + content = + remember(destination) { + RichTextParser().parseMediaUrl(destination) ?: ZoomableUrlImage(url = destination) + }, + roundedCorner = true, + accountViewModel = accountViewModel, + ) + }, + ) + } } + } } @Composable -private fun RefreshableContent(content: String, tags: ImmutableListOfLists?, accountViewModel: AccountViewModel, onCompose: @Composable (String) -> Unit) { - var markdownWithSpecialContent by remember(content) { mutableStateOf(content) } +private fun RefreshableContent( + content: String, + tags: ImmutableListOfLists?, + accountViewModel: AccountViewModel, + onCompose: @Composable (String) -> Unit, +) { + var markdownWithSpecialContent by remember(content) { mutableStateOf(content) } - ObserverAllNIP19References(content, tags, accountViewModel) { - accountViewModel.returnMarkdownWithSpecialContent(content, tags) { newMarkdownWithSpecialContent -> - if (markdownWithSpecialContent != newMarkdownWithSpecialContent) { - markdownWithSpecialContent = newMarkdownWithSpecialContent - } - } + ObserverAllNIP19References(content, tags, accountViewModel) { + accountViewModel.returnMarkdownWithSpecialContent(content, tags) { + newMarkdownWithSpecialContent, + -> + if (markdownWithSpecialContent != newMarkdownWithSpecialContent) { + markdownWithSpecialContent = newMarkdownWithSpecialContent + } } + } - markdownWithSpecialContent?.let { - onCompose(it) - } + markdownWithSpecialContent?.let { onCompose(it) } } @Composable -fun ObserverAllNIP19References(content: String, tags: ImmutableListOfLists?, accountViewModel: AccountViewModel, onRefresh: () -> Unit) { - var nip19References by remember(content) { mutableStateOf>(emptyList()) } +fun ObserverAllNIP19References( + content: String, + tags: ImmutableListOfLists?, + accountViewModel: AccountViewModel, + onRefresh: () -> Unit, +) { + var nip19References by remember(content) { mutableStateOf>(emptyList()) } - LaunchedEffect(key1 = content) { - accountViewModel.returnNIP19References(content, tags) { - nip19References = it - onRefresh() - } + LaunchedEffect(key1 = content) { + accountViewModel.returnNIP19References(content, tags) { + nip19References = it + onRefresh() } + } - nip19References.forEach { - ObserveNIP19(it, accountViewModel, onRefresh) - } + nip19References.forEach { ObserveNIP19(it, accountViewModel, onRefresh) } } @Composable fun ObserveNIP19( - it: Nip19.Return, - accountViewModel: AccountViewModel, - onRefresh: () -> Unit + it: Nip19.Return, + accountViewModel: AccountViewModel, + onRefresh: () -> Unit, ) { - if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) { - ObserveNIP19Event(it, accountViewModel, onRefresh) - } else if (it.type == Nip19.Type.USER) { - ObserveNIP19User(it, accountViewModel, onRefresh) - } + if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) { + ObserveNIP19Event(it, accountViewModel, onRefresh) + } else if (it.type == Nip19.Type.USER) { + ObserveNIP19User(it, accountViewModel, onRefresh) + } } @Composable private fun ObserveNIP19Event( - it: Nip19.Return, - accountViewModel: AccountViewModel, - onRefresh: () -> Unit + it: Nip19.Return, + accountViewModel: AccountViewModel, + onRefresh: () -> Unit, ) { - var baseNote by remember(it) { mutableStateOf(accountViewModel.getNoteIfExists(it.hex)) } + var baseNote by remember(it) { mutableStateOf(accountViewModel.getNoteIfExists(it.hex)) } - if (baseNote == null) { - LaunchedEffect(key1 = it.hex) { - if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) { - accountViewModel.checkGetOrCreateNote(it.hex) { note -> - launch(Dispatchers.Main) { baseNote = note } - } - } + if (baseNote == null) { + LaunchedEffect(key1 = it.hex) { + if ( + it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS + ) { + accountViewModel.checkGetOrCreateNote(it.hex) { note -> + launch(Dispatchers.Main) { baseNote = note } } + } } + } - baseNote?.let { note -> - ObserveNote(note, onRefresh) - } + baseNote?.let { note -> ObserveNote(note, onRefresh) } } @Composable -fun ObserveNote(note: Note, onRefresh: () -> Unit) { - val loadedNoteId by note.live().metadata.observeAsState() +fun ObserveNote( + note: Note, + onRefresh: () -> Unit, +) { + val loadedNoteId by note.live().metadata.observeAsState() - LaunchedEffect(key1 = loadedNoteId) { - if (loadedNoteId != null) { - onRefresh() - } + LaunchedEffect(key1 = loadedNoteId) { + if (loadedNoteId != null) { + onRefresh() } + } } @Composable private fun ObserveNIP19User( - it: Nip19.Return, - accountViewModel: AccountViewModel, - onRefresh: () -> Unit + it: Nip19.Return, + accountViewModel: AccountViewModel, + onRefresh: () -> Unit, ) { - var baseUser by remember(it) { mutableStateOf(accountViewModel.getUserIfExists(it.hex)) } + var baseUser by remember(it) { mutableStateOf(accountViewModel.getUserIfExists(it.hex)) } - if (baseUser == null) { - LaunchedEffect(key1 = it.hex) { - if (it.type == Nip19.Type.USER) { - accountViewModel.checkGetOrCreateUser(it.hex)?.let { user -> - launch(Dispatchers.Main) { baseUser = user } - } - } + if (baseUser == null) { + LaunchedEffect(key1 = it.hex) { + if (it.type == Nip19.Type.USER) { + accountViewModel.checkGetOrCreateUser(it.hex)?.let { user -> + launch(Dispatchers.Main) { baseUser = user } } + } } + } - baseUser?.let { user -> - ObserveUser(user, onRefresh) - } + baseUser?.let { user -> ObserveUser(user, onRefresh) } } @Composable -private fun ObserveUser(user: User, onRefresh: () -> Unit) { - val loadedUserMetaId by user.live().metadata.observeAsState() +private fun ObserveUser( + user: User, + onRefresh: () -> Unit, +) { + val loadedUserMetaId by user.live().metadata.observeAsState() - LaunchedEffect(key1 = loadedUserMetaId) { - if (loadedUserMetaId != null) { - onRefresh() - } + LaunchedEffect(key1 = loadedUserMetaId) { + if (loadedUserMetaId != null) { + onRefresh() } + } } @Composable -fun BechLink(word: String, canPreview: Boolean, backgroundColor: MutableState, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - var loadedLink by remember { mutableStateOf(null) } +fun BechLink( + word: String, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + var loadedLink by remember { mutableStateOf(null) } - if (loadedLink == null) { - LaunchedEffect(key1 = word) { - accountViewModel.parseNIP19(word) { - loadedLink = it - } - } + if (loadedLink == null) { + LaunchedEffect(key1 = word) { accountViewModel.parseNIP19(word) { loadedLink = it } } + } + + if (canPreview && loadedLink?.baseNote != null) { + Row { + DisplayFullNote( + loadedLink?.baseNote!!, + accountViewModel, + backgroundColor, + nav, + loadedLink!!, + ) } + } else if (loadedLink?.nip19 != null) { + Row { ClickableRoute(loadedLink?.nip19!!, accountViewModel, nav) } + } else { + val text = + remember(word) { + if (word.length > 16) { + word.replaceRange(8, word.length - 8, ":") + } else { + word + } + } - if (canPreview && loadedLink?.baseNote != null) { - Row() { - DisplayFullNote( - loadedLink?.baseNote!!, - accountViewModel, - backgroundColor, - nav, - loadedLink!! - ) - } - } else if (loadedLink?.nip19 != null) { - Row() { - ClickableRoute(loadedLink?.nip19!!, accountViewModel, nav) - } - } else { - val text = remember(word) { - if (word.length > 16) { - word.replaceRange(8, word.length - 8, ":") - } else { - word - } - } - - Text(text = text, maxLines = 1) - } + Text(text = text, maxLines = 1) + } } @Composable private fun DisplayFullNote( - it: Note, - accountViewModel: AccountViewModel, - backgroundColor: MutableState, - nav: (String) -> Unit, - loadedLink: LoadedBechLink + it: Note, + accountViewModel: AccountViewModel, + backgroundColor: MutableState, + nav: (String) -> Unit, + loadedLink: LoadedBechLink, ) { - NoteCompose( - baseNote = it, - accountViewModel = accountViewModel, - modifier = MaterialTheme.colorScheme.replyModifier, - parentBackgroundColor = backgroundColor, - isQuotedNote = true, - nav = nav + NoteCompose( + baseNote = it, + accountViewModel = accountViewModel, + modifier = MaterialTheme.colorScheme.replyModifier, + parentBackgroundColor = backgroundColor, + isQuotedNote = true, + nav = nav, + ) + + val extraChars = remember(loadedLink) { loadedLink.nip19.additionalChars.ifBlank { null } } + + extraChars?.let { + Text( + it, ) - - val extraChars = remember(loadedLink) { - loadedLink.nip19.additionalChars.ifBlank { null } - } - - extraChars?.let { - Text( - it - ) - } + } } @Composable -fun HashTag(word: HashTagSegment, nav: (String) -> Unit) { - RenderHashtag(word, nav) +fun HashTag( + word: HashTagSegment, + nav: (String) -> Unit, +) { + RenderHashtag(word, nav) } @Composable private fun RenderHashtag( - segment: HashTagSegment, - nav: (String) -> Unit + segment: HashTagSegment, + nav: (String) -> Unit, ) { - val primary = MaterialTheme.colorScheme.primary - val background = MaterialTheme.colorScheme.onBackground - val hashtagIcon: HashtagIcon? = remember(segment.hashtag) { - checkForHashtagWithIcon(segment.hashtag, primary) - } + val primary = MaterialTheme.colorScheme.primary + val background = MaterialTheme.colorScheme.onBackground + val hashtagIcon: HashtagIcon? = + remember(segment.hashtag) { checkForHashtagWithIcon(segment.hashtag, primary) } - val regularText = remember { SpanStyle(color = background) } - val clickableTextStyle = remember { SpanStyle(color = primary) } + val regularText = remember { SpanStyle(color = background) } + val clickableTextStyle = remember { SpanStyle(color = primary) } - val annotatedTermsString = remember { - buildAnnotatedString { - withStyle(clickableTextStyle) { - pushStringAnnotation("routeToHashtag", "") - append("#${segment.hashtag}") - } + val annotatedTermsString = remember { + buildAnnotatedString { + withStyle(clickableTextStyle) { + pushStringAnnotation("routeToHashtag", "") + append("#${segment.hashtag}") + } - if (hashtagIcon != null) { - withStyle(clickableTextStyle) { - pushStringAnnotation("routeToHashtag", "") - appendInlineContent("inlineContent", "[icon]") - } - } - - segment.extras?.ifBlank { "" }?.let { - withStyle(regularText) { - append(it) - } - } + if (hashtagIcon != null) { + withStyle(clickableTextStyle) { + pushStringAnnotation("routeToHashtag", "") + appendInlineContent("inlineContent", "[icon]") } - } + } - val inlineContent = if (hashtagIcon != null) { - mapOf("inlineContent" to InlineIcon(hashtagIcon)) + segment.extras?.ifBlank { "" }?.let { withStyle(regularText) { append(it) } } + } + } + + val inlineContent = + if (hashtagIcon != null) { + mapOf("inlineContent" to InlineIcon(hashtagIcon)) } else { - emptyMap() + emptyMap() } - val pressIndicator = remember { - Modifier.clickable { - nav("Hashtag/${segment.hashtag}") - } - } + val pressIndicator = remember { Modifier.clickable { nav("Hashtag/${segment.hashtag}") } } - Text( - text = annotatedTermsString, - modifier = pressIndicator, - inlineContent = inlineContent - ) + Text( + text = annotatedTermsString, + modifier = pressIndicator, + inlineContent = inlineContent, + ) } @Composable private fun InlineIcon(hashtagIcon: HashtagIcon) = - InlineTextContent( - Placeholder( - width = Font17SP, - height = Font17SP, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center - ) - ) { - Icon( - painter = painterResource(hashtagIcon.icon), - contentDescription = hashtagIcon.description, - tint = hashtagIcon.color, - modifier = hashtagIcon.modifier - ) - } + InlineTextContent( + Placeholder( + width = Font17SP, + height = Font17SP, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ), + ) { + Icon( + painter = painterResource(hashtagIcon.icon), + contentDescription = hashtagIcon.description, + tint = hashtagIcon.color, + modifier = hashtagIcon.modifier, + ) + } @Composable -fun TagLink(word: HashIndexUserSegment, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - LoadUser(baseUserHex = word.hex, accountViewModel) { - if (it == null) { - Text(text = word.segmentText) - } else { - Row() { - DisplayUserFromTag(it, word.extras, nav) - } - } +fun TagLink( + word: HashIndexUserSegment, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + LoadUser(baseUserHex = word.hex, accountViewModel) { + if (it == null) { + Text(text = word.segmentText) + } else { + Row { DisplayUserFromTag(it, word.extras, nav) } } + } } @Composable -fun LoadNote(baseNoteHex: String, accountViewModel: AccountViewModel, content: @Composable (Note?) -> Unit) { - var note by remember(baseNoteHex) { - mutableStateOf(accountViewModel.getNoteIfExists(baseNoteHex)) - } +fun LoadNote( + baseNoteHex: String, + accountViewModel: AccountViewModel, + content: @Composable (Note?) -> Unit, +) { + var note by + remember(baseNoteHex) { mutableStateOf(accountViewModel.getNoteIfExists(baseNoteHex)) } - if (note == null) { - LaunchedEffect(key1 = baseNoteHex) { - accountViewModel.checkGetOrCreateNote(baseNoteHex) { - note = it - } - } + if (note == null) { + LaunchedEffect(key1 = baseNoteHex) { + accountViewModel.checkGetOrCreateNote(baseNoteHex) { note = it } } + } - content(note) + content(note) } @Composable -fun TagLink(word: HashIndexEventSegment, canPreview: Boolean, backgroundColor: MutableState, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - LoadNote(baseNoteHex = word.hex, accountViewModel) { - if (it == null) { - Text(text = remember { word.segmentText.toShortenHex() }) - } else { - Row() { - DisplayNoteFromTag( - it, - word.extras, - canPreview, - accountViewModel, - backgroundColor, - nav - ) - } - } +fun TagLink( + word: HashIndexEventSegment, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + LoadNote(baseNoteHex = word.hex, accountViewModel) { + if (it == null) { + Text(text = remember { word.segmentText.toShortenHex() }) + } else { + Row { + DisplayNoteFromTag( + it, + word.extras, + canPreview, + accountViewModel, + backgroundColor, + nav, + ) + } } + } } @Composable private fun DisplayNoteFromTag( - baseNote: Note, - addedChars: String?, - canPreview: Boolean, - accountViewModel: AccountViewModel, - backgroundColor: MutableState, - nav: (String) -> Unit + baseNote: Note, + addedChars: String?, + canPreview: Boolean, + accountViewModel: AccountViewModel, + backgroundColor: MutableState, + nav: (String) -> Unit, ) { - if (canPreview) { - NoteCompose( - baseNote = baseNote, - accountViewModel = accountViewModel, - modifier = MaterialTheme.colorScheme.innerPostModifier, - parentBackgroundColor = backgroundColor, - isQuotedNote = true, - nav = nav - ) - } else { - ClickableNoteTag(baseNote, nav) - } + if (canPreview) { + NoteCompose( + baseNote = baseNote, + accountViewModel = accountViewModel, + modifier = MaterialTheme.colorScheme.innerPostModifier, + parentBackgroundColor = backgroundColor, + isQuotedNote = true, + nav = nav, + ) + } else { + ClickableNoteTag(baseNote, nav) + } - addedChars?.ifBlank { null }?.let { - Text(text = it) - } + addedChars?.ifBlank { null }?.let { Text(text = it) } } @Composable private fun DisplayUserFromTag( - baseUser: User, - addedChars: String?, - nav: (String) -> Unit + baseUser: User, + addedChars: String?, + nav: (String) -> Unit, ) { - val route = remember { "User/${baseUser.pubkeyHex}" } - val hex = remember { baseUser.pubkeyDisplayHex() } + val route = remember { "User/${baseUser.pubkeyHex}" } + val hex = remember { baseUser.pubkeyDisplayHex() } - val meta by baseUser.live().userMetadataInfo.observeAsState(baseUser.info) + val meta by baseUser.live().userMetadataInfo.observeAsState(baseUser.info) - Crossfade(targetState = meta) { - Row() { - val displayName = remember(it) { - it?.bestDisplayName() ?: it?.bestUsername() ?: hex - } - CreateClickableTextWithEmoji( - clickablePart = displayName, - suffix = addedChars, - maxLines = 1, - route = route, - nav = nav, - tags = it?.tags - ) - } + Crossfade(targetState = meta) { + Row { + val displayName = remember(it) { it?.bestDisplayName() ?: it?.bestUsername() ?: hex } + CreateClickableTextWithEmoji( + clickablePart = displayName, + suffix = addedChars, + maxLines = 1, + route = route, + nav = nav, + tags = it?.tags, + ) } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt index 95f1803f4..e20fffe69 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt @@ -1,58 +1,91 @@ -package com.vitorpamplona.amethyst.ui.components - -import android.content.Context -import android.net.Uri -import androidx.compose.runtime.Stable -import coil.ImageLoader -import coil.decode.DataSource -import coil.decode.ImageSource -import coil.fetch.Fetcher -import coil.fetch.SourceResult -import coil.request.ImageRequest -import coil.request.Options -import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.quartz.utils.Robohash -import okio.Buffer -import java.nio.charset.Charset - -@Stable -class HashImageFetcher( - private val context: Context, - private val isLightTheme: Boolean, - private val data: Uri -) : Fetcher { - - override suspend fun fetch(): SourceResult { - checkNotInMainThread() - val source = try { - val buffer = Buffer() - buffer.writeString(Robohash.assemble(data.toString(), isLightTheme), Charset.defaultCharset()) - buffer - } finally { - } - - return SourceResult( - source = ImageSource(source, context), - mimeType = "image/svg+xml", - dataSource = DataSource.MEMORY - ) - } - - object Factory : Fetcher.Factory { - override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher { - return HashImageFetcher(options.context, options.parameters.value("isLightTheme") ?: true, data) - } - } -} - -object RobohashImageRequest { - fun build(context: Context, message: String, isLightTheme: Boolean): ImageRequest { - return ImageRequest - .Builder(context) - .data(message) - .fetcherFactory(HashImageFetcher.Factory) - .setParameter("isLightTheme", isLightTheme) - .addHeader("Cache-Control", "max-age=31536000") - .build() - } -} +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components + +import android.content.Context +import android.net.Uri +import androidx.compose.runtime.Stable +import coil.ImageLoader +import coil.decode.DataSource +import coil.decode.ImageSource +import coil.fetch.Fetcher +import coil.fetch.SourceResult +import coil.request.ImageRequest +import coil.request.Options +import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.quartz.utils.Robohash +import java.nio.charset.Charset +import okio.Buffer + +@Stable +class HashImageFetcher( + private val context: Context, + private val isLightTheme: Boolean, + private val data: Uri, +) : Fetcher { + override suspend fun fetch(): SourceResult { + checkNotInMainThread() + val source = + try { + val buffer = Buffer() + buffer.writeString( + Robohash.assemble(data.toString(), isLightTheme), + Charset.defaultCharset(), + ) + buffer + } finally {} + + return SourceResult( + source = ImageSource(source, context), + mimeType = "image/svg+xml", + dataSource = DataSource.MEMORY, + ) + } + + object Factory : Fetcher.Factory { + override fun create( + data: Uri, + options: Options, + imageLoader: ImageLoader, + ): Fetcher { + return HashImageFetcher( + options.context, + options.parameters.value("isLightTheme") ?: true, + data, + ) + } + } +} + +object RobohashImageRequest { + fun build( + context: Context, + message: String, + isLightTheme: Boolean, + ): ImageRequest { + return ImageRequest.Builder(context) + .data(message) + .fetcherFactory(HashImageFetcher.Factory) + .setParameter("isLightTheme", isLightTheme) + .addHeader("Cache-Control", "max-age=31536000") + .build() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt index 80c544275..79be719c4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import android.content.Context @@ -35,142 +55,141 @@ import java.util.Base64 @Composable fun RobohashAsyncImage( - robot: String, - modifier: Modifier = Modifier, - contentDescription: String? = null, - transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = AsyncImagePainter.DefaultTransform, - onState: ((AsyncImagePainter.State) -> Unit)? = null, - alignment: Alignment = Alignment.Center, - contentScale: ContentScale = ContentScale.Fit, - alpha: Float = DefaultAlpha, - colorFilter: ColorFilter? = null, - filterQuality: FilterQuality = DrawScope.DefaultFilterQuality + robot: String, + modifier: Modifier = Modifier, + contentDescription: String? = null, + transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = + AsyncImagePainter.DefaultTransform, + onState: ((AsyncImagePainter.State) -> Unit)? = null, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + alpha: Float = DefaultAlpha, + colorFilter: ColorFilter? = null, + filterQuality: FilterQuality = DrawScope.DefaultFilterQuality, ) { - val context = LocalContext.current - val isLightTheme = MaterialTheme.colorScheme.isLight + val context = LocalContext.current + val isLightTheme = MaterialTheme.colorScheme.isLight - val imageRequest = remember(robot) { - RobohashImageRequest.build(context, robot, isLightTheme) - } + val imageRequest = remember(robot) { RobohashImageRequest.build(context, robot, isLightTheme) } - AsyncImage( - model = imageRequest, - contentDescription = contentDescription, - modifier = modifier, - transform = transform, - onState = onState, - alignment = alignment, - contentScale = contentScale, - alpha = alpha, - colorFilter = colorFilter, - filterQuality = filterQuality - ) + AsyncImage( + model = imageRequest, + contentDescription = contentDescription, + modifier = modifier, + transform = transform, + onState = onState, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + filterQuality = filterQuality, + ) } @Composable fun RobohashFallbackAsyncImage( - robot: String, - model: String?, - contentDescription: String?, - modifier: Modifier = Modifier, - alignment: Alignment = Alignment.Center, - contentScale: ContentScale = ContentScale.Fit, - alpha: Float = DefaultAlpha, - colorFilter: ColorFilter? = null, - filterQuality: FilterQuality = DrawScope.DefaultFilterQuality, - loadProfilePicture: Boolean + robot: String, + model: String?, + contentDescription: String?, + modifier: Modifier = Modifier, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + alpha: Float = DefaultAlpha, + colorFilter: ColorFilter? = null, + filterQuality: FilterQuality = DrawScope.DefaultFilterQuality, + loadProfilePicture: Boolean, ) { - val context = LocalContext.current - val isLightTheme = MaterialTheme.colorScheme.isLight - val painter = rememberAsyncImagePainter( - model = RobohashImageRequest.build(context, robot, isLightTheme) + val context = LocalContext.current + val isLightTheme = MaterialTheme.colorScheme.isLight + val painter = + rememberAsyncImagePainter( + model = RobohashImageRequest.build(context, robot, isLightTheme), ) - if (model != null && loadProfilePicture) { - val isBase64 by remember { - derivedStateOf { - model.startsWith("data:image/jpeg;base64,") - } - } + if (model != null && loadProfilePicture) { + val isBase64 by remember { derivedStateOf { model.startsWith("data:image/jpeg;base64,") } } - if (isBase64) { - val base64Painter = rememberAsyncImagePainter( - model = Base64Requester.imageRequest(context, model) - ) - - Image( - painter = base64Painter, - contentDescription = null, - modifier = modifier, - alignment = alignment, - contentScale = contentScale, - colorFilter = colorFilter - ) - } else { - AsyncImage( - model = model, - contentDescription = contentDescription, - modifier = modifier, - placeholder = painter, - fallback = painter, - error = painter, - alignment = alignment, - contentScale = contentScale, - alpha = alpha, - colorFilter = colorFilter, - filterQuality = filterQuality - ) - } - } else { - Image( - painter = painter, - contentDescription = contentDescription, - modifier = modifier, - alignment = alignment, - contentScale = contentScale, - colorFilter = colorFilter + if (isBase64) { + val base64Painter = + rememberAsyncImagePainter( + model = Base64Requester.imageRequest(context, model), ) + + Image( + painter = base64Painter, + contentDescription = null, + modifier = modifier, + alignment = alignment, + contentScale = contentScale, + colorFilter = colorFilter, + ) + } else { + AsyncImage( + model = model, + contentDescription = contentDescription, + modifier = modifier, + placeholder = painter, + fallback = painter, + error = painter, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + filterQuality = filterQuality, + ) } + } else { + Image( + painter = painter, + contentDescription = contentDescription, + modifier = modifier, + alignment = alignment, + contentScale = contentScale, + colorFilter = colorFilter, + ) + } } object Base64Requester { - fun imageRequest(context: Context, message: String): ImageRequest { - return ImageRequest - .Builder(context) - .data(message) - .fetcherFactory(Base64Fetcher.Factory) - .build() - } + fun imageRequest( + context: Context, + message: String, + ): ImageRequest { + return ImageRequest.Builder(context).data(message).fetcherFactory(Base64Fetcher.Factory).build() + } } @Stable class Base64Fetcher( - private val options: Options, - private val data: Uri + private val options: Options, + private val data: Uri, ) : Fetcher { + override suspend fun fetch(): FetchResult { + checkNotInMainThread() - override suspend fun fetch(): FetchResult { - checkNotInMainThread() + val base64String = data.toString().removePrefix("data:image/jpeg;base64,") - val base64String = data.toString().removePrefix("data:image/jpeg;base64,") + val byteArray = Base64.getDecoder().decode(base64String) + val bitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) - val byteArray = Base64.getDecoder().decode(base64String) - val bitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) - - if (bitmap == null) { - throw Exception("Unable to load base64 $base64String") - } - - return DrawableResult( - drawable = bitmap.toDrawable(options.context.resources), - isSampled = false, - dataSource = DataSource.MEMORY - ) + if (bitmap == null) { + throw Exception("Unable to load base64 $base64String") } - object Factory : Fetcher.Factory { - override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher { - return Base64Fetcher(options, data) - } + return DrawableResult( + drawable = bitmap.toDrawable(options.context.resources), + isSampled = false, + dataSource = DataSource.MEMORY, + ) + } + + object Factory : Fetcher.Factory { + override fun create( + data: Uri, + options: Options, + imageLoader: ImageLoader, + ): Fetcher { + return Base64Fetcher(options, data) } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SelectTextDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SelectTextDialog.kt index cfdf7164a..c7d130df6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SelectTextDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SelectTextDialog.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import android.content.res.Configuration @@ -25,44 +45,43 @@ import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.theme.Size24dp @Composable -fun SelectTextDialog(text: String, onDismiss: () -> Unit) { - val screenHeight = LocalConfiguration.current.screenHeightDp.dp - val maxHeight = - if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT) { - screenHeight * 0.6f - } else { - screenHeight * 0.9f - } - - Dialog( - onDismissRequest = onDismiss - ) { - Card { - Column( - modifier = Modifier.heightIn(Size24dp, maxHeight) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End - ) { - IconButton( - onClick = onDismiss - ) { - ArrowBackIcon() - } - Text(text = stringResource(R.string.select_text_dialog_top)) - } - Divider() - Column( - modifier = Modifier.verticalScroll(rememberScrollState()) - ) { - Row(modifier = Modifier.padding(16.dp)) { - SelectionContainer { - Text(text) - } - } - } - } - } +fun SelectTextDialog( + text: String, + onDismiss: () -> Unit, +) { + val screenHeight = LocalConfiguration.current.screenHeightDp.dp + val maxHeight = + if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT) { + screenHeight * 0.6f + } else { + screenHeight * 0.9f } + + Dialog( + onDismissRequest = onDismiss, + ) { + Card { + Column( + modifier = Modifier.heightIn(Size24dp, maxHeight), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End, + ) { + IconButton( + onClick = onDismiss, + ) { + ArrowBackIcon() + } + Text(text = stringResource(R.string.select_text_dialog_top)) + } + Divider() + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + ) { + Row(modifier = Modifier.padding(16.dp)) { SelectionContainer { Text(text) } } + } + } + } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt index 7363716b0..cd6c432d4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import androidx.compose.animation.Crossfade @@ -41,117 +61,106 @@ import com.vitorpamplona.quartz.events.EventInterface @Composable fun SensitivityWarning( - note: Note, - accountViewModel: AccountViewModel, - content: @Composable () -> Unit + note: Note, + accountViewModel: AccountViewModel, + content: @Composable () -> Unit, ) { - note.event?.let { - SensitivityWarning(it, accountViewModel, content) - } + note.event?.let { SensitivityWarning(it, accountViewModel, content) } } @Composable fun SensitivityWarning( - event: EventInterface, - accountViewModel: AccountViewModel, - content: @Composable () -> Unit + event: EventInterface, + accountViewModel: AccountViewModel, + content: @Composable () -> Unit, ) { - val hasSensitiveContent = remember(event) { event.isSensitive() ?: false } + val hasSensitiveContent = remember(event) { event.isSensitive() ?: false } - if (hasSensitiveContent) { - SensitivityWarning(accountViewModel, content) + if (hasSensitiveContent) { + SensitivityWarning(accountViewModel, content) + } else { + content() + } +} + +@Composable +fun SensitivityWarning( + accountViewModel: AccountViewModel, + content: @Composable () -> Unit, +) { + val accountState by accountViewModel.accountLiveData.observeAsState() + + var showContentWarningNote by + remember(accountState) { mutableStateOf(accountState?.account?.showSensitiveContent != true) } + + Crossfade(targetState = showContentWarningNote) { + if (it) { + ContentWarningNote { showContentWarningNote = false } } else { - content() - } -} - -@Composable -fun SensitivityWarning( - accountViewModel: AccountViewModel, - content: @Composable () -> Unit -) { - val accountState by accountViewModel.accountLiveData.observeAsState() - - var showContentWarningNote by remember(accountState) { - mutableStateOf(accountState?.account?.showSensitiveContent != true) - } - - Crossfade(targetState = showContentWarningNote) { - if (it) { - ContentWarningNote() { - showContentWarningNote = false - } - } else { - content() - } + content() } + } } @Composable fun ContentWarningNote(onDismiss: () -> Unit) { - Column() { - Row(modifier = Modifier.padding(horizontal = 12.dp)) { - Column(modifier = Modifier.padding(start = 10.dp)) { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - Box( - Modifier - .height(80.dp) - .width(90.dp) - ) { - Icon( - imageVector = Icons.Default.Visibility, - contentDescription = stringResource(R.string.content_warning), - modifier = Modifier - .size(70.dp) - .align(Alignment.BottomStart), - tint = MaterialTheme.colorScheme.onBackground - ) - Icon( - imageVector = Icons.Rounded.Warning, - contentDescription = stringResource(R.string.content_warning), - modifier = Modifier - .size(30.dp) - .align(Alignment.TopEnd), - tint = MaterialTheme.colorScheme.onBackground - ) - } - } - - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - Text( - text = stringResource(R.string.content_warning), - fontWeight = FontWeight.Bold, - fontSize = 18.sp - ) - } - - Row() { - Text( - text = stringResource(R.string.content_warning_explanation), - color = Color.Gray, - modifier = Modifier.padding(top = 10.dp), - textAlign = TextAlign.Center - ) - } - - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - Button( - modifier = Modifier.padding(top = 10.dp), - onClick = onDismiss, - shape = ButtonBorder, - colors = ButtonDefaults - .buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ), - contentPadding = ButtonPadding - ) { - Text( - text = stringResource(R.string.show_anyway), - color = Color.White - ) - } - } - } + Column { + Row(modifier = Modifier.padding(horizontal = 12.dp)) { + Column(modifier = Modifier.padding(start = 10.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + Box( + Modifier.height(80.dp).width(90.dp), + ) { + Icon( + imageVector = Icons.Default.Visibility, + contentDescription = stringResource(R.string.content_warning), + modifier = Modifier.size(70.dp).align(Alignment.BottomStart), + tint = MaterialTheme.colorScheme.onBackground, + ) + Icon( + imageVector = Icons.Rounded.Warning, + contentDescription = stringResource(R.string.content_warning), + modifier = Modifier.size(30.dp).align(Alignment.TopEnd), + tint = MaterialTheme.colorScheme.onBackground, + ) + } } + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + Text( + text = stringResource(R.string.content_warning), + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ) + } + + Row { + Text( + text = stringResource(R.string.content_warning_explanation), + color = Color.Gray, + modifier = Modifier.padding(top = 10.dp), + textAlign = TextAlign.Center, + ) + } + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + Button( + modifier = Modifier.padding(top = 10.dp), + onClick = onDismiss, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Text( + text = stringResource(R.string.show_anyway), + color = Color.White, + ) + } + } + } } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt index df51f7bbc..6932e7e4d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import androidx.compose.foundation.ExperimentalFoundationApi @@ -29,75 +49,66 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText @OptIn(ExperimentalFoundationApi::class) @Composable fun SlidingCarousel( - pagerState: PagerState, - modifier: Modifier = Modifier, - itemContent: @Composable (index: Int) -> Unit + pagerState: PagerState, + modifier: Modifier = Modifier, + itemContent: @Composable (index: Int) -> Unit, ) { - val isDragged by pagerState.interactionSource.collectIsDraggedAsState() + val isDragged by pagerState.interactionSource.collectIsDraggedAsState() - Box( - modifier = modifier.fillMaxWidth() + Box( + modifier = modifier.fillMaxWidth(), + ) { + HorizontalPager(state = pagerState) { page -> itemContent(page) } + + // you can remove the surface in case you don't want + // the transparent bacground + Surface( + modifier = Modifier.padding(bottom = 8.dp).align(Alignment.BottomCenter), + shape = CircleShape, + color = Color.Black.copy(alpha = 0.5f), ) { - HorizontalPager(state = pagerState) { page -> - itemContent(page) - } - - // you can remove the surface in case you don't want - // the transparent bacground - Surface( - modifier = Modifier - .padding(bottom = 8.dp) - .align(Alignment.BottomCenter), - shape = CircleShape, - color = Color.Black.copy(alpha = 0.5f) - ) { - DotsIndicator( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), - totalDots = pagerState.pageCount, - selectedIndex = if (isDragged) pagerState.currentPage else pagerState.targetPage, - dotSize = 8.dp - ) - } + DotsIndicator( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + totalDots = pagerState.pageCount, + selectedIndex = if (isDragged) pagerState.currentPage else pagerState.targetPage, + dotSize = 8.dp, + ) } + } } @Composable fun DotsIndicator( - modifier: Modifier = Modifier, - totalDots: Int, - selectedIndex: Int, - selectedColor: Color = MaterialTheme.colorScheme.primary /* Color.Yellow */, - unSelectedColor: Color = MaterialTheme.colorScheme.placeholderText /* Color.Gray */, - dotSize: Dp + modifier: Modifier = Modifier, + totalDots: Int, + selectedIndex: Int, + selectedColor: Color = MaterialTheme.colorScheme.primary, + unSelectedColor: Color = MaterialTheme.colorScheme.placeholderText, + dotSize: Dp, ) { - LazyRow( - modifier = modifier - .wrapContentWidth() - .wrapContentHeight() - ) { - items(totalDots) { index -> - IndicatorDot( - color = if (index == selectedIndex) selectedColor else unSelectedColor, - size = dotSize - ) + LazyRow( + modifier = modifier.wrapContentWidth().wrapContentHeight(), + ) { + items(totalDots) { index -> + IndicatorDot( + color = if (index == selectedIndex) selectedColor else unSelectedColor, + size = dotSize, + ) - if (index != totalDots - 1) { - Spacer(modifier = Modifier.padding(horizontal = 2.dp)) - } - } + if (index != totalDots - 1) { + Spacer(modifier = Modifier.padding(horizontal = 2.dp)) + } } + } } @Composable fun IndicatorDot( - modifier: Modifier = Modifier, - size: Dp, - color: Color + modifier: Modifier = Modifier, + size: Dp, + color: Color, ) { - Box( - modifier = modifier - .size(size) - .clip(CircleShape) - .background(color) - ) + Box( + modifier = modifier.size(size).clip(CircleShape).background(color), + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SplitItem.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SplitItem.kt index a5569e45b..59ae669d8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SplitItem.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SplitItem.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import androidx.compose.runtime.getValue @@ -6,94 +26,100 @@ import androidx.compose.runtime.setValue import kotlin.math.abs class SplitItem(val key: T) { - var percentage by mutableStateOf(0f) + var percentage by mutableStateOf(0f) } class Split() { - var items: List> by mutableStateOf(emptyList()) + var items: List> by mutableStateOf(emptyList()) - fun addItem(key: T): Int { - val wasEqualSplit = isEqualSplit() - val newItem = SplitItem(key) - items = items.plus(newItem) + fun addItem(key: T): Int { + val wasEqualSplit = isEqualSplit() + val newItem = SplitItem(key) + items = items.plus(newItem) - if (wasEqualSplit) { - forceEqualSplit() + if (wasEqualSplit) { + forceEqualSplit() + } else { + updatePercentage(items.lastIndex, equalSplit()) + } + + return items.lastIndex + } + + fun equalSplit() = 1f / items.size + + fun isEqualSplit(): Boolean { + val expectedPercentage = equalSplit() + return items.all { (it.percentage - expectedPercentage) < 0.01 } + } + + fun forceEqualSplit() { + val correctPercentage = equalSplit() + items.forEach { it.percentage = correctPercentage } + } + + fun updatePercentage( + index: Int, + percentage: Float, + ) { + if (items.isEmpty()) return + + val splitItem = items.getOrNull(index) ?: return + + if (items.size == 1) { + splitItem.percentage = 1f + } else { + splitItem.percentage = percentage + + println("Update ${items[index].key} to $percentage") + + val othersMustShare = 1.0f - splitItem.percentage + + val othersHave = + items.sumOf { if (it == splitItem) 0.0 else it.percentage.toDouble() }.toFloat() + + if (abs(othersHave - othersMustShare) < 0.01) return // nothing to do + + println("Others Must Share $othersMustShare but have $othersHave") + + bottomUpAdjustment(othersMustShare, othersHave, index) + } + } + + private fun bottomUpAdjustment( + othersMustShare: Float, + othersHave: Float, + exceptForIndex: Int, + ) { + var needToRemove = othersHave - othersMustShare + if (needToRemove > 0) { + for (i in items.indices.reversed()) { + if (i == exceptForIndex) continue // do not update the current item + + if (needToRemove < items[i].percentage) { + val oldValue = items[i].percentage + items[i].percentage -= needToRemove + needToRemove = 0f + println( + "- Updating ${items[i].key} from $oldValue to ${items[i].percentage - needToRemove}. $needToRemove left", + ) } else { - updatePercentage(items.lastIndex, equalSplit()) + val oldValue = items[i].percentage + needToRemove -= items[i].percentage + items[i].percentage = 0f + println("- Updating ${items[i].key} from $oldValue to ${0}. $needToRemove left") } - return items.lastIndex - } - - fun equalSplit() = 1f / items.size - - fun isEqualSplit(): Boolean { - val expectedPercentage = equalSplit() - return items.all { (it.percentage - expectedPercentage) < 0.01 } - } - - fun forceEqualSplit() { - val correctPercentage = equalSplit() - items.forEach { - it.percentage = correctPercentage - } - } - - fun updatePercentage(index: Int, percentage: Float) { - if (items.isEmpty()) return - - val splitItem = items.getOrNull(index) ?: return - - if (items.size == 1) { - splitItem.percentage = 1f - } else { - splitItem.percentage = percentage - - println("Update ${items[index].key} to $percentage") - - val othersMustShare = 1.0f - splitItem.percentage - - val othersHave = items.sumOf { - if (it == splitItem) 0.0 else it.percentage.toDouble() - }.toFloat() - - if (abs(othersHave - othersMustShare) < 0.01) return // nothing to do - - println("Others Must Share $othersMustShare but have $othersHave") - - bottomUpAdjustment(othersMustShare, othersHave, index) - } - } - - private fun bottomUpAdjustment(othersMustShare: Float, othersHave: Float, exceptForIndex: Int) { - var needToRemove = othersHave - othersMustShare - if (needToRemove > 0) { - for (i in items.indices.reversed()) { - if (i == exceptForIndex) continue // do not update the current item - - if (needToRemove < items[i].percentage) { - val oldValue = items[i].percentage - items[i].percentage -= needToRemove - needToRemove = 0f - println("- Updating ${items[i].key} from $oldValue to ${items[i].percentage - needToRemove}. $needToRemove left") - } else { - val oldValue = items[i].percentage - needToRemove -= items[i].percentage - items[i].percentage = 0f - println("- Updating ${items[i].key} from $oldValue to ${0}. $needToRemove left") - } - - if (needToRemove < 0.01) { - break - } - } - } else if (needToRemove < 0) { - if (items.lastIndex == exceptForIndex) { - items[items.lastIndex - 1].percentage += -needToRemove - } else { - items.last().percentage += -needToRemove - } + if (needToRemove < 0.01) { + break } + } + } else if (needToRemove < 0) { + if (items.lastIndex == exceptForIndex) { + items[items.lastIndex - 1].percentage += -needToRemove + } else { + items.last().percentage += -needToRemove + } } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt index de5f26254..5fe5a2cb6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt @@ -1,198 +1,203 @@ -package com.vitorpamplona.amethyst.ui.screen.loggedIn - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -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.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Divider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -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.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.Font14SP -import kotlinx.collections.immutable.ImmutableList - -@Composable -fun TextSpinner( - label: String?, - placeholder: String, - options: ImmutableList, - onSelect: (Int) -> Unit, - modifier: Modifier = Modifier -) { - TextSpinner( - placeholder, - options, - onSelect, - modifier - ) { currentOption, modifier -> - OutlinedTextField( - value = currentOption, - onValueChange = {}, - readOnly = true, - label = { label?.let { Text(it) } }, - modifier = modifier - ) - } -} - -@Composable -fun TextSpinner( - placeholder: String, - options: ImmutableList, - onSelect: (Int) -> Unit, - modifier: Modifier = Modifier, - mainElement: @Composable (currentOption: String, modifier: Modifier) -> Unit -) { - val focusRequester = remember { FocusRequester() } - val interactionSource = remember { MutableInteractionSource() } - var optionsShowing by remember { mutableStateOf(false) } - var currentText by remember { mutableStateOf(placeholder) } - - Box( - modifier = modifier, - contentAlignment = Alignment.Center - ) { - mainElement( - currentText, - remember { - Modifier - .fillMaxWidth() - .focusRequester(focusRequester) - } - ) - Box( - modifier = Modifier - .matchParentSize() - .clickable( - interactionSource = interactionSource, - indication = null - ) { - optionsShowing = true - focusRequester.requestFocus() - } - ) - } - - if (optionsShowing) { - options.isNotEmpty().also { - SpinnerSelectionDialog(options = options, onDismiss = { optionsShowing = false }) { - currentText = options[it].title - optionsShowing = false - onSelect(it) - } - } - } -} - -@Composable -fun SpinnerSelectionDialog( - title: String? = null, - options: ImmutableList, - onDismiss: () -> Unit, - onSelect: (Int) -> Unit -) { - SpinnerSelectionDialog( - title = title, - options = options, - onSelect = onSelect, - onDismiss = onDismiss - ) { item -> - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = item.title, color = MaterialTheme.colorScheme.onSurface) - } - item.explainer?.let { - Spacer(modifier = Modifier.height(5.dp)) - Row( - horizontalArrangement = Arrangement.Start, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = it, color = Color.Gray, fontSize = Font14SP) - } - } - } -} - -@Composable -fun SpinnerSelectionDialog( - title: String? = null, - options: ImmutableList, - onSelect: (Int) -> Unit, - onDismiss: () -> Unit, - onRenderItem: @Composable (T) -> Unit -) { - Dialog(onDismissRequest = onDismiss) { - Surface( - border = BorderStroke(0.25.dp, Color.LightGray), - shape = RoundedCornerShape(5.dp) - ) { - LazyColumn() { - title?.let { - item { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp, 16.dp), - horizontalArrangement = Arrangement.Center - ) { - Text( - text = title, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Bold - ) - } - Divider(color = Color.LightGray, thickness = DividerThickness) - } - } - itemsIndexed(options) { index, item -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - onSelect(index) - } - .padding(16.dp, 16.dp) - ) { - Column() { - onRenderItem(item) - } - } - if (index < options.lastIndex) { - Divider(color = Color.LightGray, thickness = DividerThickness) - } - } - } - } - } -} - -@Immutable -data class TitleExplainer(val title: String, val explainer: String? = null) +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Divider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.Font14SP +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun TextSpinner( + label: String?, + placeholder: String, + options: ImmutableList, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + TextSpinner( + placeholder, + options, + onSelect, + modifier, + ) { currentOption, modifier -> + OutlinedTextField( + value = currentOption, + onValueChange = {}, + readOnly = true, + label = { label?.let { Text(it) } }, + modifier = modifier, + ) + } +} + +@Composable +fun TextSpinner( + placeholder: String, + options: ImmutableList, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, + mainElement: @Composable (currentOption: String, modifier: Modifier) -> Unit, +) { + val focusRequester = remember { FocusRequester() } + val interactionSource = remember { MutableInteractionSource() } + var optionsShowing by remember { mutableStateOf(false) } + var currentText by remember { mutableStateOf(placeholder) } + + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + mainElement( + currentText, + remember { Modifier.fillMaxWidth().focusRequester(focusRequester) }, + ) + Box( + modifier = + Modifier.matchParentSize().clickable( + interactionSource = interactionSource, + indication = null, + ) { + optionsShowing = true + focusRequester.requestFocus() + }, + ) + } + + if (optionsShowing) { + options.isNotEmpty().also { + SpinnerSelectionDialog(options = options, onDismiss = { optionsShowing = false }) { + currentText = options[it].title + optionsShowing = false + onSelect(it) + } + } + } +} + +@Composable +fun SpinnerSelectionDialog( + title: String? = null, + options: ImmutableList, + onDismiss: () -> Unit, + onSelect: (Int) -> Unit, +) { + SpinnerSelectionDialog( + title = title, + options = options, + onSelect = onSelect, + onDismiss = onDismiss, + ) { item -> + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = item.title, color = MaterialTheme.colorScheme.onSurface) + } + item.explainer?.let { + Spacer(modifier = Modifier.height(5.dp)) + Row( + horizontalArrangement = Arrangement.Start, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = it, color = Color.Gray, fontSize = Font14SP) + } + } + } +} + +@Composable +fun SpinnerSelectionDialog( + title: String? = null, + options: ImmutableList, + onSelect: (Int) -> Unit, + onDismiss: () -> Unit, + onRenderItem: @Composable (T) -> Unit, +) { + Dialog(onDismissRequest = onDismiss) { + Surface( + border = BorderStroke(0.25.dp, Color.LightGray), + shape = RoundedCornerShape(5.dp), + ) { + LazyColumn { + title?.let { + item { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp, 16.dp), + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = title, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold, + ) + } + Divider(color = Color.LightGray, thickness = DividerThickness) + } + } + itemsIndexed(options) { index, item -> + Row( + modifier = Modifier.fillMaxWidth().clickable { onSelect(index) }.padding(16.dp, 16.dp), + ) { + Column { onRenderItem(item) } + } + if (index < options.lastIndex) { + Divider(color = Color.LightGray, thickness = DividerThickness) + } + } + } + } + } +} + +@Immutable data class TitleExplainer(val title: String, val explainer: String? = null) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslationConfig.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslationConfig.kt index c7c2b6501..ffc8e5f70 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslationConfig.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslationConfig.kt @@ -1,11 +1,31 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import androidx.compose.runtime.Immutable @Immutable data class TranslationConfig( - val result: String?, - val sourceLang: String?, - val targetLang: String?, - val showOriginal: Boolean + val result: String?, + val sourceLang: String?, + val targetLang: String?, + val showOriginal: Boolean, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt index 20324055f..f9dfbf093 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import androidx.compose.foundation.clickable @@ -23,52 +43,50 @@ import com.vitorpamplona.amethyst.ui.theme.innerPostModifier @Composable fun UrlPreviewCard( - url: String, - previewInfo: UrlInfoItem + url: String, + previewInfo: UrlInfoItem, ) { - val uri = LocalUriHandler.current + val uri = LocalUriHandler.current - Column( - modifier = MaterialTheme.colorScheme.innerPostModifier - .clickable { - runCatching { uri.openUri(url) } - } - ) { - AsyncImage( - model = previewInfo.imageUrlFullPath, - contentDescription = stringResource(R.string.preview_card_image_for, previewInfo.url), - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth() - ) + Column( + modifier = + MaterialTheme.colorScheme.innerPostModifier.clickable { runCatching { uri.openUri(url) } }, + ) { + AsyncImage( + model = previewInfo.imageUrlFullPath, + contentDescription = stringResource(R.string.preview_card_image_for, previewInfo.url), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + ) - Spacer(modifier = StdVertSpacer) + Spacer(modifier = StdVertSpacer) - Text( - text = previewInfo.verifiedUrl?.host ?: previewInfo.url, - style = MaterialTheme.typography.bodySmall, - modifier = MaxWidthWithHorzPadding, - color = Color.Gray, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Text( + text = previewInfo.verifiedUrl?.host ?: previewInfo.url, + style = MaterialTheme.typography.bodySmall, + modifier = MaxWidthWithHorzPadding, + color = Color.Gray, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) - Text( - text = previewInfo.title, - style = MaterialTheme.typography.bodyMedium, - modifier = MaxWidthWithHorzPadding, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Text( + text = previewInfo.title, + style = MaterialTheme.typography.bodyMedium, + modifier = MaxWidthWithHorzPadding, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) - Text( - text = previewInfo.description, - style = MaterialTheme.typography.bodySmall, - modifier = MaxWidthWithHorzPadding, - color = Color.Gray, - maxLines = 3, - overflow = TextOverflow.Ellipsis - ) + Text( + text = previewInfo.description, + style = MaterialTheme.typography.bodySmall, + modifier = MaxWidthWithHorzPadding, + color = Color.Gray, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) - Spacer(modifier = DoubleVertSpacer) - } + Spacer(modifier = DoubleVertSpacer) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewState.kt index d8035ccf3..bb5f668e3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewState.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import androidx.compose.runtime.Immutable @@ -5,16 +25,11 @@ import com.vitorpamplona.amethyst.service.previews.UrlInfoItem @Immutable sealed class UrlPreviewState { + @Immutable object Loading : UrlPreviewState() - @Immutable - object Loading : UrlPreviewState() + @Immutable class Loaded(val previewInfo: UrlInfoItem) : UrlPreviewState() - @Immutable - class Loaded(val previewInfo: UrlInfoItem) : UrlPreviewState() + @Immutable object Empty : UrlPreviewState() - @Immutable - object Empty : UrlPreviewState() - - @Immutable - class Error(val errorMessage: String) : UrlPreviewState() + @Immutable class Error(val errorMessage: String) : UrlPreviewState() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt index adfd5c523..dc6af9ee5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import android.content.Context @@ -85,6 +105,8 @@ import com.vitorpamplona.amethyst.ui.theme.Size50Modifier import com.vitorpamplona.amethyst.ui.theme.Size75dp import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize import com.vitorpamplona.amethyst.ui.theme.imageModifier +import java.util.UUID +import kotlin.math.abs import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -92,452 +114,442 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch -import java.util.UUID -import kotlin.math.abs -public var DefaultMutedSetting = mutableStateOf(true) +public val DEFAULT_MUTED_SETTING = mutableStateOf(true) @Composable fun LoadThumbAndThenVideoView( - videoUri: String, - title: String? = null, - thumbUri: String, - authorName: String? = null, - roundedCorner: Boolean, - nostrUriCallback: String? = null, - accountViewModel: AccountViewModel, - onDialog: ((Boolean) -> Unit)? = null + videoUri: String, + title: String? = null, + thumbUri: String, + authorName: String? = null, + roundedCorner: Boolean, + nostrUriCallback: String? = null, + accountViewModel: AccountViewModel, + onDialog: ((Boolean) -> Unit)? = null, ) { - var loadingFinished by remember { mutableStateOf>(Pair(false, null)) } - val context = LocalContext.current + var loadingFinished by remember { mutableStateOf>(Pair(false, null)) } + val context = LocalContext.current - LaunchedEffect(Unit) { - accountViewModel.loadThumb( - context, - thumbUri, - onReady = { - if (it != null) { - loadingFinished = Pair(true, it) - } else { - loadingFinished = Pair(true, null) - } - }, - onError = { - loadingFinished = Pair(true, null) - } - ) - } - - if (loadingFinished.first) { - if (loadingFinished.second != null) { - VideoView( - videoUri = videoUri, - title = title, - thumb = VideoThumb(loadingFinished.second), - roundedCorner = roundedCorner, - artworkUri = thumbUri, - authorName = authorName, - nostrUriCallback = nostrUriCallback, - accountViewModel = accountViewModel, - onDialog = onDialog - ) + LaunchedEffect(Unit) { + accountViewModel.loadThumb( + context, + thumbUri, + onReady = { + if (it != null) { + loadingFinished = Pair(true, it) } else { - VideoView( - videoUri = videoUri, - title = title, - thumb = null, - roundedCorner = roundedCorner, - artworkUri = thumbUri, - authorName = authorName, - nostrUriCallback = nostrUriCallback, - accountViewModel = accountViewModel, - onDialog = onDialog - ) + loadingFinished = Pair(true, null) } + }, + onError = { loadingFinished = Pair(true, null) }, + ) + } + + if (loadingFinished.first) { + if (loadingFinished.second != null) { + VideoView( + videoUri = videoUri, + title = title, + thumb = VideoThumb(loadingFinished.second), + roundedCorner = roundedCorner, + artworkUri = thumbUri, + authorName = authorName, + nostrUriCallback = nostrUriCallback, + accountViewModel = accountViewModel, + onDialog = onDialog, + ) + } else { + VideoView( + videoUri = videoUri, + title = title, + thumb = null, + roundedCorner = roundedCorner, + artworkUri = thumbUri, + authorName = authorName, + nostrUriCallback = nostrUriCallback, + accountViewModel = accountViewModel, + onDialog = onDialog, + ) } + } } @Composable fun VideoView( - videoUri: String, - title: String? = null, - thumb: VideoThumb? = null, - roundedCorner: Boolean, - topPaddingForControllers: Dp = Dp.Unspecified, - waveform: ImmutableList? = null, - artworkUri: String? = null, - authorName: String? = null, - dimensions: String? = null, - blurhash: String? = null, - nostrUriCallback: String? = null, - onDialog: ((Boolean) -> Unit)? = null, - onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, - accountViewModel: AccountViewModel, - alwaysShowVideo: Boolean = false + videoUri: String, + title: String? = null, + thumb: VideoThumb? = null, + roundedCorner: Boolean, + topPaddingForControllers: Dp = Dp.Unspecified, + waveform: ImmutableList? = null, + artworkUri: String? = null, + authorName: String? = null, + dimensions: String? = null, + blurhash: String? = null, + nostrUriCallback: String? = null, + onDialog: ((Boolean) -> Unit)? = null, + onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, + accountViewModel: AccountViewModel, + alwaysShowVideo: Boolean = false, ) { - val defaultToStart by remember(videoUri) { mutableStateOf(DefaultMutedSetting.value) } + val defaultToStart by remember(videoUri) { mutableStateOf(DEFAULT_MUTED_SETTING.value) } - val automaticallyStartPlayback = remember { - mutableStateOf( - if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback.value + val automaticallyStartPlayback = remember { + mutableStateOf( + if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback.value, + ) + } + + if (blurhash == null) { + val ratio = aspectRatio(dimensions) + val modifier = + if (ratio != null && roundedCorner && automaticallyStartPlayback.value) { + Modifier.aspectRatio(ratio) + } else { + Modifier + } + + Box(modifier) { + if (!automaticallyStartPlayback.value) { + ImageUrlWithDownloadButton(url = videoUri, showImage = automaticallyStartPlayback) + } else { + VideoViewInner( + videoUri = videoUri, + defaultToStart = defaultToStart, + title = title, + thumb = thumb, + roundedCorner = roundedCorner, + topPaddingForControllers = topPaddingForControllers, + waveform = waveform, + artworkUri = artworkUri, + authorName = authorName, + dimensions = dimensions, + blurhash = blurhash, + nostrUriCallback = nostrUriCallback, + automaticallyStartPlayback = automaticallyStartPlayback, + onControllerVisibilityChanged = onControllerVisibilityChanged, + onDialog = onDialog, ) + } } + } else { + val ratio = aspectRatio(dimensions) + val modifier = + if (ratio != null && roundedCorner) { + Modifier.aspectRatio(ratio) + } else { + Modifier + } - if (blurhash == null) { - val ratio = aspectRatio(dimensions) - val modifier = if (ratio != null && roundedCorner && automaticallyStartPlayback.value) { - Modifier.aspectRatio(ratio) - } else { - Modifier - } - - Box(modifier) { - if (!automaticallyStartPlayback.value) { - ImageUrlWithDownloadButton(url = videoUri, showImage = automaticallyStartPlayback) - } else { - VideoViewInner( - videoUri = videoUri, - defaultToStart = defaultToStart, - title = title, - thumb = thumb, - roundedCorner = roundedCorner, - topPaddingForControllers = topPaddingForControllers, - waveform = waveform, - artworkUri = artworkUri, - authorName = authorName, - dimensions = dimensions, - blurhash = blurhash, - nostrUriCallback = nostrUriCallback, - automaticallyStartPlayback = automaticallyStartPlayback, - onControllerVisibilityChanged = onControllerVisibilityChanged, - onDialog = onDialog - ) - } - } - } else { - val ratio = aspectRatio(dimensions) - val modifier = if (ratio != null && roundedCorner) { - Modifier.aspectRatio(ratio) - } else { - Modifier - } - - Box(modifier, contentAlignment = Alignment.Center) { - if (!automaticallyStartPlayback.value) { - DisplayBlurHash( - blurhash, - null, - ContentScale.Crop, - MaterialTheme.colorScheme.imageModifier - ) - IconButton( - modifier = Modifier.size(Size75dp), - onClick = { automaticallyStartPlayback.value = true } - ) { - DownloadForOfflineIcon(Size75dp, Color.White) - } - } else { - VideoViewInner( - videoUri = videoUri, - defaultToStart = defaultToStart, - title = title, - thumb = thumb, - roundedCorner = roundedCorner, - topPaddingForControllers = topPaddingForControllers, - waveform = waveform, - artworkUri = artworkUri, - authorName = authorName, - dimensions = dimensions, - blurhash = blurhash, - nostrUriCallback = nostrUriCallback, - automaticallyStartPlayback = automaticallyStartPlayback, - onControllerVisibilityChanged = onControllerVisibilityChanged, - onDialog = onDialog - ) - } + Box(modifier, contentAlignment = Alignment.Center) { + if (!automaticallyStartPlayback.value) { + DisplayBlurHash( + blurhash, + null, + ContentScale.Crop, + MaterialTheme.colorScheme.imageModifier, + ) + IconButton( + modifier = Modifier.size(Size75dp), + onClick = { automaticallyStartPlayback.value = true }, + ) { + DownloadForOfflineIcon(Size75dp, Color.White) } + } else { + VideoViewInner( + videoUri = videoUri, + defaultToStart = defaultToStart, + title = title, + thumb = thumb, + roundedCorner = roundedCorner, + topPaddingForControllers = topPaddingForControllers, + waveform = waveform, + artworkUri = artworkUri, + authorName = authorName, + dimensions = dimensions, + blurhash = blurhash, + nostrUriCallback = nostrUriCallback, + automaticallyStartPlayback = automaticallyStartPlayback, + onControllerVisibilityChanged = onControllerVisibilityChanged, + onDialog = onDialog, + ) + } } + } } @Composable @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) fun VideoViewInner( - videoUri: String, - defaultToStart: Boolean = false, - title: String? = null, - thumb: VideoThumb? = null, - roundedCorner: Boolean, - topPaddingForControllers: Dp = Dp.Unspecified, - waveform: ImmutableList? = null, - artworkUri: String? = null, - authorName: String? = null, - dimensions: String? = null, - blurhash: String? = null, - nostrUriCallback: String? = null, - automaticallyStartPlayback: State, - onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, - onDialog: ((Boolean) -> Unit)? = null + videoUri: String, + defaultToStart: Boolean = false, + title: String? = null, + thumb: VideoThumb? = null, + roundedCorner: Boolean, + topPaddingForControllers: Dp = Dp.Unspecified, + waveform: ImmutableList? = null, + artworkUri: String? = null, + authorName: String? = null, + dimensions: String? = null, + blurhash: String? = null, + nostrUriCallback: String? = null, + automaticallyStartPlayback: State, + onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, + onDialog: ((Boolean) -> Unit)? = null, ) { - VideoPlayerActiveMutex(videoUri) { modifier, activeOnScreen -> - GetMediaItem(videoUri, title, artworkUri, authorName) { mediaItem -> - GetVideoController( - mediaItem = mediaItem, - videoUri = videoUri, - defaultToStart = defaultToStart, - nostrUriCallback = nostrUriCallback - ) { controller, keepPlaying -> - RenderVideoPlayer( - controller = controller, - thumbData = thumb, - roundedCorner = roundedCorner, - dimensions = dimensions, - blurhash = blurhash, - topPaddingForControllers = topPaddingForControllers, - waveform = waveform, - keepPlaying = keepPlaying, - automaticallyStartPlayback = automaticallyStartPlayback, - activeOnScreen = activeOnScreen, - modifier = modifier, - onControllerVisibilityChanged = onControllerVisibilityChanged, - onDialog = onDialog - ) - } - } + VideoPlayerActiveMutex(videoUri) { modifier, activeOnScreen -> + GetMediaItem(videoUri, title, artworkUri, authorName) { mediaItem -> + GetVideoController( + mediaItem = mediaItem, + videoUri = videoUri, + defaultToStart = defaultToStart, + nostrUriCallback = nostrUriCallback, + ) { controller, keepPlaying -> + RenderVideoPlayer( + controller = controller, + thumbData = thumb, + roundedCorner = roundedCorner, + dimensions = dimensions, + blurhash = blurhash, + topPaddingForControllers = topPaddingForControllers, + waveform = waveform, + keepPlaying = keepPlaying, + automaticallyStartPlayback = automaticallyStartPlayback, + activeOnScreen = activeOnScreen, + modifier = modifier, + onControllerVisibilityChanged = onControllerVisibilityChanged, + onDialog = onDialog, + ) + } } + } } @Composable fun GetMediaItem( - videoUri: String, - title: String?, - artworkUri: String?, - authorName: String?, - inner: @Composable (State) -> Unit + videoUri: String, + title: String?, + artworkUri: String?, + authorName: String?, + inner: @Composable (State) -> Unit, ) { - val mediaItem = produceState( - initialValue = null, - key1 = videoUri + val mediaItem = + produceState( + initialValue = null, + key1 = videoUri, ) { - this.value = MediaItem.Builder() - .setMediaId(videoUri) - .setUri(videoUri) - .setMediaMetadata( - MediaMetadata.Builder() - .setArtist(authorName?.ifBlank { null }) - .setTitle(title?.ifBlank { null } ?: videoUri) - .setArtworkUri( - try { - if (artworkUri != null) { - Uri.parse(artworkUri) - } else { - null - } - } catch (e: Exception) { - null - } - ) - .build() - ) - .build() + this.value = + MediaItem.Builder() + .setMediaId(videoUri) + .setUri(videoUri) + .setMediaMetadata( + MediaMetadata.Builder() + .setArtist(authorName?.ifBlank { null }) + .setTitle(title?.ifBlank { null } ?: videoUri) + .setArtworkUri( + try { + if (artworkUri != null) { + Uri.parse(artworkUri) + } else { + null + } + } catch (e: Exception) { + null + }, + ) + .build(), + ) + .build() } - mediaItem.value?.let { - val myState = remember(videoUri) { - mutableStateOf(it) - } - inner(myState) - } + mediaItem.value?.let { + val myState = remember(videoUri) { mutableStateOf(it) } + inner(myState) + } } @Immutable sealed class MediaControllerState { - @Immutable - object NotStarted : MediaControllerState() + @Immutable object NotStarted : MediaControllerState() - @Immutable - object Loading : MediaControllerState() + @Immutable object Loading : MediaControllerState() - @Stable - class Loaded(val instance: MediaController) : MediaControllerState() + @Stable class Loaded(val instance: MediaController) : MediaControllerState() } @Composable @OptIn(UnstableApi::class) fun GetVideoController( - mediaItem: State, - videoUri: String, - defaultToStart: Boolean = false, - nostrUriCallback: String? = null, - inner: @Composable (controller: MediaController, keepPlaying: MutableState) -> Unit + mediaItem: State, + videoUri: String, + defaultToStart: Boolean = false, + nostrUriCallback: String? = null, + inner: @Composable (controller: MediaController, keepPlaying: MutableState) -> Unit, ) { - val context = LocalContext.current + val context = LocalContext.current - val controller = remember(videoUri) { - val globalMutex = keepPlayingMutex - mutableStateOf( - if (videoUri == globalMutex?.currentMediaItem?.mediaId) { - MediaControllerState.Loaded(globalMutex) - } else { - MediaControllerState.NotStarted + val controller = + remember(videoUri) { + val globalMutex = keepPlayingMutex + mutableStateOf( + if (videoUri == globalMutex?.currentMediaItem?.mediaId) { + MediaControllerState.Loaded(globalMutex) + } else { + MediaControllerState.NotStarted + }, + ) + } + + val keepPlaying = + remember(videoUri) { + mutableStateOf( + keepPlayingMutex != null && controller.value == keepPlayingMutex, + ) + } + + val uid = remember(videoUri) { UUID.randomUUID().toString() } + + val scope = rememberCoroutineScope() + + // Prepares a VideoPlayer from the foreground service. + DisposableEffect(key1 = videoUri) { + // If it is not null, the user might have come back from a playing video, like clicking on + // the notification of the video player. + if (controller.value == MediaControllerState.NotStarted) { + controller.value = MediaControllerState.Loading + + scope.launch(Dispatchers.IO) { + Log.d("PlaybackService", "Preparing Video $videoUri ") + PlaybackClientController.prepareController( + uid, + videoUri, + nostrUriCallback, + context, + ) { + scope.launch(Dispatchers.Main) { + // REQUIRED TO BE RUN IN THE MAIN THREAD + + val newState = MediaControllerState.Loaded(it) + + if (!it.isPlaying) { + if (keepPlayingMutex?.isPlaying == true) { + // There is a video playing, start this one on mute. + newState.instance.volume = 0f + } else { + // There is no other video playing. Use the default mute state to + // decide if sound is on or not. + newState.instance.volume = if (defaultToStart) 0f else 1f + } } - ) + + newState.instance.setMediaItem(mediaItem.value) + newState.instance.prepare() + + controller.value = newState + } + } + } + } else if (controller.value is MediaControllerState.Loaded) { + (controller.value as? MediaControllerState.Loaded)?.instance?.let { + scope.launch(Dispatchers.Main) { + if (it.playbackState == Player.STATE_IDLE || it.playbackState == Player.STATE_ENDED) { + if (it.isPlaying) { + // There is a video playing, start this one on mute. + it.volume = 0f + } else { + // There is no other video playing. Use the default mute state to + // decide if sound is on or not. + it.volume = if (defaultToStart) 0f else 1f + } + + it.setMediaItem(mediaItem.value) + it.prepare() + } + } + } } - val keepPlaying = remember(videoUri) { - mutableStateOf( - keepPlayingMutex != null && controller.value == keepPlayingMutex - ) + onDispose { + GlobalScope.launch(Dispatchers.Main) { + if (!keepPlaying.value) { + // Stops and releases the media. + (controller.value as? MediaControllerState.Loaded)?.instance?.let { + it.stop() + it.release() + Log.d("PlaybackService", "Releasing Video $videoUri ") + controller.value = MediaControllerState.NotStarted + } + } + } } + } - val uid = remember(videoUri) { - UUID.randomUUID().toString() - } - - val scope = rememberCoroutineScope() - - // Prepares a VideoPlayer from the foreground service. - DisposableEffect(key1 = videoUri) { - // If it is not null, the user might have come back from a playing video, like clicking on - // the notification of the video player. - if (controller.value == MediaControllerState.NotStarted) { + // User pauses and resumes the app. What to do with videos? + val lifeCycleOwner = LocalLifecycleOwner.current + DisposableEffect(key1 = lifeCycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + // if the controller is null, restarts the controller with a new one + // if the controller is not null, just continue playing what the controller was playing + scope.launch(Dispatchers.IO) { + if (controller.value == MediaControllerState.NotStarted) { controller.value = MediaControllerState.Loading - scope.launch(Dispatchers.IO) { - Log.d("PlaybackService", "Preparing Video $videoUri ") - PlaybackClientController.prepareController( - uid, - videoUri, - nostrUriCallback, - context - ) { - scope.launch(Dispatchers.Main) { - // REQUIRED TO BE RUN IN THE MAIN THREAD + Log.d("PlaybackService", "Preparing Video from Resume $videoUri ") - val newState = MediaControllerState.Loaded(it) + PlaybackClientController.prepareController( + uid, + videoUri, + nostrUriCallback, + context, + ) { + scope.launch(Dispatchers.Main) { + // REQUIRED TO BE RUN IN THE MAIN THREAD - if (!it.isPlaying) { - if (keepPlayingMutex?.isPlaying == true) { - // There is a video playing, start this one on mute. - newState.instance.volume = 0f - } else { - // There is no other video playing. Use the default mute state to - // decide if sound is on or not. - newState.instance.volume = if (defaultToStart) 0f else 1f - } - } + val newState = MediaControllerState.Loaded(it) - newState.instance.setMediaItem(mediaItem.value) - newState.instance.prepare() - - controller.value = newState - } + // checks again to make sure no other thread has created a controller. + if (!it.isPlaying) { + if (keepPlayingMutex?.isPlaying == true) { + // There is a video playing, start this one on mute. + newState.instance.volume = 0f + } else { + // There is no other video playing. Use the default mute state to + // decide if sound is on or not. + newState.instance.volume = if (defaultToStart) 0f else 1f + } } + + newState.instance.setMediaItem(mediaItem.value) + newState.instance.prepare() + + controller.value = newState + } } - } else if (controller.value is MediaControllerState.Loaded) { + } + } + } + if (event == Lifecycle.Event.ON_PAUSE) { + GlobalScope.launch(Dispatchers.Main) { + if (!keepPlaying.value) { + // Stops and releases the media. (controller.value as? MediaControllerState.Loaded)?.instance?.let { - scope.launch(Dispatchers.Main) { - if (it.playbackState == Player.STATE_IDLE || it.playbackState == Player.STATE_ENDED) { - if (it.isPlaying) { - // There is a video playing, start this one on mute. - it.volume = 0f - } else { - // There is no other video playing. Use the default mute state to - // decide if sound is on or not. - it.volume = if (defaultToStart) 0f else 1f - } - - it.setMediaItem(mediaItem.value) - it.prepare() - } - } - } - } - - onDispose { - GlobalScope.launch(Dispatchers.Main) { - if (!keepPlaying.value) { - // Stops and releases the media. - (controller.value as? MediaControllerState.Loaded)?.instance?.let { - it.stop() - it.release() - Log.d("PlaybackService", "Releasing Video $videoUri ") - controller.value = MediaControllerState.NotStarted - } - } + Log.d("PlaybackService", "Releasing Video from Pause $videoUri ") + it.stop() + it.release() + controller.value = MediaControllerState.NotStarted } + } } + } } - // User pauses and resumes the app. What to do with videos? - val lifeCycleOwner = LocalLifecycleOwner.current - DisposableEffect(key1 = lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - // if the controller is null, restarts the controller with a new one - // if the controller is not null, just continue playing what the controller was playing - scope.launch(Dispatchers.IO) { - if (controller.value == MediaControllerState.NotStarted) { - controller.value = MediaControllerState.Loading + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } + } - Log.d("PlaybackService", "Preparing Video from Resume $videoUri ") - - PlaybackClientController.prepareController( - uid, - videoUri, - nostrUriCallback, - context - ) { - scope.launch(Dispatchers.Main) { - // REQUIRED TO BE RUN IN THE MAIN THREAD - - val newState = MediaControllerState.Loaded(it) - - // checks again to make sure no other thread has created a controller. - if (!it.isPlaying) { - if (keepPlayingMutex?.isPlaying == true) { - // There is a video playing, start this one on mute. - newState.instance.volume = 0f - } else { - // There is no other video playing. Use the default mute state to - // decide if sound is on or not. - newState.instance.volume = - if (defaultToStart) 0f else 1f - } - } - - newState.instance.setMediaItem(mediaItem.value) - newState.instance.prepare() - - controller.value = newState - } - } - } - } - } - if (event == Lifecycle.Event.ON_PAUSE) { - GlobalScope.launch(Dispatchers.Main) { - if (!keepPlaying.value) { - // Stops and releases the media. - (controller.value as? MediaControllerState.Loaded)?.instance?.let { - Log.d("PlaybackService", "Releasing Video from Pause $videoUri ") - it.stop() - it.release() - controller.value = MediaControllerState.NotStarted - } - } - } - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { - lifeCycleOwner.lifecycle.removeObserver(observer) - } - } - - (controller.value as? MediaControllerState.Loaded) ?.let { - inner(it.instance, keepPlaying) - } + (controller.value as? MediaControllerState.Loaded)?.let { inner(it.instance, keepPlaying) } } // background playing mutex. @@ -548,482 +560,470 @@ val trackingVideos = mutableListOf() @Stable class VisibilityData() { - var distanceToCenter: Float? = null + var distanceToCenter: Float? = null } /** - * This function selects only one Video to be active. The video that is closest to the center of - * the screen wins the mutex. + * This function selects only one Video to be active. The video that is closest to the center of the + * screen wins the mutex. */ @Composable -fun VideoPlayerActiveMutex(videoUri: String, inner: @Composable (Modifier, MutableState) -> Unit) { - val myCache = remember(videoUri) { - VisibilityData() - } +fun VideoPlayerActiveMutex( + videoUri: String, + inner: @Composable (Modifier, MutableState) -> Unit, +) { + val myCache = remember(videoUri) { VisibilityData() } - // Is the current video the closest to the center? - val active = remember(videoUri) { - mutableStateOf(false) - } + // Is the current video the closest to the center? + val active = remember(videoUri) { mutableStateOf(false) } - // Keep track of all available videos. - DisposableEffect(key1 = videoUri) { - trackingVideos.add(myCache) - onDispose { - trackingVideos.remove(myCache) - } - } + // Keep track of all available videos. + DisposableEffect(key1 = videoUri) { + trackingVideos.add(myCache) + onDispose { trackingVideos.remove(myCache) } + } - val myModifier = remember(videoUri) { - Modifier - .fillMaxWidth() - .defaultMinSize(minHeight = 70.dp) - .onVisiblePositionChanges { distanceToCenter -> - myCache.distanceToCenter = distanceToCenter + val myModifier = + remember(videoUri) { + Modifier.fillMaxWidth().defaultMinSize(minHeight = 70.dp).onVisiblePositionChanges { + distanceToCenter -> + myCache.distanceToCenter = distanceToCenter - if (distanceToCenter != null) { - // finds out of the current video is the closest to the center. - var newActive = true - for (video in trackingVideos) { - val videoPos = video.distanceToCenter - if (videoPos != null && videoPos < distanceToCenter) { - newActive = false - break - } - } - - // marks the current video active - if (active.value != newActive) { - active.value = newActive - } - } else { - // got out of screen, marks video as inactive - if (active.value) { - active.value = false - } - } + if (distanceToCenter != null) { + // finds out of the current video is the closest to the center. + var newActive = true + for (video in trackingVideos) { + val videoPos = video.distanceToCenter + if (videoPos != null && videoPos < distanceToCenter) { + newActive = false + break } + } + + // marks the current video active + if (active.value != newActive) { + active.value = newActive + } + } else { + // got out of screen, marks video as inactive + if (active.value) { + active.value = false + } + } + } } - inner(myModifier, active) + inner(myModifier, active) } @Stable data class VideoThumb( - val thumb: Drawable? + val thumb: Drawable?, ) @Composable @OptIn(UnstableApi::class) private fun RenderVideoPlayer( - controller: MediaController, - thumbData: VideoThumb?, - roundedCorner: Boolean, - dimensions: String? = null, - blurhash: String? = null, - topPaddingForControllers: Dp = Dp.Unspecified, - waveform: ImmutableList? = null, - keepPlaying: MutableState, - automaticallyStartPlayback: State, - activeOnScreen: MutableState, - modifier: Modifier, - onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, - onDialog: ((Boolean) -> Unit)? + controller: MediaController, + thumbData: VideoThumb?, + roundedCorner: Boolean, + dimensions: String? = null, + blurhash: String? = null, + topPaddingForControllers: Dp = Dp.Unspecified, + waveform: ImmutableList? = null, + keepPlaying: MutableState, + automaticallyStartPlayback: State, + activeOnScreen: MutableState, + modifier: Modifier, + onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, + onDialog: ((Boolean) -> Unit)?, ) { - ControlWhenPlayerIsActive(controller, keepPlaying, automaticallyStartPlayback, activeOnScreen) + ControlWhenPlayerIsActive(controller, keepPlaying, automaticallyStartPlayback, activeOnScreen) - val controllerVisible = remember(controller) { - mutableStateOf(false) - } + val controllerVisible = remember(controller) { mutableStateOf(false) } - val videoPlaybackHeight = remember { - mutableStateOf(Dp.Unspecified) - } + val videoPlaybackHeight = remember { mutableStateOf(Dp.Unspecified) } - val localDensity = LocalDensity.current + val localDensity = LocalDensity.current - BoxWithConstraints( - modifier = Modifier - .onGloballyPositioned { coordinates -> - videoPlaybackHeight.value = with(localDensity) { coordinates.size.height.toDp() } - } - ) { - val borders = MaterialTheme.colorScheme.imageModifier + BoxWithConstraints( + modifier = + Modifier.onGloballyPositioned { coordinates -> + videoPlaybackHeight.value = with(localDensity) { coordinates.size.height.toDp() } + }, + ) { + val borders = MaterialTheme.colorScheme.imageModifier - val myModifier = remember { - if (roundedCorner) { - modifier.then( - borders - .defaultMinSize(minHeight = 75.dp) - .align(Alignment.Center) - ) - } else { - modifier - .fillMaxWidth() - .defaultMinSize(minHeight = 75.dp) - .align(Alignment.Center) - } - } - - val factory = remember(controller) { - { context: Context -> - PlayerView(context).apply { - player = controller - layoutParams = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ) - setBackgroundColor(Color.Transparent.toArgb()) - setShutterBackgroundColor(Color.Transparent.toArgb()) - controllerAutoShow = false - thumbData?.thumb?.let { defaultArtwork = it } - hideController() - resizeMode = - if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH - onDialog?.let { innerOnDialog -> - setFullscreenButtonClickListener { - controller.pause() - innerOnDialog(it) - } - } - setControllerVisibilityListener( - PlayerView.ControllerVisibilityListener { visible -> - controllerVisible.value = visible == View.VISIBLE - onControllerVisibilityChanged?.let { callback -> - callback(visible == View.VISIBLE) - } - } - ) - } - } - } - - val ratio = remember { - aspectRatio(dimensions) - } - - if (ratio != null) { - DisplayBlurHash( - blurhash, - null, - ContentScale.Crop, - myModifier.aspectRatio(ratio) - ) - } - - AndroidView( - modifier = myModifier, - factory = factory + val myModifier = remember { + if (roundedCorner) { + modifier.then( + borders.defaultMinSize(minHeight = 75.dp).align(Alignment.Center), ) - - waveform?.let { - Waveform(it, controller, remember { Modifier.align(Alignment.Center) }) - } - - val startingMuteState = remember(controller) { - controller.volume < 0.001 - } - - val topPadding = remember { - derivedStateOf { - if (topPaddingForControllers.isSpecified && videoPlaybackHeight.value.value > 0) { - val space = (abs(this.maxHeight.value - videoPlaybackHeight.value.value) / 2).dp - if (space > topPaddingForControllers) { - Size0dp - } else { - topPaddingForControllers - space - } - } else { - Size0dp - } - } - } - - MuteButton( - controllerVisible, - startingMuteState, - topPadding - ) { mute: Boolean -> - // makes the new setting the default for new creations. - DefaultMutedSetting.value = mute - - // if the user unmutes a video and it's not the current playing, switches to that one. - if (!mute && keepPlayingMutex != null && keepPlayingMutex != controller) { - keepPlayingMutex?.stop() - keepPlayingMutex?.release() - keepPlayingMutex = null - } - - controller.volume = if (mute) 0f else 1f - } - - KeepPlayingButton( - keepPlaying, - controllerVisible, - topPadding, - Modifier.align(Alignment.TopEnd) - ) { newKeepPlaying: Boolean -> - // If something else is playing and the user marks this video to keep playing, stops the other one. - if (newKeepPlaying) { - if (keepPlayingMutex != null && keepPlayingMutex != controller) { - keepPlayingMutex?.stop() - keepPlayingMutex?.release() - } - keepPlayingMutex = controller - } else { - if (keepPlayingMutex == controller) { - keepPlayingMutex = null - } - } - - keepPlaying.value = newKeepPlaying - } + } else { + modifier.fillMaxWidth().defaultMinSize(minHeight = 75.dp).align(Alignment.Center) + } } + + val factory = + remember(controller) { + { context: Context -> + PlayerView(context).apply { + player = controller + layoutParams = + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) + setBackgroundColor(Color.Transparent.toArgb()) + setShutterBackgroundColor(Color.Transparent.toArgb()) + controllerAutoShow = false + thumbData?.thumb?.let { defaultArtwork = it } + hideController() + resizeMode = + if (maxHeight.isFinite) { + AspectRatioFrameLayout.RESIZE_MODE_FIT + } else { + AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH + } + onDialog?.let { innerOnDialog -> + setFullscreenButtonClickListener { + controller.pause() + innerOnDialog(it) + } + } + setControllerVisibilityListener( + PlayerView.ControllerVisibilityListener { visible -> + controllerVisible.value = visible == View.VISIBLE + onControllerVisibilityChanged?.let { callback -> callback(visible == View.VISIBLE) } + }, + ) + } + } + } + + val ratio = remember { aspectRatio(dimensions) } + + if (ratio != null) { + DisplayBlurHash( + blurhash, + null, + ContentScale.Crop, + myModifier.aspectRatio(ratio), + ) + } + + AndroidView( + modifier = myModifier, + factory = factory, + ) + + waveform?.let { Waveform(it, controller, remember { Modifier.align(Alignment.Center) }) } + + val startingMuteState = remember(controller) { controller.volume < 0.001 } + + val topPadding = remember { + derivedStateOf { + if (topPaddingForControllers.isSpecified && videoPlaybackHeight.value.value > 0) { + val space = (abs(this.maxHeight.value - videoPlaybackHeight.value.value) / 2).dp + if (space > topPaddingForControllers) { + Size0dp + } else { + topPaddingForControllers - space + } + } else { + Size0dp + } + } + } + + MuteButton( + controllerVisible, + startingMuteState, + topPadding, + ) { mute: Boolean -> + // makes the new setting the default for new creations. + DEFAULT_MUTED_SETTING.value = mute + + // if the user unmutes a video and it's not the current playing, switches to that one. + if (!mute && keepPlayingMutex != null && keepPlayingMutex != controller) { + keepPlayingMutex?.stop() + keepPlayingMutex?.release() + keepPlayingMutex = null + } + + controller.volume = if (mute) 0f else 1f + } + + KeepPlayingButton( + keepPlaying, + controllerVisible, + topPadding, + Modifier.align(Alignment.TopEnd), + ) { newKeepPlaying: Boolean -> + // If something else is playing and the user marks this video to keep playing, stops the other + // one. + if (newKeepPlaying) { + if (keepPlayingMutex != null && keepPlayingMutex != controller) { + keepPlayingMutex?.stop() + keepPlayingMutex?.release() + } + keepPlayingMutex = controller + } else { + if (keepPlayingMutex == controller) { + keepPlayingMutex = null + } + } + + keepPlaying.value = newKeepPlaying + } + } } -private fun pollCurrentDuration(controller: MediaController) = flow { - while (controller.currentPosition <= controller.duration) { +private fun pollCurrentDuration(controller: MediaController) = + flow { + while (controller.currentPosition <= controller.duration) { emit(controller.currentPosition / controller.duration.toFloat()) delay(100) + } } -}.conflate() + .conflate() @Composable fun Waveform( - waveform: ImmutableList, - controller: MediaController, - modifier: Modifier + waveform: ImmutableList, + controller: MediaController, + modifier: Modifier, ) { - val waveformProgress = remember { mutableStateOf(0F) } + val waveformProgress = remember { mutableStateOf(0F) } - DrawWaveform(waveform, waveformProgress, modifier) + DrawWaveform(waveform, waveformProgress, modifier) - val restartFlow = remember { - mutableIntStateOf(0) - } + val restartFlow = remember { mutableIntStateOf(0) } - // Keeps the screen on while playing and viewing videos. - DisposableEffect(key1 = controller) { - val listener = object : Player.Listener { - override fun onIsPlayingChanged(isPlaying: Boolean) { - // doesn't consider the mutex because the screen can turn off if the video - // being played in the mutex is not visible. - if (isPlaying) { - restartFlow.value += 1 - } - } + // Keeps the screen on while playing and viewing videos. + DisposableEffect(key1 = controller) { + val listener = + object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + // doesn't consider the mutex because the screen can turn off if the video + // being played in the mutex is not visible. + if (isPlaying) { + restartFlow.value += 1 + } } + } - controller.addListener(listener) - onDispose { - controller.removeListener(listener) - } - } + controller.addListener(listener) + onDispose { controller.removeListener(listener) } + } - LaunchedEffect(key1 = restartFlow.value) { - pollCurrentDuration(controller).collect() { value -> - waveformProgress.value = value - } - } + LaunchedEffect(key1 = restartFlow.value) { + pollCurrentDuration(controller).collect { value -> waveformProgress.value = value } + } } @Composable -fun DrawWaveform(waveform: ImmutableList, waveformProgress: MutableState, modifier: Modifier) { - AudioWaveformReadOnly( - modifier = modifier.padding(start = 10.dp, end = 10.dp), - amplitudes = waveform, - progress = waveformProgress.value, - progressBrush = Brush.infiniteLinearGradient( - colors = listOf(Color(0xff2598cf), Color(0xff652d80)), - animation = tween(durationMillis = 6000, easing = LinearEasing), - width = 128F - ), - onProgressChange = { - waveformProgress.value = it - } - ) +fun DrawWaveform( + waveform: ImmutableList, + waveformProgress: MutableState, + modifier: Modifier, +) { + AudioWaveformReadOnly( + modifier = modifier.padding(start = 10.dp, end = 10.dp), + amplitudes = waveform, + progress = waveformProgress.value, + progressBrush = + Brush.infiniteLinearGradient( + colors = listOf(Color(0xff2598cf), Color(0xff652d80)), + animation = tween(durationMillis = 6000, easing = LinearEasing), + width = 128F, + ), + onProgressChange = { waveformProgress.value = it }, + ) } @Composable fun ControlWhenPlayerIsActive( - controller: Player, - keepPlaying: MutableState, - automaticallyStartPlayback: State, - activeOnScreen: MutableState + controller: Player, + keepPlaying: MutableState, + automaticallyStartPlayback: State, + activeOnScreen: MutableState, ) { - LaunchedEffect(key1 = activeOnScreen.value) { - // active means being fully visible - if (activeOnScreen.value) { - // should auto start video from settings? - if (!automaticallyStartPlayback.value) { - if (controller.isPlaying) { - // if it is visible, it's playing but it wasn't supposed to start automatically. - controller.pause() - } - } else if (!controller.isPlaying) { - // if it is visible, was supposed to start automatically, but it's not - - // If something else is playing, play on mute. - if (keepPlayingMutex != null && keepPlayingMutex != controller) { - controller.volume = 0f - } - controller.play() - } - } else { - // Pauses the video when it becomes invisible. - // Destroys the video later when it Disposes the element - // meanwhile if the user comes back, the position in the track is saved. - if (!keepPlaying.value) { - controller.pause() - } + LaunchedEffect(key1 = activeOnScreen.value) { + // active means being fully visible + if (activeOnScreen.value) { + // should auto start video from settings? + if (!automaticallyStartPlayback.value) { + if (controller.isPlaying) { + // if it is visible, it's playing but it wasn't supposed to start automatically. + controller.pause() } + } else if (!controller.isPlaying) { + // if it is visible, was supposed to start automatically, but it's not + + // If something else is playing, play on mute. + if (keepPlayingMutex != null && keepPlayingMutex != controller) { + controller.volume = 0f + } + controller.play() + } + } else { + // Pauses the video when it becomes invisible. + // Destroys the video later when it Disposes the element + // meanwhile if the user comes back, the position in the track is saved. + if (!keepPlaying.value) { + controller.pause() + } } + } - val view = LocalView.current + val view = LocalView.current - // Keeps the screen on while playing and viewing videos. - DisposableEffect(key1 = controller, key2 = view) { - val listener = object : Player.Listener { - override fun onIsPlayingChanged(isPlaying: Boolean) { - // doesn't consider the mutex because the screen can turn off if the video - // being played in the mutex is not visible. - view.keepScreenOn = isPlaying - } + // Keeps the screen on while playing and viewing videos. + DisposableEffect(key1 = controller, key2 = view) { + val listener = + object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + // doesn't consider the mutex because the screen can turn off if the video + // being played in the mutex is not visible. + view.keepScreenOn = isPlaying } + } - controller.addListener(listener) - onDispose { - view.keepScreenOn = false - controller.removeListener(listener) - } + controller.addListener(listener) + onDispose { + view.keepScreenOn = false + controller.removeListener(listener) } + } } fun Modifier.onVisiblePositionChanges(onVisiblePosition: (Float?) -> Unit): Modifier = composed { - val view = LocalView.current + val view = LocalView.current - onGloballyPositioned { coordinates -> - onVisiblePosition(coordinates.getDistanceToVertCenterIfVisible(view)) - } + onGloballyPositioned { coordinates -> + onVisiblePosition(coordinates.getDistanceToVertCenterIfVisible(view)) + } } fun LayoutCoordinates.getDistanceToVertCenterIfVisible(view: View): Float? { - if (!isAttached) return null - // Window relative bounds of our compose root view that are visible on the screen - val globalRootRect = Rect() - if (!view.getGlobalVisibleRect(globalRootRect)) { - // we aren't visible at all. - return null - } - - val bounds = boundsInWindow() - - if (bounds.isEmpty) return null - - // Make sure we are completely in bounds. - if (bounds.top >= globalRootRect.top && - bounds.left >= globalRootRect.left && - bounds.right <= globalRootRect.right && - bounds.bottom <= globalRootRect.bottom - ) { - return abs(((bounds.top + bounds.bottom) / 2) - ((globalRootRect.top + globalRootRect.bottom) / 2)) - } - + if (!isAttached) return null + // Window relative bounds of our compose root view that are visible on the screen + val globalRootRect = Rect() + if (!view.getGlobalVisibleRect(globalRootRect)) { + // we aren't visible at all. return null + } + + val bounds = boundsInWindow() + + if (bounds.isEmpty) return null + + // Make sure we are completely in bounds. + if ( + bounds.top >= globalRootRect.top && + bounds.left >= globalRootRect.left && + bounds.right <= globalRootRect.right && + bounds.bottom <= globalRootRect.bottom + ) { + return abs( + ((bounds.top + bounds.bottom) / 2) - ((globalRootRect.top + globalRootRect.bottom) / 2), + ) + } + + return null } @Composable private fun MuteButton( - controllerVisible: MutableState, - startingMuteState: Boolean, - topPadding: State, - toggle: (Boolean) -> Unit + controllerVisible: MutableState, + startingMuteState: Boolean, + topPadding: State, + toggle: (Boolean) -> Unit, ) { - val holdOn = remember { - mutableStateOf( - true - ) - } + val holdOn = remember { + mutableStateOf( + true, + ) + } - LaunchedEffect(key1 = controllerVisible) { - launch(Dispatchers.Default) { - delay(2000) - holdOn.value = false - } - } - - val mutedInstance = remember(startingMuteState) { mutableStateOf(startingMuteState) } - - AnimatedVisibility( - visible = holdOn.value || controllerVisible.value, - modifier = Modifier.padding(top = topPadding.value), - enter = remember { fadeIn() }, - exit = remember { fadeOut() } - ) { - Box(modifier = VolumeBottomIconSize) { - Box( - Modifier - .clip(CircleShape) - .fillMaxSize(0.6f) - .align(Alignment.Center) - .background(MaterialTheme.colorScheme.background) - ) - - IconButton( - onClick = { - mutedInstance.value = !mutedInstance.value - toggle(mutedInstance.value) - }, - modifier = Size50Modifier - ) { - if (mutedInstance.value) { - MutedIcon() - } else { - MuteIcon() - } - } + LaunchedEffect(key1 = controllerVisible) { + launch(Dispatchers.Default) { + delay(2000) + holdOn.value = false + } + } + + val mutedInstance = remember(startingMuteState) { mutableStateOf(startingMuteState) } + + AnimatedVisibility( + visible = holdOn.value || controllerVisible.value, + modifier = Modifier.padding(top = topPadding.value), + enter = remember { fadeIn() }, + exit = remember { fadeOut() }, + ) { + Box(modifier = VolumeBottomIconSize) { + Box( + Modifier.clip(CircleShape) + .fillMaxSize(0.6f) + .align(Alignment.Center) + .background(MaterialTheme.colorScheme.background), + ) + + IconButton( + onClick = { + mutedInstance.value = !mutedInstance.value + toggle(mutedInstance.value) + }, + modifier = Size50Modifier, + ) { + if (mutedInstance.value) { + MutedIcon() + } else { + MuteIcon() } + } } + } } @Composable private fun KeepPlayingButton( - keepPlayingStart: MutableState, - controllerVisible: MutableState, - topPadding: State, - alignment: Modifier, - toggle: (Boolean) -> Unit + keepPlayingStart: MutableState, + controllerVisible: MutableState, + topPadding: State, + alignment: Modifier, + toggle: (Boolean) -> Unit, ) { - val keepPlaying = remember(keepPlayingStart.value) { mutableStateOf(keepPlayingStart.value) } + val keepPlaying = remember(keepPlayingStart.value) { mutableStateOf(keepPlayingStart.value) } - AnimatedVisibility( - visible = controllerVisible.value, - modifier = alignment.padding(top = topPadding.value), - enter = remember { fadeIn() }, - exit = remember { fadeOut() } - ) { - Box(modifier = PinBottomIconSize) { - Box( - Modifier - .clip(CircleShape) - .fillMaxSize(0.6f) - .align(Alignment.Center) - .background(MaterialTheme.colorScheme.background) - ) + AnimatedVisibility( + visible = controllerVisible.value, + modifier = alignment.padding(top = topPadding.value), + enter = remember { fadeIn() }, + exit = remember { fadeOut() }, + ) { + Box(modifier = PinBottomIconSize) { + Box( + Modifier.clip(CircleShape) + .fillMaxSize(0.6f) + .align(Alignment.Center) + .background(MaterialTheme.colorScheme.background), + ) - IconButton( - onClick = { - keepPlaying.value = !keepPlaying.value - toggle(keepPlaying.value) - }, - modifier = Size50Modifier - ) { - if (keepPlaying.value) { - LyricsIcon(Size22Modifier, MaterialTheme.colorScheme.onBackground) - } else { - LyricsOffIcon(Size22Modifier, MaterialTheme.colorScheme.onBackground) - } - } + IconButton( + onClick = { + keepPlaying.value = !keepPlaying.value + toggle(keepPlaying.value) + }, + modifier = Size50Modifier, + ) { + if (keepPlaying.value) { + LyricsIcon(Size22Modifier, MaterialTheme.colorScheme.onBackground) + } else { + LyricsOffIcon(Size22Modifier, MaterialTheme.colorScheme.onBackground) } + } } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt index 36e3bb317..74a884bb1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import androidx.compose.foundation.layout.Column @@ -27,68 +47,68 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable fun ZapRaiserRequest( - titleText: String? = null, - newPostViewModel: NewPostViewModel + titleText: String? = null, + newPostViewModel: NewPostViewModel, ) { - Column( - modifier = Modifier.fillMaxWidth() + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 10.dp) - ) { - Icon( - painter = painterResource(R.drawable.lightning), - null, - modifier = Size20Modifier, - tint = Color.Unspecified - ) + Icon( + painter = painterResource(R.drawable.lightning), + null, + modifier = Size20Modifier, + tint = Color.Unspecified, + ) - Text( - text = titleText ?: stringResource(R.string.zapraiser), - fontSize = 20.sp, - fontWeight = FontWeight.W500, - modifier = Modifier.padding(start = 10.dp) - ) - } - - Divider() - - Text( - text = stringResource(R.string.zapraiser_explainer), - color = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.padding(vertical = 10.dp) - ) - - OutlinedTextField( - label = { Text(text = stringResource(R.string.zapraiser_target_amount_in_sats)) }, - modifier = Modifier.fillMaxWidth(), - value = if (newPostViewModel.zapRaiserAmount != null) { - newPostViewModel.zapRaiserAmount.toString() - } else { - "" - }, - onValueChange = { - runCatching { - if (it.isEmpty()) { - newPostViewModel.zapRaiserAmount = null - } else { - newPostViewModel.zapRaiserAmount = it.toLongOrNull() - } - } - }, - placeholder = { - Text( - text = "1000", - color = MaterialTheme.colorScheme.placeholderText - ) - }, - keyboardOptions = KeyboardOptions.Default.copy( - keyboardType = KeyboardType.Number - ), - singleLine = true - ) + Text( + text = titleText ?: stringResource(R.string.zapraiser), + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.padding(start = 10.dp), + ) } + + Divider() + + Text( + text = stringResource(R.string.zapraiser_explainer), + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(vertical = 10.dp), + ) + + OutlinedTextField( + label = { Text(text = stringResource(R.string.zapraiser_target_amount_in_sats)) }, + modifier = Modifier.fillMaxWidth(), + value = + if (newPostViewModel.zapRaiserAmount != null) { + newPostViewModel.zapRaiserAmount.toString() + } else { + "" + }, + onValueChange = { + runCatching { + if (it.isEmpty()) { + newPostViewModel.zapRaiserAmount = null + } else { + newPostViewModel.zapRaiserAmount = it.toLongOrNull() + } + } + }, + placeholder = { + Text( + text = "1000", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Number, + ), + singleLine = true, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index a1a57b9ec..d37aa8411 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import android.app.Activity @@ -109,6 +129,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.toHexKey +import java.io.File import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers @@ -116,1053 +137,1002 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.zoomable -import java.io.File @Immutable abstract class ZoomableContent( - val description: String? = null, - val dim: String? = null + val description: String? = null, + val dim: String? = null, ) @Immutable abstract class ZoomableUrlContent( - val url: String, - description: String? = null, - val hash: String? = null, - dim: String? = null, - val uri: String? = null + val url: String, + description: String? = null, + val hash: String? = null, + dim: String? = null, + val uri: String? = null, ) : ZoomableContent(description, dim) @Immutable class ZoomableUrlImage( - url: String, - description: String? = null, - hash: String? = null, - val blurhash: String? = null, - dim: String? = null, - uri: String? = null + url: String, + description: String? = null, + hash: String? = null, + val blurhash: String? = null, + dim: String? = null, + uri: String? = null, ) : ZoomableUrlContent(url, description, hash, dim, uri) @Immutable class ZoomableUrlVideo( - url: String, - description: String? = null, - hash: String? = null, - dim: String? = null, - uri: String? = null, - val artworkUri: String? = null, - val authorName: String? = null, - val blurhash: String? = null + url: String, + description: String? = null, + hash: String? = null, + dim: String? = null, + uri: String? = null, + val artworkUri: String? = null, + val authorName: String? = null, + val blurhash: String? = null, ) : ZoomableUrlContent(url, description, hash, dim, uri) @Immutable abstract class ZoomablePreloadedContent( - val localFile: File?, - description: String? = null, - val mimeType: String? = null, - val isVerified: Boolean? = null, - dim: String? = null, - val uri: String + val localFile: File?, + description: String? = null, + val mimeType: String? = null, + val isVerified: Boolean? = null, + dim: String? = null, + val uri: String, ) : ZoomableContent(description, dim) @Immutable class ZoomableLocalImage( - localFile: File?, - mimeType: String? = null, - description: String? = null, - val blurhash: String? = null, - dim: String? = null, - isVerified: Boolean? = null, - uri: String + localFile: File?, + mimeType: String? = null, + description: String? = null, + val blurhash: String? = null, + dim: String? = null, + isVerified: Boolean? = null, + uri: String, ) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri) @Immutable class ZoomableLocalVideo( - localFile: File?, - mimeType: String? = null, - description: String? = null, - dim: String? = null, - isVerified: Boolean? = null, - uri: String, - val artworkUri: String? = null, - val authorName: String? = null + localFile: File?, + mimeType: String? = null, + description: String? = null, + dim: String? = null, + isVerified: Boolean? = null, + uri: String, + val artworkUri: String? = null, + val authorName: String? = null, ) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri) fun figureOutMimeType(fullUrl: String): ZoomableContent { - val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl) - val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } - val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } + val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl) + val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } + val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } - return if (isImage) { - ZoomableUrlImage(fullUrl) - } else if (isVideo) { - ZoomableUrlVideo(fullUrl) - } else { - ZoomableUrlImage(fullUrl) - } + return if (isImage) { + ZoomableUrlImage(fullUrl) + } else if (isVideo) { + ZoomableUrlVideo(fullUrl) + } else { + ZoomableUrlImage(fullUrl) + } } @Composable @OptIn(ExperimentalFoundationApi::class) fun ZoomableContentView( - content: ZoomableContent, - images: ImmutableList = listOf(content).toImmutableList(), - roundedCorner: Boolean, - accountViewModel: AccountViewModel + content: ZoomableContent, + images: ImmutableList = listOf(content).toImmutableList(), + roundedCorner: Boolean, + accountViewModel: AccountViewModel, ) { - // store the dialog open or close state - var dialogOpen by remember { - mutableStateOf(false) - } + // store the dialog open or close state + var dialogOpen by remember { mutableStateOf(false) } - // store the dialog open or close state - val shareOpen = remember { - mutableStateOf(false) - } + // store the dialog open or close state + val shareOpen = remember { mutableStateOf(false) } - if (shareOpen.value) { - ShareImageAction(shareOpen, content) { shareOpen.value = false } - } + if (shareOpen.value) { + ShareImageAction(shareOpen, content) { shareOpen.value = false } + } - var mainImageModifier = if (roundedCorner) { - MaterialTheme.colorScheme.imageModifier + var mainImageModifier = + if (roundedCorner) { + MaterialTheme.colorScheme.imageModifier } else { - Modifier.fillMaxWidth() + Modifier.fillMaxWidth() } - if (content is ZoomableUrlContent) { - mainImageModifier = mainImageModifier.combinedClickable( - onClick = { dialogOpen = true }, - onLongClick = { shareOpen.value = true } + if (content is ZoomableUrlContent) { + mainImageModifier = + mainImageModifier.combinedClickable( + onClick = { dialogOpen = true }, + onLongClick = { shareOpen.value = true }, + ) + } else if (content is ZoomablePreloadedContent) { + mainImageModifier = + mainImageModifier.combinedClickable( + onClick = { dialogOpen = true }, + onLongClick = { shareOpen.value = true }, + ) + } else { + mainImageModifier = mainImageModifier.clickable { dialogOpen = true } + } + + when (content) { + is ZoomableUrlImage -> + UrlImageView(content, mainImageModifier, accountViewModel = accountViewModel) + is ZoomableUrlVideo -> + VideoView( + videoUri = content.url, + title = content.description, + artworkUri = content.artworkUri, + authorName = content.authorName, + dimensions = content.dim, + blurhash = content.blurhash, + roundedCorner = roundedCorner, + nostrUriCallback = content.uri, + onDialog = { dialogOpen = true }, + accountViewModel = accountViewModel, + ) + is ZoomableLocalImage -> + LocalImageView(content, mainImageModifier, accountViewModel = accountViewModel) + is ZoomableLocalVideo -> + content.localFile?.let { + VideoView( + videoUri = it.toUri().toString(), + title = content.description, + artworkUri = content.artworkUri, + authorName = content.authorName, + roundedCorner = roundedCorner, + nostrUriCallback = content.uri, + onDialog = { dialogOpen = true }, + accountViewModel = accountViewModel, ) - } else if (content is ZoomablePreloadedContent) { - mainImageModifier = mainImageModifier.combinedClickable( - onClick = { dialogOpen = true }, - onLongClick = { shareOpen.value = true } - ) - } else { - mainImageModifier = mainImageModifier.clickable { - dialogOpen = true - } - } + } + } - when (content) { - is ZoomableUrlImage -> UrlImageView(content, mainImageModifier, accountViewModel = accountViewModel) - is ZoomableUrlVideo -> VideoView( - videoUri = content.url, - title = content.description, - artworkUri = content.artworkUri, - authorName = content.authorName, - dimensions = content.dim, - blurhash = content.blurhash, - roundedCorner = roundedCorner, - nostrUriCallback = content.uri, - onDialog = { dialogOpen = true }, - accountViewModel = accountViewModel - ) - - is ZoomableLocalImage -> LocalImageView(content, mainImageModifier, accountViewModel = accountViewModel) - is ZoomableLocalVideo -> - content.localFile?.let { - VideoView( - videoUri = it.toUri().toString(), - title = content.description, - artworkUri = content.artworkUri, - authorName = content.authorName, - roundedCorner = roundedCorner, - nostrUriCallback = content.uri, - onDialog = { dialogOpen = true }, - accountViewModel = accountViewModel - ) - } - } - - if (dialogOpen) { - ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false }, accountViewModel) - } + if (dialogOpen) { + ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false }, accountViewModel) + } } @Composable private fun LocalImageView( - content: ZoomableLocalImage, - mainImageModifier: Modifier, - topPaddingForControllers: Dp = Dp.Unspecified, - accountViewModel: AccountViewModel, - alwayShowImage: Boolean = false + content: ZoomableLocalImage, + mainImageModifier: Modifier, + topPaddingForControllers: Dp = Dp.Unspecified, + accountViewModel: AccountViewModel, + alwayShowImage: Boolean = false, ) { - if (content.localFile != null && content.localFile.exists()) { - BoxWithConstraints(contentAlignment = Alignment.Center) { - val showImage = remember { - mutableStateOf( - if (alwayShowImage) true else accountViewModel.settings.showImages.value - ) - } + if (content.localFile != null && content.localFile.exists()) { + BoxWithConstraints(contentAlignment = Alignment.Center) { + val showImage = remember { + mutableStateOf( + if (alwayShowImage) true else accountViewModel.settings.showImages.value, + ) + } - val myModifier = remember { - mainImageModifier - .widthIn(max = maxWidth) - .heightIn(max = maxHeight) - /* - .run { - aspectRatio(content.dim)?.let { ratio -> - this.aspectRatio(ratio, false) - } ?: this - } - */ - } - - val contentScale = remember { - if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth - } - - val verifierModifier = if (topPaddingForControllers.isSpecified) { - Modifier - .padding(top = topPaddingForControllers) - .align(Alignment.TopEnd) - } else { - Modifier.align(Alignment.TopEnd) - } - - val painterState = remember { - mutableStateOf(null) - } - - if (showImage.value) { - AsyncImage( - model = content.localFile, - contentDescription = content.description, - contentScale = contentScale, - modifier = myModifier, - onState = { - painterState.value = it - } - ) - } - - AddedImageFeatures( - painterState, - content, - contentScale, - myModifier, - verifierModifier, - showImage - ) + val myModifier = remember { + mainImageModifier.widthIn(max = maxWidth).heightIn(max = maxHeight) + /* + .run { + aspectRatio(content.dim)?.let { ratio -> + this.aspectRatio(ratio, false) + } ?: this } - } else { - BlankNote() + */ + } + + val contentScale = remember { + if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth + } + + val verifierModifier = + if (topPaddingForControllers.isSpecified) { + Modifier.padding(top = topPaddingForControllers).align(Alignment.TopEnd) + } else { + Modifier.align(Alignment.TopEnd) + } + + val painterState = remember { mutableStateOf(null) } + + if (showImage.value) { + AsyncImage( + model = content.localFile, + contentDescription = content.description, + contentScale = contentScale, + modifier = myModifier, + onState = { painterState.value = it }, + ) + } + + AddedImageFeatures( + painterState, + content, + contentScale, + myModifier, + verifierModifier, + showImage, + ) } + } else { + BlankNote() + } } @Composable private fun UrlImageView( - content: ZoomableUrlImage, - mainImageModifier: Modifier, - topPaddingForControllers: Dp = Dp.Unspecified, - accountViewModel: AccountViewModel, - alwayShowImage: Boolean = false + content: ZoomableUrlImage, + mainImageModifier: Modifier, + topPaddingForControllers: Dp = Dp.Unspecified, + accountViewModel: AccountViewModel, + alwayShowImage: Boolean = false, ) { - BoxWithConstraints(contentAlignment = Alignment.Center) { - val showImage = remember { - mutableStateOf( - if (alwayShowImage) true else accountViewModel.settings.showImages.value - ) - } - - val myModifier = remember { - mainImageModifier - .widthIn(max = maxWidth) - .heightIn(max = maxHeight) - /* Is this necessary? It makes images bleed into other pages - .run { - aspectRatio(content.dim)?.let { ratio -> - this.aspectRatio(ratio, false) - } ?: this - } - */ - } - - val contentScale = remember { - if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth - } - - val verifierModifier = if (topPaddingForControllers.isSpecified) { - Modifier - .padding(top = topPaddingForControllers) - .align(Alignment.TopEnd) - } else { - Modifier.align(Alignment.TopEnd) - } - - val painterState = remember { - mutableStateOf(null) - } - - if (showImage.value) { - AsyncImage( - model = content.url, - contentDescription = content.description, - contentScale = contentScale, - modifier = myModifier, - onState = { - painterState.value = it - } - ) - } - - AddedImageFeatures( - painterState, - content, - contentScale, - myModifier, - verifierModifier, - showImage - ) + BoxWithConstraints(contentAlignment = Alignment.Center) { + val showImage = remember { + mutableStateOf( + if (alwayShowImage) true else accountViewModel.settings.showImages.value, + ) } + + val myModifier = remember { + mainImageModifier.widthIn(max = maxWidth).heightIn(max = maxHeight) + /* Is this necessary? It makes images bleed into other pages + .run { + aspectRatio(content.dim)?.let { ratio -> + this.aspectRatio(ratio, false) + } ?: this + } + */ + } + + val contentScale = remember { + if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth + } + + val verifierModifier = + if (topPaddingForControllers.isSpecified) { + Modifier.padding(top = topPaddingForControllers).align(Alignment.TopEnd) + } else { + Modifier.align(Alignment.TopEnd) + } + + val painterState = remember { mutableStateOf(null) } + + if (showImage.value) { + AsyncImage( + model = content.url, + contentDescription = content.description, + contentScale = contentScale, + modifier = myModifier, + onState = { painterState.value = it }, + ) + } + + AddedImageFeatures( + painterState, + content, + contentScale, + myModifier, + verifierModifier, + showImage, + ) + } } @Composable -fun ImageUrlWithDownloadButton(url: String, showImage: MutableState) { - val uri = LocalUriHandler.current +fun ImageUrlWithDownloadButton( + url: String, + showImage: MutableState, +) { + val uri = LocalUriHandler.current - val primary = MaterialTheme.colorScheme.primary - val background = MaterialTheme.colorScheme.onBackground + val primary = MaterialTheme.colorScheme.primary + val background = MaterialTheme.colorScheme.onBackground - val regularText = remember { SpanStyle(color = background) } - val clickableTextStyle = remember { SpanStyle(color = primary) } + val regularText = remember { SpanStyle(color = background) } + val clickableTextStyle = remember { SpanStyle(color = primary) } - val annotatedTermsString = remember { - buildAnnotatedString { - withStyle(clickableTextStyle) { - pushStringAnnotation("routeToImage", "") - append("$url ") - } + val annotatedTermsString = remember { + buildAnnotatedString { + withStyle(clickableTextStyle) { + pushStringAnnotation("routeToImage", "") + append("$url ") + } - withStyle(clickableTextStyle) { - pushStringAnnotation("routeToImage", "") - appendInlineContent("inlineContent", "[icon]") - } + withStyle(clickableTextStyle) { + pushStringAnnotation("routeToImage", "") + appendInlineContent("inlineContent", "[icon]") + } - withStyle(regularText) { - append(" ") - } - } + withStyle(regularText) { append(" ") } } + } - val inlineContent = mapOf("inlineContent" to InlineDownloadIcon(showImage)) + val inlineContent = mapOf("inlineContent" to InlineDownloadIcon(showImage)) - val pressIndicator = remember { - Modifier.clickable { - runCatching { uri.openUri(url) } - } - } + val pressIndicator = remember { Modifier.clickable { runCatching { uri.openUri(url) } } } - Text( - text = annotatedTermsString, - modifier = pressIndicator, - inlineContent = inlineContent - ) + Text( + text = annotatedTermsString, + modifier = pressIndicator, + inlineContent = inlineContent, + ) } @Composable private fun InlineDownloadIcon(showImage: MutableState) = - InlineTextContent( - Placeholder( - width = Font17SP, - height = Font17SP, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center - ) + InlineTextContent( + Placeholder( + width = Font17SP, + height = Font17SP, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ), + ) { + IconButton( + modifier = Modifier.size(Size20dp), + onClick = { showImage.value = true }, ) { - IconButton( - modifier = Modifier.size(Size20dp), - onClick = { showImage.value = true } - ) { - DownloadForOfflineIcon(Size24dp) - } + DownloadForOfflineIcon(Size24dp) } + } @Composable @OptIn(ExperimentalLayoutApi::class) private fun AddedImageFeatures( - painter: MutableState, - content: ZoomableLocalImage, - contentScale: ContentScale, - myModifier: Modifier, - verifiedModifier: Modifier, - showImage: MutableState + painter: MutableState, + content: ZoomableLocalImage, + contentScale: ContentScale, + myModifier: Modifier, + verifiedModifier: Modifier, + showImage: MutableState, ) { - val ratio = remember { - aspectRatio(content.dim) - } + val ratio = remember { aspectRatio(content.dim) } - if (!showImage.value) { - if (content.blurhash != null && ratio != null) { - DisplayBlurHash( - content.blurhash, - content.description, - ContentScale.Crop, - myModifier.aspectRatio(ratio).clickable { - showImage.value = true - } - ) - IconButton( - modifier = Modifier.size(Size75dp), - onClick = { showImage.value = true } - ) { - DownloadForOfflineIcon(Size75dp, Color.White) - } - } else { - ImageUrlWithDownloadButton(content.uri, showImage) - } + if (!showImage.value) { + if (content.blurhash != null && ratio != null) { + DisplayBlurHash( + content.blurhash, + content.description, + ContentScale.Crop, + myModifier.aspectRatio(ratio).clickable { showImage.value = true }, + ) + IconButton( + modifier = Modifier.size(Size75dp), + onClick = { showImage.value = true }, + ) { + DownloadForOfflineIcon(Size75dp, Color.White) + } } else { - when (painter.value) { - null, is AsyncImagePainter.State.Loading -> { - if (content.blurhash != null) { - if (ratio != null) { - DisplayBlurHash(content.blurhash, content.description, ContentScale.Crop, myModifier.aspectRatio(ratio)) - } else { - DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier) - } - } else { - FlowRow() { - DisplayUrlWithLoadingSymbol(content) - } - } - } - - is AsyncImagePainter.State.Error -> { - BlankNote() - } - - is AsyncImagePainter.State.Success -> { - if (content.isVerified != null) { - HashVerificationSymbol(content.isVerified, verifiedModifier) - } - } - - else -> { - } - } + ImageUrlWithDownloadButton(content.uri, showImage) } + } else { + when (painter.value) { + null, + is AsyncImagePainter.State.Loading, -> { + if (content.blurhash != null) { + if (ratio != null) { + DisplayBlurHash( + content.blurhash, + content.description, + ContentScale.Crop, + myModifier.aspectRatio(ratio), + ) + } else { + DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier) + } + } else { + FlowRow { DisplayUrlWithLoadingSymbol(content) } + } + } + is AsyncImagePainter.State.Error -> { + BlankNote() + } + is AsyncImagePainter.State.Success -> { + if (content.isVerified != null) { + HashVerificationSymbol(content.isVerified, verifiedModifier) + } + } + else -> {} + } + } } @Composable @OptIn(ExperimentalLayoutApi::class) private fun AddedImageFeatures( - painter: MutableState, - content: ZoomableUrlImage, - contentScale: ContentScale, - myModifier: Modifier, - verifiedModifier: Modifier, - showImage: MutableState + painter: MutableState, + content: ZoomableUrlImage, + contentScale: ContentScale, + myModifier: Modifier, + verifiedModifier: Modifier, + showImage: MutableState, ) { - val ratio = remember { - aspectRatio(content.dim) - } + val ratio = remember { aspectRatio(content.dim) } - if (!showImage.value) { - if (content.blurhash != null && ratio != null) { - DisplayBlurHash( - content.blurhash, - content.description, - ContentScale.Crop, - myModifier.aspectRatio(ratio).clickable { - showImage.value = true - } - ) - IconButton( - modifier = Modifier.size(Size75dp), - onClick = { showImage.value = true } - ) { - DownloadForOfflineIcon(Size75dp, Color.White) - } - } else { - ImageUrlWithDownloadButton(content.url, showImage) - } + if (!showImage.value) { + if (content.blurhash != null && ratio != null) { + DisplayBlurHash( + content.blurhash, + content.description, + ContentScale.Crop, + myModifier.aspectRatio(ratio).clickable { showImage.value = true }, + ) + IconButton( + modifier = Modifier.size(Size75dp), + onClick = { showImage.value = true }, + ) { + DownloadForOfflineIcon(Size75dp, Color.White) + } } else { - var verifiedHash by remember { - mutableStateOf(null) - } - - when (painter.value) { - null, is AsyncImagePainter.State.Loading -> { - if (content.blurhash != null) { - if (ratio != null) { - DisplayBlurHash(content.blurhash, content.description, ContentScale.Crop, myModifier.aspectRatio(ratio)) - } else { - DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier) - } - } else { - FlowRow(Modifier.fillMaxWidth()) { - DisplayUrlWithLoadingSymbol(content) - } - } - } - - is AsyncImagePainter.State.Error -> { - FlowRow(Modifier.fillMaxWidth()) { - ClickableUrl(urlText = "${content.url} ", url = content.url) - } - } - - is AsyncImagePainter.State.Success -> { - if (content.hash != null) { - val context = LocalContext.current - LaunchedEffect(key1 = content.url) { - launch(Dispatchers.IO) { - val newVerifiedHash = verifyHash(content, context) - if (newVerifiedHash != verifiedHash) { - verifiedHash = newVerifiedHash - } - } - } - } - - verifiedHash?.let { - HashVerificationSymbol(it, verifiedModifier) - } - } - - else -> { - } - } + ImageUrlWithDownloadButton(content.url, showImage) } + } else { + var verifiedHash by remember { mutableStateOf(null) } + + when (painter.value) { + null, + is AsyncImagePainter.State.Loading, -> { + if (content.blurhash != null) { + if (ratio != null) { + DisplayBlurHash( + content.blurhash, + content.description, + ContentScale.Crop, + myModifier.aspectRatio(ratio), + ) + } else { + DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier) + } + } else { + FlowRow(Modifier.fillMaxWidth()) { DisplayUrlWithLoadingSymbol(content) } + } + } + is AsyncImagePainter.State.Error -> { + FlowRow(Modifier.fillMaxWidth()) { + ClickableUrl(urlText = "${content.url} ", url = content.url) + } + } + is AsyncImagePainter.State.Success -> { + if (content.hash != null) { + val context = LocalContext.current + LaunchedEffect(key1 = content.url) { + launch(Dispatchers.IO) { + val newVerifiedHash = verifyHash(content, context) + if (newVerifiedHash != verifiedHash) { + verifiedHash = newVerifiedHash + } + } + } + } + + verifiedHash?.let { HashVerificationSymbol(it, verifiedModifier) } + } + else -> {} + } + } } fun aspectRatio(dim: String?): Float? { - if (dim == null) return null - if (dim == "0x0") return null + if (dim == null) return null + if (dim == "0x0") return null - val parts = dim.split("x") - if (parts.size != 2) return null + val parts = dim.split("x") + if (parts.size != 2) return null - return try { - val width = parts[0].toFloat() - val height = parts[1].toFloat() + return try { + val width = parts[0].toFloat() + val height = parts[1].toFloat() - if (width < 0.1 || height < 0.1) { - null - } else { - width / height - } - } catch (e: Exception) { - null + if (width < 0.1 || height < 0.1) { + null + } else { + width / height } + } catch (e: Exception) { + null + } } @Composable private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) { - var cnt by remember { mutableStateOf(null) } + var cnt by remember { mutableStateOf(null) } - LaunchedEffect(Unit) { - launch(Dispatchers.IO) { - delay(200) - cnt = content - } + LaunchedEffect(Unit) { + launch(Dispatchers.IO) { + delay(200) + cnt = content } + } - cnt?.let { DisplayUrlWithLoadingSymbolWait(it) } + cnt?.let { DisplayUrlWithLoadingSymbolWait(it) } } @Composable private fun DisplayUrlWithLoadingSymbolWait(content: ZoomableContent) { - val uri = LocalUriHandler.current + val uri = LocalUriHandler.current - val primary = MaterialTheme.colorScheme.primary - val background = MaterialTheme.colorScheme.onBackground + val primary = MaterialTheme.colorScheme.primary + val background = MaterialTheme.colorScheme.onBackground - val regularText = remember { SpanStyle(color = background) } - val clickableTextStyle = remember { SpanStyle(color = primary) } + val regularText = remember { SpanStyle(color = background) } + val clickableTextStyle = remember { SpanStyle(color = primary) } - val annotatedTermsString = remember { - buildAnnotatedString { - if (content is ZoomableUrlContent) { - withStyle(clickableTextStyle) { - pushStringAnnotation("routeToImage", "") - append(content.url + " ") - } - } else { - withStyle(regularText) { - append("Loading content...") - } - } - - withStyle(clickableTextStyle) { - pushStringAnnotation("routeToImage", "") - appendInlineContent("inlineContent", "[icon]") - } - - withStyle(regularText) { - append(" ") - } + val annotatedTermsString = remember { + buildAnnotatedString { + if (content is ZoomableUrlContent) { + withStyle(clickableTextStyle) { + pushStringAnnotation("routeToImage", "") + append(content.url + " ") } + } else { + withStyle(regularText) { append("Loading content...") } + } + + withStyle(clickableTextStyle) { + pushStringAnnotation("routeToImage", "") + appendInlineContent("inlineContent", "[icon]") + } + + withStyle(regularText) { append(" ") } } + } - val inlineContent = mapOf("inlineContent" to InlineLoadingIcon()) + val inlineContent = mapOf("inlineContent" to InlineLoadingIcon()) - val pressIndicator = remember { - if (content is ZoomableUrlContent) { - Modifier.clickable { - runCatching { uri.openUri(content.url) } - } - } else { - Modifier - } + val pressIndicator = remember { + if (content is ZoomableUrlContent) { + Modifier.clickable { runCatching { uri.openUri(content.url) } } + } else { + Modifier } + } - Text( - text = annotatedTermsString, - modifier = pressIndicator, - inlineContent = inlineContent - ) + Text( + text = annotatedTermsString, + modifier = pressIndicator, + inlineContent = inlineContent, + ) } @Composable private fun InlineLoadingIcon() = - InlineTextContent( - Placeholder( - width = Font17SP, - height = Font17SP, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center - ) - ) { - LoadingAnimation() - } + InlineTextContent( + Placeholder( + width = Font17SP, + height = Font17SP, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ), + ) { + LoadingAnimation() + } @Composable fun DisplayBlurHash( - blurhash: String?, - description: String?, - contentScale: ContentScale, - modifier: Modifier + blurhash: String?, + description: String?, + contentScale: ContentScale, + modifier: Modifier, ) { - if (blurhash == null) return + if (blurhash == null) return - val context = LocalContext.current - AsyncImage( - model = remember { - BlurHashRequester.imageRequest( - context, - blurhash - ) - }, - contentDescription = description, - contentScale = contentScale, - modifier = modifier - ) + val context = LocalContext.current + AsyncImage( + model = + remember { + BlurHashRequester.imageRequest( + context, + blurhash, + ) + }, + contentDescription = description, + contentScale = contentScale, + modifier = modifier, + ) } @Composable fun ZoomableImageDialog( - imageUrl: ZoomableContent, - allImages: ImmutableList = listOf(imageUrl).toImmutableList(), - onDismiss: () -> Unit, - accountViewModel: AccountViewModel + imageUrl: ZoomableContent, + allImages: ImmutableList = listOf(imageUrl).toImmutableList(), + onDismiss: () -> Unit, + accountViewModel: AccountViewModel, ) { + val orientation = LocalConfiguration.current.orientation + + Dialog( + onDismissRequest = onDismiss, + properties = + DialogProperties( + usePlatformDefaultWidth = true, + decorFitsSystemWindows = false, + ), + ) { + val view = LocalView.current + val insets = ViewCompat.getRootWindowInsets(view) + val orientation = LocalConfiguration.current.orientation + println("This Log only exists to force orientation listener $orientation") - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties( - usePlatformDefaultWidth = true, - decorFitsSystemWindows = false - ) - ) { - val view = LocalView.current - val insets = ViewCompat.getRootWindowInsets(view) - - val orientation = LocalConfiguration.current.orientation - println("This Log only exists to force orientation listener $orientation") - - val activityWindow = getActivityWindow() - val dialogWindow = getDialogWindow() - val parentView = LocalView.current.parent as View - SideEffect { - if (activityWindow != null && dialogWindow != null) { - val attributes = WindowManager.LayoutParams() - attributes.copyFrom(activityWindow.attributes) - attributes.type = dialogWindow.attributes.type - dialogWindow.attributes = attributes - parentView.layoutParams = FrameLayout.LayoutParams(activityWindow.decorView.width, activityWindow.decorView.height) - view.layoutParams = FrameLayout.LayoutParams(activityWindow.decorView.width, activityWindow.decorView.height) - } - } - - DisposableEffect(key1 = Unit) { - if (Build.VERSION.SDK_INT >= 30) { - view.windowInsetsController?.hide( - android.view.WindowInsets.Type.systemBars() - ) - } - - onDispose { - if (Build.VERSION.SDK_INT >= 30) { - view.windowInsetsController?.show( - android.view.WindowInsets.Type.systemBars() - ) - } - } - } - - Surface(modifier = Modifier.fillMaxSize()) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { - DialogContent(allImages, imageUrl, onDismiss, accountViewModel) - } - } + val activityWindow = getActivityWindow() + val dialogWindow = getDialogWindow() + val parentView = LocalView.current.parent as View + SideEffect { + if (activityWindow != null && dialogWindow != null) { + val attributes = WindowManager.LayoutParams() + attributes.copyFrom(activityWindow.attributes) + attributes.type = dialogWindow.attributes.type + dialogWindow.attributes = attributes + parentView.layoutParams = + FrameLayout.LayoutParams(activityWindow.decorView.width, activityWindow.decorView.height) + view.layoutParams = + FrameLayout.LayoutParams(activityWindow.decorView.width, activityWindow.decorView.height) + } } + + DisposableEffect(key1 = Unit) { + if (Build.VERSION.SDK_INT >= 30) { + view.windowInsetsController?.hide( + android.view.WindowInsets.Type.systemBars(), + ) + } + + onDispose { + if (Build.VERSION.SDK_INT >= 30) { + view.windowInsetsController?.show( + android.view.WindowInsets.Type.systemBars(), + ) + } + } + } + + Surface(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + DialogContent(allImages, imageUrl, onDismiss, accountViewModel) + } + } + } } @Composable @OptIn(ExperimentalFoundationApi::class) private fun DialogContent( - allImages: ImmutableList, - imageUrl: ZoomableContent, - onDismiss: () -> Unit, - accountViewModel: AccountViewModel + allImages: ImmutableList, + imageUrl: ZoomableContent, + onDismiss: () -> Unit, + accountViewModel: AccountViewModel, ) { - val pagerState: PagerState = rememberPagerState() { allImages.size } - val controllerVisible = remember { mutableStateOf(false) } - val holdOn = remember { mutableStateOf(true) } + val pagerState: PagerState = rememberPagerState { allImages.size } + val controllerVisible = remember { mutableStateOf(false) } + val holdOn = remember { mutableStateOf(true) } - LaunchedEffect(key1 = pagerState, key2 = imageUrl) { - launch { - val page = allImages.indexOf(imageUrl) - if (page > -1) { - pagerState.scrollToPage(page) - } - } - launch(Dispatchers.Default) { - delay(2000) - holdOn.value = false - } + LaunchedEffect(key1 = pagerState, key2 = imageUrl) { + launch { + val page = allImages.indexOf(imageUrl) + if (page > -1) { + pagerState.scrollToPage(page) + } } - - if (allImages.size > 1) { - SlidingCarousel( - pagerState = pagerState - ) { index -> - RenderImageOrVideo( - content = allImages[index], - roundedCorner = false, - topPaddingForControllers = Size55dp, - onControllerVisibilityChanged = { - controllerVisible.value = it - }, - onToggleControllerVisibility = { - controllerVisible.value = !controllerVisible.value - }, - accountViewModel = accountViewModel - ) - } - } else { - RenderImageOrVideo( - content = imageUrl, - roundedCorner = false, - topPaddingForControllers = Size55dp, - onControllerVisibilityChanged = { - controllerVisible.value = it - }, - onToggleControllerVisibility = { - controllerVisible.value = !controllerVisible.value - }, - accountViewModel = accountViewModel - ) + launch(Dispatchers.Default) { + delay(2000) + holdOn.value = false } + } - AnimatedVisibility( - visible = holdOn.value || controllerVisible.value, - enter = remember { fadeIn() }, - exit = remember { fadeOut() } + if (allImages.size > 1) { + SlidingCarousel( + pagerState = pagerState, + ) { index -> + RenderImageOrVideo( + content = allImages[index], + roundedCorner = false, + topPaddingForControllers = Size55dp, + onControllerVisibilityChanged = { controllerVisible.value = it }, + onToggleControllerVisibility = { controllerVisible.value = !controllerVisible.value }, + accountViewModel = accountViewModel, + ) + } + } else { + RenderImageOrVideo( + content = imageUrl, + roundedCorner = false, + topPaddingForControllers = Size55dp, + onControllerVisibilityChanged = { controllerVisible.value = it }, + onToggleControllerVisibility = { controllerVisible.value = !controllerVisible.value }, + accountViewModel = accountViewModel, + ) + } + + AnimatedVisibility( + visible = holdOn.value || controllerVisible.value, + enter = remember { fadeIn() }, + exit = remember { fadeOut() }, + ) { + Row( + modifier = Modifier.padding(10.dp).statusBarsPadding().systemBarsPadding().fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Row( - modifier = Modifier - .padding(10.dp) - .statusBarsPadding().systemBarsPadding() - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - CloseButton(onPress = onDismiss) + CloseButton(onPress = onDismiss) - val myContent = allImages[pagerState.currentPage] - if (myContent is ZoomableUrlContent) { - Row() { - CopyToClipboard(content = myContent) - Spacer(modifier = StdHorzSpacer) - SaveToGallery(url = myContent.url) - } - } else if (myContent is ZoomableLocalImage && myContent.localFile != null) { - SaveToGallery( - localFile = myContent.localFile, - mimeType = myContent.mimeType - ) - } + val myContent = allImages[pagerState.currentPage] + if (myContent is ZoomableUrlContent) { + Row { + CopyToClipboard(content = myContent) + Spacer(modifier = StdHorzSpacer) + SaveToGallery(url = myContent.url) } + } else if (myContent is ZoomableLocalImage && myContent.localFile != null) { + SaveToGallery( + localFile = myContent.localFile, + mimeType = myContent.mimeType, + ) + } } + } } @Composable @OptIn(ExperimentalFoundationApi::class) fun InlineCarrousel( - allImages: ImmutableList, - imageUrl: String + allImages: ImmutableList, + imageUrl: String, ) { - val pagerState: PagerState = rememberPagerState() { allImages.size } + val pagerState: PagerState = rememberPagerState { allImages.size } - LaunchedEffect(key1 = pagerState, key2 = imageUrl) { - launch { - val page = allImages.indexOf(imageUrl) - if (page > -1) { - pagerState.scrollToPage(page) - } - } + LaunchedEffect(key1 = pagerState, key2 = imageUrl) { + launch { + val page = allImages.indexOf(imageUrl) + if (page > -1) { + pagerState.scrollToPage(page) + } } + } - if (allImages.size > 1) { - SlidingCarousel( - pagerState = pagerState - ) { index -> - AsyncImage( - model = allImages[index], - contentDescription = null, - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth() - ) - } - } else { - AsyncImage( - model = imageUrl, - contentDescription = null, - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth() - ) + if (allImages.size > 1) { + SlidingCarousel( + pagerState = pagerState, + ) { index -> + AsyncImage( + model = allImages[index], + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + ) } + } else { + AsyncImage( + model = imageUrl, + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + ) + } } @Composable -private fun CopyToClipboard( - content: ZoomableContent -) { - val popupExpanded = remember { mutableStateOf(false) } +private fun CopyToClipboard(content: ZoomableContent) { + val popupExpanded = remember { mutableStateOf(false) } - OutlinedButton( - modifier = Modifier.padding(horizontal = Size5dp), - onClick = { popupExpanded.value = true } - ) { - Icon( - imageVector = Icons.Default.Share, - modifier = Size20Modifier, - contentDescription = stringResource(R.string.copy_url_to_clipboard) - ) + OutlinedButton( + modifier = Modifier.padding(horizontal = Size5dp), + onClick = { popupExpanded.value = true }, + ) { + Icon( + imageVector = Icons.Default.Share, + modifier = Size20Modifier, + contentDescription = stringResource(R.string.copy_url_to_clipboard), + ) - ShareImageAction(popupExpanded, content) { popupExpanded.value = false } - } + ShareImageAction(popupExpanded, content) { popupExpanded.value = false } + } } @Composable private fun ShareImageAction( - popupExpanded: - MutableState, - content: ZoomableContent, - onDismiss: () -> Unit + popupExpanded: MutableState, + content: ZoomableContent, + onDismiss: () -> Unit, ) { - DropdownMenu( - expanded = popupExpanded.value, - onDismissRequest = onDismiss - ) { - val clipboardManager = LocalClipboardManager.current + DropdownMenu( + expanded = popupExpanded.value, + onDismissRequest = onDismiss, + ) { + val clipboardManager = LocalClipboardManager.current - if (content is ZoomableUrlContent) { - DropdownMenuItem( - text = { - Text(stringResource(R.string.copy_url_to_clipboard)) - }, - onClick = { - clipboardManager.setText(AnnotatedString(content.url)); onDismiss() - } - ) - if (content.uri != null) { - DropdownMenuItem( - text = { - Text(stringResource(R.string.copy_the_note_id_to_the_clipboard)) - }, - onClick = { - clipboardManager.setText(AnnotatedString(content.uri)); onDismiss() - } - ) - } - } - - if (content is ZoomablePreloadedContent) { - DropdownMenuItem( - text = { - Text(stringResource(R.string.copy_the_note_id_to_the_clipboard)) - }, - onClick = { - clipboardManager.setText(AnnotatedString(content.uri)); onDismiss() - } - ) - } + if (content is ZoomableUrlContent) { + DropdownMenuItem( + text = { Text(stringResource(R.string.copy_url_to_clipboard)) }, + onClick = { + clipboardManager.setText(AnnotatedString(content.url)) + onDismiss() + }, + ) + if (content.uri != null) { + DropdownMenuItem( + text = { Text(stringResource(R.string.copy_the_note_id_to_the_clipboard)) }, + onClick = { + clipboardManager.setText(AnnotatedString(content.uri)) + onDismiss() + }, + ) + } } + + if (content is ZoomablePreloadedContent) { + DropdownMenuItem( + text = { Text(stringResource(R.string.copy_the_note_id_to_the_clipboard)) }, + onClick = { + clipboardManager.setText(AnnotatedString(content.uri)) + onDismiss() + }, + ) + } + } } @Composable private fun RenderImageOrVideo( - content: ZoomableContent, - roundedCorner: Boolean, - topPaddingForControllers: Dp = Dp.Unspecified, - onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, - onToggleControllerVisibility: (() -> Unit)? = null, - accountViewModel: AccountViewModel + content: ZoomableContent, + roundedCorner: Boolean, + topPaddingForControllers: Dp = Dp.Unspecified, + onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, + onToggleControllerVisibility: (() -> Unit)? = null, + accountViewModel: AccountViewModel, ) { - val automaticallyStartPlayback = remember { - mutableStateOf(true) - } + val automaticallyStartPlayback = remember { mutableStateOf(true) } - if (content is ZoomableUrlImage) { - val mainModifier = Modifier - .fillMaxSize() - .zoomable( - rememberZoomState(), - onTap = { - if (onToggleControllerVisibility != null) { - onToggleControllerVisibility() - } - } - ) - - UrlImageView( - content = content, - mainImageModifier = mainModifier, - topPaddingForControllers = topPaddingForControllers, - accountViewModel, - alwayShowImage = true - ) - } else if (content is ZoomableUrlVideo) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { - VideoViewInner( - videoUri = content.url, - title = content.description, - artworkUri = content.artworkUri, - authorName = content.authorName, - roundedCorner = roundedCorner, - topPaddingForControllers = topPaddingForControllers, - automaticallyStartPlayback = automaticallyStartPlayback, - onControllerVisibilityChanged = onControllerVisibilityChanged - ) - } - } else if (content is ZoomableLocalImage) { - val mainModifier = Modifier - .fillMaxSize() - .zoomable( - rememberZoomState(), - onTap = { - if (onToggleControllerVisibility != null) { - onToggleControllerVisibility() - } - } - ) - - LocalImageView( - content = content, - mainImageModifier = mainModifier, - topPaddingForControllers = topPaddingForControllers, - accountViewModel, - alwayShowImage = true - ) - } else if (content is ZoomableLocalVideo) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { - content.localFile?.let { - VideoViewInner( - videoUri = it.toUri().toString(), - title = content.description, - artworkUri = content.artworkUri, - authorName = content.authorName, - roundedCorner = roundedCorner, - topPaddingForControllers = topPaddingForControllers, - automaticallyStartPlayback = automaticallyStartPlayback, - onControllerVisibilityChanged = onControllerVisibilityChanged - ) + if (content is ZoomableUrlImage) { + val mainModifier = + Modifier.fillMaxSize() + .zoomable( + rememberZoomState(), + onTap = { + if (onToggleControllerVisibility != null) { + onToggleControllerVisibility() } - } + }, + ) + + UrlImageView( + content = content, + mainImageModifier = mainModifier, + topPaddingForControllers = topPaddingForControllers, + accountViewModel, + alwayShowImage = true, + ) + } else if (content is ZoomableUrlVideo) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { + VideoViewInner( + videoUri = content.url, + title = content.description, + artworkUri = content.artworkUri, + authorName = content.authorName, + roundedCorner = roundedCorner, + topPaddingForControllers = topPaddingForControllers, + automaticallyStartPlayback = automaticallyStartPlayback, + onControllerVisibilityChanged = onControllerVisibilityChanged, + ) } + } else if (content is ZoomableLocalImage) { + val mainModifier = + Modifier.fillMaxSize() + .zoomable( + rememberZoomState(), + onTap = { + if (onToggleControllerVisibility != null) { + onToggleControllerVisibility() + } + }, + ) + + LocalImageView( + content = content, + mainImageModifier = mainModifier, + topPaddingForControllers = topPaddingForControllers, + accountViewModel, + alwayShowImage = true, + ) + } else if (content is ZoomableLocalVideo) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { + content.localFile?.let { + VideoViewInner( + videoUri = it.toUri().toString(), + title = content.description, + artworkUri = content.artworkUri, + authorName = content.authorName, + roundedCorner = roundedCorner, + topPaddingForControllers = topPaddingForControllers, + automaticallyStartPlayback = automaticallyStartPlayback, + onControllerVisibilityChanged = onControllerVisibilityChanged, + ) + } + } + } } @OptIn(ExperimentalCoilApi::class) -private fun verifyHash(content: ZoomableUrlContent, context: Context): Boolean? { - if (content.hash == null) return null +private fun verifyHash( + content: ZoomableUrlContent, + context: Context, +): Boolean? { + if (content.hash == null) return null - context.imageLoader.diskCache?.get(content.url)?.use { snapshot -> - val hash = CryptoUtils.sha256(snapshot.data.toFile().readBytes()).toHexKey() + context.imageLoader.diskCache?.get(content.url)?.use { snapshot -> + val hash = CryptoUtils.sha256(snapshot.data.toFile().readBytes()).toHexKey() - Log.d("Image Hash Verification", "$hash == ${content.hash}") + Log.d("Image Hash Verification", "$hash == ${content.hash}") - return hash == content.hash - } + return hash == content.hash + } - return null + return null } @Composable -private fun HashVerificationSymbol(verifiedHash: Boolean, modifier: Modifier) { - val localContext = LocalContext.current +private fun HashVerificationSymbol( + verifiedHash: Boolean, + modifier: Modifier, +) { + val localContext = LocalContext.current - val openDialogMsg = remember { mutableStateOf(null) } + val openDialogMsg = remember { mutableStateOf(null) } - openDialogMsg.value?.let { - InformationDialog( - title = localContext.getString(R.string.hash_verification_info_title), - textContent = it - ) { - openDialogMsg.value = null - } - } - - Box( - modifier - .width(40.dp) - .height(40.dp) - .padding(10.dp) + openDialogMsg.value?.let { + InformationDialog( + title = localContext.getString(R.string.hash_verification_info_title), + textContent = it, ) { - if (verifiedHash) { - IconButton( - onClick = { - openDialogMsg.value = localContext.getString(R.string.hash_verification_passed) - } - ) { - HashCheckIcon(Size30dp) - } - } else { - IconButton( - onClick = { - openDialogMsg.value = localContext.getString(R.string.hash_verification_failed) - } - ) { - HashCheckFailedIcon(Size30dp) - } - } + openDialogMsg.value = null } + } + + Box( + modifier.width(40.dp).height(40.dp).padding(10.dp), + ) { + if (verifiedHash) { + IconButton( + onClick = { + openDialogMsg.value = localContext.getString(R.string.hash_verification_passed) + }, + ) { + HashCheckIcon(Size30dp) + } + } else { + IconButton( + onClick = { + openDialogMsg.value = localContext.getString(R.string.hash_verification_failed) + }, + ) { + HashCheckFailedIcon(Size30dp) + } + } + } } // Window utils @Composable fun getDialogWindow(): Window? = (LocalView.current.parent as? DialogWindowProvider)?.window -@Composable -fun getActivityWindow(): Window? = LocalView.current.context.getActivityWindow() +@Composable fun getActivityWindow(): Window? = LocalView.current.context.getActivityWindow() private tailrec fun Context.getActivityWindow(): Window? = - when (this) { - is Activity -> window - is ContextWrapper -> baseContext.getActivityWindow() - else -> null - } + when (this) { + is Activity -> window + is ContextWrapper -> baseContext.getActivityWindow() + else -> null + } -@Composable -fun getActivity(): Activity? = LocalView.current.context.getActivity() +@Composable fun getActivity(): Activity? = LocalView.current.context.getActivity() private tailrec fun Context.getActivity(): Activity? = - when (this) { - is Activity -> this - is ContextWrapper -> baseContext.getActivity() - else -> null - } + when (this) { + is Activity -> this + is ContextWrapper -> baseContext.getActivity() + else -> null + } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPrivateFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPrivateFeedFilter.kt index d191d8200..76c841a00 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPrivateFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPrivateFeedFilter.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account @@ -5,25 +25,27 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note class BookmarkPrivateFeedFilter(val account: Account) : FeedFilter() { - override fun feedKey(): String { - return account.userProfile().latestBookmarkList?.id ?: "" - } + override fun feedKey(): String { + return account.userProfile().latestBookmarkList?.id ?: "" + } - override fun feed(): List { - val bookmarks = account.userProfile().latestBookmarkList + override fun feed(): List { + val bookmarks = account.userProfile().latestBookmarkList - if (!account.isWriteable()) return emptyList() + if (!account.isWriteable()) return emptyList() - val privateTags = bookmarks?.cachedPrivateTags() ?: return emptyList() + val privateTags = bookmarks?.cachedPrivateTags() ?: return emptyList() - val notes = bookmarks.filterEvents(privateTags) - .mapNotNull { LocalCache.checkGetOrCreateNote(it) } + val notes = + bookmarks.filterEvents(privateTags).mapNotNull { LocalCache.checkGetOrCreateNote(it) } - val addresses = bookmarks.filterAddresses(privateTags) - .map { LocalCache.getOrCreateAddressableNote(it) } + val addresses = + bookmarks.filterAddresses(privateTags).map { LocalCache.getOrCreateAddressableNote(it) } - return notes.plus(addresses).toSet() - .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - .reversed() - } + return notes + .plus(addresses) + .toSet() + .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) + .reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPublicFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPublicFeedFilter.kt index 13ee962ca..339a2adfe 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPublicFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPublicFeedFilter.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account @@ -5,17 +25,22 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note class BookmarkPublicFeedFilter(val account: Account) : FeedFilter() { - override fun feedKey(): String { - return account.userProfile().latestBookmarkList?.id ?: "" - } - override fun feed(): List { - val bookmarks = account.userProfile().latestBookmarkList + override fun feedKey(): String { + return account.userProfile().latestBookmarkList?.id ?: "" + } - val notes = bookmarks?.taggedEvents()?.mapNotNull { LocalCache.checkGetOrCreateNote(it) } ?: emptyList() - val addresses = bookmarks?.taggedAddresses()?.map { LocalCache.getOrCreateAddressableNote(it) } ?: emptyList() + override fun feed(): List { + val bookmarks = account.userProfile().latestBookmarkList - return notes.plus(addresses).toSet() - .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - .reversed() - } + val notes = + bookmarks?.taggedEvents()?.mapNotNull { LocalCache.checkGetOrCreateNote(it) } ?: emptyList() + val addresses = + bookmarks?.taggedAddresses()?.map { LocalCache.getOrCreateAddressableNote(it) } ?: emptyList() + + return notes + .plus(addresses) + .toSet() + .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) + .reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt index 8c31ab5da..263a64564 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account @@ -5,27 +25,25 @@ import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.Note class ChannelFeedFilter(val channel: Channel, val account: Account) : AdditiveFeedFilter() { + override fun feedKey(): String { + return channel.idHex + } - override fun feedKey(): String { - return channel.idHex - } + // returns the last Note of each user. + override fun feed(): List { + return channel.notes.values + .filter { account.isAcceptable(it) } + .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) + .reversed() + } - // returns the last Note of each user. - override fun feed(): List { - return channel.notes - .values - .filter { account.isAcceptable(it) } - .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - .reversed() - } + override fun applyFilter(collection: Set): Set { + return collection + .filter { channel.notes.containsKey(it.idHex) && account.isAcceptable(it) } + .toSet() + } - override fun applyFilter(collection: Set): Set { - return collection - .filter { channel.notes.containsKey(it.idHex) && account.isAcceptable(it) } - .toSet() - } - - override fun sort(collection: Set): List { - return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - } + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt index 4c73e162b..4d1a05f7f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt @@ -1,37 +1,54 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.events.ChatroomKey -class ChatroomFeedFilter(val withUser: ChatroomKey, val account: Account) : AdditiveFeedFilter() { - // returns the last Note of each user. - override fun feedKey(): String { - return withUser.hashCode().toString() - } +class ChatroomFeedFilter(val withUser: ChatroomKey, val account: Account) : + AdditiveFeedFilter() { + // returns the last Note of each user. + override fun feedKey(): String { + return withUser.hashCode().toString() + } - override fun feed(): List { - val messages = account - .userProfile() - .privateChatrooms[withUser] ?: return emptyList() + override fun feed(): List { + val messages = account.userProfile().privateChatrooms[withUser] ?: return emptyList() - return messages.roomMessages - .filter { account.isAcceptable(it) } - .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - .reversed() - } + return messages.roomMessages + .filter { account.isAcceptable(it) } + .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) + .reversed() + } - override fun applyFilter(collection: Set): Set { - val messages = account - .userProfile() - .privateChatrooms[withUser] ?: return emptySet() + override fun applyFilter(collection: Set): Set { + val messages = account.userProfile().privateChatrooms[withUser] ?: return emptySet() - return collection - .filter { it in messages.roomMessages && account.isAcceptable(it) == true } - .toSet() - } + return collection + .filter { it in messages.roomMessages && account.isAcceptable(it) == true } + .toSet() + } - override fun sort(collection: Set): List { - return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - } + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt index f07a7928e..6157d0011 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account @@ -9,153 +29,172 @@ import com.vitorpamplona.quartz.events.ChatroomKey import com.vitorpamplona.quartz.events.ChatroomKeyable class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter() { + override fun feedKey(): String { + return account.userProfile().pubkeyHex + } - override fun feedKey(): String { - return account.userProfile().pubkeyHex - } + // returns the last Note of each user. + override fun feed(): List { + val me = account.userProfile() + val followingKeySet = account.followingKeySet() - // returns the last Note of each user. - override fun feed(): List { - val me = account.userProfile() - val followingKeySet = account.followingKeySet() + val knownChatrooms = + me.privateChatrooms.filter { + (it.value.senderIntersects(followingKeySet) || me.hasSentMessagesTo(it.key)) && + !account.isAllHidden(it.key.users) + } - val knownChatrooms = me.privateChatrooms.filter { - (it.value.senderIntersects(followingKeySet) || me.hasSentMessagesTo(it.key)) && !account.isAllHidden(it.key.users) + val privateMessages = + knownChatrooms.mapNotNull { it -> + it.value.roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).lastOrNull { + it.event != null } + } - val privateMessages = knownChatrooms.mapNotNull { it -> - it.value - .roomMessages - .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - .lastOrNull { it.event != null } - } - - val publicChannels = account.selectedChatsFollowList().mapNotNull { - LocalCache.getChannelIfExists(it) - }.mapNotNull { it -> - it.notes.values - .filter { account.isAcceptable(it) && it.event != null } - .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - .lastOrNull() - } - - return (privateMessages + publicChannels) + val publicChannels = + account + .selectedChatsFollowList() + .mapNotNull { LocalCache.getChannelIfExists(it) } + .mapNotNull { it -> + it.notes.values + .filter { account.isAcceptable(it) && it.event != null } .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - .reversed() - } - - override fun updateListWith(oldList: List, newItems: Set): List { - val me = account.userProfile() - - // Gets the latest message by channel from the new items. - val newRelevantPublicMessages = filterRelevantPublicMessages(newItems, account) - - // Gets the latest message by room from the new items. - val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account) - - if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty()) { - return oldList + .lastOrNull() } - var myNewList = oldList + return (privateMessages + publicChannels) + .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) + .reversed() + } - newRelevantPublicMessages.forEach { newNotePair -> - var hasUpdated = false - oldList.forEach { oldNote -> - if (newNotePair.key == oldNote.channelHex()) { - hasUpdated = true - if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) { - myNewList = myNewList.updated(oldNote, newNotePair.value) - } - } - } - if (!hasUpdated) { - myNewList = myNewList.plus(newNotePair.value) - } - } + override fun updateListWith( + oldList: List, + newItems: Set, + ): List { + val me = account.userProfile() - newRelevantPrivateMessages.forEach { newNotePair -> - var hasUpdated = false - oldList.forEach { oldNote -> - val oldRoom = (oldNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex) + // Gets the latest message by channel from the new items. + val newRelevantPublicMessages = filterRelevantPublicMessages(newItems, account) - if (newNotePair.key == oldRoom) { - hasUpdated = true - if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) { - myNewList = myNewList.updated(oldNote, newNotePair.value) - } - } - } - if (!hasUpdated) { - myNewList = myNewList.plus(newNotePair.value) - } - } + // Gets the latest message by room from the new items. + val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account) - return sort(myNewList.toSet()).take(1000) + if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty()) { + return oldList } - override fun applyFilter(newItems: Set): Set { - // Gets the latest message by channel from the new items. - val newRelevantPublicMessages = filterRelevantPublicMessages(newItems, account) + var myNewList = oldList - // Gets the latest message by room from the new items. - val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account) - - return if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty()) { - emptySet() - } else { - (newRelevantPrivateMessages.values + newRelevantPublicMessages.values).toSet() + newRelevantPublicMessages.forEach { newNotePair -> + var hasUpdated = false + oldList.forEach { oldNote -> + if (newNotePair.key == oldNote.channelHex()) { + hasUpdated = true + if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) { + myNewList = myNewList.updated(oldNote, newNotePair.value) + } } + } + if (!hasUpdated) { + myNewList = myNewList.plus(newNotePair.value) + } } - private fun filterRelevantPublicMessages(newItems: Set, account: Account): MutableMap { - val followingChannels = account.userProfile().latestContactList?.taggedEvents()?.toSet() ?: emptySet() - val newRelevantPublicMessages = mutableMapOf() - newItems.filter { it.event is ChannelMessageEvent }.forEach { newNote -> - newNote.channelHex()?.let { channelHex -> - if (channelHex in followingChannels && account.isAcceptable(newNote)) { - val lastNote = newRelevantPublicMessages.get(channelHex) - if (lastNote != null) { - if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) { - newRelevantPublicMessages.put(channelHex, newNote) - } - } else { - newRelevantPublicMessages.put(channelHex, newNote) - } - } + newRelevantPrivateMessages.forEach { newNotePair -> + var hasUpdated = false + oldList.forEach { oldNote -> + val oldRoom = (oldNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex) + + if (newNotePair.key == oldRoom) { + hasUpdated = true + if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) { + myNewList = myNewList.updated(oldNote, newNotePair.value) + } + } + } + if (!hasUpdated) { + myNewList = myNewList.plus(newNotePair.value) + } + } + + return sort(myNewList.toSet()).take(1000) + } + + override fun applyFilter(newItems: Set): Set { + // Gets the latest message by channel from the new items. + val newRelevantPublicMessages = filterRelevantPublicMessages(newItems, account) + + // Gets the latest message by room from the new items. + val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account) + + return if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty()) { + emptySet() + } else { + (newRelevantPrivateMessages.values + newRelevantPublicMessages.values).toSet() + } + } + + private fun filterRelevantPublicMessages( + newItems: Set, + account: Account, + ): MutableMap { + val followingChannels = + account.userProfile().latestContactList?.taggedEvents()?.toSet() ?: emptySet() + val newRelevantPublicMessages = mutableMapOf() + newItems + .filter { it.event is ChannelMessageEvent } + .forEach { newNote -> + newNote.channelHex()?.let { channelHex -> + if (channelHex in followingChannels && account.isAcceptable(newNote)) { + val lastNote = newRelevantPublicMessages.get(channelHex) + if (lastNote != null) { + if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) { + newRelevantPublicMessages.put(channelHex, newNote) + } + } else { + newRelevantPublicMessages.put(channelHex, newNote) } + } } - return newRelevantPublicMessages - } + } + return newRelevantPublicMessages + } - private fun filterRelevantPrivateMessages(newItems: Set, account: Account): MutableMap { - val me = account.userProfile() - val followingKeySet = account.followingKeySet() + private fun filterRelevantPrivateMessages( + newItems: Set, + account: Account, + ): MutableMap { + val me = account.userProfile() + val followingKeySet = account.followingKeySet() - val newRelevantPrivateMessages = mutableMapOf() - newItems.filter { it.event is ChatroomKeyable }.forEach { newNote -> - val roomKey = (newNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex) - val room = account.userProfile().privateChatrooms[roomKey] + val newRelevantPrivateMessages = mutableMapOf() + newItems + .filter { it.event is ChatroomKeyable } + .forEach { newNote -> + val roomKey = (newNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex) + val room = account.userProfile().privateChatrooms[roomKey] - if (roomKey != null && room != null) { - if ((newNote.author?.pubkeyHex == me.pubkeyHex || room.senderIntersects(followingKeySet) || me.hasSentMessagesTo(roomKey)) && !account.isAllHidden(roomKey.users)) { - val lastNote = newRelevantPrivateMessages.get(roomKey) - if (lastNote != null) { - if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) { - newRelevantPrivateMessages.put(roomKey, newNote) - } - } else { - newRelevantPrivateMessages.put(roomKey, newNote) - } - } + if (roomKey != null && room != null) { + if ( + (newNote.author?.pubkeyHex == me.pubkeyHex || + room.senderIntersects(followingKeySet) || + me.hasSentMessagesTo(roomKey)) && !account.isAllHidden(roomKey.users) + ) { + val lastNote = newRelevantPrivateMessages.get(roomKey) + if (lastNote != null) { + if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) { + newRelevantPrivateMessages.put(roomKey, newNote) + } + } else { + newRelevantPrivateMessages.put(roomKey, newNote) } + } } - return newRelevantPrivateMessages - } + } + return newRelevantPrivateMessages + } - override fun sort(collection: Set): List { - return collection - .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - .reversed() - } + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt index 07158b780..44ffb6804 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account @@ -8,104 +28,114 @@ import com.vitorpamplona.quartz.events.ChatroomKeyable import com.vitorpamplona.quartz.events.PrivateDmEvent class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter() { + override fun feedKey(): String { + return account.userProfile().pubkeyHex + } - override fun feedKey(): String { - return account.userProfile().pubkeyHex + // returns the last Note of each user. + override fun feed(): List { + val me = account.userProfile() + val followingKeySet = account.followingKeySet() + + val newChatrooms = + me.privateChatrooms.filter { + !it.value.senderIntersects(followingKeySet) && + !me.hasSentMessagesTo(it.key) && + !account.isAllHidden(it.key.users) + } + + val privateMessages = + newChatrooms.mapNotNull { it -> + it.value.roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).lastOrNull { + it.event != null + } + } + + return privateMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } + + override fun updateListWith( + oldList: List, + newItems: Set, + ): List { + val me = account.userProfile() + + // Gets the latest message by room from the new items. + val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account) + + if (newRelevantPrivateMessages.isEmpty()) { + return oldList } - // returns the last Note of each user. - override fun feed(): List { - val me = account.userProfile() - val followingKeySet = account.followingKeySet() + var myNewList = oldList - val newChatrooms = me.privateChatrooms.filter { - !it.value.senderIntersects(followingKeySet) && !me.hasSentMessagesTo(it.key) && !account.isAllHidden(it.key.users) + newRelevantPrivateMessages.forEach { newNotePair -> + var hasUpdated = false + oldList.forEach { oldNote -> + val oldRoom = (oldNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex) + + if (newNotePair.key == oldRoom) { + hasUpdated = true + if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) { + myNewList = myNewList.updated(oldNote, newNotePair.value) + } } - - val privateMessages = newChatrooms.mapNotNull { it -> - it.value - .roomMessages - .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - .lastOrNull { it.event != null } - } - - return privateMessages - .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - .reversed() + } + if (!hasUpdated) { + myNewList = myNewList.plus(newNotePair.value) + } } - override fun updateListWith(oldList: List, newItems: Set): List { - val me = account.userProfile() + return sort(myNewList.toSet()).take(1000) + } - // Gets the latest message by room from the new items. - val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account) + override fun applyFilter(newItems: Set): Set { + // Gets the latest message by room from the new items. + val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account) - if (newRelevantPrivateMessages.isEmpty()) { - return oldList - } + return if (newRelevantPrivateMessages.isEmpty()) { + emptySet() + } else { + newRelevantPrivateMessages.values.toSet() + } + } - var myNewList = oldList + private fun filterRelevantPrivateMessages( + newItems: Set, + account: Account, + ): MutableMap { + val me = account.userProfile() + val followingKeySet = account.followingKeySet() - newRelevantPrivateMessages.forEach { newNotePair -> - var hasUpdated = false - oldList.forEach { oldNote -> - val oldRoom = (oldNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex) + val newRelevantPrivateMessages = mutableMapOf() + newItems + .filter { it.event is PrivateDmEvent } + .forEach { newNote -> + val roomKey = (newNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex) + val room = account.userProfile().privateChatrooms[roomKey] - if (newNotePair.key == oldRoom) { - hasUpdated = true - if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) { - myNewList = myNewList.updated(oldNote, newNotePair.value) - } - } - } - if (!hasUpdated) { - myNewList = myNewList.plus(newNotePair.value) + if ( + roomKey != null && + room != null && + (newNote.author?.pubkeyHex != me.pubkeyHex && + room.senderIntersects(followingKeySet) && + !me.hasSentMessagesTo(roomKey)) && + !account.isAllHidden(roomKey.users) + ) { + val lastNote = newRelevantPrivateMessages.get(roomKey) + if (lastNote != null) { + if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) { + newRelevantPrivateMessages.put(roomKey, newNote) } + } else { + newRelevantPrivateMessages.put(roomKey, newNote) + } } + } + return newRelevantPrivateMessages + } - return sort(myNewList.toSet()).take(1000) - } - - override fun applyFilter(newItems: Set): Set { - // Gets the latest message by room from the new items. - val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account) - - return if (newRelevantPrivateMessages.isEmpty()) { - emptySet() - } else { - newRelevantPrivateMessages.values.toSet() - } - } - - private fun filterRelevantPrivateMessages(newItems: Set, account: Account): MutableMap { - val me = account.userProfile() - val followingKeySet = account.followingKeySet() - - val newRelevantPrivateMessages = mutableMapOf() - newItems.filter { it.event is PrivateDmEvent }.forEach { newNote -> - val roomKey = (newNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex) - val room = account.userProfile().privateChatrooms[roomKey] - - if (roomKey != null && room != null && - (newNote.author?.pubkeyHex != me.pubkeyHex && room.senderIntersects(followingKeySet) && !me.hasSentMessagesTo(roomKey)) && - !account.isAllHidden(roomKey.users) - ) { - val lastNote = newRelevantPrivateMessages.get(roomKey) - if (lastNote != null) { - if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) { - newRelevantPrivateMessages.put(roomKey, newNote) - } - } else { - newRelevantPrivateMessages.put(roomKey, newNote) - } - } - } - return newRelevantPrivateMessages - } - - override fun sort(collection: Set): List { - return collection - .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - .reversed() - } + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/CommunityFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/CommunityFeedFilter.kt index 4cb185b1b..af0ec5737 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/CommunityFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/CommunityFeedFilter.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account @@ -6,39 +26,46 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent -class CommunityFeedFilter(val note: AddressableNote, val account: Account) : AdditiveFeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + note.idHex - } +class CommunityFeedFilter(val note: AddressableNote, val account: Account) : + AdditiveFeedFilter() { + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + note.idHex + } - override fun feed(): List { - return sort(innerApplyFilter(LocalCache.notes.values)) - } + override fun feed(): List { + return sort(innerApplyFilter(LocalCache.notes.values)) + } - override fun applyFilter(collection: Set): Set { - return innerApplyFilter(collection) - } + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } - private fun innerApplyFilter(collection: Collection): Set { - val myUnapprovedPosts = collection.asSequence() - .filter { it.event is CommunityPostApprovalEvent } // Only Approvals - .filter { it.author?.pubkeyHex == account.userProfile().pubkeyHex } // made by the logged in user - .filter { it.event?.isTaggedAddressableNote(note.idHex) == true } // for this community - .filter { it.isNewThread() } // check if it is a new thread - .toSet() + private fun innerApplyFilter(collection: Collection): Set { + val myUnapprovedPosts = + collection + .asSequence() + .filter { it.event is CommunityPostApprovalEvent } // Only Approvals + .filter { + it.author?.pubkeyHex == account.userProfile().pubkeyHex + } // made by the logged in user + .filter { it.event?.isTaggedAddressableNote(note.idHex) == true } // for this community + .filter { it.isNewThread() } // check if it is a new thread + .toSet() - val approvedPosts = collection - .asSequence() - .filter { it.event is CommunityPostApprovalEvent } // Only Approvals - .filter { it.event?.isTaggedAddressableNote(note.idHex) == true } // Of the given community - .mapNotNull { it.replyTo }.flatten() // get approved posts - .filter { it.isNewThread() } // check if it is a new thread - .toSet() + val approvedPosts = + collection + .asSequence() + .filter { it.event is CommunityPostApprovalEvent } // Only Approvals + .filter { it.event?.isTaggedAddressableNote(note.idHex) == true } // Of the given community + .mapNotNull { it.replyTo } + .flatten() // get approved posts + .filter { it.isNewThread() } // check if it is a new thread + .toSet() - return myUnapprovedPosts + approvedPosts - } + return myUnapprovedPosts + approvedPosts + } - override fun sort(collection: Set): List { - return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - } + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt index d41ecce0b..25bd17a6c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account @@ -12,69 +32,81 @@ import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.utils.TimeUtils open class DiscoverChatFeedFilter(val account: Account) : AdditiveFeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList.value - } + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList.value + } - override fun showHiddenKey(): Boolean { - return account.defaultDiscoveryFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - account.defaultDiscoveryFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) - } + override fun showHiddenKey(): Boolean { + return account.defaultDiscoveryFollowList.value == + PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + account.defaultDiscoveryFollowList.value == + MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + } - override fun feed(): List { - val allChannelNotes = LocalCache.channels.values.mapNotNull { LocalCache.getNoteIfExists(it.idHex) } + override fun feed(): List { + val allChannelNotes = + LocalCache.channels.values.mapNotNull { LocalCache.getNoteIfExists(it.idHex) } - val notes = innerApplyFilter(allChannelNotes) + val notes = innerApplyFilter(allChannelNotes) - return sort(notes) - } + return sort(notes) + } - override fun applyFilter(collection: Set): Set { - return innerApplyFilter(collection) - } + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } - protected open fun innerApplyFilter(collection: Collection): Set { - val now = TimeUtils.now() - val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS - val isHiddenList = showHiddenKey() + protected open fun innerApplyFilter(collection: Collection): Set { + val now = TimeUtils.now() + val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS + val isHiddenList = showHiddenKey() - val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: emptySet() - val followingTagSet = account.liveDiscoveryFollowLists.value?.hashtags ?: emptySet() - val followingGeohashSet = account.liveDiscoveryFollowLists.value?.geotags ?: emptySet() + val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: emptySet() + val followingTagSet = account.liveDiscoveryFollowLists.value?.hashtags ?: emptySet() + val followingGeohashSet = account.liveDiscoveryFollowLists.value?.geotags ?: emptySet() - val createEvents = collection.filter { it.event is ChannelCreateEvent } - val anyOtherChannelEvent = collection - .asSequence() - .filter { it.event is IsInPublicChatChannel } - .mapNotNull { (it.event as? IsInPublicChatChannel)?.channel() } - .mapNotNull { LocalCache.checkGetOrCreateNote(it) } - .toSet() + val createEvents = collection.filter { it.event is ChannelCreateEvent } + val anyOtherChannelEvent = + collection + .asSequence() + .filter { it.event is IsInPublicChatChannel } + .mapNotNull { (it.event as? IsInPublicChatChannel)?.channel() } + .mapNotNull { LocalCache.checkGetOrCreateNote(it) } + .toSet() - val activities = (createEvents + anyOtherChannelEvent) - .asSequence() - // .filter { it.event is ChannelCreateEvent } // Event heads might not be loaded yet. - .filter { isGlobal || it.author?.pubkeyHex in followingKeySet || it.event?.isTaggedHashes(followingTagSet) == true || it.event?.isTaggedGeoHashes(followingGeohashSet) == true } - .filter { isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true } - .filter { (it.createdAt() ?: 0) <= now } - .toSet() - - return activities - } - - override fun sort(collection: Set): List { - val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: account.liveKind3Follows.value.users - - val counter = ParticipantListBuilder() - val participantCounts = collection.associate { - it to counter.countFollowsThatParticipateOn(it, followingKeySet) + val activities = + (createEvents + anyOtherChannelEvent) + .asSequence() + // .filter { it.event is ChannelCreateEvent } // Event heads might not be loaded yet. + .filter { + isGlobal || + it.author?.pubkeyHex in followingKeySet || + it.event?.isTaggedHashes(followingTagSet) == true || + it.event?.isTaggedGeoHashes(followingGeohashSet) == true } + .filter { isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true } + .filter { (it.createdAt() ?: 0) <= now } + .toSet() - return collection.sortedWith( - compareBy( - { participantCounts[it] }, - { it.createdAt() }, - { it.idHex } - ) - ).reversed() - } + return activities + } + + override fun sort(collection: Set): List { + val followingKeySet = + account.liveDiscoveryFollowLists.value?.users ?: account.liveKind3Follows.value.users + + val counter = ParticipantListBuilder() + val participantCounts = + collection.associate { it to counter.countFollowsThatParticipateOn(it, followingKeySet) } + + return collection + .sortedWith( + compareBy( + { participantCounts[it] }, + { it.createdAt() }, + { it.idHex }, + ), + ) + .reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverCommunityFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverCommunityFeedFilter.kt index 85dd9e524..1a3193dc5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverCommunityFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverCommunityFeedFilter.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account @@ -12,74 +32,85 @@ import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.utils.TimeUtils open class DiscoverCommunityFeedFilter(val account: Account) : AdditiveFeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList.value - } + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList.value + } - override fun showHiddenKey(): Boolean { - return account.defaultDiscoveryFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - account.defaultDiscoveryFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) - } + override fun showHiddenKey(): Boolean { + return account.defaultDiscoveryFollowList.value == + PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + account.defaultDiscoveryFollowList.value == + MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + } - override fun feed(): List { - val allNotes = LocalCache.addressables.values + override fun feed(): List { + val allNotes = LocalCache.addressables.values - val notes = innerApplyFilter(allNotes) + val notes = innerApplyFilter(allNotes) - return sort(notes) - } + return sort(notes) + } - override fun applyFilter(collection: Set): Set { - return innerApplyFilter(collection) - } + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } - protected open fun innerApplyFilter(collection: Collection): Set { - val now = TimeUtils.now() - val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS - val isHiddenList = showHiddenKey() + protected open fun innerApplyFilter(collection: Collection): Set { + val now = TimeUtils.now() + val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS + val isHiddenList = showHiddenKey() - val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: emptySet() - val followingTagSet = account.liveDiscoveryFollowLists.value?.hashtags ?: emptySet() - val followingGeohashSet = account.liveDiscoveryFollowLists.value?.geotags ?: emptySet() + val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: emptySet() + val followingTagSet = account.liveDiscoveryFollowLists.value?.hashtags ?: emptySet() + val followingGeohashSet = account.liveDiscoveryFollowLists.value?.geotags ?: emptySet() - val createEvents = collection.filter { it.event is CommunityDefinitionEvent } - val anyOtherCommunityEvent = collection - .asSequence() - .filter { it.event is CommunityPostApprovalEvent } - .mapNotNull { (it.event as? CommunityPostApprovalEvent)?.communities() }.flatten() - .map { LocalCache.getOrCreateAddressableNote(it) } - .toSet() + val createEvents = collection.filter { it.event is CommunityDefinitionEvent } + val anyOtherCommunityEvent = + collection + .asSequence() + .filter { it.event is CommunityPostApprovalEvent } + .mapNotNull { (it.event as? CommunityPostApprovalEvent)?.communities() } + .flatten() + .map { LocalCache.getOrCreateAddressableNote(it) } + .toSet() - val activities = (createEvents + anyOtherCommunityEvent) - .asSequence() - .filter { it.event is CommunityDefinitionEvent } - .filter { isGlobal || it.author?.pubkeyHex in followingKeySet || it.event?.isTaggedHashes(followingTagSet) == true || it.event?.isTaggedGeoHashes(followingGeohashSet) == true } - .filter { isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true } - .filter { (it.createdAt() ?: 0) <= now } - .toSet() - - return activities - } - - override fun sort(collection: Set): List { - val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: account.liveKind3Follows.value.users - - val counter = ParticipantListBuilder() - val participantCounts = collection.associate { - it to counter.countFollowsThatParticipateOn(it, followingKeySet) + val activities = + (createEvents + anyOtherCommunityEvent) + .asSequence() + .filter { it.event is CommunityDefinitionEvent } + .filter { + isGlobal || + it.author?.pubkeyHex in followingKeySet || + it.event?.isTaggedHashes(followingTagSet) == true || + it.event?.isTaggedGeoHashes(followingGeohashSet) == true } + .filter { isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true } + .filter { (it.createdAt() ?: 0) <= now } + .toSet() - val allParticipants = collection.associate { - it to counter.countFollowsThatParticipateOn(it, null) - } + return activities + } - return collection.sortedWith( - compareBy( - { participantCounts[it] }, - { allParticipants[it] }, - { it.createdAt() }, - { it.idHex } - ) - ).reversed() - } + override fun sort(collection: Set): List { + val followingKeySet = + account.liveDiscoveryFollowLists.value?.users ?: account.liveKind3Follows.value.users + + val counter = ParticipantListBuilder() + val participantCounts = + collection.associate { it to counter.countFollowsThatParticipateOn(it, followingKeySet) } + + val allParticipants = + collection.associate { it to counter.countFollowsThatParticipateOn(it, null) } + + return collection + .sortedWith( + compareBy( + { participantCounts[it] }, + { allParticipants[it] }, + { it.createdAt() }, + { it.idHex }, + ), + ) + .reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt index f19047cc5..1ad077b70 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account @@ -14,90 +34,95 @@ import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.utils.TimeUtils open class DiscoverLiveFeedFilter( - val account: Account + val account: Account, ) : AdditiveFeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + followList() - } + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + followList() + } - open fun followList(): String { - return account.defaultDiscoveryFollowList.value - } + open fun followList(): String { + return account.defaultDiscoveryFollowList.value + } - override fun showHiddenKey(): Boolean { - return followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - followList() == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) - } + override fun showHiddenKey(): Boolean { + return followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + followList() == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + } - override fun feed(): List { - val allChannelNotes = - LocalCache.channels.values.mapNotNull { LocalCache.getNoteIfExists(it.idHex) } - val allMessageNotes = LocalCache.channels.values.map { it.notes.values }.flatten() + override fun feed(): List { + val allChannelNotes = + LocalCache.channels.values.mapNotNull { LocalCache.getNoteIfExists(it.idHex) } + val allMessageNotes = LocalCache.channels.values.map { it.notes.values }.flatten() - val notes = innerApplyFilter(allChannelNotes + allMessageNotes) + val notes = innerApplyFilter(allChannelNotes + allMessageNotes) - return sort(notes) - } + return sort(notes) + } - override fun applyFilter(collection: Set): Set { - return innerApplyFilter(collection) - } + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } - protected open fun innerApplyFilter(collection: Collection): Set { - val now = TimeUtils.now() - val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS - val isHiddenList = showHiddenKey() + protected open fun innerApplyFilter(collection: Collection): Set { + val now = TimeUtils.now() + val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS + val isHiddenList = showHiddenKey() - val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: emptySet() - val followingTagSet = account.liveDiscoveryFollowLists.value?.hashtags ?: emptySet() - val followingGeohashSet = account.liveDiscoveryFollowLists.value?.geotags ?: emptySet() + val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: emptySet() + val followingTagSet = account.liveDiscoveryFollowLists.value?.hashtags ?: emptySet() + val followingGeohashSet = account.liveDiscoveryFollowLists.value?.geotags ?: emptySet() - val activities = collection - .asSequence() - .filter { it.event is LiveActivitiesEvent } - .filter { - isGlobal || (it.event as LiveActivitiesEvent).participantsIntersect(followingKeySet) || it.event?.isTaggedHashes( - followingTagSet - ) == true || it.event?.isTaggedGeoHashes( - followingGeohashSet - ) == true - } - .filter { isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true } - .filter { (it.createdAt() ?: 0) <= now } - .toSet() - - return activities - } - - override fun sort(collection: Set): List { - val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: account.liveKind3Follows.value.users - - val counter = ParticipantListBuilder() - val participantCounts = collection.associate { - it to counter.countFollowsThatParticipateOn(it, followingKeySet) + val activities = + collection + .asSequence() + .filter { it.event is LiveActivitiesEvent } + .filter { + isGlobal || + (it.event as LiveActivitiesEvent).participantsIntersect(followingKeySet) || + it.event?.isTaggedHashes( + followingTagSet, + ) == true || + it.event?.isTaggedGeoHashes( + followingGeohashSet, + ) == true } + .filter { isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true } + .filter { (it.createdAt() ?: 0) <= now } + .toSet() - val allParticipants = collection.associate { - it to counter.countFollowsThatParticipateOn(it, null) - } + return activities + } - return collection.sortedWith( - compareBy( - { convertStatusToOrder((it.event as? LiveActivitiesEvent)?.status()) }, - { participantCounts[it] }, - { allParticipants[it] }, - { (it.event as? LiveActivitiesEvent)?.starts() ?: it.createdAt() }, - { it.idHex } - ) - ).reversed() - } - - fun convertStatusToOrder(status: String?): Int { - return when (status) { - STATUS_LIVE -> 2 - STATUS_PLANNED -> 1 - STATUS_ENDED -> 0 - else -> 0 - } + override fun sort(collection: Set): List { + val followingKeySet = + account.liveDiscoveryFollowLists.value?.users ?: account.liveKind3Follows.value.users + + val counter = ParticipantListBuilder() + val participantCounts = + collection.associate { it to counter.countFollowsThatParticipateOn(it, followingKeySet) } + + val allParticipants = + collection.associate { it to counter.countFollowsThatParticipateOn(it, null) } + + return collection + .sortedWith( + compareBy( + { convertStatusToOrder((it.event as? LiveActivitiesEvent)?.status()) }, + { participantCounts[it] }, + { allParticipants[it] }, + { (it.event as? LiveActivitiesEvent)?.starts() ?: it.createdAt() }, + { it.idHex }, + ), + ) + .reversed() + } + + fun convertStatusToOrder(status: String?): Int { + return when (status) { + STATUS_LIVE -> 2 + STATUS_PLANNED -> 1 + STATUS_ENDED -> 0 + else -> 0 } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveNowFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveNowFeedFilter.kt index 5b0320f69..996d130a8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveNowFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveNowFeedFilter.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account @@ -9,26 +29,27 @@ import com.vitorpamplona.quartz.events.LiveActivitiesEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE class DiscoverLiveNowFeedFilter( - account: Account + account: Account, ) : DiscoverLiveFeedFilter(account) { - override fun followList(): String { - // uses follows by default, but other lists if they were selected in the top bar - val currentList = super.followList() - return if (currentList == GLOBAL_FOLLOWS) { - KIND3_FOLLOWS - } else { - currentList - } + override fun followList(): String { + // uses follows by default, but other lists if they were selected in the top bar + val currentList = super.followList() + return if (currentList == GLOBAL_FOLLOWS) { + KIND3_FOLLOWS + } else { + currentList } + } - override fun innerApplyFilter(collection: Collection): Set { - val allItems = super.innerApplyFilter(collection) + override fun innerApplyFilter(collection: Collection): Set { + val allItems = super.innerApplyFilter(collection) - val onlineOnly = allItems.filter { - val noteEvent = it.event as? LiveActivitiesEvent - noteEvent?.status() == STATUS_LIVE && OnlineChecker.isOnline(noteEvent.streaming()) - } + val onlineOnly = + allItems.filter { + val noteEvent = it.event as? LiveActivitiesEvent + noteEvent?.status() == STATUS_LIVE && OnlineChecker.isOnline(noteEvent.streaming()) + } - return onlineOnly.toSet() - } + return onlineOnly.toSet() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverMarketplaceFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverMarketplaceFeedFilter.kt index 80a14c1f3..fd8604ac6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverMarketplaceFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverMarketplaceFeedFilter.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account @@ -10,63 +30,66 @@ import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.utils.TimeUtils open class DiscoverMarketplaceFeedFilter( - val account: Account + val account: Account, ) : AdditiveFeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + followList() - } + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + followList() + } - open fun followList(): String { - return account.defaultDiscoveryFollowList.value - } + open fun followList(): String { + return account.defaultDiscoveryFollowList.value + } - override fun showHiddenKey(): Boolean { - return followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - followList() == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) - } + override fun showHiddenKey(): Boolean { + return followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + followList() == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + } - override fun feed(): List { - val classifieds = LocalCache.addressables - .filter { it.value.event is ClassifiedsEvent } - .map { it.value } + override fun feed(): List { + val classifieds = + LocalCache.addressables.filter { it.value.event is ClassifiedsEvent }.map { it.value } - val notes = innerApplyFilter(classifieds) + val notes = innerApplyFilter(classifieds) - return sort(notes) - } + return sort(notes) + } - override fun applyFilter(collection: Set): Set { - return innerApplyFilter(collection) - } + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } - protected open fun innerApplyFilter(collection: Collection): Set { - val now = TimeUtils.now() - val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS - val isHiddenList = showHiddenKey() + protected open fun innerApplyFilter(collection: Collection): Set { + val now = TimeUtils.now() + val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS + val isHiddenList = showHiddenKey() - val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: emptySet() - val followingTagSet = account.liveDiscoveryFollowLists.value?.hashtags ?: emptySet() - val followingGeohashSet = account.liveDiscoveryFollowLists.value?.geotags ?: emptySet() + val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: emptySet() + val followingTagSet = account.liveDiscoveryFollowLists.value?.hashtags ?: emptySet() + val followingGeohashSet = account.liveDiscoveryFollowLists.value?.geotags ?: emptySet() - val activities = collection - .asSequence() - .filter { - it.event is ClassifiedsEvent && - it.event?.hasTagWithContent("image") == true && - it.event?.hasTagWithContent("price") == true && - it.event?.hasTagWithContent("title") == true - } - .filter { - isGlobal || it.author?.pubkeyHex in followingKeySet || it.event?.isTaggedHashes(followingTagSet) == true || it.event?.isTaggedGeoHashes(followingGeohashSet) == true - } - .filter { isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true } - .filter { (it.createdAt() ?: 0) <= now } - .toSet() + val activities = + collection + .asSequence() + .filter { + it.event is ClassifiedsEvent && + it.event?.hasTagWithContent("image") == true && + it.event?.hasTagWithContent("price") == true && + it.event?.hasTagWithContent("title") == true + } + .filter { + isGlobal || + it.author?.pubkeyHex in followingKeySet || + it.event?.isTaggedHashes(followingTagSet) == true || + it.event?.isTaggedGeoHashes(followingGeohashSet) == true + } + .filter { isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true } + .filter { (it.createdAt() ?: 0) <= now } + .toSet() - return activities - } + return activities + } - override fun sort(collection: Set): List { - return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - } + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt index 1b86baca3..14a39cd3e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.dal import android.util.Log @@ -5,46 +25,49 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread import kotlin.time.measureTimedValue abstract class FeedFilter { - fun loadTop(): List { - checkNotInMainThread() + fun loadTop(): List { + checkNotInMainThread() - val (feed, elapsed) = measureTimedValue { - feed() - } + val (feed, elapsed) = measureTimedValue { feed() } - Log.d("Time", "${this.javaClass.simpleName} Full Feed in $elapsed with ${feed.size} objects") - return feed.take(limit()) - } + Log.d("Time", "${this.javaClass.simpleName} Full Feed in $elapsed with ${feed.size} objects") + return feed.take(limit()) + } - open fun limit() = 1000 + open fun limit() = 1000 - /** - * Returns a string that serves as the key to invalidate the list if it changes. - */ - abstract fun feedKey(): String - open fun showHiddenKey(): Boolean = false + /** Returns a string that serves as the key to invalidate the list if it changes. */ + abstract fun feedKey(): String - abstract fun feed(): List + open fun showHiddenKey(): Boolean = false + + abstract fun feed(): List } abstract class AdditiveFeedFilter : FeedFilter() { - abstract fun applyFilter(collection: Set): Set - abstract fun sort(collection: Set): List + abstract fun applyFilter(collection: Set): Set - open fun updateListWith(oldList: List, newItems: Set): List { - checkNotInMainThread() + abstract fun sort(collection: Set): List - val (feed, elapsed) = measureTimedValue { - val newItemsToBeAdded = applyFilter(newItems) - if (newItemsToBeAdded.isNotEmpty()) { - val newList = oldList.toSet() + newItemsToBeAdded - sort(newList).take(limit()) - } else { - oldList - } + open fun updateListWith( + oldList: List, + newItems: Set, + ): List { + checkNotInMainThread() + + val (feed, elapsed) = + measureTimedValue { + val newItemsToBeAdded = applyFilter(newItems) + if (newItemsToBeAdded.isNotEmpty()) { + val newList = oldList.toSet() + newItemsToBeAdded + sort(newList).take(limit()) + } else { + oldList } + } - // Log.d("Time", "${this.javaClass.simpleName} Additive Feed in $elapsed with ${feed.size} objects") - return feed - } + // Log.d("Time", "${this.javaClass.simpleName} Additive Feed in $elapsed with ${feed.size} + // objects") + return feed + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt index 1d5033d2b..a7bbf8423 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account @@ -11,40 +31,36 @@ import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.TextNoteEvent class GeoHashFeedFilter(val tag: String, val account: Account) : AdditiveFeedFilter() { + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + tag + } - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + tag - } + override fun feed(): List { + return sort(innerApplyFilter(LocalCache.notes.values)) + } - override fun feed(): List { - return sort(innerApplyFilter(LocalCache.notes.values)) - } + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } - override fun applyFilter(collection: Set): Set { - return innerApplyFilter(collection) - } + private fun innerApplyFilter(collection: Collection): Set { + val myTag = tag ?: return emptySet() - private fun innerApplyFilter(collection: Collection): Set { - val myTag = tag ?: return emptySet() + return collection + .asSequence() + .filter { + (it.event is TextNoteEvent || + it.event is LongTextNoteEvent || + it.event is ChannelMessageEvent || + it.event is PrivateDmEvent || + it.event is PollNoteEvent || + it.event is AudioHeaderEvent) && it.event?.isTaggedGeoHash(myTag) == true + } + .filter { account.isAcceptable(it) } + .toSet() + } - return collection - .asSequence() - .filter { - ( - it.event is TextNoteEvent || - it.event is LongTextNoteEvent || - it.event is ChannelMessageEvent || - it.event is PrivateDmEvent || - it.event is PollNoteEvent || - it.event is AudioHeaderEvent - ) && - it.event?.isTaggedGeoHash(myTag) == true - } - .filter { account.isAcceptable(it) } - .toSet() - } - - override fun sort(collection: Set): List { - return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - } + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt index 031162e92..0e17e7fec 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account @@ -11,40 +31,36 @@ import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.TextNoteEvent class HashtagFeedFilter(val tag: String, val account: Account) : AdditiveFeedFilter() { + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + tag + } - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + tag - } + override fun feed(): List { + return sort(innerApplyFilter(LocalCache.notes.values)) + } - override fun feed(): List { - return sort(innerApplyFilter(LocalCache.notes.values)) - } + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } - override fun applyFilter(collection: Set): Set { - return innerApplyFilter(collection) - } + private fun innerApplyFilter(collection: Collection): Set { + val myTag = tag ?: return emptySet() - private fun innerApplyFilter(collection: Collection): Set { - val myTag = tag ?: return emptySet() + return collection + .asSequence() + .filter { + (it.event is TextNoteEvent || + it.event is LongTextNoteEvent || + it.event is ChannelMessageEvent || + it.event is PrivateDmEvent || + it.event is PollNoteEvent || + it.event is AudioHeaderEvent) && it.event?.isTaggedHash(myTag) == true + } + .filter { account.isAcceptable(it) } + .toSet() + } - return collection - .asSequence() - .filter { - ( - it.event is TextNoteEvent || - it.event is LongTextNoteEvent || - it.event is ChannelMessageEvent || - it.event is PrivateDmEvent || - it.event is PollNoteEvent || - it.event is AudioHeaderEvent - ) && - it.event?.isTaggedHash(myTag) == true - } - .filter { account.isAcceptable(it) } - .toSet() - } - - override fun sort(collection: Set): List { - return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - } + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HiddenAccountsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HiddenAccountsFeedFilter.kt index 7b9a0b9ab..1eb86041f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HiddenAccountsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HiddenAccountsFeedFilter.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account @@ -5,45 +25,43 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User class HiddenAccountsFeedFilter(val account: Account) : FeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex - } + override fun feedKey(): String { + return account.userProfile().pubkeyHex + } - override fun showHiddenKey(): Boolean { - return true - } + override fun showHiddenKey(): Boolean { + return true + } - override fun feed(): List { - return account.flowHiddenUsers.value.hiddenUsers.map { - LocalCache.getOrCreateUser(it) - } - } + override fun feed(): List { + return account.flowHiddenUsers.value.hiddenUsers.map { LocalCache.getOrCreateUser(it) } + } } class HiddenWordsFeedFilter(val account: Account) : FeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex - } + override fun feedKey(): String { + return account.userProfile().pubkeyHex + } - override fun showHiddenKey(): Boolean { - return true - } + override fun showHiddenKey(): Boolean { + return true + } - override fun feed(): List { - return account.flowHiddenUsers.value.hiddenWords.toList() - } + override fun feed(): List { + return account.flowHiddenUsers.value.hiddenWords.toList() + } } class SpammerAccountsFeedFilter(val account: Account) : FeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex - } + override fun feedKey(): String { + return account.userProfile().pubkeyHex + } - override fun showHiddenKey(): Boolean { - return true - } + override fun showHiddenKey(): Boolean { + return true + } - override fun feed(): List { - return (account.transientHiddenUsers).map { LocalCache.getOrCreateUser(it) } - } + override fun feed(): List { + return (account.transientHiddenUsers).map { LocalCache.getOrCreateUser(it) } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt index 530f8ee59..361589cc9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account @@ -13,48 +33,56 @@ import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.utils.TimeUtils class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter() { + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList.value + } - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList.value - } + override fun showHiddenKey(): Boolean { + return account.defaultHomeFollowList.value == + PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + account.defaultHomeFollowList.value == + MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + } - override fun showHiddenKey(): Boolean { - return account.defaultHomeFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - account.defaultHomeFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) - } + override fun feed(): List { + return sort(innerApplyFilter(LocalCache.notes.values)) + } - override fun feed(): List { - return sort(innerApplyFilter(LocalCache.notes.values)) - } + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } - override fun applyFilter(collection: Set): Set { - return innerApplyFilter(collection) - } + private fun innerApplyFilter(collection: Collection): Set { + val isGlobal = account.defaultHomeFollowList.value == GLOBAL_FOLLOWS + val isHiddenList = showHiddenKey() - private fun innerApplyFilter(collection: Collection): Set { - val isGlobal = account.defaultHomeFollowList.value == GLOBAL_FOLLOWS - val isHiddenList = showHiddenKey() + val followingKeySet = account.liveHomeFollowLists.value?.users ?: emptySet() + val followingTagSet = account.liveHomeFollowLists.value?.hashtags ?: emptySet() + val followingGeohashSet = account.liveHomeFollowLists.value?.geotags ?: emptySet() - val followingKeySet = account.liveHomeFollowLists.value?.users ?: emptySet() - val followingTagSet = account.liveHomeFollowLists.value?.hashtags ?: emptySet() - val followingGeohashSet = account.liveHomeFollowLists.value?.geotags ?: emptySet() + val now = TimeUtils.now() - val now = TimeUtils.now() + return collection + .asSequence() + .filter { + (it.event is TextNoteEvent || + it.event is PollNoteEvent || + it.event is ChannelMessageEvent || + it.event is LiveActivitiesChatMessageEvent) && + (isGlobal || + it.author?.pubkeyHex in followingKeySet || + it.event?.isTaggedHashes(followingTagSet) ?: false || + it.event?.isTaggedGeoHashes(followingGeohashSet) ?: false) && + // && account.isAcceptable(it) // This filter follows only. No need to check if + // acceptable + (isHiddenList || it.author?.let { !account.isHidden(it) } ?: true) && + ((it.event?.createdAt() ?: 0) < now) && + !it.isNewThread() + } + .toSet() + } - return collection - .asSequence() - .filter { - (it.event is TextNoteEvent || it.event is PollNoteEvent || it.event is ChannelMessageEvent || it.event is LiveActivitiesChatMessageEvent) && - (isGlobal || it.author?.pubkeyHex in followingKeySet || it.event?.isTaggedHashes(followingTagSet) ?: false || it.event?.isTaggedGeoHashes(followingGeohashSet) ?: false) && - // && account.isAcceptable(it) // This filter follows only. No need to check if acceptable - (isHiddenList || it.author?.let { !account.isHidden(it) } ?: true) && - ((it.event?.createdAt() ?: 0) < now) && - !it.isNewThread() - } - .toSet() - } - - override fun sort(collection: Set): List { - return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - } + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt index c6f44569f..19bc2330b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account @@ -18,64 +38,80 @@ import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.utils.TimeUtils class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter() { + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList.value + } - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList.value - } + override fun showHiddenKey(): Boolean { + return account.defaultHomeFollowList.value == + PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + account.defaultHomeFollowList.value == + MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + } - override fun showHiddenKey(): Boolean { - return account.defaultHomeFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - account.defaultHomeFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) - } + override fun feed(): List { + val notes = innerApplyFilter(LocalCache.notes.values, true) + val longFormNotes = innerApplyFilter(LocalCache.addressables.values, false) - override fun feed(): List { - val notes = innerApplyFilter(LocalCache.notes.values, true) - val longFormNotes = innerApplyFilter(LocalCache.addressables.values, false) + return sort(notes + longFormNotes) + } - return sort(notes + longFormNotes) - } + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection, false) + } - override fun applyFilter(collection: Set): Set { - return innerApplyFilter(collection, false) - } + private fun innerApplyFilter( + collection: Collection, + ignoreAddressables: Boolean, + ): Set { + val isGlobal = account.defaultHomeFollowList.value == GLOBAL_FOLLOWS + val gRelays = account.activeGlobalRelays() + val isHiddenList = showHiddenKey() - private fun innerApplyFilter(collection: Collection, ignoreAddressables: Boolean): Set { - val isGlobal = account.defaultHomeFollowList.value == GLOBAL_FOLLOWS - val gRelays = account.activeGlobalRelays() - val isHiddenList = showHiddenKey() + val followingKeySet = account.liveHomeFollowLists.value?.users ?: emptySet() + val followingTagSet = account.liveHomeFollowLists.value?.hashtags ?: emptySet() + val followingGeohashSet = account.liveHomeFollowLists.value?.geotags ?: emptySet() + val followingCommunities = account.liveHomeFollowLists.value?.communities ?: emptySet() - val followingKeySet = account.liveHomeFollowLists.value?.users ?: emptySet() - val followingTagSet = account.liveHomeFollowLists.value?.hashtags ?: emptySet() - val followingGeohashSet = account.liveHomeFollowLists.value?.geotags ?: emptySet() - val followingCommunities = account.liveHomeFollowLists.value?.communities ?: emptySet() + val oneMinuteInTheFuture = TimeUtils.now() + (1 * 60) // one minute in the future. + val oneHr = 60 * 60 - val oneMinuteInTheFuture = TimeUtils.now() + (1 * 60) // one minute in the future. - val oneHr = 60 * 60 + return collection + .asSequence() + .filter { it -> + val noteEvent = it.event + val isGlobalRelay = it.relays.any { gRelays.contains(it.url) } + (noteEvent is TextNoteEvent || + noteEvent is ClassifiedsEvent || + noteEvent is RepostEvent || + noteEvent is GenericRepostEvent || + noteEvent is LongTextNoteEvent || + noteEvent is PollNoteEvent || + noteEvent is HighlightEvent || + noteEvent is AudioTrackEvent || + noteEvent is AudioHeaderEvent) && + (!ignoreAddressables || noteEvent.kind() < 10000) && + ((isGlobal && isGlobalRelay) || + it.author?.pubkeyHex in followingKeySet || + noteEvent.isTaggedHashes(followingTagSet) || + noteEvent.isTaggedGeoHashes(followingGeohashSet) || + noteEvent.isTaggedAddressableNotes(followingCommunities)) && + // && account.isAcceptable(it) // This filter follows only. No need to check if + // acceptable + (isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true) && + ((it.event?.createdAt() ?: 0) < oneMinuteInTheFuture) && + it.isNewThread() && + ((noteEvent !is RepostEvent && noteEvent !is GenericRepostEvent) || // not a repost + (it.replyTo?.lastOrNull()?.author?.pubkeyHex !in followingKeySet || + (noteEvent.createdAt() > + (it.replyTo?.lastOrNull()?.createdAt() + ?: 0) + oneHr)) // or a repost of by a non-follower's post (likely not seen yet) + ) + } + .toSet() + } - return collection - .asSequence() - .filter { it -> - val noteEvent = it.event - val isGlobalRelay = it.relays.any { gRelays.contains(it.url) } - (noteEvent is TextNoteEvent || noteEvent is ClassifiedsEvent || noteEvent is RepostEvent || noteEvent is GenericRepostEvent || noteEvent is LongTextNoteEvent || noteEvent is PollNoteEvent || noteEvent is HighlightEvent || noteEvent is AudioTrackEvent || noteEvent is AudioHeaderEvent) && - (!ignoreAddressables || noteEvent.kind() < 10000) && - ((isGlobal && isGlobalRelay) || it.author?.pubkeyHex in followingKeySet || noteEvent.isTaggedHashes(followingTagSet) || noteEvent.isTaggedGeoHashes(followingGeohashSet) || noteEvent.isTaggedAddressableNotes(followingCommunities)) && - // && account.isAcceptable(it) // This filter follows only. No need to check if acceptable - (isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true) && - ((it.event?.createdAt() ?: 0) < oneMinuteInTheFuture) && - it.isNewThread() && - ( - (noteEvent !is RepostEvent && noteEvent !is GenericRepostEvent) || // not a repost - ( - it.replyTo?.lastOrNull()?.author?.pubkeyHex !in followingKeySet || - (noteEvent.createdAt() > (it.replyTo?.lastOrNull()?.createdAt() ?: 0) + oneHr) - ) // or a repost of by a non-follower's post (likely not seen yet) - ) - } - .toSet() - } - - override fun sort(collection: Set): List { - return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - } + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt index 7079ade96..79caad47f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account @@ -20,74 +40,81 @@ import com.vitorpamplona.quartz.events.ReactionEvent import com.vitorpamplona.quartz.events.RepostEvent class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + account.defaultNotificationFollowList.value - } + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + account.defaultNotificationFollowList.value + } - override fun showHiddenKey(): Boolean { - return account.defaultNotificationFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - account.defaultNotificationFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) - } + override fun showHiddenKey(): Boolean { + return account.defaultNotificationFollowList.value == + PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + account.defaultNotificationFollowList.value == + MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + } - override fun feed(): List { - return sort(innerApplyFilter(LocalCache.notes.values)) - } + override fun feed(): List { + return sort(innerApplyFilter(LocalCache.notes.values)) + } - override fun applyFilter(collection: Set): Set { - return innerApplyFilter(collection) - } + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } - private fun innerApplyFilter(collection: Collection): Set { - val isGlobal = account.defaultNotificationFollowList.value == GLOBAL_FOLLOWS - val isHiddenList = showHiddenKey() + private fun innerApplyFilter(collection: Collection): Set { + val isGlobal = account.defaultNotificationFollowList.value == GLOBAL_FOLLOWS + val isHiddenList = showHiddenKey() - val followingKeySet = account.liveNotificationFollowLists.value?.users ?: emptySet() + val followingKeySet = account.liveNotificationFollowLists.value?.users ?: emptySet() - val loggedInUser = account.userProfile() - val loggedInUserHex = loggedInUser.pubkeyHex + val loggedInUser = account.userProfile() + val loggedInUserHex = loggedInUser.pubkeyHex - return collection.filter { - it.event !is ChannelCreateEvent && - it.event !is ChannelMetadataEvent && - it.event !is LnZapRequestEvent && - it.event !is BadgeDefinitionEvent && - it.event !is BadgeProfilesEvent && - it.event !is GiftWrapEvent && - (it.event is LnZapEvent || it.author !== loggedInUser) && - (isGlobal || it.author?.pubkeyHex in followingKeySet) && - it.event?.isTaggedUser(loggedInUserHex) ?: false && - (isHiddenList || it.author == null || !account.isHidden(it.author!!.pubkeyHex)) && - tagsAnEventByUser(it, loggedInUserHex) - }.toSet() - } + return collection + .filter { + it.event !is ChannelCreateEvent && + it.event !is ChannelMetadataEvent && + it.event !is LnZapRequestEvent && + it.event !is BadgeDefinitionEvent && + it.event !is BadgeProfilesEvent && + it.event !is GiftWrapEvent && + (it.event is LnZapEvent || it.author !== loggedInUser) && + (isGlobal || it.author?.pubkeyHex in followingKeySet) && + it.event?.isTaggedUser(loggedInUserHex) ?: false && + (isHiddenList || it.author == null || !account.isHidden(it.author!!.pubkeyHex)) && + tagsAnEventByUser(it, loggedInUserHex) + } + .toSet() + } - override fun sort(collection: Set): List { - return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - } + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } - fun tagsAnEventByUser(note: Note, authorHex: HexKey): Boolean { - val event = note.event + fun tagsAnEventByUser( + note: Note, + authorHex: HexKey, + ): Boolean { + val event = note.event - if (event is BaseTextNoteEvent) { - val isAuthoredPostCited = event.findCitations().any { - LocalCache.notes[it]?.author?.pubkeyHex == authorHex || LocalCache.addressables[it]?.author?.pubkeyHex == authorHex - } - - return isAuthoredPostCited || - ( - event.citedUsers().contains(authorHex) || - note.replyTo?.any { it.author?.pubkeyHex == authorHex } == true - ) + if (event is BaseTextNoteEvent) { + val isAuthoredPostCited = + event.findCitations().any { + LocalCache.notes[it]?.author?.pubkeyHex == authorHex || + LocalCache.addressables[it]?.author?.pubkeyHex == authorHex } - if (event is ReactionEvent) { - return note.replyTo?.lastOrNull()?.author?.pubkeyHex == authorHex - } - - if (event is RepostEvent || event is GenericRepostEvent) { - return note.replyTo?.lastOrNull()?.author?.pubkeyHex == authorHex - } - - return true + return isAuthoredPostCited || + (event.citedUsers().contains(authorHex) || + note.replyTo?.any { it.author?.pubkeyHex == authorHex } == true) } + + if (event is ReactionEvent) { + return note.replyTo?.lastOrNull()?.author?.pubkeyHex == authorHex + } + + if (event is RepostEvent || event is GenericRepostEvent) { + return note.replyTo?.lastOrNull()?.author?.pubkeyHex == authorHex + } + + return true + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt index 615f20dd1..a5a836493 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.dal import androidx.compose.runtime.Immutable @@ -8,23 +28,31 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class ThreadFeedFilter(val account: Account, val noteId: String) : FeedFilter() { + override fun feedKey(): String { + return noteId + } - override fun feedKey(): String { - return noteId - } + override fun feed(): List { + val cachedSignatures: MutableMap = mutableMapOf() + val followingKeySet = account.liveKind3Follows.value.users + val eventsToWatch = ThreadAssembler().findThreadFor(noteId) + val eventsInHex = eventsToWatch.map { it.idHex }.toSet() + val now = TimeUtils.now() - override fun feed(): List { - val cachedSignatures: MutableMap = mutableMapOf() - val followingKeySet = account.liveKind3Follows.value.users - val eventsToWatch = ThreadAssembler().findThreadFor(noteId) - val eventsInHex = eventsToWatch.map { it.idHex }.toSet() - val now = TimeUtils.now() + // Currently orders by date of each event, descending, at each level of the reply stack + val order = + compareByDescending { + it + .replyLevelSignature( + eventsInHex, + cachedSignatures, + account.userProfile(), + followingKeySet, + now, + ) + .signature + } - // Currently orders by date of each event, descending, at each level of the reply stack - val order = compareByDescending { - it.replyLevelSignature(eventsInHex, cachedSignatures, account.userProfile(), followingKeySet, now).signature - } - - return eventsToWatch.sortedWith(order) - } + return eventsToWatch.sortedWith(order) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileAppRecommendationsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileAppRecommendationsFeedFilter.kt index 5fda9017e..fdc333d63 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileAppRecommendationsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileAppRecommendationsFeedFilter.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.LocalCache @@ -6,36 +26,39 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.quartz.events.AppRecommendationEvent class UserProfileAppRecommendationsFeedFilter(val user: User) : AdditiveFeedFilter() { - override fun feedKey(): String { - return user.pubkeyHex - } + override fun feedKey(): String { + return user.pubkeyHex + } - override fun feed(): List { - return sort(innerApplyFilter(LocalCache.addressables.values)) - } + override fun feed(): List { + return sort(innerApplyFilter(LocalCache.addressables.values)) + } - override fun applyFilter(collection: Set): Set { - return innerApplyFilter(collection) - } + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } - private fun innerApplyFilter(collection: Collection): Set { - val recommendations = collection.asSequence() - .filter { it.event is AppRecommendationEvent } - .mapNotNull { - val noteEvent = it.event as? AppRecommendationEvent - if (noteEvent != null && noteEvent.pubKey == user.pubkeyHex) { - noteEvent.recommendations() - } else { - null - } - }.flatten().map { - LocalCache.getOrCreateAddressableNote(it) - }.toSet() + private fun innerApplyFilter(collection: Collection): Set { + val recommendations = + collection + .asSequence() + .filter { it.event is AppRecommendationEvent } + .mapNotNull { + val noteEvent = it.event as? AppRecommendationEvent + if (noteEvent != null && noteEvent.pubKey == user.pubkeyHex) { + noteEvent.recommendations() + } else { + null + } + } + .flatten() + .map { LocalCache.getOrCreateAddressableNote(it) } + .toSet() - return recommendations - } + return recommendations + } - override fun sort(collection: Set): List { - return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - } + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileBookmarksFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileBookmarksFeedFilter.kt index 0e44838c9..3497e9c01 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileBookmarksFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileBookmarksFeedFilter.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account @@ -6,22 +26,28 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User class UserProfileBookmarksFeedFilter(val user: User, val account: Account) : FeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + user.pubkeyHex - } + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + user.pubkeyHex + } - override fun feed(): List { - val notes = user.latestBookmarkList?.taggedEvents()?.mapNotNull { - LocalCache.checkGetOrCreateNote(it) - }?.toSet() ?: emptySet() + override fun feed(): List { + val notes = + user.latestBookmarkList + ?.taggedEvents() + ?.mapNotNull { LocalCache.checkGetOrCreateNote(it) } + ?.toSet() + ?: emptySet() - val addresses = user.latestBookmarkList?.taggedAddresses()?.map { - LocalCache.getOrCreateAddressableNote(it) - }?.toSet() ?: emptySet() + val addresses = + user.latestBookmarkList + ?.taggedAddresses() + ?.map { LocalCache.getOrCreateAddressableNote(it) } + ?.toSet() + ?: emptySet() - return (notes + addresses) - .filter { account.isAcceptable(it) } - .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - .reversed() - } + return (notes + addresses) + .filter { account.isAcceptable(it) } + .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) + .reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt index b2a61a82a..66eadc2ce 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account @@ -9,37 +29,37 @@ import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.TextNoteEvent -class UserProfileConversationsFeedFilter(val user: User, val account: Account) : AdditiveFeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + user.pubkeyHex - } +class UserProfileConversationsFeedFilter(val user: User, val account: Account) : + AdditiveFeedFilter() { + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + user.pubkeyHex + } - override fun feed(): List { - return sort(innerApplyFilter(LocalCache.notes.values)) - } + override fun feed(): List { + return sort(innerApplyFilter(LocalCache.notes.values)) + } - override fun applyFilter(collection: Set): Set { - return innerApplyFilter(collection) - } + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } - private fun innerApplyFilter(collection: Collection): Set { - return collection - .filter { - it.author == user && - ( - it.event is TextNoteEvent || - it.event is PollNoteEvent || - it.event is ChannelMessageEvent || - it.event is LiveActivitiesChatMessageEvent - ) && - !it.isNewThread() && - account.isAcceptable(it) == true - }.toSet() - } + private fun innerApplyFilter(collection: Collection): Set { + return collection + .filter { + it.author == user && + (it.event is TextNoteEvent || + it.event is PollNoteEvent || + it.event is ChannelMessageEvent || + it.event is LiveActivitiesChatMessageEvent) && + !it.isNewThread() && + account.isAcceptable(it) == true + } + .toSet() + } - override fun sort(collection: Set): List { - return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - } + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } - override fun limit() = 200 + override fun limit() = 200 } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowersFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowersFeedFilter.kt index 09aba96b3..6f289a7e0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowersFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowersFeedFilter.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account @@ -5,13 +25,13 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User class UserProfileFollowersFeedFilter(val user: User, val account: Account) : FeedFilter() { + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + user.pubkeyHex + } - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + user.pubkeyHex - } - override fun feed(): List { - return LocalCache.users.values.filter { it.isFollowing(user) && !account.isHidden(it) } - } + override fun feed(): List { + return LocalCache.users.values.filter { it.isFollowing(user) && !account.isHidden(it) } + } - override fun limit() = 400 + override fun limit() = 400 } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowsFeedFilter.kt index d4b45f048..d845449bd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowsFeedFilter.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account @@ -6,26 +26,27 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.quartz.events.ContactListEvent class UserProfileFollowsFeedFilter(val user: User, val account: Account) : FeedFilter() { + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + user.pubkeyHex + } - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + user.pubkeyHex - } + val cache: MutableMap> = mutableMapOf() - val cache: MutableMap> = mutableMapOf() + override fun feed(): List { + val contactList = user.latestContactList ?: return emptyList() - override fun feed(): List { - val contactList = user.latestContactList ?: return emptyList() + val previousList = cache[contactList] + if (previousList != null) return previousList - val previousList = cache[contactList] - if (previousList != null) return previousList + cache[contactList] = + user.latestContactList + ?.unverifiedFollowKeySet() + ?.mapNotNull { LocalCache.checkGetOrCreateUser(it) } + ?.toSet() + ?.filter { !account.isHidden(it) } + ?.reversed() + ?: emptyList() - cache[contactList] = user.latestContactList - ?.unverifiedFollowKeySet()?.mapNotNull { - LocalCache.checkGetOrCreateUser(it) - }?.toSet() - ?.filter { !account.isHidden(it) } - ?.reversed() ?: emptyList() - - return cache[contactList] ?: emptyList() - } + return cache[contactList] ?: emptyList() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt index 857c1be34..8b5e80460 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account @@ -14,45 +34,45 @@ import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.TextNoteEvent -class UserProfileNewThreadFeedFilter(val user: User, val account: Account) : AdditiveFeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + user.pubkeyHex - } +class UserProfileNewThreadFeedFilter(val user: User, val account: Account) : + AdditiveFeedFilter() { + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + user.pubkeyHex + } - override fun feed(): List { - val notes = innerApplyFilter(LocalCache.notes.values) - val longFormNotes = innerApplyFilter(LocalCache.addressables.values) + override fun feed(): List { + val notes = innerApplyFilter(LocalCache.notes.values) + val longFormNotes = innerApplyFilter(LocalCache.addressables.values) - return sort(notes + longFormNotes) - } + return sort(notes + longFormNotes) + } - override fun applyFilter(collection: Set): Set { - return innerApplyFilter(collection) - } + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } - private fun innerApplyFilter(collection: Collection): Set { - return collection - .filter { - it.author == user && - ( - it.event is TextNoteEvent || - it.event is ClassifiedsEvent || - it.event is RepostEvent || - it.event is GenericRepostEvent || - it.event is LongTextNoteEvent || - it.event is PollNoteEvent || - it.event is HighlightEvent || - it.event is AudioTrackEvent || - it.event is AudioHeaderEvent - ) && - it.isNewThread() && - account.isAcceptable(it) == true - }.toSet() - } + private fun innerApplyFilter(collection: Collection): Set { + return collection + .filter { + it.author == user && + (it.event is TextNoteEvent || + it.event is ClassifiedsEvent || + it.event is RepostEvent || + it.event is GenericRepostEvent || + it.event is LongTextNoteEvent || + it.event is PollNoteEvent || + it.event is HighlightEvent || + it.event is AudioTrackEvent || + it.event is AudioHeaderEvent) && + it.isNewThread() && + account.isAcceptable(it) == true + } + .toSet() + } - override fun sort(collection: Set): List { - return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - } + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } - override fun limit() = 200 + override fun limit() = 200 } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt index 353d59ff6..b3543a3e0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Note @@ -5,25 +25,27 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.quartz.events.ReportEvent class UserProfileReportsFeedFilter(val user: User) : AdditiveFeedFilter() { - override fun feedKey(): String { - return user.pubkeyHex - } + override fun feedKey(): String { + return user.pubkeyHex + } - override fun feed(): List { - return sort(innerApplyFilter(user.reports.values.flatten())) - } + override fun feed(): List { + return sort(innerApplyFilter(user.reports.values.flatten())) + } - override fun applyFilter(collection: Set): Set { - return innerApplyFilter(collection) - } + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } - private fun innerApplyFilter(collection: Collection): Set { - return collection.filter { it.event is ReportEvent && it.event?.isTaggedUser(user.pubkeyHex) == true }.toSet() - } + private fun innerApplyFilter(collection: Collection): Set { + return collection + .filter { it.event is ReportEvent && it.event?.isTaggedUser(user.pubkeyHex) == true } + .toSet() + } - override fun sort(collection: Set): List { - return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - } + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } - override fun limit() = 400 + override fun limit() = 400 } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt index 6a34b51c7..8c4be5d56 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.User @@ -5,14 +25,13 @@ import com.vitorpamplona.amethyst.ui.screen.ZapReqResponse import com.vitorpamplona.quartz.events.zaps.UserZaps class UserProfileZapsFeedFilter(val user: User) : FeedFilter() { + override fun feedKey(): String { + return user.pubkeyHex + } - override fun feedKey(): String { - return user.pubkeyHex - } + override fun feed(): List { + return UserZaps.forProfileFeed(user.zaps) + } - override fun feed(): List { - return UserZaps.forProfileFeed(user.zaps) - } - - override fun limit() = 400 + override fun limit() = 400 } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt index ed553c0a8..d3cc1c868 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account @@ -11,45 +31,58 @@ import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.utils.TimeUtils class VideoFeedFilter(val account: Account) : AdditiveFeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + account.defaultStoriesFollowList.value - } + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + account.defaultStoriesFollowList.value + } - override fun showHiddenKey(): Boolean { - return account.defaultStoriesFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - account.defaultStoriesFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) - } + override fun showHiddenKey(): Boolean { + return account.defaultStoriesFollowList.value == + PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + account.defaultStoriesFollowList.value == + MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + } - override fun feed(): List { - val notes = innerApplyFilter(LocalCache.notes.values) + override fun feed(): List { + val notes = innerApplyFilter(LocalCache.notes.values) - return sort(notes) - } + return sort(notes) + } - override fun applyFilter(collection: Set): Set { - return innerApplyFilter(collection) - } + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } - private fun innerApplyFilter(collection: Collection): Set { - val now = TimeUtils.now() - val isGlobal = account.defaultStoriesFollowList.value == GLOBAL_FOLLOWS - val isHiddenList = account.defaultStoriesFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - account.defaultStoriesFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + private fun innerApplyFilter(collection: Collection): Set { + val now = TimeUtils.now() + val isGlobal = account.defaultStoriesFollowList.value == GLOBAL_FOLLOWS + val isHiddenList = + account.defaultStoriesFollowList.value == + PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + account.defaultStoriesFollowList.value == + MuteListEvent.blockListFor(account.userProfile().pubkeyHex) - val followingKeySet = account.liveStoriesFollowLists.value?.users ?: emptySet() - val followingTagSet = account.liveStoriesFollowLists.value?.hashtags ?: emptySet() - val followingGeohashSet = account.liveStoriesFollowLists.value?.geotags ?: emptySet() + val followingKeySet = account.liveStoriesFollowLists.value?.users ?: emptySet() + val followingTagSet = account.liveStoriesFollowLists.value?.hashtags ?: emptySet() + val followingGeohashSet = account.liveStoriesFollowLists.value?.geotags ?: emptySet() - return collection - .asSequence() - .filter { (it.event is FileHeaderEvent && (it.event as FileHeaderEvent).hasUrl()) || it.event is FileStorageHeaderEvent } - .filter { isGlobal || it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false) || (it.event?.isTaggedGeoHashes(followingGeohashSet) ?: false) } - .filter { isHiddenList || account.isAcceptable(it) } - .filter { it.createdAt()!! <= now } - .toSet() - } + return collection + .asSequence() + .filter { + (it.event is FileHeaderEvent && (it.event as FileHeaderEvent).hasUrl()) || + it.event is FileStorageHeaderEvent + } + .filter { + isGlobal || + it.author?.pubkeyHex in followingKeySet || + (it.event?.isTaggedHashes(followingTagSet) ?: false) || + (it.event?.isTaggedGeoHashes(followingGeohashSet) ?: false) + } + .filter { isHiddenList || account.isAcceptable(it) } + .filter { it.createdAt()!! <= now } + .toSet() + } - override fun sort(collection: Set): List { - return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - } + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/AddRemoveButtons.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/AddRemoveButtons.kt index a06317b31..1bb4becdf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/AddRemoveButtons.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/AddRemoveButtons.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.elements import androidx.compose.foundation.layout.Column @@ -20,74 +40,74 @@ import com.vitorpamplona.amethyst.ui.theme.ThemeComparison @Composable @Preview fun AddButtonPreview() { - ThemeComparison( - onDark = { - Row() { - Column { - AddButton(isActive = true) {} - AddButton(isActive = false) {} - } - - Column { - RemoveButton(isActive = true) {} - RemoveButton(isActive = false) {} - } - } - }, - onLight = { - Row() { - Column { - AddButton(isActive = true) {} - AddButton(isActive = false) {} - } - - Column { - RemoveButton(isActive = true) {} - RemoveButton(isActive = false) {} - } - } + ThemeComparison( + onDark = { + Row { + Column { + AddButton(isActive = true) {} + AddButton(isActive = false) {} } - ) + + Column { + RemoveButton(isActive = true) {} + RemoveButton(isActive = false) {} + } + } + }, + onLight = { + Row { + Column { + AddButton(isActive = true) {} + AddButton(isActive = false) {} + } + + Column { + RemoveButton(isActive = true) {} + RemoveButton(isActive = false) {} + } + } + }, + ) } @Composable fun AddButton( - text: Int = R.string.add, - isActive: Boolean = true, - modifier: Modifier = Modifier.padding(start = 3.dp), - onClick: () -> Unit + text: Int = R.string.add, + isActive: Boolean = true, + modifier: Modifier = Modifier.padding(start = 3.dp), + onClick: () -> Unit, ) { - Button( - modifier = modifier, - onClick = { - if (isActive) { - onClick() - } - }, - shape = ButtonBorder, - enabled = isActive, - contentPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp) - ) { - Text(text = stringResource(text), color = Color.White, textAlign = TextAlign.Center) - } + Button( + modifier = modifier, + onClick = { + if (isActive) { + onClick() + } + }, + shape = ButtonBorder, + enabled = isActive, + contentPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp), + ) { + Text(text = stringResource(text), color = Color.White, textAlign = TextAlign.Center) + } } @Composable fun RemoveButton( - isActive: Boolean = true, - onClick: () -> Unit + isActive: Boolean = true, + onClick: () -> Unit, ) { - Button( - modifier = Modifier.padding(start = 3.dp), - onClick = { - if (isActive) { - onClick() - } - }, - shape = ButtonBorder, - enabled = isActive, - contentPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp) - ) { - Text(text = stringResource(R.string.remove), color = Color.White) - } + Button( + modifier = Modifier.padding(start = 3.dp), + onClick = { + if (isActive) { + onClick() + } + }, + shape = ButtonBorder, + enabled = isActive, + contentPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp), + ) { + Text(text = stringResource(R.string.remove), color = Color.White) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayCommunity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayCommunity.kt index 0ed7371b1..b4f82cefc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayCommunity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayCommunity.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.elements import androidx.compose.foundation.layout.Column @@ -17,44 +37,47 @@ import com.vitorpamplona.quartz.events.CommunityDefinitionEvent @Composable fun DisplayFollowingCommunityInPost( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - Column(HalfStartPadding) { - Row(verticalAlignment = Alignment.CenterVertically) { - DisplayCommunity(baseNote, nav) - } - } + Column(HalfStartPadding) { + Row(verticalAlignment = Alignment.CenterVertically) { DisplayCommunity(baseNote, nav) } + } } @Composable -private fun DisplayCommunity(note: Note, nav: (String) -> Unit) { - val communityTag = remember(note) { - note.event?.getTagOfAddressableKind(CommunityDefinitionEvent.kind) - } ?: return +private fun DisplayCommunity( + note: Note, + nav: (String) -> Unit, +) { + val communityTag = + remember(note) { note.event?.getTagOfAddressableKind(CommunityDefinitionEvent.KIND) } ?: return - val displayTag = remember(note) { AnnotatedString(getCommunityShortName(communityTag)) } - val route = remember(note) { "Community/${communityTag.toTag()}" } + val displayTag = remember(note) { AnnotatedString(getCommunityShortName(communityTag)) } + val route = remember(note) { "Community/${communityTag.toTag()}" } - ClickableText( - text = displayTag, - onClick = { nav(route) }, - style = LocalTextStyle.current.copy( - color = MaterialTheme.colorScheme.primary.copy( - alpha = 0.52f - ) - ), - maxLines = 1 - ) + ClickableText( + text = displayTag, + onClick = { nav(route) }, + style = + LocalTextStyle.current.copy( + color = + MaterialTheme.colorScheme.primary.copy( + alpha = 0.52f, + ), + ), + maxLines = 1, + ) } private fun getCommunityShortName(communityTag: ATag): String { - val name = if (communityTag.dTag.length > 10) { - communityTag.dTag.take(10) + "..." + val name = + if (communityTag.dTag.length > 10) { + communityTag.dTag.take(10) + "..." } else { - communityTag.dTag.take(10) + communityTag.dTag.take(10) } - return "/n/$name" + return "/n/$name" } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayHashtags.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayHashtags.kt index 5e6f104f3..08c5737bd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayHashtags.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayHashtags.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.elements import androidx.compose.foundation.layout.Arrangement @@ -22,50 +42,51 @@ import kotlinx.coroutines.launch @Composable fun DisplayFollowingHashtagsInPost( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = remember { baseNote.event } ?: return + val noteEvent = remember { baseNote.event } ?: return - val userFollowState by accountViewModel.userFollows.observeAsState() - var firstTag by remember { mutableStateOf(null) } + val userFollowState by accountViewModel.userFollows.observeAsState() + var firstTag by remember { mutableStateOf(null) } - LaunchedEffect(key1 = userFollowState) { - launch(Dispatchers.Default) { - val followingTags = userFollowState?.user?.cachedFollowingTagSet() ?: emptySet() - val newFirstTag = noteEvent.firstIsTaggedHashes(followingTags) + LaunchedEffect(key1 = userFollowState) { + launch(Dispatchers.Default) { + val followingTags = userFollowState?.user?.cachedFollowingTagSet() ?: emptySet() + val newFirstTag = noteEvent.firstIsTaggedHashes(followingTags) - if (firstTag != newFirstTag) { - launch(Dispatchers.Main) { - firstTag = newFirstTag - } - } - } + if (firstTag != newFirstTag) { + launch(Dispatchers.Main) { firstTag = newFirstTag } + } } + } - firstTag?.let { - Column(verticalArrangement = Arrangement.Center) { - Row(verticalAlignment = Alignment.CenterVertically) { - DisplayTagList(it, nav) - } - } + firstTag?.let { + Column(verticalArrangement = Arrangement.Center) { + Row(verticalAlignment = Alignment.CenterVertically) { DisplayTagList(it, nav) } } + } } @Composable -private fun DisplayTagList(firstTag: String, nav: (String) -> Unit) { - val displayTag = remember(firstTag) { AnnotatedString(" #$firstTag") } - val route = remember(firstTag) { "Hashtag/$firstTag" } +private fun DisplayTagList( + firstTag: String, + nav: (String) -> Unit, +) { + val displayTag = remember(firstTag) { AnnotatedString(" #$firstTag") } + val route = remember(firstTag) { "Hashtag/$firstTag" } - ClickableText( - text = displayTag, - onClick = { nav(route) }, - style = LocalTextStyle.current.copy( - color = MaterialTheme.colorScheme.primary.copy( - alpha = 0.52f - ) - ), - maxLines = 1 - ) + ClickableText( + text = displayTag, + onClick = { nav(route) }, + style = + LocalTextStyle.current.copy( + color = + MaterialTheme.colorScheme.primary.copy( + alpha = 0.52f, + ), + ), + maxLines = 1, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayPoW.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayPoW.kt index bef4cf915..bbfc06033 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayPoW.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayPoW.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.elements import androidx.compose.material3.MaterialTheme @@ -13,29 +33,21 @@ import com.vitorpamplona.amethyst.ui.theme.lessImportantLink @Composable @Preview fun DisplayPoWPreview() { - ThemeComparison( - onDark = { - DisplayPoW(pow = 24) - }, - onLight = { - DisplayPoW(pow = 24) - } - ) + ThemeComparison( + onDark = { DisplayPoW(pow = 24) }, + onLight = { DisplayPoW(pow = 24) }, + ) } @Composable -fun DisplayPoW( - pow: Int -) { - val powStr = remember(pow) { - "PoW-$pow" - } +fun DisplayPoW(pow: Int) { + val powStr = remember(pow) { "PoW-$pow" } - Text( - powStr, - color = MaterialTheme.colorScheme.lessImportantLink, - fontSize = Font14SP, - fontWeight = FontWeight.Bold, - maxLines = 1 - ) + Text( + powStr, + color = MaterialTheme.colorScheme.lessImportantLink, + fontSize = Font14SP, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayReward.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayReward.kt index 5c8a686cf..105194aa7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayReward.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayReward.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.elements import androidx.compose.foundation.clickable @@ -49,202 +69,206 @@ import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.placeholderText +import java.math.BigDecimal import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import java.math.BigDecimal -@Stable -data class Reward(val amount: BigDecimal) +@Stable data class Reward(val amount: BigDecimal) @Composable fun DisplayReward( - baseReward: Reward, - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseReward: Reward, + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var popupExpanded by remember { mutableStateOf(false) } + var popupExpanded by remember { mutableStateOf(false) } - Column() { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.clickable { popupExpanded = true } - ) { - ClickableText( - text = AnnotatedString("#bounty"), - onClick = { nav("Hashtag/bounty") }, - style = LocalTextStyle.current.copy( - color = MaterialTheme.colorScheme.primary.copy( - alpha = 0.52f - ) - ) - ) + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { popupExpanded = true }, + ) { + ClickableText( + text = AnnotatedString("#bounty"), + onClick = { nav("Hashtag/bounty") }, + style = + LocalTextStyle.current.copy( + color = + MaterialTheme.colorScheme.primary.copy( + alpha = 0.52f, + ), + ), + ) - RenderPledgeAmount(baseNote, baseReward, accountViewModel) - } - - if (popupExpanded) { - AddBountyAmountDialog(baseNote, accountViewModel) { - popupExpanded = false - } - } + RenderPledgeAmount(baseNote, baseReward, accountViewModel) } + + if (popupExpanded) { + AddBountyAmountDialog(baseNote, accountViewModel) { popupExpanded = false } + } + } } @Composable private fun RenderPledgeAmount( - baseNote: Note, - baseReward: Reward, - accountViewModel: AccountViewModel + baseNote: Note, + baseReward: Reward, + accountViewModel: AccountViewModel, ) { - val repliesState by baseNote.live().replies.observeAsState() - var reward by remember { - mutableStateOf( - showAmount(baseReward.amount) - ) - } - - var hasPledge by remember { - mutableStateOf( - false - ) - } - - LaunchedEffect(key1 = repliesState) { - launch(Dispatchers.IO) { - repliesState?.note?.pledgedAmountByOthers()?.let { - val newRewardAmount = showAmount(baseReward.amount.add(it)) - if (newRewardAmount != reward) { - reward = newRewardAmount - } - } - val newHasPledge = repliesState?.note?.hasPledgeBy(accountViewModel.userProfile()) == true - if (hasPledge != newHasPledge) { - launch(Dispatchers.Main) { - hasPledge = newHasPledge - } - } - } - } - - if (hasPledge) { - ZappedIcon(modifier = Size20Modifier) - } else { - ZapIcon(modifier = Size20Modifier, MaterialTheme.colorScheme.placeholderText) - } - - Text( - text = reward, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1 + val repliesState by baseNote.live().replies.observeAsState() + var reward by remember { + mutableStateOf( + showAmount(baseReward.amount), ) + } + + var hasPledge by remember { + mutableStateOf( + false, + ) + } + + LaunchedEffect(key1 = repliesState) { + launch(Dispatchers.IO) { + repliesState?.note?.pledgedAmountByOthers()?.let { + val newRewardAmount = showAmount(baseReward.amount.add(it)) + if (newRewardAmount != reward) { + reward = newRewardAmount + } + } + val newHasPledge = repliesState?.note?.hasPledgeBy(accountViewModel.userProfile()) == true + if (hasPledge != newHasPledge) { + launch(Dispatchers.Main) { hasPledge = newHasPledge } + } + } + } + + if (hasPledge) { + ZappedIcon(modifier = Size20Modifier) + } else { + ZapIcon(modifier = Size20Modifier, MaterialTheme.colorScheme.placeholderText) + } + + Text( + text = reward, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) } class AddBountyAmountViewModel : ViewModel() { - private var account: Account? = null - private var bounty: Note? = null + private var account: Account? = null + private var bounty: Note? = null - var nextAmount by mutableStateOf(TextFieldValue("")) + var nextAmount by mutableStateOf(TextFieldValue("")) - fun load(account: Account, bounty: Note?) { - this.account = account - this.bounty = bounty + fun load( + account: Account, + bounty: Note?, + ) { + this.account = account + this.bounty = bounty + } + + fun sendPost() { + val newValue = nextAmount.text.trim().toLongOrNull() + + if (newValue != null) { + account?.sendPost( + message = newValue.toString(), + replyTo = listOfNotNull(bounty), + mentions = listOfNotNull(bounty?.author), + tags = listOf("bounty-added-reward"), + wantsToMarkAsSensitive = false, + replyingTo = null, + root = null, + directMentions = setOf(), + ) + + nextAmount = TextFieldValue("") } + } - fun sendPost() { - val newValue = nextAmount.text.trim().toLongOrNull() + fun cancel() { + nextAmount = TextFieldValue("") + } - if (newValue != null) { - account?.sendPost( - message = newValue.toString(), - replyTo = listOfNotNull(bounty), - mentions = listOfNotNull(bounty?.author), - tags = listOf("bounty-added-reward"), - wantsToMarkAsSensitive = false, - replyingTo = null, - root = null, - directMentions = setOf() - ) - - nextAmount = TextFieldValue("") - } - } - - fun cancel() { - nextAmount = TextFieldValue("") - } - - fun hasChanged(): Boolean { - return nextAmount.text.trim().toLongOrNull() != null - } + fun hasChanged(): Boolean { + return nextAmount.text.trim().toLongOrNull() != null + } } @Composable -fun AddBountyAmountDialog(bounty: Note, accountViewModel: AccountViewModel, onClose: () -> Unit) { - val postViewModel: AddBountyAmountViewModel = viewModel() - postViewModel.load(accountViewModel.account, bounty) - val scope = rememberCoroutineScope() +fun AddBountyAmountDialog( + bounty: Note, + accountViewModel: AccountViewModel, + onClose: () -> Unit, +) { + val postViewModel: AddBountyAmountViewModel = viewModel() + postViewModel.load(accountViewModel.account, bounty) + val scope = rememberCoroutineScope() - Dialog( - onDismissRequest = { onClose() }, - properties = DialogProperties( - dismissOnClickOutside = false, - usePlatformDefaultWidth = false - ) - ) { - Surface() { - Column( - modifier = Modifier - .padding(10.dp) - .width(IntrinsicSize.Min) - ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - CloseButton(onPress = { - postViewModel.cancel() - onClose() - }) + Dialog( + onDismissRequest = { onClose() }, + properties = + DialogProperties( + dismissOnClickOutside = false, + usePlatformDefaultWidth = false, + ), + ) { + Surface { + Column( + modifier = Modifier.padding(10.dp).width(IntrinsicSize.Min), + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + CloseButton( + onPress = { + postViewModel.cancel() + onClose() + }, + ) - PostButton( - onPost = { - scope.launch(Dispatchers.IO) { - postViewModel.sendPost() - onClose() - } - }, - isActive = postViewModel.hasChanged() - ) - } - - Spacer(modifier = Modifier.height(10.dp)) - - Row( - modifier = Modifier.padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.pledge_amount_in_sats)) }, - value = postViewModel.nextAmount, - onValueChange = { - postViewModel.nextAmount = it - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None, - keyboardType = KeyboardType.Number - ), - placeholder = { - Text( - text = "10000, 50000, 5000000", - color = MaterialTheme.colorScheme.placeholderText - ) - }, - singleLine = true - ) - } - } + PostButton( + onPost = { + scope.launch(Dispatchers.IO) { + postViewModel.sendPost() + onClose() + } + }, + isActive = postViewModel.hasChanged(), + ) } + + Spacer(modifier = Modifier.height(10.dp)) + + Row( + modifier = Modifier.padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.pledge_amount_in_sats)) }, + value = postViewModel.nextAmount, + onValueChange = { postViewModel.nextAmount = it }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Number, + ), + placeholder = { + Text( + text = "10000, 50000, 5000000", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + ) + } + } } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayUncitedHashtags.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayUncitedHashtags.kt index 5417510b4..c790c735d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayUncitedHashtags.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayUncitedHashtags.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.elements import androidx.compose.foundation.layout.ExperimentalLayoutApi @@ -15,27 +35,27 @@ import kotlinx.collections.immutable.ImmutableList @OptIn(ExperimentalLayoutApi::class) @Composable fun DisplayUncitedHashtags( - hashtags: ImmutableList, - eventContent: String, - nav: (String) -> Unit + hashtags: ImmutableList, + eventContent: String, + nav: (String) -> Unit, ) { - val unusedHashtags = remember(eventContent) { - hashtags.filter { !eventContent.contains(it, true) } - } + val unusedHashtags = + remember(eventContent) { hashtags.filter { !eventContent.contains(it, true) } } - if (unusedHashtags.isNotEmpty()) { - FlowRow( - modifier = HalfTopPadding - ) { - unusedHashtags.forEach { hashtag -> - ClickableText( - text = remember { AnnotatedString("#$hashtag ") }, - onClick = { nav("Hashtag/$hashtag") }, - style = LocalTextStyle.current.copy( - color = MaterialTheme.colorScheme.lessImportantLink - ) - ) - } - } + if (unusedHashtags.isNotEmpty()) { + FlowRow( + modifier = HalfTopPadding, + ) { + unusedHashtags.forEach { hashtag -> + ClickableText( + text = remember { AnnotatedString("#$hashtag ") }, + onClick = { nav("Hashtag/$hashtag") }, + style = + LocalTextStyle.current.copy( + color = MaterialTheme.colorScheme.lessImportantLink, + ), + ) + } } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayZapSplits.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayZapSplits.kt index 74afd98e0..41258fa0e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayZapSplits.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayZapSplits.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.elements import androidx.compose.foundation.layout.Box @@ -32,53 +52,51 @@ import com.vitorpamplona.quartz.events.EventInterface @OptIn(ExperimentalLayoutApi::class) @Composable -fun DisplayZapSplits(noteEvent: EventInterface, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val list = remember(noteEvent) { noteEvent.zapSplitSetup() } - if (list.isEmpty()) return +fun DisplayZapSplits( + noteEvent: EventInterface, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val list = remember(noteEvent) { noteEvent.zapSplitSetup() } + if (list.isEmpty()) return - Row(verticalAlignment = Alignment.CenterVertically) { - Box( - Modifier - .height(20.dp) - .width(25.dp) - ) { - Icon( - imageVector = Icons.Outlined.Bolt, - contentDescription = stringResource(id = R.string.zaps), - modifier = Modifier - .size(20.dp) - .align(Alignment.CenterStart), - tint = BitcoinOrange - ) - Icon( - imageVector = Icons.Outlined.ArrowForwardIos, - contentDescription = stringResource(id = R.string.zaps), - modifier = Modifier - .size(13.dp) - .align(Alignment.CenterEnd), - tint = BitcoinOrange - ) - } - - Spacer(modifier = StdHorzSpacer) - - FlowRow { - list.forEach { - if (it.isLnAddress) { - ClickableText( - text = AnnotatedString(it.lnAddressOrPubKeyHex), - onClick = { }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary) - ) - } else { - UserPicture( - userHex = it.lnAddressOrPubKeyHex, - size = Size25dp, - accountViewModel = accountViewModel, - nav = nav - ) - } - } - } + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier.height(20.dp).width(25.dp), + ) { + Icon( + imageVector = Icons.Outlined.Bolt, + contentDescription = stringResource(id = R.string.zaps), + modifier = Modifier.size(20.dp).align(Alignment.CenterStart), + tint = BitcoinOrange, + ) + Icon( + imageVector = Icons.Outlined.ArrowForwardIos, + contentDescription = stringResource(id = R.string.zaps), + modifier = Modifier.size(13.dp).align(Alignment.CenterEnd), + tint = BitcoinOrange, + ) } + + Spacer(modifier = StdHorzSpacer) + + FlowRow { + list.forEach { + if (it.isLnAddress) { + ClickableText( + text = AnnotatedString(it.lnAddressOrPubKeyHex), + onClick = {}, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + ) + } else { + UserPicture( + userHex = it.lnAddressOrPubKeyHex, + size = Size25dp, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt index c03c77916..8b6ee8c76 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.layouts import androidx.compose.foundation.Image @@ -33,94 +53,91 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Composable @Preview fun ChannelNamePreview() { - Column { - ChatHeaderLayout( - channelPicture = { - Image( - painter = painterResource(R.drawable.github), - contentDescription = stringResource(id = R.string.profile_banner), - contentScale = ContentScale.FillWidth - ) - }, - firstRow = { - Text("This is my author", Modifier.weight(1f)) - TimeAgo(TimeUtils.now()) - }, - secondRow = { - Text("This is a message from this person", Modifier.weight(1f)) - NewItemsBubble() - }, - onClick = { - } + Column { + ChatHeaderLayout( + channelPicture = { + Image( + painter = painterResource(R.drawable.github), + contentDescription = stringResource(id = R.string.profile_banner), + contentScale = ContentScale.FillWidth, ) + }, + firstRow = { + Text("This is my author", Modifier.weight(1f)) + TimeAgo(TimeUtils.now()) + }, + secondRow = { + Text("This is a message from this person", Modifier.weight(1f)) + NewItemsBubble() + }, + onClick = {}, + ) - Divider() + Divider() - ListItem( - headlineContent = { - Row(verticalAlignment = Alignment.CenterVertically) { - Text("This is my author", Modifier.weight(1f)) - TimeAgo(TimeUtils.now()) - } - }, - supportingContent = { - Row(verticalAlignment = Alignment.CenterVertically) { - Text("This is a message from this person", Modifier.weight(1f)) - NewItemsBubble() - } - }, - leadingContent = { - Image( - painter = painterResource(R.drawable.github), - contentDescription = stringResource(id = R.string.profile_banner), - contentScale = ContentScale.FillWidth, - modifier = Size55Modifier - ) - } + ListItem( + headlineContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("This is my author", Modifier.weight(1f)) + TimeAgo(TimeUtils.now()) + } + }, + supportingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("This is a message from this person", Modifier.weight(1f)) + NewItemsBubble() + } + }, + leadingContent = { + Image( + painter = painterResource(R.drawable.github), + contentDescription = stringResource(id = R.string.profile_banner), + contentScale = ContentScale.FillWidth, + modifier = Size55Modifier, ) - } + }, + ) + } } @Composable fun ChatHeaderLayout( - channelPicture: @Composable () -> Unit, - firstRow: @Composable RowScope.() -> Unit, - secondRow: @Composable RowScope.() -> Unit, - onClick: () -> Unit + channelPicture: @Composable () -> Unit, + firstRow: @Composable RowScope.() -> Unit, + secondRow: @Composable RowScope.() -> Unit, + onClick: () -> Unit, ) { - Column(modifier = remember { Modifier.clickable(onClick = onClick) }) { + Column(modifier = remember { Modifier.clickable(onClick = onClick) }) { + Row( + modifier = ChatHeadlineBorders, + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Size55Modifier) { channelPicture() } + + Spacer(modifier = DoubleHorzSpacer) + + Column( + modifier = Modifier.fillMaxWidth(), + ) { Row( - modifier = ChatHeadlineBorders, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { - Box(Size55Modifier) { - channelPicture() - } - - Spacer(modifier = DoubleHorzSpacer) - - Column( - modifier = Modifier.fillMaxWidth() - ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - firstRow() - } - - Spacer(modifier = Height4dpModifier) - - Row( - verticalAlignment = Alignment.CenterVertically - ) { - secondRow() - } - } + firstRow() } - Divider( - modifier = StdTopPadding, - thickness = DividerThickness - ) + Spacer(modifier = Height4dpModifier) + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + secondRow() + } + } } + + Divider( + modifier = StdTopPadding, + thickness = DividerThickness, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayout.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayout.kt index dac59a88f..30270fa29 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayout.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayout.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.layouts import androidx.compose.foundation.Image @@ -37,103 +57,94 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable @Preview fun LeftPictureLayoutPreview() { - ThemeComparison( - onDark = { LeftPictureLayoutPreviewCard() }, - onLight = { LeftPictureLayoutPreviewCard() } - ) + ThemeComparison( + onDark = { LeftPictureLayoutPreviewCard() }, + onLight = { LeftPictureLayoutPreviewCard() }, + ) } @Composable fun LeftPictureLayoutPreviewCard() { - LeftPictureLayout( - onImage = { - Image( - painter = painterResource(R.drawable.github), - contentDescription = stringResource(id = R.string.profile_banner), - contentScale = ContentScale.FillWidth, - modifier = Modifier - .fillMaxSize() - .clip(QuoteBorder) - ) - }, - onTitleRow = { - Text( - text = "This is my title", - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) + LeftPictureLayout( + onImage = { + Image( + painter = painterResource(R.drawable.github), + contentDescription = stringResource(id = R.string.profile_banner), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxSize().clip(QuoteBorder), + ) + }, + onTitleRow = { + Text( + text = "This is my title", + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) - Spacer(modifier = StdHorzSpacer) - LikeIcon( - iconSizeModifier = Size16Modifier, - grayTint = MaterialTheme.colorScheme.onSurface - ) - TextCount(12, MaterialTheme.colorScheme.onSurface) - Spacer(modifier = StdHorzSpacer) - ZappedIcon(Size20Modifier) - TextCount(120, MaterialTheme.colorScheme.onSurface) - }, - onDescription = { - Text( - "This is 3-line description, This is 3-line description, This is 3-line description, This is 3-line description", - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - fontSize = 14.sp - ) - }, - onBottomRow = { - Text("This is my Moderator List") - } - ) + Spacer(modifier = StdHorzSpacer) + LikeIcon( + iconSizeModifier = Size16Modifier, + grayTint = MaterialTheme.colorScheme.onSurface, + ) + TextCount(12, MaterialTheme.colorScheme.onSurface) + Spacer(modifier = StdHorzSpacer) + ZappedIcon(Size20Modifier) + TextCount(120, MaterialTheme.colorScheme.onSurface) + }, + onDescription = { + Text( + "This is 3-line description, This is 3-line description, This is 3-line description, This is 3-line description", + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + fontSize = 14.sp, + ) + }, + onBottomRow = { Text("This is my Moderator List") }, + ) } @Composable fun LeftPictureLayout( - onImage: @Composable () -> Unit, - onTitleRow: @Composable RowScope.() -> Unit, - onDescription: @Composable () -> Unit, - onBottomRow: @Composable RowScope.() -> Unit + onImage: @Composable () -> Unit, + onTitleRow: @Composable RowScope.() -> Unit, + onDescription: @Composable () -> Unit, + onBottomRow: @Composable RowScope.() -> Unit, ) { - Row(Modifier.aspectRatio(ratio = 4f)) { - Column( - modifier = Modifier - .fillMaxWidth(0.25f) - .aspectRatio(ratio = 1f) - ) { - onImage() - } - - Spacer(modifier = DoubleHorzSpacer) - - Column( - modifier = Modifier - .fillMaxWidth(), - verticalArrangement = Arrangement.SpaceBetween - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - onTitleRow() - } - - Row( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - ) { - onDescription() - } - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - onBottomRow() - } - } + Row(Modifier.aspectRatio(ratio = 4f)) { + Column( + modifier = Modifier.fillMaxWidth(0.25f).aspectRatio(ratio = 1f), + ) { + onImage() } + + Spacer(modifier = DoubleHorzSpacer) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + onTitleRow() + } + + Row( + modifier = Modifier.fillMaxWidth().weight(1f), + ) { + onDescription() + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + onBottomRow() + } + } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/RepostLayout.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/RepostLayout.kt index b6f524e71..f6efd7b05 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/RepostLayout.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/RepostLayout.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.layouts import androidx.compose.foundation.layout.Box @@ -18,41 +38,31 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable @Preview private fun GenericRepostSectionPreview() { - GenericRepostLayout( - baseAuthorPicture = { - Text("ab") - }, - repostAuthorPicture = { - Text("cd") - } - ) + GenericRepostLayout( + baseAuthorPicture = { Text("ab") }, + repostAuthorPicture = { Text("cd") }, + ) } @Composable fun GenericRepostLayout( - baseAuthorPicture: @Composable () -> Unit, - repostAuthorPicture: @Composable () -> Unit + baseAuthorPicture: @Composable () -> Unit, + repostAuthorPicture: @Composable () -> Unit, ) { - Box(modifier = Size55Modifier) { - Box(remember { Size35Modifier.align(Alignment.TopStart) }) { - baseAuthorPicture() - } + Box(modifier = Size55Modifier) { + Box(remember { Size35Modifier.align(Alignment.TopStart) }) { baseAuthorPicture() } - Box( - remember { - Size18Modifier - .align(Alignment.BottomStart) - .padding(1.dp) - } - ) { - RepostedIcon(modifier = Size18Modifier, MaterialTheme.colorScheme.placeholderText) - } - - Box( - remember { Size35Modifier.align(Alignment.BottomEnd) }, - contentAlignment = Alignment.BottomEnd - ) { - repostAuthorPicture() - } + Box( + remember { Size18Modifier.align(Alignment.BottomStart).padding(1.dp) }, + ) { + RepostedIcon(modifier = Size18Modifier, MaterialTheme.colorScheme.placeholderText) } + + Box( + remember { Size35Modifier.align(Alignment.BottomEnd) }, + contentAlignment = Alignment.BottomEnd, + ) { + repostAuthorPicture() + } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt index 875da1c06..924bb1b9e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt @@ -1,307 +1,314 @@ -package com.vitorpamplona.amethyst.ui.navigation - -import androidx.compose.foundation.clickable -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.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Logout -import androidx.compose.material.icons.filled.RadioButtonChecked -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -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.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.lifecycle.map -import com.vitorpamplona.amethyst.AccountInfo -import com.vitorpamplona.amethyst.LocalPreferences -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji -import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage -import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon -import com.vitorpamplona.amethyst.ui.note.toShortenHex -import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage -import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier -import com.vitorpamplona.amethyst.ui.theme.Size10dp -import com.vitorpamplona.amethyst.ui.theme.Size55dp -import com.vitorpamplona.quartz.encoders.decodePublicKey -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.events.toImmutableListOfLists -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AccountSwitchBottomSheet( - accountViewModel: AccountViewModel, - accountStateViewModel: AccountStateViewModel -) { - val accounts = LocalPreferences.allSavedAccounts() - - var popupExpanded by remember { mutableStateOf(false) } - val scrollState = rememberScrollState() - - Column(modifier = Modifier.verticalScroll(scrollState)) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(Size10dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - Text(stringResource(R.string.account_switch_select_account), fontWeight = FontWeight.Bold) - } - accounts.forEach { acc -> - DisplayAccount(acc, accountViewModel, accountStateViewModel) - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = Size10dp, bottom = Size55dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - TextButton(onClick = { popupExpanded = true }) { - Text(stringResource(R.string.account_switch_add_account_btn)) - } - } - } - - if (popupExpanded) { - Dialog( - onDismissRequest = { popupExpanded = false }, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Surface(modifier = Modifier.fillMaxSize()) { - Box { - LoginPage(accountStateViewModel, isFirstLogin = false) - TopAppBar( - title = { Text(text = stringResource(R.string.account_switch_add_account_dialog_title)) }, - navigationIcon = { - IconButton(onClick = { popupExpanded = false }) { - ArrowBackIcon() - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface - ) - ) - } - } - } - } -} - -@Composable -fun DisplayAccount( - acc: AccountInfo, - accountViewModel: AccountViewModel, - accountStateViewModel: AccountStateViewModel -) { - var baseUser by remember { - mutableStateOf( - LocalCache.getUserIfExists( - decodePublicKey( - acc.npub - ).toHexKey() - ) - ) - } - - if (baseUser == null) { - LaunchedEffect(key1 = acc.npub) { - launch(Dispatchers.IO) { - baseUser = try { - LocalCache.getOrCreateUser( - decodePublicKey(acc.npub).toHexKey() - ) - } catch (e: Exception) { - null - } - } - } - } - - baseUser?.let { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - accountStateViewModel.switchUser(acc) - } - .padding(16.dp, 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Row( - modifier = Modifier.weight(1f), - verticalAlignment = Alignment.CenterVertically - ) { - Row( - modifier = Modifier.weight(1f), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .width(55.dp) - .padding(0.dp) - ) { - val automaticallyShowProfilePicture = remember { - accountViewModel.settings.showProfilePictures.value - } - - AccountPicture(it, automaticallyShowProfilePicture) - } - Spacer(modifier = Modifier.width(16.dp)) - Column(modifier = Modifier.weight(1f)) { - AccountName(acc, it) - } - Column(modifier = Modifier.width(32.dp)) { - ActiveMarker(acc, accountViewModel) - } - } - } - - LogoutButton(acc, accountStateViewModel) - } - } -} - -@Composable -private fun ActiveMarker(acc: AccountInfo, accountViewModel: AccountViewModel) { - val isCurrentUser by remember(accountViewModel) { - derivedStateOf { - accountViewModel.account.userProfile().pubkeyNpub() == acc.npub - } - } - - if (isCurrentUser) { - Icon( - imageVector = Icons.Default.RadioButtonChecked, - contentDescription = stringResource(R.string.account_switch_active_account), - tint = MaterialTheme.colorScheme.secondary - ) - } -} - -@Composable -private fun AccountPicture(user: User, loadProfilePicture: Boolean) { - val profilePicture by user.live().profilePictureChanges.observeAsState() - - RobohashFallbackAsyncImage( - robot = user.pubkeyHex, - model = profilePicture, - contentDescription = stringResource(R.string.profile_image), - modifier = AccountPictureModifier, - loadProfilePicture = loadProfilePicture - ) -} - -@Composable -private fun AccountName( - acc: AccountInfo, - user: User -) { - val displayName by user.live().metadata.map { - user.bestDisplayName() - }.observeAsState() - - val tags by user.live().metadata.map { - user.info?.latestMetadata?.tags?.toImmutableListOfLists() - }.observeAsState() - - displayName?.let { - CreateTextWithEmoji( - text = it, - tags = tags, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - - Text( - text = remember(user) { acc.npub.toShortenHex() } - ) -} - -@Composable -private fun LogoutButton( - acc: AccountInfo, - accountStateViewModel: AccountStateViewModel -) { - var logoutDialog by remember { mutableStateOf(false) } - if (logoutDialog) { - AlertDialog( - title = { - Text(text = stringResource(R.string.log_out)) - }, - text = { - Text(text = stringResource(R.string.are_you_sure_you_want_to_log_out)) - }, - onDismissRequest = { - logoutDialog = false - }, - confirmButton = { - TextButton( - onClick = { - logoutDialog = false - accountStateViewModel.logOff(acc) - } - ) { - Text(text = stringResource(R.string.log_out)) - } - }, - dismissButton = { - TextButton( - onClick = { - logoutDialog = false - } - ) { - Text(text = stringResource(R.string.cancel)) - } - } - ) - } - - IconButton( - onClick = { logoutDialog = true } - ) { - Icon( - imageVector = Icons.Default.Logout, - contentDescription = stringResource(R.string.log_out), - tint = MaterialTheme.colorScheme.onSurface - ) - } -} +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.navigation + +import androidx.compose.foundation.clickable +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Logout +import androidx.compose.material.icons.filled.RadioButtonChecked +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.map +import com.vitorpamplona.amethyst.AccountInfo +import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji +import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.note.toShortenHex +import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage +import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size55dp +import com.vitorpamplona.quartz.encoders.decodePublicKey +import com.vitorpamplona.quartz.encoders.toHexKey +import com.vitorpamplona.quartz.events.toImmutableListOfLists +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountSwitchBottomSheet( + accountViewModel: AccountViewModel, + accountStateViewModel: AccountStateViewModel, +) { + val accounts = LocalPreferences.allSavedAccounts() + + var popupExpanded by remember { mutableStateOf(false) } + val scrollState = rememberScrollState() + + Column(modifier = Modifier.verticalScroll(scrollState)) { + Row( + modifier = Modifier.fillMaxWidth().padding(Size10dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(stringResource(R.string.account_switch_select_account), fontWeight = FontWeight.Bold) + } + accounts.forEach { acc -> DisplayAccount(acc, accountViewModel, accountStateViewModel) } + Row( + modifier = Modifier.fillMaxWidth().padding(top = Size10dp, bottom = Size55dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = { popupExpanded = true }) { + Text(stringResource(R.string.account_switch_add_account_btn)) + } + } + } + + if (popupExpanded) { + Dialog( + onDismissRequest = { popupExpanded = false }, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface(modifier = Modifier.fillMaxSize()) { + Box { + LoginPage(accountStateViewModel, isFirstLogin = false) + TopAppBar( + title = { + Text(text = stringResource(R.string.account_switch_add_account_dialog_title)) + }, + navigationIcon = { + IconButton(onClick = { popupExpanded = false }) { ArrowBackIcon() } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + } + } + } + } +} + +@Composable +fun DisplayAccount( + acc: AccountInfo, + accountViewModel: AccountViewModel, + accountStateViewModel: AccountStateViewModel, +) { + var baseUser by remember { + mutableStateOf( + LocalCache.getUserIfExists( + decodePublicKey( + acc.npub, + ) + .toHexKey(), + ), + ) + } + + if (baseUser == null) { + LaunchedEffect(key1 = acc.npub) { + launch(Dispatchers.IO) { + baseUser = + try { + LocalCache.getOrCreateUser( + decodePublicKey(acc.npub).toHexKey(), + ) + } catch (e: Exception) { + null + } + } + } + } + + baseUser?.let { + Row( + modifier = + Modifier.fillMaxWidth() + .clickable { accountStateViewModel.switchUser(acc) } + .padding(16.dp, 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.width(55.dp).padding(0.dp), + ) { + val automaticallyShowProfilePicture = remember { + accountViewModel.settings.showProfilePictures.value + } + + AccountPicture(it, automaticallyShowProfilePicture) + } + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { AccountName(acc, it) } + Column(modifier = Modifier.width(32.dp)) { ActiveMarker(acc, accountViewModel) } + } + } + + LogoutButton(acc, accountStateViewModel) + } + } +} + +@Composable +private fun ActiveMarker( + acc: AccountInfo, + accountViewModel: AccountViewModel, +) { + val isCurrentUser by + remember(accountViewModel) { + derivedStateOf { accountViewModel.account.userProfile().pubkeyNpub() == acc.npub } + } + + if (isCurrentUser) { + Icon( + imageVector = Icons.Default.RadioButtonChecked, + contentDescription = stringResource(R.string.account_switch_active_account), + tint = MaterialTheme.colorScheme.secondary, + ) + } +} + +@Composable +private fun AccountPicture( + user: User, + loadProfilePicture: Boolean, +) { + val profilePicture by user.live().profilePictureChanges.observeAsState() + + RobohashFallbackAsyncImage( + robot = user.pubkeyHex, + model = profilePicture, + contentDescription = stringResource(R.string.profile_image), + modifier = AccountPictureModifier, + loadProfilePicture = loadProfilePicture, + ) +} + +@Composable +private fun AccountName( + acc: AccountInfo, + user: User, +) { + val displayName by user.live().metadata.map { user.bestDisplayName() }.observeAsState() + + val tags by + user + .live() + .metadata + .map { user.info?.latestMetadata?.tags?.toImmutableListOfLists() } + .observeAsState() + + displayName?.let { + CreateTextWithEmoji( + text = it, + tags = tags, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Text( + text = remember(user) { acc.npub.toShortenHex() }, + ) +} + +@Composable +private fun LogoutButton( + acc: AccountInfo, + accountStateViewModel: AccountStateViewModel, +) { + var logoutDialog by remember { mutableStateOf(false) } + if (logoutDialog) { + AlertDialog( + title = { Text(text = stringResource(R.string.log_out)) }, + text = { Text(text = stringResource(R.string.are_you_sure_you_want_to_log_out)) }, + onDismissRequest = { logoutDialog = false }, + confirmButton = { + TextButton( + onClick = { + logoutDialog = false + accountStateViewModel.logOff(acc) + }, + ) { + Text(text = stringResource(R.string.log_out)) + } + }, + dismissButton = { + TextButton( + onClick = { logoutDialog = false }, + ) { + Text(text = stringResource(R.string.cancel)) + } + }, + ) + } + + IconButton( + onClick = { logoutDialog = true }, + ) { + Icon( + imageVector = Icons.Default.Logout, + contentDescription = stringResource(R.string.log_out), + tint = MaterialTheme.colorScheme.onSurface, + ) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt index ff620fb4e..43e5df7b8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.navigation import android.graphics.Rect @@ -41,169 +61,163 @@ import com.vitorpamplona.amethyst.ui.theme.Size10Modifier import com.vitorpamplona.amethyst.ui.theme.Size10dp import kotlinx.collections.immutable.persistentListOf -val bottomNavigationItems = persistentListOf( +val bottomNavigationItems = + persistentListOf( Route.Home, Route.Message, Route.Video, Route.Discover, - Route.Notification -) + Route.Notification, + ) enum class Keyboard { - Opened, Closed + Opened, + Closed, } fun isKeyboardOpen(view: View): Keyboard { - val rect = Rect() - view.getWindowVisibleDisplayFrame(rect) - val screenHeight = view.rootView.height - val keypadHeight = screenHeight - rect.bottom + val rect = Rect() + view.getWindowVisibleDisplayFrame(rect) + val screenHeight = view.rootView.height + val keypadHeight = screenHeight - rect.bottom - return if (keypadHeight > screenHeight * 0.15) { - Keyboard.Opened - } else { - Keyboard.Closed - } + return if (keypadHeight > screenHeight * 0.15) { + Keyboard.Opened + } else { + Keyboard.Closed + } } @Composable fun keyboardAsState(): State { - val view = LocalView.current + val view = LocalView.current - val keyboardState = remember(view) { - mutableStateOf(isKeyboardOpen(view)) - } + val keyboardState = remember(view) { mutableStateOf(isKeyboardOpen(view)) } - DisposableEffect(view) { - val onGlobalListener = ViewTreeObserver.OnGlobalLayoutListener { - val newKeyboardValue = isKeyboardOpen(view) + DisposableEffect(view) { + val onGlobalListener = + ViewTreeObserver.OnGlobalLayoutListener { + val newKeyboardValue = isKeyboardOpen(view) - if (newKeyboardValue != keyboardState.value) { - keyboardState.value = newKeyboardValue - } + if (newKeyboardValue != keyboardState.value) { + keyboardState.value = newKeyboardValue } - view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener) + } + view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener) - onDispose { - view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener) - } - } + onDispose { view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener) } + } - return keyboardState + return keyboardState } @Composable -fun IfKeyboardClosed( - inner: @Composable () -> Unit +fun IfKeyboardClosed(inner: @Composable () -> Unit) { + val isKeyboardState by keyboardAsState() + if (isKeyboardState == Keyboard.Closed) { + inner() + } +} + +@Composable +fun AppBottomBar( + accountViewModel: AccountViewModel, + navEntryState: State, + nav: (Route, Boolean) -> Unit, ) { - val isKeyboardState by keyboardAsState() - if (isKeyboardState == Keyboard.Closed) { - inner() - } -} - -@Composable -fun AppBottomBar(accountViewModel: AccountViewModel, navEntryState: State, nav: (Route, Boolean) -> Unit) { - IfKeyboardClosed { - RenderBottomMenu(accountViewModel, navEntryState, nav) - } + IfKeyboardClosed { RenderBottomMenu(accountViewModel, navEntryState, nav) } } @Composable private fun RenderBottomMenu( - accountViewModel: AccountViewModel, - navEntryState: State, - nav: (Route, Boolean) -> Unit + accountViewModel: AccountViewModel, + navEntryState: State, + nav: (Route, Boolean) -> Unit, ) { - Column(modifier = BottomTopHeight) { - Divider( - thickness = DividerThickness - ) - NavigationBar(tonalElevation = Size0dp) { - bottomNavigationItems.forEach { item -> - HasNewItemsIcon(item, accountViewModel, navEntryState, nav) - } - } + Column(modifier = BottomTopHeight) { + Divider( + thickness = DividerThickness, + ) + NavigationBar(tonalElevation = Size0dp) { + bottomNavigationItems.forEach { item -> + HasNewItemsIcon(item, accountViewModel, navEntryState, nav) + } } + } } @Composable private fun RowScope.HasNewItemsIcon( - route: Route, - accountViewModel: AccountViewModel, - navEntryState: State, - nav: (Route, Boolean) -> Unit + route: Route, + accountViewModel: AccountViewModel, + navEntryState: State, + nav: (Route, Boolean) -> Unit, ) { - val selected by remember(navEntryState.value) { - derivedStateOf { - navEntryState.value?.destination?.route?.substringBefore("?") == route.base - } + val selected by + remember(navEntryState.value) { + derivedStateOf { navEntryState.value?.destination?.route?.substringBefore("?") == route.base } } - NavigationBarItem( - icon = { - NotifiableIcon( - selected, - route, - accountViewModel - ) - }, - selected = selected, - onClick = { nav(route, selected) } - ) + NavigationBarItem( + icon = { + NotifiableIcon( + selected, + route, + accountViewModel, + ) + }, + selected = selected, + onClick = { nav(route, selected) }, + ) } @Composable private fun NotifiableIcon( - selected: Boolean, - route: Route, - accountViewModel: AccountViewModel + selected: Boolean, + route: Route, + accountViewModel: AccountViewModel, ) { - Box(route.notifSize) { - Icon( - painter = painterResource(id = route.icon), - contentDescription = null, - modifier = route.iconSize, - tint = if (selected) MaterialTheme.colorScheme.primary else Color.Unspecified - ) + Box(route.notifSize) { + Icon( + painter = painterResource(id = route.icon), + contentDescription = null, + modifier = route.iconSize, + tint = if (selected) MaterialTheme.colorScheme.primary else Color.Unspecified, + ) - AddNotifIconIfNeeded(route, accountViewModel, Modifier.align(Alignment.TopEnd)) - } + AddNotifIconIfNeeded(route, accountViewModel, Modifier.align(Alignment.TopEnd)) + } } @Composable fun AddNotifIconIfNeeded( - route: Route, - accountViewModel: AccountViewModel, - modifier: Modifier + route: Route, + accountViewModel: AccountViewModel, + modifier: Modifier, ) { - val flow = accountViewModel.notificationDots.hasNewItems[route] ?: return - val hasNewItems by flow.collectAsStateWithLifecycle() - if (hasNewItems) { - NotificationDotIcon(modifier) - } + val flow = accountViewModel.notificationDots.hasNewItems[route] ?: return + val hasNewItems by flow.collectAsStateWithLifecycle() + if (hasNewItems) { + NotificationDotIcon(modifier) + } } @Composable private fun NotificationDotIcon(modifier: Modifier) { - Box(modifier.size(Size10dp)) { - Box( - modifier = remember { - Size10Modifier.clip(shape = CircleShape) - }.background(MaterialTheme.colorScheme.primary), - contentAlignment = Alignment.TopEnd - ) { - Text( - "", - color = Color.White, - textAlign = TextAlign.Center, - fontSize = Font12SP, - modifier = remember { - Modifier - .wrapContentHeight() - .align(Alignment.TopEnd) - } - ) - } + Box(modifier.size(Size10dp)) { + Box( + modifier = + remember { Size10Modifier.clip(shape = CircleShape) } + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.TopEnd, + ) { + Text( + "", + color = Color.White, + textAlign = TextAlign.Center, + fontSize = Font12SP, + modifier = remember { Modifier.wrapContentHeight().align(Alignment.TopEnd) }, + ) } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 0dbe56c64..e04d1f92f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.navigation import android.content.Context @@ -57,275 +77,336 @@ import kotlinx.coroutines.launch @Composable fun AppNavigation( - homeFeedViewModel: NostrHomeFeedViewModel, - repliesFeedViewModel: NostrHomeRepliesFeedViewModel, - knownFeedViewModel: NostrChatroomListKnownFeedViewModel, - newFeedViewModel: NostrChatroomListNewFeedViewModel, - videoFeedViewModel: NostrVideoFeedViewModel, - discoverMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel, - discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel, - discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel, - discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel, - notifFeedViewModel: NotificationViewModel, - userReactionsStatsModel: UserReactionsViewModel, - - navController: NavHostController, - accountViewModel: AccountViewModel, - sharedPreferencesViewModel: SharedPreferencesViewModel + homeFeedViewModel: NostrHomeFeedViewModel, + repliesFeedViewModel: NostrHomeRepliesFeedViewModel, + knownFeedViewModel: NostrChatroomListKnownFeedViewModel, + newFeedViewModel: NostrChatroomListNewFeedViewModel, + videoFeedViewModel: NostrVideoFeedViewModel, + discoverMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel, + discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel, + discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel, + discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel, + notifFeedViewModel: NotificationViewModel, + userReactionsStatsModel: UserReactionsViewModel, + navController: NavHostController, + accountViewModel: AccountViewModel, + sharedPreferencesViewModel: SharedPreferencesViewModel, ) { - val scope = rememberCoroutineScope() - val nav = remember { - { route: String -> - scope.launch { - if (getRouteWithArguments(navController) != route) { - navController.navigate(route) - } - } - Unit + val scope = rememberCoroutineScope() + val nav = remember { + { route: String -> + scope.launch { + if (getRouteWithArguments(navController) != route) { + navController.navigate(route) } + } + Unit + } + } + + NavHost( + navController, + startDestination = Route.Home.route, + enterTransition = { fadeIn(animationSpec = tween(200)) }, + exitTransition = { fadeOut(animationSpec = tween(200)) }, + ) { + Route.Home.let { route -> + composable( + route.route, + route.arguments, + content = { it -> + val nip47 = it.arguments?.getString("nip47") + + HomeScreen( + homeFeedViewModel = homeFeedViewModel, + repliesFeedViewModel = repliesFeedViewModel, + accountViewModel = accountViewModel, + nav = nav, + nip47 = nip47, + ) + + if (nip47 != null) { + LaunchedEffect(key1 = Unit) { + launch { + delay(1000) + it.arguments?.remove("nip47") + } + } + } + }, + ) } - NavHost( - navController, - startDestination = Route.Home.route, - enterTransition = { fadeIn(animationSpec = tween(200)) }, - exitTransition = { fadeOut(animationSpec = tween(200)) } - ) { - Route.Home.let { route -> - composable(route.route, route.arguments, content = { it -> - val nip47 = it.arguments?.getString("nip47") - - HomeScreen( - homeFeedViewModel = homeFeedViewModel, - repliesFeedViewModel = repliesFeedViewModel, - accountViewModel = accountViewModel, - nav = nav, - nip47 = nip47 - ) - - if (nip47 != null) { - LaunchedEffect(key1 = Unit) { - launch { - delay(1000) - it.arguments?.remove("nip47") - } - } - } - }) - } - - composable( - Route.Message.route, - content = { - ChatroomListScreen( - knownFeedViewModel, - newFeedViewModel, - accountViewModel, - nav - ) - } + composable( + Route.Message.route, + content = { + ChatroomListScreen( + knownFeedViewModel, + newFeedViewModel, + accountViewModel, + nav, ) + }, + ) - Route.Video.let { route -> - composable(route.route, route.arguments, content = { - VideoScreen( - videoFeedView = videoFeedViewModel, - accountViewModel = accountViewModel, - nav = nav - ) - }) - } - - Route.Discover.let { route -> - composable(route.route, route.arguments, content = { - DiscoverScreen( - discoveryMarketplaceFeedViewModel = discoverMarketplaceFeedViewModel, - discoveryLiveFeedViewModel = discoveryLiveFeedViewModel, - discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel, - discoveryChatFeedViewModel = discoveryChatFeedViewModel, - accountViewModel = accountViewModel, - nav = nav - ) - }) - } - - Route.Search.let { route -> - composable(route.route, route.arguments, content = { - SearchScreen( - accountViewModel = accountViewModel, - nav = nav - ) - }) - } - - Route.Notification.let { route -> - composable(route.route, route.arguments, content = { - NotificationScreen( - notifFeedViewModel = notifFeedViewModel, - userReactionsStatsModel = userReactionsStatsModel, - sharedPreferencesViewModel = sharedPreferencesViewModel, - accountViewModel = accountViewModel, - nav = nav - ) - }) - } - - composable(Route.BlockedUsers.route, content = { HiddenUsersScreen(accountViewModel, nav) }) - composable(Route.Bookmarks.route, content = { BookmarkListScreen(accountViewModel, nav) }) - - Route.Profile.let { route -> - composable(route.route, route.arguments, content = { - ProfileScreen( - userId = it.arguments?.getString("id"), - accountViewModel = accountViewModel, - nav = nav - ) - }) - } - - Route.Note.let { route -> - composable(route.route, route.arguments, content = { - ThreadScreen( - noteId = it.arguments?.getString("id"), - accountViewModel = accountViewModel, - nav = nav - ) - }) - } - - Route.Hashtag.let { route -> - composable(route.route, route.arguments, content = { - HashtagScreen( - tag = it.arguments?.getString("id"), - accountViewModel = accountViewModel, - nav = nav - ) - }) - } - - Route.Geohash.let { route -> - composable(route.route, route.arguments, content = { - GeoHashScreen( - tag = it.arguments?.getString("id"), - accountViewModel = accountViewModel, - nav = nav - ) - }) - } - - Route.Community.let { route -> - composable(route.route, route.arguments, content = { - CommunityScreen( - aTagHex = it.arguments?.getString("id"), - accountViewModel = accountViewModel, - nav = nav - ) - }) - } - - Route.Room.let { route -> - composable(route.route, route.arguments, content = { - ChatroomScreen( - roomId = it.arguments?.getString("id"), - draftMessage = it.arguments?.getString("message"), - accountViewModel = accountViewModel, - nav = nav - ) - }) - } - - Route.RoomByAuthor.let { route -> - composable(route.route, route.arguments, content = { - ChatroomScreenByAuthor( - authorPubKeyHex = it.arguments?.getString("id"), - accountViewModel = accountViewModel, - nav = nav - ) - }) - } - - Route.Channel.let { route -> - composable(route.route, route.arguments, content = { - ChannelScreen( - channelId = it.arguments?.getString("id"), - accountViewModel = accountViewModel, - nav = nav - ) - }) - } - - Route.Event.let { route -> - composable(route.route, route.arguments, content = { - LoadRedirectScreen( - eventId = it.arguments?.getString("id"), - accountViewModel = accountViewModel, - navController = navController - ) - }) - } - - Route.Settings.let { route -> - composable(route.route, route.arguments, content = { - SettingsScreen( - sharedPreferencesViewModel - ) - }) - } + Route.Video.let { route -> + composable( + route.route, + route.arguments, + content = { + VideoScreen( + videoFeedView = videoFeedViewModel, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) } - val activity = LocalContext.current.getActivity() - var actionableNextPage by remember { - mutableStateOf(uriToRoute(activity.intent?.data?.toString()?.ifBlank { null })) + Route.Discover.let { route -> + composable( + route.route, + route.arguments, + content = { + DiscoverScreen( + discoveryMarketplaceFeedViewModel = discoverMarketplaceFeedViewModel, + discoveryLiveFeedViewModel = discoveryLiveFeedViewModel, + discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel, + discoveryChatFeedViewModel = discoveryChatFeedViewModel, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) } - actionableNextPage?.let { - LaunchedEffect(it) { - navController.navigate(it) { - popUpTo(Route.Home.route) - launchSingleTop = true + + Route.Search.let { route -> + composable( + route.route, + route.arguments, + content = { + SearchScreen( + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + + Route.Notification.let { route -> + composable( + route.route, + route.arguments, + content = { + NotificationScreen( + notifFeedViewModel = notifFeedViewModel, + userReactionsStatsModel = userReactionsStatsModel, + sharedPreferencesViewModel = sharedPreferencesViewModel, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + + composable(Route.BlockedUsers.route, content = { HiddenUsersScreen(accountViewModel, nav) }) + composable(Route.Bookmarks.route, content = { BookmarkListScreen(accountViewModel, nav) }) + + Route.Profile.let { route -> + composable( + route.route, + route.arguments, + content = { + ProfileScreen( + userId = it.arguments?.getString("id"), + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + + Route.Note.let { route -> + composable( + route.route, + route.arguments, + content = { + ThreadScreen( + noteId = it.arguments?.getString("id"), + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + + Route.Hashtag.let { route -> + composable( + route.route, + route.arguments, + content = { + HashtagScreen( + tag = it.arguments?.getString("id"), + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + + Route.Geohash.let { route -> + composable( + route.route, + route.arguments, + content = { + GeoHashScreen( + tag = it.arguments?.getString("id"), + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + + Route.Community.let { route -> + composable( + route.route, + route.arguments, + content = { + CommunityScreen( + aTagHex = it.arguments?.getString("id"), + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + + Route.Room.let { route -> + composable( + route.route, + route.arguments, + content = { + ChatroomScreen( + roomId = it.arguments?.getString("id"), + draftMessage = it.arguments?.getString("message"), + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + + Route.RoomByAuthor.let { route -> + composable( + route.route, + route.arguments, + content = { + ChatroomScreenByAuthor( + authorPubKeyHex = it.arguments?.getString("id"), + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + + Route.Channel.let { route -> + composable( + route.route, + route.arguments, + content = { + ChannelScreen( + channelId = it.arguments?.getString("id"), + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + + Route.Event.let { route -> + composable( + route.route, + route.arguments, + content = { + LoadRedirectScreen( + eventId = it.arguments?.getString("id"), + accountViewModel = accountViewModel, + navController = navController, + ) + }, + ) + } + + Route.Settings.let { route -> + composable( + route.route, + route.arguments, + content = { + SettingsScreen( + sharedPreferencesViewModel, + ) + }, + ) + } + } + + val activity = LocalContext.current.getActivity() + var actionableNextPage by remember { + mutableStateOf(uriToRoute(activity.intent?.data?.toString()?.ifBlank { null })) + } + actionableNextPage?.let { + LaunchedEffect(it) { + navController.navigate(it) { + popUpTo(Route.Home.route) + launchSingleTop = true + } + } + actionableNextPage = null + } + + DisposableEffect(activity) { + val consumer = + Consumer { intent -> + val uri = intent?.data?.toString() + val newPage = uriToRoute(uri) + + newPage?.let { route -> + val currentRoute = getRouteWithArguments(navController) + if (!isSameRoute(currentRoute, route)) { + navController.navigate(route) { + popUpTo(Route.Home.route) + launchSingleTop = true } + } } - actionableNextPage = null - } - - DisposableEffect(activity) { - val consumer = Consumer { intent -> - val uri = intent?.data?.toString() - val newPage = uriToRoute(uri) - - newPage?.let { route -> - val currentRoute = getRouteWithArguments(navController) - if (!isSameRoute(currentRoute, route)) { - navController.navigate(route) { - popUpTo(Route.Home.route) - launchSingleTop = true - } - } - } - } - activity.addOnNewIntentListener(consumer) - onDispose { - activity.removeOnNewIntentListener(consumer) - } - } + } + activity.addOnNewIntentListener(consumer) + onDispose { activity.removeOnNewIntentListener(consumer) } + } } fun Context.getActivity(): MainActivity { - if (this is MainActivity) return this - return if (this is ContextWrapper) baseContext.getActivity() else getActivity() + if (this is MainActivity) return this + return if (this is ContextWrapper) baseContext.getActivity() else getActivity() } -private fun isSameRoute(currentRoute: String?, newRoute: String): Boolean { - if (currentRoute == null) return false +private fun isSameRoute( + currentRoute: String?, + newRoute: String, +): Boolean { + if (currentRoute == null) return false - if (currentRoute == newRoute) { - return true + if (currentRoute == newRoute) { + return true + } + + if (newRoute.startsWith("Event/") && currentRoute.contains("/")) { + if (newRoute.split("/")[1] == currentRoute.split("/")[1]) { + return true } + } - if (newRoute.startsWith("Event/") && currentRoute.contains("/")) { - if (newRoute.split("/")[1] == currentRoute.split("/")[1]) { - return true - } - } - - return false + return false } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt index b3f29889c..b9c3efa1f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.navigation import android.content.Context @@ -132,857 +152,926 @@ import kotlinx.coroutines.launch @Composable fun AppTopBar( - followLists: FollowListViewModel, - navEntryState: State, - drawerState: DrawerState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - navPopBack: () -> Unit + followLists: FollowListViewModel, + navEntryState: State, + drawerState: DrawerState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + navPopBack: () -> Unit, ) { - val currentRoute by remember(navEntryState.value) { - derivedStateOf { - navEntryState.value?.destination?.route?.substringBefore("?") - } + val currentRoute by + remember(navEntryState.value) { + derivedStateOf { navEntryState.value?.destination?.route?.substringBefore("?") } } - val id by remember(navEntryState.value) { - derivedStateOf { - navEntryState.value?.arguments?.getString("id") - } + val id by + remember(navEntryState.value) { + derivedStateOf { navEntryState.value?.arguments?.getString("id") } } - RenderTopRouteBar(currentRoute, id, followLists, drawerState, accountViewModel, nav, navPopBack) + RenderTopRouteBar(currentRoute, id, followLists, drawerState, accountViewModel, nav, navPopBack) } @Composable private fun RenderTopRouteBar( - currentRoute: String?, - id: String?, - followLists: FollowListViewModel, - drawerState: DrawerState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - navPopBack: () -> Unit + currentRoute: String?, + id: String?, + followLists: FollowListViewModel, + drawerState: DrawerState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + navPopBack: () -> Unit, ) { - when (currentRoute) { - Route.Home.base -> HomeTopBar(followLists, drawerState, accountViewModel, nav) - Route.Video.base -> StoriesTopBar(followLists, drawerState, accountViewModel, nav) - Route.Discover.base -> DiscoveryTopBar(followLists, drawerState, accountViewModel, nav) - Route.Notification.base -> NotificationTopBar(followLists, drawerState, accountViewModel, nav) - Route.Settings.base -> TopBarWithBackButton(stringResource(id = R.string.application_preferences), navPopBack) - else -> { - if (id != null) { - when (currentRoute) { - Route.Channel.base -> ChannelTopBar(id, accountViewModel, nav, navPopBack) - Route.RoomByAuthor.base -> RoomByAuthorTopBar(id, accountViewModel, nav, navPopBack) - Route.Room.base -> RoomTopBar(id, accountViewModel, nav, navPopBack) - Route.Community.base -> CommunityTopBar(id, accountViewModel, nav, navPopBack) - Route.Hashtag.base -> HashTagTopBar(id, accountViewModel, navPopBack) - Route.Geohash.base -> GeoHashTopBar(id, accountViewModel, navPopBack) - Route.Note.base -> ThreadTopBar(id, accountViewModel, navPopBack) - else -> MainTopBar(drawerState, accountViewModel, nav) - } - } else { - MainTopBar(drawerState, accountViewModel, nav) - } + when (currentRoute) { + Route.Home.base -> HomeTopBar(followLists, drawerState, accountViewModel, nav) + Route.Video.base -> StoriesTopBar(followLists, drawerState, accountViewModel, nav) + Route.Discover.base -> DiscoveryTopBar(followLists, drawerState, accountViewModel, nav) + Route.Notification.base -> NotificationTopBar(followLists, drawerState, accountViewModel, nav) + Route.Settings.base -> + TopBarWithBackButton(stringResource(id = R.string.application_preferences), navPopBack) + else -> { + if (id != null) { + when (currentRoute) { + Route.Channel.base -> ChannelTopBar(id, accountViewModel, nav, navPopBack) + Route.RoomByAuthor.base -> RoomByAuthorTopBar(id, accountViewModel, nav, navPopBack) + Route.Room.base -> RoomTopBar(id, accountViewModel, nav, navPopBack) + Route.Community.base -> CommunityTopBar(id, accountViewModel, nav, navPopBack) + Route.Hashtag.base -> HashTagTopBar(id, accountViewModel, navPopBack) + Route.Geohash.base -> GeoHashTopBar(id, accountViewModel, navPopBack) + Route.Note.base -> ThreadTopBar(id, accountViewModel, navPopBack) + else -> MainTopBar(drawerState, accountViewModel, nav) } + } else { + MainTopBar(drawerState, accountViewModel, nav) + } } + } } @Composable private fun ThreadTopBar( - id: String, - accountViewModel: AccountViewModel, - navPopBack: () -> Unit + id: String, + accountViewModel: AccountViewModel, + navPopBack: () -> Unit, ) { - FlexibleTopBarWithBackButton( - title = { - Text(stringResource(id = R.string.thread_title)) - }, - popBack = navPopBack - ) + FlexibleTopBarWithBackButton( + title = { Text(stringResource(id = R.string.thread_title)) }, + popBack = navPopBack, + ) } @Composable private fun GeoHashTopBar( - tag: String, - accountViewModel: AccountViewModel, - navPopBack: () -> Unit + tag: String, + accountViewModel: AccountViewModel, + navPopBack: () -> Unit, ) { - FlexibleTopBarWithBackButton( - title = { - DislayGeoTagHeader(tag, remember { Modifier.weight(1f) }) - GeoHashActionOptions(tag, accountViewModel) - }, - popBack = navPopBack - ) + FlexibleTopBarWithBackButton( + title = { + DislayGeoTagHeader(tag, remember { Modifier.weight(1f) }) + GeoHashActionOptions(tag, accountViewModel) + }, + popBack = navPopBack, + ) } @Composable private fun HashTagTopBar( - tag: String, - accountViewModel: AccountViewModel, - navPopBack: () -> Unit + tag: String, + accountViewModel: AccountViewModel, + navPopBack: () -> Unit, ) { - FlexibleTopBarWithBackButton( - title = { - Text( - remember(tag) { "#$tag" }, - modifier = Modifier.weight(1f) - ) + FlexibleTopBarWithBackButton( + title = { + Text( + remember(tag) { "#$tag" }, + modifier = Modifier.weight(1f), + ) - HashtagActionOptions(tag, accountViewModel) - }, - popBack = navPopBack - ) + HashtagActionOptions(tag, accountViewModel) + }, + popBack = navPopBack, + ) } @Composable private fun CommunityTopBar( - id: String, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - navPopBack: () -> Unit + id: String, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + navPopBack: () -> Unit, ) { - LoadAddressableNote(aTagHex = id, accountViewModel) { baseNote -> - if (baseNote != null) { - FlexibleTopBarWithBackButton( - title = { - ShortCommunityHeader(baseNote, accountViewModel, nav) - }, - extendableRow = { - Column(Modifier.verticalScroll(rememberScrollState())) { - LongCommunityHeader(baseNote = baseNote, accountViewModel = accountViewModel, nav = nav) - } - }, - popBack = navPopBack - ) - } else { - Spacer(BottomTopHeight) - } + LoadAddressableNote(aTagHex = id, accountViewModel) { baseNote -> + if (baseNote != null) { + FlexibleTopBarWithBackButton( + title = { ShortCommunityHeader(baseNote, accountViewModel, nav) }, + extendableRow = { + Column(Modifier.verticalScroll(rememberScrollState())) { + LongCommunityHeader(baseNote = baseNote, accountViewModel = accountViewModel, nav = nav) + } + }, + popBack = navPopBack, + ) + } else { + Spacer(BottomTopHeight) } + } } @Composable private fun RoomTopBar( - id: String, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - navPopBack: () -> Unit + id: String, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + navPopBack: () -> Unit, ) { - LoadRoom(roomId = id, accountViewModel) { room -> - if (room != null) { - RenderRoomTopBar(room, accountViewModel, nav, navPopBack) - } else { - Spacer(BottomTopHeight) - } + LoadRoom(roomId = id, accountViewModel) { room -> + if (room != null) { + RenderRoomTopBar(room, accountViewModel, nav, navPopBack) + } else { + Spacer(BottomTopHeight) } + } } @Composable private fun RoomByAuthorTopBar( - id: String, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - navPopBack: () -> Unit + id: String, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + navPopBack: () -> Unit, ) { - LoadRoomByAuthor(authorPubKeyHex = id, accountViewModel) { room -> - if (room != null) { - RenderRoomTopBar(room, accountViewModel, nav, navPopBack) - } else { - Spacer(BottomTopHeight) - } + LoadRoomByAuthor(authorPubKeyHex = id, accountViewModel) { room -> + if (room != null) { + RenderRoomTopBar(room, accountViewModel, nav, navPopBack) + } else { + Spacer(BottomTopHeight) } + } } @Composable private fun RenderRoomTopBar( - room: ChatroomKey, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - navPopBack: () -> Unit + room: ChatroomKey, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + navPopBack: () -> Unit, ) { - if (room.users.size == 1) { - FlexibleTopBarWithBackButton( - title = { - LoadUser(baseUserHex = room.users.first(), accountViewModel) { baseUser -> - if (baseUser != null) { - ClickableUserPicture( - baseUser = baseUser, - accountViewModel = accountViewModel, - size = Size34dp - ) + if (room.users.size == 1) { + FlexibleTopBarWithBackButton( + title = { + LoadUser(baseUserHex = room.users.first(), accountViewModel) { baseUser -> + if (baseUser != null) { + ClickableUserPicture( + baseUser = baseUser, + accountViewModel = accountViewModel, + size = Size34dp, + ) - Spacer(modifier = DoubleHorzSpacer) + Spacer(modifier = DoubleHorzSpacer) - UsernameDisplay(baseUser, Modifier.weight(1f), fontWeight = FontWeight.Normal) - } - } - }, - extendableRow = { - LoadUser(baseUserHex = room.users.first(), accountViewModel) { - if (it != null) { - UserCompose( - baseUser = it, - accountViewModel = accountViewModel, - nav = nav - ) - } - } - }, - popBack = navPopBack + UsernameDisplay(baseUser, Modifier.weight(1f), fontWeight = FontWeight.Normal) + } + } + }, + extendableRow = { + LoadUser(baseUserHex = room.users.first(), accountViewModel) { + if (it != null) { + UserCompose( + baseUser = it, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + }, + popBack = navPopBack, + ) + } else { + FlexibleTopBarWithBackButton( + title = { + NonClickableUserPictures( + users = room.users, + accountViewModel = accountViewModel, + size = Size34dp, ) - } else { - FlexibleTopBarWithBackButton( - title = { - NonClickableUserPictures( - users = room.users, - accountViewModel = accountViewModel, - size = Size34dp - ) - RoomNameOnlyDisplay( - room, - Modifier - .padding(start = 10.dp) - .weight(1f), - fontWeight = FontWeight.Normal, - accountViewModel.userProfile() - ) - }, - extendableRow = { - LongRoomHeader(room = room, accountViewModel = accountViewModel, nav = nav) - }, - popBack = navPopBack + RoomNameOnlyDisplay( + room, + Modifier.padding(start = 10.dp).weight(1f), + fontWeight = FontWeight.Normal, + accountViewModel.userProfile(), ) - } + }, + extendableRow = { + LongRoomHeader(room = room, accountViewModel = accountViewModel, nav = nav) + }, + popBack = navPopBack, + ) + } } @Composable private fun ChannelTopBar( - id: String, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - navPopBack: () -> Unit + id: String, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + navPopBack: () -> Unit, ) { - LoadChannel(baseChannelHex = id, accountViewModel) { baseChannel -> - FlexibleTopBarWithBackButton( - title = { - ShortChannelHeader( - baseChannel = baseChannel, - accountViewModel = accountViewModel, - nav = nav, - showFlag = true - ) - }, - extendableRow = { - LongChannelHeader(baseChannel = baseChannel, accountViewModel = accountViewModel, nav = nav) - }, - popBack = navPopBack + LoadChannel(baseChannelHex = id, accountViewModel) { baseChannel -> + FlexibleTopBarWithBackButton( + title = { + ShortChannelHeader( + baseChannel = baseChannel, + accountViewModel = accountViewModel, + nav = nav, + showFlag = true, ) + }, + extendableRow = { + LongChannelHeader(baseChannel = baseChannel, accountViewModel = accountViewModel, nav = nav) + }, + popBack = navPopBack, + ) + } +} + +@Composable fun NoTopBar() {} + +@Composable +fun StoriesTopBar( + followLists: FollowListViewModel, + drawerState: DrawerState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel -> + val list by accountViewModel.account.defaultStoriesFollowList.collectAsStateWithLifecycle() + + FollowListWithRoutes( + followListsModel = followLists, + listName = list, + ) { listName -> + accountViewModel.account.changeDefaultStoriesFollowList(listName.code) } + } } @Composable -fun NoTopBar() { +fun HomeTopBar( + followLists: FollowListViewModel, + drawerState: DrawerState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel -> + val list by accountViewModel.account.defaultHomeFollowList.collectAsStateWithLifecycle() + + FollowListWithRoutes( + followListsModel = followLists, + listName = list, + ) { listName -> + if (listName.type == CodeNameType.ROUTE) { + nav(listName.code) + } else { + accountViewModel.account.changeDefaultHomeFollowList(listName.code) + } + } + } } @Composable -fun StoriesTopBar(followLists: FollowListViewModel, drawerState: DrawerState, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel -> - val list by accountViewModel.account.defaultStoriesFollowList.collectAsStateWithLifecycle() +fun NotificationTopBar( + followLists: FollowListViewModel, + drawerState: DrawerState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel -> + val list by accountViewModel.account.defaultNotificationFollowList.collectAsStateWithLifecycle() - FollowListWithRoutes( - followListsModel = followLists, - listName = list - ) { listName -> - accountViewModel.account.changeDefaultStoriesFollowList(listName.code) - } + FollowListWithoutRoutes( + followListsModel = followLists, + listName = list, + ) { listName -> + accountViewModel.account.changeDefaultNotificationFollowList(listName.code) } + } } @Composable -fun HomeTopBar(followLists: FollowListViewModel, drawerState: DrawerState, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel -> - val list by accountViewModel.account.defaultHomeFollowList.collectAsStateWithLifecycle() +fun DiscoveryTopBar( + followLists: FollowListViewModel, + drawerState: DrawerState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel -> + val list by accountViewModel.account.defaultDiscoveryFollowList.collectAsStateWithLifecycle() - FollowListWithRoutes( - followListsModel = followLists, - listName = list - ) { listName -> - if (listName.type == CodeNameType.ROUTE) { - nav(listName.code) - } else { - accountViewModel.account.changeDefaultHomeFollowList(listName.code) - } - } + FollowListWithoutRoutes( + followListsModel = followLists, + listName = list, + ) { listName -> + accountViewModel.account.changeDefaultDiscoveryFollowList(listName.code) } + } } @Composable -fun NotificationTopBar(followLists: FollowListViewModel, drawerState: DrawerState, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel -> - val list by accountViewModel.account.defaultNotificationFollowList.collectAsStateWithLifecycle() - - FollowListWithoutRoutes( - followListsModel = followLists, - listName = list - ) { listName -> - accountViewModel.account.changeDefaultNotificationFollowList(listName.code) - } - } -} - -@Composable -fun DiscoveryTopBar(followLists: FollowListViewModel, drawerState: DrawerState, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel -> - val list by accountViewModel.account.defaultDiscoveryFollowList.collectAsStateWithLifecycle() - - FollowListWithoutRoutes( - followListsModel = followLists, - listName = list - ) { listName -> - accountViewModel.account.changeDefaultDiscoveryFollowList(listName.code) - } - } -} - -@Composable -fun MainTopBar(drawerState: DrawerState, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - GenericMainTopBar(drawerState, accountViewModel, nav) { - AmethystClickableIcon() - } +fun MainTopBar( + drawerState: DrawerState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + GenericMainTopBar(drawerState, accountViewModel, nav) { AmethystClickableIcon() } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun GenericMainTopBar( - drawerState: DrawerState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - content: @Composable (AccountViewModel) -> Unit + drawerState: DrawerState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + content: @Composable (AccountViewModel) -> Unit, ) { - Column(modifier = BottomTopHeight) { - TopAppBar( - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface - ), - title = { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box(Modifier) { - Column( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - content(accountViewModel) - } - } - } - }, - navigationIcon = { - val coroutineScope = rememberCoroutineScope() - LoggedInUserPictureDrawer(accountViewModel) { - coroutineScope.launch { - drawerState.open() - } - } - }, - actions = { - SearchButton() { - nav(Route.Search.route) - } + Column(modifier = BottomTopHeight) { + TopAppBar( + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + title = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box(Modifier) { + Column( + modifier = Modifier.fillMaxWidth().fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + content(accountViewModel) } - ) - Divider(thickness = DividerThickness) - } + } + } + }, + navigationIcon = { + val coroutineScope = rememberCoroutineScope() + LoggedInUserPictureDrawer(accountViewModel) { coroutineScope.launch { drawerState.open() } } + }, + actions = { SearchButton { nav(Route.Search.route) } }, + ) + Divider(thickness = DividerThickness) + } } @Composable private fun SearchButton(onClick: () -> Unit) { - IconButton( - onClick = onClick - ) { - SearchIcon(modifier = Size22Modifier, Color.Unspecified) - } + IconButton( + onClick = onClick, + ) { + SearchIcon(modifier = Size22Modifier, Color.Unspecified) + } } @Composable private fun LoggedInUserPictureDrawer( - accountViewModel: AccountViewModel, - onClick: () -> Unit + accountViewModel: AccountViewModel, + onClick: () -> Unit, ) { - val profilePicture by accountViewModel.account.userProfile().live().profilePictureChanges.observeAsState() - val pubkeyHex = remember { accountViewModel.userProfile().pubkeyHex } + val profilePicture by + accountViewModel.account.userProfile().live().profilePictureChanges.observeAsState() + val pubkeyHex = remember { accountViewModel.userProfile().pubkeyHex } - val automaticallyShowProfilePicture = remember { - accountViewModel.settings.showProfilePictures.value - } + val automaticallyShowProfilePicture = remember { + accountViewModel.settings.showProfilePictures.value + } - IconButton( - onClick = onClick - ) { - RobohashFallbackAsyncImage( - robot = pubkeyHex, - model = profilePicture, - contentDescription = stringResource(id = R.string.profile_image), - modifier = HeaderPictureModifier, - contentScale = ContentScale.Crop, - loadProfilePicture = automaticallyShowProfilePicture - ) - } + IconButton( + onClick = onClick, + ) { + RobohashFallbackAsyncImage( + robot = pubkeyHex, + model = profilePicture, + contentDescription = stringResource(id = R.string.profile_image), + modifier = HeaderPictureModifier, + contentScale = ContentScale.Crop, + loadProfilePicture = automaticallyShowProfilePicture, + ) + } } @Composable fun FollowListWithRoutes( - followListsModel: FollowListViewModel, - listName: String, - onChange: (CodeName) -> Unit + followListsModel: FollowListViewModel, + listName: String, + onChange: (CodeName) -> Unit, ) { - val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle() + val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle() - SimpleTextSpinner( - placeholderCode = listName, - options = allLists, - onSelect = { - onChange(allLists.getOrNull(it) ?: followListsModel.kind3Follow) - } - ) + SimpleTextSpinner( + placeholderCode = listName, + options = allLists, + onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.kind3Follow) }, + ) } @Composable fun FollowListWithoutRoutes( - followListsModel: FollowListViewModel, - listName: String, - onChange: (CodeName) -> Unit + followListsModel: FollowListViewModel, + listName: String, + onChange: (CodeName) -> Unit, ) { - val allLists by followListsModel.kind3GlobalPeople.collectAsStateWithLifecycle() + val allLists by followListsModel.kind3GlobalPeople.collectAsStateWithLifecycle() - SimpleTextSpinner( - placeholderCode = listName, - options = allLists, - onSelect = { - onChange(allLists.getOrNull(it) ?: followListsModel.kind3Follow) - } - ) + SimpleTextSpinner( + placeholderCode = listName, + options = allLists, + onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.kind3Follow) }, + ) } enum class CodeNameType { - HARDCODED, PEOPLE_LIST, ROUTE + HARDCODED, + PEOPLE_LIST, + ROUTE, } abstract class Name { - abstract fun name(): String - open fun name(context: Context) = name() + abstract fun name(): String + + open fun name(context: Context) = name() } class GeoHashName(val geoHashTag: String) : Name() { - override fun name() = "/g/$geoHashTag" + override fun name() = "/g/$geoHashTag" } + class HashtagName(val hashTag: String) : Name() { - override fun name() = "#$hashTag" + override fun name() = "#$hashTag" } + class ResourceName(val resourceId: Int) : Name() { - override fun name() = " $resourceId " // Space to make sure it goes first - override fun name(context: Context) = context.getString(resourceId) + override fun name() = " $resourceId " // Space to make sure it goes first + + override fun name(context: Context) = context.getString(resourceId) } class PeopleListName(val note: AddressableNote) : Name() { - override fun name() = (note.event as? PeopleListEvent)?.nameOrTitle() ?: note.dTag() ?: "" -} -class CommunityName(val note: AddressableNote) : Name() { - override fun name() = "/n/${(note.dTag() ?: "")}" + override fun name() = (note.event as? PeopleListEvent)?.nameOrTitle() ?: note.dTag() ?: "" } -@Immutable -data class CodeName(val code: String, val name: Name, val type: CodeNameType) +class CommunityName(val note: AddressableNote) : Name() { + override fun name() = "/n/${(note.dTag() ?: "")}" +} + +@Immutable data class CodeName(val code: String, val name: Name, val type: CodeNameType) @Stable class FollowListViewModel(val account: Account) : ViewModel() { - val kind3Follow = CodeName(KIND3_FOLLOWS, ResourceName(R.string.follow_list_kind3follows), CodeNameType.HARDCODED) - val globalFollow = CodeName(GLOBAL_FOLLOWS, ResourceName(R.string.follow_list_global), CodeNameType.HARDCODED) - val muteListFollow = CodeName( - MuteListEvent.blockListFor(account.userProfile().pubkeyHex), - ResourceName(R.string.follow_list_mute_list), - CodeNameType.HARDCODED + val kind3Follow = + CodeName(KIND3_FOLLOWS, ResourceName(R.string.follow_list_kind3follows), CodeNameType.HARDCODED) + val globalFollow = + CodeName(GLOBAL_FOLLOWS, ResourceName(R.string.follow_list_global), CodeNameType.HARDCODED) + val muteListFollow = + CodeName( + MuteListEvent.blockListFor(account.userProfile().pubkeyHex), + ResourceName(R.string.follow_list_mute_list), + CodeNameType.HARDCODED, ) - private var _kind3GlobalPeopleRoutes = MutableStateFlow>(emptyList().toPersistentList()) - val kind3GlobalPeopleRoutes = _kind3GlobalPeopleRoutes.asStateFlow() + private var _kind3GlobalPeopleRoutes = + MutableStateFlow>(emptyList().toPersistentList()) + val kind3GlobalPeopleRoutes = _kind3GlobalPeopleRoutes.asStateFlow() - private var _kind3GlobalPeople = MutableStateFlow>(emptyList().toPersistentList()) - val kind3GlobalPeople = _kind3GlobalPeople.asStateFlow() + private var _kind3GlobalPeople = + MutableStateFlow>(emptyList().toPersistentList()) + val kind3GlobalPeople = _kind3GlobalPeople.asStateFlow() - fun refresh() { - viewModelScope.launch(Dispatchers.Default) { - refreshFollows() + fun refresh() { + viewModelScope.launch(Dispatchers.Default) { refreshFollows() } + } + + private suspend fun refreshFollows() { + checkNotInMainThread() + + val newFollowLists = + LocalCache.addressables + .mapNotNull { + val event = (it.value.event as? PeopleListEvent) + // Has to have an list + if ( + event != null && + event.pubKey == account.userProfile().pubkeyHex && + (event.tags.size > 1 || event.content.length > 50) + ) { + CodeName(event.address().toTag(), PeopleListName(it.value), CodeNameType.PEOPLE_LIST) + } else { + null + } } + .sortedBy { it.name.name() } + + val communities = + account.userProfile().cachedFollowingCommunitiesSet().mapNotNull { + LocalCache.checkGetOrCreateAddressableNote(it)?.let { communityNote -> + CodeName( + "Community/${communityNote.idHex}", + CommunityName(communityNote), + CodeNameType.ROUTE, + ) + } + } + + val hashtags = + account.userProfile().cachedFollowingTagSet().map { + CodeName("Hashtag/$it", HashtagName(it), CodeNameType.ROUTE) + } + + val geotags = + account.userProfile().cachedFollowingGeohashSet().map { + CodeName("Geohash/$it", GeoHashName(it), CodeNameType.ROUTE) + } + + val routeList = (communities + hashtags + geotags).sortedBy { it.name.name() } + + val kind3GlobalPeopleRouteList = + listOf(listOf(kind3Follow, globalFollow), newFollowLists, routeList, listOf(muteListFollow)) + .flatten() + .toImmutableList() + + if (!equalImmutableLists(_kind3GlobalPeopleRoutes.value, kind3GlobalPeopleRouteList)) { + _kind3GlobalPeopleRoutes.emit(kind3GlobalPeopleRouteList) } - private suspend fun refreshFollows() { - checkNotInMainThread() + val kind3GlobalPeopleList = + listOf(listOf(kind3Follow, globalFollow), newFollowLists, listOf(muteListFollow)) + .flatten() + .toImmutableList() - val newFollowLists = LocalCache.addressables.mapNotNull { - val event = (it.value.event as? PeopleListEvent) - // Has to have an list - if (event != null && - event.pubKey == account.userProfile().pubkeyHex && - (event.tags.size > 1 || event.content.length > 50) - ) { - CodeName(event.address().toTag(), PeopleListName(it.value), CodeNameType.PEOPLE_LIST) - } else { - null - } - }.sortedBy { it.name.name() } - - val communities = account.userProfile().cachedFollowingCommunitiesSet().mapNotNull { - LocalCache.checkGetOrCreateAddressableNote(it)?.let { communityNote -> - CodeName("Community/${communityNote.idHex}", CommunityName(communityNote), CodeNameType.ROUTE) + if (!equalImmutableLists(_kind3GlobalPeople.value, kind3GlobalPeopleList)) { + _kind3GlobalPeople.emit(kind3GlobalPeopleList) + } + } + + var collectorJob: Job? = null + + init { + Log.d("Init", "App Top Bar") + refresh() + collectorJob = + viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { newNotes -> + checkNotInMainThread() + if ( + newNotes.any { + it.event?.pubKey() == account.userProfile().pubkeyHex && + (it.event is PeopleListEvent || + it.event is MuteListEvent || + it.event is ContactListEvent) } + ) { + refresh() + } } + } + } - val hashtags = account.userProfile().cachedFollowingTagSet().map { - CodeName("Hashtag/$it", HashtagName(it), CodeNameType.ROUTE) - } + override fun onCleared() { + collectorJob?.cancel() + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + super.onCleared() + } - val geotags = account.userProfile().cachedFollowingGeohashSet().map { - CodeName("Geohash/$it", GeoHashName(it), CodeNameType.ROUTE) - } - - val routeList = (communities + hashtags + geotags).sortedBy { it.name.name() } - - val kind3GlobalPeopleRouteList = listOf(listOf(kind3Follow, globalFollow), newFollowLists, routeList, listOf(muteListFollow)).flatten().toImmutableList() - - if (!equalImmutableLists(_kind3GlobalPeopleRoutes.value, kind3GlobalPeopleRouteList)) { - _kind3GlobalPeopleRoutes.emit(kind3GlobalPeopleRouteList) - } - - val kind3GlobalPeopleList = listOf(listOf(kind3Follow, globalFollow), newFollowLists, listOf(muteListFollow)).flatten().toImmutableList() - - if (!equalImmutableLists(_kind3GlobalPeople.value, kind3GlobalPeopleList)) { - _kind3GlobalPeople.emit(kind3GlobalPeopleList) - } - } - - var collectorJob: Job? = null - - init { - Log.d("Init", "App Top Bar") - refresh() - collectorJob = viewModelScope.launch(Dispatchers.IO) { - LocalCache.live.newEventBundles.collect { newNotes -> - checkNotInMainThread() - if (newNotes.any { - it.event?.pubKey() == account.userProfile().pubkeyHex && (it.event is PeopleListEvent || it.event is MuteListEvent || it.event is ContactListEvent) - } - ) { - refresh() - } - } - } - } - - override fun onCleared() { - collectorJob?.cancel() - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") - super.onCleared() - } - - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create(modelClass: Class): FollowListViewModel { - return FollowListViewModel(account) as FollowListViewModel - } + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): FollowListViewModel { + return FollowListViewModel(account) as FollowListViewModel } + } } @Composable fun SimpleTextSpinner( - placeholderCode: String, - options: ImmutableList, - onSelect: (Int) -> Unit, - modifier: Modifier = Modifier + placeholderCode: String, + options: ImmutableList, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, ) { - val interactionSource = remember { MutableInteractionSource() } - var optionsShowing by remember { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } + var optionsShowing by remember { mutableStateOf(false) } - val context = LocalContext.current - val selectAnOption = stringResource( - id = R.string.select_an_option + val context = LocalContext.current + val selectAnOption = + stringResource( + id = R.string.select_an_option, ) - var currentText by remember(placeholderCode, options) { - mutableStateOf( - options.firstOrNull { it.code == placeholderCode }?.name?.name(context) ?: selectAnOption - ) + var currentText by + remember(placeholderCode, options) { + mutableStateOf( + options.firstOrNull { it.code == placeholderCode }?.name?.name(context) ?: selectAnOption, + ) } + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(modifier = Size20Modifier) + Text(currentText) + Icon( + imageVector = Icons.Default.ExpandMore, + null, + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.placeholderText, + ) + } Box( - modifier = modifier, - contentAlignment = Alignment.Center - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Spacer(modifier = Size20Modifier) - Text(currentText) - Icon( - imageVector = Icons.Default.ExpandMore, - null, - modifier = Size20Modifier, - tint = MaterialTheme.colorScheme.placeholderText - ) - } - Box( - modifier = Modifier - .matchParentSize() - .clickable( - interactionSource = interactionSource, - indication = null - ) { - optionsShowing = true - } - ) - } + modifier = + Modifier.matchParentSize().clickable( + interactionSource = interactionSource, + indication = null, + ) { + optionsShowing = true + }, + ) + } - if (optionsShowing) { - options.isNotEmpty().also { - SpinnerSelectionDialog( - options = options, - onDismiss = { optionsShowing = false }, - onSelect = { - currentText = options[it].name.name(context) - optionsShowing = false - onSelect(it) - } - ) { - RenderOption(it.name) - } - } + if (optionsShowing) { + options.isNotEmpty().also { + SpinnerSelectionDialog( + options = options, + onDismiss = { optionsShowing = false }, + onSelect = { + currentText = options[it].name.name(context) + optionsShowing = false + onSelect(it) + }, + ) { + RenderOption(it.name) + } } + } } @Composable fun RenderOption(option: Name) { - when (option) { - is GeoHashName -> { - val geohash = runCatching { option.geoHashTag.toGeoHash() }.getOrNull() - if (geohash != null) { - LoadCityName(geohash) { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = "/g/$it", color = MaterialTheme.colorScheme.onSurface) - } - } - } - } - is HashtagName -> { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = option.name(), color = MaterialTheme.colorScheme.onSurface) - } - } - is ResourceName -> { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = stringResource(id = option.resourceId), color = MaterialTheme.colorScheme.onSurface) - } - } - is PeopleListName -> { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = option.name(), color = MaterialTheme.colorScheme.onSurface) - } - } - is CommunityName -> { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() - ) { - val name by option.note.live().metadata.map { - "/n/" + ((it.note as? AddressableNote)?.dTag() ?: "") - }.observeAsState() - - Text(text = name ?: "", color = MaterialTheme.colorScheme.onSurface) - } + when (option) { + is GeoHashName -> { + val geohash = runCatching { option.geoHashTag.toGeoHash() }.getOrNull() + if (geohash != null) { + LoadCityName(geohash) { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = "/g/$it", color = MaterialTheme.colorScheme.onSurface) + } } + } } + is HashtagName -> { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = option.name(), color = MaterialTheme.colorScheme.onSurface) + } + } + is ResourceName -> { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(id = option.resourceId), + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + is PeopleListName -> { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = option.name(), color = MaterialTheme.colorScheme.onSurface) + } + } + is CommunityName -> { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth(), + ) { + val name by + option.note + .live() + .metadata + .map { "/n/" + ((it.note as? AddressableNote)?.dTag() ?: "") } + .observeAsState() + + Text(text = name ?: "", color = MaterialTheme.colorScheme.onSurface) + } + } + } } @OptIn(ExperimentalMaterial3Api::class) @Composable -fun TopBarWithBackButton(caption: String, popBack: () -> Unit) { - Column(modifier = BottomTopHeight) { - TopAppBar( - title = { Text(caption) }, - navigationIcon = { - IconButton( - onClick = popBack, - modifier = Modifier - ) { - ArrowBackIcon() - } - }, - actions = {} - ) - Divider(thickness = DividerThickness) - } +fun TopBarWithBackButton( + caption: String, + popBack: () -> Unit, +) { + Column(modifier = BottomTopHeight) { + TopAppBar( + title = { Text(caption) }, + navigationIcon = { + IconButton( + onClick = popBack, + modifier = Modifier, + ) { + ArrowBackIcon() + } + }, + actions = {}, + ) + Divider(thickness = DividerThickness) + } } @Composable fun FlexibleTopBarWithBackButton( - title: @Composable RowScope.() -> Unit, - extendableRow: (@Composable () -> Unit)? = null, - popBack: () -> Unit + title: @Composable RowScope.() -> Unit, + extendableRow: (@Composable () -> Unit)? = null, + popBack: () -> Unit, ) { - Column() { - MyExtensibleTopAppBar( - title = title, - extendableRow = extendableRow, - navigationIcon = { - IconButton(onClick = popBack) { - ArrowBackIcon() - } - }, - actions = {} - ) - Spacer(modifier = HalfVertSpacer) - Divider(thickness = DividerThickness) - } + Column { + MyExtensibleTopAppBar( + title = title, + extendableRow = extendableRow, + navigationIcon = { IconButton(onClick = popBack) { ArrowBackIcon() } }, + actions = {}, + ) + Spacer(modifier = HalfVertSpacer) + Divider(thickness = DividerThickness) + } } @Composable fun AmethystClickableIcon() { - val context = LocalContext.current + val context = LocalContext.current - IconButton( - onClick = { - debugState(context) - } - ) { - AmethystIcon(Size40dp) - } + IconButton( + onClick = { debugState(context) }, + ) { + AmethystIcon(Size40dp) + } } fun debugState(context: Context) { - Client.allSubscriptions().map { - "$it ${ - Client.getSubscriptionFilters(it) - .joinToString { it.filter.toJson() } - }" - }.forEach { - Log.d("STATE DUMP", it) + Client.allSubscriptions() + .map { + "$it ${ + Client.getSubscriptionFilters(it) + .joinToString { it.filter.toJson() } + }" } + .forEach { Log.d("STATE DUMP", it) } - NostrAccountDataSource.printCounter() - NostrChannelDataSource.printCounter() - NostrChatroomDataSource.printCounter() - NostrChatroomListDataSource.printCounter() - NostrCommunityDataSource.printCounter() - NostrDiscoveryDataSource.printCounter() - NostrHashtagDataSource.printCounter() - NostrGeohashDataSource.printCounter() - NostrHomeDataSource.printCounter() - NostrSearchEventOrUserDataSource.printCounter() - NostrSingleChannelDataSource.printCounter() - NostrSingleEventDataSource.printCounter() - NostrSingleUserDataSource.printCounter() - NostrThreadDataSource.printCounter() - NostrUserProfileDataSource.printCounter() - NostrVideoDataSource.printCounter() + NostrAccountDataSource.printCounter() + NostrChannelDataSource.printCounter() + NostrChatroomDataSource.printCounter() + NostrChatroomListDataSource.printCounter() + NostrCommunityDataSource.printCounter() + NostrDiscoveryDataSource.printCounter() + NostrHashtagDataSource.printCounter() + NostrGeohashDataSource.printCounter() + NostrHomeDataSource.printCounter() + NostrSearchEventOrUserDataSource.printCounter() + NostrSingleChannelDataSource.printCounter() + NostrSingleEventDataSource.printCounter() + NostrSingleUserDataSource.printCounter() + NostrThreadDataSource.printCounter() + NostrUserProfileDataSource.printCounter() + NostrVideoDataSource.printCounter() - val totalMemoryKb = Runtime.getRuntime().totalMemory() / (1024 * 1024) - val freeMemoryKb = Runtime.getRuntime().freeMemory() / (1024 * 1024) + val totalMemoryKb = Runtime.getRuntime().totalMemory() / (1024 * 1024) + val freeMemoryKb = Runtime.getRuntime().freeMemory() / (1024 * 1024) - val jvmHeapAllocatedKb = totalMemoryKb - freeMemoryKb + val jvmHeapAllocatedKb = totalMemoryKb - freeMemoryKb - Log.d("STATE DUMP", "Total Heap Allocated: " + jvmHeapAllocatedKb + " MB") + Log.d("STATE DUMP", "Total Heap Allocated: " + jvmHeapAllocatedKb + " MB") - val nativeHeap = Debug.getNativeHeapAllocatedSize() / (1024 * 1024) + val nativeHeap = Debug.getNativeHeapAllocatedSize() / (1024 * 1024) - Log.d("STATE DUMP", "Total Native Heap Allocated: " + nativeHeap + " MB") + Log.d("STATE DUMP", "Total Native Heap Allocated: " + nativeHeap + " MB") - Log.d("STATE DUMP", "Connected Relays: " + RelayPool.connectedRelays()) + Log.d("STATE DUMP", "Connected Relays: " + RelayPool.connectedRelays()) - val imageLoader = Coil.imageLoader(context) - Log.d("STATE DUMP", "Image Disk Cache ${(imageLoader.diskCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.diskCache?.maxSize ?: 0) / (1024 * 1024)} MB") - Log.d("STATE DUMP", "Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.memoryCache?.maxSize ?: 0) / (1024 * 1024)} MB") + val imageLoader = Coil.imageLoader(context) + Log.d( + "STATE DUMP", + "Image Disk Cache ${(imageLoader.diskCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.diskCache?.maxSize ?: 0) / (1024 * 1024)} MB", + ) + Log.d( + "STATE DUMP", + "Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.memoryCache?.maxSize ?: 0) / (1024 * 1024)} MB", + ) - Log.d("STATE DUMP", "Notes: " + LocalCache.notes.filter { it.value.liveSet != null }.size + " / " + LocalCache.notes.filter { it.value.event != null }.size + " / " + LocalCache.notes.size) - Log.d("STATE DUMP", "Addressables: " + LocalCache.addressables.filter { it.value.liveSet != null }.size + " / " + LocalCache.addressables.filter { it.value.event != null }.size + " / " + LocalCache.addressables.size) - Log.d("STATE DUMP", "Users: " + LocalCache.users.filter { it.value.liveSet != null }.size + " / " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + " / " + LocalCache.users.size) + Log.d( + "STATE DUMP", + "Notes: " + + LocalCache.notes.filter { it.value.liveSet != null }.size + + " / " + + LocalCache.notes.filter { it.value.event != null }.size + + " / " + + LocalCache.notes.size, + ) + Log.d( + "STATE DUMP", + "Addressables: " + + LocalCache.addressables.filter { it.value.liveSet != null }.size + + " / " + + LocalCache.addressables.filter { it.value.event != null }.size + + " / " + + LocalCache.addressables.size, + ) + Log.d( + "STATE DUMP", + "Users: " + + LocalCache.users.filter { it.value.liveSet != null }.size + + " / " + + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + + " / " + + LocalCache.users.size, + ) - Log.d("STATE DUMP", "Memory used by Events: " + LocalCache.notes.values.sumOf { it.event?.countMemory() ?: 0 } / (1024 * 1024) + " MB") + Log.d( + "STATE DUMP", + "Memory used by Events: " + + LocalCache.notes.values.sumOf { it.event?.countMemory() ?: 0 } / (1024 * 1024) + + " MB", + ) - LocalCache.notes.values.groupBy { it.event?.kind() }.forEach { - Log.d("STATE DUMP", "Kind ${it.key}: \t${it.value.size} elements ") - } - LocalCache.addressables.values.groupBy { it.event?.kind() }.forEach { - Log.d("STATE DUMP", "Kind ${it.key}: \t${it.value.size} elements ") - } + LocalCache.notes.values + .groupBy { it.event?.kind() } + .forEach { Log.d("STATE DUMP", "Kind ${it.key}: \t${it.value.size} elements ") } + LocalCache.addressables.values + .groupBy { it.event?.kind() } + .forEach { Log.d("STATE DUMP", "Kind ${it.key}: \t${it.value.size} elements ") } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun MyExtensibleTopAppBar( - title: @Composable RowScope.() -> Unit, - extendableRow: (@Composable () -> Unit)? = null, - modifier: Modifier = Modifier, - navigationIcon: @Composable (() -> Unit)? = null, - actions: @Composable RowScope.() -> Unit = {} + title: @Composable RowScope.() -> Unit, + extendableRow: (@Composable () -> Unit)? = null, + modifier: Modifier = Modifier, + navigationIcon: @Composable (() -> Unit)? = null, + actions: @Composable RowScope.() -> Unit = {}, ) { - val expanded = remember { mutableStateOf(false) } + val expanded = remember { mutableStateOf(false) } - Column( - Modifier.clickable { - expanded.value = !expanded.value - } - ) { - Row(modifier = BottomTopHeight) { - TopAppBar( - title = { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - title() - } - }, - modifier = modifier, - navigationIcon = { - if (navigationIcon == null) { - Spacer(TitleInsetWithoutIcon) - } else { - Row(TitleIconModifier, verticalAlignment = Alignment.CenterVertically) { - navigationIcon() - } - } - }, - actions = actions - ) - } - - if (expanded.value && extendableRow != null) { - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically - ) { - Column { - extendableRow() - } + Column( + Modifier.clickable { expanded.value = !expanded.value }, + ) { + Row(modifier = BottomTopHeight) { + TopAppBar( + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + title() + } + }, + modifier = modifier, + navigationIcon = { + if (navigationIcon == null) { + Spacer(TitleInsetWithoutIcon) + } else { + Row(TitleIconModifier, verticalAlignment = Alignment.CenterVertically) { + navigationIcon() } - } + } + }, + actions = actions, + ) } + + if (expanded.value && extendableRow != null) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { extendableRow() } + } + } + } } private val AppBarHeight = 50.dp diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index 732c06d87..2aeb2edae 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.navigation import androidx.compose.foundation.ExperimentalFoundationApi @@ -94,687 +114,677 @@ import kotlinx.coroutines.launch @Composable fun DrawerContent( - nav: (String) -> Unit, - drawerState: DrawerState, - openSheet: () -> Unit, - accountViewModel: AccountViewModel + nav: (String) -> Unit, + drawerState: DrawerState, + openSheet: () -> Unit, + accountViewModel: AccountViewModel, ) { - val coroutineScope = rememberCoroutineScope() - val onClickUser = { - nav("User/${accountViewModel.userProfile().pubkeyHex}") - coroutineScope.launch { - drawerState.close() - } - Unit - } - - val automaticallyShowProfilePicture = remember { - accountViewModel.settings.showProfilePictures.value - } - - ModalDrawerSheet( - drawerContainerColor = MaterialTheme.colorScheme.background, - drawerTonalElevation = 0.dp - ) { - Column() { - ProfileContent( - baseAccountUser = accountViewModel.account.userProfile(), - modifier = profileContentHeaderModifier, - accountViewModel, - onClickUser - ) - - Column(drawerSpacing) { - EditStatusBoxes(accountViewModel.account.userProfile(), accountViewModel) - } - - FollowingAndFollowerCounts(accountViewModel.account.userProfile(), onClickUser) - - Divider( - thickness = DividerThickness, - modifier = Modifier.padding(top = 20.dp) - ) - - ListContent( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - drawerState, - openSheet, - accountViewModel, - nav - ) - - BottomContent( - accountViewModel.account.userProfile(), - drawerState, - automaticallyShowProfilePicture, - nav - ) - } + val coroutineScope = rememberCoroutineScope() + val onClickUser = { + nav("User/${accountViewModel.userProfile().pubkeyHex}") + coroutineScope.launch { drawerState.close() } + Unit + } + + val automaticallyShowProfilePicture = remember { + accountViewModel.settings.showProfilePictures.value + } + + ModalDrawerSheet( + drawerContainerColor = MaterialTheme.colorScheme.background, + drawerTonalElevation = 0.dp, + ) { + Column { + ProfileContent( + baseAccountUser = accountViewModel.account.userProfile(), + modifier = profileContentHeaderModifier, + accountViewModel, + onClickUser, + ) + + Column(drawerSpacing) { + EditStatusBoxes(accountViewModel.account.userProfile(), accountViewModel) + } + + FollowingAndFollowerCounts(accountViewModel.account.userProfile(), onClickUser) + + Divider( + thickness = DividerThickness, + modifier = Modifier.padding(top = 20.dp), + ) + + ListContent( + modifier = Modifier.fillMaxWidth().weight(1f), + drawerState, + openSheet, + accountViewModel, + nav, + ) + + BottomContent( + accountViewModel.account.userProfile(), + drawerState, + automaticallyShowProfilePicture, + nav, + ) } + } } @Composable fun ProfileContent( - baseAccountUser: User, - modifier: Modifier = Modifier, - accountViewModel: AccountViewModel, - onClickUser: () -> Unit + baseAccountUser: User, + modifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + onClickUser: () -> Unit, ) { - val userInfo by baseAccountUser.live().userMetadataInfo.observeAsState() + val userInfo by baseAccountUser.live().userMetadataInfo.observeAsState() - ProfileContentTemplate( - profilePubHex = baseAccountUser.pubkeyHex, - profileBanner = userInfo?.banner, - profilePicture = userInfo?.profilePicture(), - bestDisplayName = userInfo?.bestDisplayName(), - tags = userInfo?.tags, - modifier = modifier, - accountViewModel = accountViewModel, - onClick = onClickUser - ) + ProfileContentTemplate( + profilePubHex = baseAccountUser.pubkeyHex, + profileBanner = userInfo?.banner, + profilePicture = userInfo?.profilePicture(), + bestDisplayName = userInfo?.bestDisplayName(), + tags = userInfo?.tags, + modifier = modifier, + accountViewModel = accountViewModel, + onClick = onClickUser, + ) } @Composable fun ProfileContentTemplate( - profilePubHex: HexKey, - profileBanner: String?, - profilePicture: String?, - bestDisplayName: String?, - tags: ImmutableListOfLists?, - modifier: Modifier, - accountViewModel: AccountViewModel, - onClick: () -> Unit + profilePubHex: HexKey, + profileBanner: String?, + profilePicture: String?, + bestDisplayName: String?, + tags: ImmutableListOfLists?, + modifier: Modifier, + accountViewModel: AccountViewModel, + onClick: () -> Unit, ) { - Box { - if (profileBanner != null) { - AsyncImage( - model = profileBanner, - contentDescription = stringResource(id = R.string.profile_image), - contentScale = ContentScale.FillWidth, - modifier = bannerModifier - ) - } else { - Image( - painter = painterResource(R.drawable.profile_banner), - contentDescription = stringResource(R.string.profile_banner), - contentScale = ContentScale.FillWidth, - modifier = bannerModifier - ) - } - - Column(modifier = modifier) { - RobohashFallbackAsyncImage( - robot = profilePubHex, - model = profilePicture, - contentDescription = stringResource(id = R.string.profile_image), - modifier = Modifier - .width(100.dp) - .height(100.dp) - .clip(shape = CircleShape) - .border(3.dp, MaterialTheme.colorScheme.background, CircleShape) - .background(MaterialTheme.colorScheme.background) - .clickable(onClick = onClick), - loadProfilePicture = accountViewModel.settings.showProfilePictures.value - ) - - if (bestDisplayName != null) { - CreateTextWithEmoji( - text = bestDisplayName, - tags = tags, - modifier = Modifier - .padding(top = 7.dp) - .clickable(onClick = onClick), - fontWeight = FontWeight.Bold, - fontSize = 18.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } + Box { + if (profileBanner != null) { + AsyncImage( + model = profileBanner, + contentDescription = stringResource(id = R.string.profile_image), + contentScale = ContentScale.FillWidth, + modifier = bannerModifier, + ) + } else { + Image( + painter = painterResource(R.drawable.profile_banner), + contentDescription = stringResource(R.string.profile_banner), + contentScale = ContentScale.FillWidth, + modifier = bannerModifier, + ) } + + Column(modifier = modifier) { + RobohashFallbackAsyncImage( + robot = profilePubHex, + model = profilePicture, + contentDescription = stringResource(id = R.string.profile_image), + modifier = + Modifier.width(100.dp) + .height(100.dp) + .clip(shape = CircleShape) + .border(3.dp, MaterialTheme.colorScheme.background, CircleShape) + .background(MaterialTheme.colorScheme.background) + .clickable(onClick = onClick), + loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + ) + + if (bestDisplayName != null) { + CreateTextWithEmoji( + text = bestDisplayName, + tags = tags, + modifier = Modifier.padding(top = 7.dp).clickable(onClick = onClick), + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } } @Composable -private fun EditStatusBoxes(baseAccountUser: User, accountViewModel: AccountViewModel) { - LoadStatuses(user = baseAccountUser, accountViewModel) { statuses -> - if (statuses.isEmpty()) { - StatusEditBar(accountViewModel = accountViewModel) - } else { - statuses.forEach { - val originalStatus by it.live().content.observeAsState() +private fun EditStatusBoxes( + baseAccountUser: User, + accountViewModel: AccountViewModel, +) { + LoadStatuses(user = baseAccountUser, accountViewModel) { statuses -> + if (statuses.isEmpty()) { + StatusEditBar(accountViewModel = accountViewModel) + } else { + statuses.forEach { + val originalStatus by it.live().content.observeAsState() - StatusEditBar(originalStatus, it.address, accountViewModel) - } - } + StatusEditBar(originalStatus, it.address, accountViewModel) + } } + } } @Composable fun StatusEditBar( - savedStatus: String? = null, - tag: ATag? = null, - accountViewModel: AccountViewModel + savedStatus: String? = null, + tag: ATag? = null, + accountViewModel: AccountViewModel, ) { - val focusManager = LocalFocusManager.current + val focusManager = LocalFocusManager.current - val currentStatus = remember { - mutableStateOf(savedStatus ?: "") - } - val hasChanged = remember { - derivedStateOf { - currentStatus.value != (savedStatus ?: "") - } - } + val currentStatus = remember { mutableStateOf(savedStatus ?: "") } + val hasChanged = remember { derivedStateOf { currentStatus.value != (savedStatus ?: "") } } - OutlinedTextField( - value = currentStatus.value, - onValueChange = { currentStatus.value = it }, - label = { Text(text = stringResource(R.string.status_update)) }, - modifier = Modifier.fillMaxWidth(), - placeholder = { - Text( - text = stringResource(R.string.status_update), - color = MaterialTheme.colorScheme.placeholderText - ) + OutlinedTextField( + value = currentStatus.value, + onValueChange = { currentStatus.value = it }, + label = { Text(text = stringResource(R.string.status_update)) }, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = stringResource(R.string.status_update), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + imeAction = ImeAction.Send, + capitalization = KeyboardCapitalization.Sentences, + ), + keyboardActions = + KeyboardActions( + onSend = { + if (tag == null) { + accountViewModel.createStatus(currentStatus.value) + } else { + accountViewModel.updateStatus(tag, currentStatus.value) + } + + focusManager.clearFocus(true) }, - keyboardOptions = KeyboardOptions.Default.copy( - imeAction = ImeAction.Send, - capitalization = KeyboardCapitalization.Sentences - ), - keyboardActions = KeyboardActions( - onSend = { - if (tag == null) { - accountViewModel.createStatus(currentStatus.value) - } else { - accountViewModel.updateStatus(tag, currentStatus.value) - } - - focusManager.clearFocus(true) - } - ), - singleLine = true, - trailingIcon = { - if (hasChanged.value) { - SendButton { - if (tag == null) { - accountViewModel.createStatus(currentStatus.value) - } else { - accountViewModel.updateStatus(tag, currentStatus.value) - } - focusManager.clearFocus(true) - } - } else { - if (tag != null) { - UserStatusDeleteButton { - accountViewModel.deleteStatus(tag) - focusManager.clearFocus(true) - } - } - } + ), + singleLine = true, + trailingIcon = { + if (hasChanged.value) { + SendButton { + if (tag == null) { + accountViewModel.createStatus(currentStatus.value) + } else { + accountViewModel.updateStatus(tag, currentStatus.value) + } + focusManager.clearFocus(true) } - ) + } else { + if (tag != null) { + UserStatusDeleteButton { + accountViewModel.deleteStatus(tag) + focusManager.clearFocus(true) + } + } + } + }, + ) } @Composable fun SendButton(onClick: () -> Unit) { - IconButton( - modifier = Size26Modifier, - onClick = onClick - ) { - Icon( - imageVector = Icons.Default.Send, - null, - modifier = Size20Modifier, - tint = MaterialTheme.colorScheme.placeholderText - ) - } + IconButton( + modifier = Size26Modifier, + onClick = onClick, + ) { + Icon( + imageVector = Icons.Default.Send, + null, + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.placeholderText, + ) + } } @Composable fun UserStatusDeleteButton(onClick: () -> Unit) { - IconButton( - modifier = Size26Modifier, - onClick = onClick - ) { - Icon( - imageVector = Icons.Default.Delete, - null, - modifier = Size20Modifier, - tint = MaterialTheme.colorScheme.placeholderText - ) - } + IconButton( + modifier = Size26Modifier, + onClick = onClick, + ) { + Icon( + imageVector = Icons.Default.Delete, + null, + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.placeholderText, + ) + } } @Composable -private fun FollowingAndFollowerCounts(baseAccountUser: User, onClick: () -> Unit) { - var followingCount by remember { mutableStateOf("--") } - var followerCount by remember { mutableStateOf("--") } +private fun FollowingAndFollowerCounts( + baseAccountUser: User, + onClick: () -> Unit, +) { + var followingCount by remember { mutableStateOf("--") } + var followerCount by remember { mutableStateOf("--") } - WatchFollow(baseAccountUser = baseAccountUser) { newFollowing -> - if (followingCount != newFollowing) { - followingCount = newFollowing - } + WatchFollow(baseAccountUser = baseAccountUser) { newFollowing -> + if (followingCount != newFollowing) { + followingCount = newFollowing } + } - WatchFollower(baseAccountUser = baseAccountUser) { newFollower -> - if (followerCount != newFollower) { - followerCount = newFollower - } + WatchFollower(baseAccountUser = baseAccountUser) { newFollower -> + if (followerCount != newFollower) { + followerCount = newFollower } + } - Row( - modifier = drawerSpacing.clickable(onClick = onClick) - ) { - Text( - text = followingCount, - fontWeight = FontWeight.Bold - ) + Row( + modifier = drawerSpacing.clickable(onClick = onClick), + ) { + Text( + text = followingCount, + fontWeight = FontWeight.Bold, + ) - Text(stringResource(R.string.following)) + Text(stringResource(R.string.following)) - Spacer(modifier = DoubleHorzSpacer) + Spacer(modifier = DoubleHorzSpacer) - Text( - text = followerCount, - fontWeight = FontWeight.Bold - ) + Text( + text = followerCount, + fontWeight = FontWeight.Bold, + ) - Text(stringResource(R.string.followers)) - } + Text(stringResource(R.string.followers)) + } } @Composable -fun WatchFollow(baseAccountUser: User, onReady: (String) -> Unit) { - val accountUserFollowsState by baseAccountUser.live().follows.observeAsState() +fun WatchFollow( + baseAccountUser: User, + onReady: (String) -> Unit, +) { + val accountUserFollowsState by baseAccountUser.live().follows.observeAsState() - LaunchedEffect(key1 = accountUserFollowsState) { - launch(Dispatchers.IO) { - onReady(accountUserFollowsState?.user?.cachedFollowCount()?.toString() ?: "--") - } + LaunchedEffect(key1 = accountUserFollowsState) { + launch(Dispatchers.IO) { + onReady(accountUserFollowsState?.user?.cachedFollowCount()?.toString() ?: "--") } + } } @Composable -fun WatchFollower(baseAccountUser: User, onReady: (String) -> Unit) { - val accountUserFollowersState by baseAccountUser.live().followers.observeAsState() +fun WatchFollower( + baseAccountUser: User, + onReady: (String) -> Unit, +) { + val accountUserFollowersState by baseAccountUser.live().followers.observeAsState() - LaunchedEffect(key1 = accountUserFollowersState) { - launch(Dispatchers.IO) { - onReady(accountUserFollowersState?.user?.cachedFollowerCount()?.toString() ?: "--") - } + LaunchedEffect(key1 = accountUserFollowersState) { + launch(Dispatchers.IO) { + onReady(accountUserFollowersState?.user?.cachedFollowerCount()?.toString() ?: "--") } + } } @Composable fun ListContent( - modifier: Modifier, - drawerState: DrawerState, - openSheet: () -> Unit, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + modifier: Modifier, + drawerState: DrawerState, + openSheet: () -> Unit, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val route = remember(accountViewModel) { - "User/${accountViewModel.userProfile().pubkeyHex}" + val route = remember(accountViewModel) { "User/${accountViewModel.userProfile().pubkeyHex}" } + + val coroutineScope = rememberCoroutineScope() + var wantsToEditRelays by remember { mutableStateOf(false) } + + var backupDialogOpen by remember { mutableStateOf(false) } + var checked by remember { mutableStateOf(accountViewModel.account.proxy != null) } + var disconnectTorDialog by remember { mutableStateOf(false) } + var conectOrbotDialogOpen by remember { mutableStateOf(false) } + val proxyPort = remember { mutableStateOf(accountViewModel.account.proxyPort.toString()) } + val context = LocalContext.current + + Column( + modifier = modifier.fillMaxHeight().verticalScroll(rememberScrollState()), + ) { + NavigationRow( + title = stringResource(R.string.profile), + icon = Route.Profile.icon, + tint = MaterialTheme.colorScheme.primary, + nav = nav, + drawerState = drawerState, + route = route, + ) + + NavigationRow( + title = stringResource(R.string.bookmarks), + icon = Route.Bookmarks.icon, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + drawerState = drawerState, + route = Route.Bookmarks.route, + ) + + IconRowRelays( + accountViewModel = accountViewModel, + onClick = { + coroutineScope.launch { drawerState.close() } + wantsToEditRelays = true + }, + ) + + NavigationRow( + title = stringResource(R.string.security_filters), + icon = Route.BlockedUsers.icon, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + drawerState = drawerState, + route = Route.BlockedUsers.route, + ) + + accountViewModel.account.keyPair.privKey?.let { + IconRow( + title = stringResource(R.string.backup_keys), + icon = R.drawable.ic_key, + tint = MaterialTheme.colorScheme.onBackground, + onClick = { + coroutineScope.launch { drawerState.close() } + backupDialogOpen = true + }, + ) } - val coroutineScope = rememberCoroutineScope() - var wantsToEditRelays by remember { mutableStateOf(false) } - - var backupDialogOpen by remember { mutableStateOf(false) } - var checked by remember { mutableStateOf(accountViewModel.account.proxy != null) } - var disconnectTorDialog by remember { mutableStateOf(false) } - var conectOrbotDialogOpen by remember { mutableStateOf(false) } - val proxyPort = remember { mutableStateOf(accountViewModel.account.proxyPort.toString()) } - val context = LocalContext.current - - Column( - modifier = modifier - .fillMaxHeight() - .verticalScroll(rememberScrollState()) - ) { - NavigationRow( - title = stringResource(R.string.profile), - icon = Route.Profile.icon, - tint = MaterialTheme.colorScheme.primary, - nav = nav, - drawerState = drawerState, - route = route - ) - - NavigationRow( - title = stringResource(R.string.bookmarks), - icon = Route.Bookmarks.icon, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - drawerState = drawerState, - route = Route.Bookmarks.route - ) - - IconRowRelays( - accountViewModel = accountViewModel, - onClick = { - coroutineScope.launch { - drawerState.close() - } - wantsToEditRelays = true - } - ) - - NavigationRow( - title = stringResource(R.string.security_filters), - icon = Route.BlockedUsers.icon, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - drawerState = drawerState, - route = Route.BlockedUsers.route - ) - - accountViewModel.account.keyPair.privKey?.let { - IconRow( - title = stringResource(R.string.backup_keys), - icon = R.drawable.ic_key, - tint = MaterialTheme.colorScheme.onBackground, - onClick = { - coroutineScope.launch { - drawerState.close() - } - backupDialogOpen = true - } - ) + IconRow( + title = + if (checked) { + stringResource(R.string.disconnect_from_your_orbot_setup) + } else { + stringResource(R.string.connect_via_tor_short) + }, + icon = R.drawable.ic_tor, + tint = MaterialTheme.colorScheme.onBackground, + onLongClick = { + coroutineScope.launch { drawerState.close() } + conectOrbotDialogOpen = true + }, + onClick = { + if (checked) { + disconnectTorDialog = true + } else { + coroutineScope.launch { drawerState.close() } + conectOrbotDialogOpen = true } + }, + ) - IconRow( - title = if (checked) stringResource(R.string.disconnect_from_your_orbot_setup) else stringResource(R.string.connect_via_tor_short), - icon = R.drawable.ic_tor, - tint = MaterialTheme.colorScheme.onBackground, - onLongClick = { - coroutineScope.launch { - drawerState.close() - } - conectOrbotDialogOpen = true - }, - onClick = { - if (checked) { - disconnectTorDialog = true - } else { - coroutineScope.launch { - drawerState.close() - } - conectOrbotDialogOpen = true - } - } + NavigationRow( + title = stringResource(R.string.settings), + icon = Route.Settings.icon, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + drawerState = drawerState, + route = Route.Settings.route, + ) + + Spacer(modifier = Modifier.weight(1f)) + + IconRow( + title = stringResource(R.string.drawer_accounts), + icon = R.drawable.manage_accounts, + tint = MaterialTheme.colorScheme.onBackground, + onClick = openSheet, + ) + } + + if (wantsToEditRelays) { + NewRelayListView({ wantsToEditRelays = false }, accountViewModel, nav = nav) + } + if (backupDialogOpen) { + AccountBackupDialog(accountViewModel, onClose = { backupDialogOpen = false }) + } + if (conectOrbotDialogOpen) { + ConnectOrbotDialog( + onClose = { conectOrbotDialogOpen = false }, + onPost = { + conectOrbotDialogOpen = false + disconnectTorDialog = false + checked = true + accountViewModel.enableTor(true, proxyPort) + }, + onError = { + accountViewModel.toast( + context.getString(R.string.could_not_connect_to_tor), + it, ) + }, + proxyPort, + ) + } - NavigationRow( - title = stringResource(R.string.settings), - icon = Route.Settings.icon, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - drawerState = drawerState, - route = Route.Settings.route - ) - - Spacer(modifier = Modifier.weight(1f)) - - IconRow( - title = stringResource(R.string.drawer_accounts), - icon = R.drawable.manage_accounts, - tint = MaterialTheme.colorScheme.onBackground, - onClick = openSheet - ) - } - - if (wantsToEditRelays) { - NewRelayListView({ wantsToEditRelays = false }, accountViewModel, nav = nav) - } - if (backupDialogOpen) { - AccountBackupDialog(accountViewModel, onClose = { backupDialogOpen = false }) - } - if (conectOrbotDialogOpen) { - ConnectOrbotDialog( - onClose = { conectOrbotDialogOpen = false }, - onPost = { - conectOrbotDialogOpen = false - disconnectTorDialog = false - checked = true - accountViewModel.enableTor(true, proxyPort) - }, - onError = { - accountViewModel.toast( - context.getString(R.string.could_not_connect_to_tor), - it - ) - }, - proxyPort - ) - } - - if (disconnectTorDialog) { - AlertDialog( - title = { - Text(text = stringResource(R.string.do_you_really_want_to_disable_tor_title)) - }, - text = { - Text(text = stringResource(R.string.do_you_really_want_to_disable_tor_text)) - }, - onDismissRequest = { - disconnectTorDialog = false - }, - confirmButton = { - TextButton( - onClick = { - disconnectTorDialog = false - checked = false - accountViewModel.enableTor(false, proxyPort) - } - ) { - Text(text = stringResource(R.string.yes)) - } - }, - dismissButton = { - TextButton( - onClick = { - disconnectTorDialog = false - } - ) { - Text(text = stringResource(R.string.no)) - } - } - ) - } + if (disconnectTorDialog) { + AlertDialog( + title = { Text(text = stringResource(R.string.do_you_really_want_to_disable_tor_title)) }, + text = { Text(text = stringResource(R.string.do_you_really_want_to_disable_tor_text)) }, + onDismissRequest = { disconnectTorDialog = false }, + confirmButton = { + TextButton( + onClick = { + disconnectTorDialog = false + checked = false + accountViewModel.enableTor(false, proxyPort) + }, + ) { + Text(text = stringResource(R.string.yes)) + } + }, + dismissButton = { + TextButton( + onClick = { disconnectTorDialog = false }, + ) { + Text(text = stringResource(R.string.no)) + } + }, + ) + } } @Composable private fun RelayStatus(accountViewModel: AccountViewModel) { - val connectedRelaysText by RelayPool.statusFlow.collectAsStateWithLifecycle(RelayPoolStatus(0, 0)) + val connectedRelaysText by RelayPool.statusFlow.collectAsStateWithLifecycle(RelayPoolStatus(0, 0)) - RenderRelayStatus(connectedRelaysText) + RenderRelayStatus(connectedRelaysText) } @Composable -private fun RenderRelayStatus( - relayPool: RelayPoolStatus -) { - val text by remember(relayPool) { - derivedStateOf { - "${relayPool.connected}/${relayPool.available}" +private fun RenderRelayStatus(relayPool: RelayPoolStatus) { + val text by + remember(relayPool) { derivedStateOf { "${relayPool.connected}/${relayPool.available}" } } + + val placeHolder = MaterialTheme.colorScheme.placeholderText + + val color by + remember(relayPool) { + derivedStateOf { + if (relayPool.isConnected) { + placeHolder + } else { + Color.Red } + } } - val placeHolder = MaterialTheme.colorScheme.placeholderText - - val color by remember(relayPool) { - derivedStateOf { - if (relayPool.isConnected) { - placeHolder - } else { - Color.Red - } - } - } - - Text( - text = text, - color = color, - style = MaterialTheme.typography.titleMedium - ) + Text( + text = text, + color = color, + style = MaterialTheme.typography.titleMedium, + ) } @Composable fun NavigationRow( - title: String, - icon: Int, - tint: Color, - nav: (String) -> Unit, - drawerState: DrawerState, - route: String + title: String, + icon: Int, + tint: Color, + nav: (String) -> Unit, + drawerState: DrawerState, + route: String, ) { - val coroutineScope = rememberCoroutineScope() - IconRow(title, icon, tint, onClick = { - nav(route) - coroutineScope.launch { - drawerState.close() - } - }) + val coroutineScope = rememberCoroutineScope() + IconRow( + title, + icon, + tint, + onClick = { + nav(route) + coroutineScope.launch { drawerState.close() } + }, + ) } @OptIn(ExperimentalFoundationApi::class) @Composable -fun IconRow(title: String, icon: Int, tint: Color, onClick: () -> Unit, onLongClick: (() -> Unit)? = null) { +fun IconRow( + title: String, + icon: Int, + tint: Color, + onClick: () -> Unit, + onLongClick: (() -> Unit)? = null, +) { + Row( + modifier = + Modifier.fillMaxWidth() + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + ), + ) { Row( - modifier = Modifier - .fillMaxWidth() - .combinedClickable( - onClick = onClick, - onLongClick = onLongClick - ) + modifier = IconRowModifier, + verticalAlignment = Alignment.CenterVertically, ) { - Row( - modifier = IconRowModifier, - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - painter = painterResource(icon), - null, - modifier = Size22Modifier, - tint = tint - ) - Text( - modifier = IconRowTextModifier, - text = title, - fontSize = Font18SP - ) - } + Icon( + painter = painterResource(icon), + null, + modifier = Size22Modifier, + tint = tint, + ) + Text( + modifier = IconRowTextModifier, + text = title, + fontSize = Font18SP, + ) } + } } @Composable -fun IconRowRelays(accountViewModel: AccountViewModel, onClick: () -> Unit) { +fun IconRowRelays( + accountViewModel: AccountViewModel, + onClick: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().clickable { onClick() }, + ) { Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onClick() } + modifier = Modifier.fillMaxWidth().padding(vertical = 15.dp, horizontal = 25.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 15.dp, horizontal = 25.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - painter = painterResource(R.drawable.relays), - null, - modifier = Modifier.size(22.dp), - tint = MaterialTheme.colorScheme.onSurface - ) + Icon( + painter = painterResource(R.drawable.relays), + null, + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) - Text( - modifier = Modifier.padding(start = 16.dp), - text = stringResource(id = R.string.relay_setup), - fontSize = 18.sp - ) + Text( + modifier = Modifier.padding(start = 16.dp), + text = stringResource(id = R.string.relay_setup), + fontSize = 18.sp, + ) - Spacer(modifier = Modifier.width(Size16dp)) + Spacer(modifier = Modifier.width(Size16dp)) - RelayStatus(accountViewModel = accountViewModel) - } + RelayStatus(accountViewModel = accountViewModel) } + } } @Composable -fun BottomContent(user: User, drawerState: DrawerState, loadProfilePicture: Boolean, nav: (String) -> Unit) { - val coroutineScope = rememberCoroutineScope() +fun BottomContent( + user: User, + drawerState: DrawerState, + loadProfilePicture: Boolean, + nav: (String) -> Unit, +) { + val coroutineScope = rememberCoroutineScope() - // store the dialog open or close state - var dialogOpen by remember { - mutableStateOf(false) - } + // store the dialog open or close state + var dialogOpen by remember { mutableStateOf(false) } - Column(modifier = Modifier) { - Divider( - modifier = Modifier.padding(top = 15.dp), - thickness = DividerThickness + Column(modifier = Modifier) { + Divider( + modifier = Modifier.padding(top = 15.dp), + thickness = DividerThickness, + ) + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 15.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.padding(start = 16.dp), + text = "v" + BuildConfig.VERSION_NAME + "-" + BuildConfig.FLAVOR.uppercase(), + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + ) + /* + IconButton( + onClick = { + when (AppCompatDelegate.getDefaultNightMode()) { + AppCompatDelegate.MODE_NIGHT_NO -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) + AppCompatDelegate.MODE_NIGHT_YES -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) + else -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) + } + } + ) { + Icon( + painter = painterResource(R.drawable.ic_theme), + null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + }*/ + Box(modifier = Modifier.weight(1F)) + IconButton( + onClick = { + dialogOpen = true + coroutineScope.launch { drawerState.close() } + }, + ) { + Icon( + painter = painterResource(R.drawable.ic_qrcode), + null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 15.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - modifier = Modifier.padding(start = 16.dp), - text = "v" + BuildConfig.VERSION_NAME + "-" + BuildConfig.FLAVOR.uppercase(), - fontSize = 12.sp, - fontWeight = FontWeight.Bold - ) - /* - IconButton( - onClick = { - when (AppCompatDelegate.getDefaultNightMode()) { - AppCompatDelegate.MODE_NIGHT_NO -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) - AppCompatDelegate.MODE_NIGHT_YES -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) - else -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) - } - } - ) { - Icon( - painter = painterResource(R.drawable.ic_theme), - null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary - ) - }*/ - Box(modifier = Modifier.weight(1F)) - IconButton(onClick = { - dialogOpen = true - coroutineScope.launch { - drawerState.close() - } - }) { - Icon( - painter = painterResource(R.drawable.ic_qrcode), - null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary - ) - } - } + } } + } - if (dialogOpen) { - ShowQRDialog( - user, - loadProfilePicture = loadProfilePicture, - onScan = { - dialogOpen = false - coroutineScope.launch { - drawerState.close() - } - nav(it) - }, - onClose = { dialogOpen = false } - ) - } + if (dialogOpen) { + ShowQRDialog( + user, + loadProfilePicture = loadProfilePicture, + onScan = { + dialogOpen = false + coroutineScope.launch { drawerState.close() } + nav(it) + }, + onClose = { dialogOpen = false }, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt index bdbc76490..081be1ba9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.navigation import com.vitorpamplona.amethyst.model.Channel @@ -13,55 +33,70 @@ import com.vitorpamplona.quartz.events.ChatroomKeyable import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent -import kotlinx.collections.immutable.persistentSetOf import java.net.URLEncoder +import kotlinx.collections.immutable.persistentSetOf -fun routeFor(note: Note, loggedIn: User): String? { - val noteEvent = note.event +fun routeFor( + note: Note, + loggedIn: User, +): String? { + val noteEvent = note.event - if (noteEvent is ChannelMessageEvent || noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) { - note.channelHex()?.let { - return "Channel/$it" - } - } else if (noteEvent is LiveActivitiesEvent || noteEvent is LiveActivitiesChatMessageEvent) { - note.channelHex()?.let { - return "Channel/${URLEncoder.encode(it, "utf-8")}" - } - } else if (noteEvent is ChatroomKeyable) { - val room = noteEvent.chatroomKey(loggedIn.pubkeyHex) - loggedIn.createChatroom(room) - return "Room/${room.hashCode()}" - } else if (noteEvent is CommunityDefinitionEvent) { - return "Community/${URLEncoder.encode(note.idHex, "utf-8")}" - } else { - return "Note/${URLEncoder.encode(note.idHex, "utf-8")}" + if ( + noteEvent is ChannelMessageEvent || + noteEvent is ChannelCreateEvent || + noteEvent is ChannelMetadataEvent + ) { + note.channelHex()?.let { + return "Channel/$it" } + } else if (noteEvent is LiveActivitiesEvent || noteEvent is LiveActivitiesChatMessageEvent) { + note.channelHex()?.let { + return "Channel/${URLEncoder.encode(it, "utf-8")}" + } + } else if (noteEvent is ChatroomKeyable) { + val room = noteEvent.chatroomKey(loggedIn.pubkeyHex) + loggedIn.createChatroom(room) + return "Room/${room.hashCode()}" + } else if (noteEvent is CommunityDefinitionEvent) { + return "Community/${URLEncoder.encode(note.idHex, "utf-8")}" + } else { + return "Note/${URLEncoder.encode(note.idHex, "utf-8")}" + } - return null + return null } -fun routeToMessage(user: HexKey, draftMessage: String?, accountViewModel: AccountViewModel): String { - val withKey = ChatroomKey(persistentSetOf(user)) - accountViewModel.account.userProfile().createChatroom(withKey) - return if (draftMessage != null) { - "Room/${withKey.hashCode()}?message=$draftMessage" - } else { - "Room/${withKey.hashCode()}" - } +fun routeToMessage( + user: HexKey, + draftMessage: String?, + accountViewModel: AccountViewModel, +): String { + val withKey = ChatroomKey(persistentSetOf(user)) + accountViewModel.account.userProfile().createChatroom(withKey) + return if (draftMessage != null) { + "Room/${withKey.hashCode()}?message=$draftMessage" + } else { + "Room/${withKey.hashCode()}" + } } -fun routeToMessage(user: User, draftMessage: String?, accountViewModel: AccountViewModel): String { - return routeToMessage(user.pubkeyHex, draftMessage, accountViewModel) +fun routeToMessage( + user: User, + draftMessage: String?, + accountViewModel: AccountViewModel, +): String { + return routeToMessage(user.pubkeyHex, draftMessage, accountViewModel) } fun routeFor(note: Channel): String { - return "Channel/${note.idHex}" + return "Channel/${note.idHex}" } fun routeFor(user: User): String { - return "User/${user.pubkeyHex}" + return "User/${user.pubkeyHex}" } fun authorRouteFor(note: Note): String { - return "User/${note.author?.pubkeyHex}" + return "User/${note.author?.pubkeyHex}" } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index c1cd32531..c9c0e01ea 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.navigation import android.os.Bundle @@ -35,338 +55,400 @@ import kotlinx.collections.immutable.toImmutableList @Immutable sealed class Route( - val route: String, - val base: String = route.substringBefore("?"), - val icon: Int, - val notifSize: Modifier = Modifier.size(Size23dp), - val iconSize: Modifier = Modifier.size(Size20dp), - val hasNewItems: (Account, Set) -> Boolean = { _, _ -> false }, - val arguments: ImmutableList = persistentListOf() + val route: String, + val base: String = route.substringBefore("?"), + val icon: Int, + val notifSize: Modifier = Modifier.size(Size23dp), + val iconSize: Modifier = Modifier.size(Size20dp), + val hasNewItems: (Account, Set) -> Boolean = { _, _ -> + false + }, + val arguments: ImmutableList = persistentListOf(), ) { - object Home : Route( - route = "Home?nip47={nip47}", - icon = R.drawable.ic_home, - notifSize = Modifier.size(Size25dp), - iconSize = Modifier.size(Size24dp), - arguments = listOf( - navArgument("nip47") { type = NavType.StringType; nullable = true; defaultValue = null } - ).toImmutableList(), - hasNewItems = { accountViewModel, newNotes -> HomeLatestItem.hasNewItems(accountViewModel, newNotes) } + object Home : + Route( + route = "Home?nip47={nip47}", + icon = R.drawable.ic_home, + notifSize = Modifier.size(Size25dp), + iconSize = Modifier.size(Size24dp), + arguments = + listOf( + navArgument("nip47") { + type = NavType.StringType + nullable = true + defaultValue = null + }, + ) + .toImmutableList(), + hasNewItems = { accountViewModel, newNotes -> + HomeLatestItem.hasNewItems(accountViewModel, newNotes) + }, ) - object Global : Route( - route = "Global", - icon = R.drawable.ic_globe + object Global : + Route( + route = "Global", + icon = R.drawable.ic_globe, ) - object Search : Route( - route = "Search", - icon = R.drawable.ic_search + object Search : + Route( + route = "Search", + icon = R.drawable.ic_search, ) - object Video : Route( - route = "Video", - icon = R.drawable.ic_video + object Video : + Route( + route = "Video", + icon = R.drawable.ic_video, ) - object Discover : Route( - route = "Discover", - icon = R.drawable.ic_sensors, - hasNewItems = { accountViewModel, newNotes -> DiscoverLatestItem.hasNewItems(accountViewModel, newNotes) } + object Discover : + Route( + route = "Discover", + icon = R.drawable.ic_sensors, + hasNewItems = { accountViewModel, newNotes -> + DiscoverLatestItem.hasNewItems(accountViewModel, newNotes) + }, ) - object Notification : Route( - route = "Notification", - icon = R.drawable.ic_notifications, - hasNewItems = { accountViewModel, newNotes -> NotificationLatestItem.hasNewItems(accountViewModel, newNotes) } + object Notification : + Route( + route = "Notification", + icon = R.drawable.ic_notifications, + hasNewItems = { accountViewModel, newNotes -> + NotificationLatestItem.hasNewItems(accountViewModel, newNotes) + }, ) - object Message : Route( - route = "Message", - icon = R.drawable.ic_dm, - hasNewItems = { accountViewModel, newNotes -> MessagesLatestItem.hasNewItems(accountViewModel, newNotes) } + object Message : + Route( + route = "Message", + icon = R.drawable.ic_dm, + hasNewItems = { accountViewModel, newNotes -> + MessagesLatestItem.hasNewItems(accountViewModel, newNotes) + }, ) - object BlockedUsers : Route( - route = "BlockedUsers", - icon = R.drawable.ic_security + object BlockedUsers : + Route( + route = "BlockedUsers", + icon = R.drawable.ic_security, ) - object Bookmarks : Route( - route = "Bookmarks", - icon = R.drawable.ic_bookmarks + object Bookmarks : + Route( + route = "Bookmarks", + icon = R.drawable.ic_bookmarks, ) - object Profile : Route( - route = "User/{id}", - icon = R.drawable.ic_profile, - arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList() + object Profile : + Route( + route = "User/{id}", + icon = R.drawable.ic_profile, + arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), ) - object Note : Route( - route = "Note/{id}", - icon = R.drawable.ic_moments, - arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList() + object Note : + Route( + route = "Note/{id}", + icon = R.drawable.ic_moments, + arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), ) - object Hashtag : Route( - route = "Hashtag/{id}", - icon = R.drawable.ic_moments, - arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList() + object Hashtag : + Route( + route = "Hashtag/{id}", + icon = R.drawable.ic_moments, + arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), ) - object Geohash : Route( - route = "Geohash/{id}", - icon = R.drawable.ic_moments, - arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList() + object Geohash : + Route( + route = "Geohash/{id}", + icon = R.drawable.ic_moments, + arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), ) - object Community : Route( - route = "Community/{id}", - icon = R.drawable.ic_moments, - arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList() + object Community : + Route( + route = "Community/{id}", + icon = R.drawable.ic_moments, + arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), ) - object Room : Route( - route = "Room/{id}?message={message}", - icon = R.drawable.ic_moments, - arguments = listOf( + object Room : + Route( + route = "Room/{id}?message={message}", + icon = R.drawable.ic_moments, + arguments = + listOf( navArgument("id") { type = NavType.StringType }, - navArgument("message") { type = NavType.StringType; nullable = true; defaultValue = null } - ).toImmutableList() + navArgument("message") { + type = NavType.StringType + nullable = true + defaultValue = null + }, + ) + .toImmutableList(), ) - object RoomByAuthor : Route( - route = "RoomByAuthor/{id}", - icon = R.drawable.ic_moments, - arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList() + object RoomByAuthor : + Route( + route = "RoomByAuthor/{id}", + icon = R.drawable.ic_moments, + arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), ) - object Channel : Route( - route = "Channel/{id}", - icon = R.drawable.ic_moments, - arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList() + object Channel : + Route( + route = "Channel/{id}", + icon = R.drawable.ic_moments, + arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), ) - object Event : Route( - route = "Event/{id}", - icon = R.drawable.ic_moments, - arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList() + object Event : + Route( + route = "Event/{id}", + icon = R.drawable.ic_moments, + arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), ) - object Settings : Route( - route = "Settings", - icon = R.drawable.ic_settings + object Settings : + Route( + route = "Settings", + icon = R.drawable.ic_settings, ) - companion object { - val InvertedLayouts = setOf( - Channel.route, - Room.route, - RoomByAuthor.route - ) - } + companion object { + val InvertedLayouts = + setOf( + Channel.route, + Room.route, + RoomByAuthor.route, + ) + } } // ** -// * Functions below only exist because we have not broken the datasource classes into backend and frontend. +// * Functions below only exist because we have not broken the datasource classes into backend and +// frontend. // ** @Composable fun currentRoute(navController: NavHostController): String? { - val navBackStackEntry by navController.currentBackStackEntryAsState() - return navBackStackEntry?.destination?.route + val navBackStackEntry by navController.currentBackStackEntryAsState() + return navBackStackEntry?.destination?.route } open class LatestItem { - var newestItemPerAccount: Map = mapOf() + var newestItemPerAccount: Map = mapOf() - fun getNewestItem(account: Account): Note? { - return newestItemPerAccount[account.userProfile().pubkeyHex] + fun getNewestItem(account: Account): Note? { + return newestItemPerAccount[account.userProfile().pubkeyHex] + } + + fun clearNewestItem(account: Account) { + val userHex = account.userProfile().pubkeyHex + if (newestItemPerAccount.contains(userHex)) { + newestItemPerAccount = newestItemPerAccount - userHex + } + } + + fun updateNewestItem( + newNotes: Set, + account: Account, + filter: AdditiveFeedFilter, + ): Note? { + val newestItem = newestItemPerAccount[account.userProfile().pubkeyHex] + + // Block list got updated + if (newestItem == null || !account.isAcceptable(newestItem)) { + newestItemPerAccount = + newestItemPerAccount + + Pair( + account.userProfile().pubkeyHex, + filterMore(filter.feed(), account).firstOrNull { it.createdAt() != null }, + ) + } else { + newestItemPerAccount = + newestItemPerAccount + + Pair( + account.userProfile().pubkeyHex, + filter.sort(filterMore(filter.applyFilter(newNotes), account) + newestItem).first(), + ) } - fun clearNewestItem(account: Account) { - val userHex = account.userProfile().pubkeyHex - if (newestItemPerAccount.contains(userHex)) { - newestItemPerAccount = newestItemPerAccount - userHex - } - } + return newestItemPerAccount[account.userProfile().pubkeyHex] + } - fun updateNewestItem(newNotes: Set, account: Account, filter: AdditiveFeedFilter): Note? { - val newestItem = newestItemPerAccount[account.userProfile().pubkeyHex] + open fun filterMore( + newItems: Set, + account: Account, + ): Set { + return newItems + } - // Block list got updated - if (newestItem == null || !account.isAcceptable(newestItem)) { - newestItemPerAccount = newestItemPerAccount + Pair( - account.userProfile().pubkeyHex, - filterMore(filter.feed(), account).firstOrNull { it.createdAt() != null } - ) - } else { - newestItemPerAccount = newestItemPerAccount + Pair( - account.userProfile().pubkeyHex, - filter.sort(filterMore(filter.applyFilter(newNotes), account) + newestItem).first() - ) - } - - return newestItemPerAccount[account.userProfile().pubkeyHex] - } - - open fun filterMore(newItems: Set, account: Account): Set { - return newItems - } - - open fun filterMore(newItems: List, account: Account): List { - return newItems - } + open fun filterMore( + newItems: List, + account: Account, + ): List { + return newItems + } } object HomeLatestItem : LatestItem() { - fun hasNewItems( - account: Account, - newNotes: Set - ): Boolean { - checkNotInMainThread() + fun hasNewItems( + account: Account, + newNotes: Set, + ): Boolean { + checkNotInMainThread() - val lastTime = account.loadLastRead("HomeFollows") + val lastTime = account.loadLastRead("HomeFollows") - val newestItem = updateNewestItem(newNotes, account, HomeNewThreadFeedFilter(account)) + val newestItem = updateNewestItem(newNotes, account, HomeNewThreadFeedFilter(account)) - return (newestItem?.createdAt() ?: 0) > lastTime - } + return (newestItem?.createdAt() ?: 0) > lastTime + } } object DiscoverLatestItem : LatestItem() { - fun hasNewItems( - account: Account, - newNotes: Set - ): Boolean { - checkNotInMainThread() + fun hasNewItems( + account: Account, + newNotes: Set, + ): Boolean { + checkNotInMainThread() - val lastTime = account.loadLastRead(Route.Discover.base + "Live") + val lastTime = account.loadLastRead(Route.Discover.base + "Live") - val newestItem = updateNewestItem(newNotes, account, DiscoverLiveNowFeedFilter(account)) + val newestItem = updateNewestItem(newNotes, account, DiscoverLiveNowFeedFilter(account)) - val noteEvent = newestItem?.event + val noteEvent = newestItem?.event - val dateToUse = if (noteEvent is LiveActivitiesEvent) { - noteEvent.starts() ?: newestItem.createdAt() - } else { - newestItem?.createdAt() - } + val dateToUse = + if (noteEvent is LiveActivitiesEvent) { + noteEvent.starts() ?: newestItem.createdAt() + } else { + newestItem?.createdAt() + } - return (dateToUse ?: 0) > lastTime - } + return (dateToUse ?: 0) > lastTime + } } object NotificationLatestItem : LatestItem() { - fun hasNewItems( - account: Account, - newNotes: Set - ): Boolean { - checkNotInMainThread() + fun hasNewItems( + account: Account, + newNotes: Set, + ): Boolean { + checkNotInMainThread() - val lastTime = account.loadLastRead("Notification") + val lastTime = account.loadLastRead("Notification") - val newestItem = updateNewestItem(newNotes, account, NotificationFeedFilter(account)) + val newestItem = updateNewestItem(newNotes, account, NotificationFeedFilter(account)) - return (newestItem?.createdAt() ?: 0) > lastTime - } + return (newestItem?.createdAt() ?: 0) > lastTime + } } object MessagesLatestItem : LatestItem() { - fun hasNewItems( - account: Account, - newNotes: Set - ): Boolean { - checkNotInMainThread() + fun hasNewItems( + account: Account, + newNotes: Set, + ): Boolean { + checkNotInMainThread() - // Checks if the current newest item is still unread. - // If so, there is no need to check anything else - if (isNew(getNewestItem(account), account)) { - return true - } - - clearNewestItem(account) - - // gets the newest of the unread items - val newestItem = updateNewestItem(newNotes, account, ChatroomListKnownFeedFilter(account)) - - return isNew(newestItem, account) + // Checks if the current newest item is still unread. + // If so, there is no need to check anything else + if (isNew(getNewestItem(account), account)) { + return true } - fun isNew(it: Note?, account: Account): Boolean { - if (it == null) return false + clearNewestItem(account) - val currentUser = account.userProfile().pubkeyHex - val room = (it.event as? ChatroomKeyable)?.chatroomKey(currentUser) - return if (room != null) { - val lastRead = account.loadLastRead("Room/${room.hashCode()}") - (it.createdAt() ?: 0) > lastRead - } else { - false - } - } + // gets the newest of the unread items + val newestItem = updateNewestItem(newNotes, account, ChatroomListKnownFeedFilter(account)) - override fun filterMore(newItems: Set, account: Account): Set { - return newItems.filter { - isNew(it, account) - }.toSet() - } + return isNew(newestItem, account) + } - override fun filterMore(newItems: List, account: Account): List { - return newItems.filter { - isNew(it, account) - } + fun isNew( + it: Note?, + account: Account, + ): Boolean { + if (it == null) return false + + val currentUser = account.userProfile().pubkeyHex + val room = (it.event as? ChatroomKeyable)?.chatroomKey(currentUser) + return if (room != null) { + val lastRead = account.loadLastRead("Room/${room.hashCode()}") + (it.createdAt() ?: 0) > lastRead + } else { + false } + } + + override fun filterMore( + newItems: Set, + account: Account, + ): Set { + return newItems.filter { isNew(it, account) }.toSet() + } + + override fun filterMore( + newItems: List, + account: Account, + ): List { + return newItems.filter { isNew(it, account) } + } } fun getRouteWithArguments(navController: NavHostController): String? { - val currentEntry = navController.currentBackStackEntry ?: return null - return getRouteWithArguments(currentEntry.destination, currentEntry.arguments) + val currentEntry = navController.currentBackStackEntry ?: return null + return getRouteWithArguments(currentEntry.destination, currentEntry.arguments) } fun getRouteWithArguments(navState: State): String? { - return navState.value?.let { - getRouteWithArguments(it.destination, it.arguments) - } + return navState.value?.let { getRouteWithArguments(it.destination, it.arguments) } } private fun getRouteWithArguments( - destination: NavDestination, - arguments: Bundle? + destination: NavDestination, + arguments: Bundle?, ): String? { - var route = destination.route ?: return null - arguments?.let { bundle -> - destination.arguments.forEach { - val key = it.key - val value = it.value.type[bundle, key]?.toString() - if (value == null) { - val keyStart = route.indexOf("{$key}") - // if it is a parameter, removes the complete segment `var={key}` and adjust connectors `#`, `&` or `&` - if (keyStart > 0 && route[keyStart - 1] == '=') { - val end = keyStart + "{$key}".length - var start = keyStart - for (i in keyStart downTo 0) { - if (route[i] == '#' || route[i] == '?' || route[i] == '&') { - start = i + 1 - break - } - } - if (end < route.length && route[end] == '&') { - route = route.removeRange(start, end + 1) - } else if (end < route.length && route[end] == '#') { - route = route.removeRange(start - 1, end) - } else if (end == route.length) { - route = route.removeRange(start - 1, end) - } else { - route = route.removeRange(start, end) - } - } else { - route = route.replaceFirst("{$key}", "") - } - } else { - route = route.replaceFirst("{$key}", value) + var route = destination.route ?: return null + arguments?.let { bundle -> + destination.arguments.forEach { + val key = it.key + val value = it.value.type[bundle, key]?.toString() + if (value == null) { + val keyStart = route.indexOf("{$key}") + // if it is a parameter, removes the complete segment `var={key}` and adjust connectors `#`, + // `&` or `&` + if (keyStart > 0 && route[keyStart - 1] == '=') { + val end = keyStart + "{$key}".length + var start = keyStart + for (i in keyStart downTo 0) { + if (route[i] == '#' || route[i] == '?' || route[i] == '&') { + start = i + 1 + break } + } + if (end < route.length && route[end] == '&') { + route = route.removeRange(start, end + 1) + } else if (end < route.length && route[end] == '#') { + route = route.removeRange(start - 1, end) + } else if (end == route.length) { + route = route.removeRange(start - 1, end) + } else { + route = route.removeRange(start, end) + } + } else { + route = route.replaceFirst("{$key}", "") } + } else { + route = route.replaceFirst("{$key}", value) + } } - return route + } + return route } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt index 142b6933c..2733aad93 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.note import androidx.compose.foundation.ExperimentalFoundationApi @@ -43,130 +63,131 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable -fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForLastRead: String, showHidden: Boolean = false, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val noteState by likeSetCard.note.live().metadata.observeAsState() - val note = noteState?.note +fun BadgeCompose( + likeSetCard: BadgeCard, + isInnerNote: Boolean = false, + routeForLastRead: String, + showHidden: Boolean = false, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteState by likeSetCard.note.live().metadata.observeAsState() + val note = noteState?.note - val context = LocalContext.current.applicationContext + val context = LocalContext.current.applicationContext - val popupExpanded = remember { mutableStateOf(false) } - val enablePopup = remember { - { popupExpanded.value = true } + val popupExpanded = remember { mutableStateOf(false) } + val enablePopup = remember { { popupExpanded.value = true } } + + val scope = rememberCoroutineScope() + + if (note == null) { + BlankNote(Modifier, isInnerNote) + } else { + val defaultBackgroundColor = MaterialTheme.colorScheme.background + val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } + val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor + + LaunchedEffect(key1 = likeSetCard) { + accountViewModel.loadAndMarkAsRead(routeForLastRead, likeSetCard.createdAt()) { isNew -> + val newBackgroundColor = + if (isNew) { + newItemColor.compositeOver(defaultBackgroundColor) + } else { + defaultBackgroundColor + } + + if (backgroundColor.value != newBackgroundColor) { + backgroundColor.value = newBackgroundColor + } + } } - val scope = rememberCoroutineScope() - - if (note == null) { - BlankNote(Modifier, isInnerNote) - } else { - val defaultBackgroundColor = MaterialTheme.colorScheme.background - val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } - val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor - - LaunchedEffect(key1 = likeSetCard) { - accountViewModel.loadAndMarkAsRead(routeForLastRead, likeSetCard.createdAt()) { isNew -> - val newBackgroundColor = if (isNew) { - newItemColor.compositeOver(defaultBackgroundColor) - } else { - defaultBackgroundColor - } - - if (backgroundColor.value != newBackgroundColor) { - backgroundColor.value = newBackgroundColor - } - } + Column( + modifier = + Modifier.background(backgroundColor.value) + .combinedClickable( + onClick = { + scope.launch { + routeFor( + note, + accountViewModel.userProfile(), + ) + ?.let { nav(it) } + } + }, + onLongClick = enablePopup, + ), + ) { + Row( + modifier = + Modifier.padding( + start = if (!isInnerNote) 12.dp else 0.dp, + end = if (!isInnerNote) 12.dp else 0.dp, + top = 10.dp, + ), + ) { + // Draws the like picture outside the boosted card. + if (!isInnerNote) { + Box( + modifier = Modifier.width(55.dp).padding(0.dp), + ) { + Icon( + imageVector = Icons.Default.MilitaryTech, + null, + modifier = Modifier.size(25.dp).align(Alignment.TopEnd), + tint = MaterialTheme.colorScheme.primary, + ) + } } - Column( - modifier = Modifier - .background(backgroundColor.value) - .combinedClickable( - onClick = { - scope.launch { - routeFor( - note, - accountViewModel.userProfile() - )?.let { nav(it) } - } - }, - onLongClick = enablePopup - ) - ) { - Row( - modifier = Modifier - .padding( - start = if (!isInnerNote) 12.dp else 0.dp, - end = if (!isInnerNote) 12.dp else 0.dp, - top = 10.dp - ) + Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) { + Row { + Text( + stringResource(R.string.new_badge_award_notif), + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 5.dp).weight(1f), + ) + + Text( + timeAgo(note.createdAt(), context = context), + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) + + IconButton( + modifier = Modifier.then(Modifier.size(24.dp)), + onClick = enablePopup, ) { - // Draws the like picture outside the boosted card. - if (!isInnerNote) { - Box( - modifier = Modifier - .width(55.dp) - .padding(0.dp) - ) { - Icon( - imageVector = Icons.Default.MilitaryTech, - null, - modifier = Modifier - .size(25.dp) - .align(Alignment.TopEnd), - tint = MaterialTheme.colorScheme.primary - ) - } - } + Icon( + imageVector = Icons.Default.MoreVert, + null, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colorScheme.placeholderText, + ) - Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) { - Row() { - Text( - stringResource(R.string.new_badge_award_notif), - fontWeight = FontWeight.Bold, - modifier = Modifier - .padding(bottom = 5.dp) - .weight(1f) - ) - - Text( - timeAgo(note.createdAt(), context = context), - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1 - ) - - IconButton( - modifier = Modifier.then(Modifier.size(24.dp)), - onClick = enablePopup - ) { - Icon( - imageVector = Icons.Default.MoreVert, - null, - modifier = Modifier.size(15.dp), - tint = MaterialTheme.colorScheme.placeholderText - ) - - NoteDropDownMenu(note, popupExpanded, accountViewModel) - } - } - - note.replyTo?.firstOrNull()?.let { - NoteCompose( - baseNote = it, - routeForLastRead = null, - isBoostedNote = true, - showHidden = showHidden, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav - ) - } - - Divider( - modifier = Modifier.padding(top = 10.dp), - thickness = DividerThickness - ) - } + NoteDropDownMenu(note, popupExpanded, accountViewModel) } + } + + note.replyTo?.firstOrNull()?.let { + NoteCompose( + baseNote = it, + routeForLastRead = null, + isBoostedNote = true, + showHidden = showHidden, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = DividerThickness, + ) } + } } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt index 511e73047..d63529350 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.note import androidx.compose.foundation.layout.Arrangement @@ -27,99 +47,104 @@ import com.vitorpamplona.amethyst.ui.theme.Size35dp import kotlinx.collections.immutable.ImmutableSet @Composable -fun BlankNote(modifier: Modifier = Modifier, showDivider: Boolean = false, idHex: String? = null) { - Column(modifier = modifier) { - Row() { - Column() { - Row( - modifier = Modifier.padding( - start = 20.dp, - end = 20.dp, - bottom = 8.dp, - top = 15.dp - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - Text( - text = stringResource(R.string.post_not_found) + if (idHex != null) ": $idHex" else "", - modifier = Modifier.padding(30.dp), - color = Color.Gray - ) - } - - if (!showDivider) { - Divider( - modifier = Modifier.padding(vertical = 10.dp), - thickness = DividerThickness - ) - } - } +fun BlankNote( + modifier: Modifier = Modifier, + showDivider: Boolean = false, + idHex: String? = null, +) { + Column(modifier = modifier) { + Row { + Column { + Row( + modifier = + Modifier.padding( + start = 20.dp, + end = 20.dp, + bottom = 8.dp, + top = 15.dp, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.post_not_found) + if (idHex != null) ": $idHex" else "", + modifier = Modifier.padding(30.dp), + color = Color.Gray, + ) } + + if (!showDivider) { + Divider( + modifier = Modifier.padding(vertical = 10.dp), + thickness = DividerThickness, + ) + } + } } + } } @OptIn(ExperimentalLayoutApi::class) @Composable fun HiddenNote( - reports: ImmutableSet, - isHiddenAuthor: Boolean, - accountViewModel: AccountViewModel, - modifier: Modifier = Modifier, - isQuote: Boolean = false, - nav: (String) -> Unit, - onClick: () -> Unit + reports: ImmutableSet, + isHiddenAuthor: Boolean, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, + isQuote: Boolean = false, + nav: (String) -> Unit, + onClick: () -> Unit, ) { - Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { - Row( - modifier = Modifier.padding(start = if (!isQuote) 30.dp else 25.dp, end = 20.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(30.dp) - ) { - Text( - text = stringResource(R.string.post_was_flagged_as_inappropriate_by), - color = Color.Gray - ) - FlowRow(modifier = Modifier.padding(top = 10.dp)) { - if (isHiddenAuthor) { - UserPicture( - user = accountViewModel.userProfile(), - size = Size35dp, - nav = nav, - accountViewModel = accountViewModel - ) - } - reports.forEach { - NoteAuthorPicture( - baseNote = it, - size = Size35dp, - nav = nav, - accountViewModel = accountViewModel - ) - } - } - - Button( - modifier = Modifier.padding(top = 10.dp), - onClick = onClick, - shape = ButtonBorder, - colors = ButtonDefaults - .buttonColors( - contentColor = MaterialTheme.colorScheme.primary - ), - contentPadding = ButtonPadding - ) { - Text(text = stringResource(R.string.show_anyway), color = Color.White) - } - } + Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { + Row( + modifier = Modifier.padding(start = if (!isQuote) 30.dp else 25.dp, end = 20.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(30.dp), + ) { + Text( + text = stringResource(R.string.post_was_flagged_as_inappropriate_by), + color = Color.Gray, + ) + FlowRow(modifier = Modifier.padding(top = 10.dp)) { + if (isHiddenAuthor) { + UserPicture( + user = accountViewModel.userProfile(), + size = Size35dp, + nav = nav, + accountViewModel = accountViewModel, + ) + } + reports.forEach { + NoteAuthorPicture( + baseNote = it, + size = Size35dp, + nav = nav, + accountViewModel = accountViewModel, + ) + } } - Divider( - thickness = DividerThickness - ) + Button( + modifier = Modifier.padding(top = 10.dp), + onClick = onClick, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + contentColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Text(text = stringResource(R.string.show_anyway), color = Color.White) + } + } } + + Divider( + thickness = DividerThickness, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt index 53aa8d2d5..3ed3c0782 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.note import androidx.compose.animation.Crossfade @@ -91,887 +111,933 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable fun ChannelCardCompose( - baseNote: Note, - routeForLastRead: String? = null, - modifier: Modifier = Modifier, - parentBackgroundColor: MutableState? = null, - forceEventKind: Int?, - showHidden: Boolean = false, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + parentBackgroundColor: MutableState? = null, + forceEventKind: Int?, + showHidden: Boolean = false, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null) + val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null) - Crossfade(targetState = hasEvent, label = "ChannelCardCompose") { - if (it) { - if (forceEventKind == null || baseNote.event?.kind() == forceEventKind) { - CheckHiddenChannelCardCompose( - baseNote, - routeForLastRead, - modifier, - parentBackgroundColor, - showHidden, - accountViewModel, - nav - ) - } - } else { - LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> - BlankNote( - remember { - modifier.combinedClickable( - onClick = { }, - onLongClick = showPopup - ) - }, - false - ) - } - } + Crossfade(targetState = hasEvent, label = "ChannelCardCompose") { + if (it) { + if (forceEventKind == null || baseNote.event?.kind() == forceEventKind) { + CheckHiddenChannelCardCompose( + baseNote, + routeForLastRead, + modifier, + parentBackgroundColor, + showHidden, + accountViewModel, + nav, + ) + } + } else { + LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup, + -> + BlankNote( + remember { + modifier.combinedClickable( + onClick = {}, + onLongClick = showPopup, + ) + }, + false, + ) + } } + } } @Composable fun CheckHiddenChannelCardCompose( - note: Note, - routeForLastRead: String? = null, - modifier: Modifier = Modifier, - parentBackgroundColor: MutableState? = null, - showHidden: Boolean, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + note: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + parentBackgroundColor: MutableState? = null, + showHidden: Boolean, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - if (showHidden) { - val state by remember { - mutableStateOf( - AccountViewModel.NoteComposeReportState() - ) - } - - RenderChannelCardReportState( - state = state, - note = note, - routeForLastRead = routeForLastRead, - modifier = modifier, - parentBackgroundColor = parentBackgroundColor, - accountViewModel = accountViewModel, - nav = nav - ) - } else { - val isHidden by accountViewModel.account.liveHiddenUsers.map { - note.isHiddenFor(it) - }.distinctUntilChanged().observeAsState(accountViewModel.isNoteHidden(note)) - - Crossfade(targetState = isHidden, label = "CheckHiddenChannelCardCompose") { - if (!it) { - LoadedChannelCardCompose( - note, - routeForLastRead, - modifier, - parentBackgroundColor, - accountViewModel, - nav - ) - } - } + if (showHidden) { + val state by remember { + mutableStateOf( + AccountViewModel.NoteComposeReportState(), + ) } + + RenderChannelCardReportState( + state = state, + note = note, + routeForLastRead = routeForLastRead, + modifier = modifier, + parentBackgroundColor = parentBackgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } else { + val isHidden by + accountViewModel.account.liveHiddenUsers + .map { note.isHiddenFor(it) } + .distinctUntilChanged() + .observeAsState(accountViewModel.isNoteHidden(note)) + + Crossfade(targetState = isHidden, label = "CheckHiddenChannelCardCompose") { + if (!it) { + LoadedChannelCardCompose( + note, + routeForLastRead, + modifier, + parentBackgroundColor, + accountViewModel, + nav, + ) + } + } + } } @Composable fun LoadedChannelCardCompose( - note: Note, - routeForLastRead: String? = null, - modifier: Modifier = Modifier, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + note: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var state by remember { - mutableStateOf( - AccountViewModel.NoteComposeReportState() - ) - } + var state by remember { + mutableStateOf( + AccountViewModel.NoteComposeReportState(), + ) + } - val scope = rememberCoroutineScope() + val scope = rememberCoroutineScope() - WatchForReports(note, accountViewModel) { newState -> - if (state != newState) { - scope.launch(Dispatchers.Main) { - state = newState - } - } + WatchForReports(note, accountViewModel) { newState -> + if (state != newState) { + scope.launch(Dispatchers.Main) { state = newState } } + } - Crossfade(targetState = state, label = "CheckHiddenChannelCardCompose") { - RenderChannelCardReportState( - it, - note, - routeForLastRead, - modifier, - parentBackgroundColor, - accountViewModel, - nav - ) - } + Crossfade(targetState = state, label = "CheckHiddenChannelCardCompose") { + RenderChannelCardReportState( + it, + note, + routeForLastRead, + modifier, + parentBackgroundColor, + accountViewModel, + nav, + ) + } } @Composable fun RenderChannelCardReportState( - state: AccountViewModel.NoteComposeReportState, - note: Note, - routeForLastRead: String? = null, - modifier: Modifier = Modifier, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + state: AccountViewModel.NoteComposeReportState, + note: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var showReportedNote by remember { mutableStateOf(false) } + var showReportedNote by remember { mutableStateOf(false) } - Crossfade(targetState = !state.isAcceptable && !showReportedNote, label = "CheckHiddenChannelCardCompose") { showHiddenNote -> - if (showHiddenNote) { - HiddenNote( - state.relevantReports, - state.isHiddenAuthor, - accountViewModel, - modifier, - false, - nav, - onClick = { showReportedNote = true } - ) - } else { - NormalChannelCard( - note, - routeForLastRead, - modifier, - parentBackgroundColor, - accountViewModel, - nav - ) - } + Crossfade( + targetState = !state.isAcceptable && !showReportedNote, + label = "CheckHiddenChannelCardCompose", + ) { showHiddenNote -> + if (showHiddenNote) { + HiddenNote( + state.relevantReports, + state.isHiddenAuthor, + accountViewModel, + modifier, + false, + nav, + onClick = { showReportedNote = true }, + ) + } else { + NormalChannelCard( + note, + routeForLastRead, + modifier, + parentBackgroundColor, + accountViewModel, + nav, + ) } + } } @Composable fun NormalChannelCard( - baseNote: Note, - routeForLastRead: String? = null, - modifier: Modifier = Modifier, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> - CheckNewAndRenderChannelCard( - baseNote, - routeForLastRead, - modifier, - parentBackgroundColor, - accountViewModel, - showPopup, - nav - ) - } + LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> + CheckNewAndRenderChannelCard( + baseNote, + routeForLastRead, + modifier, + parentBackgroundColor, + accountViewModel, + showPopup, + nav, + ) + } } @Composable private fun CheckNewAndRenderChannelCard( - baseNote: Note, - routeForLastRead: String? = null, - modifier: Modifier = Modifier, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - showPopup: () -> Unit, - nav: (String) -> Unit + baseNote: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + showPopup: () -> Unit, + nav: (String) -> Unit, ) { - val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor - val defaultBackgroundColor = MaterialTheme.colorScheme.background - val backgroundColor = remember { - mutableStateOf( + val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor + val defaultBackgroundColor = MaterialTheme.colorScheme.background + val backgroundColor = remember { + mutableStateOf( + parentBackgroundColor?.value ?: defaultBackgroundColor, + ) + } + + LaunchedEffect(key1 = routeForLastRead, key2 = parentBackgroundColor?.value) { + routeForLastRead?.let { + accountViewModel.loadAndMarkAsRead(routeForLastRead, baseNote.createdAt()) { isNew -> + val newBackgroundColor = + if (isNew) { + if (parentBackgroundColor != null) { + newItemColor.compositeOver(parentBackgroundColor.value) + } else { + newItemColor.compositeOver(defaultBackgroundColor) + } + } else { parentBackgroundColor?.value ?: defaultBackgroundColor - ) - } + } - LaunchedEffect(key1 = routeForLastRead, key2 = parentBackgroundColor?.value) { - routeForLastRead?.let { - accountViewModel.loadAndMarkAsRead(routeForLastRead, baseNote.createdAt()) { isNew -> - val newBackgroundColor = if (isNew) { - if (parentBackgroundColor != null) { - newItemColor.compositeOver(parentBackgroundColor.value) - } else { - newItemColor.compositeOver(defaultBackgroundColor) - } - } else { - parentBackgroundColor?.value ?: defaultBackgroundColor - } - - if (newBackgroundColor != backgroundColor.value) { - launch(Dispatchers.Main) { - backgroundColor.value = newBackgroundColor - } - } - } - } ?: run { - val newBackgroundColor = parentBackgroundColor?.value ?: defaultBackgroundColor - if (newBackgroundColor != backgroundColor.value) { - launch(Dispatchers.Main) { - backgroundColor.value = newBackgroundColor - } - } + if (newBackgroundColor != backgroundColor.value) { + launch(Dispatchers.Main) { backgroundColor.value = newBackgroundColor } } + } } + ?: run { + val newBackgroundColor = parentBackgroundColor?.value ?: defaultBackgroundColor + if (newBackgroundColor != backgroundColor.value) { + launch(Dispatchers.Main) { backgroundColor.value = newBackgroundColor } + } + } + } - ClickableNote( - baseNote = baseNote, - backgroundColor = backgroundColor, - modifier = modifier, - accountViewModel = accountViewModel, - showPopup = showPopup, - nav = nav - ) { - InnerChannelCardWithReactions( - baseNote = baseNote, - accountViewModel = accountViewModel, - nav = nav - ) - } + ClickableNote( + baseNote = baseNote, + backgroundColor = backgroundColor, + modifier = modifier, + accountViewModel = accountViewModel, + showPopup = showPopup, + nav = nav, + ) { + InnerChannelCardWithReactions( + baseNote = baseNote, + accountViewModel = accountViewModel, + nav = nav, + ) + } } @Composable fun InnerChannelCardWithReactions( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - when (remember { baseNote.event }) { - is LiveActivitiesEvent -> { - InnerCardRow(baseNote, accountViewModel, nav) - } - is CommunityDefinitionEvent -> { - InnerCardRow(baseNote, accountViewModel, nav) - } - is ChannelCreateEvent -> { - InnerCardRow(baseNote, accountViewModel, nav) - } - is ClassifiedsEvent -> { - InnerCardBox(baseNote, accountViewModel, nav) - } + when (remember { baseNote.event }) { + is LiveActivitiesEvent -> { + InnerCardRow(baseNote, accountViewModel, nav) } + is CommunityDefinitionEvent -> { + InnerCardRow(baseNote, accountViewModel, nav) + } + is ChannelCreateEvent -> { + InnerCardRow(baseNote, accountViewModel, nav) + } + is ClassifiedsEvent -> { + InnerCardBox(baseNote, accountViewModel, nav) + } + } } @Composable fun InnerCardRow( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - Column(StdPadding) { - SensitivityWarning( - note = baseNote, - accountViewModel = accountViewModel - ) { - RenderNoteRow( - baseNote, - accountViewModel, - nav - ) - } + Column(StdPadding) { + SensitivityWarning( + note = baseNote, + accountViewModel = accountViewModel, + ) { + RenderNoteRow( + baseNote, + accountViewModel, + nav, + ) } + } - Divider( - thickness = DividerThickness - ) + Divider( + thickness = DividerThickness, + ) } @Composable fun InnerCardBox( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - Column(HalfPadding) { - SensitivityWarning( - note = baseNote, - accountViewModel = accountViewModel - ) { - RenderClassifiedsThumb(baseNote, accountViewModel, nav) - } + Column(HalfPadding) { + SensitivityWarning( + note = baseNote, + accountViewModel = accountViewModel, + ) { + RenderClassifiedsThumb(baseNote, accountViewModel, nav) } + } } @Composable private fun RenderNoteRow( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - when (remember { baseNote.event }) { - is LiveActivitiesEvent -> { - RenderLiveActivityThumb(baseNote, accountViewModel, nav) - } - is CommunityDefinitionEvent -> { - RenderCommunitiesThumb(baseNote, accountViewModel, nav) - } - is ChannelCreateEvent -> { - RenderChannelThumb(baseNote, accountViewModel, nav) - } + when (remember { baseNote.event }) { + is LiveActivitiesEvent -> { + RenderLiveActivityThumb(baseNote, accountViewModel, nav) } + is CommunityDefinitionEvent -> { + RenderCommunitiesThumb(baseNote, accountViewModel, nav) + } + is ChannelCreateEvent -> { + RenderChannelThumb(baseNote, accountViewModel, nav) + } + } } @Immutable data class ClassifiedsThumb( - val image: String?, - val title: String?, - val price: Price? + val image: String?, + val title: String?, + val price: Price?, ) @Composable -fun RenderClassifiedsThumb(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val noteEvent = baseNote.event as? ClassifiedsEvent ?: return +fun RenderClassifiedsThumb( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = baseNote.event as? ClassifiedsEvent ?: return - val card by baseNote.live().metadata.map { + val card by + baseNote + .live() + .metadata + .map { val noteEvent = it.note.event as? ClassifiedsEvent ClassifiedsThumb( - image = noteEvent?.image(), - title = noteEvent?.title(), - price = noteEvent?.price() + image = noteEvent?.image(), + title = noteEvent?.title(), + price = noteEvent?.price(), ) - }.distinctUntilChanged().observeAsState( + } + .distinctUntilChanged() + .observeAsState( ClassifiedsThumb( - image = noteEvent.image(), - title = noteEvent.title(), - price = noteEvent.price() - ) - ) + image = noteEvent.image(), + title = noteEvent.title(), + price = noteEvent.price(), + ), + ) - RenderClassifiedsThumb(card, baseNote.author) + RenderClassifiedsThumb(card, baseNote.author) } @Preview @Composable fun RenderClassifiedsThumbPreview() { - Surface(Modifier.size(200.dp)) { - RenderClassifiedsThumb( - card = ClassifiedsThumb( - image = null, - title = "Like New", - price = Price("800000", "SATS", null) - ), - author = null - ) - } + Surface(Modifier.size(200.dp)) { + RenderClassifiedsThumb( + card = + ClassifiedsThumb( + image = null, + title = "Like New", + price = Price("800000", "SATS", null), + ), + author = null, + ) + } } @Composable -fun RenderClassifiedsThumb(card: ClassifiedsThumb, author: User?) { - Box( - Modifier - .fillMaxWidth() - .aspectRatio(1f), - contentAlignment = BottomStart - ) { - card.image?.let { - AsyncImage( - model = it, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() - ) - } ?: run { - author?.let { - DisplayAuthorBanner(it) - } - } - - Row( - Modifier - .fillMaxWidth() - .background(Color.Black.copy(0.6f)) - .padding(Size5dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - card.title?.let { - Text( - text = it, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = Color.White, - modifier = Modifier.weight(1f) - ) - } - - card.price?.let { - val priceTag = remember(card) { - val newAmount = it.amount.toBigDecimalOrNull()?.let { - showAmountAxis(it) - } ?: it.amount - - if (it.frequency != null && it.currency != null) { - "$newAmount ${it.currency}/${it.frequency}" - } else if (it.currency != null) { - "$newAmount ${it.currency}" - } else { - newAmount - } - } - - Text( - text = priceTag, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = Color.White - ) - } - } +fun RenderClassifiedsThumb( + card: ClassifiedsThumb, + author: User?, +) { + Box( + Modifier.fillMaxWidth().aspectRatio(1f), + contentAlignment = BottomStart, + ) { + card.image?.let { + AsyncImage( + model = it, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) } + ?: run { author?.let { DisplayAuthorBanner(it) } } + + Row( + Modifier.fillMaxWidth().background(Color.Black.copy(0.6f)).padding(Size5dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + card.title?.let { + Text( + text = it, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = Color.White, + modifier = Modifier.weight(1f), + ) + } + + card.price?.let { + val priceTag = + remember(card) { + val newAmount = it.amount.toBigDecimalOrNull()?.let { showAmountAxis(it) } ?: it.amount + + if (it.frequency != null && it.currency != null) { + "$newAmount ${it.currency}/${it.frequency}" + } else if (it.currency != null) { + "$newAmount ${it.currency}" + } else { + newAmount + } + } + + Text( + text = priceTag, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = Color.White, + ) + } + } + } } @Immutable data class LiveActivityCard( - val name: String, - val cover: String?, - val media: String?, - val subject: String?, - val content: String?, - val participants: ImmutableList, - val status: String?, - val starts: Long? + val name: String, + val cover: String?, + val media: String?, + val subject: String?, + val content: String?, + val participants: ImmutableList, + val status: String?, + val starts: Long?, ) @Composable fun RenderLiveActivityThumb( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = baseNote.event as? LiveActivitiesEvent ?: return + val noteEvent = baseNote.event as? LiveActivitiesEvent ?: return - val card by baseNote.live().metadata.map { + val card by + baseNote + .live() + .metadata + .map { val noteEvent = it.note.event as? LiveActivitiesEvent LiveActivityCard( - name = noteEvent?.dTag() ?: "", - cover = noteEvent?.image()?.ifBlank { null }, - media = noteEvent?.streaming(), - subject = noteEvent?.title()?.ifBlank { null }, - content = noteEvent?.summary(), - participants = noteEvent?.participants()?.toImmutableList() ?: persistentListOf(), - status = noteEvent?.status(), - starts = noteEvent?.starts() + name = noteEvent?.dTag() ?: "", + cover = noteEvent?.image()?.ifBlank { null }, + media = noteEvent?.streaming(), + subject = noteEvent?.title()?.ifBlank { null }, + content = noteEvent?.summary(), + participants = noteEvent?.participants()?.toImmutableList() ?: persistentListOf(), + status = noteEvent?.status(), + starts = noteEvent?.starts(), ) - }.distinctUntilChanged().observeAsState( + } + .distinctUntilChanged() + .observeAsState( LiveActivityCard( - name = noteEvent.dTag(), - cover = noteEvent.image()?.ifBlank { null }, - media = noteEvent.streaming(), - subject = noteEvent.title()?.ifBlank { null }, - content = noteEvent.summary(), - participants = noteEvent.participants().toImmutableList(), - status = noteEvent.status(), - starts = noteEvent.starts() - ) - ) + name = noteEvent.dTag(), + cover = noteEvent.image()?.ifBlank { null }, + media = noteEvent.streaming(), + subject = noteEvent.title()?.ifBlank { null }, + content = noteEvent.summary(), + participants = noteEvent.participants().toImmutableList(), + status = noteEvent.status(), + starts = noteEvent.starts(), + ), + ) - Column( - modifier = Modifier.fillMaxWidth() + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Box( + contentAlignment = TopEnd, + modifier = Modifier.aspectRatio(ratio = 16f / 9f).fillMaxWidth(), ) { - Box( - contentAlignment = TopEnd, - modifier = Modifier - .aspectRatio(ratio = 16f / 9f) - .fillMaxWidth() - ) { - card.cover?.let { - AsyncImage( - model = it, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxSize() - .clip(QuoteBorder) - ) - } ?: run { - baseNote.author?.let { - DisplayAuthorBanner(it) - } - } - - Box(Modifier.padding(10.dp)) { - Crossfade(targetState = card.status, label = "RenderLiveActivityThumb") { - when (it) { - STATUS_LIVE -> { - val url = card.media - if (url.isNullOrBlank()) { - LiveFlag() - } else { - CheckIfUrlIsOnline(url, accountViewModel) { isOnline -> - if (isOnline) { - LiveFlag() - } else { - OfflineFlag() - } - } - } - } - STATUS_ENDED -> { - EndedFlag() - } - STATUS_PLANNED -> { - ScheduledFlag(card.starts) - } - else -> { - EndedFlag() - } - } - } - } - - LoadParticipants(card.participants, baseNote, accountViewModel) { participantUsers -> - Box( - Modifier - .padding(10.dp) - .align(BottomStart) - ) { - if (participantUsers.isNotEmpty()) { - Gallery(participantUsers, accountViewModel) - } - } - } - } - - Spacer(modifier = DoubleVertSpacer) - - ChannelHeader( - channelHex = remember { baseNote.idHex }, - showVideo = false, - showBottomDiviser = false, - showFlag = false, - sendToChannel = true, - modifier = remember { - Modifier.padding(start = 0.dp, end = 0.dp, top = 5.dp, bottom = 5.dp) - }, - accountViewModel = accountViewModel, - nav = nav + card.cover?.let { + AsyncImage( + model = it, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize().clip(QuoteBorder), ) + } + ?: run { baseNote.author?.let { DisplayAuthorBanner(it) } } + + Box(Modifier.padding(10.dp)) { + Crossfade(targetState = card.status, label = "RenderLiveActivityThumb") { + when (it) { + STATUS_LIVE -> { + val url = card.media + if (url.isNullOrBlank()) { + LiveFlag() + } else { + CheckIfUrlIsOnline(url, accountViewModel) { isOnline -> + if (isOnline) { + LiveFlag() + } else { + OfflineFlag() + } + } + } + } + STATUS_ENDED -> { + EndedFlag() + } + STATUS_PLANNED -> { + ScheduledFlag(card.starts) + } + else -> { + EndedFlag() + } + } + } + } + + LoadParticipants(card.participants, baseNote, accountViewModel) { participantUsers -> + Box( + Modifier.padding(10.dp).align(BottomStart), + ) { + if (participantUsers.isNotEmpty()) { + Gallery(participantUsers, accountViewModel) + } + } + } } + + Spacer(modifier = DoubleVertSpacer) + + ChannelHeader( + channelHex = remember { baseNote.idHex }, + showVideo = false, + showBottomDiviser = false, + showFlag = false, + sendToChannel = true, + modifier = remember { Modifier.padding(start = 0.dp, end = 0.dp, top = 5.dp, bottom = 5.dp) }, + accountViewModel = accountViewModel, + nav = nav, + ) + } } @Immutable data class CommunityCard( - val name: String, - val description: String?, - val cover: String?, - val moderators: ImmutableList + val name: String, + val description: String?, + val cover: String?, + val moderators: ImmutableList, ) @Composable -fun RenderCommunitiesThumb(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val noteEvent = baseNote.event as? CommunityDefinitionEvent ?: return +fun RenderCommunitiesThumb( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = baseNote.event as? CommunityDefinitionEvent ?: return - val card by baseNote.live().metadata.map { + val card by + baseNote + .live() + .metadata + .map { val noteEvent = it.note.event as? CommunityDefinitionEvent CommunityCard( - name = noteEvent?.dTag() ?: "", - description = noteEvent?.description(), - cover = noteEvent?.image()?.ifBlank { null }, - moderators = noteEvent?.moderators()?.toImmutableList() ?: persistentListOf() + name = noteEvent?.dTag() ?: "", + description = noteEvent?.description(), + cover = noteEvent?.image()?.ifBlank { null }, + moderators = noteEvent?.moderators()?.toImmutableList() ?: persistentListOf(), ) - }.distinctUntilChanged().observeAsState( + } + .distinctUntilChanged() + .observeAsState( CommunityCard( - name = noteEvent.dTag(), - description = noteEvent.description(), - cover = noteEvent.image()?.ifBlank { null }, - moderators = noteEvent.moderators().toImmutableList() - ) - ) + name = noteEvent.dTag(), + description = noteEvent.description(), + cover = noteEvent.image()?.ifBlank { null }, + moderators = noteEvent.moderators().toImmutableList(), + ), + ) - LeftPictureLayout( - onImage = { - card.cover?.let { - Box(contentAlignment = BottomStart) { - AsyncImage( - model = it, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxSize() - .clip(QuoteBorder) - ) - } - } ?: run { - baseNote.author?.let { - DisplayAuthorBanner(it) - } - } - }, - onTitleRow = { - Text( - text = card.name, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - - Spacer(modifier = StdHorzSpacer) - LikeReaction(baseNote = baseNote, grayTint = MaterialTheme.colorScheme.onSurface, accountViewModel = accountViewModel, nav) - Spacer(modifier = StdHorzSpacer) - ZapReaction(baseNote = baseNote, grayTint = MaterialTheme.colorScheme.onSurface, accountViewModel = accountViewModel, nav = nav) - }, - onDescription = { - card.description?.let { - Spacer(modifier = StdVertSpacer) - Row() { - Text( - text = it, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - fontSize = 14.sp - ) - } - } - }, - onBottomRow = { - Spacer(modifier = StdVertSpacer) - LoadModerators(card.moderators, baseNote, accountViewModel) { participantUsers -> - if (participantUsers.isNotEmpty()) { - Gallery(participantUsers, accountViewModel) - } - } + LeftPictureLayout( + onImage = { + card.cover?.let { + Box(contentAlignment = BottomStart) { + AsyncImage( + model = it, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize().clip(QuoteBorder), + ) } - ) + } + ?: run { baseNote.author?.let { DisplayAuthorBanner(it) } } + }, + onTitleRow = { + Text( + text = card.name, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + Spacer(modifier = StdHorzSpacer) + LikeReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav, + ) + Spacer(modifier = StdHorzSpacer) + ZapReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + onDescription = { + card.description?.let { + Spacer(modifier = StdVertSpacer) + Row { + Text( + text = it, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + fontSize = 14.sp, + ) + } + } + }, + onBottomRow = { + Spacer(modifier = StdVertSpacer) + LoadModerators(card.moderators, baseNote, accountViewModel) { participantUsers -> + if (participantUsers.isNotEmpty()) { + Gallery(participantUsers, accountViewModel) + } + } + }, + ) } @Composable fun LoadModerators( - moderators: ImmutableList, - baseNote: Note, - accountViewModel: AccountViewModel, - content: @Composable (ImmutableList) -> Unit + moderators: ImmutableList, + baseNote: Note, + accountViewModel: AccountViewModel, + content: @Composable (ImmutableList) -> Unit, ) { - var participantUsers by remember { - mutableStateOf>( - persistentListOf() - ) - } + var participantUsers by remember { + mutableStateOf>( + persistentListOf(), + ) + } - LaunchedEffect(key1 = moderators) { - launch(Dispatchers.IO) { - val hosts = moderators.mapNotNull { part -> - if (part.key != baseNote.author?.pubkeyHex) { - LocalCache.checkGetOrCreateUser(part.key) - } else { - null - } - } - - val followingKeySet = accountViewModel.account.liveDiscoveryFollowLists.value?.users - val allParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).minus(hosts) - - val newParticipantUsers = if (followingKeySet == null) { - val allFollows = accountViewModel.account.userProfile().cachedFollowingKeySet() - val followingParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).minus(hosts) - - (hosts + followingParticipants + (allParticipants - followingParticipants)).toImmutableList() - } else { - (hosts + allParticipants).toImmutableList() - } - - if (!equalImmutableLists(newParticipantUsers, participantUsers)) { - participantUsers = newParticipantUsers - } + LaunchedEffect(key1 = moderators) { + launch(Dispatchers.IO) { + val hosts = + moderators.mapNotNull { part -> + if (part.key != baseNote.author?.pubkeyHex) { + LocalCache.checkGetOrCreateUser(part.key) + } else { + null + } } - } - content(participantUsers) + val followingKeySet = accountViewModel.account.liveDiscoveryFollowLists.value?.users + val allParticipants = + ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).minus(hosts) + + val newParticipantUsers = + if (followingKeySet == null) { + val allFollows = accountViewModel.account.userProfile().cachedFollowingKeySet() + val followingParticipants = + ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).minus(hosts) + + (hosts + followingParticipants + (allParticipants - followingParticipants)) + .toImmutableList() + } else { + (hosts + allParticipants).toImmutableList() + } + + if (!equalImmutableLists(newParticipantUsers, participantUsers)) { + participantUsers = newParticipantUsers + } + } + } + + content(participantUsers) } @Composable private fun LoadParticipants( - participants: ImmutableList, - baseNote: Note, - accountViewModel: AccountViewModel, - inner: @Composable (ImmutableList) -> Unit + participants: ImmutableList, + baseNote: Note, + accountViewModel: AccountViewModel, + inner: @Composable (ImmutableList) -> Unit, ) { - var participantUsers by remember { - mutableStateOf>( - persistentListOf() - ) - } - - LaunchedEffect(key1 = participants) { - launch(Dispatchers.IO) { - val hosts = participants.mapNotNull { part -> - if (part.key != baseNote.author?.pubkeyHex) { - LocalCache.checkGetOrCreateUser(part.key) - } else { - null - } - } - - val hostsAuthor = hosts + ( - baseNote.author?.let { - listOf(it) - } ?: emptyList() - ) - - val followingKeySet = accountViewModel.account.liveDiscoveryFollowLists.value?.users - - val allParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).minus(hostsAuthor) - - val newParticipantUsers = if (followingKeySet == null) { - val allFollows = accountViewModel.account.userProfile().cachedFollowingKeySet() - val followingParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).minus(hostsAuthor) - - (hosts + followingParticipants + (allParticipants - followingParticipants)).toImmutableList() - } else { - (hosts + allParticipants).toImmutableList() - } - - if (!equalImmutableLists(newParticipantUsers, participantUsers)) { - participantUsers = newParticipantUsers - } - } - } - - inner(participantUsers) -} - -@Composable -fun RenderChannelThumb(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val noteEvent = baseNote.event as? ChannelCreateEvent ?: return - - LoadChannel(baseChannelHex = baseNote.idHex, accountViewModel) { - RenderChannelThumb(baseNote = baseNote, channel = it, accountViewModel, nav) - } -} - -@Composable -fun RenderChannelThumb(baseNote: Note, channel: Channel, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val channelUpdates by channel.live.observeAsState() - - val name = remember(channelUpdates) { channelUpdates?.channel?.toBestDisplayName() ?: "" } - val description = remember(channelUpdates) { channelUpdates?.channel?.summary() } - val cover by remember(channelUpdates) { - derivedStateOf { - channelUpdates?.channel?.profilePicture()?.ifBlank { null } - } - } - - var participantUsers by remember(baseNote) { - mutableStateOf>( - persistentListOf() - ) - } - - LaunchedEffect(key1 = channelUpdates) { - launch(Dispatchers.IO) { - val followingKeySet = accountViewModel.account.liveDiscoveryFollowLists.value?.users - val allParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).toImmutableList() - - val newParticipantUsers = if (followingKeySet == null) { - val allFollows = accountViewModel.account.userProfile().cachedFollowingKeySet() - val followingParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).toList() - - (followingParticipants + (allParticipants - followingParticipants)).toImmutableList() - } else { - allParticipants.toImmutableList() - } - - if (!equalImmutableLists(newParticipantUsers, participantUsers)) { - participantUsers = newParticipantUsers - } - } - } - - LeftPictureLayout( - onImage = { - cover?.let { - Box(contentAlignment = BottomStart) { - AsyncImage( - model = it, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxSize() - .clip(QuoteBorder) - ) - } - } ?: run { - baseNote.author?.let { - DisplayAuthorBanner(it) - } - } - }, - onTitleRow = { - Text( - text = name, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - - Spacer(modifier = StdHorzSpacer) - LikeReaction(baseNote = baseNote, grayTint = MaterialTheme.colorScheme.onSurface, accountViewModel = accountViewModel, nav) - Spacer(modifier = StdHorzSpacer) - ZapReaction(baseNote = baseNote, grayTint = MaterialTheme.colorScheme.onSurface, accountViewModel = accountViewModel, nav = nav) - }, - onDescription = { - description?.let { - Text( - text = it, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - fontSize = 14.sp - ) - } - }, - onBottomRow = { - if (participantUsers.isNotEmpty()) { - Spacer(modifier = StdVertSpacer) - Row() { - Gallery(participantUsers, accountViewModel) - } - } - } + var participantUsers by remember { + mutableStateOf>( + persistentListOf(), ) + } + + LaunchedEffect(key1 = participants) { + launch(Dispatchers.IO) { + val hosts = + participants.mapNotNull { part -> + if (part.key != baseNote.author?.pubkeyHex) { + LocalCache.checkGetOrCreateUser(part.key) + } else { + null + } + } + + val hostsAuthor = hosts + (baseNote.author?.let { listOf(it) } ?: emptyList()) + + val followingKeySet = accountViewModel.account.liveDiscoveryFollowLists.value?.users + + val allParticipants = + ParticipantListBuilder() + .followsThatParticipateOn(baseNote, followingKeySet) + .minus(hostsAuthor) + + val newParticipantUsers = + if (followingKeySet == null) { + val allFollows = accountViewModel.account.userProfile().cachedFollowingKeySet() + val followingParticipants = + ParticipantListBuilder() + .followsThatParticipateOn(baseNote, allFollows) + .minus(hostsAuthor) + + (hosts + followingParticipants + (allParticipants - followingParticipants)) + .toImmutableList() + } else { + (hosts + allParticipants).toImmutableList() + } + + if (!equalImmutableLists(newParticipantUsers, participantUsers)) { + participantUsers = newParticipantUsers + } + } + } + + inner(participantUsers) +} + +@Composable +fun RenderChannelThumb( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = baseNote.event as? ChannelCreateEvent ?: return + + LoadChannel(baseChannelHex = baseNote.idHex, accountViewModel) { + RenderChannelThumb(baseNote = baseNote, channel = it, accountViewModel, nav) + } +} + +@Composable +fun RenderChannelThumb( + baseNote: Note, + channel: Channel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val channelUpdates by channel.live.observeAsState() + + val name = remember(channelUpdates) { channelUpdates?.channel?.toBestDisplayName() ?: "" } + val description = remember(channelUpdates) { channelUpdates?.channel?.summary() } + val cover by + remember(channelUpdates) { + derivedStateOf { channelUpdates?.channel?.profilePicture()?.ifBlank { null } } + } + + var participantUsers by + remember(baseNote) { + mutableStateOf>( + persistentListOf(), + ) + } + + LaunchedEffect(key1 = channelUpdates) { + launch(Dispatchers.IO) { + val followingKeySet = accountViewModel.account.liveDiscoveryFollowLists.value?.users + val allParticipants = + ParticipantListBuilder() + .followsThatParticipateOn(baseNote, followingKeySet) + .toImmutableList() + + val newParticipantUsers = + if (followingKeySet == null) { + val allFollows = accountViewModel.account.userProfile().cachedFollowingKeySet() + val followingParticipants = + ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).toList() + + (followingParticipants + (allParticipants - followingParticipants)).toImmutableList() + } else { + allParticipants.toImmutableList() + } + + if (!equalImmutableLists(newParticipantUsers, participantUsers)) { + participantUsers = newParticipantUsers + } + } + } + + LeftPictureLayout( + onImage = { + cover?.let { + Box(contentAlignment = BottomStart) { + AsyncImage( + model = it, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize().clip(QuoteBorder), + ) + } + } + ?: run { baseNote.author?.let { DisplayAuthorBanner(it) } } + }, + onTitleRow = { + Text( + text = name, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + Spacer(modifier = StdHorzSpacer) + LikeReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav, + ) + Spacer(modifier = StdHorzSpacer) + ZapReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + onDescription = { + description?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + fontSize = 14.sp, + ) + } + }, + onBottomRow = { + if (participantUsers.isNotEmpty()) { + Spacer(modifier = StdVertSpacer) + Row { Gallery(participantUsers, accountViewModel) } + } + }, + ) } @OptIn(ExperimentalLayoutApi::class) @Composable -fun Gallery(users: ImmutableList, accountViewModel: AccountViewModel) { - FlowRow(verticalArrangement = Arrangement.Center) { - users.take(6).forEach { - ClickableUserPicture(it, Size35dp, accountViewModel) - } +fun Gallery( + users: ImmutableList, + accountViewModel: AccountViewModel, +) { + FlowRow(verticalArrangement = Arrangement.Center) { + users.take(6).forEach { ClickableUserPicture(it, Size35dp, accountViewModel) } - if (users.size > 6) { - Text( - text = remember(users) { " + " + (showCount(users.size - 6)) }, - fontSize = 13.sp, - color = MaterialTheme.colorScheme.onSurface - ) - } + if (users.size > 6) { + Text( + text = remember(users) { " + " + (showCount(users.size - 6)) }, + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurface, + ) } + } } @Composable fun DisplayAuthorBanner(author: User) { - val picture by author.live().metadata.map { - it.user.info?.banner?.ifBlank { null } ?: it.user.info?.picture?.ifBlank { null } - }.observeAsState() + val picture by + author + .live() + .metadata + .map { it.user.info?.banner?.ifBlank { null } ?: it.user.info?.picture?.ifBlank { null } } + .observeAsState() - AsyncImage( - model = picture, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxSize() - .clip(QuoteBorder) - ) + AsyncImage( + model = picture, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize().clip(QuoteBorder), + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomHeaderCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomHeaderCompose.kt index b995a7321..b477b910e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomHeaderCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomHeaderCompose.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.note import androidx.compose.animation.Crossfade @@ -62,474 +82,465 @@ import kotlinx.coroutines.launch @Composable fun ChatroomHeaderCompose( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null) + val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null) - if (hasEvent) { - ChatroomComposeChannelOrUser(baseNote, accountViewModel, nav) - } else { - BlankNote() - } + if (hasEvent) { + ChatroomComposeChannelOrUser(baseNote, accountViewModel, nav) + } else { + BlankNote() + } } @Composable fun ChatroomComposeChannelOrUser( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val channelHex by remember(baseNote) { - derivedStateOf { - baseNote.channelHex() - } - } + val channelHex by remember(baseNote) { derivedStateOf { baseNote.channelHex() } } - if (channelHex != null) { - ChatroomChannel(channelHex!!, baseNote, accountViewModel, nav) - } else { - ChatroomPrivateMessages(baseNote, accountViewModel, nav) - } + if (channelHex != null) { + ChatroomChannel(channelHex!!, baseNote, accountViewModel, nav) + } else { + ChatroomPrivateMessages(baseNote, accountViewModel, nav) + } } @Composable private fun ChatroomPrivateMessages( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val userRoom by remember(baseNote) { - derivedStateOf { - (baseNote.event as? ChatroomKeyable)?.chatroomKey(accountViewModel.userProfile().pubkeyHex) - } + val userRoom by + remember(baseNote) { + derivedStateOf { + (baseNote.event as? ChatroomKeyable)?.chatroomKey(accountViewModel.userProfile().pubkeyHex) + } } - Crossfade(userRoom, label = "ChatroomPrivateMessages") { room -> - if (room != null) { - UserRoomCompose(baseNote, room, accountViewModel, nav) - } else { - Box(emptyLineItemModifier) { - // Makes sure just a max amount of objects are loaded. - } - } + Crossfade(userRoom, label = "ChatroomPrivateMessages") { room -> + if (room != null) { + UserRoomCompose(baseNote, room, accountViewModel, nav) + } else { + Box(emptyLineItemModifier) { + // Makes sure just a max amount of objects are loaded. + } } + } } @Composable private fun ChatroomChannel( - channelHex: HexKey, - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + channelHex: HexKey, + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - LoadChannel(baseChannelHex = channelHex, accountViewModel) { channel -> - ChannelRoomCompose(baseNote, channel, accountViewModel, nav) - } + LoadChannel(baseChannelHex = channelHex, accountViewModel) { channel -> + ChannelRoomCompose(baseNote, channel, accountViewModel, nav) + } } @Composable private fun ChannelRoomCompose( - note: Note, - channel: Channel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + note: Note, + channel: Channel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val authorState by note.author!!.live().metadata.observeAsState() - val authorName = remember(note, authorState) { - authorState?.user?.toBestDisplayName() - } + val authorState by note.author!!.live().metadata.observeAsState() + val authorName = remember(note, authorState) { authorState?.user?.toBestDisplayName() } - val chanHex = remember { channel.idHex } + val chanHex = remember { channel.idHex } - val channelState by channel.live.observeAsState() - val channelPicture by remember(note, channelState) { - derivedStateOf { - channel.profilePicture() - } - } - val channelName by remember(note, channelState) { - derivedStateOf { - channel.toBestDisplayName() - } - } + val channelState by channel.live.observeAsState() + val channelPicture by remember(note, channelState) { derivedStateOf { channel.profilePicture() } } + val channelName by remember(note, channelState) { derivedStateOf { channel.toBestDisplayName() } } - val noteEvent = note.event + val noteEvent = note.event - val route = remember(note) { - "Channel/$chanHex" - } + val route = remember(note) { "Channel/$chanHex" } - val description = if (noteEvent is ChannelCreateEvent) { - stringResource(R.string.channel_created) + val description = + if (noteEvent is ChannelCreateEvent) { + stringResource(R.string.channel_created) } else if (noteEvent is ChannelMetadataEvent) { - "${stringResource(R.string.channel_information_changed_to)} " + "${stringResource(R.string.channel_information_changed_to)} " } else { - noteEvent?.content() + noteEvent?.content() } - val hasNewMessages = remember { mutableStateOf(false) } + val hasNewMessages = remember { mutableStateOf(false) } - val automaticallyShowProfilePicture = remember { - accountViewModel.settings.showProfilePictures.value + val automaticallyShowProfilePicture = remember { + accountViewModel.settings.showProfilePictures.value + } + + WatchNotificationChanges(note, route, accountViewModel) { newHasNewMessages -> + if (hasNewMessages.value != newHasNewMessages) { + hasNewMessages.value = newHasNewMessages } + } - WatchNotificationChanges(note, route, accountViewModel) { newHasNewMessages -> - if (hasNewMessages.value != newHasNewMessages) { - hasNewMessages.value = newHasNewMessages - } - } - - ChannelName( - channelIdHex = chanHex, - channelPicture = channelPicture, - channelTitle = { modifier -> - ChannelTitleWithLabelInfo(channelName, modifier) - }, - channelLastTime = remember(note) { note.createdAt() }, - channelLastContent = remember(note, authorState) { "$authorName: $description" }, - hasNewMessages = hasNewMessages, - loadProfilePicture = automaticallyShowProfilePicture, - onClick = { nav(route) } - ) + ChannelName( + channelIdHex = chanHex, + channelPicture = channelPicture, + channelTitle = { modifier -> ChannelTitleWithLabelInfo(channelName, modifier) }, + channelLastTime = remember(note) { note.createdAt() }, + channelLastContent = remember(note, authorState) { "$authorName: $description" }, + hasNewMessages = hasNewMessages, + loadProfilePicture = automaticallyShowProfilePicture, + onClick = { nav(route) }, + ) } @Composable -private fun ChannelTitleWithLabelInfo(channelName: String, modifier: Modifier) { - val label = stringResource(id = R.string.public_chat) - val placeHolderColor = MaterialTheme.colorScheme.placeholderText - val channelNameAndBoostInfo = remember(channelName) { - buildAnnotatedString { - withStyle( - SpanStyle( - fontWeight = FontWeight.Bold - ) - ) { - append(channelName) - } - - withStyle( - SpanStyle( - color = placeHolderColor, - fontWeight = FontWeight.Normal - ) - ) { - append(" $label") - } +private fun ChannelTitleWithLabelInfo( + channelName: String, + modifier: Modifier, +) { + val label = stringResource(id = R.string.public_chat) + val placeHolderColor = MaterialTheme.colorScheme.placeholderText + val channelNameAndBoostInfo = + remember(channelName) { + buildAnnotatedString { + withStyle( + SpanStyle( + fontWeight = FontWeight.Bold, + ), + ) { + append(channelName) } + + withStyle( + SpanStyle( + color = placeHolderColor, + fontWeight = FontWeight.Normal, + ), + ) { + append(" $label") + } + } } - Text( - text = channelNameAndBoostInfo, - fontWeight = FontWeight.Bold, - modifier = modifier, - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Text( + text = channelNameAndBoostInfo, + fontWeight = FontWeight.Bold, + modifier = modifier, + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } @Composable private fun UserRoomCompose( - note: Note, - room: ChatroomKey, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + note: Note, + room: ChatroomKey, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val hasNewMessages = remember { mutableStateOf(false) } + val hasNewMessages = remember { mutableStateOf(false) } - val route = remember(room) { - "Room/${room.hashCode()}" + val route = remember(room) { "Room/${room.hashCode()}" } + + val createAt by remember(note) { derivedStateOf { note.createdAt() } } + + WatchNotificationChanges(note, route, accountViewModel) { newHasNewMessages -> + if (hasNewMessages.value != newHasNewMessages) { + hasNewMessages.value = newHasNewMessages } + } - val createAt by remember(note) { - derivedStateOf { - note.createdAt() - } - } - - WatchNotificationChanges(note, route, accountViewModel) { newHasNewMessages -> - if (hasNewMessages.value != newHasNewMessages) { - hasNewMessages.value = newHasNewMessages - } - } - - LoadDecryptedContentOrNull(note, accountViewModel) { content -> - ChannelName( - channelPicture = { - NonClickableUserPictures( - users = room.users, - accountViewModel = accountViewModel, - size = Size55dp - ) - }, - channelTitle = { - RoomNameDisplay(room, it, accountViewModel) - }, - channelLastTime = createAt, - channelLastContent = content, - hasNewMessages = hasNewMessages, - onClick = { nav(route) } + LoadDecryptedContentOrNull(note, accountViewModel) { content -> + ChannelName( + channelPicture = { + NonClickableUserPictures( + users = room.users, + accountViewModel = accountViewModel, + size = Size55dp, ) - } + }, + channelTitle = { RoomNameDisplay(room, it, accountViewModel) }, + channelLastTime = createAt, + channelLastContent = content, + hasNewMessages = hasNewMessages, + onClick = { nav(route) }, + ) + } } @Composable -fun RoomNameDisplay(room: ChatroomKey, modifier: Modifier, accountViewModel: AccountViewModel) { - val roomSubject by accountViewModel.userProfile().live().messages.map { - it.user.privateChatrooms[room]?.subject - }.distinctUntilChanged().observeAsState(accountViewModel.userProfile().privateChatrooms[room]?.subject) +fun RoomNameDisplay( + room: ChatroomKey, + modifier: Modifier, + accountViewModel: AccountViewModel, +) { + val roomSubject by + accountViewModel + .userProfile() + .live() + .messages + .map { it.user.privateChatrooms[room]?.subject } + .distinctUntilChanged() + .observeAsState(accountViewModel.userProfile().privateChatrooms[room]?.subject) - Crossfade(targetState = roomSubject, modifier, label = "RoomNameDisplay") { - if (!it.isNullOrBlank()) { - if (room.users.size > 1) { - DisplayRoomSubject(it) - } else { - DisplayUserAndSubject(room.users.first(), it, accountViewModel) - } - } else { - DisplayUserSetAsSubject(room, accountViewModel) - } + Crossfade(targetState = roomSubject, modifier, label = "RoomNameDisplay") { + if (!it.isNullOrBlank()) { + if (room.users.size > 1) { + DisplayRoomSubject(it) + } else { + DisplayUserAndSubject(room.users.first(), it, accountViewModel) + } + } else { + DisplayUserSetAsSubject(room, accountViewModel) } + } } @Composable private fun DisplayUserAndSubject( - user: HexKey, - subject: String, - accountViewModel: AccountViewModel + user: HexKey, + subject: String, + accountViewModel: AccountViewModel, ) { - Row() { - Text( - text = subject, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = " - ", - fontWeight = FontWeight.Bold, - maxLines = 1 - ) - LoadUser(baseUserHex = user, accountViewModel = accountViewModel) { - it?.let { - UsernameDisplay(it, Modifier.weight(1f)) - } - } + Row { + Text( + text = subject, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = " - ", + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + LoadUser(baseUserHex = user, accountViewModel = accountViewModel) { + it?.let { UsernameDisplay(it, Modifier.weight(1f)) } } + } } @Composable fun DisplayUserSetAsSubject( - room: ChatroomKey, - accountViewModel: AccountViewModel, - fontWeight: FontWeight = FontWeight.Bold + room: ChatroomKey, + accountViewModel: AccountViewModel, + fontWeight: FontWeight = FontWeight.Bold, ) { - val userList = remember(room) { - room.users.toList() - } + val userList = remember(room) { room.users.toList() } - if (userList.size == 1) { - // Regular Design - Row() { - LoadUser(baseUserHex = userList[0], accountViewModel) { - it?.let { - UsernameDisplay(it, Modifier.weight(1f), fontWeight = fontWeight) - } - } - } - } else { - Row() { - userList.take(4).forEachIndexedExtended { index, isFirst, isLast, value -> - LoadUser(baseUserHex = value, accountViewModel) { - it?.let { - ShortUsernameDisplay(baseUser = it, fontWeight = fontWeight) - } - } - - if (!isLast) { - Text( - text = ", ", - fontWeight = fontWeight, - maxLines = 1 - ) - } - } - } + if (userList.size == 1) { + // Regular Design + Row { + LoadUser(baseUserHex = userList[0], accountViewModel) { + it?.let { UsernameDisplay(it, Modifier.weight(1f), fontWeight = fontWeight) } + } } + } else { + Row { + userList.take(4).forEachIndexedExtended { index, isFirst, isLast, value -> + LoadUser(baseUserHex = value, accountViewModel) { + it?.let { ShortUsernameDisplay(baseUser = it, fontWeight = fontWeight) } + } + + if (!isLast) { + Text( + text = ", ", + fontWeight = fontWeight, + maxLines = 1, + ) + } + } + } + } } @Composable -fun DisplayRoomSubject(roomSubject: String, fontWeight: FontWeight = FontWeight.Bold) { - Row() { - Text( - text = roomSubject, - fontWeight = fontWeight, - maxLines = 1 - ) - } +fun DisplayRoomSubject( + roomSubject: String, + fontWeight: FontWeight = FontWeight.Bold, +) { + Row { + Text( + text = roomSubject, + fontWeight = fontWeight, + maxLines = 1, + ) + } } @Composable -fun ShortUsernameDisplay(baseUser: User, weight: Modifier = Modifier, fontWeight: FontWeight = FontWeight.Bold) { - val userName by baseUser.live().metadata.map { - it.user.toBestShortFirstName() - }.distinctUntilChanged().observeAsState(baseUser.toBestShortFirstName()) +fun ShortUsernameDisplay( + baseUser: User, + weight: Modifier = Modifier, + fontWeight: FontWeight = FontWeight.Bold, +) { + val userName by + baseUser + .live() + .metadata + .map { it.user.toBestShortFirstName() } + .distinctUntilChanged() + .observeAsState(baseUser.toBestShortFirstName()) - Crossfade(targetState = userName, modifier = weight) { - CreateTextWithEmoji( - text = it, - tags = baseUser.info?.tags, - fontWeight = fontWeight, - maxLines = 1 - ) - } + Crossfade(targetState = userName, modifier = weight) { + CreateTextWithEmoji( + text = it, + tags = baseUser.info?.tags, + fontWeight = fontWeight, + maxLines = 1, + ) + } } @Composable private fun WatchNotificationChanges( - note: Note, - route: String, - accountViewModel: AccountViewModel, - onNewStatus: (Boolean) -> Unit + note: Note, + route: String, + accountViewModel: AccountViewModel, + onNewStatus: (Boolean) -> Unit, ) { - LaunchedEffect(key1 = note, accountViewModel.accountMarkAsReadUpdates.intValue) { - launch(Dispatchers.IO) { - note.event?.createdAt()?.let { - val lastTime = accountViewModel.account.loadLastRead(route) - onNewStatus(it > lastTime) - } - } + LaunchedEffect(key1 = note, accountViewModel.accountMarkAsReadUpdates.intValue) { + launch(Dispatchers.IO) { + note.event?.createdAt()?.let { + val lastTime = accountViewModel.account.loadLastRead(route) + onNewStatus(it > lastTime) + } } + } } @Composable -fun LoadUser(baseUserHex: String, accountViewModel: AccountViewModel, content: @Composable (User?) -> Unit) { - var user by remember(baseUserHex) { - mutableStateOf(accountViewModel.getUserIfExists(baseUserHex)) - } +fun LoadUser( + baseUserHex: String, + accountViewModel: AccountViewModel, + content: @Composable (User?) -> Unit, +) { + var user by + remember(baseUserHex) { mutableStateOf(accountViewModel.getUserIfExists(baseUserHex)) } - if (user == null) { - LaunchedEffect(key1 = baseUserHex) { - accountViewModel.checkGetOrCreateUser(baseUserHex) { newUser -> - if (user != newUser) { - user = newUser - } - } + if (user == null) { + LaunchedEffect(key1 = baseUserHex) { + accountViewModel.checkGetOrCreateUser(baseUserHex) { newUser -> + if (user != newUser) { + user = newUser } + } } + } - content(user) + content(user) } @Composable fun ChannelName( - channelIdHex: String, - channelPicture: String?, - channelTitle: @Composable (Modifier) -> Unit, - channelLastTime: Long?, - channelLastContent: String?, - hasNewMessages: MutableState, - loadProfilePicture: Boolean, - onClick: () -> Unit + channelIdHex: String, + channelPicture: String?, + channelTitle: @Composable (Modifier) -> Unit, + channelLastTime: Long?, + channelLastContent: String?, + hasNewMessages: MutableState, + loadProfilePicture: Boolean, + onClick: () -> Unit, ) { - ChannelName( - channelPicture = { - RobohashFallbackAsyncImage( - robot = channelIdHex, - model = channelPicture, - contentDescription = stringResource(R.string.channel_image), - modifier = AccountPictureModifier, - loadProfilePicture = loadProfilePicture - ) - }, - channelTitle, - channelLastTime, - channelLastContent, - hasNewMessages, - onClick - ) + ChannelName( + channelPicture = { + RobohashFallbackAsyncImage( + robot = channelIdHex, + model = channelPicture, + contentDescription = stringResource(R.string.channel_image), + modifier = AccountPictureModifier, + loadProfilePicture = loadProfilePicture, + ) + }, + channelTitle, + channelLastTime, + channelLastContent, + hasNewMessages, + onClick, + ) } @Composable fun ChannelName( - channelPicture: @Composable () -> Unit, - channelTitle: @Composable (Modifier) -> Unit, - channelLastTime: Long?, - channelLastContent: String?, - hasNewMessages: MutableState, - onClick: () -> Unit + channelPicture: @Composable () -> Unit, + channelTitle: @Composable (Modifier) -> Unit, + channelLastTime: Long?, + channelLastContent: String?, + hasNewMessages: MutableState, + onClick: () -> Unit, ) { - ChatHeaderLayout( - channelPicture = channelPicture, - firstRow = { - channelTitle(Modifier.weight(1f)) - TimeAgo(channelLastTime) - }, - secondRow = { - if (channelLastContent != null) { - Text( - channelLastContent, - color = MaterialTheme.colorScheme.grayText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - modifier = Modifier.weight(1f) - ) - } else { - Text( - stringResource(R.string.referenced_event_not_found), - color = MaterialTheme.colorScheme.grayText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - } + ChatHeaderLayout( + channelPicture = channelPicture, + firstRow = { + channelTitle(Modifier.weight(1f)) + TimeAgo(channelLastTime) + }, + secondRow = { + if (channelLastContent != null) { + Text( + channelLastContent, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + modifier = Modifier.weight(1f), + ) + } else { + Text( + stringResource(R.string.referenced_event_not_found), + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } - if (hasNewMessages.value) { - NewItemsBubble() - } - }, - onClick = onClick - ) + if (hasNewMessages.value) { + NewItemsBubble() + } + }, + onClick = onClick, + ) } @Composable private fun TimeAgo(channelLastTime: Long?) { - if (channelLastTime == null) return + if (channelLastTime == null) return - val context = LocalContext.current - val timeAgo = remember(channelLastTime) { - timeAgo(channelLastTime, context) - } - Text( - text = timeAgo, - color = MaterialTheme.colorScheme.grayText, - maxLines = 1 - ) + val context = LocalContext.current + val timeAgo = remember(channelLastTime) { timeAgo(channelLastTime, context) } + Text( + text = timeAgo, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + ) } @Composable fun NewItemsBubble() { - Box( - modifier = Modifier - .padding(start = 3.dp) - .width(10.dp) - .height(10.dp) - .clip(shape = CircleShape) - .background(MaterialTheme.colorScheme.primary), - contentAlignment = Alignment.Center - ) { - Text( - "", - color = Color.White, - textAlign = TextAlign.Center, - fontSize = 12.sp, - maxLines = 1, - modifier = Modifier - .wrapContentHeight() - .align(Alignment.Center) - ) - } + Box( + modifier = + Modifier.padding(start = 3.dp) + .width(10.dp) + .height(10.dp) + .clip(shape = CircleShape) + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center, + ) { + Text( + "", + color = Color.White, + textAlign = TextAlign.Center, + fontSize = 12.sp, + maxLines = 1, + modifier = Modifier.wrapContentHeight().align(Alignment.Center), + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt index 4c0994b7a..88bbce3cb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.note import androidx.compose.animation.Crossfade @@ -80,726 +100,690 @@ import com.vitorpamplona.quartz.events.toImmutableListOfLists @OptIn(ExperimentalFoundationApi::class) @Composable fun ChatroomMessageCompose( - baseNote: Note, - routeForLastRead: String?, - innerQuote: Boolean = false, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - onWantsToReply: (Note) -> Unit + baseNote: Note, + routeForLastRead: String?, + innerQuote: Boolean = false, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + onWantsToReply: (Note) -> Unit, ) { - val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null) + val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null) - Crossfade(targetState = hasEvent) { - if (it) { - CheckHiddenChatMessage( - baseNote, - routeForLastRead, - innerQuote, - parentBackgroundColor, - accountViewModel, - nav, - onWantsToReply + Crossfade(targetState = hasEvent) { + if (it) { + CheckHiddenChatMessage( + baseNote, + routeForLastRead, + innerQuote, + parentBackgroundColor, + accountViewModel, + nav, + onWantsToReply, + ) + } else { + LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup, + -> + BlankNote( + remember { + Modifier.combinedClickable( + onClick = {}, + onLongClick = showPopup, ) - } else { - LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> - BlankNote( - remember { - Modifier.combinedClickable( - onClick = { }, - onLongClick = showPopup - ) - } - ) - } - } + }, + ) + } } + } } @Composable fun CheckHiddenChatMessage( - baseNote: Note, - routeForLastRead: String?, - innerQuote: Boolean = false, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - onWantsToReply: (Note) -> Unit + baseNote: Note, + routeForLastRead: String?, + innerQuote: Boolean = false, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + onWantsToReply: (Note) -> Unit, ) { - val isHidden by remember { - accountViewModel.account.liveHiddenUsers.map { - baseNote.isHiddenFor(it) - }.distinctUntilChanged() - }.observeAsState(accountViewModel.isNoteHidden(baseNote)) + val isHidden by + remember { + accountViewModel.account.liveHiddenUsers + .map { baseNote.isHiddenFor(it) } + .distinctUntilChanged() + } + .observeAsState(accountViewModel.isNoteHidden(baseNote)) - if (!isHidden) { - LoadedChatMessageCompose( - baseNote, - routeForLastRead, - innerQuote, - parentBackgroundColor, - accountViewModel, - nav, - onWantsToReply - ) - } + if (!isHidden) { + LoadedChatMessageCompose( + baseNote, + routeForLastRead, + innerQuote, + parentBackgroundColor, + accountViewModel, + nav, + onWantsToReply, + ) + } } @Composable fun LoadedChatMessageCompose( - baseNote: Note, - routeForLastRead: String?, - innerQuote: Boolean = false, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - onWantsToReply: (Note) -> Unit + baseNote: Note, + routeForLastRead: String?, + innerQuote: Boolean = false, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + onWantsToReply: (Note) -> Unit, ) { - var state by remember { - mutableStateOf( - AccountViewModel.NoteComposeReportState() - ) + var state by remember { + mutableStateOf( + AccountViewModel.NoteComposeReportState(), + ) + } + + WatchForReports(baseNote, accountViewModel) { newState -> + if (state != newState) { + state = newState + } + } + + var showReportedNote by remember { mutableStateOf(false) } + + val showHiddenNote by + remember(state, showReportedNote) { + derivedStateOf { !state.isAcceptable && !showReportedNote } } - WatchForReports(baseNote, accountViewModel) { newState -> - if (state != newState) { - state = newState - } - } - - var showReportedNote by remember { mutableStateOf(false) } - - val showHiddenNote by remember(state, showReportedNote) { - derivedStateOf { - !state.isAcceptable && !showReportedNote - } - } - - Crossfade(targetState = showHiddenNote) { - if (it) { - HiddenNote( - state.relevantReports, - state.isHiddenAuthor, - accountViewModel, - Modifier, - innerQuote, - nav, - onClick = { showReportedNote = true } - ) - } else { - val canPreview by remember(state, showReportedNote) { - derivedStateOf { - (!state.isAcceptable && showReportedNote) || state.canPreview - } - } - - NormalChatNote( - baseNote, - routeForLastRead, - innerQuote, - canPreview, - parentBackgroundColor, - accountViewModel, - nav, - onWantsToReply - ) + Crossfade(targetState = showHiddenNote) { + if (it) { + HiddenNote( + state.relevantReports, + state.isHiddenAuthor, + accountViewModel, + Modifier, + innerQuote, + nav, + onClick = { showReportedNote = true }, + ) + } else { + val canPreview by + remember(state, showReportedNote) { + derivedStateOf { (!state.isAcceptable && showReportedNote) || state.canPreview } } + + NormalChatNote( + baseNote, + routeForLastRead, + innerQuote, + canPreview, + parentBackgroundColor, + accountViewModel, + nav, + onWantsToReply, + ) } + } } @OptIn(ExperimentalFoundationApi::class) @Composable fun NormalChatNote( - note: Note, - routeForLastRead: String?, - innerQuote: Boolean = false, - canPreview: Boolean = true, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - onWantsToReply: (Note) -> Unit + note: Note, + routeForLastRead: String?, + innerQuote: Boolean = false, + canPreview: Boolean = true, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + onWantsToReply: (Note) -> Unit, ) { - val drawAuthorInfo by remember(note) { - derivedStateOf { - val noteEvent = note.event - if (accountViewModel.isLoggedUser(note.author)) { - false // never shows the user's pictures - } else if (noteEvent is PrivateDmEvent) { - false // one-on-one, never shows it. - } else if (noteEvent is ChatMessageEvent) { - // only shows in a group chat. - noteEvent.chatroomKey(accountViewModel.userProfile().pubkeyHex).users.size > 1 - } else { - true + val drawAuthorInfo by + remember(note) { + derivedStateOf { + val noteEvent = note.event + if (accountViewModel.isLoggedUser(note.author)) { + false // never shows the user's pictures + } else if (noteEvent is PrivateDmEvent) { + false // one-on-one, never shows it. + } else if (noteEvent is ChatMessageEvent) { + // only shows in a group chat. + noteEvent.chatroomKey(accountViewModel.userProfile().pubkeyHex).users.size > 1 + } else { + true + } + } + } + + val loggedInColors = MaterialTheme.colorScheme.mediumImportanceLink + val otherColors = MaterialTheme.colorScheme.subtleBorder + val defaultBackground = MaterialTheme.colorScheme.background + + val backgroundBubbleColor = remember { + if (accountViewModel.isLoggedUser(note.author)) { + mutableStateOf( + loggedInColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground), + ) + } else { + mutableStateOf(otherColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground)) + } + } + val alignment: Arrangement.Horizontal = remember { + if (accountViewModel.isLoggedUser(note.author)) { + Arrangement.End + } else { + Arrangement.Start + } + } + val shape: Shape = remember { + if (accountViewModel.isLoggedUser(note.author)) { + ChatBubbleShapeMe + } else { + ChatBubbleShapeThem + } + } + + if (routeForLastRead != null) { + LaunchedEffect(key1 = routeForLastRead) { + accountViewModel.loadAndMarkAsRead(routeForLastRead, note.createdAt()) {} + } + } + + Column { + Row( + modifier = if (innerQuote) ChatPaddingInnerQuoteModifier else ChatPaddingModifier, + horizontalArrangement = alignment, + ) { + val availableBubbleSize = remember { mutableIntStateOf(0) } + var popupExpanded by remember { mutableStateOf(false) } + + val modif2 = if (innerQuote) Modifier else ChatBubbleMaxSizeModifier + + val clickableModifier = remember { + Modifier.combinedClickable( + onClick = { + if (note.event is ChannelCreateEvent) { + nav("Channel/${note.idHex}") } - } - } + }, + onLongClick = { popupExpanded = true }, + ) + } - val loggedInColors = MaterialTheme.colorScheme.mediumImportanceLink - val otherColors = MaterialTheme.colorScheme.subtleBorder - val defaultBackground = MaterialTheme.colorScheme.background - - val backgroundBubbleColor = remember { - if (accountViewModel.isLoggedUser(note.author)) { - mutableStateOf(loggedInColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground)) - } else { - mutableStateOf(otherColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground)) - } - } - val alignment: Arrangement.Horizontal = remember { - if (accountViewModel.isLoggedUser(note.author)) { - Arrangement.End - } else { - Arrangement.Start - } - } - val shape: Shape = remember { - if (accountViewModel.isLoggedUser(note.author)) { - ChatBubbleShapeMe - } else { - ChatBubbleShapeThem - } - } - - if (routeForLastRead != null) { - LaunchedEffect(key1 = routeForLastRead) { - accountViewModel.loadAndMarkAsRead(routeForLastRead, note.createdAt()) { } - } - } - - Column() { - Row( - modifier = if (innerQuote) ChatPaddingInnerQuoteModifier else ChatPaddingModifier, - horizontalArrangement = alignment + Row( + horizontalArrangement = alignment, + modifier = + modif2.onSizeChanged { + if (availableBubbleSize.intValue != it.width) { + availableBubbleSize.intValue = it.width + } + }, + ) { + Surface( + color = backgroundBubbleColor.value, + shape = shape, + modifier = clickableModifier, ) { - val availableBubbleSize = remember { mutableIntStateOf(0) } - var popupExpanded by remember { mutableStateOf(false) } - - val modif2 = if (innerQuote) Modifier else ChatBubbleMaxSizeModifier - - val clickableModifier = remember { - Modifier - .combinedClickable( - onClick = { - if (note.event is ChannelCreateEvent) { - nav("Channel/${note.idHex}") - } - }, - onLongClick = { popupExpanded = true } - ) - } - - Row( - horizontalArrangement = alignment, - modifier = modif2.onSizeChanged { - if (availableBubbleSize.intValue != it.width) { - availableBubbleSize.intValue = it.width - } - } - ) { - Surface( - color = backgroundBubbleColor.value, - shape = shape, - modifier = clickableModifier - ) { - RenderBubble( - note, - drawAuthorInfo, - alignment, - innerQuote, - backgroundBubbleColor, - onWantsToReply, - canPreview, - availableBubbleSize, - accountViewModel, - nav - ) - } - } - - NoteQuickActionMenu( - note = note, - popupExpanded = popupExpanded, - onDismiss = { popupExpanded = false }, - accountViewModel = accountViewModel - ) + RenderBubble( + note, + drawAuthorInfo, + alignment, + innerQuote, + backgroundBubbleColor, + onWantsToReply, + canPreview, + availableBubbleSize, + accountViewModel, + nav, + ) } + } + + NoteQuickActionMenu( + note = note, + popupExpanded = popupExpanded, + onDismiss = { popupExpanded = false }, + accountViewModel = accountViewModel, + ) } + } } @Composable private fun RenderBubble( - baseNote: Note, - drawAuthorInfo: Boolean, - alignment: Arrangement.Horizontal, - innerQuote: Boolean, - backgroundBubbleColor: MutableState, - onWantsToReply: (Note) -> Unit, - canPreview: Boolean, - availableBubbleSize: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + drawAuthorInfo: Boolean, + alignment: Arrangement.Horizontal, + innerQuote: Boolean, + backgroundBubbleColor: MutableState, + onWantsToReply: (Note) -> Unit, + canPreview: Boolean, + availableBubbleSize: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val bubbleSize = remember { mutableIntStateOf(0) } + val bubbleSize = remember { mutableIntStateOf(0) } - val bubbleModifier = remember { - Modifier - .padding(start = 10.dp, end = 5.dp, bottom = 5.dp) - .onSizeChanged { - if (bubbleSize.intValue != it.width) { - bubbleSize.intValue = it.width - } - } + val bubbleModifier = remember { + Modifier.padding(start = 10.dp, end = 5.dp, bottom = 5.dp).onSizeChanged { + if (bubbleSize.intValue != it.width) { + bubbleSize.intValue = it.width + } } + } - Column(modifier = bubbleModifier) { - MessageBubbleLines( - drawAuthorInfo, - baseNote, - alignment, - nav, - innerQuote, - backgroundBubbleColor, - accountViewModel, - onWantsToReply, - canPreview, - bubbleSize, - availableBubbleSize - ) - } + Column(modifier = bubbleModifier) { + MessageBubbleLines( + drawAuthorInfo, + baseNote, + alignment, + nav, + innerQuote, + backgroundBubbleColor, + accountViewModel, + onWantsToReply, + canPreview, + bubbleSize, + availableBubbleSize, + ) + } } @Composable private fun MessageBubbleLines( - drawAuthorInfo: Boolean, - baseNote: Note, - alignment: Arrangement.Horizontal, - nav: (String) -> Unit, - innerQuote: Boolean, - backgroundBubbleColor: MutableState, - accountViewModel: AccountViewModel, - onWantsToReply: (Note) -> Unit, - canPreview: Boolean, - bubbleSize: MutableState, - availableBubbleSize: MutableState + drawAuthorInfo: Boolean, + baseNote: Note, + alignment: Arrangement.Horizontal, + nav: (String) -> Unit, + innerQuote: Boolean, + backgroundBubbleColor: MutableState, + accountViewModel: AccountViewModel, + onWantsToReply: (Note) -> Unit, + canPreview: Boolean, + bubbleSize: MutableState, + availableBubbleSize: MutableState, ) { - if (drawAuthorInfo) { - DrawAuthorInfo( - baseNote, - alignment, - accountViewModel.settings.showProfilePictures.value, - nav - ) - } else { - Spacer(modifier = StdVertSpacer) - } + if (drawAuthorInfo) { + DrawAuthorInfo( + baseNote, + alignment, + accountViewModel.settings.showProfilePictures.value, + nav, + ) + } else { + Spacer(modifier = StdVertSpacer) + } - RenderReplyRow( - note = baseNote, - innerQuote = innerQuote, - backgroundBubbleColor = backgroundBubbleColor, + RenderReplyRow( + note = baseNote, + innerQuote = innerQuote, + backgroundBubbleColor = backgroundBubbleColor, + accountViewModel = accountViewModel, + nav = nav, + onWantsToReply = onWantsToReply, + ) + + NoteRow( + note = baseNote, + canPreview = canPreview, + backgroundBubbleColor = backgroundBubbleColor, + accountViewModel = accountViewModel, + nav = nav, + ) + + ConstrainedStatusRow( + bubbleSize = bubbleSize, + availableBubbleSize = availableBubbleSize, + firstColumn = { + IncognitoBadge(baseNote) + ChatTimeAgo(baseNote) + RelayBadgesHorizontal(baseNote, accountViewModel, nav = nav) + Spacer(modifier = DoubleHorzSpacer) + }, + secondColumn = { + LikeReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav) + Spacer(modifier = StdHorzSpacer) + ZapReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav) + Spacer(modifier = DoubleHorzSpacer) + ReplyReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.placeholderText, accountViewModel = accountViewModel, - nav = nav, - onWantsToReply = onWantsToReply - ) - - NoteRow( - note = baseNote, - canPreview = canPreview, - backgroundBubbleColor = backgroundBubbleColor, - accountViewModel = accountViewModel, - nav = nav - ) - - ConstrainedStatusRow( - bubbleSize = bubbleSize, - availableBubbleSize = availableBubbleSize, - firstColumn = { - IncognitoBadge(baseNote) - ChatTimeAgo(baseNote) - RelayBadgesHorizontal(baseNote, accountViewModel, nav = nav) - Spacer(modifier = DoubleHorzSpacer) - }, - secondColumn = { - LikeReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav) - Spacer(modifier = StdHorzSpacer) - ZapReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav) - Spacer(modifier = DoubleHorzSpacer) - ReplyReaction( - baseNote = baseNote, - grayTint = MaterialTheme.colorScheme.placeholderText, - accountViewModel = accountViewModel, - showCounter = false, - iconSizeModifier = Size15Modifier - ) { - onWantsToReply(baseNote) - } - Spacer(modifier = StdHorzSpacer) - } - ) + showCounter = false, + iconSizeModifier = Size15Modifier, + ) { + onWantsToReply(baseNote) + } + Spacer(modifier = StdHorzSpacer) + }, + ) } @Composable private fun RenderReplyRow( - note: Note, - innerQuote: Boolean, - backgroundBubbleColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - onWantsToReply: (Note) -> Unit + note: Note, + innerQuote: Boolean, + backgroundBubbleColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + onWantsToReply: (Note) -> Unit, ) { - val hasReply by remember { - derivedStateOf { - !innerQuote && note.replyTo?.lastOrNull() != null - } - } + val hasReply by remember { derivedStateOf { !innerQuote && note.replyTo?.lastOrNull() != null } } - if (hasReply) { - RenderReply(note, backgroundBubbleColor, accountViewModel, nav, onWantsToReply) - } + if (hasReply) { + RenderReply(note, backgroundBubbleColor, accountViewModel, nav, onWantsToReply) + } } @Composable private fun RenderReply( - note: Note, - backgroundBubbleColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - onWantsToReply: (Note) -> Unit + note: Note, + backgroundBubbleColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + onWantsToReply: (Note) -> Unit, ) { - Row(verticalAlignment = Alignment.CenterVertically) { - val replyTo by remember { - derivedStateOf { - note.replyTo?.lastOrNull() - } - } - replyTo?.let { note -> - ChatroomMessageCompose( - note, - null, - innerQuote = true, - parentBackgroundColor = backgroundBubbleColor, - accountViewModel = accountViewModel, - nav = nav, - onWantsToReply = onWantsToReply - ) - } + Row(verticalAlignment = Alignment.CenterVertically) { + val replyTo by remember { derivedStateOf { note.replyTo?.lastOrNull() } } + replyTo?.let { note -> + ChatroomMessageCompose( + note, + null, + innerQuote = true, + parentBackgroundColor = backgroundBubbleColor, + accountViewModel = accountViewModel, + nav = nav, + onWantsToReply = onWantsToReply, + ) } + } } @Composable private fun NoteRow( - note: Note, - canPreview: Boolean, - backgroundBubbleColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + note: Note, + canPreview: Boolean, + backgroundBubbleColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - Row(verticalAlignment = Alignment.CenterVertically) { - when (remember(note) { note.event }) { - is ChannelCreateEvent -> { - RenderCreateChannelNote(note) - } - - is ChannelMetadataEvent -> { - RenderChangeChannelMetadataNote(note) - } - - else -> { - RenderRegularTextNote( - note, - canPreview, - backgroundBubbleColor, - accountViewModel, - nav - ) - } - } + Row(verticalAlignment = Alignment.CenterVertically) { + when (remember(note) { note.event }) { + is ChannelCreateEvent -> { + RenderCreateChannelNote(note) + } + is ChannelMetadataEvent -> { + RenderChangeChannelMetadataNote(note) + } + else -> { + RenderRegularTextNote( + note, + canPreview, + backgroundBubbleColor, + accountViewModel, + nav, + ) + } } + } } @Composable private fun ConstrainedStatusRow( - bubbleSize: MutableState, - availableBubbleSize: MutableState, - firstColumn: @Composable () -> Unit, - secondColumn: @Composable () -> Unit + bubbleSize: MutableState, + availableBubbleSize: MutableState, + firstColumn: @Composable () -> Unit, + secondColumn: @Composable () -> Unit, ) { - Row( + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = + with(LocalDensity.current) { + Modifier.padding(top = Size5dp) + .height(Size20dp) + .widthIn( + bubbleSize.value.toDp(), + availableBubbleSize.value.toDp(), + ) + }, + ) { + Column(modifier = ReactionRowHeightChat) { + Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = with(LocalDensity.current) { - Modifier - .padding(top = Size5dp) - .height(Size20dp) - .widthIn( - bubbleSize.value.toDp(), - availableBubbleSize.value.toDp() - ) - } - ) { - Column(modifier = ReactionRowHeightChat) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = ReactionRowHeightChat - ) { - firstColumn() - } - } - - Column(modifier = ReactionRowHeightChat) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = ReactionRowHeightChat - ) { - secondColumn() - } - } + modifier = ReactionRowHeightChat, + ) { + firstColumn() + } } + + Column(modifier = ReactionRowHeightChat) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = ReactionRowHeightChat, + ) { + secondColumn() + } + } + } } @Composable fun IncognitoBadge(baseNote: Note) { - if (baseNote.event is ChatMessageEvent) { - Icon( - painter = painterResource(id = R.drawable.incognito), - null, - modifier = Modifier - .padding(top = 1.dp) - .size(14.dp), - tint = MaterialTheme.colorScheme.placeholderText - ) - Spacer(modifier = StdHorzSpacer) - } else if (baseNote.event is PrivateDmEvent) { - Icon( - painter = painterResource(id = R.drawable.incognito_off), - null, - modifier = Modifier - .padding(top = 1.dp) - .size(14.dp), - tint = MaterialTheme.colorScheme.placeholderText - ) - Spacer(modifier = StdHorzSpacer) - } + if (baseNote.event is ChatMessageEvent) { + Icon( + painter = painterResource(id = R.drawable.incognito), + null, + modifier = Modifier.padding(top = 1.dp).size(14.dp), + tint = MaterialTheme.colorScheme.placeholderText, + ) + Spacer(modifier = StdHorzSpacer) + } else if (baseNote.event is PrivateDmEvent) { + Icon( + painter = painterResource(id = R.drawable.incognito_off), + null, + modifier = Modifier.padding(top = 1.dp).size(14.dp), + tint = MaterialTheme.colorScheme.placeholderText, + ) + Spacer(modifier = StdHorzSpacer) + } } @Composable fun ChatTimeAgo(baseNote: Note) { - val nowStr = stringResource(id = R.string.now) + val nowStr = stringResource(id = R.string.now) - val time by remember(baseNote) { - derivedStateOf { - timeAgoShort(baseNote.createdAt() ?: 0, nowStr) - } - } + val time by + remember(baseNote) { derivedStateOf { timeAgoShort(baseNote.createdAt() ?: 0, nowStr) } } - Text( - text = time, - color = MaterialTheme.colorScheme.placeholderText, - fontSize = Font12SP, - maxLines = 1 - ) + Text( + text = time, + color = MaterialTheme.colorScheme.placeholderText, + fontSize = Font12SP, + maxLines = 1, + ) } @Composable private fun RenderRegularTextNote( - note: Note, - canPreview: Boolean, - backgroundBubbleColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + note: Note, + canPreview: Boolean, + backgroundBubbleColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val tags = remember(note.event) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } - val modifier = remember { Modifier.padding(top = 5.dp) } + val tags = remember(note.event) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } + val modifier = remember { Modifier.padding(top = 5.dp) } - LoadDecryptedContentOrNull(note = note, accountViewModel = accountViewModel) { eventContent -> - if (eventContent != null) { - SensitivityWarning( - note = note, - accountViewModel = accountViewModel - ) { - TranslatableRichTextViewer( - content = eventContent!!, - canPreview = canPreview, - modifier = modifier, - tags = tags, - backgroundColor = backgroundBubbleColor, - accountViewModel = accountViewModel, - nav = nav - ) - } - } else { - TranslatableRichTextViewer( - content = stringResource(id = R.string.could_not_decrypt_the_message), - canPreview = true, - modifier = modifier, - tags = tags, - backgroundColor = backgroundBubbleColor, - accountViewModel = accountViewModel, - nav = nav - ) - } + LoadDecryptedContentOrNull(note = note, accountViewModel = accountViewModel) { eventContent -> + if (eventContent != null) { + SensitivityWarning( + note = note, + accountViewModel = accountViewModel, + ) { + TranslatableRichTextViewer( + content = eventContent!!, + canPreview = canPreview, + modifier = modifier, + tags = tags, + backgroundColor = backgroundBubbleColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } else { + TranslatableRichTextViewer( + content = stringResource(id = R.string.could_not_decrypt_the_message), + canPreview = true, + modifier = modifier, + tags = tags, + backgroundColor = backgroundBubbleColor, + accountViewModel = accountViewModel, + nav = nav, + ) } + } } @Composable -private fun RenderChangeChannelMetadataNote( - note: Note -) { - val noteEvent = note.event as? ChannelMetadataEvent ?: return +private fun RenderChangeChannelMetadataNote(note: Note) { + val noteEvent = note.event as? ChannelMetadataEvent ?: return - val channelInfo = noteEvent.channelInfo() - val text = note.author?.toBestDisplayName() - .toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + ( - channelInfo.name - ?: "" - ) + "', ${stringResource(R.string.description_to)} '" + ( - channelInfo.about - ?: "" - ) + "', ${stringResource(R.string.and_picture_to)} '" + ( - channelInfo.picture - ?: "" - ) + "'" + val channelInfo = noteEvent.channelInfo() + val text = + note.author?.toBestDisplayName().toString() + + " ${stringResource(R.string.changed_chat_name_to)} '" + + (channelInfo.name ?: "") + + "', ${stringResource(R.string.description_to)} '" + + (channelInfo.about ?: "") + + "', ${stringResource(R.string.and_picture_to)} '" + + (channelInfo.picture ?: "") + + "'" - CreateTextWithEmoji( - text = text, - tags = remember { note.author?.info?.latestMetadata?.tags?.toImmutableListOfLists() } - ) + CreateTextWithEmoji( + text = text, + tags = remember { note.author?.info?.latestMetadata?.tags?.toImmutableListOfLists() }, + ) } @Composable private fun RenderCreateChannelNote(note: Note) { - val noteEvent = note.event as? ChannelCreateEvent ?: return - val channelInfo = remember { noteEvent.channelInfo() } + val noteEvent = note.event as? ChannelCreateEvent ?: return + val channelInfo = remember { noteEvent.channelInfo() } - val text = note.author?.toBestDisplayName() - .toString() + " ${stringResource(R.string.created)} " + ( - channelInfo.name - ?: "" - ) + " ${stringResource(R.string.with_description_of)} '" + ( - channelInfo.about - ?: "" - ) + "', ${stringResource(R.string.and_picture)} '" + ( - channelInfo.picture - ?: "" - ) + "'" + val text = + note.author?.toBestDisplayName().toString() + + " ${stringResource(R.string.created)} " + + (channelInfo.name ?: "") + + " ${stringResource(R.string.with_description_of)} '" + + (channelInfo.about ?: "") + + "', ${stringResource(R.string.and_picture)} '" + + (channelInfo.picture ?: "") + + "'" - CreateTextWithEmoji( - text = text, - tags = remember { note.author?.info?.latestMetadata?.tags?.toImmutableListOfLists() } - ) + CreateTextWithEmoji( + text = text, + tags = remember { note.author?.info?.latestMetadata?.tags?.toImmutableListOfLists() }, + ) } @Composable private fun DrawAuthorInfo( - baseNote: Note, - alignment: Arrangement.Horizontal, - loadProfilePicture: Boolean, - nav: (String) -> Unit + baseNote: Note, + alignment: Arrangement.Horizontal, + loadProfilePicture: Boolean, + nav: (String) -> Unit, ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = alignment, - modifier = Modifier.padding(top = Size10dp) - ) { - DisplayAndWatchNoteAuthor(baseNote, loadProfilePicture, nav) - } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = alignment, + modifier = Modifier.padding(top = Size10dp), + ) { + DisplayAndWatchNoteAuthor(baseNote, loadProfilePicture, nav) + } } @Composable private fun DisplayAndWatchNoteAuthor( - baseNote: Note, - loadProfilePicture: Boolean, - nav: (String) -> Unit + baseNote: Note, + loadProfilePicture: Boolean, + nav: (String) -> Unit, ) { - val author = remember { - baseNote.author - } - author?.let { - WatchAndDisplayUser(it, loadProfilePicture, nav) - } + val author = remember { baseNote.author } + author?.let { WatchAndDisplayUser(it, loadProfilePicture, nav) } } @Composable private fun WatchAndDisplayUser( - author: User, - loadProfilePicture: Boolean, - nav: (String) -> Unit + author: User, + loadProfilePicture: Boolean, + nav: (String) -> Unit, ) { - val pubkeyHex = remember { author.pubkeyHex } - val route = remember { "User/${author.pubkeyHex}" } + val pubkeyHex = remember { author.pubkeyHex } + val route = remember { "User/${author.pubkeyHex}" } - val userState by author.live().metadata.observeAsState() + val userState by author.live().metadata.observeAsState() - val userDisplayName by remember(userState) { - derivedStateOf { - userState?.user?.toBestDisplayName() - } + val userDisplayName by + remember(userState) { derivedStateOf { userState?.user?.toBestDisplayName() } } + + val userProfilePicture by + remember(userState) { derivedStateOf { userState?.user?.profilePicture() } } + + val userTags by + remember(userState) { + derivedStateOf { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() } } - val userProfilePicture by remember(userState) { - derivedStateOf { - userState?.user?.profilePicture() - } - } + UserIcon(pubkeyHex, userProfilePicture, loadProfilePicture, nav, route) - val userTags by remember(userState) { - derivedStateOf { - userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() - } - } - - UserIcon(pubkeyHex, userProfilePicture, loadProfilePicture, nav, route) - - userDisplayName?.let { - DisplayMessageUsername(it, userTags, route, nav) - } + userDisplayName?.let { DisplayMessageUsername(it, userTags, route, nav) } } @Composable private fun UserIcon( - pubkeyHex: String, - userProfilePicture: String?, - loadProfilePicture: Boolean, - nav: (String) -> Unit, - route: String + pubkeyHex: String, + userProfilePicture: String?, + loadProfilePicture: Boolean, + nav: (String) -> Unit, + route: String, ) { - RobohashFallbackAsyncImage( - robot = pubkeyHex, - model = userProfilePicture, - contentDescription = stringResource(id = R.string.profile_image), - loadProfilePicture = loadProfilePicture, - modifier = remember { - Modifier - .width(Size25dp) - .height(Size25dp) - .clip(shape = CircleShape) - .clickable(onClick = { - nav(route) - }) - } - ) + RobohashFallbackAsyncImage( + robot = pubkeyHex, + model = userProfilePicture, + contentDescription = stringResource(id = R.string.profile_image), + loadProfilePicture = loadProfilePicture, + modifier = + remember { + Modifier.width(Size25dp) + .height(Size25dp) + .clip(shape = CircleShape) + .clickable(onClick = { nav(route) }) + }, + ) } @Composable private fun DisplayMessageUsername( - userDisplayName: String, - userTags: ImmutableListOfLists?, - route: String, - nav: (String) -> Unit + userDisplayName: String, + userTags: ImmutableListOfLists?, + route: String, + nav: (String) -> Unit, ) { - Spacer(modifier = StdHorzSpacer) - CreateClickableTextWithEmoji( - clickablePart = userDisplayName, - suffix = "", - maxLines = 1, - tags = userTags, - fontWeight = FontWeight.Bold, - overrideColor = MaterialTheme.colorScheme.onBackground, // we do not want clickable names in purple here. - route = route, - nav = nav - ) + Spacer(modifier = StdHorzSpacer) + CreateClickableTextWithEmoji( + clickablePart = userDisplayName, + suffix = "", + maxLines = 1, + tags = userTags, + fontWeight = FontWeight.Bold, + overrideColor = MaterialTheme.colorScheme.onBackground, + route = route, + nav = nav, + ) - Spacer(modifier = StdHorzSpacer) - DrawPlayName(userDisplayName) + Spacer(modifier = StdHorzSpacer) + DrawPlayName(userDisplayName) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt index b94b0a4ae..d0b504159 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.note import androidx.compose.foundation.layout.size @@ -40,371 +60,416 @@ import com.vitorpamplona.amethyst.ui.theme.subtleButton @Composable fun AmethystIcon(iconSize: Dp) { - Icon( - painter = painterResource(R.drawable.amethyst), - null, - modifier = Modifier.size(iconSize), - tint = Color.Unspecified - ) + Icon( + painter = painterResource(R.drawable.amethyst), + null, + modifier = Modifier.size(iconSize), + tint = Color.Unspecified, + ) } @Composable fun FollowingIcon(iconSize: Dp) { - Icon( - painter = painterResource(R.drawable.following), - contentDescription = stringResource(id = R.string.following), - modifier = remember(iconSize) { Modifier.size(iconSize) }, - tint = Color.Unspecified - ) + Icon( + painter = painterResource(R.drawable.following), + contentDescription = stringResource(id = R.string.following), + modifier = remember(iconSize) { Modifier.size(iconSize) }, + tint = Color.Unspecified, + ) } @Composable fun ArrowBackIcon() { - Icon( - imageVector = Icons.Default.ArrowBack, - contentDescription = stringResource(R.string.back), - tint = MaterialTheme.colorScheme.grayText - ) + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = stringResource(R.string.back), + tint = MaterialTheme.colorScheme.grayText, + ) } @Composable fun MessageIcon(modifier: Modifier) { - Icon( - painter = painterResource(R.drawable.ic_dm), - null, - modifier = modifier, - tint = MaterialTheme.colorScheme.primary - ) + Icon( + painter = painterResource(R.drawable.ic_dm), + null, + modifier = modifier, + tint = MaterialTheme.colorScheme.primary, + ) } @Composable -fun DownloadForOfflineIcon(iconSize: Dp, tint: Color = MaterialTheme.colorScheme.primary) { - Icon( - imageVector = Icons.Default.DownloadForOffline, - null, - modifier = remember(iconSize) { Modifier.size(iconSize) }, - tint = tint - ) +fun DownloadForOfflineIcon( + iconSize: Dp, + tint: Color = MaterialTheme.colorScheme.primary, +) { + Icon( + imageVector = Icons.Default.DownloadForOffline, + null, + modifier = remember(iconSize) { Modifier.size(iconSize) }, + tint = tint, + ) } @Composable fun HashCheckIcon(iconSize: Dp) { - Icon( - painter = painterResource(R.drawable.original), - contentDescription = stringResource(id = R.string.hash_verification_passed), - modifier = remember(iconSize) { Modifier.size(iconSize) }, - tint = Color.Unspecified - ) + Icon( + painter = painterResource(R.drawable.original), + contentDescription = stringResource(id = R.string.hash_verification_passed), + modifier = remember(iconSize) { Modifier.size(iconSize) }, + tint = Color.Unspecified, + ) } @Composable fun HashCheckFailedIcon(iconSize: Dp) { - Icon( - imageVector = Icons.Default.Report, - contentDescription = stringResource(id = R.string.hash_verification_failed), - modifier = remember(iconSize) { Modifier.size(iconSize) }, - tint = Color.Red - ) + Icon( + imageVector = Icons.Default.Report, + contentDescription = stringResource(id = R.string.hash_verification_failed), + modifier = remember(iconSize) { Modifier.size(iconSize) }, + tint = Color.Red, + ) } @Composable fun LikedIcon(iconSize: Dp) { - LikedIcon(modifier = remember(iconSize) { Modifier.size(iconSize) }) + LikedIcon(modifier = remember(iconSize) { Modifier.size(iconSize) }) } @Composable fun LikedIcon(modifier: Modifier) { - Icon( - painter = painterResource(R.drawable.ic_liked), - null, - modifier = modifier, - tint = Color.Unspecified - ) + Icon( + painter = painterResource(R.drawable.ic_liked), + null, + modifier = modifier, + tint = Color.Unspecified, + ) } @Composable -fun LikeIcon(iconSizeModifier: Modifier, grayTint: Color) { - Icon( - painter = painterResource(R.drawable.ic_like), - null, - modifier = iconSizeModifier, - tint = grayTint - ) +fun LikeIcon( + iconSizeModifier: Modifier, + grayTint: Color, +) { + Icon( + painter = painterResource(R.drawable.ic_like), + null, + modifier = iconSizeModifier, + tint = grayTint, + ) } @Composable -fun RepostedIcon(modifier: Modifier, tint: Color = Color.Unspecified) { - Icon( - painter = painterResource(R.drawable.ic_retweeted), - null, - modifier = modifier, - tint = tint - ) +fun RepostedIcon( + modifier: Modifier, + tint: Color = Color.Unspecified, +) { + Icon( + painter = painterResource(R.drawable.ic_retweeted), + null, + modifier = modifier, + tint = tint, + ) } @Composable -fun LightningAddressIcon(modifier: Modifier, tint: Color = Color.Unspecified) { - Icon( - imageVector = Icons.Default.Bolt, - contentDescription = stringResource(R.string.lightning_address), - tint = tint, - modifier = modifier - ) +fun LightningAddressIcon( + modifier: Modifier, + tint: Color = Color.Unspecified, +) { + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = stringResource(R.string.lightning_address), + tint = tint, + modifier = modifier, + ) } @Composable fun ZappedIcon(iconSize: Dp) { - ZappedIcon(modifier = remember(iconSize) { Modifier.size(iconSize) }) + ZappedIcon(modifier = remember(iconSize) { Modifier.size(iconSize) }) } @Composable fun ZappedIcon(modifier: Modifier) { - ZapIcon(modifier = modifier, BitcoinOrange) + ZapIcon(modifier = modifier, BitcoinOrange) } @Composable -fun ZapIcon(modifier: Modifier, tint: Color = Color.Unspecified) { - Icon( - imageVector = Icons.Default.Bolt, - contentDescription = stringResource(R.string.zaps), - tint = tint, - modifier = modifier - ) +fun ZapIcon( + modifier: Modifier, + tint: Color = Color.Unspecified, +) { + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = stringResource(R.string.zaps), + tint = tint, + modifier = modifier, + ) } @Composable fun CashuIcon(modifier: Modifier) { - Icon( - painter = painterResource(R.drawable.cashu), - "Cashu", - tint = Color.Unspecified, - modifier = modifier - ) + Icon( + painter = painterResource(R.drawable.cashu), + "Cashu", + tint = Color.Unspecified, + modifier = modifier, + ) } @Composable -fun CopyIcon(modifier: Modifier, tint: Color = Color.Unspecified) { - Icon( - imageVector = Icons.Default.ContentCopy, - stringResource(id = R.string.copy_to_clipboard), - tint = tint, - modifier = modifier - ) +fun CopyIcon( + modifier: Modifier, + tint: Color = Color.Unspecified, +) { + Icon( + imageVector = Icons.Default.ContentCopy, + stringResource(id = R.string.copy_to_clipboard), + tint = tint, + modifier = modifier, + ) } @Composable -fun OpenInNewIcon(modifier: Modifier, tint: Color = Color.Unspecified) { - Icon( - imageVector = Icons.Default.OpenInNew, - stringResource(id = R.string.copy_to_clipboard), - tint = tint, - modifier = modifier - ) +fun OpenInNewIcon( + modifier: Modifier, + tint: Color = Color.Unspecified, +) { + Icon( + imageVector = Icons.Default.OpenInNew, + stringResource(id = R.string.copy_to_clipboard), + tint = tint, + modifier = modifier, + ) } @Composable fun ExpandLessIcon(modifier: Modifier) { - Icon( - imageVector = Icons.Default.ExpandLess, - null, - modifier = modifier, - tint = MaterialTheme.colorScheme.subtleButton - ) + Icon( + imageVector = Icons.Default.ExpandLess, + null, + modifier = modifier, + tint = MaterialTheme.colorScheme.subtleButton, + ) } @Composable fun ExpandMoreIcon(modifier: Modifier) { - Icon( - imageVector = Icons.Default.ExpandMore, - null, - modifier = modifier, - tint = MaterialTheme.colorScheme.subtleButton - ) + Icon( + imageVector = Icons.Default.ExpandMore, + null, + modifier = modifier, + tint = MaterialTheme.colorScheme.subtleButton, + ) } @Composable -fun CommentIcon(iconSizeModifier: Modifier, tint: Color) { - Icon( - painter = painterResource(R.drawable.ic_comment), - contentDescription = null, - modifier = iconSizeModifier, - tint = tint - ) +fun CommentIcon( + iconSizeModifier: Modifier, + tint: Color, +) { + Icon( + painter = painterResource(R.drawable.ic_comment), + contentDescription = null, + modifier = iconSizeModifier, + tint = tint, + ) } @Composable -fun ViewCountIcon(modifier: Modifier, tint: Color = Color.Unspecified) { - Icon( - imageVector = Icons.Outlined.BarChart, - null, - modifier = modifier, - tint = tint - ) +fun ViewCountIcon( + modifier: Modifier, + tint: Color = Color.Unspecified, +) { + Icon( + imageVector = Icons.Outlined.BarChart, + null, + modifier = modifier, + tint = tint, + ) } @Composable fun PollIcon() { - Icon( - painter = painterResource(R.drawable.ic_poll), - null, - modifier = Size20Modifier, - tint = MaterialTheme.colorScheme.onBackground - ) + Icon( + painter = painterResource(R.drawable.ic_poll), + null, + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.onBackground, + ) } @Composable fun RegularPostIcon() { - Icon( - painter = painterResource(R.drawable.ic_lists), - null, - modifier = Size20Modifier, - tint = MaterialTheme.colorScheme.onBackground - ) + Icon( + painter = painterResource(R.drawable.ic_lists), + null, + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.onBackground, + ) } @Composable fun CancelIcon() { - Icon( - imageVector = Icons.Default.Cancel, - null, - modifier = Size30Modifier, - tint = MaterialTheme.colorScheme.placeholderText - ) + Icon( + imageVector = Icons.Default.Cancel, + null, + modifier = Size30Modifier, + tint = MaterialTheme.colorScheme.placeholderText, + ) } @Composable fun CloseIcon() { - Icon( - painter = painterResource(id = R.drawable.ic_close), - contentDescription = stringResource(id = R.string.cancel), - modifier = Size20Modifier - ) + Icon( + painter = painterResource(id = R.drawable.ic_close), + contentDescription = stringResource(id = R.string.cancel), + modifier = Size20Modifier, + ) } @Composable fun MutedIcon() { - Icon( - imageVector = Icons.Default.VolumeOff, - contentDescription = stringResource(id = R.string.muted_button), - tint = MaterialTheme.colorScheme.onBackground, - modifier = Size30Modifier - ) + Icon( + imageVector = Icons.Default.VolumeOff, + contentDescription = stringResource(id = R.string.muted_button), + tint = MaterialTheme.colorScheme.onBackground, + modifier = Size30Modifier, + ) } @Composable fun MuteIcon() { - Icon( - imageVector = Icons.Default.VolumeUp, - contentDescription = stringResource(id = R.string.mute_button), - tint = MaterialTheme.colorScheme.onBackground, - modifier = Size30Modifier - ) + Icon( + imageVector = Icons.Default.VolumeUp, + contentDescription = stringResource(id = R.string.mute_button), + tint = MaterialTheme.colorScheme.onBackground, + modifier = Size30Modifier, + ) } @Composable -fun SearchIcon(modifier: Modifier, tint: Color = Color.Unspecified) { - Icon( - painter = painterResource(R.drawable.ic_search), - contentDescription = stringResource(id = R.string.search_button), - modifier = modifier, - tint = tint - ) +fun SearchIcon( + modifier: Modifier, + tint: Color = Color.Unspecified, +) { + Icon( + painter = painterResource(R.drawable.ic_search), + contentDescription = stringResource(id = R.string.search_button), + modifier = modifier, + tint = tint, + ) } @Composable -fun PlayIcon(modifier: Modifier, tint: Color) { - Icon( - imageVector = Icons.Outlined.PlayCircle, - contentDescription = null, - modifier = modifier, - tint = tint - ) +fun PlayIcon( + modifier: Modifier, + tint: Color, +) { + Icon( + imageVector = Icons.Outlined.PlayCircle, + contentDescription = null, + modifier = modifier, + tint = tint, + ) } @Composable -fun PinIcon(modifier: Modifier, tint: Color) { - Icon( - imageVector = Icons.Default.PushPin, - contentDescription = null, - modifier = modifier, - tint = tint - ) +fun PinIcon( + modifier: Modifier, + tint: Color, +) { + Icon( + imageVector = Icons.Default.PushPin, + contentDescription = null, + modifier = modifier, + tint = tint, + ) } @Composable -fun LyricsIcon(modifier: Modifier, tint: Color) { - Icon( - painter = painterResource(id = R.drawable.lyrics_on), - contentDescription = null, - modifier = modifier, - tint = tint - ) +fun LyricsIcon( + modifier: Modifier, + tint: Color, +) { + Icon( + painter = painterResource(id = R.drawable.lyrics_on), + contentDescription = null, + modifier = modifier, + tint = tint, + ) } @Composable -fun LyricsOffIcon(modifier: Modifier, tint: Color) { - Icon( - painter = painterResource(id = R.drawable.lyrics_off), - contentDescription = null, - modifier = modifier, - tint = tint - ) +fun LyricsOffIcon( + modifier: Modifier, + tint: Color, +) { + Icon( + painter = painterResource(id = R.drawable.lyrics_off), + contentDescription = null, + modifier = modifier, + tint = tint, + ) } @Composable fun ClearTextIcon() { - Icon( - imageVector = Icons.Default.Clear, - contentDescription = stringResource(R.string.clear) - ) + Icon( + imageVector = Icons.Default.Clear, + contentDescription = stringResource(R.string.clear), + ) } @Composable -fun LinkIcon(modifier: Modifier, tint: Color) { - Icon( - imageVector = Icons.Default.Link, - contentDescription = stringResource(R.string.website), - modifier = modifier, - tint = tint - ) +fun LinkIcon( + modifier: Modifier, + tint: Color, +) { + Icon( + imageVector = Icons.Default.Link, + contentDescription = stringResource(R.string.website), + modifier = modifier, + tint = tint, + ) } @Composable fun VerticalDotsIcon() { - Icon( - imageVector = Icons.Default.MoreVert, - null, - modifier = Size18Modifier, - tint = MaterialTheme.colorScheme.placeholderText - ) + Icon( + imageVector = Icons.Default.MoreVert, + null, + modifier = Size18Modifier, + tint = MaterialTheme.colorScheme.placeholderText, + ) } @Composable fun NIP05CheckingIcon(modifier: Modifier) { - Icon( - imageVector = Icons.Default.Downloading, - contentDescription = stringResource(id = R.string.nip05_checking), - modifier = modifier, - tint = Color.Yellow - ) + Icon( + imageVector = Icons.Default.Downloading, + contentDescription = stringResource(id = R.string.nip05_checking), + modifier = modifier, + tint = Color.Yellow, + ) } @Composable fun NIP05VerifiedIcon(modifier: Modifier) { - Icon( - painter = painterResource(R.drawable.nip_05), - contentDescription = stringResource(id = R.string.nip05_verified), - modifier = modifier, - tint = Color.Unspecified - ) + Icon( + painter = painterResource(R.drawable.nip_05), + contentDescription = stringResource(id = R.string.nip05_verified), + modifier = modifier, + tint = Color.Unspecified, + ) } @Composable fun NIP05FailedVerification(modifier: Modifier) { - Icon( - imageVector = Icons.Default.Report, - contentDescription = stringResource(id = R.string.nip05_failed), - modifier = modifier, - tint = Color.Red - ) + Icon( + imageVector = Icons.Default.Report, + contentDescription = stringResource(id = R.string.nip05_failed), + modifier = modifier, + tint = Color.Red, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt index 8d7181723..8d95745c2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.note import androidx.compose.foundation.ExperimentalFoundationApi @@ -31,92 +51,90 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable -fun MessageSetCompose(messageSetCard: MessageSetCard, routeForLastRead: String, showHidden: Boolean = false, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val baseNote = remember { messageSetCard.note } +fun MessageSetCompose( + messageSetCard: MessageSetCard, + routeForLastRead: String, + showHidden: Boolean = false, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val baseNote = remember { messageSetCard.note } - val popupExpanded = remember { mutableStateOf(false) } - val enablePopup = remember { - { popupExpanded.value = true } - } + val popupExpanded = remember { mutableStateOf(false) } + val enablePopup = remember { { popupExpanded.value = true } } - val scope = rememberCoroutineScope() + val scope = rememberCoroutineScope() - val defaultBackgroundColor = MaterialTheme.colorScheme.background - val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } - val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor + val defaultBackgroundColor = MaterialTheme.colorScheme.background + val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } + val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor - LaunchedEffect(key1 = messageSetCard) { - accountViewModel.loadAndMarkAsRead(routeForLastRead, messageSetCard.createdAt()) { isNew -> - val newBackgroundColor = if (isNew) { - newItemColor.compositeOver(defaultBackgroundColor) - } else { - defaultBackgroundColor - } - - if (backgroundColor.value != newBackgroundColor) { - backgroundColor.value = newBackgroundColor - } - } - } - - val columnModifier = remember(backgroundColor.value) { - Modifier - .background(backgroundColor.value) - .padding( - start = 12.dp, - end = 12.dp, - top = 10.dp - ) - .combinedClickable( - onClick = { - scope.launch { - routeFor( - baseNote, - accountViewModel.userProfile() - )?.let { nav(it) } - } - }, - onLongClick = enablePopup - ) - .fillMaxWidth() - } - - Column(columnModifier) { - Row(Modifier.fillMaxWidth()) { - Box( - modifier = remember { - Modifier - .width(55.dp) - .padding(top = 5.dp, end = 5.dp) - } - ) { - MessageIcon( - remember { - Modifier - .size(16.dp) - .align(Alignment.TopEnd) - } - ) - } - - Column(modifier = remember { Modifier.padding(start = 10.dp) }) { - NoteCompose( - baseNote = baseNote, - routeForLastRead = null, - isBoostedNote = true, - addMarginTop = false, - showHidden = showHidden, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav - ) - - NoteDropDownMenu(baseNote, popupExpanded, accountViewModel) - } + LaunchedEffect(key1 = messageSetCard) { + accountViewModel.loadAndMarkAsRead(routeForLastRead, messageSetCard.createdAt()) { isNew -> + val newBackgroundColor = + if (isNew) { + newItemColor.compositeOver(defaultBackgroundColor) + } else { + defaultBackgroundColor } - Divider( - thickness = DividerThickness + if (backgroundColor.value != newBackgroundColor) { + backgroundColor.value = newBackgroundColor + } + } + } + + val columnModifier = + remember(backgroundColor.value) { + Modifier.background(backgroundColor.value) + .padding( + start = 12.dp, + end = 12.dp, + top = 10.dp, ) + .combinedClickable( + onClick = { + scope.launch { + routeFor( + baseNote, + accountViewModel.userProfile(), + ) + ?.let { nav(it) } + } + }, + onLongClick = enablePopup, + ) + .fillMaxWidth() } + + Column(columnModifier) { + Row(Modifier.fillMaxWidth()) { + Box( + modifier = remember { Modifier.width(55.dp).padding(top = 5.dp, end = 5.dp) }, + ) { + MessageIcon( + remember { Modifier.size(16.dp).align(Alignment.TopEnd) }, + ) + } + + Column(modifier = remember { Modifier.padding(start = 10.dp) }) { + NoteCompose( + baseNote = baseNote, + routeForLastRead = null, + isBoostedNote = true, + addMarginTop = false, + showHidden = showHidden, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + + NoteDropDownMenu(baseNote, popupExpanded, accountViewModel) + } + } + + Divider( + thickness = DividerThickness, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index cad8f59a1..8d0ae48ee 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.note import androidx.compose.animation.Crossfade @@ -71,350 +91,330 @@ import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor import com.vitorpamplona.amethyst.ui.theme.overPictureBackground import com.vitorpamplona.amethyst.ui.theme.profile35dpModifier import com.vitorpamplona.quartz.events.EmptyTagList +import kotlin.time.ExperimentalTime import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlin.time.ExperimentalTime @OptIn(ExperimentalFoundationApi::class, ExperimentalTime::class) @Composable -fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, showHidden: Boolean = false, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val baseNote = remember { multiSetCard.note } +fun MultiSetCompose( + multiSetCard: MultiSetCard, + routeForLastRead: String, + showHidden: Boolean = false, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val baseNote = remember { multiSetCard.note } - val popupExpanded = remember { mutableStateOf(false) } - val enablePopup = remember { - { popupExpanded.value = true } - } + val popupExpanded = remember { mutableStateOf(false) } + val enablePopup = remember { { popupExpanded.value = true } } - val scope = rememberCoroutineScope() + val scope = rememberCoroutineScope() - val defaultBackgroundColor = MaterialTheme.colorScheme.background - val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } - val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor + val defaultBackgroundColor = MaterialTheme.colorScheme.background + val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } + val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor - LaunchedEffect(key1 = multiSetCard) { - accountViewModel.loadAndMarkAsRead(routeForLastRead, multiSetCard.maxCreatedAt) { isNew -> - val newBackgroundColor = if (isNew) { - newItemColor.compositeOver(defaultBackgroundColor) - } else { - defaultBackgroundColor - } - - if (backgroundColor.value != newBackgroundColor) { - launch(Dispatchers.Main) { - backgroundColor.value = newBackgroundColor - } - } - } - } - - val columnModifier = remember(backgroundColor.value) { - Modifier - .fillMaxWidth() - .background(backgroundColor.value) - .combinedClickable( - onClick = { - scope.launch { - routeFor(baseNote, accountViewModel.userProfile())?.let { nav(it) } - } - }, - onLongClick = enablePopup - ) - .padding( - start = 12.dp, - end = 12.dp, - top = 10.dp - ) - } - - Column(modifier = columnModifier) { - Galeries(multiSetCard, backgroundColor, accountViewModel, nav) - - Row(Modifier.fillMaxWidth()) { - Spacer(modifier = WidthAuthorPictureModifierWithPadding) - - NoteCompose( - baseNote = baseNote, - routeForLastRead = null, - modifier = remember { Modifier.padding(top = 5.dp) }, - isBoostedNote = true, - showHidden = showHidden, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav - ) - - NoteDropDownMenu(baseNote, popupExpanded, accountViewModel) + LaunchedEffect(key1 = multiSetCard) { + accountViewModel.loadAndMarkAsRead(routeForLastRead, multiSetCard.maxCreatedAt) { isNew -> + val newBackgroundColor = + if (isNew) { + newItemColor.compositeOver(defaultBackgroundColor) + } else { + defaultBackgroundColor } - Divider( - thickness = DividerThickness + if (backgroundColor.value != newBackgroundColor) { + launch(Dispatchers.Main) { backgroundColor.value = newBackgroundColor } + } + } + } + + val columnModifier = + remember(backgroundColor.value) { + Modifier.fillMaxWidth() + .background(backgroundColor.value) + .combinedClickable( + onClick = { + scope.launch { routeFor(baseNote, accountViewModel.userProfile())?.let { nav(it) } } + }, + onLongClick = enablePopup, + ) + .padding( + start = 12.dp, + end = 12.dp, + top = 10.dp, ) } + + Column(modifier = columnModifier) { + Galeries(multiSetCard, backgroundColor, accountViewModel, nav) + + Row(Modifier.fillMaxWidth()) { + Spacer(modifier = WidthAuthorPictureModifierWithPadding) + + NoteCompose( + baseNote = baseNote, + routeForLastRead = null, + modifier = remember { Modifier.padding(top = 5.dp) }, + isBoostedNote = true, + showHidden = showHidden, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + + NoteDropDownMenu(baseNote, popupExpanded, accountViewModel) + } + + Divider( + thickness = DividerThickness, + ) + } } @Composable private fun Galeries( - multiSetCard: MultiSetCard, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + multiSetCard: MultiSetCard, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val hasZapEvents by remember { derivedStateOf { multiSetCard.zapEvents.isNotEmpty() } } - val hasBoostEvents by remember { derivedStateOf { multiSetCard.boostEvents.isNotEmpty() } } - val hasLikeEvents by remember { derivedStateOf { multiSetCard.likeEvents.isNotEmpty() } } + val hasZapEvents by remember { derivedStateOf { multiSetCard.zapEvents.isNotEmpty() } } + val hasBoostEvents by remember { derivedStateOf { multiSetCard.boostEvents.isNotEmpty() } } + val hasLikeEvents by remember { derivedStateOf { multiSetCard.likeEvents.isNotEmpty() } } - if (hasZapEvents) { - var zapEvents by remember(multiSetCard.zapEvents) { - mutableStateOf( - accountViewModel.cachedDecryptAmountMessageInGroup(multiSetCard.zapEvents) - ) - } + if (hasZapEvents) { + var zapEvents by + remember(multiSetCard.zapEvents) { + mutableStateOf( + accountViewModel.cachedDecryptAmountMessageInGroup(multiSetCard.zapEvents), + ) + } - LaunchedEffect(key1 = Unit) { - accountViewModel.decryptAmountMessageInGroup(multiSetCard.zapEvents) { - zapEvents = it - } - } - - RenderZapGallery(zapEvents, backgroundColor, nav, accountViewModel) + LaunchedEffect(key1 = Unit) { + accountViewModel.decryptAmountMessageInGroup(multiSetCard.zapEvents) { zapEvents = it } } - if (hasBoostEvents) { - RenderBoostGallery(multiSetCard.boostEvents, nav, accountViewModel) - } + RenderZapGallery(zapEvents, backgroundColor, nav, accountViewModel) + } - if (hasLikeEvents) { - multiSetCard.likeEventsByType.forEach { - RenderLikeGallery(it.key, it.value, nav, accountViewModel) - } + if (hasBoostEvents) { + RenderBoostGallery(multiSetCard.boostEvents, nav, accountViewModel) + } + + if (hasLikeEvents) { + multiSetCard.likeEventsByType.forEach { + RenderLikeGallery(it.key, it.value, nav, accountViewModel) } + } } @Composable fun RenderLikeGallery( - reactionType: String, - likeEvents: ImmutableList, - nav: (String) -> Unit, - accountViewModel: AccountViewModel + reactionType: String, + likeEvents: ImmutableList, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - if (likeEvents.isNotEmpty()) { - Row(Modifier.fillMaxWidth()) { - Box( - modifier = NotificationIconModifier - ) { - val modifier = remember { - Modifier.align(Alignment.TopEnd) - } + if (likeEvents.isNotEmpty()) { + Row(Modifier.fillMaxWidth()) { + Box( + modifier = NotificationIconModifier, + ) { + val modifier = remember { Modifier.align(Alignment.TopEnd) } - if (reactionType.startsWith(":")) { - val noStartColon = reactionType.removePrefix(":") - val url = noStartColon.substringAfter(":") + if (reactionType.startsWith(":")) { + val noStartColon = reactionType.removePrefix(":") + val url = noStartColon.substringAfter(":") - val renderable = listOf( - ImageUrlType(url) - ).toImmutableList() + val renderable = + listOf( + ImageUrlType(url), + ) + .toImmutableList() - InLineIconRenderer( - renderable, - style = SpanStyle(color = Color.White), - maxLines = 1, - modifier = modifier - ) - } else { - when (val shortReaction = reactionType) { - "+" -> LikedIcon(modifier.size(Size18dp)) - "-" -> Text(text = "\uD83D\uDC4E", modifier = modifier) - else -> Text(text = shortReaction, modifier = modifier) - } - } - } - - AuthorGallery(likeEvents, nav, accountViewModel) + InLineIconRenderer( + renderable, + style = SpanStyle(color = Color.White), + maxLines = 1, + modifier = modifier, + ) + } else { + when (val shortReaction = reactionType) { + "+" -> LikedIcon(modifier.size(Size18dp)) + "-" -> Text(text = "\uD83D\uDC4E", modifier = modifier) + else -> Text(text = shortReaction, modifier = modifier) + } } + } + + AuthorGallery(likeEvents, nav, accountViewModel) } + } } @Composable fun RenderZapGallery( - zapEvents: ImmutableList, - backgroundColor: MutableState, - nav: (String) -> Unit, - accountViewModel: AccountViewModel + zapEvents: ImmutableList, + backgroundColor: MutableState, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - Row(Modifier.fillMaxWidth()) { - Box( - modifier = WidthAuthorPictureModifier - ) { - ZappedIcon( - modifier = remember { - Modifier - .size(Size25dp) - .align(Alignment.TopEnd) - } - ) - } - - AuthorGalleryZaps(zapEvents, backgroundColor, nav, accountViewModel) + Row(Modifier.fillMaxWidth()) { + Box( + modifier = WidthAuthorPictureModifier, + ) { + ZappedIcon( + modifier = remember { Modifier.size(Size25dp).align(Alignment.TopEnd) }, + ) } + + AuthorGalleryZaps(zapEvents, backgroundColor, nav, accountViewModel) + } } @Composable fun RenderBoostGallery( - boostEvents: ImmutableList, - nav: (String) -> Unit, - accountViewModel: AccountViewModel + boostEvents: ImmutableList, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - Row( - modifier = Modifier.fillMaxWidth() + Row( + modifier = Modifier.fillMaxWidth(), + ) { + Box( + modifier = NotificationIconModifierSmaller, ) { - Box( - modifier = NotificationIconModifierSmaller - ) { - RepostedIcon( - modifier = remember { - Modifier - .size(Size19dp) - .align(Alignment.TopEnd) - } - ) - } - - AuthorGallery(boostEvents, nav, accountViewModel) + RepostedIcon( + modifier = remember { Modifier.size(Size19dp).align(Alignment.TopEnd) }, + ) } + + AuthorGallery(boostEvents, nav, accountViewModel) + } } @Composable fun RenderBoostGallery( - noteToGetBoostEvents: NoteState, - nav: (String) -> Unit, - accountViewModel: AccountViewModel + noteToGetBoostEvents: NoteState, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - Row( - modifier = Modifier.fillMaxWidth() + Row( + modifier = Modifier.fillMaxWidth(), + ) { + Box( + modifier = NotificationIconModifierSmaller, ) { - Box( - modifier = NotificationIconModifierSmaller - ) { - RepostedIcon( - modifier = remember { - Modifier - .size(Size19dp) - .align(Alignment.TopEnd) - } - ) - } - - AuthorGallery(noteToGetBoostEvents, nav, accountViewModel) + RepostedIcon( + modifier = remember { Modifier.size(Size19dp).align(Alignment.TopEnd) }, + ) } + + AuthorGallery(noteToGetBoostEvents, nav, accountViewModel) + } } @Composable fun MapZaps( - zaps: ImmutableList, - accountViewModel: AccountViewModel, - content: @Composable (ImmutableList) -> Unit + zaps: ImmutableList, + accountViewModel: AccountViewModel, + content: @Composable (ImmutableList) -> Unit, ) { - var zapEvents by remember(zaps) { - mutableStateOf>(persistentListOf()) + var zapEvents by + remember(zaps) { + mutableStateOf>(persistentListOf()) } - LaunchedEffect(key1 = zaps) { - accountViewModel.decryptAmountMessageInGroup(zaps) { - zapEvents = it - } - } + LaunchedEffect(key1 = zaps) { + accountViewModel.decryptAmountMessageInGroup(zaps) { zapEvents = it } + } - content(zapEvents) + content(zapEvents) } @OptIn(ExperimentalLayoutApi::class) @Composable fun AuthorGalleryZaps( - authorNotes: ImmutableList, - backgroundColor: MutableState, - nav: (String) -> Unit, - accountViewModel: AccountViewModel + authorNotes: ImmutableList, + backgroundColor: MutableState, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - Column(modifier = StdStartPadding) { - FlowRow() { - authorNotes.forEach { - RenderState(it, backgroundColor, accountViewModel, nav) - } - } - } + Column(modifier = StdStartPadding) { + FlowRow { authorNotes.forEach { RenderState(it, backgroundColor, accountViewModel, nav) } } + } } @Immutable data class ZapAmountCommentNotification( - val user: User?, - val comment: String?, - val amount: String? + val user: User?, + val comment: String?, + val amount: String?, ) @Composable private fun ParseAuthorCommentAndAmount( - zapRequest: Note, - zapEvent: Note?, - accountViewModel: AccountViewModel, - onReady: @Composable (MutableState) -> Unit + zapRequest: Note, + zapEvent: Note?, + accountViewModel: AccountViewModel, + onReady: @Composable (MutableState) -> Unit, ) { - val content = remember { - mutableStateOf( - ZapAmountCommentNotification( - user = zapRequest.author, - comment = null, - amount = null - ) - ) - } + val content = remember { + mutableStateOf( + ZapAmountCommentNotification( + user = zapRequest.author, + comment = null, + amount = null, + ), + ) + } - LaunchedEffect(key1 = zapRequest.idHex, key2 = zapEvent?.idHex) { - accountViewModel.decryptAmountMessage(zapRequest, zapEvent) { newState -> - if (newState != null) { - content.value = newState - } - } + LaunchedEffect(key1 = zapRequest.idHex, key2 = zapEvent?.idHex) { + accountViewModel.decryptAmountMessage(zapRequest, zapEvent) { newState -> + if (newState != null) { + content.value = newState + } } + } - onReady(content) + onReady(content) } -fun click(content: ZapAmountCommentNotification, nav: (String) -> Unit) { - content.user?.let { - nav(routeFor(it)) - } +fun click( + content: ZapAmountCommentNotification, + nav: (String) -> Unit, +) { + content.user?.let { nav(routeFor(it)) } } @Composable private fun RenderState( - content: ZapAmountCommentNotification, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + content: ZapAmountCommentNotification, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - Row( - modifier = Modifier.clickable { click(content, nav) }, - verticalAlignment = Alignment.CenterVertically - ) { - DisplayAuthorCommentAndAmount( - authorComment = content, - backgroundColor = backgroundColor, - nav = nav, - accountViewModel = accountViewModel - ) - } + Row( + modifier = Modifier.clickable { click(content, nav) }, + verticalAlignment = Alignment.CenterVertically, + ) { + DisplayAuthorCommentAndAmount( + authorComment = content, + backgroundColor = backgroundColor, + nav = nav, + accountViewModel = accountViewModel, + ) + } } -val amountBoxModifier = Modifier - .size(Size35dp) - .clip(shape = CircleShape) +val amountBoxModifier = Modifier.size(Size35dp).clip(shape = CircleShape) -val textBoxModifier = Modifier - .padding(start = 5.dp) - .fillMaxWidth() +val textBoxModifier = Modifier.padding(start = 5.dp).fillMaxWidth() val bottomPadding1dp = Modifier.padding(bottom = 1.dp) @@ -422,175 +422,169 @@ val commentTextSize = 12.sp @Composable private fun DisplayAuthorCommentAndAmount( - authorComment: ZapAmountCommentNotification, - backgroundColor: MutableState, - nav: (String) -> Unit, - accountViewModel: AccountViewModel + authorComment: ZapAmountCommentNotification, + backgroundColor: MutableState, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - Box(modifier = Size35Modifier, contentAlignment = Alignment.BottomCenter) { - WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor(authorComment.user, accountViewModel) - authorComment.amount?.let { - CrossfadeToDisplayAmount(it) - } - } + Box(modifier = Size35Modifier, contentAlignment = Alignment.BottomCenter) { + WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor( + authorComment.user, + accountViewModel, + ) + authorComment.amount?.let { CrossfadeToDisplayAmount(it) } + } - authorComment.comment?.let { - CrossfadeToDisplayComment(it, backgroundColor, nav, accountViewModel) - } + authorComment.comment?.let { + CrossfadeToDisplayComment(it, backgroundColor, nav, accountViewModel) + } } @Composable fun CrossfadeToDisplayAmount(amount: String) { + Box( + modifier = amountBoxModifier, + contentAlignment = Alignment.BottomCenter, + ) { + val backgroundColor = MaterialTheme.colorScheme.overPictureBackground Box( - modifier = amountBoxModifier, - contentAlignment = Alignment.BottomCenter + modifier = remember { Modifier.width(Size35dp).background(backgroundColor) }, + contentAlignment = Alignment.BottomCenter, ) { - val backgroundColor = MaterialTheme.colorScheme.overPictureBackground - Box( - modifier = remember { - Modifier - .width(Size35dp) - .background(backgroundColor) - }, - contentAlignment = Alignment.BottomCenter - ) { - Text( - text = amount, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.bitcoinColor, - fontSize = commentTextSize, - modifier = bottomPadding1dp - ) - } + Text( + text = amount, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.bitcoinColor, + fontSize = commentTextSize, + modifier = bottomPadding1dp, + ) } + } } @Composable fun CrossfadeToDisplayComment( - comment: String, - backgroundColor: MutableState, - nav: (String) -> Unit, - accountViewModel: AccountViewModel + comment: String, + backgroundColor: MutableState, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - TranslatableRichTextViewer( - content = comment, - canPreview = true, - tags = EmptyTagList, - modifier = textBoxModifier, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav - ) + TranslatableRichTextViewer( + content = comment, + canPreview = true, + tags = EmptyTagList, + modifier = textBoxModifier, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) } @OptIn(ExperimentalLayoutApi::class) @Composable fun AuthorGallery( - authorNotes: ImmutableList, - nav: (String) -> Unit, - accountViewModel: AccountViewModel + authorNotes: ImmutableList, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - Column(modifier = StdStartPadding) { - FlowRow() { - authorNotes.forEach { note -> - BoxedAuthor(note, nav, accountViewModel) - } - } - } + Column(modifier = StdStartPadding) { + FlowRow { authorNotes.forEach { note -> BoxedAuthor(note, nav, accountViewModel) } } + } } @OptIn(ExperimentalLayoutApi::class) @Composable fun AuthorGallery( - noteToGetBoostEvents: NoteState, - nav: (String) -> Unit, - accountViewModel: AccountViewModel + noteToGetBoostEvents: NoteState, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - Column(modifier = StdStartPadding) { - FlowRow() { - noteToGetBoostEvents.note.boosts.forEach { note -> - BoxedAuthor(note, nav, accountViewModel) - } - } + Column(modifier = StdStartPadding) { + FlowRow { + noteToGetBoostEvents.note.boosts.forEach { note -> BoxedAuthor(note, nav, accountViewModel) } } + } } @Composable private fun BoxedAuthor( - note: Note, - nav: (String) -> Unit, - accountViewModel: AccountViewModel + note: Note, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - Box(modifier = Size35Modifier.clickable(onClick = { nav(authorRouteFor(note)) })) { - WatchNoteAuthor(note) { targetAuthor -> - Crossfade(targetState = targetAuthor, modifier = Size35Modifier) { author -> - WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor(author, accountViewModel) - } - } + Box(modifier = Size35Modifier.clickable(onClick = { nav(authorRouteFor(note)) })) { + WatchNoteAuthor(note) { targetAuthor -> + Crossfade(targetState = targetAuthor, modifier = Size35Modifier) { author -> + WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor( + author, + accountViewModel, + ) + } } + } } @Composable fun WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor( - author: User?, - accountViewModel: AccountViewModel + author: User?, + accountViewModel: AccountViewModel, ) { - if (author != null) { - WatchUserMetadataAndFollowsAndRenderUserProfilePicture(author, accountViewModel) - } else { - DisplayBlankAuthor(Size35dp) - } + if (author != null) { + WatchUserMetadataAndFollowsAndRenderUserProfilePicture(author, accountViewModel) + } else { + DisplayBlankAuthor(Size35dp) + } } @Composable fun WatchUserMetadataAndFollowsAndRenderUserProfilePicture( - author: User, - accountViewModel: AccountViewModel + author: User, + accountViewModel: AccountViewModel, ) { - val automaticallyShowProfilePicture = remember { - accountViewModel.settings.showProfilePictures.value - } + val automaticallyShowProfilePicture = remember { + accountViewModel.settings.showProfilePictures.value + } - WatchUserMetadata(author) { baseUserPicture -> - // Crossfade(targetState = baseUserPicture) { userPicture -> - RobohashFallbackAsyncImage( - robot = author.pubkeyHex, - model = baseUserPicture, - contentDescription = stringResource(id = R.string.profile_image), - modifier = MaterialTheme.colorScheme.profile35dpModifier, - contentScale = ContentScale.Crop, - loadProfilePicture = automaticallyShowProfilePicture - ) - // } - } + WatchUserMetadata(author) { baseUserPicture -> + // Crossfade(targetState = baseUserPicture) { userPicture -> + RobohashFallbackAsyncImage( + robot = author.pubkeyHex, + model = baseUserPicture, + contentDescription = stringResource(id = R.string.profile_image), + modifier = MaterialTheme.colorScheme.profile35dpModifier, + contentScale = ContentScale.Crop, + loadProfilePicture = automaticallyShowProfilePicture, + ) + // } + } - WatchUserFollows(author.pubkeyHex, accountViewModel) { isFollowing -> - // Crossfade(targetState = isFollowing) { - if (isFollowing) { - Box(modifier = Size35Modifier, contentAlignment = Alignment.TopEnd) { - FollowingIcon(Size10dp) - } - } - // } + WatchUserFollows(author.pubkeyHex, accountViewModel) { isFollowing -> + // Crossfade(targetState = isFollowing) { + if (isFollowing) { + Box(modifier = Size35Modifier, contentAlignment = Alignment.TopEnd) { + FollowingIcon(Size10dp) + } } + // } + } } @Composable private fun WatchNoteAuthor( - baseNote: Note, - onContent: @Composable (User?) -> Unit + baseNote: Note, + onContent: @Composable (User?) -> Unit, ) { - val author by baseNote.live().authorChanges.observeAsState(baseNote.author) + val author by baseNote.live().authorChanges.observeAsState(baseNote.author) - onContent(author) + onContent(author) } @Composable private fun WatchUserMetadata( - author: User, - onNewMetadata: @Composable (String?) -> Unit + author: User, + onNewMetadata: @Composable (String?) -> Unit, ) { - val userProfile by author.live().profilePictureChanges.observeAsState(author.profilePicture()) + val userProfile by author.live().profilePictureChanges.observeAsState(author.profilePicture()) - onNewMetadata(userProfile) + onNewMetadata(userProfile) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt index e1cbe9dac..31aa9fa81 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import androidx.compose.animation.Crossfade @@ -53,319 +73,338 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.events.AddressableEvent import com.vitorpamplona.quartz.events.UserMetadata import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.time.Duration.Companion.seconds import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.delay -import kotlin.time.Duration.Companion.seconds @Composable -fun nip05VerificationAsAState(userMetadata: UserMetadata, pubkeyHex: String, accountViewModel: AccountViewModel): MutableState { - val nip05Verified = remember(userMetadata.nip05) { - // starts with null if must verify or already filled in if verified in the last hour - val default = if ((userMetadata.nip05LastVerificationTime ?: 0) > TimeUtils.oneHourAgo()) { - userMetadata.nip05Verified +fun nip05VerificationAsAState( + userMetadata: UserMetadata, + pubkeyHex: String, + accountViewModel: AccountViewModel, +): MutableState { + val nip05Verified = + remember(userMetadata.nip05) { + // starts with null if must verify or already filled in if verified in the last hour + val default = + if ((userMetadata.nip05LastVerificationTime ?: 0) > TimeUtils.oneHourAgo()) { + userMetadata.nip05Verified } else { - null + null } - mutableStateOf(default) + mutableStateOf(default) } - if (nip05Verified.value == null) { - LaunchedEffect(key1 = userMetadata.nip05) { - accountViewModel.verifyNip05(userMetadata, pubkeyHex) { newVerificationStatus -> - if (nip05Verified.value != newVerificationStatus) { - nip05Verified.value = newVerificationStatus - } - } + if (nip05Verified.value == null) { + LaunchedEffect(key1 = userMetadata.nip05) { + accountViewModel.verifyNip05(userMetadata, pubkeyHex) { newVerificationStatus -> + if (nip05Verified.value != newVerificationStatus) { + nip05Verified.value = newVerificationStatus } + } } + } - return nip05Verified + return nip05Verified } @Composable fun ObserveDisplayNip05Status( - baseNote: Note, - columnModifier: Modifier = Modifier, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + columnModifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val author by baseNote.live().authorChanges.observeAsState() + val author by baseNote.live().authorChanges.observeAsState() - author?.let { - ObserveDisplayNip05Status(it, columnModifier, accountViewModel, nav) - } + author?.let { ObserveDisplayNip05Status(it, columnModifier, accountViewModel, nav) } } @Composable fun ObserveDisplayNip05Status( - baseUser: User, - columnModifier: Modifier = Modifier, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseUser: User, + columnModifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val nip05 by baseUser.live().nip05Changes.observeAsState(baseUser.nip05()) + val nip05 by baseUser.live().nip05Changes.observeAsState(baseUser.nip05()) - LoadStatuses(baseUser, accountViewModel) { statuses -> - Crossfade(targetState = nip05, modifier = columnModifier, label = "ObserveDisplayNip05StatusCrossfade") { - VerifyAndDisplayNIP05OrStatusLine(it, statuses, baseUser, columnModifier, accountViewModel, nav) - } + LoadStatuses(baseUser, accountViewModel) { statuses -> + Crossfade( + targetState = nip05, + modifier = columnModifier, + label = "ObserveDisplayNip05StatusCrossfade", + ) { + VerifyAndDisplayNIP05OrStatusLine( + it, + statuses, + baseUser, + columnModifier, + accountViewModel, + nav, + ) } + } } @Composable private fun VerifyAndDisplayNIP05OrStatusLine( - nip05: String?, - statuses: ImmutableList, - baseUser: User, - columnModifier: Modifier = Modifier, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + nip05: String?, + statuses: ImmutableList, + baseUser: User, + columnModifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - Column(modifier = columnModifier) { - Row(verticalAlignment = Alignment.CenterVertically) { - if (nip05 != null) { - val nip05Verified = nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex, accountViewModel) + Column(modifier = columnModifier) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (nip05 != null) { + val nip05Verified = + nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex, accountViewModel) - if (nip05Verified.value != true) { - DisplayNIP05(nip05, nip05Verified) - } else if (!statuses.isEmpty()) { - RotateStatuses(statuses, accountViewModel, nav) - } else { - DisplayNIP05(nip05, nip05Verified) - } - } else { - if (!statuses.isEmpty()) { - RotateStatuses(statuses, accountViewModel, nav) - } else { - DisplayUsersNpub(baseUser.pubkeyDisplayHex()) - } - } + if (nip05Verified.value != true) { + DisplayNIP05(nip05, nip05Verified) + } else if (!statuses.isEmpty()) { + RotateStatuses(statuses, accountViewModel, nav) + } else { + DisplayNIP05(nip05, nip05Verified) } + } else { + if (!statuses.isEmpty()) { + RotateStatuses(statuses, accountViewModel, nav) + } else { + DisplayUsersNpub(baseUser.pubkeyDisplayHex()) + } + } } + } } @Composable fun RotateStatuses( - statuses: ImmutableList, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + statuses: ImmutableList, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var indexToDisplay by remember(statuses) { - mutableIntStateOf(0) - } + var indexToDisplay by remember(statuses) { mutableIntStateOf(0) } - DisplayStatus(statuses[indexToDisplay], accountViewModel, nav) + DisplayStatus(statuses[indexToDisplay], accountViewModel, nav) - if (statuses.size > 1) { - LaunchedEffect(Unit) { - while (true) { - delay(10.seconds) - indexToDisplay = (indexToDisplay + 1) % statuses.size - } - } + if (statuses.size > 1) { + LaunchedEffect(Unit) { + while (true) { + delay(10.seconds) + indexToDisplay = (indexToDisplay + 1) % statuses.size + } } + } } @Composable fun DisplayUsersNpub(npub: String) { - Text( - text = npub, - fontSize = 14.sp, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Text( + text = npub, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } @Composable fun DisplayStatus( - addressableNote: AddressableNote, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + addressableNote: AddressableNote, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteState by addressableNote.live().metadata.observeAsState() + val noteState by addressableNote.live().metadata.observeAsState() - val content = remember(noteState) { addressableNote.event?.content() ?: "" } - val type = remember(noteState) { - (addressableNote.event as? AddressableEvent)?.dTag() ?: "" - } - val url = remember(noteState) { - addressableNote.event?.firstTaggedUrl()?.ifBlank { null } - } - val nostrATag = remember(noteState) { - addressableNote.event?.firstTaggedAddress() - } - val nostrHexID = remember(noteState) { - addressableNote.event?.firstTaggedEvent()?.ifBlank { null } - } + val content = remember(noteState) { addressableNote.event?.content() ?: "" } + val type = remember(noteState) { (addressableNote.event as? AddressableEvent)?.dTag() ?: "" } + val url = remember(noteState) { addressableNote.event?.firstTaggedUrl()?.ifBlank { null } } + val nostrATag = remember(noteState) { addressableNote.event?.firstTaggedAddress() } + val nostrHexID = + remember(noteState) { addressableNote.event?.firstTaggedEvent()?.ifBlank { null } } - when (type) { - "music" -> Icon( - painter = painterResource(id = R.drawable.tunestr), - null, - modifier = Size15Modifier.padding(end = Size5dp), - tint = MaterialTheme.colorScheme.placeholderText - ) - else -> {} + when (type) { + "music" -> + Icon( + painter = painterResource(id = R.drawable.tunestr), + null, + modifier = Size15Modifier.padding(end = Size5dp), + tint = MaterialTheme.colorScheme.placeholderText, + ) + else -> {} + } + + Text( + text = content, + fontSize = Font14SP, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + if (url != null) { + val uri = LocalUriHandler.current + Spacer(modifier = StdHorzSpacer) + IconButton( + modifier = Size15Modifier, + onClick = { runCatching { uri.openUri(url.trim()) } }, + ) { + Icon( + imageVector = Icons.Default.OpenInNew, + null, + modifier = Size15Modifier, + tint = MaterialTheme.colorScheme.lessImportantLink, + ) } - - Text( - text = content, - fontSize = Font14SP, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - if (url != null) { - val uri = LocalUriHandler.current + } else if (nostrATag != null) { + LoadAddressableNote(nostrATag, accountViewModel) { note -> + if (note != null) { Spacer(modifier = StdHorzSpacer) IconButton( - modifier = Size15Modifier, - onClick = { runCatching { uri.openUri(url.trim()) } } + modifier = Size15Modifier, + onClick = { + routeFor( + note, + accountViewModel.userProfile(), + ) + ?.let { nav(it) } + }, ) { - Icon( - imageVector = Icons.Default.OpenInNew, - null, - modifier = Size15Modifier, - tint = MaterialTheme.colorScheme.lessImportantLink - ) - } - } else if (nostrATag != null) { - LoadAddressableNote(nostrATag, accountViewModel) { note -> - if (note != null) { - Spacer(modifier = StdHorzSpacer) - IconButton( - modifier = Size15Modifier, - onClick = { - routeFor( - note, - accountViewModel.userProfile() - )?.let { nav(it) } - } - ) { - Icon( - imageVector = Icons.Default.OpenInNew, - null, - modifier = Size15Modifier, - tint = MaterialTheme.colorScheme.lessImportantLink - ) - } - } - } - } else if (nostrHexID != null) { - LoadNote(baseNoteHex = nostrHexID, accountViewModel) { - if (it != null) { - Spacer(modifier = StdHorzSpacer) - IconButton( - modifier = Size15Modifier, - onClick = { - routeFor( - it, - accountViewModel.userProfile() - )?.let { nav(it) } - } - ) { - Icon( - imageVector = Icons.Default.OpenInNew, - null, - modifier = Size15Modifier, - tint = MaterialTheme.colorScheme.lessImportantLink - ) - } - } + Icon( + imageVector = Icons.Default.OpenInNew, + null, + modifier = Size15Modifier, + tint = MaterialTheme.colorScheme.lessImportantLink, + ) } + } } + } else if (nostrHexID != null) { + LoadNote(baseNoteHex = nostrHexID, accountViewModel) { + if (it != null) { + Spacer(modifier = StdHorzSpacer) + IconButton( + modifier = Size15Modifier, + onClick = { + routeFor( + it, + accountViewModel.userProfile(), + ) + ?.let { nav(it) } + }, + ) { + Icon( + imageVector = Icons.Default.OpenInNew, + null, + modifier = Size15Modifier, + tint = MaterialTheme.colorScheme.lessImportantLink, + ) + } + } + } + } } @Composable private fun DisplayNIP05( - nip05: String, - nip05Verified: MutableState + nip05: String, + nip05Verified: MutableState, ) { - val uri = LocalUriHandler.current - val (user, domain) = remember(nip05) { - val parts = nip05.split("@") - if (parts.size == 1) { - listOf("_", parts[0]) - } else { - listOf(parts[0], parts[1]) - } + val uri = LocalUriHandler.current + val (user, domain) = + remember(nip05) { + val parts = nip05.split("@") + if (parts.size == 1) { + listOf("_", parts[0]) + } else { + listOf(parts[0], parts[1]) + } } - if (user != "_") { - Text( - text = remember(nip05) { AnnotatedString(user) }, - fontSize = Font14SP, - color = MaterialTheme.colorScheme.nip05, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - - NIP05VerifiedSymbol(nip05Verified, NIP05IconSize) - - ClickableText( - text = remember(nip05) { AnnotatedString(domain) }, - onClick = { runCatching { uri.openUri("https://$domain") } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.nip05, fontSize = Font14SP), - maxLines = 1, - overflow = TextOverflow.Visible + if (user != "_") { + Text( + text = remember(nip05) { AnnotatedString(user) }, + fontSize = Font14SP, + color = MaterialTheme.colorScheme.nip05, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) + } + + NIP05VerifiedSymbol(nip05Verified, NIP05IconSize) + + ClickableText( + text = remember(nip05) { AnnotatedString(domain) }, + onClick = { runCatching { uri.openUri("https://$domain") } }, + style = + LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.nip05, fontSize = Font14SP), + maxLines = 1, + overflow = TextOverflow.Visible, + ) } @Composable -private fun NIP05VerifiedSymbol(nip05Verified: MutableState, modifier: Modifier) { - Crossfade(targetState = nip05Verified.value) { - when (it) { - null -> NIP05CheckingIcon(modifier = modifier) - true -> NIP05VerifiedIcon(modifier = modifier) - false -> NIP05FailedVerification(modifier = modifier) - } +private fun NIP05VerifiedSymbol( + nip05Verified: MutableState, + modifier: Modifier, +) { + Crossfade(targetState = nip05Verified.value) { + when (it) { + null -> NIP05CheckingIcon(modifier = modifier) + true -> NIP05VerifiedIcon(modifier = modifier) + false -> NIP05FailedVerification(modifier = modifier) } + } } @Composable -fun DisplayNip05ProfileStatus(user: User, accountViewModel: AccountViewModel) { - val uri = LocalUriHandler.current +fun DisplayNip05ProfileStatus( + user: User, + accountViewModel: AccountViewModel, +) { + val uri = LocalUriHandler.current - user.nip05()?.let { nip05 -> - if (nip05.split("@").size <= 2) { - val nip05Verified = nip05VerificationAsAState(user.info!!, user.pubkeyHex, accountViewModel) - Row(verticalAlignment = Alignment.CenterVertically) { - NIP05VerifiedSymbol(nip05Verified, Size16Modifier) - var domainPadStart = 5.dp + user.nip05()?.let { nip05 -> + if (nip05.split("@").size <= 2) { + val nip05Verified = nip05VerificationAsAState(user.info!!, user.pubkeyHex, accountViewModel) + Row(verticalAlignment = Alignment.CenterVertically) { + NIP05VerifiedSymbol(nip05Verified, Size16Modifier) + var domainPadStart = 5.dp - val (user, domain) = remember(nip05) { - val parts = nip05.split("@") - if (parts.size == 1) { - listOf("_", parts[0]) - } else { - listOf(parts[0], parts[1]) - } - } - - if (user != "_") { - Text( - text = remember { AnnotatedString(user + "@") }, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - domainPadStart = 0.dp - } - - ClickableText( - text = AnnotatedString(domain), - onClick = { nip05.let { runCatching { uri.openUri("https://${it.split("@")[1]}") } } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), - modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = domainPadStart), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + val (user, domain) = + remember(nip05) { + val parts = nip05.split("@") + if (parts.size == 1) { + listOf("_", parts[0]) + } else { + listOf(parts[0], parts[1]) } + } + + if (user != "_") { + Text( + text = remember { AnnotatedString(user + "@") }, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + domainPadStart = 0.dp } + + ClickableText( + text = AnnotatedString(domain), + onClick = { nip05.let { runCatching { uri.openUri("https://${it.split("@")[1]}") } } }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = domainPadStart), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 698157d76..eb3d3acf8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.note import android.graphics.Bitmap @@ -208,159 +228,287 @@ import com.vitorpamplona.quartz.events.VideoEvent import com.vitorpamplona.quartz.events.VideoHorizontalEvent import com.vitorpamplona.quartz.events.VideoVerticalEvent import com.vitorpamplona.quartz.events.toImmutableListOfLists +import java.io.File +import java.net.URL +import java.util.Locale import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import java.io.File -import java.net.URL -import java.util.Locale @OptIn(ExperimentalFoundationApi::class) @Composable fun NoteCompose( - baseNote: Note, - routeForLastRead: String? = null, - modifier: Modifier = Modifier, - isBoostedNote: Boolean = false, - isQuotedNote: Boolean = false, - unPackReply: Boolean = true, - makeItShort: Boolean = false, - addMarginTop: Boolean = true, - showHidden: Boolean = false, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + isBoostedNote: Boolean = false, + isQuotedNote: Boolean = false, + unPackReply: Boolean = true, + makeItShort: Boolean = false, + addMarginTop: Boolean = true, + showHidden: Boolean = false, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null) + val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null) - Crossfade(targetState = hasEvent, label = "Event presence") { - if (it) { - CheckHiddenNoteCompose( - note = baseNote, - routeForLastRead = routeForLastRead, - modifier = modifier, - isBoostedNote = isBoostedNote, - isQuotedNote = isQuotedNote, - unPackReply = unPackReply, - makeItShort = makeItShort, - addMarginTop = addMarginTop, - showHidden = showHidden, - parentBackgroundColor = parentBackgroundColor, - accountViewModel = accountViewModel, - nav = nav + Crossfade(targetState = hasEvent, label = "Event presence") { + if (it) { + CheckHiddenNoteCompose( + note = baseNote, + routeForLastRead = routeForLastRead, + modifier = modifier, + isBoostedNote = isBoostedNote, + isQuotedNote = isQuotedNote, + unPackReply = unPackReply, + makeItShort = makeItShort, + addMarginTop = addMarginTop, + showHidden = showHidden, + parentBackgroundColor = parentBackgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } else { + LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup, + -> + BlankNote( + remember { + modifier.combinedClickable( + onClick = {}, + onLongClick = showPopup, ) - } else { - LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> - BlankNote( - remember { - modifier.combinedClickable( - onClick = { }, - onLongClick = showPopup - ) - }, - isBoostedNote || isQuotedNote - ) - } - } + }, + isBoostedNote || isQuotedNote, + ) + } } + } } @Composable fun CheckHiddenNoteCompose( - note: Note, - routeForLastRead: String? = null, - modifier: Modifier = Modifier, - isBoostedNote: Boolean = false, - isQuotedNote: Boolean = false, - unPackReply: Boolean = true, - makeItShort: Boolean = false, - addMarginTop: Boolean = true, - showHidden: Boolean = false, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + note: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + isBoostedNote: Boolean = false, + isQuotedNote: Boolean = false, + unPackReply: Boolean = true, + makeItShort: Boolean = false, + addMarginTop: Boolean = true, + showHidden: Boolean = false, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - if (showHidden) { - // Ignores reports as well - val state by remember(note) { - mutableStateOf( - AccountViewModel.NoteComposeReportState() - ) - } - - RenderReportState( - state = state, - note = note, - routeForLastRead = routeForLastRead, - modifier = modifier, - isBoostedNote = isBoostedNote, - isQuotedNote = isQuotedNote, - unPackReply = unPackReply, - makeItShort = makeItShort, - addMarginTop = addMarginTop, - parentBackgroundColor = parentBackgroundColor, - accountViewModel = accountViewModel, - nav = nav + if (showHidden) { + // Ignores reports as well + val state by + remember(note) { + mutableStateOf( + AccountViewModel.NoteComposeReportState(), ) - } else { - val isHidden by remember(note) { - accountViewModel.account.liveHiddenUsers.map { - note.isHiddenFor(it) - }.distinctUntilChanged() - }.observeAsState(accountViewModel.isNoteHidden(note)) + } - Crossfade(targetState = isHidden, label = "CheckHiddenNoteCompose") { - if (!it) { - LoadedNoteCompose( - note = note, - routeForLastRead = routeForLastRead, - modifier = modifier, - isBoostedNote = isBoostedNote, - isQuotedNote = isQuotedNote, - unPackReply = unPackReply, - makeItShort = makeItShort, - addMarginTop = addMarginTop, - parentBackgroundColor = parentBackgroundColor, - accountViewModel = accountViewModel, - nav = nav - ) - } + RenderReportState( + state = state, + note = note, + routeForLastRead = routeForLastRead, + modifier = modifier, + isBoostedNote = isBoostedNote, + isQuotedNote = isQuotedNote, + unPackReply = unPackReply, + makeItShort = makeItShort, + addMarginTop = addMarginTop, + parentBackgroundColor = parentBackgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } else { + val isHidden by + remember(note) { + accountViewModel.account.liveHiddenUsers + .map { note.isHiddenFor(it) } + .distinctUntilChanged() } + .observeAsState(accountViewModel.isNoteHidden(note)) + + Crossfade(targetState = isHidden, label = "CheckHiddenNoteCompose") { + if (!it) { + LoadedNoteCompose( + note = note, + routeForLastRead = routeForLastRead, + modifier = modifier, + isBoostedNote = isBoostedNote, + isQuotedNote = isQuotedNote, + unPackReply = unPackReply, + makeItShort = makeItShort, + addMarginTop = addMarginTop, + parentBackgroundColor = parentBackgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } } + } } @Composable fun LoadedNoteCompose( - note: Note, - routeForLastRead: String? = null, - modifier: Modifier = Modifier, - isBoostedNote: Boolean = false, - isQuotedNote: Boolean = false, - unPackReply: Boolean = true, - makeItShort: Boolean = false, - addMarginTop: Boolean = true, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + note: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + isBoostedNote: Boolean = false, + isQuotedNote: Boolean = false, + unPackReply: Boolean = true, + makeItShort: Boolean = false, + addMarginTop: Boolean = true, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var state by remember(note) { - mutableStateOf( - AccountViewModel.NoteComposeReportState() + var state by + remember(note) { + mutableStateOf( + AccountViewModel.NoteComposeReportState(), + ) + } + + WatchForReports(note, accountViewModel) { newState -> + if (state != newState) { + state = newState + } + } + + Crossfade(targetState = state, label = "LoadedNoteCompose") { + RenderReportState( + it, + note, + routeForLastRead, + modifier, + isBoostedNote, + isQuotedNote, + unPackReply, + makeItShort, + addMarginTop, + parentBackgroundColor, + accountViewModel, + nav, + ) + } +} + +@Composable +fun RenderReportState( + state: AccountViewModel.NoteComposeReportState, + note: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + isBoostedNote: Boolean = false, + isQuotedNote: Boolean = false, + unPackReply: Boolean = true, + makeItShort: Boolean = false, + addMarginTop: Boolean = true, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + var showReportedNote by remember(note) { mutableStateOf(false) } + + Crossfade(targetState = !state.isAcceptable && !showReportedNote, label = "RenderReportState") { + showHiddenNote -> + if (showHiddenNote) { + HiddenNote( + state.relevantReports, + state.isHiddenAuthor, + accountViewModel, + modifier, + isBoostedNote, + nav, + onClick = { showReportedNote = true }, + ) + } else { + val canPreview = (!state.isAcceptable && showReportedNote) || state.canPreview + + NormalNote( + baseNote = note, + routeForLastRead = routeForLastRead, + modifier = modifier, + isBoostedNote = isBoostedNote, + isQuotedNote = isQuotedNote, + unPackReply = unPackReply, + makeItShort = makeItShort, + addMarginTop = addMarginTop, + canPreview = canPreview, + parentBackgroundColor = parentBackgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} + +@Composable +fun WatchForReports( + note: Note, + accountViewModel: AccountViewModel, + onChange: (AccountViewModel.NoteComposeReportState) -> Unit, +) { + val userFollowsState by accountViewModel.userFollows.observeAsState() + val noteReportsState by note.live().reports.observeAsState() + val userBlocks by accountViewModel.account.flowHiddenUsers.collectAsStateWithLifecycle() + + LaunchedEffect(key1 = noteReportsState, key2 = userFollowsState, userBlocks) { + accountViewModel.isNoteAcceptable(note, onChange) + } +} + +@Composable +fun NormalNote( + baseNote: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + isBoostedNote: Boolean = false, + isQuotedNote: Boolean = false, + unPackReply: Boolean = true, + makeItShort: Boolean = false, + addMarginTop: Boolean = true, + canPreview: Boolean = true, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + if (isQuotedNote || isBoostedNote) { + when (baseNote.event) { + is ChannelCreateEvent, + is ChannelMetadataEvent, -> + ChannelHeader( + channelNote = baseNote, + showVideo = !makeItShort, + showBottomDiviser = true, + sendToChannel = true, + accountViewModel = accountViewModel, + nav = nav, ) - } - - WatchForReports(note, accountViewModel) { newState -> - if (state != newState) { - state = newState + is CommunityDefinitionEvent -> + (baseNote as? AddressableNote)?.let { + CommunityHeader( + baseNote = it, + showBottomDiviser = true, + sendToCommunity = true, + accountViewModel = accountViewModel, + nav = nav, + ) } - } - - Crossfade(targetState = state, label = "LoadedNoteCompose") { - RenderReportState( - it, - note, + is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote) + else -> + LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { + showPopup, + -> + CheckNewAndRenderNote( + baseNote, routeForLastRead, modifier, isBoostedNote, @@ -368,3403 +516,3256 @@ fun LoadedNoteCompose( unPackReply, makeItShort, addMarginTop, + canPreview, parentBackgroundColor, accountViewModel, - nav + showPopup, + nav, + ) + } + } + } else { + when (baseNote.event) { + is ChannelCreateEvent, + is ChannelMetadataEvent, -> + ChannelHeader( + channelNote = baseNote, + showVideo = !makeItShort, + showBottomDiviser = true, + sendToChannel = true, + accountViewModel = accountViewModel, + nav = nav, ) - } -} - -@Composable -fun RenderReportState( - state: AccountViewModel.NoteComposeReportState, - note: Note, - routeForLastRead: String? = null, - modifier: Modifier = Modifier, - isBoostedNote: Boolean = false, - isQuotedNote: Boolean = false, - unPackReply: Boolean = true, - makeItShort: Boolean = false, - addMarginTop: Boolean = true, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit -) { - var showReportedNote by remember(note) { mutableStateOf(false) } - - Crossfade(targetState = !state.isAcceptable && !showReportedNote, label = "RenderReportState") { showHiddenNote -> - if (showHiddenNote) { - HiddenNote( - state.relevantReports, - state.isHiddenAuthor, - accountViewModel, - modifier, - isBoostedNote, - nav, - onClick = { showReportedNote = true } - ) - } else { - val canPreview = (!state.isAcceptable && showReportedNote) || state.canPreview - - NormalNote( - baseNote = note, - routeForLastRead = routeForLastRead, - modifier = modifier, - isBoostedNote = isBoostedNote, - isQuotedNote = isQuotedNote, - unPackReply = unPackReply, - makeItShort = makeItShort, - addMarginTop = addMarginTop, - canPreview = canPreview, - parentBackgroundColor = parentBackgroundColor, - accountViewModel = accountViewModel, - nav = nav - ) - } - } -} - -@Composable -fun WatchForReports( - note: Note, - accountViewModel: AccountViewModel, - onChange: (AccountViewModel.NoteComposeReportState) -> Unit -) { - val userFollowsState by accountViewModel.userFollows.observeAsState() - val noteReportsState by note.live().reports.observeAsState() - val userBlocks by accountViewModel.account.flowHiddenUsers.collectAsStateWithLifecycle() - - LaunchedEffect(key1 = noteReportsState, key2 = userFollowsState, userBlocks) { - accountViewModel.isNoteAcceptable(note, onChange) - } -} - -@Composable -fun NormalNote( - baseNote: Note, - routeForLastRead: String? = null, - modifier: Modifier = Modifier, - isBoostedNote: Boolean = false, - isQuotedNote: Boolean = false, - unPackReply: Boolean = true, - makeItShort: Boolean = false, - addMarginTop: Boolean = true, - canPreview: Boolean = true, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit -) { - if (isQuotedNote || isBoostedNote) { - when (baseNote.event) { - is ChannelCreateEvent, is ChannelMetadataEvent -> ChannelHeader( - channelNote = baseNote, - showVideo = !makeItShort, - showBottomDiviser = true, - sendToChannel = true, - accountViewModel = accountViewModel, - nav = nav - ) - is CommunityDefinitionEvent -> (baseNote as? AddressableNote)?.let { - CommunityHeader( - baseNote = it, - showBottomDiviser = true, - sendToCommunity = true, - accountViewModel = accountViewModel, - nav = nav - ) - } - is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote) - else -> - LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> - CheckNewAndRenderNote( - baseNote, - routeForLastRead, - modifier, - isBoostedNote, - isQuotedNote, - unPackReply, - makeItShort, - addMarginTop, - canPreview, - parentBackgroundColor, - accountViewModel, - showPopup, - nav - ) - } - } - } else { - when (baseNote.event) { - is ChannelCreateEvent, is ChannelMetadataEvent -> ChannelHeader( - channelNote = baseNote, - showVideo = !makeItShort, - showBottomDiviser = true, - sendToChannel = true, - accountViewModel = accountViewModel, - nav = nav - ) - is CommunityDefinitionEvent -> (baseNote as? AddressableNote)?.let { - CommunityHeader( - baseNote = it, - showBottomDiviser = true, - sendToCommunity = true, - accountViewModel = accountViewModel, - nav = nav - ) - } - is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote) - is FileHeaderEvent -> FileHeaderDisplay(baseNote, false, accountViewModel) - is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, false, accountViewModel) - else -> - LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> - CheckNewAndRenderNote( - baseNote = baseNote, - routeForLastRead = routeForLastRead, - modifier = modifier, - isBoostedNote = isBoostedNote, - isQuotedNote = isQuotedNote, - unPackReply = unPackReply, - makeItShort = makeItShort, - addMarginTop = addMarginTop, - canPreview = canPreview, - parentBackgroundColor = parentBackgroundColor, - accountViewModel = accountViewModel, - showPopup = showPopup, - nav = nav - ) - } + is CommunityDefinitionEvent -> + (baseNote as? AddressableNote)?.let { + CommunityHeader( + baseNote = it, + showBottomDiviser = true, + sendToCommunity = true, + accountViewModel = accountViewModel, + nav = nav, + ) + } + is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote) + is FileHeaderEvent -> FileHeaderDisplay(baseNote, false, accountViewModel) + is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, false, accountViewModel) + else -> + LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { + showPopup, + -> + CheckNewAndRenderNote( + baseNote = baseNote, + routeForLastRead = routeForLastRead, + modifier = modifier, + isBoostedNote = isBoostedNote, + isQuotedNote = isQuotedNote, + unPackReply = unPackReply, + makeItShort = makeItShort, + addMarginTop = addMarginTop, + canPreview = canPreview, + parentBackgroundColor = parentBackgroundColor, + accountViewModel = accountViewModel, + showPopup = showPopup, + nav = nav, + ) } } + } } @Composable fun CommunityHeader( - baseNote: AddressableNote, - showBottomDiviser: Boolean, - sendToCommunity: Boolean, - modifier: Modifier = StdPadding, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: AddressableNote, + showBottomDiviser: Boolean, + sendToCommunity: Boolean, + modifier: Modifier = StdPadding, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val expanded = remember { mutableStateOf(false) } + val expanded = remember { mutableStateOf(false) } - Column(Modifier.fillMaxWidth()) { - Column( - verticalArrangement = Arrangement.Center, - modifier = Modifier.clickable { - if (sendToCommunity) { - routeFor(baseNote, accountViewModel.userProfile())?.let { - nav(it) - } - } else { - expanded.value = !expanded.value - } - } - ) { - ShortCommunityHeader( - baseNote = baseNote, - accountViewModel = accountViewModel, - nav = nav - ) + Column(Modifier.fillMaxWidth()) { + Column( + verticalArrangement = Arrangement.Center, + modifier = + Modifier.clickable { + if (sendToCommunity) { + routeFor(baseNote, accountViewModel.userProfile())?.let { nav(it) } + } else { + expanded.value = !expanded.value + } + }, + ) { + ShortCommunityHeader( + baseNote = baseNote, + accountViewModel = accountViewModel, + nav = nav, + ) - if (expanded.value) { - Column(Modifier.verticalScroll(rememberScrollState())) { - LongCommunityHeader( - baseNote = baseNote, - lineModifier = modifier, - accountViewModel = accountViewModel, - nav = nav - ) - } - } - } - - if (showBottomDiviser) { - Divider( - thickness = DividerThickness - ) + if (expanded.value) { + Column(Modifier.verticalScroll(rememberScrollState())) { + LongCommunityHeader( + baseNote = baseNote, + lineModifier = modifier, + accountViewModel = accountViewModel, + nav = nav, + ) } + } } + + if (showBottomDiviser) { + Divider( + thickness = DividerThickness, + ) + } + } } @Composable fun LongCommunityHeader( - baseNote: AddressableNote, - lineModifier: Modifier = Modifier.padding(horizontal = Size10dp, vertical = Size5dp), - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: AddressableNote, + lineModifier: Modifier = Modifier.padding(horizontal = Size10dp, vertical = Size5dp), + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteState by baseNote.live().metadata.observeAsState() - val noteEvent = remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return + val noteState by baseNote.live().metadata.observeAsState() + val noteEvent = + remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return - Row( - lineModifier - ) { - val rulesLabel = stringResource(id = R.string.rules) - val summary = remember(noteState) { - val subject = noteEvent.subject()?.ifEmpty { null } - val body = noteEvent.description()?.ifBlank { null } - val rules = noteEvent.rules()?.ifBlank { null } + Row( + lineModifier, + ) { + val rulesLabel = stringResource(id = R.string.rules) + val summary = + remember(noteState) { + val subject = noteEvent.subject()?.ifEmpty { null } + val body = noteEvent.description()?.ifBlank { null } + val rules = noteEvent.rules()?.ifBlank { null } - if (!subject.isNullOrBlank() && body?.split("\n")?.get(0)?.contains(subject) == false) { - if (rules == null) { - "### $subject\n$body" - } else { - "### $subject\n$body\n\n### $rulesLabel\n\n$rules" - } - } else { - if (rules == null) { - body - } else { - "$body\n\n$rulesLabel\n$rules" - } - } + if (!subject.isNullOrBlank() && body?.split("\n")?.get(0)?.contains(subject) == false) { + if (rules == null) { + "### $subject\n$body" + } else { + "### $subject\n$body\n\n### $rulesLabel\n\n$rules" + } + } else { + if (rules == null) { + body + } else { + "$body\n\n$rulesLabel\n$rules" + } } + } - Column( - Modifier.weight(1f) + Column( + Modifier.weight(1f), + ) { + Row(verticalAlignment = CenterVertically) { + val defaultBackground = MaterialTheme.colorScheme.background + val background = remember { mutableStateOf(defaultBackground) } + + TranslatableRichTextViewer( + content = summary ?: stringResource(id = R.string.community_no_descriptor), + canPreview = false, + tags = EmptyTagList, + backgroundColor = background, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (summary != null && noteEvent.hasHashtags()) { + DisplayUncitedHashtags( + remember(noteEvent) { noteEvent.hashtags().toImmutableList() }, + summary ?: "", + nav, + ) + } + } + + Column { + Row { + Spacer(DoubleHorzSpacer) + LongCommunityActionOptions(baseNote, accountViewModel, nav) + } + } + } + + Row( + lineModifier, + verticalAlignment = CenterVertically, + ) { + Text( + text = stringResource(id = R.string.owner), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(75.dp), + ) + Spacer(DoubleHorzSpacer) + NoteAuthorPicture(baseNote, nav, accountViewModel, Size25dp) + Spacer(DoubleHorzSpacer) + NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) }) + } + + var participantUsers by + remember(baseNote) { + mutableStateOf>>( + persistentListOf(), + ) + } + + LaunchedEffect(key1 = noteState) { + val participants = (noteState?.note?.event as? CommunityDefinitionEvent)?.moderators() + + if (participants != null) { + accountViewModel.loadParticipants(participants) { newParticipantUsers -> + if ( + newParticipantUsers != null && !equalImmutableLists(newParticipantUsers, participantUsers) ) { - Row(verticalAlignment = CenterVertically) { - val defaultBackground = MaterialTheme.colorScheme.background - val background = remember { - mutableStateOf(defaultBackground) - } - - TranslatableRichTextViewer( - content = summary ?: stringResource(id = R.string.community_no_descriptor), - canPreview = false, - tags = EmptyTagList, - backgroundColor = background, - accountViewModel = accountViewModel, - nav = nav - ) - } - - if (summary != null && noteEvent.hasHashtags()) { - DisplayUncitedHashtags( - remember(noteEvent) { noteEvent.hashtags().toImmutableList() }, - summary ?: "", - nav - ) - } - } - - Column() { - Row() { - Spacer(DoubleHorzSpacer) - LongCommunityActionOptions(baseNote, accountViewModel, nav) - } + participantUsers = newParticipantUsers } + } } + } + participantUsers.forEach { Row( - lineModifier, - verticalAlignment = CenterVertically + lineModifier.clickable { nav("User/${it.second.pubkeyHex}") }, + verticalAlignment = CenterVertically, ) { + it.first.role?.let { it1 -> Text( - text = stringResource(id = R.string.owner), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.width(75.dp) + text = it1.capitalize(Locale.ROOT), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(75.dp), ) - Spacer(DoubleHorzSpacer) - NoteAuthorPicture(baseNote, nav, accountViewModel, Size25dp) - Spacer(DoubleHorzSpacer) - NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) }) + } + Spacer(DoubleHorzSpacer) + ClickableUserPicture(it.second, Size25dp, accountViewModel) + Spacer(DoubleHorzSpacer) + UsernameDisplay(it.second, remember { Modifier.weight(1f) }) } + } - var participantUsers by remember(baseNote) { - mutableStateOf>>( - persistentListOf() - ) - } - - LaunchedEffect(key1 = noteState) { - val participants = (noteState?.note?.event as? CommunityDefinitionEvent)?.moderators() - - if (participants != null) { - accountViewModel.loadParticipants(participants) { newParticipantUsers -> - if (newParticipantUsers != null && !equalImmutableLists(newParticipantUsers, participantUsers)) { - participantUsers = newParticipantUsers - } - } - } - } - - participantUsers.forEach { - Row( - lineModifier.clickable { - nav("User/${it.second.pubkeyHex}") - }, - verticalAlignment = CenterVertically - ) { - it.first.role?.let { it1 -> - Text( - text = it1.capitalize(Locale.ROOT), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.width(75.dp) - ) - } - Spacer(DoubleHorzSpacer) - ClickableUserPicture(it.second, Size25dp, accountViewModel) - Spacer(DoubleHorzSpacer) - UsernameDisplay(it.second, remember { Modifier.weight(1f) }) - } - } - - Row( - lineModifier, - verticalAlignment = CenterVertically - ) { - Text( - text = stringResource(id = R.string.created_at), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.width(75.dp) - ) - Spacer(DoubleHorzSpacer) - NormalTimeAgo(baseNote = baseNote, Modifier.weight(1f)) - MoreOptionsButton(baseNote, accountViewModel) - } + Row( + lineModifier, + verticalAlignment = CenterVertically, + ) { + Text( + text = stringResource(id = R.string.created_at), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(75.dp), + ) + Spacer(DoubleHorzSpacer) + NormalTimeAgo(baseNote = baseNote, Modifier.weight(1f)) + MoreOptionsButton(baseNote, accountViewModel) + } } @Composable -fun ShortCommunityHeader(baseNote: AddressableNote, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val noteState by baseNote.live().metadata.observeAsState() - val noteEvent = remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return +fun ShortCommunityHeader( + baseNote: AddressableNote, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteState by baseNote.live().metadata.observeAsState() + val noteEvent = + remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return - val automaticallyShowProfilePicture = remember { - accountViewModel.settings.showProfilePictures.value + val automaticallyShowProfilePicture = remember { + accountViewModel.settings.showProfilePictures.value + } + + Row(verticalAlignment = CenterVertically) { + noteEvent.image()?.let { + RobohashFallbackAsyncImage( + robot = baseNote.idHex, + model = it, + contentDescription = stringResource(R.string.profile_image), + contentScale = ContentScale.Crop, + modifier = HeaderPictureModifier, + loadProfilePicture = automaticallyShowProfilePicture, + ) } - Row(verticalAlignment = CenterVertically) { - noteEvent.image()?.let { - RobohashFallbackAsyncImage( - robot = baseNote.idHex, - model = it, - contentDescription = stringResource(R.string.profile_image), - contentScale = ContentScale.Crop, - modifier = HeaderPictureModifier, - loadProfilePicture = automaticallyShowProfilePicture - ) - } - - Column( - modifier = Modifier - .padding(start = 10.dp) - .height(Size35dp) - .weight(1f), - verticalArrangement = Arrangement.Center - ) { - Row(verticalAlignment = CenterVertically) { - Text( - text = remember(noteState) { noteEvent.dTag() }, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - - Row( - modifier = Modifier - .height(Size35dp) - .padding(start = 5.dp), - verticalAlignment = CenterVertically - ) { - ShortCommunityActionOptions(baseNote, accountViewModel, nav) - } + Column( + modifier = Modifier.padding(start = 10.dp).height(Size35dp).weight(1f), + verticalArrangement = Arrangement.Center, + ) { + Row(verticalAlignment = CenterVertically) { + Text( + text = remember(noteState) { noteEvent.dTag() }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } + + Row( + modifier = Modifier.height(Size35dp).padding(start = 5.dp), + verticalAlignment = CenterVertically, + ) { + ShortCommunityActionOptions(baseNote, accountViewModel, nav) + } + } } @Composable private fun ShortCommunityActionOptions( - note: AddressableNote, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + note: AddressableNote, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - Spacer(modifier = StdHorzSpacer) - LikeReaction(baseNote = note, grayTint = MaterialTheme.colorScheme.onSurface, accountViewModel = accountViewModel, nav = nav) - Spacer(modifier = StdHorzSpacer) - ZapReaction(baseNote = note, grayTint = MaterialTheme.colorScheme.onSurface, accountViewModel = accountViewModel, nav = nav) + Spacer(modifier = StdHorzSpacer) + LikeReaction( + baseNote = note, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav = nav, + ) + Spacer(modifier = StdHorzSpacer) + ZapReaction( + baseNote = note, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav = nav, + ) - WatchAddressableNoteFollows(note, accountViewModel) { isFollowing -> - if (!isFollowing) { - Spacer(modifier = StdHorzSpacer) - JoinCommunityButton(accountViewModel, note, nav) - } + WatchAddressableNoteFollows(note, accountViewModel) { isFollowing -> + if (!isFollowing) { + Spacer(modifier = StdHorzSpacer) + JoinCommunityButton(accountViewModel, note, nav) } + } } @Composable -fun WatchAddressableNoteFollows(note: AddressableNote, accountViewModel: AccountViewModel, onFollowChanges: @Composable (Boolean) -> Unit) { - val showFollowingMark by remember { - accountViewModel.userFollows.map { - it.user.latestContactList?.isTaggedAddressableNote(note.idHex) ?: false - }.distinctUntilChanged() - }.observeAsState(false) +fun WatchAddressableNoteFollows( + note: AddressableNote, + accountViewModel: AccountViewModel, + onFollowChanges: @Composable (Boolean) -> Unit, +) { + val showFollowingMark by + remember { + accountViewModel.userFollows + .map { it.user.latestContactList?.isTaggedAddressableNote(note.idHex) ?: false } + .distinctUntilChanged() + } + .observeAsState(false) - onFollowChanges(showFollowingMark) + onFollowChanges(showFollowingMark) } @Composable private fun LongCommunityActionOptions( - note: AddressableNote, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + note: AddressableNote, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - WatchAddressableNoteFollows(note, accountViewModel) { isFollowing -> - if (isFollowing) { - LeaveCommunityButton(accountViewModel, note, nav) - } + WatchAddressableNoteFollows(note, accountViewModel) { isFollowing -> + if (isFollowing) { + LeaveCommunityButton(accountViewModel, note, nav) } + } } @Composable private fun CheckNewAndRenderNote( - baseNote: Note, - routeForLastRead: String? = null, - modifier: Modifier = Modifier, - isBoostedNote: Boolean = false, - isQuotedNote: Boolean = false, - unPackReply: Boolean = true, - makeItShort: Boolean = false, - addMarginTop: Boolean = true, - canPreview: Boolean = true, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - showPopup: () -> Unit, - nav: (String) -> Unit + baseNote: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + isBoostedNote: Boolean = false, + isQuotedNote: Boolean = false, + unPackReply: Boolean = true, + makeItShort: Boolean = false, + addMarginTop: Boolean = true, + canPreview: Boolean = true, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + showPopup: () -> Unit, + nav: (String) -> Unit, ) { - val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor - val defaultBackgroundColor = MaterialTheme.colorScheme.background - val backgroundColor = remember(baseNote) { mutableStateOf(parentBackgroundColor?.value ?: defaultBackgroundColor) } + val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor + val defaultBackgroundColor = MaterialTheme.colorScheme.background + val backgroundColor = + remember(baseNote) { + mutableStateOf(parentBackgroundColor?.value ?: defaultBackgroundColor) + } - LaunchedEffect(key1 = routeForLastRead, key2 = parentBackgroundColor?.value) { - routeForLastRead?.let { - accountViewModel.loadAndMarkAsRead(it, baseNote.createdAt()) { isNew -> - val newBackgroundColor = if (isNew) { - if (parentBackgroundColor != null) { - newItemColor.compositeOver(parentBackgroundColor.value) - } else { - newItemColor.compositeOver(defaultBackgroundColor) - } - } else { - parentBackgroundColor?.value ?: defaultBackgroundColor - } - - if (newBackgroundColor != backgroundColor.value) { - launch(Dispatchers.Main) { - backgroundColor.value = newBackgroundColor - } - } + LaunchedEffect(key1 = routeForLastRead, key2 = parentBackgroundColor?.value) { + routeForLastRead?.let { + accountViewModel.loadAndMarkAsRead(it, baseNote.createdAt()) { isNew -> + val newBackgroundColor = + if (isNew) { + if (parentBackgroundColor != null) { + newItemColor.compositeOver(parentBackgroundColor.value) + } else { + newItemColor.compositeOver(defaultBackgroundColor) } - } ?: run { - val newBackgroundColor = parentBackgroundColor?.value ?: defaultBackgroundColor + } else { + parentBackgroundColor?.value ?: defaultBackgroundColor + } - if (newBackgroundColor != backgroundColor.value) { - launch(Dispatchers.Main) { - backgroundColor.value = newBackgroundColor - } - } + if (newBackgroundColor != backgroundColor.value) { + launch(Dispatchers.Main) { backgroundColor.value = newBackgroundColor } } + } } + ?: run { + val newBackgroundColor = parentBackgroundColor?.value ?: defaultBackgroundColor - ClickableNote( - baseNote = baseNote, - backgroundColor = backgroundColor, - modifier = modifier, - accountViewModel = accountViewModel, - showPopup = showPopup, - nav = nav - ) { - InnerNoteWithReactions( - baseNote = baseNote, - backgroundColor = backgroundColor, - isBoostedNote = isBoostedNote, - isQuotedNote = isQuotedNote, - addMarginTop = addMarginTop, - unPackReply = unPackReply, - makeItShort = makeItShort, - canPreview = canPreview, - accountViewModel = accountViewModel, - nav = nav - ) - } + if (newBackgroundColor != backgroundColor.value) { + launch(Dispatchers.Main) { backgroundColor.value = newBackgroundColor } + } + } + } + + ClickableNote( + baseNote = baseNote, + backgroundColor = backgroundColor, + modifier = modifier, + accountViewModel = accountViewModel, + showPopup = showPopup, + nav = nav, + ) { + InnerNoteWithReactions( + baseNote = baseNote, + backgroundColor = backgroundColor, + isBoostedNote = isBoostedNote, + isQuotedNote = isQuotedNote, + addMarginTop = addMarginTop, + unPackReply = unPackReply, + makeItShort = makeItShort, + canPreview = canPreview, + accountViewModel = accountViewModel, + nav = nav, + ) + } } @Composable @OptIn(ExperimentalFoundationApi::class) fun ClickableNote( - baseNote: Note, - modifier: Modifier, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - showPopup: () -> Unit, - nav: (String) -> Unit, - content: @Composable () -> Unit + baseNote: Note, + modifier: Modifier, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + showPopup: () -> Unit, + nav: (String) -> Unit, + content: @Composable () -> Unit, ) { - val scope = rememberCoroutineScope() + val scope = rememberCoroutineScope() - val updatedModifier = remember(baseNote, backgroundColor.value) { - modifier - .combinedClickable( - onClick = { - scope.launch { - val redirectToNote = - if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) { - baseNote.replyTo?.lastOrNull() ?: baseNote - } else { - baseNote - } - routeFor(redirectToNote, accountViewModel.userProfile())?.let { - nav(it) - } - } - }, - onLongClick = showPopup - ) - .background(backgroundColor.value) + val updatedModifier = + remember(baseNote, backgroundColor.value) { + modifier + .combinedClickable( + onClick = { + scope.launch { + val redirectToNote = + if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) { + baseNote.replyTo?.lastOrNull() ?: baseNote + } else { + baseNote + } + routeFor(redirectToNote, accountViewModel.userProfile())?.let { nav(it) } + } + }, + onLongClick = showPopup, + ) + .background(backgroundColor.value) } - Column(modifier = updatedModifier) { - content() - } + Column(modifier = updatedModifier) { content() } } @Composable fun InnerNoteWithReactions( - baseNote: Note, - backgroundColor: MutableState, - isBoostedNote: Boolean, - isQuotedNote: Boolean, - addMarginTop: Boolean, - unPackReply: Boolean, - makeItShort: Boolean, - canPreview: Boolean, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + backgroundColor: MutableState, + isBoostedNote: Boolean, + isQuotedNote: Boolean, + addMarginTop: Boolean, + unPackReply: Boolean, + makeItShort: Boolean, + canPreview: Boolean, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val notBoostedNorQuote = !isBoostedNote && !isQuotedNote - - Row( - modifier = if (!isBoostedNote && addMarginTop) { - normalWithTopMarginNoteModifier - } else if (!isBoostedNote) { - normalNoteModifier - } else { - boostedNoteModifier - } - ) { - if (notBoostedNorQuote) { - Column(WidthAuthorPictureModifier) { - AuthorAndRelayInformation(baseNote, accountViewModel, nav) - } - Spacer(modifier = DoubleHorzSpacer) - } - - Column(Modifier.fillMaxWidth()) { - val showSecondRow = baseNote.event !is RepostEvent && baseNote.event !is GenericRepostEvent && !isBoostedNote && !isQuotedNote - NoteBody( - baseNote = baseNote, - showAuthorPicture = isQuotedNote, - unPackReply = unPackReply, - makeItShort = makeItShort, - canPreview = canPreview, - showSecondRow = showSecondRow, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav - ) - } - } - - val isNotRepost = baseNote.event !is RepostEvent && baseNote.event !is GenericRepostEvent - - if (isNotRepost) { - if (makeItShort) { - if (isBoostedNote) { - } else { - Spacer(modifier = DoubleVertSpacer) - } - } else { - ReactionsRow( - baseNote = baseNote, - showReactionDetail = notBoostedNorQuote, - accountViewModel = accountViewModel, - nav = nav - ) - } - } + val notBoostedNorQuote = !isBoostedNote && !isQuotedNote + Row( + modifier = + if (!isBoostedNote && addMarginTop) { + normalWithTopMarginNoteModifier + } else if (!isBoostedNote) { + normalNoteModifier + } else { + boostedNoteModifier + }, + ) { if (notBoostedNorQuote) { - Divider( - thickness = DividerThickness - ) + Column(WidthAuthorPictureModifier) { + AuthorAndRelayInformation(baseNote, accountViewModel, nav) + } + Spacer(modifier = DoubleHorzSpacer) } + + Column(Modifier.fillMaxWidth()) { + val showSecondRow = + baseNote.event !is RepostEvent && + baseNote.event !is GenericRepostEvent && + !isBoostedNote && + !isQuotedNote + NoteBody( + baseNote = baseNote, + showAuthorPicture = isQuotedNote, + unPackReply = unPackReply, + makeItShort = makeItShort, + canPreview = canPreview, + showSecondRow = showSecondRow, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + + val isNotRepost = baseNote.event !is RepostEvent && baseNote.event !is GenericRepostEvent + + if (isNotRepost) { + if (makeItShort) { + if (isBoostedNote) {} else { + Spacer(modifier = DoubleVertSpacer) + } + } else { + ReactionsRow( + baseNote = baseNote, + showReactionDetail = notBoostedNorQuote, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + + if (notBoostedNorQuote) { + Divider( + thickness = DividerThickness, + ) + } } @Composable private fun NoteBody( - baseNote: Note, - showAuthorPicture: Boolean = false, - unPackReply: Boolean = true, - makeItShort: Boolean = false, - canPreview: Boolean = true, - showSecondRow: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + showAuthorPicture: Boolean = false, + unPackReply: Boolean = true, + makeItShort: Boolean = false, + canPreview: Boolean = true, + showSecondRow: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - FirstUserInfoRow( - baseNote = baseNote, - showAuthorPicture = showAuthorPicture, - accountViewModel = accountViewModel, - nav = nav + FirstUserInfoRow( + baseNote = baseNote, + showAuthorPicture = showAuthorPicture, + accountViewModel = accountViewModel, + nav = nav, + ) + + if (showSecondRow) { + SecondUserInfoRow( + baseNote, + accountViewModel, + nav, ) + } - if (showSecondRow) { - SecondUserInfoRow( - baseNote, - accountViewModel, - nav - ) - } + if (baseNote.event !is RepostEvent && baseNote.event !is GenericRepostEvent) { + Spacer(modifier = Modifier.height(3.dp)) + } - if (baseNote.event !is RepostEvent && baseNote.event !is GenericRepostEvent) { - Spacer(modifier = Modifier.height(3.dp)) - } - - if (!makeItShort) { - ReplyRow( - baseNote, - unPackReply, - backgroundColor, - accountViewModel, - nav - ) - } - - RenderNoteRow( - baseNote, - backgroundColor, - makeItShort, - canPreview, - accountViewModel, - nav + if (!makeItShort) { + ReplyRow( + baseNote, + unPackReply, + backgroundColor, + accountViewModel, + nav, ) + } - val noteEvent = baseNote.event - val zapSplits = remember(noteEvent) { noteEvent?.hasZapSplitSetup() ?: false } - if (zapSplits && noteEvent != null) { - Spacer(modifier = HalfDoubleVertSpacer) - DisplayZapSplits(noteEvent, accountViewModel, nav) - } + RenderNoteRow( + baseNote, + backgroundColor, + makeItShort, + canPreview, + accountViewModel, + nav, + ) + + val noteEvent = baseNote.event + val zapSplits = remember(noteEvent) { noteEvent?.hasZapSplitSetup() ?: false } + if (zapSplits && noteEvent != null) { + Spacer(modifier = HalfDoubleVertSpacer) + DisplayZapSplits(noteEvent, accountViewModel, nav) + } } @Composable private fun RenderNoteRow( - baseNote: Note, - backgroundColor: MutableState, - makeItShort: Boolean, - canPreview: Boolean, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + backgroundColor: MutableState, + makeItShort: Boolean, + canPreview: Boolean, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = baseNote.event - when (noteEvent) { - is AppDefinitionEvent -> { - RenderAppDefinition(baseNote, accountViewModel, nav) - } - - is AudioTrackEvent -> { - RenderAudioTrack(baseNote, accountViewModel, nav) - } - - is AudioHeaderEvent -> { - RenderAudioHeader(baseNote, accountViewModel, nav) - } - - is ReactionEvent -> { - RenderReaction(baseNote, backgroundColor, accountViewModel, nav) - } - - is RepostEvent -> { - RenderRepost(baseNote, backgroundColor, accountViewModel, nav) - } - - is GenericRepostEvent -> { - RenderRepost(baseNote, backgroundColor, accountViewModel, nav) - } - - is ReportEvent -> { - RenderReport(baseNote, backgroundColor, accountViewModel, nav) - } - - is LongTextNoteEvent -> { - RenderLongFormContent(baseNote, accountViewModel, nav) - } - - is BadgeAwardEvent -> { - RenderBadgeAward(baseNote, backgroundColor, accountViewModel, nav) - } - - is PeopleListEvent -> { - DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav) - } - - is RelaySetEvent -> { - DisplayRelaySet(baseNote, backgroundColor, accountViewModel, nav) - } - - is PinListEvent -> { - RenderPinListEvent(baseNote, backgroundColor, accountViewModel, nav) - } - - is EmojiPackEvent -> { - RenderEmojiPack(baseNote, true, backgroundColor, accountViewModel) - } - - is LiveActivitiesEvent -> { - RenderLiveActivityEvent(baseNote, accountViewModel, nav) - } - - is PrivateDmEvent -> { - RenderPrivateMessage( - baseNote, - makeItShort, - canPreview, - backgroundColor, - accountViewModel, - nav - ) - } - - is ClassifiedsEvent -> { - RenderClassifieds( - noteEvent, - baseNote, - accountViewModel, - nav - ) - } - - is HighlightEvent -> { - RenderHighlight( - baseNote, - makeItShort, - canPreview, - backgroundColor, - accountViewModel, - nav - ) - } - - is PollNoteEvent -> { - RenderPoll( - baseNote, - makeItShort, - canPreview, - backgroundColor, - accountViewModel, - nav - ) - } - - is FileHeaderEvent -> { - FileHeaderDisplay(baseNote, true, accountViewModel) - } - - is VideoHorizontalEvent -> { - VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) - } - - is VideoVerticalEvent -> { - VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) - } - - is FileStorageHeaderEvent -> { - FileStorageHeaderDisplay(baseNote, true, accountViewModel) - } - - is CommunityPostApprovalEvent -> { - RenderPostApproval( - baseNote, - makeItShort, - canPreview, - backgroundColor, - accountViewModel, - nav - ) - } - - else -> { - RenderTextEvent( - baseNote, - makeItShort, - canPreview, - backgroundColor, - accountViewModel, - nav - ) - } + val noteEvent = baseNote.event + when (noteEvent) { + is AppDefinitionEvent -> { + RenderAppDefinition(baseNote, accountViewModel, nav) } + is AudioTrackEvent -> { + RenderAudioTrack(baseNote, accountViewModel, nav) + } + is AudioHeaderEvent -> { + RenderAudioHeader(baseNote, accountViewModel, nav) + } + is ReactionEvent -> { + RenderReaction(baseNote, backgroundColor, accountViewModel, nav) + } + is RepostEvent -> { + RenderRepost(baseNote, backgroundColor, accountViewModel, nav) + } + is GenericRepostEvent -> { + RenderRepost(baseNote, backgroundColor, accountViewModel, nav) + } + is ReportEvent -> { + RenderReport(baseNote, backgroundColor, accountViewModel, nav) + } + is LongTextNoteEvent -> { + RenderLongFormContent(baseNote, accountViewModel, nav) + } + is BadgeAwardEvent -> { + RenderBadgeAward(baseNote, backgroundColor, accountViewModel, nav) + } + is PeopleListEvent -> { + DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav) + } + is RelaySetEvent -> { + DisplayRelaySet(baseNote, backgroundColor, accountViewModel, nav) + } + is PinListEvent -> { + RenderPinListEvent(baseNote, backgroundColor, accountViewModel, nav) + } + is EmojiPackEvent -> { + RenderEmojiPack(baseNote, true, backgroundColor, accountViewModel) + } + is LiveActivitiesEvent -> { + RenderLiveActivityEvent(baseNote, accountViewModel, nav) + } + is PrivateDmEvent -> { + RenderPrivateMessage( + baseNote, + makeItShort, + canPreview, + backgroundColor, + accountViewModel, + nav, + ) + } + is ClassifiedsEvent -> { + RenderClassifieds( + noteEvent, + baseNote, + accountViewModel, + nav, + ) + } + is HighlightEvent -> { + RenderHighlight( + baseNote, + makeItShort, + canPreview, + backgroundColor, + accountViewModel, + nav, + ) + } + is PollNoteEvent -> { + RenderPoll( + baseNote, + makeItShort, + canPreview, + backgroundColor, + accountViewModel, + nav, + ) + } + is FileHeaderEvent -> { + FileHeaderDisplay(baseNote, true, accountViewModel) + } + is VideoHorizontalEvent -> { + VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) + } + is VideoVerticalEvent -> { + VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) + } + is FileStorageHeaderEvent -> { + FileStorageHeaderDisplay(baseNote, true, accountViewModel) + } + is CommunityPostApprovalEvent -> { + RenderPostApproval( + baseNote, + makeItShort, + canPreview, + backgroundColor, + accountViewModel, + nav, + ) + } + else -> { + RenderTextEvent( + baseNote, + makeItShort, + canPreview, + backgroundColor, + accountViewModel, + nav, + ) + } + } } @Composable fun LoadDecryptedContent( - note: Note, - accountViewModel: AccountViewModel, - inner: @Composable (String) -> Unit + note: Note, + accountViewModel: AccountViewModel, + inner: @Composable (String) -> Unit, ) { - var decryptedContent by remember(note.event) { - mutableStateOf( - accountViewModel.cachedDecrypt(note) - ) + var decryptedContent by + remember(note.event) { + mutableStateOf( + accountViewModel.cachedDecrypt(note), + ) } - decryptedContent?.let { - inner(it) - } ?: run { - LaunchedEffect(key1 = decryptedContent) { - accountViewModel.decrypt(note) { - decryptedContent = it - } - } + decryptedContent?.let { inner(it) } + ?: run { + LaunchedEffect(key1 = decryptedContent) { + accountViewModel.decrypt(note) { decryptedContent = it } + } } } @Composable fun LoadDecryptedContentOrNull( - note: Note, - accountViewModel: AccountViewModel, - inner: @Composable (String?) -> Unit + note: Note, + accountViewModel: AccountViewModel, + inner: @Composable (String?) -> Unit, ) { - var decryptedContent by remember(note.event) { - mutableStateOf( - accountViewModel.cachedDecrypt(note) - ) + var decryptedContent by + remember(note.event) { + mutableStateOf( + accountViewModel.cachedDecrypt(note), + ) } - if (decryptedContent == null) { - LaunchedEffect(key1 = decryptedContent) { - accountViewModel.decrypt(note) { - decryptedContent = it - } - } + if (decryptedContent == null) { + LaunchedEffect(key1 = decryptedContent) { + accountViewModel.decrypt(note) { decryptedContent = it } } + } - inner(decryptedContent) + inner(decryptedContent) } @Composable fun RenderTextEvent( - note: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - LoadDecryptedContent(note, accountViewModel) { body -> - val eventContent by remember(note.event) { - derivedStateOf { - val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null } + LoadDecryptedContent(note, accountViewModel) { body -> + val eventContent by + remember(note.event) { + derivedStateOf { + val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null } - if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) { - "### $subject\n$body" - } else { - body - } - } + if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) { + "### $subject\n$body" + } else { + body + } } + } - val isAuthorTheLoggedUser = remember(note.event) { accountViewModel.isLoggedUser(note.author) } + val isAuthorTheLoggedUser = remember(note.event) { accountViewModel.isLoggedUser(note.author) } - if (makeItShort && isAuthorTheLoggedUser) { - Text( - text = eventContent, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } else { - SensitivityWarning( - note = note, - accountViewModel = accountViewModel - ) { - val modifier = remember(note) { Modifier.fillMaxWidth() } - val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } + if (makeItShort && isAuthorTheLoggedUser) { + Text( + text = eventContent, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } else { + SensitivityWarning( + note = note, + accountViewModel = accountViewModel, + ) { + val modifier = remember(note) { Modifier.fillMaxWidth() } + val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } - TranslatableRichTextViewer( - content = eventContent, - canPreview = canPreview && !makeItShort, - modifier = modifier, - tags = tags, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav - ) - } + TranslatableRichTextViewer( + content = eventContent, + canPreview = canPreview && !makeItShort, + modifier = modifier, + tags = tags, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } - if (note.event?.hasHashtags() == true) { - val hashtags = remember(note.event) { note.event?.hashtags()?.toImmutableList() ?: persistentListOf() } - DisplayUncitedHashtags(hashtags, eventContent, nav) - } - } + if (note.event?.hasHashtags() == true) { + val hashtags = + remember(note.event) { note.event?.hashtags()?.toImmutableList() ?: persistentListOf() } + DisplayUncitedHashtags(hashtags, eventContent, nav) + } } + } } @Composable fun RenderPoll( - note: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = note.event as? PollNoteEvent ?: return - val eventContent = remember(note) { noteEvent.content() } + val noteEvent = note.event as? PollNoteEvent ?: return + val eventContent = remember(note) { noteEvent.content() } - if (makeItShort && accountViewModel.isLoggedUser(note.author)) { - Text( - text = eventContent, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } else { - val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } + if (makeItShort && accountViewModel.isLoggedUser(note.author)) { + Text( + text = eventContent, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } else { + val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } - SensitivityWarning( - note = note, - accountViewModel = accountViewModel - ) { - TranslatableRichTextViewer( - content = eventContent, - canPreview = canPreview && !makeItShort, - modifier = remember { Modifier.fillMaxWidth() }, - tags = tags, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav - ) + SensitivityWarning( + note = note, + accountViewModel = accountViewModel, + ) { + TranslatableRichTextViewer( + content = eventContent, + canPreview = canPreview && !makeItShort, + modifier = remember { Modifier.fillMaxWidth() }, + tags = tags, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) - PollNote( - note, - canPreview = canPreview && !makeItShort, - backgroundColor, - accountViewModel, - nav - ) - } - - if (noteEvent.hasHashtags()) { - val hashtags = remember { noteEvent.hashtags().toImmutableList() } - DisplayUncitedHashtags(hashtags, eventContent, nav) - } + PollNote( + note, + canPreview = canPreview && !makeItShort, + backgroundColor, + accountViewModel, + nav, + ) } + + if (noteEvent.hasHashtags()) { + val hashtags = remember { noteEvent.hashtags().toImmutableList() } + DisplayUncitedHashtags(hashtags, eventContent, nav) + } + } } @OptIn(ExperimentalFoundationApi::class) @Composable fun RenderAppDefinition( - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = note.event as? AppDefinitionEvent ?: return + val noteEvent = note.event as? AppDefinitionEvent ?: return - var metadata by remember { - mutableStateOf(null) - } + var metadata by remember { mutableStateOf(null) } - LaunchedEffect(key1 = noteEvent) { - launch(Dispatchers.Default) { - metadata = noteEvent.appMetaData() + LaunchedEffect(key1 = noteEvent) { + launch(Dispatchers.Default) { metadata = noteEvent.appMetaData() } + } + + metadata?.let { + Box { + val clipboardManager = LocalClipboardManager.current + val uri = LocalUriHandler.current + + if (!it.banner.isNullOrBlank()) { + var zoomImageDialogOpen by remember { mutableStateOf(false) } + + AsyncImage( + model = it.banner, + contentDescription = stringResource(id = R.string.profile_image), + contentScale = ContentScale.FillWidth, + modifier = + Modifier.fillMaxWidth() + .height(125.dp) + .combinedClickable( + onClick = {}, + onLongClick = { clipboardManager.setText(AnnotatedString(it.banner!!)) }, + ), + ) + + if (zoomImageDialogOpen) { + ZoomableImageDialog( + imageUrl = figureOutMimeType(it.banner!!), + onDismiss = { zoomImageDialogOpen = false }, + accountViewModel = accountViewModel, + ) } - } + } else { + Image( + painter = painterResource(R.drawable.profile_banner), + contentDescription = stringResource(id = R.string.profile_banner), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth().height(125.dp), + ) + } - metadata?.let { - Box { - val clipboardManager = LocalClipboardManager.current - val uri = LocalUriHandler.current + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp).padding(top = 75.dp), + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom, + ) { + var zoomImageDialogOpen by remember { mutableStateOf(false) } - if (!it.banner.isNullOrBlank()) { - var zoomImageDialogOpen by remember { mutableStateOf(false) } - - AsyncImage( - model = it.banner, - contentDescription = stringResource(id = R.string.profile_image), - contentScale = ContentScale.FillWidth, - modifier = Modifier - .fillMaxWidth() - .height(125.dp) - .combinedClickable( - onClick = {}, - onLongClick = { - clipboardManager.setText(AnnotatedString(it.banner!!)) - } - ) - ) - - if (zoomImageDialogOpen) { - ZoomableImageDialog( - imageUrl = figureOutMimeType(it.banner!!), - onDismiss = { zoomImageDialogOpen = false }, - accountViewModel = accountViewModel + Box(Modifier.size(100.dp)) { + it.picture?.let { + AsyncImage( + model = it, + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = + Modifier.border( + 3.dp, + MaterialTheme.colorScheme.background, + CircleShape, ) - } - } else { - Image( - painter = painterResource(R.drawable.profile_banner), - contentDescription = stringResource(id = R.string.profile_banner), - contentScale = ContentScale.FillWidth, - modifier = Modifier - .fillMaxWidth() - .height(125.dp) - ) + .clip(shape = CircleShape) + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .combinedClickable( + onClick = { zoomImageDialogOpen = true }, + onLongClick = { clipboardManager.setText(AnnotatedString(it)) }, + ), + ) } + } - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 10.dp) - .padding(top = 75.dp) - ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Bottom - ) { - var zoomImageDialogOpen by remember { mutableStateOf(false) } + if (zoomImageDialogOpen) { + ZoomableImageDialog( + imageUrl = figureOutMimeType(it.banner!!), + onDismiss = { zoomImageDialogOpen = false }, + accountViewModel = accountViewModel, + ) + } - Box(Modifier.size(100.dp)) { - it.picture?.let { - AsyncImage( - model = it, - contentDescription = null, - contentScale = ContentScale.FillWidth, - modifier = Modifier - .border( - 3.dp, - MaterialTheme.colorScheme.background, - CircleShape - ) - .clip(shape = CircleShape) - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .combinedClickable( - onClick = { zoomImageDialogOpen = true }, - onLongClick = { - clipboardManager.setText(AnnotatedString(it)) - } - ) - ) - } - } + Spacer(Modifier.weight(1f)) - if (zoomImageDialogOpen) { - ZoomableImageDialog( - imageUrl = figureOutMimeType(it.banner!!), - onDismiss = { zoomImageDialogOpen = false }, - accountViewModel = accountViewModel - ) - } - - Spacer(Modifier.weight(1f)) - - Row( - modifier = Modifier - .height(Size35dp) - .padding(bottom = 3.dp) - ) { - } - } - - val name = remember(it) { it.anyName() } - name?.let { - Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(top = 7.dp)) { - CreateTextWithEmoji( - text = it, - tags = remember { (note.event?.tags() ?: emptyArray()).toImmutableListOfLists() }, - fontWeight = FontWeight.Bold, - fontSize = 25.sp - ) - } - } - - val website = remember(it) { it.website } - if (!website.isNullOrEmpty()) { - Row(verticalAlignment = CenterVertically) { - LinkIcon(Size16Modifier, MaterialTheme.colorScheme.placeholderText) - - ClickableText( - text = AnnotatedString(website.removePrefix("https://")), - onClick = { website.let { runCatching { uri.openUri(it) } } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), - modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp) - ) - } - } - - it.about?.let { - Row( - modifier = Modifier.padding(top = 5.dp, bottom = 5.dp) - ) { - val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } - val bgColor = MaterialTheme.colorScheme.background - val backgroundColor = remember { - mutableStateOf(bgColor) - } - TranslatableRichTextViewer( - content = it, - canPreview = false, - tags = tags, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav - ) - } - } - } + Row( + modifier = Modifier.height(Size35dp).padding(bottom = 3.dp), + ) {} } + + val name = remember(it) { it.anyName() } + name?.let { + Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(top = 7.dp)) { + CreateTextWithEmoji( + text = it, + tags = remember { (note.event?.tags() ?: emptyArray()).toImmutableListOfLists() }, + fontWeight = FontWeight.Bold, + fontSize = 25.sp, + ) + } + } + + val website = remember(it) { it.website } + if (!website.isNullOrEmpty()) { + Row(verticalAlignment = CenterVertically) { + LinkIcon(Size16Modifier, MaterialTheme.colorScheme.placeholderText) + + ClickableText( + text = AnnotatedString(website.removePrefix("https://")), + onClick = { website.let { runCatching { uri.openUri(it) } } }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp), + ) + } + } + + it.about?.let { + Row( + modifier = Modifier.padding(top = 5.dp, bottom = 5.dp), + ) { + val tags = + remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } + val bgColor = MaterialTheme.colorScheme.background + val backgroundColor = remember { mutableStateOf(bgColor) } + TranslatableRichTextViewer( + content = it, + canPreview = false, + tags = tags, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } } + } } @Composable private fun RenderHighlight( - note: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val quote = remember { - (note.event as? HighlightEvent)?.quote() ?: "" - } - val author = remember() { - (note.event as? HighlightEvent)?.author() - } - val url = remember() { - (note.event as? HighlightEvent)?.inUrl() - } - val postHex = remember() { - (note.event as? HighlightEvent)?.taggedAddresses()?.firstOrNull() - } + val quote = remember { (note.event as? HighlightEvent)?.quote() ?: "" } + val author = remember { (note.event as? HighlightEvent)?.author() } + val url = remember { (note.event as? HighlightEvent)?.inUrl() } + val postHex = remember { (note.event as? HighlightEvent)?.taggedAddresses()?.firstOrNull() } - DisplayHighlight( - highlight = quote, - authorHex = author, - url = url, - postAddress = postHex, - makeItShort = makeItShort, - canPreview = canPreview, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav - ) + DisplayHighlight( + highlight = quote, + authorHex = author, + url = url, + postAddress = postHex, + makeItShort = makeItShort, + canPreview = canPreview, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) } @Composable private fun RenderPrivateMessage( - note: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = note.event as? PrivateDmEvent ?: return + val noteEvent = note.event as? PrivateDmEvent ?: return - val withMe = remember { noteEvent.with(accountViewModel.userProfile().pubkeyHex) } - if (withMe) { - LoadDecryptedContent(note, accountViewModel) { eventContent -> - val modifier = remember(note.event?.id()) { Modifier.fillMaxWidth() } - val isAuthorTheLoggedUser = remember(note.event?.id()) { accountViewModel.isLoggedUser(note.author) } + val withMe = remember { noteEvent.with(accountViewModel.userProfile().pubkeyHex) } + if (withMe) { + LoadDecryptedContent(note, accountViewModel) { eventContent -> + val modifier = remember(note.event?.id()) { Modifier.fillMaxWidth() } + val isAuthorTheLoggedUser = + remember(note.event?.id()) { accountViewModel.isLoggedUser(note.author) } - val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } + val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } - if (makeItShort && isAuthorTheLoggedUser) { - Text( - text = eventContent, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } else { - SensitivityWarning( - note = note, - accountViewModel = accountViewModel - ) { - TranslatableRichTextViewer( - content = eventContent, - canPreview = canPreview && !makeItShort, - modifier = modifier, - tags = tags, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav - ) - } - - if (noteEvent.hasHashtags()) { - val hashtags = remember(note.event?.id()) { note.event?.hashtags()?.toImmutableList() ?: persistentListOf() } - DisplayUncitedHashtags(hashtags, eventContent, nav) - } - } - } - } else { - val recipient = noteEvent.recipientPubKeyBytes()?.toNpub() ?: "Someone" - - TranslatableRichTextViewer( - stringResource( - id = R.string.private_conversation_notification, - "@${note.author?.pubkeyNpub()}", - "@$recipient" - ), - canPreview = !makeItShort, - Modifier.fillMaxWidth(), - EmptyTagList, - backgroundColor, - accountViewModel, - nav + if (makeItShort && isAuthorTheLoggedUser) { + Text( + text = eventContent, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) + } else { + SensitivityWarning( + note = note, + accountViewModel = accountViewModel, + ) { + TranslatableRichTextViewer( + content = eventContent, + canPreview = canPreview && !makeItShort, + modifier = modifier, + tags = tags, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (noteEvent.hasHashtags()) { + val hashtags = + remember(note.event?.id()) { + note.event?.hashtags()?.toImmutableList() ?: persistentListOf() + } + DisplayUncitedHashtags(hashtags, eventContent, nav) + } + } } + } else { + val recipient = noteEvent.recipientPubKeyBytes()?.toNpub() ?: "Someone" + + TranslatableRichTextViewer( + stringResource( + id = R.string.private_conversation_notification, + "@${note.author?.pubkeyNpub()}", + "@$recipient", + ), + canPreview = !makeItShort, + Modifier.fillMaxWidth(), + EmptyTagList, + backgroundColor, + accountViewModel, + nav, + ) + } } @Composable fun DisplayRelaySet( - baseNote: Note, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = baseNote.event as? RelaySetEvent ?: return + val noteEvent = baseNote.event as? RelaySetEvent ?: return - val relays by remember(baseNote) { - mutableStateOf( - noteEvent.relays().map { RelayBriefInfoCache.RelayBriefInfo(it) }.toImmutableList() - ) + val relays by + remember(baseNote) { + mutableStateOf( + noteEvent.relays().map { RelayBriefInfoCache.RelayBriefInfo(it) }.toImmutableList(), + ) } - var expanded by remember { - mutableStateOf(false) - } + var expanded by remember { mutableStateOf(false) } - val toMembersShow = if (expanded) { - relays + val toMembersShow = + if (expanded) { + relays } else { - relays.take(3) + relays.take(3) } - val relayListName by remember { - derivedStateOf { - "#${noteEvent.dTag()}" - } - } + val relayListName by remember { derivedStateOf { "#${noteEvent.dTag()}" } } - val relayDescription by remember { - derivedStateOf { - noteEvent.description() - } - } + val relayDescription by remember { derivedStateOf { noteEvent.description() } } + Text( + text = relayListName, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth().padding(5.dp), + textAlign = TextAlign.Center, + ) + + relayDescription?.let { Text( - text = relayListName, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .fillMaxWidth() - .padding(5.dp), - textAlign = TextAlign.Center + text = it, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth().padding(5.dp), + textAlign = TextAlign.Center, + color = Color.Gray, ) + } - relayDescription?.let { - Text( - text = it, - maxLines = 3, + Box { + Column(modifier = Modifier.padding(top = 5.dp)) { + toMembersShow.forEach { relay -> + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) { + Text( + text = relay.displayUrl, + fontWeight = FontWeight.Bold, + maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier - .fillMaxWidth() - .padding(5.dp), - textAlign = TextAlign.Center, - color = Color.Gray - ) + modifier = Modifier.padding(start = 10.dp, bottom = 5.dp).weight(1f), + ) + + Column(modifier = Modifier.padding(start = 10.dp)) { + RelayOptionsAction(relay.url, accountViewModel, nav) + } + } + } } - Box { - Column(modifier = Modifier.padding(top = 5.dp)) { - toMembersShow.forEach { relay -> - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) { - Text( - text = relay.displayUrl, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .padding(start = 10.dp, bottom = 5.dp) - .weight(1f) - ) - - Column(modifier = Modifier.padding(start = 10.dp)) { - RelayOptionsAction(relay.url, accountViewModel, nav) - } - } - } - } - - if (relays.size > 3 && !expanded) { - Row( - verticalAlignment = CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .background(getGradient(backgroundColor)) - ) { - ShowMoreButton { - expanded = !expanded - } - } - } + if (relays.size > 3 && !expanded) { + Row( + verticalAlignment = CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = + Modifier.align(Alignment.BottomCenter) + .fillMaxWidth() + .background(getGradient(backgroundColor)), + ) { + ShowMoreButton { expanded = !expanded } + } } + } } @Composable private fun RelayOptionsAction( - relay: String, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + relay: String, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val userStateRelayInfo by accountViewModel.account.userProfile().live().relayInfo.observeAsState() - val isCurrentlyOnTheUsersList by remember(userStateRelayInfo) { - derivedStateOf { - userStateRelayInfo?.user?.latestContactList?.relays()?.none { it.key == relay } == true - } + val userStateRelayInfo by accountViewModel.account.userProfile().live().relayInfo.observeAsState() + val isCurrentlyOnTheUsersList by + remember(userStateRelayInfo) { + derivedStateOf { + userStateRelayInfo?.user?.latestContactList?.relays()?.none { it.key == relay } == true + } } - var wantsToAddRelay by remember { - mutableStateOf("") - } + var wantsToAddRelay by remember { mutableStateOf("") } - if (wantsToAddRelay.isNotEmpty()) { - NewRelayListView({ wantsToAddRelay = "" }, accountViewModel, wantsToAddRelay, nav = nav) - } + if (wantsToAddRelay.isNotEmpty()) { + NewRelayListView({ wantsToAddRelay = "" }, accountViewModel, wantsToAddRelay, nav = nav) + } - if (isCurrentlyOnTheUsersList) { - AddRelayButton { wantsToAddRelay = relay } - } else { - RemoveRelayButton { wantsToAddRelay = relay } - } + if (isCurrentlyOnTheUsersList) { + AddRelayButton { wantsToAddRelay = relay } + } else { + RemoveRelayButton { wantsToAddRelay = relay } + } } @OptIn(ExperimentalLayoutApi::class) @Composable fun DisplayPeopleList( - baseNote: Note, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = baseNote.event as? PeopleListEvent ?: return + val noteEvent = baseNote.event as? PeopleListEvent ?: return - var members by remember { mutableStateOf>(persistentListOf()) } + var members by remember { mutableStateOf>(persistentListOf()) } - var expanded by remember { - mutableStateOf(false) - } + var expanded by remember { mutableStateOf(false) } - val toMembersShow = if (expanded) { - members + val toMembersShow = + if (expanded) { + members } else { - members.take(3) + members.take(3) } - val name by remember { - derivedStateOf { - "#${noteEvent.dTag()}" + val name by remember { derivedStateOf { "#${noteEvent.dTag()}" } } + + Text( + text = name, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth().padding(5.dp), + textAlign = TextAlign.Center, + ) + + LaunchedEffect(Unit) { accountViewModel.loadUsers(noteEvent.bookmarkedPeople()) { members = it } } + + Box { + FlowRow(modifier = Modifier.padding(top = 5.dp)) { + toMembersShow.forEach { user -> + Row(modifier = Modifier.fillMaxWidth()) { + UserCompose( + user, + overallModifier = Modifier, + accountViewModel = accountViewModel, + nav = nav, + ) } + } } - Text( - text = name, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier + if (members.size > 3 && !expanded) { + Row( + verticalAlignment = CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = + Modifier.align(Alignment.BottomCenter) .fillMaxWidth() - .padding(5.dp), - textAlign = TextAlign.Center - ) - - LaunchedEffect(Unit) { - accountViewModel.loadUsers(noteEvent.bookmarkedPeople()) { - members = it - } - } - - Box { - FlowRow(modifier = Modifier.padding(top = 5.dp)) { - toMembersShow.forEach { user -> - Row(modifier = Modifier.fillMaxWidth()) { - UserCompose( - user, - overallModifier = Modifier, - accountViewModel = accountViewModel, - nav = nav - ) - } - } - } - - if (members.size > 3 && !expanded) { - Row( - verticalAlignment = CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .background(getGradient(backgroundColor)) - ) { - ShowMoreButton { - expanded = !expanded - } - } - } + .background(getGradient(backgroundColor)), + ) { + ShowMoreButton { expanded = !expanded } + } } + } } @OptIn(ExperimentalLayoutApi::class) @Composable private fun RenderBadgeAward( - note: Note, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + note: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - if (note.replyTo.isNullOrEmpty()) return + if (note.replyTo.isNullOrEmpty()) return - val noteEvent = note.event as? BadgeAwardEvent ?: return - var awardees by remember { mutableStateOf>(listOf()) } + val noteEvent = note.event as? BadgeAwardEvent ?: return + var awardees by remember { mutableStateOf>(listOf()) } - Text(text = stringResource(R.string.award_granted_to)) + Text(text = stringResource(R.string.award_granted_to)) - LaunchedEffect(key1 = note) { - accountViewModel.loadUsers(noteEvent.awardees()) { - awardees = it - } - } + LaunchedEffect(key1 = note) { accountViewModel.loadUsers(noteEvent.awardees()) { awardees = it } } - FlowRow(modifier = Modifier.padding(top = 5.dp)) { - awardees.take(100).forEach { user -> - Row( - modifier = Modifier - .size(size = Size35dp) - .clickable { - nav("User/${user.pubkeyHex}") - }, - verticalAlignment = CenterVertically - ) { - ClickableUserPicture( - baseUser = user, - accountViewModel = accountViewModel, - size = Size35dp - ) - } - } - - if (awardees.size > 100) { - Text(" and ${awardees.size - 100} others", maxLines = 1) - } - } - - note.replyTo?.firstOrNull()?.let { - NoteCompose( - it, - modifier = Modifier, - isBoostedNote = false, - isQuotedNote = true, - unPackReply = false, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav + FlowRow(modifier = Modifier.padding(top = 5.dp)) { + awardees.take(100).forEach { user -> + Row( + modifier = Modifier.size(size = Size35dp).clickable { nav("User/${user.pubkeyHex}") }, + verticalAlignment = CenterVertically, + ) { + ClickableUserPicture( + baseUser = user, + accountViewModel = accountViewModel, + size = Size35dp, ) + } } + + if (awardees.size > 100) { + Text(" and ${awardees.size - 100} others", maxLines = 1) + } + } + + note.replyTo?.firstOrNull()?.let { + NoteCompose( + it, + modifier = Modifier, + isBoostedNote = false, + isQuotedNote = true, + unPackReply = false, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } } @Composable private fun RenderReaction( - note: Note, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + note: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - note.replyTo?.lastOrNull()?.let { - NoteCompose( - it, - modifier = Modifier, - isBoostedNote = true, - unPackReply = false, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav - ) - } - - // Reposts have trash in their contents. - val refactorReactionText = - if (note.event?.content() == "+") "โค" else note.event?.content() ?: "" - - Text( - text = refactorReactionText, - maxLines = 1 + note.replyTo?.lastOrNull()?.let { + NoteCompose( + it, + modifier = Modifier, + isBoostedNote = true, + unPackReply = false, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, ) + } + + // Reposts have trash in their contents. + val refactorReactionText = if (note.event?.content() == "+") "โค" else note.event?.content() ?: "" + + Text( + text = refactorReactionText, + maxLines = 1, + ) } @Composable fun RenderRepost( - note: Note, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + note: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val boostedNote = remember { - note.replyTo?.lastOrNull() - } + val boostedNote = remember { note.replyTo?.lastOrNull() } - boostedNote?.let { - NoteCompose( - it, - modifier = Modifier, - isBoostedNote = true, - unPackReply = false, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav - ) - } + boostedNote?.let { + NoteCompose( + it, + modifier = Modifier, + isBoostedNote = true, + unPackReply = false, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } } @Composable fun RenderPostApproval( - note: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - if (note.replyTo.isNullOrEmpty()) return + if (note.replyTo.isNullOrEmpty()) return - val noteEvent = note.event as? CommunityPostApprovalEvent ?: return + val noteEvent = note.event as? CommunityPostApprovalEvent ?: return - Column(Modifier.fillMaxWidth()) { - noteEvent.communities().forEach { - LoadAddressableNote(it, accountViewModel) { - it?.let { - NoteCompose( - it, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav - ) - } - } - } - - Text( - text = stringResource(id = R.string.community_approved_posts), - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .fillMaxWidth() - .padding(5.dp), - textAlign = TextAlign.Center - ) - - note.replyTo?.forEach { - NoteCompose( - it, - modifier = MaterialTheme.colorScheme.replyModifier, - unPackReply = false, - makeItShort = true, - isQuotedNote = true, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav - ) + Column(Modifier.fillMaxWidth()) { + noteEvent.communities().forEach { + LoadAddressableNote(it, accountViewModel) { + it?.let { + NoteCompose( + it, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) } + } } + + Text( + text = stringResource(id = R.string.community_approved_posts), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth().padding(5.dp), + textAlign = TextAlign.Center, + ) + + note.replyTo?.forEach { + NoteCompose( + it, + modifier = MaterialTheme.colorScheme.replyModifier, + unPackReply = false, + makeItShort = true, + isQuotedNote = true, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } } @Composable -fun LoadAddressableNote(aTagHex: String, accountViewModel: AccountViewModel, content: @Composable (AddressableNote?) -> Unit) { - var note by remember(aTagHex) { - mutableStateOf(accountViewModel.getAddressableNoteIfExists(aTagHex)) +fun LoadAddressableNote( + aTagHex: String, + accountViewModel: AccountViewModel, + content: @Composable (AddressableNote?) -> Unit, +) { + var note by + remember(aTagHex) { + mutableStateOf(accountViewModel.getAddressableNoteIfExists(aTagHex)) } - if (note == null) { - LaunchedEffect(key1 = aTagHex) { - accountViewModel.checkGetOrCreateAddressableNote(aTagHex) { newNote -> - if (newNote != note) { - note = newNote - } - } + if (note == null) { + LaunchedEffect(key1 = aTagHex) { + accountViewModel.checkGetOrCreateAddressableNote(aTagHex) { newNote -> + if (newNote != note) { + note = newNote } + } } + } - content(note) + content(note) } @Composable -fun LoadAddressableNote(aTag: ATag, accountViewModel: AccountViewModel, content: @Composable (AddressableNote?) -> Unit) { - var note by remember(aTag) { - mutableStateOf(accountViewModel.getAddressableNoteIfExists(aTag.toTag())) +fun LoadAddressableNote( + aTag: ATag, + accountViewModel: AccountViewModel, + content: @Composable (AddressableNote?) -> Unit, +) { + var note by + remember(aTag) { + mutableStateOf(accountViewModel.getAddressableNoteIfExists(aTag.toTag())) } - if (note == null) { - LaunchedEffect(key1 = aTag) { - accountViewModel.getOrCreateAddressableNote(aTag) { newNote -> - if (newNote != note) { - note = newNote - } - } + if (note == null) { + LaunchedEffect(key1 = aTag) { + accountViewModel.getOrCreateAddressableNote(aTag) { newNote -> + if (newNote != note) { + note = newNote } + } } + } - content(note) + content(note) } @Composable public fun RenderEmojiPack( - baseNote: Note, - actionable: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - onClick: ((EmojiUrl) -> Unit)? = null + baseNote: Note, + actionable: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + onClick: ((EmojiUrl) -> Unit)? = null, ) { - val noteEvent by baseNote.live().metadata.map { - it.note.event - }.distinctUntilChanged().observeAsState(baseNote.event) + val noteEvent by + baseNote + .live() + .metadata + .map { it.note.event } + .distinctUntilChanged() + .observeAsState(baseNote.event) - if (noteEvent == null || noteEvent !is EmojiPackEvent) return + if (noteEvent == null || noteEvent !is EmojiPackEvent) return - (noteEvent as? EmojiPackEvent)?.let { - RenderEmojiPack( - noteEvent = it, - baseNote = baseNote, - actionable = actionable, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - onClick = onClick - ) - } + (noteEvent as? EmojiPackEvent)?.let { + RenderEmojiPack( + noteEvent = it, + baseNote = baseNote, + actionable = actionable, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + onClick = onClick, + ) + } } @OptIn(ExperimentalLayoutApi::class) @Composable public fun RenderEmojiPack( - noteEvent: EmojiPackEvent, - baseNote: Note, - actionable: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - onClick: ((EmojiUrl) -> Unit)? = null + noteEvent: EmojiPackEvent, + baseNote: Note, + actionable: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + onClick: ((EmojiUrl) -> Unit)? = null, ) { - var expanded by remember { - mutableStateOf(false) - } + var expanded by remember { mutableStateOf(false) } - val allEmojis = remember(noteEvent) { - noteEvent.taggedEmojis() - } + val allEmojis = remember(noteEvent) { noteEvent.taggedEmojis() } - val emojisToShow = if (expanded) { - allEmojis + val emojisToShow = + if (expanded) { + allEmojis } else { - allEmojis.take(60) + allEmojis.take(60) } - Row(verticalAlignment = CenterVertically) { - Text( - text = remember(noteEvent) { "#${noteEvent.dTag()}" }, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .weight(1F) - .padding(5.dp), - textAlign = TextAlign.Center - ) + Row(verticalAlignment = CenterVertically) { + Text( + text = remember(noteEvent) { "#${noteEvent.dTag()}" }, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1F).padding(5.dp), + textAlign = TextAlign.Center, + ) - if (actionable) { - EmojiListOptions(accountViewModel, baseNote) + if (actionable) { + EmojiListOptions(accountViewModel, baseNote) + } + } + + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter) { + FlowRow(modifier = Modifier.padding(top = 5.dp)) { + emojisToShow.forEach { emoji -> + if (onClick != null) { + IconButton(onClick = { onClick(emoji) }, modifier = Size35Modifier) { + AsyncImage( + model = emoji.url, + contentDescription = null, + modifier = Size35Modifier, + ) + } + } else { + Box( + modifier = Size35Modifier, + contentAlignment = Alignment.Center, + ) { + AsyncImage( + model = emoji.url, + contentDescription = null, + modifier = Size35Modifier, + ) + } } + } } - Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter) { - FlowRow(modifier = Modifier.padding(top = 5.dp)) { - emojisToShow.forEach { emoji -> - if (onClick != null) { - IconButton(onClick = { onClick(emoji) }, modifier = Size35Modifier) { - AsyncImage( - model = emoji.url, - contentDescription = null, - modifier = Size35Modifier - ) - } - } else { - Box( - modifier = Size35Modifier, - contentAlignment = Alignment.Center - ) { - AsyncImage( - model = emoji.url, - contentDescription = null, - modifier = Size35Modifier - ) - } - } - } - } - - if (allEmojis.size > 60 && !expanded) { - Row( - verticalAlignment = CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .background(getGradient(backgroundColor)) - ) { - ShowMoreButton { - expanded = !expanded - } - } - } + if (allEmojis.size > 60 && !expanded) { + Row( + verticalAlignment = CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = + Modifier.align(Alignment.BottomCenter) + .fillMaxWidth() + .background(getGradient(backgroundColor)), + ) { + ShowMoreButton { expanded = !expanded } + } } + } } @Composable private fun EmojiListOptions( - accountViewModel: AccountViewModel, - emojiPackNote: Note + accountViewModel: AccountViewModel, + emojiPackNote: Note, ) { - LoadAddressableNote( - aTag = ATag( - EmojiPackSelectionEvent.kind, - accountViewModel.userProfile().pubkeyHex, - "", - null - ), - accountViewModel - ) { - it?.let { usersEmojiList -> - val hasAddedThis by remember { - usersEmojiList.live().metadata.map { - usersEmojiList.event?.isTaggedAddressableNote(emojiPackNote.idHex) - }.distinctUntilChanged() - }.observeAsState() + LoadAddressableNote( + aTag = + ATag( + EmojiPackSelectionEvent.KIND, + accountViewModel.userProfile().pubkeyHex, + "", + null, + ), + accountViewModel, + ) { + it?.let { usersEmojiList -> + val hasAddedThis by + remember { + usersEmojiList + .live() + .metadata + .map { usersEmojiList.event?.isTaggedAddressableNote(emojiPackNote.idHex) } + .distinctUntilChanged() + } + .observeAsState() - Crossfade(targetState = hasAddedThis, label = "EmojiListOptions") { - if (it != true) { - AddButton() { - accountViewModel.addEmojiPack(usersEmojiList, emojiPackNote) - } - } else { - RemoveButton { - accountViewModel.removeEmojiPack(usersEmojiList, emojiPackNote) - } - } - } + Crossfade(targetState = hasAddedThis, label = "EmojiListOptions") { + if (it != true) { + AddButton { accountViewModel.addEmojiPack(usersEmojiList, emojiPackNote) } + } else { + RemoveButton { accountViewModel.removeEmojiPack(usersEmojiList, emojiPackNote) } } + } } + } } @OptIn(ExperimentalLayoutApi::class) @Composable fun RenderPinListEvent( - baseNote: Note, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = baseNote.event as? PinListEvent ?: return + val noteEvent = baseNote.event as? PinListEvent ?: return - val pins by remember { mutableStateOf(noteEvent.pins()) } + val pins by remember { mutableStateOf(noteEvent.pins()) } - var expanded by remember { - mutableStateOf(false) - } + var expanded by remember { mutableStateOf(false) } - val pinsToShow = if (expanded) { - pins + val pinsToShow = + if (expanded) { + pins } else { - pins.take(3) + pins.take(3) } - Text( - text = "#${noteEvent.dTag()}", - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier + Text( + text = "#${noteEvent.dTag()}", + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth().padding(5.dp), + textAlign = TextAlign.Center, + ) + + Box { + FlowRow(modifier = Modifier.padding(top = 5.dp)) { + pinsToShow.forEach { pin -> + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) { + PinIcon( + modifier = Size15Modifier, + tint = MaterialTheme.colorScheme.onBackground.copy(0.32f), + ) + + Spacer(modifier = Modifier.width(5.dp)) + + TranslatableRichTextViewer( + content = pin, + canPreview = true, + tags = EmptyTagList, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + + if (pins.size > 3 && !expanded) { + Row( + verticalAlignment = CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = + Modifier.align(Alignment.BottomCenter) .fillMaxWidth() - .padding(5.dp), - textAlign = TextAlign.Center - ) - - Box { - FlowRow(modifier = Modifier.padding(top = 5.dp)) { - pinsToShow.forEach { pin -> - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) { - PinIcon(modifier = Size15Modifier, tint = MaterialTheme.colorScheme.onBackground.copy(0.32f)) - - Spacer(modifier = Modifier.width(5.dp)) - - TranslatableRichTextViewer( - content = pin, - canPreview = true, - tags = EmptyTagList, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav - ) - } - } - } - - if (pins.size > 3 && !expanded) { - Row( - verticalAlignment = CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .background(getGradient(backgroundColor)) - ) { - ShowMoreButton { - expanded = !expanded - } - } - } + .background(getGradient(backgroundColor)), + ) { + ShowMoreButton { expanded = !expanded } + } } + } } fun getGradient(backgroundColor: MutableState): Brush { - return Brush.verticalGradient( - colors = listOf( - backgroundColor.value.copy(alpha = 0f), - backgroundColor.value - ) - ) + return Brush.verticalGradient( + colors = + listOf( + backgroundColor.value.copy(alpha = 0f), + backgroundColor.value, + ), + ) } @Composable private fun RenderAudioTrack( - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = note.event as? AudioTrackEvent ?: return + val noteEvent = note.event as? AudioTrackEvent ?: return - AudioTrackHeader(noteEvent, note, accountViewModel, nav) + AudioTrackHeader(noteEvent, note, accountViewModel, nav) } @Composable private fun RenderAudioHeader( - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = note.event as? AudioHeaderEvent ?: return + val noteEvent = note.event as? AudioHeaderEvent ?: return - AudioHeader(noteEvent, note, accountViewModel, nav) + AudioHeader(noteEvent, note, accountViewModel, nav) } @Composable private fun RenderLongFormContent( - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = note.event as? LongTextNoteEvent ?: return + val noteEvent = note.event as? LongTextNoteEvent ?: return - LongFormHeader(noteEvent, note, accountViewModel) + LongFormHeader(noteEvent, note, accountViewModel) } @Composable private fun RenderReport( - note: Note, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + note: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = note.event as? ReportEvent ?: return + val noteEvent = note.event as? ReportEvent ?: return - val base = remember { - (noteEvent.reportedPost() + noteEvent.reportedAuthor()) - } + val base = remember { (noteEvent.reportedPost() + noteEvent.reportedAuthor()) } - val reportType = base.map { + val reportType = + base + .map { when (it.reportType) { - ReportEvent.ReportType.EXPLICIT -> stringResource(R.string.explicit_content) - ReportEvent.ReportType.NUDITY -> stringResource(R.string.nudity) - ReportEvent.ReportType.PROFANITY -> stringResource(R.string.profanity_hateful_speech) - ReportEvent.ReportType.SPAM -> stringResource(R.string.spam) - ReportEvent.ReportType.IMPERSONATION -> stringResource(R.string.impersonation) - ReportEvent.ReportType.ILLEGAL -> stringResource(R.string.illegal_behavior) + ReportEvent.ReportType.EXPLICIT -> stringResource(R.string.explicit_content) + ReportEvent.ReportType.NUDITY -> stringResource(R.string.nudity) + ReportEvent.ReportType.PROFANITY -> stringResource(R.string.profanity_hateful_speech) + ReportEvent.ReportType.SPAM -> stringResource(R.string.spam) + ReportEvent.ReportType.IMPERSONATION -> stringResource(R.string.impersonation) + ReportEvent.ReportType.ILLEGAL -> stringResource(R.string.illegal_behavior) } - }.toSet().joinToString(", ") + } + .toSet() + .joinToString(", ") - val content = remember { - reportType + (note.event?.content()?.ifBlank { null }?.let { ": $it" } ?: "") - } + val content = remember { + reportType + (note.event?.content()?.ifBlank { null }?.let { ": $it" } ?: "") + } - TranslatableRichTextViewer( - content = content, - canPreview = true, - modifier = Modifier, - tags = EmptyTagList, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav + TranslatableRichTextViewer( + content = content, + canPreview = true, + modifier = Modifier, + tags = EmptyTagList, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + + note.replyTo?.lastOrNull()?.let { + NoteCompose( + baseNote = it, + isQuotedNote = true, + modifier = + Modifier.padding(top = 5.dp) + .fillMaxWidth() + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ), + unPackReply = false, + makeItShort = true, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, ) - - note.replyTo?.lastOrNull()?.let { - NoteCompose( - baseNote = it, - isQuotedNote = true, - modifier = Modifier - .padding(top = 5.dp) - .fillMaxWidth() - .clip(shape = QuoteBorder) - .border( - 1.dp, - MaterialTheme.colorScheme.subtleBorder, - QuoteBorder - ), - unPackReply = false, - makeItShort = true, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav - ) - } + } } @Composable private fun ReplyRow( - note: Note, - unPackReply: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + note: Note, + unPackReply: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = note.event + val noteEvent = note.event - val showReply by remember(note) { - derivedStateOf { - noteEvent is BaseTextNoteEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser()) - } + val showReply by + remember(note) { + derivedStateOf { + noteEvent is BaseTextNoteEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser()) + } } - val showChannelInfo by remember(note) { - derivedStateOf { - if (noteEvent is ChannelMessageEvent || noteEvent is LiveActivitiesChatMessageEvent) { - note.channelHex() - } else { - null + val showChannelInfo by + remember(note) { + derivedStateOf { + if (noteEvent is ChannelMessageEvent || noteEvent is LiveActivitiesChatMessageEvent) { + note.channelHex() + } else { + null + } + } + } + + showChannelInfo?.let { + ChannelHeader( + channelHex = it, + showVideo = false, + showBottomDiviser = false, + sendToChannel = true, + modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (showReply) { + val replyingDirectlyTo = + remember(note) { + if (noteEvent is BaseTextNoteEvent) { + val replyingTo = noteEvent.replyingTo() + if (replyingTo != null) { + note.replyTo?.firstOrNull { + // important to test both ids in case it's a replaceable event. + it.idHex == replyingTo || it.event?.id() == replyingTo } + } else { + note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.KIND } + } + } else { + note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.KIND } } - } + } + if (replyingDirectlyTo != null && unPackReply) { + ReplyNoteComposition(replyingDirectlyTo, backgroundColor, accountViewModel, nav) + Spacer(modifier = StdVertSpacer) + } else if (showChannelInfo != null) { + val replies = remember { note.replyTo?.toImmutableList() } + val mentions = remember { + (note.event as? BaseTextNoteEvent)?.mentions()?.toImmutableList() ?: persistentListOf() + } - showChannelInfo?.let { - ChannelHeader( - channelHex = it, - showVideo = false, - showBottomDiviser = false, - sendToChannel = true, - modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), - accountViewModel = accountViewModel, - nav = nav - ) - } - - if (showReply) { - val replyingDirectlyTo = remember(note) { - if (noteEvent is BaseTextNoteEvent) { - val replyingTo = noteEvent.replyingTo() - if (replyingTo != null) { - note.replyTo?.firstOrNull() { - // important to test both ids in case it's a replaceable event. - it.idHex == replyingTo || it.event?.id() == replyingTo - } - } else { - note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.kind } - } - } else { - note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.kind } - } - } - if (replyingDirectlyTo != null && unPackReply) { - ReplyNoteComposition(replyingDirectlyTo, backgroundColor, accountViewModel, nav) - Spacer(modifier = StdVertSpacer) - } else if (showChannelInfo != null) { - val replies = remember { note.replyTo?.toImmutableList() } - val mentions = remember { - (note.event as? BaseTextNoteEvent)?.mentions()?.toImmutableList() - ?: persistentListOf() - } - - ReplyInformationChannel(replies, mentions, accountViewModel, nav) - } + ReplyInformationChannel(replies, mentions, accountViewModel, nav) } + } } @Composable private fun ReplyNoteComposition( - replyingDirectlyTo: Note, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + replyingDirectlyTo: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val replyBackgroundColor = remember { - mutableStateOf(backgroundColor.value) - } - val defaultReplyBackground = MaterialTheme.colorScheme.replyBackground + val replyBackgroundColor = remember { mutableStateOf(backgroundColor.value) } + val defaultReplyBackground = MaterialTheme.colorScheme.replyBackground - LaunchedEffect(key1 = backgroundColor.value, key2 = defaultReplyBackground) { - launch(Dispatchers.Default) { - val newReplyBackgroundColor = - defaultReplyBackground.compositeOver(backgroundColor.value) - if (replyBackgroundColor.value != newReplyBackgroundColor) { - replyBackgroundColor.value = newReplyBackgroundColor - } - } + LaunchedEffect(key1 = backgroundColor.value, key2 = defaultReplyBackground) { + launch(Dispatchers.Default) { + val newReplyBackgroundColor = defaultReplyBackground.compositeOver(backgroundColor.value) + if (replyBackgroundColor.value != newReplyBackgroundColor) { + replyBackgroundColor.value = newReplyBackgroundColor + } } + } - NoteCompose( - baseNote = replyingDirectlyTo, - isQuotedNote = true, - modifier = MaterialTheme.colorScheme.replyModifier, - unPackReply = false, - makeItShort = true, - parentBackgroundColor = replyBackgroundColor, - accountViewModel = accountViewModel, - nav = nav - ) + NoteCompose( + baseNote = replyingDirectlyTo, + isQuotedNote = true, + modifier = MaterialTheme.colorScheme.replyModifier, + unPackReply = false, + makeItShort = true, + parentBackgroundColor = replyBackgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) } @Composable fun SecondUserInfoRow( - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteEvent = remember { note.event } ?: return - val noteAuthor = remember { note.author } ?: return + val noteEvent = remember { note.event } ?: return + val noteAuthor = remember { note.author } ?: return - Row( - verticalAlignment = CenterVertically, - modifier = UserNameMaxRowHeight - ) { - ObserveDisplayNip05Status(noteAuthor, remember { Modifier.weight(1f) }, accountViewModel, nav) + Row( + verticalAlignment = CenterVertically, + modifier = UserNameMaxRowHeight, + ) { + ObserveDisplayNip05Status(noteAuthor, remember { Modifier.weight(1f) }, accountViewModel, nav) - val geo = remember { noteEvent.getGeoHash() } - if (geo != null) { - Spacer(StdHorzSpacer) - DisplayLocation(geo, nav) - } - - val baseReward = remember { noteEvent.getReward()?.let { Reward(it) } } - if (baseReward != null) { - Spacer(StdHorzSpacer) - DisplayReward(baseReward, note, accountViewModel, nav) - } - - val pow = remember { noteEvent.getPoWRank() } - if (pow > 20) { - Spacer(StdHorzSpacer) - DisplayPoW(pow) - } + val geo = remember { noteEvent.getGeoHash() } + if (geo != null) { + Spacer(StdHorzSpacer) + DisplayLocation(geo, nav) } + + val baseReward = remember { noteEvent.getReward()?.let { Reward(it) } } + if (baseReward != null) { + Spacer(StdHorzSpacer) + DisplayReward(baseReward, note, accountViewModel, nav) + } + + val pow = remember { noteEvent.getPoWRank() } + if (pow > 20) { + Spacer(StdHorzSpacer) + DisplayPoW(pow) + } + } } @Composable fun LoadStatuses( - user: User, - accountViewModel: AccountViewModel, - content: @Composable (ImmutableList) -> Unit + user: User, + accountViewModel: AccountViewModel, + content: @Composable (ImmutableList) -> Unit, ) { - var statuses: ImmutableList by remember { - mutableStateOf(persistentListOf()) + var statuses: ImmutableList by remember { mutableStateOf(persistentListOf()) } + + val userStatus by user.live().statuses.observeAsState() + + LaunchedEffect(key1 = userStatus) { + accountViewModel.findStatusesForUser(userStatus?.user ?: user) { newStatuses -> + if (!equalImmutableLists(statuses, newStatuses)) { + statuses = newStatuses + } } + } - val userStatus by user.live().statuses.observeAsState() - - LaunchedEffect(key1 = userStatus) { - accountViewModel.findStatusesForUser(userStatus?.user ?: user) { newStatuses -> - if (!equalImmutableLists(statuses, newStatuses)) { - statuses = newStatuses - } - } - } - - content(statuses) + content(statuses) } @Composable -fun LoadCityName(geohash: GeoHash, content: @Composable (String) -> Unit) { - val context = LocalContext.current - var cityName by remember(geohash) { - mutableStateOf(geohash.toString()) - } +fun LoadCityName( + geohash: GeoHash, + content: @Composable (String) -> Unit, +) { + val context = LocalContext.current + var cityName by remember(geohash) { mutableStateOf(geohash.toString()) } - LaunchedEffect(key1 = geohash) { - launch(Dispatchers.IO) { - val newCityName = ReverseGeoLocationUtil().execute(geohash.toLocation(), context)?.ifBlank { null } - if (newCityName != null && newCityName != cityName) { - cityName = newCityName - } - } + LaunchedEffect(key1 = geohash) { + launch(Dispatchers.IO) { + val newCityName = + ReverseGeoLocationUtil().execute(geohash.toLocation(), context)?.ifBlank { null } + if (newCityName != null && newCityName != cityName) { + cityName = newCityName + } } + } - content(cityName) + content(cityName) } @Composable -fun DisplayLocation(geohashStr: String, nav: (String) -> Unit) { - val geoHash = runCatching { geohashStr.toGeoHash() }.getOrNull() - if (geoHash != null) { - LoadCityName(geoHash) { cityName -> - ClickableText( - text = AnnotatedString(cityName), - onClick = { nav("Geohash/$geoHash") }, - style = LocalTextStyle.current.copy( - color = MaterialTheme.colorScheme.primary.copy( - alpha = 0.52f - ), - fontSize = Font14SP, - fontWeight = FontWeight.Bold - ), - maxLines = 1 - ) - } +fun DisplayLocation( + geohashStr: String, + nav: (String) -> Unit, +) { + val geoHash = runCatching { geohashStr.toGeoHash() }.getOrNull() + if (geoHash != null) { + LoadCityName(geoHash) { cityName -> + ClickableText( + text = AnnotatedString(cityName), + onClick = { nav("Geohash/$geoHash") }, + style = + LocalTextStyle.current.copy( + color = + MaterialTheme.colorScheme.primary.copy( + alpha = 0.52f, + ), + fontSize = Font14SP, + fontWeight = FontWeight.Bold, + ), + maxLines = 1, + ) } + } } @Composable fun FirstUserInfoRow( - baseNote: Note, - showAuthorPicture: Boolean, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + showAuthorPicture: Boolean, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - Row(verticalAlignment = CenterVertically, modifier = remember { UserNameRowHeight }) { - val isRepost by remember(baseNote) { - derivedStateOf { - baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent - } + Row(verticalAlignment = CenterVertically, modifier = remember { UserNameRowHeight }) { + val isRepost by + remember(baseNote) { + derivedStateOf { baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent } + } + + val isCommunityPost by + remember(baseNote) { + derivedStateOf { + baseNote.event?.isTaggedAddressableKind(CommunityDefinitionEvent.KIND) == true } + } - val isCommunityPost by remember(baseNote) { - derivedStateOf { - baseNote.event?.isTaggedAddressableKind(CommunityDefinitionEvent.kind) == true - } - } + val textColor = if (isRepost) MaterialTheme.colorScheme.grayText else Color.Unspecified - val textColor = if (isRepost) MaterialTheme.colorScheme.grayText else Color.Unspecified - - if (showAuthorPicture) { - NoteAuthorPicture(baseNote, nav, accountViewModel, Size25dp) - Spacer(HalfPadding) - NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) }, textColor = textColor) - } else { - NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) }, textColor = textColor) - } - - if (isRepost) { - BoostedMark() - } else if (isCommunityPost) { - DisplayFollowingCommunityInPost(baseNote, accountViewModel, nav) - } else { - DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav) - } - - TimeAgo(baseNote) - - MoreOptionsButton(baseNote, accountViewModel) + if (showAuthorPicture) { + NoteAuthorPicture(baseNote, nav, accountViewModel, Size25dp) + Spacer(HalfPadding) + NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) }, textColor = textColor) + } else { + NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) }, textColor = textColor) } + + if (isRepost) { + BoostedMark() + } else if (isCommunityPost) { + DisplayFollowingCommunityInPost(baseNote, accountViewModel, nav) + } else { + DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav) + } + + TimeAgo(baseNote) + + MoreOptionsButton(baseNote, accountViewModel) + } } @Composable private fun BoostedMark() { - Text( - stringResource(id = R.string.boosted), - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - modifier = HalfStartPadding - ) + Text( + stringResource(id = R.string.boosted), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + modifier = HalfStartPadding, + ) } @Composable fun MoreOptionsButton( - baseNote: Note, - accountViewModel: AccountViewModel + baseNote: Note, + accountViewModel: AccountViewModel, ) { - val popupExpanded = remember { mutableStateOf(false) } - val enablePopup = remember { - { popupExpanded.value = true } - } + val popupExpanded = remember { mutableStateOf(false) } + val enablePopup = remember { { popupExpanded.value = true } } - IconButton( - modifier = Size24Modifier, - onClick = enablePopup - ) { - VerticalDotsIcon() + IconButton( + modifier = Size24Modifier, + onClick = enablePopup, + ) { + VerticalDotsIcon() - NoteDropDownMenu( - baseNote, - popupExpanded, - accountViewModel - ) - } + NoteDropDownMenu( + baseNote, + popupExpanded, + accountViewModel, + ) + } } @Composable fun TimeAgo(note: Note) { - val time = remember(note) { note.createdAt() } ?: return - TimeAgo(time) + val time = remember(note) { note.createdAt() } ?: return + TimeAgo(time) } @Composable fun TimeAgo(time: Long) { - val context = LocalContext.current - val timeStr by remember(time) { mutableStateOf(timeAgo(time, context = context)) } + val context = LocalContext.current + val timeStr by remember(time) { mutableStateOf(timeAgo(time, context = context)) } - Text( - text = timeStr, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1 - ) + Text( + text = timeStr, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) } @Composable -private fun AuthorAndRelayInformation(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - // Draws the boosted picture outside the boosted card. - Box(modifier = Size55Modifier, contentAlignment = Alignment.BottomEnd) { - RenderAuthorImages(baseNote, nav, accountViewModel) - } +private fun AuthorAndRelayInformation( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + // Draws the boosted picture outside the boosted card. + Box(modifier = Size55Modifier, contentAlignment = Alignment.BottomEnd) { + RenderAuthorImages(baseNote, nav, accountViewModel) + } - BadgeBox(baseNote, accountViewModel, nav) + BadgeBox(baseNote, accountViewModel, nav) } @Composable private fun BadgeBox( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) { - baseNote.replyTo?.lastOrNull()?.let { - RelayBadges(it, accountViewModel, nav) - } - } else { - RelayBadges(baseNote, accountViewModel, nav) - } + if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) { + baseNote.replyTo?.lastOrNull()?.let { RelayBadges(it, accountViewModel, nav) } + } else { + RelayBadges(baseNote, accountViewModel, nav) + } } @Composable private fun RenderAuthorImages( - baseNote: Note, - nav: (String) -> Unit, - accountViewModel: AccountViewModel + baseNote: Note, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - val isRepost = baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent + val isRepost = baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent - if (isRepost) { - val baseRepost = baseNote.replyTo?.lastOrNull() - if (baseRepost != null) { - RepostNoteAuthorPicture(baseNote, baseRepost, accountViewModel, nav) - } else { - NoteAuthorPicture(baseNote, nav, accountViewModel, Size55dp) - } + if (isRepost) { + val baseRepost = baseNote.replyTo?.lastOrNull() + if (baseRepost != null) { + RepostNoteAuthorPicture(baseNote, baseRepost, accountViewModel, nav) } else { - NoteAuthorPicture(baseNote, nav, accountViewModel, Size55dp) + NoteAuthorPicture(baseNote, nav, accountViewModel, Size55dp) } + } else { + NoteAuthorPicture(baseNote, nav, accountViewModel, Size55dp) + } - if (baseNote.event is ChannelMessageEvent) { - val baseChannelHex = remember(baseNote) { baseNote.channelHex() } - if (baseChannelHex != null) { - LoadChannel(baseChannelHex, accountViewModel) { channel -> - ChannelNotePicture(channel, loadProfilePicture = accountViewModel.settings.showProfilePictures.value) - } - } - } -} - -@Composable -fun LoadChannel(baseChannelHex: String, accountViewModel: AccountViewModel, content: @Composable (Channel) -> Unit) { - var channel by remember(baseChannelHex) { - mutableStateOf(accountViewModel.getChannelIfExists(baseChannelHex)) - } - - if (channel == null) { - LaunchedEffect(key1 = baseChannelHex) { - accountViewModel.checkGetOrCreateChannel(baseChannelHex) { newChannel -> - launch(Dispatchers.Main) { - channel = newChannel - } - } - } - } - - channel?.let { - content(it) - } -} - -@Composable -private fun ChannelNotePicture(baseChannel: Channel, loadProfilePicture: Boolean) { - val model by baseChannel.live.map { - it.channel.profilePicture() - }.distinctUntilChanged().observeAsState() - - Box(Size30Modifier) { - RobohashFallbackAsyncImage( - robot = baseChannel.idHex, - model = model, - contentDescription = stringResource(R.string.group_picture), - modifier = MaterialTheme.colorScheme.channelNotePictureModifier, - loadProfilePicture = loadProfilePicture + if (baseNote.event is ChannelMessageEvent) { + val baseChannelHex = remember(baseNote) { baseNote.channelHex() } + if (baseChannelHex != null) { + LoadChannel(baseChannelHex, accountViewModel) { channel -> + ChannelNotePicture( + channel, + loadProfilePicture = accountViewModel.settings.showProfilePictures.value, ) + } } + } +} + +@Composable +fun LoadChannel( + baseChannelHex: String, + accountViewModel: AccountViewModel, + content: @Composable (Channel) -> Unit, +) { + var channel by + remember(baseChannelHex) { + mutableStateOf(accountViewModel.getChannelIfExists(baseChannelHex)) + } + + if (channel == null) { + LaunchedEffect(key1 = baseChannelHex) { + accountViewModel.checkGetOrCreateChannel(baseChannelHex) { newChannel -> + launch(Dispatchers.Main) { channel = newChannel } + } + } + } + + channel?.let { content(it) } +} + +@Composable +private fun ChannelNotePicture( + baseChannel: Channel, + loadProfilePicture: Boolean, +) { + val model by + baseChannel.live.map { it.channel.profilePicture() }.distinctUntilChanged().observeAsState() + + Box(Size30Modifier) { + RobohashFallbackAsyncImage( + robot = baseChannel.idHex, + model = model, + contentDescription = stringResource(R.string.group_picture), + modifier = MaterialTheme.colorScheme.channelNotePictureModifier, + loadProfilePicture = loadProfilePicture, + ) + } } @Composable private fun RepostNoteAuthorPicture( - baseNote: Note, - baseRepost: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + baseRepost: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - GenericRepostLayout( - baseAuthorPicture = { - NoteAuthorPicture( - baseNote = baseNote, - nav = nav, - accountViewModel = accountViewModel, - size = Size34dp - ) - }, - repostAuthorPicture = { - NoteAuthorPicture( - baseNote = baseRepost, - nav = nav, - accountViewModel = accountViewModel, - size = Size34dp - ) - } - ) + GenericRepostLayout( + baseAuthorPicture = { + NoteAuthorPicture( + baseNote = baseNote, + nav = nav, + accountViewModel = accountViewModel, + size = Size34dp, + ) + }, + repostAuthorPicture = { + NoteAuthorPicture( + baseNote = baseRepost, + nav = nav, + accountViewModel = accountViewModel, + size = Size34dp, + ) + }, + ) } @Composable fun DisplayHighlight( - highlight: String, - authorHex: String?, - url: String?, - postAddress: ATag?, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + highlight: String, + authorHex: String?, + url: String?, + postAddress: ATag?, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val quote = - remember { - highlight - .split("\n").joinToString("\n") { "> *${it.removeSuffix(" ")}*" } - } + val quote = remember { + highlight.split("\n").joinToString("\n") { "> *${it.removeSuffix(" ")}*" } + } - TranslatableRichTextViewer( - quote, - canPreview = canPreview && !makeItShort, - remember { Modifier.fillMaxWidth() }, - EmptyTagList, - backgroundColor, - accountViewModel, - nav - ) + TranslatableRichTextViewer( + quote, + canPreview = canPreview && !makeItShort, + remember { Modifier.fillMaxWidth() }, + EmptyTagList, + backgroundColor, + accountViewModel, + nav, + ) - DisplayQuoteAuthor(authorHex ?: "", url, postAddress, accountViewModel, nav) + DisplayQuoteAuthor(authorHex ?: "", url, postAddress, accountViewModel, nav) } @OptIn(ExperimentalLayoutApi::class) @Composable private fun DisplayQuoteAuthor( - authorHex: String, - url: String?, - postAddress: ATag?, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + authorHex: String, + url: String?, + postAddress: ATag?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var userBase by remember { mutableStateOf(accountViewModel.getUserIfExists(authorHex)) } + var userBase by remember { mutableStateOf(accountViewModel.getUserIfExists(authorHex)) } - if (userBase == null) { - LaunchedEffect(Unit) { - accountViewModel.checkGetOrCreateUser(authorHex) { newUserBase -> - userBase = newUserBase - } - } + if (userBase == null) { + LaunchedEffect(Unit) { + accountViewModel.checkGetOrCreateUser(authorHex) { newUserBase -> userBase = newUserBase } } + } - val spaceWidth = measureSpaceWidth(textStyle = LocalTextStyle.current) + val spaceWidth = measureSpaceWidth(textStyle = LocalTextStyle.current) - FlowRow(horizontalArrangement = Arrangement.spacedBy(spaceWidth), verticalArrangement = Arrangement.Center) { - userBase?.let { userBase -> - LoadAndDisplayUser(userBase, nav) - } + FlowRow( + horizontalArrangement = Arrangement.spacedBy(spaceWidth), + verticalArrangement = Arrangement.Center, + ) { + userBase?.let { userBase -> LoadAndDisplayUser(userBase, nav) } - url?.let { url -> - LoadAndDisplayUrl(url) - } + url?.let { url -> LoadAndDisplayUrl(url) } - postAddress?.let { address -> - LoadAndDisplayPost(address, accountViewModel, nav) - } - } + postAddress?.let { address -> LoadAndDisplayPost(address, accountViewModel, nav) } + } } @Composable -private fun LoadAndDisplayPost(postAddress: ATag, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - LoadAddressableNote(aTag = postAddress, accountViewModel) { - it?.let { note -> - val noteEvent by note.live().metadata.map { - it.note.event - }.distinctUntilChanged().observeAsState(note.event) +private fun LoadAndDisplayPost( + postAddress: ATag, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + LoadAddressableNote(aTag = postAddress, accountViewModel) { + it?.let { note -> + val noteEvent by + note.live().metadata.map { it.note.event }.distinctUntilChanged().observeAsState(note.event) - val title = remember(noteEvent) { - (noteEvent as? LongTextNoteEvent)?.title() - } + val title = remember(noteEvent) { (noteEvent as? LongTextNoteEvent)?.title() } - title?.let { - Text(remember { "-" }, maxLines = 1) - ClickableText( - text = AnnotatedString(title), - onClick = { - routeFor(note, accountViewModel.userProfile())?.let { - nav(it) - } - }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary) - ) - } - } + title?.let { + Text(remember { "-" }, maxLines = 1) + ClickableText( + text = AnnotatedString(title), + onClick = { routeFor(note, accountViewModel.userProfile())?.let { nav(it) } }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + ) + } } + } } @Composable private fun LoadAndDisplayUrl(url: String) { - val validatedUrl = remember { - try { - URL(url) - } catch (e: Exception) { - Log.w("Note Compose", "Invalid URI: $url") - null - } + val validatedUrl = remember { + try { + URL(url) + } catch (e: Exception) { + Log.w("Note Compose", "Invalid URI: $url") + null } + } - validatedUrl?.host?.let { host -> - Text(remember { "-" }, maxLines = 1) - ClickableUrl(urlText = host, url = url) - } + validatedUrl?.host?.let { host -> + Text(remember { "-" }, maxLines = 1) + ClickableUrl(urlText = host, url = url) + } } @Composable private fun LoadAndDisplayUser( - userBase: User, - nav: (String) -> Unit + userBase: User, + nav: (String) -> Unit, ) { - val route = remember { "User/${userBase.pubkeyHex}" } + val route = remember { "User/${userBase.pubkeyHex}" } - val userState by userBase.live().metadata.observeAsState() - val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() } - val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() } + val userState by userBase.live().metadata.observeAsState() + val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() } + val userTags = + remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() } - if (userDisplayName != null) { - CreateClickableTextWithEmoji( - clickablePart = userDisplayName, - suffix = " ", - maxLines = 1, - route = route, - nav = nav, - tags = userTags - ) - } + if (userDisplayName != null) { + CreateClickableTextWithEmoji( + clickablePart = userDisplayName, + suffix = " ", + maxLines = 1, + route = route, + nav = nav, + tags = userTags, + ) + } } @Composable fun BadgeDisplay(baseNote: Note) { - val background = MaterialTheme.colorScheme.background - val badgeData = baseNote.event as? BadgeDefinitionEvent ?: return + val background = MaterialTheme.colorScheme.background + val badgeData = baseNote.event as? BadgeDefinitionEvent ?: return - val image = remember { badgeData.thumb()?.ifBlank { null } ?: badgeData.image() } - val name = remember { badgeData.name() } - val description = remember { badgeData.description() } + val image = remember { badgeData.thumb()?.ifBlank { null } ?: badgeData.image() } + val name = remember { badgeData.name() } + val description = remember { badgeData.description() } - var backgroundFromImage by remember { mutableStateOf(Pair(background, background)) } - var imageResult by remember { mutableStateOf(null) } + var backgroundFromImage by remember { mutableStateOf(Pair(background, background)) } + var imageResult by remember { mutableStateOf(null) } - LaunchedEffect(key1 = imageResult) { - launch(Dispatchers.IO) { - imageResult?.let { - val backgroundColor = it.drawable.toBitmap(200, 200).copy(Bitmap.Config.ARGB_8888, false).get(0, 199) - val colorFromImage = Color(backgroundColor) - val textBackground = if (colorFromImage.luminance() > 0.5) { - lightColorScheme().onBackground - } else { - darkColorScheme().onBackground - } + LaunchedEffect(key1 = imageResult) { + launch(Dispatchers.IO) { + imageResult?.let { + val backgroundColor = + it.drawable.toBitmap(200, 200).copy(Bitmap.Config.ARGB_8888, false).get(0, 199) + val colorFromImage = Color(backgroundColor) + val textBackground = + if (colorFromImage.luminance() > 0.5) { + lightColorScheme().onBackground + } else { + darkColorScheme().onBackground + } - launch(Dispatchers.Main) { - backgroundFromImage = Pair(colorFromImage, textBackground) - } - } - } + launch(Dispatchers.Main) { backgroundFromImage = Pair(colorFromImage, textBackground) } + } } + } - Row( - modifier = Modifier - .padding(10.dp) - .clip(shape = CutCornerShape(20, 20, 20, 20)) - .border( - 5.dp, - MaterialTheme.colorScheme.mediumImportanceLink, - CutCornerShape(20) - ) - .background(backgroundFromImage.first) + Row( + modifier = + Modifier.padding(10.dp) + .clip(shape = CutCornerShape(20, 20, 20, 20)) + .border( + 5.dp, + MaterialTheme.colorScheme.mediumImportanceLink, + CutCornerShape(20), + ) + .background(backgroundFromImage.first), + ) { + RenderBadge( + image, + name, + backgroundFromImage.second, + description, ) { - RenderBadge( - image, - name, - backgroundFromImage.second, - description - ) { - if (imageResult == null) { - imageResult = it.result - } - } + if (imageResult == null) { + imageResult = it.result + } } + } } @Composable private fun RenderBadge( - image: String?, - name: String?, - backgroundFromImage: Color, - description: String?, - onSuccess: (AsyncImagePainter.State.Success) -> Unit + image: String?, + name: String?, + backgroundFromImage: Color, + description: String?, + onSuccess: (AsyncImagePainter.State.Success) -> Unit, ) { - Column { - image.let { - AsyncImage( - model = it, - contentDescription = stringResource( - R.string.badge_award_image_for, - name ?: "" - ), - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth(), - onSuccess = onSuccess - ) - } - - name?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(start = 10.dp, end = 10.dp), - color = backgroundFromImage - ) - } - - description?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(start = 10.dp, end = 10.dp, bottom = 10.dp), - color = Color.Gray, - maxLines = 3, - overflow = TextOverflow.Ellipsis - ) - } + Column { + image.let { + AsyncImage( + model = it, + contentDescription = + stringResource( + R.string.badge_award_image_for, + name ?: "", + ), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + onSuccess = onSuccess, + ) } + + name?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp), + color = backgroundFromImage, + ) + } + + description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, bottom = 10.dp), + color = Color.Gray, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } } @Composable -fun FileHeaderDisplay(note: Note, roundedCorner: Boolean, accountViewModel: AccountViewModel) { - val event = (note.event as? FileHeaderEvent) ?: return - val fullUrl = event.url() ?: return +fun FileHeaderDisplay( + note: Note, + roundedCorner: Boolean, + accountViewModel: AccountViewModel, +) { + val event = (note.event as? FileHeaderEvent) ?: return + val fullUrl = event.url() ?: return - val content by remember(note) { - val blurHash = event.blurhash() - val hash = event.hash() - val dimensions = event.dimensions() - val description = event.alt() ?: event.content - val isImage = imageExtensions.any { removeQueryParamsForExtensionComparison(fullUrl).lowercase().endsWith(it) } - val uri = note.toNostrUri() + val content by + remember(note) { + val blurHash = event.blurhash() + val hash = event.hash() + val dimensions = event.dimensions() + val description = event.alt() ?: event.content + val isImage = + imageExtensions.any { + removeQueryParamsForExtensionComparison(fullUrl).lowercase().endsWith(it) + } + val uri = note.toNostrUri() - mutableStateOf( - if (isImage) { - ZoomableUrlImage( - url = fullUrl, - description = description, - hash = hash, - blurhash = blurHash, - dim = dimensions, - uri = uri - ) - } else { - ZoomableUrlVideo( - url = fullUrl, - description = description, - hash = hash, - dim = dimensions, - uri = uri, - authorName = note.author?.toBestDisplayName() - ) - } - ) + mutableStateOf( + if (isImage) { + ZoomableUrlImage( + url = fullUrl, + description = description, + hash = hash, + blurhash = blurHash, + dim = dimensions, + uri = uri, + ) + } else { + ZoomableUrlVideo( + url = fullUrl, + description = description, + hash = hash, + dim = dimensions, + uri = uri, + authorName = note.author?.toBestDisplayName(), + ) + }, + ) } - SensitivityWarning(note = note, accountViewModel = accountViewModel) { - ZoomableContentView(content = content, roundedCorner = roundedCorner, accountViewModel = accountViewModel) - } + SensitivityWarning(note = note, accountViewModel = accountViewModel) { + ZoomableContentView( + content = content, + roundedCorner = roundedCorner, + accountViewModel = accountViewModel, + ) + } } @Composable fun VideoDisplay( - note: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val event = (note.event as? VideoEvent) ?: return - val fullUrl = event.url() ?: return + val event = (note.event as? VideoEvent) ?: return + val fullUrl = event.url() ?: return - val title = event.title() - val summary = event.content.ifBlank { null }?.takeIf { title != it } - val image = event.thumb() ?: event.image() - val isYouTube = fullUrl.contains("youtube.com") || fullUrl.contains("youtu.be") - val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } + val title = event.title() + val summary = event.content.ifBlank { null }?.takeIf { title != it } + val image = event.thumb() ?: event.image() + val isYouTube = fullUrl.contains("youtube.com") || fullUrl.contains("youtu.be") + val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } - val content by remember(note) { - val blurHash = event.blurhash() - val hash = event.hash() - val dimensions = event.dimensions() - val description = event.alt() ?: event.content - val isImage = imageExtensions.any { - removeQueryParamsForExtensionComparison(fullUrl).lowercase().endsWith(it) + val content by + remember(note) { + val blurHash = event.blurhash() + val hash = event.hash() + val dimensions = event.dimensions() + val description = event.alt() ?: event.content + val isImage = + imageExtensions.any { + removeQueryParamsForExtensionComparison(fullUrl).lowercase().endsWith(it) } - val uri = note.toNostrUri() + val uri = note.toNostrUri() - mutableStateOf( - if (isImage) { - ZoomableUrlImage( - url = fullUrl, - description = description, - hash = hash, - blurhash = blurHash, - dim = dimensions, - uri = uri - ) - } else { - ZoomableUrlVideo( - url = fullUrl, - description = description, - hash = hash, - dim = dimensions, - uri = uri, - authorName = note.author?.toBestDisplayName(), - artworkUri = event.thumb() ?: event.image() - ) - } - ) + mutableStateOf( + if (isImage) { + ZoomableUrlImage( + url = fullUrl, + description = description, + hash = hash, + blurhash = blurHash, + dim = dimensions, + uri = uri, + ) + } else { + ZoomableUrlVideo( + url = fullUrl, + description = description, + hash = hash, + dim = dimensions, + uri = uri, + authorName = note.author?.toBestDisplayName(), + artworkUri = event.thumb() ?: event.image(), + ) + }, + ) } - SensitivityWarning(note = note, accountViewModel = accountViewModel) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 5.dp), - horizontalAlignment = Alignment.CenterHorizontally + SensitivityWarning(note = note, accountViewModel = accountViewModel) { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 5.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (isYouTube) { + val uri = LocalUriHandler.current + Row( + modifier = Modifier.clickable { runCatching { uri.openUri(fullUrl) } }, ) { - if (isYouTube) { - val uri = LocalUriHandler.current - Row( - modifier = Modifier.clickable { runCatching { uri.openUri(fullUrl) } } - ) { - image?.let { - AsyncImage( - model = it, - contentDescription = stringResource( - R.string.preview_card_image_for, - it - ), - contentScale = ContentScale.FillWidth, - modifier = MaterialTheme.colorScheme.imageModifier - ) - } ?: CreateImageHeader(note, accountViewModel) - } - } else { - ZoomableContentView( - content = content, - roundedCorner = true, - accountViewModel = accountViewModel - ) - } - - title?.let { - Text( - text = it, - fontWeight = FontWeight.Bold, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .fillMaxWidth() - .padding(top = 5.dp) - ) - } - - summary?.let { - TranslatableRichTextViewer( - content = it, - canPreview = canPreview && !makeItShort, - modifier = Modifier.fillMaxWidth(), - tags = tags, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav - ) - } - - if (event.hasHashtags()) { - Row( - Modifier.fillMaxWidth() - ) { - DisplayUncitedHashtags( - remember(event) { event.hashtags().toImmutableList() }, - summary ?: "", - nav - ) - } - } + image?.let { + AsyncImage( + model = it, + contentDescription = + stringResource( + R.string.preview_card_image_for, + it, + ), + contentScale = ContentScale.FillWidth, + modifier = MaterialTheme.colorScheme.imageModifier, + ) + } + ?: CreateImageHeader(note, accountViewModel) } + } else { + ZoomableContentView( + content = content, + roundedCorner = true, + accountViewModel = accountViewModel, + ) + } + + title?.let { + Text( + text = it, + fontWeight = FontWeight.Bold, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth().padding(top = 5.dp), + ) + } + + summary?.let { + TranslatableRichTextViewer( + content = it, + canPreview = canPreview && !makeItShort, + modifier = Modifier.fillMaxWidth(), + tags = tags, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (event.hasHashtags()) { + Row( + Modifier.fillMaxWidth(), + ) { + DisplayUncitedHashtags( + remember(event) { event.hashtags().toImmutableList() }, + summary ?: "", + nav, + ) + } + } } + } } @Composable -fun FileStorageHeaderDisplay(baseNote: Note, roundedCorner: Boolean, accountViewModel: AccountViewModel) { - val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return - val dataEventId = eventHeader.dataEventId() ?: return +fun FileStorageHeaderDisplay( + baseNote: Note, + roundedCorner: Boolean, + accountViewModel: AccountViewModel, +) { + val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return + val dataEventId = eventHeader.dataEventId() ?: return - LoadNote(baseNoteHex = dataEventId, accountViewModel) { contentNote -> - if (contentNote != null) { - ObserverAndRenderNIP95(baseNote, contentNote, roundedCorner, accountViewModel) - } + LoadNote(baseNoteHex = dataEventId, accountViewModel) { contentNote -> + if (contentNote != null) { + ObserverAndRenderNIP95(baseNote, contentNote, roundedCorner, accountViewModel) } + } } @Composable private fun ObserverAndRenderNIP95( - header: Note, - content: Note, - roundedCorner: Boolean, - accountViewModel: AccountViewModel + header: Note, + content: Note, + roundedCorner: Boolean, + accountViewModel: AccountViewModel, ) { - val eventHeader = (header.event as? FileStorageHeaderEvent) ?: return + val eventHeader = (header.event as? FileStorageHeaderEvent) ?: return - val appContext = LocalContext.current.applicationContext + val appContext = LocalContext.current.applicationContext - val noteState by content.live().metadata.observeAsState() + val noteState by content.live().metadata.observeAsState() - val content by remember(noteState) { - // Creates a new object when the event arrives to force an update of the image. - val note = noteState?.note - val uri = header.toNostrUri() - val localDir = note?.idHex?.let { File(File(appContext.cacheDir, "NIP95"), it) } - val blurHash = eventHeader.blurhash() - val dimensions = eventHeader.dimensions() - val description = eventHeader.alt() ?: eventHeader.content - val mimeType = eventHeader.mimeType() + val content by + remember(noteState) { + // Creates a new object when the event arrives to force an update of the image. + val note = noteState?.note + val uri = header.toNostrUri() + val localDir = note?.idHex?.let { File(File(appContext.cacheDir, "NIP95"), it) } + val blurHash = eventHeader.blurhash() + val dimensions = eventHeader.dimensions() + val description = eventHeader.alt() ?: eventHeader.content + val mimeType = eventHeader.mimeType() - val newContent = if (mimeType?.startsWith("image") == true) { - ZoomableLocalImage( - localFile = localDir, - mimeType = mimeType, - description = description, - blurhash = blurHash, - dim = dimensions, - isVerified = true, - uri = uri - ) + val newContent = + if (mimeType?.startsWith("image") == true) { + ZoomableLocalImage( + localFile = localDir, + mimeType = mimeType, + description = description, + blurhash = blurHash, + dim = dimensions, + isVerified = true, + uri = uri, + ) } else { - ZoomableLocalVideo( - localFile = localDir, - mimeType = mimeType, - description = description, - dim = dimensions, - isVerified = true, - uri = uri, - authorName = header.author?.toBestDisplayName() - ) + ZoomableLocalVideo( + localFile = localDir, + mimeType = mimeType, + description = description, + dim = dimensions, + isVerified = true, + uri = uri, + authorName = header.author?.toBestDisplayName(), + ) } - mutableStateOf(newContent) + mutableStateOf(newContent) } - Crossfade(targetState = content) { - if (it != null) { - SensitivityWarning(note = header, accountViewModel = accountViewModel) { - ZoomableContentView(content = it, roundedCorner = roundedCorner, accountViewModel = accountViewModel) - } - } - } -} - -@Composable -fun AudioTrackHeader(noteEvent: AudioTrackEvent, note: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val media = remember { noteEvent.media() } - val cover = remember { noteEvent.cover() } - val subject = remember { noteEvent.subject() } - val content = remember { noteEvent.content() } - val participants = remember { noteEvent.participants() } - - var participantUsers by remember { mutableStateOf>>(emptyList()) } - - LaunchedEffect(key1 = participants) { - accountViewModel.loadParticipants(participants) { - participantUsers = it - } - } - - Row(modifier = Modifier.padding(top = 5.dp)) { - Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { - Row() { - subject?.let { - Row(verticalAlignment = CenterVertically, modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)) { - Text( - text = it, - fontWeight = FontWeight.Bold, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth() - ) - } - } - } - - participantUsers.forEach { - Row( - verticalAlignment = CenterVertically, - modifier = Modifier - .padding(top = 5.dp, start = 10.dp, end = 10.dp) - .clickable { - nav("User/${it.second.pubkeyHex}") - } - ) { - ClickableUserPicture(it.second, 25.dp, accountViewModel) - Spacer(Modifier.width(5.dp)) - UsernameDisplay(it.second, Modifier.weight(1f)) - Spacer(Modifier.width(5.dp)) - it.first.role?.let { - Text( - text = it.capitalize(Locale.ROOT), - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1 - ) - } - } - } - - media?.let { media -> - Row( - verticalAlignment = CenterVertically - ) { - cover?.let { cover -> - LoadThumbAndThenVideoView( - videoUri = media, - title = noteEvent.subject(), - thumbUri = cover, - authorName = note.author?.toBestDisplayName(), - roundedCorner = true, - nostrUriCallback = "nostr:${note.toNEvent()}", - accountViewModel = accountViewModel - ) - } - ?: VideoView( - videoUri = media, - title = noteEvent.subject(), - authorName = note.author?.toBestDisplayName(), - roundedCorner = true, - accountViewModel = accountViewModel - ) - } - } - } - } -} - -@Composable -fun AudioHeader(noteEvent: AudioHeaderEvent, note: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val media = remember { noteEvent.stream() ?: noteEvent.download() } - val waveform = remember { noteEvent.wavefrom()?.toImmutableList()?.ifEmpty { null } } - val content = remember { noteEvent.content().ifBlank { null } } - - val defaultBackground = MaterialTheme.colorScheme.background - val background = remember { mutableStateOf(defaultBackground) } - val tags = remember(noteEvent) { noteEvent.tags()?.toImmutableListOfLists() ?: EmptyTagList } - - Row(modifier = Modifier.padding(top = 5.dp)) { - Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { - media?.let { media -> - Row( - verticalAlignment = CenterVertically - ) { - VideoView( - videoUri = media, - waveform = waveform, - title = noteEvent.subject(), - authorName = note.author?.toBestDisplayName(), - roundedCorner = true, - accountViewModel = accountViewModel, - nostrUriCallback = note.toNostrUri() - ) - } - } - - content?.let { - Row( - verticalAlignment = CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(top = 5.dp) - ) { - TranslatableRichTextViewer( - content = it, - canPreview = true, - tags = tags, - backgroundColor = background, - accountViewModel = accountViewModel, - nav = nav - ) - } - } - - if (noteEvent.hasHashtags()) { - Row(Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) { - val hashtags = remember(noteEvent) { noteEvent.hashtags().toImmutableList() } - DisplayUncitedHashtags(hashtags, content ?: "", nav) - } - } - } - } -} - -@Composable -fun RenderLiveActivityEvent(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - Row(modifier = Modifier.padding(top = 5.dp)) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - RenderLiveActivityEventInner(baseNote = baseNote, accountViewModel, nav) - } - } -} - -@Composable -fun RenderLiveActivityEventInner(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val noteEvent = baseNote.event as? LiveActivitiesEvent ?: return - - val eventUpdates by baseNote.live().metadata.observeAsState() - - val media = remember(eventUpdates) { noteEvent.streaming() } - val cover = remember(eventUpdates) { noteEvent.image() } - val subject = remember(eventUpdates) { noteEvent.title() } - val content = remember(eventUpdates) { noteEvent.summary() } - val participants = remember(eventUpdates) { noteEvent.participants() } - val status = remember(eventUpdates) { noteEvent.status() } - val starts = remember(eventUpdates) { noteEvent.starts() } - - Row( - verticalAlignment = CenterVertically, - modifier = Modifier - .padding(vertical = 5.dp) - .fillMaxWidth() - ) { - subject?.let { - Text( - text = it, - fontWeight = FontWeight.Bold, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - } - - Spacer(modifier = StdHorzSpacer) - - Crossfade(targetState = status, label = "RenderLiveActivityEventInner") { - when (it) { - STATUS_LIVE -> { - media?.let { - CrossfadeCheckIfUrlIsOnline(it, accountViewModel) { - LiveFlag() - } - } - } - STATUS_PLANNED -> { - ScheduledFlag(starts) - } - } - } - } - - var participantUsers by remember { - mutableStateOf>>( - persistentListOf() + Crossfade(targetState = content) { + if (it != null) { + SensitivityWarning(note = header, accountViewModel = accountViewModel) { + ZoomableContentView( + content = it, + roundedCorner = roundedCorner, + accountViewModel = accountViewModel, ) + } } + } +} - LaunchedEffect(key1 = eventUpdates) { - accountViewModel.loadParticipants(participants) { newParticipantUsers -> - if (!equalImmutableLists(newParticipantUsers, participantUsers)) { - participantUsers = newParticipantUsers - } - } - } +@Composable +fun AudioTrackHeader( + noteEvent: AudioTrackEvent, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val media = remember { noteEvent.media() } + val cover = remember { noteEvent.cover() } + val subject = remember { noteEvent.subject() } + val content = remember { noteEvent.content() } + val participants = remember { noteEvent.participants() } - media?.let { media -> - if (status == STATUS_LIVE) { - CheckIfUrlIsOnline(media, accountViewModel) { isOnline -> - if (isOnline) { - Row( - verticalAlignment = CenterVertically - ) { - VideoView( - videoUri = media, - title = subject, - artworkUri = cover, - authorName = baseNote.author?.toBestDisplayName(), - roundedCorner = true, - accountViewModel = accountViewModel, - nostrUriCallback = "nostr:${baseNote.toNEvent()}" - ) - } - } else { - Row( - verticalAlignment = CenterVertically, - modifier = Modifier - .padding(10.dp) - .height(100.dp) - ) { - Text( - text = stringResource(id = R.string.live_stream_is_offline), - color = MaterialTheme.colorScheme.onBackground, - fontWeight = FontWeight.Bold - ) - } - } - } - } else if (status == STATUS_ENDED) { - Row( - verticalAlignment = CenterVertically, - modifier = Modifier - .padding(10.dp) - .height(100.dp) - ) { - Text( - text = stringResource(id = R.string.live_stream_has_ended), - color = MaterialTheme.colorScheme.onBackground, - fontWeight = FontWeight.Bold - ) - } - } - } + var participantUsers by remember { mutableStateOf>>(emptyList()) } - participantUsers.forEach { - Row( + LaunchedEffect(key1 = participants) { + accountViewModel.loadParticipants(participants) { participantUsers = it } + } + + Row(modifier = Modifier.padding(top = 5.dp)) { + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + Row { + subject?.let { + Row( verticalAlignment = CenterVertically, - modifier = Modifier - .padding(vertical = 5.dp) - .clickable { - nav("User/${it.second.pubkeyHex}") - } + modifier = Modifier.padding(top = 5.dp, bottom = 5.dp), + ) { + Text( + text = it, + fontWeight = FontWeight.Bold, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + + participantUsers.forEach { + Row( + verticalAlignment = CenterVertically, + modifier = + Modifier.padding(top = 5.dp, start = 10.dp, end = 10.dp).clickable { + nav("User/${it.second.pubkeyHex}") + }, ) { - ClickableUserPicture(it.second, 25.dp, accountViewModel) - Spacer(StdHorzSpacer) - UsernameDisplay(it.second, Modifier.weight(1f)) - Spacer(StdHorzSpacer) - it.first.role?.let { - Text( - text = it.capitalize(Locale.ROOT), - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1 - ) - } + ClickableUserPicture(it.second, 25.dp, accountViewModel) + Spacer(Modifier.width(5.dp)) + UsernameDisplay(it.second, Modifier.weight(1f)) + Spacer(Modifier.width(5.dp)) + it.first.role?.let { + Text( + text = it.capitalize(Locale.ROOT), + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) + } } + } + + media?.let { media -> + Row( + verticalAlignment = CenterVertically, + ) { + cover?.let { cover -> + LoadThumbAndThenVideoView( + videoUri = media, + title = noteEvent.subject(), + thumbUri = cover, + authorName = note.author?.toBestDisplayName(), + roundedCorner = true, + nostrUriCallback = "nostr:${note.toNEvent()}", + accountViewModel = accountViewModel, + ) + } + ?: VideoView( + videoUri = media, + title = noteEvent.subject(), + authorName = note.author?.toBestDisplayName(), + roundedCorner = true, + accountViewModel = accountViewModel, + ) + } + } } + } } @Composable -private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, accountViewModel: AccountViewModel) { - val image = remember(noteEvent) { noteEvent.image() } - val title = remember(noteEvent) { noteEvent.title() } - val summary = remember(noteEvent) { noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null } } +fun AudioHeader( + noteEvent: AudioHeaderEvent, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val media = remember { noteEvent.stream() ?: noteEvent.download() } + val waveform = remember { noteEvent.wavefrom()?.toImmutableList()?.ifEmpty { null } } + val content = remember { noteEvent.content().ifBlank { null } } - Row( - modifier = Modifier - .padding(top = Size5dp) - .clip(shape = QuoteBorder) - .border( - 1.dp, - MaterialTheme.colorScheme.subtleBorder, - QuoteBorder - ) - ) { - Column { - val automaticallyShowUrlPreview = remember { - accountViewModel.settings.showUrlPreview.value - } + val defaultBackground = MaterialTheme.colorScheme.background + val background = remember { mutableStateOf(defaultBackground) } + val tags = remember(noteEvent) { noteEvent.tags()?.toImmutableListOfLists() ?: EmptyTagList } - if (automaticallyShowUrlPreview) { - image?.let { - AsyncImage( - model = it, - contentDescription = stringResource( - R.string.preview_card_image_for, - it - ), - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth() - ) - } ?: CreateImageHeader(note, accountViewModel) - } - - title?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier - .fillMaxWidth() - .padding(start = 10.dp, end = 10.dp, top = 10.dp) - ) - } - - summary?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier - .fillMaxWidth() - .padding(start = 10.dp, end = 10.dp, bottom = 10.dp), - color = Color.Gray, - maxLines = 3, - overflow = TextOverflow.Ellipsis - ) - } + Row(modifier = Modifier.padding(top = 5.dp)) { + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + media?.let { media -> + Row( + verticalAlignment = CenterVertically, + ) { + VideoView( + videoUri = media, + waveform = waveform, + title = noteEvent.subject(), + authorName = note.author?.toBestDisplayName(), + roundedCorner = true, + accountViewModel = accountViewModel, + nostrUriCallback = note.toNostrUri(), + ) } + } + + content?.let { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.fillMaxWidth().padding(top = 5.dp), + ) { + TranslatableRichTextViewer( + content = it, + canPreview = true, + tags = tags, + backgroundColor = background, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + + if (noteEvent.hasHashtags()) { + Row(Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) { + val hashtags = remember(noteEvent) { noteEvent.hashtags().toImmutableList() } + DisplayUncitedHashtags(hashtags, content ?: "", nav) + } + } } + } } @Composable -private fun RenderClassifieds(noteEvent: ClassifiedsEvent, note: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val image = remember(noteEvent) { noteEvent.image() } - val title = remember(noteEvent) { noteEvent.title() } - val summary = remember(noteEvent) { noteEvent.summary() ?: noteEvent.content.take(200).ifBlank { null } } - val price = remember(noteEvent) { noteEvent.price() } - val location = remember(noteEvent) { noteEvent.location() } - - Row( - modifier = Modifier - .clip(shape = QuoteBorder) - .border( - 1.dp, - MaterialTheme.colorScheme.subtleBorder, - QuoteBorder - ) +fun RenderLiveActivityEvent( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + Row(modifier = Modifier.padding(top = 5.dp)) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, ) { - Column { - Row() { - image?.let { - AsyncImage( - model = it, - contentDescription = stringResource( - R.string.preview_card_image_for, - it - ), - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth() - ) - } ?: CreateImageHeader(note, accountViewModel) - } - - Row(Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp), verticalAlignment = CenterVertically) { - title?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - } - - price?.let { - val priceTag = remember(noteEvent) { - val newAmount = price.amount.toBigDecimalOrNull()?.let { - showAmount(it) - } ?: price.amount - - if (price.frequency != null && price.currency != null) { - "$newAmount ${price.currency}/${price.frequency}" - } else if (price.currency != null) { - "$newAmount ${price.currency}" - } else { - newAmount - } - } - - Text( - text = priceTag, - maxLines = 1, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - modifier = remember { - Modifier - .clip(SmallBorder) - .padding(start = 5.dp) - } - ) - } - } - - if (summary != null || location != null) { - Row(Modifier.padding(start = 10.dp, end = 10.dp, top = 5.dp), verticalAlignment = CenterVertically) { - summary?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier - .weight(1f), - color = Color.Gray, - maxLines = 3, - overflow = TextOverflow.Ellipsis - ) - } - - /* - Column { - location?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = Color.Gray, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(start = 5.dp) - ) - } - - Button( - modifier = Modifier - .padding(horizontal = 3.dp) - .width(50.dp), - onClick = { - note.author?.let { - accountViewModel.createChatRoomFor(it) { - nav("Room/$it") - } - } - }, - contentPadding = ZeroPadding, - colors = ButtonDefaults - .buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ) - ) { - Icon( - painter = painterResource(R.drawable.ic_dm), - stringResource(R.string.send_a_direct_message), - modifier = Modifier.size(20.dp), - tint = Color.White - ) - } - } - - */ - } - } - - Spacer(modifier = DoubleVertSpacer) - } + RenderLiveActivityEventInner(baseNote = baseNote, accountViewModel, nav) } + } +} + +@Composable +fun RenderLiveActivityEventInner( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = baseNote.event as? LiveActivitiesEvent ?: return + + val eventUpdates by baseNote.live().metadata.observeAsState() + + val media = remember(eventUpdates) { noteEvent.streaming() } + val cover = remember(eventUpdates) { noteEvent.image() } + val subject = remember(eventUpdates) { noteEvent.title() } + val content = remember(eventUpdates) { noteEvent.summary() } + val participants = remember(eventUpdates) { noteEvent.participants() } + val status = remember(eventUpdates) { noteEvent.status() } + val starts = remember(eventUpdates) { noteEvent.starts() } + + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = 5.dp).fillMaxWidth(), + ) { + subject?.let { + Text( + text = it, + fontWeight = FontWeight.Bold, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + + Spacer(modifier = StdHorzSpacer) + + Crossfade(targetState = status, label = "RenderLiveActivityEventInner") { + when (it) { + STATUS_LIVE -> { + media?.let { CrossfadeCheckIfUrlIsOnline(it, accountViewModel) { LiveFlag() } } + } + STATUS_PLANNED -> { + ScheduledFlag(starts) + } + } + } + } + + var participantUsers by remember { + mutableStateOf>>( + persistentListOf(), + ) + } + + LaunchedEffect(key1 = eventUpdates) { + accountViewModel.loadParticipants(participants) { newParticipantUsers -> + if (!equalImmutableLists(newParticipantUsers, participantUsers)) { + participantUsers = newParticipantUsers + } + } + } + + media?.let { media -> + if (status == STATUS_LIVE) { + CheckIfUrlIsOnline(media, accountViewModel) { isOnline -> + if (isOnline) { + Row( + verticalAlignment = CenterVertically, + ) { + VideoView( + videoUri = media, + title = subject, + artworkUri = cover, + authorName = baseNote.author?.toBestDisplayName(), + roundedCorner = true, + accountViewModel = accountViewModel, + nostrUriCallback = "nostr:${baseNote.toNEvent()}", + ) + } + } else { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(10.dp).height(100.dp), + ) { + Text( + text = stringResource(id = R.string.live_stream_is_offline), + color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.Bold, + ) + } + } + } + } else if (status == STATUS_ENDED) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(10.dp).height(100.dp), + ) { + Text( + text = stringResource(id = R.string.live_stream_has_ended), + color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.Bold, + ) + } + } + } + + participantUsers.forEach { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = 5.dp).clickable { nav("User/${it.second.pubkeyHex}") }, + ) { + ClickableUserPicture(it.second, 25.dp, accountViewModel) + Spacer(StdHorzSpacer) + UsernameDisplay(it.second, Modifier.weight(1f)) + Spacer(StdHorzSpacer) + it.first.role?.let { + Text( + text = it.capitalize(Locale.ROOT), + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) + } + } + } +} + +@Composable +private fun LongFormHeader( + noteEvent: LongTextNoteEvent, + note: Note, + accountViewModel: AccountViewModel, +) { + val image = remember(noteEvent) { noteEvent.image() } + val title = remember(noteEvent) { noteEvent.title() } + val summary = + remember(noteEvent) { + noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null } + } + + Row( + modifier = + Modifier.padding(top = Size5dp) + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ), + ) { + Column { + val automaticallyShowUrlPreview = remember { accountViewModel.settings.showUrlPreview.value } + + if (automaticallyShowUrlPreview) { + image?.let { + AsyncImage( + model = it, + contentDescription = + stringResource( + R.string.preview_card_image_for, + it, + ), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + ) + } + ?: CreateImageHeader(note, accountViewModel) + } + + title?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp), + ) + } + + summary?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, bottom = 10.dp), + color = Color.Gray, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun RenderClassifieds( + noteEvent: ClassifiedsEvent, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val image = remember(noteEvent) { noteEvent.image() } + val title = remember(noteEvent) { noteEvent.title() } + val summary = + remember(noteEvent) { noteEvent.summary() ?: noteEvent.content.take(200).ifBlank { null } } + val price = remember(noteEvent) { noteEvent.price() } + val location = remember(noteEvent) { noteEvent.location() } + + Row( + modifier = + Modifier.clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ), + ) { + Column { + Row { + image?.let { + AsyncImage( + model = it, + contentDescription = + stringResource( + R.string.preview_card_image_for, + it, + ), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + ) + } + ?: CreateImageHeader(note, accountViewModel) + } + + Row( + Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp), + verticalAlignment = CenterVertically, + ) { + title?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + + price?.let { + val priceTag = + remember(noteEvent) { + val newAmount = + price.amount.toBigDecimalOrNull()?.let { showAmount(it) } ?: price.amount + + if (price.frequency != null && price.currency != null) { + "$newAmount ${price.currency}/${price.frequency}" + } else if (price.currency != null) { + "$newAmount ${price.currency}" + } else { + newAmount + } + } + + Text( + text = priceTag, + maxLines = 1, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + modifier = remember { Modifier.clip(SmallBorder).padding(start = 5.dp) }, + ) + } + } + + if (summary != null || location != null) { + Row( + Modifier.padding(start = 10.dp, end = 10.dp, top = 5.dp), + verticalAlignment = CenterVertically, + ) { + summary?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + color = Color.Gray, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + + /* + Column { + location?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 5.dp) + ) + } + + Button( + modifier = Modifier + .padding(horizontal = 3.dp) + .width(50.dp), + onClick = { + note.author?.let { + accountViewModel.createChatRoomFor(it) { + nav("Room/$it") + } + } + }, + contentPadding = ZeroPadding, + colors = ButtonDefaults + .buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Icon( + painter = painterResource(R.drawable.ic_dm), + stringResource(R.string.send_a_direct_message), + modifier = Modifier.size(20.dp), + tint = Color.White + ) + } + } + + */ + } + } + + Spacer(modifier = DoubleVertSpacer) + } + } } @Composable fun CreateImageHeader( - note: Note, - accountViewModel: AccountViewModel + note: Note, + accountViewModel: AccountViewModel, ) { - val banner = remember(note.author?.info) { note.author?.info?.banner } + val banner = remember(note.author?.info) { note.author?.info?.banner } - Box() { - banner?.let { - AsyncImage( - model = it, - contentDescription = stringResource( - R.string.preview_card_image_for, - it - ), - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth() - ) - } ?: Image( - painter = painterResource(R.drawable.profile_banner), - contentDescription = stringResource(R.string.profile_banner), - contentScale = ContentScale.FillWidth, - modifier = remember { - Modifier - .fillMaxWidth() - .height(150.dp) - } - ) - - Box( - remember { - Modifier - .width(75.dp) - .height(75.dp) - .padding(10.dp) - .align(Alignment.BottomStart) - } - ) { - NoteAuthorPicture(baseNote = note, accountViewModel = accountViewModel, size = Size55dp) - } + Box { + banner?.let { + AsyncImage( + model = it, + contentDescription = + stringResource( + R.string.preview_card_image_for, + it, + ), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + ) } + ?: Image( + painter = painterResource(R.drawable.profile_banner), + contentDescription = stringResource(R.string.profile_banner), + contentScale = ContentScale.FillWidth, + modifier = remember { Modifier.fillMaxWidth().height(150.dp) }, + ) + + Box( + remember { Modifier.width(75.dp).height(75.dp).padding(10.dp).align(Alignment.BottomStart) }, + ) { + NoteAuthorPicture(baseNote = note, accountViewModel = accountViewModel, size = Size55dp) + } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index bb4401bea..c34728a84 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -1,530 +1,563 @@ -package com.vitorpamplona.amethyst.ui.note - -import android.content.Intent -import android.widget.Toast -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -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.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AlternateEmail -import androidx.compose.material.icons.filled.Block -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.FormatQuote -import androidx.compose.material.icons.filled.PersonAdd -import androidx.compose.material.icons.filled.PersonRemove -import androidx.compose.material.icons.filled.Report -import androidx.compose.material.icons.filled.Share -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonColors -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Divider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Popup -import androidx.core.content.ContextCompat -import androidx.core.graphics.ColorUtils -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.components.SelectTextDialog -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog -import com.vitorpamplona.amethyst.ui.theme.WarningColor -import com.vitorpamplona.amethyst.ui.theme.isLight -import com.vitorpamplona.amethyst.ui.theme.secondaryButtonBackground -import com.vitorpamplona.quartz.events.AudioTrackEvent -import com.vitorpamplona.quartz.events.FileHeaderEvent -import com.vitorpamplona.quartz.events.PeopleListEvent -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -private fun lightenColor(color: Color, amount: Float): Color { - var argb = color.toArgb() - val hslOut = floatArrayOf(0f, 0f, 0f) - ColorUtils.colorToHSL(argb, hslOut) - hslOut[2] += amount - argb = ColorUtils.HSLToColor(hslOut) - return Color(argb) -} - -val externalLinkForNote = { note: Note -> - if (note is AddressableNote) { - if (note.event?.getReward() != null) { - "https://nostrbounties.com/b/${note.address().toNAddr()}" - } else if (note.event is PeopleListEvent) { - "https://listr.lol/a/${note.address()?.toNAddr()}" - } else if (note.event is AudioTrackEvent) { - "https://zapstr.live/?track=${note.address()?.toNAddr()}" - } else { - "https://habla.news/a/${note.address()?.toNAddr()}" - } - } else { - if (note.event is FileHeaderEvent) { - "https://filestr.vercel.app/e/${note.toNEvent()}" - } else { - "https://njump.me/${note.toNEvent()}" - } - } -} - -@Composable -private fun VerticalDivider(color: Color) = - Divider( - color = color, - modifier = Modifier - .fillMaxHeight() - .width(1.dp) - ) - -@Composable -fun LongPressToQuickAction(baseNote: Note, accountViewModel: AccountViewModel, content: @Composable (() -> Unit) -> Unit) { - val popupExpanded = remember { mutableStateOf(false) } - val showPopup = remember { { popupExpanded.value = true } } - val hidePopup = remember { { popupExpanded.value = false } } - - content(showPopup) - - NoteQuickActionMenu(baseNote, popupExpanded.value, hidePopup, accountViewModel) -} - -@Composable -fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, accountViewModel: AccountViewModel) { - val showSelectTextDialog = remember { mutableStateOf(false) } - val showDeleteAlertDialog = remember { mutableStateOf(false) } - val showBlockAlertDialog = remember { mutableStateOf(false) } - val showReportDialog = remember { mutableStateOf(false) } - - if (popupExpanded) { - RenderMainPopup( - accountViewModel, - note, - onDismiss, - showBlockAlertDialog, - showDeleteAlertDialog, - showReportDialog - ) - } - - if (showSelectTextDialog.value) { - val decryptedNote = remember { - mutableStateOf(null) - } - - LaunchedEffect(key1 = Unit) { - accountViewModel.decrypt(note) { - decryptedNote.value = it - } - } - - decryptedNote.value?.let { - SelectTextDialog(it) { - showSelectTextDialog.value = false - decryptedNote.value = null - } - } - } - - if (showDeleteAlertDialog.value) { - DeleteAlertDialog(note, accountViewModel) { - showDeleteAlertDialog.value = false - onDismiss() - } - } - - if (showBlockAlertDialog.value) { - BlockAlertDialog(note, accountViewModel) { - showBlockAlertDialog.value = false - onDismiss() - } - } - - if (showReportDialog.value) { - ReportNoteDialog(note, accountViewModel) { - showReportDialog.value = false - onDismiss() - } - } -} - -@Composable -private fun RenderMainPopup( - accountViewModel: AccountViewModel, - note: Note, - onDismiss: () -> Unit, - showBlockAlertDialog: MutableState, - showDeleteAlertDialog: MutableState, - showReportDialog: MutableState -) { - val context = LocalContext.current - val primaryLight = lightenColor(MaterialTheme.colorScheme.primary, 0.1f) - val cardShape = RoundedCornerShape(5.dp) - val clipboardManager = LocalClipboardManager.current - val scope = rememberCoroutineScope() - - val backgroundColor = if (MaterialTheme.colorScheme.isLight) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.secondaryButtonBackground - } - - val showToast = { stringResource: Int -> - scope.launch { - Toast.makeText( - context, - context.getString(stringResource), - Toast.LENGTH_SHORT - ).show() - } - } - - val isOwnNote = accountViewModel.isLoggedUser(note.author) - val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author) - - Popup(onDismissRequest = onDismiss, alignment = Alignment.Center) { - Card( - modifier = Modifier.shadow(elevation = 6.dp, shape = cardShape), - shape = cardShape, - colors = CardDefaults.cardColors(containerColor = backgroundColor) - ) { - Column(modifier = Modifier.width(IntrinsicSize.Min)) { - Row(modifier = Modifier.height(IntrinsicSize.Min)) { - NoteQuickActionItem( - icon = Icons.Default.ContentCopy, - label = stringResource(R.string.quick_action_copy_text) - ) { - accountViewModel.decrypt(note) { - clipboardManager.setText(AnnotatedString(it)) - showToast(R.string.copied_note_text_to_clipboard) - } - - onDismiss() - } - VerticalDivider(primaryLight) - NoteQuickActionItem( - Icons.Default.AlternateEmail, - stringResource(R.string.quick_action_copy_user_id) - ) { - scope.launch(Dispatchers.IO) { - clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}")) - showToast(R.string.copied_user_id_to_clipboard) - onDismiss() - } - } - VerticalDivider(primaryLight) - NoteQuickActionItem( - Icons.Default.FormatQuote, - stringResource(R.string.quick_action_copy_note_id) - ) { - scope.launch(Dispatchers.IO) { - clipboardManager.setText(AnnotatedString("nostr:${note.toNEvent()}")) - showToast(R.string.copied_note_id_to_clipboard) - onDismiss() - } - } - - if (!isOwnNote) { - VerticalDivider(primaryLight) - - NoteQuickActionItem( - Icons.Default.Block, - stringResource(R.string.quick_action_block) - ) { - if (accountViewModel.hideBlockAlertDialog) { - note.author?.let { accountViewModel.hide(it) } - onDismiss() - } else { - showBlockAlertDialog.value = true - } - } - } - } - Divider( - color = primaryLight, - modifier = Modifier - .fillMaxWidth() - .width(1.dp) - ) - Row(modifier = Modifier.height(IntrinsicSize.Min)) { - if (isOwnNote) { - NoteQuickActionItem( - Icons.Default.Delete, - stringResource(R.string.quick_action_delete) - ) { - if (accountViewModel.hideDeleteRequestDialog) { - accountViewModel.delete(note) - onDismiss() - } else { - showDeleteAlertDialog.value = true - } - } - } else if (isFollowingUser) { - NoteQuickActionItem( - Icons.Default.PersonRemove, - stringResource(R.string.quick_action_unfollow) - ) { - accountViewModel.unfollow(note.author!!) - onDismiss() - } - } else { - NoteQuickActionItem( - Icons.Default.PersonAdd, - stringResource(R.string.quick_action_follow) - ) { - accountViewModel.follow(note.author!!) - onDismiss() - } - } - - VerticalDivider(primaryLight) - NoteQuickActionItem( - icon = ImageVector.vectorResource(id = R.drawable.relays), - label = stringResource(R.string.broadcast) - ) { - scope.launch(Dispatchers.IO) { - accountViewModel.broadcast(note) - // showSelectTextDialog = true - onDismiss() - } - } - VerticalDivider(primaryLight) - NoteQuickActionItem( - icon = Icons.Default.Share, - label = stringResource(R.string.quick_action_share) - ) { - val sendIntent = Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra( - Intent.EXTRA_TEXT, - externalLinkForNote(note) - ) - putExtra( - Intent.EXTRA_TITLE, - context.getString(R.string.quick_action_share_browser_link) - ) - } - - val shareIntent = Intent.createChooser( - sendIntent, - context.getString(R.string.quick_action_share) - ) - ContextCompat.startActivity(context, shareIntent, null) - onDismiss() - } - - if (!isOwnNote) { - VerticalDivider(primaryLight) - - NoteQuickActionItem( - Icons.Default.Report, - stringResource(R.string.quick_action_report) - ) { - showReportDialog.value = true - } - } - } - } - } - } -} - -@Composable -fun NoteQuickActionItem(icon: ImageVector, label: String, onClick: () -> Unit) { - Column( - modifier = Modifier - .size(70.dp) - .clickable { onClick() }, - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier - .size(24.dp) - .padding(bottom = 5.dp), - tint = Color.White - ) - Text(text = label, fontSize = 12.sp, color = Color.White, textAlign = TextAlign.Center) - } -} - -@Composable -fun DeleteAlertDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) { - QuickActionAlertDialog( - title = stringResource(R.string.quick_action_request_deletion_alert_title), - textContent = stringResource(R.string.quick_action_request_deletion_alert_body), - buttonIcon = Icons.Default.Delete, - buttonText = stringResource(R.string.quick_action_delete_dialog_btn), - onClickDoOnce = { - accountViewModel.delete(note) - onDismiss() - }, - onClickDontShowAgain = { - accountViewModel.delete(note) - accountViewModel.dontShowDeleteRequestDialog() - onDismiss() - }, - onDismiss = onDismiss - ) -} - -@Composable -private fun BlockAlertDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) = - QuickActionAlertDialog( - title = stringResource(R.string.report_dialog_block_hide_user_btn), - textContent = stringResource(R.string.report_dialog_blocking_a_user), - buttonIcon = Icons.Default.Block, - buttonText = stringResource(R.string.quick_action_block_dialog_btn), - buttonColors = ButtonDefaults.buttonColors( - containerColor = WarningColor, - contentColor = Color.White - ), - onClickDoOnce = { - note.author?.let { accountViewModel.hide(it) } - onDismiss() - }, - onClickDontShowAgain = { - note.author?.let { accountViewModel.hide(it) } - accountViewModel.dontShowBlockAlertDialog() - onDismiss() - }, - onDismiss = onDismiss - ) - -@Composable -fun QuickActionAlertDialog( - title: String, - textContent: String, - buttonIcon: ImageVector, - buttonText: String, - buttonColors: ButtonColors = ButtonDefaults.buttonColors(), - onClickDoOnce: () -> Unit, - onClickDontShowAgain: () -> Unit, - onDismiss: () -> Unit -) { - QuickActionAlertDialog( - title = title, - textContent = textContent, - icon = { - Icon( - imageVector = buttonIcon, - contentDescription = null - ) - }, - buttonText = buttonText, - buttonColors = buttonColors, - onClickDoOnce = onClickDoOnce, - onClickDontShowAgain = onClickDontShowAgain, - onDismiss = onDismiss - ) -} - -@Composable -fun QuickActionAlertDialog( - title: String, - textContent: String, - buttonIconResource: Int, - buttonText: String, - buttonColors: ButtonColors = ButtonDefaults.buttonColors(), - onClickDoOnce: () -> Unit, - onClickDontShowAgain: () -> Unit, - onDismiss: () -> Unit -) { - QuickActionAlertDialog( - title = title, - textContent = textContent, - icon = { - Icon( - painter = painterResource(buttonIconResource), - contentDescription = null - ) - }, - buttonText = buttonText, - buttonColors = buttonColors, - onClickDoOnce = onClickDoOnce, - onClickDontShowAgain = onClickDontShowAgain, - onDismiss = onDismiss - ) -} - -@Composable -fun QuickActionAlertDialog( - title: String, - textContent: String, - icon: @Composable () -> Unit, - buttonText: String, - buttonColors: ButtonColors = ButtonDefaults.buttonColors(), - onClickDoOnce: () -> Unit, - onClickDontShowAgain: () -> Unit, - onDismiss: () -> Unit -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text(title) - }, - text = { - Text(textContent) - }, - confirmButton = { - Row( - modifier = Modifier - .padding(all = 8.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - TextButton(onClick = onClickDontShowAgain) { - Text(stringResource(R.string.quick_action_dont_show_again_button)) - } - Button(onClick = onClickDoOnce, colors = buttonColors, contentPadding = PaddingValues(horizontal = 16.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - icon() - Spacer(Modifier.width(8.dp)) - Text(buttonText) - } - } - } - } - ) -} +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note + +import android.content.Intent +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +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.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AlternateEmail +import androidx.compose.material.icons.filled.Block +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.FormatQuote +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material.icons.filled.PersonRemove +import androidx.compose.material.icons.filled.Report +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.core.content.ContextCompat +import androidx.core.graphics.ColorUtils +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.SelectTextDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog +import com.vitorpamplona.amethyst.ui.theme.WarningColor +import com.vitorpamplona.amethyst.ui.theme.isLight +import com.vitorpamplona.amethyst.ui.theme.secondaryButtonBackground +import com.vitorpamplona.quartz.events.AudioTrackEvent +import com.vitorpamplona.quartz.events.FileHeaderEvent +import com.vitorpamplona.quartz.events.PeopleListEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +private fun lightenColor( + color: Color, + amount: Float, +): Color { + var argb = color.toArgb() + val hslOut = floatArrayOf(0f, 0f, 0f) + ColorUtils.colorToHSL(argb, hslOut) + hslOut[2] += amount + argb = ColorUtils.HSLToColor(hslOut) + return Color(argb) +} + +val externalLinkForNote = { note: Note -> + if (note is AddressableNote) { + if (note.event?.getReward() != null) { + "https://nostrbounties.com/b/${note.address().toNAddr()}" + } else if (note.event is PeopleListEvent) { + "https://listr.lol/a/${note.address()?.toNAddr()}" + } else if (note.event is AudioTrackEvent) { + "https://zapstr.live/?track=${note.address()?.toNAddr()}" + } else { + "https://habla.news/a/${note.address()?.toNAddr()}" + } + } else { + if (note.event is FileHeaderEvent) { + "https://filestr.vercel.app/e/${note.toNEvent()}" + } else { + "https://njump.me/${note.toNEvent()}" + } + } +} + +@Composable +private fun VerticalDivider(color: Color) = + Divider( + color = color, + modifier = Modifier.fillMaxHeight().width(1.dp), + ) + +@Composable +fun LongPressToQuickAction( + baseNote: Note, + accountViewModel: AccountViewModel, + content: @Composable (() -> Unit) -> Unit, +) { + val popupExpanded = remember { mutableStateOf(false) } + val showPopup = remember { { popupExpanded.value = true } } + val hidePopup = remember { { popupExpanded.value = false } } + + content(showPopup) + + NoteQuickActionMenu(baseNote, popupExpanded.value, hidePopup, accountViewModel) +} + +@Composable +fun NoteQuickActionMenu( + note: Note, + popupExpanded: Boolean, + onDismiss: () -> Unit, + accountViewModel: AccountViewModel, +) { + val showSelectTextDialog = remember { mutableStateOf(false) } + val showDeleteAlertDialog = remember { mutableStateOf(false) } + val showBlockAlertDialog = remember { mutableStateOf(false) } + val showReportDialog = remember { mutableStateOf(false) } + + if (popupExpanded) { + RenderMainPopup( + accountViewModel, + note, + onDismiss, + showBlockAlertDialog, + showDeleteAlertDialog, + showReportDialog, + ) + } + + if (showSelectTextDialog.value) { + val decryptedNote = remember { mutableStateOf(null) } + + LaunchedEffect(key1 = Unit) { accountViewModel.decrypt(note) { decryptedNote.value = it } } + + decryptedNote.value?.let { + SelectTextDialog(it) { + showSelectTextDialog.value = false + decryptedNote.value = null + } + } + } + + if (showDeleteAlertDialog.value) { + DeleteAlertDialog(note, accountViewModel) { + showDeleteAlertDialog.value = false + onDismiss() + } + } + + if (showBlockAlertDialog.value) { + BlockAlertDialog(note, accountViewModel) { + showBlockAlertDialog.value = false + onDismiss() + } + } + + if (showReportDialog.value) { + ReportNoteDialog(note, accountViewModel) { + showReportDialog.value = false + onDismiss() + } + } +} + +@Composable +private fun RenderMainPopup( + accountViewModel: AccountViewModel, + note: Note, + onDismiss: () -> Unit, + showBlockAlertDialog: MutableState, + showDeleteAlertDialog: MutableState, + showReportDialog: MutableState, +) { + val context = LocalContext.current + val primaryLight = lightenColor(MaterialTheme.colorScheme.primary, 0.1f) + val cardShape = RoundedCornerShape(5.dp) + val clipboardManager = LocalClipboardManager.current + val scope = rememberCoroutineScope() + + val backgroundColor = + if (MaterialTheme.colorScheme.isLight) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.secondaryButtonBackground + } + + val showToast = { stringResource: Int -> + scope.launch { + Toast.makeText( + context, + context.getString(stringResource), + Toast.LENGTH_SHORT, + ) + .show() + } + } + + val isOwnNote = accountViewModel.isLoggedUser(note.author) + val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author) + + Popup(onDismissRequest = onDismiss, alignment = Alignment.Center) { + Card( + modifier = Modifier.shadow(elevation = 6.dp, shape = cardShape), + shape = cardShape, + colors = CardDefaults.cardColors(containerColor = backgroundColor), + ) { + Column(modifier = Modifier.width(IntrinsicSize.Min)) { + Row(modifier = Modifier.height(IntrinsicSize.Min)) { + NoteQuickActionItem( + icon = Icons.Default.ContentCopy, + label = stringResource(R.string.quick_action_copy_text), + ) { + accountViewModel.decrypt(note) { + clipboardManager.setText(AnnotatedString(it)) + showToast(R.string.copied_note_text_to_clipboard) + } + + onDismiss() + } + VerticalDivider(primaryLight) + NoteQuickActionItem( + Icons.Default.AlternateEmail, + stringResource(R.string.quick_action_copy_user_id), + ) { + scope.launch(Dispatchers.IO) { + clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}")) + showToast(R.string.copied_user_id_to_clipboard) + onDismiss() + } + } + VerticalDivider(primaryLight) + NoteQuickActionItem( + Icons.Default.FormatQuote, + stringResource(R.string.quick_action_copy_note_id), + ) { + scope.launch(Dispatchers.IO) { + clipboardManager.setText(AnnotatedString("nostr:${note.toNEvent()}")) + showToast(R.string.copied_note_id_to_clipboard) + onDismiss() + } + } + + if (!isOwnNote) { + VerticalDivider(primaryLight) + + NoteQuickActionItem( + Icons.Default.Block, + stringResource(R.string.quick_action_block), + ) { + if (accountViewModel.hideBlockAlertDialog) { + note.author?.let { accountViewModel.hide(it) } + onDismiss() + } else { + showBlockAlertDialog.value = true + } + } + } + } + Divider( + color = primaryLight, + modifier = Modifier.fillMaxWidth().width(1.dp), + ) + Row(modifier = Modifier.height(IntrinsicSize.Min)) { + if (isOwnNote) { + NoteQuickActionItem( + Icons.Default.Delete, + stringResource(R.string.quick_action_delete), + ) { + if (accountViewModel.hideDeleteRequestDialog) { + accountViewModel.delete(note) + onDismiss() + } else { + showDeleteAlertDialog.value = true + } + } + } else if (isFollowingUser) { + NoteQuickActionItem( + Icons.Default.PersonRemove, + stringResource(R.string.quick_action_unfollow), + ) { + accountViewModel.unfollow(note.author!!) + onDismiss() + } + } else { + NoteQuickActionItem( + Icons.Default.PersonAdd, + stringResource(R.string.quick_action_follow), + ) { + accountViewModel.follow(note.author!!) + onDismiss() + } + } + + VerticalDivider(primaryLight) + NoteQuickActionItem( + icon = ImageVector.vectorResource(id = R.drawable.relays), + label = stringResource(R.string.broadcast), + ) { + scope.launch(Dispatchers.IO) { + accountViewModel.broadcast(note) + // showSelectTextDialog = true + onDismiss() + } + } + VerticalDivider(primaryLight) + NoteQuickActionItem( + icon = Icons.Default.Share, + label = stringResource(R.string.quick_action_share), + ) { + val sendIntent = + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, + externalLinkForNote(note), + ) + putExtra( + Intent.EXTRA_TITLE, + context.getString(R.string.quick_action_share_browser_link), + ) + } + + val shareIntent = + Intent.createChooser( + sendIntent, + context.getString(R.string.quick_action_share), + ) + ContextCompat.startActivity(context, shareIntent, null) + onDismiss() + } + + if (!isOwnNote) { + VerticalDivider(primaryLight) + + NoteQuickActionItem( + Icons.Default.Report, + stringResource(R.string.quick_action_report), + ) { + showReportDialog.value = true + } + } + } + } + } + } +} + +@Composable +fun NoteQuickActionItem( + icon: ImageVector, + label: String, + onClick: () -> Unit, +) { + Column( + modifier = Modifier.size(70.dp).clickable { onClick() }, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp).padding(bottom = 5.dp), + tint = Color.White, + ) + Text(text = label, fontSize = 12.sp, color = Color.White, textAlign = TextAlign.Center) + } +} + +@Composable +fun DeleteAlertDialog( + note: Note, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, +) { + QuickActionAlertDialog( + title = stringResource(R.string.quick_action_request_deletion_alert_title), + textContent = stringResource(R.string.quick_action_request_deletion_alert_body), + buttonIcon = Icons.Default.Delete, + buttonText = stringResource(R.string.quick_action_delete_dialog_btn), + onClickDoOnce = { + accountViewModel.delete(note) + onDismiss() + }, + onClickDontShowAgain = { + accountViewModel.delete(note) + accountViewModel.dontShowDeleteRequestDialog() + onDismiss() + }, + onDismiss = onDismiss, + ) +} + +@Composable +private fun BlockAlertDialog( + note: Note, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, +) = + QuickActionAlertDialog( + title = stringResource(R.string.report_dialog_block_hide_user_btn), + textContent = stringResource(R.string.report_dialog_blocking_a_user), + buttonIcon = Icons.Default.Block, + buttonText = stringResource(R.string.quick_action_block_dialog_btn), + buttonColors = + ButtonDefaults.buttonColors( + containerColor = WarningColor, + contentColor = Color.White, + ), + onClickDoOnce = { + note.author?.let { accountViewModel.hide(it) } + onDismiss() + }, + onClickDontShowAgain = { + note.author?.let { accountViewModel.hide(it) } + accountViewModel.dontShowBlockAlertDialog() + onDismiss() + }, + onDismiss = onDismiss, + ) + +@Composable +fun QuickActionAlertDialog( + title: String, + textContent: String, + buttonIcon: ImageVector, + buttonText: String, + buttonColors: ButtonColors = ButtonDefaults.buttonColors(), + onClickDoOnce: () -> Unit, + onClickDontShowAgain: () -> Unit, + onDismiss: () -> Unit, +) { + QuickActionAlertDialog( + title = title, + textContent = textContent, + icon = { + Icon( + imageVector = buttonIcon, + contentDescription = null, + ) + }, + buttonText = buttonText, + buttonColors = buttonColors, + onClickDoOnce = onClickDoOnce, + onClickDontShowAgain = onClickDontShowAgain, + onDismiss = onDismiss, + ) +} + +@Composable +fun QuickActionAlertDialog( + title: String, + textContent: String, + buttonIconResource: Int, + buttonText: String, + buttonColors: ButtonColors = ButtonDefaults.buttonColors(), + onClickDoOnce: () -> Unit, + onClickDontShowAgain: () -> Unit, + onDismiss: () -> Unit, +) { + QuickActionAlertDialog( + title = title, + textContent = textContent, + icon = { + Icon( + painter = painterResource(buttonIconResource), + contentDescription = null, + ) + }, + buttonText = buttonText, + buttonColors = buttonColors, + onClickDoOnce = onClickDoOnce, + onClickDontShowAgain = onClickDontShowAgain, + onDismiss = onDismiss, + ) +} + +@Composable +fun QuickActionAlertDialog( + title: String, + textContent: String, + icon: @Composable () -> Unit, + buttonText: String, + buttonColors: ButtonColors = ButtonDefaults.buttonColors(), + onClickDoOnce: () -> Unit, + onClickDontShowAgain: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { Text(textContent) }, + confirmButton = { + Row( + modifier = Modifier.padding(all = 8.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + TextButton(onClick = onClickDontShowAgain) { + Text(stringResource(R.string.quick_action_dont_show_again_button)) + } + Button( + onClick = onClickDoOnce, + colors = buttonColors, + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + icon() + Spacer(Modifier.width(8.dp)) + Text(buttonText) + } + } + } + }, + ) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index 829b3d7c4..837eb710f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.note import androidx.compose.foundation.ExperimentalFoundationApi @@ -67,515 +87,483 @@ import com.vitorpamplona.quartz.events.EmptyTagList import com.vitorpamplona.quartz.events.ImmutableListOfLists import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.toImmutableListOfLists +import kotlin.math.roundToInt import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlin.math.roundToInt @Composable fun PollNote( - baseNote: Note, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val pollViewModel: PollNoteViewModel = viewModel(key = "PollNoteViewModel") + val pollViewModel: PollNoteViewModel = viewModel(key = "PollNoteViewModel") - pollViewModel.load(accountViewModel.account, baseNote) + pollViewModel.load(accountViewModel.account, baseNote) - PollNote( - baseNote = baseNote, - pollViewModel = pollViewModel, - canPreview = canPreview, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav - ) + PollNote( + baseNote = baseNote, + pollViewModel = pollViewModel, + canPreview = canPreview, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) } @Composable fun PollNote( - baseNote: Note, - pollViewModel: PollNoteViewModel, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + pollViewModel: PollNoteViewModel, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - WatchZapsAndUpdateTallies(baseNote, pollViewModel) + WatchZapsAndUpdateTallies(baseNote, pollViewModel) - val tallies by pollViewModel.tallies.collectAsStateWithLifecycle() + val tallies by pollViewModel.tallies.collectAsStateWithLifecycle() - tallies.forEach { poll_op -> - OptionNote( - poll_op, - pollViewModel, - baseNote, - accountViewModel, - canPreview, - backgroundColor, - nav - ) - } + tallies.forEach { poll_op -> + OptionNote( + poll_op, + pollViewModel, + baseNote, + accountViewModel, + canPreview, + backgroundColor, + nav, + ) + } } @Composable private fun WatchZapsAndUpdateTallies( - baseNote: Note, - pollViewModel: PollNoteViewModel + baseNote: Note, + pollViewModel: PollNoteViewModel, ) { - val zapsState by baseNote.live().zaps.observeAsState() + val zapsState by baseNote.live().zaps.observeAsState() - LaunchedEffect(key1 = zapsState) { - pollViewModel.refreshTallies() - } + LaunchedEffect(key1 = zapsState) { pollViewModel.refreshTallies() } } @Composable private fun OptionNote( - poolOption: PollOption, - pollViewModel: PollNoteViewModel, - baseNote: Note, - accountViewModel: AccountViewModel, - canPreview: Boolean, - backgroundColor: MutableState, - nav: (String) -> Unit + poolOption: PollOption, + pollViewModel: PollNoteViewModel, + baseNote: Note, + accountViewModel: AccountViewModel, + canPreview: Boolean, + backgroundColor: MutableState, + nav: (String) -> Unit, ) { - val tags = remember(baseNote) { - baseNote.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList - } + val tags = remember(baseNote) { baseNote.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 3.dp) - ) { - if (!pollViewModel.canZap()) { - val color = if (poolOption.consensusThreadhold) { - Color.Green.copy(alpha = 0.32f) - } else { - MaterialTheme.colorScheme.mediumImportanceLink - } - - ZapVote( - baseNote, - poolOption, - pollViewModel = pollViewModel, - nonClickablePrepend = { - RenderOptionAfterVote( - poolOption.descriptor, - poolOption.tally.toFloat(), - color, - canPreview, - tags, - backgroundColor, - accountViewModel, - nav - ) - }, - clickablePrepend = { - }, - accountViewModel = accountViewModel, - nav = nav - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 3.dp), + ) { + if (!pollViewModel.canZap()) { + val color = + if (poolOption.consensusThreadhold) { + Color.Green.copy(alpha = 0.32f) } else { - ZapVote( - baseNote, - poolOption, - pollViewModel = pollViewModel, - nonClickablePrepend = {}, - clickablePrepend = { - RenderOptionBeforeVote(poolOption.descriptor, canPreview, tags, backgroundColor, accountViewModel, nav) - }, - accountViewModel = accountViewModel, - nav = nav - ) + MaterialTheme.colorScheme.mediumImportanceLink } + + ZapVote( + baseNote, + poolOption, + pollViewModel = pollViewModel, + nonClickablePrepend = { + RenderOptionAfterVote( + poolOption.descriptor, + poolOption.tally.toFloat(), + color, + canPreview, + tags, + backgroundColor, + accountViewModel, + nav, + ) + }, + clickablePrepend = {}, + accountViewModel = accountViewModel, + nav = nav, + ) + } else { + ZapVote( + baseNote, + poolOption, + pollViewModel = pollViewModel, + nonClickablePrepend = {}, + clickablePrepend = { + RenderOptionBeforeVote( + poolOption.descriptor, + canPreview, + tags, + backgroundColor, + accountViewModel, + nav, + ) + }, + accountViewModel = accountViewModel, + nav = nav, + ) } + } } @Composable private fun RenderOptionAfterVote( - description: String, - totalRatio: Float, - color: Color, - canPreview: Boolean, - tags: ImmutableListOfLists, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + description: String, + totalRatio: Float, + color: Color, + canPreview: Boolean, + tags: ImmutableListOfLists, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val totalPercentage = remember(totalRatio) { - "${(totalRatio * 100).roundToInt()}%" - } + val totalPercentage = remember(totalRatio) { "${(totalRatio * 100).roundToInt()}%" } - Box( - Modifier - .fillMaxWidth(0.75f) - .clip(shape = QuoteBorder) - .border( - 2.dp, - color, - QuoteBorder - ) + Box( + Modifier.fillMaxWidth(0.75f) + .clip(shape = QuoteBorder) + .border( + 2.dp, + color, + QuoteBorder, + ), + ) { + LinearProgressIndicator( + modifier = Modifier.matchParentSize(), + color = color, + progress = totalRatio, + ) + + Row( + verticalAlignment = Alignment.CenterVertically, ) { - LinearProgressIndicator( - modifier = Modifier.matchParentSize(), - color = color, - progress = totalRatio + Column( + horizontalAlignment = Alignment.End, + modifier = remember { Modifier.padding(horizontal = 10.dp).width(40.dp) }, + ) { + Text( + text = totalPercentage, + fontWeight = FontWeight.Bold, ) + } - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Column( - horizontalAlignment = Alignment.End, - modifier = remember { - Modifier - .padding(horizontal = 10.dp) - .width(40.dp) - } - ) { - Text( - text = totalPercentage, - fontWeight = FontWeight.Bold - ) - } - - Column( - modifier = remember { - Modifier - .fillMaxWidth() - .padding(15.dp) - } - ) { - TranslatableRichTextViewer( - description, - canPreview, - remember { Modifier }, - tags, - backgroundColor, - accountViewModel, - nav - ) - } - } + Column( + modifier = remember { Modifier.fillMaxWidth().padding(15.dp) }, + ) { + TranslatableRichTextViewer( + description, + canPreview, + remember { Modifier }, + tags, + backgroundColor, + accountViewModel, + nav, + ) + } } + } } @Composable private fun RenderOptionBeforeVote( - description: String, - canPreview: Boolean, - tags: ImmutableListOfLists, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + description: String, + canPreview: Boolean, + tags: ImmutableListOfLists, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - Box( - Modifier - .fillMaxWidth(0.75f) - .clip(shape = QuoteBorder) - .border( - 2.dp, - MaterialTheme.colorScheme.primary, - QuoteBorder - ) - ) { - TranslatableRichTextViewer( - description, - canPreview, - remember { Modifier.padding(15.dp) }, - tags, - backgroundColor, - accountViewModel, - nav - ) - } + Box( + Modifier.fillMaxWidth(0.75f) + .clip(shape = QuoteBorder) + .border( + 2.dp, + MaterialTheme.colorScheme.primary, + QuoteBorder, + ), + ) { + TranslatableRichTextViewer( + description, + canPreview, + remember { Modifier.padding(15.dp) }, + tags, + backgroundColor, + accountViewModel, + nav, + ) + } } @Composable @OptIn(ExperimentalFoundationApi::class) fun ZapVote( - baseNote: Note, - poolOption: PollOption, - modifier: Modifier = Modifier, - pollViewModel: PollNoteViewModel, - nonClickablePrepend: @Composable () -> Unit, - clickablePrepend: @Composable () -> Unit, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + poolOption: PollOption, + modifier: Modifier = Modifier, + pollViewModel: PollNoteViewModel, + nonClickablePrepend: @Composable () -> Unit, + clickablePrepend: @Composable () -> Unit, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val isLoggedUser by remember { - derivedStateOf { - accountViewModel.isLoggedUser(baseNote.author) - } + val isLoggedUser by remember { derivedStateOf { accountViewModel.isLoggedUser(baseNote.author) } } + + var wantsToZap by remember { mutableStateOf(false) } + var wantsToPay by remember { + mutableStateOf>( + persistentListOf(), + ) + } + + var zappingProgress by remember { mutableStateOf(0f) } + var showErrorMessageDialog by remember { mutableStateOf(null) } + + val context = LocalContext.current + val scope = rememberCoroutineScope() + + nonClickablePrepend() + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.combinedClickable( + role = Role.Button, + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false, radius = 24.dp), + onClick = { + if (!accountViewModel.isWriteable()) { + accountViewModel.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_send_zaps, + ) + } else if (pollViewModel.isPollClosed()) { + accountViewModel.toast( + R.string.poll_unable_to_vote, + R.string.poll_is_closed_explainer, + ) + } else if (isLoggedUser) { + accountViewModel.toast( + R.string.poll_unable_to_vote, + R.string.poll_author_no_vote, + ) + } else if (pollViewModel.isVoteAmountAtomic() && poolOption.zappedByLoggedIn) { + // only allow one vote per option when min==max, i.e. atomic vote amount specified + accountViewModel.toast( + R.string.poll_unable_to_vote, + R.string.one_vote_per_user_on_atomic_votes, + ) + return@combinedClickable + } else if ( + accountViewModel.account.zapAmountChoices.size == 1 && + pollViewModel.isValidInputVoteAmount( + accountViewModel.account.zapAmountChoices.first(), + ) + ) { + accountViewModel.zap( + baseNote, + accountViewModel.account.zapAmountChoices.first() * 1000, + poolOption.option, + "", + context, + onError = { title, message -> + zappingProgress = 0f + showErrorMessageDialog = StringToastMsg(title, message) + }, + onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } }, + onPayViaIntent = {}, + zapType = accountViewModel.account.defaultZapType, + ) + } else { + wantsToZap = true + } + }, + ), + ) { + if (wantsToZap) { + FilteredZapAmountChoicePopup( + baseNote, + accountViewModel, + pollViewModel, + poolOption.option, + onDismiss = { + wantsToZap = false + zappingProgress = 0f + }, + onChangeAmount = { wantsToZap = false }, + onError = { title, message -> + showErrorMessageDialog = StringToastMsg(title, message) + zappingProgress = 0f + }, + onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } }, + onPayViaIntent = { wantsToPay = it }, + ) } - var wantsToZap by remember { mutableStateOf(false) } - var wantsToPay by remember { - mutableStateOf>( - persistentListOf() + if (wantsToPay.isNotEmpty()) { + PayViaIntentDialog( + payingInvoices = wantsToPay, + accountViewModel = accountViewModel, + onClose = { wantsToPay = persistentListOf() }, + onError = { + wantsToPay = persistentListOf() + scope.launch { + zappingProgress = 0f + showErrorMessageDialog = + StringToastMsg( + context.getString(R.string.error_dialog_zap_error), + it, + ) + } + }, + ) + } + + showErrorMessageDialog?.let { toast -> + ErrorMessageDialog( + title = toast.title, + textContent = toast.msg, + onClickStartMessage = { + baseNote.author?.let { nav(routeToMessage(it, toast.msg, accountViewModel)) } + }, + onDismiss = { showErrorMessageDialog = null }, + ) + } + + clickablePrepend() + + if (poolOption.zappedByLoggedIn) { + zappingProgress = 1f + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = stringResource(R.string.zaps), + modifier = Modifier.size(20.dp), + tint = BitcoinOrange, + ) + } else { + if (zappingProgress < 0.1 || zappingProgress > 0.99) { + Icon( + imageVector = Icons.Outlined.Bolt, + contentDescription = stringResource(id = R.string.zaps), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.placeholderText, ) - } - - var zappingProgress by remember { mutableStateOf(0f) } - var showErrorMessageDialog by remember { mutableStateOf(null) } - - val context = LocalContext.current - val scope = rememberCoroutineScope() - - nonClickablePrepend() - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.combinedClickable( - role = Role.Button, - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false, radius = 24.dp), - onClick = { - if (!accountViewModel.isWriteable()) { - accountViewModel.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_send_zaps - ) - } else if (pollViewModel.isPollClosed()) { - accountViewModel.toast( - R.string.poll_unable_to_vote, - R.string.poll_is_closed_explainer - ) - } else if (isLoggedUser) { - accountViewModel.toast( - R.string.poll_unable_to_vote, - R.string.poll_author_no_vote - ) - } else if (pollViewModel.isVoteAmountAtomic() && poolOption.zappedByLoggedIn) { - // only allow one vote per option when min==max, i.e. atomic vote amount specified - accountViewModel.toast( - R.string.poll_unable_to_vote, - R.string.one_vote_per_user_on_atomic_votes - ) - return@combinedClickable - } else if (accountViewModel.account.zapAmountChoices.size == 1 && - pollViewModel.isValidInputVoteAmount(accountViewModel.account.zapAmountChoices.first()) - ) { - accountViewModel.zap( - baseNote, - accountViewModel.account.zapAmountChoices.first() * 1000, - poolOption.option, - "", - context, - onError = { title, message -> - zappingProgress = 0f - showErrorMessageDialog = StringToastMsg(title, message) - }, - onProgress = { - scope.launch(Dispatchers.Main) { - zappingProgress = it - } - }, - onPayViaIntent = { - }, - zapType = accountViewModel.account.defaultZapType - ) - } else { - wantsToZap = true - } - } + } else { + Spacer(Modifier.width(3.dp)) + CircularProgressIndicator( + progress = zappingProgress, + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, ) - ) { - if (wantsToZap) { - FilteredZapAmountChoicePopup( - baseNote, - accountViewModel, - pollViewModel, - poolOption.option, - onDismiss = { - wantsToZap = false - zappingProgress = 0f - }, - onChangeAmount = { - wantsToZap = false - }, - onError = { title, message -> - showErrorMessageDialog = StringToastMsg(title, message) - zappingProgress = 0f - }, - onProgress = { - scope.launch(Dispatchers.Main) { - zappingProgress = it - } - }, - onPayViaIntent = { - wantsToPay = it - } - ) - } - - if (wantsToPay.isNotEmpty()) { - PayViaIntentDialog( - payingInvoices = wantsToPay, - accountViewModel = accountViewModel, - onClose = { - wantsToPay = persistentListOf() - }, - onError = { - wantsToPay = persistentListOf() - scope.launch { - zappingProgress = 0f - showErrorMessageDialog = StringToastMsg( - context.getString(R.string.error_dialog_zap_error), - it - ) - } - } - ) - } - - showErrorMessageDialog?.let { toast -> - ErrorMessageDialog( - title = toast.title, - textContent = toast.msg, - onClickStartMessage = { - baseNote.author?.let { - nav(routeToMessage(it, toast.msg, accountViewModel)) - } - }, - onDismiss = { showErrorMessageDialog = null } - ) - } - - clickablePrepend() - - if (poolOption.zappedByLoggedIn) { - zappingProgress = 1f - Icon( - imageVector = Icons.Default.Bolt, - contentDescription = stringResource(R.string.zaps), - modifier = Modifier.size(20.dp), - tint = BitcoinOrange - ) - } else { - if (zappingProgress < 0.1 || zappingProgress > 0.99) { - Icon( - imageVector = Icons.Outlined.Bolt, - contentDescription = stringResource(id = R.string.zaps), - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.placeholderText - ) - } else { - Spacer(Modifier.width(3.dp)) - CircularProgressIndicator( - progress = zappingProgress, - modifier = Modifier.size(14.dp), - strokeWidth = 2.dp - ) - } - } + } } + } - // only show tallies after a user has zapped note - if (!pollViewModel.canZap()) { - val amountStr = remember(poolOption.zappedValue) { - showAmount(poolOption.zappedValue) - } - Text( - text = amountStr, - fontSize = Font14SP, - color = MaterialTheme.colorScheme.placeholderText, - modifier = modifier - ) - } + // only show tallies after a user has zapped note + if (!pollViewModel.canZap()) { + val amountStr = remember(poolOption.zappedValue) { showAmount(poolOption.zappedValue) } + Text( + text = amountStr, + fontSize = Font14SP, + color = MaterialTheme.colorScheme.placeholderText, + modifier = modifier, + ) + } } @OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) @Composable fun FilteredZapAmountChoicePopup( - baseNote: Note, - accountViewModel: AccountViewModel, - pollViewModel: PollNoteViewModel, - pollOption: Int, - onDismiss: () -> Unit, - onChangeAmount: () -> Unit, - onError: (title: String, text: String) -> Unit, - onProgress: (percent: Float) -> Unit, - onPayViaIntent: (ImmutableList) -> Unit + baseNote: Note, + accountViewModel: AccountViewModel, + pollViewModel: PollNoteViewModel, + pollOption: Int, + onDismiss: () -> Unit, + onChangeAmount: () -> Unit, + onError: (title: String, text: String) -> Unit, + onProgress: (percent: Float) -> Unit, + onPayViaIntent: (ImmutableList) -> Unit, ) { - val context = LocalContext.current + val context = LocalContext.current - val accountState by accountViewModel.accountLiveData.observeAsState() - val defaultZapType by remember(accountState) { - derivedStateOf { - accountState?.account?.defaultZapType ?: LnZapEvent.ZapType.PRIVATE - } - } - - val zapMessage = "" - - val sortedOptions = remember(accountState) { - pollViewModel.createZapOptionsThatMatchThePollingParameters() - } - - Popup( - alignment = Alignment.BottomCenter, - offset = IntOffset(0, -100), - onDismissRequest = { onDismiss() } - ) { - FlowRow(horizontalArrangement = Arrangement.Center) { - sortedOptions.forEach { amountInSats -> - val zapAmount = remember { - "โšก ${showAmount(amountInSats.toBigDecimal().setScale(1))}" - } - - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = { - accountViewModel.zap( - baseNote, - amountInSats * 1000, - pollOption, - zapMessage, - context, - onError, - onProgress, - onPayViaIntent, - defaultZapType - ) - onDismiss() - }, - shape = ButtonBorder, - colors = ButtonDefaults - .buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ) - ) { - Text( - text = zapAmount, - color = Color.White, - textAlign = TextAlign.Center, - modifier = Modifier.combinedClickable( - onClick = { - accountViewModel.zap( - baseNote, - amountInSats * 1000, - pollOption, - zapMessage, - context, - onError, - onProgress, - onPayViaIntent, - defaultZapType - ) - onDismiss() - }, - onLongClick = { - onChangeAmount() - } - ) - ) - } - } + val accountState by accountViewModel.accountLiveData.observeAsState() + val defaultZapType by + remember(accountState) { + derivedStateOf { accountState?.account?.defaultZapType ?: LnZapEvent.ZapType.PRIVATE } + } + + val zapMessage = "" + + val sortedOptions = + remember(accountState) { pollViewModel.createZapOptionsThatMatchThePollingParameters() } + + Popup( + alignment = Alignment.BottomCenter, + offset = IntOffset(0, -100), + onDismissRequest = { onDismiss() }, + ) { + FlowRow(horizontalArrangement = Arrangement.Center) { + sortedOptions.forEach { amountInSats -> + val zapAmount = remember { "โšก ${showAmount(amountInSats.toBigDecimal().setScale(1))}" } + + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = { + accountViewModel.zap( + baseNote, + amountInSats * 1000, + pollOption, + zapMessage, + context, + onError, + onProgress, + onPayViaIntent, + defaultZapType, + ) + onDismiss() + }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text( + text = zapAmount, + color = Color.White, + textAlign = TextAlign.Center, + modifier = + Modifier.combinedClickable( + onClick = { + accountViewModel.zap( + baseNote, + amountInSats * 1000, + pollOption, + zapMessage, + context, + onError, + onProgress, + onPayViaIntent, + defaultZapType, + ) + onDismiss() + }, + onLongClick = { onChangeAmount() }, + ), + ) } + } } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt index e43e60b93..d28e003cf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.note import androidx.compose.runtime.Immutable @@ -15,216 +35,247 @@ import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.VALUE_MAXIMUM import com.vitorpamplona.quartz.events.VALUE_MINIMUM import com.vitorpamplona.quartz.utils.TimeUtils +import java.math.BigDecimal +import java.math.RoundingMode import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import java.math.BigDecimal -import java.math.RoundingMode @Immutable data class PollOption( - val option: Int, - val descriptor: String, - val zappedValue: BigDecimal, - val tally: BigDecimal, - val consensusThreadhold: Boolean, - val zappedByLoggedIn: Boolean + val option: Int, + val descriptor: String, + val zappedValue: BigDecimal, + val tally: BigDecimal, + val consensusThreadhold: Boolean, + val zappedByLoggedIn: Boolean, ) @Stable class PollNoteViewModel : ViewModel() { - private var account: Account? = null - private var pollNote: Note? = null + private var account: Account? = null + private var pollNote: Note? = null - private var pollEvent: PollNoteEvent? = null - private var pollOptions: Map? = null - private var valueMaximum: Long? = null - private var valueMinimum: Long? = null - private var valueMaximumBD: BigDecimal? = null - private var valueMinimumBD: BigDecimal? = null + private var pollEvent: PollNoteEvent? = null + private var pollOptions: Map? = null + private var valueMaximum: Long? = null + private var valueMinimum: Long? = null + private var valueMaximumBD: BigDecimal? = null + private var valueMinimumBD: BigDecimal? = null - private var closedAt: Long? = null - private var consensusThreshold: BigDecimal? = null + private var closedAt: Long? = null + private var consensusThreshold: BigDecimal? = null - private var totalZapped: BigDecimal = BigDecimal.ZERO - private var wasZappedByLoggedInAccount: Boolean = false + private var totalZapped: BigDecimal = BigDecimal.ZERO + private var wasZappedByLoggedInAccount: Boolean = false - private val _tallies = MutableStateFlow>(emptyList()) - val tallies = _tallies.asStateFlow() + private val _tallies = MutableStateFlow>(emptyList()) + val tallies = _tallies.asStateFlow() - fun load(acc: Account, note: Note?) { - account = acc - pollNote = note - pollEvent = pollNote?.event as PollNoteEvent - pollOptions = pollEvent?.pollOptions() - valueMaximum = pollEvent?.getTagLong(VALUE_MAXIMUM) - valueMinimum = pollEvent?.getTagLong(VALUE_MINIMUM) - valueMinimumBD = valueMinimum?.let { BigDecimal(it) } - valueMaximumBD = valueMaximum?.let { BigDecimal(it) } - consensusThreshold = pollEvent?.getTagLong(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal() - closedAt = pollEvent?.getTagLong(CLOSED_AT) - } + fun load( + acc: Account, + note: Note?, + ) { + account = acc + pollNote = note + pollEvent = pollNote?.event as PollNoteEvent + pollOptions = pollEvent?.pollOptions() + valueMaximum = pollEvent?.getTagLong(VALUE_MAXIMUM) + valueMinimum = pollEvent?.getTagLong(VALUE_MINIMUM) + valueMinimumBD = valueMinimum?.let { BigDecimal(it) } + valueMaximumBD = valueMaximum?.let { BigDecimal(it) } + consensusThreshold = + pollEvent?.getTagLong(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal() + closedAt = pollEvent?.getTagLong(CLOSED_AT) + } - fun refreshTallies() { - viewModelScope.launch(Dispatchers.Default) { - totalZapped = totalZapped() - wasZappedByLoggedInAccount = false - account?.calculateIfNoteWasZappedByAccount(pollNote) { - wasZappedByLoggedInAccount = true + fun refreshTallies() { + viewModelScope.launch(Dispatchers.Default) { + totalZapped = totalZapped() + wasZappedByLoggedInAccount = false + account?.calculateIfNoteWasZappedByAccount(pollNote) { wasZappedByLoggedInAccount = true } + + val newOptions = + pollOptions?.keys?.map { option -> + val zappedInOption = zappedPollOptionAmount(option) + + val myTally = + if (totalZapped.compareTo(BigDecimal.ZERO) > 0) { + zappedInOption.divide(totalZapped, 2, RoundingMode.HALF_UP) + } else { + BigDecimal.ZERO } - val newOptions = pollOptions?.keys?.map { option -> - val zappedInOption = zappedPollOptionAmount(option) + val cachedZappedByLoggedIn = + account?.userProfile()?.let { it1 -> cachedIsPollOptionZappedBy(option, it1) } ?: false - val myTally = if (totalZapped.compareTo(BigDecimal.ZERO) > 0) { - zappedInOption.divide(totalZapped, 2, RoundingMode.HALF_UP) - } else { - BigDecimal.ZERO - } + val consensus = consensusThreshold != null && myTally >= consensusThreshold!! - val cachedZappedByLoggedIn = account?.userProfile()?.let { it1 -> cachedIsPollOptionZappedBy(option, it1) } ?: false - - val consensus = consensusThreshold != null && myTally >= consensusThreshold!! - - PollOption(option, pollOptions?.get(option) ?: "", zappedInOption, myTally, consensus, cachedZappedByLoggedIn) - } - - _tallies.emit( - newOptions ?: emptyList() - ) + PollOption( + option, + pollOptions?.get(option) ?: "", + zappedInOption, + myTally, + consensus, + cachedZappedByLoggedIn, + ) } + + _tallies.emit( + newOptions ?: emptyList(), + ) } + } - fun canZap(): Boolean { - val account = account ?: return false - val note = pollNote ?: return false - return account.userProfile() != note.author && !wasZappedByLoggedInAccount - } + fun canZap(): Boolean { + val account = account ?: return false + val note = pollNote ?: return false + return account.userProfile() != note.author && !wasZappedByLoggedInAccount + } - fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum + fun isVoteAmountAtomic() = + valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum - fun isPollClosed(): Boolean = closedAt?.let { // allow 2 minute leeway for zap to propagate - pollNote?.createdAt()?.plus(it * (86400 + 120))!! < TimeUtils.now() + fun isPollClosed(): Boolean = + closedAt?.let { // allow 2 minute leeway for zap to propagate + pollNote?.createdAt()?.plus(it * (86400 + 120))!! < TimeUtils.now() } == true - fun voteAmountPlaceHolderText(sats: String): String = if (valueMinimum == null && valueMaximum == null) { - sats + fun voteAmountPlaceHolderText(sats: String): String = + if (valueMinimum == null && valueMaximum == null) { + sats } else if (valueMinimum == null) { - "1โ€”$valueMaximum $sats" + "1โ€”$valueMaximum $sats" } else if (valueMaximum == null) { - ">$valueMinimum $sats" + ">$valueMinimum $sats" } else { - "$valueMinimumโ€”$valueMaximum $sats" + "$valueMinimumโ€”$valueMaximum $sats" } - fun inputVoteAmountLong(textAmount: String) = if (textAmount.isEmpty()) { null } else { - try { - textAmount.toLong() - } catch (e: Exception) { null } + fun inputVoteAmountLong(textAmount: String) = + if (textAmount.isEmpty()) { + null + } else { + try { + textAmount.toLong() + } catch (e: Exception) { + null + } } - fun isValidInputVoteAmount(amount: BigDecimal?): Boolean { - if (amount == null) { - return false - } else if (valueMinimum == null && valueMaximum == null) { - if (amount > BigDecimal.ZERO) { - return true - } - } else if (valueMinimum == null) { - if (amount > BigDecimal.ZERO && amount <= valueMaximumBD!!) { - return true - } - } else if (valueMaximum == null) { - if (amount >= valueMinimumBD!!) { - return true - } - } else { - if ((valueMinimumBD!! <= amount) && (amount <= valueMaximumBD!!)) { - return true - } + fun isValidInputVoteAmount(amount: BigDecimal?): Boolean { + if (amount == null) { + return false + } else if (valueMinimum == null && valueMaximum == null) { + if (amount > BigDecimal.ZERO) { + return true + } + } else if (valueMinimum == null) { + if (amount > BigDecimal.ZERO && amount <= valueMaximumBD!!) { + return true + } + } else if (valueMaximum == null) { + if (amount >= valueMinimumBD!!) { + return true + } + } else { + if ((valueMinimumBD!! <= amount) && (amount <= valueMaximumBD!!)) { + return true + } + } + return false + } + + fun isValidInputVoteAmount(amount: Long?): Boolean { + if (amount == null) { + return false + } else if (valueMinimum == null && valueMaximum == null) { + if (amount > 0) { + return true + } + } else if (valueMinimum == null) { + if (amount > 0 && amount <= valueMaximum!!) { + return true + } + } else if (valueMaximum == null) { + if (amount >= valueMinimum!!) { + return true + } + } else { + if ((valueMinimum!! <= amount) && (amount <= valueMaximum!!)) { + return true + } + } + return false + } + + fun isPollOptionZappedBy( + option: Int, + user: User, + onWasZappedByAuthor: () -> Unit, + ) { + pollNote?.isZappedBy(option, user, account!!, onWasZappedByAuthor) + } + + fun cachedIsPollOptionZappedBy( + option: Int, + user: User, + ): Boolean { + return pollNote!!.zaps.any { + val zapEvent = it.value?.event as? LnZapEvent + val privateZapAuthor = (it.key.event as? LnZapRequestEvent)?.cachedPrivateZap() + zapEvent?.zappedPollOption() == option && + (it.key.author?.pubkeyHex == user.pubkeyHex || privateZapAuthor?.pubKey == user.pubkeyHex) + } + } + + private fun zappedPollOptionAmount(option: Int): BigDecimal { + return pollNote?.zaps?.values?.sumOf { + val event = it?.event as? LnZapEvent + val zapAmount = event?.amount ?: BigDecimal.ZERO + val isValidAmount = isValidInputVoteAmount(event?.amount) + + if (isValidAmount && event?.zappedPollOption() == option) { + zapAmount + } else { + BigDecimal.ZERO + } + } + ?: BigDecimal.ZERO + } + + private fun totalZapped(): BigDecimal { + return pollNote?.zaps?.values?.sumOf { + val zapEvent = (it?.event as? LnZapEvent) + val zapAmount = zapEvent?.amount ?: BigDecimal.ZERO + val isValidAmount = isValidInputVoteAmount(zapEvent?.amount) + + if (isValidAmount && zapEvent?.zappedPollOption() != null) { + zapAmount + } else { + BigDecimal.ZERO + } + } + ?: BigDecimal.ZERO + } + + fun createZapOptionsThatMatchThePollingParameters(): List { + val options = + account?.zapAmountChoices?.filter { isValidInputVoteAmount(it) }?.toMutableList() + ?: mutableListOf() + if (options.isEmpty()) { + valueMinimum?.let { minimum -> + valueMaximum?.let { maximum -> + if (minimum != maximum) { + options.add(((minimum + maximum) / 2).toLong()) + } } - return false + } } + valueMinimum?.let { options.add(it) } + valueMaximum?.let { options.add(it) } - fun isValidInputVoteAmount(amount: Long?): Boolean { - if (amount == null) { - return false - } else if (valueMinimum == null && valueMaximum == null) { - if (amount > 0) { - return true - } - } else if (valueMinimum == null) { - if (amount > 0 && amount <= valueMaximum!!) { - return true - } - } else if (valueMaximum == null) { - if (amount >= valueMinimum!!) { - return true - } - } else { - if ((valueMinimum!! <= amount) && (amount <= valueMaximum!!)) { - return true - } - } - return false - } - - fun isPollOptionZappedBy(option: Int, user: User, onWasZappedByAuthor: () -> Unit) { - pollNote?.isZappedBy(option, user, account!!, onWasZappedByAuthor) - } - - fun cachedIsPollOptionZappedBy(option: Int, user: User): Boolean { - return pollNote!!.zaps - .any { - val zapEvent = it.value?.event as? LnZapEvent - val privateZapAuthor = (it.key.event as? LnZapRequestEvent)?.cachedPrivateZap() - zapEvent?.zappedPollOption() == option && (it.key.author?.pubkeyHex == user.pubkeyHex || privateZapAuthor?.pubKey == user.pubkeyHex) - } - } - - private fun zappedPollOptionAmount(option: Int): BigDecimal { - return pollNote?.zaps?.values?.sumOf { - val event = it?.event as? LnZapEvent - val zapAmount = event?.amount ?: BigDecimal.ZERO - val isValidAmount = isValidInputVoteAmount(event?.amount) - - if (isValidAmount && event?.zappedPollOption() == option) { - zapAmount - } else { - BigDecimal.ZERO - } - } ?: BigDecimal.ZERO - } - - private fun totalZapped(): BigDecimal { - return pollNote?.zaps?.values?.sumOf { - val zapEvent = (it?.event as? LnZapEvent) - val zapAmount = zapEvent?.amount ?: BigDecimal.ZERO - val isValidAmount = isValidInputVoteAmount(zapEvent?.amount) - - if (isValidAmount && zapEvent?.zappedPollOption() != null) { - zapAmount - } else { - BigDecimal.ZERO - } - } ?: BigDecimal.ZERO - } - - fun createZapOptionsThatMatchThePollingParameters(): List { - val options = account?.zapAmountChoices?.filter { isValidInputVoteAmount(it) }?.toMutableList() ?: mutableListOf() - if (options.isEmpty()) { - valueMinimum?.let { minimum -> - valueMaximum?.let { maximum -> - if (minimum != maximum) { - options.add(((minimum + maximum) / 2).toLong()) - } - } - } - } - valueMinimum?.let { options.add(it) } - valueMaximum?.let { options.add(it) } - - return options.toSet().sorted() - } + return options.toSet().sorted() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PubKeyFormatter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PubKeyFormatter.kt index 8fb415f84..ae68b9680 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PubKeyFormatter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PubKeyFormatter.kt @@ -1,17 +1,37 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.note import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.toHexKey fun ByteArray.toShortenHex(): String { - return toHexKey().toShortenHex() + return toHexKey().toShortenHex() } fun String.toShortenHex(): String { - if (length <= 16) return this - return replaceRange(8, length - 8, ":") + if (length <= 16) return this + return replaceRange(8, length - 8, ":") } fun HexKey.toDisplayHexKey(): String { - return this.toShortenHex() + return this.toShortenHex() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 670a9c160..ebfec3ca8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.note import android.content.Context @@ -108,6 +128,10 @@ import com.vitorpamplona.amethyst.ui.theme.TinyBorders import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderTextColorFilter +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.DecimalFormat +import kotlin.math.roundToInt import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf @@ -116,1308 +140,1279 @@ import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import java.math.BigDecimal -import java.math.RoundingMode -import java.text.DecimalFormat -import kotlin.math.roundToInt @Composable fun ReactionsRow( - baseNote: Note, - showReactionDetail: Boolean, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + showReactionDetail: Boolean, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val wantsToSeeReactions = remember(baseNote) { - mutableStateOf(false) - } + val wantsToSeeReactions = remember(baseNote) { mutableStateOf(false) } + Spacer(modifier = HalfDoubleVertSpacer) + + InnerReactionRow(baseNote, showReactionDetail, wantsToSeeReactions, accountViewModel, nav) + + Spacer(modifier = HalfDoubleVertSpacer) + + LoadAndDisplayZapraiser(baseNote, showReactionDetail, wantsToSeeReactions, accountViewModel) + + if (showReactionDetail && wantsToSeeReactions.value) { + ReactionDetailGallery(baseNote, nav, accountViewModel) Spacer(modifier = HalfDoubleVertSpacer) - - InnerReactionRow(baseNote, showReactionDetail, wantsToSeeReactions, accountViewModel, nav) - - Spacer(modifier = HalfDoubleVertSpacer) - - LoadAndDisplayZapraiser(baseNote, showReactionDetail, wantsToSeeReactions, accountViewModel) - - if (showReactionDetail && wantsToSeeReactions.value) { - ReactionDetailGallery(baseNote, nav, accountViewModel) - Spacer(modifier = HalfDoubleVertSpacer) - } + } } @Composable private fun InnerReactionRow( - baseNote: Note, - showReactionDetail: Boolean, - wantsToSeeReactions: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + showReactionDetail: Boolean, + wantsToSeeReactions: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - GenericInnerReactionRow( - showReactionDetail = showReactionDetail, - one = { - WatchReactionsZapsBoostsAndDisplayIfExists(baseNote) { - RenderShowIndividualReactionsButton(wantsToSeeReactions) - } - }, - two = { - ReplyReactionWithDialog(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav) - }, - three = { - BoostWithDialog( - baseNote, - MaterialTheme.colorScheme.placeholderText, - accountViewModel, - nav - ) - }, - four = { - LikeReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav) - }, - five = { - ZapReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav) - }, - six = { - ViewCountReaction( - note = baseNote, - grayTint = MaterialTheme.colorScheme.placeholderText, - viewCountColorFilter = MaterialTheme.colorScheme.placeholderTextColorFilter - ) - } - ) + GenericInnerReactionRow( + showReactionDetail = showReactionDetail, + one = { + WatchReactionsZapsBoostsAndDisplayIfExists(baseNote) { + RenderShowIndividualReactionsButton(wantsToSeeReactions) + } + }, + two = { + ReplyReactionWithDialog( + baseNote, + MaterialTheme.colorScheme.placeholderText, + accountViewModel, + nav, + ) + }, + three = { + BoostWithDialog( + baseNote, + MaterialTheme.colorScheme.placeholderText, + accountViewModel, + nav, + ) + }, + four = { + LikeReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav) + }, + five = { + ZapReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav) + }, + six = { + ViewCountReaction( + note = baseNote, + grayTint = MaterialTheme.colorScheme.placeholderText, + viewCountColorFilter = MaterialTheme.colorScheme.placeholderTextColorFilter, + ) + }, + ) } @Composable private fun GenericInnerReactionRow( - showReactionDetail: Boolean, - one: @Composable () -> Unit, - two: @Composable () -> Unit, - three: @Composable () -> Unit, - four: @Composable () -> Unit, - five: @Composable () -> Unit, - six: @Composable () -> Unit + showReactionDetail: Boolean, + one: @Composable () -> Unit, + two: @Composable () -> Unit, + three: @Composable () -> Unit, + four: @Composable () -> Unit, + five: @Composable () -> Unit, + six: @Composable () -> Unit, ) { - Row(verticalAlignment = CenterVertically, modifier = ReactionRowHeight) { - val fullWeight = remember { Modifier.weight(1f) } + Row(verticalAlignment = CenterVertically, modifier = ReactionRowHeight) { + val fullWeight = remember { Modifier.weight(1f) } - if (showReactionDetail) { - Row( - verticalAlignment = CenterVertically, - modifier = remember { ReactionRowExpandButton.then(fullWeight) } - ) { - one() - } - } - - Row(verticalAlignment = CenterVertically, modifier = fullWeight) { - two() - } - - Row(verticalAlignment = CenterVertically, modifier = fullWeight) { - three() - } - - Row(verticalAlignment = CenterVertically, modifier = fullWeight) { - four() - } - - Row(verticalAlignment = CenterVertically, modifier = fullWeight) { - five() - } - - Row(verticalAlignment = CenterVertically, modifier = fullWeight) { - six() - } + if (showReactionDetail) { + Row( + verticalAlignment = CenterVertically, + modifier = remember { ReactionRowExpandButton.then(fullWeight) }, + ) { + one() + } } + + Row(verticalAlignment = CenterVertically, modifier = fullWeight) { two() } + + Row(verticalAlignment = CenterVertically, modifier = fullWeight) { three() } + + Row(verticalAlignment = CenterVertically, modifier = fullWeight) { four() } + + Row(verticalAlignment = CenterVertically, modifier = fullWeight) { five() } + + Row(verticalAlignment = CenterVertically, modifier = fullWeight) { six() } + } } @Composable private fun LoadAndDisplayZapraiser( - baseNote: Note, - showReactionDetail: Boolean, - wantsToSeeReactions: MutableState, - accountViewModel: AccountViewModel + baseNote: Note, + showReactionDetail: Boolean, + wantsToSeeReactions: MutableState, + accountViewModel: AccountViewModel, ) { - val zapraiserAmount by remember(baseNote) { - derivedStateOf { - baseNote.event?.zapraiserAmount() ?: 0 - } - } + val zapraiserAmount by + remember(baseNote) { derivedStateOf { baseNote.event?.zapraiserAmount() ?: 0 } } - if (zapraiserAmount > 0) { - Box( - modifier = remember { - ReactionRowZapraiserSize - .padding(start = if (showReactionDetail) Size75dp else Size0dp) - }, - contentAlignment = CenterStart - ) { - RenderZapRaiser(baseNote, zapraiserAmount, wantsToSeeReactions.value, accountViewModel) - } - } -} - -@Immutable -data class ZapraiserStatus(val progress: Float, val left: String) - -@Composable -fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, accountViewModel: AccountViewModel) { - val zapsState by baseNote.live().zaps.observeAsState() - - var zapraiserStatus by remember { mutableStateOf(ZapraiserStatus(0F, "$zapraiserAmount")) } - - LaunchedEffect(key1 = zapsState) { - zapsState?.note?.let { - accountViewModel.calculateZapraiser(baseNote) { newStatus -> - if (zapraiserStatus != newStatus) { - zapraiserStatus = newStatus - } - } - } - } - - val color = if (zapraiserStatus.progress > 0.99) { - DarkerGreen - } else { - MaterialTheme.colorScheme.mediumImportanceLink - } - - LinearProgressIndicator( - modifier = remember(details) { - Modifier - .fillMaxWidth() - .height(if (details) 24.dp else 4.dp) + if (zapraiserAmount > 0) { + Box( + modifier = + remember { + ReactionRowZapraiserSize.padding(start = if (showReactionDetail) Size75dp else Size0dp) }, - color = color, - progress = zapraiserStatus.progress - ) - - if (details) { - Box( - contentAlignment = Center, - modifier = TinyBorders - ) { - val totalPercentage by remember(zapraiserStatus) { - derivedStateOf { - "${(zapraiserStatus.progress * 100).roundToInt()}%" - } - } - - Text( - text = stringResource(id = R.string.sats_to_complete, totalPercentage, zapraiserStatus.left), - modifier = NoSoTinyBorders, - color = MaterialTheme.colorScheme.placeholderText, - fontSize = Font14SP, - maxLines = 1 - ) - } + contentAlignment = CenterStart, + ) { + RenderZapRaiser(baseNote, zapraiserAmount, wantsToSeeReactions.value, accountViewModel) } + } +} + +@Immutable data class ZapraiserStatus(val progress: Float, val left: String) + +@Composable +fun RenderZapRaiser( + baseNote: Note, + zapraiserAmount: Long, + details: Boolean, + accountViewModel: AccountViewModel, +) { + val zapsState by baseNote.live().zaps.observeAsState() + + var zapraiserStatus by remember { mutableStateOf(ZapraiserStatus(0F, "$zapraiserAmount")) } + + LaunchedEffect(key1 = zapsState) { + zapsState?.note?.let { + accountViewModel.calculateZapraiser(baseNote) { newStatus -> + if (zapraiserStatus != newStatus) { + zapraiserStatus = newStatus + } + } + } + } + + val color = + if (zapraiserStatus.progress > 0.99) { + DarkerGreen + } else { + MaterialTheme.colorScheme.mediumImportanceLink + } + + LinearProgressIndicator( + modifier = remember(details) { Modifier.fillMaxWidth().height(if (details) 24.dp else 4.dp) }, + color = color, + progress = zapraiserStatus.progress, + ) + + if (details) { + Box( + contentAlignment = Center, + modifier = TinyBorders, + ) { + val totalPercentage by + remember(zapraiserStatus) { + derivedStateOf { "${(zapraiserStatus.progress * 100).roundToInt()}%" } + } + + Text( + text = + stringResource(id = R.string.sats_to_complete, totalPercentage, zapraiserStatus.left), + modifier = NoSoTinyBorders, + color = MaterialTheme.colorScheme.placeholderText, + fontSize = Font14SP, + maxLines = 1, + ) + } + } } @Composable -private fun WatchReactionsZapsBoostsAndDisplayIfExists(baseNote: Note, content: @Composable () -> Unit) { - val hasReactions by baseNote.live().hasReactions.observeAsState( +private fun WatchReactionsZapsBoostsAndDisplayIfExists( + baseNote: Note, + content: @Composable () -> Unit, +) { + val hasReactions by + baseNote + .live() + .hasReactions + .observeAsState( baseNote.zaps.isNotEmpty() || - baseNote.boosts.isNotEmpty() || - baseNote.reactions.isNotEmpty() - ) + baseNote.boosts.isNotEmpty() || + baseNote.reactions.isNotEmpty(), + ) - if (hasReactions) { - content() - } + if (hasReactions) { + content() + } } fun LiveData.combineWith( - liveData1: LiveData, - block: (T?, K?) -> R + liveData1: LiveData, + block: (T?, K?) -> R, ): LiveData { - val result = MediatorLiveData() - result.addSource(this) { - result.value = block(this.value, liveData1.value) - } - result.addSource(liveData1) { - result.value = block(this.value, liveData1.value) - } - return result + val result = MediatorLiveData() + result.addSource(this) { result.value = block(this.value, liveData1.value) } + result.addSource(liveData1) { result.value = block(this.value, liveData1.value) } + return result } fun LiveData.combineWith( - liveData1: LiveData, - liveData2: LiveData

, - block: (T?, K?, P?) -> R + liveData1: LiveData, + liveData2: LiveData

, + block: (T?, K?, P?) -> R, ): LiveData { - val result = MediatorLiveData() - result.addSource(this) { - result.value = block(this.value, liveData1.value, liveData2.value) - } - result.addSource(liveData1) { - result.value = block(this.value, liveData1.value, liveData2.value) - } - result.addSource(liveData2) { - result.value = block(this.value, liveData1.value, liveData2.value) - } - return result + val result = MediatorLiveData() + result.addSource(this) { result.value = block(this.value, liveData1.value, liveData2.value) } + result.addSource(liveData1) { result.value = block(this.value, liveData1.value, liveData2.value) } + result.addSource(liveData2) { result.value = block(this.value, liveData1.value, liveData2.value) } + return result } @Composable private fun RenderShowIndividualReactionsButton(wantsToSeeReactions: MutableState) { - IconButton( - onClick = { - wantsToSeeReactions.value = !wantsToSeeReactions.value - }, - modifier = Size20Modifier + IconButton( + onClick = { wantsToSeeReactions.value = !wantsToSeeReactions.value }, + modifier = Size20Modifier, + ) { + Crossfade( + targetState = wantsToSeeReactions.value, + label = "RenderShowIndividualReactionsButton", ) { - Crossfade(targetState = wantsToSeeReactions.value, label = "RenderShowIndividualReactionsButton") { - if (it) { - ExpandLessIcon(modifier = Size22Modifier) - } else { - ExpandMoreIcon(modifier = Size22Modifier) - } - } + if (it) { + ExpandLessIcon(modifier = Size22Modifier) + } else { + ExpandMoreIcon(modifier = Size22Modifier) + } } + } } @Composable private fun ReactionDetailGallery( - baseNote: Note, - nav: (String) -> Unit, - accountViewModel: AccountViewModel + baseNote: Note, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - val defaultBackgroundColor = MaterialTheme.colorScheme.background - val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } + val defaultBackgroundColor = MaterialTheme.colorScheme.background + val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } - val hasReactions by baseNote.live().hasReactions.observeAsState( - baseNote.zaps.isNotEmpty() || baseNote.boosts.isNotEmpty() || baseNote.reactions.isNotEmpty() - ) + val hasReactions by + baseNote + .live() + .hasReactions + .observeAsState( + baseNote.zaps.isNotEmpty() || + baseNote.boosts.isNotEmpty() || + baseNote.reactions.isNotEmpty(), + ) - if (hasReactions) { - Row(verticalAlignment = CenterVertically, modifier = Modifier.padding(start = 10.dp, top = 5.dp)) { - Column() { - WatchZapAndRenderGallery(baseNote, backgroundColor, nav, accountViewModel) - WatchBoostsAndRenderGallery(baseNote, nav, accountViewModel) - WatchReactionsAndRenderGallery(baseNote, nav, accountViewModel) - } - } + if (hasReactions) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(start = 10.dp, top = 5.dp), + ) { + Column { + WatchZapAndRenderGallery(baseNote, backgroundColor, nav, accountViewModel) + WatchBoostsAndRenderGallery(baseNote, nav, accountViewModel) + WatchReactionsAndRenderGallery(baseNote, nav, accountViewModel) + } } + } } @Composable private fun WatchBoostsAndRenderGallery( - baseNote: Note, - nav: (String) -> Unit, - accountViewModel: AccountViewModel + baseNote: Note, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - val boostsEvents by baseNote.live().boosts.observeAsState() + val boostsEvents by baseNote.live().boosts.observeAsState() - boostsEvents?.let { - if (it.note.boosts.isNotEmpty()) { - RenderBoostGallery( - it, - nav, - accountViewModel - ) - } + boostsEvents?.let { + if (it.note.boosts.isNotEmpty()) { + RenderBoostGallery( + it, + nav, + accountViewModel, + ) } + } } @Composable private fun WatchReactionsAndRenderGallery( - baseNote: Note, - nav: (String) -> Unit, - accountViewModel: AccountViewModel + baseNote: Note, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - val reactionsState by baseNote.live().reactions.observeAsState() - val reactionEvents by remember(reactionsState) { - derivedStateOf { baseNote.reactions.toImmutableMap() } - } + val reactionsState by baseNote.live().reactions.observeAsState() + val reactionEvents by + remember(reactionsState) { derivedStateOf { baseNote.reactions.toImmutableMap() } } - if (reactionEvents.isNotEmpty()) { - reactionEvents.forEach { - val reactions = remember(it.value) { it.value.toImmutableList() } - RenderLikeGallery( - it.key, - reactions, - nav, - accountViewModel - ) - } + if (reactionEvents.isNotEmpty()) { + reactionEvents.forEach { + val reactions = remember(it.value) { it.value.toImmutableList() } + RenderLikeGallery( + it.key, + reactions, + nav, + accountViewModel, + ) } + } } @Composable private fun WatchZapAndRenderGallery( - baseNote: Note, - backgroundColor: MutableState, - nav: (String) -> Unit, - accountViewModel: AccountViewModel + baseNote: Note, + backgroundColor: MutableState, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - val zapsState by baseNote.live().zaps.observeAsState() + val zapsState by baseNote.live().zaps.observeAsState() - var zapEvents by remember(zapsState) { - mutableStateOf( - accountViewModel.cachedDecryptAmountMessageInGroup(baseNote) - ) + var zapEvents by + remember(zapsState) { + mutableStateOf( + accountViewModel.cachedDecryptAmountMessageInGroup(baseNote), + ) } - LaunchedEffect(key1 = zapsState) { - accountViewModel.decryptAmountMessageInGroup(baseNote) { - zapEvents = it - } - } + LaunchedEffect(key1 = zapsState) { + accountViewModel.decryptAmountMessageInGroup(baseNote) { zapEvents = it } + } - if (zapEvents.isNotEmpty()) { - RenderZapGallery( - zapEvents, - backgroundColor, - nav, - accountViewModel - ) - } + if (zapEvents.isNotEmpty()) { + RenderZapGallery( + zapEvents, + backgroundColor, + nav, + accountViewModel, + ) + } } @Composable private fun BoostWithDialog( - baseNote: Note, - grayTint: Color, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + grayTint: Color, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var wantsToQuote by remember { - mutableStateOf(null) - } + var wantsToQuote by remember { mutableStateOf(null) } - if (wantsToQuote != null) { - NewPostView( - onClose = { wantsToQuote = null }, - baseReplyTo = null, - quote = wantsToQuote, - accountViewModel = accountViewModel, - nav = nav - ) - } + if (wantsToQuote != null) { + NewPostView( + onClose = { wantsToQuote = null }, + baseReplyTo = null, + quote = wantsToQuote, + accountViewModel = accountViewModel, + nav = nav, + ) + } - BoostReaction(baseNote, grayTint, accountViewModel) { - wantsToQuote = baseNote - } + BoostReaction(baseNote, grayTint, accountViewModel) { wantsToQuote = baseNote } } @Composable private fun ReplyReactionWithDialog( - baseNote: Note, - grayTint: Color, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + grayTint: Color, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var wantsToReplyTo by remember { - mutableStateOf(null) - } + var wantsToReplyTo by remember { mutableStateOf(null) } - if (wantsToReplyTo != null) { - NewPostView( - onClose = { wantsToReplyTo = null }, - baseReplyTo = wantsToReplyTo, - quote = null, - accountViewModel = accountViewModel, - nav = nav - ) - } + if (wantsToReplyTo != null) { + NewPostView( + onClose = { wantsToReplyTo = null }, + baseReplyTo = wantsToReplyTo, + quote = null, + accountViewModel = accountViewModel, + nav = nav, + ) + } - ReplyReaction(baseNote, grayTint, accountViewModel) { - wantsToReplyTo = baseNote - } + ReplyReaction(baseNote, grayTint, accountViewModel) { wantsToReplyTo = baseNote } } @Composable fun ReplyReaction( - baseNote: Note, - grayTint: Color, - accountViewModel: AccountViewModel, - showCounter: Boolean = true, - iconSizeModifier: Modifier = Size17Modifier, - onPress: () -> Unit + baseNote: Note, + grayTint: Color, + accountViewModel: AccountViewModel, + showCounter: Boolean = true, + iconSizeModifier: Modifier = Size17Modifier, + onPress: () -> Unit, ) { - IconButton( - modifier = iconSizeModifier, - onClick = { - if (accountViewModel.isWriteable()) { - onPress() - } else { - accountViewModel.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_reply - ) - } - } - ) { - CommentIcon(iconSizeModifier, grayTint) - } + IconButton( + modifier = iconSizeModifier, + onClick = { + if (accountViewModel.isWriteable()) { + onPress() + } else { + accountViewModel.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_reply, + ) + } + }, + ) { + CommentIcon(iconSizeModifier, grayTint) + } - if (showCounter) { - ReplyCounter(baseNote, grayTint) - } + if (showCounter) { + ReplyCounter(baseNote, grayTint) + } } @Composable -fun ReplyCounter(baseNote: Note, textColor: Color) { - val repliesState by baseNote.live().replyCount.observeAsState(baseNote.replies.size) +fun ReplyCounter( + baseNote: Note, + textColor: Color, +) { + val repliesState by baseNote.live().replyCount.observeAsState(baseNote.replies.size) - SlidingAnimationCount(repliesState, textColor) + SlidingAnimationCount(repliesState, textColor) } @Composable -private fun SlidingAnimationCount(baseCount: Int, textColor: Color) { - AnimatedContent( - targetState = baseCount, - transitionSpec = AnimatedContentTransitionScope::transitionSpec, - label = "SlidingAnimationCount" - ) { count -> - TextCount(count, textColor) - } +private fun SlidingAnimationCount( + baseCount: Int, + textColor: Color, +) { + AnimatedContent( + targetState = baseCount, + transitionSpec = AnimatedContentTransitionScope::transitionSpec, + label = "SlidingAnimationCount", + ) { count -> + TextCount(count, textColor) + } } @OptIn(ExperimentalAnimationApi::class) private fun AnimatedContentTransitionScope.transitionSpec(): ContentTransform { - return slideAnimation + return slideAnimation } @ExperimentalAnimationApi val slideAnimation: ContentTransform = - ( - slideInVertically(animationSpec = tween(durationMillis = 100)) { height -> height } + fadeIn( - animationSpec = tween(durationMillis = 100) - ) - ).togetherWith( - slideOutVertically(animationSpec = tween(durationMillis = 100)) { height -> -height } + fadeOut( - animationSpec = tween(durationMillis = 100) - ) + (slideInVertically(animationSpec = tween(durationMillis = 100)) { height -> height } + + fadeIn( + animationSpec = tween(durationMillis = 100), + )) + .togetherWith( + slideOutVertically(animationSpec = tween(durationMillis = 100)) { height -> -height } + + fadeOut( + animationSpec = tween(durationMillis = 100), + ), ) @Composable -fun TextCount(count: Int, textColor: Color) { - Text( - text = showCount(count), - fontSize = Font14SP, - color = textColor, - modifier = HalfStartPadding, - maxLines = 1 - ) +fun TextCount( + count: Int, + textColor: Color, +) { + Text( + text = showCount(count), + fontSize = Font14SP, + color = textColor, + modifier = HalfStartPadding, + maxLines = 1, + ) } @Composable -private fun SlidingAnimationAmount(amount: MutableState, textColor: Color) { - AnimatedContent( - targetState = amount.value, - transitionSpec = AnimatedContentTransitionScope::transitionSpec, - label = "SlidingAnimationAmount" - ) { count -> - Text( - text = count, - fontSize = Font14SP, - color = textColor, - maxLines = 1 - ) - } +private fun SlidingAnimationAmount( + amount: MutableState, + textColor: Color, +) { + AnimatedContent( + targetState = amount.value, + transitionSpec = AnimatedContentTransitionScope::transitionSpec, + label = "SlidingAnimationAmount", + ) { count -> + Text( + text = count, + fontSize = Font14SP, + color = textColor, + maxLines = 1, + ) + } } @Composable fun BoostReaction( - baseNote: Note, - grayTint: Color, - accountViewModel: AccountViewModel, - iconSizeModifier: Modifier = Size20Modifier, - iconSize: Dp = Size20dp, - onQuotePress: () -> Unit + baseNote: Note, + grayTint: Color, + accountViewModel: AccountViewModel, + iconSizeModifier: Modifier = Size20Modifier, + iconSize: Dp = Size20dp, + onQuotePress: () -> Unit, ) { - var wantsToBoost by remember { mutableStateOf(false) } + var wantsToBoost by remember { mutableStateOf(false) } - IconButton( - modifier = iconSizeModifier, - onClick = { - accountViewModel.tryBoost(baseNote) { - wantsToBoost = true - } - } - ) { - ObserveBoostIcon(baseNote, accountViewModel) { hasBoosted -> - RepostedIcon(iconSizeModifier, if (hasBoosted) Color.Unspecified else grayTint) - } - - if (wantsToBoost) { - BoostTypeChoicePopup( - baseNote, - iconSize, - accountViewModel, - onDismiss = { - wantsToBoost = false - }, - onQuote = { - wantsToBoost = false - onQuotePress() - }, - onRepost = { - accountViewModel.boost(baseNote) - } - ) - } + IconButton( + modifier = iconSizeModifier, + onClick = { accountViewModel.tryBoost(baseNote) { wantsToBoost = true } }, + ) { + ObserveBoostIcon(baseNote, accountViewModel) { hasBoosted -> + RepostedIcon(iconSizeModifier, if (hasBoosted) Color.Unspecified else grayTint) } - BoostText(baseNote, grayTint) + if (wantsToBoost) { + BoostTypeChoicePopup( + baseNote, + iconSize, + accountViewModel, + onDismiss = { wantsToBoost = false }, + onQuote = { + wantsToBoost = false + onQuotePress() + }, + onRepost = { accountViewModel.boost(baseNote) }, + ) + } + } + + BoostText(baseNote, grayTint) } @Composable -fun ObserveBoostIcon(baseNote: Note, accountViewModel: AccountViewModel, inner: @Composable (Boolean) -> Unit) { - val hasBoosted by remember(baseNote) { - baseNote.live().boosts.map { - it.note.isBoostedBy(accountViewModel.userProfile()) - }.distinctUntilChanged() - }.observeAsState( - baseNote.isBoostedBy(accountViewModel.userProfile()) - ) +fun ObserveBoostIcon( + baseNote: Note, + accountViewModel: AccountViewModel, + inner: @Composable (Boolean) -> Unit, +) { + val hasBoosted by + remember(baseNote) { + baseNote + .live() + .boosts + .map { it.note.isBoostedBy(accountViewModel.userProfile()) } + .distinctUntilChanged() + } + .observeAsState( + baseNote.isBoostedBy(accountViewModel.userProfile()), + ) - inner(hasBoosted) + inner(hasBoosted) } @Composable -fun BoostText(baseNote: Note, grayTint: Color) { - val boostState by baseNote.live().boostCount.observeAsState(baseNote.boosts.size) +fun BoostText( + baseNote: Note, + grayTint: Color, +) { + val boostState by baseNote.live().boostCount.observeAsState(baseNote.boosts.size) - SlidingAnimationCount(boostState, grayTint) + SlidingAnimationCount(boostState, grayTint) } @OptIn(ExperimentalFoundationApi::class) @Composable fun LikeReaction( - baseNote: Note, - grayTint: Color, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - iconSize: Dp = Size20dp, - heartSizeModifier: Modifier = Size16Modifier, - iconFontSize: TextUnit = Font14SP + baseNote: Note, + grayTint: Color, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + iconSize: Dp = Size20dp, + heartSizeModifier: Modifier = Size16Modifier, + iconFontSize: TextUnit = Font14SP, ) { - var wantsToChangeReactionSymbol by remember { mutableStateOf(false) } - var wantsToReact by remember { mutableStateOf(false) } + var wantsToChangeReactionSymbol by remember { mutableStateOf(false) } + var wantsToReact by remember { mutableStateOf(false) } - Box( - contentAlignment = Center, - modifier = Modifier.size(iconSize).combinedClickable( - role = Role.Button, - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false, radius = Size24dp), - onClick = { - likeClick( - accountViewModel, - onMultipleChoices = { - wantsToReact = true - }, - onWantsToSignReaction = { - accountViewModel.reactToOrDelete(baseNote) - } - ) - }, - onLongClick = { - wantsToChangeReactionSymbol = true - } - ) - ) { - ObserveLikeIcon(baseNote, accountViewModel) { reactionType -> - Crossfade(targetState = reactionType.value, label = "LikeIcon") { - if (it != null) { - RenderReactionType(it, heartSizeModifier, iconFontSize) - } else { - LikeIcon(heartSizeModifier, grayTint) - } - } - } - - if (wantsToChangeReactionSymbol) { - UpdateReactionTypeDialog( - { wantsToChangeReactionSymbol = false }, - accountViewModel = accountViewModel, - nav - ) - } - - if (wantsToReact) { - ReactionChoicePopup( - baseNote, - iconSize, - accountViewModel, - onDismiss = { - wantsToReact = false - }, - onChangeAmount = { - wantsToReact = false - wantsToChangeReactionSymbol = true - } + Box( + contentAlignment = Center, + modifier = + Modifier.size(iconSize) + .combinedClickable( + role = Role.Button, + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false, radius = Size24dp), + onClick = { + likeClick( + accountViewModel, + onMultipleChoices = { wantsToReact = true }, + onWantsToSignReaction = { accountViewModel.reactToOrDelete(baseNote) }, ) + }, + onLongClick = { wantsToChangeReactionSymbol = true }, + ), + ) { + ObserveLikeIcon(baseNote, accountViewModel) { reactionType -> + Crossfade(targetState = reactionType.value, label = "LikeIcon") { + if (it != null) { + RenderReactionType(it, heartSizeModifier, iconFontSize) + } else { + LikeIcon(heartSizeModifier, grayTint) } + } } - ObserveLikeText(baseNote) { reactionCount -> - SlidingAnimationCount(reactionCount, grayTint) + if (wantsToChangeReactionSymbol) { + UpdateReactionTypeDialog( + { wantsToChangeReactionSymbol = false }, + accountViewModel = accountViewModel, + nav, + ) } + + if (wantsToReact) { + ReactionChoicePopup( + baseNote, + iconSize, + accountViewModel, + onDismiss = { wantsToReact = false }, + onChangeAmount = { + wantsToReact = false + wantsToChangeReactionSymbol = true + }, + ) + } + } + + ObserveLikeText(baseNote) { reactionCount -> SlidingAnimationCount(reactionCount, grayTint) } } @Composable fun ObserveLikeIcon( - baseNote: Note, - accountViewModel: AccountViewModel, - inner: @Composable (MutableState) -> Unit + baseNote: Note, + accountViewModel: AccountViewModel, + inner: @Composable (MutableState) -> Unit, ) { - val reactionType = remember(baseNote) { - mutableStateOf(null) + val reactionType = remember(baseNote) { mutableStateOf(null) } + + val reactionsState by baseNote.live().reactions.observeAsState() + + LaunchedEffect(key1 = reactionsState) { + accountViewModel.loadReactionTo(reactionsState?.note) { newReactionType -> + if (reactionType.value != newReactionType) { + reactionType.value = newReactionType + } } + } - val reactionsState by baseNote.live().reactions.observeAsState() - - LaunchedEffect(key1 = reactionsState) { - accountViewModel.loadReactionTo(reactionsState?.note) { newReactionType -> - if (reactionType.value != newReactionType) { - reactionType.value = newReactionType - } - } - } - - inner(reactionType) + inner(reactionType) } @Composable private fun RenderReactionType( - reactionType: String, - iconSizeModifier: Modifier = Size20Modifier, - iconFontSize: TextUnit + reactionType: String, + iconSizeModifier: Modifier = Size20Modifier, + iconFontSize: TextUnit, ) { - if (reactionType.isNotEmpty() && reactionType[0] == ':') { - val renderable = remember(reactionType) { - listOf( - ImageUrlType(reactionType.removePrefix(":").substringAfter(":")) - ).toImmutableList() - } + if (reactionType.isNotEmpty() && reactionType[0] == ':') { + val renderable = + remember(reactionType) { + listOf( + ImageUrlType(reactionType.removePrefix(":").substringAfter(":")), + ) + .toImmutableList() + } - InLineIconRenderer( - renderable, - style = SpanStyle(color = Color.White), - fontSize = iconFontSize, - maxLines = 1 - ) - } else { - when (reactionType) { - "+" -> LikedIcon(iconSizeModifier) - "-" -> Text(text = "\uD83D\uDC4E", fontSize = iconFontSize) - else -> Text(text = reactionType, fontSize = iconFontSize) - } + InLineIconRenderer( + renderable, + style = SpanStyle(color = Color.White), + fontSize = iconFontSize, + maxLines = 1, + ) + } else { + when (reactionType) { + "+" -> LikedIcon(iconSizeModifier) + "-" -> Text(text = "\uD83D\uDC4E", fontSize = iconFontSize) + else -> Text(text = reactionType, fontSize = iconFontSize) } + } } @Composable -fun ObserveLikeText(baseNote: Note, inner: @Composable (Int) -> Unit) { - val reactionCount by baseNote.live().reactionCount.observeAsState(0) +fun ObserveLikeText( + baseNote: Note, + inner: @Composable (Int) -> Unit, +) { + val reactionCount by baseNote.live().reactionCount.observeAsState(0) - inner(reactionCount) + inner(reactionCount) } private fun likeClick( - accountViewModel: AccountViewModel, - onMultipleChoices: () -> Unit, - onWantsToSignReaction: () -> Unit + accountViewModel: AccountViewModel, + onMultipleChoices: () -> Unit, + onWantsToSignReaction: () -> Unit, ) { - if (accountViewModel.account.reactionChoices.isEmpty()) { - accountViewModel.toast( - R.string.no_reactions_setup, - R.string.no_reaction_type_setup_long_press_to_change - ) - } else if (!accountViewModel.isWriteable()) { - accountViewModel.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_like_posts - ) - } else if (accountViewModel.account.reactionChoices.size == 1) { - onWantsToSignReaction() - } else if (accountViewModel.account.reactionChoices.size > 1) { - onMultipleChoices() - } + if (accountViewModel.account.reactionChoices.isEmpty()) { + accountViewModel.toast( + R.string.no_reactions_setup, + R.string.no_reaction_type_setup_long_press_to_change, + ) + } else if (!accountViewModel.isWriteable()) { + accountViewModel.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_like_posts, + ) + } else if (accountViewModel.account.reactionChoices.size == 1) { + onWantsToSignReaction() + } else if (accountViewModel.account.reactionChoices.size > 1) { + onMultipleChoices() + } } @Composable @OptIn(ExperimentalFoundationApi::class) fun ZapReaction( - baseNote: Note, - grayTint: Color, - accountViewModel: AccountViewModel, - iconSize: Dp = Size20dp, - iconSizeModifier: Modifier = Size20Modifier, - animationSize: Dp = 14.dp, - nav: (String) -> Unit + baseNote: Note, + grayTint: Color, + accountViewModel: AccountViewModel, + iconSize: Dp = Size20dp, + iconSizeModifier: Modifier = Size20Modifier, + animationSize: Dp = 14.dp, + nav: (String) -> Unit, ) { - var wantsToZap by remember { mutableStateOf(false) } - var wantsToChangeZapAmount by remember { mutableStateOf(false) } - var wantsToSetCustomZap by remember { mutableStateOf(false) } - var showErrorMessageDialog by remember { mutableStateOf(null) } - var wantsToPay by remember(baseNote) { - mutableStateOf>( - persistentListOf() - ) + var wantsToZap by remember { mutableStateOf(false) } + var wantsToChangeZapAmount by remember { mutableStateOf(false) } + var wantsToSetCustomZap by remember { mutableStateOf(false) } + var showErrorMessageDialog by remember { mutableStateOf(null) } + var wantsToPay by + remember(baseNote) { + mutableStateOf>( + persistentListOf(), + ) } - val context = LocalContext.current - val scope = rememberCoroutineScope() + val context = LocalContext.current + val scope = rememberCoroutineScope() - var zappingProgress by remember { mutableFloatStateOf(0f) } + var zappingProgress by remember { mutableFloatStateOf(0f) } - Row( - verticalAlignment = CenterVertically, - modifier = iconSizeModifier.combinedClickable( - role = Role.Button, - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false, radius = Size24dp), - onClick = { - zapClick( - baseNote, - accountViewModel, - context, - onZappingProgress = { progress: Float -> - scope.launch { - zappingProgress = progress - } - }, - onMultipleChoices = { - wantsToZap = true - }, - onError = { _, message -> - scope.launch { - zappingProgress = 0f - showErrorMessageDialog = message - } - }, - onPayViaIntent = { - wantsToPay = it - } - ) + Row( + verticalAlignment = CenterVertically, + modifier = + iconSizeModifier.combinedClickable( + role = Role.Button, + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false, radius = Size24dp), + onClick = { + zapClick( + baseNote, + accountViewModel, + context, + onZappingProgress = { progress: Float -> scope.launch { zappingProgress = progress } }, + onMultipleChoices = { wantsToZap = true }, + onError = { _, message -> + scope.launch { + zappingProgress = 0f + showErrorMessageDialog = message + } }, - onLongClick = { - wantsToChangeZapAmount = true - }, - onDoubleClick = { - wantsToSetCustomZap = true - } - ) - ) { - if (wantsToZap) { - ZapAmountChoicePopup( - baseNote = baseNote, - iconSize = iconSize, - accountViewModel = accountViewModel, - onDismiss = { - wantsToZap = false - zappingProgress = 0f - }, - onChangeAmount = { - wantsToZap = false - wantsToChangeZapAmount = true - }, - onError = { _, message -> - scope.launch { - zappingProgress = 0f - showErrorMessageDialog = message - } - }, - onProgress = { - scope.launch(Dispatchers.Main) { - zappingProgress = it - } - }, - onPayViaIntent = { - wantsToPay = it - } - ) - } - - if (showErrorMessageDialog != null) { - ErrorMessageDialog( - title = stringResource(id = R.string.error_dialog_zap_error), - textContent = showErrorMessageDialog ?: "", - onClickStartMessage = { - baseNote.author?.let { - scope.launch(Dispatchers.IO) { - val route = routeToMessage(it, showErrorMessageDialog, accountViewModel) - nav(route) - } - } - }, - onDismiss = { showErrorMessageDialog = null } - ) - } - - if (wantsToChangeZapAmount) { - UpdateZapAmountDialog( - onClose = { wantsToChangeZapAmount = false }, - accountViewModel = accountViewModel - ) - } - - if (wantsToPay.isNotEmpty()) { - PayViaIntentDialog( - payingInvoices = wantsToPay, - accountViewModel = accountViewModel, - onClose = { - wantsToPay = persistentListOf() - }, - onError = { - wantsToPay = persistentListOf() - scope.launch { - zappingProgress = 0f - showErrorMessageDialog = it - } - } - ) - } - - if (wantsToSetCustomZap) { - ZapCustomDialog( - onClose = { wantsToSetCustomZap = false }, - onError = { _, message -> - scope.launch { - zappingProgress = 0f - showErrorMessageDialog = message - } - }, - onProgress = { - scope.launch(Dispatchers.Main) { - zappingProgress = it - } - }, - onPayViaIntent = { - wantsToPay = it - }, - accountViewModel = accountViewModel, - baseNote = baseNote - ) - } - - if (zappingProgress > 0.00001 && zappingProgress < 0.99999) { - Spacer(ModifierWidth3dp) - - CircularProgressIndicator( - progress = animateFloatAsState( - targetValue = zappingProgress, - animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, - label = "ZapIconIndicator" - ).value, - modifier = remember { Modifier.size(animationSize) }, - strokeWidth = 2.dp - ) - } else { - ObserveZapIcon( - baseNote, - accountViewModel - ) { wasZappedByLoggedInUser -> - Crossfade(targetState = wasZappedByLoggedInUser.value, label = "ZapIcon") { - if (it) { - ZappedIcon(iconSizeModifier) - } else { - ZapIcon(iconSizeModifier, grayTint) - } - } - } - } + onPayViaIntent = { wantsToPay = it }, + ) + }, + onLongClick = { wantsToChangeZapAmount = true }, + onDoubleClick = { wantsToSetCustomZap = true }, + ), + ) { + if (wantsToZap) { + ZapAmountChoicePopup( + baseNote = baseNote, + iconSize = iconSize, + accountViewModel = accountViewModel, + onDismiss = { + wantsToZap = false + zappingProgress = 0f + }, + onChangeAmount = { + wantsToZap = false + wantsToChangeZapAmount = true + }, + onError = { _, message -> + scope.launch { + zappingProgress = 0f + showErrorMessageDialog = message + } + }, + onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } }, + onPayViaIntent = { wantsToPay = it }, + ) } - ObserveZapAmountText(baseNote, accountViewModel) { zapAmountTxt -> - SlidingAnimationAmount(zapAmountTxt, grayTint) + if (showErrorMessageDialog != null) { + ErrorMessageDialog( + title = stringResource(id = R.string.error_dialog_zap_error), + textContent = showErrorMessageDialog ?: "", + onClickStartMessage = { + baseNote.author?.let { + scope.launch(Dispatchers.IO) { + val route = routeToMessage(it, showErrorMessageDialog, accountViewModel) + nav(route) + } + } + }, + onDismiss = { showErrorMessageDialog = null }, + ) } + + if (wantsToChangeZapAmount) { + UpdateZapAmountDialog( + onClose = { wantsToChangeZapAmount = false }, + accountViewModel = accountViewModel, + ) + } + + if (wantsToPay.isNotEmpty()) { + PayViaIntentDialog( + payingInvoices = wantsToPay, + accountViewModel = accountViewModel, + onClose = { wantsToPay = persistentListOf() }, + onError = { + wantsToPay = persistentListOf() + scope.launch { + zappingProgress = 0f + showErrorMessageDialog = it + } + }, + ) + } + + if (wantsToSetCustomZap) { + ZapCustomDialog( + onClose = { wantsToSetCustomZap = false }, + onError = { _, message -> + scope.launch { + zappingProgress = 0f + showErrorMessageDialog = message + } + }, + onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } }, + onPayViaIntent = { wantsToPay = it }, + accountViewModel = accountViewModel, + baseNote = baseNote, + ) + } + + if (zappingProgress > 0.00001 && zappingProgress < 0.99999) { + Spacer(ModifierWidth3dp) + + CircularProgressIndicator( + progress = + animateFloatAsState( + targetValue = zappingProgress, + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, + label = "ZapIconIndicator", + ) + .value, + modifier = remember { Modifier.size(animationSize) }, + strokeWidth = 2.dp, + ) + } else { + ObserveZapIcon( + baseNote, + accountViewModel, + ) { wasZappedByLoggedInUser -> + Crossfade(targetState = wasZappedByLoggedInUser.value, label = "ZapIcon") { + if (it) { + ZappedIcon(iconSizeModifier) + } else { + ZapIcon(iconSizeModifier, grayTint) + } + } + } + } + } + + ObserveZapAmountText(baseNote, accountViewModel) { zapAmountTxt -> + SlidingAnimationAmount(zapAmountTxt, grayTint) + } } private fun zapClick( - baseNote: Note, - accountViewModel: AccountViewModel, - context: Context, - onZappingProgress: (Float) -> Unit, - onMultipleChoices: () -> Unit, - onError: (String, String) -> Unit, - onPayViaIntent: (ImmutableList) -> Unit + baseNote: Note, + accountViewModel: AccountViewModel, + context: Context, + onZappingProgress: (Float) -> Unit, + onMultipleChoices: () -> Unit, + onError: (String, String) -> Unit, + onPayViaIntent: (ImmutableList) -> Unit, ) { - if (accountViewModel.account.zapAmountChoices.isEmpty()) { - accountViewModel.toast( - context.getString(R.string.error_dialog_zap_error), - context.getString(R.string.no_zap_amount_setup_long_press_to_change) - ) - } else if (!accountViewModel.isWriteable()) { - accountViewModel.toast( - context.getString(R.string.error_dialog_zap_error), - context.getString(R.string.login_with_a_private_key_to_be_able_to_send_zaps) - ) - } else if (accountViewModel.account.zapAmountChoices.size == 1) { - accountViewModel.zap( - baseNote, - accountViewModel.account.zapAmountChoices.first() * 1000, - null, - "", - context, - onError = onError, - onProgress = { - onZappingProgress(it) - }, - zapType = accountViewModel.account.defaultZapType, - onPayViaIntent = onPayViaIntent - ) - } else if (accountViewModel.account.zapAmountChoices.size > 1) { - onMultipleChoices() - } + if (accountViewModel.account.zapAmountChoices.isEmpty()) { + accountViewModel.toast( + context.getString(R.string.error_dialog_zap_error), + context.getString(R.string.no_zap_amount_setup_long_press_to_change), + ) + } else if (!accountViewModel.isWriteable()) { + accountViewModel.toast( + context.getString(R.string.error_dialog_zap_error), + context.getString(R.string.login_with_a_private_key_to_be_able_to_send_zaps), + ) + } else if (accountViewModel.account.zapAmountChoices.size == 1) { + accountViewModel.zap( + baseNote, + accountViewModel.account.zapAmountChoices.first() * 1000, + null, + "", + context, + onError = onError, + onProgress = { onZappingProgress(it) }, + zapType = accountViewModel.account.defaultZapType, + onPayViaIntent = onPayViaIntent, + ) + } else if (accountViewModel.account.zapAmountChoices.size > 1) { + onMultipleChoices() + } } @Composable private fun ObserveZapIcon( - baseNote: Note, - accountViewModel: AccountViewModel, - inner: @Composable (MutableState) -> Unit + baseNote: Note, + accountViewModel: AccountViewModel, + inner: @Composable (MutableState) -> Unit, ) { - val wasZappedByLoggedInUser = remember { mutableStateOf(false) } + val wasZappedByLoggedInUser = remember { mutableStateOf(false) } - if (!wasZappedByLoggedInUser.value) { - val zapsState by baseNote.live().zaps.observeAsState() + if (!wasZappedByLoggedInUser.value) { + val zapsState by baseNote.live().zaps.observeAsState() - LaunchedEffect(key1 = zapsState) { - accountViewModel.calculateIfNoteWasZappedByAccount(baseNote) { newWasZapped -> - if (wasZappedByLoggedInUser.value != newWasZapped) { - wasZappedByLoggedInUser.value = newWasZapped - } - } + LaunchedEffect(key1 = zapsState) { + accountViewModel.calculateIfNoteWasZappedByAccount(baseNote) { newWasZapped -> + if (wasZappedByLoggedInUser.value != newWasZapped) { + wasZappedByLoggedInUser.value = newWasZapped } + } } + } - inner(wasZappedByLoggedInUser) + inner(wasZappedByLoggedInUser) } @Composable private fun ObserveZapAmountText( - baseNote: Note, - accountViewModel: AccountViewModel, - inner: @Composable (MutableState) -> Unit + baseNote: Note, + accountViewModel: AccountViewModel, + inner: @Composable (MutableState) -> Unit, ) { - val zapAmountTxt = remember(baseNote) { - mutableStateOf(showAmount(baseNote.zapsAmount)) - } - val zapsState by baseNote.live().zaps.observeAsState() + val zapAmountTxt = remember(baseNote) { mutableStateOf(showAmount(baseNote.zapsAmount)) } + val zapsState by baseNote.live().zaps.observeAsState() - LaunchedEffect(key1 = zapsState) { - accountViewModel.calculateZapAmount(baseNote) { newZapAmount -> - if (zapAmountTxt.value != newZapAmount) { - zapAmountTxt.value = newZapAmount - } - } + LaunchedEffect(key1 = zapsState) { + accountViewModel.calculateZapAmount(baseNote) { newZapAmount -> + if (zapAmountTxt.value != newZapAmount) { + zapAmountTxt.value = newZapAmount + } } + } - inner(zapAmountTxt) + inner(zapAmountTxt) } @Composable fun ViewCountReaction( - note: Note, - grayTint: Color, - barChartModifier: Modifier = Size19Modifier, - numberSizeModifier: Modifier = Height24dpModifier, - viewCountColorFilter: ColorFilter + note: Note, + grayTint: Color, + barChartModifier: Modifier = Size19Modifier, + numberSizeModifier: Modifier = Height24dpModifier, + viewCountColorFilter: ColorFilter, ) { - ViewCountIcon(barChartModifier, grayTint) - DrawViewCount(note, numberSizeModifier, viewCountColorFilter) + ViewCountIcon(barChartModifier, grayTint) + DrawViewCount(note, numberSizeModifier, viewCountColorFilter) } @Composable private fun DrawViewCount( - note: Note, - iconModifier: Modifier = Modifier, - viewCountColorFilter: ColorFilter + note: Note, + iconModifier: Modifier = Modifier, + viewCountColorFilter: ColorFilter, ) { - val context = LocalContext.current + val context = LocalContext.current - AsyncImage( - model = remember(note) { - ImageRequest.Builder(context) - .data("https://counter.amethyst.social/${note.idHex}.svg?label=+&color=00000000") - .diskCachePolicy(CachePolicy.DISABLED) - .memoryCachePolicy(CachePolicy.ENABLED) - .build() - }, - contentDescription = context.getString(R.string.view_count), - modifier = iconModifier, - colorFilter = viewCountColorFilter - ) + AsyncImage( + model = + remember(note) { + ImageRequest.Builder(context) + .data("https://counter.amethyst.social/${note.idHex}.svg?label=+&color=00000000") + .diskCachePolicy(CachePolicy.DISABLED) + .memoryCachePolicy(CachePolicy.ENABLED) + .build() + }, + contentDescription = context.getString(R.string.view_count), + modifier = iconModifier, + colorFilter = viewCountColorFilter, + ) } @OptIn(ExperimentalLayoutApi::class) @Composable -private fun BoostTypeChoicePopup(baseNote: Note, iconSize: Dp, accountViewModel: AccountViewModel, onDismiss: () -> Unit, onQuote: () -> Unit, onRepost: () -> Unit) { - val iconSizePx = with(LocalDensity.current) { - -iconSize.toPx().toInt() - } +private fun BoostTypeChoicePopup( + baseNote: Note, + iconSize: Dp, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, + onQuote: () -> Unit, + onRepost: () -> Unit, +) { + val iconSizePx = with(LocalDensity.current) { -iconSize.toPx().toInt() } - Popup( - alignment = Alignment.BottomCenter, - offset = IntOffset(0, iconSizePx), - onDismissRequest = { onDismiss() } - ) { - FlowRow { - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = { - if (accountViewModel.isWriteable()) { - accountViewModel.boost(baseNote) - onDismiss() - } else { - onRepost() - onDismiss() - } - }, - shape = ButtonBorder, - colors = ButtonDefaults - .buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ) - ) { - Text(stringResource(R.string.boost), color = Color.White, textAlign = TextAlign.Center) - } + Popup( + alignment = Alignment.BottomCenter, + offset = IntOffset(0, iconSizePx), + onDismissRequest = { onDismiss() }, + ) { + FlowRow { + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = { + if (accountViewModel.isWriteable()) { + accountViewModel.boost(baseNote) + onDismiss() + } else { + onRepost() + onDismiss() + } + }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text(stringResource(R.string.boost), color = Color.White, textAlign = TextAlign.Center) + } - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = onQuote, - shape = ButtonBorder, - colors = ButtonDefaults - .buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ) - ) { - Text(stringResource(R.string.quote), color = Color.White, textAlign = TextAlign.Center) - } - } + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = onQuote, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text(stringResource(R.string.quote), color = Color.White, textAlign = TextAlign.Center) + } } + } } @OptIn(ExperimentalLayoutApi::class) @Composable fun ReactionChoicePopup( - baseNote: Note, - iconSize: Dp, - accountViewModel: AccountViewModel, - onDismiss: () -> Unit, - onChangeAmount: () -> Unit + baseNote: Note, + iconSize: Dp, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, + onChangeAmount: () -> Unit, ) { - val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account ?: return - val toRemove = remember { - baseNote.reactedBy(account.userProfile()).toImmutableSet() - } + val toRemove = remember { baseNote.reactedBy(account.userProfile()).toImmutableSet() } - val iconSizePx = with(LocalDensity.current) { - -iconSize.toPx().toInt() - } + val iconSizePx = with(LocalDensity.current) { -iconSize.toPx().toInt() } - Popup( - alignment = Alignment.BottomCenter, - offset = IntOffset(0, iconSizePx), - onDismissRequest = { onDismiss() } - ) { - FlowRow(horizontalArrangement = Arrangement.Center) { - account.reactionChoices.forEach { reactionType -> - ActionableReactionButton( - baseNote, - reactionType, - accountViewModel, - onDismiss, - onChangeAmount, - toRemove - ) - } - } + Popup( + alignment = Alignment.BottomCenter, + offset = IntOffset(0, iconSizePx), + onDismissRequest = { onDismiss() }, + ) { + FlowRow(horizontalArrangement = Arrangement.Center) { + account.reactionChoices.forEach { reactionType -> + ActionableReactionButton( + baseNote, + reactionType, + accountViewModel, + onDismiss, + onChangeAmount, + toRemove, + ) + } } + } } @Composable @OptIn(ExperimentalFoundationApi::class) private fun ActionableReactionButton( - baseNote: Note, - reactionType: String, - accountViewModel: AccountViewModel, - onDismiss: () -> Unit, - onChangeAmount: () -> Unit, - toRemove: ImmutableSet + baseNote: Note, + reactionType: String, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, + onChangeAmount: () -> Unit, + toRemove: ImmutableSet, ) { - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = { + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = { + accountViewModel.reactToOrDelete( + baseNote, + reactionType, + ) + onDismiss() + }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + val thisModifier = + remember(reactionType) { + Modifier.combinedClickable( + onClick = { accountViewModel.reactToOrDelete( - baseNote, - reactionType + baseNote, + reactionType, ) onDismiss() - }, - shape = ButtonBorder, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary + }, + onLongClick = { onChangeAmount() }, ) - ) { - val thisModifier = remember(reactionType) { - Modifier.combinedClickable( - onClick = { - accountViewModel.reactToOrDelete( - baseNote, - reactionType - ) - onDismiss() - }, - onLongClick = { - onChangeAmount() - } - ) - } + } - val removeSymbol = remember(reactionType) { - if (reactionType in toRemove) { - " โœ–" - } else { - "" - } - } - - if (reactionType.startsWith(":")) { - val noStartColon = reactionType.removePrefix(":") - val url = noStartColon.substringAfter(":") - - val renderable = listOf( - ImageUrlType(url), - TextType(removeSymbol) - ).toImmutableList() - - InLineIconRenderer( - renderable, - style = SpanStyle(color = Color.White), - maxLines = 1 - ) + val removeSymbol = + remember(reactionType) { + if (reactionType in toRemove) { + " โœ–" } else { - when (reactionType) { - "+" -> { - Icon( - painter = painterResource(R.drawable.ic_liked), - null, - modifier = remember { thisModifier.size(16.dp) }, - tint = Color.White - ) - Text( - text = removeSymbol, - color = Color.White, - textAlign = TextAlign.Center, - modifier = thisModifier - ) - } - - "-" -> Text( - text = "\uD83D\uDC4E$removeSymbol", - color = Color.White, - textAlign = TextAlign.Center, - modifier = thisModifier - ) - - else -> Text( - "$reactionType$removeSymbol", - color = Color.White, - textAlign = TextAlign.Center, - modifier = thisModifier - ) - } + "" } + } + + if (reactionType.startsWith(":")) { + val noStartColon = reactionType.removePrefix(":") + val url = noStartColon.substringAfter(":") + + val renderable = + listOf( + ImageUrlType(url), + TextType(removeSymbol), + ) + .toImmutableList() + + InLineIconRenderer( + renderable, + style = SpanStyle(color = Color.White), + maxLines = 1, + ) + } else { + when (reactionType) { + "+" -> { + Icon( + painter = painterResource(R.drawable.ic_liked), + null, + modifier = remember { thisModifier.size(16.dp) }, + tint = Color.White, + ) + Text( + text = removeSymbol, + color = Color.White, + textAlign = TextAlign.Center, + modifier = thisModifier, + ) + } + "-" -> + Text( + text = "\uD83D\uDC4E$removeSymbol", + color = Color.White, + textAlign = TextAlign.Center, + modifier = thisModifier, + ) + else -> + Text( + "$reactionType$removeSymbol", + color = Color.White, + textAlign = TextAlign.Center, + modifier = thisModifier, + ) + } } + } } @OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) @Composable fun ZapAmountChoicePopup( - baseNote: Note, - accountViewModel: AccountViewModel, - iconSize: Dp, - onDismiss: () -> Unit, - onChangeAmount: () -> Unit, - onError: (title: String, text: String) -> Unit, - onProgress: (percent: Float) -> Unit, - onPayViaIntent: (ImmutableList) -> Unit + baseNote: Note, + accountViewModel: AccountViewModel, + iconSize: Dp, + onDismiss: () -> Unit, + onChangeAmount: () -> Unit, + onError: (title: String, text: String) -> Unit, + onProgress: (percent: Float) -> Unit, + onPayViaIntent: (ImmutableList) -> Unit, ) { - val context = LocalContext.current - val zapMessage = "" + val context = LocalContext.current + val zapMessage = "" - val iconSizePx = with(LocalDensity.current) { - -iconSize.toPx().toInt() - } + val iconSizePx = with(LocalDensity.current) { -iconSize.toPx().toInt() } - Popup( - alignment = Alignment.BottomCenter, - offset = IntOffset(0, iconSizePx), - onDismissRequest = { onDismiss() } - ) { - FlowRow(horizontalArrangement = Arrangement.Center) { - accountViewModel.account.zapAmountChoices.forEach { amountInSats -> - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = { - accountViewModel.zap( - baseNote, - amountInSats * 1000, - null, - zapMessage, - context, - onError, - onProgress, - onPayViaIntent, - accountViewModel.account.defaultZapType - ) - onDismiss() - }, - shape = ButtonBorder, - colors = ButtonDefaults - .buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ) - ) { - Text( - "โšก ${showAmount(amountInSats.toBigDecimal().setScale(1))}", - color = Color.White, - textAlign = TextAlign.Center, - modifier = Modifier.combinedClickable( - onClick = { - accountViewModel.zap( - baseNote, - amountInSats * 1000, - null, - zapMessage, - context, - onError, - onProgress, - onPayViaIntent, - accountViewModel.account.defaultZapType - ) - onDismiss() - }, - onLongClick = { - onChangeAmount() - } - ) - ) - } - } + Popup( + alignment = Alignment.BottomCenter, + offset = IntOffset(0, iconSizePx), + onDismissRequest = { onDismiss() }, + ) { + FlowRow(horizontalArrangement = Arrangement.Center) { + accountViewModel.account.zapAmountChoices.forEach { amountInSats -> + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = { + accountViewModel.zap( + baseNote, + amountInSats * 1000, + null, + zapMessage, + context, + onError, + onProgress, + onPayViaIntent, + accountViewModel.account.defaultZapType, + ) + onDismiss() + }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text( + "โšก ${showAmount(amountInSats.toBigDecimal().setScale(1))}", + color = Color.White, + textAlign = TextAlign.Center, + modifier = + Modifier.combinedClickable( + onClick = { + accountViewModel.zap( + baseNote, + amountInSats * 1000, + null, + zapMessage, + context, + onError, + onProgress, + onPayViaIntent, + accountViewModel.account.defaultZapType, + ) + onDismiss() + }, + onLongClick = { onChangeAmount() }, + ), + ) } + } } + } } fun showCount(count: Int?): String { - if (count == null) return "" - if (count == 0) return "" + if (count == null) return "" + if (count == 0) return "" - return when { - count >= 1000000000 -> "${(count / 1000000000f).roundToInt()}G" - count >= 1000000 -> "${(count / 1000000f).roundToInt()}M" - count >= 10000 -> "${(count / 1000f).roundToInt()}k" - else -> "$count" - } + return when { + count >= 1000000000 -> "${(count / 1000000000f).roundToInt()}G" + count >= 1000000 -> "${(count / 1000000f).roundToInt()}M" + count >= 10000 -> "${(count / 1000f).roundToInt()}k" + else -> "$count" + } } val OneGiga = BigDecimal(1000000000) @@ -1431,13 +1426,13 @@ var dfK: DecimalFormat = DecimalFormat("#.0k") var dfN: DecimalFormat = DecimalFormat("#") fun showAmount(amount: BigDecimal?): String { - if (amount == null) return "" - if (amount.abs() < BigDecimal(0.01)) return "" + if (amount == null) return "" + if (amount.abs() < BigDecimal(0.01)) return "" - return when { - amount >= OneGiga -> dfG.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) - amount >= OneMega -> dfM.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) - amount >= TenKilo -> dfK.format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) - else -> dfN.format(amount) - } + return when { + amount >= OneGiga -> dfG.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) + amount >= OneMega -> dfM.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) + amount >= TenKilo -> dfK.format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) + else -> dfN.format(amount) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt index 35109dd82..7af10dd18 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.note import androidx.compose.foundation.layout.Column @@ -35,114 +55,114 @@ import java.time.format.DateTimeFormatter @Composable fun RelayCompose( - relay: RelayInfo, - accountViewModel: AccountViewModel, - onAddRelay: () -> Unit, - onRemoveRelay: () -> Unit + relay: RelayInfo, + accountViewModel: AccountViewModel, + onAddRelay: () -> Unit, + onRemoveRelay: () -> Unit, ) { - val context = LocalContext.current + val context = LocalContext.current - Column() { - Row( - modifier = Modifier - .padding(start = 12.dp, end = 12.dp, top = 10.dp) - ) { - Column( - modifier = Modifier - .padding(start = 10.dp) - .weight(1f) - ) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Text( - relay.url.trim().removePrefix("wss://"), - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Column { + Row( + modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp), + ) { + Column( + modifier = Modifier.padding(start = 10.dp).weight(1f), + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + relay.url.trim().removePrefix("wss://"), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) - val lastTime by remember(relay.lastEvent) { - derivedStateOf { - timeAgo(relay.lastEvent, context = context) - } - } - - Text( - text = lastTime, - maxLines = 1 - ) - } - - Text( - "${relay.counter} ${stringResource(R.string.posts_received)}", - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + val lastTime by + remember(relay.lastEvent) { + derivedStateOf { timeAgo(relay.lastEvent, context = context) } } - Column(modifier = Modifier.padding(start = 10.dp)) { - RelayOptions(accountViewModel, relay, onAddRelay, onRemoveRelay) - } + Text( + text = lastTime, + maxLines = 1, + ) } - Divider( - modifier = Modifier.padding(top = 10.dp), - thickness = DividerThickness + Text( + "${relay.counter} ${stringResource(R.string.posts_received)}", + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) + } + + Column(modifier = Modifier.padding(start = 10.dp)) { + RelayOptions(accountViewModel, relay, onAddRelay, onRemoveRelay) + } } + + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = DividerThickness, + ) + } } @Composable private fun RelayOptions( - accountViewModel: AccountViewModel, - relay: RelayInfo, - onAddRelay: () -> Unit, - onRemoveRelay: () -> Unit + accountViewModel: AccountViewModel, + relay: RelayInfo, + onAddRelay: () -> Unit, + onRemoveRelay: () -> Unit, ) { - val userState by accountViewModel.userRelays.observeAsState() + val userState by accountViewModel.userRelays.observeAsState() - val isNotUsingRelay = remember(userState) { - accountViewModel.account.activeRelays()?.none { it.url == relay.url } == true + val isNotUsingRelay = + remember(userState) { + accountViewModel.account.activeRelays()?.none { it.url == relay.url } == true } - if (isNotUsingRelay) { - AddRelayButton(onAddRelay) - } else { - RemoveRelayButton(onRemoveRelay) - } + if (isNotUsingRelay) { + AddRelayButton(onAddRelay) + } else { + RemoveRelayButton(onRemoveRelay) + } } @Composable fun AddRelayButton(onClick: () -> Unit) { - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = onClick, - shape = ButtonBorder, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ), - contentPadding = ButtonPadding - ) { - Text(text = stringResource(id = R.string.add), color = Color.White) - } + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = onClick, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Text(text = stringResource(id = R.string.add), color = Color.White) + } } @Composable fun RemoveRelayButton(onClick: () -> Unit) { - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = onClick, - shape = ButtonBorder, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ), - contentPadding = ButtonPadding - ) { - Text(text = stringResource(R.string.remove), color = Color.White) - } + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = onClick, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Text(text = stringResource(R.string.remove), color = Color.White) + } } fun formattedDateTime(timestamp: Long): String { - return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault()) - .format(DateTimeFormatter.ofPattern("MMM d, uuuu hh:mm a")) + return Instant.ofEpochSecond(timestamp) + .atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("MMM d, uuuu hh:mm a")) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt index ff1e09882..d58b3fcfe 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.note import androidx.compose.foundation.layout.Arrangement @@ -27,56 +47,50 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText @OptIn(ExperimentalLayoutApi::class) @Composable -fun RelayBadges(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - var expanded by remember { mutableStateOf(false) } +fun RelayBadges( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } - val relayList by baseNote.live().relayInfo.observeAsState(baseNote.relays) + val relayList by baseNote.live().relayInfo.observeAsState(baseNote.relays) - Spacer(DoubleVertSpacer) + Spacer(DoubleVertSpacer) - // FlowRow Seems to be a lot faster than LazyVerticalGrid - FlowRow() { - if (expanded) { - relayList?.forEach { - RenderRelay(it, accountViewModel, nav) - } - } else { - relayList?.getOrNull(0)?.let { - RenderRelay(it, accountViewModel, nav) - } - relayList?.getOrNull(1)?.let { - RenderRelay(it, accountViewModel, nav) - } - relayList?.getOrNull(2)?.let { - RenderRelay(it, accountViewModel, nav) - } - } + // FlowRow Seems to be a lot faster than LazyVerticalGrid + FlowRow { + if (expanded) { + relayList?.forEach { RenderRelay(it, accountViewModel, nav) } + } else { + relayList?.getOrNull(0)?.let { RenderRelay(it, accountViewModel, nav) } + relayList?.getOrNull(1)?.let { RenderRelay(it, accountViewModel, nav) } + relayList?.getOrNull(2)?.let { RenderRelay(it, accountViewModel, nav) } } + } - if (relayList.size > 3 && !expanded) { - ShowMoreRelaysButton { - expanded = true - } - } + if (relayList.size > 3 && !expanded) { + ShowMoreRelaysButton { expanded = true } + } } @Composable private fun ShowMoreRelaysButton(onClick: () -> Unit) { - Row( - modifier = ShowMoreRelaysButtonBoxModifer, - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.Top + Row( + modifier = ShowMoreRelaysButtonBoxModifer, + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.Top, + ) { + IconButton( + modifier = ShowMoreRelaysButtonIconButtonModifier, + onClick = onClick, ) { - IconButton( - modifier = ShowMoreRelaysButtonIconButtonModifier, - onClick = onClick - ) { - Icon( - imageVector = Icons.Default.ExpandMore, - null, - modifier = ShowMoreRelaysButtonIconModifier, - tint = MaterialTheme.colorScheme.placeholderText - ) - } + Icon( + imageVector = Icons.Default.ExpandMore, + null, + modifier = ShowMoreRelaysButtonIconModifier, + tint = MaterialTheme.colorScheme.placeholderText, + ) } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt index 8399c95a3..1cb3a10a3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.note import androidx.compose.foundation.clickable @@ -44,148 +64,167 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.relayIconModifier @Composable -public fun RelayBadgesHorizontal(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val expanded = remember { mutableStateOf(false) } +public fun RelayBadgesHorizontal( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val expanded = remember { mutableStateOf(false) } - RenderRelayList(baseNote, expanded, accountViewModel, nav) + RenderRelayList(baseNote, expanded, accountViewModel, nav) - RenderExpandButton(baseNote, expanded) { - ChatRelayExpandButton { expanded.value = true } - } + RenderExpandButton(baseNote, expanded) { ChatRelayExpandButton { expanded.value = true } } } @OptIn(ExperimentalLayoutApi::class) @Composable -fun RenderRelayList(baseNote: Note, expanded: MutableState, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val noteRelays by baseNote.live().relayInfo.observeAsState(baseNote.relays) +fun RenderRelayList( + baseNote: Note, + expanded: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteRelays by baseNote.live().relayInfo.observeAsState(baseNote.relays) - FlowRow(StdStartPadding, verticalArrangement = Arrangement.Center) { - if (expanded.value) { - noteRelays?.forEach { - RenderRelay(it, accountViewModel, nav) - } - } else { - noteRelays?.getOrNull(0)?.let { - RenderRelay(it, accountViewModel, nav) - } - noteRelays?.getOrNull(1)?.let { - RenderRelay(it, accountViewModel, nav) - } - noteRelays?.getOrNull(2)?.let { - RenderRelay(it, accountViewModel, nav) - } - } + FlowRow(StdStartPadding, verticalArrangement = Arrangement.Center) { + if (expanded.value) { + noteRelays?.forEach { RenderRelay(it, accountViewModel, nav) } + } else { + noteRelays?.getOrNull(0)?.let { RenderRelay(it, accountViewModel, nav) } + noteRelays?.getOrNull(1)?.let { RenderRelay(it, accountViewModel, nav) } + noteRelays?.getOrNull(2)?.let { RenderRelay(it, accountViewModel, nav) } } + } } @Composable fun RenderExpandButton( - baseNote: Note, - expanded: MutableState, - content: @Composable () -> Unit + baseNote: Note, + expanded: MutableState, + content: @Composable () -> Unit, ) { - val showExpandButton by baseNote.live().relays.map { - it.note.relays.size > 3 - }.observeAsState(baseNote.relays.size > 3) + val showExpandButton by + baseNote.live().relays.map { it.note.relays.size > 3 }.observeAsState(baseNote.relays.size > 3) - if (showExpandButton && !expanded.value) { - content() - } + if (showExpandButton && !expanded.value) { + content() + } } @Composable fun ChatRelayExpandButton(onClick: () -> Unit) { - IconButton( - modifier = Size15Modifier, - onClick = onClick - ) { - Icon( - imageVector = Icons.Default.ChevronRight, - null, - modifier = Size15Modifier, - tint = MaterialTheme.colorScheme.placeholderText - ) - } + IconButton( + modifier = Size15Modifier, + onClick = onClick, + ) { + Icon( + imageVector = Icons.Default.ChevronRight, + null, + modifier = Size15Modifier, + tint = MaterialTheme.colorScheme.placeholderText, + ) + } } @Composable -fun RenderRelay(relay: RelayBriefInfoCache.RelayBriefInfo, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - var relayInfo: RelayInformation? by remember { mutableStateOf(null) } +fun RenderRelay( + relay: RelayBriefInfoCache.RelayBriefInfo, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + var relayInfo: RelayInformation? by remember { mutableStateOf(null) } - if (relayInfo != null) { - RelayInformationDialog( - onClose = { - relayInfo = null - }, - relayInfo = relayInfo!!, - relayBriefInfo = relay, - accountViewModel = accountViewModel, - nav = nav + if (relayInfo != null) { + RelayInformationDialog( + onClose = { relayInfo = null }, + relayInfo = relayInfo!!, + relayBriefInfo = relay, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + val context = LocalContext.current + + val interactionSource = remember { MutableInteractionSource() } + val ripple = rememberRipple(bounded = false, radius = Size15dp) + + val automaticallyShowProfilePicture = remember { + accountViewModel.settings.showProfilePictures.value + } + + val clickableModifier = + remember(relay) { + Modifier.padding(1.dp) + .size(Size15dp) + .clickable( + role = Role.Button, + interactionSource = interactionSource, + indication = ripple, + onClick = { + accountViewModel.retrieveRelayDocument( + relay.url, + onInfo = { relayInfo = it }, + onError = { url, errorCode, exceptionMessage -> + val msg = + when (errorCode) { + Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> + context.getString( + R.string.relay_information_document_error_assemble_url, + url, + exceptionMessage, + ) + Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> + context.getString( + R.string.relay_information_document_error_assemble_url, + url, + exceptionMessage, + ) + Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> + context.getString( + R.string.relay_information_document_error_assemble_url, + url, + exceptionMessage, + ) + Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> + context.getString( + R.string.relay_information_document_error_assemble_url, + url, + exceptionMessage, + ) + } + + accountViewModel.toast( + context.getString(R.string.unable_to_download_relay_document), + msg, + ) + }, + ) + }, ) } - val context = LocalContext.current - - val interactionSource = remember { MutableInteractionSource() } - val ripple = rememberRipple(bounded = false, radius = Size15dp) - - val automaticallyShowProfilePicture = remember { - accountViewModel.settings.showProfilePictures.value - } - - val clickableModifier = remember(relay) { - Modifier - .padding(1.dp) - .size(Size15dp) - .clickable( - role = Role.Button, - interactionSource = interactionSource, - indication = ripple, - onClick = { - accountViewModel.retrieveRelayDocument( - relay.url, - onInfo = { - relayInfo = it - }, - onError = { url, errorCode, exceptionMessage -> - val msg = when (errorCode) { - Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage) - Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage) - Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage) - Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage) - } - - accountViewModel.toast( - context.getString(R.string.unable_to_download_relay_document), - msg - ) - } - ) - } - ) - } - - Box( - modifier = clickableModifier, - contentAlignment = Alignment.Center - ) { - RenderRelayIcon(relay.displayUrl, relay.favIcon, automaticallyShowProfilePicture) - } + Box( + modifier = clickableModifier, + contentAlignment = Alignment.Center, + ) { + RenderRelayIcon(relay.displayUrl, relay.favIcon, automaticallyShowProfilePicture) + } } @Composable fun RenderRelayIcon( - displayUrl: String, - iconUrl: String, - loadProfilePicture: Boolean, - iconModifier: Modifier = MaterialTheme.colorScheme.relayIconModifier + displayUrl: String, + iconUrl: String, + loadProfilePicture: Boolean, + iconModifier: Modifier = MaterialTheme.colorScheme.relayIconModifier, ) { - RobohashFallbackAsyncImage( - robot = displayUrl, - model = iconUrl, - contentDescription = stringResource(id = R.string.relay_icon), - colorFilter = RelayIconFilter, - modifier = iconModifier, - loadProfilePicture = loadProfilePicture - ) + RobohashFallbackAsyncImage( + robot = displayUrl, + model = iconUrl, + contentDescription = stringResource(id = R.string.relay_icon), + colorFilter = RelayIconFilter, + modifier = iconModifier, + loadProfilePicture = loadProfilePicture, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt index 47c3efec6..e31a1ac8e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.note import androidx.compose.foundation.layout.ExperimentalLayoutApi @@ -35,183 +55,190 @@ import kotlinx.coroutines.launch @Composable fun ReplyInformation( - replyTo: ImmutableList?, - mentions: ImmutableList, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + replyTo: ImmutableList?, + mentions: ImmutableList, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var sortedMentions by remember { mutableStateOf?>(null) } + var sortedMentions by remember { mutableStateOf?>(null) } - LaunchedEffect(Unit) { - launch(Dispatchers.IO) { - sortedMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) } - .toSet() - .sortedBy { !accountViewModel.account.userProfile().isFollowingCached(it) } - .toImmutableList() - } + LaunchedEffect(Unit) { + launch(Dispatchers.IO) { + sortedMentions = + mentions + .mapNotNull { LocalCache.checkGetOrCreateUser(it) } + .toSet() + .sortedBy { !accountViewModel.account.userProfile().isFollowingCached(it) } + .toImmutableList() } + } - if (sortedMentions != null) { - ReplyInformation(replyTo, sortedMentions) { - nav("User/${it.pubkeyHex}") - } - } + if (sortedMentions != null) { + ReplyInformation(replyTo, sortedMentions) { nav("User/${it.pubkeyHex}") } + } } @OptIn(ExperimentalLayoutApi::class) @Composable private fun ReplyInformation( - replyTo: ImmutableList?, - sortedMentions: ImmutableList?, - prefix: String = "", - onUserTagClick: (User) -> Unit + replyTo: ImmutableList?, + sortedMentions: ImmutableList?, + prefix: String = "", + onUserTagClick: (User) -> Unit, ) { - var expanded by remember { mutableStateOf((sortedMentions?.size ?: 0) <= 2) } + var expanded by remember { mutableStateOf((sortedMentions?.size ?: 0) <= 2) } - FlowRow() { - if (sortedMentions != null && sortedMentions.isNotEmpty()) { - if (replyTo != null && replyTo.isNotEmpty()) { - val repliesToDisplay = if (expanded) sortedMentions else sortedMentions.take(2) + FlowRow { + if (sortedMentions != null && sortedMentions.isNotEmpty()) { + if (replyTo != null && replyTo.isNotEmpty()) { + val repliesToDisplay = if (expanded) sortedMentions else sortedMentions.take(2) - Text( - stringResource(R.string.replying_to), - fontSize = 13.sp, - color = MaterialTheme.colorScheme.placeholderText - ) + Text( + stringResource(R.string.replying_to), + fontSize = 13.sp, + color = MaterialTheme.colorScheme.placeholderText, + ) - repliesToDisplay.forEachIndexed { idx, user -> - ReplyInfoMention(user, prefix, onUserTagClick) + repliesToDisplay.forEachIndexed { idx, user -> + ReplyInfoMention(user, prefix, onUserTagClick) - if (expanded) { - if (idx < repliesToDisplay.size - 2) { - Text( - ", ", - fontSize = 13.sp, - color = MaterialTheme.colorScheme.placeholderText - ) - } else if (idx < repliesToDisplay.size - 1) { - Text( - stringResource(R.string.and), - fontSize = 13.sp, - color = MaterialTheme.colorScheme.placeholderText - ) - } - } else { - if (idx < repliesToDisplay.size - 1) { - Text( - ", ", - fontSize = 13.sp, - color = MaterialTheme.colorScheme.placeholderText - ) - } else if (idx < repliesToDisplay.size) { - Text( - stringResource(R.string.and), - fontSize = 13.sp, - color = MaterialTheme.colorScheme.placeholderText - ) - - ClickableText( - AnnotatedString("${sortedMentions.size - 2}"), - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.lessImportantLink, fontSize = 13.sp), - onClick = { expanded = true } - ) - - Text( - " ${stringResource(R.string.others)}", - fontSize = 13.sp, - color = MaterialTheme.colorScheme.placeholderText - ) - } - } - } + if (expanded) { + if (idx < repliesToDisplay.size - 2) { + Text( + ", ", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.placeholderText, + ) + } else if (idx < repliesToDisplay.size - 1) { + Text( + stringResource(R.string.and), + fontSize = 13.sp, + color = MaterialTheme.colorScheme.placeholderText, + ) } + } else { + if (idx < repliesToDisplay.size - 1) { + Text( + ", ", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.placeholderText, + ) + } else if (idx < repliesToDisplay.size) { + Text( + stringResource(R.string.and), + fontSize = 13.sp, + color = MaterialTheme.colorScheme.placeholderText, + ) + + ClickableText( + AnnotatedString("${sortedMentions.size - 2}"), + style = + LocalTextStyle.current.copy( + color = MaterialTheme.colorScheme.lessImportantLink, + fontSize = 13.sp, + ), + onClick = { expanded = true }, + ) + + Text( + " ${stringResource(R.string.others)}", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + } } + } } + } } @Composable fun ReplyInformationChannel( - replyTo: ImmutableList?, - mentions: ImmutableList, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + replyTo: ImmutableList?, + mentions: ImmutableList, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var sortedMentions by remember { mutableStateOf>(persistentListOf()) } + var sortedMentions by remember { mutableStateOf>(persistentListOf()) } - LaunchedEffect(Unit) { - accountViewModel.loadMentions(mentions) { newSortedMentions -> - if (newSortedMentions != sortedMentions) { - sortedMentions = newSortedMentions - } - } + LaunchedEffect(Unit) { + accountViewModel.loadMentions(mentions) { newSortedMentions -> + if (newSortedMentions != sortedMentions) { + sortedMentions = newSortedMentions + } } + } - if (sortedMentions.isNotEmpty()) { - ReplyInformationChannel( - replyTo, - sortedMentions, - onUserTagClick = { - nav("User/${it.pubkeyHex}") - } - ) - Spacer(modifier = StdVertSpacer) - } + if (sortedMentions.isNotEmpty()) { + ReplyInformationChannel( + replyTo, + sortedMentions, + onUserTagClick = { nav("User/${it.pubkeyHex}") }, + ) + Spacer(modifier = StdVertSpacer) + } } @OptIn(ExperimentalLayoutApi::class) @Composable fun ReplyInformationChannel( - replyTo: ImmutableList?, - mentions: ImmutableList?, - prefix: String = "", - onUserTagClick: (User) -> Unit + replyTo: ImmutableList?, + mentions: ImmutableList?, + prefix: String = "", + onUserTagClick: (User) -> Unit, ) { - FlowRow() { - if (mentions != null && mentions.isNotEmpty()) { - if (replyTo != null && replyTo.isNotEmpty()) { - Text( - stringResource(id = R.string.replying_to), - fontSize = 13.sp, - color = MaterialTheme.colorScheme.placeholderText - ) + FlowRow { + if (mentions != null && mentions.isNotEmpty()) { + if (replyTo != null && replyTo.isNotEmpty()) { + Text( + stringResource(id = R.string.replying_to), + fontSize = 13.sp, + color = MaterialTheme.colorScheme.placeholderText, + ) - mentions.forEachIndexed { idx, user -> - ReplyInfoMention(user, prefix, onUserTagClick) + mentions.forEachIndexed { idx, user -> + ReplyInfoMention(user, prefix, onUserTagClick) - if (idx < mentions.size - 2) { - Text( - ", ", - fontSize = 13.sp, - color = MaterialTheme.colorScheme.placeholderText - ) - } else if (idx < mentions.size - 1) { - Text( - " ${stringResource(id = R.string.and)} ", - fontSize = 13.sp, - color = MaterialTheme.colorScheme.placeholderText - ) - } - } - } + if (idx < mentions.size - 2) { + Text( + ", ", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.placeholderText, + ) + } else if (idx < mentions.size - 1) { + Text( + " ${stringResource(id = R.string.and)} ", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.placeholderText, + ) + } } + } } + } } @Composable private fun ReplyInfoMention( - user: User, - prefix: String, - onUserTagClick: (User) -> Unit + user: User, + prefix: String, + onUserTagClick: (User) -> Unit, ) { - val innerUserState by user.live().metadata.observeAsState() + val innerUserState by user.live().metadata.observeAsState() - CreateClickableTextWithEmoji( - clickablePart = remember(innerUserState) { "$prefix${innerUserState?.user?.toBestDisplayName()}" }, - tags = remember(innerUserState) { innerUserState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() }, - style = LocalTextStyle.current.copy( - color = MaterialTheme.colorScheme.lessImportantLink, - fontSize = 13.sp - ), - onClick = { onUserTagClick(user) } - ) + CreateClickableTextWithEmoji( + clickablePart = + remember(innerUserState) { "$prefix${innerUserState?.user?.toBestDisplayName()}" }, + tags = + remember(innerUserState) { + innerUserState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() + }, + style = + LocalTextStyle.current.copy( + color = MaterialTheme.colorScheme.lessImportantLink, + fontSize = 13.sp, + ), + onClick = { onUserTagClick(user) }, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt index 12fa0a245..4a0dca4f0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.note import android.content.Context @@ -11,55 +31,63 @@ var locale = Locale.getDefault() var yearFormatter = SimpleDateFormat(" โ€ข MMM dd, yyyy", locale) var monthFormatter = SimpleDateFormat(" โ€ข MMM dd", locale) -fun timeAgo(time: Long?, context: Context): String { - if (time == null) return " " - if (time == 0L) return " โ€ข ${context.getString(R.string.never)}" +fun timeAgo( + time: Long?, + context: Context, +): String { + if (time == null) return " " + if (time == 0L) return " โ€ข ${context.getString(R.string.never)}" - val timeDifference = TimeUtils.now() - time + val timeDifference = TimeUtils.now() - time - return if (timeDifference > TimeUtils.oneYear) { - // Dec 12, 2022 + return if (timeDifference > TimeUtils.ONE_YEAR) { + // Dec 12, 2022 - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(" โ€ข MMM dd, yyyy", locale) - monthFormatter = SimpleDateFormat(" โ€ข MMM dd", locale) - } - - yearFormatter.format(time * 1000) - } else if (timeDifference > TimeUtils.oneMonth) { - // Dec 12 - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(" โ€ข MMM dd, yyyy", locale) - monthFormatter = SimpleDateFormat(" โ€ข MMM dd", locale) - } - - monthFormatter.format(time * 1000) - } else if (timeDifference > TimeUtils.oneDay) { - // 2 days - " โ€ข " + (timeDifference / TimeUtils.oneDay).toString() + context.getString(R.string.d) - } else if (timeDifference > TimeUtils.oneHour) { - " โ€ข " + (timeDifference / TimeUtils.oneHour).toString() + context.getString(R.string.h) - } else if (timeDifference > TimeUtils.oneMinute) { - " โ€ข " + (timeDifference / TimeUtils.oneMinute).toString() + context.getString(R.string.m) - } else { - " โ€ข " + context.getString(R.string.now) + if (locale != Locale.getDefault()) { + locale = Locale.getDefault() + yearFormatter = SimpleDateFormat(" โ€ข MMM dd, yyyy", locale) + monthFormatter = SimpleDateFormat(" โ€ข MMM dd", locale) } + + yearFormatter.format(time * 1000) + } else if (timeDifference > TimeUtils.ONE_MONTH) { + // Dec 12 + if (locale != Locale.getDefault()) { + locale = Locale.getDefault() + yearFormatter = SimpleDateFormat(" โ€ข MMM dd, yyyy", locale) + monthFormatter = SimpleDateFormat(" โ€ข MMM dd", locale) + } + + monthFormatter.format(time * 1000) + } else if (timeDifference > TimeUtils.ONE_DAY) { + // 2 days + " โ€ข " + (timeDifference / TimeUtils.ONE_DAY).toString() + context.getString(R.string.d) + } else if (timeDifference > TimeUtils.ONE_HOUR) { + " โ€ข " + (timeDifference / TimeUtils.ONE_HOUR).toString() + context.getString(R.string.h) + } else if (timeDifference > TimeUtils.ONE_MINUTE) { + " โ€ข " + (timeDifference / TimeUtils.ONE_MINUTE).toString() + context.getString(R.string.m) + } else { + " โ€ข " + context.getString(R.string.now) + } } -fun timeAgoShort(mills: Long?, stringForNow: String): String { - if (mills == null) return " " +fun timeAgoShort( + mills: Long?, + stringForNow: String, +): String { + if (mills == null) return " " - var humanReadable = DateUtils.getRelativeTimeSpanString( + var humanReadable = + DateUtils.getRelativeTimeSpanString( mills * 1000, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS, - DateUtils.FORMAT_ABBREV_ALL - ).toString() - if (humanReadable.startsWith("In") || humanReadable.startsWith("0")) { - humanReadable = stringForNow - } + DateUtils.FORMAT_ABBREV_ALL, + ) + .toString() + if (humanReadable.startsWith("In") || humanReadable.startsWith("0")) { + humanReadable = stringForNow + } - return humanReadable + return humanReadable } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt index 5f81079f1..21ce28481 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.note import androidx.compose.animation.animateContentSize @@ -75,313 +95,317 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch class UpdateReactionTypeViewModel(val account: Account) : ViewModel() { - var nextChoice by mutableStateOf(TextFieldValue("")) - var reactionSet by mutableStateOf(listOf()) + var nextChoice by mutableStateOf(TextFieldValue("")) + var reactionSet by mutableStateOf(listOf()) - fun load() { - this.reactionSet = account.reactionChoices - } - - fun toListOfChoices(commaSeparatedAmounts: String): List { - return commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 } - } - - fun addChoice() { - val newValue = nextChoice.text.trim().firstFullChar() - reactionSet = reactionSet + newValue - - nextChoice = TextFieldValue("") - } - - fun addChoice(customEmoji: EmojiUrl) { - reactionSet = reactionSet + (customEmoji.encode()) - } - - fun removeChoice(reaction: String) { - reactionSet = reactionSet - reaction - } - - fun sendPost() { - account.changeReactionTypes(reactionSet) - nextChoice = TextFieldValue("") - } - - fun cancel() { - nextChoice = TextFieldValue("") - } - - fun hasChanged(): Boolean { - return reactionSet != account.reactionChoices - } - - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create(modelClass: Class): UpdateReactionTypeViewModel { - return UpdateReactionTypeViewModel(account) as UpdateReactionTypeViewModel - } + fun load() { + this.reactionSet = account.reactionChoices + } + + fun toListOfChoices(commaSeparatedAmounts: String): List { + return commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 } + } + + fun addChoice() { + val newValue = nextChoice.text.trim().firstFullChar() + reactionSet = reactionSet + newValue + + nextChoice = TextFieldValue("") + } + + fun addChoice(customEmoji: EmojiUrl) { + reactionSet = reactionSet + (customEmoji.encode()) + } + + fun removeChoice(reaction: String) { + reactionSet = reactionSet - reaction + } + + fun sendPost() { + account.changeReactionTypes(reactionSet) + nextChoice = TextFieldValue("") + } + + fun cancel() { + nextChoice = TextFieldValue("") + } + + fun hasChanged(): Boolean { + return reactionSet != account.reactionChoices + } + + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): UpdateReactionTypeViewModel { + return UpdateReactionTypeViewModel(account) as UpdateReactionTypeViewModel } + } } @OptIn(ExperimentalLayoutApi::class) @Composable fun UpdateReactionTypeDialog( - onClose: () -> Unit, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + onClose: () -> Unit, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val postViewModel: UpdateReactionTypeViewModel = viewModel( - key = "UpdateReactionTypeViewModel", - factory = UpdateReactionTypeViewModel.Factory(accountViewModel.account) + val postViewModel: UpdateReactionTypeViewModel = + viewModel( + key = "UpdateReactionTypeViewModel", + factory = UpdateReactionTypeViewModel.Factory(accountViewModel.account), ) - LaunchedEffect(accountViewModel) { - postViewModel.load() - } + LaunchedEffect(accountViewModel) { postViewModel.load() } - Dialog( - onDismissRequest = { onClose() }, - properties = DialogProperties( - usePlatformDefaultWidth = false, - dismissOnClickOutside = false, - decorFitsSystemWindows = false - ) + Dialog( + onDismissRequest = { onClose() }, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + decorFitsSystemWindows = false, + ), + ) { + Surface( + modifier = Modifier.fillMaxWidth(), ) { - Surface( - modifier = Modifier.fillMaxWidth() + Column( + modifier = Modifier.padding(10.dp).imePadding(), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Column( - modifier = Modifier - .padding(10.dp) - .imePadding() - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - CloseButton(onPress = { - postViewModel.cancel() - onClose() - }) + CloseButton( + onPress = { + postViewModel.cancel() + onClose() + }, + ) - SaveButton( - onPost = { - postViewModel.sendPost() - onClose() - }, - isActive = postViewModel.hasChanged() - ) - } - - Spacer(modifier = Modifier.height(10.dp)) - - Row( - modifier = Modifier - .fillMaxWidth() - ) { - Column( - modifier = Modifier.verticalScroll(rememberScrollState()) - ) { - Row(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.animateContentSize()) { - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - postViewModel.reactionSet.forEach { reactionType -> - RenderReactionOption(reactionType, postViewModel) - } - } - } - } - - Spacer(modifier = Modifier.height(10.dp)) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.new_reaction_symbol)) }, - value = postViewModel.nextChoice, - onValueChange = { - postViewModel.nextChoice = it - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None, - keyboardType = KeyboardType.Text - ), - placeholder = { - Text( - text = "\uD83D\uDCAF, \uD83C\uDF89, \uD83D\uDC4E", - color = MaterialTheme.colorScheme.placeholderText - ) - }, - singleLine = true, - modifier = Modifier - .padding(end = 10.dp) - .weight(1f) - ) - - Button( - onClick = { postViewModel.addChoice() }, - shape = ButtonBorder, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ) - ) { - Text(text = stringResource(R.string.add), color = Color.White) - } - } - } - } - - EmojiSelector( - accountViewModel = accountViewModel, - nav = nav - ) { - postViewModel.addChoice(it) - } - } + SaveButton( + onPost = { + postViewModel.sendPost() + onClose() + }, + isActive = postViewModel.hasChanged(), + ) } + + Spacer(modifier = Modifier.height(10.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + ) { + Row(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.animateContentSize()) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + postViewModel.reactionSet.forEach { reactionType -> + RenderReactionOption(reactionType, postViewModel) + } + } + } + } + + Spacer(modifier = Modifier.height(10.dp)) + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.new_reaction_symbol)) }, + value = postViewModel.nextChoice, + onValueChange = { postViewModel.nextChoice = it }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Text, + ), + placeholder = { + Text( + text = "\uD83D\uDCAF, \uD83C\uDF89, \uD83D\uDC4E", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + modifier = Modifier.padding(end = 10.dp).weight(1f), + ) + + Button( + onClick = { postViewModel.addChoice() }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text(text = stringResource(R.string.add), color = Color.White) + } + } + } + } + + EmojiSelector( + accountViewModel = accountViewModel, + nav = nav, + ) { + postViewModel.addChoice(it) + } + } } + } } @Composable private fun RenderReactionOption( - reactionType: String, - postViewModel: UpdateReactionTypeViewModel + reactionType: String, + postViewModel: UpdateReactionTypeViewModel, ) { - Button( - modifier = Modifier.padding(horizontal = 3.dp), - shape = ButtonBorder, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ), - onClick = { - postViewModel.removeChoice(reactionType) - }, - contentPadding = PaddingValues(horizontal = 5.dp) - ) { - if (reactionType.startsWith(":")) { - val noStartColon = reactionType.removePrefix(":") - val url = noStartColon.substringAfter(":") + Button( + modifier = Modifier.padding(horizontal = 3.dp), + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + onClick = { postViewModel.removeChoice(reactionType) }, + contentPadding = PaddingValues(horizontal = 5.dp), + ) { + if (reactionType.startsWith(":")) { + val noStartColon = reactionType.removePrefix(":") + val url = noStartColon.substringAfter(":") - val renderable = listOf( - ImageUrlType(url), - TextType(" โœ–") - ).toImmutableList() + val renderable = + listOf( + ImageUrlType(url), + TextType(" โœ–"), + ) + .toImmutableList() - InLineIconRenderer( - renderable, - style = SpanStyle(color = Color.White), - maxLines = 1 - ) - } else { - when (reactionType) { - "+" -> { - Icon( - painter = painterResource(R.drawable.ic_liked), - null, - modifier = remember { Modifier.size(16.dp) }, - tint = Color.White - ) - Text( - text = " โœ–", - color = Color.White, - textAlign = TextAlign.Center - ) - } - - "-" -> Text( - text = "\uD83D\uDC4E โœ–", - color = Color.White, - textAlign = TextAlign.Center - ) - - else -> Text( - text = "$reactionType โœ–", - color = Color.White, - textAlign = TextAlign.Center - ) - } + InLineIconRenderer( + renderable, + style = SpanStyle(color = Color.White), + maxLines = 1, + ) + } else { + when (reactionType) { + "+" -> { + Icon( + painter = painterResource(R.drawable.ic_liked), + null, + modifier = remember { Modifier.size(16.dp) }, + tint = Color.White, + ) + Text( + text = " โœ–", + color = Color.White, + textAlign = TextAlign.Center, + ) } + "-" -> + Text( + text = "\uD83D\uDC4E โœ–", + color = Color.White, + textAlign = TextAlign.Center, + ) + else -> + Text( + text = "$reactionType โœ–", + color = Color.White, + textAlign = TextAlign.Center, + ) + } } + } } @Composable -private fun EmojiSelector(accountViewModel: AccountViewModel, nav: (String) -> Unit, onClick: ((EmojiUrl) -> Unit)? = null) { - LoadAddressableNote( - aTag = ATag( - EmojiPackSelectionEvent.kind, - accountViewModel.userProfile().pubkeyHex, - "", - null - ), - accountViewModel - ) { emptyNote -> - emptyNote?.let { usersEmojiList -> - val collections by usersEmojiList.live().metadata.map { - (it.note.event as? EmojiPackSelectionEvent)?.taggedAddresses()?.toImmutableList() - }.distinctUntilChanged().observeAsState( - (usersEmojiList.event as? EmojiPackSelectionEvent)?.taggedAddresses()?.toImmutableList() - ) +private fun EmojiSelector( + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + onClick: ((EmojiUrl) -> Unit)? = null, +) { + LoadAddressableNote( + aTag = + ATag( + EmojiPackSelectionEvent.KIND, + accountViewModel.userProfile().pubkeyHex, + "", + null, + ), + accountViewModel, + ) { emptyNote -> + emptyNote?.let { usersEmojiList -> + val collections by + usersEmojiList + .live() + .metadata + .map { (it.note.event as? EmojiPackSelectionEvent)?.taggedAddresses()?.toImmutableList() } + .distinctUntilChanged() + .observeAsState( + (usersEmojiList.event as? EmojiPackSelectionEvent) + ?.taggedAddresses() + ?.toImmutableList(), + ) - collections?.let { - EmojiCollectionGallery(it, accountViewModel, nav, onClick) - } - } + collections?.let { EmojiCollectionGallery(it, accountViewModel, nav, onClick) } } + } } @Composable -fun EmojiCollectionGallery(emojiCollections: ImmutableList, accountViewModel: AccountViewModel, nav: (String) -> Unit, onClick: ((EmojiUrl) -> Unit)? = null) { - val color = MaterialTheme.colorScheme.background - val bgColor = remember { mutableStateOf(color) } +fun EmojiCollectionGallery( + emojiCollections: ImmutableList, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + onClick: ((EmojiUrl) -> Unit)? = null, +) { + val color = MaterialTheme.colorScheme.background + val bgColor = remember { mutableStateOf(color) } - val listState = rememberLazyListState() + val listState = rememberLazyListState() - LazyColumn( - state = listState - ) { - itemsIndexed(emojiCollections, key = { _, item -> item.toTag() }) { _, item -> - LoadAddressableNote(aTag = item, accountViewModel) { - it?.let { - WatchAndRenderNote(it, bgColor, accountViewModel, nav, onClick) - } - } - } + LazyColumn( + state = listState, + ) { + itemsIndexed(emojiCollections, key = { _, item -> item.toTag() }) { _, item -> + LoadAddressableNote(aTag = item, accountViewModel) { + it?.let { WatchAndRenderNote(it, bgColor, accountViewModel, nav, onClick) } + } } + } } @Composable private fun WatchAndRenderNote( - emojiPack: AddressableNote, - bgColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - onClick: ((EmojiUrl) -> Unit)? + emojiPack: AddressableNote, + bgColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + onClick: ((EmojiUrl) -> Unit)?, ) { - val scope = rememberCoroutineScope() + val scope = rememberCoroutineScope() - Column( - Modifier - .fillMaxWidth() - .clickable { - scope.launch { - routeFor(emojiPack, accountViewModel.userProfile())?.let { - nav(it) - } - } - } - ) { - RenderEmojiPack( - baseNote = emojiPack, - actionable = false, - backgroundColor = bgColor, - accountViewModel = accountViewModel, - onClick = onClick - ) - } + Column( + Modifier.fillMaxWidth().clickable { + scope.launch { routeFor(emojiPack, accountViewModel.userProfile())?.let { nav(it) } } + }, + ) { + RenderEmojiPack( + baseNote = emojiPack, + actionable = false, + backgroundColor = bgColor, + accountViewModel = accountViewModel, + onClick = onClick, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index 14998605e..b48621479 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.note import android.app.Activity @@ -85,585 +105,607 @@ import com.vitorpamplona.quartz.events.LnZapEvent import kotlinx.collections.immutable.toImmutableList class UpdateZapAmountViewModel(val account: Account) : ViewModel() { - var nextAmount by mutableStateOf(TextFieldValue("")) - var amountSet by mutableStateOf(listOf()) - var walletConnectRelay by mutableStateOf(TextFieldValue("")) - var walletConnectPubkey by mutableStateOf(TextFieldValue("")) - var walletConnectSecret by mutableStateOf(TextFieldValue("")) - var selectedZapType by mutableStateOf(LnZapEvent.ZapType.PRIVATE) + var nextAmount by mutableStateOf(TextFieldValue("")) + var amountSet by mutableStateOf(listOf()) + var walletConnectRelay by mutableStateOf(TextFieldValue("")) + var walletConnectPubkey by mutableStateOf(TextFieldValue("")) + var walletConnectSecret by mutableStateOf(TextFieldValue("")) + var selectedZapType by mutableStateOf(LnZapEvent.ZapType.PRIVATE) - fun load() { - this.amountSet = account.zapAmountChoices - this.walletConnectPubkey = account.zapPaymentRequest?.pubKeyHex?.let { TextFieldValue(it) } ?: TextFieldValue("") - this.walletConnectRelay = account.zapPaymentRequest?.relayUri?.let { TextFieldValue(it) } ?: TextFieldValue("") - this.walletConnectSecret = account.zapPaymentRequest?.secret?.let { TextFieldValue(it) } ?: TextFieldValue("") - this.selectedZapType = account.defaultZapType + fun load() { + this.amountSet = account.zapAmountChoices + this.walletConnectPubkey = + account.zapPaymentRequest?.pubKeyHex?.let { TextFieldValue(it) } ?: TextFieldValue("") + this.walletConnectRelay = + account.zapPaymentRequest?.relayUri?.let { TextFieldValue(it) } ?: TextFieldValue("") + this.walletConnectSecret = + account.zapPaymentRequest?.secret?.let { TextFieldValue(it) } ?: TextFieldValue("") + this.selectedZapType = account.defaultZapType + } + + fun toListOfAmounts(commaSeparatedAmounts: String): List { + return commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 } + } + + fun addAmount() { + val newValue = nextAmount.text.trim().toLongOrNull() + if (newValue != null) { + amountSet = amountSet + newValue } - fun toListOfAmounts(commaSeparatedAmounts: String): List { - return commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 } - } + nextAmount = TextFieldValue("") + } - fun addAmount() { - val newValue = nextAmount.text.trim().toLongOrNull() - if (newValue != null) { - amountSet = amountSet + newValue + fun removeAmount(amount: Long) { + amountSet = amountSet - amount + } + + fun sendPost() { + account?.changeZapAmounts(amountSet) + account?.changeDefaultZapType(selectedZapType) + + if (walletConnectRelay.text.isNotBlank() && walletConnectPubkey.text.isNotBlank()) { + val pubkeyHex = + try { + decodePublicKey(walletConnectPubkey.text.trim()).toHexKey() + } catch (e: Exception) { + null } - nextAmount = TextFieldValue("") - } + val relayUrl = + walletConnectRelay.text + .ifBlank { null } + ?.let { + var addedWSS = + if (!it.startsWith("wss://") && !it.startsWith("ws://")) "wss://$it" else it + if (addedWSS.endsWith("/")) addedWSS = addedWSS.dropLast(1) - fun removeAmount(amount: Long) { - amountSet = amountSet - amount - } + addedWSS + } - fun sendPost() { - account?.changeZapAmounts(amountSet) - account?.changeDefaultZapType(selectedZapType) - - if (walletConnectRelay.text.isNotBlank() && walletConnectPubkey.text.isNotBlank()) { - val pubkeyHex = try { - decodePublicKey(walletConnectPubkey.text.trim()).toHexKey() - } catch (e: Exception) { - null - } - - val relayUrl = walletConnectRelay.text.ifBlank { null }?.let { - var addedWSS = - if (!it.startsWith("wss://") && !it.startsWith("ws://")) "wss://$it" else it - if (addedWSS.endsWith("/")) addedWSS = addedWSS.dropLast(1) - - addedWSS - } - - val unverifiedPrivKey = walletConnectSecret.text.ifBlank { null } - val privKeyHex = try { - unverifiedPrivKey?.let { decodePublicKey(it).toHexKey() } - } catch (e: Exception) { - null - } - - if (pubkeyHex != null) { - account?.changeZapPaymentRequest( - Nip47URI( - pubkeyHex, - relayUrl, - privKeyHex - ) - ) - } else { - account?.changeZapPaymentRequest(null) - } - } else { - account?.changeZapPaymentRequest(null) + val unverifiedPrivKey = walletConnectSecret.text.ifBlank { null } + val privKeyHex = + try { + unverifiedPrivKey?.let { decodePublicKey(it).toHexKey() } + } catch (e: Exception) { + null } - nextAmount = TextFieldValue("") + if (pubkeyHex != null) { + account?.changeZapPaymentRequest( + Nip47URI( + pubkeyHex, + relayUrl, + privKeyHex, + ), + ) + } else { + account?.changeZapPaymentRequest(null) + } + } else { + account?.changeZapPaymentRequest(null) } - fun cancel() { - nextAmount = TextFieldValue("") - } + nextAmount = TextFieldValue("") + } - fun hasChanged(): Boolean { - return ( - selectedZapType != account?.defaultZapType || - amountSet != account?.zapAmountChoices || - walletConnectPubkey.text != (account?.zapPaymentRequest?.pubKeyHex ?: "") || - walletConnectRelay.text != (account?.zapPaymentRequest?.relayUri ?: "") || - walletConnectSecret.text != (account?.zapPaymentRequest?.secret ?: "") - ) - } + fun cancel() { + nextAmount = TextFieldValue("") + } - fun updateNIP47(uri: String) { - val contact = Nip47WalletConnectParser.parse(uri) - if (contact != null) { - walletConnectPubkey = - TextFieldValue(contact.pubKeyHex) - walletConnectRelay = - TextFieldValue(contact.relayUri ?: "") - walletConnectSecret = - TextFieldValue(contact.secret ?: "") - } - } + fun hasChanged(): Boolean { + return (selectedZapType != account?.defaultZapType || + amountSet != account?.zapAmountChoices || + walletConnectPubkey.text != (account?.zapPaymentRequest?.pubKeyHex ?: "") || + walletConnectRelay.text != (account?.zapPaymentRequest?.relayUri ?: "") || + walletConnectSecret.text != (account?.zapPaymentRequest?.secret ?: "")) + } - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create(modelClass: Class): UpdateZapAmountViewModel { - return UpdateZapAmountViewModel(account) as UpdateZapAmountViewModel - } + fun updateNIP47(uri: String) { + val contact = Nip47WalletConnectParser.parse(uri) + if (contact != null) { + walletConnectPubkey = TextFieldValue(contact.pubKeyHex) + walletConnectRelay = TextFieldValue(contact.relayUri ?: "") + walletConnectSecret = TextFieldValue(contact.secret ?: "") } + } + + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): UpdateZapAmountViewModel { + return UpdateZapAmountViewModel(account) as UpdateZapAmountViewModel + } + } } @OptIn(ExperimentalLayoutApi::class) @Composable fun UpdateZapAmountDialog( - onClose: () -> Unit, - nip47uri: String? = null, - accountViewModel: AccountViewModel + onClose: () -> Unit, + nip47uri: String? = null, + accountViewModel: AccountViewModel, ) { - val context = LocalContext.current - val scope = rememberCoroutineScope() + val context = LocalContext.current + val scope = rememberCoroutineScope() - val postViewModel: UpdateZapAmountViewModel = viewModel( - key = "UpdateZapAmountViewModel", - factory = UpdateZapAmountViewModel.Factory(accountViewModel.account) + val postViewModel: UpdateZapAmountViewModel = + viewModel( + key = "UpdateZapAmountViewModel", + factory = UpdateZapAmountViewModel.Factory(accountViewModel.account), ) - val uri = LocalUriHandler.current + val uri = LocalUriHandler.current - val zapTypes = listOf( - Triple(LnZapEvent.ZapType.PUBLIC, stringResource(id = R.string.zap_type_public), stringResource(id = R.string.zap_type_public_explainer)), - Triple(LnZapEvent.ZapType.PRIVATE, stringResource(id = R.string.zap_type_private), stringResource(id = R.string.zap_type_private_explainer)), - Triple(LnZapEvent.ZapType.ANONYMOUS, stringResource(id = R.string.zap_type_anonymous), stringResource(id = R.string.zap_type_anonymous_explainer)), - Triple(LnZapEvent.ZapType.NONZAP, stringResource(id = R.string.zap_type_nonzap), stringResource(id = R.string.zap_type_nonzap_explainer)) + val zapTypes = + listOf( + Triple( + LnZapEvent.ZapType.PUBLIC, + stringResource(id = R.string.zap_type_public), + stringResource(id = R.string.zap_type_public_explainer), + ), + Triple( + LnZapEvent.ZapType.PRIVATE, + stringResource(id = R.string.zap_type_private), + stringResource(id = R.string.zap_type_private_explainer), + ), + Triple( + LnZapEvent.ZapType.ANONYMOUS, + stringResource(id = R.string.zap_type_anonymous), + stringResource(id = R.string.zap_type_anonymous_explainer), + ), + Triple( + LnZapEvent.ZapType.NONZAP, + stringResource(id = R.string.zap_type_nonzap), + stringResource(id = R.string.zap_type_nonzap_explainer), + ), ) - val zapOptions = remember { zapTypes.map { TitleExplainer(it.second, it.third) }.toImmutableList() } + val zapOptions = remember { + zapTypes.map { TitleExplainer(it.second, it.third) }.toImmutableList() + } - LaunchedEffect(accountViewModel, nip47uri) { - postViewModel.load() - if (nip47uri != null) { - try { - postViewModel.updateNIP47(nip47uri) - } catch (e: IllegalArgumentException) { - if (e.message != null) { - accountViewModel.toast( - context.getString(R.string.error_parsing_nip47_title), - context.getString(R.string.error_parsing_nip47, nip47uri, e.message!!) - ) - } else { - accountViewModel.toast( - context.getString(R.string.error_parsing_nip47_title), - context.getString(R.string.error_parsing_nip47_no_error, nip47uri) - ) - } - } + LaunchedEffect(accountViewModel, nip47uri) { + postViewModel.load() + if (nip47uri != null) { + try { + postViewModel.updateNIP47(nip47uri) + } catch (e: IllegalArgumentException) { + if (e.message != null) { + accountViewModel.toast( + context.getString(R.string.error_parsing_nip47_title), + context.getString(R.string.error_parsing_nip47, nip47uri, e.message!!), + ) + } else { + accountViewModel.toast( + context.getString(R.string.error_parsing_nip47_title), + context.getString(R.string.error_parsing_nip47_no_error, nip47uri), + ) } + } } + } - Dialog( - onDismissRequest = { onClose() }, - properties = DialogProperties( - usePlatformDefaultWidth = false, - dismissOnClickOutside = false, - decorFitsSystemWindows = false - ) + Dialog( + onDismissRequest = { onClose() }, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + decorFitsSystemWindows = false, + ), + ) { + Surface( + modifier = Modifier.fillMaxWidth(), ) { - Surface( - modifier = Modifier - .fillMaxWidth() + Column(modifier = Modifier.padding(10.dp).imePadding()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Column(modifier = Modifier.padding(10.dp).imePadding()) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - CloseButton(onPress = { - postViewModel.cancel() - onClose() - }) + CloseButton( + onPress = { + postViewModel.cancel() + onClose() + }, + ) - SaveButton( - onPost = { - postViewModel.sendPost() - onClose() - }, - isActive = postViewModel.hasChanged() - ) - } - - Spacer(modifier = Modifier.height(10.dp)) - - Row( - modifier = Modifier - .fillMaxWidth() - ) { - Column( - modifier = Modifier.verticalScroll(rememberScrollState()) - ) { - Row(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.animateContentSize()) { - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - postViewModel.amountSet.forEach { amountInSats -> - Button( - modifier = Modifier.padding(horizontal = 3.dp), - shape = ButtonBorder, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ), - onClick = { - postViewModel.removeAmount(amountInSats) - } - ) { - Text( - "โšก ${ - showAmount( - amountInSats.toBigDecimal().setScale(1) - ) - } โœ–", - color = Color.White, - textAlign = TextAlign.Center - ) - } - } - } - } - } - - Spacer(modifier = Modifier.height(10.dp)) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.new_amount_in_sats)) }, - value = postViewModel.nextAmount, - onValueChange = { - postViewModel.nextAmount = it - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None, - keyboardType = KeyboardType.Number - ), - placeholder = { - Text( - text = "100, 1000, 5000", - color = MaterialTheme.colorScheme.placeholderText - ) - }, - singleLine = true, - modifier = Modifier - .padding(end = 10.dp) - .weight(1f) - ) - - Button( - onClick = { postViewModel.addAmount() }, - shape = ButtonBorder, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ) - ) { - Text(text = stringResource(R.string.add), color = Color.White) - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically - ) { - TextSpinner( - label = stringResource(id = R.string.zap_type_explainer), - placeholder = zapTypes.filter { it.first == accountViewModel.defaultZapType() } - .first().second, - options = zapOptions, - onSelect = { - postViewModel.selectedZapType = zapTypes[it].first - }, - modifier = Modifier - .weight(1f) - .padding(end = 5.dp) - ) - } - - Divider( - modifier = Modifier.padding(vertical = 10.dp), - thickness = DividerThickness - ) - - var qrScanning by remember { mutableStateOf(false) } - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - stringResource(id = R.string.wallet_connect_service), - Modifier.weight(1f) - ) - - /* TODO: Find a way to open this in the PWA - IconButton(onClick = { - onClose() - runCatching { uri.openUri("https://app.mutinywallet.com/settings/connections?callbackUri=nostr+walletconnect&name=Amethyst") } - }) { - Icon( - painter = painterResource(R.mipmap.mutiny), - null, - modifier = Modifier.size(24.dp), - tint = Color.Unspecified - ) - }*/ - - IconButton(onClick = { - onClose() - runCatching { uri.openUri("https://nwc.getalby.com/apps/new?c=Amethyst") } - }) { - Icon( - painter = painterResource(R.drawable.alby), - null, - modifier = Modifier.size(24.dp), - tint = Color.Unspecified - ) - } - - IconButton(onClick = { - qrScanning = true - }) { - Icon( - painter = painterResource(R.drawable.ic_qrcode), - null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary - ) - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 5.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - stringResource(id = R.string.wallet_connect_service_explainer), - Modifier.weight(1f), - color = MaterialTheme.colorScheme.placeholderText, - fontSize = Font14SP - ) - } - - if (qrScanning) { - SimpleQrCodeScanner { - qrScanning = false - if (!it.isNullOrEmpty()) { - try { - postViewModel.updateNIP47(it) - } catch (e: IllegalArgumentException) { - if (e.message != null) { - accountViewModel.toast( - context.getString(R.string.error_parsing_nip47_title), - context.getString(R.string.error_parsing_nip47, it, e.message!!) - ) - } else { - accountViewModel.toast( - context.getString(R.string.error_parsing_nip47_title), - context.getString(R.string.error_parsing_nip47_no_error, it) - ) - } - } - } - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.wallet_connect_service_pubkey)) }, - value = postViewModel.walletConnectPubkey, - onValueChange = { - postViewModel.walletConnectPubkey = it - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None - ), - placeholder = { - Text( - text = "npub, hex", - color = MaterialTheme.colorScheme.placeholderText - ) - }, - singleLine = true, - modifier = Modifier.weight(1f) - ) - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.wallet_connect_service_relay)) }, - modifier = Modifier.weight(1f), - value = postViewModel.walletConnectRelay, - onValueChange = { postViewModel.walletConnectRelay = it }, - placeholder = { - Text( - text = "wss://relay.server.com", - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1 - ) - }, - singleLine = true - ) - } - - var showPassword by remember { - mutableStateOf(false) - } - - val context = LocalContext.current - - val keyguardLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> - if (result.resultCode == Activity.RESULT_OK) { - showPassword = true - } - } - - val authTitle = - stringResource(id = R.string.wallet_connect_service_show_secret) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.wallet_connect_service_secret)) }, - modifier = Modifier.weight(1f), - value = postViewModel.walletConnectSecret, - onValueChange = { postViewModel.walletConnectSecret = it }, - keyboardOptions = KeyboardOptions( - autoCorrect = false, - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Go - ), - placeholder = { - Text( - text = stringResource(R.string.wallet_connect_service_secret_placeholder), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - trailingIcon = { - IconButton(onClick = { - if (!showPassword) { - authenticate( - title = authTitle, - context = context, - keyguardLauncher = keyguardLauncher, - onApproved = { - showPassword = true - }, - onError = { title, message -> - accountViewModel.toast(title, message) - } - ) - } else { - showPassword = false - } - }) { - Icon( - imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, - contentDescription = if (showPassword) { - stringResource(R.string.show_password) - } else { - stringResource( - R.string.hide_password - ) - } - ) - } - }, - visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation() - ) - } - } - } - } + SaveButton( + onPost = { + postViewModel.sendPost() + onClose() + }, + isActive = postViewModel.hasChanged(), + ) } + + Spacer(modifier = Modifier.height(10.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + ) { + Row(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.animateContentSize()) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + postViewModel.amountSet.forEach { amountInSats -> + Button( + modifier = Modifier.padding(horizontal = 3.dp), + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + onClick = { postViewModel.removeAmount(amountInSats) }, + ) { + Text( + "โšก ${ + showAmount( + amountInSats.toBigDecimal().setScale(1), + ) + } โœ–", + color = Color.White, + textAlign = TextAlign.Center, + ) + } + } + } + } + } + + Spacer(modifier = Modifier.height(10.dp)) + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.new_amount_in_sats)) }, + value = postViewModel.nextAmount, + onValueChange = { postViewModel.nextAmount = it }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Number, + ), + placeholder = { + Text( + text = "100, 1000, 5000", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + modifier = Modifier.padding(end = 10.dp).weight(1f), + ) + + Button( + onClick = { postViewModel.addAmount() }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text(text = stringResource(R.string.add), color = Color.White) + } + } + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TextSpinner( + label = stringResource(id = R.string.zap_type_explainer), + placeholder = + zapTypes.filter { it.first == accountViewModel.defaultZapType() }.first().second, + options = zapOptions, + onSelect = { postViewModel.selectedZapType = zapTypes[it].first }, + modifier = Modifier.weight(1f).padding(end = 5.dp), + ) + } + + Divider( + modifier = Modifier.padding(vertical = 10.dp), + thickness = DividerThickness, + ) + + var qrScanning by remember { mutableStateOf(false) } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(id = R.string.wallet_connect_service), + Modifier.weight(1f), + ) + + /* TODO: Find a way to open this in the PWA + IconButton(onClick = { + onClose() + runCatching { uri.openUri("https://app.mutinywallet.com/settings/connections?callbackUri=nostr+walletconnect&name=Amethyst") } + }) { + Icon( + painter = painterResource(R.mipmap.mutiny), + null, + modifier = Modifier.size(24.dp), + tint = Color.Unspecified + ) + }*/ + + IconButton( + onClick = { + onClose() + runCatching { uri.openUri("https://nwc.getalby.com/apps/new?c=Amethyst") } + }, + ) { + Icon( + painter = painterResource(R.drawable.alby), + null, + modifier = Modifier.size(24.dp), + tint = Color.Unspecified, + ) + } + + IconButton(onClick = { qrScanning = true }) { + Icon( + painter = painterResource(R.drawable.ic_qrcode), + null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(id = R.string.wallet_connect_service_explainer), + Modifier.weight(1f), + color = MaterialTheme.colorScheme.placeholderText, + fontSize = Font14SP, + ) + } + + if (qrScanning) { + SimpleQrCodeScanner { + qrScanning = false + if (!it.isNullOrEmpty()) { + try { + postViewModel.updateNIP47(it) + } catch (e: IllegalArgumentException) { + if (e.message != null) { + accountViewModel.toast( + context.getString(R.string.error_parsing_nip47_title), + context.getString(R.string.error_parsing_nip47, it, e.message!!), + ) + } else { + accountViewModel.toast( + context.getString(R.string.error_parsing_nip47_title), + context.getString(R.string.error_parsing_nip47_no_error, it), + ) + } + } + } + } + } + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.wallet_connect_service_pubkey)) }, + value = postViewModel.walletConnectPubkey, + onValueChange = { postViewModel.walletConnectPubkey = it }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + ), + placeholder = { + Text( + text = "npub, hex", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + } + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.wallet_connect_service_relay)) }, + modifier = Modifier.weight(1f), + value = postViewModel.walletConnectRelay, + onValueChange = { postViewModel.walletConnectRelay = it }, + placeholder = { + Text( + text = "wss://relay.server.com", + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) + }, + singleLine = true, + ) + } + + var showPassword by remember { mutableStateOf(false) } + + val context = LocalContext.current + + val keyguardLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + result: ActivityResult -> + if (result.resultCode == Activity.RESULT_OK) { + showPassword = true + } + } + + val authTitle = stringResource(id = R.string.wallet_connect_service_show_secret) + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.wallet_connect_service_secret)) }, + modifier = Modifier.weight(1f), + value = postViewModel.walletConnectSecret, + onValueChange = { postViewModel.walletConnectSecret = it }, + keyboardOptions = + KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + ), + placeholder = { + Text( + text = stringResource(R.string.wallet_connect_service_secret_placeholder), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + trailingIcon = { + IconButton( + onClick = { + if (!showPassword) { + authenticate( + title = authTitle, + context = context, + keyguardLauncher = keyguardLauncher, + onApproved = { showPassword = true }, + onError = { title, message -> accountViewModel.toast(title, message) }, + ) + } else { + showPassword = false + } + }, + ) { + Icon( + imageVector = + if (showPassword) { + Icons.Outlined.VisibilityOff + } else { + Icons.Outlined.Visibility + }, + contentDescription = + if (showPassword) { + stringResource(R.string.show_password) + } else { + stringResource( + R.string.hide_password, + ) + }, + ) + } + }, + visualTransformation = + if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), + ) + } + } + } + } } + } } fun authenticate( - title: String, - context: Context, - keyguardLauncher: ManagedActivityResultLauncher, - onApproved: () -> Unit, - onError: (String, String) -> Unit + title: String, + context: Context, + keyguardLauncher: ManagedActivityResultLauncher, + onApproved: () -> Unit, + onError: (String, String) -> Unit, ) { - val fragmentContext = context.getFragmentActivity()!! - val keyguardManager = - fragmentContext.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + val fragmentContext = context.getFragmentActivity()!! + val keyguardManager = + fragmentContext.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager - if (!keyguardManager.isDeviceSecure) { - onApproved() - return - } + if (!keyguardManager.isDeviceSecure) { + onApproved() + return + } - @Suppress("DEPRECATION") - fun keyguardPrompt() { - val intent = keyguardManager.createConfirmDeviceCredentialIntent( - context.getString(R.string.app_name_release), - title - ) + @Suppress("DEPRECATION") + fun keyguardPrompt() { + val intent = + keyguardManager.createConfirmDeviceCredentialIntent( + context.getString(R.string.app_name_release), + title, + ) - keyguardLauncher.launch(intent) - } + keyguardLauncher.launch(intent) + } - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) { - keyguardPrompt() - return - } + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) { + keyguardPrompt() + return + } - val biometricManager = BiometricManager.from(context) - val authenticators = BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL + val biometricManager = BiometricManager.from(context) + val authenticators = + BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.DEVICE_CREDENTIAL - val promptInfo = BiometricPrompt.PromptInfo.Builder() - .setTitle(context.getString(R.string.app_name_release)) - .setSubtitle(title) - .setAllowedAuthenticators(authenticators) - .build() + val promptInfo = + BiometricPrompt.PromptInfo.Builder() + .setTitle(context.getString(R.string.app_name_release)) + .setSubtitle(title) + .setAllowedAuthenticators(authenticators) + .build() - val biometricPrompt = BiometricPrompt( - fragmentContext, - object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - super.onAuthenticationError(errorCode, errString) + val biometricPrompt = + BiometricPrompt( + fragmentContext, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError( + errorCode: Int, + errString: CharSequence, + ) { + super.onAuthenticationError(errorCode, errString) - when (errorCode) { - BiometricPrompt.ERROR_NEGATIVE_BUTTON -> keyguardPrompt() - BiometricPrompt.ERROR_LOCKOUT -> keyguardPrompt() - else -> onError( - context.getString(R.string.biometric_authentication_failed), - context.getString(R.string.biometric_authentication_failed_explainer_with_error, errString) - ) - } - } - - override fun onAuthenticationFailed() { - super.onAuthenticationFailed() - onError( - context.getString(R.string.biometric_authentication_failed), - context.getString(R.string.biometric_authentication_failed_explainer) - ) - } - - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - super.onAuthenticationSucceeded(result) - onApproved() - } + when (errorCode) { + BiometricPrompt.ERROR_NEGATIVE_BUTTON -> keyguardPrompt() + BiometricPrompt.ERROR_LOCKOUT -> keyguardPrompt() + else -> + onError( + context.getString(R.string.biometric_authentication_failed), + context.getString( + R.string.biometric_authentication_failed_explainer_with_error, + errString, + ), + ) + } } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + onError( + context.getString(R.string.biometric_authentication_failed), + context.getString(R.string.biometric_authentication_failed_explainer), + ) + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + onApproved() + } + }, ) - when (biometricManager.canAuthenticate(authenticators)) { - BiometricManager.BIOMETRIC_SUCCESS -> biometricPrompt.authenticate(promptInfo) - else -> keyguardPrompt() - } + when (biometricManager.canAuthenticate(authenticators)) { + BiometricManager.BIOMETRIC_SUCCESS -> biometricPrompt.authenticate(promptInfo) + else -> keyguardPrompt() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt index 192a75ad4..cc051a971 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.note import androidx.compose.foundation.clickable @@ -18,41 +38,39 @@ import com.vitorpamplona.amethyst.ui.theme.StdPadding @Composable fun UserCompose( - baseUser: User, - overallModifier: Modifier = StdPadding, - showDiviser: Boolean = true, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseUser: User, + overallModifier: Modifier = StdPadding, + showDiviser: Boolean = true, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - Column( - modifier = - Modifier.clickable( - onClick = { nav("User/${baseUser.pubkeyHex}") } - ) + Column( + modifier = + Modifier.clickable( + onClick = { nav("User/${baseUser.pubkeyHex}") }, + ), + ) { + Row( + modifier = overallModifier, + verticalAlignment = Alignment.CenterVertically, ) { - Row( - modifier = overallModifier, - verticalAlignment = Alignment.CenterVertically - ) { - UserPicture(baseUser, Size55dp, accountViewModel = accountViewModel, nav = nav) + UserPicture(baseUser, Size55dp, accountViewModel = accountViewModel, nav = nav) - Column(modifier = remember { Modifier.padding(start = 10.dp).weight(1f) }) { - Row(verticalAlignment = Alignment.CenterVertically) { - UsernameDisplay(baseUser) - } + Column(modifier = remember { Modifier.padding(start = 10.dp).weight(1f) }) { + Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseUser) } - AboutDisplay(baseUser) - } + AboutDisplay(baseUser) + } - Column(modifier = remember { Modifier.padding(start = 10.dp) }) { - UserActionOptions(baseUser, accountViewModel) - } - } - - if (showDiviser) { - Divider( - thickness = DividerThickness - ) - } + Column(modifier = remember { Modifier.padding(start = 10.dp) }) { + UserActionOptions(baseUser, accountViewModel) + } } + + if (showDiviser) { + Divider( + thickness = DividerThickness, + ) + } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt index 90155c0b9..9eab528ff 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.note import android.content.Intent @@ -57,558 +77,606 @@ import kotlinx.coroutines.launch @Composable fun NoteAuthorPicture( - baseNote: Note, - nav: (String) -> Unit, - accountViewModel: AccountViewModel, - size: Dp, - pictureModifier: Modifier = Modifier + baseNote: Note, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, + size: Dp, + pictureModifier: Modifier = Modifier, ) { - NoteAuthorPicture(baseNote, size, accountViewModel, pictureModifier) { - nav("User/${it.pubkeyHex}") - } + NoteAuthorPicture(baseNote, size, accountViewModel, pictureModifier) { + nav("User/${it.pubkeyHex}") + } } @Composable fun NoteAuthorPicture( - baseNote: Note, - size: Dp, - accountViewModel: AccountViewModel, - modifier: Modifier = Modifier, - onClick: ((User) -> Unit)? = null + baseNote: Note, + size: Dp, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, + onClick: ((User) -> Unit)? = null, ) { - val author by baseNote.live().authorChanges.observeAsState(baseNote.author) + val author by baseNote.live().authorChanges.observeAsState(baseNote.author) - Crossfade(targetState = author, label = "NoteAuthorPicture") { - if (it == null) { - DisplayBlankAuthor(size, modifier) - } else { - ClickableUserPicture(it, size, accountViewModel, modifier, onClick) - } + Crossfade(targetState = author, label = "NoteAuthorPicture") { + if (it == null) { + DisplayBlankAuthor(size, modifier) + } else { + ClickableUserPicture(it, size, accountViewModel, modifier, onClick) } + } } @Composable -fun DisplayBlankAuthor(size: Dp, modifier: Modifier = Modifier) { - val backgroundColor = MaterialTheme.colorScheme.background +fun DisplayBlankAuthor( + size: Dp, + modifier: Modifier = Modifier, +) { + val backgroundColor = MaterialTheme.colorScheme.background - val nullModifier = remember { - modifier - .size(size) - .clip(shape = CircleShape) - .background(backgroundColor) - } + val nullModifier = remember { + modifier.size(size).clip(shape = CircleShape).background(backgroundColor) + } - RobohashAsyncImage( - robot = "authornotfound", - contentDescription = stringResource(R.string.unknown_author), - modifier = nullModifier - ) + RobohashAsyncImage( + robot = "authornotfound", + contentDescription = stringResource(R.string.unknown_author), + modifier = nullModifier, + ) } @Composable fun UserPicture( - userHex: String, - size: Dp, - pictureModifier: Modifier = Modifier, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + userHex: String, + size: Dp, + pictureModifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - LoadUser(baseUserHex = userHex, accountViewModel) { - if (it != null) { - UserPicture( - user = it, - size = size, - pictureModifier = pictureModifier, - accountViewModel = accountViewModel, - nav = nav - ) - } else { - DisplayBlankAuthor( - size, - pictureModifier - ) - } - } -} - -@Composable -fun UserPicture( - user: User, - size: Dp, - pictureModifier: Modifier = Modifier, - accountViewModel: AccountViewModel, - nav: (String) -> Unit -) { - ClickableUserPicture( - baseUser = user, + LoadUser(baseUserHex = userHex, accountViewModel) { + if (it != null) { + UserPicture( + user = it, size = size, + pictureModifier = pictureModifier, accountViewModel = accountViewModel, - modifier = pictureModifier, - onClick = { - nav("User/${user.pubkeyHex}") - } - ) + nav = nav, + ) + } else { + DisplayBlankAuthor( + size, + pictureModifier, + ) + } + } +} + +@Composable +fun UserPicture( + user: User, + size: Dp, + pictureModifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + ClickableUserPicture( + baseUser = user, + size = size, + accountViewModel = accountViewModel, + modifier = pictureModifier, + onClick = { nav("User/${user.pubkeyHex}") }, + ) } @OptIn(ExperimentalFoundationApi::class) @Composable fun ClickableUserPicture( - baseUser: User, - size: Dp, - accountViewModel: AccountViewModel, - modifier: Modifier = Modifier, - onClick: ((User) -> Unit)? = null, - onLongClick: ((User) -> Unit)? = null + baseUser: User, + size: Dp, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, + onClick: ((User) -> Unit)? = null, + onLongClick: ((User) -> Unit)? = null, ) { - val interactionSource = remember { MutableInteractionSource() } - val ripple = rememberRipple(bounded = false, radius = size) + val interactionSource = remember { MutableInteractionSource() } + val ripple = rememberRipple(bounded = false, radius = size) - // BaseUser is the same reference as accountState.user - val myModifier = remember { - if (onClick != null && onLongClick != null) { - Modifier - .size(size) - .combinedClickable( - onClick = { onClick(baseUser) }, - onLongClick = { onLongClick(baseUser) }, - role = Role.Button, - interactionSource = interactionSource, - indication = ripple - ) - } else if (onClick != null) { - Modifier - .size(size) - .clickable( - onClick = { onClick(baseUser) }, - role = Role.Button, - interactionSource = interactionSource, - indication = ripple - ) - } else { - Modifier.size(size) - } + // BaseUser is the same reference as accountState.user + val myModifier = remember { + if (onClick != null && onLongClick != null) { + Modifier.size(size) + .combinedClickable( + onClick = { onClick(baseUser) }, + onLongClick = { onLongClick(baseUser) }, + role = Role.Button, + interactionSource = interactionSource, + indication = ripple, + ) + } else if (onClick != null) { + Modifier.size(size) + .clickable( + onClick = { onClick(baseUser) }, + role = Role.Button, + interactionSource = interactionSource, + indication = ripple, + ) + } else { + Modifier.size(size) } + } - Box(modifier = myModifier, contentAlignment = Alignment.TopEnd) { - BaseUserPicture(baseUser, size, accountViewModel, modifier) - } + Box(modifier = myModifier, contentAlignment = Alignment.TopEnd) { + BaseUserPicture(baseUser, size, accountViewModel, modifier) + } } @Composable fun NonClickableUserPictures( - users: ImmutableSet, - size: Dp, - accountViewModel: AccountViewModel + users: ImmutableSet, + size: Dp, + accountViewModel: AccountViewModel, ) { - val myBoxModifier = remember { - Modifier.size(size) - } + val myBoxModifier = remember { Modifier.size(size) } - Box(myBoxModifier, contentAlignment = Alignment.TopEnd) { - val userList = remember(users) { - users.toList() - } + Box(myBoxModifier, contentAlignment = Alignment.TopEnd) { + val userList = remember(users) { users.toList() } - when (userList.size) { - 0 -> {} - 1 -> LoadUser(baseUserHex = userList[0], accountViewModel) { - it?.let { - BaseUserPicture(it, size, accountViewModel, outerModifier = Modifier) - } - } - 2 -> { - LoadUser(baseUserHex = userList[0], accountViewModel) { - it?.let { - BaseUserPicture(it, size.div(1.5f), accountViewModel, outerModifier = Modifier.align(Alignment.CenterStart)) - } - } - LoadUser(baseUserHex = userList[1], accountViewModel) { - it?.let { - BaseUserPicture(it, size.div(1.5f), accountViewModel, outerModifier = Modifier.align(Alignment.CenterEnd)) - } - } - } - 3 -> { - LoadUser(baseUserHex = userList[0], accountViewModel) { - it?.let { - BaseUserPicture(it, size.div(1.8f), accountViewModel, outerModifier = Modifier.align(Alignment.BottomStart)) - } - } - LoadUser(baseUserHex = userList[1], accountViewModel) { - it?.let { - BaseUserPicture(it, size.div(1.8f), accountViewModel, outerModifier = Modifier.align(Alignment.TopCenter)) - } - } - LoadUser(baseUserHex = userList[2], accountViewModel) { - it?.let { - BaseUserPicture(it, size.div(1.8f), accountViewModel, outerModifier = Modifier.align(Alignment.BottomEnd)) - } - } - } - else -> { - LoadUser(baseUserHex = userList[0], accountViewModel) { - it?.let { - BaseUserPicture(it, size.div(2f), accountViewModel, outerModifier = Modifier.align(Alignment.BottomStart)) - } - } - LoadUser(baseUserHex = userList[1], accountViewModel) { - it?.let { - BaseUserPicture(it, size.div(2f), accountViewModel, outerModifier = Modifier.align(Alignment.TopStart)) - } - } - LoadUser(baseUserHex = userList[2], accountViewModel) { - it?.let { - BaseUserPicture(it, size.div(2f), accountViewModel, outerModifier = Modifier.align(Alignment.BottomEnd)) - } - } - LoadUser(baseUserHex = userList[3], accountViewModel) { - it?.let { - BaseUserPicture(it, size.div(2f), accountViewModel, outerModifier = Modifier.align(Alignment.TopEnd)) - } - } - } + when (userList.size) { + 0 -> {} + 1 -> + LoadUser(baseUserHex = userList[0], accountViewModel) { + it?.let { BaseUserPicture(it, size, accountViewModel, outerModifier = Modifier) } } + 2 -> { + LoadUser(baseUserHex = userList[0], accountViewModel) { + it?.let { + BaseUserPicture( + it, + size.div(1.5f), + accountViewModel, + outerModifier = Modifier.align(Alignment.CenterStart), + ) + } + } + LoadUser(baseUserHex = userList[1], accountViewModel) { + it?.let { + BaseUserPicture( + it, + size.div(1.5f), + accountViewModel, + outerModifier = Modifier.align(Alignment.CenterEnd), + ) + } + } + } + 3 -> { + LoadUser(baseUserHex = userList[0], accountViewModel) { + it?.let { + BaseUserPicture( + it, + size.div(1.8f), + accountViewModel, + outerModifier = Modifier.align(Alignment.BottomStart), + ) + } + } + LoadUser(baseUserHex = userList[1], accountViewModel) { + it?.let { + BaseUserPicture( + it, + size.div(1.8f), + accountViewModel, + outerModifier = Modifier.align(Alignment.TopCenter), + ) + } + } + LoadUser(baseUserHex = userList[2], accountViewModel) { + it?.let { + BaseUserPicture( + it, + size.div(1.8f), + accountViewModel, + outerModifier = Modifier.align(Alignment.BottomEnd), + ) + } + } + } + else -> { + LoadUser(baseUserHex = userList[0], accountViewModel) { + it?.let { + BaseUserPicture( + it, + size.div(2f), + accountViewModel, + outerModifier = Modifier.align(Alignment.BottomStart), + ) + } + } + LoadUser(baseUserHex = userList[1], accountViewModel) { + it?.let { + BaseUserPicture( + it, + size.div(2f), + accountViewModel, + outerModifier = Modifier.align(Alignment.TopStart), + ) + } + } + LoadUser(baseUserHex = userList[2], accountViewModel) { + it?.let { + BaseUserPicture( + it, + size.div(2f), + accountViewModel, + outerModifier = Modifier.align(Alignment.BottomEnd), + ) + } + } + LoadUser(baseUserHex = userList[3], accountViewModel) { + it?.let { + BaseUserPicture( + it, + size.div(2f), + accountViewModel, + outerModifier = Modifier.align(Alignment.TopEnd), + ) + } + } + } } + } } @Composable fun BaseUserPicture( - baseUser: User, - size: Dp, - accountViewModel: AccountViewModel, - innerModifier: Modifier = Modifier, - outerModifier: Modifier = remember { Modifier.size(size) } + baseUser: User, + size: Dp, + accountViewModel: AccountViewModel, + innerModifier: Modifier = Modifier, + outerModifier: Modifier = remember { Modifier.size(size) }, ) { - val myIconSize by remember(size) { - derivedStateOf { - size.div(3.5f) - } + val myIconSize by remember(size) { derivedStateOf { size.div(3.5f) } } + + Box(outerModifier, contentAlignment = Alignment.TopEnd) { + LoadUserProfilePicture(baseUser) { userProfilePicture -> + InnerUserPicture( + userHex = baseUser.pubkeyHex, + userPicture = userProfilePicture, + size = size, + modifier = innerModifier, + accountViewModel = accountViewModel, + ) } - Box(outerModifier, contentAlignment = Alignment.TopEnd) { - LoadUserProfilePicture(baseUser) { userProfilePicture -> - InnerUserPicture( - userHex = baseUser.pubkeyHex, - userPicture = userProfilePicture, - size = size, - modifier = innerModifier, - accountViewModel = accountViewModel - ) - } - - ObserveAndDisplayFollowingMark(baseUser.pubkeyHex, myIconSize, accountViewModel) - } + ObserveAndDisplayFollowingMark(baseUser.pubkeyHex, myIconSize, accountViewModel) + } } @Composable fun LoadUserProfilePicture( - baseUser: User, - innerContent: @Composable (String?) -> Unit + baseUser: User, + innerContent: @Composable (String?) -> Unit, ) { - val userProfile by baseUser.live().profilePictureChanges.observeAsState(baseUser.profilePicture()) + val userProfile by baseUser.live().profilePictureChanges.observeAsState(baseUser.profilePicture()) - innerContent(userProfile) + innerContent(userProfile) } @Composable fun InnerUserPicture( - userHex: String, - userPicture: String?, - size: Dp, - modifier: Modifier, - accountViewModel: AccountViewModel + userHex: String, + userPicture: String?, + size: Dp, + modifier: Modifier, + accountViewModel: AccountViewModel, ) { - val backgroundColor = MaterialTheme.colorScheme.background - val myImageModifier = remember { - modifier - .size(size) - .clip(shape = CircleShape) - .background(backgroundColor) - } + val backgroundColor = MaterialTheme.colorScheme.background + val myImageModifier = remember { + modifier.size(size).clip(shape = CircleShape).background(backgroundColor) + } - val automaticallyShowProfilePicture = remember { - accountViewModel.settings.showProfilePictures.value - } + val automaticallyShowProfilePicture = remember { + accountViewModel.settings.showProfilePictures.value + } - RobohashFallbackAsyncImage( - robot = userHex, - model = userPicture, - contentDescription = stringResource(id = R.string.profile_image), - modifier = myImageModifier, - contentScale = ContentScale.Crop, - loadProfilePicture = automaticallyShowProfilePicture - ) + RobohashFallbackAsyncImage( + robot = userHex, + model = userPicture, + contentDescription = stringResource(id = R.string.profile_image), + modifier = myImageModifier, + contentScale = ContentScale.Crop, + loadProfilePicture = automaticallyShowProfilePicture, + ) } @Composable -fun ObserveAndDisplayFollowingMark(userHex: String, iconSize: Dp, accountViewModel: AccountViewModel) { - WatchUserFollows(userHex, accountViewModel) { newFollowingState -> - AnimatedVisibility( - visible = newFollowingState, - enter = remember { fadeIn() }, - exit = remember { fadeOut() } - ) { - FollowingIcon(iconSize) - } +fun ObserveAndDisplayFollowingMark( + userHex: String, + iconSize: Dp, + accountViewModel: AccountViewModel, +) { + WatchUserFollows(userHex, accountViewModel) { newFollowingState -> + AnimatedVisibility( + visible = newFollowingState, + enter = remember { fadeIn() }, + exit = remember { fadeOut() }, + ) { + FollowingIcon(iconSize) } + } } @Composable -fun WatchUserFollows(userHex: String, accountViewModel: AccountViewModel, onFollowChanges: @Composable (Boolean) -> Unit) { - val showFollowingMark by remember { - accountViewModel.userFollows.map { - it.user.isFollowingCached(userHex) || (userHex == accountViewModel.account.userProfile().pubkeyHex) - }.distinctUntilChanged() - }.observeAsState( +fun WatchUserFollows( + userHex: String, + accountViewModel: AccountViewModel, + onFollowChanges: @Composable (Boolean) -> Unit, +) { + val showFollowingMark by + remember { + accountViewModel.userFollows + .map { + it.user.isFollowingCached(userHex) || + (userHex == accountViewModel.account.userProfile().pubkeyHex) + } + .distinctUntilChanged() + } + .observeAsState( accountViewModel.account.userProfile().isFollowingCached(userHex) || - (userHex == accountViewModel.account.userProfile().pubkeyHex) - ) + (userHex == accountViewModel.account.userProfile().pubkeyHex), + ) - onFollowChanges(showFollowingMark) + onFollowChanges(showFollowingMark) } @Immutable data class DropDownParams( - val isFollowingAuthor: Boolean, - val isPrivateBookmarkNote: Boolean, - val isPublicBookmarkNote: Boolean, - val isLoggedUser: Boolean, - val isSensitive: Boolean, - val showSensitiveContent: Boolean? + val isFollowingAuthor: Boolean, + val isPrivateBookmarkNote: Boolean, + val isPublicBookmarkNote: Boolean, + val isLoggedUser: Boolean, + val isSensitive: Boolean, + val showSensitiveContent: Boolean?, ) @Composable -fun NoteDropDownMenu(note: Note, popupExpanded: MutableState, accountViewModel: AccountViewModel) { - var reportDialogShowing by remember { mutableStateOf(false) } +fun NoteDropDownMenu( + note: Note, + popupExpanded: MutableState, + accountViewModel: AccountViewModel, +) { + var reportDialogShowing by remember { mutableStateOf(false) } - var state by remember { - mutableStateOf( - DropDownParams( - isFollowingAuthor = false, - isPrivateBookmarkNote = false, - isPublicBookmarkNote = false, - isLoggedUser = false, - isSensitive = false, - showSensitiveContent = null - ) - ) + var state by remember { + mutableStateOf( + DropDownParams( + isFollowingAuthor = false, + isPrivateBookmarkNote = false, + isPublicBookmarkNote = false, + isLoggedUser = false, + isSensitive = false, + showSensitiveContent = null, + ), + ) + } + + val onDismiss = remember(popupExpanded) { { popupExpanded.value = false } } + + DropdownMenu( + expanded = popupExpanded.value, + onDismissRequest = onDismiss, + ) { + val clipboardManager = LocalClipboardManager.current + val appContext = LocalContext.current.applicationContext + val actContext = LocalContext.current + + WatchBookmarksFollowsAndAccount(note, accountViewModel) { newState -> + if (state != newState) { + state = newState + } } - val onDismiss = remember(popupExpanded) { - { popupExpanded.value = false } + val scope = rememberCoroutineScope() + + if (!state.isFollowingAuthor) { + DropdownMenuItem( + text = { Text(stringResource(R.string.follow)) }, + onClick = { + val author = note.author ?: return@DropdownMenuItem + accountViewModel.follow(author) + onDismiss() + }, + ) + Divider() } + DropdownMenuItem( + text = { Text(stringResource(R.string.copy_text)) }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.decrypt(note) { clipboardManager.setText(AnnotatedString(it)) } + onDismiss() + } + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.copy_user_pubkey)) }, + onClick = { + scope.launch(Dispatchers.IO) { + clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}")) + onDismiss() + } + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.copy_note_id)) }, + onClick = { + scope.launch(Dispatchers.IO) { + clipboardManager.setText(AnnotatedString("nostr:" + note.toNEvent())) + onDismiss() + } + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.quick_action_share)) }, + onClick = { + val sendIntent = + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, + externalLinkForNote(note), + ) + putExtra( + Intent.EXTRA_TITLE, + actContext.getString(R.string.quick_action_share_browser_link), + ) + } - DropdownMenu( - expanded = popupExpanded.value, - onDismissRequest = onDismiss - ) { - val clipboardManager = LocalClipboardManager.current - val appContext = LocalContext.current.applicationContext - val actContext = LocalContext.current - - WatchBookmarksFollowsAndAccount(note, accountViewModel) { newState -> - if (state != newState) { - state = newState - } - } - - val scope = rememberCoroutineScope() - - if (!state.isFollowingAuthor) { - DropdownMenuItem( - text = { - Text(stringResource(R.string.follow)) - }, - onClick = { - val author = note.author ?: return@DropdownMenuItem - accountViewModel.follow(author) - onDismiss() - } - ) - Divider() - } - DropdownMenuItem( - text = { - Text(stringResource(R.string.copy_text)) - }, - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.decrypt(note) { - clipboardManager.setText(AnnotatedString(it)) - } - onDismiss() - } - } - ) - DropdownMenuItem( - text = { - Text(stringResource(R.string.copy_user_pubkey)) - }, - onClick = { - scope.launch(Dispatchers.IO) { - clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}")) - onDismiss() - } - } - ) - DropdownMenuItem( - text = { - Text(stringResource(R.string.copy_note_id)) - }, - onClick = { - scope.launch(Dispatchers.IO) { - clipboardManager.setText(AnnotatedString("nostr:" + note.toNEvent())) - onDismiss() - } - } - ) - DropdownMenuItem( - text = { - Text(stringResource(R.string.quick_action_share)) - }, - onClick = { - val sendIntent = Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra( - Intent.EXTRA_TEXT, - externalLinkForNote(note) - ) - putExtra(Intent.EXTRA_TITLE, actContext.getString(R.string.quick_action_share_browser_link)) - } - - val shareIntent = Intent.createChooser(sendIntent, appContext.getString(R.string.quick_action_share)) - ContextCompat.startActivity(actContext, shareIntent, null) - onDismiss() - } - ) - Divider() - DropdownMenuItem( - text = { - Text(stringResource(R.string.broadcast)) - }, - onClick = { - scope.launch(Dispatchers.IO) { accountViewModel.broadcast(note); onDismiss() } - } - ) - Divider() - if (state.isPrivateBookmarkNote) { - DropdownMenuItem( - text = { - Text(stringResource(R.string.remove_from_private_bookmarks)) - }, - onClick = { - accountViewModel.removePrivateBookmark(note) - onDismiss() - } - ) - } else { - DropdownMenuItem( - text = { - Text(stringResource(R.string.add_to_private_bookmarks)) - }, - onClick = { - accountViewModel.addPrivateBookmark(note) - onDismiss() - } - ) - } - if (state.isPublicBookmarkNote) { - DropdownMenuItem( - text = { - Text(stringResource(R.string.remove_from_public_bookmarks)) - }, - onClick = { - accountViewModel.removePublicBookmark(note) - onDismiss() - } - ) - } else { - DropdownMenuItem( - text = { - Text(stringResource(R.string.add_to_public_bookmarks)) - }, - onClick = { - accountViewModel.addPublicBookmark(note) - onDismiss() - } - ) - } - Divider() - if (state.showSensitiveContent == null || state.showSensitiveContent == true) { - DropdownMenuItem( - text = { - Text(stringResource(R.string.content_warning_hide_all_sensitive_content)) - }, - onClick = { scope.launch(Dispatchers.IO) { accountViewModel.hideSensitiveContent(); onDismiss() } } - ) - } - if (state.showSensitiveContent == null || state.showSensitiveContent == false) { - DropdownMenuItem( - text = { - Text(stringResource(R.string.content_warning_show_all_sensitive_content)) - }, - onClick = { scope.launch(Dispatchers.IO) { accountViewModel.disableContentWarnings(); onDismiss() } } - ) - } - if (state.showSensitiveContent != null) { - DropdownMenuItem( - text = { - Text(stringResource(R.string.content_warning_see_warnings)) - }, - onClick = { scope.launch(Dispatchers.IO) { accountViewModel.seeContentWarnings(); onDismiss() } } - ) - } - Divider() - if (state.isLoggedUser) { - DropdownMenuItem( - text = { - Text(stringResource(R.string.request_deletion)) - }, - onClick = { scope.launch(Dispatchers.IO) { accountViewModel.delete(note); onDismiss() } } - ) - } else { - DropdownMenuItem( - text = { - Text(stringResource(R.string.block_report)) - }, - onClick = { reportDialogShowing = true } - ) + val shareIntent = + Intent.createChooser(sendIntent, appContext.getString(R.string.quick_action_share)) + ContextCompat.startActivity(actContext, shareIntent, null) + onDismiss() + }, + ) + Divider() + DropdownMenuItem( + text = { Text(stringResource(R.string.broadcast)) }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.broadcast(note) + onDismiss() } + }, + ) + Divider() + if (state.isPrivateBookmarkNote) { + DropdownMenuItem( + text = { Text(stringResource(R.string.remove_from_private_bookmarks)) }, + onClick = { + accountViewModel.removePrivateBookmark(note) + onDismiss() + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.add_to_private_bookmarks)) }, + onClick = { + accountViewModel.addPrivateBookmark(note) + onDismiss() + }, + ) } - - if (reportDialogShowing) { - ReportNoteDialog(note = note, accountViewModel = accountViewModel) { - reportDialogShowing = false + if (state.isPublicBookmarkNote) { + DropdownMenuItem( + text = { Text(stringResource(R.string.remove_from_public_bookmarks)) }, + onClick = { + accountViewModel.removePublicBookmark(note) + onDismiss() + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.add_to_public_bookmarks)) }, + onClick = { + accountViewModel.addPublicBookmark(note) + onDismiss() + }, + ) + } + Divider() + if (state.showSensitiveContent == null || state.showSensitiveContent == true) { + DropdownMenuItem( + text = { Text(stringResource(R.string.content_warning_hide_all_sensitive_content)) }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.hideSensitiveContent() onDismiss() - } + } + }, + ) } + if (state.showSensitiveContent == null || state.showSensitiveContent == false) { + DropdownMenuItem( + text = { Text(stringResource(R.string.content_warning_show_all_sensitive_content)) }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.disableContentWarnings() + onDismiss() + } + }, + ) + } + if (state.showSensitiveContent != null) { + DropdownMenuItem( + text = { Text(stringResource(R.string.content_warning_see_warnings)) }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.seeContentWarnings() + onDismiss() + } + }, + ) + } + Divider() + if (state.isLoggedUser) { + DropdownMenuItem( + text = { Text(stringResource(R.string.request_deletion)) }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.delete(note) + onDismiss() + } + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.block_report)) }, + onClick = { reportDialogShowing = true }, + ) + } + } + + if (reportDialogShowing) { + ReportNoteDialog(note = note, accountViewModel = accountViewModel) { + reportDialogShowing = false + onDismiss() + } + } } @Composable -fun WatchBookmarksFollowsAndAccount(note: Note, accountViewModel: AccountViewModel, onNew: (DropDownParams) -> Unit) { - val followState by accountViewModel.userProfile().live().follows.observeAsState() - val bookmarkState by accountViewModel.userProfile().live().bookmarks.observeAsState() - val showSensitiveContent by accountViewModel.showSensitiveContentChanges.observeAsState(accountViewModel.account.showSensitiveContent) +fun WatchBookmarksFollowsAndAccount( + note: Note, + accountViewModel: AccountViewModel, + onNew: (DropDownParams) -> Unit, +) { + val followState by accountViewModel.userProfile().live().follows.observeAsState() + val bookmarkState by accountViewModel.userProfile().live().bookmarks.observeAsState() + val showSensitiveContent by + accountViewModel.showSensitiveContentChanges.observeAsState( + accountViewModel.account.showSensitiveContent, + ) - LaunchedEffect(key1 = followState, key2 = bookmarkState, key3 = showSensitiveContent) { - launch(Dispatchers.IO) { - accountViewModel.isInPrivateBookmarks(note) { - val newState = DropDownParams( - isFollowingAuthor = accountViewModel.isFollowing(note.author), - isPrivateBookmarkNote = it, - isPublicBookmarkNote = accountViewModel.isInPublicBookmarks(note), - isLoggedUser = accountViewModel.isLoggedUser(note.author), - isSensitive = note.event?.isSensitive() ?: false, - showSensitiveContent = showSensitiveContent - ) + LaunchedEffect(key1 = followState, key2 = bookmarkState, key3 = showSensitiveContent) { + launch(Dispatchers.IO) { + accountViewModel.isInPrivateBookmarks(note) { + val newState = + DropDownParams( + isFollowingAuthor = accountViewModel.isFollowing(note.author), + isPrivateBookmarkNote = it, + isPublicBookmarkNote = accountViewModel.isInPublicBookmarks(note), + isLoggedUser = accountViewModel.isLoggedUser(note.author), + isSensitive = note.event?.isSensitive() ?: false, + showSensitiveContent = showSensitiveContent, + ) - launch(Dispatchers.Main) { - onNew( - newState - ) - } - } + launch(Dispatchers.Main) { + onNew( + newState, + ) } + } } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt index 8930d0db2..db4a5d560 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.note import android.util.Log @@ -53,6 +73,11 @@ import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.ReactionEvent import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.TextNoteEvent +import java.math.BigDecimal +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -60,394 +85,401 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import java.math.BigDecimal -import java.time.Instant -import java.time.LocalDateTime -import java.time.ZoneId -import java.time.format.DateTimeFormatter @Composable fun UserReactionsRow( - model: UserReactionsViewModel, - onClick: () -> Unit + model: UserReactionsViewModel, + onClick: () -> Unit, ) { - Row( - verticalAlignment = CenterVertically, - modifier = Modifier - .clickable(onClick = onClick) - .padding(10.dp) - ) { - Row(verticalAlignment = CenterVertically, modifier = Modifier.width(68.dp)) { - Text( - text = stringResource(id = R.string.today), - fontWeight = FontWeight.Bold - ) + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.clickable(onClick = onClick).padding(10.dp), + ) { + Row(verticalAlignment = CenterVertically, modifier = Modifier.width(68.dp)) { + Text( + text = stringResource(id = R.string.today), + fontWeight = FontWeight.Bold, + ) - Icon( - imageVector = Icons.Default.ExpandMore, - null, - modifier = Size20Modifier, - tint = MaterialTheme.colorScheme.placeholderText - ) - } - - Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) { - UserReplyModel(model) - } - - Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) { - UserBoostModel(model) - } - - Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) { - UserReactionModel(model) - } - - Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) { - UserZapModel(model) - } + Icon( + imageVector = Icons.Default.ExpandMore, + null, + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.placeholderText, + ) } + + Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) { + UserReplyModel(model) + } + + Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) { + UserBoostModel(model) + } + + Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) { + UserReactionModel(model) + } + + Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) { + UserZapModel(model) + } + } } @Composable private fun UserZapModel(model: UserReactionsViewModel) { - Icon( - imageVector = Icons.Default.Bolt, - contentDescription = stringResource(R.string.zaps), - modifier = Size24Modifier, - tint = BitcoinOrange - ) + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = stringResource(R.string.zaps), + modifier = Size24Modifier, + tint = BitcoinOrange, + ) - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(8.dp)) - UserZapReaction(model) + UserZapReaction(model) } @Composable private fun UserReactionModel(model: UserReactionsViewModel) { - Icon( - painter = painterResource(R.drawable.ic_liked), - null, - modifier = Size20Modifier, - tint = Color.Unspecified - ) + Icon( + painter = painterResource(R.drawable.ic_liked), + null, + modifier = Size20Modifier, + tint = Color.Unspecified, + ) - Spacer(modifier = StdHorzSpacer) + Spacer(modifier = StdHorzSpacer) - UserLikeReaction(model) + UserLikeReaction(model) } @Composable private fun UserBoostModel(model: UserReactionsViewModel) { - Icon( - painter = painterResource(R.drawable.ic_retweeted), - null, - modifier = Size24Modifier, - tint = Color.Unspecified - ) + Icon( + painter = painterResource(R.drawable.ic_retweeted), + null, + modifier = Size24Modifier, + tint = Color.Unspecified, + ) - Spacer(modifier = StdHorzSpacer) + Spacer(modifier = StdHorzSpacer) - UserBoostReaction(model) + UserBoostReaction(model) } @Composable private fun UserReplyModel(model: UserReactionsViewModel) { - Icon( - painter = painterResource(R.drawable.ic_comment), - null, - modifier = Size20Modifier, - tint = RoyalBlue - ) + Icon( + painter = painterResource(R.drawable.ic_comment), + null, + modifier = Size20Modifier, + tint = RoyalBlue, + ) - Spacer(modifier = StdHorzSpacer) + Spacer(modifier = StdHorzSpacer) - UserReplyReaction(model) + UserReplyReaction(model) } @Stable class UserReactionsViewModel(val account: Account) : ViewModel() { - val user: User = account.userProfile() + val user: User = account.userProfile() - private var _reactions = MutableStateFlow>(emptyMap()) - private var _boosts = MutableStateFlow>(emptyMap()) - private var _zaps = MutableStateFlow>(emptyMap()) - private var _replies = MutableStateFlow>(emptyMap()) + private var _reactions = MutableStateFlow>(emptyMap()) + private var _boosts = MutableStateFlow>(emptyMap()) + private var _zaps = MutableStateFlow>(emptyMap()) + private var _replies = MutableStateFlow>(emptyMap()) - private var _chartModel = MutableStateFlow?>(null) - private var _axisLabels = MutableStateFlow>(emptyList()) + private var _chartModel = MutableStateFlow?>(null) + private var _axisLabels = MutableStateFlow>(emptyList()) - val reactions = _reactions.asStateFlow() - val boosts = _boosts.asStateFlow() - val zaps = _zaps.asStateFlow() - val replies = _replies.asStateFlow() + val reactions = _reactions.asStateFlow() + val boosts = _boosts.asStateFlow() + val zaps = _zaps.asStateFlow() + val replies = _replies.asStateFlow() - val chartModel = _chartModel.asStateFlow() - val axisLabels = _axisLabels.asStateFlow() + val chartModel = _chartModel.asStateFlow() + val axisLabels = _axisLabels.asStateFlow() - private var takenIntoAccount = setOf() - private val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat() + private var takenIntoAccount = setOf() + private val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat() - val todaysReplyCount = _replies.map { showCount(it[today()]) }.distinctUntilChanged() - val todaysBoostCount = _boosts.map { showCount(it[today()]) }.distinctUntilChanged() - val todaysReactionCount = _reactions.map { showCount(it[today()]) }.distinctUntilChanged() - val todaysZapAmount = _zaps.map { showAmountAxis(it[today()]) }.distinctUntilChanged() + val todaysReplyCount = _replies.map { showCount(it[today()]) }.distinctUntilChanged() + val todaysBoostCount = _boosts.map { showCount(it[today()]) }.distinctUntilChanged() + val todaysReactionCount = _reactions.map { showCount(it[today()]) }.distinctUntilChanged() + val todaysZapAmount = _zaps.map { showAmountAxis(it[today()]) }.distinctUntilChanged() - var shouldShowDecimalsInAxis = false + var shouldShowDecimalsInAxis = false - fun formatDate(createAt: Long): String { - return sdf.format( - Instant.ofEpochSecond(createAt) - .atZone(ZoneId.systemDefault()) - .toLocalDateTime() + fun formatDate(createAt: Long): String { + return sdf.format( + Instant.ofEpochSecond(createAt).atZone(ZoneId.systemDefault()).toLocalDateTime(), + ) + } + + fun today() = sdf.format(LocalDateTime.now()) + + private suspend fun initializeSuspend() { + checkNotInMainThread() + + val currentUser = user.pubkeyHex + + val reactions = mutableMapOf() + val boosts = mutableMapOf() + val zaps = mutableMapOf() + val replies = mutableMapOf() + val takenIntoAccount = mutableSetOf() + + LocalCache.notes.values.forEach { + val noteEvent = it.event + if (noteEvent != null && !takenIntoAccount.contains(noteEvent.id())) { + if (noteEvent is ReactionEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + reactions[netDate] = (reactions[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + } + } else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey() != currentUser) { + val netDate = formatDate(noteEvent.createdAt()) + boosts[netDate] = (boosts[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + } + } else if (noteEvent is LnZapEvent) { + if ( + noteEvent.isTaggedUser(currentUser) + ) { // the user might be sending his own receipts noteEvent.pubKey != currentUser + val netDate = formatDate(noteEvent.createdAt) + zaps[netDate] = + (zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO) + takenIntoAccount.add(noteEvent.id()) + } + } else if (noteEvent is TextNoteEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + replies[netDate] = (replies[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + } + } + } + } + + this.takenIntoAccount = takenIntoAccount + this._reactions.emit(reactions) + this._replies.emit(replies) + this._zaps.emit(zaps) + this._boosts.emit(boosts) + + refreshChartModel() + } + + suspend fun addToStatsSuspend(newNotes: Set) { + checkNotInMainThread() + + val currentUser = user.pubkeyHex + + val reactions = this._reactions.value.toMutableMap() + val boosts = this._boosts.value.toMutableMap() + val zaps = this._zaps.value.toMutableMap() + val replies = this._replies.value.toMutableMap() + val takenIntoAccount = this.takenIntoAccount.toMutableSet() + var hasNewElements = false + + newNotes.forEach { + val noteEvent = it.event + if (noteEvent != null && !takenIntoAccount.contains(noteEvent.id())) { + if (noteEvent is ReactionEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + reactions[netDate] = (reactions[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + hasNewElements = true + } + } else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey() != currentUser) { + val netDate = formatDate(noteEvent.createdAt()) + boosts[netDate] = (boosts[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + hasNewElements = true + } + } else if (noteEvent is LnZapEvent) { + if ( + noteEvent.isTaggedUser(currentUser) + ) { // && noteEvent.pubKey != currentUser User might be sending his own receipts + val netDate = formatDate(noteEvent.createdAt) + zaps[netDate] = + (zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO) + takenIntoAccount.add(noteEvent.id()) + hasNewElements = true + } + } else if (noteEvent is TextNoteEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + replies[netDate] = (replies[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + hasNewElements = true + } + } + } + } + + if (hasNewElements) { + this.takenIntoAccount = takenIntoAccount + this._reactions.emit(reactions) + this._replies.emit(replies) + this._zaps.emit(zaps) + this._boosts.emit(boosts) + + refreshChartModel() + } + } + + private suspend fun refreshChartModel() { + checkNotInMainThread() + + val day = 24 * 60 * 60L + val now = LocalDateTime.now() + val displayAxisFormatter = DateTimeFormatter.ofPattern("EEE") + + val dataAxisLabels = listOf(6, 5, 4, 3, 2, 1, 0).map { sdf.format(now.minusSeconds(day * it)) } + + val listOfCountCurves = + listOf( + dataAxisLabels.mapIndexed { index, dateStr -> + entryOf(index, _replies.value[dateStr]?.toFloat() ?: 0f) + }, + dataAxisLabels.mapIndexed { index, dateStr -> + entryOf(index, _boosts.value[dateStr]?.toFloat() ?: 0f) + }, + dataAxisLabels.mapIndexed { index, dateStr -> + entryOf(index, _reactions.value[dateStr]?.toFloat() ?: 0f) + }, + ) + + val listOfValueCurves = + listOf( + dataAxisLabels.mapIndexed { index, dateStr -> + entryOf(index, _zaps.value[dateStr]?.toFloat() ?: 0f) + }, + ) + + val chartEntryModelProducer1 = ChartEntryModelProducer(listOfCountCurves).getModel() + val chartEntryModelProducer2 = ChartEntryModelProducer(listOfValueCurves).getModel() + + chartEntryModelProducer1?.let { chart1 -> + chartEntryModelProducer2?.let { chart2 -> + this.shouldShowDecimalsInAxis = shouldShowDecimals(chart2.minY, chart2.maxY) + + this._axisLabels.emit( + listOf(6, 5, 4, 3, 2, 1, 0).map { + displayAxisFormatter.format(now.minusSeconds(day * it)) + }, ) + this._chartModel.emit(chart1.plus(chart2)) + } + } + } + + // determine if the min max are so close that they render to the same number. + fun shouldShowDecimals( + min: Float, + max: Float, + ): Boolean { + val step = (max - min) / 8 + + var previous = showAmountAxis(min.toBigDecimal()) + for (i in 1..7) { + val current = showAmountAxis((min + (i * step)).toBigDecimal()) + if (previous == current) { + return true + } + previous = current } - fun today() = sdf.format(LocalDateTime.now()) + return false + } - private suspend fun initializeSuspend() { - checkNotInMainThread() + var collectorJob: Job? = null - val currentUser = user.pubkeyHex + init { + Log.d("Init", "User Reactions Row") + viewModelScope.launch(Dispatchers.IO) { + initializeSuspend() - val reactions = mutableMapOf() - val boosts = mutableMapOf() - val zaps = mutableMapOf() - val replies = mutableMapOf() - val takenIntoAccount = mutableSetOf() - - LocalCache.notes.values.forEach { - val noteEvent = it.event - if (noteEvent != null && !takenIntoAccount.contains(noteEvent.id())) { - if (noteEvent is ReactionEvent) { - if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { - val netDate = formatDate(noteEvent.createdAt) - reactions[netDate] = (reactions[netDate] ?: 0) + 1 - takenIntoAccount.add(noteEvent.id()) - } - } else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) { - if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey() != currentUser) { - val netDate = formatDate(noteEvent.createdAt()) - boosts[netDate] = (boosts[netDate] ?: 0) + 1 - takenIntoAccount.add(noteEvent.id()) - } - } else if (noteEvent is LnZapEvent) { - if (noteEvent.isTaggedUser(currentUser)) { // the user might be sending his own receipts noteEvent.pubKey != currentUser - val netDate = formatDate(noteEvent.createdAt) - zaps[netDate] = (zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO) - takenIntoAccount.add(noteEvent.id()) - } - } else if (noteEvent is TextNoteEvent) { - if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { - val netDate = formatDate(noteEvent.createdAt) - replies[netDate] = (replies[netDate] ?: 0) + 1 - takenIntoAccount.add(noteEvent.id()) - } - } - } - } - - this.takenIntoAccount = takenIntoAccount - this._reactions.emit(reactions) - this._replies.emit(replies) - this._zaps.emit(zaps) - this._boosts.emit(boosts) - - refreshChartModel() - } - - suspend fun addToStatsSuspend(newNotes: Set) { - checkNotInMainThread() - - val currentUser = user.pubkeyHex - - val reactions = this._reactions.value.toMutableMap() - val boosts = this._boosts.value.toMutableMap() - val zaps = this._zaps.value.toMutableMap() - val replies = this._replies.value.toMutableMap() - val takenIntoAccount = this.takenIntoAccount.toMutableSet() - var hasNewElements = false - - newNotes.forEach { - val noteEvent = it.event - if (noteEvent != null && !takenIntoAccount.contains(noteEvent.id())) { - if (noteEvent is ReactionEvent) { - if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { - val netDate = formatDate(noteEvent.createdAt) - reactions[netDate] = (reactions[netDate] ?: 0) + 1 - takenIntoAccount.add(noteEvent.id()) - hasNewElements = true - } - } else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) { - if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey() != currentUser) { - val netDate = formatDate(noteEvent.createdAt()) - boosts[netDate] = (boosts[netDate] ?: 0) + 1 - takenIntoAccount.add(noteEvent.id()) - hasNewElements = true - } - } else if (noteEvent is LnZapEvent) { - if (noteEvent.isTaggedUser(currentUser)) { // && noteEvent.pubKey != currentUser User might be sending his own receipts - val netDate = formatDate(noteEvent.createdAt) - zaps[netDate] = (zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO) - takenIntoAccount.add(noteEvent.id()) - hasNewElements = true - } - } else if (noteEvent is TextNoteEvent) { - if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { - val netDate = formatDate(noteEvent.createdAt) - replies[netDate] = (replies[netDate] ?: 0) + 1 - takenIntoAccount.add(noteEvent.id()) - hasNewElements = true - } - } - } - } - - if (hasNewElements) { - this.takenIntoAccount = takenIntoAccount - this._reactions.emit(reactions) - this._replies.emit(replies) - this._zaps.emit(zaps) - this._boosts.emit(boosts) - - refreshChartModel() - } - } - - private suspend fun refreshChartModel() { - checkNotInMainThread() - - val day = 24 * 60 * 60L - val now = LocalDateTime.now() - val displayAxisFormatter = DateTimeFormatter.ofPattern("EEE") - - val dataAxisLabels = listOf(6, 5, 4, 3, 2, 1, 0).map { sdf.format(now.minusSeconds(day * it)) } - - val listOfCountCurves = listOf( - dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, _replies.value[dateStr]?.toFloat() ?: 0f) }, - dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, _boosts.value[dateStr]?.toFloat() ?: 0f) }, - dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, _reactions.value[dateStr]?.toFloat() ?: 0f) } - ) - - val listOfValueCurves = listOf( - dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, _zaps.value[dateStr]?.toFloat() ?: 0f) } - ) - - val chartEntryModelProducer1 = ChartEntryModelProducer(listOfCountCurves).getModel() - val chartEntryModelProducer2 = ChartEntryModelProducer(listOfValueCurves).getModel() - - chartEntryModelProducer1?.let { chart1 -> - chartEntryModelProducer2?.let { chart2 -> - this.shouldShowDecimalsInAxis = shouldShowDecimals(chart2.minY, chart2.maxY) - - this._axisLabels.emit(listOf(6, 5, 4, 3, 2, 1, 0).map { displayAxisFormatter.format(now.minusSeconds(day * it)) }) - this._chartModel.emit(chart1.plus(chart2)) - } - } - } - - // determine if the min max are so close that they render to the same number. - fun shouldShowDecimals(min: Float, max: Float): Boolean { - val step = (max - min) / 8 - - var previous = showAmountAxis(min.toBigDecimal()) - for (i in 1..7) { - val current = showAmountAxis((min + (i * step)).toBigDecimal()) - if (previous == current) { - return true - } - previous = current - } - - return false - } - - var collectorJob: Job? = null - - init { - Log.d("Init", "User Reactions Row") + collectorJob = viewModelScope.launch(Dispatchers.IO) { - initializeSuspend() + LocalCache.live.newEventBundles.collect { newNotes -> + checkNotInMainThread() - collectorJob = viewModelScope.launch(Dispatchers.IO) { - LocalCache.live.newEventBundles.collect { newNotes -> - checkNotInMainThread() - - invalidateInsertData(newNotes) - } - } + invalidateInsertData(newNotes) + } } } + } - private val bundlerInsert = BundledInsert>(250, Dispatchers.IO) + private val bundlerInsert = BundledInsert>(250, Dispatchers.IO) - fun invalidateInsertData(newItems: Set) { - bundlerInsert.invalidateList(newItems) { - addToStatsSuspend(it.flatten().toSet()) - } - } - - override fun onCleared() { - collectorJob?.cancel() - bundlerInsert.cancel() - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") - super.onCleared() - } - - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create(modelClass: Class): UserReactionsViewModel { - return UserReactionsViewModel(account) as UserReactionsViewModel - } + fun invalidateInsertData(newItems: Set) { + bundlerInsert.invalidateList(newItems) { addToStatsSuspend(it.flatten().toSet()) } + } + + override fun onCleared() { + collectorJob?.cancel() + bundlerInsert.cancel() + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + super.onCleared() + } + + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): UserReactionsViewModel { + return UserReactionsViewModel(account) as UserReactionsViewModel } + } } @Composable -fun UserReplyReaction( - model: UserReactionsViewModel -) { - val showCounts by model.todaysReplyCount.collectAsStateWithLifecycle("") +fun UserReplyReaction(model: UserReactionsViewModel) { + val showCounts by model.todaysReplyCount.collectAsStateWithLifecycle("") - Text( - showCounts, - fontWeight = FontWeight.Bold, - fontSize = 18.sp - ) + Text( + showCounts, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ) } @Composable -fun UserBoostReaction( - model: UserReactionsViewModel -) { - val boosts by model.todaysBoostCount.collectAsStateWithLifecycle("") +fun UserBoostReaction(model: UserReactionsViewModel) { + val boosts by model.todaysBoostCount.collectAsStateWithLifecycle("") - Text( - boosts, - fontWeight = FontWeight.Bold, - fontSize = 18.sp - ) + Text( + boosts, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ) } @Composable -fun UserLikeReaction( - model: UserReactionsViewModel -) { - val reactions by model.todaysReactionCount.collectAsStateWithLifecycle("") +fun UserLikeReaction(model: UserReactionsViewModel) { + val reactions by model.todaysReactionCount.collectAsStateWithLifecycle("") - Text( - text = reactions, - fontWeight = FontWeight.Bold, - fontSize = 18.sp - ) + Text( + text = reactions, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ) } @Composable -fun UserZapReaction( - model: UserReactionsViewModel -) { - val amount by model.todaysZapAmount.collectAsStateWithLifecycle("") - Text( - amount, - fontWeight = FontWeight.Bold, - fontSize = 18.sp - ) +fun UserZapReaction(model: UserReactionsViewModel) { + val amount by model.todaysZapAmount.collectAsStateWithLifecycle("") + Text( + amount, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index e345b90dc..9e3dcebca 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.note import android.content.Context @@ -31,161 +51,181 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.events.ImmutableListOfLists @Composable -fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier, showPlayButton: Boolean = true, textColor: Color = Color.Unspecified) { - val authorState by baseNote.live().authorChanges.observeAsState(baseNote.author) +fun NoteUsernameDisplay( + baseNote: Note, + weight: Modifier = Modifier, + showPlayButton: Boolean = true, + textColor: Color = Color.Unspecified, +) { + val authorState by baseNote.live().authorChanges.observeAsState(baseNote.author) - Crossfade(targetState = authorState, modifier = weight, label = "NoteUsernameDisplay") { - it?.let { - UsernameDisplay(it, weight, showPlayButton, textColor = textColor) - } - } + Crossfade(targetState = authorState, modifier = weight, label = "NoteUsernameDisplay") { + it?.let { UsernameDisplay(it, weight, showPlayButton, textColor = textColor) } + } } @Composable -fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier, showPlayButton: Boolean = true, fontWeight: FontWeight = FontWeight.Bold, textColor: Color = Color.Unspecified) { - val npubDisplay by remember { - derivedStateOf { - baseUser.pubkeyDisplayHex() - } - } +fun UsernameDisplay( + baseUser: User, + weight: Modifier = Modifier, + showPlayButton: Boolean = true, + fontWeight: FontWeight = FontWeight.Bold, + textColor: Color = Color.Unspecified, +) { + val npubDisplay by remember { derivedStateOf { baseUser.pubkeyDisplayHex() } } - val userMetadata by baseUser.live().userMetadataInfo.observeAsState(baseUser.info) + val userMetadata by baseUser.live().userMetadataInfo.observeAsState(baseUser.info) - Crossfade(targetState = userMetadata, modifier = weight, label = "UsernameDisplay") { - if (it != null) { - UserNameDisplay(it.bestUsername(), it.bestDisplayName(), npubDisplay, it.tags, weight, showPlayButton, fontWeight, textColor) - } else { - NPubDisplay(npubDisplay, weight, fontWeight, textColor) - } + Crossfade(targetState = userMetadata, modifier = weight, label = "UsernameDisplay") { + if (it != null) { + UserNameDisplay( + it.bestUsername(), + it.bestDisplayName(), + npubDisplay, + it.tags, + weight, + showPlayButton, + fontWeight, + textColor, + ) + } else { + NPubDisplay(npubDisplay, weight, fontWeight, textColor) } + } } @Composable private fun UserNameDisplay( - bestUserName: String?, - bestDisplayName: String?, - npubDisplay: String, - tags: ImmutableListOfLists?, - modifier: Modifier, - showPlayButton: Boolean = true, - fontWeight: FontWeight = FontWeight.Bold, - textColor: Color = Color.Unspecified + bestUserName: String?, + bestDisplayName: String?, + npubDisplay: String, + tags: ImmutableListOfLists?, + modifier: Modifier, + showPlayButton: Boolean = true, + fontWeight: FontWeight = FontWeight.Bold, + textColor: Color = Color.Unspecified, ) { - if (bestUserName != null && bestDisplayName != null && bestDisplayName != bestUserName) { - UserAndUsernameDisplay(bestDisplayName.trim(), tags, bestUserName.trim(), modifier, showPlayButton, fontWeight, textColor) - } else if (bestDisplayName != null) { - UserDisplay(bestDisplayName.trim(), tags, modifier, showPlayButton, fontWeight, textColor) - } else if (bestUserName != null) { - UserDisplay(bestUserName.trim(), tags, modifier, showPlayButton, fontWeight, textColor) - } else { - NPubDisplay(npubDisplay, modifier, fontWeight, textColor) - } + if (bestUserName != null && bestDisplayName != null && bestDisplayName != bestUserName) { + UserAndUsernameDisplay( + bestDisplayName.trim(), + tags, + bestUserName.trim(), + modifier, + showPlayButton, + fontWeight, + textColor, + ) + } else if (bestDisplayName != null) { + UserDisplay(bestDisplayName.trim(), tags, modifier, showPlayButton, fontWeight, textColor) + } else if (bestUserName != null) { + UserDisplay(bestUserName.trim(), tags, modifier, showPlayButton, fontWeight, textColor) + } else { + NPubDisplay(npubDisplay, modifier, fontWeight, textColor) + } } @Composable -fun NPubDisplay(npubDisplay: String, modifier: Modifier, fontWeight: FontWeight = FontWeight.Bold, textColor: Color = Color.Unspecified) { - Text( - text = npubDisplay, - fontWeight = fontWeight, - modifier = modifier, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = textColor - ) +fun NPubDisplay( + npubDisplay: String, + modifier: Modifier, + fontWeight: FontWeight = FontWeight.Bold, + textColor: Color = Color.Unspecified, +) { + Text( + text = npubDisplay, + fontWeight = fontWeight, + modifier = modifier, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = textColor, + ) } @Composable private fun UserDisplay( - bestDisplayName: String, - tags: ImmutableListOfLists?, - modifier: Modifier, - showPlayButton: Boolean = true, - fontWeight: FontWeight = FontWeight.Bold, - textColor: Color = Color.Unspecified + bestDisplayName: String, + tags: ImmutableListOfLists?, + modifier: Modifier, + showPlayButton: Boolean = true, + fontWeight: FontWeight = FontWeight.Bold, + textColor: Color = Color.Unspecified, ) { - Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { - CreateTextWithEmoji( - text = bestDisplayName, - tags = tags, - fontWeight = fontWeight, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = modifier, - color = textColor - ) - if (showPlayButton) { - Spacer(StdHorzSpacer) - DrawPlayName(bestDisplayName) - } + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + CreateTextWithEmoji( + text = bestDisplayName, + tags = tags, + fontWeight = fontWeight, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + color = textColor, + ) + if (showPlayButton) { + Spacer(StdHorzSpacer) + DrawPlayName(bestDisplayName) } + } } @Composable private fun UserAndUsernameDisplay( - bestDisplayName: String, - tags: ImmutableListOfLists?, - bestUserName: String, - modifier: Modifier, - showPlayButton: Boolean = true, - fontWeight: FontWeight = FontWeight.Bold, - textColor: Color = Color.Unspecified + bestDisplayName: String, + tags: ImmutableListOfLists?, + bestUserName: String, + modifier: Modifier, + showPlayButton: Boolean = true, + fontWeight: FontWeight = FontWeight.Bold, + textColor: Color = Color.Unspecified, ) { - Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { - CreateTextWithEmoji( - text = bestDisplayName, - tags = tags, - fontWeight = fontWeight, - maxLines = 1, - modifier = modifier, - color = textColor - ) - /* - CreateTextWithEmoji( - text = remember { "@$bestUserName" }, - tags = tags, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + CreateTextWithEmoji( + text = bestDisplayName, + tags = tags, + fontWeight = fontWeight, + maxLines = 1, + modifier = modifier, + color = textColor, + ) + /* + CreateTextWithEmoji( + text = remember { "@$bestUserName" }, + tags = tags, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, - )*/ - if (showPlayButton) { - Spacer(StdHorzSpacer) - DrawPlayName(bestDisplayName) - } + )*/ + if (showPlayButton) { + Spacer(StdHorzSpacer) + DrawPlayName(bestDisplayName) } + } } @Composable fun DrawPlayName(name: String) { - val context = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current - DrawPlayNameIcon { - speak(name, context, lifecycleOwner) - } + DrawPlayNameIcon { speak(name, context, lifecycleOwner) } } @Composable fun DrawPlayNameIcon(onClick: () -> Unit) { - IconButton(onClick = onClick, modifier = StdButtonSizeModifier) { - PlayIcon(modifier = StdButtonSizeModifier, tint = MaterialTheme.colorScheme.placeholderText) - } + IconButton(onClick = onClick, modifier = StdButtonSizeModifier) { + PlayIcon(modifier = StdButtonSizeModifier, tint = MaterialTheme.colorScheme.placeholderText) + } } private fun speak( - message: String, - context: Context, - owner: LifecycleOwner + message: String, + context: Context, + owner: LifecycleOwner, ) { - TextToSpeechHelper - .getInstance(context) - .registerLifecycle(owner) - .speak(message) - .highlight() - .onDone { - Log.d("TextToSpeak", "speak: done") - } - .onError { - Log.d("TextToSpeak", "speak error: $it") - } + TextToSpeechHelper.getInstance(context) + .registerLifecycle(owner) + .speak(message) + .highlight() + .onDone { Log.d("TextToSpeak", "speak: done") } + .onError { Log.d("TextToSpeak", "speak error: $it") } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index bd38b67c7..5fd290a8b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -1,434 +1,478 @@ -package com.vitorpamplona.amethyst.ui.note - -import android.content.Context -import android.content.Intent -import android.net.Uri -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Done -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonColors -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -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.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextOverflow -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 androidx.core.content.ContextCompat -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.ZapPaymentHandler -import com.vitorpamplona.amethyst.ui.actions.CloseButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner -import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer -import com.vitorpamplona.amethyst.ui.theme.ButtonBorder -import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer -import com.vitorpamplona.amethyst.ui.theme.Size10dp -import com.vitorpamplona.amethyst.ui.theme.Size16dp -import com.vitorpamplona.amethyst.ui.theme.Size55dp -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.ZeroPadding -import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.events.LnZapEvent -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList - -class ZapOptionstViewModel : ViewModel() { - private var account: Account? = null - - var customAmount by mutableStateOf(TextFieldValue("21")) - var customMessage by mutableStateOf(TextFieldValue("")) - - fun load(account: Account) { - this.account = account - } - - fun canSend(): Boolean { - return value() != null - } - - fun value(): Long? { - return try { - customAmount.text.trim().toLongOrNull() - } catch (e: Exception) { - null - } - } - - fun cancel() { - } -} - -@Composable -fun ZapCustomDialog( - onClose: () -> Unit, - onError: (title: String, text: String) -> Unit, - onProgress: (percent: Float) -> Unit, - onPayViaIntent: (ImmutableList) -> Unit, - accountViewModel: AccountViewModel, - baseNote: Note -) { - val context = LocalContext.current - val postViewModel: ZapOptionstViewModel = viewModel() - - LaunchedEffect(accountViewModel) { - postViewModel.load(accountViewModel.account) - } - - val zapTypes = listOf( - Triple(LnZapEvent.ZapType.PUBLIC, stringResource(id = R.string.zap_type_public), stringResource(id = R.string.zap_type_public_explainer)), - Triple(LnZapEvent.ZapType.PRIVATE, stringResource(id = R.string.zap_type_private), stringResource(id = R.string.zap_type_private_explainer)), - Triple(LnZapEvent.ZapType.ANONYMOUS, stringResource(id = R.string.zap_type_anonymous), stringResource(id = R.string.zap_type_anonymous_explainer)), - Triple(LnZapEvent.ZapType.NONZAP, stringResource(id = R.string.zap_type_nonzap), stringResource(id = R.string.zap_type_nonzap_explainer)) - ) - - val zapOptions = remember { zapTypes.map { TitleExplainer(it.second, it.third) }.toImmutableList() } - var selectedZapType by remember(accountViewModel) { mutableStateOf(accountViewModel.account.defaultZapType) } - - Dialog( - onDismissRequest = { onClose() }, - properties = DialogProperties( - dismissOnClickOutside = false, - usePlatformDefaultWidth = false - ) - ) { - Surface() { - Column(modifier = Modifier.padding(10.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - CloseButton(onPress = { - postViewModel.cancel() - onClose() - }) - - ZapButton( - isActive = postViewModel.canSend() - ) { - accountViewModel.zap( - baseNote, - postViewModel.value()!! * 1000L, - null, - postViewModel.customMessage.text, - context, - onError = onError, - onProgress = onProgress, - onPayViaIntent = onPayViaIntent, - zapType = selectedZapType - ) - onClose() - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 5.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedTextField( - // stringResource(R.string.new_amount_in_sats - label = { Text(text = stringResource(id = R.string.amount_in_sats)) }, - value = postViewModel.customAmount, - onValueChange = { - postViewModel.customAmount = it - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None, - keyboardType = KeyboardType.Number - ), - placeholder = { - Text( - text = "100, 1000, 5000", - color = MaterialTheme.colorScheme.placeholderText - ) - }, - singleLine = true, - modifier = Modifier - .padding(end = 5.dp) - .weight(1f) - ) - - TextSpinner( - label = stringResource(id = R.string.zap_type), - placeholder = zapTypes.filter { it.first == accountViewModel.account.defaultZapType }.first().second, - options = zapOptions, - onSelect = { - selectedZapType = zapTypes[it].first - }, - modifier = Modifier - .weight(1f) - .padding(end = 5.dp) - ) - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedTextField( - // stringResource(R.string.new_amount_in_sats - label = { - if (selectedZapType == LnZapEvent.ZapType.PUBLIC || selectedZapType == LnZapEvent.ZapType.ANONYMOUS) { - Text(text = stringResource(id = R.string.custom_zaps_add_a_message)) - } else if (selectedZapType == LnZapEvent.ZapType.PRIVATE) { - Text(text = stringResource(id = R.string.custom_zaps_add_a_message_private)) - } else if (selectedZapType == LnZapEvent.ZapType.NONZAP) { - Text(text = stringResource(id = R.string.custom_zaps_add_a_message_nonzap)) - } - }, - value = postViewModel.customMessage, - onValueChange = { - postViewModel.customMessage = it - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None, - keyboardType = KeyboardType.Text - ), - placeholder = { - Text( - text = stringResource(id = R.string.custom_zaps_add_a_message_example), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - singleLine = true, - modifier = Modifier - .padding(end = 5.dp) - .weight(1f) - ) - } - } - } - } -} - -@Composable -fun ZapButton(isActive: Boolean, onPost: () -> Unit) { - Button( - onClick = { onPost() }, - shape = ButtonBorder, - colors = ButtonDefaults.buttonColors( - containerColor = if (isActive) MaterialTheme.colorScheme.primary else Color.Gray - ) - ) { - Text(text = "โšกZap ", color = Color.White) - } -} - -@Composable -fun ErrorMessageDialog( - title: String, - textContent: String, - buttonColors: ButtonColors = ButtonDefaults.buttonColors(), - onClickStartMessage: (() -> Unit)? = null, - onDismiss: () -> Unit -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text(title) - }, - text = { - SelectionContainer { - Text(textContent) - } - }, - confirmButton = { - Row( - modifier = Modifier - .padding(vertical = 8.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - onClickStartMessage?.let { - TextButton(onClick = onClickStartMessage) { - Icon( - painter = painterResource(R.drawable.ic_dm), - contentDescription = null - ) - Spacer(StdHorzSpacer) - Text(stringResource(R.string.error_dialog_talk_to_user)) - } - } - Button(onClick = onDismiss, colors = buttonColors, contentPadding = PaddingValues(horizontal = Size16dp)) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Outlined.Done, - contentDescription = null - ) - Spacer(StdHorzSpacer) - Text(stringResource(R.string.error_dialog_button_ok)) - } - } - } - } - ) -} - -@Composable -fun PayViaIntentDialog( - payingInvoices: ImmutableList, - accountViewModel: AccountViewModel, - onClose: () -> Unit, - onError: (String) -> Unit -) { - val context = LocalContext.current - - if (payingInvoices.size == 1) { - payViaIntent(payingInvoices.first().invoice, context, onError) - onClose() - } else { - Dialog( - onDismissRequest = onClose, - properties = DialogProperties( - dismissOnClickOutside = false, - usePlatformDefaultWidth = false - ) - ) { - Surface() { - Column(modifier = Modifier.padding(10.dp)) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - CloseButton(onPress = onClose) - } - - Spacer(modifier = DoubleVertSpacer) - - payingInvoices.forEachIndexed { index, it -> - val paid = remember { - mutableStateOf(false) - } - - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size10dp)) { - if (it.user != null) { - BaseUserPicture(it.user, Size55dp, accountViewModel = accountViewModel) - } else { - DisplayBlankAuthor(size = Size55dp) - } - - Spacer(modifier = DoubleHorzSpacer) - - Column(modifier = Modifier.weight(1f)) { - if (it.user != null) { - UsernameDisplay(it.user, showPlayButton = false) - } else { - Text( - text = stringResource(id = R.string.wallet_number, index + 1), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - fontWeight = FontWeight.Bold, - fontSize = 18.sp - ) - } - Row() { - Text( - text = showAmount((it.amountMilliSats / 1000.0f).toBigDecimal()), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - fontWeight = FontWeight.Bold, - fontSize = 18.sp - ) - Spacer(modifier = StdHorzSpacer) - Text( - text = stringResource(id = R.string.sats), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - fontWeight = FontWeight.Bold, - fontSize = 18.sp - ) - } - } - - Spacer(modifier = DoubleHorzSpacer) - - PayButton(isActive = !paid.value) { - paid.value = true - - payViaIntent(it.invoice, context, onError) - } - } - } - } - } - } - } -} - -fun payViaIntent(invoice: String, context: Context, onError: (String) -> Unit) { - try { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$invoice")) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - - ContextCompat.startActivity(context, intent, null) - } catch (e: Exception) { - if (e.message != null) { - onError(context.getString(R.string.no_wallet_found_with_error, e.message!!)) - } else { - onError(context.getString(R.string.no_wallet_found)) - } - } -} - -@Composable -fun PayButton(isActive: Boolean, modifier: Modifier = Modifier, onPost: () -> Unit = {}) { - Button( - modifier = modifier, - onClick = onPost, - shape = ButtonBorder, - colors = ButtonDefaults.buttonColors( - containerColor = if (isActive) MaterialTheme.colorScheme.primary else Color.Gray - ), - contentPadding = ZeroPadding - ) { - if (isActive) { - Text(text = stringResource(R.string.pay), color = Color.White) - } else { - Text(text = stringResource(R.string.paid), color = Color.White) - } - } -} +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Done +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +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 androidx.core.content.ContextCompat +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.ZapPaymentHandler +import com.vitorpamplona.amethyst.ui.actions.CloseButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner +import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer +import com.vitorpamplona.amethyst.ui.theme.ButtonBorder +import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size16dp +import com.vitorpamplona.amethyst.ui.theme.Size55dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.ZeroPadding +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.events.LnZapEvent +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +class ZapOptionstViewModel : ViewModel() { + private var account: Account? = null + + var customAmount by mutableStateOf(TextFieldValue("21")) + var customMessage by mutableStateOf(TextFieldValue("")) + + fun load(account: Account) { + this.account = account + } + + fun canSend(): Boolean { + return value() != null + } + + fun value(): Long? { + return try { + customAmount.text.trim().toLongOrNull() + } catch (e: Exception) { + null + } + } + + fun cancel() {} +} + +@Composable +fun ZapCustomDialog( + onClose: () -> Unit, + onError: (title: String, text: String) -> Unit, + onProgress: (percent: Float) -> Unit, + onPayViaIntent: (ImmutableList) -> Unit, + accountViewModel: AccountViewModel, + baseNote: Note, +) { + val context = LocalContext.current + val postViewModel: ZapOptionstViewModel = viewModel() + + LaunchedEffect(accountViewModel) { postViewModel.load(accountViewModel.account) } + + val zapTypes = + listOf( + Triple( + LnZapEvent.ZapType.PUBLIC, + stringResource(id = R.string.zap_type_public), + stringResource(id = R.string.zap_type_public_explainer), + ), + Triple( + LnZapEvent.ZapType.PRIVATE, + stringResource(id = R.string.zap_type_private), + stringResource(id = R.string.zap_type_private_explainer), + ), + Triple( + LnZapEvent.ZapType.ANONYMOUS, + stringResource(id = R.string.zap_type_anonymous), + stringResource(id = R.string.zap_type_anonymous_explainer), + ), + Triple( + LnZapEvent.ZapType.NONZAP, + stringResource(id = R.string.zap_type_nonzap), + stringResource(id = R.string.zap_type_nonzap_explainer), + ), + ) + + val zapOptions = remember { + zapTypes.map { TitleExplainer(it.second, it.third) }.toImmutableList() + } + var selectedZapType by + remember(accountViewModel) { mutableStateOf(accountViewModel.account.defaultZapType) } + + Dialog( + onDismissRequest = { onClose() }, + properties = + DialogProperties( + dismissOnClickOutside = false, + usePlatformDefaultWidth = false, + ), + ) { + Surface { + Column(modifier = Modifier.padding(10.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + CloseButton( + onPress = { + postViewModel.cancel() + onClose() + }, + ) + + ZapButton( + isActive = postViewModel.canSend(), + ) { + accountViewModel.zap( + baseNote, + postViewModel.value()!! * 1000L, + null, + postViewModel.customMessage.text, + context, + onError = onError, + onProgress = onProgress, + onPayViaIntent = onPayViaIntent, + zapType = selectedZapType, + ) + onClose() + } + } + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + // stringResource(R.string.new_amount_in_sats + label = { Text(text = stringResource(id = R.string.amount_in_sats)) }, + value = postViewModel.customAmount, + onValueChange = { postViewModel.customAmount = it }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Number, + ), + placeholder = { + Text( + text = "100, 1000, 5000", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + modifier = Modifier.padding(end = 5.dp).weight(1f), + ) + + TextSpinner( + label = stringResource(id = R.string.zap_type), + placeholder = + zapTypes + .filter { it.first == accountViewModel.account.defaultZapType } + .first() + .second, + options = zapOptions, + onSelect = { selectedZapType = zapTypes[it].first }, + modifier = Modifier.weight(1f).padding(end = 5.dp), + ) + } + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + // stringResource(R.string.new_amount_in_sats + label = { + if ( + selectedZapType == LnZapEvent.ZapType.PUBLIC || + selectedZapType == LnZapEvent.ZapType.ANONYMOUS + ) { + Text(text = stringResource(id = R.string.custom_zaps_add_a_message)) + } else if (selectedZapType == LnZapEvent.ZapType.PRIVATE) { + Text(text = stringResource(id = R.string.custom_zaps_add_a_message_private)) + } else if (selectedZapType == LnZapEvent.ZapType.NONZAP) { + Text(text = stringResource(id = R.string.custom_zaps_add_a_message_nonzap)) + } + }, + value = postViewModel.customMessage, + onValueChange = { postViewModel.customMessage = it }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Text, + ), + placeholder = { + Text( + text = stringResource(id = R.string.custom_zaps_add_a_message_example), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + modifier = Modifier.padding(end = 5.dp).weight(1f), + ) + } + } + } + } +} + +@Composable +fun ZapButton( + isActive: Boolean, + onPost: () -> Unit, +) { + Button( + onClick = { onPost() }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = if (isActive) MaterialTheme.colorScheme.primary else Color.Gray, + ), + ) { + Text(text = "โšกZap ", color = Color.White) + } +} + +@Composable +fun ErrorMessageDialog( + title: String, + textContent: String, + buttonColors: ButtonColors = ButtonDefaults.buttonColors(), + onClickStartMessage: (() -> Unit)? = null, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { SelectionContainer { Text(textContent) } }, + confirmButton = { + Row( + modifier = Modifier.padding(vertical = 8.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + onClickStartMessage?.let { + TextButton(onClick = onClickStartMessage) { + Icon( + painter = painterResource(R.drawable.ic_dm), + contentDescription = null, + ) + Spacer(StdHorzSpacer) + Text(stringResource(R.string.error_dialog_talk_to_user)) + } + } + Button( + onClick = onDismiss, + colors = buttonColors, + contentPadding = PaddingValues(horizontal = Size16dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Done, + contentDescription = null, + ) + Spacer(StdHorzSpacer) + Text(stringResource(R.string.error_dialog_button_ok)) + } + } + } + }, + ) +} + +@Composable +fun PayViaIntentDialog( + payingInvoices: ImmutableList, + accountViewModel: AccountViewModel, + onClose: () -> Unit, + onError: (String) -> Unit, +) { + val context = LocalContext.current + + if (payingInvoices.size == 1) { + payViaIntent(payingInvoices.first().invoice, context, onError) + onClose() + } else { + Dialog( + onDismissRequest = onClose, + properties = + DialogProperties( + dismissOnClickOutside = false, + usePlatformDefaultWidth = false, + ), + ) { + Surface { + Column(modifier = Modifier.padding(10.dp)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + CloseButton(onPress = onClose) + } + + Spacer(modifier = DoubleVertSpacer) + + payingInvoices.forEachIndexed { index, it -> + val paid = remember { mutableStateOf(false) } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = Size10dp), + ) { + if (it.user != null) { + BaseUserPicture(it.user, Size55dp, accountViewModel = accountViewModel) + } else { + DisplayBlankAuthor(size = Size55dp) + } + + Spacer(modifier = DoubleHorzSpacer) + + Column(modifier = Modifier.weight(1f)) { + if (it.user != null) { + UsernameDisplay(it.user, showPlayButton = false) + } else { + Text( + text = stringResource(id = R.string.wallet_number, index + 1), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ) + } + Row { + Text( + text = showAmount((it.amountMilliSats / 1000.0f).toBigDecimal()), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ) + Spacer(modifier = StdHorzSpacer) + Text( + text = stringResource(id = R.string.sats), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ) + } + } + + Spacer(modifier = DoubleHorzSpacer) + + PayButton(isActive = !paid.value) { + paid.value = true + + payViaIntent(it.invoice, context, onError) + } + } + } + } + } + } + } +} + +fun payViaIntent( + invoice: String, + context: Context, + onError: (String) -> Unit, +) { + try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$invoice")) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + + ContextCompat.startActivity(context, intent, null) + } catch (e: Exception) { + if (e.message != null) { + onError(context.getString(R.string.no_wallet_found_with_error, e.message!!)) + } else { + onError(context.getString(R.string.no_wallet_found)) + } + } +} + +@Composable +fun PayButton( + isActive: Boolean, + modifier: Modifier = Modifier, + onPost: () -> Unit = {}, +) { + Button( + modifier = modifier, + onClick = onPost, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = if (isActive) MaterialTheme.colorScheme.primary else Color.Gray, + ), + contentPadding = ZeroPadding, + ) { + if (isActive) { + Text(text = stringResource(R.string.pay), color = Color.White) + } else { + Text(text = stringResource(R.string.paid), color = Color.White) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt index aae9d426f..98424b0be 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.note import androidx.compose.foundation.clickable @@ -42,197 +62,177 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @Composable -fun ZapNoteCompose(baseReqResponse: ZapReqResponse, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val baseNoteRequest by baseReqResponse.zapRequest.live().metadata.observeAsState() +fun ZapNoteCompose( + baseReqResponse: ZapReqResponse, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val baseNoteRequest by baseReqResponse.zapRequest.live().metadata.observeAsState() - var baseAuthor by remember { - mutableStateOf(null) + var baseAuthor by remember { mutableStateOf(null) } + + LaunchedEffect(baseNoteRequest) { + baseNoteRequest?.note?.let { + accountViewModel.decryptAmountMessage(it, baseReqResponse.zapEvent) { baseAuthor = it?.user } } + } - LaunchedEffect(baseNoteRequest) { - baseNoteRequest?.note?.let { - accountViewModel.decryptAmountMessage(it, baseReqResponse.zapEvent) { - baseAuthor = it?.user - } - } - } - - if (baseAuthor == null) { - BlankNote() - } else { - val route = remember(baseAuthor) { - "User/${baseAuthor?.pubkeyHex}" - } - - Column( - modifier = - Modifier.clickable( - onClick = { nav(route) } - ), - verticalArrangement = Arrangement.Center - ) { - baseAuthor?.let { - RenderZapNote(it, baseReqResponse.zapEvent, nav, accountViewModel) - } - - Divider( - modifier = Modifier.padding(top = 10.dp), - thickness = DividerThickness - ) - } + if (baseAuthor == null) { + BlankNote() + } else { + val route = remember(baseAuthor) { "User/${baseAuthor?.pubkeyHex}" } + + Column( + modifier = + Modifier.clickable( + onClick = { nav(route) }, + ), + verticalArrangement = Arrangement.Center, + ) { + baseAuthor?.let { RenderZapNote(it, baseReqResponse.zapEvent, nav, accountViewModel) } + + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = DividerThickness, + ) } + } } @Composable private fun RenderZapNote( - baseAuthor: User, - zapNote: Note, - nav: (String) -> Unit, - accountViewModel: AccountViewModel + baseAuthor: User, + zapNote: Note, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - Row( - modifier = remember { - Modifier - .padding( - start = 12.dp, - end = 12.dp, - top = 10.dp - ) - }, - verticalAlignment = Alignment.CenterVertically + Row( + modifier = + remember { + Modifier.padding( + start = 12.dp, + end = 12.dp, + top = 10.dp, + ) + }, + verticalAlignment = Alignment.CenterVertically, + ) { + UserPicture(baseAuthor, Size55dp, accountViewModel = accountViewModel, nav = nav) + + Column( + modifier = remember { Modifier.padding(start = 10.dp).weight(1f) }, ) { - UserPicture(baseAuthor, Size55dp, accountViewModel = accountViewModel, nav = nav) - - Column( - modifier = remember { - Modifier - .padding(start = 10.dp) - .weight(1f) - } - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - UsernameDisplay(baseAuthor) - } - Row(verticalAlignment = Alignment.CenterVertically) { - AboutDisplay(baseAuthor) - } - } - - Column( - modifier = remember { - Modifier.padding(start = 10.dp) - }, - verticalArrangement = Arrangement.Center - ) { - ZapAmount(zapNote) - } - - Column(modifier = Modifier.padding(start = 10.dp)) { - UserActionOptions(baseAuthor, accountViewModel) - } + Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseAuthor) } + Row(verticalAlignment = Alignment.CenterVertically) { AboutDisplay(baseAuthor) } } + + Column( + modifier = remember { Modifier.padding(start = 10.dp) }, + verticalArrangement = Arrangement.Center, + ) { + ZapAmount(zapNote) + } + + Column(modifier = Modifier.padding(start = 10.dp)) { + UserActionOptions(baseAuthor, accountViewModel) + } + } } @Composable private fun ZapAmount(zapEventNote: Note) { - val noteState by zapEventNote.live().metadata.observeAsState() + val noteState by zapEventNote.live().metadata.observeAsState() - var zapAmount by remember { mutableStateOf(null) } + var zapAmount by remember { mutableStateOf(null) } - LaunchedEffect(key1 = noteState) { - launch(Dispatchers.IO) { - val newZapAmount = showAmountAxis((noteState?.note?.event as? LnZapEvent)?.amount) - if (zapAmount != newZapAmount) { - zapAmount = newZapAmount - } - } + LaunchedEffect(key1 = noteState) { + launch(Dispatchers.IO) { + val newZapAmount = showAmountAxis((noteState?.note?.event as? LnZapEvent)?.amount) + if (zapAmount != newZapAmount) { + zapAmount = newZapAmount + } } + } - zapAmount?.let { - Text( - text = it, - color = BitcoinOrange, - fontSize = 20.sp, - fontWeight = FontWeight.W500 - ) - } + zapAmount?.let { + Text( + text = it, + color = BitcoinOrange, + fontSize = 20.sp, + fontWeight = FontWeight.W500, + ) + } } @Composable fun UserActionOptions( - baseAuthor: User, - accountViewModel: AccountViewModel + baseAuthor: User, + accountViewModel: AccountViewModel, ) { - val scope = rememberCoroutineScope() + val scope = rememberCoroutineScope() - WatchIsHiddenUser(baseAuthor, accountViewModel) { isHidden -> - if (isHidden) { - ShowUserButton { - accountViewModel.show(baseAuthor) - } - } else { - ShowFollowingOrUnfollowingButton(baseAuthor, accountViewModel) - } + WatchIsHiddenUser(baseAuthor, accountViewModel) { isHidden -> + if (isHidden) { + ShowUserButton { accountViewModel.show(baseAuthor) } + } else { + ShowFollowingOrUnfollowingButton(baseAuthor, accountViewModel) } + } } @Composable fun ShowFollowingOrUnfollowingButton( - baseAuthor: User, - accountViewModel: AccountViewModel + baseAuthor: User, + accountViewModel: AccountViewModel, ) { - var isFollowing by remember { mutableStateOf(false) } - val accountFollowsState by accountViewModel.account.userProfile().live().follows.observeAsState() + var isFollowing by remember { mutableStateOf(false) } + val accountFollowsState by accountViewModel.account.userProfile().live().follows.observeAsState() - LaunchedEffect(key1 = accountFollowsState) { - launch(Dispatchers.Default) { - val newShowFollowingMark = - accountFollowsState?.user?.isFollowing(baseAuthor) == true + LaunchedEffect(key1 = accountFollowsState) { + launch(Dispatchers.Default) { + val newShowFollowingMark = accountFollowsState?.user?.isFollowing(baseAuthor) == true - if (newShowFollowingMark != isFollowing) { - isFollowing = newShowFollowingMark - } - } + if (newShowFollowingMark != isFollowing) { + isFollowing = newShowFollowingMark + } } + } - if (isFollowing) { - UnfollowButton { - if (!accountViewModel.isWriteable()) { - accountViewModel.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_unfollow - ) - } else { - accountViewModel.unfollow(baseAuthor) - } - } - } else { - FollowButton { - if (!accountViewModel.isWriteable()) { - accountViewModel.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_follow - ) - } else { - accountViewModel.follow(baseAuthor) - } - } + if (isFollowing) { + UnfollowButton { + if (!accountViewModel.isWriteable()) { + accountViewModel.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_unfollow, + ) + } else { + accountViewModel.unfollow(baseAuthor) + } } + } else { + FollowButton { + if (!accountViewModel.isWriteable()) { + accountViewModel.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_follow, + ) + } else { + accountViewModel.follow(baseAuthor) + } + } + } } @Composable fun AboutDisplay(baseAuthor: User) { - val baseAuthorState by baseAuthor.live().metadata.observeAsState() - val userAboutMe by remember(baseAuthorState) { - derivedStateOf { - baseAuthorState?.user?.info?.about ?: "" - } - } + val baseAuthorState by baseAuthor.live().metadata.observeAsState() + val userAboutMe by + remember(baseAuthorState) { derivedStateOf { baseAuthorState?.user?.info?.about ?: "" } } - Text( - userAboutMe, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Text( + userAboutMe, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt index 2a089baf8..d35c0140b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.note import androidx.compose.foundation.background @@ -30,87 +50,90 @@ import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor @Composable -fun ZapUserSetCompose(zapSetCard: ZapUserSetCard, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val defaultBackgroundColor = MaterialTheme.colorScheme.background - val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } - val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor +fun ZapUserSetCompose( + zapSetCard: ZapUserSetCard, + isInnerNote: Boolean = false, + routeForLastRead: String, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val defaultBackgroundColor = MaterialTheme.colorScheme.background + val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } + val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor - LaunchedEffect(key1 = zapSetCard.createdAt()) { - accountViewModel.loadAndMarkAsRead(routeForLastRead, zapSetCard.createdAt) { isNew -> - val newBackgroundColor = if (isNew) { - newItemColor.compositeOver(defaultBackgroundColor) - } else { - defaultBackgroundColor - } - - if (backgroundColor.value != newBackgroundColor) { - backgroundColor.value = newBackgroundColor - } + LaunchedEffect(key1 = zapSetCard.createdAt()) { + accountViewModel.loadAndMarkAsRead(routeForLastRead, zapSetCard.createdAt) { isNew -> + val newBackgroundColor = + if (isNew) { + newItemColor.compositeOver(defaultBackgroundColor) + } else { + defaultBackgroundColor } - } - Column( - modifier = Modifier - .background(backgroundColor.value) - .clickable { - nav("User/${zapSetCard.user.pubkeyHex}") - } + if (backgroundColor.value != newBackgroundColor) { + backgroundColor.value = newBackgroundColor + } + } + } + + Column( + modifier = + Modifier.background(backgroundColor.value).clickable { + nav("User/${zapSetCard.user.pubkeyHex}") + }, + ) { + Row( + modifier = + Modifier.padding( + start = if (!isInnerNote) 12.dp else 0.dp, + end = if (!isInnerNote) 12.dp else 0.dp, + top = 10.dp, + ), ) { - Row( - modifier = Modifier - .padding( - start = if (!isInnerNote) 12.dp else 0.dp, - end = if (!isInnerNote) 12.dp else 0.dp, - top = 10.dp - ) + // Draws the like picture outside the boosted card. + if (!isInnerNote) { + Box( + modifier = Size55Modifier, ) { - // Draws the like picture outside the boosted card. - if (!isInnerNote) { - Box( - modifier = Size55Modifier - ) { - ZappedIcon( - remember { - Modifier - .size(Size25dp) - .align(Alignment.TopEnd) - } - ) - } - } + ZappedIcon( + remember { Modifier.size(Size25dp).align(Alignment.TopEnd) }, + ) + } + } - Column(modifier = Modifier) { - Row(Modifier.fillMaxWidth()) { - MapZaps(zapSetCard.zapEvents, accountViewModel) { - AuthorGalleryZaps(it, backgroundColor, nav, accountViewModel) - } - } - - Spacer(DoubleVertSpacer) - - Row(Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - UserPicture( - zapSetCard.user, - Size55dp, - accountViewModel = accountViewModel, - nav = nav - ) - - Column(modifier = remember { Modifier.padding(start = 10.dp).weight(1f) }) { - Row(verticalAlignment = Alignment.CenterVertically) { - UsernameDisplay(zapSetCard.user) - } - - AboutDisplay(zapSetCard.user) - } - } - - Spacer(DoubleVertSpacer) - } + Column(modifier = Modifier) { + Row(Modifier.fillMaxWidth()) { + MapZaps(zapSetCard.zapEvents, accountViewModel) { + AuthorGalleryZaps(it, backgroundColor, nav, accountViewModel) + } } - Divider( - thickness = DividerThickness - ) + Spacer(DoubleVertSpacer) + + Row( + Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp).fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + UserPicture( + zapSetCard.user, + Size55dp, + accountViewModel = accountViewModel, + nav = nav, + ) + + Column(modifier = remember { Modifier.padding(start = 10.dp).weight(1f) }) { + Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(zapSetCard.user) } + + AboutDisplay(zapSetCard.user) + } + } + + Spacer(DoubleVertSpacer) + } } + + Divider( + thickness = DividerThickness, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeDrawer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeDrawer.kt index 99fe63f79..009ce0c9f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeDrawer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeDrawer.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.qrcode import androidx.compose.foundation.Canvas @@ -32,127 +52,140 @@ const val QR_MARGIN_PX = 100f @Preview @Composable fun QrCodeDrawerPreview() { - QrCodeDrawer("Test QR data") + QrCodeDrawer("Test QR data") } @Composable -fun QrCodeDrawer(contents: String, modifier: Modifier = Modifier) { - val qrCode = remember(contents) { - createQrCode(contents = contents) - } - - val foregroundColor = MaterialTheme.colorScheme.onSurface - - Box( - modifier = modifier - .defaultMinSize(48.dp, 48.dp) - .aspectRatio(1f) - .background(MaterialTheme.colorScheme.background) - ) { - Canvas(modifier = Modifier.fillMaxSize()) { - // Calculate the height and width of each column/row - val rowHeight = (size.width - QR_MARGIN_PX * 2f) / qrCode.matrix.height - val columnWidth = (size.width - QR_MARGIN_PX * 2f) / qrCode.matrix.width - - // Draw all of the finder patterns required by the QR spec. Calculate the ratio - // of the number of rows/columns to the width and height - drawQrCodeFinders( - sideLength = size.width, - finderPatternSize = Size( - width = columnWidth * FINDER_PATTERN_ROW_COUNT, - height = rowHeight * FINDER_PATTERN_ROW_COUNT - ), - color = foregroundColor - ) - - // Draw data bits (encoded data part) - drawAllQrCodeDataBits( - bytes = qrCode.matrix, - size = Size( - width = columnWidth, - height = rowHeight - ), - color = foregroundColor - ) - } +fun QrCodeDrawer( + contents: String, + modifier: Modifier = Modifier, +) { + val qrCode = remember(contents) { createQrCode(contents = contents) } + + val foregroundColor = MaterialTheme.colorScheme.onSurface + + Box( + modifier = + modifier + .defaultMinSize(48.dp, 48.dp) + .aspectRatio(1f) + .background(MaterialTheme.colorScheme.background), + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + // Calculate the height and width of each column/row + val rowHeight = (size.width - QR_MARGIN_PX * 2f) / qrCode.matrix.height + val columnWidth = (size.width - QR_MARGIN_PX * 2f) / qrCode.matrix.width + + // Draw all of the finder patterns required by the QR spec. Calculate the ratio + // of the number of rows/columns to the width and height + drawQrCodeFinders( + sideLength = size.width, + finderPatternSize = + Size( + width = columnWidth * FINDER_PATTERN_ROW_COUNT, + height = rowHeight * FINDER_PATTERN_ROW_COUNT, + ), + color = foregroundColor, + ) + + // Draw data bits (encoded data part) + drawAllQrCodeDataBits( + bytes = qrCode.matrix, + size = + Size( + width = columnWidth, + height = rowHeight, + ), + color = foregroundColor, + ) } + } } private typealias Coordinate = Pair private fun createQrCode(contents: String): QRCode { - require(contents.isNotEmpty()) + require(contents.isNotEmpty()) - return Encoder.encode( - contents, - ErrorCorrectionLevel.Q, - mapOf( - EncodeHintType.CHARACTER_SET to "UTF-8", - EncodeHintType.MARGIN to QR_MARGIN_PX, - EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.Q - ) - ) + return Encoder.encode( + contents, + ErrorCorrectionLevel.Q, + mapOf( + EncodeHintType.CHARACTER_SET to "UTF-8", + EncodeHintType.MARGIN to QR_MARGIN_PX, + EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.Q, + ), + ) } -fun newPath(withPath: Path.() -> Unit) = Path().apply { +fun newPath(withPath: Path.() -> Unit) = + Path().apply { fillType = PathFillType.EvenOdd withPath(this) -} + } fun DrawScope.drawAllQrCodeDataBits( - bytes: ByteMatrix, - size: Size, - color: Color + bytes: ByteMatrix, + size: Size, + color: Color, ) { - setOf( - // data bits between top left finder pattern and top right finder pattern. - Pair( - first = Coordinate(first = FINDER_PATTERN_ROW_COUNT, second = 0), - second = Coordinate( - first = (bytes.width - FINDER_PATTERN_ROW_COUNT), - second = FINDER_PATTERN_ROW_COUNT + setOf( + // data bits between top left finder pattern and top right finder pattern. + Pair( + first = Coordinate(first = FINDER_PATTERN_ROW_COUNT, second = 0), + second = + Coordinate( + first = (bytes.width - FINDER_PATTERN_ROW_COUNT), + second = FINDER_PATTERN_ROW_COUNT, + ), + ), + // data bits below top left finder pattern and above bottom left finder pattern. + Pair( + first = Coordinate(first = 0, second = FINDER_PATTERN_ROW_COUNT), + second = + Coordinate( + first = bytes.width, + second = bytes.height - FINDER_PATTERN_ROW_COUNT, + ), + ), + // data bits to the right of the bottom left finder pattern. + Pair( + first = + Coordinate( + first = FINDER_PATTERN_ROW_COUNT, + second = (bytes.height - FINDER_PATTERN_ROW_COUNT), + ), + second = + Coordinate( + first = bytes.width, + second = bytes.height, + ), + ), + ) + .forEach { section -> + for (y in section.first.second until section.second.second) { + for (x in section.first.first until section.second.first) { + if (bytes[x, y] == 1.toByte()) { + drawPath( + color = color, + path = + newPath { + addRect( + rect = + Rect( + offset = + Offset( + x = QR_MARGIN_PX + x * size.width, + y = QR_MARGIN_PX + y * size.height, + ), + size = size, + ), + ) + }, ) - ), - // data bits below top left finder pattern and above bottom left finder pattern. - Pair( - first = Coordinate(first = 0, second = FINDER_PATTERN_ROW_COUNT), - second = Coordinate( - first = bytes.width, - second = bytes.height - FINDER_PATTERN_ROW_COUNT - ) - ), - // data bits to the right of the bottom left finder pattern. - Pair( - first = Coordinate( - first = FINDER_PATTERN_ROW_COUNT, - second = (bytes.height - FINDER_PATTERN_ROW_COUNT) - ), - second = Coordinate( - first = bytes.width, - second = bytes.height - ) - ) - ).forEach { section -> - for (y in section.first.second until section.second.second) { - for (x in section.first.first until section.second.first) { - if (bytes[x, y] == 1.toByte()) { - drawPath( - color = color, - path = newPath { - addRect( - rect = Rect( - offset = Offset( - x = QR_MARGIN_PX + x * size.width, - y = QR_MARGIN_PX + y * size.height - ), - size = size - ) - ) - } - ) - } - } + } } + } } } @@ -165,86 +198,94 @@ private const val INTERIOR_BACKGROUND_EXTERIOR_OFFSET_RATIO = 1f / FINDER_PATTER private const val INTERIOR_BACKGROUND_EXTERIOR_SHAPE_CORNER_RADIUS = 0.5f /** - * A valid QR code has three finder patterns (top left, top right, bottom left). + * A valid QR code has three finder patterns (top left, top right, bottom left). * - * @param qrCodeProperties how the QR code is drawn - * @param sideLength length, in pixels, of each side of the QR code - * @param finderPatternSize [Size] of each finder patten, based on the QR code spec + * @param qrCodeProperties how the QR code is drawn + * @param sideLength length, in pixels, of each side of the QR code + * @param finderPatternSize [Size] of each finder patten, based on the QR code spec */ internal fun DrawScope.drawQrCodeFinders( - sideLength: Float, - finderPatternSize: Size, - color: Color + sideLength: Float, + finderPatternSize: Size, + color: Color, ) { - setOf( - // Draw top left finder pattern. - Offset(x = QR_MARGIN_PX, y = QR_MARGIN_PX), - // Draw top right finder pattern. - Offset(x = sideLength - (QR_MARGIN_PX + finderPatternSize.width), y = QR_MARGIN_PX), - // Draw bottom finder pattern. - Offset(x = QR_MARGIN_PX, y = sideLength - (QR_MARGIN_PX + finderPatternSize.height)) - ).forEach { offset -> - drawQrCodeFinder( - topLeft = offset, - finderPatternSize = finderPatternSize, - cornerRadius = CornerRadius.Zero, - color = color - ) + setOf( + // Draw top left finder pattern. + Offset(x = QR_MARGIN_PX, y = QR_MARGIN_PX), + // Draw top right finder pattern. + Offset(x = sideLength - (QR_MARGIN_PX + finderPatternSize.width), y = QR_MARGIN_PX), + // Draw bottom finder pattern. + Offset(x = QR_MARGIN_PX, y = sideLength - (QR_MARGIN_PX + finderPatternSize.height)), + ) + .forEach { offset -> + drawQrCodeFinder( + topLeft = offset, + finderPatternSize = finderPatternSize, + cornerRadius = CornerRadius.Zero, + color = color, + ) } } -/** - * This func is responsible for drawing a single finder pattern, for a QR code - */ +/** This func is responsible for drawing a single finder pattern, for a QR code */ private fun DrawScope.drawQrCodeFinder( - topLeft: Offset, - finderPatternSize: Size, - cornerRadius: CornerRadius, - color: Color + topLeft: Offset, + finderPatternSize: Size, + cornerRadius: CornerRadius, + color: Color, ) { - drawPath( - color = color, - path = newPath { - // Draw the outer rectangle for the finder pattern. - addRoundRect( - roundRect = RoundRect( - rect = Rect( - offset = topLeft, - size = finderPatternSize - ), - cornerRadius = cornerRadius - ) - ) + drawPath( + color = color, + path = + newPath { + // Draw the outer rectangle for the finder pattern. + addRoundRect( + roundRect = + RoundRect( + rect = + Rect( + offset = topLeft, + size = finderPatternSize, + ), + cornerRadius = cornerRadius, + ), + ) - // Draw background for the finder pattern interior (this keeps the arc ratio consistent). - val innerBackgroundOffset = Offset( - x = finderPatternSize.width * INTERIOR_BACKGROUND_EXTERIOR_OFFSET_RATIO, - y = finderPatternSize.height * INTERIOR_BACKGROUND_EXTERIOR_OFFSET_RATIO - ) - addRoundRect( - roundRect = RoundRect( - rect = Rect( - offset = topLeft + innerBackgroundOffset, - size = finderPatternSize * INTERIOR_BACKGROUND_EXTERIOR_SHAPE_RATIO - ), - cornerRadius = cornerRadius * INTERIOR_BACKGROUND_EXTERIOR_SHAPE_CORNER_RADIUS - ) - ) + // Draw background for the finder pattern interior (this keeps the arc ratio consistent). + val innerBackgroundOffset = + Offset( + x = finderPatternSize.width * INTERIOR_BACKGROUND_EXTERIOR_OFFSET_RATIO, + y = finderPatternSize.height * INTERIOR_BACKGROUND_EXTERIOR_OFFSET_RATIO, + ) + addRoundRect( + roundRect = + RoundRect( + rect = + Rect( + offset = topLeft + innerBackgroundOffset, + size = finderPatternSize * INTERIOR_BACKGROUND_EXTERIOR_SHAPE_RATIO, + ), + cornerRadius = cornerRadius * INTERIOR_BACKGROUND_EXTERIOR_SHAPE_CORNER_RADIUS, + ), + ) - // Draw the inner rectangle for the finder pattern. - val innerRectOffset = Offset( - x = finderPatternSize.width * INTERIOR_EXTERIOR_OFFSET_RATIO, - y = finderPatternSize.height * INTERIOR_EXTERIOR_OFFSET_RATIO - ) - addRoundRect( - roundRect = RoundRect( - rect = Rect( - offset = topLeft + innerRectOffset, - size = finderPatternSize * INTERIOR_EXTERIOR_SHAPE_RATIO - ), - cornerRadius = cornerRadius * INTERIOR_EXTERIOR_SHAPE_CORNER_RADIUS - ) - ) - } - ) + // Draw the inner rectangle for the finder pattern. + val innerRectOffset = + Offset( + x = finderPatternSize.width * INTERIOR_EXTERIOR_OFFSET_RATIO, + y = finderPatternSize.height * INTERIOR_EXTERIOR_OFFSET_RATIO, + ) + addRoundRect( + roundRect = + RoundRect( + rect = + Rect( + offset = topLeft + innerRectOffset, + size = finderPatternSize * INTERIOR_EXTERIOR_SHAPE_RATIO, + ), + cornerRadius = cornerRadius * INTERIOR_EXTERIOR_SHAPE_CORNER_RADIUS, + ), + ) + }, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeScanner.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeScanner.kt index 2ad62d8a4..9e4a79cc2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeScanner.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeScanner.kt @@ -1,63 +1,85 @@ -package com.vitorpamplona.amethyst.ui.qrcode - -import android.util.Log -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.ui.res.stringResource -import com.google.zxing.client.android.Intents -import com.journeyapps.barcodescanner.ScanContract -import com.journeyapps.barcodescanner.ScanOptions -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.quartz.encoders.Nip19 - -@Composable -fun NIP19QrCodeScanner(onScan: (String?) -> Unit) { - SimpleQrCodeScanner { - try { - val nip19 = Nip19.uriToRoute(it) - val startingPage = when (nip19?.type) { - Nip19.Type.USER -> "User/${nip19.hex}" - Nip19.Type.NOTE -> "Note/${nip19.hex}" - Nip19.Type.EVENT -> "Event/${nip19.hex}" - Nip19.Type.ADDRESS -> "Note/${nip19.hex}" - else -> null - } - - if (startingPage != null) { - onScan(startingPage) - } else { - onScan(null) - } - } catch (e: Throwable) { - Log.e("NIP19 Scanner", "Error parsing $it", e) - // QR can be anything, do not throw errors. - onScan(null) - } - } -} - -@Composable -fun SimpleQrCodeScanner(onScan: (String?) -> Unit) { - val qrLauncher = - rememberLauncherForActivityResult(ScanContract()) { - if (it.contents != null) { - onScan(it.contents) - } else { - onScan(null) - } - } - - val scanOptions = ScanOptions().apply { - setDesiredBarcodeFormats(ScanOptions.QR_CODE) - setPrompt(stringResource(id = R.string.point_to_the_qr_code)) - setBeepEnabled(false) - setOrientationLocked(false) - addExtra(Intents.Scan.SCAN_TYPE, Intents.Scan.MIXED_SCAN) - } - - DisposableEffect(Unit) { - qrLauncher.launch(scanOptions) - onDispose { } - } -} +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.qrcode + +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.res.stringResource +import com.google.zxing.client.android.Intents +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.quartz.encoders.Nip19 + +@Composable +fun NIP19QrCodeScanner(onScan: (String?) -> Unit) { + SimpleQrCodeScanner { + try { + val nip19 = Nip19.uriToRoute(it) + val startingPage = + when (nip19?.type) { + Nip19.Type.USER -> "User/${nip19.hex}" + Nip19.Type.NOTE -> "Note/${nip19.hex}" + Nip19.Type.EVENT -> "Event/${nip19.hex}" + Nip19.Type.ADDRESS -> "Note/${nip19.hex}" + else -> null + } + + if (startingPage != null) { + onScan(startingPage) + } else { + onScan(null) + } + } catch (e: Throwable) { + Log.e("NIP19 Scanner", "Error parsing $it", e) + // QR can be anything, do not throw errors. + onScan(null) + } + } +} + +@Composable +fun SimpleQrCodeScanner(onScan: (String?) -> Unit) { + val qrLauncher = + rememberLauncherForActivityResult(ScanContract()) { + if (it.contents != null) { + onScan(it.contents) + } else { + onScan(null) + } + } + + val scanOptions = + ScanOptions().apply { + setDesiredBarcodeFormats(ScanOptions.QR_CODE) + setPrompt(stringResource(id = R.string.point_to_the_qr_code)) + setBeepEnabled(false) + setOrientationLocked(false) + addExtra(Intents.Scan.SCAN_TYPE, Intents.Scan.MIXED_SCAN) + } + + DisposableEffect(Unit) { + qrLauncher.launch(scanOptions) + onDispose {} + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt index 4a66f4bf3..b10a76b52 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt @@ -1,152 +1,178 @@ -package com.vitorpamplona.amethyst.ui.qrcode - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -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.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.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.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview -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 com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.actions.CloseButton -import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji -import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage -import com.vitorpamplona.amethyst.ui.theme.Size35dp -import com.vitorpamplona.quartz.events.UserMetadata -import com.vitorpamplona.quartz.events.toImmutableListOfLists - -@Preview -@Composable -fun ShowQRDialogPreview() { - val user = User("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") - - user.info = UserMetadata().apply { - name = "My Name" - picture = "Picture" - banner = "http://banner.com/test" - website = "http://mywebsite.com/test" - about = "This is the about me" - } - - ShowQRDialog( - user = user, - loadProfilePicture = false, - onScan = {}, - onClose = {} - ) -} - -@Composable -fun ShowQRDialog(user: User, loadProfilePicture: Boolean, onScan: (String) -> Unit, onClose: () -> Unit) { - var presenting by remember { mutableStateOf(true) } - - Dialog( - onDismissRequest = onClose, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Surface { - Column { - Row( - modifier = Modifier.padding(10.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - CloseButton(onPress = onClose) - } - - Column( - modifier = Modifier.fillMaxSize().padding(horizontal = 10.dp), - verticalArrangement = Arrangement.SpaceAround - ) { - if (presenting) { - Column() { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() - ) { - RobohashFallbackAsyncImage( - robot = user.pubkeyHex, - model = user.profilePicture(), - contentDescription = stringResource(R.string.profile_image), - modifier = Modifier - .width(100.dp) - .height(100.dp) - .clip(shape = CircleShape) - .border(3.dp, MaterialTheme.colorScheme.background, CircleShape) - .background(MaterialTheme.colorScheme.background), - loadProfilePicture = loadProfilePicture - ) - } - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth().padding(top = 5.dp) - ) { - CreateTextWithEmoji( - text = user.bestDisplayName() ?: user.bestUsername() ?: "", - tags = user.info?.latestMetadata?.tags?.toImmutableListOfLists(), - fontWeight = FontWeight.Bold, - fontSize = 18.sp - ) - } - } - - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth().padding(horizontal = Size35dp) - ) { - QrCodeDrawer("nostr:${user.pubkeyNpub()}") - } - - Row(modifier = Modifier.padding(horizontal = 30.dp)) { - Button( - onClick = { presenting = false }, - shape = RoundedCornerShape(Size35dp), - modifier = Modifier.fillMaxWidth().height(50.dp), - colors = ButtonDefaults - .buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ) - ) { - Text(text = stringResource(R.string.scan_qr)) - } - } - } else { - NIP19QrCodeScanner { - if (it.isNullOrEmpty()) { - presenting = true - } else { - onScan(it) - } - } - } - } - } - } - } -} +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.qrcode + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.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.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +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 com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.actions.CloseButton +import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji +import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.quartz.events.UserMetadata +import com.vitorpamplona.quartz.events.toImmutableListOfLists + +@Preview +@Composable +fun ShowQRDialogPreview() { + val user = User("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") + + user.info = + UserMetadata().apply { + name = "My Name" + picture = "Picture" + banner = "http://banner.com/test" + website = "http://mywebsite.com/test" + about = "This is the about me" + } + + ShowQRDialog( + user = user, + loadProfilePicture = false, + onScan = {}, + onClose = {}, + ) +} + +@Composable +fun ShowQRDialog( + user: User, + loadProfilePicture: Boolean, + onScan: (String) -> Unit, + onClose: () -> Unit, +) { + var presenting by remember { mutableStateOf(true) } + + Dialog( + onDismissRequest = onClose, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface { + Column { + Row( + modifier = Modifier.padding(10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + CloseButton(onPress = onClose) + } + + Column( + modifier = Modifier.fillMaxSize().padding(horizontal = 10.dp), + verticalArrangement = Arrangement.SpaceAround, + ) { + if (presenting) { + Column { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth(), + ) { + RobohashFallbackAsyncImage( + robot = user.pubkeyHex, + model = user.profilePicture(), + contentDescription = stringResource(R.string.profile_image), + modifier = + Modifier.width(100.dp) + .height(100.dp) + .clip(shape = CircleShape) + .border(3.dp, MaterialTheme.colorScheme.background, CircleShape) + .background(MaterialTheme.colorScheme.background), + loadProfilePicture = loadProfilePicture, + ) + } + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth().padding(top = 5.dp), + ) { + CreateTextWithEmoji( + text = user.bestDisplayName() ?: user.bestUsername() ?: "", + tags = user.info?.latestMetadata?.tags?.toImmutableListOfLists(), + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ) + } + } + + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth().padding(horizontal = Size35dp), + ) { + QrCodeDrawer("nostr:${user.pubkeyNpub()}") + } + + Row(modifier = Modifier.padding(horizontal = 30.dp)) { + Button( + onClick = { presenting = false }, + shape = RoundedCornerShape(Size35dp), + modifier = Modifier.fillMaxWidth().height(50.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text(text = stringResource(R.string.scan_qr)) + } + } + } else { + NIP19QrCodeScanner { + if (it.isNullOrEmpty()) { + presenting = true + } else { + onScan(it) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt index fcb5a2542..9676947e7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen import android.app.Activity @@ -36,124 +56,121 @@ import kotlinx.coroutines.launch @Composable fun AccountScreen( - accountStateViewModel: AccountStateViewModel, - sharedPreferencesViewModel: SharedPreferencesViewModel + accountStateViewModel: AccountStateViewModel, + sharedPreferencesViewModel: SharedPreferencesViewModel, ) { - val accountState by accountStateViewModel.accountContent.collectAsStateWithLifecycle() + val accountState by accountStateViewModel.accountContent.collectAsStateWithLifecycle() - Crossfade( - targetState = accountState, - animationSpec = tween(durationMillis = 100), - label = "AccountState" - ) { state -> - when (state) { - is AccountState.Loading -> { - LoadingAccounts() - } - is AccountState.LoggedOff -> { - LoginPage(accountStateViewModel, isFirstLogin = true) - } - is AccountState.LoggedIn -> { - CompositionLocalProvider( - LocalViewModelStoreOwner provides state.currentViewModelStore - ) { - LoggedInPage( - state.account, - accountStateViewModel, - sharedPreferencesViewModel - ) - } - } - is AccountState.LoggedInViewOnly -> { - CompositionLocalProvider( - LocalViewModelStoreOwner provides state.currentViewModelStore - ) { - LoggedInPage( - state.account, - accountStateViewModel, - sharedPreferencesViewModel - ) - } - } + Crossfade( + targetState = accountState, + animationSpec = tween(durationMillis = 100), + label = "AccountState", + ) { state -> + when (state) { + is AccountState.Loading -> { + LoadingAccounts() + } + is AccountState.LoggedOff -> { + LoginPage(accountStateViewModel, isFirstLogin = true) + } + is AccountState.LoggedIn -> { + CompositionLocalProvider( + LocalViewModelStoreOwner provides state.currentViewModelStore, + ) { + LoggedInPage( + state.account, + accountStateViewModel, + sharedPreferencesViewModel, + ) } + } + is AccountState.LoggedInViewOnly -> { + CompositionLocalProvider( + LocalViewModelStoreOwner provides state.currentViewModelStore, + ) { + LoggedInPage( + state.account, + accountStateViewModel, + sharedPreferencesViewModel, + ) + } + } } + } } @Composable fun LoggedInPage( - account: Account, - accountStateViewModel: AccountStateViewModel, - sharedPreferencesViewModel: SharedPreferencesViewModel + account: Account, + accountStateViewModel: AccountStateViewModel, + sharedPreferencesViewModel: SharedPreferencesViewModel, ) { - val accountViewModel: AccountViewModel = viewModel( - key = "AccountViewModel", - factory = AccountViewModel.Factory( - account, - sharedPreferencesViewModel.sharedPrefs - ) + val accountViewModel: AccountViewModel = + viewModel( + key = "AccountViewModel", + factory = + AccountViewModel.Factory( + account, + sharedPreferencesViewModel.sharedPrefs, + ), ) - val activity = getActivity() as MainActivity - // Find a better way to associate these two. - accountViewModel.serviceManager = activity.serviceManager + val activity = getActivity() as MainActivity + // Find a better way to associate these two. + accountViewModel.serviceManager = activity.serviceManager - if (accountViewModel.account.signer is NostrSignerExternal) { - val launcher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult(), - onResult = { result -> - if (result.resultCode != Activity.RESULT_OK) { - accountViewModel.toast( - R.string.sign_request_rejected, - R.string.sign_request_rejected_description - ) - } else { - result.data?.let { - accountViewModel.runOnIO { - accountViewModel.account.signer.launcher.newResult(it) - } - } - } - } - ) - - DisposableEffect(accountViewModel, accountViewModel.account, launcher, activity) { - accountViewModel.account.signer.launcher.registerLauncher( - launcher = { - try { - activity.prepareToLaunchSigner() - launcher.launch(it) - } catch (e: Exception) { - Log.e("Signer", "Error opening Signer app", e) - accountViewModel.toast( - R.string.error_opening_external_signer, - R.string.error_opening_external_signer_description - ) - } - }, - contentResolver = { Amethyst.instance.contentResolver } + if (accountViewModel.account.signer is NostrSignerExternal) { + val launcher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + onResult = { result -> + if (result.resultCode != Activity.RESULT_OK) { + accountViewModel.toast( + R.string.sign_request_rejected, + R.string.sign_request_rejected_description, ) - onDispose { - accountViewModel.account.signer.launcher.clearLauncher() + } else { + result.data?.let { + accountViewModel.runOnIO { accountViewModel.account.signer.launcher.newResult(it) } } - } - } + } + }, + ) - MainScreen(accountViewModel, accountStateViewModel, sharedPreferencesViewModel) + DisposableEffect(accountViewModel, accountViewModel.account, launcher, activity) { + accountViewModel.account.signer.launcher.registerLauncher( + launcher = { + try { + activity.prepareToLaunchSigner() + launcher.launch(it) + } catch (e: Exception) { + Log.e("Signer", "Error opening Signer app", e) + accountViewModel.toast( + R.string.error_opening_external_signer, + R.string.error_opening_external_signer_description, + ) + } + }, + contentResolver = { Amethyst.instance.contentResolver }, + ) + onDispose { accountViewModel.account.signer.launcher.clearLauncher() } + } + } + + MainScreen(accountViewModel, accountStateViewModel, sharedPreferencesViewModel) } class AccountCentricViewModelStore(val account: Account) : ViewModelStoreOwner { - override val viewModelStore = ViewModelStore() + override val viewModelStore = ViewModelStore() } @Composable fun LoadingAccounts() { - Column( - Modifier - .fillMaxHeight() - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text(stringResource(R.string.loading_account)) - } + Column( + Modifier.fillMaxHeight().fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(stringResource(R.string.loading_account)) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt index 72c3623a2..351737ff1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt @@ -1,14 +1,37 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen import com.vitorpamplona.amethyst.model.Account sealed class AccountState { - object Loading : AccountState() - object LoggedOff : AccountState() - class LoggedInViewOnly(val account: Account) : AccountState() { - val currentViewModelStore = AccountCentricViewModelStore(account) - } - class LoggedIn(val account: Account) : AccountState() { - val currentViewModelStore = AccountCentricViewModelStore(account) - } + object Loading : AccountState() + + object LoggedOff : AccountState() + + class LoggedInViewOnly(val account: Account) : AccountState() { + val currentViewModelStore = AccountCentricViewModelStore(account) + } + + class LoggedIn(val account: Account) : AccountState() { + val currentViewModelStore = AccountCentricViewModelStore(account) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index 22acc1fc8..0832f1a02 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen import android.util.Log @@ -19,6 +39,7 @@ import com.vitorpamplona.quartz.encoders.toNpub import com.vitorpamplona.quartz.signers.ExternalSignerLauncher import com.vitorpamplona.quartz.signers.NostrSignerExternal import com.vitorpamplona.quartz.signers.NostrSignerInternal +import java.util.regex.Pattern import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -27,168 +48,196 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.util.regex.Pattern val EMAIL_PATTERN = Pattern.compile(".+@.+\\.[a-z]+") @Stable class AccountStateViewModel() : ViewModel() { - var serviceManager: ServiceManager? = null + var serviceManager: ServiceManager? = null - private val _accountContent = MutableStateFlow(AccountState.Loading) - val accountContent = _accountContent.asStateFlow() + private val _accountContent = MutableStateFlow(AccountState.Loading) + val accountContent = _accountContent.asStateFlow() - fun tryLoginExistingAccountAsync() { - // pulls account from storage. - viewModelScope.launch(Dispatchers.IO) { - tryLoginExistingAccount() - } + fun tryLoginExistingAccountAsync() { + // pulls account from storage. + viewModelScope.launch(Dispatchers.IO) { tryLoginExistingAccount() } + } + + private suspend fun tryLoginExistingAccount() = + withContext(Dispatchers.IO) { + LocalPreferences.loadCurrentAccountFromEncryptedStorage()?.let { startUI(it) } + ?: run { requestLoginUI() } } - private suspend fun tryLoginExistingAccount() = withContext(Dispatchers.IO) { - LocalPreferences.loadCurrentAccountFromEncryptedStorage()?.let { - startUI(it) - } ?: run { - requestLoginUI() - } - } + private suspend fun requestLoginUI() { + _accountContent.update { AccountState.LoggedOff } - private suspend fun requestLoginUI() { - _accountContent.update { AccountState.LoggedOff } + viewModelScope.launch(Dispatchers.IO) { serviceManager?.pauseForGoodAndClearAccount() } + } - viewModelScope.launch(Dispatchers.IO) { - serviceManager?.pauseForGoodAndClearAccount() - } - } + suspend fun loginAndStartUI( + key: String, + useProxy: Boolean, + proxyPort: Int, + loginWithExternalSigner: Boolean = false, + packageName: String = "", + ) = + withContext(Dispatchers.IO) { + val parsed = Nip19.uriToRoute(key) + val pubKeyParsed = parsed?.hex?.hexToByteArray() + val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort) - suspend fun loginAndStartUI( - key: String, - useProxy: Boolean, - proxyPort: Int, - loginWithExternalSigner: Boolean = false, - packageName: String = "" - ) = withContext(Dispatchers.IO) { - val parsed = Nip19.uriToRoute(key) - val pubKeyParsed = parsed?.hex?.hexToByteArray() - val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort) + if (loginWithExternalSigner && pubKeyParsed == null) { + throw Exception("Invalid key while trying to login with external signer") + } - if (loginWithExternalSigner && pubKeyParsed == null) { - throw Exception("Invalid key while trying to login with external signer") - } - - val account = - if (loginWithExternalSigner) { - val keyPair = KeyPair(pubKey = pubKeyParsed) - val localPackageName = packageName.ifBlank { "com.greenart7c3.nostrsigner" } - Account(keyPair, proxy = proxy, proxyPort = proxyPort, signer = NostrSignerExternal(keyPair.pubKey.toHexKey(), ExternalSignerLauncher(keyPair.pubKey.toNpub(), localPackageName))) - } else if (key.startsWith("nsec")) { - val keyPair = KeyPair(privKey = key.bechToBytes()) - Account(keyPair, proxy = proxy, proxyPort = proxyPort, signer = NostrSignerInternal(keyPair)) - } else if (pubKeyParsed != null) { - val keyPair = KeyPair(pubKey = pubKeyParsed) - Account(keyPair, proxy = proxy, proxyPort = proxyPort, signer = NostrSignerInternal(keyPair)) - } else if (EMAIL_PATTERN.matcher(key).matches()) { - val keyPair = KeyPair() - // Evaluate NIP-5 - Account(keyPair, proxy = proxy, proxyPort = proxyPort, signer = NostrSignerInternal(keyPair)) - } else { - val keyPair = KeyPair(Hex.decode(key)) - Account(keyPair, proxy = proxy, proxyPort = proxyPort, signer = NostrSignerInternal(keyPair)) - } - - LocalPreferences.updatePrefsForLogin(account) - - startUI(account) - } - - suspend fun startUI(account: Account) = withContext(Dispatchers.Main) { - if (account.isWriteable()) { - _accountContent.update { AccountState.LoggedIn(account) } + val account = + if (loginWithExternalSigner) { + val keyPair = KeyPair(pubKey = pubKeyParsed) + val localPackageName = packageName.ifBlank { "com.greenart7c3.nostrsigner" } + Account( + keyPair, + proxy = proxy, + proxyPort = proxyPort, + signer = + NostrSignerExternal( + keyPair.pubKey.toHexKey(), + ExternalSignerLauncher(keyPair.pubKey.toNpub(), localPackageName), + ), + ) + } else if (key.startsWith("nsec")) { + val keyPair = KeyPair(privKey = key.bechToBytes()) + Account( + keyPair, + proxy = proxy, + proxyPort = proxyPort, + signer = NostrSignerInternal(keyPair), + ) + } else if (pubKeyParsed != null) { + val keyPair = KeyPair(pubKey = pubKeyParsed) + Account( + keyPair, + proxy = proxy, + proxyPort = proxyPort, + signer = NostrSignerInternal(keyPair), + ) + } else if (EMAIL_PATTERN.matcher(key).matches()) { + val keyPair = KeyPair() + // Evaluate NIP-5 + Account( + keyPair, + proxy = proxy, + proxyPort = proxyPort, + signer = NostrSignerInternal(keyPair), + ) } else { - _accountContent.update { AccountState.LoggedInViewOnly(account) } + val keyPair = KeyPair(Hex.decode(key)) + Account( + keyPair, + proxy = proxy, + proxyPort = proxyPort, + signer = NostrSignerInternal(keyPair), + ) } - viewModelScope.launch(Dispatchers.IO) { - withContext(Dispatchers.Main) { - // Prepares livedata objects on the main user. - account.userProfile().live() - } - serviceManager?.restartIfDifferentAccount(account) - } + LocalPreferences.updatePrefsForLogin(account) - account.saveable.observeForever(saveListener) + startUI(account) } - @OptIn(DelicateCoroutinesApi::class) - private val saveListener: (com.vitorpamplona.amethyst.model.AccountState) -> Unit = { - GlobalScope.launch(Dispatchers.IO) { - LocalPreferences.saveToEncryptedStorage(it.account) + suspend fun startUI(account: Account) = + withContext(Dispatchers.Main) { + if (account.isWriteable()) { + _accountContent.update { AccountState.LoggedIn(account) } + } else { + _accountContent.update { AccountState.LoggedInViewOnly(account) } + } + + viewModelScope.launch(Dispatchers.IO) { + withContext(Dispatchers.Main) { + // Prepares livedata objects on the main user. + account.userProfile().live() } + serviceManager?.restartIfDifferentAccount(account) + } + + account.saveable.observeForever(saveListener) } - private suspend fun prepareLogoutOrSwitch() = withContext(Dispatchers.Main) { - when (val state = _accountContent.value) { - is AccountState.LoggedIn -> { - state.account.saveable.removeObserver(saveListener) - withContext(Dispatchers.IO) { - state.currentViewModelStore.viewModelStore.clear() - } - } - is AccountState.LoggedInViewOnly -> { - state.account.saveable.removeObserver(saveListener) - withContext(Dispatchers.IO) { - state.currentViewModelStore.viewModelStore.clear() - } - } - else -> {} + @OptIn(DelicateCoroutinesApi::class) + private val saveListener: (com.vitorpamplona.amethyst.model.AccountState) -> Unit = { + GlobalScope.launch(Dispatchers.IO) { LocalPreferences.saveToEncryptedStorage(it.account) } + } + + private suspend fun prepareLogoutOrSwitch() = + withContext(Dispatchers.Main) { + when (val state = _accountContent.value) { + is AccountState.LoggedIn -> { + state.account.saveable.removeObserver(saveListener) + withContext(Dispatchers.IO) { state.currentViewModelStore.viewModelStore.clear() } } + is AccountState.LoggedInViewOnly -> { + state.account.saveable.removeObserver(saveListener) + withContext(Dispatchers.IO) { state.currentViewModelStore.viewModelStore.clear() } + } + else -> {} + } } - fun login( - key: String, - useProxy: Boolean, - proxyPort: Int, - loginWithExternalSigner: Boolean = false, - packageName: String = "", - onError: () -> Unit - ) { - viewModelScope.launch(Dispatchers.IO) { - try { - loginAndStartUI(key, useProxy, proxyPort, loginWithExternalSigner, packageName) - } catch (e: Exception) { - Log.e("Login", "Could not sign in", e) - onError() - } - } + fun login( + key: String, + useProxy: Boolean, + proxyPort: Int, + loginWithExternalSigner: Boolean = false, + packageName: String = "", + onError: () -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + try { + loginAndStartUI(key, useProxy, proxyPort, loginWithExternalSigner, packageName) + } catch (e: Exception) { + Log.e("Login", "Could not sign in", e) + onError() + } } + } - fun newKey(useProxy: Boolean, proxyPort: Int) { - viewModelScope.launch(Dispatchers.IO) { - val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort) - val keyPair = KeyPair() - val account = Account(keyPair, proxy = proxy, proxyPort = proxyPort, signer = NostrSignerInternal(keyPair)) + fun newKey( + useProxy: Boolean, + proxyPort: Int, + ) { + viewModelScope.launch(Dispatchers.IO) { + val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort) + val keyPair = KeyPair() + val account = + Account( + keyPair, + proxy = proxy, + proxyPort = proxyPort, + signer = NostrSignerInternal(keyPair), + ) - account.follow(account.userProfile()) + account.follow(account.userProfile()) - // saves to local preferences - LocalPreferences.updatePrefsForLogin(account) - startUI(account) - } + // saves to local preferences + LocalPreferences.updatePrefsForLogin(account) + startUI(account) } + } - fun switchUser(accountInfo: AccountInfo) { - viewModelScope.launch(Dispatchers.IO) { - prepareLogoutOrSwitch() - LocalPreferences.switchToAccount(accountInfo) - tryLoginExistingAccount() - } + fun switchUser(accountInfo: AccountInfo) { + viewModelScope.launch(Dispatchers.IO) { + prepareLogoutOrSwitch() + LocalPreferences.switchToAccount(accountInfo) + tryLoginExistingAccount() } + } - fun logOff(accountInfo: AccountInfo) { - viewModelScope.launch(Dispatchers.IO) { - prepareLogoutOrSwitch() - LocalPreferences.updatePrefsForLogout(accountInfo) - tryLoginExistingAccount() - } + fun logOff(accountInfo: AccountInfo) { + viewModelScope.launch(Dispatchers.IO) { + prepareLogoutOrSwitch() + LocalPreferences.updatePrefsForLogout(accountInfo) + tryLoginExistingAccount() } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt index 176ad67cc..4782cd1f8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.runtime.Immutable @@ -13,88 +33,97 @@ import kotlinx.collections.immutable.toImmutableMap @Immutable abstract class Card() { - abstract fun createdAt(): Long - abstract fun id(): String + abstract fun createdAt(): Long + + abstract fun id(): String } @Immutable class BadgeCard(val note: Note) : Card() { - override fun createdAt(): Long { - return note.createdAt() ?: 0 - } + override fun createdAt(): Long { + return note.createdAt() ?: 0 + } - override fun id() = note.idHex + override fun id() = note.idHex } @Immutable class NoteCard(val note: Note) : Card() { - override fun createdAt(): Long { - return note.createdAt() ?: 0 - } + override fun createdAt(): Long { + return note.createdAt() ?: 0 + } - override fun id() = note.idHex + override fun id() = note.idHex } @Immutable class ZapUserSetCard(val user: User, val zapEvents: ImmutableList) : Card() { - val createdAt = zapEvents.maxOf { it.createdAt() ?: 0 } - override fun createdAt(): Long { - return createdAt - } - override fun id() = user.pubkeyHex + "U" + createdAt + val createdAt = zapEvents.maxOf { it.createdAt() ?: 0 } + + override fun createdAt(): Long { + return createdAt + } + + override fun id() = user.pubkeyHex + "U" + createdAt } @Immutable class MultiSetCard( - val note: Note, - val boostEvents: ImmutableList, - val likeEvents: ImmutableList, - val zapEvents: ImmutableList + val note: Note, + val boostEvents: ImmutableList, + val likeEvents: ImmutableList, + val zapEvents: ImmutableList, ) : Card() { - val maxCreatedAt = maxOf( - zapEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0, - likeEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0, - boostEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0 + val maxCreatedAt = + maxOf( + zapEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0, + likeEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0, + boostEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0, ) - val minCreatedAt = minOf( - zapEvents.minOfOrNull { it.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE, - likeEvents.minOfOrNull { it.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE, - boostEvents.minOfOrNull { it.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE + val minCreatedAt = + minOf( + zapEvents.minOfOrNull { it.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE, + likeEvents.minOfOrNull { it.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE, + boostEvents.minOfOrNull { it.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE, ) - val likeEventsByType = likeEvents.groupBy { - it.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(it.event?.tags() ?: emptyArray())) ?: "+" - }.mapValues { - it.value.toImmutableList() - }.toImmutableMap() + val likeEventsByType = + likeEvents + .groupBy { + it.event + ?.content() + ?.firstFullCharOrEmoji(ImmutableListOfLists(it.event?.tags() ?: emptyArray())) + ?: "+" + } + .mapValues { it.value.toImmutableList() } + .toImmutableMap() - override fun createdAt(): Long { - return maxCreatedAt - } - override fun id() = note.idHex + "X" + maxCreatedAt + "X" + minCreatedAt + override fun createdAt(): Long { + return maxCreatedAt + } + + override fun id() = note.idHex + "X" + maxCreatedAt + "X" + minCreatedAt } @Immutable class MessageSetCard(val note: Note) : Card() { - override fun createdAt(): Long { - return note.createdAt() ?: 0 - } + override fun createdAt(): Long { + return note.createdAt() ?: 0 + } - override fun id() = note.idHex + override fun id() = note.idHex } @Immutable sealed class CardFeedState { - @Immutable - object Loading : CardFeedState() + @Immutable object Loading : CardFeedState() - @Stable - class Loaded(val feed: MutableState>, val showHidden: MutableState) : CardFeedState() + @Stable + class Loaded(val feed: MutableState>, val showHidden: MutableState) : + CardFeedState() - @Immutable - object Empty : CardFeedState() + @Immutable object Empty : CardFeedState() - @Immutable - class FeedError(val errorMessage: String) : CardFeedState() + @Immutable class FeedError(val errorMessage: String) : CardFeedState() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt index e1fdc4fbe..948f179e1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.animation.Crossfade @@ -35,224 +55,225 @@ import com.vitorpamplona.amethyst.ui.theme.FeedPadding @Composable fun RefresheableCardView( - viewModel: CardFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - routeForLastRead: String, - scrollStateKey: String? = null, - enablePullRefresh: Boolean = true + viewModel: CardFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + routeForLastRead: String, + scrollStateKey: String? = null, + enablePullRefresh: Boolean = true, ) { - var refreshing by remember { mutableStateOf(false) } - val pullRefreshState = rememberPullRefreshState( - refreshing, - onRefresh = - { - refreshing = true - viewModel.invalidateData() - refreshing = false - } + var refreshing by remember { mutableStateOf(false) } + val pullRefreshState = + rememberPullRefreshState( + refreshing, + onRefresh = { + refreshing = true + viewModel.invalidateData() + refreshing = false + }, ) - val modifier = if (enablePullRefresh) { - Modifier.fillMaxSize().pullRefresh(pullRefreshState) + val modifier = + if (enablePullRefresh) { + Modifier.fillMaxSize().pullRefresh(pullRefreshState) } else { - Modifier.fillMaxSize() + Modifier.fillMaxSize() } - Box(modifier) { - SaveableCardFeedState(viewModel, accountViewModel, nav, routeForLastRead, scrollStateKey) + Box(modifier) { + SaveableCardFeedState(viewModel, accountViewModel, nav, routeForLastRead, scrollStateKey) - if (enablePullRefresh) { - PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) - } + if (enablePullRefresh) { + PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) } + } } @Composable private fun SaveableCardFeedState( - viewModel: CardFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - routeForLastRead: String, - scrollStateKey: String? = null + viewModel: CardFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + routeForLastRead: String, + scrollStateKey: String? = null, ) { - val listState = if (scrollStateKey != null) { - rememberForeverLazyListState(scrollStateKey) + val listState = + if (scrollStateKey != null) { + rememberForeverLazyListState(scrollStateKey) } else { - rememberLazyListState() + rememberLazyListState() } - WatchScrollToTop(viewModel, listState) + WatchScrollToTop(viewModel, listState) - RenderCardFeed(viewModel, accountViewModel, listState, nav, routeForLastRead) + RenderCardFeed(viewModel, accountViewModel, listState, nav, routeForLastRead) } @Composable private fun WatchScrollToTop( - viewModel: CardFeedViewModel, - listState: LazyListState + viewModel: CardFeedViewModel, + listState: LazyListState, ) { - val scrollToTop by viewModel.scrollToTop.collectAsStateWithLifecycle() + val scrollToTop by viewModel.scrollToTop.collectAsStateWithLifecycle() - LaunchedEffect(scrollToTop) { - if (scrollToTop > 0 && viewModel.scrolltoTopPending) { - listState.scrollToItem(index = 0) - viewModel.sentToTop() - } + LaunchedEffect(scrollToTop) { + if (scrollToTop > 0 && viewModel.scrolltoTopPending) { + listState.scrollToItem(index = 0) + viewModel.sentToTop() } + } } @Composable fun RenderCardFeed( - viewModel: CardFeedViewModel, - accountViewModel: AccountViewModel, - listState: LazyListState, - nav: (String) -> Unit, - routeForLastRead: String + viewModel: CardFeedViewModel, + accountViewModel: AccountViewModel, + listState: LazyListState, + nav: (String) -> Unit, + routeForLastRead: String, ) { - val feedState by viewModel.feedContent.collectAsStateWithLifecycle() + val feedState by viewModel.feedContent.collectAsStateWithLifecycle() - Crossfade( - modifier = Modifier.fillMaxSize(), - targetState = feedState, - animationSpec = tween(durationMillis = 100) - ) { state -> - when (state) { - is CardFeedState.Empty -> { - FeedEmpty { - viewModel.invalidateData() - } - } - is CardFeedState.FeedError -> { - FeedError(state.errorMessage) { - viewModel.invalidateData() - } - } - is CardFeedState.Loaded -> { - FeedLoaded( - state = state, - listState = listState, - routeForLastRead = routeForLastRead, - accountViewModel = accountViewModel, - nav = nav - ) - } - CardFeedState.Loading -> { - LoadingFeed() - } - } + Crossfade( + modifier = Modifier.fillMaxSize(), + targetState = feedState, + animationSpec = tween(durationMillis = 100), + ) { state -> + when (state) { + is CardFeedState.Empty -> { + FeedEmpty { viewModel.invalidateData() } + } + is CardFeedState.FeedError -> { + FeedError(state.errorMessage) { viewModel.invalidateData() } + } + is CardFeedState.Loaded -> { + FeedLoaded( + state = state, + listState = listState, + routeForLastRead = routeForLastRead, + accountViewModel = accountViewModel, + nav = nav, + ) + } + CardFeedState.Loading -> { + LoadingFeed() + } } + } } @OptIn(ExperimentalFoundationApi::class) @Composable private fun FeedLoaded( - state: CardFeedState.Loaded, - listState: LazyListState, - routeForLastRead: String, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + state: CardFeedState.Loaded, + listState: LazyListState, + routeForLastRead: String, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = FeedPadding, - state = listState - ) { - itemsIndexed(state.feed.value, key = { _, item -> item.id() }) { _, item -> - val defaultModifier = remember { - Modifier.fillMaxWidth().animateItemPlacement() - } + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = FeedPadding, + state = listState, + ) { + itemsIndexed(state.feed.value, key = { _, item -> item.id() }) { _, item -> + val defaultModifier = remember { Modifier.fillMaxWidth().animateItemPlacement() } - Row(defaultModifier) { - RenderCardItem(item, routeForLastRead, showHidden = state.showHidden.value, accountViewModel, nav) - } - } + Row(defaultModifier) { + RenderCardItem( + item, + routeForLastRead, + showHidden = state.showHidden.value, + accountViewModel, + nav, + ) + } } + } } @Composable private fun RenderCardItem( - item: Card, - routeForLastRead: String, - showHidden: Boolean, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + item: Card, + routeForLastRead: String, + showHidden: Boolean, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - when (item) { - is NoteCard -> NoteCardCompose( - item, - isBoostedNote = false, - accountViewModel = accountViewModel, - showHidden = showHidden, - nav = nav, - routeForLastRead = routeForLastRead - ) - - is ZapUserSetCard -> ZapUserSetCompose( - item, - isInnerNote = false, - accountViewModel = accountViewModel, - nav = nav, - routeForLastRead = routeForLastRead - ) - - is MultiSetCard -> MultiSetCompose( - item, - accountViewModel = accountViewModel, - showHidden = showHidden, - nav = nav, - routeForLastRead = routeForLastRead - ) - - is BadgeCard -> BadgeCompose( - item, - accountViewModel = accountViewModel, - showHidden = showHidden, - nav = nav, - routeForLastRead = routeForLastRead - ) - - is MessageSetCard -> MessageSetCompose( - messageSetCard = item, - routeForLastRead = routeForLastRead, - showHidden = showHidden, - accountViewModel = accountViewModel, - nav = nav - ) - } + when (item) { + is NoteCard -> + NoteCardCompose( + item, + isBoostedNote = false, + accountViewModel = accountViewModel, + showHidden = showHidden, + nav = nav, + routeForLastRead = routeForLastRead, + ) + is ZapUserSetCard -> + ZapUserSetCompose( + item, + isInnerNote = false, + accountViewModel = accountViewModel, + nav = nav, + routeForLastRead = routeForLastRead, + ) + is MultiSetCard -> + MultiSetCompose( + item, + accountViewModel = accountViewModel, + showHidden = showHidden, + nav = nav, + routeForLastRead = routeForLastRead, + ) + is BadgeCard -> + BadgeCompose( + item, + accountViewModel = accountViewModel, + showHidden = showHidden, + nav = nav, + routeForLastRead = routeForLastRead, + ) + is MessageSetCard -> + MessageSetCompose( + messageSetCard = item, + routeForLastRead = routeForLastRead, + showHidden = showHidden, + accountViewModel = accountViewModel, + nav = nav, + ) + } } @Composable fun NoteCardCompose( - baseNote: NoteCard, - routeForLastRead: String? = null, - modifier: Modifier = remember { Modifier }, - isBoostedNote: Boolean = false, - isQuotedNote: Boolean = false, - unPackReply: Boolean = true, - makeItShort: Boolean = false, - addMarginTop: Boolean = true, - showHidden: Boolean = false, - parentBackgroundColor: MutableState? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: NoteCard, + routeForLastRead: String? = null, + modifier: Modifier = remember { Modifier }, + isBoostedNote: Boolean = false, + isQuotedNote: Boolean = false, + unPackReply: Boolean = true, + makeItShort: Boolean = false, + addMarginTop: Boolean = true, + showHidden: Boolean = false, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val note = remember(baseNote) { - baseNote.note - } + val note = remember(baseNote) { baseNote.note } - NoteCompose( - baseNote = note, - routeForLastRead = routeForLastRead, - modifier = modifier, - isBoostedNote = isBoostedNote, - isQuotedNote = isQuotedNote, - unPackReply = unPackReply, - makeItShort = makeItShort, - addMarginTop = addMarginTop, - showHidden = showHidden, - parentBackgroundColor = parentBackgroundColor, - accountViewModel = accountViewModel, - nav = nav - ) + NoteCompose( + baseNote = note, + routeForLastRead = routeForLastRead, + modifier = modifier, + isBoostedNote = isBoostedNote, + isQuotedNote = isQuotedNote, + unPackReply = unPackReply, + makeItShort = makeItShort, + addMarginTop = addMarginTop, + showHidden = showHidden, + parentBackgroundColor = parentBackgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt index 7975ab545..2b934246d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen import android.util.Log @@ -26,6 +46,10 @@ import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.ReactionEvent import com.vitorpamplona.quartz.events.RepostEvent +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import kotlin.time.measureTimedValue import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers @@ -34,364 +58,414 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import kotlin.time.measureTimedValue @Stable -class NotificationViewModel(val account: Account) : CardFeedViewModel(NotificationFeedFilter(account)) { - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NotificationViewModel { - return NotificationViewModel(account) as NotificationViewModel - } +class NotificationViewModel(val account: Account) : + CardFeedViewModel(NotificationFeedFilter(account)) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): NotificationViewModel { + return NotificationViewModel(account) as NotificationViewModel } + } } @Stable open class CardFeedViewModel(val localFilter: FeedFilter) : ViewModel() { - private val _feedContent = MutableStateFlow(CardFeedState.Loading) - val feedContent = _feedContent.asStateFlow() + private val _feedContent = MutableStateFlow(CardFeedState.Loading) + val feedContent = _feedContent.asStateFlow() - // Simple counter that changes when it needs to invalidate everything - private val _scrollToTop = MutableStateFlow(0) - val scrollToTop = _scrollToTop.asStateFlow() - var scrolltoTopPending = false + // Simple counter that changes when it needs to invalidate everything + private val _scrollToTop = MutableStateFlow(0) + val scrollToTop = _scrollToTop.asStateFlow() + var scrolltoTopPending = false - private var lastFeedKey: String? = null + private var lastFeedKey: String? = null - fun sendToTop() { - if (scrolltoTopPending) return + fun sendToTop() { + if (scrolltoTopPending) return - scrolltoTopPending = true - viewModelScope.launch(Dispatchers.IO) { - _scrollToTop.emit(_scrollToTop.value + 1) + scrolltoTopPending = true + viewModelScope.launch(Dispatchers.IO) { _scrollToTop.emit(_scrollToTop.value + 1) } + } + + suspend fun sentToTop() { + scrolltoTopPending = false + } + + private var lastAccount: Account? = null + private var lastNotes: Set? = null + + fun refresh() { + viewModelScope.launch(Dispatchers.Default) { refreshSuspended() } + } + + @Synchronized + private fun refreshSuspended() { + checkNotInMainThread() + + val notes = localFilter.feed() + lastFeedKey = localFilter.feedKey() + + val thisAccount = (localFilter as? NotificationFeedFilter)?.account + val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null + + val oldNotesState = _feedContent.value + if (lastNotesCopy != null && oldNotesState is CardFeedState.Loaded) { + val newCards = convertToCard(notes.minus(lastNotesCopy)) + if (newCards.isNotEmpty()) { + lastNotes = notes.toSet() + lastAccount = (localFilter as? NotificationFeedFilter)?.account + + val updatedCards = + (oldNotesState.feed.value + newCards) + .distinctBy { it.id() } + .sortedWith(compareBy({ it.createdAt() }, { it.id() })) + .reversed() + .take(localFilter.limit()) + .toImmutableList() + + if (!equalImmutableLists(oldNotesState.feed.value, updatedCards)) { + updateFeed(updatedCards) } + } + } else { + lastNotes = notes.toSet() + lastAccount = (localFilter as? NotificationFeedFilter)?.account + + val cards = + convertToCard(notes) + .sortedWith(compareBy({ it.createdAt() }, { it.id() })) + .reversed() + .take(localFilter.limit()) + .toImmutableList() + + updateFeed(cards) } + } - suspend fun sentToTop() { - scrolltoTopPending = false - } + private fun convertToCard(notes: Collection): List { + checkNotInMainThread() - private var lastAccount: Account? = null - private var lastNotes: Set? = null - - fun refresh() { - viewModelScope.launch(Dispatchers.Default) { - refreshSuspended() + val reactionsPerEvent = mutableMapOf>() + notes + .filter { it.event is ReactionEvent } + .forEach { + val reactedPost = + it.replyTo?.lastOrNull { + it.event !is ChannelMetadataEvent && it.event !is ChannelCreateEvent + } + if (reactedPost != null) { + reactionsPerEvent.getOrPut(reactedPost, { mutableListOf() }).add(it) } - } + } - @Synchronized - private fun refreshSuspended() { - checkNotInMainThread() - - val notes = localFilter.feed() - lastFeedKey = localFilter.feedKey() - - val thisAccount = (localFilter as? NotificationFeedFilter)?.account - val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null - - val oldNotesState = _feedContent.value - if (lastNotesCopy != null && oldNotesState is CardFeedState.Loaded) { - val newCards = convertToCard(notes.minus(lastNotesCopy)) - if (newCards.isNotEmpty()) { - lastNotes = notes.toSet() - lastAccount = (localFilter as? NotificationFeedFilter)?.account - - val updatedCards = (oldNotesState.feed.value + newCards) - .distinctBy { it.id() } - .sortedWith(compareBy({ it.createdAt() }, { it.id() })) - .reversed() - .take(localFilter.limit()) - .toImmutableList() - - if (!equalImmutableLists(oldNotesState.feed.value, updatedCards)) { - updateFeed(updatedCards) - } - } + // val reactionCards = reactionsPerEvent.map { LikeSetCard(it.key, it.value) } + val zapsPerUser = mutableMapOf>() + val zapsPerEvent = mutableMapOf>() + notes + .filter { it.event is LnZapEvent } + .forEach { zapEvent -> + val zappedPost = zapEvent.replyTo?.lastOrNull() + if (zappedPost != null) { + val zapRequest = zappedPost.zaps.filter { it.value == zapEvent }.keys.firstOrNull() + if (zapRequest != null) { + // var newZapRequestEvent = LocalCache.checkPrivateZap(zapRequest.event as Event) + // zapRequest.event = newZapRequestEvent + zapsPerEvent + .getOrPut(zappedPost, { mutableListOf() }) + .add(CombinedZap(zapRequest, zapEvent)) + } } else { - lastNotes = notes.toSet() - lastAccount = (localFilter as? NotificationFeedFilter)?.account + val event = (zapEvent.event as LnZapEvent) + val author = + event.zappedAuthor().firstNotNullOfOrNull { + LocalCache.users[it] // don't create user if it doesn't exist + } + if (author != null) { + val zapRequest = author.zaps.filter { it.value == zapEvent }.keys.firstOrNull() + if (zapRequest != null) { + zapsPerUser + .getOrPut(author, { mutableListOf() }) + .add(CombinedZap(zapRequest, zapEvent)) + } + } + } + } - val cards = convertToCard(notes) - .sortedWith(compareBy({ it.createdAt() }, { it.id() })) + val boostsPerEvent = mutableMapOf>() + notes + .filter { it.event is RepostEvent || it.event is GenericRepostEvent } + .forEach { + val boostedPost = + it.replyTo?.lastOrNull { + it.event !is ChannelMetadataEvent && it.event !is ChannelCreateEvent + } + if (boostedPost != null) { + boostsPerEvent.getOrPut(boostedPost, { mutableListOf() }).add(it) + } + } + + val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat() + + val allBaseNotes = zapsPerEvent.keys + boostsPerEvent.keys + reactionsPerEvent.keys + val multiCards = + allBaseNotes + .map { baseNote -> + val boostsInCard = boostsPerEvent[baseNote] ?: emptyList() + val reactionsInCard = reactionsPerEvent[baseNote] ?: emptyList() + val zapsInCard = zapsPerEvent[baseNote] ?: emptyList() + + val singleList = + (boostsInCard + zapsInCard.map { it.response } + reactionsInCard).groupBy { + sdf.format( + Instant.ofEpochSecond(it.createdAt() ?: 0) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(), + ) + } + + val days = singleList.keys.sortedBy { it } + + days + .mapNotNull { + val sortedList = + singleList + .get(it) + ?.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) + ?.reversed() + + sortedList?.chunked(30)?.map { chunk -> + MultiSetCard( + baseNote, + boostsInCard.filter { it in chunk }.toImmutableList(), + reactionsInCard.filter { it in chunk }.toImmutableList(), + zapsInCard.filter { it.response in chunk }.toImmutableList(), + ) + } + } + .flatten() + } + .flatten() + + val userZaps = + zapsPerUser + .map { user -> + val byDay = + user.value.groupBy { + sdf.format( + Instant.ofEpochSecond(it.createdAt() ?: 0) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(), + ) + } + + byDay.values.map { + ZapUserSetCard( + user.key, + it + .sortedWith(compareBy({ it.createdAt() }, { it.idHex() })) .reversed() - .take(localFilter.limit()) - .toImmutableList() - - updateFeed(cards) + .toImmutableList(), + ) + } } - } + .flatten() - private fun convertToCard(notes: Collection): List { - checkNotInMainThread() - - val reactionsPerEvent = mutableMapOf>() - notes - .filter { it.event is ReactionEvent } - .forEach { - val reactedPost = it.replyTo?.lastOrNull() { it.event !is ChannelMetadataEvent && it.event !is ChannelCreateEvent } - if (reactedPost != null) { - reactionsPerEvent.getOrPut(reactedPost, { mutableListOf() }).add(it) - } - } - - // val reactionCards = reactionsPerEvent.map { LikeSetCard(it.key, it.value) } - val zapsPerUser = mutableMapOf>() - val zapsPerEvent = mutableMapOf>() - notes - .filter { it.event is LnZapEvent } - .forEach { zapEvent -> - val zappedPost = zapEvent.replyTo?.lastOrNull() - if (zappedPost != null) { - val zapRequest = zappedPost.zaps.filter { it.value == zapEvent }.keys.firstOrNull() - if (zapRequest != null) { - // var newZapRequestEvent = LocalCache.checkPrivateZap(zapRequest.event as Event) - // zapRequest.event = newZapRequestEvent - zapsPerEvent.getOrPut(zappedPost, { mutableListOf() }).add(CombinedZap(zapRequest, zapEvent)) - } - } else { - val event = (zapEvent.event as LnZapEvent) - val author = event.zappedAuthor().firstNotNullOfOrNull { - LocalCache.users[it] // don't create user if it doesn't exist - } - if (author != null) { - val zapRequest = author.zaps.filter { it.value == zapEvent }.keys.firstOrNull() - if (zapRequest != null) { - zapsPerUser.getOrPut(author, { mutableListOf() }) - .add(CombinedZap(zapRequest, zapEvent)) - } - } - } - } - - val boostsPerEvent = mutableMapOf>() - notes - .filter { it.event is RepostEvent || it.event is GenericRepostEvent } - .forEach { - val boostedPost = it.replyTo?.lastOrNull() { it.event !is ChannelMetadataEvent && it.event !is ChannelCreateEvent } - if (boostedPost != null) { - boostsPerEvent.getOrPut(boostedPost, { mutableListOf() }).add(it) - } - } - - val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat() - - val allBaseNotes = zapsPerEvent.keys + boostsPerEvent.keys + reactionsPerEvent.keys - val multiCards = allBaseNotes.map { baseNote -> - val boostsInCard = boostsPerEvent[baseNote] ?: emptyList() - val reactionsInCard = reactionsPerEvent[baseNote] ?: emptyList() - val zapsInCard = zapsPerEvent[baseNote] ?: emptyList() - - val singleList = (boostsInCard + zapsInCard.map { it.response } + reactionsInCard) - .groupBy { - sdf.format( - Instant.ofEpochSecond(it.createdAt() ?: 0) - .atZone(ZoneId.systemDefault()) - .toLocalDateTime() - ) - } - - val days = singleList.keys.sortedBy { it } - - days.mapNotNull { - val sortedList = singleList.get(it)?.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))?.reversed() - - sortedList?.chunked(30)?.map { chunk -> - MultiSetCard( - baseNote, - boostsInCard.filter { it in chunk }.toImmutableList(), - reactionsInCard.filter { it in chunk }.toImmutableList(), - zapsInCard.filter { it.response in chunk }.toImmutableList() - ) - } - }.flatten() - }.flatten() - - val userZaps = zapsPerUser.map { user -> - val byDay = user.value.groupBy { - sdf.format( - Instant.ofEpochSecond(it.createdAt() ?: 0) - .atZone(ZoneId.systemDefault()) - .toLocalDateTime() - ) - } - - byDay.values.map { - ZapUserSetCard( - user.key, - it.sortedWith(compareBy({ it.createdAt() }, { it.idHex() })).reversed().toImmutableList() - ) - } - }.flatten() - - val textNoteCards = notes.filter { it.event !is ReactionEvent && it.event !is RepostEvent && it.event !is GenericRepostEvent && it.event !is LnZapEvent }.map { - if (it.event is PrivateDmEvent || it.event is ChatMessageEvent) { - MessageSetCard(it) - } else if (it.event is BadgeAwardEvent) { - BadgeCard(it) - } else { - NoteCard(it) - } + val textNoteCards = + notes + .filter { + it.event !is ReactionEvent && + it.event !is RepostEvent && + it.event !is GenericRepostEvent && + it.event !is LnZapEvent + } + .map { + if (it.event is PrivateDmEvent || it.event is ChatMessageEvent) { + MessageSetCard(it) + } else if (it.event is BadgeAwardEvent) { + BadgeCard(it) + } else { + NoteCard(it) + } } - return (multiCards + textNoteCards + userZaps).sortedWith(compareBy({ it.createdAt() }, { it.id() })).reversed() - } + return (multiCards + textNoteCards + userZaps) + .sortedWith(compareBy({ it.createdAt() }, { it.id() })) + .reversed() + } - private fun updateFeed(notes: ImmutableList) { - viewModelScope.launch(Dispatchers.Main) { - val currentState = _feedContent.value + private fun updateFeed(notes: ImmutableList) { + viewModelScope.launch(Dispatchers.Main) { + val currentState = _feedContent.value - if (notes.isEmpty()) { - _feedContent.update { CardFeedState.Empty } - } else if (currentState is CardFeedState.Loaded) { - currentState.showHidden.value = localFilter.showHiddenKey() - currentState.feed.value = notes - } else { - _feedContent.update { CardFeedState.Loaded(mutableStateOf(notes), mutableStateOf(localFilter.showHiddenKey())) } - } + if (notes.isEmpty()) { + _feedContent.update { CardFeedState.Empty } + } else if (currentState is CardFeedState.Loaded) { + currentState.showHidden.value = localFilter.showHiddenKey() + currentState.feed.value = notes + } else { + _feedContent.update { + CardFeedState.Loaded(mutableStateOf(notes), mutableStateOf(localFilter.showHiddenKey())) } + } } + } - private fun refreshFromOldState(newItems: Set) { - val oldNotesState = _feedContent.value + private fun refreshFromOldState(newItems: Set) { + val oldNotesState = _feedContent.value - val thisAccount = (localFilter as? NotificationFeedFilter)?.account - val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null + val thisAccount = (localFilter as? NotificationFeedFilter)?.account + val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null - if (lastNotesCopy != null && localFilter is AdditiveFeedFilter && oldNotesState is CardFeedState.Loaded && lastFeedKey == localFilter.feedKey()) { - val filteredNewList = localFilter.applyFilter(newItems) + if ( + lastNotesCopy != null && + localFilter is AdditiveFeedFilter && + oldNotesState is CardFeedState.Loaded && + lastFeedKey == localFilter.feedKey() + ) { + val filteredNewList = localFilter.applyFilter(newItems) - if (filteredNewList.isEmpty()) return + if (filteredNewList.isEmpty()) return - val actuallyNew = filteredNewList.minus(lastNotesCopy) + val actuallyNew = filteredNewList.minus(lastNotesCopy) - if (actuallyNew.isEmpty()) return + if (actuallyNew.isEmpty()) return - val newCards = convertToCard(actuallyNew) + val newCards = convertToCard(actuallyNew) - if (newCards.isNotEmpty()) { - lastNotes = lastNotesCopy + actuallyNew - lastAccount = (localFilter as? NotificationFeedFilter)?.account + if (newCards.isNotEmpty()) { + lastNotes = lastNotesCopy + actuallyNew + lastAccount = (localFilter as? NotificationFeedFilter)?.account - val updatedCards = (oldNotesState.feed.value + newCards) - .distinctBy { it.id() } - .sortedWith(compareBy({ it.createdAt() }, { it.id() })) - .reversed() - .take(localFilter.limit()) - .toImmutableList() + val updatedCards = + (oldNotesState.feed.value + newCards) + .distinctBy { it.id() } + .sortedWith(compareBy({ it.createdAt() }, { it.id() })) + .reversed() + .take(localFilter.limit()) + .toImmutableList() - if (!equalImmutableLists(oldNotesState.feed.value, updatedCards)) { - updateFeed(updatedCards) - } - } - } else { - // Refresh Everything + if (!equalImmutableLists(oldNotesState.feed.value, updatedCards)) { + updateFeed(updatedCards) + } + } + } else { + // Refresh Everything + refreshSuspended() + } + } + + private val bundler = BundledUpdate(1000, Dispatchers.IO) + private val bundlerInsert = BundledInsert>(1000, Dispatchers.IO) + + fun invalidateData(ignoreIfDoing: Boolean = false) { + bundler.invalidate(ignoreIfDoing) { + // adds the time to perform the refresh into this delay + // holding off new updates in case of heavy refresh routines. + val (value, elapsed) = measureTimedValue { refreshSuspended() } + Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed") + } + } + + fun invalidateDataAndSendToTop() { + clear() + bundler.invalidate(false) { + // adds the time to perform the refresh into this delay + // holding off new updates in case of heavy refresh routines. + val (value, elapsed) = + measureTimedValue { + refreshSuspended() + sendToTop() + } + Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed") + } + } + + fun checkKeysInvalidateDataAndSendToTop() { + if (lastFeedKey != localFilter.feedKey()) { + clear() + bundler.invalidate(false) { + // adds the time to perform the refresh into this delay + // holding off new updates in case of heavy refresh routines. + val (value, elapsed) = + measureTimedValue { refreshSuspended() + sendToTop() + } + Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed") + } + } + } + + fun invalidateInsertData(newItems: Set) { + bundlerInsert.invalidateList(newItems) { + val newObjects = it.flatten().toSet() + val (value, elapsed) = + measureTimedValue { + if (newObjects.isNotEmpty()) { + refreshFromOldState(newObjects) + } } + Log.d( + "Time", + "${this.javaClass.simpleName} Card additive update $elapsed. ${newObjects.size}", + ) } + } - private val bundler = BundledUpdate(1000, Dispatchers.IO) - private val bundlerInsert = BundledInsert>(1000, Dispatchers.IO) + var collectorJob: Job? = null - fun invalidateData(ignoreIfDoing: Boolean = false) { - bundler.invalidate(ignoreIfDoing) { - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - val (value, elapsed) = measureTimedValue { - refreshSuspended() - } - Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed") + init { + Log.d("Init", "${this.javaClass.simpleName}") + collectorJob = + viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { newNotes -> + checkNotInMainThread() + + if (localFilter is AdditiveFeedFilter && _feedContent.value is CardFeedState.Loaded) { + invalidateInsertData(newNotes) + } else { + // Refresh Everything + invalidateData() + } } - } + } + } - fun invalidateDataAndSendToTop() { - clear() - bundler.invalidate(false) { - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - val (value, elapsed) = measureTimedValue { - refreshSuspended() - sendToTop() - } - Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed") - } - } + fun clear() { + lastAccount = null + lastNotes = null + } - fun checkKeysInvalidateDataAndSendToTop() { - if (lastFeedKey != localFilter.feedKey()) { - clear() - bundler.invalidate(false) { - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - val (value, elapsed) = measureTimedValue { - refreshSuspended() - sendToTop() - } - Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed") - } - } - } - - fun invalidateInsertData(newItems: Set) { - bundlerInsert.invalidateList(newItems) { - val newObjects = it.flatten().toSet() - val (value, elapsed) = measureTimedValue { - if (newObjects.isNotEmpty()) { - refreshFromOldState(newObjects) - } - } - Log.d("Time", "${this.javaClass.simpleName} Card additive update $elapsed. ${newObjects.size}") - } - } - - var collectorJob: Job? = null - - init { - Log.d("Init", "${this.javaClass.simpleName}") - collectorJob = viewModelScope.launch(Dispatchers.IO) { - LocalCache.live.newEventBundles.collect { newNotes -> - checkNotInMainThread() - - if (localFilter is AdditiveFeedFilter && _feedContent.value is CardFeedState.Loaded) { - invalidateInsertData(newNotes) - } else { - // Refresh Everything - invalidateData() - } - } - } - } - - fun clear() { - lastAccount = null - lastNotes = null - } - - override fun onCleared() { - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") - clear() - bundlerInsert.cancel() - bundler.cancel() - collectorJob?.cancel() - super.onCleared() - } + override fun onCleared() { + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + clear() + bundlerInsert.cancel() + bundler.cancel() + collectorJob?.cancel() + super.onCleared() + } } -fun equalImmutableLists(list1: ImmutableList, list2: ImmutableList): Boolean { - if (list1 === list2) return true - if (list1.size != list2.size) return false - for (i in 0 until list1.size) { - if (list1[i] !== list2[i]) { - return false - } +fun equalImmutableLists( + list1: ImmutableList, + list2: ImmutableList, +): Boolean { + if (list1 === list2) return true + if (list1.size != list2.size) return false + for (i in 0 until list1.size) { + if (list1[i] !== list2[i]) { + return false } - return true + } + return true } @Immutable data class CombinedZap(val request: Note, val response: Note) { - fun createdAt() = response.createdAt() - fun idHex() = response.idHex + fun createdAt() = response.createdAt() + + fun idHex() = response.idHex } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt index d159fe399..0758a24f1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.animation.Crossfade @@ -26,113 +46,121 @@ import com.vitorpamplona.amethyst.ui.theme.HalfPadding @Composable fun RefreshingChatroomFeedView( - viewModel: FeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - routeForLastRead: String, - onWantsToReply: (Note) -> Unit, - scrollStateKey: String? = null, - enablePullRefresh: Boolean = true + viewModel: FeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + routeForLastRead: String, + onWantsToReply: (Note) -> Unit, + scrollStateKey: String? = null, + enablePullRefresh: Boolean = true, ) { - RefresheableView(viewModel, enablePullRefresh) { - SaveableFeedState(viewModel, scrollStateKey) { listState -> - RenderChatroomFeedView(viewModel, accountViewModel, listState, nav, routeForLastRead, onWantsToReply) - } + RefresheableView(viewModel, enablePullRefresh) { + SaveableFeedState(viewModel, scrollStateKey) { listState -> + RenderChatroomFeedView( + viewModel, + accountViewModel, + listState, + nav, + routeForLastRead, + onWantsToReply, + ) } + } } @Composable fun RenderChatroomFeedView( - viewModel: FeedViewModel, - accountViewModel: AccountViewModel, - listState: LazyListState, - nav: (String) -> Unit, - routeForLastRead: String, - onWantsToReply: (Note) -> Unit + viewModel: FeedViewModel, + accountViewModel: AccountViewModel, + listState: LazyListState, + nav: (String) -> Unit, + routeForLastRead: String, + onWantsToReply: (Note) -> Unit, ) { - val feedState by viewModel.feedContent.collectAsStateWithLifecycle() + val feedState by viewModel.feedContent.collectAsStateWithLifecycle() - Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> - when (state) { - is FeedState.Empty -> { - FeedEmpty { - viewModel.invalidateData() - } - } - is FeedState.FeedError -> { - FeedError(state.errorMessage) { - viewModel.invalidateData() - } - } - is FeedState.Loaded -> { - ChatroomFeedLoaded(state, accountViewModel, listState, nav, routeForLastRead, onWantsToReply) - } - is FeedState.Loading -> { - LoadingFeed() - } - } + Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> + when (state) { + is FeedState.Empty -> { + FeedEmpty { viewModel.invalidateData() } + } + is FeedState.FeedError -> { + FeedError(state.errorMessage) { viewModel.invalidateData() } + } + is FeedState.Loaded -> { + ChatroomFeedLoaded( + state, + accountViewModel, + listState, + nav, + routeForLastRead, + onWantsToReply, + ) + } + is FeedState.Loading -> { + LoadingFeed() + } } + } } @Composable fun ChatroomFeedLoaded( - state: FeedState.Loaded, - accountViewModel: AccountViewModel, - listState: LazyListState, - nav: (String) -> Unit, - routeForLastRead: String, - onWantsToReply: (Note) -> Unit + state: FeedState.Loaded, + accountViewModel: AccountViewModel, + listState: LazyListState, + nav: (String) -> Unit, + routeForLastRead: String, + onWantsToReply: (Note) -> Unit, ) { - LaunchedEffect(state.feed.value.firstOrNull()) { - if (listState.firstVisibleItemIndex <= 1) { - listState.animateScrollToItem(0) - } + LaunchedEffect(state.feed.value.firstOrNull()) { + if (listState.firstVisibleItemIndex <= 1) { + listState.animateScrollToItem(0) } + } - LazyColumn( - contentPadding = FeedPadding, - modifier = Modifier.fillMaxSize(), - reverseLayout = true, - state = listState - ) { - itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item -> - ChatroomMessageCompose( - baseNote = item, - routeForLastRead = routeForLastRead, - accountViewModel = accountViewModel, - nav = nav, - onWantsToReply = onWantsToReply - ) - NewSubject(item) - } + LazyColumn( + contentPadding = FeedPadding, + modifier = Modifier.fillMaxSize(), + reverseLayout = true, + state = listState, + ) { + itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item -> + ChatroomMessageCompose( + baseNote = item, + routeForLastRead = routeForLastRead, + accountViewModel = accountViewModel, + nav = nav, + onWantsToReply = onWantsToReply, + ) + NewSubject(item) } + } } @Composable fun NewSubject(note: Note) { - val subject = remember(note) { - note.event?.subject() - } + val subject = remember(note) { note.event?.subject() } - if (subject != null) { - NewSubject(newSubject = subject) - } + if (subject != null) { + NewSubject(newSubject = subject) + } } @Composable fun NewSubject(newSubject: String) { - Row(verticalAlignment = Alignment.CenterVertically) { - Divider( - modifier = Modifier.weight(1f) - ) - Text( - text = newSubject, - fontWeight = FontWeight.Bold, - fontSize = Font14SP, - modifier = HalfPadding - ) - Divider( - modifier = Modifier.weight(1f) - ) - } + Row(verticalAlignment = Alignment.CenterVertically) { + Divider( + modifier = Modifier.weight(1f), + ) + Text( + text = newSubject, + fontWeight = FontWeight.Bold, + fontSize = Font14SP, + modifier = HalfPadding, + ) + Divider( + modifier = Modifier.weight(1f), + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt index 72ccbba7a..0388a35e2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.animation.Crossfade @@ -20,86 +40,75 @@ import kotlin.time.ExperimentalTime @Composable fun ChatroomListFeedView( - viewModel: FeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - markAsRead: MutableState + viewModel: FeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + markAsRead: MutableState, ) { - RefresheableView(viewModel, true) { - CrossFadeState(viewModel, accountViewModel, nav, markAsRead) - } + RefresheableView(viewModel, true) { CrossFadeState(viewModel, accountViewModel, nav, markAsRead) } } @Composable private fun CrossFadeState( - viewModel: FeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - markAsRead: MutableState + viewModel: FeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + markAsRead: MutableState, ) { - val feedState by viewModel.feedContent.collectAsStateWithLifecycle() + val feedState by viewModel.feedContent.collectAsStateWithLifecycle() - Crossfade( - targetState = feedState, - animationSpec = tween(durationMillis = 100) - ) { state -> - when (state) { - is FeedState.Empty -> { - FeedEmpty { - viewModel.invalidateData() - } - } - - is FeedState.FeedError -> { - FeedError(state.errorMessage) { - viewModel.invalidateData() - } - } - - is FeedState.Loaded -> { - FeedLoaded(state, accountViewModel, nav, markAsRead) - } - - FeedState.Loading -> { - LoadingFeed() - } - } + Crossfade( + targetState = feedState, + animationSpec = tween(durationMillis = 100), + ) { state -> + when (state) { + is FeedState.Empty -> { + FeedEmpty { viewModel.invalidateData() } + } + is FeedState.FeedError -> { + FeedError(state.errorMessage) { viewModel.invalidateData() } + } + is FeedState.Loaded -> { + FeedLoaded(state, accountViewModel, nav, markAsRead) + } + FeedState.Loading -> { + LoadingFeed() + } } + } } @OptIn(ExperimentalTime::class) @Composable private fun FeedLoaded( - state: FeedState.Loaded, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - markAsRead: MutableState + state: FeedState.Loaded, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + markAsRead: MutableState, ) { - val listState = rememberLazyListState() + val listState = rememberLazyListState() - LaunchedEffect(key1 = markAsRead.value) { - if (markAsRead.value) { - accountViewModel.markAllAsRead(state.feed.value) { - markAsRead.value = false - } - } + LaunchedEffect(key1 = markAsRead.value) { + if (markAsRead.value) { + accountViewModel.markAllAsRead(state.feed.value) { markAsRead.value = false } } + } - LazyColumn( - contentPadding = FeedPadding, - state = listState - ) { - itemsIndexed( - state.feed.value, - key = { index, item -> if (index == 0) index else item.idHex } - ) { _, item -> - Row(Modifier.fillMaxWidth()) { - ChatroomHeaderCompose( - item, - accountViewModel = accountViewModel, - nav = nav - ) - } - } + LazyColumn( + contentPadding = FeedPadding, + state = listState, + ) { + itemsIndexed( + state.feed.value, + key = { index, item -> if (index == 0) index else item.idHex }, + ) { _, item -> + Row(Modifier.fillMaxWidth()) { + ChatroomHeaderCompose( + item, + accountViewModel = accountViewModel, + nav = nav, + ) + } } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedState.kt index 31eaa550b..9bb9c06b9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedState.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.runtime.MutableState @@ -5,8 +25,12 @@ import com.vitorpamplona.amethyst.model.Note import kotlinx.collections.immutable.ImmutableList sealed class FeedState { - object Loading : FeedState() - class Loaded(val feed: MutableState>, val showHidden: MutableState) : FeedState() - object Empty : FeedState() - class FeedError(val errorMessage: String) : FeedState() + object Loading : FeedState() + + class Loaded(val feed: MutableState>, val showHidden: MutableState) : + FeedState() + + object Empty : FeedState() + + class FeedError(val errorMessage: String) : FeedState() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt index 5d68a697a..f12d9002e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.animation.Crossfade @@ -40,251 +60,239 @@ import kotlin.time.ExperimentalTime @Composable fun RefresheableFeedView( - viewModel: FeedViewModel, - routeForLastRead: String?, - enablePullRefresh: Boolean = true, - scrollStateKey: String? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + viewModel: FeedViewModel, + routeForLastRead: String?, + enablePullRefresh: Boolean = true, + scrollStateKey: String? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - RefresheableView(viewModel, enablePullRefresh) { - SaveableFeedState(viewModel, routeForLastRead, scrollStateKey, accountViewModel, nav) - } + RefresheableView(viewModel, enablePullRefresh) { + SaveableFeedState(viewModel, routeForLastRead, scrollStateKey, accountViewModel, nav) + } } @Composable fun RefresheableView( - viewModel: InvalidatableViewModel, - enablePullRefresh: Boolean = true, - content: @Composable () -> Unit + viewModel: InvalidatableViewModel, + enablePullRefresh: Boolean = true, + content: @Composable () -> Unit, ) { - var refreshing by remember { mutableStateOf(false) } - val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false } - val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) + var refreshing by remember { mutableStateOf(false) } + val refresh = { + refreshing = true + viewModel.invalidateData() + refreshing = false + } + val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) - val modifier = remember { - if (enablePullRefresh) { - Modifier - .fillMaxSize() - .pullRefresh(pullRefreshState) - } else { - Modifier.fillMaxSize() - } + val modifier = remember { + if (enablePullRefresh) { + Modifier.fillMaxSize().pullRefresh(pullRefreshState) + } else { + Modifier.fillMaxSize() } + } - Box(modifier) { - content() + Box(modifier) { + content() - if (enablePullRefresh) { - PullRefreshIndicator( - refreshing = refreshing, - state = pullRefreshState, - modifier = remember { - Modifier.align(Alignment.TopCenter) - } - ) - } + if (enablePullRefresh) { + PullRefreshIndicator( + refreshing = refreshing, + state = pullRefreshState, + modifier = remember { Modifier.align(Alignment.TopCenter) }, + ) } + } } @Composable private fun SaveableFeedState( - viewModel: FeedViewModel, - routeForLastRead: String?, - scrollStateKey: String? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + viewModel: FeedViewModel, + routeForLastRead: String?, + scrollStateKey: String? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - SaveableFeedState(viewModel, scrollStateKey) { listState -> - RenderFeed(viewModel, accountViewModel, listState, nav, routeForLastRead) - } + SaveableFeedState(viewModel, scrollStateKey) { listState -> + RenderFeed(viewModel, accountViewModel, listState, nav, routeForLastRead) + } } @Composable fun SaveableFeedState( - viewModel: FeedViewModel, - scrollStateKey: String? = null, - content: @Composable (LazyListState) -> Unit + viewModel: FeedViewModel, + scrollStateKey: String? = null, + content: @Composable (LazyListState) -> Unit, ) { - val listState = if (scrollStateKey != null) { - rememberForeverLazyListState(scrollStateKey) + val listState = + if (scrollStateKey != null) { + rememberForeverLazyListState(scrollStateKey) } else { - rememberLazyListState() + rememberLazyListState() } - WatchScrollToTop(viewModel, listState) + WatchScrollToTop(viewModel, listState) - content(listState) + content(listState) } @Composable fun SaveableGridFeedState( - viewModel: FeedViewModel, - scrollStateKey: String? = null, - content: @Composable (LazyGridState) -> Unit + viewModel: FeedViewModel, + scrollStateKey: String? = null, + content: @Composable (LazyGridState) -> Unit, ) { - val gridState = if (scrollStateKey != null) { - rememberForeverLazyGridState(scrollStateKey) + val gridState = + if (scrollStateKey != null) { + rememberForeverLazyGridState(scrollStateKey) } else { - rememberLazyGridState() + rememberLazyGridState() } - WatchScrollToTop(viewModel, gridState) + WatchScrollToTop(viewModel, gridState) - content(gridState) + content(gridState) } @Composable private fun RenderFeed( - viewModel: FeedViewModel, - accountViewModel: AccountViewModel, - listState: LazyListState, - nav: (String) -> Unit, - routeForLastRead: String? + viewModel: FeedViewModel, + accountViewModel: AccountViewModel, + listState: LazyListState, + nav: (String) -> Unit, + routeForLastRead: String?, ) { - val feedState by viewModel.feedContent.collectAsStateWithLifecycle() + val feedState by viewModel.feedContent.collectAsStateWithLifecycle() - Crossfade( - targetState = feedState, - animationSpec = tween(durationMillis = 100) - ) { state -> - when (state) { - is FeedState.Empty -> { - FeedEmpty { - viewModel.invalidateData() - } - } - - is FeedState.FeedError -> { - FeedError(state.errorMessage) { - viewModel.invalidateData() - } - } - - is FeedState.Loaded -> { - FeedLoaded( - state = state, - listState = listState, - routeForLastRead = routeForLastRead, - accountViewModel = accountViewModel, - nav = nav - ) - } - - is FeedState.Loading -> { - LoadingFeed() - } - } + Crossfade( + targetState = feedState, + animationSpec = tween(durationMillis = 100), + ) { state -> + when (state) { + is FeedState.Empty -> { + FeedEmpty { viewModel.invalidateData() } + } + is FeedState.FeedError -> { + FeedError(state.errorMessage) { viewModel.invalidateData() } + } + is FeedState.Loaded -> { + FeedLoaded( + state = state, + listState = listState, + routeForLastRead = routeForLastRead, + accountViewModel = accountViewModel, + nav = nav, + ) + } + is FeedState.Loading -> { + LoadingFeed() + } } + } } @Composable private fun WatchScrollToTop( - viewModel: FeedViewModel, - listState: LazyListState + viewModel: FeedViewModel, + listState: LazyListState, ) { - val scrollToTop by viewModel.scrollToTop.collectAsStateWithLifecycle() + val scrollToTop by viewModel.scrollToTop.collectAsStateWithLifecycle() - LaunchedEffect(scrollToTop) { - if (scrollToTop > 0 && viewModel.scrolltoTopPending) { - listState.scrollToItem(index = 0) - viewModel.sentToTop() - } + LaunchedEffect(scrollToTop) { + if (scrollToTop > 0 && viewModel.scrolltoTopPending) { + listState.scrollToItem(index = 0) + viewModel.sentToTop() } + } } @Composable private fun WatchScrollToTop( - viewModel: FeedViewModel, - listState: LazyGridState + viewModel: FeedViewModel, + listState: LazyGridState, ) { - val scrollToTop by viewModel.scrollToTop.collectAsStateWithLifecycle() + val scrollToTop by viewModel.scrollToTop.collectAsStateWithLifecycle() - LaunchedEffect(scrollToTop) { - if (scrollToTop > 0 && viewModel.scrolltoTopPending) { - listState.scrollToItem(index = 0) - viewModel.sentToTop() - } + LaunchedEffect(scrollToTop) { + if (scrollToTop > 0 && viewModel.scrolltoTopPending) { + listState.scrollToItem(index = 0) + viewModel.sentToTop() } + } } @OptIn(ExperimentalFoundationApi::class, ExperimentalTime::class) @Composable private fun FeedLoaded( - state: FeedState.Loaded, - listState: LazyListState, - routeForLastRead: String?, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + state: FeedState.Loaded, + listState: LazyListState, + routeForLastRead: String?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - LazyColumn( - contentPadding = FeedPadding, - state = listState - ) { - itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item -> - val defaultModifier = remember { - Modifier - .fillMaxWidth() - .animateItemPlacement() - } + LazyColumn( + contentPadding = FeedPadding, + state = listState, + ) { + itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item -> + val defaultModifier = remember { Modifier.fillMaxWidth().animateItemPlacement() } - Row(defaultModifier) { - NoteCompose( - item, - routeForLastRead = routeForLastRead, - modifier = Modifier, - isBoostedNote = false, - showHidden = state.showHidden.value, - accountViewModel = accountViewModel, - nav = nav - ) - } - } + Row(defaultModifier) { + NoteCompose( + item, + routeForLastRead = routeForLastRead, + modifier = Modifier, + isBoostedNote = false, + showHidden = state.showHidden.value, + accountViewModel = accountViewModel, + nav = nav, + ) + } } + } } @Composable fun LoadingFeed() { - Column( - Modifier - .fillMaxHeight() - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text(stringResource(R.string.loading_feed)) - } + Column( + Modifier.fillMaxHeight().fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(stringResource(R.string.loading_feed)) + } } @Composable -fun FeedError(errorMessage: String, onRefresh: () -> Unit) { - Column( - Modifier - .fillMaxHeight() - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center +fun FeedError( + errorMessage: String, + onRefresh: () -> Unit, +) { + Column( + Modifier.fillMaxHeight().fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text("${stringResource(R.string.error_loading_replies)} $errorMessage") + Button( + modifier = Modifier.align(Alignment.CenterHorizontally), + onClick = onRefresh, ) { - Text("${stringResource(R.string.error_loading_replies)} $errorMessage") - Button( - modifier = Modifier.align(Alignment.CenterHorizontally), - onClick = onRefresh - ) { - Text(text = stringResource(R.string.try_again)) - } + Text(text = stringResource(R.string.try_again)) } + } } @Composable fun FeedEmpty(onRefresh: () -> Unit) { - Column( - Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text(stringResource(R.string.feed_is_empty)) - OutlinedButton(onClick = onRefresh) { - Text(text = stringResource(R.string.refresh)) - } - } + Column( + Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(stringResource(R.string.feed_is_empty)) + OutlinedButton(onClick = onRefresh) { Text(text = stringResource(R.string.refresh)) } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index 87b71d6ea..e1d8d61b8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen import android.util.Log @@ -49,327 +69,407 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -class NostrChannelFeedViewModel(val channel: Channel, val account: Account) : FeedViewModel(ChannelFeedFilter(channel, account)) { - class Factory(val channel: Channel, val account: Account) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrChannelFeedViewModel { - return NostrChannelFeedViewModel(channel, account) as NostrChannelFeedViewModel - } +class NostrChannelFeedViewModel(val channel: Channel, val account: Account) : + FeedViewModel(ChannelFeedFilter(channel, account)) { + class Factory(val channel: Channel, val account: Account) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): NostrChannelFeedViewModel { + return NostrChannelFeedViewModel(channel, account) as NostrChannelFeedViewModel } + } } -class NostrChatroomFeedViewModel(val user: ChatroomKey, val account: Account) : FeedViewModel(ChatroomFeedFilter(user, account)) { - class Factory(val user: ChatroomKey, val account: Account) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrChatRoomFeedViewModel { - return NostrChatroomFeedViewModel(user, account) as NostrChatRoomFeedViewModel - } + +class NostrChatroomFeedViewModel(val user: ChatroomKey, val account: Account) : + FeedViewModel(ChatroomFeedFilter(user, account)) { + class Factory(val user: ChatroomKey, val account: Account) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): NostrChatRoomFeedViewModel { + return NostrChatroomFeedViewModel(user, account) as NostrChatRoomFeedViewModel } + } } @Stable class NostrVideoFeedViewModel(val account: Account) : FeedViewModel(VideoFeedFilter(account)) { - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrVideoFeedViewModel { - return NostrVideoFeedViewModel(account) as NostrVideoFeedViewModel - } + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): NostrVideoFeedViewModel { + return NostrVideoFeedViewModel(account) as NostrVideoFeedViewModel } + } } -class NostrDiscoverMarketplaceFeedViewModel(val account: Account) : FeedViewModel( - DiscoverMarketplaceFeedFilter(account) -) { - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrDiscoverMarketplaceFeedViewModel { - return NostrDiscoverMarketplaceFeedViewModel(account) as NostrDiscoverMarketplaceFeedViewModel - } +class NostrDiscoverMarketplaceFeedViewModel(val account: Account) : + FeedViewModel( + DiscoverMarketplaceFeedFilter(account), + ) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): NostrDiscoverMarketplaceFeedViewModel { + return NostrDiscoverMarketplaceFeedViewModel(account) as NostrDiscoverMarketplaceFeedViewModel } + } } -class NostrDiscoverLiveFeedViewModel(val account: Account) : FeedViewModel(DiscoverLiveFeedFilter(account)) { - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrDiscoverLiveFeedViewModel { - return NostrDiscoverLiveFeedViewModel(account) as NostrDiscoverLiveFeedViewModel - } +class NostrDiscoverLiveFeedViewModel(val account: Account) : + FeedViewModel(DiscoverLiveFeedFilter(account)) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): NostrDiscoverLiveFeedViewModel { + return NostrDiscoverLiveFeedViewModel(account) as NostrDiscoverLiveFeedViewModel } + } } -class NostrDiscoverCommunityFeedViewModel(val account: Account) : FeedViewModel(DiscoverCommunityFeedFilter(account)) { - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrDiscoverCommunityFeedViewModel { - return NostrDiscoverCommunityFeedViewModel(account) as NostrDiscoverCommunityFeedViewModel - } +class NostrDiscoverCommunityFeedViewModel(val account: Account) : + FeedViewModel(DiscoverCommunityFeedFilter(account)) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): NostrDiscoverCommunityFeedViewModel { + return NostrDiscoverCommunityFeedViewModel(account) as NostrDiscoverCommunityFeedViewModel } + } } -class NostrDiscoverChatFeedViewModel(val account: Account) : FeedViewModel(DiscoverChatFeedFilter(account)) { - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrDiscoverChatFeedViewModel { - return NostrDiscoverChatFeedViewModel(account) as NostrDiscoverChatFeedViewModel - } +class NostrDiscoverChatFeedViewModel(val account: Account) : + FeedViewModel(DiscoverChatFeedFilter(account)) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): NostrDiscoverChatFeedViewModel { + return NostrDiscoverChatFeedViewModel(account) as NostrDiscoverChatFeedViewModel } + } } -class NostrThreadFeedViewModel(account: Account, noteId: String) : FeedViewModel(ThreadFeedFilter(account, noteId)) { - class Factory(val account: Account, val noteId: String) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrThreadFeedViewModel { - return NostrThreadFeedViewModel(account, noteId) as NostrThreadFeedViewModel - } +class NostrThreadFeedViewModel(account: Account, noteId: String) : + FeedViewModel(ThreadFeedFilter(account, noteId)) { + class Factory(val account: Account, val noteId: String) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): NostrThreadFeedViewModel { + return NostrThreadFeedViewModel(account, noteId) as NostrThreadFeedViewModel } + } } -class NostrUserProfileNewThreadsFeedViewModel(val user: User, val account: Account) : FeedViewModel(UserProfileNewThreadFeedFilter(user, account)) { - class Factory(val user: User, val account: Account) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrUserProfileNewThreadsFeedViewModel { - return NostrUserProfileNewThreadsFeedViewModel(user, account) as NostrUserProfileNewThreadsFeedViewModel - } - } -} -class NostrUserProfileConversationsFeedViewModel(val user: User, val account: Account) : FeedViewModel(UserProfileConversationsFeedFilter(user, account)) { - class Factory(val user: User, val account: Account) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrUserProfileConversationsFeedViewModel { - return NostrUserProfileConversationsFeedViewModel(user, account) as NostrUserProfileConversationsFeedViewModel - } +class NostrUserProfileNewThreadsFeedViewModel(val user: User, val account: Account) : + FeedViewModel(UserProfileNewThreadFeedFilter(user, account)) { + class Factory(val user: User, val account: Account) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): NostrUserProfileNewThreadsFeedViewModel { + return NostrUserProfileNewThreadsFeedViewModel(user, account) + as NostrUserProfileNewThreadsFeedViewModel } + } } -class NostrHashtagFeedViewModel(val hashtag: String, val account: Account) : FeedViewModel(HashtagFeedFilter(hashtag, account)) { - class Factory(val hashtag: String, val account: Account) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrHashtagFeedViewModel { - return NostrHashtagFeedViewModel(hashtag, account) as NostrHashtagFeedViewModel - } +class NostrUserProfileConversationsFeedViewModel(val user: User, val account: Account) : + FeedViewModel(UserProfileConversationsFeedFilter(user, account)) { + class Factory(val user: User, val account: Account) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): NostrUserProfileConversationsFeedViewModel { + return NostrUserProfileConversationsFeedViewModel(user, account) + as NostrUserProfileConversationsFeedViewModel } + } } -class NostrGeoHashFeedViewModel(val geohash: String, val account: Account) : FeedViewModel(GeoHashFeedFilter(geohash, account)) { - class Factory(val geohash: String, val account: Account) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrGeoHashFeedViewModel { - return NostrGeoHashFeedViewModel(geohash, account) as NostrGeoHashFeedViewModel - } +class NostrHashtagFeedViewModel(val hashtag: String, val account: Account) : + FeedViewModel(HashtagFeedFilter(hashtag, account)) { + class Factory(val hashtag: String, val account: Account) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): NostrHashtagFeedViewModel { + return NostrHashtagFeedViewModel(hashtag, account) as NostrHashtagFeedViewModel } + } } -class NostrCommunityFeedViewModel(val note: AddressableNote, val account: Account) : FeedViewModel(CommunityFeedFilter(note, account)) { - class Factory(val note: AddressableNote, val account: Account) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrCommunityFeedViewModel { - return NostrCommunityFeedViewModel(note, account) as NostrCommunityFeedViewModel - } +class NostrGeoHashFeedViewModel(val geohash: String, val account: Account) : + FeedViewModel(GeoHashFeedFilter(geohash, account)) { + class Factory(val geohash: String, val account: Account) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): NostrGeoHashFeedViewModel { + return NostrGeoHashFeedViewModel(geohash, account) as NostrGeoHashFeedViewModel } + } } -class NostrUserProfileReportFeedViewModel(val user: User) : FeedViewModel(UserProfileReportsFeedFilter(user)) { - class Factory(val user: User) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrUserProfileReportFeedViewModel { - return NostrUserProfileReportFeedViewModel(user) as NostrUserProfileReportFeedViewModel - } - } -} -class NostrUserProfileBookmarksFeedViewModel(val user: User, val account: Account) : FeedViewModel(UserProfileBookmarksFeedFilter(user, account)) { - class Factory(val user: User, val account: Account) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrUserProfileBookmarksFeedViewModel { - return NostrUserProfileBookmarksFeedViewModel(user, account) as NostrUserProfileBookmarksFeedViewModel - } +class NostrCommunityFeedViewModel(val note: AddressableNote, val account: Account) : + FeedViewModel(CommunityFeedFilter(note, account)) { + class Factory(val note: AddressableNote, val account: Account) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): NostrCommunityFeedViewModel { + return NostrCommunityFeedViewModel(note, account) as NostrCommunityFeedViewModel } + } } -class NostrChatroomListKnownFeedViewModel(val account: Account) : FeedViewModel(ChatroomListKnownFeedFilter(account)) { - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrChatroomListKnownFeedViewModel { - return NostrChatroomListKnownFeedViewModel(account) as NostrChatroomListKnownFeedViewModel - } +class NostrUserProfileReportFeedViewModel(val user: User) : + FeedViewModel(UserProfileReportsFeedFilter(user)) { + class Factory(val user: User) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): NostrUserProfileReportFeedViewModel { + return NostrUserProfileReportFeedViewModel(user) as NostrUserProfileReportFeedViewModel } + } } -class NostrChatroomListNewFeedViewModel(val account: Account) : FeedViewModel(ChatroomListNewFeedFilter(account)) { - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrChatroomListNewFeedViewModel { - return NostrChatroomListNewFeedViewModel(account) as NostrChatroomListNewFeedViewModel - } + +class NostrUserProfileBookmarksFeedViewModel(val user: User, val account: Account) : + FeedViewModel(UserProfileBookmarksFeedFilter(user, account)) { + class Factory(val user: User, val account: Account) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): NostrUserProfileBookmarksFeedViewModel { + return NostrUserProfileBookmarksFeedViewModel(user, account) + as NostrUserProfileBookmarksFeedViewModel } + } +} + +class NostrChatroomListKnownFeedViewModel(val account: Account) : + FeedViewModel(ChatroomListKnownFeedFilter(account)) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): NostrChatroomListKnownFeedViewModel { + return NostrChatroomListKnownFeedViewModel(account) as NostrChatroomListKnownFeedViewModel + } + } +} + +class NostrChatroomListNewFeedViewModel(val account: Account) : + FeedViewModel(ChatroomListNewFeedFilter(account)) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): NostrChatroomListNewFeedViewModel { + return NostrChatroomListNewFeedViewModel(account) as NostrChatroomListNewFeedViewModel + } + } } @Stable -class NostrHomeFeedViewModel(val account: Account) : FeedViewModel(HomeNewThreadFeedFilter(account)) { - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrHomeFeedViewModel { - return NostrHomeFeedViewModel(account) as NostrHomeFeedViewModel - } +class NostrHomeFeedViewModel(val account: Account) : + FeedViewModel(HomeNewThreadFeedFilter(account)) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): NostrHomeFeedViewModel { + return NostrHomeFeedViewModel(account) as NostrHomeFeedViewModel } + } } @Stable -class NostrHomeRepliesFeedViewModel(val account: Account) : FeedViewModel(HomeConversationsFeedFilter(account)) { - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrHomeRepliesFeedViewModel { - return NostrHomeRepliesFeedViewModel(account) as NostrHomeRepliesFeedViewModel - } +class NostrHomeRepliesFeedViewModel(val account: Account) : + FeedViewModel(HomeConversationsFeedFilter(account)) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): NostrHomeRepliesFeedViewModel { + return NostrHomeRepliesFeedViewModel(account) as NostrHomeRepliesFeedViewModel } + } } @Stable -class NostrBookmarkPublicFeedViewModel(val account: Account) : FeedViewModel(BookmarkPublicFeedFilter(account)) { - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrBookmarkPublicFeedViewModel { - return NostrBookmarkPublicFeedViewModel(account) as NostrBookmarkPublicFeedViewModel - } +class NostrBookmarkPublicFeedViewModel(val account: Account) : + FeedViewModel(BookmarkPublicFeedFilter(account)) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): NostrBookmarkPublicFeedViewModel { + return NostrBookmarkPublicFeedViewModel(account) as NostrBookmarkPublicFeedViewModel } + } } @Stable -class NostrBookmarkPrivateFeedViewModel(val account: Account) : FeedViewModel(BookmarkPrivateFeedFilter(account)) { - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrBookmarkPrivateFeedViewModel { - return NostrBookmarkPrivateFeedViewModel(account) as NostrBookmarkPrivateFeedViewModel - } +class NostrBookmarkPrivateFeedViewModel(val account: Account) : + FeedViewModel(BookmarkPrivateFeedFilter(account)) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): NostrBookmarkPrivateFeedViewModel { + return NostrBookmarkPrivateFeedViewModel(account) as NostrBookmarkPrivateFeedViewModel } + } } -class NostrUserAppRecommendationsFeedViewModel(val user: User) : FeedViewModel(UserProfileAppRecommendationsFeedFilter(user)) { - class Factory(val user: User) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrUserAppRecommendationsFeedViewModel { - return NostrUserAppRecommendationsFeedViewModel(user) as NostrUserAppRecommendationsFeedViewModel - } +class NostrUserAppRecommendationsFeedViewModel(val user: User) : + FeedViewModel(UserProfileAppRecommendationsFeedFilter(user)) { + class Factory(val user: User) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): NostrUserAppRecommendationsFeedViewModel { + return NostrUserAppRecommendationsFeedViewModel(user) + as NostrUserAppRecommendationsFeedViewModel } + } } @Stable -abstract class FeedViewModel(val localFilter: FeedFilter) : ViewModel(), InvalidatableViewModel { - private val _feedContent = MutableStateFlow(FeedState.Loading) - val feedContent = _feedContent.asStateFlow() +abstract class FeedViewModel(val localFilter: FeedFilter) : + ViewModel(), InvalidatableViewModel { + private val _feedContent = MutableStateFlow(FeedState.Loading) + val feedContent = _feedContent.asStateFlow() - // Simple counter that changes when it needs to invalidate everything - private val _scrollToTop = MutableStateFlow(0) - val scrollToTop = _scrollToTop.asStateFlow() - var scrolltoTopPending = false + // Simple counter that changes when it needs to invalidate everything + private val _scrollToTop = MutableStateFlow(0) + val scrollToTop = _scrollToTop.asStateFlow() + var scrolltoTopPending = false - private var lastFeedKey: String? = null + private var lastFeedKey: String? = null - fun sendToTop() { - if (scrolltoTopPending) return + fun sendToTop() { + if (scrolltoTopPending) return - scrolltoTopPending = true - viewModelScope.launch(Dispatchers.IO) { - _scrollToTop.emit(_scrollToTop.value + 1) + scrolltoTopPending = true + viewModelScope.launch(Dispatchers.IO) { _scrollToTop.emit(_scrollToTop.value + 1) } + } + + suspend fun sentToTop() { + scrolltoTopPending = false + } + + private fun refresh() { + viewModelScope.launch(Dispatchers.Default) { refreshSuspended() } + } + + fun refreshSuspended() { + checkNotInMainThread() + + lastFeedKey = localFilter.feedKey() + val notes = localFilter.loadTop().distinctBy { it.idHex }.toImmutableList() + + val oldNotesState = _feedContent.value + if (oldNotesState is FeedState.Loaded) { + if (!equalImmutableLists(notes, oldNotesState.feed.value)) { + updateFeed(notes) + } + } else { + updateFeed(notes) + } + } + + private fun updateFeed(notes: ImmutableList) { + viewModelScope.launch(Dispatchers.Main) { + val currentState = _feedContent.value + if (notes.isEmpty()) { + _feedContent.update { FeedState.Empty } + } else if (currentState is FeedState.Loaded) { + // updates the current list + currentState.showHidden.value = localFilter.showHiddenKey() + currentState.feed.value = notes + } else { + _feedContent.update { + FeedState.Loaded(mutableStateOf(notes), mutableStateOf(localFilter.showHiddenKey())) } + } } + } - suspend fun sentToTop() { - scrolltoTopPending = false - } - - private fun refresh() { - viewModelScope.launch(Dispatchers.Default) { - refreshSuspended() + fun refreshFromOldState(newItems: Set) { + val oldNotesState = _feedContent.value + if (localFilter is AdditiveFeedFilter && lastFeedKey == localFilter.feedKey()) { + if (oldNotesState is FeedState.Loaded) { + val newList = + localFilter + .updateListWith(oldNotesState.feed.value, newItems) + .distinctBy { it.idHex } + .toImmutableList() + if (!equalImmutableLists(newList, oldNotesState.feed.value)) { + updateFeed(newList) } - } - - fun refreshSuspended() { - checkNotInMainThread() - - lastFeedKey = localFilter.feedKey() - val notes = localFilter.loadTop().distinctBy { it.idHex }.toImmutableList() - - val oldNotesState = _feedContent.value - if (oldNotesState is FeedState.Loaded) { - if (!equalImmutableLists(notes, oldNotesState.feed.value)) { - updateFeed(notes) - } - } else { - updateFeed(notes) + } else if (oldNotesState is FeedState.Empty) { + val newList = + localFilter + .updateListWith(emptyList(), newItems) + .distinctBy { it.idHex } + .toImmutableList() + if (newList.isNotEmpty()) { + updateFeed(newList) } + } else { + // Refresh Everything + refreshSuspended() + } + } else { + // Refresh Everything + refreshSuspended() } + } - private fun updateFeed(notes: ImmutableList) { - viewModelScope.launch(Dispatchers.Main) { - val currentState = _feedContent.value - if (notes.isEmpty()) { - _feedContent.update { FeedState.Empty } - } else if (currentState is FeedState.Loaded) { - // updates the current list - currentState.showHidden.value = localFilter.showHiddenKey() - currentState.feed.value = notes - } else { - _feedContent.update { FeedState.Loaded(mutableStateOf(notes), mutableStateOf(localFilter.showHiddenKey())) } - } + private val bundler = BundledUpdate(250, Dispatchers.IO) + private val bundlerInsert = BundledInsert>(250, Dispatchers.IO) + + override fun invalidateData(ignoreIfDoing: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + bundler.invalidate(ignoreIfDoing) { + // adds the time to perform the refresh into this delay + // holding off new updates in case of heavy refresh routines. + refreshSuspended() + } + } + } + + fun checkKeysInvalidateDataAndSendToTop() { + if (lastFeedKey != localFilter.feedKey()) { + viewModelScope.launch(Dispatchers.IO) { + bundler.invalidate(false) { + // adds the time to perform the refresh into this delay + // holding off new updates in case of heavy refresh routines. + refreshSuspended() + sendToTop() } + } } + } - fun refreshFromOldState(newItems: Set) { - val oldNotesState = _feedContent.value - if (localFilter is AdditiveFeedFilter && lastFeedKey == localFilter.feedKey()) { - if (oldNotesState is FeedState.Loaded) { - val newList = localFilter.updateListWith(oldNotesState.feed.value, newItems).distinctBy { it.idHex }.toImmutableList() - if (!equalImmutableLists(newList, oldNotesState.feed.value)) { - updateFeed(newList) - } - } else if (oldNotesState is FeedState.Empty) { - val newList = localFilter.updateListWith(emptyList(), newItems).distinctBy { it.idHex }.toImmutableList() - if (newList.isNotEmpty()) { - updateFeed(newList) - } - } else { - // Refresh Everything - refreshSuspended() - } - } else { + fun invalidateInsertData(newItems: Set) { + bundlerInsert.invalidateList(newItems) { refreshFromOldState(it.flatten().toSet()) } + } + + private var collectorJob: Job? = null + + init { + Log.d("Init", "Starting new Model: ${this.javaClass.simpleName}") + collectorJob = + viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { newNotes -> + checkNotInMainThread() + + if ( + localFilter is AdditiveFeedFilter && + (_feedContent.value is FeedState.Loaded || _feedContent.value is FeedState.Empty) + ) { + invalidateInsertData(newNotes) + } else { // Refresh Everything - refreshSuspended() + invalidateData() + } } - } + } + } - private val bundler = BundledUpdate(250, Dispatchers.IO) - private val bundlerInsert = BundledInsert>(250, Dispatchers.IO) - - override fun invalidateData(ignoreIfDoing: Boolean) { - viewModelScope.launch(Dispatchers.IO) { - bundler.invalidate(ignoreIfDoing) { - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - refreshSuspended() - } - } - } - - fun checkKeysInvalidateDataAndSendToTop() { - if (lastFeedKey != localFilter.feedKey()) { - viewModelScope.launch(Dispatchers.IO) { - bundler.invalidate(false) { - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - refreshSuspended() - sendToTop() - } - } - } - } - - fun invalidateInsertData(newItems: Set) { - bundlerInsert.invalidateList(newItems) { - refreshFromOldState(it.flatten().toSet()) - } - } - - private var collectorJob: Job? = null - - init { - Log.d("Init", "Starting new Model: ${this.javaClass.simpleName}") - collectorJob = viewModelScope.launch(Dispatchers.IO) { - LocalCache.live.newEventBundles.collect { newNotes -> - checkNotInMainThread() - - if (localFilter is AdditiveFeedFilter && - (_feedContent.value is FeedState.Loaded || _feedContent.value is FeedState.Empty) - ) { - invalidateInsertData(newNotes) - } else { - // Refresh Everything - invalidateData() - } - } - } - } - - override fun onCleared() { - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") - bundlerInsert.cancel() - bundler.cancel() - collectorJob?.cancel() - super.onCleared() - } + override fun onCleared() { + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + bundlerInsert.cancel() + bundler.cancel() + collectorJob?.cancel() + super.onCleared() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedState.kt index 1d1c9dc31..71eef8d05 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedState.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.runtime.Immutable @@ -6,13 +26,15 @@ import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.model.Note import kotlinx.collections.immutable.ImmutableList -@Immutable -data class ZapReqResponse(val zapRequest: Note, val zapEvent: Note) +@Immutable data class ZapReqResponse(val zapRequest: Note, val zapEvent: Note) @Stable sealed class LnZapFeedState { - object Loading : LnZapFeedState() - class Loaded(val feed: MutableState>) : LnZapFeedState() - object Empty : LnZapFeedState() - class FeedError(val errorMessage: String) : LnZapFeedState() + object Loading : LnZapFeedState() + + class Loaded(val feed: MutableState>) : LnZapFeedState() + + object Empty : LnZapFeedState() + + class FeedError(val errorMessage: String) : LnZapFeedState() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt index b76797b7f..90012088f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.animation.Crossfade @@ -14,48 +34,44 @@ import com.vitorpamplona.amethyst.ui.theme.FeedPadding @Composable fun LnZapFeedView( - viewModel: LnZapFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + viewModel: LnZapFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val feedState by viewModel.feedContent.collectAsStateWithLifecycle() + val feedState by viewModel.feedContent.collectAsStateWithLifecycle() - Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> - when (state) { - is LnZapFeedState.Empty -> { - FeedEmpty { - viewModel.invalidateData() - } - } - is LnZapFeedState.FeedError -> { - FeedError(state.errorMessage) { - viewModel.invalidateData() - } - } - is LnZapFeedState.Loaded -> { - LnZapFeedLoaded(state, accountViewModel, nav) - } - is LnZapFeedState.Loading -> { - LoadingFeed() - } - } + Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> + when (state) { + is LnZapFeedState.Empty -> { + FeedEmpty { viewModel.invalidateData() } + } + is LnZapFeedState.FeedError -> { + FeedError(state.errorMessage) { viewModel.invalidateData() } + } + is LnZapFeedState.Loaded -> { + LnZapFeedLoaded(state, accountViewModel, nav) + } + is LnZapFeedState.Loading -> { + LoadingFeed() + } } + } } @Composable private fun LnZapFeedLoaded( - state: LnZapFeedState.Loaded, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + state: LnZapFeedState.Loaded, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val listState = rememberLazyListState() + val listState = rememberLazyListState() - LazyColumn( - contentPadding = FeedPadding, - state = listState - ) { - itemsIndexed(state.feed.value, key = { _, item -> item.zapEvent.idHex }) { _, item -> - ZapNoteCompose(item, accountViewModel = accountViewModel, nav = nav) - } + LazyColumn( + contentPadding = FeedPadding, + state = listState, + ) { + itemsIndexed(state.feed.value, key = { _, item -> item.zapEvent.idHex }) { _, item -> + ZapNoteCompose(item, accountViewModel = accountViewModel, nav = nav) } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt index 754144202..6994e2787 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen import android.util.Log @@ -21,83 +41,85 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -class NostrUserProfileZapsFeedViewModel(user: User) : LnZapFeedViewModel(UserProfileZapsFeedFilter(user)) { - class Factory(val user: User) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrUserProfileZapsFeedViewModel { - return NostrUserProfileZapsFeedViewModel(user) as NostrUserProfileZapsFeedViewModel - } +class NostrUserProfileZapsFeedViewModel(user: User) : + LnZapFeedViewModel(UserProfileZapsFeedFilter(user)) { + class Factory(val user: User) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): NostrUserProfileZapsFeedViewModel { + return NostrUserProfileZapsFeedViewModel(user) as NostrUserProfileZapsFeedViewModel } + } } @Stable open class LnZapFeedViewModel(val dataSource: FeedFilter) : ViewModel() { - private val _feedContent = MutableStateFlow(LnZapFeedState.Loading) - val feedContent = _feedContent.asStateFlow() + private val _feedContent = MutableStateFlow(LnZapFeedState.Loading) + val feedContent = _feedContent.asStateFlow() - private fun refresh() { - viewModelScope.launch(Dispatchers.Default) { - refreshSuspended() - } + private fun refresh() { + viewModelScope.launch(Dispatchers.Default) { refreshSuspended() } + } + + private fun refreshSuspended() { + checkNotInMainThread() + val notes = dataSource.loadTop().toImmutableList() + + val oldNotesState = _feedContent.value + if (oldNotesState is LnZapFeedState.Loaded) { + // Using size as a proxy for has changed. + if (!equalImmutableLists(notes, oldNotesState.feed.value)) { + updateFeed(notes) + } + } else { + updateFeed(notes) } + } - private fun refreshSuspended() { + private fun updateFeed(notes: ImmutableList) { + viewModelScope.launch(Dispatchers.Main) { + val currentState = _feedContent.value + if (notes.isEmpty()) { + _feedContent.update { LnZapFeedState.Empty } + } else if (currentState is LnZapFeedState.Loaded) { + // updates the current list + currentState.feed.value = notes + } else { + _feedContent.update { LnZapFeedState.Loaded(mutableStateOf(notes)) } + } + } + } + + private val bundler = BundledUpdate(250, Dispatchers.IO) + + fun invalidateData() { + bundler.invalidate { + // adds the time to perform the refresh into this delay + // holding off new updates in case of heavy refresh routines. + refreshSuspended() + } + } + + var collectorJob: Job? = null + + init { + Log.d("Init", "${this.javaClass.simpleName}") + collectorJob = + viewModelScope.launch(Dispatchers.IO) { checkNotInMainThread() - val notes = dataSource.loadTop().toImmutableList() - val oldNotesState = _feedContent.value - if (oldNotesState is LnZapFeedState.Loaded) { - // Using size as a proxy for has changed. - if (!equalImmutableLists(notes, oldNotesState.feed.value)) { - updateFeed(notes) - } - } else { - updateFeed(notes) + LocalCache.live.newEventBundles.collect { newNotes -> + checkNotInMainThread() + + invalidateData() } - } + } + } - private fun updateFeed(notes: ImmutableList) { - viewModelScope.launch(Dispatchers.Main) { - val currentState = _feedContent.value - if (notes.isEmpty()) { - _feedContent.update { LnZapFeedState.Empty } - } else if (currentState is LnZapFeedState.Loaded) { - // updates the current list - currentState.feed.value = notes - } else { - _feedContent.update { LnZapFeedState.Loaded(mutableStateOf(notes)) } - } - } - } - - private val bundler = BundledUpdate(250, Dispatchers.IO) - - fun invalidateData() { - bundler.invalidate() { - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - refreshSuspended() - } - } - - var collectorJob: Job? = null - - init { - Log.d("Init", "${this.javaClass.simpleName}") - collectorJob = viewModelScope.launch(Dispatchers.IO) { - checkNotInMainThread() - - LocalCache.live.newEventBundles.collect { newNotes -> - checkNotInMainThread() - - invalidateData() - } - } - } - - override fun onCleared() { - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") - bundler.cancel() - collectorJob?.cancel() - super.onCleared() - } + override fun onCleared() { + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + bundler.cancel() + collectorJob?.cancel() + super.onCleared() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt index 07a1b4722..454e6fa75 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen import android.util.Log @@ -36,120 +56,124 @@ import kotlinx.coroutines.launch @Stable class RelayFeedViewModel : ViewModel() { - val order = compareByDescending { it.lastEvent }.thenByDescending { it.counter }.thenBy { it.url } + val order = + compareByDescending { it.lastEvent } + .thenByDescending { it.counter } + .thenBy { it.url } - private val _feedContent = MutableStateFlow>(emptyList()) - val feedContent = _feedContent.asStateFlow() + private val _feedContent = MutableStateFlow>(emptyList()) + val feedContent = _feedContent.asStateFlow() - var currentUser: User? = null + var currentUser: User? = null - fun refresh() { - viewModelScope.launch(Dispatchers.Default) { - refreshSuspended() + fun refresh() { + viewModelScope.launch(Dispatchers.Default) { refreshSuspended() } + } + + fun refreshSuspended() { + val beingUsed = currentUser?.relaysBeingUsed?.values ?: emptyList() + val beingUsedSet = currentUser?.relaysBeingUsed?.keys ?: emptySet() + + val newRelaysFromRecord = + currentUser?.latestContactList?.relays()?.entries?.mapNotNull { + if (it.key !in beingUsedSet) { + RelayInfo(it.key, 0, 0) + } else { + null } + } + ?: emptyList() + + val newList = (beingUsed + newRelaysFromRecord).sortedWith(order) + + _feedContent.update { newList } + } + + val listener: (UserState) -> Unit = { invalidateData() } + + fun subscribeTo(user: User) { + if (currentUser != user) { + currentUser = user + user.live().relays.observeForever(listener) + user.live().relayInfo.observeForever(listener) + invalidateData() } + } - fun refreshSuspended() { - val beingUsed = currentUser?.relaysBeingUsed?.values ?: emptyList() - val beingUsedSet = currentUser?.relaysBeingUsed?.keys ?: emptySet() - - val newRelaysFromRecord = currentUser?.latestContactList?.relays()?.entries?.mapNotNull { - if (it.key !in beingUsedSet) { - RelayInfo(it.key, 0, 0) - } else { - null - } - } ?: emptyList() - - val newList = (beingUsed + newRelaysFromRecord).sortedWith(order) - - _feedContent.update { newList } + fun unsubscribeTo(user: User) { + if (currentUser == user) { + user.live().relays.removeObserver(listener) + user.live().relayInfo.removeObserver(listener) + currentUser = null } + } - val listener: (UserState) -> Unit = { - invalidateData() + private val bundler = BundledUpdate(250, Dispatchers.IO) + + fun invalidateData() { + bundler.invalidate { + // adds the time to perform the refresh into this delay + // holding off new updates in case of heavy refresh routines. + refreshSuspended() } + } - fun subscribeTo(user: User) { - if (currentUser != user) { - currentUser = user - user.live().relays.observeForever(listener) - user.live().relayInfo.observeForever(listener) - invalidateData() - } - } - - fun unsubscribeTo(user: User) { - if (currentUser == user) { - user.live().relays.removeObserver(listener) - user.live().relayInfo.removeObserver(listener) - currentUser = null - } - } - - private val bundler = BundledUpdate(250, Dispatchers.IO) - - fun invalidateData() { - bundler.invalidate() { - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - refreshSuspended() - } - } - - override fun onCleared() { - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") - bundler.cancel() - super.onCleared() - } + override fun onCleared() { + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + bundler.cancel() + super.onCleared() + } } @Composable fun RelayFeedView( - viewModel: RelayFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - enablePullRefresh: Boolean = true + viewModel: RelayFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + enablePullRefresh: Boolean = true, ) { - val feedState by viewModel.feedContent.collectAsStateWithLifecycle() + val feedState by viewModel.feedContent.collectAsStateWithLifecycle() - var wantsToAddRelay by remember { - mutableStateOf("") - } + var wantsToAddRelay by remember { mutableStateOf("") } - if (wantsToAddRelay.isNotEmpty()) { - NewRelayListView({ wantsToAddRelay = "" }, accountViewModel, wantsToAddRelay, nav = nav) - } + if (wantsToAddRelay.isNotEmpty()) { + NewRelayListView({ wantsToAddRelay = "" }, accountViewModel, wantsToAddRelay, nav = nav) + } - var refreshing by remember { mutableStateOf(false) } - val refresh = { refreshing = true; viewModel.refresh(); refreshing = false } - val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) + var refreshing by remember { mutableStateOf(false) } + val refresh = { + refreshing = true + viewModel.refresh() + refreshing = false + } + val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) - val modifier = if (enablePullRefresh) { - Modifier.fillMaxSize().pullRefresh(pullRefreshState) + val modifier = + if (enablePullRefresh) { + Modifier.fillMaxSize().pullRefresh(pullRefreshState) } else { - Modifier.fillMaxSize() + Modifier.fillMaxSize() } - Box(modifier) { - val listState = rememberLazyListState() + Box(modifier) { + val listState = rememberLazyListState() - LazyColumn( - contentPadding = FeedPadding, - state = listState - ) { - itemsIndexed(feedState, key = { _, item -> item.url }) { _, item -> - RelayCompose( - item, - accountViewModel = accountViewModel, - onAddRelay = { wantsToAddRelay = item.url }, - onRemoveRelay = { wantsToAddRelay = item.url } - ) - } - } - - if (enablePullRefresh) { - PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) - } + LazyColumn( + contentPadding = FeedPadding, + state = listState, + ) { + itemsIndexed(feedState, key = { _, item -> item.url }) { _, item -> + RelayCompose( + item, + accountViewModel = accountViewModel, + onAddRelay = { wantsToAddRelay = item.url }, + onRemoveRelay = { wantsToAddRelay = item.url }, + ) + } } + + if (enablePullRefresh) { + PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) + } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt index 46b3b4983..c799f2610 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.foundation.ExperimentalFoundationApi @@ -16,99 +36,104 @@ private val savedScrollStates = mutableMapOf() private data class ScrollState(val index: Int, val scrollOffsetFraction: Float) object ScrollStateKeys { - const val GLOBAL_SCREEN = "Global" - const val NOTIFICATION_SCREEN = "Notifications" - const val VIDEO_SCREEN = "Video" - const val DISCOVER_SCREEN = "Discover" - val HOME_FOLLOWS = Route.Home.base + "Follows" - val HOME_REPLIES = Route.Home.base + "FollowsReplies" + const val GLOBAL_SCREEN = "Global" + const val NOTIFICATION_SCREEN = "Notifications" + const val VIDEO_SCREEN = "Video" + const val DISCOVER_SCREEN = "Discover" + val HOME_FOLLOWS = Route.Home.base + "Follows" + val HOME_REPLIES = Route.Home.base + "FollowsReplies" - val DISCOVER_MARKETPLACE = Route.Home.base + "Marketplace" - val DISCOVER_LIVE = Route.Home.base + "Live" - val DISCOVER_COMMUNITY = Route.Home.base + "Communities" - val DISCOVER_CHATS = Route.Home.base + "Chats" + val DISCOVER_MARKETPLACE = Route.Home.base + "Marketplace" + val DISCOVER_LIVE = Route.Home.base + "Live" + val DISCOVER_COMMUNITY = Route.Home.base + "Communities" + val DISCOVER_CHATS = Route.Home.base + "Chats" } object PagerStateKeys { - const val HOME_SCREEN = "PagerHome" - const val DISCOVER_SCREEN = "PagerDiscover" + const val HOME_SCREEN = "PagerHome" + const val DISCOVER_SCREEN = "PagerDiscover" } @Composable fun rememberForeverLazyGridState( - key: String, - initialFirstVisibleItemIndex: Int = 0, - initialFirstVisibleItemScrollOffset: Int = 0 + key: String, + initialFirstVisibleItemIndex: Int = 0, + initialFirstVisibleItemScrollOffset: Int = 0, ): LazyGridState { - val scrollState = rememberSaveable(saver = LazyGridState.Saver) { - val savedValue = savedScrollStates[key] - val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex - val savedOffset = savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset.toFloat() - LazyGridState( - savedIndex, - savedOffset.roundToInt() - ) + val scrollState = + rememberSaveable(saver = LazyGridState.Saver) { + val savedValue = savedScrollStates[key] + val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex + val savedOffset = + savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset.toFloat() + LazyGridState( + savedIndex, + savedOffset.roundToInt(), + ) } - DisposableEffect(scrollState) { - onDispose { - val lastIndex = scrollState.firstVisibleItemIndex - val lastOffset = scrollState.firstVisibleItemScrollOffset - savedScrollStates[key] = ScrollState(lastIndex, lastOffset.toFloat()) - } + DisposableEffect(scrollState) { + onDispose { + val lastIndex = scrollState.firstVisibleItemIndex + val lastOffset = scrollState.firstVisibleItemScrollOffset + savedScrollStates[key] = ScrollState(lastIndex, lastOffset.toFloat()) } - return scrollState + } + return scrollState } @Composable fun rememberForeverLazyListState( - key: String, - initialFirstVisibleItemIndex: Int = 0, - initialFirstVisibleItemScrollOffset: Int = 0 + key: String, + initialFirstVisibleItemIndex: Int = 0, + initialFirstVisibleItemScrollOffset: Int = 0, ): LazyListState { - val scrollState = rememberSaveable(saver = LazyListState.Saver) { - val savedValue = savedScrollStates[key] - val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex - val savedOffset = savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset.toFloat() - LazyListState( - savedIndex, - savedOffset.roundToInt() - ) + val scrollState = + rememberSaveable(saver = LazyListState.Saver) { + val savedValue = savedScrollStates[key] + val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex + val savedOffset = + savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset.toFloat() + LazyListState( + savedIndex, + savedOffset.roundToInt(), + ) } - DisposableEffect(scrollState) { - onDispose { - val lastIndex = scrollState.firstVisibleItemIndex - val lastOffset = scrollState.firstVisibleItemScrollOffset - savedScrollStates[key] = ScrollState(lastIndex, lastOffset.toFloat()) - } + DisposableEffect(scrollState) { + onDispose { + val lastIndex = scrollState.firstVisibleItemIndex + val lastOffset = scrollState.firstVisibleItemScrollOffset + savedScrollStates[key] = ScrollState(lastIndex, lastOffset.toFloat()) } - return scrollState + } + return scrollState } @OptIn(ExperimentalFoundationApi::class) @Composable fun rememberForeverPagerState( - key: String, - initialFirstVisibleItemIndex: Int = 0, - initialFirstVisibleItemScrollOffset: Float = 0.0f, - pageCount: () -> Int + key: String, + initialFirstVisibleItemIndex: Int = 0, + initialFirstVisibleItemScrollOffset: Float = 0.0f, + pageCount: () -> Int, ): PagerState { - val savedValue = savedScrollStates[key] - val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex - val savedOffset = savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset + val savedValue = savedScrollStates[key] + val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex + val savedOffset = savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset - val scrollState = rememberPagerState( - savedIndex, - savedOffset, - pageCount + val scrollState = + rememberPagerState( + savedIndex, + savedOffset, + pageCount, ) - DisposableEffect(scrollState) { - onDispose { - val lastIndex = scrollState.currentPage - val lastOffset = scrollState.currentPageOffsetFraction - savedScrollStates[key] = ScrollState(lastIndex, lastOffset) - } + DisposableEffect(scrollState) { + onDispose { + val lastIndex = scrollState.currentPage + val lastOffset = scrollState.currentPageOffsetFraction + savedScrollStates[key] = ScrollState(lastIndex, lastOffset) } + } - return scrollState + return scrollState } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt index 7ca3ccbce..a48042962 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen import androidx.appcompat.app.AppCompatDelegate @@ -22,184 +42,188 @@ import kotlinx.coroutines.launch @Stable class SettingsState() { - var theme by mutableStateOf(ThemeType.SYSTEM) - var language by mutableStateOf(null) + var theme by mutableStateOf(ThemeType.SYSTEM) + var language by mutableStateOf(null) - var automaticallyShowImages by mutableStateOf(ConnectivityType.ALWAYS) - var automaticallyStartPlayback by mutableStateOf(ConnectivityType.ALWAYS) - var automaticallyShowUrlPreview by mutableStateOf(ConnectivityType.ALWAYS) - var automaticallyHideNavigationBars by mutableStateOf(BooleanType.ALWAYS) - var automaticallyShowProfilePictures by mutableStateOf(ConnectivityType.ALWAYS) - var dontShowPushNotificationSelector by mutableStateOf(false) - var dontAskForNotificationPermissions by mutableStateOf(false) + var automaticallyShowImages by mutableStateOf(ConnectivityType.ALWAYS) + var automaticallyStartPlayback by mutableStateOf(ConnectivityType.ALWAYS) + var automaticallyShowUrlPreview by mutableStateOf(ConnectivityType.ALWAYS) + var automaticallyHideNavigationBars by mutableStateOf(BooleanType.ALWAYS) + var automaticallyShowProfilePictures by mutableStateOf(ConnectivityType.ALWAYS) + var dontShowPushNotificationSelector by mutableStateOf(false) + var dontAskForNotificationPermissions by mutableStateOf(false) - var isOnMobileData: State = mutableStateOf(false) + var isOnMobileData: State = mutableStateOf(false) - var windowSizeClass = mutableStateOf(null) - var displayFeatures = mutableStateOf>(emptyList()) + var windowSizeClass = mutableStateOf(null) + var displayFeatures = mutableStateOf>(emptyList()) - val showProfilePictures = derivedStateOf { - when (automaticallyShowProfilePictures) { - ConnectivityType.WIFI_ONLY -> !isOnMobileData.value - ConnectivityType.NEVER -> false - ConnectivityType.ALWAYS -> true - } + val showProfilePictures = derivedStateOf { + when (automaticallyShowProfilePictures) { + ConnectivityType.WIFI_ONLY -> !isOnMobileData.value + ConnectivityType.NEVER -> false + ConnectivityType.ALWAYS -> true } + } - val showUrlPreview = derivedStateOf { - when (automaticallyShowUrlPreview) { - ConnectivityType.WIFI_ONLY -> !isOnMobileData.value - ConnectivityType.NEVER -> false - ConnectivityType.ALWAYS -> true - } + val showUrlPreview = derivedStateOf { + when (automaticallyShowUrlPreview) { + ConnectivityType.WIFI_ONLY -> !isOnMobileData.value + ConnectivityType.NEVER -> false + ConnectivityType.ALWAYS -> true } + } - val startVideoPlayback = derivedStateOf { - when (automaticallyStartPlayback) { - ConnectivityType.WIFI_ONLY -> !isOnMobileData.value - ConnectivityType.NEVER -> false - ConnectivityType.ALWAYS -> true - } + val startVideoPlayback = derivedStateOf { + when (automaticallyStartPlayback) { + ConnectivityType.WIFI_ONLY -> !isOnMobileData.value + ConnectivityType.NEVER -> false + ConnectivityType.ALWAYS -> true } + } - val showImages = derivedStateOf { - when (automaticallyShowImages) { - ConnectivityType.WIFI_ONLY -> !isOnMobileData.value - ConnectivityType.NEVER -> false - ConnectivityType.ALWAYS -> true - } + val showImages = derivedStateOf { + when (automaticallyShowImages) { + ConnectivityType.WIFI_ONLY -> !isOnMobileData.value + ConnectivityType.NEVER -> false + ConnectivityType.ALWAYS -> true } + } } @Stable class SharedPreferencesViewModel : ViewModel() { - val sharedPrefs: SettingsState = SettingsState() + val sharedPrefs: SettingsState = SettingsState() - fun init() { - viewModelScope.launch(Dispatchers.IO) { - val savedSettings = LocalPreferences.loadSharedSettings() - ?: LocalPreferences.migrateOldSharedSettings() - ?: Settings() + fun init() { + viewModelScope.launch(Dispatchers.IO) { + val savedSettings = + LocalPreferences.loadSharedSettings() + ?: LocalPreferences.migrateOldSharedSettings() ?: Settings() - sharedPrefs.theme = savedSettings.theme - sharedPrefs.language = savedSettings.preferredLanguage - sharedPrefs.automaticallyShowImages = savedSettings.automaticallyShowImages - sharedPrefs.automaticallyStartPlayback = savedSettings.automaticallyStartPlayback - sharedPrefs.automaticallyShowUrlPreview = savedSettings.automaticallyShowUrlPreview - sharedPrefs.automaticallyHideNavigationBars = savedSettings.automaticallyHideNavigationBars - sharedPrefs.automaticallyShowProfilePictures = savedSettings.automaticallyShowProfilePictures - sharedPrefs.dontShowPushNotificationSelector = savedSettings.dontShowPushNotificationSelector - sharedPrefs.dontAskForNotificationPermissions = savedSettings.dontAskForNotificationPermissions + sharedPrefs.theme = savedSettings.theme + sharedPrefs.language = savedSettings.preferredLanguage + sharedPrefs.automaticallyShowImages = savedSettings.automaticallyShowImages + sharedPrefs.automaticallyStartPlayback = savedSettings.automaticallyStartPlayback + sharedPrefs.automaticallyShowUrlPreview = savedSettings.automaticallyShowUrlPreview + sharedPrefs.automaticallyHideNavigationBars = savedSettings.automaticallyHideNavigationBars + sharedPrefs.automaticallyShowProfilePictures = savedSettings.automaticallyShowProfilePictures + sharedPrefs.dontShowPushNotificationSelector = savedSettings.dontShowPushNotificationSelector + sharedPrefs.dontAskForNotificationPermissions = + savedSettings.dontAskForNotificationPermissions - updateLanguageInTheUI() - } + updateLanguageInTheUI() } + } - fun updateTheme(newTheme: ThemeType) { - if (sharedPrefs.theme != newTheme) { - sharedPrefs.theme = newTheme + fun updateTheme(newTheme: ThemeType) { + if (sharedPrefs.theme != newTheme) { + sharedPrefs.theme = newTheme - saveSharedSettings() - } + saveSharedSettings() } + } - fun updateLanguage(newLanguage: String?) { - if (sharedPrefs.language != newLanguage) { - sharedPrefs.language = newLanguage - updateLanguageInTheUI() - saveSharedSettings() - } + fun updateLanguage(newLanguage: String?) { + if (sharedPrefs.language != newLanguage) { + sharedPrefs.language = newLanguage + updateLanguageInTheUI() + saveSharedSettings() } + } - fun updateLanguageInTheUI() { - if (sharedPrefs.language != null) { - viewModelScope.launch(Dispatchers.Main) { - AppCompatDelegate.setApplicationLocales( - LocaleListCompat.forLanguageTags(sharedPrefs.language) - ) - } - } + fun updateLanguageInTheUI() { + if (sharedPrefs.language != null) { + viewModelScope.launch(Dispatchers.Main) { + AppCompatDelegate.setApplicationLocales( + LocaleListCompat.forLanguageTags(sharedPrefs.language), + ) + } } + } - fun updateAutomaticallyStartPlayback(newAutomaticallyStartPlayback: ConnectivityType) { - if (sharedPrefs.automaticallyStartPlayback != newAutomaticallyStartPlayback) { - sharedPrefs.automaticallyStartPlayback = newAutomaticallyStartPlayback - saveSharedSettings() - } + fun updateAutomaticallyStartPlayback(newAutomaticallyStartPlayback: ConnectivityType) { + if (sharedPrefs.automaticallyStartPlayback != newAutomaticallyStartPlayback) { + sharedPrefs.automaticallyStartPlayback = newAutomaticallyStartPlayback + saveSharedSettings() } + } - fun updateAutomaticallyShowUrlPreview(newAutomaticallyShowUrlPreview: ConnectivityType) { - if (sharedPrefs.automaticallyShowUrlPreview != newAutomaticallyShowUrlPreview) { - sharedPrefs.automaticallyShowUrlPreview = newAutomaticallyShowUrlPreview - saveSharedSettings() - } + fun updateAutomaticallyShowUrlPreview(newAutomaticallyShowUrlPreview: ConnectivityType) { + if (sharedPrefs.automaticallyShowUrlPreview != newAutomaticallyShowUrlPreview) { + sharedPrefs.automaticallyShowUrlPreview = newAutomaticallyShowUrlPreview + saveSharedSettings() } + } - fun updateAutomaticallyShowProfilePicture(newAutomaticallyShowProfilePictures: ConnectivityType) { - if (sharedPrefs.automaticallyShowProfilePictures != newAutomaticallyShowProfilePictures) { - sharedPrefs.automaticallyShowProfilePictures = newAutomaticallyShowProfilePictures - saveSharedSettings() - } + fun updateAutomaticallyShowProfilePicture(newAutomaticallyShowProfilePictures: ConnectivityType) { + if (sharedPrefs.automaticallyShowProfilePictures != newAutomaticallyShowProfilePictures) { + sharedPrefs.automaticallyShowProfilePictures = newAutomaticallyShowProfilePictures + saveSharedSettings() } + } - fun updateAutomaticallyHideNavBars(newAutomaticallyHideHavBars: BooleanType) { - if (sharedPrefs.automaticallyHideNavigationBars != newAutomaticallyHideHavBars) { - sharedPrefs.automaticallyHideNavigationBars = newAutomaticallyHideHavBars - saveSharedSettings() - } + fun updateAutomaticallyHideNavBars(newAutomaticallyHideHavBars: BooleanType) { + if (sharedPrefs.automaticallyHideNavigationBars != newAutomaticallyHideHavBars) { + sharedPrefs.automaticallyHideNavigationBars = newAutomaticallyHideHavBars + saveSharedSettings() } + } - fun updateAutomaticallyShowImages(newAutomaticallyShowImages: ConnectivityType) { - if (sharedPrefs.automaticallyShowImages != newAutomaticallyShowImages) { - sharedPrefs.automaticallyShowImages = newAutomaticallyShowImages - saveSharedSettings() - } + fun updateAutomaticallyShowImages(newAutomaticallyShowImages: ConnectivityType) { + if (sharedPrefs.automaticallyShowImages != newAutomaticallyShowImages) { + sharedPrefs.automaticallyShowImages = newAutomaticallyShowImages + saveSharedSettings() } + } - fun dontShowPushNotificationSelector() { - if (sharedPrefs.dontShowPushNotificationSelector == false) { - sharedPrefs.dontShowPushNotificationSelector = true - saveSharedSettings() - } + fun dontShowPushNotificationSelector() { + if (sharedPrefs.dontShowPushNotificationSelector == false) { + sharedPrefs.dontShowPushNotificationSelector = true + saveSharedSettings() } + } - fun dontAskForNotificationPermissions() { - if (sharedPrefs.dontAskForNotificationPermissions == false) { - sharedPrefs.dontAskForNotificationPermissions = true - saveSharedSettings() - } + fun dontAskForNotificationPermissions() { + if (sharedPrefs.dontAskForNotificationPermissions == false) { + sharedPrefs.dontAskForNotificationPermissions = true + saveSharedSettings() } + } - fun updateConnectivityStatusState(isOnMobileDataState: State) { - if (sharedPrefs.isOnMobileData != isOnMobileDataState) { - sharedPrefs.isOnMobileData = isOnMobileDataState - } + fun updateConnectivityStatusState(isOnMobileDataState: State) { + if (sharedPrefs.isOnMobileData != isOnMobileDataState) { + sharedPrefs.isOnMobileData = isOnMobileDataState } + } - fun updateDisplaySettings(windowSizeClass: WindowSizeClass, displayFeatures: List) { - if (sharedPrefs.windowSizeClass.value != windowSizeClass) { - sharedPrefs.windowSizeClass.value = windowSizeClass - } - if (sharedPrefs.displayFeatures.value != displayFeatures) { - sharedPrefs.displayFeatures.value = displayFeatures - } + fun updateDisplaySettings( + windowSizeClass: WindowSizeClass, + displayFeatures: List, + ) { + if (sharedPrefs.windowSizeClass.value != windowSizeClass) { + sharedPrefs.windowSizeClass.value = windowSizeClass } + if (sharedPrefs.displayFeatures.value != displayFeatures) { + sharedPrefs.displayFeatures.value = displayFeatures + } + } - fun saveSharedSettings() { - viewModelScope.launch(Dispatchers.IO) { - LocalPreferences.saveSharedSettings( - Settings( - sharedPrefs.theme, - sharedPrefs.language, - sharedPrefs.automaticallyShowImages, - sharedPrefs.automaticallyStartPlayback, - sharedPrefs.automaticallyShowUrlPreview, - sharedPrefs.automaticallyHideNavigationBars, - sharedPrefs.automaticallyShowProfilePictures, - sharedPrefs.dontShowPushNotificationSelector, - sharedPrefs.dontAskForNotificationPermissions - ) - ) - } + fun saveSharedSettings() { + viewModelScope.launch(Dispatchers.IO) { + LocalPreferences.saveSharedSettings( + Settings( + sharedPrefs.theme, + sharedPrefs.language, + sharedPrefs.automaticallyShowImages, + sharedPrefs.automaticallyStartPlayback, + sharedPrefs.automaticallyShowUrlPreview, + sharedPrefs.automaticallyHideNavigationBars, + sharedPrefs.automaticallyShowProfilePictures, + sharedPrefs.dontShowPushNotificationSelector, + sharedPrefs.dontAskForNotificationPermissions, + ), + ) } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedState.kt index a1c94ac42..6492104bb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedState.kt @@ -1,11 +1,34 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.runtime.MutableState import kotlinx.collections.immutable.ImmutableList sealed class StringFeedState { - object Loading : StringFeedState() - class Loaded(val feed: MutableState>) : StringFeedState() - object Empty : StringFeedState() - class FeedError(val errorMessage: String) : StringFeedState() + object Loading : StringFeedState() + + class Loaded(val feed: MutableState>) : StringFeedState() + + object Empty : StringFeedState() + + class FeedError(val errorMessage: String) : StringFeedState() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedView.kt index 56904188f..c5ec0a7a3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedView.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.animation.Crossfade @@ -21,96 +41,79 @@ import com.vitorpamplona.amethyst.ui.theme.FeedPadding @Composable fun RefreshingFeedStringFeedView( - viewModel: StringFeedViewModel, - enablePullRefresh: Boolean = true, - inner: @Composable (String) -> Unit + viewModel: StringFeedViewModel, + enablePullRefresh: Boolean = true, + inner: @Composable (String) -> Unit, ) { - RefresheableView(viewModel, enablePullRefresh) { - StringFeedView(viewModel, inner = inner) - } + RefresheableView(viewModel, enablePullRefresh) { StringFeedView(viewModel, inner = inner) } } @Composable fun StringFeedView( - viewModel: StringFeedViewModel, - pre: (@Composable () -> Unit)? = null, - post: (@Composable () -> Unit)? = null, - inner: @Composable (String) -> Unit + viewModel: StringFeedViewModel, + pre: (@Composable () -> Unit)? = null, + post: (@Composable () -> Unit)? = null, + inner: @Composable (String) -> Unit, ) { - val feedState by viewModel.feedContent.collectAsStateWithLifecycle() + val feedState by viewModel.feedContent.collectAsStateWithLifecycle() - Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> - when (state) { - is StringFeedState.Empty -> { - StringFeedEmpty(pre, post) { - viewModel.invalidateData() - } - } - - is StringFeedState.FeedError -> { - FeedError(state.errorMessage) { - viewModel.invalidateData() - } - } - - is StringFeedState.Loaded -> { - FeedLoaded(state, pre, post, inner) - } - - is StringFeedState.Loading -> { - LoadingFeed() - } - } + Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> + when (state) { + is StringFeedState.Empty -> { + StringFeedEmpty(pre, post) { viewModel.invalidateData() } + } + is StringFeedState.FeedError -> { + FeedError(state.errorMessage) { viewModel.invalidateData() } + } + is StringFeedState.Loaded -> { + FeedLoaded(state, pre, post, inner) + } + is StringFeedState.Loading -> { + LoadingFeed() + } } + } } @Composable fun StringFeedEmpty( - pre: (@Composable () -> Unit)? = null, - post: (@Composable () -> Unit)? = null, - onRefresh: () -> Unit + pre: (@Composable () -> Unit)? = null, + post: (@Composable () -> Unit)? = null, + onRefresh: () -> Unit, ) { - Column() { - pre?.let { it() } + Column { + pre?.let { it() } - Column( - Modifier.weight(1f).fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text(stringResource(R.string.feed_is_empty)) - OutlinedButton(onClick = onRefresh) { - Text(text = stringResource(R.string.refresh)) - } - } - - post?.let { it() } + Column( + Modifier.weight(1f).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(stringResource(R.string.feed_is_empty)) + OutlinedButton(onClick = onRefresh) { Text(text = stringResource(R.string.refresh)) } } + + post?.let { it() } + } } @Composable private fun FeedLoaded( - state: StringFeedState.Loaded, - pre: (@Composable () -> Unit)? = null, - post: (@Composable () -> Unit)? = null, - inner: @Composable (String) -> Unit + state: StringFeedState.Loaded, + pre: (@Composable () -> Unit)? = null, + post: (@Composable () -> Unit)? = null, + inner: @Composable (String) -> Unit, ) { - val listState = rememberLazyListState() + val listState = rememberLazyListState() - LazyColumn( - contentPadding = FeedPadding, - state = listState - ) { - item { - pre?.let { it() } - } + LazyColumn( + contentPadding = FeedPadding, + state = listState, + ) { + item { pre?.let { it() } } - itemsIndexed(state.feed.value, key = { _, item -> item }) { _, item -> - inner(item) - } + itemsIndexed(state.feed.value, key = { _, item -> item }) { _, item -> inner(item) } - item { - post?.let { it() } - } - } + item { post?.let { it() } } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedViewModel.kt index 41a35926b..6e0809f3d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedViewModel.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen import android.util.Log @@ -21,88 +41,91 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -class NostrHiddenWordsFeedViewModel(val account: Account) : StringFeedViewModel( - HiddenWordsFeedFilter(account) -) { - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrHiddenWordsFeedViewModel { - return NostrHiddenWordsFeedViewModel(account) as NostrHiddenWordsFeedViewModel - } +class NostrHiddenWordsFeedViewModel(val account: Account) : + StringFeedViewModel( + HiddenWordsFeedFilter(account), + ) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): NostrHiddenWordsFeedViewModel { + return NostrHiddenWordsFeedViewModel(account) as NostrHiddenWordsFeedViewModel } + } } @Stable -open class StringFeedViewModel(val dataSource: FeedFilter) : ViewModel(), InvalidatableViewModel { - private val _feedContent = MutableStateFlow(StringFeedState.Loading) - val feedContent = _feedContent.asStateFlow() +open class StringFeedViewModel(val dataSource: FeedFilter) : + ViewModel(), InvalidatableViewModel { + private val _feedContent = MutableStateFlow(StringFeedState.Loading) + val feedContent = _feedContent.asStateFlow() - private fun refresh() { - viewModelScope.launch(Dispatchers.Default) { - refreshSuspended() - } + private fun refresh() { + viewModelScope.launch(Dispatchers.Default) { refreshSuspended() } + } + + private fun refreshSuspended() { + checkNotInMainThread() + + val notes = dataSource.loadTop().toImmutableList() + + val oldNotesState = _feedContent.value + if (oldNotesState is StringFeedState.Loaded) { + // Using size as a proxy for has changed. + if (!equalImmutableLists(notes, oldNotesState.feed.value)) { + updateFeed(notes) + } + } else { + updateFeed(notes) } + } - private fun refreshSuspended() { + private fun updateFeed(notes: ImmutableList) { + viewModelScope.launch(Dispatchers.Main) { + val currentState = _feedContent.value + if (notes.isEmpty()) { + _feedContent.update { StringFeedState.Empty } + } else if (currentState is StringFeedState.Loaded) { + // updates the current list + currentState.feed.value = notes + } else { + _feedContent.update { StringFeedState.Loaded(mutableStateOf(notes)) } + } + } + } + + private val bundler = BundledUpdate(250, Dispatchers.IO) + + override fun invalidateData(ignoreIfDoing: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + bundler.invalidate(ignoreIfDoing) { + // adds the time to perform the refresh into this delay + // holding off new updates in case of heavy refresh routines. + refreshSuspended() + } + } + } + + var collectorJob: Job? = null + + init { + Log.d("Init", this.javaClass.simpleName) + collectorJob = + viewModelScope.launch(Dispatchers.IO) { checkNotInMainThread() - val notes = dataSource.loadTop().toImmutableList() + LocalCache.live.newEventBundles.collect { newNotes -> + checkNotInMainThread() - val oldNotesState = _feedContent.value - if (oldNotesState is StringFeedState.Loaded) { - // Using size as a proxy for has changed. - if (!equalImmutableLists(notes, oldNotesState.feed.value)) { - updateFeed(notes) - } - } else { - updateFeed(notes) + invalidateData() } - } + } + } - private fun updateFeed(notes: ImmutableList) { - viewModelScope.launch(Dispatchers.Main) { - val currentState = _feedContent.value - if (notes.isEmpty()) { - _feedContent.update { StringFeedState.Empty } - } else if (currentState is StringFeedState.Loaded) { - // updates the current list - currentState.feed.value = notes - } else { - _feedContent.update { StringFeedState.Loaded(mutableStateOf(notes)) } - } - } - } - - private val bundler = BundledUpdate(250, Dispatchers.IO) - - override fun invalidateData(ignoreIfDoing: Boolean) { - viewModelScope.launch(Dispatchers.IO) { - bundler.invalidate(ignoreIfDoing) { - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - refreshSuspended() - } - } - } - - var collectorJob: Job? = null - - init { - Log.d("Init", this.javaClass.simpleName) - collectorJob = viewModelScope.launch(Dispatchers.IO) { - checkNotInMainThread() - - LocalCache.live.newEventBundles.collect { newNotes -> - checkNotInMainThread() - - invalidateData() - } - } - } - - override fun onCleared() { - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") - bundler.cancel() - collectorJob?.cancel() - super.onCleared() - } + override fun onCleared() { + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + bundler.cancel() + collectorJob?.cancel() + super.onCleared() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index cf9112019..5797cb0af 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.animation.Crossfade @@ -140,606 +160,622 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @Composable -fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val feedState by viewModel.feedContent.collectAsStateWithLifecycle() +fun ThreadFeedView( + noteId: String, + viewModel: FeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val feedState by viewModel.feedContent.collectAsStateWithLifecycle() - val listState = rememberLazyListState() + val listState = rememberLazyListState() - var refreshing by remember { mutableStateOf(false) } - val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false } - val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) + var refreshing by remember { mutableStateOf(false) } + val refresh = { + refreshing = true + viewModel.invalidateData() + refreshing = false + } + val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) - Box(Modifier.pullRefresh(pullRefreshState)) { - Column() { - Crossfade( - targetState = feedState, - animationSpec = tween(durationMillis = 100), - label = "ThreadViewMainState" - ) { state -> - when (state) { - is FeedState.Empty -> { - FeedEmpty { - refreshing = true - } - } - is FeedState.FeedError -> { - FeedError(state.errorMessage) { - refreshing = true - } - } - is FeedState.Loaded -> { - refreshing = false - LaunchedEffect(noteId) { - launch(Dispatchers.IO) { - // waits to load the thread to scroll to item. - delay(100) - val noteForPosition = state.feed.value.filter { it.idHex == noteId }.firstOrNull() - var position = state.feed.value.indexOf(noteForPosition) + Box(Modifier.pullRefresh(pullRefreshState)) { + Column { + Crossfade( + targetState = feedState, + animationSpec = tween(durationMillis = 100), + label = "ThreadViewMainState", + ) { state -> + when (state) { + is FeedState.Empty -> { + FeedEmpty { refreshing = true } + } + is FeedState.FeedError -> { + FeedError(state.errorMessage) { refreshing = true } + } + is FeedState.Loaded -> { + refreshing = false + LaunchedEffect(noteId) { + launch(Dispatchers.IO) { + // waits to load the thread to scroll to item. + delay(100) + val noteForPosition = state.feed.value.filter { it.idHex == noteId }.firstOrNull() + var position = state.feed.value.indexOf(noteForPosition) - if (position >= 0) { - if (position >= 1 && position < state.feed.value.size - 1) { - position-- // show the replying note - } + if (position >= 0) { + if (position >= 1 && position < state.feed.value.size - 1) { + position-- // show the replying note + } - withContext(Dispatchers.Main) { - listState.scrollToItem(position) - } - } - } - } - - LazyColumn( - contentPadding = FeedPadding, - state = listState - ) { - itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { index, item -> - if (index == 0) { - ProvideTextStyle(TextStyle(fontSize = 18.sp, lineHeight = 1.20.em)) { - NoteMaster( - item, - modifier = Modifier.drawReplyLevel( - item.replyLevel(), - MaterialTheme.colorScheme.placeholderText, - if (item.idHex == noteId) MaterialTheme.colorScheme.lessImportantLink else MaterialTheme.colorScheme.placeholderText - ), - accountViewModel = accountViewModel, - nav = nav - ) - } - } else { - Column() { - Row() { - val selectedNoteColor = MaterialTheme.colorScheme.selectedNote - val background = remember { - if (item.idHex == noteId) mutableStateOf(selectedNoteColor) else null - } - - NoteCompose( - item, - modifier = Modifier.drawReplyLevel( - item.replyLevel(), - MaterialTheme.colorScheme.placeholderText, - if (item.idHex == noteId) MaterialTheme.colorScheme.lessImportantLink else MaterialTheme.colorScheme.placeholderText - ), - parentBackgroundColor = background, - isBoostedNote = false, - unPackReply = false, - accountViewModel = accountViewModel, - nav = nav - ) - } - } - } - } - } - } - FeedState.Loading -> { - LoadingFeed() - } + withContext(Dispatchers.Main) { listState.scrollToItem(position) } } + } } - } - PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) + LazyColumn( + contentPadding = FeedPadding, + state = listState, + ) { + itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { index, item -> + if (index == 0) { + ProvideTextStyle(TextStyle(fontSize = 18.sp, lineHeight = 1.20.em)) { + NoteMaster( + item, + modifier = + Modifier.drawReplyLevel( + item.replyLevel(), + MaterialTheme.colorScheme.placeholderText, + if (item.idHex == noteId) { + MaterialTheme.colorScheme.lessImportantLink + } else { + MaterialTheme.colorScheme.placeholderText + }, + ), + accountViewModel = accountViewModel, + nav = nav, + ) + } + } else { + Column { + Row { + val selectedNoteColor = MaterialTheme.colorScheme.selectedNote + val background = remember { + if (item.idHex == noteId) mutableStateOf(selectedNoteColor) else null + } + + NoteCompose( + item, + modifier = + Modifier.drawReplyLevel( + item.replyLevel(), + MaterialTheme.colorScheme.placeholderText, + if (item.idHex == noteId) { + MaterialTheme.colorScheme.lessImportantLink + } else { + MaterialTheme.colorScheme.placeholderText + }, + ), + parentBackgroundColor = background, + isBoostedNote = false, + unPackReply = false, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + } + } + } + FeedState.Loading -> { + LoadingFeed() + } + } + } } + + PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) + } } // Creates a Zebra pattern where each bar is a reply level. -fun Modifier.drawReplyLevel(level: Int, color: Color, selected: Color): Modifier = this - .drawBehind { - val paddingDp = 2 - val strokeWidthDp = 2 - val levelWidthDp = strokeWidthDp + 1 +fun Modifier.drawReplyLevel( + level: Int, + color: Color, + selected: Color, +): Modifier = + this.drawBehind { + val paddingDp = 2 + val strokeWidthDp = 2 + val levelWidthDp = strokeWidthDp + 1 - val padding = paddingDp.dp.toPx() - val strokeWidth = strokeWidthDp.dp.toPx() - val levelWidth = levelWidthDp.dp.toPx() + val padding = paddingDp.dp.toPx() + val strokeWidth = strokeWidthDp.dp.toPx() + val levelWidth = levelWidthDp.dp.toPx() - repeat(level) { - this.drawLine( - if (it == level - 1) selected else color, - Offset(padding + it * levelWidth, 0f), - Offset(padding + it * levelWidth, size.height), - strokeWidth = strokeWidth - ) - } + repeat(level) { + this.drawLine( + if (it == level - 1) selected else color, + Offset(padding + it * levelWidth, 0f), + Offset(padding + it * levelWidth, size.height), + strokeWidth = strokeWidth, + ) + } - return@drawBehind + return@drawBehind } .padding(start = (2 + (level * 3)).dp) @OptIn(ExperimentalFoundationApi::class) @Composable fun NoteMaster( - baseNote: Note, - modifier: Modifier = Modifier, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseNote: Note, + modifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val noteState by baseNote.live().metadata.observeAsState() - val note = noteState?.note + val noteState by baseNote.live().metadata.observeAsState() + val note = noteState?.note - val noteReportsState by baseNote.live().reports.observeAsState() - val noteForReports = noteReportsState?.note ?: return + val noteReportsState by baseNote.live().reports.observeAsState() + val noteForReports = noteReportsState?.note ?: return - val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account ?: return - var showHiddenNote by remember { mutableStateOf(false) } + var showHiddenNote by remember { mutableStateOf(false) } - val context = LocalContext.current + val context = LocalContext.current - val moreActionsExpanded = remember { mutableStateOf(false) } - val enablePopup = remember { - { moreActionsExpanded.value = true } - } + val moreActionsExpanded = remember { mutableStateOf(false) } + val enablePopup = remember { { moreActionsExpanded.value = true } } - val noteEvent = note?.event + val noteEvent = note?.event - var popupExpanded by remember { mutableStateOf(false) } + var popupExpanded by remember { mutableStateOf(false) } - val defaultBackgroundColor = MaterialTheme.colorScheme.background - val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } + val defaultBackgroundColor = MaterialTheme.colorScheme.background + val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } - if (noteEvent == null) { - BlankNote() - } else if (!account.isAcceptable(noteForReports) && !showHiddenNote) { - val reports = remember { - account.getRelevantReports(noteForReports).toImmutableSet() - } + if (noteEvent == null) { + BlankNote() + } else if (!account.isAcceptable(noteForReports) && !showHiddenNote) { + val reports = remember { account.getRelevantReports(noteForReports).toImmutableSet() } - HiddenNote( - reports, - note.author?.let { account.isHidden(it) } ?: false, - accountViewModel, - Modifier, - false, - nav, - onClick = { showHiddenNote = true } + HiddenNote( + reports, + note.author?.let { account.isHidden(it) } ?: false, + accountViewModel, + Modifier, + false, + nav, + onClick = { showHiddenNote = true }, + ) + } else { + Column( + modifier.fillMaxWidth().padding(top = 10.dp), + ) { + Row( + modifier = + Modifier.padding(start = 12.dp, end = 12.dp) + .clickable(onClick = { note.author?.let { nav("User/${it.pubkeyHex}") } }), + ) { + NoteAuthorPicture( + baseNote = baseNote, + nav = nav, + accountViewModel = accountViewModel, + size = 55.dp, ) - } else { - Column( - modifier - .fillMaxWidth() - .padding(top = 10.dp) - ) { - Row( - modifier = Modifier - .padding(start = 12.dp, end = 12.dp) - .clickable(onClick = { - note.author?.let { - nav("User/${it.pubkeyHex}") - } - }) - ) { - NoteAuthorPicture( - baseNote = baseNote, - nav = nav, - accountViewModel = accountViewModel, - size = 55.dp - ) - Column(modifier = Modifier.padding(start = 10.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - NoteUsernameDisplay(baseNote, Modifier.weight(1f)) + Column(modifier = Modifier.padding(start = 10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + NoteUsernameDisplay(baseNote, Modifier.weight(1f)) - val isCommunityPost by remember(baseNote) { - derivedStateOf { - baseNote.event?.isTaggedAddressableKind(CommunityDefinitionEvent.kind) == true - } - } - - if (isCommunityPost) { - DisplayFollowingCommunityInPost(baseNote, accountViewModel, nav) - } else { - DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav) - } - - Text( - timeAgo(note.createdAt(), context = context), - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1 - ) - - IconButton( - modifier = Modifier.then(Modifier.size(24.dp)), - onClick = enablePopup - ) { - Icon( - imageVector = Icons.Default.MoreVert, - null, - modifier = Modifier.size(15.dp), - tint = MaterialTheme.colorScheme.placeholderText - ) - - NoteDropDownMenu(baseNote, moreActionsExpanded, accountViewModel) - } - } - - Row(verticalAlignment = Alignment.CenterVertically) { - ObserveDisplayNip05Status(baseNote, remember { Modifier.weight(1f) }, accountViewModel, nav) - - val geo = remember { noteEvent.getGeoHash() } - if (geo != null) { - DisplayLocation(geo, nav) - } - - val baseReward = remember { noteEvent.getReward()?.let { Reward(it) } } - if (baseReward != null) { - DisplayReward(baseReward, baseNote, accountViewModel, nav) - } - - val pow = remember { noteEvent.getPoWRank() } - if (pow > 20) { - DisplayPoW(pow) - } - } + val isCommunityPost by + remember(baseNote) { + derivedStateOf { + baseNote.event?.isTaggedAddressableKind(CommunityDefinitionEvent.KIND) == true } + } + + if (isCommunityPost) { + DisplayFollowingCommunityInPost(baseNote, accountViewModel, nav) + } else { + DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav) } - Spacer(modifier = Modifier.height(10.dp)) - - if (noteEvent is BadgeDefinitionEvent) { - BadgeDisplay(baseNote = note) - } else if (noteEvent is LongTextNoteEvent) { - RenderLongFormHeaderForThread(noteEvent) - } else if (noteEvent is ClassifiedsEvent) { - RenderClassifiedsReaderForThread(noteEvent, note, accountViewModel, nav) - } - - Row( - modifier = Modifier - .padding(horizontal = 12.dp) - .combinedClickable( - onClick = { }, - onLongClick = { popupExpanded = true } - ) - ) { - Column() { - if ((noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) && note.channelHex() != null) { - ChannelHeader( - channelHex = note.channelHex()!!, - showVideo = true, - showBottomDiviser = false, - sendToChannel = true, - accountViewModel = accountViewModel, - nav = nav - ) - } else if (noteEvent is VideoEvent) { - VideoDisplay(baseNote, false, true, backgroundColor, accountViewModel, nav) - } else if (noteEvent is FileHeaderEvent) { - FileHeaderDisplay(baseNote, true, accountViewModel) - } else if (noteEvent is FileStorageHeaderEvent) { - FileStorageHeaderDisplay(baseNote, true, accountViewModel) - } else if (noteEvent is PeopleListEvent) { - DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav) - } else if (noteEvent is AudioTrackEvent) { - AudioTrackHeader(noteEvent, baseNote, accountViewModel, nav) - } else if (noteEvent is AudioHeaderEvent) { - AudioHeader(noteEvent, baseNote, accountViewModel, nav) - } else if (noteEvent is CommunityPostApprovalEvent) { - RenderPostApproval( - baseNote, - false, - true, - backgroundColor, - accountViewModel, - nav - ) - } else if (noteEvent is PinListEvent) { - RenderPinListEvent( - baseNote, - backgroundColor, - accountViewModel, - nav - ) - } else if (noteEvent is EmojiPackEvent) { - RenderEmojiPack( - baseNote, - true, - backgroundColor, - accountViewModel - ) - } else if (noteEvent is RelaySetEvent) { - DisplayRelaySet( - baseNote, - backgroundColor, - accountViewModel, - nav - ) - } else if (noteEvent is AppDefinitionEvent) { - RenderAppDefinition(baseNote, accountViewModel, nav) - } else if (noteEvent is HighlightEvent) { - DisplayHighlight( - noteEvent.quote(), - noteEvent.author(), - noteEvent.inUrl(), - noteEvent.inPost(), - false, - true, - backgroundColor, - accountViewModel, - nav - ) - } else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) { - RenderRepost(baseNote, backgroundColor, accountViewModel, nav) - } else if (noteEvent is PollNoteEvent) { - val canPreview = note.author == account.userProfile() || - (note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) || - !noteForReports.hasAnyReports() - - RenderPoll( - baseNote, - false, - canPreview, - backgroundColor, - accountViewModel, - nav - ) - } else { - val canPreview = note.author == account.userProfile() || - (note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) || - !noteForReports.hasAnyReports() - - RenderTextEvent( - baseNote, - false, - canPreview, - backgroundColor, - accountViewModel, - nav - ) - } - } - } - - val noteEvent = baseNote.event - val zapSplits = remember(noteEvent) { noteEvent?.hasZapSplitSetup() ?: false } - if (zapSplits && noteEvent != null) { - Spacer(modifier = DoubleVertSpacer) - DisplayZapSplits(noteEvent, accountViewModel, nav) - } - - ReactionsRow(note, true, accountViewModel, nav) - - Divider( - thickness = DividerThickness + Text( + timeAgo(note.createdAt(), context = context), + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, ) - } - NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel) + IconButton( + modifier = Modifier.then(Modifier.size(24.dp)), + onClick = enablePopup, + ) { + Icon( + imageVector = Icons.Default.MoreVert, + null, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colorScheme.placeholderText, + ) + + NoteDropDownMenu(baseNote, moreActionsExpanded, accountViewModel) + } + } + + Row(verticalAlignment = Alignment.CenterVertically) { + ObserveDisplayNip05Status( + baseNote, + remember { Modifier.weight(1f) }, + accountViewModel, + nav, + ) + + val geo = remember { noteEvent.getGeoHash() } + if (geo != null) { + DisplayLocation(geo, nav) + } + + val baseReward = remember { noteEvent.getReward()?.let { Reward(it) } } + if (baseReward != null) { + DisplayReward(baseReward, baseNote, accountViewModel, nav) + } + + val pow = remember { noteEvent.getPoWRank() } + if (pow > 20) { + DisplayPoW(pow) + } + } + } + } + + Spacer(modifier = Modifier.height(10.dp)) + + if (noteEvent is BadgeDefinitionEvent) { + BadgeDisplay(baseNote = note) + } else if (noteEvent is LongTextNoteEvent) { + RenderLongFormHeaderForThread(noteEvent) + } else if (noteEvent is ClassifiedsEvent) { + RenderClassifiedsReaderForThread(noteEvent, note, accountViewModel, nav) + } + + Row( + modifier = + Modifier.padding(horizontal = 12.dp) + .combinedClickable( + onClick = {}, + onLongClick = { popupExpanded = true }, + ), + ) { + Column { + if ( + (noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) && + note.channelHex() != null + ) { + ChannelHeader( + channelHex = note.channelHex()!!, + showVideo = true, + showBottomDiviser = false, + sendToChannel = true, + accountViewModel = accountViewModel, + nav = nav, + ) + } else if (noteEvent is VideoEvent) { + VideoDisplay(baseNote, false, true, backgroundColor, accountViewModel, nav) + } else if (noteEvent is FileHeaderEvent) { + FileHeaderDisplay(baseNote, true, accountViewModel) + } else if (noteEvent is FileStorageHeaderEvent) { + FileStorageHeaderDisplay(baseNote, true, accountViewModel) + } else if (noteEvent is PeopleListEvent) { + DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav) + } else if (noteEvent is AudioTrackEvent) { + AudioTrackHeader(noteEvent, baseNote, accountViewModel, nav) + } else if (noteEvent is AudioHeaderEvent) { + AudioHeader(noteEvent, baseNote, accountViewModel, nav) + } else if (noteEvent is CommunityPostApprovalEvent) { + RenderPostApproval( + baseNote, + false, + true, + backgroundColor, + accountViewModel, + nav, + ) + } else if (noteEvent is PinListEvent) { + RenderPinListEvent( + baseNote, + backgroundColor, + accountViewModel, + nav, + ) + } else if (noteEvent is EmojiPackEvent) { + RenderEmojiPack( + baseNote, + true, + backgroundColor, + accountViewModel, + ) + } else if (noteEvent is RelaySetEvent) { + DisplayRelaySet( + baseNote, + backgroundColor, + accountViewModel, + nav, + ) + } else if (noteEvent is AppDefinitionEvent) { + RenderAppDefinition(baseNote, accountViewModel, nav) + } else if (noteEvent is HighlightEvent) { + DisplayHighlight( + noteEvent.quote(), + noteEvent.author(), + noteEvent.inUrl(), + noteEvent.inPost(), + false, + true, + backgroundColor, + accountViewModel, + nav, + ) + } else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) { + RenderRepost(baseNote, backgroundColor, accountViewModel, nav) + } else if (noteEvent is PollNoteEvent) { + val canPreview = + note.author == account.userProfile() || + (note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) || + !noteForReports.hasAnyReports() + + RenderPoll( + baseNote, + false, + canPreview, + backgroundColor, + accountViewModel, + nav, + ) + } else { + val canPreview = + note.author == account.userProfile() || + (note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) || + !noteForReports.hasAnyReports() + + RenderTextEvent( + baseNote, + false, + canPreview, + backgroundColor, + accountViewModel, + nav, + ) + } + } + } + + val noteEvent = baseNote.event + val zapSplits = remember(noteEvent) { noteEvent?.hasZapSplitSetup() ?: false } + if (zapSplits && noteEvent != null) { + Spacer(modifier = DoubleVertSpacer) + DisplayZapSplits(noteEvent, accountViewModel, nav) + } + + ReactionsRow(note, true, accountViewModel, nav) + + Divider( + thickness = DividerThickness, + ) } + + NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel) + } } @Composable private fun RenderClassifiedsReaderForThread( - noteEvent: ClassifiedsEvent, - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + noteEvent: ClassifiedsEvent, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val images = remember(noteEvent) { noteEvent.images().toImmutableList() } - val title = remember(noteEvent) { noteEvent.title() } - val summary = remember(noteEvent) { - val sum = noteEvent.summary() - if (sum != noteEvent.content) { - sum - } else { - null - } + val images = remember(noteEvent) { noteEvent.images().toImmutableList() } + val title = remember(noteEvent) { noteEvent.title() } + val summary = + remember(noteEvent) { + val sum = noteEvent.summary() + if (sum != noteEvent.content) { + sum + } else { + null + } } - val price = remember(noteEvent) { noteEvent.price() } - val location = remember(noteEvent) { noteEvent.location() } + val price = remember(noteEvent) { noteEvent.price() } + val location = remember(noteEvent) { noteEvent.location() } - Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) { - Column { - if (images.isNotEmpty()) { - Row() { - InlineCarrousel( - images, - images.first() - ) - } - } else { - CreateImageHeader(note, accountViewModel) - } - - Row( - Modifier.padding(top = 10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - title?.let { - Text( - text = it, - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.weight(1f) - ) - } - } - - price?.let { - Row( - Modifier.padding(top = 5.dp), - verticalAlignment = Alignment.CenterVertically - ) { - val newAmount = price.amount.toBigDecimalOrNull()?.let { - showAmount(it) - } ?: price.amount - - val priceTag = remember(noteEvent) { - if (price.frequency != null && price.currency != null) { - "$newAmount ${price.currency}/${price.frequency}" - } else if (price.currency != null) { - "$newAmount ${price.currency}" - } else { - newAmount - } - } - - Text( - text = priceTag, - maxLines = 1, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f) - ) - - location?.let { - Text( - text = it, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - } - - summary?.let { - Row( - Modifier.padding(top = 5.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.weight(1f), - color = Color.Gray, - overflow = TextOverflow.Ellipsis - ) - } - } - - Row( - Modifier - .padding(start = 20.dp, end = 20.dp, bottom = 5.dp, top = 15.dp) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - painter = painterResource(R.drawable.ic_dm), - stringResource(R.string.send_a_direct_message), - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary - ) - - Spacer(modifier = StdHorzSpacer) - - Text(stringResource(id = R.string.send_the_seller_a_message)) - } - - Row( - modifier = Modifier - .padding(start = 10.dp, end = 10.dp, bottom = 5.dp, top = 5.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - val sellerName = note.author?.bestDisplayName() ?: note.author?.bestUsername() - - val msg = if (sellerName != null) { - stringResource( - id = R.string.hi_seller_is_this_still_available, - sellerName - ) - } else { - stringResource(id = R.string.hi_there_is_this_still_available) - } - - var message by remember { - mutableStateOf(TextFieldValue(msg)) - } - - TextField( - value = message, - onValueChange = { - message = it - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences - ), - shape = EditFieldBorder, - modifier = Modifier.weight(1f, true), - placeholder = { - Text( - text = stringResource(R.string.reply_here), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - trailingIcon = { - ThinSendButton( - isActive = message.text.isNotBlank(), - modifier = EditFieldTrailingIconModifier - ) { - note.author?.let { - nav(routeToMessage(it, msg, accountViewModel)) - } - } - }, - colors = TextFieldDefaults.colors( - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent - ) - ) - } + Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) { + Column { + if (images.isNotEmpty()) { + Row { + InlineCarrousel( + images, + images.first(), + ) } + } else { + CreateImageHeader(note, accountViewModel) + } + + Row( + Modifier.padding(top = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + title?.let { + Text( + text = it, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.weight(1f), + ) + } + } + + price?.let { + Row( + Modifier.padding(top = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val newAmount = price.amount.toBigDecimalOrNull()?.let { showAmount(it) } ?: price.amount + + val priceTag = + remember(noteEvent) { + if (price.frequency != null && price.currency != null) { + "$newAmount ${price.currency}/${price.frequency}" + } else if (price.currency != null) { + "$newAmount ${price.currency}" + } else { + newAmount + } + } + + Text( + text = priceTag, + maxLines = 1, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + + location?.let { + Text( + text = it, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + + summary?.let { + Row( + Modifier.padding(top = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + color = Color.Gray, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Row( + Modifier.padding(start = 20.dp, end = 20.dp, bottom = 5.dp, top = 15.dp).fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(R.drawable.ic_dm), + stringResource(R.string.send_a_direct_message), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Spacer(modifier = StdHorzSpacer) + + Text(stringResource(id = R.string.send_the_seller_a_message)) + } + + Row( + modifier = + Modifier.padding(start = 10.dp, end = 10.dp, bottom = 5.dp, top = 5.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + val sellerName = note.author?.bestDisplayName() ?: note.author?.bestUsername() + + val msg = + if (sellerName != null) { + stringResource( + id = R.string.hi_seller_is_this_still_available, + sellerName, + ) + } else { + stringResource(id = R.string.hi_there_is_this_still_available) + } + + var message by remember { mutableStateOf(TextFieldValue(msg)) } + + TextField( + value = message, + onValueChange = { message = it }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + shape = EditFieldBorder, + modifier = Modifier.weight(1f, true), + placeholder = { + Text( + text = stringResource(R.string.reply_here), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + trailingIcon = { + ThinSendButton( + isActive = message.text.isNotBlank(), + modifier = EditFieldTrailingIconModifier, + ) { + note.author?.let { nav(routeToMessage(it, msg, accountViewModel)) } + } + }, + colors = + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + ) + } } + } } @Composable private fun RenderLongFormHeaderForThread(noteEvent: LongTextNoteEvent) { - Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) { - Column { - noteEvent.image()?.let { - AsyncImage( - model = it, - contentDescription = stringResource( - R.string.preview_card_image_for, - it - ), - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth() - ) - } + Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) { + Column { + noteEvent.image()?.let { + AsyncImage( + model = it, + contentDescription = + stringResource( + R.string.preview_card_image_for, + it, + ), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + ) + } - noteEvent.title()?.let { - Spacer(modifier = DoubleVertSpacer) - Text( - text = it, - fontSize = 28.sp, - fontWeight = FontWeight.Bold, - modifier = Modifier.fillMaxWidth() - ) - } + noteEvent.title()?.let { + Spacer(modifier = DoubleVertSpacer) + Text( + text = it, + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.fillMaxWidth(), + ) + } - noteEvent.summary()?.ifBlank { null }?.let { - Spacer(modifier = DoubleVertSpacer) - Text( - text = it, - modifier = Modifier.fillMaxWidth(), - color = Color.Gray - ) - } + noteEvent + .summary() + ?.ifBlank { null } + ?.let { + Spacer(modifier = DoubleVertSpacer) + Text( + text = it, + modifier = Modifier.fillMaxWidth(), + color = Color.Gray, + ) } } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt index 18ecf6a9b..bae76d22a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.runtime.MutableState @@ -5,8 +25,11 @@ import com.vitorpamplona.amethyst.model.User import kotlinx.collections.immutable.ImmutableList sealed class UserFeedState { - object Loading : UserFeedState() - class Loaded(val feed: MutableState>) : UserFeedState() - object Empty : UserFeedState() - class FeedError(val errorMessage: String) : UserFeedState() + object Loading : UserFeedState() + + class Loaded(val feed: MutableState>) : UserFeedState() + + object Empty : UserFeedState() + + class FeedError(val errorMessage: String) : UserFeedState() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt index 7851182ac..1a8a6090f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.animation.Crossfade @@ -14,63 +34,54 @@ import com.vitorpamplona.amethyst.ui.theme.FeedPadding @Composable fun RefreshingFeedUserFeedView( - viewModel: UserFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - enablePullRefresh: Boolean = true + viewModel: UserFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + enablePullRefresh: Boolean = true, ) { - RefresheableView(viewModel, enablePullRefresh) { - UserFeedView(viewModel, accountViewModel, nav) - } + RefresheableView(viewModel, enablePullRefresh) { UserFeedView(viewModel, accountViewModel, nav) } } @Composable fun UserFeedView( - viewModel: UserFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + viewModel: UserFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val feedState by viewModel.feedContent.collectAsStateWithLifecycle() + val feedState by viewModel.feedContent.collectAsStateWithLifecycle() - Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> - when (state) { - is UserFeedState.Empty -> { - FeedEmpty { - viewModel.invalidateData() - } - } - - is UserFeedState.FeedError -> { - FeedError(state.errorMessage) { - viewModel.invalidateData() - } - } - - is UserFeedState.Loaded -> { - FeedLoaded(state, accountViewModel, nav) - } - - is UserFeedState.Loading -> { - LoadingFeed() - } - } + Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> + when (state) { + is UserFeedState.Empty -> { + FeedEmpty { viewModel.invalidateData() } + } + is UserFeedState.FeedError -> { + FeedError(state.errorMessage) { viewModel.invalidateData() } + } + is UserFeedState.Loaded -> { + FeedLoaded(state, accountViewModel, nav) + } + is UserFeedState.Loading -> { + LoadingFeed() + } } + } } @Composable private fun FeedLoaded( - state: UserFeedState.Loaded, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + state: UserFeedState.Loaded, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val listState = rememberLazyListState() + val listState = rememberLazyListState() - LazyColumn( - contentPadding = FeedPadding, - state = listState - ) { - itemsIndexed(state.feed.value, key = { _, item -> item.pubkeyHex }) { _, item -> - UserCompose(item, accountViewModel = accountViewModel, nav = nav) - } + LazyColumn( + contentPadding = FeedPadding, + state = listState, + ) { + itemsIndexed(state.feed.value, key = { _, item -> item.pubkeyHex }) { _, item -> + UserCompose(item, accountViewModel = accountViewModel, nav = nav) } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt index 01afe18ed..662c75e4e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen import android.util.Log @@ -25,114 +45,128 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -class NostrUserProfileFollowsUserFeedViewModel(val user: User, val account: Account) : UserFeedViewModel(UserProfileFollowsFeedFilter(user, account)) { - class Factory(val user: User, val account: Account) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrUserProfileFollowsUserFeedViewModel { - return NostrUserProfileFollowsUserFeedViewModel(user, account) as NostrUserProfileFollowsUserFeedViewModel - } +class NostrUserProfileFollowsUserFeedViewModel(val user: User, val account: Account) : + UserFeedViewModel(UserProfileFollowsFeedFilter(user, account)) { + class Factory(val user: User, val account: Account) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): NostrUserProfileFollowsUserFeedViewModel { + return NostrUserProfileFollowsUserFeedViewModel(user, account) + as NostrUserProfileFollowsUserFeedViewModel } + } } -class NostrUserProfileFollowersUserFeedViewModel(val user: User, val account: Account) : UserFeedViewModel(UserProfileFollowersFeedFilter(user, account)) { - class Factory(val user: User, val account: Account) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrUserProfileFollowersUserFeedViewModel { - return NostrUserProfileFollowersUserFeedViewModel(user, account) as NostrUserProfileFollowersUserFeedViewModel - } +class NostrUserProfileFollowersUserFeedViewModel(val user: User, val account: Account) : + UserFeedViewModel(UserProfileFollowersFeedFilter(user, account)) { + class Factory(val user: User, val account: Account) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): NostrUserProfileFollowersUserFeedViewModel { + return NostrUserProfileFollowersUserFeedViewModel(user, account) + as NostrUserProfileFollowersUserFeedViewModel } + } } -class NostrHiddenAccountsFeedViewModel(val account: Account) : UserFeedViewModel(HiddenAccountsFeedFilter(account)) { - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrHiddenAccountsFeedViewModel { - return NostrHiddenAccountsFeedViewModel(account) as NostrHiddenAccountsFeedViewModel - } +class NostrHiddenAccountsFeedViewModel(val account: Account) : + UserFeedViewModel(HiddenAccountsFeedFilter(account)) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): NostrHiddenAccountsFeedViewModel { + return NostrHiddenAccountsFeedViewModel(account) as NostrHiddenAccountsFeedViewModel } + } } -class NostrSpammerAccountsFeedViewModel(val account: Account) : UserFeedViewModel(SpammerAccountsFeedFilter(account)) { - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrSpammerAccountsFeedViewModel { - return NostrSpammerAccountsFeedViewModel(account) as NostrSpammerAccountsFeedViewModel - } +class NostrSpammerAccountsFeedViewModel(val account: Account) : + UserFeedViewModel(SpammerAccountsFeedFilter(account)) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): NostrSpammerAccountsFeedViewModel { + return NostrSpammerAccountsFeedViewModel(account) as NostrSpammerAccountsFeedViewModel } + } } @Stable -open class UserFeedViewModel(val dataSource: FeedFilter) : ViewModel(), InvalidatableViewModel { - private val _feedContent = MutableStateFlow(UserFeedState.Loading) - val feedContent = _feedContent.asStateFlow() +open class UserFeedViewModel(val dataSource: FeedFilter) : + ViewModel(), InvalidatableViewModel { + private val _feedContent = MutableStateFlow(UserFeedState.Loading) + val feedContent = _feedContent.asStateFlow() - private fun refresh() { - viewModelScope.launch(Dispatchers.Default) { - refreshSuspended() - } + private fun refresh() { + viewModelScope.launch(Dispatchers.Default) { refreshSuspended() } + } + + private fun refreshSuspended() { + checkNotInMainThread() + + val notes = dataSource.loadTop().toImmutableList() + + val oldNotesState = _feedContent.value + if (oldNotesState is UserFeedState.Loaded) { + // Using size as a proxy for has changed. + if (!equalImmutableLists(notes, oldNotesState.feed.value)) { + updateFeed(notes) + } + } else { + updateFeed(notes) } + } - private fun refreshSuspended() { + private fun updateFeed(notes: ImmutableList) { + viewModelScope.launch(Dispatchers.Main) { + val currentState = _feedContent.value + if (notes.isEmpty()) { + _feedContent.update { UserFeedState.Empty } + } else if (currentState is UserFeedState.Loaded) { + // updates the current list + currentState.feed.value = notes + } else { + _feedContent.update { UserFeedState.Loaded(mutableStateOf(notes)) } + } + } + } + + private val bundler = BundledUpdate(250, Dispatchers.IO) + + override fun invalidateData(ignoreIfDoing: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + bundler.invalidate(ignoreIfDoing) { + // adds the time to perform the refresh into this delay + // holding off new updates in case of heavy refresh routines. + refreshSuspended() + } + } + } + + var collectorJob: Job? = null + + init { + Log.d("Init", "${this.javaClass.simpleName}") + collectorJob = + viewModelScope.launch(Dispatchers.IO) { checkNotInMainThread() - val notes = dataSource.loadTop().toImmutableList() + LocalCache.live.newEventBundles.collect { newNotes -> + checkNotInMainThread() - val oldNotesState = _feedContent.value - if (oldNotesState is UserFeedState.Loaded) { - // Using size as a proxy for has changed. - if (!equalImmutableLists(notes, oldNotesState.feed.value)) { - updateFeed(notes) - } - } else { - updateFeed(notes) + invalidateData() } - } + } + } - private fun updateFeed(notes: ImmutableList) { - viewModelScope.launch(Dispatchers.Main) { - val currentState = _feedContent.value - if (notes.isEmpty()) { - _feedContent.update { UserFeedState.Empty } - } else if (currentState is UserFeedState.Loaded) { - // updates the current list - currentState.feed.value = notes - } else { - _feedContent.update { UserFeedState.Loaded(mutableStateOf(notes)) } - } - } - } - - private val bundler = BundledUpdate(250, Dispatchers.IO) - - override fun invalidateData(ignoreIfDoing: Boolean) { - viewModelScope.launch(Dispatchers.IO) { - bundler.invalidate(ignoreIfDoing) { - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - refreshSuspended() - } - } - } - - var collectorJob: Job? = null - - init { - Log.d("Init", "${this.javaClass.simpleName}") - collectorJob = viewModelScope.launch(Dispatchers.IO) { - checkNotInMainThread() - - LocalCache.live.newEventBundles.collect { newNotes -> - checkNotInMainThread() - - invalidateData() - } - } - } - - override fun onCleared() { - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") - bundler.cancel() - collectorJob?.cancel() - super.onCleared() - } + override fun onCleared() { + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + bundler.cancel() + collectorJob?.cancel() + super.onCleared() + } } interface InvalidatableViewModel { - fun invalidateData(ignoreIfDoing: Boolean = false) + fun invalidateData(ignoreIfDoing: Boolean = false) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt index 4beb7d24c..65d006b49 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt @@ -1,173 +1,191 @@ -package com.vitorpamplona.amethyst.ui.screen.loggedIn - -import android.app.Activity -import android.content.Context -import android.content.ContextWrapper -import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.ActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.background -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.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Key -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ClipboardManager -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.fragment.app.FragmentActivity -import com.halilibo.richtext.markdown.Markdown -import com.halilibo.richtext.ui.RichTextStyle -import com.halilibo.richtext.ui.material3.Material3RichText -import com.halilibo.richtext.ui.resolveDefaults -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.ui.actions.CloseButton -import com.vitorpamplona.amethyst.ui.note.authenticate -import com.vitorpamplona.amethyst.ui.theme.ButtonBorder -import com.vitorpamplona.amethyst.ui.theme.ButtonPadding -import com.vitorpamplona.quartz.encoders.toNsec -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch - -@Composable -fun AccountBackupDialog(accountViewModel: AccountViewModel, onClose: () -> Unit) { - Dialog( - onDismissRequest = onClose, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Surface(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .background(MaterialTheme.colorScheme.background) - .fillMaxSize() - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(10.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - CloseButton(onPress = onClose) - } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 30.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Material3RichText( - style = RichTextStyle().resolveDefaults() - ) { - Markdown( - content = stringResource(R.string.account_backup_tips_md) - ) - } - - Spacer(modifier = Modifier.height(30.dp)) - - NSecCopyButton(accountViewModel) - } - } - } - } -} - -@Composable -private fun NSecCopyButton( - accountViewModel: AccountViewModel -) { - val clipboardManager = LocalClipboardManager.current - val context = LocalContext.current - val scope = rememberCoroutineScope() - - val keyguardLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> - if (result.resultCode == Activity.RESULT_OK) { - copyNSec(context, scope, accountViewModel.account, clipboardManager) - } - } - - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = { - authenticate( - title = context.getString(R.string.copy_my_secret_key), - context = context, - keyguardLauncher = keyguardLauncher, - onApproved = { - copyNSec(context, scope, accountViewModel.account, clipboardManager) - }, - onError = { title, message -> - accountViewModel.toast(title, message) - } - ) - }, - shape = ButtonBorder, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ), - contentPadding = ButtonPadding - ) { - Icon( - tint = MaterialTheme.colorScheme.onPrimary, - imageVector = Icons.Default.Key, - contentDescription = stringResource(R.string.copies_the_nsec_id_your_password_to_the_clipboard_for_backup), - modifier = Modifier.padding(end = 5.dp) - ) - Text(stringResource(id = R.string.copy_my_secret_key), color = MaterialTheme.colorScheme.onPrimary) - } -} - -fun Context.getFragmentActivity(): FragmentActivity? { - var currentContext = this - while (currentContext is ContextWrapper) { - if (currentContext is FragmentActivity) { - return currentContext - } - currentContext = currentContext.baseContext - } - return null -} - -private fun copyNSec( - context: Context, - scope: CoroutineScope, - account: Account, - clipboardManager: ClipboardManager -) { - account.keyPair.privKey?.let { - clipboardManager.setText(AnnotatedString(it.toNsec())) - scope.launch { - Toast.makeText( - context, - context.getString(R.string.secret_key_copied_to_clipboard), - Toast.LENGTH_SHORT - ).show() - } - } -} +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Key +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.fragment.app.FragmentActivity +import com.halilibo.richtext.markdown.Markdown +import com.halilibo.richtext.ui.RichTextStyle +import com.halilibo.richtext.ui.material3.Material3RichText +import com.halilibo.richtext.ui.resolveDefaults +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.actions.CloseButton +import com.vitorpamplona.amethyst.ui.note.authenticate +import com.vitorpamplona.amethyst.ui.theme.ButtonBorder +import com.vitorpamplona.amethyst.ui.theme.ButtonPadding +import com.vitorpamplona.quartz.encoders.toNsec +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Composable +fun AccountBackupDialog( + accountViewModel: AccountViewModel, + onClose: () -> Unit, +) { + Dialog( + onDismissRequest = onClose, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.background(MaterialTheme.colorScheme.background).fillMaxSize(), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + CloseButton(onPress = onClose) + } + + Column( + modifier = Modifier.fillMaxSize().padding(horizontal = 30.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Material3RichText( + style = RichTextStyle().resolveDefaults(), + ) { + Markdown( + content = stringResource(R.string.account_backup_tips_md), + ) + } + + Spacer(modifier = Modifier.height(30.dp)) + + NSecCopyButton(accountViewModel) + } + } + } + } +} + +@Composable +private fun NSecCopyButton(accountViewModel: AccountViewModel) { + val clipboardManager = LocalClipboardManager.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val keyguardLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + result: ActivityResult -> + if (result.resultCode == Activity.RESULT_OK) { + copyNSec(context, scope, accountViewModel.account, clipboardManager) + } + } + + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = { + authenticate( + title = context.getString(R.string.copy_my_secret_key), + context = context, + keyguardLauncher = keyguardLauncher, + onApproved = { copyNSec(context, scope, accountViewModel.account, clipboardManager) }, + onError = { title, message -> accountViewModel.toast(title, message) }, + ) + }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Icon( + tint = MaterialTheme.colorScheme.onPrimary, + imageVector = Icons.Default.Key, + contentDescription = + stringResource(R.string.copies_the_nsec_id_your_password_to_the_clipboard_for_backup), + modifier = Modifier.padding(end = 5.dp), + ) + Text( + stringResource(id = R.string.copy_my_secret_key), + color = MaterialTheme.colorScheme.onPrimary, + ) + } +} + +fun Context.getFragmentActivity(): FragmentActivity? { + var currentContext = this + while (currentContext is ContextWrapper) { + if (currentContext is FragmentActivity) { + return currentContext + } + currentContext = currentContext.baseContext + } + return null +} + +private fun copyNSec( + context: Context, + scope: CoroutineScope, + account: Account, + clipboardManager: ClipboardManager, +) { + account.keyPair.privKey?.let { + clipboardManager.setText(AnnotatedString(it.toNsec())) + scope.launch { + Toast.makeText( + context, + context.getString(R.string.secret_key_copied_to_clipboard), + Toast.LENGTH_SHORT, + ) + .show() + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 5bb3acaa7..e9b72677f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.content.Context @@ -62,6 +82,9 @@ import com.vitorpamplona.quartz.events.ReportEvent import com.vitorpamplona.quartz.events.SealedGossipEvent import com.vitorpamplona.quartz.events.UserMetadata import com.vitorpamplona.quartz.utils.TimeUtils +import java.util.Locale +import kotlin.coroutines.resume +import kotlin.time.measureTimedValue import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf @@ -75,1074 +98,1158 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeoutOrNull -import java.util.Locale -import kotlin.coroutines.resume -import kotlin.time.measureTimedValue -@Immutable -open class ToastMsg() +@Immutable open class ToastMsg() -@Immutable -class StringToastMsg(val title: String, val msg: String) : ToastMsg() +@Immutable class StringToastMsg(val title: String, val msg: String) : ToastMsg() -@Immutable -class ResourceToastMsg(val titleResId: Int, val resourceId: Int) : ToastMsg() +@Immutable class ResourceToastMsg(val titleResId: Int, val resourceId: Int) : ToastMsg() @Stable class AccountViewModel(val account: Account, val settings: SettingsState) : ViewModel(), Dao { - val accountLiveData: LiveData = account.live.map { it } - val accountLanguagesLiveData: LiveData = account.liveLanguages.map { it } - val accountMarkAsReadUpdates = mutableIntStateOf(0) + val accountLiveData: LiveData = account.live.map { it } + val accountLanguagesLiveData: LiveData = account.liveLanguages.map { it } + val accountMarkAsReadUpdates = mutableIntStateOf(0) - val userFollows: LiveData = account.userProfile().live().follows.map { it } - val userRelays: LiveData = account.userProfile().live().relays.map { it } + val userFollows: LiveData = account.userProfile().live().follows.map { it } + val userRelays: LiveData = account.userProfile().live().relays.map { it } - val toasts = MutableSharedFlow(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val toasts = MutableSharedFlow(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST) - var serviceManager: ServiceManager? = null + var serviceManager: ServiceManager? = null - val showSensitiveContentChanges = account.live.map { - it.account.showSensitiveContent - }.distinctUntilChanged() + val showSensitiveContentChanges = + account.live.map { it.account.showSensitiveContent }.distinctUntilChanged() - fun clearToasts() { - viewModelScope.launch { - toasts.emit(null) - } - } + fun clearToasts() { + viewModelScope.launch { toasts.emit(null) } + } - fun toast(title: String, message: String) { - viewModelScope.launch { - toasts.emit(StringToastMsg(title, message)) - } - } + fun toast( + title: String, + message: String, + ) { + viewModelScope.launch { toasts.emit(StringToastMsg(title, message)) } + } - fun toast(titleResId: Int, resourceId: Int) { - viewModelScope.launch { - toasts.emit(ResourceToastMsg(titleResId, resourceId)) - } - } + fun toast( + titleResId: Int, + resourceId: Int, + ) { + viewModelScope.launch { toasts.emit(ResourceToastMsg(titleResId, resourceId)) } + } - fun isWriteable(): Boolean { - return account.isWriteable() - } + fun isWriteable(): Boolean { + return account.isWriteable() + } - fun userProfile(): User { - return account.userProfile() - } + fun userProfile(): User { + return account.userProfile() + } - suspend fun reactTo(note: Note, reaction: String) { + suspend fun reactTo( + note: Note, + reaction: String, + ) { + account.reactTo(note, reaction) + } + + fun reactToOrDelete( + note: Note, + reaction: String, + ) { + viewModelScope.launch(Dispatchers.IO) { + val currentReactions = account.reactionTo(note, reaction) + if (currentReactions.isNotEmpty()) { + account.delete(currentReactions) + } else { account.reactTo(note, reaction) + } } + } - fun reactToOrDelete(note: Note, reaction: String) { - viewModelScope.launch(Dispatchers.IO) { - val currentReactions = account.reactionTo(note, reaction) - if (currentReactions.isNotEmpty()) { - account.delete(currentReactions) - } else { - account.reactTo(note, reaction) - } + fun reactToOrDelete(note: Note) { + viewModelScope.launch(Dispatchers.IO) { + val reaction = account.reactionChoices.first() + if (hasReactedTo(note, reaction)) { + deleteReactionTo(note, reaction) + } else { + reactTo(note, reaction) + } + } + } + + fun isNoteHidden(note: Note): Boolean { + return note.isHiddenFor(account.flowHiddenUsers.value) + } + + fun hasReactedTo( + baseNote: Note, + reaction: String, + ): Boolean { + return account.hasReacted(baseNote, reaction) + } + + suspend fun deleteReactionTo( + note: Note, + reaction: String, + ) { + account.delete(account.reactionTo(note, reaction)) + } + + fun hasBoosted(baseNote: Note): Boolean { + return account.hasBoosted(baseNote) + } + + fun deleteBoostsTo(note: Note) { + viewModelScope.launch(Dispatchers.IO) { account.delete(account.boostsTo(note)) } + } + + fun calculateIfNoteWasZappedByAccount( + zappedNote: Note, + onWasZapped: (Boolean) -> Unit, + ) { + viewModelScope.launch(Dispatchers.Default) { + account.calculateIfNoteWasZappedByAccount(zappedNote) { onWasZapped(true) } + } + } + + fun calculateZapAmount( + zappedNote: Note, + onZapAmount: (String) -> Unit, + ) { + if (zappedNote.zapPayments.isNotEmpty()) { + viewModelScope.launch(Dispatchers.IO) { + account.calculateZappedAmount(zappedNote) { onZapAmount(showAmount(it)) } + } + } else { + onZapAmount(showAmount(zappedNote.zapsAmount)) + } + } + + fun calculateZapraiser( + zappedNote: Note, + onZapraiserStatus: (ZapraiserStatus) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + val zapraiserAmount = zappedNote.event?.zapraiserAmount() ?: 0 + account.calculateZappedAmount(zappedNote) { newZapAmount -> + var percentage = newZapAmount.div(zapraiserAmount.toBigDecimal()).toFloat() + + if (percentage > 1) { + percentage = 1f } - } - fun reactToOrDelete(note: Note) { - viewModelScope.launch(Dispatchers.IO) { - val reaction = account.reactionChoices.first() - if (hasReactedTo(note, reaction)) { - deleteReactionTo(note, reaction) - } else { - reactTo(note, reaction) - } - } + val newZapraiserProgress = percentage + val newZapraiserLeft = + if (percentage > 0.99) { + "0" + } else { + showAmount((zapraiserAmount * (1 - percentage)).toBigDecimal()) + } + onZapraiserStatus(ZapraiserStatus(newZapraiserProgress, newZapraiserLeft)) + } } + } - fun isNoteHidden(note: Note): Boolean { - return note.isHiddenFor(account.flowHiddenUsers.value) + fun decryptAmountMessageInGroup( + zaps: ImmutableList, + onNewState: (ImmutableList) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + val myList = zaps.toList() + + val initialResults = + myList + .associate { + it.request to + ZapAmountCommentNotification( + it.request.author, + it.request.event?.content()?.ifBlank { null }, + showAmountAxis((it.response?.event as? LnZapEvent)?.amount), + ) + } + .toMutableMap() + + collectSuccessfulSigningOperations( + operationsInput = myList, + runRequestFor = { next, onReady -> + innerDecryptAmountMessage(next.request, next.response, onReady) + }, + ) { + it.forEach { decrypted -> initialResults[decrypted.key.request] = decrypted.value } + + onNewState(initialResults.values.toImmutableList()) + } } + } - fun hasReactedTo(baseNote: Note, reaction: String): Boolean { - return account.hasReacted(baseNote, reaction) - } - - suspend fun deleteReactionTo(note: Note, reaction: String) { - account.delete(account.reactionTo(note, reaction)) - } - - fun hasBoosted(baseNote: Note): Boolean { - return account.hasBoosted(baseNote) - } - - fun deleteBoostsTo(note: Note) { - viewModelScope.launch(Dispatchers.IO) { - account.delete(account.boostsTo(note)) - } - } - - fun calculateIfNoteWasZappedByAccount(zappedNote: Note, onWasZapped: (Boolean) -> Unit) { - viewModelScope.launch(Dispatchers.Default) { - account.calculateIfNoteWasZappedByAccount(zappedNote) { - onWasZapped(true) - } - } - } - - fun calculateZapAmount(zappedNote: Note, onZapAmount: (String) -> Unit) { - if (zappedNote.zapPayments.isNotEmpty()) { - viewModelScope.launch(Dispatchers.IO) { - account.calculateZappedAmount(zappedNote) { - onZapAmount(showAmount(it)) - } - } + fun cachedDecryptAmountMessageInGroup( + zapNotes: List + ): ImmutableList { + return zapNotes + .map { + val request = it.request.event as? LnZapRequestEvent + if (request?.isPrivateZap() == true) { + val cachedPrivateRequest = request.cachedPrivateZap() + if (cachedPrivateRequest != null) { + ZapAmountCommentNotification( + LocalCache.getUserIfExists(cachedPrivateRequest.pubKey) ?: it.request.author, + cachedPrivateRequest.content.ifBlank { null }, + showAmountAxis((it.response.event as? LnZapEvent)?.amount), + ) + } else { + ZapAmountCommentNotification( + it.request.author, + it.request.event?.content()?.ifBlank { null }, + showAmountAxis((it.response.event as? LnZapEvent)?.amount), + ) + } } else { - onZapAmount(showAmount(zappedNote.zapsAmount)) + ZapAmountCommentNotification( + it.request.author, + it.request.event?.content()?.ifBlank { null }, + showAmountAxis((it.response.event as? LnZapEvent)?.amount), + ) } - } + } + .toImmutableList() + } - fun calculateZapraiser(zappedNote: Note, onZapraiserStatus: (ZapraiserStatus) -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - val zapraiserAmount = zappedNote.event?.zapraiserAmount() ?: 0 - account.calculateZappedAmount(zappedNote) { newZapAmount -> - var percentage = newZapAmount.div(zapraiserAmount.toBigDecimal()).toFloat() + fun cachedDecryptAmountMessageInGroup( + baseNote: Note + ): ImmutableList { + val myList = baseNote.zaps.toList() - if (percentage > 1) { - percentage = 1f - } - - val newZapraiserProgress = percentage - val newZapraiserLeft = if (percentage > 0.99) { - "0" - } else { - showAmount((zapraiserAmount * (1 - percentage)).toBigDecimal()) - } - onZapraiserStatus(ZapraiserStatus(newZapraiserProgress, newZapraiserLeft)) - } + return myList + .map { + val request = it.first.event as? LnZapRequestEvent + if (request?.isPrivateZap() == true) { + val cachedPrivateRequest = request.cachedPrivateZap() + if (cachedPrivateRequest != null) { + ZapAmountCommentNotification( + LocalCache.getUserIfExists(cachedPrivateRequest.pubKey) ?: it.first.author, + cachedPrivateRequest.content.ifBlank { null }, + showAmountAxis((it.second?.event as? LnZapEvent)?.amount), + ) + } else { + ZapAmountCommentNotification( + it.first.author, + it.first.event?.content()?.ifBlank { null }, + showAmountAxis((it.second?.event as? LnZapEvent)?.amount), + ) + } + } else { + ZapAmountCommentNotification( + it.first.author, + it.first.event?.content()?.ifBlank { null }, + showAmountAxis((it.second?.event as? LnZapEvent)?.amount), + ) } + } + .toImmutableList() + } + + fun decryptAmountMessageInGroup( + baseNote: Note, + onNewState: (ImmutableList) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + val myList = baseNote.zaps.toList() + + val initialResults = + myList + .associate { + it.first to + ZapAmountCommentNotification( + it.first.author, + it.first.event?.content()?.ifBlank { null }, + showAmountAxis((it.second?.event as? LnZapEvent)?.amount), + ) + } + .toMutableMap() + + collectSuccessfulSigningOperations, ZapAmountCommentNotification>( + operationsInput = myList, + runRequestFor = { next, onReady -> + innerDecryptAmountMessage(next.first, next.second, onReady) + }, + ) { + it.forEach { decrypted -> initialResults[decrypted.key.first] = decrypted.value } + + onNewState(initialResults.values.toImmutableList()) + } } + } - fun decryptAmountMessageInGroup( - zaps: ImmutableList, - onNewState: (ImmutableList) -> Unit - ) { - viewModelScope.launch(Dispatchers.IO) { - val myList = zaps.toList() + fun decryptAmountMessage( + zapRequest: Note, + zapEvent: Note?, + onNewState: (ZapAmountCommentNotification?) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + innerDecryptAmountMessage(zapRequest, zapEvent, onNewState) + } + } - val initialResults = myList.associate { - it.request to ZapAmountCommentNotification( - it.request.author, - it.request.event?.content()?.ifBlank { null }, - showAmountAxis((it.response?.event as? LnZapEvent)?.amount) - ) - }.toMutableMap() + private fun innerDecryptAmountMessage( + zapRequest: Note, + zapEvent: Note?, + onReady: (ZapAmountCommentNotification) -> Unit, + ) { + checkNotInMainThread() - collectSuccessfulSigningOperations( - operationsInput = myList, - runRequestFor = { next, onReady -> - innerDecryptAmountMessage(next.request, next.response, onReady) - } - ) { - it.forEach { decrypted -> - initialResults[decrypted.key.request] = decrypted.value - } - - onNewState(initialResults.values.toImmutableList()) - } + (zapRequest.event as? LnZapRequestEvent)?.let { + if (it.isPrivateZap()) { + decryptZap(zapRequest) { decryptedContent -> + val amount = (zapEvent?.event as? LnZapEvent)?.amount + val newAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey) + onReady( + ZapAmountCommentNotification( + newAuthor, + decryptedContent.content.ifBlank { null }, + showAmountAxis(amount), + ), + ) } + } else { + val amount = (zapEvent?.event as? LnZapEvent)?.amount + if (!zapRequest.event?.content().isNullOrBlank() || amount != null) { + onReady( + ZapAmountCommentNotification( + zapRequest.author, + zapRequest.event?.content()?.ifBlank { null }, + showAmountAxis(amount), + ), + ) + } + } } + } - fun cachedDecryptAmountMessageInGroup(zapNotes: List): ImmutableList { - return zapNotes.map { - val request = it.request.event as? LnZapRequestEvent - if (request?.isPrivateZap() == true) { - val cachedPrivateRequest = request.cachedPrivateZap() - if (cachedPrivateRequest != null) { - ZapAmountCommentNotification( - LocalCache.getUserIfExists(cachedPrivateRequest.pubKey) ?: it.request.author, - cachedPrivateRequest.content.ifBlank { null }, - showAmountAxis((it.response.event as? LnZapEvent)?.amount) - ) - } else { - ZapAmountCommentNotification( - it.request.author, - it.request.event?.content()?.ifBlank { null }, - showAmountAxis((it.response.event as? LnZapEvent)?.amount) - ) - } + fun zap( + note: Note, + amount: Long, + pollOption: Int?, + message: String, + context: Context, + onError: (String, String) -> Unit, + onProgress: (percent: Float) -> Unit, + onPayViaIntent: (ImmutableList) -> Unit, + zapType: LnZapEvent.ZapType, + ) { + viewModelScope.launch(Dispatchers.IO) { + ZapPaymentHandler(account) + .zap( + note, + amount, + pollOption, + message, + context, + onError, + onProgress, + onPayViaIntent, + zapType, + ) + } + } + + fun report( + note: Note, + type: ReportEvent.ReportType, + content: String = "", + ) { + viewModelScope.launch(Dispatchers.IO) { account.report(note, type, content) } + } + + fun report( + user: User, + type: ReportEvent.ReportType, + ) { + viewModelScope.launch(Dispatchers.IO) { + account.report(user, type) + account.hideUser(user.pubkeyHex) + } + } + + fun boost(note: Note) { + viewModelScope.launch(Dispatchers.IO) { account.boost(note) } + } + + fun removeEmojiPack( + usersEmojiList: Note, + emojiList: Note, + ) { + viewModelScope.launch(Dispatchers.IO) { account.removeEmojiPack(usersEmojiList, emojiList) } + } + + fun addEmojiPack( + usersEmojiList: Note, + emojiList: Note, + ) { + viewModelScope.launch(Dispatchers.IO) { account.addEmojiPack(usersEmojiList, emojiList) } + } + + fun addPrivateBookmark(note: Note) { + viewModelScope.launch(Dispatchers.IO) { account.addBookmark(note, true) } + } + + fun addPublicBookmark(note: Note) { + viewModelScope.launch(Dispatchers.IO) { account.addBookmark(note, false) } + } + + fun removePrivateBookmark(note: Note) { + viewModelScope.launch(Dispatchers.IO) { account.removeBookmark(note, true) } + } + + fun removePublicBookmark(note: Note) { + viewModelScope.launch(Dispatchers.IO) { account.removeBookmark(note, false) } + } + + fun isInPrivateBookmarks( + note: Note, + onReady: (Boolean) -> Unit, + ) { + account.isInPrivateBookmarks(note, onReady) + } + + fun isInPublicBookmarks(note: Note): Boolean { + return account.isInPublicBookmarks(note) + } + + fun broadcast(note: Note) { + account.broadcast(note) + } + + fun delete(note: Note) { + viewModelScope.launch(Dispatchers.IO) { account.delete(note) } + } + + fun cachedDecrypt(note: Note): String? { + return account.cachedDecryptContent(note) + } + + fun decrypt( + note: Note, + onReady: (String) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { account.decryptContent(note, onReady) } + } + + fun decryptZap( + note: Note, + onReady: (Event) -> Unit, + ) { + account.decryptZapContentAuthor(note, onReady) + } + + fun translateTo(lang: Locale) { + account.updateTranslateTo(lang.language) + } + + fun dontTranslateFrom(lang: String) { + account.addDontTranslateFrom(lang) + } + + fun prefer( + source: String, + target: String, + preference: String, + ) { + account.prefer(source, target, preference) + } + + fun follow(user: User) { + viewModelScope.launch(Dispatchers.IO) { account.follow(user) } + } + + fun unfollow(user: User) { + viewModelScope.launch(Dispatchers.IO) { account.unfollow(user) } + } + + fun followGeohash(tag: String) { + viewModelScope.launch(Dispatchers.IO) { account.followGeohash(tag) } + } + + fun unfollowGeohash(tag: String) { + viewModelScope.launch(Dispatchers.IO) { account.unfollowGeohash(tag) } + } + + fun followHashtag(tag: String) { + viewModelScope.launch(Dispatchers.IO) { account.followHashtag(tag) } + } + + fun unfollowHashtag(tag: String) { + viewModelScope.launch(Dispatchers.IO) { account.unfollowHashtag(tag) } + } + + fun showWord(word: String) { + viewModelScope.launch(Dispatchers.IO) { account.showWord(word) } + } + + fun hideWord(word: String) { + viewModelScope.launch(Dispatchers.IO) { account.hideWord(word) } + } + + fun isLoggedUser(user: User?): Boolean { + return account.userProfile().pubkeyHex == user?.pubkeyHex + } + + fun isFollowing(user: User?): Boolean { + if (user == null) return false + return account.userProfile().isFollowingCached(user) + } + + fun isFollowing(user: HexKey): Boolean { + return account.userProfile().isFollowingCached(user) + } + + val hideDeleteRequestDialog: Boolean + get() = account.hideDeleteRequestDialog + + fun dontShowDeleteRequestDialog() { + viewModelScope.launch(Dispatchers.IO) { account.setHideDeleteRequestDialog() } + } + + val hideNIP24WarningDialog: Boolean + get() = account.hideNIP24WarningDialog + + fun dontShowNIP24WarningDialog() { + account.setHideNIP24WarningDialog() + } + + val hideBlockAlertDialog: Boolean + get() = account.hideBlockAlertDialog + + fun dontShowBlockAlertDialog() { + account.setHideBlockAlertDialog() + } + + fun hideSensitiveContent() { + account.updateShowSensitiveContent(false) + } + + fun disableContentWarnings() { + account.updateShowSensitiveContent(true) + } + + fun seeContentWarnings() { + account.updateShowSensitiveContent(null) + } + + fun defaultZapType(): LnZapEvent.ZapType { + return account.defaultZapType + } + + @Immutable + data class NoteComposeReportState( + val isAcceptable: Boolean = true, + val canPreview: Boolean = true, + val isHiddenAuthor: Boolean = false, + val relevantReports: ImmutableSet = persistentSetOf(), + ) + + fun isNoteAcceptable( + note: Note, + onReady: (NoteComposeReportState) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + val isFromLoggedIn = note.author?.pubkeyHex == userProfile().pubkeyHex + val isFromLoggedInFollow = note.author?.let { userProfile().isFollowingCached(it) } ?: true + + if (isFromLoggedIn || isFromLoggedInFollow) { + // No need to process if from trusted people + onReady(NoteComposeReportState(true, true, false, persistentSetOf())) + } else if (note.author?.let { account.isHidden(it) } == true) { + onReady(NoteComposeReportState(false, false, true, persistentSetOf())) + } else { + val newCanPreview = !note.hasAnyReports() + + val newIsAcceptable = account.isAcceptable(note) + + if (newCanPreview && newIsAcceptable) { + // No need to process reports if nothing is wrong + onReady(NoteComposeReportState(true, true, false, persistentSetOf())) + } else { + val newRelevantReports = account.getRelevantReports(note) + + onReady( + NoteComposeReportState( + newIsAcceptable, + newCanPreview, + false, + newRelevantReports.toImmutableSet(), + ), + ) + } + } + } + } + + fun unwrap( + event: GiftWrapEvent, + onReady: (Event) -> Unit, + ) { + account.unwrap(event, onReady) + } + + fun unseal( + event: SealedGossipEvent, + onReady: (Event) -> Unit, + ) { + account.unseal(event, onReady) + } + + fun show(user: User) { + viewModelScope.launch(Dispatchers.IO) { account.showUser(user.pubkeyHex) } + } + + fun hide(user: User) { + viewModelScope.launch(Dispatchers.IO) { account.hideUser(user.pubkeyHex) } + } + + fun hide(word: String) { + viewModelScope.launch(Dispatchers.IO) { account.hideWord(word) } + } + + fun showUser(pubkeyHex: String) { + viewModelScope.launch(Dispatchers.IO) { account.showUser(pubkeyHex) } + } + + fun createStatus(newStatus: String) { + viewModelScope.launch(Dispatchers.IO) { account.createStatus(newStatus) } + } + + fun updateStatus( + it: ATag, + newStatus: String, + ) { + viewModelScope.launch(Dispatchers.IO) { + account.updateStatus(LocalCache.getOrCreateAddressableNote(it), newStatus) + } + } + + fun deleteStatus(it: ATag) { + viewModelScope.launch(Dispatchers.IO) { + account.deleteStatus(LocalCache.getOrCreateAddressableNote(it)) + } + } + + fun urlPreview( + url: String, + onResult: suspend (UrlPreviewState) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { UrlCachedPreviewer.previewInfo(url, onResult) } + } + + fun loadReactionTo( + note: Note?, + onNewReactionType: (String?) -> Unit, + ) { + if (note == null) return + + viewModelScope.launch(Dispatchers.Default) { + onNewReactionType(note.getReactionBy(userProfile())) + } + } + + fun verifyNip05( + userMetadata: UserMetadata, + pubkeyHex: String, + onResult: (Boolean) -> Unit, + ) { + val nip05 = userMetadata.nip05?.ifBlank { null } ?: return + + viewModelScope.launch(Dispatchers.IO) { + Nip05NostrAddressVerifier() + .verifyNip05( + nip05, + onSuccess = { + // Marks user as verified + if (it == pubkeyHex) { + userMetadata.nip05Verified = true + userMetadata.nip05LastVerificationTime = TimeUtils.now() + + onResult(userMetadata.nip05Verified) } else { - ZapAmountCommentNotification( - it.request.author, - it.request.event?.content()?.ifBlank { null }, - showAmountAxis((it.response.event as? LnZapEvent)?.amount) - ) + userMetadata.nip05Verified = false + userMetadata.nip05LastVerificationTime = 0 + + onResult(userMetadata.nip05Verified) } - }.toImmutableList() + }, + onError = { + userMetadata.nip05LastVerificationTime = 0 + userMetadata.nip05Verified = false + + onResult(userMetadata.nip05Verified) + }, + ) } + } - fun cachedDecryptAmountMessageInGroup(baseNote: Note): ImmutableList { - val myList = baseNote.zaps.toList() + fun retrieveRelayDocument( + dirtyUrl: String, + onInfo: (RelayInformation) -> Unit, + onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + Nip11CachedRetriever.loadRelayInfo(dirtyUrl, onInfo, onError) + } + } - return myList.map { - val request = it.first.event as? LnZapRequestEvent - if (request?.isPrivateZap() == true) { - val cachedPrivateRequest = request.cachedPrivateZap() - if (cachedPrivateRequest != null) { - ZapAmountCommentNotification( - LocalCache.getUserIfExists(cachedPrivateRequest.pubKey) ?: it.first.author, - cachedPrivateRequest.content.ifBlank { null }, - showAmountAxis((it.second?.event as? LnZapEvent)?.amount) - ) - } else { - ZapAmountCommentNotification( - it.first.author, - it.first.event?.content()?.ifBlank { null }, - showAmountAxis((it.second?.event as? LnZapEvent)?.amount) - ) - } + fun runOnIO(runOnIO: () -> Unit) { + viewModelScope.launch(Dispatchers.IO) { runOnIO() } + } + + suspend fun checkGetOrCreateUser(key: HexKey): User? { + return LocalCache.checkGetOrCreateUser(key) + } + + override suspend fun getOrCreateUser(key: HexKey): User { + return LocalCache.getOrCreateUser(key) + } + + fun checkGetOrCreateUser( + key: HexKey, + onResult: (User?) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreateUser(key)) } + } + + fun getUserIfExists(hex: HexKey): User? { + return LocalCache.getUserIfExists(hex) + } + + private suspend fun checkGetOrCreateNote(key: HexKey): Note? { + return LocalCache.checkGetOrCreateNote(key) + } + + override suspend fun getOrCreateNote(key: HexKey): Note { + return LocalCache.getOrCreateNote(key) + } + + fun checkGetOrCreateNote( + key: HexKey, + onResult: (Note?) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreateNote(key)) } + } + + fun getNoteIfExists(hex: HexKey): Note? { + return LocalCache.getNoteIfExists(hex) + } + + override suspend fun checkGetOrCreateAddressableNote(key: HexKey): AddressableNote? { + return LocalCache.checkGetOrCreateAddressableNote(key) + } + + fun checkGetOrCreateAddressableNote( + key: HexKey, + onResult: (AddressableNote?) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreateAddressableNote(key)) } + } + + private suspend fun getOrCreateAddressableNote(key: ATag): AddressableNote? { + return LocalCache.getOrCreateAddressableNote(key) + } + + fun getOrCreateAddressableNote( + key: ATag, + onResult: (AddressableNote?) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { onResult(getOrCreateAddressableNote(key)) } + } + + fun getAddressableNoteIfExists(key: String): AddressableNote? { + return LocalCache.addressables[key] + } + + fun findStatusesForUser( + myUser: User, + onResult: (ImmutableList) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { onResult(LocalCache.findStatusesForUser(myUser)) } + } + + private suspend fun checkGetOrCreateChannel(key: HexKey): Channel? { + return LocalCache.checkGetOrCreateChannel(key) + } + + fun checkGetOrCreateChannel( + key: HexKey, + onResult: (Channel?) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreateChannel(key)) } + } + + fun getChannelIfExists(hex: HexKey): Channel? { + return LocalCache.getChannelIfExists(hex) + } + + fun loadParticipants( + participants: List, + onReady: (ImmutableList>) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + val participantUsers = + participants + .mapNotNull { part -> + checkGetOrCreateUser(part.key)?.let { + Pair( + part, + it, + ) + } + } + .toImmutableList() + + onReady(participantUsers) + } + } + + fun loadUsers( + hexList: List, + onReady: (ImmutableList) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + onReady( + hexList + .mapNotNull { hex -> checkGetOrCreateUser(hex) } + .sortedBy { account.isFollowing(it) } + .reversed() + .toImmutableList(), + ) + } + } + + fun returnNIP19References( + content: String, + tags: ImmutableListOfLists?, + onNewReferences: (List) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + onNewReferences(MarkdownParser().returnNIP19References(content, tags)) + } + } + + fun returnMarkdownWithSpecialContent( + content: String, + tags: ImmutableListOfLists?, + onNewContent: (String) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + onNewContent(MarkdownParser().returnMarkdownWithSpecialContent(content, tags)) + } + } + + fun parseNIP19( + str: String, + onNote: (LoadedBechLink) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + Nip19.uriToRoute(str)?.let { + var returningNote: Note? = null + if ( + it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS + ) { + LocalCache.checkGetOrCreateNote(it.hex)?.let { note -> returningNote = note } + } + + onNote(LoadedBechLink(returningNote, it)) + } + } + } + + fun checkIsOnline( + media: String?, + onDone: (Boolean) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { onDone(OnlineChecker.isOnline(media)) } + } + + suspend fun refreshMarkAsReadObservers() { + updateNotificationDots() + accountMarkAsReadUpdates.value++ + } + + fun loadAndMarkAsRead( + routeForLastRead: String, + createdAt: Long?, + onIsNew: (Boolean) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + val lastTime = account.loadLastRead(routeForLastRead) + + if (createdAt != null) { + if (account.markAsRead(routeForLastRead, createdAt)) { + refreshMarkAsReadObservers() + } + onIsNew(createdAt > lastTime) + } else { + onIsNew(false) + } + } + } + + fun markAllAsRead( + notes: ImmutableList, + onDone: () -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + var atLeastOne = false + + for (note in notes) { + note.event?.let { noteEvent -> + val channelHex = note.channelHex() + val route = + if (channelHex != null) { + "Channel/$channelHex" + } else if (note.event is ChatroomKeyable) { + val withKey = (note.event as ChatroomKeyable).chatroomKey(userProfile().pubkeyHex) + "Room/${withKey.hashCode()}" } else { - ZapAmountCommentNotification( - it.first.author, - it.first.event?.content()?.ifBlank { null }, - showAmountAxis((it.second?.event as? LnZapEvent)?.amount) - ) + null } - }.toImmutableList() - } - fun decryptAmountMessageInGroup( - baseNote: Note, - onNewState: (ImmutableList) -> Unit - ) { - viewModelScope.launch(Dispatchers.IO) { - val myList = baseNote.zaps.toList() - - val initialResults = myList.associate { - it.first to ZapAmountCommentNotification( - it.first.author, - it.first.event?.content()?.ifBlank { null }, - showAmountAxis((it.second?.event as? LnZapEvent)?.amount) - ) - }.toMutableMap() - - collectSuccessfulSigningOperations, ZapAmountCommentNotification>( - operationsInput = myList, - runRequestFor = { next, onReady -> - innerDecryptAmountMessage(next.first, next.second, onReady) - } - ) { - it.forEach { decrypted -> - initialResults[decrypted.key.first] = decrypted.value - } - - onNewState(initialResults.values.toImmutableList()) + route?.let { + if (account.markAsRead(route, noteEvent.createdAt())) { + atLeastOne = true } + } } + } + + if (atLeastOne) { + refreshMarkAsReadObservers() + } + + onDone() } + } - fun decryptAmountMessage( - zapRequest: Note, - zapEvent: Note?, - onNewState: (ZapAmountCommentNotification?) -> Unit - ) { - viewModelScope.launch(Dispatchers.IO) { - innerDecryptAmountMessage(zapRequest, zapEvent, onNewState) - } + fun createChatRoomFor( + user: User, + then: (Int) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + val withKey = ChatroomKey(persistentSetOf(user.pubkeyHex)) + account.userProfile().createChatroom(withKey) + then(withKey.hashCode()) } + } - private fun innerDecryptAmountMessage( - zapRequest: Note, - zapEvent: Note?, - onReady: (ZapAmountCommentNotification) -> Unit - ) { - checkNotInMainThread() - - (zapRequest.event as? LnZapRequestEvent)?.let { - if (it.isPrivateZap()) { - decryptZap(zapRequest) { decryptedContent -> - val amount = (zapEvent?.event as? LnZapEvent)?.amount - val newAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey) - onReady( - ZapAmountCommentNotification( - newAuthor, - decryptedContent.content.ifBlank { null }, - showAmountAxis(amount) - ) - ) - } - } else { - val amount = (zapEvent?.event as? LnZapEvent)?.amount - if (!zapRequest.event?.content().isNullOrBlank() || amount != null) { - onReady( - ZapAmountCommentNotification( - zapRequest.author, - zapRequest.event?.content()?.ifBlank { null }, - showAmountAxis(amount) - ) - ) - } - } - } + fun enableTor( + checked: Boolean, + portNumber: MutableState, + ) { + viewModelScope.launch(Dispatchers.IO) { + account.proxyPort = portNumber.value.toInt() + account.proxy = HttpClient.initProxy(checked, "127.0.0.1", account.proxyPort) + account.saveable.invalidateData() + serviceManager?.forceRestart() } + } - fun zap( - note: Note, - amount: Long, - pollOption: Int?, - message: String, - context: Context, - onError: (String, String) -> Unit, - onProgress: (percent: Float) -> Unit, - onPayViaIntent: (ImmutableList) -> Unit, - zapType: LnZapEvent.ZapType - ) { - viewModelScope.launch(Dispatchers.IO) { - ZapPaymentHandler(account).zap(note, amount, pollOption, message, context, onError, onProgress, onPayViaIntent, zapType) - } + class Factory(val account: Account, val settings: SettingsState) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): AccountViewModel { + return AccountViewModel(account, settings) as AccountViewModel } + } - fun report(note: Note, type: ReportEvent.ReportType, content: String = "") { - viewModelScope.launch(Dispatchers.IO) { - account.report(note, type, content) - } - } + private var collectorJob: Job? = null + val notificationDots = HasNotificationDot(bottomNavigationItems) + private val bundlerInsert = BundledInsert>(3000, Dispatchers.IO) - fun report(user: User, type: ReportEvent.ReportType) { - viewModelScope.launch(Dispatchers.IO) { - account.report(user, type) - account.hideUser(user.pubkeyHex) - } - } + fun invalidateInsertData(newItems: Set) { + bundlerInsert.invalidateList(newItems) { updateNotificationDots(it.flatten().toSet()) } + } - fun boost(note: Note) { - viewModelScope.launch(Dispatchers.IO) { - account.boost(note) - } - } - - fun removeEmojiPack(usersEmojiList: Note, emojiList: Note) { - viewModelScope.launch(Dispatchers.IO) { - account.removeEmojiPack(usersEmojiList, emojiList) - } - } - - fun addEmojiPack(usersEmojiList: Note, emojiList: Note) { - viewModelScope.launch(Dispatchers.IO) { - account.addEmojiPack(usersEmojiList, emojiList) - } - } - - fun addPrivateBookmark(note: Note) { - viewModelScope.launch(Dispatchers.IO) { - account.addBookmark(note, true) - } - } - - fun addPublicBookmark(note: Note) { - viewModelScope.launch(Dispatchers.IO) { - account.addBookmark(note, false) - } - } - - fun removePrivateBookmark(note: Note) { - viewModelScope.launch(Dispatchers.IO) { - account.removeBookmark(note, true) - } - } - - fun removePublicBookmark(note: Note) { - viewModelScope.launch(Dispatchers.IO) { - account.removeBookmark(note, false) - } - } - - fun isInPrivateBookmarks(note: Note, onReady: (Boolean) -> Unit) { - account.isInPrivateBookmarks(note, onReady) - } - - fun isInPublicBookmarks(note: Note): Boolean { - return account.isInPublicBookmarks(note) - } - - fun broadcast(note: Note) { - account.broadcast(note) - } - - fun delete(note: Note) { - viewModelScope.launch(Dispatchers.IO) { - account.delete(note) - } - } - - fun cachedDecrypt(note: Note): String? { - return account.cachedDecryptContent(note) - } - - fun decrypt(note: Note, onReady: (String) -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - account.decryptContent(note, onReady) - } - } - - fun decryptZap(note: Note, onReady: (Event) -> Unit) { - account.decryptZapContentAuthor(note, onReady) - } - - fun translateTo(lang: Locale) { - account.updateTranslateTo(lang.language) - } - - fun dontTranslateFrom(lang: String) { - account.addDontTranslateFrom(lang) - } - - fun prefer(source: String, target: String, preference: String) { - account.prefer(source, target, preference) - } - - fun follow(user: User) { - viewModelScope.launch(Dispatchers.IO) { - account.follow(user) - } - } - - fun unfollow(user: User) { - viewModelScope.launch(Dispatchers.IO) { - account.unfollow(user) - } - } - - fun followGeohash(tag: String) { - viewModelScope.launch(Dispatchers.IO) { - account.followGeohash(tag) - } - } - - fun unfollowGeohash(tag: String) { - viewModelScope.launch(Dispatchers.IO) { - account.unfollowGeohash(tag) - } - } - - fun followHashtag(tag: String) { - viewModelScope.launch(Dispatchers.IO) { - account.followHashtag(tag) - } - } - - fun unfollowHashtag(tag: String) { - viewModelScope.launch(Dispatchers.IO) { - account.unfollowHashtag(tag) - } - } - - fun showWord(word: String) { - viewModelScope.launch(Dispatchers.IO) { - account.showWord(word) - } - } - - fun hideWord(word: String) { - viewModelScope.launch(Dispatchers.IO) { - account.hideWord(word) - } - } - - fun isLoggedUser(user: User?): Boolean { - return account.userProfile().pubkeyHex == user?.pubkeyHex - } - - fun isFollowing(user: User?): Boolean { - if (user == null) return false - return account.userProfile().isFollowingCached(user) - } - - fun isFollowing(user: HexKey): Boolean { - return account.userProfile().isFollowingCached(user) - } - - val hideDeleteRequestDialog: Boolean - get() = account.hideDeleteRequestDialog - - fun dontShowDeleteRequestDialog() { - viewModelScope.launch(Dispatchers.IO) { - account.setHideDeleteRequestDialog() - } - } - - val hideNIP24WarningDialog: Boolean - get() = account.hideNIP24WarningDialog - - fun dontShowNIP24WarningDialog() { - account.setHideNIP24WarningDialog() - } - - val hideBlockAlertDialog: Boolean - get() = account.hideBlockAlertDialog - - fun dontShowBlockAlertDialog() { - account.setHideBlockAlertDialog() - } - - fun hideSensitiveContent() { - account.updateShowSensitiveContent(false) - } - - fun disableContentWarnings() { - account.updateShowSensitiveContent(true) - } - - fun seeContentWarnings() { - account.updateShowSensitiveContent(null) - } - - fun defaultZapType(): LnZapEvent.ZapType { - return account.defaultZapType - } - - @Immutable - data class NoteComposeReportState( - val isAcceptable: Boolean = true, - val canPreview: Boolean = true, - val isHiddenAuthor: Boolean = false, - val relevantReports: ImmutableSet = persistentSetOf() + fun updateNotificationDots(newNotes: Set = emptySet()) { + val (value, elapsed) = measureTimedValue { notificationDots.update(newNotes, account) } + Log.d( + "Rendering Metrics", + "Notification Dots Calculation in $elapsed for ${newNotes.size} new notes", ) + } - fun isNoteAcceptable(note: Note, onReady: (NoteComposeReportState) -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - val isFromLoggedIn = note.author?.pubkeyHex == userProfile().pubkeyHex - val isFromLoggedInFollow = note.author?.let { userProfile().isFollowingCached(it) } ?: true - - if (isFromLoggedIn || isFromLoggedInFollow) { - // No need to process if from trusted people - onReady(NoteComposeReportState(true, true, false, persistentSetOf())) - } else if (note.author?.let { account.isHidden(it) } == true) { - onReady(NoteComposeReportState(false, false, true, persistentSetOf())) - } else { - val newCanPreview = !note.hasAnyReports() - - val newIsAcceptable = account.isAcceptable(note) - - if (newCanPreview && newIsAcceptable) { - // No need to process reports if nothing is wrong - onReady(NoteComposeReportState(true, true, false, persistentSetOf())) - } else { - val newRelevantReports = account.getRelevantReports(note) - - onReady( - NoteComposeReportState( - newIsAcceptable, - newCanPreview, - false, - newRelevantReports.toImmutableSet() - ) - ) - } - } + init { + Log.d("Init", "AccountViewModel") + collectorJob = + viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { newNotes -> + Log.d( + "Rendering Metrics", + "Notification Dots Calculation refresh ${this@AccountViewModel} for ${account.userProfile().toBestDisplayName()}", + ) + invalidateInsertData(newNotes) } + } + } + + override fun onCleared() { + Log.d("Init", "AccountViewModel onCleared") + collectorJob?.cancel() + super.onCleared() + } + + fun loadThumb( + context: Context, + thumbUri: String, + onReady: (Drawable?) -> Unit, + onError: (String?) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + try { + val request = ImageRequest.Builder(context).data(thumbUri).build() + val myCover = context.imageLoader.execute(request).drawable + onReady(myCover) + } catch (e: Exception) { + Log.e("VideoView", "Fail to load cover $thumbUri", e) + onError(e.message) + } } + } - fun unwrap(event: GiftWrapEvent, onReady: (Event) -> Unit) { - account.unwrap(event, onReady) + fun loadMentions( + mentions: ImmutableList, + onReady: (ImmutableList) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + val newSortedMentions = + mentions + .mapNotNull { LocalCache.checkGetOrCreateUser(it) } + .toSet() + .sortedBy { account.isFollowing(it) } + .toImmutableList() + + onReady(newSortedMentions) } - fun unseal(event: SealedGossipEvent, onReady: (Event) -> Unit) { - account.unseal(event, onReady) + } + + fun tryBoost( + baseNote: Note, + onMore: () -> Unit, + ) { + if (isWriteable()) { + if (hasBoosted(baseNote)) { + deleteBoostsTo(baseNote) + } else { + onMore() + } + } else { + toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_boost_posts, + ) } + } - fun show(user: User) { - viewModelScope.launch(Dispatchers.IO) { - account.showUser(user.pubkeyHex) - } - } - - fun hide(user: User) { - viewModelScope.launch(Dispatchers.IO) { - account.hideUser(user.pubkeyHex) - } - } - - fun hide(word: String) { - viewModelScope.launch(Dispatchers.IO) { - account.hideWord(word) - } - } - - fun showUser(pubkeyHex: String) { - viewModelScope.launch(Dispatchers.IO) { - account.showUser(pubkeyHex) - } - } - - fun createStatus(newStatus: String) { - viewModelScope.launch(Dispatchers.IO) { - account.createStatus(newStatus) - } - } - - fun updateStatus(it: ATag, newStatus: String) { - viewModelScope.launch(Dispatchers.IO) { - account.updateStatus(LocalCache.getOrCreateAddressableNote(it), newStatus) - } - } - - fun deleteStatus(it: ATag) { - viewModelScope.launch(Dispatchers.IO) { - account.deleteStatus(LocalCache.getOrCreateAddressableNote(it)) - } - } - - fun urlPreview(url: String, onResult: suspend (UrlPreviewState) -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - UrlCachedPreviewer.previewInfo(url, onResult) - } - } - - fun loadReactionTo(note: Note?, onNewReactionType: (String?) -> Unit) { - if (note == null) return - - viewModelScope.launch(Dispatchers.Default) { - onNewReactionType(note.getReactionBy(userProfile())) - } - } - - fun verifyNip05(userMetadata: UserMetadata, pubkeyHex: String, onResult: (Boolean) -> Unit) { - val nip05 = userMetadata.nip05?.ifBlank { null } ?: return - - viewModelScope.launch(Dispatchers.IO) { - Nip05NostrAddressVerifier().verifyNip05( - nip05, - onSuccess = { - // Marks user as verified - if (it == pubkeyHex) { - userMetadata.nip05Verified = true - userMetadata.nip05LastVerificationTime = TimeUtils.now() - - onResult(userMetadata.nip05Verified) - } else { - userMetadata.nip05Verified = false - userMetadata.nip05LastVerificationTime = 0 - - onResult(userMetadata.nip05Verified) - } - }, - onError = { - userMetadata.nip05LastVerificationTime = 0 - userMetadata.nip05Verified = false - - onResult(userMetadata.nip05Verified) - } - ) - } - } - - fun retrieveRelayDocument( - dirtyUrl: String, - onInfo: (RelayInformation) -> Unit, - onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit - ) { - viewModelScope.launch(Dispatchers.IO) { - Nip11CachedRetriever.loadRelayInfo(dirtyUrl, onInfo, onError) - } - } - - fun runOnIO(runOnIO: () -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - runOnIO() - } - } - - suspend fun checkGetOrCreateUser(key: HexKey): User? { - return LocalCache.checkGetOrCreateUser(key) - } - - override suspend fun getOrCreateUser(key: HexKey): User { - return LocalCache.getOrCreateUser(key) - } - - fun checkGetOrCreateUser(key: HexKey, onResult: (User?) -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - onResult(checkGetOrCreateUser(key)) - } - } - - fun getUserIfExists(hex: HexKey): User? { - return LocalCache.getUserIfExists(hex) - } - - private suspend fun checkGetOrCreateNote(key: HexKey): Note? { - return LocalCache.checkGetOrCreateNote(key) - } - - override suspend fun getOrCreateNote(key: HexKey): Note { - return LocalCache.getOrCreateNote(key) - } - - fun checkGetOrCreateNote(key: HexKey, onResult: (Note?) -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - onResult(checkGetOrCreateNote(key)) - } - } - - fun getNoteIfExists(hex: HexKey): Note? { - return LocalCache.getNoteIfExists(hex) - } - - override suspend fun checkGetOrCreateAddressableNote(key: HexKey): AddressableNote? { - return LocalCache.checkGetOrCreateAddressableNote(key) - } - - fun checkGetOrCreateAddressableNote(key: HexKey, onResult: (AddressableNote?) -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - onResult(checkGetOrCreateAddressableNote(key)) - } - } - - private suspend fun getOrCreateAddressableNote(key: ATag): AddressableNote? { - return LocalCache.getOrCreateAddressableNote(key) - } - - fun getOrCreateAddressableNote(key: ATag, onResult: (AddressableNote?) -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - onResult(getOrCreateAddressableNote(key)) - } - } - - fun getAddressableNoteIfExists(key: String): AddressableNote? { - return LocalCache.addressables[key] - } - - fun findStatusesForUser(myUser: User, onResult: (ImmutableList) -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - onResult(LocalCache.findStatusesForUser(myUser)) - } - } - - private suspend fun checkGetOrCreateChannel(key: HexKey): Channel? { - return LocalCache.checkGetOrCreateChannel(key) - } - - fun checkGetOrCreateChannel(key: HexKey, onResult: (Channel?) -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - onResult(checkGetOrCreateChannel(key)) - } - } - - fun getChannelIfExists(hex: HexKey): Channel? { - return LocalCache.getChannelIfExists(hex) - } - - fun loadParticipants(participants: List, onReady: (ImmutableList>) -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - val participantUsers = participants.mapNotNull { part -> - checkGetOrCreateUser(part.key)?.let { - Pair( - part, - it - ) - } - }.toImmutableList() - - onReady(participantUsers) - } - } - - fun loadUsers(hexList: List, onReady: (ImmutableList) -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - onReady( - hexList.mapNotNull { hex -> - checkGetOrCreateUser(hex) - }.sortedBy { account.isFollowing(it) }.reversed().toImmutableList() - ) - } - } - - fun returnNIP19References(content: String, tags: ImmutableListOfLists?, onNewReferences: (List) -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - onNewReferences(MarkdownParser().returnNIP19References(content, tags)) - } - } - - fun returnMarkdownWithSpecialContent(content: String, tags: ImmutableListOfLists?, onNewContent: (String) -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - onNewContent(MarkdownParser().returnMarkdownWithSpecialContent(content, tags)) - } - } - - fun parseNIP19(str: String, onNote: (LoadedBechLink) -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - Nip19.uriToRoute(str)?.let { - var returningNote: Note? = null - if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) { - LocalCache.checkGetOrCreateNote(it.hex)?.let { note -> - returningNote = note - } - } - - onNote(LoadedBechLink(returningNote, it)) - } - } - } - - fun checkIsOnline(media: String?, onDone: (Boolean) -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - onDone(OnlineChecker.isOnline(media)) - } - } - - suspend fun refreshMarkAsReadObservers() { - updateNotificationDots() - accountMarkAsReadUpdates.value++ - } - - fun loadAndMarkAsRead(routeForLastRead: String, createdAt: Long?, onIsNew: (Boolean) -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - val lastTime = account.loadLastRead(routeForLastRead) - - if (createdAt != null) { - if (account.markAsRead(routeForLastRead, createdAt)) { - refreshMarkAsReadObservers() - } - onIsNew(createdAt > lastTime) - } else { - onIsNew(false) - } - } - } - - fun markAllAsRead(notes: ImmutableList, onDone: () -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - var atLeastOne = false - - for (note in notes) { - note.event?.let { noteEvent -> - val channelHex = note.channelHex() - val route = if (channelHex != null) { - "Channel/$channelHex" - } else if (note.event is ChatroomKeyable) { - val withKey = - (note.event as ChatroomKeyable).chatroomKey(userProfile().pubkeyHex) - "Room/${withKey.hashCode()}" - } else { - null - } - - route?.let { - if (account.markAsRead(route, noteEvent.createdAt())) { - atLeastOne = true - } - } - } - } - - if (atLeastOne) { - refreshMarkAsReadObservers() - } - - onDone() - } - } - - fun createChatRoomFor(user: User, then: (Int) -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - val withKey = ChatroomKey(persistentSetOf(user.pubkeyHex)) - account.userProfile().createChatroom(withKey) - then(withKey.hashCode()) - } - } - - fun enableTor( - checked: Boolean, - portNumber: MutableState - ) { - viewModelScope.launch(Dispatchers.IO) { - account.proxyPort = portNumber.value.toInt() - account.proxy = HttpClient.initProxy(checked, "127.0.0.1", account.proxyPort) - account.saveable.invalidateData() - serviceManager?.forceRestart() - } - } - - class Factory(val account: Account, val settings: SettingsState) : ViewModelProvider.Factory { - override fun create(modelClass: Class): AccountViewModel { - return AccountViewModel(account, settings) as AccountViewModel - } - } - - private var collectorJob: Job? = null - val notificationDots = HasNotificationDot(bottomNavigationItems) - private val bundlerInsert = BundledInsert>(3000, Dispatchers.IO) - - fun invalidateInsertData(newItems: Set) { - bundlerInsert.invalidateList(newItems) { - updateNotificationDots(it.flatten().toSet()) - } - } - - fun updateNotificationDots(newNotes: Set = emptySet()) { - val (value, elapsed) = measureTimedValue { - notificationDots.update(newNotes, account) - } - Log.d("Rendering Metrics", "Notification Dots Calculation in $elapsed for ${newNotes.size} new notes") - } - - init { - Log.d("Init", "AccountViewModel") - collectorJob = viewModelScope.launch(Dispatchers.IO) { - LocalCache.live.newEventBundles.collect { newNotes -> - Log.d("Rendering Metrics", "Notification Dots Calculation refresh ${this@AccountViewModel} for ${account.userProfile().toBestDisplayName()}") - invalidateInsertData(newNotes) - } - } - } - - override fun onCleared() { - Log.d("Init", "AccountViewModel onCleared") - collectorJob?.cancel() - super.onCleared() - } - - fun loadThumb( - context: Context, - thumbUri: String, - onReady: (Drawable?) -> Unit, - onError: (String?) -> Unit - ) { - viewModelScope.launch(Dispatchers.IO) { - try { - val request = ImageRequest.Builder(context).data(thumbUri).build() - val myCover = context.imageLoader.execute(request).drawable - onReady(myCover) - } catch (e: Exception) { - Log.e("VideoView", "Fail to load cover $thumbUri", e) - onError(e.message) - } - } - } - - fun loadMentions(mentions: ImmutableList, onReady: (ImmutableList) -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - val newSortedMentions = mentions - .mapNotNull { LocalCache.checkGetOrCreateUser(it) } - .toSet() - .sortedBy { account.isFollowing(it) } - .toImmutableList() - - onReady(newSortedMentions) - } - } - - fun tryBoost(baseNote: Note, onMore: () -> Unit) { - if (isWriteable()) { - if (hasBoosted(baseNote)) { - deleteBoostsTo(baseNote) - } else { - onMore() - } - } else { - toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_boost_posts - ) - } - } - - fun dismissPaymentRequest(request: Account.PaymentRequest) { - viewModelScope.launch(Dispatchers.IO) { - account.dismissPaymentRequest(request) - } - } - - fun meltCashu( - token: CashuToken, - context: Context, - onDone: (String, String) -> Unit - ) { - val lud16 = account.userProfile().info?.lud16 - if (lud16 != null) { - viewModelScope.launch(Dispatchers.IO) { - CashuProcessor().melt( - token, - lud16, - onSuccess = { title, message -> - onDone(title, message) - }, - onError = { title, message -> - onDone(title, message) - }, - context - ) - } - } else { - onDone( - context.getString(R.string.no_lightning_address_set), - context.getString(R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats, account.userProfile().toBestDisplayName()) - ) - } + fun dismissPaymentRequest(request: Account.PaymentRequest) { + viewModelScope.launch(Dispatchers.IO) { account.dismissPaymentRequest(request) } + } + + fun meltCashu( + token: CashuToken, + context: Context, + onDone: (String, String) -> Unit, + ) { + val lud16 = account.userProfile().info?.lud16 + if (lud16 != null) { + viewModelScope.launch(Dispatchers.IO) { + CashuProcessor() + .melt( + token, + lud16, + onSuccess = { title, message -> onDone(title, message) }, + onError = { title, message -> onDone(title, message) }, + context, + ) + } + } else { + onDone( + context.getString(R.string.no_lightning_address_set), + context.getString( + R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats, + account.userProfile().toBestDisplayName(), + ), + ) } + } } class HasNotificationDot(bottomNavigationItems: ImmutableList) { - val hasNewItems = bottomNavigationItems.associateWith { MutableStateFlow(false) } + val hasNewItems = bottomNavigationItems.associateWith { MutableStateFlow(false) } - fun update(newNotes: Set, account: Account) { - checkNotInMainThread() + fun update( + newNotes: Set, + account: Account, + ) { + checkNotInMainThread() - hasNewItems.forEach { - val (value, elapsed) = measureTimedValue { - val newResult = it.key.hasNewItems(account, newNotes) - if (newResult != it.value.value) { - it.value.value = newResult - } - } - Log.d("Rendering Metrics", "Notification Dots Calculation for ${it.key.route} in $elapsed for ${newNotes.size} new notes") + hasNewItems.forEach { + val (value, elapsed) = + measureTimedValue { + val newResult = it.key.hasNewItems(account, newNotes) + if (newResult != it.value.value) { + it.value.value = newResult + } } + Log.d( + "Rendering Metrics", + "Notification Dots Calculation for ${it.key.route} in $elapsed for ${newNotes.size} new notes", + ) } + } } -@Immutable -data class LoadedBechLink(val baseNote: Note?, val nip19: Nip19.Return) +@Immutable data class LoadedBechLink(val baseNote: Note?, val nip19: Nip19.Return) public fun allOrNothingSigningOperations( - remainingTos: List, - runRequestFor: (T, (K) -> Unit) -> Unit, - output: MutableList = mutableListOf(), - onReady: (List) -> Unit + remainingTos: List, + runRequestFor: (T, (K) -> Unit) -> Unit, + output: MutableList = mutableListOf(), + onReady: (List) -> Unit, ) { - if (remainingTos.isEmpty()) { - onReady(output) - return - } + if (remainingTos.isEmpty()) { + onReady(output) + return + } - val next = remainingTos.first() + val next = remainingTos.first() - runRequestFor(next) { result: K -> - output.add(result) - allOrNothingSigningOperations(remainingTos.minus(next), runRequestFor, output, onReady) - } + runRequestFor(next) { result: K -> + output.add(result) + allOrNothingSigningOperations(remainingTos.minus(next), runRequestFor, output, onReady) + } } public suspend fun collectSuccessfulSigningOperations( - operationsInput: List, - runRequestFor: (T, (K) -> Unit) -> Unit, - output: MutableMap = mutableMapOf(), - onReady: (MutableMap) -> Unit + operationsInput: List, + runRequestFor: (T, (K) -> Unit) -> Unit, + output: MutableMap = mutableMapOf(), + onReady: (MutableMap) -> Unit, ) { - if (operationsInput.isEmpty()) { - onReady(output) - return - } - - for (input in operationsInput) { - // runs in sequence to avoid overcrowding Amber. - val result = withTimeoutOrNull(100) { - suspendCancellableCoroutine { continuation -> - runRequestFor(input) { result: K -> - continuation.resume(result) - } - } - } - if (result != null) { - output[input] = result - } - } - + if (operationsInput.isEmpty()) { onReady(output) + return + } + + for (input in operationsInput) { + // runs in sequence to avoid overcrowding Amber. + val result = + withTimeoutOrNull(100) { + suspendCancellableCoroutine { continuation -> + runRequestFor(input) { result: K -> continuation.resume(result) } + } + } + if (result != null) { + output[input] = result + } + } + + onReady(output) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt index 7d3da7072..b8ecc2f3d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen.loggedIn import androidx.compose.foundation.ExperimentalFoundationApi @@ -26,64 +46,67 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable -fun BookmarkListScreen(accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val publicFeedViewModel: NostrBookmarkPublicFeedViewModel = viewModel( - key = "NotificationViewModel", - factory = NostrBookmarkPublicFeedViewModel.Factory(accountViewModel.account) +fun BookmarkListScreen( + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val publicFeedViewModel: NostrBookmarkPublicFeedViewModel = + viewModel( + key = "NotificationViewModel", + factory = NostrBookmarkPublicFeedViewModel.Factory(accountViewModel.account), ) - val privateFeedViewModel: NostrBookmarkPrivateFeedViewModel = viewModel( - key = "NotificationViewModel", - factory = NostrBookmarkPrivateFeedViewModel.Factory(accountViewModel.account) + val privateFeedViewModel: NostrBookmarkPrivateFeedViewModel = + viewModel( + key = "NotificationViewModel", + factory = NostrBookmarkPrivateFeedViewModel.Factory(accountViewModel.account), ) - val userState by accountViewModel.account.decryptBookmarks.observeAsState() + val userState by accountViewModel.account.decryptBookmarks.observeAsState() - LaunchedEffect(userState) { - publicFeedViewModel.invalidateData() - privateFeedViewModel.invalidateData() + LaunchedEffect(userState) { + publicFeedViewModel.invalidateData() + privateFeedViewModel.invalidateData() + } + + Column(Modifier.fillMaxHeight()) { + val pagerState = rememberPagerState { 2 } + val coroutineScope = rememberCoroutineScope() + + TabRow( + containerColor = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + selectedTabIndex = pagerState.currentPage, + modifier = TabRowHeight, + ) { + Tab( + selected = pagerState.currentPage == 0, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } }, + text = { Text(text = stringResource(R.string.private_bookmarks)) }, + ) + Tab( + selected = pagerState.currentPage == 1, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } }, + text = { Text(text = stringResource(R.string.public_bookmarks)) }, + ) } - - Column(Modifier.fillMaxHeight()) { - val pagerState = rememberPagerState() { 2 } - val coroutineScope = rememberCoroutineScope() - - TabRow( - containerColor = MaterialTheme.colorScheme.background, - contentColor = MaterialTheme.colorScheme.onBackground, - selectedTabIndex = pagerState.currentPage, - modifier = TabRowHeight - ) { - Tab( - selected = pagerState.currentPage == 0, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } }, - text = { - Text(text = stringResource(R.string.private_bookmarks)) - } - ) - Tab( - selected = pagerState.currentPage == 1, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } }, - text = { - Text(text = stringResource(R.string.public_bookmarks)) - } - ) - } - HorizontalPager(state = pagerState) { page -> - when (page) { - 0 -> RefresheableFeedView( - privateFeedViewModel, - null, - accountViewModel = accountViewModel, - nav = nav - ) - 1 -> RefresheableFeedView( - publicFeedViewModel, - null, - accountViewModel = accountViewModel, - nav = nav - ) - } - } + HorizontalPager(state = pagerState) { page -> + when (page) { + 0 -> + RefresheableFeedView( + privateFeedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) + 1 -> + RefresheableFeedView( + publicFeedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) + } } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt index 1e05a306f..58776be22 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.widget.Toast @@ -137,1059 +157,1042 @@ import com.vitorpamplona.quartz.events.EmptyTagList import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE import com.vitorpamplona.quartz.events.Participant import com.vitorpamplona.quartz.events.toImmutableListOfLists +import java.util.Locale import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.util.Locale @Composable fun ChannelScreen( - channelId: String?, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + channelId: String?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - if (channelId == null) return + if (channelId == null) return - LoadChannel(channelId, accountViewModel) { - PrepareChannelViewModels( - baseChannel = it, - accountViewModel = accountViewModel, - nav = nav - ) + LoadChannel(channelId, accountViewModel) { + PrepareChannelViewModels( + baseChannel = it, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@Composable +fun PrepareChannelViewModels( + baseChannel: Channel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val feedViewModel: NostrChannelFeedViewModel = + viewModel( + key = baseChannel.idHex + "ChannelFeedViewModel", + factory = + NostrChannelFeedViewModel.Factory( + baseChannel, + accountViewModel.account, + ), + ) + + val channelScreenModel: NewPostViewModel = viewModel() + channelScreenModel.accountViewModel = accountViewModel + channelScreenModel.account = accountViewModel.account + + ChannelScreen( + channel = baseChannel, + feedViewModel = feedViewModel, + newPostModel = channelScreenModel, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +fun ChannelScreen( + channel: Channel, + feedViewModel: NostrChannelFeedViewModel, + newPostModel: NewPostViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val context = LocalContext.current + + NostrChannelDataSource.loadMessagesBetween(accountViewModel.account, channel) + + val lifeCycleOwner = LocalLifecycleOwner.current + + LaunchedEffect(Unit) { + launch(Dispatchers.IO) { + newPostModel.imageUploadingError.collect { error -> + withContext(Dispatchers.Main) { Toast.makeText(context, error, Toast.LENGTH_SHORT).show() } + } } -} - -@Composable -fun PrepareChannelViewModels(baseChannel: Channel, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val feedViewModel: NostrChannelFeedViewModel = viewModel( - key = baseChannel.idHex + "ChannelFeedViewModel", - factory = NostrChannelFeedViewModel.Factory( - baseChannel, - accountViewModel.account - ) - ) - - val channelScreenModel: NewPostViewModel = viewModel() - channelScreenModel.accountViewModel = accountViewModel - channelScreenModel.account = accountViewModel.account - - ChannelScreen( - channel = baseChannel, - feedViewModel = feedViewModel, - newPostModel = channelScreenModel, - accountViewModel = accountViewModel, - nav = nav - ) -} - -@Composable -fun ChannelScreen( - channel: Channel, - feedViewModel: NostrChannelFeedViewModel, - newPostModel: NewPostViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit -) { - val context = LocalContext.current + } + DisposableEffect(accountViewModel) { NostrChannelDataSource.loadMessagesBetween(accountViewModel.account, channel) + NostrChannelDataSource.start() + feedViewModel.invalidateData(true) - val lifeCycleOwner = LocalLifecycleOwner.current - - LaunchedEffect(Unit) { - launch(Dispatchers.IO) { - newPostModel.imageUploadingError.collect { error -> - withContext(Dispatchers.Main) { - Toast.makeText(context, error, Toast.LENGTH_SHORT).show() - } - } - } + onDispose { + NostrChannelDataSource.clear() + NostrChannelDataSource.stop() } + } - DisposableEffect(accountViewModel) { - NostrChannelDataSource.loadMessagesBetween(accountViewModel.account, channel) + DisposableEffect(lifeCycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Channel Start") NostrChannelDataSource.start() feedViewModel.invalidateData(true) + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Channel Stop") - onDispose { - NostrChannelDataSource.clear() - NostrChannelDataSource.stop() - } + NostrChannelDataSource.clear() + NostrChannelDataSource.stop() + } } - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Channel Start") - NostrChannelDataSource.start() - feedViewModel.invalidateData(true) - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Channel Stop") + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } + } - NostrChannelDataSource.clear() - NostrChannelDataSource.stop() - } - } + Column(Modifier.fillMaxHeight()) { + val replyTo = remember { mutableStateOf(null) } - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { - lifeCycleOwner.lifecycle.removeObserver(observer) - } + Column( + modifier = remember { Modifier.fillMaxHeight().padding(vertical = 0.dp).weight(1f, true) }, + ) { + if (channel is LiveActivitiesChannel) { + ShowVideoStreaming(channel, accountViewModel) + } + RefreshingChatroomFeedView( + viewModel = feedViewModel, + accountViewModel = accountViewModel, + nav = nav, + routeForLastRead = "Channel/${channel.idHex}", + onWantsToReply = { replyTo.value = it }, + ) } - Column(Modifier.fillMaxHeight()) { - val replyTo = remember { mutableStateOf(null) } + Spacer(modifier = DoubleVertSpacer) - Column( - modifier = remember { - Modifier - .fillMaxHeight() - .padding(vertical = 0.dp) - .weight(1f, true) - } - ) { - if (channel is LiveActivitiesChannel) { - ShowVideoStreaming(channel, accountViewModel) - } - RefreshingChatroomFeedView( - viewModel = feedViewModel, - accountViewModel = accountViewModel, - nav = nav, - routeForLastRead = "Channel/${channel.idHex}", - onWantsToReply = { - replyTo.value = it - } - ) - } - - Spacer(modifier = DoubleVertSpacer) - - replyTo.value?.let { - DisplayReplyingToNote(it, accountViewModel, nav) { - replyTo.value = null - } - } - - val scope = rememberCoroutineScope() - - // LAST ROW - EditFieldRow(newPostModel, isPrivate = false, accountViewModel = accountViewModel) { - scope.launch(Dispatchers.IO) { - val tagger = NewMessageTagger( - message = newPostModel.message.text, - pTags = listOfNotNull(replyTo.value?.author), - eTags = listOfNotNull(replyTo.value), - channelHex = channel.idHex, - dao = accountViewModel - ) - tagger.run() - if (channel is PublicChatChannel) { - accountViewModel.account.sendChannelMessage( - message = tagger.message, - toChannel = channel.idHex, - replyTo = tagger.eTags, - mentions = tagger.pTags, - wantsToMarkAsSensitive = false - ) - } else if (channel is LiveActivitiesChannel) { - accountViewModel.account.sendLiveMessage( - message = tagger.message, - toChannel = channel.address, - replyTo = tagger.eTags, - mentions = tagger.pTags, - wantsToMarkAsSensitive = false - ) - } - newPostModel.message = TextFieldValue("") - replyTo.value = null - feedViewModel.sendToTop() - } + replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { replyTo.value = null } } + + val scope = rememberCoroutineScope() + + // LAST ROW + EditFieldRow(newPostModel, isPrivate = false, accountViewModel = accountViewModel) { + scope.launch(Dispatchers.IO) { + val tagger = + NewMessageTagger( + message = newPostModel.message.text, + pTags = listOfNotNull(replyTo.value?.author), + eTags = listOfNotNull(replyTo.value), + channelHex = channel.idHex, + dao = accountViewModel, + ) + tagger.run() + if (channel is PublicChatChannel) { + accountViewModel.account.sendChannelMessage( + message = tagger.message, + toChannel = channel.idHex, + replyTo = tagger.eTags, + mentions = tagger.pTags, + wantsToMarkAsSensitive = false, + ) + } else if (channel is LiveActivitiesChannel) { + accountViewModel.account.sendLiveMessage( + message = tagger.message, + toChannel = channel.address, + replyTo = tagger.eTags, + mentions = tagger.pTags, + wantsToMarkAsSensitive = false, + ) } + newPostModel.message = TextFieldValue("") + replyTo.value = null + feedViewModel.sendToTop() + } } + } } @Composable fun DisplayReplyingToNote( - replyingNote: Note?, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - onCancel: () -> Unit + replyingNote: Note?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + onCancel: () -> Unit, ) { - Row( - Modifier - .padding(horizontal = 10.dp) - .animateContentSize(), - verticalAlignment = Alignment.CenterVertically - ) { - if (replyingNote != null) { - Column(remember { Modifier.weight(1f) }) { - ChatroomMessageCompose( - baseNote = replyingNote, - null, - innerQuote = true, - accountViewModel = accountViewModel, - nav = nav, - onWantsToReply = {} - ) - } + Row( + Modifier.padding(horizontal = 10.dp).animateContentSize(), + verticalAlignment = Alignment.CenterVertically, + ) { + if (replyingNote != null) { + Column(remember { Modifier.weight(1f) }) { + ChatroomMessageCompose( + baseNote = replyingNote, + null, + innerQuote = true, + accountViewModel = accountViewModel, + nav = nav, + onWantsToReply = {}, + ) + } - Column(Modifier.padding(end = 10.dp)) { - IconButton( - modifier = Modifier.size(30.dp), - onClick = onCancel - ) { - Icon( - imageVector = Icons.Default.Cancel, - null, - modifier = Modifier - .padding(end = 5.dp) - .size(30.dp), - tint = MaterialTheme.colorScheme.placeholderText - ) - } - } + Column(Modifier.padding(end = 10.dp)) { + IconButton( + modifier = Modifier.size(30.dp), + onClick = onCancel, + ) { + Icon( + imageVector = Icons.Default.Cancel, + null, + modifier = Modifier.padding(end = 5.dp).size(30.dp), + tint = MaterialTheme.colorScheme.placeholderText, + ) } + } } + } } @Composable fun EditFieldRow( - channelScreenModel: NewPostViewModel, - isPrivate: Boolean, - accountViewModel: AccountViewModel, - onSendNewMessage: () -> Unit + channelScreenModel: NewPostViewModel, + isPrivate: Boolean, + accountViewModel: AccountViewModel, + onSendNewMessage: () -> Unit, ) { - Row( - modifier = EditFieldModifier, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - val context = LocalContext.current + Row( + modifier = EditFieldModifier, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + val context = LocalContext.current - MyTextField( - value = channelScreenModel.message, - onValueChange = { - channelScreenModel.updateMessage(it) - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences - ), - shape = EditFieldBorder, - modifier = Modifier.weight(1f, true), - placeholder = { - Text( - text = stringResource(R.string.reply_here), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - trailingIcon = { - ThinSendButton( - isActive = channelScreenModel.message.text.isNotBlank() && !channelScreenModel.isUploadingImage, - modifier = EditFieldTrailingIconModifier - ) { - onSendNewMessage() - } - }, - leadingIcon = { - UploadFromGallery( - isUploading = channelScreenModel.isUploadingImage, - tint = MaterialTheme.colorScheme.placeholderText, - modifier = EditFieldLeadingIconModifier - ) { - channelScreenModel.upload( - galleryUri = it, - alt = null, - sensitiveContent = false, - server = ServerOption(accountViewModel.account.defaultFileServer, false), - context = context - ) - } - }, - colors = TextFieldDefaults.colors( - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent - ) + MyTextField( + value = channelScreenModel.message, + onValueChange = { channelScreenModel.updateMessage(it) }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + shape = EditFieldBorder, + modifier = Modifier.weight(1f, true), + placeholder = { + Text( + text = stringResource(R.string.reply_here), + color = MaterialTheme.colorScheme.placeholderText, ) - } + }, + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + trailingIcon = { + ThinSendButton( + isActive = + channelScreenModel.message.text.isNotBlank() && !channelScreenModel.isUploadingImage, + modifier = EditFieldTrailingIconModifier, + ) { + onSendNewMessage() + } + }, + leadingIcon = { + UploadFromGallery( + isUploading = channelScreenModel.isUploadingImage, + tint = MaterialTheme.colorScheme.placeholderText, + modifier = EditFieldLeadingIconModifier, + ) { + channelScreenModel.upload( + galleryUri = it, + alt = null, + sensitiveContent = false, + server = ServerOption(accountViewModel.account.defaultFileServer, false), + context = context, + ) + } + }, + colors = + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + ) + } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun MyTextField( - value: TextFieldValue, - onValueChange: (TextFieldValue) -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - readOnly: Boolean = false, - textStyle: TextStyle = LocalTextStyle.current, - label: @Composable (() -> Unit)? = null, - placeholder: @Composable (() -> Unit)? = null, - leadingIcon: @Composable (() -> Unit)? = null, - trailingIcon: @Composable (() -> Unit)? = null, - prefix: @Composable (() -> Unit)? = null, - suffix: @Composable (() -> Unit)? = null, - supportingText: @Composable (() -> Unit)? = null, - isError: Boolean = false, - visualTransformation: VisualTransformation = VisualTransformation.None, - keyboardOptions: KeyboardOptions = KeyboardOptions.Default, - keyboardActions: KeyboardActions = KeyboardActions(), - singleLine: Boolean = false, - maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, - minLines: Int = 1, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - shape: Shape = TextFieldDefaults.shape, - colors: TextFieldColors = TextFieldDefaults.colors() + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + isError: Boolean = false, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions(), + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + shape: Shape = TextFieldDefaults.shape, + colors: TextFieldColors = TextFieldDefaults.colors(), ) { - // COPIED FROM TEXT FIELD - // The only change is the contentPadding below + // COPIED FROM TEXT FIELD + // The only change is the contentPadding below - val textColor = textStyle.color.takeOrElse { - val focused by interactionSource.collectIsFocusedAsState() + val textColor = + textStyle.color.takeOrElse { + val focused by interactionSource.collectIsFocusedAsState() - val targetValue = when { - !enabled -> MaterialTheme.colorScheme.placeholderText - isError -> MaterialTheme.colorScheme.onSurface - focused -> MaterialTheme.colorScheme.onSurface - else -> MaterialTheme.colorScheme.onSurface + val targetValue = + when { + !enabled -> MaterialTheme.colorScheme.placeholderText + isError -> MaterialTheme.colorScheme.onSurface + focused -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurface } - rememberUpdatedState(targetValue).value + rememberUpdatedState(targetValue).value } - val mergedTextStyle = textStyle.merge(TextStyle(color = textColor)) + val mergedTextStyle = textStyle.merge(TextStyle(color = textColor)) - CompositionLocalProvider(LocalTextSelectionColors provides LocalTextSelectionColors.current) { - BasicTextField( - value = value, - modifier = modifier - .defaultMinSize( - minWidth = TextFieldDefaults.MinWidth, - minHeight = 36.dp - ), - onValueChange = onValueChange, - enabled = enabled, - readOnly = readOnly, - textStyle = mergedTextStyle, - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + CompositionLocalProvider(LocalTextSelectionColors provides LocalTextSelectionColors.current) { + BasicTextField( + value = value, + modifier = + modifier.defaultMinSize( + minWidth = TextFieldDefaults.MinWidth, + minHeight = 36.dp, + ), + onValueChange = onValueChange, + enabled = enabled, + readOnly = readOnly, + textStyle = mergedTextStyle, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + interactionSource = interactionSource, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + decorationBox = + @Composable { innerTextField -> + TextFieldDefaults.DecorationBox( + value = value.text, visualTransformation = visualTransformation, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - interactionSource = interactionSource, + innerTextField = innerTextField, + placeholder = placeholder, + label = label, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + prefix = prefix, + suffix = suffix, + supportingText = supportingText, + shape = shape, singleLine = singleLine, - maxLines = maxLines, - minLines = minLines, - decorationBox = @Composable { innerTextField -> - TextFieldDefaults.DecorationBox( - value = value.text, - visualTransformation = visualTransformation, - innerTextField = innerTextField, - placeholder = placeholder, - label = label, - leadingIcon = leadingIcon, - trailingIcon = trailingIcon, - prefix = prefix, - suffix = suffix, - supportingText = supportingText, - shape = shape, - singleLine = singleLine, - enabled = enabled, - isError = isError, - interactionSource = interactionSource, - colors = colors, - contentPadding = TextFieldDefaults.contentPaddingWithoutLabel( - start = 10.dp, - top = 12.dp, - end = 10.dp, - bottom = 12.dp - ) - ) - } - ) - } + enabled = enabled, + isError = isError, + interactionSource = interactionSource, + colors = colors, + contentPadding = + TextFieldDefaults.contentPaddingWithoutLabel( + start = 10.dp, + top = 12.dp, + end = 10.dp, + bottom = 12.dp, + ), + ) + }, + ) + } } @Composable fun ChannelHeader( - channelNote: Note, - showVideo: Boolean, - showBottomDiviser: Boolean, - sendToChannel: Boolean, - modifier: Modifier = StdPadding, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + channelNote: Note, + showVideo: Boolean, + showBottomDiviser: Boolean, + sendToChannel: Boolean, + modifier: Modifier = StdPadding, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val channelHex by remember { - derivedStateOf { - channelNote.channelHex() - } - } - channelHex?.let { - ChannelHeader( - channelHex = it, - showVideo = showVideo, - showBottomDiviser = showBottomDiviser, - sendToChannel = sendToChannel, - accountViewModel = accountViewModel, - nav = nav - ) - } + val channelHex by remember { derivedStateOf { channelNote.channelHex() } } + channelHex?.let { + ChannelHeader( + channelHex = it, + showVideo = showVideo, + showBottomDiviser = showBottomDiviser, + sendToChannel = sendToChannel, + accountViewModel = accountViewModel, + nav = nav, + ) + } } @Composable fun ChannelHeader( - channelHex: String, - showVideo: Boolean, - showBottomDiviser: Boolean, - showFlag: Boolean = true, - sendToChannel: Boolean = false, - modifier: Modifier = StdPadding, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + channelHex: String, + showVideo: Boolean, + showBottomDiviser: Boolean, + showFlag: Boolean = true, + sendToChannel: Boolean = false, + modifier: Modifier = StdPadding, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - LoadChannel(channelHex, accountViewModel) { - ChannelHeader( - it, - showVideo, - showBottomDiviser, - showFlag, - sendToChannel, - modifier, - accountViewModel, - nav - ) - } + LoadChannel(channelHex, accountViewModel) { + ChannelHeader( + it, + showVideo, + showBottomDiviser, + showFlag, + sendToChannel, + modifier, + accountViewModel, + nav, + ) + } } @Composable fun ChannelHeader( - baseChannel: Channel, - showVideo: Boolean, - showBottomDiviser: Boolean, - showFlag: Boolean = true, - sendToChannel: Boolean = false, - modifier: Modifier = StdPadding, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseChannel: Channel, + showVideo: Boolean, + showBottomDiviser: Boolean, + showFlag: Boolean = true, + sendToChannel: Boolean = false, + modifier: Modifier = StdPadding, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - Column(Modifier.fillMaxWidth()) { - if (showVideo && baseChannel is LiveActivitiesChannel) { - ShowVideoStreaming(baseChannel, accountViewModel) - } - - val expanded = remember { mutableStateOf(false) } - - Column( - verticalArrangement = Arrangement.Center, - modifier = modifier.clickable { - if (sendToChannel) { - nav(routeFor(baseChannel)) - } else { - expanded.value = !expanded.value - } - } - ) { - ShortChannelHeader( - baseChannel = baseChannel, - accountViewModel = accountViewModel, - nav = nav, - showFlag = showFlag - ) - - if (expanded.value) { - LongChannelHeader(baseChannel = baseChannel, accountViewModel = accountViewModel, nav = nav) - } - } - - if (showBottomDiviser) { - Divider( - thickness = DividerThickness - ) - } + Column(Modifier.fillMaxWidth()) { + if (showVideo && baseChannel is LiveActivitiesChannel) { + ShowVideoStreaming(baseChannel, accountViewModel) } + + val expanded = remember { mutableStateOf(false) } + + Column( + verticalArrangement = Arrangement.Center, + modifier = + modifier.clickable { + if (sendToChannel) { + nav(routeFor(baseChannel)) + } else { + expanded.value = !expanded.value + } + }, + ) { + ShortChannelHeader( + baseChannel = baseChannel, + accountViewModel = accountViewModel, + nav = nav, + showFlag = showFlag, + ) + + if (expanded.value) { + LongChannelHeader(baseChannel = baseChannel, accountViewModel = accountViewModel, nav = nav) + } + } + + if (showBottomDiviser) { + Divider( + thickness = DividerThickness, + ) + } + } } @Composable fun ShowVideoStreaming( - baseChannel: LiveActivitiesChannel, - accountViewModel: AccountViewModel + baseChannel: LiveActivitiesChannel, + accountViewModel: AccountViewModel, ) { - baseChannel.info?.let { - SensitivityWarning( - event = it, - accountViewModel = accountViewModel - ) { - val streamingInfo by baseChannel.live.map { - val activity = it.channel as? LiveActivitiesChannel - activity?.info - }.distinctUntilChanged().observeAsState(baseChannel.info) + baseChannel.info?.let { + SensitivityWarning( + event = it, + accountViewModel = accountViewModel, + ) { + val streamingInfo by + baseChannel.live + .map { + val activity = it.channel as? LiveActivitiesChannel + activity?.info + } + .distinctUntilChanged() + .observeAsState(baseChannel.info) - streamingInfo?.let { event -> - val url = remember(streamingInfo) { - event.streaming() - } - val artworkUri = remember(streamingInfo) { - event.image() - } - val title = remember(streamingInfo) { - baseChannel.toBestDisplayName() + streamingInfo?.let { event -> + val url = remember(streamingInfo) { event.streaming() } + val artworkUri = remember(streamingInfo) { event.image() } + val title = remember(streamingInfo) { baseChannel.toBestDisplayName() } + + val author = remember(streamingInfo) { baseChannel.creatorName() } + + url?.let { + CrossfadeCheckIfUrlIsOnline(url, accountViewModel) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = remember { Modifier.heightIn(max = 300.dp) }, + ) { + val zoomableUrlVideo = + remember(it) { + ZoomableUrlVideo( + url = url, + description = title, + artworkUri = artworkUri, + authorName = author, + uri = event.toNostrUri(), + ) } - val author = remember(streamingInfo) { - baseChannel.creatorName() - } - - url?.let { - CrossfadeCheckIfUrlIsOnline(url, accountViewModel) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = remember { Modifier.heightIn(max = 300.dp) } - ) { - val zoomableUrlVideo = remember(it) { - ZoomableUrlVideo( - url = url, - description = title, - artworkUri = artworkUri, - authorName = author, - uri = event.toNostrUri() - ) - } - - ZoomableContentView( - content = zoomableUrlVideo, - roundedCorner = false, - accountViewModel = accountViewModel - ) - } - } - } + ZoomableContentView( + content = zoomableUrlVideo, + roundedCorner = false, + accountViewModel = accountViewModel, + ) } + } } + } } + } } @Composable fun ShortChannelHeader( - baseChannel: Channel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - showFlag: Boolean + baseChannel: Channel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + showFlag: Boolean, ) { - val channelState = baseChannel.live.observeAsState() - val channel = remember(channelState) { - channelState.value?.channel - } ?: return + val channelState = baseChannel.live.observeAsState() + val channel = remember(channelState) { channelState.value?.channel } ?: return - val automaticallyShowProfilePicture = remember { - accountViewModel.settings.showProfilePictures.value + val automaticallyShowProfilePicture = remember { + accountViewModel.settings.showProfilePictures.value + } + + Row(verticalAlignment = Alignment.CenterVertically) { + if (channel is LiveActivitiesChannel) { + channel.creator?.let { + UserPicture( + user = it, + size = Size34dp, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } else { + channel.profilePicture()?.let { + RobohashFallbackAsyncImage( + robot = channel.idHex, + model = it, + contentDescription = stringResource(R.string.profile_image), + contentScale = ContentScale.Crop, + modifier = HeaderPictureModifier, + loadProfilePicture = automaticallyShowProfilePicture, + ) + } } - Row(verticalAlignment = Alignment.CenterVertically) { - if (channel is LiveActivitiesChannel) { - channel.creator?.let { - UserPicture( - user = it, - size = Size34dp, - accountViewModel = accountViewModel, - nav = nav - ) - } - } else { - channel.profilePicture()?.let { - RobohashFallbackAsyncImage( - robot = channel.idHex, - model = it, - contentDescription = stringResource(R.string.profile_image), - contentScale = ContentScale.Crop, - modifier = HeaderPictureModifier, - loadProfilePicture = automaticallyShowProfilePicture - ) - } - } - - Column( - modifier = Modifier - .padding(start = 10.dp) - .height(35.dp) - .weight(1f), - verticalArrangement = Arrangement.Center - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = remember(channelState) { channel.toBestDisplayName() }, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - - Row( - modifier = Modifier - .height(Size35dp) - .padding(start = 5.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (channel is PublicChatChannel) { - ShortChannelActionOptions(channel, accountViewModel, nav) - } - if (channel is LiveActivitiesChannel) { - LiveChannelActionOptions(channel, showFlag, accountViewModel, nav) - } - } + Column( + modifier = Modifier.padding(start = 10.dp).height(35.dp).weight(1f), + verticalArrangement = Arrangement.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = remember(channelState) { channel.toBestDisplayName() }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } + + Row( + modifier = Modifier.height(Size35dp).padding(start = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (channel is PublicChatChannel) { + ShortChannelActionOptions(channel, accountViewModel, nav) + } + if (channel is LiveActivitiesChannel) { + LiveChannelActionOptions(channel, showFlag, accountViewModel, nav) + } + } + } } @Composable fun LongChannelHeader( - baseChannel: Channel, - lineModifier: Modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseChannel: Channel, + lineModifier: Modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val channelState = baseChannel.live.observeAsState() - val channel = remember(channelState) { - channelState.value?.channel - } ?: return + val channelState = baseChannel.live.observeAsState() + val channel = remember(channelState) { channelState.value?.channel } ?: return - Row( - lineModifier + Row( + lineModifier, + ) { + val summary = remember(channelState) { channel.summary()?.ifBlank { null } } + + Column( + Modifier.weight(1f), ) { - val summary = remember(channelState) { - channel.summary()?.ifBlank { null } - } + Row(verticalAlignment = Alignment.CenterVertically) { + val defaultBackground = MaterialTheme.colorScheme.background + val background = remember { mutableStateOf(defaultBackground) } - Column( - Modifier.weight(1f) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - val defaultBackground = MaterialTheme.colorScheme.background - val background = remember { - mutableStateOf(defaultBackground) - } - - val tags = remember(channelState) { - if (baseChannel is LiveActivitiesChannel) { - baseChannel.info?.tags()?.toImmutableListOfLists() ?: EmptyTagList - } else { - EmptyTagList - } - } - - TranslatableRichTextViewer( - content = summary ?: stringResource(id = R.string.groups_no_descriptor), - canPreview = false, - tags = tags, - backgroundColor = background, - accountViewModel = accountViewModel, - nav = nav - ) + val tags = + remember(channelState) { + if (baseChannel is LiveActivitiesChannel) { + baseChannel.info?.tags()?.toImmutableListOfLists() ?: EmptyTagList + } else { + EmptyTagList } + } - if (baseChannel is LiveActivitiesChannel && baseChannel.info?.hasHashtags() == true) { - val hashtags = remember(baseChannel.info) { - baseChannel.info?.hashtags()?.toImmutableList() ?: persistentListOf() - } - DisplayUncitedHashtags(hashtags, summary ?: "", nav) - } - } - - Column() { - if (channel is PublicChatChannel) { - Row() { - Spacer(DoubleHorzSpacer) - LongChannelActionOptions(channel, accountViewModel, nav) - } - } - } - } - - LoadNote(baseNoteHex = channel.idHex, accountViewModel) { loadingNote -> - loadingNote?.let { note -> - Row( - lineModifier, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(id = R.string.owner), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.width(75.dp) - ) - Spacer(DoubleHorzSpacer) - NoteAuthorPicture(note, nav, accountViewModel, Size25dp) - Spacer(DoubleHorzSpacer) - NoteUsernameDisplay(note, remember { Modifier.weight(1f) }) - } - - Row( - lineModifier, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(id = R.string.created_at), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.width(75.dp) - ) - Spacer(DoubleHorzSpacer) - NormalTimeAgo(note, remember { Modifier.weight(1f) }) - MoreOptionsButton(note, accountViewModel) - } - } - } - - var participantUsers by remember(baseChannel) { - mutableStateOf>>( - persistentListOf() + TranslatableRichTextViewer( + content = summary ?: stringResource(id = R.string.groups_no_descriptor), + canPreview = false, + tags = tags, + backgroundColor = background, + accountViewModel = accountViewModel, + nav = nav, ) + } + + if (baseChannel is LiveActivitiesChannel && baseChannel.info?.hasHashtags() == true) { + val hashtags = + remember(baseChannel.info) { + baseChannel.info?.hashtags()?.toImmutableList() ?: persistentListOf() + } + DisplayUncitedHashtags(hashtags, summary ?: "", nav) + } } - if (channel is LiveActivitiesChannel) { - LaunchedEffect(key1 = channelState) { - launch(Dispatchers.IO) { - val newParticipantUsers = channel.info?.participants()?.mapNotNull { part -> - LocalCache.checkGetOrCreateUser(part.key)?.let { Pair(part, it) } - }?.toImmutableList() - - if (newParticipantUsers != null && !equalImmutableLists(newParticipantUsers, participantUsers)) { - participantUsers = newParticipantUsers - } - } - } - - participantUsers.forEach { - Row( - lineModifier - .clickable { - nav("User/${it.second.pubkeyHex}") - }, - verticalAlignment = Alignment.CenterVertically - ) { - it.first.role?.let { it1 -> - Text( - text = it1.capitalize(Locale.ROOT), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.width(55.dp) - ) - } - Spacer(DoubleHorzSpacer) - ClickableUserPicture(it.second, Size25dp, accountViewModel) - Spacer(DoubleHorzSpacer) - UsernameDisplay(it.second, remember { Modifier.weight(1f) }) - } + Column { + if (channel is PublicChatChannel) { + Row { + Spacer(DoubleHorzSpacer) + LongChannelActionOptions(channel, accountViewModel, nav) } + } } + } + + LoadNote(baseNoteHex = channel.idHex, accountViewModel) { loadingNote -> + loadingNote?.let { note -> + Row( + lineModifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(id = R.string.owner), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(75.dp), + ) + Spacer(DoubleHorzSpacer) + NoteAuthorPicture(note, nav, accountViewModel, Size25dp) + Spacer(DoubleHorzSpacer) + NoteUsernameDisplay(note, remember { Modifier.weight(1f) }) + } + + Row( + lineModifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(id = R.string.created_at), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(75.dp), + ) + Spacer(DoubleHorzSpacer) + NormalTimeAgo(note, remember { Modifier.weight(1f) }) + MoreOptionsButton(note, accountViewModel) + } + } + } + + var participantUsers by + remember(baseChannel) { + mutableStateOf>>( + persistentListOf(), + ) + } + + if (channel is LiveActivitiesChannel) { + LaunchedEffect(key1 = channelState) { + launch(Dispatchers.IO) { + val newParticipantUsers = + channel.info + ?.participants() + ?.mapNotNull { part -> + LocalCache.checkGetOrCreateUser(part.key)?.let { Pair(part, it) } + } + ?.toImmutableList() + + if ( + newParticipantUsers != null && !equalImmutableLists(newParticipantUsers, participantUsers) + ) { + participantUsers = newParticipantUsers + } + } + } + + participantUsers.forEach { + Row( + lineModifier.clickable { nav("User/${it.second.pubkeyHex}") }, + verticalAlignment = Alignment.CenterVertically, + ) { + it.first.role?.let { it1 -> + Text( + text = it1.capitalize(Locale.ROOT), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(55.dp), + ) + } + Spacer(DoubleHorzSpacer) + ClickableUserPicture(it.second, Size25dp, accountViewModel) + Spacer(DoubleHorzSpacer) + UsernameDisplay(it.second, remember { Modifier.weight(1f) }) + } + } + } } @Composable -fun NormalTimeAgo(baseNote: Note, modifier: Modifier) { - val nowStr = stringResource(id = R.string.now) +fun NormalTimeAgo( + baseNote: Note, + modifier: Modifier, +) { + val nowStr = stringResource(id = R.string.now) - val time by remember(baseNote) { - derivedStateOf { - timeAgoShort(baseNote.createdAt() ?: 0, nowStr) - } - } + val time by + remember(baseNote) { derivedStateOf { timeAgoShort(baseNote.createdAt() ?: 0, nowStr) } } - Text( - text = time, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = modifier - ) + Text( + text = time, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + ) } @Composable private fun ShortChannelActionOptions( - channel: PublicChatChannel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + channel: PublicChatChannel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - LoadNote(baseNoteHex = channel.idHex, accountViewModel) { - it?.let { - Spacer(modifier = StdHorzSpacer) - LikeReaction(baseNote = it, grayTint = MaterialTheme.colorScheme.onSurface, accountViewModel = accountViewModel, nav) - Spacer(modifier = StdHorzSpacer) - ZapReaction(baseNote = it, grayTint = MaterialTheme.colorScheme.onSurface, accountViewModel = accountViewModel, nav = nav) - Spacer(modifier = StdHorzSpacer) - } + LoadNote(baseNoteHex = channel.idHex, accountViewModel) { + it?.let { + Spacer(modifier = StdHorzSpacer) + LikeReaction( + baseNote = it, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav, + ) + Spacer(modifier = StdHorzSpacer) + ZapReaction( + baseNote = it, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav = nav, + ) + Spacer(modifier = StdHorzSpacer) } + } - WatchChannelFollows(channel, accountViewModel) { isFollowing -> - if (!isFollowing) { - JoinChatButton(accountViewModel, channel, nav) - } + WatchChannelFollows(channel, accountViewModel) { isFollowing -> + if (!isFollowing) { + JoinChatButton(accountViewModel, channel, nav) } + } } @Composable private fun WatchChannelFollows( - channel: PublicChatChannel, - accountViewModel: AccountViewModel, - content: @Composable (Boolean) -> Unit + channel: PublicChatChannel, + accountViewModel: AccountViewModel, + content: @Composable (Boolean) -> Unit, ) { - val isFollowing by accountViewModel.userProfile().live().follows.map { - it.user.latestContactList?.isTaggedEvent(channel.idHex) ?: false - }.distinctUntilChanged().observeAsState( - accountViewModel.userProfile().latestContactList?.isTaggedEvent(channel.idHex) ?: false - ) + val isFollowing by + accountViewModel + .userProfile() + .live() + .follows + .map { it.user.latestContactList?.isTaggedEvent(channel.idHex) ?: false } + .distinctUntilChanged() + .observeAsState( + accountViewModel.userProfile().latestContactList?.isTaggedEvent(channel.idHex) ?: false, + ) - content(isFollowing) + content(isFollowing) } @Composable private fun LongChannelActionOptions( - channel: PublicChatChannel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + channel: PublicChatChannel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val isMe by remember(accountViewModel) { - derivedStateOf { - channel.creator == accountViewModel.account.userProfile() - } + val isMe by + remember(accountViewModel) { + derivedStateOf { channel.creator == accountViewModel.account.userProfile() } } - if (isMe) { - EditButton(accountViewModel, channel) - } + if (isMe) { + EditButton(accountViewModel, channel) + } - WatchChannelFollows(channel, accountViewModel) { isFollowing -> - if (isFollowing) { - LeaveChatButton(accountViewModel, channel, nav) - } + WatchChannelFollows(channel, accountViewModel) { isFollowing -> + if (isFollowing) { + LeaveChatButton(accountViewModel, channel, nav) } + } } @Composable private fun LiveChannelActionOptions( - channel: LiveActivitiesChannel, - showFlag: Boolean = true, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + channel: LiveActivitiesChannel, + showFlag: Boolean = true, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val isLive by remember(channel) { - derivedStateOf { - channel.info?.status() == STATUS_LIVE - } + val isLive by remember(channel) { derivedStateOf { channel.info?.status() == STATUS_LIVE } } + + val note = remember(channel.idHex) { LocalCache.getNoteIfExists(channel.idHex) } + + note?.let { + if (showFlag && isLive) { + LiveFlag() + Spacer(modifier = StdHorzSpacer) } - val note = remember(channel.idHex) { - LocalCache.getNoteIfExists(channel.idHex) - } - - note?.let { - if (showFlag && isLive) { - LiveFlag() - Spacer(modifier = StdHorzSpacer) - } - - LikeReaction(baseNote = it, grayTint = MaterialTheme.colorScheme.onSurface, accountViewModel = accountViewModel, nav) - Spacer(modifier = StdHorzSpacer) - ZapReaction(baseNote = it, grayTint = MaterialTheme.colorScheme.onSurface, accountViewModel = accountViewModel, nav = nav) - } + LikeReaction( + baseNote = it, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav, + ) + Spacer(modifier = StdHorzSpacer) + ZapReaction( + baseNote = it, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav = nav, + ) + } } @Composable fun LiveFlag() { - Text( - text = stringResource(id = R.string.live_stream_live_tag), - color = Color.White, - fontWeight = FontWeight.Bold, - fontSize = 16.sp, - modifier = remember { - Modifier - .clip(SmallBorder) - .background(Color.Red) - .padding(horizontal = 5.dp) - } - ) + Text( + text = stringResource(id = R.string.live_stream_live_tag), + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + modifier = + remember { Modifier.clip(SmallBorder).background(Color.Red).padding(horizontal = 5.dp) }, + ) } @Composable fun EndedFlag() { - Text( - text = stringResource(id = R.string.live_stream_ended_tag), - color = Color.White, - fontWeight = FontWeight.Bold, - modifier = remember { - Modifier - .clip(SmallBorder) - .background(Color.Black) - .padding(horizontal = 5.dp) - } - ) + Text( + text = stringResource(id = R.string.live_stream_ended_tag), + color = Color.White, + fontWeight = FontWeight.Bold, + modifier = + remember { Modifier.clip(SmallBorder).background(Color.Black).padding(horizontal = 5.dp) }, + ) } @Composable fun OfflineFlag() { - Text( - text = stringResource(id = R.string.live_stream_offline_tag), - color = Color.White, - fontWeight = FontWeight.Bold, - modifier = remember { - Modifier - .clip(SmallBorder) - .background(Color.Black) - .padding(horizontal = 5.dp) - } - ) + Text( + text = stringResource(id = R.string.live_stream_offline_tag), + color = Color.White, + fontWeight = FontWeight.Bold, + modifier = + remember { Modifier.clip(SmallBorder).background(Color.Black).padding(horizontal = 5.dp) }, + ) } @Composable fun ScheduledFlag(starts: Long?) { - val context = LocalContext.current - val startsIn = starts?.let { timeAgo(it, context) } + val context = LocalContext.current + val startsIn = starts?.let { timeAgo(it, context) } - Text( - text = startsIn ?: stringResource(id = R.string.live_stream_planned_tag), - color = Color.White, - fontWeight = FontWeight.Bold, - modifier = remember { - Modifier - .clip(SmallBorder) - .background(Color.Black) - .padding(horizontal = 5.dp) - } + Text( + text = startsIn ?: stringResource(id = R.string.live_stream_planned_tag), + color = Color.White, + fontWeight = FontWeight.Bold, + modifier = + remember { Modifier.clip(SmallBorder).background(Color.Black).padding(horizontal = 5.dp) }, + ) +} + +@Composable +private fun NoteCopyButton(note: Channel) { + var popupExpanded by remember { mutableStateOf(false) } + + Button( + modifier = Modifier.padding(horizontal = 3.dp).width(50.dp), + onClick = { popupExpanded = true }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.placeholderText, + ), + ) { + Icon( + tint = Color.White, + imageVector = Icons.Default.Share, + contentDescription = stringResource(R.string.copies_the_note_id_to_the_clipboard_for_sharing), ) + + DropdownMenu( + expanded = popupExpanded, + onDismissRequest = { popupExpanded = false }, + ) { + val clipboardManager = LocalClipboardManager.current + + DropdownMenuItem( + text = { Text(stringResource(R.string.copy_channel_id_note_to_the_clipboard)) }, + onClick = { + clipboardManager.setText(AnnotatedString("nostr:" + note.idNote())) + popupExpanded = false + }, + ) + } + } } @Composable -private fun NoteCopyButton( - note: Channel +private fun EditButton( + accountViewModel: AccountViewModel, + channel: PublicChatChannel, ) { - var popupExpanded by remember { mutableStateOf(false) } + var wantsToPost by remember { mutableStateOf(false) } - Button( - modifier = Modifier - .padding(horizontal = 3.dp) - .width(50.dp), - onClick = { popupExpanded = true }, - shape = ButtonBorder, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.placeholderText - ) - ) { - Icon( - tint = Color.White, - imageVector = Icons.Default.Share, - contentDescription = stringResource(R.string.copies_the_note_id_to_the_clipboard_for_sharing) - ) + if (wantsToPost) { + NewChannelView({ wantsToPost = false }, accountViewModel, channel) + } - DropdownMenu( - expanded = popupExpanded, - onDismissRequest = { popupExpanded = false } - ) { - val clipboardManager = LocalClipboardManager.current - - DropdownMenuItem( - text = { - Text(stringResource(R.string.copy_channel_id_note_to_the_clipboard)) - }, - onClick = { clipboardManager.setText(AnnotatedString("nostr:" + note.idNote())); popupExpanded = false } - ) - } - } + Button( + modifier = Modifier.padding(horizontal = 3.dp).width(50.dp), + onClick = { wantsToPost = true }, + contentPadding = ZeroPadding, + ) { + Icon( + tint = Color.White, + imageVector = Icons.Default.EditNote, + contentDescription = stringResource(R.string.edits_the_channel_metadata), + ) + } } @Composable -private fun EditButton(accountViewModel: AccountViewModel, channel: PublicChatChannel) { - var wantsToPost by remember { - mutableStateOf(false) - } +fun JoinChatButton( + accountViewModel: AccountViewModel, + channel: Channel, + nav: (String) -> Unit, +) { + val scope = rememberCoroutineScope() - if (wantsToPost) { - NewChannelView({ wantsToPost = false }, accountViewModel, channel) - } - - Button( - modifier = Modifier.padding(horizontal = 3.dp).width(50.dp), - onClick = { wantsToPost = true }, - contentPadding = ZeroPadding - ) { - Icon( - tint = Color.White, - imageVector = Icons.Default.EditNote, - contentDescription = stringResource(R.string.edits_the_channel_metadata) - ) - } + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = { scope.launch(Dispatchers.IO) { accountViewModel.account.follow(channel) } }, + contentPadding = ButtonPadding, + ) { + Text(text = stringResource(R.string.join), color = Color.White) + } } @Composable -fun JoinChatButton(accountViewModel: AccountViewModel, channel: Channel, nav: (String) -> Unit) { - val scope = rememberCoroutineScope() +fun LeaveChatButton( + accountViewModel: AccountViewModel, + channel: Channel, + nav: (String) -> Unit, +) { + val scope = rememberCoroutineScope() - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.account.follow(channel) - } - }, - contentPadding = ButtonPadding - ) { - Text(text = stringResource(R.string.join), color = Color.White) - } + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = { scope.launch(Dispatchers.IO) { accountViewModel.account.unfollow(channel) } }, + contentPadding = ButtonPadding, + ) { + Text(text = stringResource(R.string.leave), color = Color.White) + } } @Composable -fun LeaveChatButton(accountViewModel: AccountViewModel, channel: Channel, nav: (String) -> Unit) { - val scope = rememberCoroutineScope() +fun JoinCommunityButton( + accountViewModel: AccountViewModel, + note: AddressableNote, + nav: (String) -> Unit, +) { + val scope = rememberCoroutineScope() - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.account.unfollow(channel) - } - }, - contentPadding = ButtonPadding - ) { - Text(text = stringResource(R.string.leave), color = Color.White) - } + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = { scope.launch(Dispatchers.IO) { accountViewModel.account.follow(note) } }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Text(text = stringResource(R.string.join), color = Color.White) + } } @Composable -fun JoinCommunityButton(accountViewModel: AccountViewModel, note: AddressableNote, nav: (String) -> Unit) { - val scope = rememberCoroutineScope() +fun LeaveCommunityButton( + accountViewModel: AccountViewModel, + note: AddressableNote, + nav: (String) -> Unit, +) { + val scope = rememberCoroutineScope() - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.account.follow(note) - } - }, - shape = ButtonBorder, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ), - contentPadding = ButtonPadding - ) { - Text(text = stringResource(R.string.join), color = Color.White) - } -} - -@Composable -fun LeaveCommunityButton(accountViewModel: AccountViewModel, note: AddressableNote, nav: (String) -> Unit) { - val scope = rememberCoroutineScope() - - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.account.unfollow(note) - } - }, - shape = ButtonBorder, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ), - contentPadding = ButtonPadding - ) { - Text(text = stringResource(R.string.leave), color = Color.White) - } + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = { scope.launch(Dispatchers.IO) { accountViewModel.account.unfollow(note) } }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Text(text = stringResource(R.string.leave), color = Color.White) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt index 89c31e1f9..f53f5022c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen.loggedIn import androidx.compose.foundation.ExperimentalFoundationApi @@ -62,272 +82,266 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Composable fun ChatroomListScreen( - knownFeedViewModel: NostrChatroomListKnownFeedViewModel, - newFeedViewModel: NostrChatroomListNewFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + knownFeedViewModel: NostrChatroomListKnownFeedViewModel, + newFeedViewModel: NostrChatroomListNewFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val windowSizeClass = accountViewModel.settings.windowSizeClass.value + val windowSizeClass = accountViewModel.settings.windowSizeClass.value - val twoPane by remember { - derivedStateOf { - when (windowSizeClass?.widthSizeClass) { - WindowWidthSizeClass.Compact -> false - WindowWidthSizeClass.Expanded, WindowWidthSizeClass.Medium -> true - else -> false - } - } + val twoPane by remember { + derivedStateOf { + when (windowSizeClass?.widthSizeClass) { + WindowWidthSizeClass.Compact -> false + WindowWidthSizeClass.Expanded, + WindowWidthSizeClass.Medium, -> true + else -> false + } } + } - if (twoPane && windowSizeClass != null) { - ChatroomListTwoPane( - knownFeedViewModel = knownFeedViewModel, - newFeedViewModel = newFeedViewModel, - widthSizeClass = windowSizeClass.widthSizeClass, - accountViewModel = accountViewModel, - nav = nav - ) - } else { - ChatroomListScreenOnlyList( - knownFeedViewModel = knownFeedViewModel, - newFeedViewModel = newFeedViewModel, - accountViewModel = accountViewModel, - nav = nav - ) - } + if (twoPane && windowSizeClass != null) { + ChatroomListTwoPane( + knownFeedViewModel = knownFeedViewModel, + newFeedViewModel = newFeedViewModel, + widthSizeClass = windowSizeClass.widthSizeClass, + accountViewModel = accountViewModel, + nav = nav, + ) + } else { + ChatroomListScreenOnlyList( + knownFeedViewModel = knownFeedViewModel, + newFeedViewModel = newFeedViewModel, + accountViewModel = accountViewModel, + nav = nav, + ) + } } data class RouteId(val route: String, val id: String) @Composable fun ChatroomListTwoPane( - knownFeedViewModel: NostrChatroomListKnownFeedViewModel, - newFeedViewModel: NostrChatroomListNewFeedViewModel, - widthSizeClass: WindowWidthSizeClass, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + knownFeedViewModel: NostrChatroomListKnownFeedViewModel, + newFeedViewModel: NostrChatroomListNewFeedViewModel, + widthSizeClass: WindowWidthSizeClass, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - /** - * The index of the currently selected word, or `null` if none is selected - */ - var selectedRoute: RouteId? by remember { mutableStateOf(null) } + /** The index of the currently selected word, or `null` if none is selected */ + var selectedRoute: RouteId? by remember { mutableStateOf(null) } - val navInterceptor = remember { - { fullRoute: String -> - if (fullRoute.startsWith("Room/") || fullRoute.startsWith("Channel/")) { - val route = fullRoute.substringBefore("/") - val id = fullRoute.substringAfter("/") - selectedRoute = RouteId(route, id) - } else { - nav(fullRoute) - } - } + val navInterceptor = remember { + { fullRoute: String -> + if (fullRoute.startsWith("Room/") || fullRoute.startsWith("Channel/")) { + val route = fullRoute.substringBefore("/") + val id = fullRoute.substringAfter("/") + selectedRoute = RouteId(route, id) + } else { + nav(fullRoute) + } } + } - val strategy = remember { - if (widthSizeClass == WindowWidthSizeClass.Expanded) { - HorizontalTwoPaneStrategy( - splitFraction = 1f / 3f - ) - } else { - HorizontalTwoPaneStrategy( - splitFraction = 1f / 2.5f - ) - } + val strategy = remember { + if (widthSizeClass == WindowWidthSizeClass.Expanded) { + HorizontalTwoPaneStrategy( + splitFraction = 1f / 3f, + ) + } else { + HorizontalTwoPaneStrategy( + splitFraction = 1f / 2.5f, + ) } + } - TwoPane( - first = { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomEnd) { - ChatroomListScreenOnlyList( - knownFeedViewModel, - newFeedViewModel, - accountViewModel, - navInterceptor - ) - Box(Modifier.padding(Size20dp), contentAlignment = Alignment.Center) { - ChannelFabColumn(accountViewModel, nav) - } - Divider( - modifier = Modifier - .fillMaxHeight() // fill the max height - .width(DividerThickness) - ) - } - }, - second = { - selectedRoute?.let { - if (it.route == "Room") { - ChatroomScreen( - roomId = it.id, - accountViewModel = accountViewModel, - nav = nav - ) - } + TwoPane( + first = { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomEnd) { + ChatroomListScreenOnlyList( + knownFeedViewModel, + newFeedViewModel, + accountViewModel, + navInterceptor, + ) + Box(Modifier.padding(Size20dp), contentAlignment = Alignment.Center) { + ChannelFabColumn(accountViewModel, nav) + } + Divider( + modifier = + Modifier.fillMaxHeight() // fill the max height + .width(DividerThickness), + ) + } + }, + second = { + selectedRoute?.let { + if (it.route == "Room") { + ChatroomScreen( + roomId = it.id, + accountViewModel = accountViewModel, + nav = nav, + ) + } - if (it.route == "Channel") { - ChannelScreen( - channelId = it.id, - accountViewModel = accountViewModel, - nav = nav - ) - } - } - }, - strategy = strategy, - displayFeatures = accountViewModel.settings.displayFeatures.value, - foldAwareConfiguration = FoldAwareConfiguration.VerticalFoldsOnly, - modifier = Modifier.fillMaxSize() - ) + if (it.route == "Channel") { + ChannelScreen( + channelId = it.id, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + }, + strategy = strategy, + displayFeatures = accountViewModel.settings.displayFeatures.value, + foldAwareConfiguration = FoldAwareConfiguration.VerticalFoldsOnly, + modifier = Modifier.fillMaxSize(), + ) } @OptIn(ExperimentalFoundationApi::class) @Composable fun ChatroomListScreenOnlyList( - knownFeedViewModel: NostrChatroomListKnownFeedViewModel, - newFeedViewModel: NostrChatroomListNewFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + knownFeedViewModel: NostrChatroomListKnownFeedViewModel, + newFeedViewModel: NostrChatroomListNewFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val pagerState = rememberPagerState() { 2 } - val coroutineScope = rememberCoroutineScope() + val pagerState = rememberPagerState { 2 } + val coroutineScope = rememberCoroutineScope() - var moreActionsExpanded by remember { mutableStateOf(false) } - val markKnownAsRead = remember { mutableStateOf(false) } - val markNewAsRead = remember { mutableStateOf(false) } + var moreActionsExpanded by remember { mutableStateOf(false) } + val markKnownAsRead = remember { mutableStateOf(false) } + val markNewAsRead = remember { mutableStateOf(false) } - WatchAccountForListScreen(knownFeedViewModel, newFeedViewModel, accountViewModel) + WatchAccountForListScreen(knownFeedViewModel, newFeedViewModel, accountViewModel) - val lifeCycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - NostrChatroomListDataSource.start() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { - lifeCycleOwner.lifecycle.removeObserver(observer) - } + val lifeCycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifeCycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + NostrChatroomListDataSource.start() + } } - val tabs by remember(knownFeedViewModel, markKnownAsRead) { - derivedStateOf { - listOf( - ChatroomListTabItem(R.string.known, knownFeedViewModel, markKnownAsRead), - ChatroomListTabItem(R.string.new_requests, newFeedViewModel, markNewAsRead) - ) - } + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } + } + + val tabs by + remember(knownFeedViewModel, markKnownAsRead) { + derivedStateOf { + listOf( + ChatroomListTabItem(R.string.known, knownFeedViewModel, markKnownAsRead), + ChatroomListTabItem(R.string.new_requests, newFeedViewModel, markNewAsRead), + ) + } } - Column( - modifier = Modifier.fillMaxHeight() - ) { - Box(Modifier.fillMaxWidth()) { - TabRow( - containerColor = MaterialTheme.colorScheme.background, - contentColor = MaterialTheme.colorScheme.onBackground, - selectedTabIndex = pagerState.currentPage, - modifier = TabRowHeight - ) { - tabs.forEachIndexed { index, tab -> - Tab( - selected = pagerState.currentPage == index, - text = { - Text(text = stringResource(tab.resource)) - }, - onClick = { - coroutineScope.launch { pagerState.animateScrollToPage(index) } - } - ) - } - } - - IconButton( - modifier = Modifier - .size(40.dp) - .align(Alignment.CenterEnd), - onClick = { moreActionsExpanded = true } - ) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = null, - tint = MaterialTheme.colorScheme.placeholderText - ) - - ChatroomTabMenu( - moreActionsExpanded, - { moreActionsExpanded = false }, - { markKnownAsRead.value = true }, - { markNewAsRead.value = true } - ) - } + Column( + modifier = Modifier.fillMaxHeight(), + ) { + Box(Modifier.fillMaxWidth()) { + TabRow( + containerColor = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + selectedTabIndex = pagerState.currentPage, + modifier = TabRowHeight, + ) { + tabs.forEachIndexed { index, tab -> + Tab( + selected = pagerState.currentPage == index, + text = { Text(text = stringResource(tab.resource)) }, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } }, + ) } + } - HorizontalPager( - state = pagerState, - modifier = Modifier.fillMaxSize() - ) { page -> - ChatroomListFeedView( - viewModel = tabs[page].viewModel, - accountViewModel = accountViewModel, - nav = nav, - markAsRead = tabs[page].markAsRead - ) - } + IconButton( + modifier = Modifier.size(40.dp).align(Alignment.CenterEnd), + onClick = { moreActionsExpanded = true }, + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = null, + tint = MaterialTheme.colorScheme.placeholderText, + ) + + ChatroomTabMenu( + moreActionsExpanded, + { moreActionsExpanded = false }, + { markKnownAsRead.value = true }, + { markNewAsRead.value = true }, + ) + } } + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + ) { page -> + ChatroomListFeedView( + viewModel = tabs[page].viewModel, + accountViewModel = accountViewModel, + nav = nav, + markAsRead = tabs[page].markAsRead, + ) + } + } } @Composable -fun WatchAccountForListScreen(knownFeedViewModel: NostrChatroomListKnownFeedViewModel, newFeedViewModel: NostrChatroomListNewFeedViewModel, accountViewModel: AccountViewModel) { - LaunchedEffect(accountViewModel) { - launch(Dispatchers.IO) { - NostrChatroomListDataSource.start() - knownFeedViewModel.invalidateData(true) - newFeedViewModel.invalidateData(true) - } +fun WatchAccountForListScreen( + knownFeedViewModel: NostrChatroomListKnownFeedViewModel, + newFeedViewModel: NostrChatroomListNewFeedViewModel, + accountViewModel: AccountViewModel, +) { + LaunchedEffect(accountViewModel) { + launch(Dispatchers.IO) { + NostrChatroomListDataSource.start() + knownFeedViewModel.invalidateData(true) + newFeedViewModel.invalidateData(true) } + } } @Immutable -class ChatroomListTabItem(val resource: Int, val viewModel: FeedViewModel, val markAsRead: MutableState) +class ChatroomListTabItem( + val resource: Int, + val viewModel: FeedViewModel, + val markAsRead: MutableState, +) @Composable fun ChatroomTabMenu( - expanded: Boolean, - onDismiss: () -> Unit, - onMarkKnownAsRead: () -> Unit, - onMarkNewAsRead: () -> Unit + expanded: Boolean, + onDismiss: () -> Unit, + onMarkKnownAsRead: () -> Unit, + onMarkNewAsRead: () -> Unit, ) { - DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { - DropdownMenuItem( - text = { - Text(stringResource(R.string.mark_all_known_as_read)) - }, - onClick = { - onMarkKnownAsRead() - onDismiss() - } - ) - DropdownMenuItem( - text = { - Text(stringResource(R.string.mark_all_new_as_read)) - }, - onClick = { - onMarkNewAsRead() - onDismiss() - } - ) - DropdownMenuItem( - text = { - Text(stringResource(R.string.mark_all_as_read)) - }, - onClick = { - onMarkKnownAsRead() - onMarkNewAsRead() - onDismiss() - } - ) - } + DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { + DropdownMenuItem( + text = { Text(stringResource(R.string.mark_all_known_as_read)) }, + onClick = { + onMarkKnownAsRead() + onDismiss() + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.mark_all_new_as_read)) }, + onClick = { + onMarkNewAsRead() + onDismiss() + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.mark_all_as_read)) }, + onClick = { + onMarkKnownAsRead() + onMarkNewAsRead() + onDismiss() + }, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt index 21fa4f57c..e791a1185 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.widget.Toast @@ -103,670 +123,679 @@ import kotlinx.coroutines.withContext @Composable fun ChatroomScreen( - roomId: String?, - draftMessage: String? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + roomId: String?, + draftMessage: String? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - if (roomId == null) return + if (roomId == null) return - LoadRoom(roomId, accountViewModel) { - it?.let { - PrepareChatroomViewModels( - room = it, - draftMessage = draftMessage, - accountViewModel = accountViewModel, - nav = nav - ) - } + LoadRoom(roomId, accountViewModel) { + it?.let { + PrepareChatroomViewModels( + room = it, + draftMessage = draftMessage, + accountViewModel = accountViewModel, + nav = nav, + ) } + } } @Composable fun ChatroomScreenByAuthor( - authorPubKeyHex: String?, - draftMessage: String? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + authorPubKeyHex: String?, + draftMessage: String? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - if (authorPubKeyHex == null) return + if (authorPubKeyHex == null) return - LoadRoomByAuthor(authorPubKeyHex, accountViewModel) { - it?.let { - PrepareChatroomViewModels( - room = it, - draftMessage = draftMessage, - accountViewModel = accountViewModel, - nav = nav - ) - } + LoadRoomByAuthor(authorPubKeyHex, accountViewModel) { + it?.let { + PrepareChatroomViewModels( + room = it, + draftMessage = draftMessage, + accountViewModel = accountViewModel, + nav = nav, + ) } + } } @Composable -fun LoadRoom(roomId: String, accountViewModel: AccountViewModel, content: @Composable (ChatroomKey?) -> Unit) { - var room by remember(roomId) { - mutableStateOf(null) - } +fun LoadRoom( + roomId: String, + accountViewModel: AccountViewModel, + content: @Composable (ChatroomKey?) -> Unit, +) { + var room by remember(roomId) { mutableStateOf(null) } - if (room == null) { - LaunchedEffect(key1 = roomId) { - launch(Dispatchers.IO) { - val newRoom = accountViewModel.userProfile().privateChatrooms.keys.firstOrNull { it.hashCode().toString() == roomId } - if (room != newRoom) { - room = newRoom - } - } + if (room == null) { + LaunchedEffect(key1 = roomId) { + launch(Dispatchers.IO) { + val newRoom = + accountViewModel.userProfile().privateChatrooms.keys.firstOrNull { + it.hashCode().toString() == roomId + } + if (room != newRoom) { + room = newRoom } + } } + } - content(room) + content(room) } @Composable -fun LoadRoomByAuthor(authorPubKeyHex: String, accountViewModel: AccountViewModel, content: @Composable (ChatroomKey?) -> Unit) { - val room by remember(authorPubKeyHex) { - mutableStateOf(ChatroomKey(persistentSetOf(authorPubKeyHex))) +fun LoadRoomByAuthor( + authorPubKeyHex: String, + accountViewModel: AccountViewModel, + content: @Composable (ChatroomKey?) -> Unit, +) { + val room by + remember(authorPubKeyHex) { + mutableStateOf(ChatroomKey(persistentSetOf(authorPubKeyHex))) } - content(room) + content(room) } @Composable fun PrepareChatroomViewModels( - room: ChatroomKey, - draftMessage: String?, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + room: ChatroomKey, + draftMessage: String?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val feedViewModel: NostrChatroomFeedViewModel = viewModel( - key = room.hashCode().toString() + "ChatroomViewModels", - factory = NostrChatroomFeedViewModel.Factory( - room, - accountViewModel.account - ) + val feedViewModel: NostrChatroomFeedViewModel = + viewModel( + key = room.hashCode().toString() + "ChatroomViewModels", + factory = + NostrChatroomFeedViewModel.Factory( + room, + accountViewModel.account, + ), ) - val newPostModel: NewPostViewModel = viewModel() - newPostModel.accountViewModel = accountViewModel - newPostModel.account = accountViewModel.account - newPostModel.requiresNIP24 = room.users.size > 1 - if (newPostModel.requiresNIP24) { + val newPostModel: NewPostViewModel = viewModel() + newPostModel.accountViewModel = accountViewModel + newPostModel.account = accountViewModel.account + newPostModel.requiresNIP24 = room.users.size > 1 + if (newPostModel.requiresNIP24) { + newPostModel.nip24 = true + } + + LaunchedEffect(key1 = newPostModel) { + launch(Dispatchers.IO) { + val hasNIP24 = + accountViewModel.userProfile().privateChatrooms[room]?.roomMessages?.any { + it.event is ChatMessageEvent && + (it.event as ChatMessageEvent).pubKey != accountViewModel.userProfile().pubkeyHex + } + if (hasNIP24 == true && newPostModel.nip24 == false) { newPostModel.nip24 = true + } } + } - LaunchedEffect(key1 = newPostModel) { - launch(Dispatchers.IO) { - val hasNIP24 = accountViewModel.userProfile().privateChatrooms[room]?.roomMessages?.any { - it.event is ChatMessageEvent && (it.event as ChatMessageEvent).pubKey != accountViewModel.userProfile().pubkeyHex - } - if (hasNIP24 == true && newPostModel.nip24 == false) { - newPostModel.nip24 = true - } - } - } + if (draftMessage != null) { + LaunchedEffect(key1 = draftMessage) { newPostModel.message = TextFieldValue(draftMessage) } + } - if (draftMessage != null) { - LaunchedEffect(key1 = draftMessage) { - newPostModel.message = TextFieldValue(draftMessage) - } - } - - ChatroomScreen( - room = room, - feedViewModel = feedViewModel, - newPostModel = newPostModel, - accountViewModel = accountViewModel, - nav = nav - ) + ChatroomScreen( + room = room, + feedViewModel = feedViewModel, + newPostModel = newPostModel, + accountViewModel = accountViewModel, + nav = nav, + ) } @Composable fun ChatroomScreen( - room: ChatroomKey, - feedViewModel: NostrChatroomFeedViewModel, - newPostModel: NewPostViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + room: ChatroomKey, + feedViewModel: NostrChatroomFeedViewModel, + newPostModel: NewPostViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val context = LocalContext.current + val context = LocalContext.current - NostrChatroomDataSource.loadMessagesBetween(accountViewModel.account, room) + NostrChatroomDataSource.loadMessagesBetween(accountViewModel.account, room) - val lifeCycleOwner = LocalLifecycleOwner.current + val lifeCycleOwner = LocalLifecycleOwner.current - LaunchedEffect(room, accountViewModel) { - launch(Dispatchers.IO) { - newPostModel.imageUploadingError.collect { error -> - withContext(Dispatchers.Main) { - Toast.makeText(context, error, Toast.LENGTH_SHORT).show() - } - } - } + LaunchedEffect(room, accountViewModel) { + launch(Dispatchers.IO) { + newPostModel.imageUploadingError.collect { error -> + withContext(Dispatchers.Main) { Toast.makeText(context, error, Toast.LENGTH_SHORT).show() } + } } + } - DisposableEffect(room, accountViewModel) { - NostrChatroomDataSource.loadMessagesBetween(accountViewModel.account, room) + DisposableEffect(room, accountViewModel) { + NostrChatroomDataSource.loadMessagesBetween(accountViewModel.account, room) + NostrChatroomDataSource.start() + feedViewModel.invalidateData() + + onDispose { NostrChatroomDataSource.stop() } + } + + DisposableEffect(lifeCycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Private Message Start") NostrChatroomDataSource.start() feedViewModel.invalidateData() - - onDispose { - NostrChatroomDataSource.stop() - } + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Private Message Stop") + NostrChatroomDataSource.stop() + } } - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Private Message Start") - NostrChatroomDataSource.start() - feedViewModel.invalidateData() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Private Message Stop") - NostrChatroomDataSource.stop() - } - } + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } + } - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { - lifeCycleOwner.lifecycle.removeObserver(observer) - } + Column(Modifier.fillMaxHeight()) { + val replyTo = remember { mutableStateOf(null) } + Column( + modifier = Modifier.fillMaxHeight().padding(vertical = 0.dp).weight(1f, true), + ) { + RefreshingChatroomFeedView( + viewModel = feedViewModel, + accountViewModel = accountViewModel, + nav = nav, + routeForLastRead = "Room/${room.hashCode()}", + onWantsToReply = { replyTo.value = it }, + ) } - Column(Modifier.fillMaxHeight()) { - val replyTo = remember { mutableStateOf(null) } - Column( - modifier = Modifier - .fillMaxHeight() - .padding(vertical = 0.dp) - .weight(1f, true) - ) { - RefreshingChatroomFeedView( - viewModel = feedViewModel, - accountViewModel = accountViewModel, - nav = nav, - routeForLastRead = "Room/${room.hashCode()}", - onWantsToReply = { - replyTo.value = it - } - ) + Spacer(modifier = Modifier.height(10.dp)) + + replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { replyTo.value = null } } + + val scope = rememberCoroutineScope() + + // LAST ROW + PrivateMessageEditFieldRow(newPostModel, isPrivate = true, accountViewModel) { + scope.launch(Dispatchers.IO) { + if (newPostModel.nip24 || room.users.size > 1 || replyTo.value?.event is ChatMessageEvent) { + accountViewModel.account.sendNIP24PrivateMessage( + message = newPostModel.message.text, + toUsers = room.users.toList(), + replyingTo = replyTo.value, + mentions = null, + wantsToMarkAsSensitive = false, + ) + } else { + accountViewModel.account.sendPrivateMessage( + message = newPostModel.message.text, + toUser = room.users.first(), + replyingTo = replyTo.value, + mentions = null, + wantsToMarkAsSensitive = false, + ) } - Spacer(modifier = Modifier.height(10.dp)) - - replyTo.value?.let { - DisplayReplyingToNote(it, accountViewModel, nav) { - replyTo.value = null - } - } - - val scope = rememberCoroutineScope() - - // LAST ROW - PrivateMessageEditFieldRow(newPostModel, isPrivate = true, accountViewModel) { - scope.launch(Dispatchers.IO) { - if (newPostModel.nip24 || room.users.size > 1 || replyTo.value?.event is ChatMessageEvent) { - accountViewModel.account.sendNIP24PrivateMessage( - message = newPostModel.message.text, - toUsers = room.users.toList(), - replyingTo = replyTo.value, - mentions = null, - wantsToMarkAsSensitive = false - ) - } else { - accountViewModel.account.sendPrivateMessage( - message = newPostModel.message.text, - toUser = room.users.first(), - replyingTo = replyTo.value, - mentions = null, - wantsToMarkAsSensitive = false - ) - } - - newPostModel.message = TextFieldValue("") - replyTo.value = null - feedViewModel.sendToTop() - } - } + newPostModel.message = TextFieldValue("") + replyTo.value = null + feedViewModel.sendToTop() + } } + } } @Composable fun PrivateMessageEditFieldRow( - channelScreenModel: NewPostViewModel, - isPrivate: Boolean, - accountViewModel: AccountViewModel, - onSendNewMessage: () -> Unit + channelScreenModel: NewPostViewModel, + isPrivate: Boolean, + accountViewModel: AccountViewModel, + onSendNewMessage: () -> Unit, ) { - Row( - modifier = EditFieldModifier, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - val context = LocalContext.current + Row( + modifier = EditFieldModifier, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + val context = LocalContext.current - MyTextField( - value = channelScreenModel.message, - onValueChange = { - channelScreenModel.updateMessage(it) - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences - ), - shape = EditFieldBorder, - modifier = Modifier.weight(1f, true), - placeholder = { - Text( - text = stringResource(R.string.reply_here), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - trailingIcon = { - ThinSendButton( - isActive = channelScreenModel.message.text.isNotBlank() && !channelScreenModel.isUploadingImage, - modifier = EditFieldTrailingIconModifier - ) { - onSendNewMessage() - } - }, - leadingIcon = { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 6.dp)) { - UploadFromGallery( - isUploading = channelScreenModel.isUploadingImage, - tint = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier - .size(30.dp) - .padding(start = 2.dp) - ) { - channelScreenModel.upload( - galleryUri = it, - alt = null, - sensitiveContent = false, - isPrivate = isPrivate, - server = ServerOption(accountViewModel.account.defaultFileServer, false), - context = context - ) - } - - var wantsToActivateNIP24 by remember { - mutableStateOf(false) - } - - if (wantsToActivateNIP24) { - NewFeatureNIP24AlertDialog( - accountViewModel = accountViewModel, - onConfirm = { - channelScreenModel.toggleNIP04And24() - }, - onDismiss = { - wantsToActivateNIP24 = false - } - ) - } - - IconButton( - modifier = Size30Modifier, - onClick = { - if (!accountViewModel.hideNIP24WarningDialog && !channelScreenModel.nip24 && !channelScreenModel.requiresNIP24) { - wantsToActivateNIP24 = true - } else { - channelScreenModel.toggleNIP04And24() - } - } - ) { - if (channelScreenModel.nip24) { - Icon( - painter = painterResource(id = R.drawable.incognito), - null, - modifier = Modifier - .padding(top = 2.dp) - .size(18.dp), - tint = MaterialTheme.colorScheme.primary - ) - } else { - Icon( - painter = painterResource(id = R.drawable.incognito_off), - null, - modifier = Modifier - .padding(top = 2.dp) - .size(18.dp), - tint = MaterialTheme.colorScheme.placeholderText - ) - } - } - } - }, - colors = TextFieldDefaults.colors( - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent - ) + MyTextField( + value = channelScreenModel.message, + onValueChange = { channelScreenModel.updateMessage(it) }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + shape = EditFieldBorder, + modifier = Modifier.weight(1f, true), + placeholder = { + Text( + text = stringResource(R.string.reply_here), + color = MaterialTheme.colorScheme.placeholderText, ) - } -} - -@Composable -fun NewFeatureNIP24AlertDialog(accountViewModel: AccountViewModel, onConfirm: () -> Unit, onDismiss: () -> Unit) { - val scope = rememberCoroutineScope() - - QuickActionAlertDialog( - title = stringResource(R.string.new_feature_nip24_might_not_be_available_title), - textContent = stringResource(R.string.new_feature_nip24_might_not_be_available_description), - buttonIconResource = R.drawable.incognito, - buttonText = stringResource(R.string.new_feature_nip24_activate), - onClickDoOnce = { - scope.launch(Dispatchers.IO) { - onConfirm() - } - onDismiss() - }, - onClickDontShowAgain = { - scope.launch(Dispatchers.IO) { - onConfirm() - accountViewModel.dontShowNIP24WarningDialog() - } - onDismiss() - }, - onDismiss = onDismiss - ) -} - -@Composable -fun ThinSendButton(isActive: Boolean, modifier: Modifier, onClick: () -> Unit) { - IconButton( - enabled = isActive, - modifier = modifier, - onClick = onClick - ) { - Icon( - imageVector = Icons.Default.Send, - null, - modifier = Size20Modifier - ) - } -} - -@Composable -fun ChatroomHeader( - room: ChatroomKey, - modifier: Modifier = StdPadding, - accountViewModel: AccountViewModel, - nav: (String) -> Unit -) { - if (room.users.size == 1) { - LoadUser(baseUserHex = room.users.first(), accountViewModel) { baseUser -> - if (baseUser != null) { - ChatroomHeader(baseUser = baseUser, modifier = modifier, accountViewModel = accountViewModel, nav = nav) - } - } - } else { - GroupChatroomHeader(room = room, modifier = modifier, accountViewModel = accountViewModel, nav = nav) - } -} - -@Composable -fun ChatroomHeader( - baseUser: User, - modifier: Modifier = StdPadding, - accountViewModel: AccountViewModel, - nav: (String) -> Unit -) { - Column( - modifier = Modifier - .fillMaxWidth() - .clickable( - onClick = { nav("User/${baseUser.pubkeyHex}") } - ) - ) { - Column( - verticalArrangement = Arrangement.Center, - modifier = modifier + }, + trailingIcon = { + ThinSendButton( + isActive = + channelScreenModel.message.text.isNotBlank() && !channelScreenModel.isUploadingImage, + modifier = EditFieldTrailingIconModifier, ) { - Row(verticalAlignment = Alignment.CenterVertically) { - ClickableUserPicture( - baseUser = baseUser, - accountViewModel = accountViewModel, - size = Size34dp - ) - - Column(modifier = Modifier.padding(start = 10.dp)) { - UsernameDisplay(baseUser) - ObserveDisplayNip05Status(baseUser, accountViewModel = accountViewModel, nav = nav) - } - } + onSendNewMessage() } + }, + leadingIcon = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 6.dp), + ) { + UploadFromGallery( + isUploading = channelScreenModel.isUploadingImage, + tint = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.size(30.dp).padding(start = 2.dp), + ) { + channelScreenModel.upload( + galleryUri = it, + alt = null, + sensitiveContent = false, + isPrivate = isPrivate, + server = ServerOption(accountViewModel.account.defaultFileServer, false), + context = context, + ) + } - Divider( - thickness = DividerThickness + var wantsToActivateNIP24 by remember { mutableStateOf(false) } + + if (wantsToActivateNIP24) { + NewFeatureNIP24AlertDialog( + accountViewModel = accountViewModel, + onConfirm = { channelScreenModel.toggleNIP04And24() }, + onDismiss = { wantsToActivateNIP24 = false }, + ) + } + + IconButton( + modifier = Size30Modifier, + onClick = { + if ( + !accountViewModel.hideNIP24WarningDialog && + !channelScreenModel.nip24 && + !channelScreenModel.requiresNIP24 + ) { + wantsToActivateNIP24 = true + } else { + channelScreenModel.toggleNIP04And24() + } + }, + ) { + if (channelScreenModel.nip24) { + Icon( + painter = painterResource(id = R.drawable.incognito), + null, + modifier = Modifier.padding(top = 2.dp).size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } else { + Icon( + painter = painterResource(id = R.drawable.incognito_off), + null, + modifier = Modifier.padding(top = 2.dp).size(18.dp), + tint = MaterialTheme.colorScheme.placeholderText, + ) + } + } + } + }, + colors = + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + ) + } +} + +@Composable +fun NewFeatureNIP24AlertDialog( + accountViewModel: AccountViewModel, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + val scope = rememberCoroutineScope() + + QuickActionAlertDialog( + title = stringResource(R.string.new_feature_nip24_might_not_be_available_title), + textContent = stringResource(R.string.new_feature_nip24_might_not_be_available_description), + buttonIconResource = R.drawable.incognito, + buttonText = stringResource(R.string.new_feature_nip24_activate), + onClickDoOnce = { + scope.launch(Dispatchers.IO) { onConfirm() } + onDismiss() + }, + onClickDontShowAgain = { + scope.launch(Dispatchers.IO) { + onConfirm() + accountViewModel.dontShowNIP24WarningDialog() + } + onDismiss() + }, + onDismiss = onDismiss, + ) +} + +@Composable +fun ThinSendButton( + isActive: Boolean, + modifier: Modifier, + onClick: () -> Unit, +) { + IconButton( + enabled = isActive, + modifier = modifier, + onClick = onClick, + ) { + Icon( + imageVector = Icons.Default.Send, + null, + modifier = Size20Modifier, + ) + } +} + +@Composable +fun ChatroomHeader( + room: ChatroomKey, + modifier: Modifier = StdPadding, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + if (room.users.size == 1) { + LoadUser(baseUserHex = room.users.first(), accountViewModel) { baseUser -> + if (baseUser != null) { + ChatroomHeader( + baseUser = baseUser, + modifier = modifier, + accountViewModel = accountViewModel, + nav = nav, ) + } } + } else { + GroupChatroomHeader( + room = room, + modifier = modifier, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@Composable +fun ChatroomHeader( + baseUser: User, + modifier: Modifier = StdPadding, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + Column( + modifier = + Modifier.fillMaxWidth() + .clickable( + onClick = { nav("User/${baseUser.pubkeyHex}") }, + ), + ) { + Column( + verticalArrangement = Arrangement.Center, + modifier = modifier, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + ClickableUserPicture( + baseUser = baseUser, + accountViewModel = accountViewModel, + size = Size34dp, + ) + + Column(modifier = Modifier.padding(start = 10.dp)) { + UsernameDisplay(baseUser) + ObserveDisplayNip05Status(baseUser, accountViewModel = accountViewModel, nav = nav) + } + } + } + + Divider( + thickness = DividerThickness, + ) + } } @Composable fun GroupChatroomHeader( - room: ChatroomKey, - modifier: Modifier = StdPadding, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + room: ChatroomKey, + modifier: Modifier = StdPadding, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val expanded = remember { mutableStateOf(false) } + val expanded = remember { mutableStateOf(false) } + Column( + modifier = Modifier.fillMaxWidth().clickable { expanded.value = !expanded.value }, + ) { Column( - modifier = Modifier - .fillMaxWidth() - .clickable { - expanded.value = !expanded.value - } + verticalArrangement = Arrangement.Center, + modifier = modifier, ) { - Column( - verticalArrangement = Arrangement.Center, - modifier = modifier + Row(verticalAlignment = Alignment.CenterVertically) { + NonClickableUserPictures( + users = room.users, + accountViewModel = accountViewModel, + size = Size34dp, + ) + + Column(modifier = Modifier.padding(start = 10.dp)) { + RoomNameOnlyDisplay(room, Modifier, FontWeight.Bold, accountViewModel.userProfile()) + DisplayUserSetAsSubject(room, accountViewModel, FontWeight.Normal) + } + } + + if (expanded.value) { + LongRoomHeader(room = room, accountViewModel = accountViewModel, nav = nav) + } + } + + Divider( + thickness = DividerThickness, + ) + } +} + +@Composable +private fun EditRoomSubjectButton( + room: ChatroomKey, + accountViewModel: AccountViewModel, +) { + var wantsToPost by remember { mutableStateOf(false) } + + if (wantsToPost) { + NewSubjectView({ wantsToPost = false }, accountViewModel, room) + } + + Button( + modifier = Modifier.padding(horizontal = 3.dp).width(50.dp), + onClick = { wantsToPost = true }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Icon( + tint = Color.White, + imageVector = Icons.Default.EditNote, + contentDescription = stringResource(R.string.edits_the_channel_metadata), + ) + } +} + +@Composable +fun NewSubjectView( + onClose: () -> Unit, + accountViewModel: AccountViewModel, + room: ChatroomKey, +) { + Dialog( + onDismissRequest = { onClose() }, + properties = + DialogProperties( + dismissOnClickOutside = false, + ), + ) { + Surface { + val groupName = remember { + mutableStateOf(accountViewModel.userProfile().privateChatrooms[room]?.subject ?: "") + } + val message = remember { mutableStateOf("") } + val scope = rememberCoroutineScope() + + Column( + modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState()), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Row(verticalAlignment = Alignment.CenterVertically) { - NonClickableUserPictures( - users = room.users, - accountViewModel = accountViewModel, - size = Size34dp + CloseButton(onPress = { onClose() }) + + PostButton( + onPost = { + scope.launch(Dispatchers.IO) { + accountViewModel.account.sendNIP24PrivateMessage( + message = message.value, + toUsers = room.users.toList(), + subject = groupName.value.ifBlank { null }, + replyingTo = null, + mentions = null, + wantsToMarkAsSensitive = false, ) + } - Column(modifier = Modifier.padding(start = 10.dp)) { - RoomNameOnlyDisplay(room, Modifier, FontWeight.Bold, accountViewModel.userProfile()) - DisplayUserSetAsSubject(room, accountViewModel, FontWeight.Normal) - } - } - - if (expanded.value) { - LongRoomHeader(room = room, accountViewModel = accountViewModel, nav = nav) - } + onClose() + }, + true, + ) } - Divider( - thickness = DividerThickness + Spacer(modifier = Modifier.height(15.dp)) + + OutlinedTextField( + label = { Text(text = stringResource(R.string.messages_new_message_subject)) }, + modifier = Modifier.fillMaxWidth(), + value = groupName.value, + onValueChange = { groupName.value = it }, + placeholder = { + Text( + text = stringResource(R.string.messages_new_message_subject_caption), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), ) - } -} -@Composable -private fun EditRoomSubjectButton(room: ChatroomKey, accountViewModel: AccountViewModel) { - var wantsToPost by remember { - mutableStateOf(false) - } + Spacer(modifier = Modifier.height(15.dp)) - if (wantsToPost) { - NewSubjectView({ wantsToPost = false }, accountViewModel, room) - } - - Button( - modifier = Modifier - .padding(horizontal = 3.dp) - .width(50.dp), - onClick = { wantsToPost = true }, - shape = ButtonBorder, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ) - ) { - Icon( - tint = Color.White, - imageVector = Icons.Default.EditNote, - contentDescription = stringResource(R.string.edits_the_channel_metadata) + OutlinedTextField( + label = { Text(text = stringResource(R.string.messages_new_subject_message)) }, + modifier = Modifier.fillMaxWidth().height(100.dp), + value = message.value, + onValueChange = { message.value = it }, + placeholder = { + Text( + text = stringResource(R.string.messages_new_subject_message_placeholder), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + maxLines = 10, ) + } } -} - -@Composable -fun NewSubjectView(onClose: () -> Unit, accountViewModel: AccountViewModel, room: ChatroomKey) { - Dialog( - onDismissRequest = { onClose() }, - properties = DialogProperties( - dismissOnClickOutside = false - ) - ) { - Surface { - val groupName = remember { - mutableStateOf(accountViewModel.userProfile().privateChatrooms[room]?.subject ?: "") - } - val message = remember { - mutableStateOf("") - } - val scope = rememberCoroutineScope() - - Column( - modifier = Modifier - .padding(10.dp) - .verticalScroll(rememberScrollState()) - ) { - Row( - modifier = Modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - CloseButton(onPress = { - onClose() - }) - - PostButton( - onPost = { - scope.launch(Dispatchers.IO) { - accountViewModel.account.sendNIP24PrivateMessage( - message = message.value, - toUsers = room.users.toList(), - subject = groupName.value.ifBlank { null }, - replyingTo = null, - mentions = null, - wantsToMarkAsSensitive = false - ) - } - - onClose() - }, - true - ) - } - - Spacer(modifier = Modifier.height(15.dp)) - - OutlinedTextField( - label = { Text(text = stringResource(R.string.messages_new_message_subject)) }, - modifier = Modifier.fillMaxWidth(), - value = groupName.value, - onValueChange = { groupName.value = it }, - placeholder = { - Text( - text = stringResource(R.string.messages_new_message_subject_caption), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences - ), - textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content) - ) - - Spacer(modifier = Modifier.height(15.dp)) - - OutlinedTextField( - label = { Text(text = stringResource(R.string.messages_new_subject_message)) }, - modifier = Modifier - .fillMaxWidth() - .height(100.dp), - value = message.value, - onValueChange = { message.value = it }, - placeholder = { - Text( - text = stringResource(R.string.messages_new_subject_message_placeholder), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences - ), - textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - maxLines = 10 - - ) - } - } - } + } } @Composable fun LongRoomHeader( - room: ChatroomKey, - lineModifier: Modifier = StdPadding, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + room: ChatroomKey, + lineModifier: Modifier = StdPadding, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val list = remember(room) { - room.users.toPersistentList() - } + val list = remember(room) { room.users.toPersistentList() } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(id = R.string.messages_group_descriptor), - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - textAlign = TextAlign.Center - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(id = R.string.messages_group_descriptor), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + ) - EditRoomSubjectButton(room, accountViewModel) - } + EditRoomSubjectButton(room, accountViewModel) + } - LazyColumn( - modifier = Modifier, - state = rememberLazyListState() - ) { - itemsIndexed(list, key = { _, item -> item }) { _, item -> - LoadUser(baseUserHex = item, accountViewModel) { - if (it != null) { - UserCompose( - baseUser = it, - overallModifier = lineModifier, - accountViewModel = accountViewModel, - nav = nav - ) - } - } + LazyColumn( + modifier = Modifier, + state = rememberLazyListState(), + ) { + itemsIndexed(list, key = { _, item -> item }) { _, item -> + LoadUser(baseUserHex = item, accountViewModel) { + if (it != null) { + UserCompose( + baseUser = it, + overallModifier = lineModifier, + accountViewModel = accountViewModel, + nav = nav, + ) } + } } + } } @Composable -fun RoomNameOnlyDisplay(room: ChatroomKey, modifier: Modifier, fontWeight: FontWeight = FontWeight.Bold, loggedInUser: User) { - val roomSubject by loggedInUser.live().messages.map { - it.user.privateChatrooms[room]?.subject - }.distinctUntilChanged().observeAsState(loggedInUser.privateChatrooms[room]?.subject) +fun RoomNameOnlyDisplay( + room: ChatroomKey, + modifier: Modifier, + fontWeight: FontWeight = FontWeight.Bold, + loggedInUser: User, +) { + val roomSubject by + loggedInUser + .live() + .messages + .map { it.user.privateChatrooms[room]?.subject } + .distinctUntilChanged() + .observeAsState(loggedInUser.privateChatrooms[room]?.subject) - Crossfade(targetState = roomSubject, modifier) { - if (it != null && it.isNotBlank()) { - DisplayRoomSubject(it, fontWeight) - } + Crossfade(targetState = roomSubject, modifier) { + if (it != null && it.isNotBlank()) { + DisplayRoomSubject(it, fontWeight) } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/CommunityScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/CommunityScreen.kt index 8d7d256c5..3b58ac6b6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/CommunityScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/CommunityScreen.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen.loggedIn import androidx.compose.foundation.layout.Column @@ -17,69 +37,80 @@ import com.vitorpamplona.amethyst.ui.screen.NostrCommunityFeedViewModel import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView @Composable -fun CommunityScreen(aTagHex: String?, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - if (aTagHex == null) return +fun CommunityScreen( + aTagHex: String?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + if (aTagHex == null) return - LoadAddressableNote(aTagHex = aTagHex, accountViewModel) { - it?.let { - PrepareViewModelsCommunityScreen( - note = it, - accountViewModel = accountViewModel, - nav = nav - ) - } + LoadAddressableNote(aTagHex = aTagHex, accountViewModel) { + it?.let { + PrepareViewModelsCommunityScreen( + note = it, + accountViewModel = accountViewModel, + nav = nav, + ) } + } } @Composable -fun PrepareViewModelsCommunityScreen(note: AddressableNote, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val followsFeedViewModel: NostrCommunityFeedViewModel = viewModel( - key = note.idHex + "CommunityFeedViewModel", - factory = NostrCommunityFeedViewModel.Factory( - note, - accountViewModel.account - ) +fun PrepareViewModelsCommunityScreen( + note: AddressableNote, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val followsFeedViewModel: NostrCommunityFeedViewModel = + viewModel( + key = note.idHex + "CommunityFeedViewModel", + factory = + NostrCommunityFeedViewModel.Factory( + note, + accountViewModel.account, + ), ) - CommunityScreen(note, followsFeedViewModel, accountViewModel, nav) + CommunityScreen(note, followsFeedViewModel, accountViewModel, nav) } @Composable -fun CommunityScreen(note: AddressableNote, feedViewModel: NostrCommunityFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val lifeCycleOwner = LocalLifecycleOwner.current +fun CommunityScreen( + note: AddressableNote, + feedViewModel: NostrCommunityFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val lifeCycleOwner = LocalLifecycleOwner.current - NostrCommunityDataSource.loadCommunity(note) + NostrCommunityDataSource.loadCommunity(note) - LaunchedEffect(note) { + LaunchedEffect(note) { feedViewModel.invalidateData() } + + DisposableEffect(lifeCycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Community Start") + NostrCommunityDataSource.start() feedViewModel.invalidateData() + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Community Stop") + NostrCommunityDataSource.loadCommunity(null) + NostrCommunityDataSource.stop() + } } - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Community Start") - NostrCommunityDataSource.start() - feedViewModel.invalidateData() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Community Stop") - NostrCommunityDataSource.loadCommunity(null) - NostrCommunityDataSource.stop() - } - } + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } + } - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { - lifeCycleOwner.lifecycle.removeObserver(observer) - } - } - - Column(Modifier.fillMaxSize()) { - RefresheableFeedView( - feedViewModel, - null, - accountViewModel = accountViewModel, - nav = nav - ) - } + Column(Modifier.fillMaxSize()) { + RefresheableFeedView( + feedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt index 4e788c135..46ad9162e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen.loggedIn import androidx.compose.foundation.layout.Arrangement @@ -36,105 +56,117 @@ import com.vitorpamplona.amethyst.ui.theme.RichTextDefaults import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable -fun ConnectOrbotDialog(onClose: () -> Unit, onPost: () -> Unit, onError: (String) -> Unit, portNumber: MutableState) { - Dialog( - onDismissRequest = onClose, - properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false) - ) { - Surface() { - Column( - modifier = Modifier.padding(10.dp) - ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - CloseButton(onPress = { - onClose() - }) +fun ConnectOrbotDialog( + onClose: () -> Unit, + onPost: () -> Unit, + onError: (String) -> Unit, + portNumber: MutableState, +) { + Dialog( + onDismissRequest = onClose, + properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false), + ) { + Surface { + Column( + modifier = Modifier.padding(10.dp), + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + CloseButton(onPress = { onClose() }) - val toastMessage = stringResource(R.string.invalid_port_number) + val toastMessage = stringResource(R.string.invalid_port_number) - UseOrbotButton( - onPost = { - try { - Integer.parseInt(portNumber.value) - } catch (_: Exception) { - onError(toastMessage) - return@UseOrbotButton - } + UseOrbotButton( + onPost = { + try { + Integer.parseInt(portNumber.value) + } catch (_: Exception) { + onError(toastMessage) + return@UseOrbotButton + } - onPost() - }, - isActive = true - ) - } - - Column( - modifier = Modifier.padding(30.dp) - ) { - val myMarkDownStyle = RichTextDefaults.copy( - stringStyle = RichTextDefaults.stringStyle?.copy( - linkStyle = SpanStyle( - textDecoration = TextDecoration.Underline, - color = MaterialTheme.colorScheme.primary - ) - ) - ) - - Row() { - Material3RichText( - style = myMarkDownStyle - ) { - Markdown( - content = stringResource(R.string.connect_through_your_orbot_setup_markdown) - ) - } - } - - Spacer(modifier = Modifier.height(15.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - OutlinedTextField( - value = portNumber.value, - onValueChange = { portNumber.value = it }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None, - keyboardType = KeyboardType.Number - ), - label = { Text(text = stringResource(R.string.orbot_socks_port)) }, - placeholder = { - Text( - text = "9050", - color = MaterialTheme.colorScheme.placeholderText - ) - } - ) - } - } - } + onPost() + }, + isActive = true, + ) } + + Column( + modifier = Modifier.padding(30.dp), + ) { + val myMarkDownStyle = + RichTextDefaults.copy( + stringStyle = + RichTextDefaults.stringStyle?.copy( + linkStyle = + SpanStyle( + textDecoration = TextDecoration.Underline, + color = MaterialTheme.colorScheme.primary, + ), + ), + ) + + Row { + Material3RichText( + style = myMarkDownStyle, + ) { + Markdown( + content = stringResource(R.string.connect_through_your_orbot_setup_markdown), + ) + } + } + + Spacer(modifier = Modifier.height(15.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + OutlinedTextField( + value = portNumber.value, + onValueChange = { portNumber.value = it }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Number, + ), + label = { Text(text = stringResource(R.string.orbot_socks_port)) }, + placeholder = { + Text( + text = "9050", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + ) + } + } + } } + } } @Composable -fun UseOrbotButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier = Modifier) { - Button( - modifier = modifier, - onClick = { - if (isActive) { - onPost() - } - }, - shape = ButtonBorder, - colors = ButtonDefaults.buttonColors( - containerColor = if (isActive) MaterialTheme.colorScheme.primary else Color.Gray - ) - ) { - Text(text = stringResource(R.string.use_orbot), color = Color.White) - } +fun UseOrbotButton( + onPost: () -> Unit = {}, + isActive: Boolean, + modifier: Modifier = Modifier, +) { + Button( + modifier = modifier, + onClick = { + if (isActive) { + onPost() + } + }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = if (isActive) MaterialTheme.colorScheme.primary else Color.Gray, + ), + ) { + Text(text = stringResource(R.string.use_orbot), color = Color.White) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt index e137e82cd..8de0a47ab 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen.loggedIn import androidx.compose.animation.Crossfade @@ -68,262 +88,273 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable fun DiscoverScreen( - discoveryMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel, - discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel, - discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel, - discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + discoveryMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel, + discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel, + discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel, + discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val lifeCycleOwner = LocalLifecycleOwner.current + val lifeCycleOwner = LocalLifecycleOwner.current - val tabs by remember(discoveryLiveFeedViewModel, discoveryCommunityFeedViewModel, discoveryChatFeedViewModel) { - mutableStateOf( - listOf( - TabItem(R.string.discover_marketplace, discoveryMarketplaceFeedViewModel, Route.Discover.base + "Marketplace", ScrollStateKeys.DISCOVER_MARKETPLACE, ClassifiedsEvent.kind), - TabItem(R.string.discover_live, discoveryLiveFeedViewModel, Route.Discover.base + "Live", ScrollStateKeys.DISCOVER_LIVE, LiveActivitiesEvent.kind), - TabItem(R.string.discover_community, discoveryCommunityFeedViewModel, Route.Discover.base + "Community", ScrollStateKeys.DISCOVER_COMMUNITY, CommunityDefinitionEvent.kind), - TabItem(R.string.discover_chat, discoveryChatFeedViewModel, Route.Discover.base + "Chats", ScrollStateKeys.DISCOVER_CHATS, ChannelCreateEvent.kind) - ).toImmutableList() - ) + val tabs by + remember( + discoveryLiveFeedViewModel, + discoveryCommunityFeedViewModel, + discoveryChatFeedViewModel, + ) { + mutableStateOf( + listOf( + TabItem( + R.string.discover_marketplace, + discoveryMarketplaceFeedViewModel, + Route.Discover.base + "Marketplace", + ScrollStateKeys.DISCOVER_MARKETPLACE, + ClassifiedsEvent.KIND, + ), + TabItem( + R.string.discover_live, + discoveryLiveFeedViewModel, + Route.Discover.base + "Live", + ScrollStateKeys.DISCOVER_LIVE, + LiveActivitiesEvent.KIND, + ), + TabItem( + R.string.discover_community, + discoveryCommunityFeedViewModel, + Route.Discover.base + "Community", + ScrollStateKeys.DISCOVER_COMMUNITY, + CommunityDefinitionEvent.KIND, + ), + TabItem( + R.string.discover_chat, + discoveryChatFeedViewModel, + Route.Discover.base + "Chats", + ScrollStateKeys.DISCOVER_CHATS, + ChannelCreateEvent.KIND, + ), + ) + .toImmutableList(), + ) } - val pagerState = rememberForeverPagerState(key = PagerStateKeys.DISCOVER_SCREEN) { tabs.size } + val pagerState = rememberForeverPagerState(key = PagerStateKeys.DISCOVER_SCREEN) { tabs.size } - WatchAccountForDiscoveryScreen( - discoverMarketplaceFeedViewModel = discoveryMarketplaceFeedViewModel, - discoveryLiveFeedViewModel = discoveryLiveFeedViewModel, - discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel, - discoveryChatFeedViewModel = discoveryChatFeedViewModel, - accountViewModel = accountViewModel - ) + WatchAccountForDiscoveryScreen( + discoverMarketplaceFeedViewModel = discoveryMarketplaceFeedViewModel, + discoveryLiveFeedViewModel = discoveryLiveFeedViewModel, + discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel, + discoveryChatFeedViewModel = discoveryChatFeedViewModel, + accountViewModel = accountViewModel, + ) - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Discovery Start") - NostrDiscoveryDataSource.start() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { - lifeCycleOwner.lifecycle.removeObserver(observer) - } + DisposableEffect(lifeCycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Discovery Start") + NostrDiscoveryDataSource.start() + } } - Column(Modifier.fillMaxHeight()) { - Column( - modifier = Modifier.padding(vertical = 0.dp) - ) { - DiscoverPages(pagerState, tabs, accountViewModel, nav) - } + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } + } + + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp), + ) { + DiscoverPages(pagerState, tabs, accountViewModel, nav) } + } } @Composable @OptIn(ExperimentalFoundationApi::class) private fun DiscoverPages( - pagerState: PagerState, - tabs: ImmutableList, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + pagerState: PagerState, + tabs: ImmutableList, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - ScrollableTabRow( - containerColor = MaterialTheme.colorScheme.background, - contentColor = MaterialTheme.colorScheme.onBackground, - selectedTabIndex = pagerState.currentPage, - modifier = TabRowHeight, - edgePadding = 8.dp - ) { - val coroutineScope = rememberCoroutineScope() + ScrollableTabRow( + containerColor = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + selectedTabIndex = pagerState.currentPage, + modifier = TabRowHeight, + edgePadding = 8.dp, + ) { + val coroutineScope = rememberCoroutineScope() - tabs.forEachIndexed { index, tab -> - Tab( - selected = pagerState.currentPage == index, - text = { - Text(text = stringResource(tab.resource)) - }, - onClick = { - coroutineScope.launch { pagerState.animateScrollToPage(index) } - } - ) - } + tabs.forEachIndexed { index, tab -> + Tab( + selected = pagerState.currentPage == index, + text = { Text(text = stringResource(tab.resource)) }, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } }, + ) } + } - HorizontalPager(state = pagerState) { page -> - RefresheableView(tabs[page].viewModel, true) { - if (tabs[page].viewModel is NostrDiscoverMarketplaceFeedViewModel) { - SaveableGridFeedState(tabs[page].viewModel, scrollStateKey = tabs[page].scrollStateKey) { listState -> - RenderDiscoverFeed( - viewModel = tabs[page].viewModel, - routeForLastRead = tabs[page].routeForLastRead, - forceEventKind = tabs[page].forceEventKind, - listState = listState, - accountViewModel = accountViewModel, - nav = nav - ) - } - } else { - SaveableFeedState(tabs[page].viewModel, scrollStateKey = tabs[page].scrollStateKey) { listState -> - RenderDiscoverFeed( - viewModel = tabs[page].viewModel, - routeForLastRead = tabs[page].routeForLastRead, - forceEventKind = tabs[page].forceEventKind, - listState = listState, - accountViewModel = accountViewModel, - nav = nav - ) - } - } + HorizontalPager(state = pagerState) { page -> + RefresheableView(tabs[page].viewModel, true) { + if (tabs[page].viewModel is NostrDiscoverMarketplaceFeedViewModel) { + SaveableGridFeedState(tabs[page].viewModel, scrollStateKey = tabs[page].scrollStateKey) { + listState -> + RenderDiscoverFeed( + viewModel = tabs[page].viewModel, + routeForLastRead = tabs[page].routeForLastRead, + forceEventKind = tabs[page].forceEventKind, + listState = listState, + accountViewModel = accountViewModel, + nav = nav, + ) } + } else { + SaveableFeedState(tabs[page].viewModel, scrollStateKey = tabs[page].scrollStateKey) { + listState -> + RenderDiscoverFeed( + viewModel = tabs[page].viewModel, + routeForLastRead = tabs[page].routeForLastRead, + forceEventKind = tabs[page].forceEventKind, + listState = listState, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } } + } } @Composable private fun RenderDiscoverFeed( - viewModel: FeedViewModel, - routeForLastRead: String?, - forceEventKind: Int?, - listState: ScrollableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + viewModel: FeedViewModel, + routeForLastRead: String?, + forceEventKind: Int?, + listState: ScrollableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val feedState by viewModel.feedContent.collectAsStateWithLifecycle() + val feedState by viewModel.feedContent.collectAsStateWithLifecycle() - Crossfade( - targetState = feedState, - animationSpec = tween(durationMillis = 100), - label = "RenderDiscoverFeed" - ) { state -> - when (state) { - is FeedState.Empty -> { - FeedEmpty { - viewModel.invalidateData() - } - } - - is FeedState.FeedError -> { - FeedError(state.errorMessage) { - viewModel.invalidateData() - } - } - - is FeedState.Loaded -> { - if (listState is LazyGridState) { - DiscoverFeedColumnsLoaded( - state, - routeForLastRead, - listState, - forceEventKind, - accountViewModel, - nav - ) - } else if (listState is LazyListState) { - DiscoverFeedLoaded( - state, - routeForLastRead, - listState, - forceEventKind, - accountViewModel, - nav - ) - } - } - - is FeedState.Loading -> { - LoadingFeed() - } + Crossfade( + targetState = feedState, + animationSpec = tween(durationMillis = 100), + label = "RenderDiscoverFeed", + ) { state -> + when (state) { + is FeedState.Empty -> { + FeedEmpty { viewModel.invalidateData() } + } + is FeedState.FeedError -> { + FeedError(state.errorMessage) { viewModel.invalidateData() } + } + is FeedState.Loaded -> { + if (listState is LazyGridState) { + DiscoverFeedColumnsLoaded( + state, + routeForLastRead, + listState, + forceEventKind, + accountViewModel, + nav, + ) + } else if (listState is LazyListState) { + DiscoverFeedLoaded( + state, + routeForLastRead, + listState, + forceEventKind, + accountViewModel, + nav, + ) } + } + is FeedState.Loading -> { + LoadingFeed() + } } + } } @Composable fun WatchAccountForDiscoveryScreen( - discoverMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel, - discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel, - discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel, - discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel, - accountViewModel: AccountViewModel + discoverMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel, + discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel, + discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel, + discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel, + accountViewModel: AccountViewModel, ) { - val listState by accountViewModel.account.liveStoriesFollowLists.collectAsStateWithLifecycle() + val listState by accountViewModel.account.liveStoriesFollowLists.collectAsStateWithLifecycle() - LaunchedEffect(accountViewModel, listState) { - NostrDiscoveryDataSource.resetFilters() - discoverMarketplaceFeedViewModel.checkKeysInvalidateDataAndSendToTop() - discoveryLiveFeedViewModel.checkKeysInvalidateDataAndSendToTop() - discoveryCommunityFeedViewModel.checkKeysInvalidateDataAndSendToTop() - discoveryChatFeedViewModel.checkKeysInvalidateDataAndSendToTop() - } + LaunchedEffect(accountViewModel, listState) { + NostrDiscoveryDataSource.resetFilters() + discoverMarketplaceFeedViewModel.checkKeysInvalidateDataAndSendToTop() + discoveryLiveFeedViewModel.checkKeysInvalidateDataAndSendToTop() + discoveryCommunityFeedViewModel.checkKeysInvalidateDataAndSendToTop() + discoveryChatFeedViewModel.checkKeysInvalidateDataAndSendToTop() + } } @OptIn(ExperimentalFoundationApi::class) @Composable private fun DiscoverFeedLoaded( - state: FeedState.Loaded, - routeForLastRead: String?, - listState: LazyListState, - forceEventKind: Int?, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + state: FeedState.Loaded, + routeForLastRead: String?, + listState: LazyListState, + forceEventKind: Int?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - LazyColumn( - contentPadding = FeedPadding, - state = listState - ) { - itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item -> - val defaultModifier = remember { - Modifier - .fillMaxWidth() - .animateItemPlacement() - } + LazyColumn( + contentPadding = FeedPadding, + state = listState, + ) { + itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item -> + val defaultModifier = remember { Modifier.fillMaxWidth().animateItemPlacement() } - Row(defaultModifier) { - ChannelCardCompose( - baseNote = item, - routeForLastRead = routeForLastRead, - modifier = Modifier, - forceEventKind = forceEventKind, - accountViewModel = accountViewModel, - nav = nav - ) - } - } + Row(defaultModifier) { + ChannelCardCompose( + baseNote = item, + routeForLastRead = routeForLastRead, + modifier = Modifier, + forceEventKind = forceEventKind, + accountViewModel = accountViewModel, + nav = nav, + ) + } } + } } @OptIn(ExperimentalFoundationApi::class) @Composable private fun DiscoverFeedColumnsLoaded( - state: FeedState.Loaded, - routeForLastRead: String?, - listState: LazyGridState, - forceEventKind: Int?, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + state: FeedState.Loaded, + routeForLastRead: String?, + listState: LazyGridState, + forceEventKind: Int?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - LazyVerticalGrid( - columns = GridCells.Fixed(2), - contentPadding = FeedPadding, - state = listState - ) { - itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item -> - val defaultModifier = remember { - Modifier - .fillMaxWidth() - .animateItemPlacement() - } + LazyVerticalGrid( + columns = GridCells.Fixed(2), + contentPadding = FeedPadding, + state = listState, + ) { + itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item -> + val defaultModifier = remember { Modifier.fillMaxWidth().animateItemPlacement() } - Row(defaultModifier) { - ChannelCardCompose( - baseNote = item, - routeForLastRead = routeForLastRead, - modifier = Modifier, - forceEventKind = forceEventKind, - accountViewModel = accountViewModel, - nav = nav - ) - } - } + Row(defaultModifier) { + ChannelCardCompose( + baseNote = item, + routeForLastRead = routeForLastRead, + modifier = Modifier, + forceEventKind = forceEventKind, + accountViewModel = accountViewModel, + nav = nav, + ) + } } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/GeoHashScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/GeoHashScreen.kt index 542e2cde2..d7628967b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/GeoHashScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/GeoHashScreen.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen.loggedIn import androidx.compose.foundation.clickable @@ -39,154 +59,175 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @Composable -fun GeoHashScreen(tag: String?, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - if (tag == null) return +fun GeoHashScreen( + tag: String?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + if (tag == null) return - PrepareViewModelsGeoHashScreen(tag, accountViewModel, nav) + PrepareViewModelsGeoHashScreen(tag, accountViewModel, nav) } @Composable -fun PrepareViewModelsGeoHashScreen(tag: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val followsFeedViewModel: NostrGeoHashFeedViewModel = viewModel( - key = tag + "GeoHashFeedViewModel", - factory = NostrGeoHashFeedViewModel.Factory( - tag, - accountViewModel.account - ) +fun PrepareViewModelsGeoHashScreen( + tag: String, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val followsFeedViewModel: NostrGeoHashFeedViewModel = + viewModel( + key = tag + "GeoHashFeedViewModel", + factory = + NostrGeoHashFeedViewModel.Factory( + tag, + accountViewModel.account, + ), ) - GeoHashScreen(tag, followsFeedViewModel, accountViewModel, nav) + GeoHashScreen(tag, followsFeedViewModel, accountViewModel, nav) } @Composable -fun GeoHashScreen(tag: String, feedViewModel: NostrGeoHashFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val lifeCycleOwner = LocalLifecycleOwner.current +fun GeoHashScreen( + tag: String, + feedViewModel: NostrGeoHashFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val lifeCycleOwner = LocalLifecycleOwner.current - NostrGeohashDataSource.loadHashtag(tag) + NostrGeohashDataSource.loadHashtag(tag) - DisposableEffect(tag) { + DisposableEffect(tag) { + NostrGeohashDataSource.start() + feedViewModel.invalidateData() + onDispose { + NostrGeohashDataSource.loadHashtag(null) + NostrGeohashDataSource.stop() + } + } + + DisposableEffect(lifeCycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Hashtag Start") + NostrGeohashDataSource.loadHashtag(tag) NostrGeohashDataSource.start() feedViewModel.invalidateData() - onDispose { - NostrGeohashDataSource.loadHashtag(null) - NostrGeohashDataSource.stop() - } + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Hashtag Stop") + NostrGeohashDataSource.loadHashtag(null) + NostrGeohashDataSource.stop() + } } - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Hashtag Start") - NostrGeohashDataSource.loadHashtag(tag) - NostrGeohashDataSource.start() - feedViewModel.invalidateData() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Hashtag Stop") - NostrGeohashDataSource.loadHashtag(null) - NostrGeohashDataSource.stop() - } - } + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } + } - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { - lifeCycleOwner.lifecycle.removeObserver(observer) - } - } - - Column(Modifier.fillMaxHeight()) { - Column( - modifier = Modifier.padding(vertical = 0.dp) - ) { - RefresheableFeedView( - feedViewModel, - null, - accountViewModel = accountViewModel, - nav = nav - ) - } - } -} - -@Composable -fun GeoHashHeader(tag: String, modifier: Modifier = StdPadding, account: AccountViewModel, onClick: () -> Unit = { }) { + Column(Modifier.fillMaxHeight()) { Column( - Modifier.fillMaxWidth().clickable { onClick() } + modifier = Modifier.padding(vertical = 0.dp), ) { - Column(modifier = modifier) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - DislayGeoTagHeader(tag, remember { Modifier.weight(1f) }) - - GeoHashActionOptions(tag, account) - } - } - - Divider( - thickness = DividerThickness - ) + RefresheableFeedView( + feedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) } + } } @Composable -fun DislayGeoTagHeader(geohash: String, modifier: Modifier) { - val context = LocalContext.current +fun GeoHashHeader( + tag: String, + modifier: Modifier = StdPadding, + account: AccountViewModel, + onClick: () -> Unit = {}, +) { + Column( + Modifier.fillMaxWidth().clickable { onClick() }, + ) { + Column(modifier = modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + DislayGeoTagHeader(tag, remember { Modifier.weight(1f) }) - var cityName by remember(geohash) { - mutableStateOf(geohash) + GeoHashActionOptions(tag, account) + } } - LaunchedEffect(key1 = geohash) { - launch(Dispatchers.IO) { - val newCityName = ReverseGeoLocationUtil().execute(geohash.toGeoHash().toLocation(), context)?.ifBlank { null } - if (newCityName != null && newCityName != cityName) { - cityName = "$newCityName ($geohash)" - } - } - } - - Text( - cityName, - fontWeight = FontWeight.Bold, - modifier = modifier + Divider( + thickness = DividerThickness, ) + } +} + +@Composable +fun DislayGeoTagHeader( + geohash: String, + modifier: Modifier, +) { + val context = LocalContext.current + + var cityName by remember(geohash) { mutableStateOf(geohash) } + + LaunchedEffect(key1 = geohash) { + launch(Dispatchers.IO) { + val newCityName = + ReverseGeoLocationUtil().execute(geohash.toGeoHash().toLocation(), context)?.ifBlank { + null + } + if (newCityName != null && newCityName != cityName) { + cityName = "$newCityName ($geohash)" + } + } + } + + Text( + cityName, + fontWeight = FontWeight.Bold, + modifier = modifier, + ) } @Composable fun GeoHashActionOptions( - tag: String, - accountViewModel: AccountViewModel + tag: String, + accountViewModel: AccountViewModel, ) { - val userState by accountViewModel.userProfile().live().follows.observeAsState() - val isFollowingTag by remember(userState) { - derivedStateOf { - userState?.user?.isFollowingGeohashCached(tag) ?: false - } + val userState by accountViewModel.userProfile().live().follows.observeAsState() + val isFollowingTag by + remember(userState) { + derivedStateOf { userState?.user?.isFollowingGeohashCached(tag) ?: false } } - if (isFollowingTag) { - UnfollowButton { - if (!accountViewModel.isWriteable()) { - accountViewModel.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_unfollow - ) - } else { - accountViewModel.unfollowGeohash(tag) - } - } - } else { - FollowButton { - if (!accountViewModel.isWriteable()) { - accountViewModel.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_follow - ) - } else { - accountViewModel.followGeohash(tag) - } - } + if (isFollowingTag) { + UnfollowButton { + if (!accountViewModel.isWriteable()) { + accountViewModel.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_unfollow, + ) + } else { + accountViewModel.unfollowGeohash(tag) + } } + } else { + FollowButton { + if (!accountViewModel.isWriteable()) { + accountViewModel.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_follow, + ) + } else { + accountViewModel.followGeohash(tag) + } + } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt index a9a4e463f..ede6d4bb8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen.loggedIn import androidx.compose.foundation.clickable @@ -31,135 +51,152 @@ import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.StdPadding @Composable -fun HashtagScreen(tag: String?, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - if (tag == null) return +fun HashtagScreen( + tag: String?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + if (tag == null) return - PrepareViewModelsHashtagScreen(tag, accountViewModel, nav) + PrepareViewModelsHashtagScreen(tag, accountViewModel, nav) } @Composable -fun PrepareViewModelsHashtagScreen(tag: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val followsFeedViewModel: NostrHashtagFeedViewModel = viewModel( - key = tag + "HashtagFeedViewModel", - factory = NostrHashtagFeedViewModel.Factory( - tag, - accountViewModel.account - ) +fun PrepareViewModelsHashtagScreen( + tag: String, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val followsFeedViewModel: NostrHashtagFeedViewModel = + viewModel( + key = tag + "HashtagFeedViewModel", + factory = + NostrHashtagFeedViewModel.Factory( + tag, + accountViewModel.account, + ), ) - HashtagScreen(tag, followsFeedViewModel, accountViewModel, nav) + HashtagScreen(tag, followsFeedViewModel, accountViewModel, nav) } @Composable -fun HashtagScreen(tag: String, feedViewModel: NostrHashtagFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val lifeCycleOwner = LocalLifecycleOwner.current +fun HashtagScreen( + tag: String, + feedViewModel: NostrHashtagFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val lifeCycleOwner = LocalLifecycleOwner.current - NostrHashtagDataSource.loadHashtag(tag) + NostrHashtagDataSource.loadHashtag(tag) - DisposableEffect(tag) { + DisposableEffect(tag) { + NostrHashtagDataSource.start() + feedViewModel.invalidateData() + + onDispose { + NostrHashtagDataSource.loadHashtag(null) + NostrHashtagDataSource.stop() + } + } + + DisposableEffect(lifeCycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Hashtag Start") + NostrHashtagDataSource.loadHashtag(tag) NostrHashtagDataSource.start() feedViewModel.invalidateData() - - onDispose { - NostrHashtagDataSource.loadHashtag(null) - NostrHashtagDataSource.stop() - } + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Hashtag Stop") + NostrHashtagDataSource.loadHashtag(null) + NostrHashtagDataSource.stop() + } } - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Hashtag Start") - NostrHashtagDataSource.loadHashtag(tag) - NostrHashtagDataSource.start() - feedViewModel.invalidateData() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Hashtag Stop") - NostrHashtagDataSource.loadHashtag(null) - NostrHashtagDataSource.stop() - } - } + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } + } - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { - lifeCycleOwner.lifecycle.removeObserver(observer) - } - } - - Column(Modifier.fillMaxHeight()) { - Column( - modifier = Modifier.padding(vertical = 0.dp) - ) { - RefresheableFeedView( - feedViewModel, - null, - accountViewModel = accountViewModel, - nav = nav - ) - } + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp), + ) { + RefresheableFeedView( + feedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) } + } } @Composable -fun HashtagHeader(tag: String, modifier: Modifier = StdPadding, account: AccountViewModel, onClick: () -> Unit = { }) { - Column( - Modifier.fillMaxWidth().clickable { onClick() } - ) { - Column(modifier = modifier) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - Text( - "#$tag", - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f) - ) - - HashtagActionOptions(tag, account) - } - } - - Divider( - thickness = DividerThickness +fun HashtagHeader( + tag: String, + modifier: Modifier = StdPadding, + account: AccountViewModel, + onClick: () -> Unit = {}, +) { + Column( + Modifier.fillMaxWidth().clickable { onClick() }, + ) { + Column(modifier = modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Text( + "#$tag", + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), ) + + HashtagActionOptions(tag, account) + } } + + Divider( + thickness = DividerThickness, + ) + } } @Composable fun HashtagActionOptions( - tag: String, - accountViewModel: AccountViewModel + tag: String, + accountViewModel: AccountViewModel, ) { - val userState by accountViewModel.userProfile().live().follows.observeAsState() - val isFollowingTag by remember(userState) { - derivedStateOf { - userState?.user?.isFollowingHashtagCached(tag) ?: false - } + val userState by accountViewModel.userProfile().live().follows.observeAsState() + val isFollowingTag by + remember(userState) { + derivedStateOf { userState?.user?.isFollowingHashtagCached(tag) ?: false } } - if (isFollowingTag) { - UnfollowButton { - if (!accountViewModel.isWriteable()) { - accountViewModel.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_unfollow - ) - } else { - accountViewModel.unfollowHashtag(tag) - } - } - } else { - FollowButton { - if (!accountViewModel.isWriteable()) { - accountViewModel.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_follow - ) - } else { - accountViewModel.followHashtag(tag) - } - } + if (isFollowingTag) { + UnfollowButton { + if (!accountViewModel.isWriteable()) { + accountViewModel.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_unfollow, + ) + } else { + accountViewModel.unfollowHashtag(tag) + } } + } else { + FollowButton { + if (!accountViewModel.isWriteable()) { + accountViewModel.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_follow, + ) + } else { + accountViewModel.followHashtag(tag) + } + } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt index 40627ae22..4c1271bdc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen.loggedIn import androidx.compose.foundation.ExperimentalFoundationApi @@ -65,297 +85,306 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText import kotlinx.coroutines.launch @Composable -fun HiddenUsersScreen(accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val hiddenFeedViewModel: NostrHiddenAccountsFeedViewModel = viewModel( - factory = NostrHiddenAccountsFeedViewModel.Factory(accountViewModel.account) +fun HiddenUsersScreen( + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val hiddenFeedViewModel: NostrHiddenAccountsFeedViewModel = + viewModel( + factory = NostrHiddenAccountsFeedViewModel.Factory(accountViewModel.account), ) - val hiddenWordsFeedViewModel: NostrHiddenWordsFeedViewModel = viewModel( - factory = NostrHiddenWordsFeedViewModel.Factory(accountViewModel.account) + val hiddenWordsFeedViewModel: NostrHiddenWordsFeedViewModel = + viewModel( + factory = NostrHiddenWordsFeedViewModel.Factory(accountViewModel.account), ) - val spammerFeedViewModel: NostrSpammerAccountsFeedViewModel = viewModel( - factory = NostrSpammerAccountsFeedViewModel.Factory(accountViewModel.account) + val spammerFeedViewModel: NostrSpammerAccountsFeedViewModel = + viewModel( + factory = NostrSpammerAccountsFeedViewModel.Factory(accountViewModel.account), ) - HiddenUsersScreen(hiddenFeedViewModel, hiddenWordsFeedViewModel, spammerFeedViewModel, accountViewModel, nav) + HiddenUsersScreen( + hiddenFeedViewModel, + hiddenWordsFeedViewModel, + spammerFeedViewModel, + accountViewModel, + nav, + ) } @OptIn(ExperimentalFoundationApi::class) @Composable fun HiddenUsersScreen( - hiddenFeedViewModel: NostrHiddenAccountsFeedViewModel, - hiddenWordsViewModel: NostrHiddenWordsFeedViewModel, - spammerFeedViewModel: NostrSpammerAccountsFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + hiddenFeedViewModel: NostrHiddenAccountsFeedViewModel, + hiddenWordsViewModel: NostrHiddenWordsFeedViewModel, + spammerFeedViewModel: NostrSpammerAccountsFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val lifeCycleOwner = LocalLifecycleOwner.current + val lifeCycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Hidden Users Start") - hiddenWordsViewModel.invalidateData() - hiddenFeedViewModel.invalidateData() - spammerFeedViewModel.invalidateData() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { - lifeCycleOwner.lifecycle.removeObserver(observer) - } + DisposableEffect(lifeCycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Hidden Users Start") + hiddenWordsViewModel.invalidateData() + hiddenFeedViewModel.invalidateData() + spammerFeedViewModel.invalidateData() + } } - Column(Modifier.fillMaxHeight()) { - val pagerState = rememberPagerState() { 3 } - val coroutineScope = rememberCoroutineScope() - var warnAboutReports by remember { mutableStateOf(accountViewModel.account.warnAboutPostsWithReports) } - var filterSpam by remember { mutableStateOf(accountViewModel.account.filterSpamFromStrangers) } + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } + } - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = warnAboutReports, - onCheckedChange = { - warnAboutReports = it - accountViewModel.account.updateOptOutOptions(warnAboutReports, filterSpam) - } - ) - - Text(stringResource(R.string.warn_when_posts_have_reports_from_your_follows)) - } - - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = filterSpam, - onCheckedChange = { - filterSpam = it - accountViewModel.account.updateOptOutOptions(warnAboutReports, filterSpam) - } - ) - - Text(stringResource(R.string.filter_spam_from_strangers)) - } - - ScrollableTabRow( - containerColor = MaterialTheme.colorScheme.background, - contentColor = MaterialTheme.colorScheme.onBackground, - edgePadding = 8.dp, - selectedTabIndex = pagerState.currentPage, - modifier = TabRowHeight, - divider = { - Divider(thickness = DividerThickness) - } - ) { - Tab( - selected = pagerState.currentPage == 0, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } }, - text = { - Text(text = stringResource(R.string.blocked_users)) - } - ) - Tab( - selected = pagerState.currentPage == 1, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } }, - text = { - Text(text = stringResource(R.string.spamming_users)) - } - ) - Tab( - selected = pagerState.currentPage == 2, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } }, - text = { - Text(text = stringResource(R.string.hidden_words)) - } - ) - } - HorizontalPager(state = pagerState) { page -> - when (page) { - 0 -> RefreshingUserFeedView(hiddenFeedViewModel, accountViewModel) { - RefreshingFeedUserFeedView(hiddenFeedViewModel, accountViewModel, nav) - } - 1 -> RefreshingUserFeedView(spammerFeedViewModel, accountViewModel) { - RefreshingFeedUserFeedView(spammerFeedViewModel, accountViewModel, nav) - } - 2 -> HiddenWordsFeed(hiddenWordsViewModel, accountViewModel) - } - } + Column(Modifier.fillMaxHeight()) { + val pagerState = rememberPagerState { 3 } + val coroutineScope = rememberCoroutineScope() + var warnAboutReports by remember { + mutableStateOf(accountViewModel.account.warnAboutPostsWithReports) } + var filterSpam by remember { mutableStateOf(accountViewModel.account.filterSpamFromStrangers) } + + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = warnAboutReports, + onCheckedChange = { + warnAboutReports = it + accountViewModel.account.updateOptOutOptions(warnAboutReports, filterSpam) + }, + ) + + Text(stringResource(R.string.warn_when_posts_have_reports_from_your_follows)) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = filterSpam, + onCheckedChange = { + filterSpam = it + accountViewModel.account.updateOptOutOptions(warnAboutReports, filterSpam) + }, + ) + + Text(stringResource(R.string.filter_spam_from_strangers)) + } + + ScrollableTabRow( + containerColor = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + edgePadding = 8.dp, + selectedTabIndex = pagerState.currentPage, + modifier = TabRowHeight, + divider = { Divider(thickness = DividerThickness) }, + ) { + Tab( + selected = pagerState.currentPage == 0, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } }, + text = { Text(text = stringResource(R.string.blocked_users)) }, + ) + Tab( + selected = pagerState.currentPage == 1, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } }, + text = { Text(text = stringResource(R.string.spamming_users)) }, + ) + Tab( + selected = pagerState.currentPage == 2, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } }, + text = { Text(text = stringResource(R.string.hidden_words)) }, + ) + } + HorizontalPager(state = pagerState) { page -> + when (page) { + 0 -> + RefreshingUserFeedView(hiddenFeedViewModel, accountViewModel) { + RefreshingFeedUserFeedView(hiddenFeedViewModel, accountViewModel, nav) + } + 1 -> + RefreshingUserFeedView(spammerFeedViewModel, accountViewModel) { + RefreshingFeedUserFeedView(spammerFeedViewModel, accountViewModel, nav) + } + 2 -> HiddenWordsFeed(hiddenWordsViewModel, accountViewModel) + } + } + } } @Composable private fun HiddenWordsFeed( - hiddenWordsViewModel: NostrHiddenWordsFeedViewModel, - accountViewModel: AccountViewModel + hiddenWordsViewModel: NostrHiddenWordsFeedViewModel, + accountViewModel: AccountViewModel, ) { - RefresheableView(hiddenWordsViewModel, false) { - StringFeedView( - hiddenWordsViewModel, - post = { AddMuteWordTextField(accountViewModel) } - ) { - MutedWordHeader(tag = it, account = accountViewModel) - } + RefresheableView(hiddenWordsViewModel, false) { + StringFeedView( + hiddenWordsViewModel, + post = { AddMuteWordTextField(accountViewModel) }, + ) { + MutedWordHeader(tag = it, account = accountViewModel) } + } } @Composable private fun AddMuteWordTextField(accountViewModel: AccountViewModel) { - Row() { - val currentWordToAdd = remember { - mutableStateOf("") - } - val hasChanged by remember { - derivedStateOf { - currentWordToAdd.value != "" - } - } + Row { + val currentWordToAdd = remember { mutableStateOf("") } + val hasChanged by remember { derivedStateOf { currentWordToAdd.value != "" } } - OutlinedTextField( - value = currentWordToAdd.value, - onValueChange = { currentWordToAdd.value = it }, - label = { Text(text = stringResource(R.string.hide_new_word_label)) }, - modifier = Modifier - .fillMaxWidth() - .padding(10.dp), - placeholder = { - Text( - text = stringResource(R.string.hide_new_word_label), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - keyboardOptions = KeyboardOptions.Default.copy( - imeAction = ImeAction.Send, - capitalization = KeyboardCapitalization.Sentences - ), - keyboardActions = KeyboardActions( - onSend = { - if (hasChanged) { - accountViewModel.hide(currentWordToAdd.value) - currentWordToAdd.value = "" - } - } - ), - singleLine = true, - trailingIcon = { - AddButton(isActive = hasChanged, modifier = HorzPadding) { - accountViewModel.hide(currentWordToAdd.value) - currentWordToAdd.value = "" - } - } + OutlinedTextField( + value = currentWordToAdd.value, + onValueChange = { currentWordToAdd.value = it }, + label = { Text(text = stringResource(R.string.hide_new_word_label)) }, + modifier = Modifier.fillMaxWidth().padding(10.dp), + placeholder = { + Text( + text = stringResource(R.string.hide_new_word_label), + color = MaterialTheme.colorScheme.placeholderText, ) - } + }, + keyboardOptions = + KeyboardOptions.Default.copy( + imeAction = ImeAction.Send, + capitalization = KeyboardCapitalization.Sentences, + ), + keyboardActions = + KeyboardActions( + onSend = { + if (hasChanged) { + accountViewModel.hide(currentWordToAdd.value) + currentWordToAdd.value = "" + } + }, + ), + singleLine = true, + trailingIcon = { + AddButton(isActive = hasChanged, modifier = HorzPadding) { + accountViewModel.hide(currentWordToAdd.value) + currentWordToAdd.value = "" + } + }, + ) + } } @Composable fun RefreshingUserFeedView( - feedViewModel: UserFeedViewModel, - accountViewModel: AccountViewModel, - inner: @Composable () -> Unit + feedViewModel: UserFeedViewModel, + accountViewModel: AccountViewModel, + inner: @Composable () -> Unit, ) { - WatchAccountAndBlockList(feedViewModel, accountViewModel) - inner() + WatchAccountAndBlockList(feedViewModel, accountViewModel) + inner() } @Composable fun WatchAccountAndBlockList( - feedViewModel: UserFeedViewModel, - accountViewModel: AccountViewModel + feedViewModel: UserFeedViewModel, + accountViewModel: AccountViewModel, ) { - val accountState by accountViewModel.accountLiveData.observeAsState() - val blockListState by accountViewModel.account.flowHiddenUsers.collectAsStateWithLifecycle() + val accountState by accountViewModel.accountLiveData.observeAsState() + val blockListState by accountViewModel.account.flowHiddenUsers.collectAsStateWithLifecycle() - LaunchedEffect(accountViewModel, accountState, blockListState) { - feedViewModel.invalidateData() - } + LaunchedEffect(accountViewModel, accountState, blockListState) { feedViewModel.invalidateData() } } @Composable -fun MutedWordHeader(tag: String, modifier: Modifier = StdPadding, account: AccountViewModel) { - Column( - Modifier.fillMaxWidth() - ) { - Column(modifier = modifier) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - Text( - tag, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f) - ) - - MutedWordActionOptions(tag, account) - } - } - - Divider( - thickness = DividerThickness +fun MutedWordHeader( + tag: String, + modifier: Modifier = StdPadding, + account: AccountViewModel, +) { + Column( + Modifier.fillMaxWidth(), + ) { + Column(modifier = modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Text( + tag, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), ) + + MutedWordActionOptions(tag, account) + } } + + Divider( + thickness = DividerThickness, + ) + } } @Composable fun MutedWordActionOptions( - word: String, - accountViewModel: AccountViewModel + word: String, + accountViewModel: AccountViewModel, ) { - val isMutedWord by accountViewModel.account.liveHiddenUsers.map { - word in it.hiddenWords - }.distinctUntilChanged().observeAsState() + val isMutedWord by + accountViewModel.account.liveHiddenUsers + .map { word in it.hiddenWords } + .distinctUntilChanged() + .observeAsState() - if (isMutedWord == true) { - ShowWordButton { - if (!accountViewModel.isWriteable()) { - accountViewModel.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_show_word - ) - } else { - accountViewModel.showWord(word) - } - } - } else { - HideWordButton { - if (!accountViewModel.isWriteable()) { - accountViewModel.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_hide_word - ) - } else { - accountViewModel.hideWord(word) - } - } + if (isMutedWord == true) { + ShowWordButton { + if (!accountViewModel.isWriteable()) { + accountViewModel.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_show_word, + ) + } else { + accountViewModel.showWord(word) + } } + } else { + HideWordButton { + if (!accountViewModel.isWriteable()) { + accountViewModel.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_hide_word, + ) + } else { + accountViewModel.hideWord(word) + } + } + } } @Composable fun HideWordButton(onClick: () -> Unit) { - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = onClick, - shape = ButtonBorder, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ), - contentPadding = ButtonPadding - ) { - Text(text = stringResource(R.string.block_only), color = Color.White) - } + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = onClick, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Text(text = stringResource(R.string.block_only), color = Color.White) + } } @Composable -fun ShowWordButton(text: Int = R.string.unblock, onClick: () -> Unit) { - Button( - modifier = Modifier.padding(start = 3.dp), - onClick = onClick, - shape = ButtonBorder, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ), - contentPadding = ButtonPadding - ) { - Text(text = stringResource(text), color = Color.White, textAlign = TextAlign.Center) - } +fun ShowWordButton( + text: Int = R.string.unblock, + onClick: () -> Unit, +) { + Button( + modifier = Modifier.padding(start = 3.dp), + onClick = onClick, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Text(text = stringResource(text), color = Color.White, textAlign = TextAlign.Center) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt index fd883cde1..08bc43812 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen.loggedIn import androidx.compose.animation.Crossfade @@ -45,193 +65,205 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable fun HomeScreen( - homeFeedViewModel: NostrHomeFeedViewModel, - repliesFeedViewModel: NostrHomeRepliesFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - nip47: String? = null + homeFeedViewModel: NostrHomeFeedViewModel, + repliesFeedViewModel: NostrHomeRepliesFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + nip47: String? = null, ) { - ResolveNIP47(nip47, accountViewModel) + ResolveNIP47(nip47, accountViewModel) - WatchAccountForHomeScreen(homeFeedViewModel, repliesFeedViewModel, accountViewModel) + WatchAccountForHomeScreen(homeFeedViewModel, repliesFeedViewModel, accountViewModel) - WatchLifeCycleChanges(accountViewModel) + WatchLifeCycleChanges(accountViewModel) - AssembleHomeTabs(homeFeedViewModel, repliesFeedViewModel) { pagerState, tabItems -> - AssembleHomePage(pagerState, tabItems, accountViewModel, nav) - } + AssembleHomeTabs(homeFeedViewModel, repliesFeedViewModel) { pagerState, tabItems -> + AssembleHomePage(pagerState, tabItems, accountViewModel, nav) + } } @OptIn(ExperimentalFoundationApi::class) @Composable private fun AssembleHomeTabs( - homeFeedViewModel: NostrHomeFeedViewModel, - repliesFeedViewModel: NostrHomeRepliesFeedViewModel, - inner: @Composable (PagerState, ImmutableList) -> Unit + homeFeedViewModel: NostrHomeFeedViewModel, + repliesFeedViewModel: NostrHomeRepliesFeedViewModel, + inner: @Composable (PagerState, ImmutableList) -> Unit, ) { - val pagerState = rememberForeverPagerState(key = PagerStateKeys.HOME_SCREEN) { 2 } + val pagerState = rememberForeverPagerState(key = PagerStateKeys.HOME_SCREEN) { 2 } - val tabs by remember(homeFeedViewModel, repliesFeedViewModel) { - mutableStateOf( - listOf( - TabItem(R.string.new_threads, homeFeedViewModel, Route.Home.base + "Follows", ScrollStateKeys.HOME_FOLLOWS), - TabItem(R.string.conversations, repliesFeedViewModel, Route.Home.base + "FollowsReplies", ScrollStateKeys.HOME_REPLIES) - ).toImmutableList() - ) + val tabs by + remember(homeFeedViewModel, repliesFeedViewModel) { + mutableStateOf( + listOf( + TabItem( + R.string.new_threads, + homeFeedViewModel, + Route.Home.base + "Follows", + ScrollStateKeys.HOME_FOLLOWS, + ), + TabItem( + R.string.conversations, + repliesFeedViewModel, + Route.Home.base + "FollowsReplies", + ScrollStateKeys.HOME_REPLIES, + ), + ) + .toImmutableList(), + ) } - inner(pagerState, tabs) + inner(pagerState, tabs) } @OptIn(ExperimentalFoundationApi::class) @Composable private fun AssembleHomePage( - pagerState: PagerState, - tabs: ImmutableList, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + pagerState: PagerState, + tabs: ImmutableList, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - Column(Modifier.fillMaxHeight()) { - HomePages(pagerState, tabs, accountViewModel, nav) - } + Column(Modifier.fillMaxHeight()) { HomePages(pagerState, tabs, accountViewModel, nav) } } @Composable fun ResolveNIP47( - nip47: String?, - accountViewModel: AccountViewModel + nip47: String?, + accountViewModel: AccountViewModel, ) { - var wantsToAddNip47 by remember(nip47) { mutableStateOf(nip47) } + var wantsToAddNip47 by remember(nip47) { mutableStateOf(nip47) } - if (wantsToAddNip47 != null) { - UpdateZapAmountDialog({ wantsToAddNip47 = null }, wantsToAddNip47, accountViewModel) - } + if (wantsToAddNip47 != null) { + UpdateZapAmountDialog({ wantsToAddNip47 = null }, wantsToAddNip47, accountViewModel) + } } @Composable private fun WatchLifeCycleChanges(accountViewModel: AccountViewModel) { - val lifeCycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - NostrHomeDataSource.account = accountViewModel.account - NostrHomeDataSource.invalidateFilters() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { - lifeCycleOwner.lifecycle.removeObserver(observer) - } + val lifeCycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifeCycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + NostrHomeDataSource.account = accountViewModel.account + NostrHomeDataSource.invalidateFilters() + } } + + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } + } } @Composable @OptIn(ExperimentalFoundationApi::class) private fun HomePages( - pagerState: PagerState, - tabs: ImmutableList, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + pagerState: PagerState, + tabs: ImmutableList, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - TabRow( - containerColor = MaterialTheme.colorScheme.background, - contentColor = MaterialTheme.colorScheme.onBackground, - modifier = TabRowHeight, - selectedTabIndex = pagerState.currentPage - ) { - val coroutineScope = rememberCoroutineScope() + TabRow( + containerColor = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + modifier = TabRowHeight, + selectedTabIndex = pagerState.currentPage, + ) { + val coroutineScope = rememberCoroutineScope() - tabs.forEachIndexed { index, tab -> - Tab( - selected = pagerState.currentPage == index, - text = { - Text(text = stringResource(tab.resource)) - }, - onClick = { - coroutineScope.launch { pagerState.animateScrollToPage(index) } - } - ) - } + tabs.forEachIndexed { index, tab -> + Tab( + selected = pagerState.currentPage == index, + text = { Text(text = stringResource(tab.resource)) }, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } }, + ) } + } - HorizontalPager(state = pagerState, userScrollEnabled = false) { page -> - RefresheableFeedView( - viewModel = tabs[page].viewModel, - routeForLastRead = tabs[page].routeForLastRead, - scrollStateKey = tabs[page].scrollStateKey, - accountViewModel = accountViewModel, - nav = nav - ) - } + HorizontalPager(state = pagerState, userScrollEnabled = false) { page -> + RefresheableFeedView( + viewModel = tabs[page].viewModel, + routeForLastRead = tabs[page].routeForLastRead, + scrollStateKey = tabs[page].scrollStateKey, + accountViewModel = accountViewModel, + nav = nav, + ) + } } @Composable -fun CheckIfUrlIsOnline(url: String, accountViewModel: AccountViewModel, whenOnline: @Composable (Boolean) -> Unit) { - var online by remember { - mutableStateOf( - OnlineChecker.isOnlineCached(url) - ) - } +fun CheckIfUrlIsOnline( + url: String, + accountViewModel: AccountViewModel, + whenOnline: @Composable (Boolean) -> Unit, +) { + var online by remember { + mutableStateOf( + OnlineChecker.isOnlineCached(url), + ) + } - LaunchedEffect(key1 = url) { - accountViewModel.checkIsOnline(url) { isOnline -> - if (online != isOnline) { - online = isOnline - } - } + LaunchedEffect(key1 = url) { + accountViewModel.checkIsOnline(url) { isOnline -> + if (online != isOnline) { + online = isOnline + } } + } - whenOnline(online) + whenOnline(online) } @Composable -fun CrossfadeCheckIfUrlIsOnline(url: String, accountViewModel: AccountViewModel, whenOnline: @Composable () -> Unit) { - var online by remember { - mutableStateOf( - OnlineChecker.isOnlineCached(url) - ) - } +fun CrossfadeCheckIfUrlIsOnline( + url: String, + accountViewModel: AccountViewModel, + whenOnline: @Composable () -> Unit, +) { + var online by remember { + mutableStateOf( + OnlineChecker.isOnlineCached(url), + ) + } - LaunchedEffect(key1 = url) { - accountViewModel.checkIsOnline(url) { isOnline -> - if (online != isOnline) { - online = isOnline - } - } + LaunchedEffect(key1 = url) { + accountViewModel.checkIsOnline(url) { isOnline -> + if (online != isOnline) { + online = isOnline + } } + } - Crossfade( - targetState = online, - label = "CheckIfUrlIsOnline" - ) { - if (it) { - whenOnline() - } + Crossfade( + targetState = online, + label = "CheckIfUrlIsOnline", + ) { + if (it) { + whenOnline() } + } } @Composable fun WatchAccountForHomeScreen( - homeFeedViewModel: NostrHomeFeedViewModel, - repliesFeedViewModel: NostrHomeRepliesFeedViewModel, - accountViewModel: AccountViewModel + homeFeedViewModel: NostrHomeFeedViewModel, + repliesFeedViewModel: NostrHomeRepliesFeedViewModel, + accountViewModel: AccountViewModel, ) { - val homeFollowList by accountViewModel.account.liveHomeFollowLists.collectAsStateWithLifecycle() + val homeFollowList by accountViewModel.account.liveHomeFollowLists.collectAsStateWithLifecycle() - LaunchedEffect(accountViewModel, homeFollowList) { - NostrHomeDataSource.account = accountViewModel.account - NostrHomeDataSource.invalidateFilters() - homeFeedViewModel.checkKeysInvalidateDataAndSendToTop() - repliesFeedViewModel.checkKeysInvalidateDataAndSendToTop() - } + LaunchedEffect(accountViewModel, homeFollowList) { + NostrHomeDataSource.account = accountViewModel.account + NostrHomeDataSource.invalidateFilters() + homeFeedViewModel.checkKeysInvalidateDataAndSendToTop() + repliesFeedViewModel.checkKeysInvalidateDataAndSendToTop() + } } @Immutable class TabItem( - val resource: Int, - val viewModel: FeedViewModel, - val routeForLastRead: String, - val scrollStateKey: String, - val forceEventKind: Int? = null + val resource: Int, + val viewModel: FeedViewModel, + val routeForLastRead: String, + val scrollStateKey: String, + val forceEventKind: Int? = null, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt index c2ec320d1..fbe82bae5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen.loggedIn import androidx.compose.foundation.layout.Arrangement @@ -34,99 +54,100 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @Composable -fun LoadRedirectScreen(eventId: String?, accountViewModel: AccountViewModel, navController: NavController) { - if (eventId == null) return +fun LoadRedirectScreen( + eventId: String?, + accountViewModel: AccountViewModel, + navController: NavController, +) { + if (eventId == null) return - var noteBase by remember { mutableStateOf(null) } - val scope = rememberCoroutineScope() + var noteBase by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() - val nav = remember(navController) { - { route: String -> - scope.launch { - navController.navigate(route) { - popUpTo(Route.Event.route) { - inclusive = true - } - } - } - Unit + val nav = + remember(navController) { + { route: String -> + scope.launch { + navController.navigate(route) { popUpTo(Route.Event.route) { inclusive = true } } } + Unit + } } - LaunchedEffect(eventId) { - launch(Dispatchers.IO) { - val newNoteBase = LocalCache.checkGetOrCreateNote(eventId) - if (newNoteBase != noteBase) { - noteBase = newNoteBase - } - } + LaunchedEffect(eventId) { + launch(Dispatchers.IO) { + val newNoteBase = LocalCache.checkGetOrCreateNote(eventId) + if (newNoteBase != noteBase) { + noteBase = newNoteBase + } } + } - noteBase?.let { - LoadRedirectScreen( - baseNote = it, - accountViewModel = accountViewModel, - nav = nav - ) - } + noteBase?.let { + LoadRedirectScreen( + baseNote = it, + accountViewModel = accountViewModel, + nav = nav, + ) + } } @Composable -fun LoadRedirectScreen(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val noteState by baseNote.live().metadata.observeAsState() +fun LoadRedirectScreen( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteState by baseNote.live().metadata.observeAsState() - LaunchedEffect(key1 = noteState) { - val note = noteState?.note ?: return@LaunchedEffect - val event = note.event + LaunchedEffect(key1 = noteState) { + val note = noteState?.note ?: return@LaunchedEffect + val event = note.event - if (event != null) { - withContext(Dispatchers.IO) { - redirect(event, note, accountViewModel, nav) - } - } + if (event != null) { + withContext(Dispatchers.IO) { redirect(event, note, accountViewModel, nav) } } + } - Column( - Modifier - .fillMaxHeight() - .fillMaxWidth() - .padding(horizontal = 50.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text(stringResource(R.string.looking_for_event, baseNote.idHex)) - } + Column( + Modifier.fillMaxHeight().fillMaxWidth().padding(horizontal = 50.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(stringResource(R.string.looking_for_event, baseNote.idHex)) + } } -fun redirect(event: EventInterface, note: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val channelHex = note.channelHex() +fun redirect( + event: EventInterface, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val channelHex = note.channelHex() - if (event is GiftWrapEvent) { - accountViewModel.unwrap(event) { - redirect(it, note, accountViewModel, nav) - } - } else if (event is SealedGossipEvent) { - accountViewModel.unseal(event) { - redirect(it, note, accountViewModel, nav) - } + if (event is GiftWrapEvent) { + accountViewModel.unwrap(event) { redirect(it, note, accountViewModel, nav) } + } else if (event is SealedGossipEvent) { + accountViewModel.unseal(event) { redirect(it, note, accountViewModel, nav) } + } else { + if (event == null) { + // stay here, loading + } else if (event is ChannelCreateEvent) { + nav("Channel/${note.idHex}") + } else if (event is ChatroomKeyable) { + note.author?.let { + val withKey = + (event as ChatroomKeyable).chatroomKey(accountViewModel.userProfile().pubkeyHex) + + accountViewModel.userProfile().createChatroom(withKey) + + nav("Room/${withKey.hashCode()}") + } + } else if (channelHex != null) { + nav("Channel/$channelHex") } else { - if (event == null) { - // stay here, loading - } else if (event is ChannelCreateEvent) { - nav("Channel/${note.idHex}") - } else if (event is ChatroomKeyable) { - note.author?.let { - val withKey = (event as ChatroomKeyable) - .chatroomKey(accountViewModel.userProfile().pubkeyHex) - - accountViewModel.userProfile().createChatroom(withKey) - - nav("Room/${withKey.hashCode()}") - } - } else if (channelHex != null) { - nav("Channel/$channelHex") - } else { - nav("Note/${note.idHex}") - } + nav("Note/${note.idHex}") } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt index c49fb97da..42ba6983d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.content.res.Configuration @@ -89,456 +109,484 @@ import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel -import kotlinx.coroutines.launch import kotlin.math.abs +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreen( - accountViewModel: AccountViewModel, - accountStateViewModel: AccountStateViewModel, - sharedPreferencesViewModel: SharedPreferencesViewModel + accountViewModel: AccountViewModel, + accountStateViewModel: AccountStateViewModel, + sharedPreferencesViewModel: SharedPreferencesViewModel, ) { - val scope = rememberCoroutineScope() - var openBottomSheet by rememberSaveable { mutableStateOf(false) } + val scope = rememberCoroutineScope() + var openBottomSheet by rememberSaveable { mutableStateOf(false) } - val drawerState = rememberDrawerState(DrawerValue.Closed) - val sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true, - confirmValueChange = { it != SheetValue.PartiallyExpanded } + val drawerState = rememberDrawerState(DrawerValue.Closed) + val sheetState = + rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { it != SheetValue.PartiallyExpanded }, ) - val openSheetFunction = remember { - { - scope.launch { - openBottomSheet = true - sheetState.show() + val openSheetFunction = remember { + { + scope.launch { + openBottomSheet = true + sheetState.show() + } + Unit + } + } + + val navController = rememberNavController() + val navState = navController.currentBackStackEntryAsState() + + val orientation = LocalConfiguration.current.orientation + val currentDrawerState = drawerState.currentValue + LaunchedEffect(key1 = orientation) { + if ( + orientation == Configuration.ORIENTATION_LANDSCAPE && currentDrawerState == DrawerValue.Closed + ) { + drawerState.close() + } + } + + val nav = + remember(navController) { + { route: String -> + scope.launch { + if (getRouteWithArguments(navController) != route) { + navController.navigate(route) + } + } + Unit + } + } + + DisplayErrorMessages(accountViewModel) + DisplayNotifyMessages(accountViewModel, nav) + + val navPopBack = + remember(navController) { + { + navController.popBackStack() + Unit + } + } + + val followLists: FollowListViewModel = + viewModel( + key = "FollowListViewModel", + factory = FollowListViewModel.Factory(accountViewModel.account), + ) + + // Avoids creating ViewModels for performance reasons (up to 1 second delays) + val homeFeedViewModel: NostrHomeFeedViewModel = + viewModel( + key = "NostrHomeFeedViewModel", + factory = NostrHomeFeedViewModel.Factory(accountViewModel.account), + ) + + val repliesFeedViewModel: NostrHomeRepliesFeedViewModel = + viewModel( + key = "NostrHomeRepliesFeedViewModel", + factory = NostrHomeRepliesFeedViewModel.Factory(accountViewModel.account), + ) + + val videoFeedViewModel: NostrVideoFeedViewModel = + viewModel( + key = "NostrVideoFeedViewModel", + factory = NostrVideoFeedViewModel.Factory(accountViewModel.account), + ) + + val discoverMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel = + viewModel( + key = "NostrDiscoveryMarketplaceFeedViewModel", + factory = NostrDiscoverMarketplaceFeedViewModel.Factory(accountViewModel.account), + ) + + val discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel = + viewModel( + key = "NostrDiscoveryLiveFeedViewModel", + factory = NostrDiscoverLiveFeedViewModel.Factory(accountViewModel.account), + ) + + val discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel = + viewModel( + key = "NostrDiscoveryCommunityFeedViewModel", + factory = NostrDiscoverCommunityFeedViewModel.Factory(accountViewModel.account), + ) + + val discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel = + viewModel( + key = "NostrDiscoveryChatFeedViewModel", + factory = NostrDiscoverChatFeedViewModel.Factory(accountViewModel.account), + ) + + val notifFeedViewModel: NotificationViewModel = + viewModel( + key = "NotificationViewModel", + factory = NotificationViewModel.Factory(accountViewModel.account), + ) + + val userReactionsStatsModel: UserReactionsViewModel = + viewModel( + key = "UserReactionsViewModel", + factory = UserReactionsViewModel.Factory(accountViewModel.account), + ) + + val knownFeedViewModel: NostrChatroomListKnownFeedViewModel = + viewModel( + key = "NostrChatroomListKnownFeedViewModel", + factory = NostrChatroomListKnownFeedViewModel.Factory(accountViewModel.account), + ) + + val newFeedViewModel: NostrChatroomListNewFeedViewModel = + viewModel( + key = "NostrChatroomListNewFeedViewModel", + factory = NostrChatroomListNewFeedViewModel.Factory(accountViewModel.account), + ) + + val navBottomRow = + remember(navController) { + { route: Route, selected: Boolean -> + scope.launch { + if (!selected) { + navController.navigate(route.base) { + popUpTo(Route.Home.route) + launchSingleTop = true } - Unit - } - } - - val navController = rememberNavController() - val navState = navController.currentBackStackEntryAsState() - - val orientation = LocalConfiguration.current.orientation - val currentDrawerState = drawerState.currentValue - LaunchedEffect(key1 = orientation) { - if (orientation == Configuration.ORIENTATION_LANDSCAPE && currentDrawerState == DrawerValue.Closed) { - drawerState.close() - } - } - - val nav = remember(navController) { - { route: String -> - scope.launch { - if (getRouteWithArguments(navController) != route) { - navController.navigate(route) - } - } - Unit - } - } - - DisplayErrorMessages(accountViewModel) - DisplayNotifyMessages(accountViewModel, nav) - - val navPopBack = remember(navController) { - { - navController.popBackStack() - Unit - } - } - - val followLists: FollowListViewModel = viewModel( - key = "FollowListViewModel", - factory = FollowListViewModel.Factory(accountViewModel.account) - ) - - // Avoids creating ViewModels for performance reasons (up to 1 second delays) - val homeFeedViewModel: NostrHomeFeedViewModel = viewModel( - key = "NostrHomeFeedViewModel", - factory = NostrHomeFeedViewModel.Factory(accountViewModel.account) - ) - - val repliesFeedViewModel: NostrHomeRepliesFeedViewModel = viewModel( - key = "NostrHomeRepliesFeedViewModel", - factory = NostrHomeRepliesFeedViewModel.Factory(accountViewModel.account) - ) - - val videoFeedViewModel: NostrVideoFeedViewModel = viewModel( - key = "NostrVideoFeedViewModel", - factory = NostrVideoFeedViewModel.Factory(accountViewModel.account) - ) - - val discoverMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel = viewModel( - key = "NostrDiscoveryMarketplaceFeedViewModel", - factory = NostrDiscoverMarketplaceFeedViewModel.Factory(accountViewModel.account) - ) - - val discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel = viewModel( - key = "NostrDiscoveryLiveFeedViewModel", - factory = NostrDiscoverLiveFeedViewModel.Factory(accountViewModel.account) - ) - - val discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel = viewModel( - key = "NostrDiscoveryCommunityFeedViewModel", - factory = NostrDiscoverCommunityFeedViewModel.Factory(accountViewModel.account) - ) - - val discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel = viewModel( - key = "NostrDiscoveryChatFeedViewModel", - factory = NostrDiscoverChatFeedViewModel.Factory(accountViewModel.account) - ) - - val notifFeedViewModel: NotificationViewModel = viewModel( - key = "NotificationViewModel", - factory = NotificationViewModel.Factory(accountViewModel.account) - ) - - val userReactionsStatsModel: UserReactionsViewModel = viewModel( - key = "UserReactionsViewModel", - factory = UserReactionsViewModel.Factory(accountViewModel.account) - ) - - val knownFeedViewModel: NostrChatroomListKnownFeedViewModel = viewModel( - key = "NostrChatroomListKnownFeedViewModel", - factory = NostrChatroomListKnownFeedViewModel.Factory(accountViewModel.account) - ) - - val newFeedViewModel: NostrChatroomListNewFeedViewModel = viewModel( - key = "NostrChatroomListNewFeedViewModel", - factory = NostrChatroomListNewFeedViewModel.Factory(accountViewModel.account) - ) - - val navBottomRow = remember(navController) { - { route: Route, selected: Boolean -> - scope.launch { - if (!selected) { - navController.navigate(route.base) { - popUpTo(Route.Home.route) - launchSingleTop = true - } - } else { - // deals with scroll to top here to avoid passing as parameter - // and having to deal with all recompositions with scroll to top true - when (route.base) { - Route.Home.base -> { - homeFeedViewModel.sendToTop() - repliesFeedViewModel.sendToTop() - } - - Route.Video.base -> { - videoFeedViewModel.sendToTop() - } - - Route.Discover.base -> { - discoveryLiveFeedViewModel.sendToTop() - discoveryCommunityFeedViewModel.sendToTop() - discoveryChatFeedViewModel.sendToTop() - } - - Route.Notification.base -> { - notifFeedViewModel.invalidateDataAndSendToTop() - } - } - - navController.navigate(route.route) { - popUpTo(route.route) - launchSingleTop = true - } - } + } else { + // deals with scroll to top here to avoid passing as parameter + // and having to deal with all recompositions with scroll to top true + when (route.base) { + Route.Home.base -> { + homeFeedViewModel.sendToTop() + repliesFeedViewModel.sendToTop() + } + Route.Video.base -> { + videoFeedViewModel.sendToTop() + } + Route.Discover.base -> { + discoveryLiveFeedViewModel.sendToTop() + discoveryCommunityFeedViewModel.sendToTop() + discoveryChatFeedViewModel.sendToTop() + } + Route.Notification.base -> { + notifFeedViewModel.invalidateDataAndSendToTop() + } } - Unit - } - } - - val bottomBarHeightPx = with(LocalDensity.current) { 50.dp.roundToPx().toFloat() } - val bottomBarOffsetHeightPx = remember { mutableFloatStateOf(0f) } - val shouldShow = remember { mutableStateOf(true) } - - val nestedScrollConnection = remember { - object : NestedScrollConnection { - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - val newOffset = bottomBarOffsetHeightPx.floatValue + available.y - - if (accountViewModel.settings.automaticallyHideNavigationBars == BooleanType.ALWAYS) { - val newBottomBarOffset = if (navState.value?.destination?.route !in InvertedLayouts) { - newOffset.coerceIn(-bottomBarHeightPx, 0f) - } else { - newOffset.coerceIn(0f, bottomBarHeightPx) - } - - if (newBottomBarOffset != bottomBarOffsetHeightPx.floatValue) { - bottomBarOffsetHeightPx.floatValue = newBottomBarOffset - } - } else { - if (abs(bottomBarOffsetHeightPx.floatValue) > 0.1) { - bottomBarOffsetHeightPx.floatValue = 0f - } - } - - val newShouldShow = abs(bottomBarOffsetHeightPx.floatValue) < bottomBarHeightPx / 2.0f - - if (shouldShow.value != newShouldShow) { - shouldShow.value = newShouldShow - } - - return Offset.Zero + navController.navigate(route.route) { + popUpTo(route.route) + launchSingleTop = true } + } } + + Unit + } } - WatchNavStateToUpdateBarVisibility(navState) { - bottomBarOffsetHeightPx.floatValue = 0f - shouldShow.value = true - } + val bottomBarHeightPx = with(LocalDensity.current) { 50.dp.roundToPx().toFloat() } + val bottomBarOffsetHeightPx = remember { mutableFloatStateOf(0f) } + val shouldShow = remember { mutableStateOf(true) } - ModalNavigationDrawer( - drawerState = drawerState, - drawerContent = { - DrawerContent(nav, drawerState, openSheetFunction, accountViewModel) - BackHandler(enabled = drawerState.isOpen) { - scope.launch { drawerState.close() } + val nestedScrollConnection = remember { + object : NestedScrollConnection { + override fun onPreScroll( + available: Offset, + source: NestedScrollSource, + ): Offset { + val newOffset = bottomBarOffsetHeightPx.floatValue + available.y + + if (accountViewModel.settings.automaticallyHideNavigationBars == BooleanType.ALWAYS) { + val newBottomBarOffset = + if (navState.value?.destination?.route !in InvertedLayouts) { + newOffset.coerceIn(-bottomBarHeightPx, 0f) + } else { + newOffset.coerceIn(0f, bottomBarHeightPx) } + + if (newBottomBarOffset != bottomBarOffsetHeightPx.floatValue) { + bottomBarOffsetHeightPx.floatValue = newBottomBarOffset + } + } else { + if (abs(bottomBarOffsetHeightPx.floatValue) > 0.1) { + bottomBarOffsetHeightPx.floatValue = 0f + } + } + + val newShouldShow = abs(bottomBarOffsetHeightPx.floatValue) < bottomBarHeightPx / 2.0f + + if (shouldShow.value != newShouldShow) { + shouldShow.value = newShouldShow + } + + return Offset.Zero + } + } + } + + WatchNavStateToUpdateBarVisibility(navState) { + bottomBarOffsetHeightPx.floatValue = 0f + shouldShow.value = true + } + + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + DrawerContent(nav, drawerState, openSheetFunction, accountViewModel) + BackHandler(enabled = drawerState.isOpen) { scope.launch { drawerState.close() } } + }, + content = { + Scaffold( + modifier = + Modifier.background(MaterialTheme.colorScheme.secondary) + .statusBarsPadding() + .nestedScroll(nestedScrollConnection), + bottomBar = { + AnimatedContent( + targetState = shouldShow.value, + transitionSpec = AnimatedContentTransitionScope::bottomBarTransitionSpec, + label = "BottomBarAnimatedContent", + ) { isVisible -> + if (isVisible) { + AppBottomBar(accountViewModel, navState, navBottomRow) + } + } }, - content = { - Scaffold( - modifier = Modifier - .background(MaterialTheme.colorScheme.secondary) - .statusBarsPadding() - .nestedScroll(nestedScrollConnection), - bottomBar = { - AnimatedContent( - targetState = shouldShow.value, - transitionSpec = AnimatedContentTransitionScope::bottomBarTransitionSpec, - label = "BottomBarAnimatedContent" - ) { isVisible -> - if (isVisible) { - AppBottomBar(accountViewModel, navState, navBottomRow) - } - } - }, - topBar = { - AnimatedContent( - targetState = shouldShow.value, - transitionSpec = AnimatedContentTransitionScope::topBarTransitionSpec, - label = "TopBarAnimatedContent" - ) { isVisible -> - if (isVisible) { - AppTopBar( - followLists, - navState, - drawerState, - accountViewModel, - nav = nav, - navPopBack - ) - } - } - }, - floatingActionButton = { - AnimatedVisibility( - visible = shouldShow.value, - enter = remember { scaleIn() }, - exit = remember { scaleOut() } - ) { - Box( - modifier = Modifier.defaultMinSize(minWidth = 55.dp, minHeight = 55.dp) - ) { - FloatingButtons( - navState, - accountViewModel, - accountStateViewModel, - nav, - navBottomRow - ) - } - } - } - ) { - Column( - modifier = Modifier - .padding( - top = it.calculateTopPadding(), - bottom = it.calculateBottomPadding() - ) - ) { - AppNavigation( - homeFeedViewModel = homeFeedViewModel, - repliesFeedViewModel = repliesFeedViewModel, - knownFeedViewModel = knownFeedViewModel, - newFeedViewModel = newFeedViewModel, - videoFeedViewModel = videoFeedViewModel, - discoverMarketplaceFeedViewModel = discoverMarketplaceFeedViewModel, - discoveryLiveFeedViewModel = discoveryLiveFeedViewModel, - discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel, - discoveryChatFeedViewModel = discoveryChatFeedViewModel, - notifFeedViewModel = notifFeedViewModel, - userReactionsStatsModel = userReactionsStatsModel, - navController = navController, - accountViewModel = accountViewModel, - sharedPreferencesViewModel = sharedPreferencesViewModel - ) - } + topBar = { + AnimatedContent( + targetState = shouldShow.value, + transitionSpec = AnimatedContentTransitionScope::topBarTransitionSpec, + label = "TopBarAnimatedContent", + ) { isVisible -> + if (isVisible) { + AppTopBar( + followLists, + navState, + drawerState, + accountViewModel, + nav = nav, + navPopBack, + ) } - } - ) - - // Sheet content - if (openBottomSheet) { - ModalBottomSheet( - onDismissRequest = { - scope.launch { sheetState.hide() }.invokeOnCompletion { - if (!sheetState.isVisible) { - openBottomSheet = false - } - } - }, - sheetState = sheetState + } + }, + floatingActionButton = { + AnimatedVisibility( + visible = shouldShow.value, + enter = remember { scaleIn() }, + exit = remember { scaleOut() }, + ) { + Box( + modifier = Modifier.defaultMinSize(minWidth = 55.dp, minHeight = 55.dp), + ) { + FloatingButtons( + navState, + accountViewModel, + accountStateViewModel, + nav, + navBottomRow, + ) + } + } + }, + ) { + Column( + modifier = + Modifier.padding( + top = it.calculateTopPadding(), + bottom = it.calculateBottomPadding(), + ), ) { - AccountSwitchBottomSheet(accountViewModel = accountViewModel, accountStateViewModel = accountStateViewModel) + AppNavigation( + homeFeedViewModel = homeFeedViewModel, + repliesFeedViewModel = repliesFeedViewModel, + knownFeedViewModel = knownFeedViewModel, + newFeedViewModel = newFeedViewModel, + videoFeedViewModel = videoFeedViewModel, + discoverMarketplaceFeedViewModel = discoverMarketplaceFeedViewModel, + discoveryLiveFeedViewModel = discoveryLiveFeedViewModel, + discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel, + discoveryChatFeedViewModel = discoveryChatFeedViewModel, + notifFeedViewModel = notifFeedViewModel, + userReactionsStatsModel = userReactionsStatsModel, + navController = navController, + accountViewModel = accountViewModel, + sharedPreferencesViewModel = sharedPreferencesViewModel, + ) } + } + }, + ) + + // Sheet content + if (openBottomSheet) { + ModalBottomSheet( + onDismissRequest = { + scope + .launch { sheetState.hide() } + .invokeOnCompletion { + if (!sheetState.isVisible) { + openBottomSheet = false + } + } + }, + sheetState = sheetState, + ) { + AccountSwitchBottomSheet( + accountViewModel = accountViewModel, + accountStateViewModel = accountStateViewModel, + ) } + } } @OptIn(ExperimentalAnimationApi::class) private fun AnimatedContentTransitionScope.topBarTransitionSpec(): ContentTransform { - return topBarAnimation + return topBarAnimation } @OptIn(ExperimentalAnimationApi::class) private fun AnimatedContentTransitionScope.bottomBarTransitionSpec(): ContentTransform { - return bottomBarAnimation + return bottomBarAnimation } @ExperimentalAnimationApi val topBarAnimation: ContentTransform = - slideInVertically { height -> 0 } togetherWith slideOutVertically { height -> 0 } + slideInVertically { height -> 0 } togetherWith slideOutVertically { height -> 0 } val bottomBarAnimation: ContentTransform = - slideInVertically { height -> height } togetherWith slideOutVertically { height -> height } + slideInVertically { height -> height } togetherWith slideOutVertically { height -> height } @Composable private fun DisplayErrorMessages(accountViewModel: AccountViewModel) { - val context = LocalContext.current - val openDialogMsg = accountViewModel.toasts.collectAsStateWithLifecycle(null) + val context = LocalContext.current + val openDialogMsg = accountViewModel.toasts.collectAsStateWithLifecycle(null) - openDialogMsg.value?.let { obj -> - when (obj) { - is ResourceToastMsg -> InformationDialog( - context.getString(obj.titleResId), - context.getString(obj.resourceId) - ) { - accountViewModel.clearToasts() - } - - is StringToastMsg -> InformationDialog( - obj.title, - obj.msg - ) { - accountViewModel.clearToasts() - } - } - } -} - -@Composable -private fun DisplayNotifyMessages(accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val openDialogMsg = accountViewModel.account.transientPaymentRequests.collectAsStateWithLifecycle(null) - - openDialogMsg.value?.firstOrNull()?.let { request -> - NotifyRequestDialog( - title = stringResource(id = R.string.payment_required_title, request.relayUrl.removePrefix("wss://").removeSuffix("/")), - textContent = request.description, - accountViewModel = accountViewModel, - nav = nav + openDialogMsg.value?.let { obj -> + when (obj) { + is ResourceToastMsg -> + InformationDialog( + context.getString(obj.titleResId), + context.getString(obj.resourceId), ) { - accountViewModel.dismissPaymentRequest(request) + accountViewModel.clearToasts() + } + is StringToastMsg -> + InformationDialog( + obj.title, + obj.msg, + ) { + accountViewModel.clearToasts() } } + } } @Composable -fun WatchNavStateToUpdateBarVisibility(navState: State, onReset: () -> Unit) { - LaunchedEffect(key1 = navState.value) { +private fun DisplayNotifyMessages( + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val openDialogMsg = + accountViewModel.account.transientPaymentRequests.collectAsStateWithLifecycle(null) + + openDialogMsg.value?.firstOrNull()?.let { request -> + NotifyRequestDialog( + title = + stringResource( + id = R.string.payment_required_title, + request.relayUrl.removePrefix("wss://").removeSuffix("/"), + ), + textContent = request.description, + accountViewModel = accountViewModel, + nav = nav, + ) { + accountViewModel.dismissPaymentRequest(request) + } + } +} + +@Composable +fun WatchNavStateToUpdateBarVisibility( + navState: State, + onReset: () -> Unit, +) { + LaunchedEffect(key1 = navState.value) { onReset() } + + val lifeCycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifeCycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { onReset() + } } - val lifeCycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - onReset() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { - lifeCycleOwner.lifecycle.removeObserver(observer) - } - } + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } + } } @Composable fun FloatingButtons( - navEntryState: State, - accountViewModel: AccountViewModel, - accountStateViewModel: AccountStateViewModel, - nav: (String) -> Unit, - navScrollToTop: (Route, Boolean) -> Unit + navEntryState: State, + accountViewModel: AccountViewModel, + accountStateViewModel: AccountStateViewModel, + nav: (String) -> Unit, + navScrollToTop: (Route, Boolean) -> Unit, ) { - val accountState by accountStateViewModel.accountContent.collectAsStateWithLifecycle() + val accountState by accountStateViewModel.accountContent.collectAsStateWithLifecycle() - when (accountState) { - is AccountState.Loading -> { - // Does nothing. - } - - is AccountState.LoggedInViewOnly -> { - WritePermissionButtons(navEntryState, accountViewModel, nav, navScrollToTop) - } - is AccountState.LoggedOff -> { - // Does nothing. - } - is AccountState.LoggedIn -> { - WritePermissionButtons(navEntryState, accountViewModel, nav, navScrollToTop) - } + when (accountState) { + is AccountState.Loading -> { + // Does nothing. } + is AccountState.LoggedInViewOnly -> { + WritePermissionButtons(navEntryState, accountViewModel, nav, navScrollToTop) + } + is AccountState.LoggedOff -> { + // Does nothing. + } + is AccountState.LoggedIn -> { + WritePermissionButtons(navEntryState, accountViewModel, nav, navScrollToTop) + } + } } @Composable private fun WritePermissionButtons( - navEntryState: State, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - navScrollToTop: (Route, Boolean) -> Unit + navEntryState: State, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + navScrollToTop: (Route, Boolean) -> Unit, ) { - val currentRoute by remember(navEntryState.value) { - derivedStateOf { - navEntryState.value?.destination?.route?.substringBefore("?") - } + val currentRoute by + remember(navEntryState.value) { + derivedStateOf { navEntryState.value?.destination?.route?.substringBefore("?") } } - when (currentRoute) { - Route.Home.base -> NewNoteButton(accountViewModel, nav) - Route.Message.base -> { - if (accountViewModel.settings.windowSizeClass.value?.widthSizeClass == WindowWidthSizeClass.Compact) { - ChannelFabColumn(accountViewModel, nav) - } - } - Route.Video.base -> NewImageButton(accountViewModel, nav, navScrollToTop) - Route.Community.base -> { - val communityId by remember(navEntryState.value) { - derivedStateOf { - navEntryState.value?.arguments?.getString("id") - } - } - - communityId?.let { - NewCommunityNoteButton(it, accountViewModel, nav) - } - } + when (currentRoute) { + Route.Home.base -> NewNoteButton(accountViewModel, nav) + Route.Message.base -> { + if ( + accountViewModel.settings.windowSizeClass.value?.widthSizeClass == + WindowWidthSizeClass.Compact + ) { + ChannelFabColumn(accountViewModel, nav) + } } + Route.Video.base -> NewImageButton(accountViewModel, nav, navScrollToTop) + Route.Community.base -> { + val communityId by + remember(navEntryState.value) { + derivedStateOf { navEntryState.value?.arguments?.getString("id") } + } + + communityId?.let { NewCommunityNoteButton(it, accountViewModel, nav) } + } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt index abc5ea360..1110f902e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.Manifest @@ -72,211 +92,214 @@ import kotlin.math.roundToInt @Composable fun NotificationScreen( - notifFeedViewModel: NotificationViewModel, - userReactionsStatsModel: UserReactionsViewModel, - sharedPreferencesViewModel: SharedPreferencesViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + notifFeedViewModel: NotificationViewModel, + userReactionsStatsModel: UserReactionsViewModel, + sharedPreferencesViewModel: SharedPreferencesViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - SelectNotificationProvider(sharedPreferencesViewModel) + SelectNotificationProvider(sharedPreferencesViewModel) - WatchAccountForNotifications(notifFeedViewModel, accountViewModel) + WatchAccountForNotifications(notifFeedViewModel, accountViewModel) - val lifeCycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - NostrAccountDataSource.account = accountViewModel.account - NostrAccountDataSource.invalidateFilters() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { - lifeCycleOwner.lifecycle.removeObserver(observer) - } + val lifeCycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifeCycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + NostrAccountDataSource.account = accountViewModel.account + NostrAccountDataSource.invalidateFilters() + } } - Column(Modifier.fillMaxHeight()) { - SummaryBar( - model = userReactionsStatsModel - ) + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } + } - RefresheableCardView( - viewModel = notifFeedViewModel, - accountViewModel = accountViewModel, - nav = nav, - routeForLastRead = Route.Notification.base, - scrollStateKey = ScrollStateKeys.NOTIFICATION_SCREEN - ) - } + Column(Modifier.fillMaxHeight()) { + SummaryBar( + model = userReactionsStatsModel, + ) + + RefresheableCardView( + viewModel = notifFeedViewModel, + accountViewModel = accountViewModel, + nav = nav, + routeForLastRead = Route.Notification.base, + scrollStateKey = ScrollStateKeys.NOTIFICATION_SCREEN, + ) + } } @OptIn(ExperimentalPermissionsApi::class) @Composable fun CheckifItNeedsToRequestNotificationPermission( - sharedPreferencesViewModel: SharedPreferencesViewModel + sharedPreferencesViewModel: SharedPreferencesViewModel ): PermissionState { - val notificationPermissionState = rememberPermissionState( - Manifest.permission.POST_NOTIFICATIONS + val notificationPermissionState = + rememberPermissionState( + Manifest.permission.POST_NOTIFICATIONS, ) - if (!sharedPreferencesViewModel.sharedPrefs.dontAskForNotificationPermissions) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - if (!notificationPermissionState.status.isGranted) { - sharedPreferencesViewModel.dontAskForNotificationPermissions() + if (!sharedPreferencesViewModel.sharedPrefs.dontAskForNotificationPermissions) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (!notificationPermissionState.status.isGranted) { + sharedPreferencesViewModel.dontAskForNotificationPermissions() - // This will pause the APP, including the connection with relays. - LaunchedEffect(notificationPermissionState) { - notificationPermissionState.launchPermissionRequest() - } - } + // This will pause the APP, including the connection with relays. + LaunchedEffect(notificationPermissionState) { + notificationPermissionState.launchPermissionRequest() } + } } + } - return notificationPermissionState + return notificationPermissionState } @Composable fun WatchAccountForNotifications( - notifFeedViewModel: NotificationViewModel, - accountViewModel: AccountViewModel + notifFeedViewModel: NotificationViewModel, + accountViewModel: AccountViewModel, ) { - val listState by accountViewModel.account.liveNotificationFollowLists.collectAsStateWithLifecycle() + val listState by + accountViewModel.account.liveNotificationFollowLists.collectAsStateWithLifecycle() - LaunchedEffect(accountViewModel, listState) { - NostrAccountDataSource.account = accountViewModel.account - NostrAccountDataSource.invalidateFilters() - notifFeedViewModel.checkKeysInvalidateDataAndSendToTop() - } + LaunchedEffect(accountViewModel, listState) { + NostrAccountDataSource.account = accountViewModel.account + NostrAccountDataSource.invalidateFilters() + notifFeedViewModel.checkKeysInvalidateDataAndSendToTop() + } } @Composable fun SummaryBar(model: UserReactionsViewModel) { - var showChart by remember { - mutableStateOf(false) - } + var showChart by remember { mutableStateOf(false) } - UserReactionsRow(model) { - showChart = !showChart - } + UserReactionsRow(model) { showChart = !showChart } - if (showChart) { - val lineChartCount = - lineChart( - lines = listOf(RoyalBlue, Color.Green, Color.Red).map { lineChartColor -> - LineChart.LineSpec( - lineColor = lineChartColor.toArgb(), - lineBackgroundShader = DynamicShaders.fromBrush( - Brush.verticalGradient( - listOf( - lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_START), - lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_END) - ) - ) - ) - ) - }, - targetVerticalAxisPosition = AxisPosition.Vertical.Start + if (showChart) { + val lineChartCount = + lineChart( + lines = + listOf(RoyalBlue, Color.Green, Color.Red).map { lineChartColor -> + LineChart.LineSpec( + lineColor = lineChartColor.toArgb(), + lineBackgroundShader = + DynamicShaders.fromBrush( + Brush.verticalGradient( + listOf( + lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_START), + lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_END), + ), + ), + ), ) + }, + targetVerticalAxisPosition = AxisPosition.Vertical.Start, + ) - val lineChartZaps = - lineChart( - lines = listOf(BitcoinOrange).map { lineChartColor -> - LineChart.LineSpec( - lineColor = lineChartColor.toArgb(), - lineBackgroundShader = DynamicShaders.fromBrush( - Brush.verticalGradient( - listOf( - lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_START), - lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_END) - ) - ) - ) - ) - }, - targetVerticalAxisPosition = AxisPosition.Vertical.End + val lineChartZaps = + lineChart( + lines = + listOf(BitcoinOrange).map { lineChartColor -> + LineChart.LineSpec( + lineColor = lineChartColor.toArgb(), + lineBackgroundShader = + DynamicShaders.fromBrush( + Brush.verticalGradient( + listOf( + lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_START), + lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_END), + ), + ), + ), ) + }, + targetVerticalAxisPosition = AxisPosition.Vertical.End, + ) - Row( - modifier = Modifier - .padding(vertical = 10.dp, horizontal = 20.dp) - .clickable(onClick = { showChart = !showChart }) - ) { - ProvideChartStyle( - chartStyle = MaterialTheme.colorScheme.chartStyle - ) { - ObserveAndShowChart(model, lineChartCount, lineChartZaps) - } - } + Row( + modifier = + Modifier.padding(vertical = 10.dp, horizontal = 20.dp) + .clickable(onClick = { showChart = !showChart }), + ) { + ProvideChartStyle( + chartStyle = MaterialTheme.colorScheme.chartStyle, + ) { + ObserveAndShowChart(model, lineChartCount, lineChartZaps) + } } + } - Divider( - thickness = DividerThickness - ) + Divider( + thickness = DividerThickness, + ) } @Composable private fun ObserveAndShowChart( - model: UserReactionsViewModel, - lineChartCount: LineChart, - lineChartZaps: LineChart + model: UserReactionsViewModel, + lineChartCount: LineChart, + lineChartZaps: LineChart, ) { - val axisModel = model.axisLabels.collectAsStateWithLifecycle() - val chartModel by model.chartModel.collectAsStateWithLifecycle() + val axisModel = model.axisLabels.collectAsStateWithLifecycle() + val chartModel by model.chartModel.collectAsStateWithLifecycle() - chartModel?.let { - Chart( - chart = remember(lineChartCount, lineChartZaps) { - lineChartCount.plus(lineChartZaps) - }, - model = it, - startAxis = rememberStartAxis( - valueFormatter = CountAxisValueFormatter() - ), - endAxis = rememberEndAxis( - label = axisLabelComponent(color = BitcoinOrange), - valueFormatter = AmountAxisValueFormatter(model.shouldShowDecimalsInAxis) - ), - bottomAxis = rememberBottomAxis( - valueFormatter = LabelValueFormatter(axisModel) - ) - ) - } + chartModel?.let { + Chart( + chart = remember(lineChartCount, lineChartZaps) { lineChartCount.plus(lineChartZaps) }, + model = it, + startAxis = + rememberStartAxis( + valueFormatter = CountAxisValueFormatter(), + ), + endAxis = + rememberEndAxis( + label = axisLabelComponent(color = BitcoinOrange), + valueFormatter = AmountAxisValueFormatter(model.shouldShowDecimalsInAxis), + ), + bottomAxis = + rememberBottomAxis( + valueFormatter = LabelValueFormatter(axisModel), + ), + ) + } } @Stable -class LabelValueFormatter(val axisLabels: State>) : AxisValueFormatter { - override fun formatValue( - value: Float, - chartValues: ChartValues - ): String { - return axisLabels.value[value.roundToInt()] - } +class LabelValueFormatter(val axisLabels: State>) : + AxisValueFormatter { + override fun formatValue( + value: Float, + chartValues: ChartValues, + ): String { + return axisLabels.value[value.roundToInt()] + } } @Stable class CountAxisValueFormatter() : AxisValueFormatter { - override fun formatValue( - value: Float, - chartValues: ChartValues - ): String { - return showCount(value.roundToInt()) - } + override fun formatValue( + value: Float, + chartValues: ChartValues, + ): String { + return showCount(value.roundToInt()) + } } @Stable -class AmountAxisValueFormatter(val showDecimals: Boolean) : AxisValueFormatter { - override fun formatValue( - value: Float, - chartValues: ChartValues - ): String { - return if (showDecimals) { - showAmount(value.toBigDecimal()) - } else { - showAmountAxis(value.toBigDecimal()) - } +class AmountAxisValueFormatter(val showDecimals: Boolean) : + AxisValueFormatter { + override fun formatValue( + value: Float, + chartValues: ChartValues, + ): String { + return if (showDecimals) { + showAmount(value.toBigDecimal()) + } else { + showAmountAxis(value.toBigDecimal()) } + } } var dfG: DecimalFormat = DecimalFormat("#G") @@ -285,13 +308,13 @@ var dfK: DecimalFormat = DecimalFormat("#k") var dfN: DecimalFormat = DecimalFormat("#") fun showAmountAxis(amount: BigDecimal?): String { - if (amount == null) return "" - if (amount.abs() < BigDecimal(0.01)) return "" + if (amount == null) return "" + if (amount.abs() < BigDecimal(0.01)) return "" - return when { - amount >= OneGiga -> dfG.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) - amount >= OneMega -> dfM.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) - amount >= TenKilo -> dfK.format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) - else -> dfN.format(amount) - } + return when { + amount >= OneGiga -> dfG.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) + amount >= OneMega -> dfM.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) + amount >= TenKilo -> dfK.format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) + else -> dfN.format(amount) + } } 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 069d1d117..47a7528dc 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 @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen.loggedIn import androidx.compose.animation.Crossfade @@ -153,1677 +173,1693 @@ import com.vitorpamplona.quartz.events.ReportEvent import com.vitorpamplona.quartz.events.TelegramIdentity import com.vitorpamplona.quartz.events.TwitterIdentity import com.vitorpamplona.quartz.events.toImmutableListOfLists +import java.math.BigDecimal import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import java.math.BigDecimal @Composable -fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - if (userId == null) return +fun ProfileScreen( + userId: String?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + if (userId == null) return - var userBase by remember { mutableStateOf(LocalCache.getUserIfExists(userId)) } + var userBase by remember { mutableStateOf(LocalCache.getUserIfExists(userId)) } - if (userBase == null) { - LaunchedEffect(userId) { - // waits to resolve. - launch(Dispatchers.IO) { - val newUserBase = LocalCache.checkGetOrCreateUser(userId) - if (newUserBase != userBase) { - userBase = newUserBase - } - } + if (userBase == null) { + LaunchedEffect(userId) { + // waits to resolve. + launch(Dispatchers.IO) { + val newUserBase = LocalCache.checkGetOrCreateUser(userId) + if (newUserBase != userBase) { + userBase = newUserBase } + } } + } - userBase?.let { - PrepareViewModels( - baseUser = it, - accountViewModel = accountViewModel, - nav = nav - ) - } + userBase?.let { + PrepareViewModels( + baseUser = it, + accountViewModel = accountViewModel, + nav = nav, + ) + } } @Composable -fun PrepareViewModels(baseUser: User, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel = viewModel( - key = baseUser.pubkeyHex + "UserProfileFollowsUserFeedViewModel", - factory = NostrUserProfileFollowsUserFeedViewModel.Factory( - baseUser, - accountViewModel.account - ) +fun PrepareViewModels( + baseUser: User, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel = + viewModel( + key = baseUser.pubkeyHex + "UserProfileFollowsUserFeedViewModel", + factory = + NostrUserProfileFollowsUserFeedViewModel.Factory( + baseUser, + accountViewModel.account, + ), ) - val followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel = viewModel( - key = baseUser.pubkeyHex + "UserProfileFollowersUserFeedViewModel", - factory = NostrUserProfileFollowersUserFeedViewModel.Factory( - baseUser, - accountViewModel.account - ) + val followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel = + viewModel( + key = baseUser.pubkeyHex + "UserProfileFollowersUserFeedViewModel", + factory = + NostrUserProfileFollowersUserFeedViewModel.Factory( + baseUser, + accountViewModel.account, + ), ) - val appRecommendations: NostrUserAppRecommendationsFeedViewModel = viewModel( - key = baseUser.pubkeyHex + "UserAppRecommendationsFeedViewModel", - factory = NostrUserAppRecommendationsFeedViewModel.Factory( - baseUser - ) + val appRecommendations: NostrUserAppRecommendationsFeedViewModel = + viewModel( + key = baseUser.pubkeyHex + "UserAppRecommendationsFeedViewModel", + factory = + NostrUserAppRecommendationsFeedViewModel.Factory( + baseUser, + ), ) - val zapFeedViewModel: NostrUserProfileZapsFeedViewModel = viewModel( - key = baseUser.pubkeyHex + "UserProfileZapsFeedViewModel", - factory = NostrUserProfileZapsFeedViewModel.Factory( - baseUser - ) + val zapFeedViewModel: NostrUserProfileZapsFeedViewModel = + viewModel( + key = baseUser.pubkeyHex + "UserProfileZapsFeedViewModel", + factory = + NostrUserProfileZapsFeedViewModel.Factory( + baseUser, + ), ) - val threadsViewModel: NostrUserProfileNewThreadsFeedViewModel = viewModel( - key = baseUser.pubkeyHex + "UserProfileNewThreadsFeedViewModel", - factory = NostrUserProfileNewThreadsFeedViewModel.Factory( - baseUser, - accountViewModel.account - ) + val threadsViewModel: NostrUserProfileNewThreadsFeedViewModel = + viewModel( + key = baseUser.pubkeyHex + "UserProfileNewThreadsFeedViewModel", + factory = + NostrUserProfileNewThreadsFeedViewModel.Factory( + baseUser, + accountViewModel.account, + ), ) - val repliesViewModel: NostrUserProfileConversationsFeedViewModel = viewModel( - key = baseUser.pubkeyHex + "UserProfileConversationsFeedViewModel", - factory = NostrUserProfileConversationsFeedViewModel.Factory( - baseUser, - accountViewModel.account - ) + val repliesViewModel: NostrUserProfileConversationsFeedViewModel = + viewModel( + key = baseUser.pubkeyHex + "UserProfileConversationsFeedViewModel", + factory = + NostrUserProfileConversationsFeedViewModel.Factory( + baseUser, + accountViewModel.account, + ), ) - val bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel = viewModel( - key = baseUser.pubkeyHex + "UserProfileBookmarksFeedViewModel", - factory = NostrUserProfileBookmarksFeedViewModel.Factory( - baseUser, - accountViewModel.account - ) + val bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel = + viewModel( + key = baseUser.pubkeyHex + "UserProfileBookmarksFeedViewModel", + factory = + NostrUserProfileBookmarksFeedViewModel.Factory( + baseUser, + accountViewModel.account, + ), ) - val reportsFeedViewModel: NostrUserProfileReportFeedViewModel = viewModel( - key = baseUser.pubkeyHex + "UserProfileReportFeedViewModel", - factory = NostrUserProfileReportFeedViewModel.Factory( - baseUser - ) + val reportsFeedViewModel: NostrUserProfileReportFeedViewModel = + viewModel( + key = baseUser.pubkeyHex + "UserProfileReportFeedViewModel", + factory = + NostrUserProfileReportFeedViewModel.Factory( + baseUser, + ), ) - ProfileScreen( - baseUser = baseUser, - threadsViewModel, - repliesViewModel, - followsFeedViewModel, - followersFeedViewModel, - appRecommendations, - zapFeedViewModel, - bookmarksFeedViewModel, - reportsFeedViewModel, - accountViewModel = accountViewModel, - nav = nav - ) + ProfileScreen( + baseUser = baseUser, + threadsViewModel, + repliesViewModel, + followsFeedViewModel, + followersFeedViewModel, + appRecommendations, + zapFeedViewModel, + bookmarksFeedViewModel, + reportsFeedViewModel, + accountViewModel = accountViewModel, + nav = nav, + ) } @Composable fun ProfileScreen( - baseUser: User, - threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, - repliesViewModel: NostrUserProfileConversationsFeedViewModel, - followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel, - followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, - appRecommendations: NostrUserAppRecommendationsFeedViewModel, - zapFeedViewModel: NostrUserProfileZapsFeedViewModel, - bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel, - reportsFeedViewModel: NostrUserProfileReportFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseUser: User, + threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, + repliesViewModel: NostrUserProfileConversationsFeedViewModel, + followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel, + followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, + appRecommendations: NostrUserAppRecommendationsFeedViewModel, + zapFeedViewModel: NostrUserProfileZapsFeedViewModel, + bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel, + reportsFeedViewModel: NostrUserProfileReportFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - NostrUserProfileDataSource.loadUserProfile(baseUser) + NostrUserProfileDataSource.loadUserProfile(baseUser) - val lifeCycleOwner = LocalLifecycleOwner.current + val lifeCycleOwner = LocalLifecycleOwner.current - DisposableEffect(accountViewModel) { + DisposableEffect(accountViewModel) { + NostrUserProfileDataSource.start() + onDispose { + NostrUserProfileDataSource.loadUserProfile(null) + NostrUserProfileDataSource.stop() + } + } + + DisposableEffect(lifeCycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Profidle Start") + NostrUserProfileDataSource.loadUserProfile(baseUser) NostrUserProfileDataSource.start() - onDispose { - NostrUserProfileDataSource.loadUserProfile(null) - NostrUserProfileDataSource.stop() - } + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Profile Stop") + NostrUserProfileDataSource.loadUserProfile(null) + NostrUserProfileDataSource.stop() + } } - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Profidle Start") - NostrUserProfileDataSource.loadUserProfile(baseUser) - NostrUserProfileDataSource.start() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Profile Stop") - NostrUserProfileDataSource.loadUserProfile(null) - NostrUserProfileDataSource.stop() - } - } + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } + } - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { - lifeCycleOwner.lifecycle.removeObserver(observer) - } + RenderSurface( + baseUser, + threadsViewModel, + repliesViewModel, + appRecommendations, + followsFeedViewModel, + followersFeedViewModel, + zapFeedViewModel, + bookmarksFeedViewModel, + reportsFeedViewModel, + accountViewModel, + nav, + ) +} + +@Composable +private fun RenderSurface( + baseUser: User, + threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, + repliesViewModel: NostrUserProfileConversationsFeedViewModel, + appRecommendations: NostrUserAppRecommendationsFeedViewModel, + followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel, + followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, + zapFeedViewModel: NostrUserProfileZapsFeedViewModel, + bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel, + reportsFeedViewModel: NostrUserProfileReportFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.background, + ) { + var columnSize by remember { mutableStateOf(IntSize.Zero) } + var tabsSize by remember { mutableStateOf(IntSize.Zero) } + + Column( + modifier = Modifier.fillMaxSize().onSizeChanged { columnSize = it }, + ) { + val coroutineScope = rememberCoroutineScope() + val scrollState = rememberScrollState() + + val tabRowModifier = remember { Modifier.onSizeChanged { tabsSize = it } } + + val pagerModifier = + with(LocalDensity.current) { Modifier.height((columnSize.height - tabsSize.height).toDp()) } + + Box( + modifier = + remember { + Modifier.verticalScroll(scrollState) + .nestedScroll( + object : NestedScrollConnection { + override fun onPreScroll( + available: Offset, + source: NestedScrollSource, + ): Offset { + // When scrolling vertically, scroll the container first. + return if (available.y < 0 && scrollState.canScrollForward) { + coroutineScope.launch { scrollState.scrollBy(-available.y) } + Offset(0f, available.y) + } else { + Offset.Zero + } + } + }, + ) + .fillMaxHeight() + }, + ) { + RenderScreen( + baseUser, + tabRowModifier, + pagerModifier, + threadsViewModel, + repliesViewModel, + appRecommendations, + followsFeedViewModel, + followersFeedViewModel, + zapFeedViewModel, + bookmarksFeedViewModel, + reportsFeedViewModel, + accountViewModel, + nav, + ) + } } + } +} - RenderSurface( +@Composable +@OptIn(ExperimentalFoundationApi::class) +private fun RenderScreen( + baseUser: User, + tabRowModifier: Modifier, + pagerModifier: Modifier, + threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, + repliesViewModel: NostrUserProfileConversationsFeedViewModel, + appRecommendations: NostrUserAppRecommendationsFeedViewModel, + followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel, + followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, + zapFeedViewModel: NostrUserProfileZapsFeedViewModel, + bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel, + reportsFeedViewModel: NostrUserProfileReportFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val pagerState = rememberPagerState { 9 } + + Column { + ProfileHeader(baseUser, appRecommendations, nav, accountViewModel) + ScrollableTabRow( + containerColor = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + selectedTabIndex = pagerState.currentPage, + edgePadding = 8.dp, + modifier = tabRowModifier, + divider = { Divider(thickness = DividerThickness) }, + ) { + CreateAndRenderTabs(baseUser, pagerState) + } + HorizontalPager( + state = pagerState, + modifier = pagerModifier, + ) { page -> + CreateAndRenderPages( + page, baseUser, threadsViewModel, repliesViewModel, - appRecommendations, followsFeedViewModel, followersFeedViewModel, zapFeedViewModel, bookmarksFeedViewModel, reportsFeedViewModel, accountViewModel, - nav - ) -} - -@Composable -private fun RenderSurface( - baseUser: User, - threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, - repliesViewModel: NostrUserProfileConversationsFeedViewModel, - appRecommendations: NostrUserAppRecommendationsFeedViewModel, - followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel, - followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, - zapFeedViewModel: NostrUserProfileZapsFeedViewModel, - bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel, - reportsFeedViewModel: NostrUserProfileReportFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit -) { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.background - ) { - var columnSize by remember { mutableStateOf(IntSize.Zero) } - var tabsSize by remember { mutableStateOf(IntSize.Zero) } - - Column( - modifier = Modifier - .fillMaxSize() - .onSizeChanged { - columnSize = it - } - ) { - val coroutineScope = rememberCoroutineScope() - val scrollState = rememberScrollState() - - val tabRowModifier = remember { - Modifier.onSizeChanged { - tabsSize = it - } - } - - val pagerModifier = with(LocalDensity.current) { - Modifier.height((columnSize.height - tabsSize.height).toDp()) - } - - Box( - modifier = remember { - Modifier - .verticalScroll(scrollState) - .nestedScroll(object : NestedScrollConnection { - override fun onPreScroll( - available: Offset, - source: NestedScrollSource - ): Offset { - // When scrolling vertically, scroll the container first. - return if (available.y < 0 && scrollState.canScrollForward) { - coroutineScope.launch { - scrollState.scrollBy(-available.y) - } - Offset(0f, available.y) - } else { - Offset.Zero - } - } - }) - .fillMaxHeight() - } - ) { - RenderScreen( - baseUser, - tabRowModifier, - pagerModifier, - threadsViewModel, - repliesViewModel, - appRecommendations, - followsFeedViewModel, - followersFeedViewModel, - zapFeedViewModel, - bookmarksFeedViewModel, - reportsFeedViewModel, - accountViewModel, - nav - ) - } - } - } -} - -@Composable -@OptIn(ExperimentalFoundationApi::class) -private fun RenderScreen( - baseUser: User, - tabRowModifier: Modifier, - pagerModifier: Modifier, - threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, - repliesViewModel: NostrUserProfileConversationsFeedViewModel, - appRecommendations: NostrUserAppRecommendationsFeedViewModel, - followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel, - followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, - zapFeedViewModel: NostrUserProfileZapsFeedViewModel, - bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel, - reportsFeedViewModel: NostrUserProfileReportFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit -) { - val pagerState = rememberPagerState { 9 } - - Column { - ProfileHeader(baseUser, appRecommendations, nav, accountViewModel) - ScrollableTabRow( - containerColor = MaterialTheme.colorScheme.background, - contentColor = MaterialTheme.colorScheme.onBackground, - selectedTabIndex = pagerState.currentPage, - edgePadding = 8.dp, - modifier = tabRowModifier, - divider = { - Divider(thickness = DividerThickness) - } - ) { - CreateAndRenderTabs(baseUser, pagerState) - } - HorizontalPager( - state = pagerState, - modifier = pagerModifier - ) { page -> - CreateAndRenderPages( - page, - baseUser, - threadsViewModel, - repliesViewModel, - followsFeedViewModel, - followersFeedViewModel, - zapFeedViewModel, - bookmarksFeedViewModel, - reportsFeedViewModel, - accountViewModel, - nav - ) - } + nav, + ) } + } } @Composable private fun CreateAndRenderPages( - page: Int, - baseUser: User, - threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, - repliesViewModel: NostrUserProfileConversationsFeedViewModel, - followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel, - followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, - zapFeedViewModel: NostrUserProfileZapsFeedViewModel, - bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel, - reportsFeedViewModel: NostrUserProfileReportFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + page: Int, + baseUser: User, + threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, + repliesViewModel: NostrUserProfileConversationsFeedViewModel, + followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel, + followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, + zapFeedViewModel: NostrUserProfileZapsFeedViewModel, + bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel, + reportsFeedViewModel: NostrUserProfileReportFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - UpdateThreadsAndRepliesWhenBlockUnblock(baseUser, threadsViewModel, repliesViewModel, accountViewModel) + UpdateThreadsAndRepliesWhenBlockUnblock( + baseUser, + threadsViewModel, + repliesViewModel, + accountViewModel, + ) - when (page) { - 0 -> TabNotesNewThreads(threadsViewModel, accountViewModel, nav) - 1 -> TabNotesConversations(repliesViewModel, accountViewModel, nav) - 2 -> TabFollows(baseUser, followsFeedViewModel, accountViewModel, nav) - 3 -> TabFollowers(baseUser, followersFeedViewModel, accountViewModel, nav) - 4 -> TabReceivedZaps(baseUser, zapFeedViewModel, accountViewModel, nav) - 5 -> TabBookmarks(bookmarksFeedViewModel, accountViewModel, nav) - 6 -> TabFollowedTags(baseUser, accountViewModel, nav) - 7 -> TabReports(baseUser, reportsFeedViewModel, accountViewModel, nav) - 8 -> TabRelays(baseUser, accountViewModel, nav) - } + when (page) { + 0 -> TabNotesNewThreads(threadsViewModel, accountViewModel, nav) + 1 -> TabNotesConversations(repliesViewModel, accountViewModel, nav) + 2 -> TabFollows(baseUser, followsFeedViewModel, accountViewModel, nav) + 3 -> TabFollowers(baseUser, followersFeedViewModel, accountViewModel, nav) + 4 -> TabReceivedZaps(baseUser, zapFeedViewModel, accountViewModel, nav) + 5 -> TabBookmarks(bookmarksFeedViewModel, accountViewModel, nav) + 6 -> TabFollowedTags(baseUser, accountViewModel, nav) + 7 -> TabReports(baseUser, reportsFeedViewModel, accountViewModel, nav) + 8 -> TabRelays(baseUser, accountViewModel, nav) + } } @Composable -fun UpdateThreadsAndRepliesWhenBlockUnblock(baseUser: User, threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, repliesViewModel: NostrUserProfileConversationsFeedViewModel, accountViewModel: AccountViewModel) { - val isHidden by accountViewModel.account.liveHiddenUsers.map { +fun UpdateThreadsAndRepliesWhenBlockUnblock( + baseUser: User, + threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, + repliesViewModel: NostrUserProfileConversationsFeedViewModel, + accountViewModel: AccountViewModel, +) { + val isHidden by + accountViewModel.account.liveHiddenUsers + .map { it.hiddenUsers.contains(baseUser.pubkeyHex) || it.spammers.contains(baseUser.pubkeyHex) - }.observeAsState(accountViewModel.account.isHidden(baseUser)) + } + .observeAsState(accountViewModel.account.isHidden(baseUser)) - LaunchedEffect(key1 = isHidden) { - threadsViewModel.invalidateData() - repliesViewModel.invalidateData() - } + LaunchedEffect(key1 = isHidden) { + threadsViewModel.invalidateData() + repliesViewModel.invalidateData() + } } @OptIn(ExperimentalFoundationApi::class) @Composable private fun CreateAndRenderTabs( - baseUser: User, - pagerState: PagerState + baseUser: User, + pagerState: PagerState, ) { - val coroutineScope = rememberCoroutineScope() + val coroutineScope = rememberCoroutineScope() - val tabs = listOf<@Composable (() -> Unit)?>( - { Text(text = stringResource(R.string.notes)) }, - { Text(text = stringResource(R.string.replies)) }, - { FollowTabHeader(baseUser) }, - { FollowersTabHeader(baseUser) }, - { ZapTabHeader(baseUser) }, - { BookmarkTabHeader(baseUser) }, - { FollowedTagsTabHeader(baseUser) }, - { ReportsTabHeader(baseUser) }, - { RelaysTabHeader(baseUser) } + val tabs = + listOf<@Composable (() -> Unit)?>( + { Text(text = stringResource(R.string.notes)) }, + { Text(text = stringResource(R.string.replies)) }, + { FollowTabHeader(baseUser) }, + { FollowersTabHeader(baseUser) }, + { ZapTabHeader(baseUser) }, + { BookmarkTabHeader(baseUser) }, + { FollowedTagsTabHeader(baseUser) }, + { ReportsTabHeader(baseUser) }, + { RelaysTabHeader(baseUser) }, ) - tabs.forEachIndexed { index, function -> - Tab( - selected = pagerState.currentPage == index, - onClick = { - coroutineScope.launch { pagerState.animateScrollToPage(index) } - }, - text = function - ) - } + tabs.forEachIndexed { index, function -> + Tab( + selected = pagerState.currentPage == index, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } }, + text = function, + ) + } } @Composable private fun RelaysTabHeader(baseUser: User) { - val userState by baseUser.live().relays.observeAsState() - val userRelaysBeingUsed = remember(userState) { userState?.user?.relaysBeingUsed?.size ?: "--" } + val userState by baseUser.live().relays.observeAsState() + val userRelaysBeingUsed = remember(userState) { userState?.user?.relaysBeingUsed?.size ?: "--" } - val userStateRelayInfo by baseUser.live().relayInfo.observeAsState() - val userRelays = remember(userStateRelayInfo) { userStateRelayInfo?.user?.latestContactList?.relays()?.size ?: "--" } + val userStateRelayInfo by baseUser.live().relayInfo.observeAsState() + val userRelays = + remember(userStateRelayInfo) { + userStateRelayInfo?.user?.latestContactList?.relays()?.size ?: "--" + } - Text(text = "$userRelaysBeingUsed / $userRelays ${stringResource(R.string.relays)}") + Text(text = "$userRelaysBeingUsed / $userRelays ${stringResource(R.string.relays)}") } @Composable private fun ReportsTabHeader(baseUser: User) { - val userState by baseUser.live().reports.observeAsState() - var userReports by remember { mutableIntStateOf(0) } + val userState by baseUser.live().reports.observeAsState() + var userReports by remember { mutableIntStateOf(0) } - LaunchedEffect(key1 = userState) { - launch(Dispatchers.IO) { - val newSize = UserProfileReportsFeedFilter(baseUser).feed().size + LaunchedEffect(key1 = userState) { + launch(Dispatchers.IO) { + val newSize = UserProfileReportsFeedFilter(baseUser).feed().size - if (newSize != userReports) { - userReports = newSize - } - } + if (newSize != userReports) { + userReports = newSize + } } + } - Text(text = "$userReports ${stringResource(R.string.reports)}") + Text(text = "$userReports ${stringResource(R.string.reports)}") } @Composable private fun FollowedTagsTabHeader(baseUser: User) { - var usertags by remember { mutableIntStateOf(0) } + var usertags by remember { mutableIntStateOf(0) } - LaunchedEffect(key1 = baseUser) { - launch(Dispatchers.IO) { - val contactList = baseUser.latestContactList + LaunchedEffect(key1 = baseUser) { + launch(Dispatchers.IO) { + val contactList = baseUser.latestContactList - val newTags = (contactList?.verifiedFollowTagSet?.count() ?: 0) + val newTags = (contactList?.verifiedFollowTagSet?.count() ?: 0) - if (newTags != usertags) { - usertags = newTags - } - } + if (newTags != usertags) { + usertags = newTags + } } + } - Text(text = "$usertags ${stringResource(R.string.followed_tags)}") + Text(text = "$usertags ${stringResource(R.string.followed_tags)}") } @Composable private fun BookmarkTabHeader(baseUser: User) { - val userState by baseUser.live().bookmarks.observeAsState() + val userState by baseUser.live().bookmarks.observeAsState() - var userBookmarks by remember { mutableIntStateOf(0) } + var userBookmarks by remember { mutableIntStateOf(0) } - LaunchedEffect(key1 = userState) { - launch(Dispatchers.IO) { - val bookmarkList = userState?.user?.latestBookmarkList + LaunchedEffect(key1 = userState) { + launch(Dispatchers.IO) { + val bookmarkList = userState?.user?.latestBookmarkList - val newBookmarks = (bookmarkList?.taggedEvents()?.count() ?: 0) + (bookmarkList?.taggedAddresses()?.count() ?: 0) + val newBookmarks = + (bookmarkList?.taggedEvents()?.count() + ?: 0) + (bookmarkList?.taggedAddresses()?.count() ?: 0) - if (newBookmarks != userBookmarks) { - userBookmarks = newBookmarks - } - } + if (newBookmarks != userBookmarks) { + userBookmarks = newBookmarks + } } + } - Text(text = "$userBookmarks ${stringResource(R.string.bookmarks)}") + Text(text = "$userBookmarks ${stringResource(R.string.bookmarks)}") } @Composable private fun ZapTabHeader(baseUser: User) { - val userState by baseUser.live().zaps.observeAsState() - var zapAmount by remember { mutableStateOf(null) } + val userState by baseUser.live().zaps.observeAsState() + var zapAmount by remember { mutableStateOf(null) } - LaunchedEffect(key1 = userState) { - launch(Dispatchers.Default) { - val tempAmount = baseUser.zappedAmount() - if (zapAmount != tempAmount) { - zapAmount = tempAmount - } - } + LaunchedEffect(key1 = userState) { + launch(Dispatchers.Default) { + val tempAmount = baseUser.zappedAmount() + if (zapAmount != tempAmount) { + zapAmount = tempAmount + } } + } - Text(text = "${showAmountAxis(zapAmount)} ${stringResource(id = R.string.zaps)}") + Text(text = "${showAmountAxis(zapAmount)} ${stringResource(id = R.string.zaps)}") } @Composable private fun FollowersTabHeader(baseUser: User) { - val userState by baseUser.live().followers.observeAsState() - var followerCount by remember { mutableStateOf("--") } + val userState by baseUser.live().followers.observeAsState() + var followerCount by remember { mutableStateOf("--") } - val text = stringResource(R.string.followers) + val text = stringResource(R.string.followers) - LaunchedEffect(key1 = userState) { - launch(Dispatchers.IO) { - val newFollower = (userState?.user?.transientFollowerCount()?.toString() ?: "--") + " " + text + LaunchedEffect(key1 = userState) { + launch(Dispatchers.IO) { + val newFollower = (userState?.user?.transientFollowerCount()?.toString() ?: "--") + " " + text - if (followerCount != newFollower) { - followerCount = newFollower - } - } + if (followerCount != newFollower) { + followerCount = newFollower + } } + } - Text(text = followerCount) + Text(text = followerCount) } @Composable private fun FollowTabHeader(baseUser: User) { - val userState by baseUser.live().follows.observeAsState() - var followCount by remember { mutableStateOf("--") } + val userState by baseUser.live().follows.observeAsState() + var followCount by remember { mutableStateOf("--") } - val text = stringResource(R.string.follows) + val text = stringResource(R.string.follows) - LaunchedEffect(key1 = userState) { - launch(Dispatchers.IO) { - val newFollow = (userState?.user?.transientFollowCount()?.toString() ?: "--") + " " + text + LaunchedEffect(key1 = userState) { + launch(Dispatchers.IO) { + val newFollow = (userState?.user?.transientFollowCount()?.toString() ?: "--") + " " + text - if (followCount != newFollow) { - followCount = newFollow - } - } + if (followCount != newFollow) { + followCount = newFollow + } } + } - Text(text = followCount) + Text(text = followCount) } @Composable private fun ProfileHeader( - baseUser: User, - appRecommendations: NostrUserAppRecommendationsFeedViewModel, - nav: (String) -> Unit, - accountViewModel: AccountViewModel + baseUser: User, + appRecommendations: NostrUserAppRecommendationsFeedViewModel, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - var popupExpanded by remember { mutableStateOf(false) } - var zoomImageDialogOpen by remember { mutableStateOf(false) } + var popupExpanded by remember { mutableStateOf(false) } + var zoomImageDialogOpen by remember { mutableStateOf(false) } - Box { - DrawBanner(baseUser, accountViewModel) + Box { + DrawBanner(baseUser, accountViewModel) - Box( - modifier = Modifier - .padding(horizontal = 10.dp) - .size(40.dp) - .align(Alignment.TopEnd) - ) { - Button( - modifier = Modifier - .size(30.dp) - .align(Alignment.Center), - onClick = { - popupExpanded = true - }, - shape = ButtonBorder, - colors = ButtonDefaults - .buttonColors( - containerColor = MaterialTheme.colorScheme.background - ), - contentPadding = ZeroPadding - ) { - Icon( - tint = MaterialTheme.colorScheme.placeholderText, - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.more_options) - ) + Box( + modifier = Modifier.padding(horizontal = 10.dp).size(40.dp).align(Alignment.TopEnd), + ) { + Button( + modifier = Modifier.size(30.dp).align(Alignment.Center), + onClick = { popupExpanded = true }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.background, + ), + contentPadding = ZeroPadding, + ) { + Icon( + tint = MaterialTheme.colorScheme.placeholderText, + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more_options), + ) - UserProfileDropDownMenu(baseUser, popupExpanded, { popupExpanded = false }, accountViewModel) - } - } - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 10.dp) - .padding(top = 75.dp) - ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Bottom - ) { - val clipboardManager = LocalClipboardManager.current - - ClickableUserPicture( - baseUser = baseUser, - accountViewModel = accountViewModel, - size = 100.dp, - modifier = Modifier.border( - 3.dp, - MaterialTheme.colorScheme.background, - CircleShape - ), - onClick = { - if (baseUser.profilePicture() != null) { - zoomImageDialogOpen = true - } - }, - onLongClick = { - it.info?.picture?.let { it1 -> - clipboardManager.setText( - AnnotatedString(it1) - ) - } - } - ) - - Spacer(Modifier.weight(1f)) - - Row( - modifier = Modifier - .height(Size35dp) - .padding(bottom = 3.dp) - ) { - MessageButton(baseUser, accountViewModel, nav) - - ProfileActions(baseUser, accountViewModel) - } - } - - DrawAdditionalInfo(baseUser, appRecommendations, accountViewModel, nav) - - Divider(modifier = Modifier.padding(top = 6.dp)) - } + UserProfileDropDownMenu( + baseUser, + popupExpanded, + { popupExpanded = false }, + accountViewModel, + ) + } } - val profilePic = baseUser.profilePicture() - if (zoomImageDialogOpen && profilePic != null) { - ZoomableImageDialog(figureOutMimeType(profilePic), onDismiss = { zoomImageDialogOpen = false }, accountViewModel = accountViewModel) + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp).padding(top = 75.dp), + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom, + ) { + val clipboardManager = LocalClipboardManager.current + + ClickableUserPicture( + baseUser = baseUser, + accountViewModel = accountViewModel, + size = 100.dp, + modifier = + Modifier.border( + 3.dp, + MaterialTheme.colorScheme.background, + CircleShape, + ), + onClick = { + if (baseUser.profilePicture() != null) { + zoomImageDialogOpen = true + } + }, + onLongClick = { + it.info?.picture?.let { it1 -> + clipboardManager.setText( + AnnotatedString(it1), + ) + } + }, + ) + + Spacer(Modifier.weight(1f)) + + Row( + modifier = Modifier.height(Size35dp).padding(bottom = 3.dp), + ) { + MessageButton(baseUser, accountViewModel, nav) + + ProfileActions(baseUser, accountViewModel) + } + } + + DrawAdditionalInfo(baseUser, appRecommendations, accountViewModel, nav) + + Divider(modifier = Modifier.padding(top = 6.dp)) } + } + + val profilePic = baseUser.profilePicture() + if (zoomImageDialogOpen && profilePic != null) { + ZoomableImageDialog( + figureOutMimeType(profilePic), + onDismiss = { zoomImageDialogOpen = false }, + accountViewModel = accountViewModel, + ) + } } @Composable private fun ProfileActions( - baseUser: User, - accountViewModel: AccountViewModel + baseUser: User, + accountViewModel: AccountViewModel, ) { - val isMe by remember(accountViewModel) { - derivedStateOf { - accountViewModel.userProfile() == baseUser - } - } + val isMe by + remember(accountViewModel) { derivedStateOf { accountViewModel.userProfile() == baseUser } } - if (isMe) { - EditButton(accountViewModel.account) - } + if (isMe) { + EditButton(accountViewModel.account) + } - WatchIsHiddenUser(baseUser, accountViewModel) { isHidden -> - if (isHidden) { - ShowUserButton { - accountViewModel.showUser(baseUser.pubkeyHex) - } - } else { - DisplayFollowUnfollowButton(baseUser, accountViewModel) - } + WatchIsHiddenUser(baseUser, accountViewModel) { isHidden -> + if (isHidden) { + ShowUserButton { accountViewModel.showUser(baseUser.pubkeyHex) } + } else { + DisplayFollowUnfollowButton(baseUser, accountViewModel) } + } } @Composable private fun DisplayFollowUnfollowButton( - baseUser: User, - accountViewModel: AccountViewModel + baseUser: User, + accountViewModel: AccountViewModel, ) { - val isLoggedInFollowingUser by accountViewModel.account.userProfile().live().follows.map { - it.user.isFollowing(baseUser) - }.distinctUntilChanged().observeAsState(initial = accountViewModel.account.isFollowing(baseUser)) + val isLoggedInFollowingUser by + accountViewModel.account + .userProfile() + .live() + .follows + .map { it.user.isFollowing(baseUser) } + .distinctUntilChanged() + .observeAsState(initial = accountViewModel.account.isFollowing(baseUser)) - val isUserFollowingLoggedIn by baseUser.live().follows.map { - it.user.isFollowing(accountViewModel.account.userProfile()) - }.distinctUntilChanged().observeAsState(initial = baseUser.isFollowing(accountViewModel.account.userProfile())) + val isUserFollowingLoggedIn by + baseUser + .live() + .follows + .map { it.user.isFollowing(accountViewModel.account.userProfile()) } + .distinctUntilChanged() + .observeAsState(initial = baseUser.isFollowing(accountViewModel.account.userProfile())) - if (isLoggedInFollowingUser) { - UnfollowButton { - if (!accountViewModel.isWriteable()) { - accountViewModel.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_unfollow - ) - } else { - accountViewModel.unfollow(baseUser) - } - } - } else { - if (isUserFollowingLoggedIn) { - FollowButton(R.string.follow_back) { - if (!accountViewModel.isWriteable()) { - accountViewModel.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_follow - ) - } else { - accountViewModel.follow(baseUser) - } - } - } else { - FollowButton(R.string.follow) { - if (!accountViewModel.isWriteable()) { - accountViewModel.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_follow - ) - } else { - accountViewModel.follow(baseUser) - } - } - } + if (isLoggedInFollowingUser) { + UnfollowButton { + if (!accountViewModel.isWriteable()) { + accountViewModel.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_unfollow, + ) + } else { + accountViewModel.unfollow(baseUser) + } } + } else { + if (isUserFollowingLoggedIn) { + FollowButton(R.string.follow_back) { + if (!accountViewModel.isWriteable()) { + accountViewModel.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_follow, + ) + } else { + accountViewModel.follow(baseUser) + } + } + } else { + FollowButton(R.string.follow) { + if (!accountViewModel.isWriteable()) { + accountViewModel.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_follow, + ) + } else { + accountViewModel.follow(baseUser) + } + } + } + } } @Composable -fun WatchIsHiddenUser(baseUser: User, accountViewModel: AccountViewModel, content: @Composable (Boolean) -> Unit) { - val isHidden by accountViewModel.account.liveHiddenUsers.map { +fun WatchIsHiddenUser( + baseUser: User, + accountViewModel: AccountViewModel, + content: @Composable (Boolean) -> Unit, +) { + val isHidden by + accountViewModel.account.liveHiddenUsers + .map { it.hiddenUsers.contains(baseUser.pubkeyHex) || it.spammers.contains(baseUser.pubkeyHex) - }.observeAsState(accountViewModel.account.isHidden(baseUser)) + } + .observeAsState(accountViewModel.account.isHidden(baseUser)) - content(isHidden) + content(isHidden) } fun getIdentityClaimIcon(identity: IdentityClaim): Int { - return when (identity) { - is TwitterIdentity -> R.drawable.twitter - is TelegramIdentity -> R.drawable.telegram - is MastodonIdentity -> R.drawable.mastodon - is GitHubIdentity -> R.drawable.github - else -> R.drawable.github - } + return when (identity) { + is TwitterIdentity -> R.drawable.twitter + is TelegramIdentity -> R.drawable.telegram + is MastodonIdentity -> R.drawable.mastodon + is GitHubIdentity -> R.drawable.github + else -> R.drawable.github + } } fun getIdentityClaimDescription(identity: IdentityClaim): Int { - return when (identity) { - is TwitterIdentity -> R.string.twitter - is TelegramIdentity -> R.string.telegram - is MastodonIdentity -> R.string.mastodon - is GitHubIdentity -> R.string.github - else -> R.drawable.github - } + return when (identity) { + is TwitterIdentity -> R.string.twitter + is TelegramIdentity -> R.string.telegram + is MastodonIdentity -> R.string.mastodon + is GitHubIdentity -> R.string.github + else -> R.drawable.github + } } @Composable private fun DrawAdditionalInfo( - baseUser: User, - appRecommendations: NostrUserAppRecommendationsFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseUser: User, + appRecommendations: NostrUserAppRecommendationsFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val userState by baseUser.live().metadata.observeAsState() - val user = remember(userState) { userState?.user } ?: return - val tags = remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() } + val userState by baseUser.live().metadata.observeAsState() + val user = remember(userState) { userState?.user } ?: return + val tags = + remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() } - val uri = LocalUriHandler.current - val clipboardManager = LocalClipboardManager.current + val uri = LocalUriHandler.current + val clipboardManager = LocalClipboardManager.current - val automaticallyShowProfilePicture = remember { - accountViewModel.settings.showProfilePictures.value + val automaticallyShowProfilePicture = remember { + accountViewModel.settings.showProfilePictures.value + } + + user.toBestDisplayName().let { + Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(top = 7.dp)) { + CreateTextWithEmoji( + text = it, + tags = tags, + fontWeight = FontWeight.Bold, + fontSize = 25.sp, + ) + } + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = user.pubkeyDisplayHex(), + modifier = Modifier.padding(top = 1.dp, bottom = 1.dp), + color = MaterialTheme.colorScheme.placeholderText, + ) + + IconButton( + modifier = Modifier.size(25.dp).padding(start = 5.dp), + onClick = { clipboardManager.setText(AnnotatedString(user.pubkeyNpub())) }, + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + null, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colorScheme.placeholderText, + ) } - user.toBestDisplayName().let { - Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(top = 7.dp)) { - CreateTextWithEmoji( - text = it, - tags = tags, - fontWeight = FontWeight.Bold, - fontSize = 25.sp - ) - } + var dialogOpen by remember { mutableStateOf(false) } + + if (dialogOpen) { + ShowQRDialog( + user, + automaticallyShowProfilePicture, + onScan = { + dialogOpen = false + nav(it) + }, + onClose = { dialogOpen = false }, + ) } + IconButton( + modifier = Modifier.size(25.dp), + onClick = { dialogOpen = true }, + ) { + Icon( + painter = painterResource(R.drawable.ic_qrcode), + null, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colorScheme.placeholderText, + ) + } + } + + DisplayBadges(baseUser, accountViewModel, nav) + + DisplayNip05ProfileStatus(user, accountViewModel) + + val website = user.info?.website + if (!website.isNullOrEmpty()) { Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = user.pubkeyDisplayHex(), - modifier = Modifier.padding(top = 1.dp, bottom = 1.dp), - color = MaterialTheme.colorScheme.placeholderText + Icon( + tint = MaterialTheme.colorScheme.placeholderText, + imageVector = Icons.Default.Link, + contentDescription = stringResource(R.string.website), + modifier = Modifier.size(16.dp), + ) + + ClickableText( + text = AnnotatedString(website.removePrefix("https://")), + onClick = { + website.let { + runCatching { + if (it.contains("://")) { + uri.openUri(it) + } else { + uri.openUri("http://$it") + } + } + } + }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp), + ) + } + } + + val lud16 = remember(userState) { user.info?.lud16?.trim() ?: user.info?.lud06?.trim() } + val pubkeyHex = remember { baseUser.pubkeyHex } + DisplayLNAddress(lud16, pubkeyHex, accountViewModel, nav) + + val identities = user.info?.latestMetadata?.identityClaims() + if (!identities.isNullOrEmpty()) { + identities.forEach { identity: IdentityClaim -> + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + tint = Color.Unspecified, + painter = painterResource(id = getIdentityClaimIcon(identity)), + contentDescription = stringResource(getIdentityClaimDescription(identity)), + modifier = Modifier.size(16.dp), ) - IconButton( - modifier = Modifier - .size(25.dp) - .padding(start = 5.dp), - onClick = { clipboardManager.setText(AnnotatedString(user.pubkeyNpub())); } - ) { - Icon( - imageVector = Icons.Default.ContentCopy, - null, - modifier = Modifier.size(15.dp), - tint = MaterialTheme.colorScheme.placeholderText - ) - } - - var dialogOpen by remember { - mutableStateOf(false) - } - - if (dialogOpen) { - ShowQRDialog( - user, - automaticallyShowProfilePicture, - onScan = { - dialogOpen = false - nav(it) - }, - onClose = { dialogOpen = false } - ) - } - - IconButton( - modifier = Modifier.size(25.dp), - onClick = { dialogOpen = true } - ) { - Icon( - painter = painterResource(R.drawable.ic_qrcode), - null, - modifier = Modifier.size(15.dp), - tint = MaterialTheme.colorScheme.placeholderText - ) - } + ClickableText( + text = AnnotatedString(identity.identity), + onClick = { runCatching { uri.openUri(identity.toProofUrl()) } }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp).weight(1f), + ) + } } + } - DisplayBadges(baseUser, accountViewModel, nav) + user.info?.about?.let { + Row( + modifier = Modifier.padding(top = 5.dp, bottom = 5.dp), + ) { + val defaultBackground = MaterialTheme.colorScheme.background + val background = remember { mutableStateOf(defaultBackground) } - DisplayNip05ProfileStatus(user, accountViewModel) - - val website = user.info?.website - if (!website.isNullOrEmpty()) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - tint = MaterialTheme.colorScheme.placeholderText, - imageVector = Icons.Default.Link, - contentDescription = stringResource(R.string.website), - modifier = Modifier.size(16.dp) - ) - - ClickableText( - text = AnnotatedString(website.removePrefix("https://")), - onClick = { - website.let { - runCatching { - if (it.contains("://")) { - uri.openUri(it) - } else { - uri.openUri("http://$it") - } - } - } - }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), - modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp) - ) - } + TranslatableRichTextViewer( + content = it, + canPreview = false, + tags = EmptyTagList, + backgroundColor = background, + accountViewModel = accountViewModel, + nav = nav, + ) } + } - val lud16 = remember(userState) { user.info?.lud16?.trim() ?: user.info?.lud06?.trim() } - val pubkeyHex = remember { baseUser.pubkeyHex } - DisplayLNAddress(lud16, pubkeyHex, accountViewModel, nav) - - val identities = user.info?.latestMetadata?.identityClaims() - if (!identities.isNullOrEmpty()) { - identities.forEach { identity: IdentityClaim -> - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - tint = Color.Unspecified, - painter = painterResource(id = getIdentityClaimIcon(identity)), - contentDescription = stringResource(getIdentityClaimDescription(identity)), - modifier = Modifier.size(16.dp) - ) - - ClickableText( - text = AnnotatedString(identity.identity), - onClick = { runCatching { uri.openUri(identity.toProofUrl()) } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), - modifier = Modifier - .padding(top = 1.dp, bottom = 1.dp, start = 5.dp) - .weight(1f) - ) - } - } - } - - user.info?.about?.let { - Row( - modifier = Modifier.padding(top = 5.dp, bottom = 5.dp) - ) { - val defaultBackground = MaterialTheme.colorScheme.background - val background = remember { - mutableStateOf(defaultBackground) - } - - TranslatableRichTextViewer( - content = it, - canPreview = false, - tags = EmptyTagList, - backgroundColor = background, - accountViewModel = accountViewModel, - nav = nav - ) - } - } - - DisplayAppRecommendations(appRecommendations, nav) + DisplayAppRecommendations(appRecommendations, nav) } @Composable fun DisplayLNAddress( - lud16: String?, - userHex: String, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + lud16: String?, + userHex: String, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - var zapExpanded by remember { mutableStateOf(false) } + val context = LocalContext.current + val scope = rememberCoroutineScope() + var zapExpanded by remember { mutableStateOf(false) } - var showErrorMessageDialog by remember { mutableStateOf(null) } + var showErrorMessageDialog by remember { mutableStateOf(null) } - if (showErrorMessageDialog != null) { - ErrorMessageDialog( - title = stringResource(id = R.string.error_dialog_zap_error), - textContent = showErrorMessageDialog ?: "", - onClickStartMessage = { - scope.launch(Dispatchers.IO) { - val route = routeToMessage(userHex, showErrorMessageDialog, accountViewModel) - nav(route) + if (showErrorMessageDialog != null) { + ErrorMessageDialog( + title = stringResource(id = R.string.error_dialog_zap_error), + textContent = showErrorMessageDialog ?: "", + onClickStartMessage = { + scope.launch(Dispatchers.IO) { + val route = routeToMessage(userHex, showErrorMessageDialog, accountViewModel) + nav(route) + } + }, + onDismiss = { showErrorMessageDialog = null }, + ) + } + + var showInfoMessageDialog by remember { mutableStateOf(null) } + if (showInfoMessageDialog != null) { + InformationDialog( + title = context.getString(R.string.payment_successful), + textContent = showInfoMessageDialog ?: "", + ) { + showInfoMessageDialog = null + } + } + + if (!lud16.isNullOrEmpty()) { + Row(verticalAlignment = Alignment.CenterVertically) { + LightningAddressIcon(modifier = Size16Modifier, tint = BitcoinOrange) + + ClickableText( + text = AnnotatedString(lud16), + onClick = { zapExpanded = !zapExpanded }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp).weight(1f), + ) + } + + if (zapExpanded) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 5.dp), + ) { + InvoiceRequestCard( + lud16, + userHex, + accountViewModel.account, + onSuccess = { + zapExpanded = false + // pay directly + if (accountViewModel.account.hasWalletConnectSetup()) { + accountViewModel.account.sendZapPaymentRequestFor(it, null) { response -> + if (response is PayInvoiceSuccessResponse) { + showInfoMessageDialog = context.getString(R.string.payment_successful) + } else if (response is PayInvoiceErrorResponse) { + showErrorMessageDialog = + response.error?.message + ?: response.error?.code?.toString() + ?: context.getString(R.string.error_parsing_error_message) } - }, - onDismiss = { showErrorMessageDialog = null } - ) - } - - var showInfoMessageDialog by remember { mutableStateOf(null) } - if (showInfoMessageDialog != null) { - InformationDialog( - title = context.getString(R.string.payment_successful), - textContent = showInfoMessageDialog ?: "" - ) { - showInfoMessageDialog = null - } - } - - if (!lud16.isNullOrEmpty()) { - Row(verticalAlignment = Alignment.CenterVertically) { - LightningAddressIcon(modifier = Size16Modifier, tint = BitcoinOrange) - - ClickableText( - text = AnnotatedString(lud16), - onClick = { zapExpanded = !zapExpanded }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), - modifier = Modifier - .padding(top = 1.dp, bottom = 1.dp, start = 5.dp) - .weight(1f) - ) - } - - if (zapExpanded) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 5.dp) - ) { - InvoiceRequestCard( - lud16, - userHex, - accountViewModel.account, - onSuccess = { - zapExpanded = false - // pay directly - if (accountViewModel.account.hasWalletConnectSetup()) { - accountViewModel.account.sendZapPaymentRequestFor(it, null) { response -> - if (response is PayInvoiceSuccessResponse) { - showInfoMessageDialog = context.getString(R.string.payment_successful) - } else if (response is PayInvoiceErrorResponse) { - showErrorMessageDialog = response.error?.message - ?: response.error?.code?.toString() - ?: context.getString(R.string.error_parsing_error_message) - } - } - } else { - payViaIntent(it, context) { - showErrorMessageDialog = it - } - } - }, - onClose = { - zapExpanded = false - }, - onError = { title, message -> - accountViewModel.toast(title, message) - } - ) + } + } else { + payViaIntent(it, context) { showErrorMessageDialog = it } } - } + }, + onClose = { zapExpanded = false }, + onError = { title, message -> accountViewModel.toast(title, message) }, + ) + } } + } } @Composable @OptIn(ExperimentalLayoutApi::class) private fun DisplayAppRecommendations( - appRecommendations: NostrUserAppRecommendationsFeedViewModel, - nav: (String) -> Unit + appRecommendations: NostrUserAppRecommendationsFeedViewModel, + nav: (String) -> Unit, ) { - val feedState by appRecommendations.feedContent.collectAsStateWithLifecycle() + val feedState by appRecommendations.feedContent.collectAsStateWithLifecycle() - LaunchedEffect(key1 = Unit) { - appRecommendations.invalidateData() - } + LaunchedEffect(key1 = Unit) { appRecommendations.invalidateData() } - Crossfade( - targetState = feedState, - animationSpec = tween(durationMillis = 100) - ) { state -> - when (state) { - is FeedState.Loaded -> { - Column { - Text(stringResource(id = R.string.recommended_apps)) + Crossfade( + targetState = feedState, + animationSpec = tween(durationMillis = 100), + ) { state -> + when (state) { + is FeedState.Loaded -> { + Column { + Text(stringResource(id = R.string.recommended_apps)) - FlowRow( - verticalArrangement = Arrangement.Center, - modifier = Modifier.padding(vertical = 5.dp) - ) { - state.feed.value.forEach { app -> - WatchApp(app, nav) - } - } - } - } - - else -> {} + FlowRow( + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(vertical = 5.dp), + ) { + state.feed.value.forEach { app -> WatchApp(app, nav) } + } } + } + else -> {} } + } } @Composable -private fun WatchApp(baseApp: Note, nav: (String) -> Unit) { - val appState by baseApp.live().metadata.observeAsState() +private fun WatchApp( + baseApp: Note, + nav: (String) -> Unit, +) { + val appState by baseApp.live().metadata.observeAsState() - var appLogo by remember(baseApp) { mutableStateOf(null) } + var appLogo by remember(baseApp) { mutableStateOf(null) } - LaunchedEffect(key1 = appState) { - launch(Dispatchers.Default) { - val newAppLogo = (appState?.note?.event as? AppDefinitionEvent)?.appMetaData()?.picture?.ifBlank { null } - if (newAppLogo != appLogo) { - appLogo = newAppLogo - } - } + LaunchedEffect(key1 = appState) { + launch(Dispatchers.Default) { + val newAppLogo = + (appState?.note?.event as? AppDefinitionEvent)?.appMetaData()?.picture?.ifBlank { null } + if (newAppLogo != appLogo) { + appLogo = newAppLogo + } } + } - appLogo?.let { - Box( - remember { - Modifier - .size(Size35dp) - .clickable { - nav("Note/${baseApp.idHex}") - } - } - ) { - AsyncImage( - model = appLogo, - contentDescription = null, - modifier = remember { - Modifier - .size(Size35dp) - .clip(shape = CircleShape) - } - ) - } + appLogo?.let { + Box( + remember { Modifier.size(Size35dp).clickable { nav("Note/${baseApp.idHex}") } }, + ) { + AsyncImage( + model = appLogo, + contentDescription = null, + modifier = remember { Modifier.size(Size35dp).clip(shape = CircleShape) }, + ) } + } } @Composable private fun DisplayBadges( - baseUser: User, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + baseUser: User, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val automaticallyShowProfilePicture = remember { - accountViewModel.settings.showProfilePictures.value - } + val automaticallyShowProfilePicture = remember { + accountViewModel.settings.showProfilePictures.value + } - LoadAddressableNote( - aTag = ATag( - BadgeProfilesEvent.kind, - baseUser.pubkeyHex, - BadgeProfilesEvent.standardDTAg, - null - ), - accountViewModel - ) { note -> - if (note != null) { - WatchAndRenderBadgeList(note, automaticallyShowProfilePicture, nav) - } + LoadAddressableNote( + aTag = + ATag( + BadgeProfilesEvent.KIND, + baseUser.pubkeyHex, + BadgeProfilesEvent.STANDARD_D_TAG, + null, + ), + accountViewModel, + ) { note -> + if (note != null) { + WatchAndRenderBadgeList(note, automaticallyShowProfilePicture, nav) } + } } @Composable private fun WatchAndRenderBadgeList( - note: AddressableNote, - loadProfilePicture: Boolean, - nav: (String) -> Unit + note: AddressableNote, + loadProfilePicture: Boolean, + nav: (String) -> Unit, ) { - val badgeList by note.live().metadata.map { - (it.note.event as? BadgeProfilesEvent)?.badgeAwardEvents()?.toImmutableList() - }.distinctUntilChanged().observeAsState() + val badgeList by + note + .live() + .metadata + .map { (it.note.event as? BadgeProfilesEvent)?.badgeAwardEvents()?.toImmutableList() } + .distinctUntilChanged() + .observeAsState() - badgeList?.let { list -> - RenderBadgeList(list, loadProfilePicture, nav) - } + badgeList?.let { list -> RenderBadgeList(list, loadProfilePicture, nav) } } @Composable @OptIn(ExperimentalLayoutApi::class) private fun RenderBadgeList( - list: ImmutableList, - loadProfilePicture: Boolean, - nav: (String) -> Unit + list: ImmutableList, + loadProfilePicture: Boolean, + nav: (String) -> Unit, ) { - FlowRow( - verticalArrangement = Arrangement.Center, - modifier = Modifier.padding(vertical = 5.dp) - ) { - list.forEach { badgeAwardEvent -> - LoadAndRenderBadge(badgeAwardEvent, loadProfilePicture, nav) - } - } + FlowRow( + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(vertical = 5.dp), + ) { + list.forEach { badgeAwardEvent -> LoadAndRenderBadge(badgeAwardEvent, loadProfilePicture, nav) } + } } @Composable -private fun LoadAndRenderBadge(badgeAwardEventHex: String, loadProfilePicture: Boolean, nav: (String) -> Unit) { - var baseNote by remember { - mutableStateOf(LocalCache.getNoteIfExists(badgeAwardEventHex)) - } +private fun LoadAndRenderBadge( + badgeAwardEventHex: String, + loadProfilePicture: Boolean, + nav: (String) -> Unit, +) { + var baseNote by remember { mutableStateOf(LocalCache.getNoteIfExists(badgeAwardEventHex)) } - LaunchedEffect(key1 = badgeAwardEventHex) { - if (baseNote == null) { - launch(Dispatchers.IO) { - baseNote = LocalCache.checkGetOrCreateNote(badgeAwardEventHex) - } - } + LaunchedEffect(key1 = badgeAwardEventHex) { + if (baseNote == null) { + launch(Dispatchers.IO) { baseNote = LocalCache.checkGetOrCreateNote(badgeAwardEventHex) } } + } - baseNote?.let { - ObserveAndRenderBadge(it, loadProfilePicture, nav) - } + baseNote?.let { ObserveAndRenderBadge(it, loadProfilePicture, nav) } } @Composable private fun ObserveAndRenderBadge( - it: Note, - loadProfilePicture: Boolean, - nav: (String) -> Unit + it: Note, + loadProfilePicture: Boolean, + nav: (String) -> Unit, ) { - val badgeAwardState by it.live().metadata.observeAsState() - val baseBadgeDefinition by remember(badgeAwardState) { - derivedStateOf { - badgeAwardState?.note?.replyTo?.firstOrNull() - } - } + val badgeAwardState by it.live().metadata.observeAsState() + val baseBadgeDefinition by + remember(badgeAwardState) { derivedStateOf { badgeAwardState?.note?.replyTo?.firstOrNull() } } - baseBadgeDefinition?.let { - BadgeThumb(it, loadProfilePicture, nav, Size35dp) - } + baseBadgeDefinition?.let { BadgeThumb(it, loadProfilePicture, nav, Size35dp) } } @Composable fun BadgeThumb( - note: Note, - loadProfilePicture: Boolean, - nav: (String) -> Unit, - size: Dp, - pictureModifier: Modifier = Modifier + note: Note, + loadProfilePicture: Boolean, + nav: (String) -> Unit, + size: Dp, + pictureModifier: Modifier = Modifier, ) { - BadgeThumb(note, loadProfilePicture, size, pictureModifier) { - nav("Note/${note.idHex}") - } + BadgeThumb(note, loadProfilePicture, size, pictureModifier) { nav("Note/${note.idHex}") } } @Composable fun BadgeThumb( - baseNote: Note, - loadProfilePicture: Boolean, - size: Dp, - pictureModifier: Modifier = Modifier, - onClick: ((String) -> Unit)? = null + baseNote: Note, + loadProfilePicture: Boolean, + size: Dp, + pictureModifier: Modifier = Modifier, + onClick: ((String) -> Unit)? = null, ) { - Box( - remember { - Modifier - .width(size) - .height(size) - } - ) { - WatchAndRenderBadgeImage(baseNote, loadProfilePicture, size, pictureModifier, onClick) - } + Box( + remember { Modifier.width(size).height(size) }, + ) { + WatchAndRenderBadgeImage(baseNote, loadProfilePicture, size, pictureModifier, onClick) + } } @Composable private fun WatchAndRenderBadgeImage( - baseNote: Note, - loadProfilePicture: Boolean, - size: Dp, - pictureModifier: Modifier, - onClick: ((String) -> Unit)? + baseNote: Note, + loadProfilePicture: Boolean, + size: Dp, + pictureModifier: Modifier, + onClick: ((String) -> Unit)?, ) { - val noteState by baseNote.live().metadata.observeAsState() - val eventId = remember(noteState) { noteState?.note?.idHex } ?: return - val image by remember(noteState) { - derivedStateOf { - val event = noteState?.note?.event as? BadgeDefinitionEvent - event?.thumb()?.ifBlank { null } ?: event?.image()?.ifBlank { null } - } + val noteState by baseNote.live().metadata.observeAsState() + val eventId = remember(noteState) { noteState?.note?.idHex } ?: return + val image by + remember(noteState) { + derivedStateOf { + val event = noteState?.note?.event as? BadgeDefinitionEvent + event?.thumb()?.ifBlank { null } ?: event?.image()?.ifBlank { null } + } } - val bgColor = MaterialTheme.colorScheme.background + val bgColor = MaterialTheme.colorScheme.background - if (image == null) { - RobohashAsyncImage( - robot = "authornotfound", - contentDescription = stringResource(R.string.unknown_author), - modifier = remember { - pictureModifier - .width(size) - .height(size) - .background(bgColor) + if (image == null) { + RobohashAsyncImage( + robot = "authornotfound", + contentDescription = stringResource(R.string.unknown_author), + modifier = remember { pictureModifier.width(size).height(size).background(bgColor) }, + ) + } else { + RobohashFallbackAsyncImage( + robot = eventId, + model = image!!, + contentDescription = stringResource(id = R.string.profile_image), + modifier = + remember { + pictureModifier + .width(size) + .height(size) + .clip(shape = CircleShape) + .background(bgColor) + .run { + if (onClick != null) { + this.clickable(onClick = { onClick(eventId) }) + } else { + this + } } - ) - } else { - RobohashFallbackAsyncImage( - robot = eventId, - model = image!!, - contentDescription = stringResource(id = R.string.profile_image), - modifier = remember { - pictureModifier - .width(size) - .height(size) - .clip(shape = CircleShape) - .background(bgColor) - .run { - if (onClick != null) { - this.clickable(onClick = { onClick(eventId) }) - } else { - this - } - } - }, - loadProfilePicture = loadProfilePicture - ) - } + }, + loadProfilePicture = loadProfilePicture, + ) + } } @OptIn(ExperimentalFoundationApi::class) @Composable -fun DrawBanner(baseUser: User, accountViewModel: AccountViewModel) { - val userState by baseUser.live().metadata.observeAsState() - val banner = remember(userState) { userState?.user?.info?.banner } +fun DrawBanner( + baseUser: User, + accountViewModel: AccountViewModel, +) { + val userState by baseUser.live().metadata.observeAsState() + val banner = remember(userState) { userState?.user?.info?.banner } - val clipboardManager = LocalClipboardManager.current - var zoomImageDialogOpen by remember { mutableStateOf(false) } + val clipboardManager = LocalClipboardManager.current + var zoomImageDialogOpen by remember { mutableStateOf(false) } - if (!banner.isNullOrBlank()) { - AsyncImage( - model = banner, - contentDescription = stringResource(id = R.string.profile_image), - contentScale = ContentScale.FillWidth, - modifier = Modifier - .fillMaxWidth() - .height(125.dp) - .combinedClickable( - onClick = { zoomImageDialogOpen = true }, - onLongClick = { - clipboardManager.setText(AnnotatedString(banner)) - } - ) - ) + if (!banner.isNullOrBlank()) { + AsyncImage( + model = banner, + contentDescription = stringResource(id = R.string.profile_image), + contentScale = ContentScale.FillWidth, + modifier = + Modifier.fillMaxWidth() + .height(125.dp) + .combinedClickable( + onClick = { zoomImageDialogOpen = true }, + onLongClick = { clipboardManager.setText(AnnotatedString(banner)) }, + ), + ) - if (zoomImageDialogOpen) { - ZoomableImageDialog(imageUrl = figureOutMimeType(banner), onDismiss = { zoomImageDialogOpen = false }, accountViewModel = accountViewModel) - } - } else { - Image( - painter = painterResource(R.drawable.profile_banner), - contentDescription = stringResource(id = R.string.profile_banner), - contentScale = ContentScale.FillWidth, - modifier = Modifier - .fillMaxWidth() - .height(125.dp) - ) + if (zoomImageDialogOpen) { + ZoomableImageDialog( + imageUrl = figureOutMimeType(banner), + onDismiss = { zoomImageDialogOpen = false }, + accountViewModel = accountViewModel, + ) } + } else { + Image( + painter = painterResource(R.drawable.profile_banner), + contentDescription = stringResource(id = R.string.profile_banner), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth().height(125.dp), + ) + } } @Composable -fun TabNotesNewThreads(feedViewModel: NostrUserProfileNewThreadsFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - Column(Modifier.fillMaxHeight()) { - Column( - modifier = Modifier.padding(vertical = 0.dp) - ) { - RefresheableFeedView( - feedViewModel, - null, - enablePullRefresh = false, - accountViewModel = accountViewModel, - nav = nav - ) - } +fun TabNotesNewThreads( + feedViewModel: NostrUserProfileNewThreadsFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp), + ) { + RefresheableFeedView( + feedViewModel, + null, + enablePullRefresh = false, + accountViewModel = accountViewModel, + nav = nav, + ) } + } } @Composable -fun TabNotesConversations(feedViewModel: NostrUserProfileConversationsFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - Column(Modifier.fillMaxHeight()) { - Column( - modifier = Modifier.padding(vertical = 0.dp) - ) { - RefresheableFeedView( - feedViewModel, - null, - enablePullRefresh = false, - accountViewModel = accountViewModel, - nav = nav - ) - } +fun TabNotesConversations( + feedViewModel: NostrUserProfileConversationsFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp), + ) { + RefresheableFeedView( + feedViewModel, + null, + enablePullRefresh = false, + accountViewModel = accountViewModel, + nav = nav, + ) } + } } @Composable -fun TabFollowedTags(baseUser: User, account: AccountViewModel, nav: (String) -> Unit) { - Column(Modifier.fillMaxHeight()) { - Column( - modifier = Modifier.padding(vertical = 0.dp) - ) { - baseUser.latestContactList?.let { - it.unverifiedFollowTagSet().forEach { hashtag -> - HashtagHeader( - tag = hashtag, - account = account, - onClick = { - nav("Hashtag/$hashtag") - } - ) - } - } +fun TabFollowedTags( + baseUser: User, + account: AccountViewModel, + nav: (String) -> Unit, +) { + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp), + ) { + baseUser.latestContactList?.let { + it.unverifiedFollowTagSet().forEach { hashtag -> + HashtagHeader( + tag = hashtag, + account = account, + onClick = { nav("Hashtag/$hashtag") }, + ) } + } } + } } @Composable -fun TabBookmarks(feedViewModel: NostrUserProfileBookmarksFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - LaunchedEffect(Unit) { - feedViewModel.invalidateData() - } +fun TabBookmarks( + feedViewModel: NostrUserProfileBookmarksFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + LaunchedEffect(Unit) { feedViewModel.invalidateData() } - Column(Modifier.fillMaxHeight()) { - Column( - modifier = Modifier.padding(vertical = 0.dp) - ) { - RefresheableFeedView( - feedViewModel, - null, - enablePullRefresh = false, - accountViewModel = accountViewModel, - nav = nav - ) - } + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp), + ) { + RefresheableFeedView( + feedViewModel, + null, + enablePullRefresh = false, + accountViewModel = accountViewModel, + nav = nav, + ) } + } } @Composable -fun TabFollows(baseUser: User, feedViewModel: UserFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - WatchFollowChanges(baseUser, feedViewModel) +fun TabFollows( + baseUser: User, + feedViewModel: UserFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + WatchFollowChanges(baseUser, feedViewModel) - Column(Modifier.fillMaxHeight()) { - Column { - RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false) - } + Column(Modifier.fillMaxHeight()) { + Column { + RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false) } + } } @Composable -fun TabFollowers(baseUser: User, feedViewModel: UserFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - WatchFollowerChanges(baseUser, feedViewModel) +fun TabFollowers( + baseUser: User, + feedViewModel: UserFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + WatchFollowerChanges(baseUser, feedViewModel) - Column(Modifier.fillMaxHeight()) { - Column { - RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false) - } + Column(Modifier.fillMaxHeight()) { + Column { + RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false) } + } } @Composable private fun WatchFollowChanges( - baseUser: User, - feedViewModel: UserFeedViewModel + baseUser: User, + feedViewModel: UserFeedViewModel, ) { - val userState by baseUser.live().follows.observeAsState() + val userState by baseUser.live().follows.observeAsState() - LaunchedEffect(userState) { - feedViewModel.invalidateData() - } + LaunchedEffect(userState) { feedViewModel.invalidateData() } } @Composable private fun WatchFollowerChanges( - baseUser: User, - feedViewModel: UserFeedViewModel + baseUser: User, + feedViewModel: UserFeedViewModel, ) { - val userState by baseUser.live().followers.observeAsState() + val userState by baseUser.live().followers.observeAsState() - LaunchedEffect(userState) { - feedViewModel.invalidateData() - } + LaunchedEffect(userState) { feedViewModel.invalidateData() } } @Composable -fun TabReceivedZaps(baseUser: User, zapFeedViewModel: NostrUserProfileZapsFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - WatchZapsAndUpdateFeed(baseUser, zapFeedViewModel) +fun TabReceivedZaps( + baseUser: User, + zapFeedViewModel: NostrUserProfileZapsFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + WatchZapsAndUpdateFeed(baseUser, zapFeedViewModel) - Column(Modifier.fillMaxHeight()) { - Column { - LnZapFeedView(zapFeedViewModel, accountViewModel, nav) - } - } + Column(Modifier.fillMaxHeight()) { + Column { LnZapFeedView(zapFeedViewModel, accountViewModel, nav) } + } } @Composable private fun WatchZapsAndUpdateFeed( - baseUser: User, - feedViewModel: NostrUserProfileZapsFeedViewModel + baseUser: User, + feedViewModel: NostrUserProfileZapsFeedViewModel, ) { - val userState by baseUser.live().zaps.observeAsState() + val userState by baseUser.live().zaps.observeAsState() - LaunchedEffect(userState) { - feedViewModel.invalidateData() - } + LaunchedEffect(userState) { feedViewModel.invalidateData() } } @Composable -fun TabReports(baseUser: User, feedViewModel: NostrUserProfileReportFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - WatchReportsAndUpdateFeed(baseUser, feedViewModel) +fun TabReports( + baseUser: User, + feedViewModel: NostrUserProfileReportFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + WatchReportsAndUpdateFeed(baseUser, feedViewModel) - Column(Modifier.fillMaxHeight()) { - Column { - RefresheableFeedView( - feedViewModel, - null, - enablePullRefresh = false, - accountViewModel = accountViewModel, - nav = nav - ) - } + Column(Modifier.fillMaxHeight()) { + Column { + RefresheableFeedView( + feedViewModel, + null, + enablePullRefresh = false, + accountViewModel = accountViewModel, + nav = nav, + ) } + } } @Composable private fun WatchReportsAndUpdateFeed( - baseUser: User, - feedViewModel: NostrUserProfileReportFeedViewModel + baseUser: User, + feedViewModel: NostrUserProfileReportFeedViewModel, ) { - val userState by baseUser.live().reports.observeAsState() - LaunchedEffect(userState) { - feedViewModel.invalidateData() - } + val userState by baseUser.live().reports.observeAsState() + LaunchedEffect(userState) { feedViewModel.invalidateData() } } @Composable -fun TabRelays(user: User, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val feedViewModel: RelayFeedViewModel = viewModel() +fun TabRelays( + user: User, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val feedViewModel: RelayFeedViewModel = viewModel() - val lifeCycleOwner = LocalLifecycleOwner.current + val lifeCycleOwner = LocalLifecycleOwner.current - DisposableEffect(user) { + DisposableEffect(user) { + feedViewModel.subscribeTo(user) + onDispose { feedViewModel.unsubscribeTo(user) } + } + + DisposableEffect(lifeCycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Profile Relay Start") feedViewModel.subscribeTo(user) - onDispose { - feedViewModel.unsubscribeTo(user) - } + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Profile Relay Stop") + feedViewModel.unsubscribeTo(user) + } } - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Profile Relay Start") - feedViewModel.subscribeTo(user) - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Profile Relay Stop") - feedViewModel.unsubscribeTo(user) - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { - lifeCycleOwner.lifecycle.removeObserver(observer) - println("Profile Relay Dispose") - feedViewModel.unsubscribeTo(user) - } + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { + lifeCycleOwner.lifecycle.removeObserver(observer) + println("Profile Relay Dispose") + feedViewModel.unsubscribeTo(user) } + } - Column(Modifier.fillMaxHeight()) { - Column( - modifier = Modifier.padding(vertical = 0.dp) - ) { - RelayFeedView(feedViewModel, accountViewModel, enablePullRefresh = false, nav = nav) - } + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp), + ) { + RelayFeedView(feedViewModel, accountViewModel, enablePullRefresh = false, nav = nav) } + } } @Composable -private fun MessageButton(user: User, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val scope = rememberCoroutineScope() +private fun MessageButton( + user: User, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val scope = rememberCoroutineScope() - Button( - modifier = Modifier - .padding(horizontal = 3.dp) - .width(50.dp), - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.createChatRoomFor(user) { - nav("Room/$it") - } - } - }, - contentPadding = ZeroPadding - ) { - Icon( - painter = painterResource(R.drawable.ic_dm), - stringResource(R.string.send_a_direct_message), - modifier = Modifier.size(20.dp), - tint = Color.White - ) - } + Button( + modifier = Modifier.padding(horizontal = 3.dp).width(50.dp), + onClick = { + scope.launch(Dispatchers.IO) { accountViewModel.createChatRoomFor(user) { nav("Room/$it") } } + }, + contentPadding = ZeroPadding, + ) { + Icon( + painter = painterResource(R.drawable.ic_dm), + stringResource(R.string.send_a_direct_message), + modifier = Modifier.size(20.dp), + tint = Color.White, + ) + } } @Composable private fun EditButton(account: Account) { - var wantsToEdit by remember { - mutableStateOf(false) - } + var wantsToEdit by remember { mutableStateOf(false) } - if (wantsToEdit) { - NewUserMetadataView({ wantsToEdit = false }, account) - } + if (wantsToEdit) { + NewUserMetadataView({ wantsToEdit = false }, account) + } - InnerEditButton { wantsToEdit = true } + InnerEditButton { wantsToEdit = true } } @Preview @Composable private fun InnerEditButtonPreview() { - InnerEditButton {} + InnerEditButton {} } @Composable private fun InnerEditButton(onClick: () -> Unit) { - Button( - modifier = Modifier - .padding(horizontal = 3.dp) - .width(50.dp), - onClick = onClick, - contentPadding = ZeroPadding - ) { - Icon( - tint = Color.White, - imageVector = Icons.Default.EditNote, - contentDescription = stringResource(R.string.edits_the_user_s_metadata) - ) - } + Button( + modifier = Modifier.padding(horizontal = 3.dp).width(50.dp), + onClick = onClick, + contentPadding = ZeroPadding, + ) { + Icon( + tint = Color.White, + imageVector = Icons.Default.EditNote, + contentDescription = stringResource(R.string.edits_the_user_s_metadata), + ) + } } @Composable fun UnfollowButton(onClick: () -> Unit) { - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = onClick, - shape = ButtonBorder, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ), - contentPadding = ButtonPadding - ) { - Text(text = stringResource(R.string.unfollow), color = Color.White) - } + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = onClick, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Text(text = stringResource(R.string.unfollow), color = Color.White) + } } @Composable -fun FollowButton(text: Int = R.string.follow, onClick: () -> Unit) { - Button( - modifier = Modifier.padding(start = 3.dp), - onClick = onClick, - shape = ButtonBorder, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ), - contentPadding = ButtonPadding - ) { - Text(text = stringResource(text), color = Color.White, textAlign = TextAlign.Center) - } +fun FollowButton( + text: Int = R.string.follow, + onClick: () -> Unit, +) { + Button( + modifier = Modifier.padding(start = 3.dp), + onClick = onClick, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Text(text = stringResource(text), color = Color.White, textAlign = TextAlign.Center) + } } @Composable fun ShowUserButton(onClick: () -> Unit) { - Button( - modifier = Modifier.padding(start = 3.dp), - onClick = onClick, - shape = ButtonBorder, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ), - contentPadding = ButtonPadding - ) { - Text(text = stringResource(R.string.unblock), color = Color.White) - } + Button( + modifier = Modifier.padding(start = 3.dp), + onClick = onClick, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Text(text = stringResource(R.string.unblock), color = Color.White) + } } @Composable -fun UserProfileDropDownMenu(user: User, popupExpanded: Boolean, onDismiss: () -> Unit, accountViewModel: AccountViewModel) { - DropdownMenu( - expanded = popupExpanded, - onDismissRequest = onDismiss - ) { - val clipboardManager = LocalClipboardManager.current +fun UserProfileDropDownMenu( + user: User, + popupExpanded: Boolean, + onDismiss: () -> Unit, + accountViewModel: AccountViewModel, +) { + DropdownMenu( + expanded = popupExpanded, + onDismissRequest = onDismiss, + ) { + val clipboardManager = LocalClipboardManager.current + DropdownMenuItem( + text = { Text(stringResource(R.string.copy_user_id)) }, + onClick = { + clipboardManager.setText(AnnotatedString(user.pubkeyNpub())) + onDismiss() + }, + ) + + if (accountViewModel.userProfile() != user) { + Divider() + if (accountViewModel.account.isHidden(user)) { DropdownMenuItem( - text = { - Text(stringResource(R.string.copy_user_id)) - }, - onClick = { clipboardManager.setText(AnnotatedString(user.pubkeyNpub())); onDismiss() } + text = { Text(stringResource(R.string.unblock_user)) }, + onClick = { + accountViewModel.show(user) + onDismiss() + }, ) - - if (accountViewModel.userProfile() != user) { - Divider() - if (accountViewModel.account.isHidden(user)) { - DropdownMenuItem( - text = { - Text(stringResource(R.string.unblock_user)) - }, - onClick = { - accountViewModel.show(user) - onDismiss() - } - ) - } else { - DropdownMenuItem( - text = { - Text(stringResource(id = R.string.block_hide_user)) - }, - onClick = { - accountViewModel.hide(user) - onDismiss() - } - ) - } - Divider() - DropdownMenuItem( - text = { - Text(stringResource(id = R.string.report_spam_scam)) - }, - onClick = { - accountViewModel.report(user, ReportEvent.ReportType.SPAM) - onDismiss() - } - ) - DropdownMenuItem( - text = { - Text(stringResource(R.string.report_hateful_speech)) - }, - onClick = { - accountViewModel.report(user, ReportEvent.ReportType.PROFANITY) - onDismiss() - } - ) - DropdownMenuItem( - text = { - Text(stringResource(id = R.string.report_impersonation)) - }, - onClick = { - accountViewModel.report(user, ReportEvent.ReportType.IMPERSONATION) - onDismiss() - } - ) - DropdownMenuItem( - text = { - Text(stringResource(R.string.report_nudity_porn)) - }, - onClick = { - accountViewModel.report(user, ReportEvent.ReportType.NUDITY) - onDismiss() - } - ) - DropdownMenuItem( - text = { - Text(stringResource(id = R.string.report_illegal_behaviour)) - }, - onClick = { - accountViewModel.report(user, ReportEvent.ReportType.ILLEGAL) - onDismiss() - } - ) - } + } else { + DropdownMenuItem( + text = { Text(stringResource(id = R.string.block_hide_user)) }, + onClick = { + accountViewModel.hide(user) + onDismiss() + }, + ) + } + Divider() + DropdownMenuItem( + text = { Text(stringResource(id = R.string.report_spam_scam)) }, + onClick = { + accountViewModel.report(user, ReportEvent.ReportType.SPAM) + onDismiss() + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.report_hateful_speech)) }, + onClick = { + accountViewModel.report(user, ReportEvent.ReportType.PROFANITY) + onDismiss() + }, + ) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.report_impersonation)) }, + onClick = { + accountViewModel.report(user, ReportEvent.ReportType.IMPERSONATION) + onDismiss() + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.report_nudity_porn)) }, + onClick = { + accountViewModel.report(user, ReportEvent.ReportType.NUDITY) + onDismiss() + }, + ) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.report_illegal_behaviour)) }, + onClick = { + accountViewModel.report(user, ReportEvent.ReportType.ILLEGAL) + onDismiss() + }, + ) } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt index faf184d92..779d7b14d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt @@ -1,178 +1,210 @@ -package com.vitorpamplona.amethyst.ui.screen.loggedIn - -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.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Block -import androidx.compose.material.icons.filled.Report -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Divider -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -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.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -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 com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon -import com.vitorpamplona.amethyst.ui.theme.WarningColor -import com.vitorpamplona.quartz.events.ReportEvent -import kotlinx.collections.immutable.toImmutableList - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ReportNoteDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) { - val reportTypes = listOf( - Pair(ReportEvent.ReportType.SPAM, stringResource(R.string.report_dialog_spam)), - Pair(ReportEvent.ReportType.PROFANITY, stringResource(R.string.report_dialog_profanity)), - Pair(ReportEvent.ReportType.IMPERSONATION, stringResource(R.string.report_dialog_impersonation)), - Pair(ReportEvent.ReportType.NUDITY, stringResource(R.string.report_dialog_nudity)), - Pair(ReportEvent.ReportType.ILLEGAL, stringResource(R.string.report_dialog_illegal)) - ) - - val reasonOptions = remember { reportTypes.map { TitleExplainer(it.second) }.toImmutableList() } - var additionalReason by remember { mutableStateOf("") } - var selectedReason by remember { mutableStateOf(-1) } - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Scaffold( - topBar = { - TopAppBar( - title = { Text(text = stringResource(id = R.string.report_dialog_title)) }, - navigationIcon = { - IconButton(onClick = onDismiss) { - ArrowBackIcon() - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface - ) - ) - } - ) { pad -> - Column( - modifier = Modifier.padding(16.dp, pad.calculateTopPadding(), 16.dp, pad.calculateBottomPadding()), - verticalArrangement = Arrangement.SpaceAround - ) { - SpacerH16() - SectionHeader(text = stringResource(id = R.string.block_only)) - SpacerH16() - Text( - text = stringResource(R.string.report_dialog_blocking_a_user) - ) - SpacerH16() - ActionButton( - text = stringResource(R.string.report_dialog_block_hide_user_btn), - icon = Icons.Default.Block, - onClick = { - note.author?.let { accountViewModel.hide(it) } - onDismiss() - } - ) - SpacerH16() - - Divider(color = MaterialTheme.colorScheme.onSurface, thickness = 0.25.dp) - - SpacerH16() - SectionHeader(text = stringResource(R.string.report_dialog_report_btn)) - SpacerH16() - Text(stringResource(R.string.report_dialog_reminder_public)) - SpacerH16() - TextSpinner( - label = stringResource(R.string.report_dialog_select_reason_label), - placeholder = stringResource(R.string.report_dialog_select_reason_placeholder), - options = reasonOptions, - onSelect = { - selectedReason = it - }, - modifier = Modifier.fillMaxWidth() - ) - SpacerH16() - OutlinedTextField( - value = additionalReason, - onValueChange = { additionalReason = it }, - placeholder = { Text(text = stringResource(R.string.report_dialog_additional_reason_placeholder)) }, - label = { Text(stringResource(R.string.report_dialog_additional_reason_label)) }, - modifier = Modifier.fillMaxWidth() - ) - SpacerH16() - - ActionButton( - text = stringResource(R.string.report_dialog_post_report_btn), - icon = Icons.Default.Report, - enabled = selectedReason in 0..reportTypes.lastIndex, - onClick = { - accountViewModel.report( - note, - reportTypes[selectedReason].first, - additionalReason - ) - note.author?.let { accountViewModel.hide(it) } - onDismiss() - } - ) - } - } - } -} - -@Composable -private fun SpacerH16() = Spacer(modifier = Modifier.height(16.dp)) - -@Composable -private fun SectionHeader(text: String) = Text( - text = text, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - fontSize = 18.sp -) - -@Composable -private fun ActionButton(text: String, icon: ImageVector, enabled: Boolean = true, onClick: () -> Unit) = Button( - onClick = onClick, - enabled = enabled, - colors = ButtonDefaults.buttonColors(containerColor = WarningColor), - modifier = Modifier.fillMaxWidth() -) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = Color.White - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(text = text, color = Color.White) - } -} +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn + +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.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Block +import androidx.compose.material.icons.filled.Report +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +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.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +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 com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.theme.WarningColor +import com.vitorpamplona.quartz.events.ReportEvent +import kotlinx.collections.immutable.toImmutableList + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReportNoteDialog( + note: Note, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, +) { + val reportTypes = + listOf( + Pair(ReportEvent.ReportType.SPAM, stringResource(R.string.report_dialog_spam)), + Pair(ReportEvent.ReportType.PROFANITY, stringResource(R.string.report_dialog_profanity)), + Pair( + ReportEvent.ReportType.IMPERSONATION, + stringResource(R.string.report_dialog_impersonation), + ), + Pair(ReportEvent.ReportType.NUDITY, stringResource(R.string.report_dialog_nudity)), + Pair(ReportEvent.ReportType.ILLEGAL, stringResource(R.string.report_dialog_illegal)), + ) + + val reasonOptions = remember { reportTypes.map { TitleExplainer(it.second) }.toImmutableList() } + var additionalReason by remember { mutableStateOf("") } + var selectedReason by remember { mutableStateOf(-1) } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = stringResource(id = R.string.report_dialog_title)) }, + navigationIcon = { IconButton(onClick = onDismiss) { ArrowBackIcon() } }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + }, + ) { pad -> + Column( + modifier = + Modifier.padding(16.dp, pad.calculateTopPadding(), 16.dp, pad.calculateBottomPadding()), + verticalArrangement = Arrangement.SpaceAround, + ) { + SpacerH16() + SectionHeader(text = stringResource(id = R.string.block_only)) + SpacerH16() + Text( + text = stringResource(R.string.report_dialog_blocking_a_user), + ) + SpacerH16() + ActionButton( + text = stringResource(R.string.report_dialog_block_hide_user_btn), + icon = Icons.Default.Block, + onClick = { + note.author?.let { accountViewModel.hide(it) } + onDismiss() + }, + ) + SpacerH16() + + Divider(color = MaterialTheme.colorScheme.onSurface, thickness = 0.25.dp) + + SpacerH16() + SectionHeader(text = stringResource(R.string.report_dialog_report_btn)) + SpacerH16() + Text(stringResource(R.string.report_dialog_reminder_public)) + SpacerH16() + TextSpinner( + label = stringResource(R.string.report_dialog_select_reason_label), + placeholder = stringResource(R.string.report_dialog_select_reason_placeholder), + options = reasonOptions, + onSelect = { selectedReason = it }, + modifier = Modifier.fillMaxWidth(), + ) + SpacerH16() + OutlinedTextField( + value = additionalReason, + onValueChange = { additionalReason = it }, + placeholder = { + Text(text = stringResource(R.string.report_dialog_additional_reason_placeholder)) + }, + label = { Text(stringResource(R.string.report_dialog_additional_reason_label)) }, + modifier = Modifier.fillMaxWidth(), + ) + SpacerH16() + + ActionButton( + text = stringResource(R.string.report_dialog_post_report_btn), + icon = Icons.Default.Report, + enabled = selectedReason in 0..reportTypes.lastIndex, + onClick = { + accountViewModel.report( + note, + reportTypes[selectedReason].first, + additionalReason, + ) + note.author?.let { accountViewModel.hide(it) } + onDismiss() + }, + ) + } + } + } +} + +@Composable private fun SpacerH16() = Spacer(modifier = Modifier.height(16.dp)) + +@Composable +private fun SectionHeader(text: String) = + Text( + text = text, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 18.sp, + ) + +@Composable +private fun ActionButton( + text: String, + icon: ImageVector, + enabled: Boolean = true, + onClick: () -> Unit, +) = + Button( + onClick = onClick, + enabled = enabled, + colors = ButtonDefaults.buttonColors(containerColor = WarningColor), + modifier = Modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = Color.White, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = text, color = Color.White) + } + } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt index ad2467c0d..e212f32d4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.util.Log @@ -71,6 +91,7 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.events.findHashtags import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.Channel as CoroutineChannel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest @@ -80,404 +101,389 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.coroutines.channels.Channel as CoroutineChannel @Composable fun SearchScreen( - accountViewModel: AccountViewModel, - nav: (String) -> Unit + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val searchBarViewModel: SearchBarViewModel = viewModel( - key = "SearchBarViewModel", - factory = SearchBarViewModel.Factory( - accountViewModel.account - ) + val searchBarViewModel: SearchBarViewModel = + viewModel( + key = "SearchBarViewModel", + factory = + SearchBarViewModel.Factory( + accountViewModel.account, + ), ) - SearchScreen(searchBarViewModel, accountViewModel, nav) + SearchScreen(searchBarViewModel, accountViewModel, nav) } @Composable fun SearchScreen( - searchBarViewModel: SearchBarViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + searchBarViewModel: SearchBarViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val lifeCycleOwner = LocalLifecycleOwner.current + val lifeCycleOwner = LocalLifecycleOwner.current - WatchAccountForSearchScreen(accountViewModel) + WatchAccountForSearchScreen(accountViewModel) - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Search Start") - NostrSearchEventOrUserDataSource.start() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Search Stop") - NostrSearchEventOrUserDataSource.clear() - NostrSearchEventOrUserDataSource.stop() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { - lifeCycleOwner.lifecycle.removeObserver(observer) - } + DisposableEffect(lifeCycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Search Start") + NostrSearchEventOrUserDataSource.start() + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Search Stop") + NostrSearchEventOrUserDataSource.clear() + NostrSearchEventOrUserDataSource.stop() + } } - val listState = rememberLazyListState() + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } + } - Column(Modifier.fillMaxSize()) { - SearchBar(searchBarViewModel, listState) - DisplaySearchResults(searchBarViewModel, listState, nav, accountViewModel) - } + val listState = rememberLazyListState() + + Column(Modifier.fillMaxSize()) { + SearchBar(searchBarViewModel, listState) + DisplaySearchResults(searchBarViewModel, listState, nav, accountViewModel) + } } @Composable fun WatchAccountForSearchScreen(accountViewModel: AccountViewModel) { - LaunchedEffect(accountViewModel) { - launch(Dispatchers.IO) { - NostrSearchEventOrUserDataSource.start() - } - } + LaunchedEffect(accountViewModel) { + launch(Dispatchers.IO) { NostrSearchEventOrUserDataSource.start() } + } } @Stable class SearchBarViewModel(val account: Account) : ViewModel() { - var searchValue by mutableStateOf("") + var searchValue by mutableStateOf("") - private var _searchResultsUsers = MutableStateFlow>(emptyList()) - private var _searchResultsNotes = MutableStateFlow>(emptyList()) - private var _searchResultsChannels = MutableStateFlow>(emptyList()) - private var _hashtagResults = MutableStateFlow>(emptyList()) + private var _searchResultsUsers = MutableStateFlow>(emptyList()) + private var _searchResultsNotes = MutableStateFlow>(emptyList()) + private var _searchResultsChannels = MutableStateFlow>(emptyList()) + private var _hashtagResults = MutableStateFlow>(emptyList()) - val searchResultsUsers = _searchResultsUsers.asStateFlow() - val searchResultsNotes = _searchResultsNotes.asStateFlow() - val searchResultsChannels = _searchResultsChannels.asStateFlow() - val hashtagResults = _hashtagResults.asStateFlow() + val searchResultsUsers = _searchResultsUsers.asStateFlow() + val searchResultsNotes = _searchResultsNotes.asStateFlow() + val searchResultsChannels = _searchResultsChannels.asStateFlow() + val hashtagResults = _hashtagResults.asStateFlow() - val isSearching by derivedStateOf { - searchValue.isNotBlank() + val isSearching by derivedStateOf { searchValue.isNotBlank() } + + fun updateSearchValue(newValue: String) { + searchValue = newValue + } + + private suspend fun runSearch() { + if (searchValue.isBlank()) { + _hashtagResults.value = emptyList() + _searchResultsUsers.value = emptyList() + _searchResultsChannels.value = emptyList() + _searchResultsNotes.value = emptyList() + return } - fun updateSearchValue(newValue: String) { - searchValue = newValue + _hashtagResults.emit(findHashtags(searchValue)) + _searchResultsUsers.emit( + LocalCache.findUsersStartingWith(searchValue) + .sortedWith(compareBy({ account.isFollowing(it) }, { it.toBestDisplayName() })) + .reversed(), + ) + _searchResultsNotes.emit( + LocalCache.findNotesStartingWith(searchValue) + .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) + .reversed(), + ) + _searchResultsChannels.emit(LocalCache.findChannelsStartingWith(searchValue)) + } + + fun clear() { + searchValue = "" + _searchResultsUsers.value = emptyList() + _searchResultsChannels.value = emptyList() + _searchResultsNotes.value = emptyList() + _searchResultsChannels.value = emptyList() + } + + private val bundler = BundledUpdate(250, Dispatchers.IO) + + fun invalidateData() { + bundler.invalidate { + // adds the time to perform the refresh into this delay + // holding off new updates in case of heavy refresh routines. + runSearch() } + } - private suspend fun runSearch() { - if (searchValue.isBlank()) { - _hashtagResults.value = emptyList() - _searchResultsUsers.value = emptyList() - _searchResultsChannels.value = emptyList() - _searchResultsNotes.value = emptyList() - return - } + override fun onCleared() { + bundler.cancel() + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + super.onCleared() + } - _hashtagResults.emit(findHashtags(searchValue)) - _searchResultsUsers.emit(LocalCache.findUsersStartingWith(searchValue).sortedWith(compareBy({ account.isFollowing(it) }, { it.toBestDisplayName() })).reversed()) - _searchResultsNotes.emit(LocalCache.findNotesStartingWith(searchValue).sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()) - _searchResultsChannels.emit(LocalCache.findChannelsStartingWith(searchValue)) - } - - fun clear() { - searchValue = "" - _searchResultsUsers.value = emptyList() - _searchResultsChannels.value = emptyList() - _searchResultsNotes.value = emptyList() - _searchResultsChannels.value = emptyList() - } - - private val bundler = BundledUpdate(250, Dispatchers.IO) - - fun invalidateData() { - bundler.invalidate() { - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - runSearch() - } - } - - override fun onCleared() { - bundler.cancel() - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") - super.onCleared() - } - - fun isSearchingFun() = searchValue.isNotBlank() - - class Factory(val account: Account) : ViewModelProvider.Factory { - override fun create(modelClass: Class): SearchBarViewModel { - return SearchBarViewModel(account) as SearchBarViewModel - } + fun isSearchingFun() = searchValue.isNotBlank() + + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): SearchBarViewModel { + return SearchBarViewModel(account) as SearchBarViewModel } + } } @OptIn(FlowPreview::class) @Composable private fun SearchBar( - searchBarViewModel: SearchBarViewModel, - listState: LazyListState + searchBarViewModel: SearchBarViewModel, + listState: LazyListState, ) { - val scope = rememberCoroutineScope() + val scope = rememberCoroutineScope() - // Create a channel for processing search queries. - val searchTextChanges = remember { - CoroutineChannel(CoroutineChannel.CONFLATED) + // Create a channel for processing search queries. + val searchTextChanges = remember { CoroutineChannel(CoroutineChannel.CONFLATED) } + + LaunchedEffect(Unit) { + launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { + checkNotInMainThread() + + if (searchBarViewModel.isSearchingFun()) { + searchBarViewModel.invalidateData() + } + } } + } - LaunchedEffect(Unit) { - launch(Dispatchers.IO) { - LocalCache.live.newEventBundles.collect { - checkNotInMainThread() + LaunchedEffect(Unit) { + // Wait for text changes to stop for 300 ms before firing off search. + withContext(Dispatchers.IO) { + searchTextChanges + .receiveAsFlow() + .filter { it.isNotBlank() } + .distinctUntilChanged() + .debounce(300) + .collectLatest { + if (it.length >= 2) { + NostrSearchEventOrUserDataSource.search(it.trim()) + } - if (searchBarViewModel.isSearchingFun()) { - searchBarViewModel.invalidateData() - } - } + searchBarViewModel.invalidateData() + + // makes sure to show the top of the search + launch(Dispatchers.Main) { listState.animateScrollToItem(0) } } } + } - LaunchedEffect(Unit) { - // Wait for text changes to stop for 300 ms before firing off search. - withContext(Dispatchers.IO) { - searchTextChanges.receiveAsFlow() - .filter { it.isNotBlank() } - .distinctUntilChanged() - .debounce(300) - .collectLatest { - if (it.length >= 2) { - NostrSearchEventOrUserDataSource.search(it.trim()) - } + DisposableEffect(Unit) { onDispose { NostrSearchEventOrUserDataSource.clear() } } - searchBarViewModel.invalidateData() - - // makes sure to show the top of the search - launch(Dispatchers.Main) { listState.animateScrollToItem(0) } - } - } - } - - DisposableEffect(Unit) { - onDispose { - NostrSearchEventOrUserDataSource.clear() - } - } - - // LAST ROW - SearchTextField(searchBarViewModel) { - scope.launch(Dispatchers.IO) { - searchTextChanges.trySend(it) - } - } + // LAST ROW + SearchTextField(searchBarViewModel) { + scope.launch(Dispatchers.IO) { searchTextChanges.trySend(it) } + } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun SearchTextField( - searchBarViewModel: SearchBarViewModel, - onTextChanges: (String) -> Unit + searchBarViewModel: SearchBarViewModel, + onTextChanges: (String) -> Unit, ) { - Row( - modifier = Modifier - .padding(10.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - TextField( - value = searchBarViewModel.searchValue, - onValueChange = { - searchBarViewModel.updateSearchValue(it) - onTextChanges(it) - }, - shape = RoundedCornerShape(25.dp), - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences - ), - leadingIcon = { - SearchIcon(modifier = Size20Modifier, Color.Unspecified) - }, - modifier = Modifier - .weight(1f, true) - .defaultMinSize(minHeight = 20.dp), - placeholder = { - Text( - text = stringResource(R.string.npub_hex_username), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - trailingIcon = { - if (searchBarViewModel.isSearching) { - IconButton( - onClick = { - searchBarViewModel.clear() - NostrSearchEventOrUserDataSource.clear() - } - ) { - ClearTextIcon() - } - } - }, - singleLine = true, - colors = TextFieldDefaults.colors( - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent - ) + Row( + modifier = Modifier.padding(10.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + TextField( + value = searchBarViewModel.searchValue, + onValueChange = { + searchBarViewModel.updateSearchValue(it) + onTextChanges(it) + }, + shape = RoundedCornerShape(25.dp), + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + leadingIcon = { SearchIcon(modifier = Size20Modifier, Color.Unspecified) }, + modifier = Modifier.weight(1f, true).defaultMinSize(minHeight = 20.dp), + placeholder = { + Text( + text = stringResource(R.string.npub_hex_username), + color = MaterialTheme.colorScheme.placeholderText, ) - } + }, + trailingIcon = { + if (searchBarViewModel.isSearching) { + IconButton( + onClick = { + searchBarViewModel.clear() + NostrSearchEventOrUserDataSource.clear() + }, + ) { + ClearTextIcon() + } + } + }, + singleLine = true, + colors = + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + ) + } } @Composable private fun DisplaySearchResults( - searchBarViewModel: SearchBarViewModel, - listState: LazyListState, - nav: (String) -> Unit, - accountViewModel: AccountViewModel + searchBarViewModel: SearchBarViewModel, + listState: LazyListState, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - if (!searchBarViewModel.isSearching) { - return + if (!searchBarViewModel.isSearching) { + return + } + + val hashTags by searchBarViewModel.hashtagResults.collectAsStateWithLifecycle() + val users by searchBarViewModel.searchResultsUsers.collectAsStateWithLifecycle() + val channels by searchBarViewModel.searchResultsChannels.collectAsStateWithLifecycle() + val notes by searchBarViewModel.searchResultsNotes.collectAsStateWithLifecycle() + + val hasNewMessages = remember { mutableStateOf(false) } + + val automaticallyShowProfilePicture = remember { + accountViewModel.settings.showProfilePictures.value + } + + LazyColumn( + modifier = Modifier.fillMaxHeight(), + contentPadding = FeedPadding, + state = listState, + ) { + itemsIndexed( + hashTags, + key = { _, item -> "#$item" }, + ) { _, item -> + HashtagLine(item) { nav("Hashtag/$item") } } - val hashTags by searchBarViewModel.hashtagResults.collectAsStateWithLifecycle() - val users by searchBarViewModel.searchResultsUsers.collectAsStateWithLifecycle() - val channels by searchBarViewModel.searchResultsChannels.collectAsStateWithLifecycle() - val notes by searchBarViewModel.searchResultsNotes.collectAsStateWithLifecycle() - - val hasNewMessages = remember { - mutableStateOf(false) + itemsIndexed( + users, + key = { _, item -> "u" + item.pubkeyHex }, + ) { _, item -> + UserCompose(item, accountViewModel = accountViewModel, nav = nav) } - val automaticallyShowProfilePicture = remember { - accountViewModel.settings.showProfilePictures.value + itemsIndexed( + channels, + key = { _, item -> "c" + item.idHex }, + ) { _, item -> + ChannelName( + channelIdHex = item.idHex, + channelPicture = item.profilePicture(), + channelTitle = { + Text( + item.toBestDisplayName(), + fontWeight = FontWeight.Bold, + ) + }, + channelLastTime = null, + channelLastContent = item.summary(), + hasNewMessages = hasNewMessages, + loadProfilePicture = automaticallyShowProfilePicture, + onClick = { nav("Channel/${item.idHex}") }, + ) } - LazyColumn( - modifier = Modifier.fillMaxHeight(), - contentPadding = FeedPadding, - state = listState - ) { - itemsIndexed( - hashTags, - key = { _, item -> "#$item" } - ) { _, item -> - HashtagLine(item) { - nav("Hashtag/$item") - } - } - - itemsIndexed( - users, - key = { _, item -> "u" + item.pubkeyHex } - ) { _, item -> - UserCompose(item, accountViewModel = accountViewModel, nav = nav) - } - - itemsIndexed( - channels, - key = { _, item -> "c" + item.idHex } - ) { _, item -> - ChannelName( - channelIdHex = item.idHex, - channelPicture = item.profilePicture(), - channelTitle = { - Text( - item.toBestDisplayName(), - fontWeight = FontWeight.Bold - ) - }, - channelLastTime = null, - channelLastContent = item.summary(), - hasNewMessages = hasNewMessages, - loadProfilePicture = automaticallyShowProfilePicture, - onClick = { nav("Channel/${item.idHex}") } - ) - } - - itemsIndexed( - notes, - key = { _, item -> "n" + item.idHex } - ) { _, item -> - NoteCompose( - item, - accountViewModel = accountViewModel, - nav = nav - ) - } + itemsIndexed( + notes, + key = { _, item -> "n" + item.idHex }, + ) { _, item -> + NoteCompose( + item, + accountViewModel = accountViewModel, + nav = nav, + ) } + } } @Composable -fun HashtagLine(tag: String, onClick: () -> Unit) { - Column( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) +fun HashtagLine( + tag: String, + onClick: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + ) { + Row( + modifier = + Modifier.padding( + start = 12.dp, + end = 12.dp, + top = 10.dp, + ), ) { - Row( - modifier = Modifier - .padding( - start = 12.dp, - end = 12.dp, - top = 10.dp - ) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() - ) { - Text( - "Search hashtag: #$tag", - fontWeight = FontWeight.Bold - ) - } - } - - Divider( - modifier = Modifier.padding(top = 10.dp), - thickness = DividerThickness + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + "Search hashtag: #$tag", + fontWeight = FontWeight.Bold, ) + } } + + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = DividerThickness, + ) + } } @Composable fun UserLine( - baseUser: User, - accountViewModel: AccountViewModel, - onClick: () -> Unit + baseUser: User, + accountViewModel: AccountViewModel, + onClick: () -> Unit, ) { - Column( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) + Column( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + ) { + Row( + modifier = + Modifier.padding( + start = 12.dp, + end = 12.dp, + top = 10.dp, + ), ) { - Row( - modifier = Modifier - .padding( - start = 12.dp, - end = 12.dp, - top = 10.dp - ) - ) { - ClickableUserPicture(baseUser, 55.dp, accountViewModel, Modifier, null) + ClickableUserPicture(baseUser, 55.dp, accountViewModel, Modifier, null) - Column( - modifier = Modifier - .padding(start = 10.dp) - .weight(1f) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - UsernameDisplay(baseUser) - } + Column( + modifier = Modifier.padding(start = 10.dp).weight(1f), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseUser) } - AboutDisplay(baseUser) - } - } - - Divider( - modifier = Modifier.padding(top = 10.dp), - thickness = DividerThickness - ) + AboutDisplay(baseUser) + } } + + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = DividerThickness, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SettingsScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SettingsScreen.kt index 60126f290..3631e3f15 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SettingsScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SettingsScreen.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.content.Context @@ -38,6 +58,7 @@ import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size20dp +import java.io.IOException import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentListOf @@ -45,216 +66,219 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParserException -import java.io.IOException fun Context.getLocaleListFromXml(): LocaleListCompat { - val tagsList = mutableListOf() - try { - val xpp: XmlPullParser = resources.getXml(R.xml.locales_config) - while (xpp.eventType != XmlPullParser.END_DOCUMENT) { - if (xpp.eventType == XmlPullParser.START_TAG) { - if (xpp.name == "locale") { - tagsList.add(xpp.getAttributeValue(0)) - } - } - xpp.next() + val tagsList = mutableListOf() + try { + val xpp: XmlPullParser = resources.getXml(R.xml.locales_config) + while (xpp.eventType != XmlPullParser.END_DOCUMENT) { + if (xpp.eventType == XmlPullParser.START_TAG) { + if (xpp.name == "locale") { + tagsList.add(xpp.getAttributeValue(0)) } - } catch (e: XmlPullParserException) { - e.printStackTrace() - } catch (e: IOException) { - e.printStackTrace() + } + xpp.next() } + } catch (e: XmlPullParserException) { + e.printStackTrace() + } catch (e: IOException) { + e.printStackTrace() + } - return LocaleListCompat.forLanguageTags(tagsList.joinToString(",")) + return LocaleListCompat.forLanguageTags(tagsList.joinToString(",")) } fun Context.getLangPreferenceDropdownEntries(): ImmutableMap { - val localeList = getLocaleListFromXml() - val map = mutableMapOf() + val localeList = getLocaleListFromXml() + val map = mutableMapOf() - for (a in 0 until localeList.size()) { - localeList[a].let { - map.put(it!!.getDisplayName(it).replaceFirstChar { char -> char.uppercase() }, it.toLanguageTag()) - } + for (a in 0 until localeList.size()) { + localeList[a].let { + map.put( + it!!.getDisplayName(it).replaceFirstChar { char -> char.uppercase() }, + it.toLanguageTag(), + ) } - return map.toImmutableMap() + } + return map.toImmutableMap() } -fun getLanguageIndex(languageEntries: ImmutableMap, sharedPreferencesViewModel: SharedPreferencesViewModel): Int { - val language = sharedPreferencesViewModel.sharedPrefs.language - var languageIndex = -1 - if (language != null) { - languageIndex = languageEntries.values.toTypedArray().indexOf(language) - } else { - languageIndex = languageEntries.values.toTypedArray().indexOf(Locale.current.toLanguageTag()) - } - if (languageIndex == -1) languageIndex = languageEntries.values.toTypedArray().indexOf(Locale.current.language) - if (languageIndex == -1) languageIndex = languageEntries.values.toTypedArray().indexOf("en") - return languageIndex +fun getLanguageIndex( + languageEntries: ImmutableMap, + sharedPreferencesViewModel: SharedPreferencesViewModel, +): Int { + val language = sharedPreferencesViewModel.sharedPrefs.language + var languageIndex = -1 + if (language != null) { + languageIndex = languageEntries.values.toTypedArray().indexOf(language) + } else { + languageIndex = languageEntries.values.toTypedArray().indexOf(Locale.current.toLanguageTag()) + } + if (languageIndex == -1) { + languageIndex = languageEntries.values.toTypedArray().indexOf(Locale.current.language) + } + if (languageIndex == -1) languageIndex = languageEntries.values.toTypedArray().indexOf("en") + return languageIndex } @Composable -fun SettingsScreen( - sharedPreferencesViewModel: SharedPreferencesViewModel -) { - val selectedItens = persistentListOf( - TitleExplainer(stringResource(ConnectivityType.ALWAYS.resourceId)), - TitleExplainer(stringResource(ConnectivityType.WIFI_ONLY.resourceId)), - TitleExplainer(stringResource(ConnectivityType.NEVER.resourceId)) +fun SettingsScreen(sharedPreferencesViewModel: SharedPreferencesViewModel) { + val selectedItens = + persistentListOf( + TitleExplainer(stringResource(ConnectivityType.ALWAYS.resourceId)), + TitleExplainer(stringResource(ConnectivityType.WIFI_ONLY.resourceId)), + TitleExplainer(stringResource(ConnectivityType.NEVER.resourceId)), ) - val themeItens = persistentListOf( - TitleExplainer(stringResource(ThemeType.SYSTEM.resourceId)), - TitleExplainer(stringResource(ThemeType.LIGHT.resourceId)), - TitleExplainer(stringResource(ThemeType.DARK.resourceId)) + val themeItens = + persistentListOf( + TitleExplainer(stringResource(ThemeType.SYSTEM.resourceId)), + TitleExplainer(stringResource(ThemeType.LIGHT.resourceId)), + TitleExplainer(stringResource(ThemeType.DARK.resourceId)), ) - val booleanItems = persistentListOf( - TitleExplainer(stringResource(ConnectivityType.ALWAYS.resourceId)), - TitleExplainer(stringResource(ConnectivityType.NEVER.resourceId)) + val booleanItems = + persistentListOf( + TitleExplainer(stringResource(ConnectivityType.ALWAYS.resourceId)), + TitleExplainer(stringResource(ConnectivityType.NEVER.resourceId)), ) - val showImagesIndex = sharedPreferencesViewModel.sharedPrefs.automaticallyShowImages.screenCode - val videoIndex = sharedPreferencesViewModel.sharedPrefs.automaticallyStartPlayback.screenCode - val linkIndex = sharedPreferencesViewModel.sharedPrefs.automaticallyShowUrlPreview.screenCode - val hideNavBarsIndex = sharedPreferencesViewModel.sharedPrefs.automaticallyHideNavigationBars.screenCode - val profilePictureIndex = sharedPreferencesViewModel.sharedPrefs.automaticallyShowProfilePictures.screenCode - val themeIndex = sharedPreferencesViewModel.sharedPrefs.theme.screenCode + val showImagesIndex = sharedPreferencesViewModel.sharedPrefs.automaticallyShowImages.screenCode + val videoIndex = sharedPreferencesViewModel.sharedPrefs.automaticallyStartPlayback.screenCode + val linkIndex = sharedPreferencesViewModel.sharedPrefs.automaticallyShowUrlPreview.screenCode + val hideNavBarsIndex = + sharedPreferencesViewModel.sharedPrefs.automaticallyHideNavigationBars.screenCode + val profilePictureIndex = + sharedPreferencesViewModel.sharedPrefs.automaticallyShowProfilePictures.screenCode + val themeIndex = sharedPreferencesViewModel.sharedPrefs.theme.screenCode - val context = LocalContext.current + val context = LocalContext.current - val languageEntries = remember { - context.getLangPreferenceDropdownEntries() - } - val languageList = remember { - languageEntries.keys.map { TitleExplainer(it) }.toImmutableList() - } - val languageIndex = getLanguageIndex(languageEntries, sharedPreferencesViewModel) + val languageEntries = remember { context.getLangPreferenceDropdownEntries() } + val languageList = remember { languageEntries.keys.map { TitleExplainer(it) }.toImmutableList() } + val languageIndex = getLanguageIndex(languageEntries, sharedPreferencesViewModel) - Column( - Modifier - .fillMaxSize() - .padding(top = Size10dp, start = Size20dp, end = Size20dp) - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally + Column( + Modifier.fillMaxSize() + .padding(top = Size10dp, start = Size20dp, end = Size20dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + SettingsRow( + R.string.language, + R.string.language_description, + languageList, + languageIndex, ) { - SettingsRow( - R.string.language, - R.string.language_description, - languageList, - languageIndex - ) { - sharedPreferencesViewModel.updateLanguage(languageEntries[languageList[it].title]) - } - - Spacer(modifier = HalfVertSpacer) - - SettingsRow( - R.string.theme, - R.string.theme_description, - themeItens, - themeIndex - ) { - sharedPreferencesViewModel.updateTheme(parseThemeType(it)) - } - - Spacer(modifier = HalfVertSpacer) - - SettingsRow( - R.string.automatically_load_images_gifs, - R.string.automatically_load_images_gifs_description, - selectedItens, - showImagesIndex - ) { - sharedPreferencesViewModel.updateAutomaticallyShowImages(parseConnectivityType(it)) - } - - Spacer(modifier = HalfVertSpacer) - - SettingsRow( - R.string.automatically_play_videos, - R.string.automatically_play_videos_description, - selectedItens, - videoIndex - ) { - sharedPreferencesViewModel.updateAutomaticallyStartPlayback(parseConnectivityType(it)) - } - - Spacer(modifier = HalfVertSpacer) - - SettingsRow( - R.string.automatically_show_url_preview, - R.string.automatically_show_url_preview_description, - selectedItens, - linkIndex - ) { - sharedPreferencesViewModel.updateAutomaticallyShowUrlPreview(parseConnectivityType(it)) - } - - SettingsRow( - R.string.automatically_show_profile_picture, - R.string.automatically_show_profile_picture_description, - selectedItens, - profilePictureIndex - ) { - sharedPreferencesViewModel.updateAutomaticallyShowProfilePicture(parseConnectivityType(it)) - } - - Spacer(modifier = HalfVertSpacer) - - SettingsRow( - R.string.automatically_hide_nav_bars, - R.string.automatically_hide_nav_bars_description, - booleanItems, - hideNavBarsIndex - ) { - sharedPreferencesViewModel.updateAutomaticallyHideNavBars(parseBooleanType(it)) - } - - Spacer(modifier = HalfVertSpacer) - - PushNotificationSettingsRow(sharedPreferencesViewModel) + sharedPreferencesViewModel.updateLanguage(languageEntries[languageList[it].title]) } + + Spacer(modifier = HalfVertSpacer) + + SettingsRow( + R.string.theme, + R.string.theme_description, + themeItens, + themeIndex, + ) { + sharedPreferencesViewModel.updateTheme(parseThemeType(it)) + } + + Spacer(modifier = HalfVertSpacer) + + SettingsRow( + R.string.automatically_load_images_gifs, + R.string.automatically_load_images_gifs_description, + selectedItens, + showImagesIndex, + ) { + sharedPreferencesViewModel.updateAutomaticallyShowImages(parseConnectivityType(it)) + } + + Spacer(modifier = HalfVertSpacer) + + SettingsRow( + R.string.automatically_play_videos, + R.string.automatically_play_videos_description, + selectedItens, + videoIndex, + ) { + sharedPreferencesViewModel.updateAutomaticallyStartPlayback(parseConnectivityType(it)) + } + + Spacer(modifier = HalfVertSpacer) + + SettingsRow( + R.string.automatically_show_url_preview, + R.string.automatically_show_url_preview_description, + selectedItens, + linkIndex, + ) { + sharedPreferencesViewModel.updateAutomaticallyShowUrlPreview(parseConnectivityType(it)) + } + + SettingsRow( + R.string.automatically_show_profile_picture, + R.string.automatically_show_profile_picture_description, + selectedItens, + profilePictureIndex, + ) { + sharedPreferencesViewModel.updateAutomaticallyShowProfilePicture(parseConnectivityType(it)) + } + + Spacer(modifier = HalfVertSpacer) + + SettingsRow( + R.string.automatically_hide_nav_bars, + R.string.automatically_hide_nav_bars_description, + booleanItems, + hideNavBarsIndex, + ) { + sharedPreferencesViewModel.updateAutomaticallyHideNavBars(parseBooleanType(it)) + } + + Spacer(modifier = HalfVertSpacer) + + PushNotificationSettingsRow(sharedPreferencesViewModel) + } } @Composable fun SettingsRow( - name: Int, - description: Int, - selectedItens: ImmutableList, - selectedIndex: Int, - onSelect: (Int) -> Unit + name: Int, + description: Int, + selectedItens: ImmutableList, + selectedIndex: Int, + onSelect: (Int) -> Unit, ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.weight(2.0f), + verticalArrangement = Arrangement.spacedBy(3.dp), ) { - Column( - modifier = Modifier.weight(2.0f), - verticalArrangement = Arrangement.spacedBy(3.dp) - ) { - Text( - text = stringResource(name), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = stringResource(description), - style = MaterialTheme.typography.bodySmall, - color = Color.Gray, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - - TextSpinner( - label = "", - placeholder = selectedItens[selectedIndex].title, - options = selectedItens, - onSelect = onSelect, - modifier = Modifier - .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) - .weight(1f) - ) + Text( + text = stringResource(name), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringResource(description), + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) } + + TextSpinner( + label = "", + placeholder = selectedItens[selectedIndex].title, + options = selectedItens, + onSelect = onSelect, + modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)).weight(1f), + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ThreadScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ThreadScreen.kt index a003bfd4e..730f1fa80 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ThreadScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ThreadScreen.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen.loggedIn import androidx.compose.foundation.layout.Column @@ -14,50 +34,51 @@ import com.vitorpamplona.amethyst.ui.screen.NostrThreadFeedViewModel import com.vitorpamplona.amethyst.ui.screen.ThreadFeedView @Composable -fun ThreadScreen(noteId: String?, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - if (noteId == null) return +fun ThreadScreen( + noteId: String?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + if (noteId == null) return - val lifeCycleOwner = LocalLifecycleOwner.current + val lifeCycleOwner = LocalLifecycleOwner.current - val feedViewModel: NostrThreadFeedViewModel = viewModel( - key = noteId + "NostrThreadFeedViewModel", - factory = NostrThreadFeedViewModel.Factory(accountViewModel.account, noteId) + val feedViewModel: NostrThreadFeedViewModel = + viewModel( + key = noteId + "NostrThreadFeedViewModel", + factory = NostrThreadFeedViewModel.Factory(accountViewModel.account, noteId), ) - NostrThreadDataSource.loadThread(noteId) + NostrThreadDataSource.loadThread(noteId) - DisposableEffect(noteId) { + DisposableEffect(noteId) { + feedViewModel.invalidateData(true) + onDispose { + NostrThreadDataSource.loadThread(null) + NostrThreadDataSource.stop() + } + } + + DisposableEffect(lifeCycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Thread Start") + NostrThreadDataSource.loadThread(noteId) + NostrThreadDataSource.start() feedViewModel.invalidateData(true) - onDispose { - NostrThreadDataSource.loadThread(null) - NostrThreadDataSource.stop() - } + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Thread Stop") + NostrThreadDataSource.loadThread(null) + NostrThreadDataSource.stop() + } } - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Thread Start") - NostrThreadDataSource.loadThread(noteId) - NostrThreadDataSource.start() - feedViewModel.invalidateData(true) - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Thread Stop") - NostrThreadDataSource.loadThread(null) - NostrThreadDataSource.stop() - } - } + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } + } - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { - lifeCycleOwner.lifecycle.removeObserver(observer) - } - } - - Column(Modifier.fillMaxHeight()) { - Column() { - ThreadFeedView(noteId, feedViewModel, accountViewModel, nav) - } - } + Column(Modifier.fillMaxHeight()) { + Column { ThreadFeedView(noteId, feedViewModel, accountViewModel, nav) } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt index 8eaa91168..43125fa60 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen.loggedIn import androidx.compose.animation.Crossfade @@ -84,401 +104,403 @@ import kotlinx.coroutines.launch @Composable fun VideoScreen( - videoFeedView: NostrVideoFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + videoFeedView: NostrVideoFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val lifeCycleOwner = LocalLifecycleOwner.current + val lifeCycleOwner = LocalLifecycleOwner.current - WatchAccountForVideoScreen(videoFeedView = videoFeedView, accountViewModel = accountViewModel) + WatchAccountForVideoScreen(videoFeedView = videoFeedView, accountViewModel = accountViewModel) - DisposableEffect(lifeCycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Video Start") - NostrVideoDataSource.start() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { - lifeCycleOwner.lifecycle.removeObserver(observer) - } + DisposableEffect(lifeCycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Video Start") + NostrVideoDataSource.start() + } } - Column(Modifier.fillMaxHeight()) { - RenderPage( - videoFeedView = videoFeedView, - pagerStateKey = ScrollStateKeys.VIDEO_SCREEN, - accountViewModel = accountViewModel, - nav = nav - ) - } + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } + } + + Column(Modifier.fillMaxHeight()) { + RenderPage( + videoFeedView = videoFeedView, + pagerStateKey = ScrollStateKeys.VIDEO_SCREEN, + accountViewModel = accountViewModel, + nav = nav, + ) + } } @Composable -fun WatchAccountForVideoScreen(videoFeedView: NostrVideoFeedViewModel, accountViewModel: AccountViewModel) { - val listState by accountViewModel.account.liveStoriesFollowLists.collectAsStateWithLifecycle() - val hiddenUsers = accountViewModel.account.flowHiddenUsers.collectAsStateWithLifecycle() +fun WatchAccountForVideoScreen( + videoFeedView: NostrVideoFeedViewModel, + accountViewModel: AccountViewModel, +) { + val listState by accountViewModel.account.liveStoriesFollowLists.collectAsStateWithLifecycle() + val hiddenUsers = accountViewModel.account.flowHiddenUsers.collectAsStateWithLifecycle() - LaunchedEffect(accountViewModel, listState, hiddenUsers) { - NostrVideoDataSource.resetFilters() - videoFeedView.checkKeysInvalidateDataAndSendToTop() - } + LaunchedEffect(accountViewModel, listState, hiddenUsers) { + NostrVideoDataSource.resetFilters() + videoFeedView.checkKeysInvalidateDataAndSendToTop() + } } @OptIn(ExperimentalFoundationApi::class) @Composable public fun WatchScrollToTop( - viewModel: FeedViewModel, - pagerState: PagerState + viewModel: FeedViewModel, + pagerState: PagerState, ) { - val scrollToTop by viewModel.scrollToTop.collectAsStateWithLifecycle() + val scrollToTop by viewModel.scrollToTop.collectAsStateWithLifecycle() - LaunchedEffect(scrollToTop) { - if (scrollToTop > 0 && viewModel.scrolltoTopPending) { - pagerState.scrollToPage(page = 0) - viewModel.sentToTop() - } + LaunchedEffect(scrollToTop) { + if (scrollToTop > 0 && viewModel.scrolltoTopPending) { + pagerState.scrollToPage(page = 0) + viewModel.sentToTop() } + } } @Composable fun RenderPage( - videoFeedView: NostrVideoFeedViewModel, - pagerStateKey: String?, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + videoFeedView: NostrVideoFeedViewModel, + pagerStateKey: String?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val feedState by videoFeedView.feedContent.collectAsStateWithLifecycle() + val feedState by videoFeedView.feedContent.collectAsStateWithLifecycle() - Crossfade( - targetState = feedState, - animationSpec = tween(durationMillis = 100), - label = "RenderPage" - ) { state -> - when (state) { - is FeedState.Empty -> { - FeedEmpty {} - } - - is FeedState.FeedError -> { - FeedError(state.errorMessage) {} - } - - is FeedState.Loaded -> { - LoadedState(state, pagerStateKey, videoFeedView, accountViewModel, nav) - } - - is FeedState.Loading -> { - LoadingFeed() - } - } + Crossfade( + targetState = feedState, + animationSpec = tween(durationMillis = 100), + label = "RenderPage", + ) { state -> + when (state) { + is FeedState.Empty -> { + FeedEmpty {} + } + is FeedState.FeedError -> { + FeedError(state.errorMessage) {} + } + is FeedState.Loaded -> { + LoadedState(state, pagerStateKey, videoFeedView, accountViewModel, nav) + } + is FeedState.Loading -> { + LoadingFeed() + } } + } } @Composable @OptIn(ExperimentalFoundationApi::class) private fun LoadedState( - state: FeedState.Loaded, - pagerStateKey: String?, - videoFeedView: NostrVideoFeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + state: FeedState.Loaded, + pagerStateKey: String?, + videoFeedView: NostrVideoFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - val pagerState = if (pagerStateKey != null) { - rememberForeverPagerState(pagerStateKey) { state.feed.value.size } + val pagerState = + if (pagerStateKey != null) { + rememberForeverPagerState(pagerStateKey) { state.feed.value.size } } else { - rememberPagerState { state.feed.value.size } + rememberPagerState { state.feed.value.size } } - WatchScrollToTop(videoFeedView, pagerState) + WatchScrollToTop(videoFeedView, pagerState) - RefresheableView(viewModel = videoFeedView) { - SlidingCarousel( - state.feed, - pagerState, - state.showHidden.value, - accountViewModel, - nav - ) - } + RefresheableView(viewModel = videoFeedView) { + SlidingCarousel( + state.feed, + pagerState, + state.showHidden.value, + accountViewModel, + nav, + ) + } } @OptIn(ExperimentalFoundationApi::class) @Composable fun SlidingCarousel( - feed: MutableState>, - pagerState: PagerState, - showHidden: Boolean, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + feed: MutableState>, + pagerState: PagerState, + showHidden: Boolean, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - VerticalPager( - state = pagerState, - beyondBoundsPageCount = 1, - modifier = Modifier.fillMaxSize(), - key = { index -> - feed.value.getOrNull(index)?.idHex ?: "$index" - } - ) { index -> - feed.value.getOrNull(index)?.let { note -> - LoadedVideoCompose(note, showHidden, accountViewModel, nav) - } + VerticalPager( + state = pagerState, + beyondBoundsPageCount = 1, + modifier = Modifier.fillMaxSize(), + key = { index -> feed.value.getOrNull(index)?.idHex ?: "$index" }, + ) { index -> + feed.value.getOrNull(index)?.let { note -> + LoadedVideoCompose(note, showHidden, accountViewModel, nav) } + } } @Composable fun LoadedVideoCompose( - note: Note, - showHidden: Boolean, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + note: Note, + showHidden: Boolean, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var state by remember(note) { - mutableStateOf( - AccountViewModel.NoteComposeReportState() - ) + var state by + remember(note) { + mutableStateOf( + AccountViewModel.NoteComposeReportState(), + ) } - if (!showHidden) { - val scope = rememberCoroutineScope() + if (!showHidden) { + val scope = rememberCoroutineScope() - WatchForReports(note, accountViewModel) { newState -> - if (state != newState) { - scope.launch(Dispatchers.Main) { - state = newState - } - } - } + WatchForReports(note, accountViewModel) { newState -> + if (state != newState) { + scope.launch(Dispatchers.Main) { state = newState } + } } + } - Crossfade(targetState = state, label = "LoadedVideoCompose") { - RenderReportState( - it, - note, - accountViewModel, - nav - ) - } + Crossfade(targetState = state, label = "LoadedVideoCompose") { + RenderReportState( + it, + note, + accountViewModel, + nav, + ) + } } @Composable fun RenderReportState( - state: AccountViewModel.NoteComposeReportState, - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + state: AccountViewModel.NoteComposeReportState, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var showReportedNote by remember { mutableStateOf(false) } + var showReportedNote by remember { mutableStateOf(false) } - Crossfade(targetState = (!state.isAcceptable || state.isHiddenAuthor) && !showReportedNote) { showHiddenNote -> - if (showHiddenNote) { - Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) { - HiddenNote( - state.relevantReports, - state.isHiddenAuthor, - accountViewModel, - Modifier.fillMaxWidth(), - false, - nav, - onClick = { showReportedNote = true } - ) - } - } else { - RenderVideoOrPictureNote( - note, - accountViewModel, - nav - ) - } + Crossfade(targetState = (!state.isAcceptable || state.isHiddenAuthor) && !showReportedNote) { + showHiddenNote -> + if (showHiddenNote) { + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) { + HiddenNote( + state.relevantReports, + state.isHiddenAuthor, + accountViewModel, + Modifier.fillMaxWidth(), + false, + nav, + onClick = { showReportedNote = true }, + ) + } + } else { + RenderVideoOrPictureNote( + note, + accountViewModel, + nav, + ) } + } } @Composable private fun RenderVideoOrPictureNote( - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - Column(remember { Modifier.fillMaxSize(1f) }, verticalArrangement = Arrangement.Center) { - Row(remember { Modifier.weight(1f) }, verticalAlignment = Alignment.CenterVertically) { - val noteEvent = remember { note.event } - if (noteEvent is FileHeaderEvent) { - FileHeaderDisplay(note, false, accountViewModel) - } else if (noteEvent is FileStorageHeaderEvent) { - FileStorageHeaderDisplay(note, false, accountViewModel) - } - } + Column(remember { Modifier.fillMaxSize(1f) }, verticalArrangement = Arrangement.Center) { + Row(remember { Modifier.weight(1f) }, verticalAlignment = Alignment.CenterVertically) { + val noteEvent = remember { note.event } + if (noteEvent is FileHeaderEvent) { + FileHeaderDisplay(note, false, accountViewModel) + } else if (noteEvent is FileStorageHeaderEvent) { + FileStorageHeaderDisplay(note, false, accountViewModel) + } + } + } + + Row(verticalAlignment = Alignment.Bottom, modifier = remember { Modifier.fillMaxSize(1f) }) { + Column(remember { Modifier.weight(1f) }) { + RenderAuthorInformation(note, nav, accountViewModel) } - Row(verticalAlignment = Alignment.Bottom, modifier = remember { Modifier.fillMaxSize(1f) }) { - Column(remember { Modifier.weight(1f) }) { - RenderAuthorInformation(note, nav, accountViewModel) - } - - Column( - remember { - Modifier - .width(65.dp) - .padding(bottom = 10.dp) - }, - verticalArrangement = Arrangement.Center - ) { - Row(horizontalArrangement = Arrangement.Center) { - ReactionsColumn(note, accountViewModel, nav) - } - } + Column( + remember { Modifier.width(65.dp).padding(bottom = 10.dp) }, + verticalArrangement = Arrangement.Center, + ) { + Row(horizontalArrangement = Arrangement.Center) { + ReactionsColumn(note, accountViewModel, nav) + } } + } } @Composable private fun RenderAuthorInformation( - note: Note, - nav: (String) -> Unit, - accountViewModel: AccountViewModel + note: Note, + nav: (String) -> Unit, + accountViewModel: AccountViewModel, ) { - Row(remember { Modifier.padding(10.dp) }, verticalAlignment = Alignment.Bottom) { - Column(remember { Modifier.size(55.dp) }, verticalArrangement = Arrangement.Center) { - NoteAuthorPicture(note, nav, accountViewModel, 55.dp) - } - - Column( - remember { - Modifier - .padding(start = 10.dp, end = 10.dp) - .height(65.dp) - .weight(1f) - }, - verticalArrangement = Arrangement.Center - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - NoteUsernameDisplay(note, remember { Modifier.weight(1f) }) - VideoUserOptionAction(note, accountViewModel) - } - Row(verticalAlignment = Alignment.CenterVertically) { - ObserveDisplayNip05Status( - remember { note.author!! }, - remember { Modifier.weight(1f) }, - accountViewModel, - nav = nav - ) - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(top = 2.dp) - ) { - RelayBadges(baseNote = note, accountViewModel, nav) - } - } + Row(remember { Modifier.padding(10.dp) }, verticalAlignment = Alignment.Bottom) { + Column(remember { Modifier.size(55.dp) }, verticalArrangement = Arrangement.Center) { + NoteAuthorPicture(note, nav, accountViewModel, 55.dp) } + + Column( + remember { Modifier.padding(start = 10.dp, end = 10.dp).height(65.dp).weight(1f) }, + verticalArrangement = Arrangement.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + NoteUsernameDisplay(note, remember { Modifier.weight(1f) }) + VideoUserOptionAction(note, accountViewModel) + } + Row(verticalAlignment = Alignment.CenterVertically) { + ObserveDisplayNip05Status( + remember { note.author!! }, + remember { Modifier.weight(1f) }, + accountViewModel, + nav = nav, + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 2.dp), + ) { + RelayBadges(baseNote = note, accountViewModel, nav) + } + } + } } @Composable private fun VideoUserOptionAction( - note: Note, - accountViewModel: AccountViewModel + note: Note, + accountViewModel: AccountViewModel, ) { - val popupExpanded = remember { mutableStateOf(false) } - val enablePopup = remember { - { popupExpanded.value = true } - } + val popupExpanded = remember { mutableStateOf(false) } + val enablePopup = remember { { popupExpanded.value = true } } - IconButton( - modifier = remember { Modifier.size(22.dp) }, - onClick = enablePopup - ) { - Icon( - imageVector = Icons.Default.MoreVert, - null, - modifier = remember { Modifier.size(20.dp) }, - tint = MaterialTheme.colorScheme.placeholderText - ) + IconButton( + modifier = remember { Modifier.size(22.dp) }, + onClick = enablePopup, + ) { + Icon( + imageVector = Icons.Default.MoreVert, + null, + modifier = remember { Modifier.size(20.dp) }, + tint = MaterialTheme.colorScheme.placeholderText, + ) - NoteDropDownMenu( - note, - popupExpanded, - accountViewModel - ) - } + NoteDropDownMenu( + note, + popupExpanded, + accountViewModel, + ) + } } @OptIn(ExperimentalLayoutApi::class) @Composable -private fun RelayBadges(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val noteRelays by baseNote.live().relayInfo.observeAsState(baseNote.relays) +private fun RelayBadges( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteRelays by baseNote.live().relayInfo.observeAsState(baseNote.relays) - FlowRow { - noteRelays?.forEach { relayInfo -> - RenderRelay(relayInfo, accountViewModel, nav) - } - } + FlowRow { noteRelays?.forEach { relayInfo -> RenderRelay(relayInfo, accountViewModel, nav) } } } @Composable -fun ReactionsColumn(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - var wantsToReplyTo by remember { - mutableStateOf(null) - } +fun ReactionsColumn( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + var wantsToReplyTo by remember { mutableStateOf(null) } - var wantsToQuote by remember { - mutableStateOf(null) - } + var wantsToQuote by remember { mutableStateOf(null) } - if (wantsToReplyTo != null) { - NewPostView(onClose = { wantsToReplyTo = null }, baseReplyTo = wantsToReplyTo, quote = null, accountViewModel = accountViewModel, nav = nav) - } + if (wantsToReplyTo != null) { + NewPostView( + onClose = { wantsToReplyTo = null }, + baseReplyTo = wantsToReplyTo, + quote = null, + accountViewModel = accountViewModel, + nav = nav, + ) + } - if (wantsToQuote != null) { - NewPostView(onClose = { wantsToQuote = null }, baseReplyTo = null, quote = wantsToQuote, accountViewModel = accountViewModel, nav = nav) - } + if (wantsToQuote != null) { + NewPostView( + onClose = { wantsToQuote = null }, + baseReplyTo = null, + quote = wantsToQuote, + accountViewModel = accountViewModel, + nav = nav, + ) + } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) - Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(bottom = 75.dp, end = 20.dp)) { - ReplyReaction( - baseNote = baseNote, - grayTint = MaterialTheme.colorScheme.onBackground, - accountViewModel = accountViewModel, - iconSizeModifier = Size40Modifier - ) { - routeFor( - baseNote, - accountViewModel.userProfile() - )?.let { nav(it) } - } - BoostReaction( - baseNote = baseNote, - grayTint = MaterialTheme.colorScheme.onBackground, - accountViewModel = accountViewModel, - iconSizeModifier = Size40Modifier, - iconSize = Size40dp - ) { - wantsToQuote = baseNote - } - LikeReaction( - baseNote = baseNote, - grayTint = MaterialTheme.colorScheme.onBackground, - accountViewModel = accountViewModel, - nav = nav, - iconSize = Size40dp, - heartSizeModifier = Size35Modifier, - 28.sp - ) - ZapReaction( - baseNote = baseNote, - grayTint = MaterialTheme.colorScheme.onBackground, - accountViewModel = accountViewModel, - iconSize = Size40dp, - iconSizeModifier = Size40Modifier, - animationSize = Size35dp, - nav = nav - ) - ViewCountReaction( - note = baseNote, - grayTint = MaterialTheme.colorScheme.onBackground, - barChartModifier = Size39Modifier, - viewCountColorFilter = MaterialTheme.colorScheme.onBackgroundColorFilter + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(bottom = 75.dp, end = 20.dp), + ) { + ReplyReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onBackground, + accountViewModel = accountViewModel, + iconSizeModifier = Size40Modifier, + ) { + routeFor( + baseNote, + accountViewModel.userProfile(), ) + ?.let { nav(it) } } + BoostReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onBackground, + accountViewModel = accountViewModel, + iconSizeModifier = Size40Modifier, + iconSize = Size40dp, + ) { + wantsToQuote = baseNote + } + LikeReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onBackground, + accountViewModel = accountViewModel, + nav = nav, + iconSize = Size40dp, + heartSizeModifier = Size35Modifier, + 28.sp, + ) + ZapReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onBackground, + accountViewModel = accountViewModel, + iconSize = Size40dp, + iconSizeModifier = Size40Modifier, + animationSize = Size35dp, + nav = nav, + ) + ViewCountReaction( + note = baseNote, + grayTint = MaterialTheme.colorScheme.onBackground, + barChartModifier = Size39Modifier, + viewCountColorFilter = MaterialTheme.colorScheme.onBackgroundColorFilter, + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt index 450323703..b692241f5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.screen.loggedOff import android.app.Activity @@ -81,395 +101,394 @@ import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.signers.ExternalSignerLauncher import com.vitorpamplona.quartz.signers.SignerType +import java.util.UUID import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import java.util.UUID @OptIn(ExperimentalComposeUiApi::class) @Composable fun LoginPage( - accountViewModel: AccountStateViewModel, - isFirstLogin: Boolean + accountViewModel: AccountStateViewModel, + isFirstLogin: Boolean, ) { - val key = remember { mutableStateOf(TextFieldValue("")) } - var errorMessage by remember { mutableStateOf("") } - val acceptedTerms = remember { mutableStateOf(!isFirstLogin) } - var termsAcceptanceIsRequired by remember { mutableStateOf("") } + val key = remember { mutableStateOf(TextFieldValue("")) } + var errorMessage by remember { mutableStateOf("") } + val acceptedTerms = remember { mutableStateOf(!isFirstLogin) } + var termsAcceptanceIsRequired by remember { mutableStateOf("") } - val uri = LocalUriHandler.current - val context = LocalContext.current - var dialogOpen by remember { - mutableStateOf(false) - } - val useProxy = remember { mutableStateOf(false) } - val proxyPort = remember { mutableStateOf("9050") } - var connectOrbotDialogOpen by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() - var loginWithExternalSigner by remember { mutableStateOf(false) } + val uri = LocalUriHandler.current + val context = LocalContext.current + var dialogOpen by remember { mutableStateOf(false) } + val useProxy = remember { mutableStateOf(false) } + val proxyPort = remember { mutableStateOf("9050") } + var connectOrbotDialogOpen by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + var loginWithExternalSigner by remember { mutableStateOf(false) } - if (loginWithExternalSigner) { - val externalSignerLauncher = remember { ExternalSignerLauncher("", signerPackageName = "") } - val id = remember { UUID.randomUUID().toString() } + if (loginWithExternalSigner) { + val externalSignerLauncher = remember { ExternalSignerLauncher("", signerPackageName = "") } + val id = remember { UUID.randomUUID().toString() } - val launcher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult(), - onResult = { result -> - if (result.resultCode != Activity.RESULT_OK) { - scope.launch(Dispatchers.Main) { - Toast.makeText( - Amethyst.instance, - "Sign request rejected", - Toast.LENGTH_SHORT - ).show() - } - } else { - result.data?.let { - externalSignerLauncher.newResult(it) - } - } + val launcher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + onResult = { result -> + if (result.resultCode != Activity.RESULT_OK) { + scope.launch(Dispatchers.Main) { + Toast.makeText( + Amethyst.instance, + "Sign request rejected", + Toast.LENGTH_SHORT, + ) + .show() } - ) + } else { + result.data?.let { externalSignerLauncher.newResult(it) } + } + }, + ) - val activity = getActivity() as MainActivity + val activity = getActivity() as MainActivity - DisposableEffect(launcher, activity, externalSignerLauncher) { - externalSignerLauncher.registerLauncher( - launcher = { - try { - activity.prepareToLaunchSigner() - launcher.launch(it) - } catch (e: Exception) { - Log.e("Signer", "Error opening Signer app", e) - scope.launch(Dispatchers.Main) { - Toast.makeText( - Amethyst.instance, - R.string.error_opening_external_signer, - Toast.LENGTH_SHORT - ).show() - } - } - }, - contentResolver = { Amethyst.instance.contentResolver } - ) - onDispose { - externalSignerLauncher.clearLauncher() + DisposableEffect(launcher, activity, externalSignerLauncher) { + externalSignerLauncher.registerLauncher( + launcher = { + try { + activity.prepareToLaunchSigner() + launcher.launch(it) + } catch (e: Exception) { + Log.e("Signer", "Error opening Signer app", e) + scope.launch(Dispatchers.Main) { + Toast.makeText( + Amethyst.instance, + R.string.error_opening_external_signer, + Toast.LENGTH_SHORT, + ) + .show() } - } - - LaunchedEffect(loginWithExternalSigner, externalSignerLauncher) { - externalSignerLauncher.openSignerApp( - "", - SignerType.GET_PUBLIC_KEY, - "", - id - ) { result -> - val split = result.split("-") - val pubkey = split.first() - val packageName = if (split.size > 1) split[1] else "" - key.value = TextFieldValue(pubkey) - if (!acceptedTerms.value) { - termsAcceptanceIsRequired = - context.getString(R.string.acceptance_of_terms_is_required) - } - - if (key.value.text.isBlank()) { - errorMessage = context.getString(R.string.key_is_required) - } - - if (acceptedTerms.value && key.value.text.isNotBlank()) { - accountViewModel.login(key.value.text, useProxy.value, proxyPort.value.toInt(), true, packageName) { - errorMessage = context.getString(R.string.invalid_key) - } - } - } - } + } + }, + contentResolver = { Amethyst.instance.contentResolver }, + ) + onDispose { externalSignerLauncher.clearLauncher() } } + LaunchedEffect(loginWithExternalSigner, externalSignerLauncher) { + externalSignerLauncher.openSignerApp( + "", + SignerType.GET_PUBLIC_KEY, + "", + id, + ) { result -> + val split = result.split("-") + val pubkey = split.first() + val packageName = if (split.size > 1) split[1] else "" + key.value = TextFieldValue(pubkey) + if (!acceptedTerms.value) { + termsAcceptanceIsRequired = context.getString(R.string.acceptance_of_terms_is_required) + } + + if (key.value.text.isBlank()) { + errorMessage = context.getString(R.string.key_is_required) + } + + if (acceptedTerms.value && key.value.text.isNotBlank()) { + accountViewModel.login( + key.value.text, + useProxy.value, + proxyPort.value.toInt(), + true, + packageName, + ) { + errorMessage = context.getString(R.string.invalid_key) + } + } + } + } + } + + Column( + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.SpaceBetween, + ) { + // The first child is glued to the top. + // Hence we have nothing at the top, an empty box is used. + Box(modifier = Modifier.height(0.dp)) + + // The second child, this column, is centered vertically. Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.SpaceBetween + modifier = Modifier.padding(20.dp).fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, ) { - // The first child is glued to the top. - // Hence we have nothing at the top, an empty box is used. - Box(modifier = Modifier.height(0.dp)) + Image( + painterResource(id = R.drawable.amethyst), + contentDescription = stringResource(R.string.app_logo), + modifier = Modifier.size(200.dp), + contentScale = ContentScale.Inside, + ) - // The second child, this column, is centered vertically. - Column( - modifier = Modifier - .padding(20.dp) - .fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Image( - painterResource(id = R.drawable.amethyst), - contentDescription = stringResource(R.string.app_logo), - modifier = Modifier.size(200.dp), - contentScale = ContentScale.Inside + Spacer(modifier = Modifier.height(40.dp)) + + var showPassword by remember { mutableStateOf(false) } + + val autofillNode = + AutofillNode( + autofillTypes = listOf(AutofillType.Password), + onFill = { key.value = TextFieldValue(it) }, + ) + val autofill = LocalAutofill.current + LocalAutofillTree.current += autofillNode + + OutlinedTextField( + modifier = + Modifier.onGloballyPositioned { coordinates -> + autofillNode.boundingBox = coordinates.boundsInWindow() + } + .onFocusChanged { focusState -> + autofill?.run { + if (focusState.isFocused) { + requestAutofillForNode(autofillNode) + } else { + cancelAutofillForNode(autofillNode) + } + } + }, + value = key.value, + onValueChange = { key.value = it }, + keyboardOptions = + KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + ), + placeholder = { + Text( + text = stringResource(R.string.nsec_npub_hex_private_key), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + trailingIcon = { + Row { + IconButton(onClick = { showPassword = !showPassword }) { + Icon( + imageVector = + if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = + if (showPassword) { + stringResource(R.string.show_password) + } else { + stringResource( + R.string.hide_password, + ) + }, + ) + } + } + }, + leadingIcon = { + if (dialogOpen) { + SimpleQrCodeScanner { + dialogOpen = false + if (!it.isNullOrEmpty()) { + key.value = TextFieldValue(it) + } + } + } + IconButton(onClick = { dialogOpen = true }) { + Icon( + painter = painterResource(R.drawable.ic_qrcode), + null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, ) + } + }, + visualTransformation = + if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), + keyboardActions = + KeyboardActions( + onGo = { + if (!acceptedTerms.value) { + termsAcceptanceIsRequired = + context.getString(R.string.acceptance_of_terms_is_required) + } - Spacer(modifier = Modifier.height(40.dp)) + if (key.value.text.isBlank()) { + errorMessage = context.getString(R.string.key_is_required) + } - var showPassword by remember { - mutableStateOf(false) - } - - val autofillNode = AutofillNode( - autofillTypes = listOf(AutofillType.Password), - onFill = { key.value = TextFieldValue(it) } - ) - val autofill = LocalAutofill.current - LocalAutofillTree.current += autofillNode - - OutlinedTextField( - modifier = Modifier - .onGloballyPositioned { coordinates -> - autofillNode.boundingBox = coordinates.boundsInWindow() - } - .onFocusChanged { focusState -> - autofill?.run { - if (focusState.isFocused) { - requestAutofillForNode(autofillNode) - } else { - cancelAutofillForNode(autofillNode) - } - } - }, - value = key.value, - onValueChange = { key.value = it }, - keyboardOptions = KeyboardOptions( - autoCorrect = false, - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Go - ), - placeholder = { - Text( - text = stringResource(R.string.nsec_npub_hex_private_key), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - trailingIcon = { - Row { - IconButton(onClick = { showPassword = !showPassword }) { - Icon( - imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, - contentDescription = if (showPassword) { - stringResource(R.string.show_password) - } else { - stringResource( - R.string.hide_password - ) - } - ) - } - } - }, - leadingIcon = { - if (dialogOpen) { - SimpleQrCodeScanner { - dialogOpen = false - if (!it.isNullOrEmpty()) { - key.value = TextFieldValue(it) - } - } - } - IconButton(onClick = { dialogOpen = true }) { - Icon( - painter = painterResource(R.drawable.ic_qrcode), - null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary - ) - } - }, - visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), - keyboardActions = KeyboardActions( - onGo = { - if (!acceptedTerms.value) { - termsAcceptanceIsRequired = context.getString(R.string.acceptance_of_terms_is_required) - } - - if (key.value.text.isBlank()) { - errorMessage = context.getString(R.string.key_is_required) - } - - if (acceptedTerms.value && key.value.text.isNotBlank()) { - accountViewModel.login(key.value.text, useProxy.value, proxyPort.value.toInt()) { - errorMessage = context.getString(R.string.invalid_key) - } - } - } - ) - ) - if (errorMessage.isNotBlank()) { - Text( - text = errorMessage, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall - ) - } - - Spacer(modifier = Modifier.height(20.dp)) - - if (isFirstLogin) { - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = acceptedTerms.value, - onCheckedChange = { acceptedTerms.value = it } - ) - - val regularText = - SpanStyle(color = MaterialTheme.colorScheme.onBackground) - - val clickableTextStyle = - SpanStyle(color = MaterialTheme.colorScheme.primary) - - val annotatedTermsString = buildAnnotatedString { - withStyle(regularText) { - append(stringResource(R.string.i_accept_the)) - } - - withStyle(clickableTextStyle) { - pushStringAnnotation("openTerms", "") - append(stringResource(R.string.terms_of_use)) - } - } - - ClickableText( - text = annotatedTermsString - ) { spanOffset -> - annotatedTermsString.getStringAnnotations(spanOffset, spanOffset) - .firstOrNull() - ?.also { span -> - if (span.tag == "openTerms") { - runCatching { uri.openUri("https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md") } - } - } - } + if (acceptedTerms.value && key.value.text.isNotBlank()) { + accountViewModel.login(key.value.text, useProxy.value, proxyPort.value.toInt()) { + errorMessage = context.getString(R.string.invalid_key) } + } + }, + ), + ) + if (errorMessage.isNotBlank()) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } - if (termsAcceptanceIsRequired.isNotBlank()) { - Text( - text = termsAcceptanceIsRequired, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall - ) - } + Spacer(modifier = Modifier.height(20.dp)) + + if (isFirstLogin) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = acceptedTerms.value, + onCheckedChange = { acceptedTerms.value = it }, + ) + + val regularText = SpanStyle(color = MaterialTheme.colorScheme.onBackground) + + val clickableTextStyle = SpanStyle(color = MaterialTheme.colorScheme.primary) + + val annotatedTermsString = buildAnnotatedString { + withStyle(regularText) { append(stringResource(R.string.i_accept_the)) } + + withStyle(clickableTextStyle) { + pushStringAnnotation("openTerms", "") + append(stringResource(R.string.terms_of_use)) } + } - if (PackageUtils.isOrbotInstalled(context)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = useProxy.value, - onCheckedChange = { - if (it) { - connectOrbotDialogOpen = true - } - } - ) - - Text(stringResource(R.string.connect_via_tor)) - } - - if (connectOrbotDialogOpen) { - ConnectOrbotDialog( - onClose = { connectOrbotDialogOpen = false }, - onPost = { - connectOrbotDialogOpen = false - useProxy.value = true - }, - onError = { - scope.launch { - Toast.makeText( - context, - it, - Toast.LENGTH_LONG - ).show() - } - }, - proxyPort - ) - } - } - - Spacer(modifier = Modifier.height(20.dp)) - - Box(modifier = Modifier.padding(40.dp, 0.dp, 40.dp, 0.dp)) { - Button( - enabled = acceptedTerms.value, - onClick = { - if (!acceptedTerms.value) { - termsAcceptanceIsRequired = context.getString(R.string.acceptance_of_terms_is_required) - } - - if (key.value.text.isBlank()) { - errorMessage = context.getString(R.string.key_is_required) - } - - if (acceptedTerms.value && key.value.text.isNotBlank()) { - accountViewModel.login(key.value.text, useProxy.value, proxyPort.value.toInt()) { - errorMessage = context.getString(R.string.invalid_key) - } - } - }, - shape = RoundedCornerShape(Size35dp), - modifier = Modifier - .height(50.dp) - ) { - Text( - text = stringResource(R.string.login), - modifier = Modifier.padding(horizontal = 40.dp) - ) - } - } - - if (PackageUtils.isAmberInstalled(context)) { - Box(modifier = Modifier.padding(40.dp, 40.dp, 40.dp, 0.dp)) { - Button( - enabled = acceptedTerms.value, - onClick = { - if (!acceptedTerms.value) { - termsAcceptanceIsRequired = context.getString(R.string.acceptance_of_terms_is_required) - return@Button - } - - loginWithExternalSigner = true - return@Button - }, - shape = RoundedCornerShape(Size35dp), - modifier = Modifier - .height(50.dp) - ) { - Text( - text = stringResource(R.string.login_with_external_signer), - modifier = Modifier.padding(horizontal = 40.dp) - ) - } + ClickableText( + text = annotatedTermsString, + ) { spanOffset -> + annotatedTermsString.getStringAnnotations(spanOffset, spanOffset).firstOrNull()?.also { + span -> + if (span.tag == "openTerms") { + runCatching { + uri.openUri("https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md") } + } } + } } - // The last child is glued to the bottom. - ClickableText( - text = AnnotatedString(stringResource(R.string.generate_a_new_key)), - modifier = Modifier - .padding(20.dp) - .fillMaxWidth(), - onClick = { - if (acceptedTerms.value) { - accountViewModel.newKey(useProxy.value, proxyPort.value.toInt()) - } else { - termsAcceptanceIsRequired = - context.getString(R.string.acceptance_of_terms_is_required) - } + if (termsAcceptanceIsRequired.isNotBlank()) { + Text( + text = termsAcceptanceIsRequired, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } + + if (PackageUtils.isOrbotInstalled(context)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = useProxy.value, + onCheckedChange = { + if (it) { + connectOrbotDialogOpen = true + } }, - style = TextStyle( - fontSize = Font14SP, - textDecoration = TextDecoration.Underline, - color = MaterialTheme.colorScheme.primary, - textAlign = TextAlign.Center + ) + + Text(stringResource(R.string.connect_via_tor)) + } + + if (connectOrbotDialogOpen) { + ConnectOrbotDialog( + onClose = { connectOrbotDialogOpen = false }, + onPost = { + connectOrbotDialogOpen = false + useProxy.value = true + }, + onError = { + scope.launch { + Toast.makeText( + context, + it, + Toast.LENGTH_LONG, + ) + .show() + } + }, + proxyPort, + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + Box(modifier = Modifier.padding(40.dp, 0.dp, 40.dp, 0.dp)) { + Button( + enabled = acceptedTerms.value, + onClick = { + if (!acceptedTerms.value) { + termsAcceptanceIsRequired = + context.getString(R.string.acceptance_of_terms_is_required) + } + + if (key.value.text.isBlank()) { + errorMessage = context.getString(R.string.key_is_required) + } + + if (acceptedTerms.value && key.value.text.isNotBlank()) { + accountViewModel.login(key.value.text, useProxy.value, proxyPort.value.toInt()) { + errorMessage = context.getString(R.string.invalid_key) + } + } + }, + shape = RoundedCornerShape(Size35dp), + modifier = Modifier.height(50.dp), + ) { + Text( + text = stringResource(R.string.login), + modifier = Modifier.padding(horizontal = 40.dp), + ) + } + } + + if (PackageUtils.isAmberInstalled(context)) { + Box(modifier = Modifier.padding(40.dp, 40.dp, 40.dp, 0.dp)) { + Button( + enabled = acceptedTerms.value, + onClick = { + if (!acceptedTerms.value) { + termsAcceptanceIsRequired = + context.getString(R.string.acceptance_of_terms_is_required) + return@Button + } + + loginWithExternalSigner = true + return@Button + }, + shape = RoundedCornerShape(Size35dp), + modifier = Modifier.height(50.dp), + ) { + Text( + text = stringResource(R.string.login_with_external_signer), + modifier = Modifier.padding(horizontal = 40.dp), ) - ) + } + } + } } + + // The last child is glued to the bottom. + ClickableText( + text = AnnotatedString(stringResource(R.string.generate_a_new_key)), + modifier = Modifier.padding(20.dp).fillMaxWidth(), + onClick = { + if (acceptedTerms.value) { + accountViewModel.newKey(useProxy.value, proxyPort.value.toInt()) + } else { + termsAcceptanceIsRequired = context.getString(R.string.acceptance_of_terms_is_required) + } + }, + style = + TextStyle( + fontSize = Font14SP, + textDecoration = TextDecoration.Underline, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + ), + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt index d51d13d20..7e0d21bb9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.theme import androidx.compose.ui.graphics.Color diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index ee3f51c7d..ddb38ffc6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.theme import androidx.compose.foundation.layout.PaddingValues @@ -15,11 +35,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp -val Shapes = Shapes( +val Shapes = + Shapes( small = RoundedCornerShape(4.dp), medium = RoundedCornerShape(4.dp), - large = RoundedCornerShape(0.dp) -) + large = RoundedCornerShape(0.dp), + ) val RippleRadius45dp = 45.dp // Ripple should be +10.dp over the component size @@ -142,58 +163,55 @@ val VolumeBottomIconSize = Modifier.size(70.dp).padding(10.dp) val PinBottomIconSize = Modifier.size(70.dp).padding(10.dp) val NIP05IconSize = Modifier.size(13.dp).padding(top = 1.dp, start = 1.dp, end = 1.dp) -val EditFieldModifier = Modifier - .padding(start = 10.dp, end = 10.dp, bottom = 10.dp, top = 5.dp) - .fillMaxWidth() -val EditFieldTrailingIconModifier = Modifier - .height(26.dp) - .padding(start = 5.dp, end = 0.dp) -val EditFieldLeadingIconModifier = Modifier - .height(32.dp) - .padding(start = 2.dp) +val EditFieldModifier = + Modifier.padding(start = 10.dp, end = 10.dp, bottom = 10.dp, top = 5.dp).fillMaxWidth() +val EditFieldTrailingIconModifier = Modifier.height(26.dp).padding(start = 5.dp, end = 0.dp) +val EditFieldLeadingIconModifier = Modifier.height(32.dp).padding(start = 2.dp) val ZeroPadding = PaddingValues(0.dp) val FeedPadding = PaddingValues(top = 10.dp, bottom = 10.dp) val ButtonPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp) val ChatPaddingInnerQuoteModifier = Modifier.padding(top = 10.dp, end = 5.dp) -val ChatPaddingModifier = Modifier - .fillMaxWidth(1f) +val ChatPaddingModifier = + Modifier.fillMaxWidth(1f) .padding( - start = 12.dp, - end = 12.dp, - top = 5.dp, - bottom = 5.dp + start = 12.dp, + end = 12.dp, + top = 5.dp, + bottom = 5.dp, ) -val profileContentHeaderModifier = Modifier.fillMaxWidth().padding(top = 70.dp, start = Size25dp, end = Size25dp) +val profileContentHeaderModifier = + Modifier.fillMaxWidth().padding(top = 70.dp, start = Size25dp, end = Size25dp) val bannerModifier = Modifier.fillMaxWidth().height(120.dp) val drawerSpacing = Modifier.padding(top = Size10dp, start = Size25dp, end = Size25dp) val IconRowTextModifier = Modifier.padding(start = 16.dp) -val IconRowModifier = Modifier - .fillMaxWidth() - .padding(vertical = 15.dp, horizontal = 25.dp) +val IconRowModifier = Modifier.fillMaxWidth().padding(vertical = 15.dp, horizontal = 25.dp) val emptyLineItemModifier = Modifier.height(Size75dp).fillMaxWidth() -val normalNoteModifier = Modifier.fillMaxWidth() +val normalNoteModifier = + Modifier.fillMaxWidth() .padding( - start = 12.dp, - end = 12.dp, - top = 0.dp + start = 12.dp, + end = 12.dp, + top = 0.dp, ) -val normalWithTopMarginNoteModifier = Modifier.fillMaxWidth() +val normalWithTopMarginNoteModifier = + Modifier.fillMaxWidth() .padding( - start = 12.dp, - end = 12.dp, - top = 10.dp + start = 12.dp, + end = 12.dp, + top = 10.dp, ) -val boostedNoteModifier = Modifier.fillMaxWidth() +val boostedNoteModifier = + Modifier.fillMaxWidth() .padding( - start = 0.dp, - end = 0.dp, - top = 0.dp + start = 0.dp, + end = 0.dp, + top = 0.dp, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index 7c087bcad..d8fef596f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.theme import android.app.Activity @@ -37,21 +57,23 @@ import com.patrykandpatrick.vico.core.DefaultColors import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel -private val DarkColorPalette = darkColorScheme( +private val DarkColorPalette = + darkColorScheme( primary = Purple200, secondary = Teal200, tertiary = Teal200, background = Color(0xFF000000), surface = Color(0xFF000000), - surfaceVariant = Color(red = 29, green = 26, blue = 34) -) + surfaceVariant = Color(red = 29, green = 26, blue = 34), + ) -private val LightColorPalette = lightColorScheme( +private val LightColorPalette = + lightColorScheme( primary = Purple500, secondary = Teal200, tertiary = Teal200, - surfaceVariant = Color(red = 250, green = 245, blue = 252) -) + surfaceVariant = Color(red = 250, green = 245, blue = 252), + ) private val DarkNewItemBackground = DarkColorPalette.primary.copy(0.12f) private val LightNewItemBackground = LightColorPalette.primary.copy(0.12f) @@ -59,8 +81,10 @@ private val LightNewItemBackground = LightColorPalette.primary.copy(0.12f) private val DarkSelectedNote = DarkNewItemBackground.compositeOver(DarkColorPalette.background) private val LightSelectedNote = LightNewItemBackground.compositeOver(LightColorPalette.background) -private val DarkButtonBackground = DarkColorPalette.primary.copy(alpha = 0.32f).compositeOver(DarkColorPalette.background) -private val LightButtonBackground = LightColorPalette.primary.copy(alpha = 0.32f).compositeOver(LightColorPalette.background) +private val DarkButtonBackground = + DarkColorPalette.primary.copy(alpha = 0.32f).compositeOver(DarkColorPalette.background) +private val LightButtonBackground = + LightColorPalette.primary.copy(alpha = 0.32f).compositeOver(LightColorPalette.background) private val DarkLessImportantLink = DarkColorPalette.primary.copy(alpha = 0.52f) private val LightLessImportantLink = LightColorPalette.primary.copy(alpha = 0.52f) @@ -92,8 +116,10 @@ private val LightSubtleBorder = LightColorPalette.onSurface.copy(alpha = 0.12f) private val DarkReplyItemBackground = DarkColorPalette.onSurface.copy(alpha = 0.05f) private val LightReplyItemBackground = LightColorPalette.onSurface.copy(alpha = 0.05f) -private val DarkZapraiserBackground = BitcoinOrange.copy(0.52f).compositeOver(DarkColorPalette.background) -private val LightZapraiserBackground = BitcoinOrange.copy(0.52f).compositeOver(LightColorPalette.background) +private val DarkZapraiserBackground = + BitcoinOrange.copy(0.52f).compositeOver(DarkColorPalette.background) +private val LightZapraiserBackground = + BitcoinOrange.copy(0.52f).compositeOver(LightColorPalette.background) private val DarkOverPictureBackground = DarkColorPalette.background.copy(0.62f) private val LightOverPictureBackground = LightColorPalette.background.copy(0.62f) @@ -101,296 +127,294 @@ private val LightOverPictureBackground = LightColorPalette.background.copy(0.62f val RepostPictureBorderDark = Modifier.border(2.dp, DarkColorPalette.background, CircleShape) val RepostPictureBorderLight = Modifier.border(2.dp, LightColorPalette.background, CircleShape) -val DarkImageModifier = Modifier +val DarkImageModifier = + Modifier.fillMaxWidth().clip(shape = QuoteBorder).border(1.dp, DarkSubtleBorder, QuoteBorder) + +val LightImageModifier = + Modifier.fillMaxWidth().clip(shape = QuoteBorder).border(1.dp, LightSubtleBorder, QuoteBorder) + +val DarkProfile35dpModifier = Modifier.size(Size35dp).clip(shape = CircleShape) + +val LightProfile35dpModifier = Modifier.fillMaxWidth().clip(shape = CircleShape) + +val DarkReplyBorderModifier = + Modifier.padding(top = 5.dp) .fillMaxWidth() .clip(shape = QuoteBorder) .border(1.dp, DarkSubtleBorder, QuoteBorder) -val LightImageModifier = Modifier +val LightReplyBorderModifier = + Modifier.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp) .fillMaxWidth() .clip(shape = QuoteBorder) .border(1.dp, LightSubtleBorder, QuoteBorder) -val DarkProfile35dpModifier = Modifier - .size(Size35dp) - .clip(shape = CircleShape) - -val LightProfile35dpModifier = Modifier - .fillMaxWidth() - .clip(shape = CircleShape) - -val DarkReplyBorderModifier = Modifier - .padding(top = 5.dp) +val DarkInnerPostBorderModifier = + Modifier.padding(top = 5.dp) .fillMaxWidth() .clip(shape = QuoteBorder) .border(1.dp, DarkSubtleBorder, QuoteBorder) -val LightReplyBorderModifier = Modifier - .padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp) +val LightInnerPostBorderModifier = + Modifier.padding(top = 5.dp) .fillMaxWidth() .clip(shape = QuoteBorder) .border(1.dp, LightSubtleBorder, QuoteBorder) -val DarkInnerPostBorderModifier = Modifier - .padding(top = 5.dp) - .fillMaxWidth() - .clip(shape = QuoteBorder) - .border(1.dp, DarkSubtleBorder, QuoteBorder) - -val LightInnerPostBorderModifier = Modifier - .padding(top = 5.dp) - .fillMaxWidth() - .clip(shape = QuoteBorder) - .border(1.dp, LightSubtleBorder, QuoteBorder) - -val DarkChannelNotePictureModifier = Modifier - .size(30.dp) +val DarkChannelNotePictureModifier = + Modifier.size(30.dp) .clip(shape = CircleShape) .background(DarkColorPalette.background) .border(2.dp, DarkColorPalette.background, CircleShape) -val LightChannelNotePictureModifier = Modifier - .size(30.dp) +val LightChannelNotePictureModifier = + Modifier.size(30.dp) .clip(shape = CircleShape) .background(LightColorPalette.background) .border(2.dp, LightColorPalette.background, CircleShape) -val LightRelayIconModifier = Modifier - .size(Size13dp) - .clip(shape = CircleShape) - .background(LightColorPalette.background) +val LightRelayIconModifier = + Modifier.size(Size13dp).clip(shape = CircleShape).background(LightColorPalette.background) -val DarkRelayIconModifier = Modifier - .size(Size13dp) - .clip(shape = CircleShape) - .background(DarkColorPalette.background) +val DarkRelayIconModifier = + Modifier.size(Size13dp).clip(shape = CircleShape).background(DarkColorPalette.background) -val LightLargeRelayIconModifier = Modifier - .size(Size55dp) - .clip(shape = CircleShape) - .background(LightColorPalette.background) +val LightLargeRelayIconModifier = + Modifier.size(Size55dp).clip(shape = CircleShape).background(LightColorPalette.background) -val DarkLargeRelayIconModifier = Modifier - .size(Size55dp) - .clip(shape = CircleShape) - .background(DarkColorPalette.background) +val DarkLargeRelayIconModifier = + Modifier.size(Size55dp).clip(shape = CircleShape).background(DarkColorPalette.background) val RichTextDefaults = RichTextStyle().resolveDefaults() -val MarkDownStyleOnDark = RichTextDefaults.copy( +val MarkDownStyleOnDark = + RichTextDefaults.copy( paragraphSpacing = DefaultParagraphSpacing, headingStyle = DefaultHeadingStyle, - listStyle = RichTextDefaults.listStyle?.copy( - itemSpacing = 10.sp - ), - codeBlockStyle = RichTextDefaults.codeBlockStyle?.copy( - textStyle = TextStyle( + listStyle = + RichTextDefaults.listStyle?.copy( + itemSpacing = 10.sp, + ), + codeBlockStyle = + RichTextDefaults.codeBlockStyle?.copy( + textStyle = + TextStyle( fontFamily = FontFamily.Monospace, - fontSize = Font14SP - ), - modifier = Modifier - .padding(0.dp) + fontSize = Font14SP, + ), + modifier = + Modifier.padding(0.dp) .fillMaxWidth() .clip(shape = QuoteBorder) .border(1.dp, DarkSubtleBorder, QuoteBorder) - .background(DarkColorPalette.onSurface.copy(alpha = 0.05f)) - ), - stringStyle = RichTextDefaults.stringStyle?.copy( - linkStyle = SpanStyle( - color = DarkColorPalette.primary - ), - codeStyle = SpanStyle( + .background(DarkColorPalette.onSurface.copy(alpha = 0.05f)), + ), + stringStyle = + RichTextDefaults.stringStyle?.copy( + linkStyle = + SpanStyle( + color = DarkColorPalette.primary, + ), + codeStyle = + SpanStyle( fontFamily = FontFamily.Monospace, fontSize = Font14SP, - background = DarkColorPalette.onSurface.copy(alpha = 0.22f) - ) - ) -) + background = DarkColorPalette.onSurface.copy(alpha = 0.22f), + ), + ), + ) -val MarkDownStyleOnLight = RichTextDefaults.copy( +val MarkDownStyleOnLight = + RichTextDefaults.copy( paragraphSpacing = DefaultParagraphSpacing, headingStyle = DefaultHeadingStyle, - listStyle = RichTextDefaults.listStyle?.copy( - itemSpacing = 10.sp - ), - codeBlockStyle = RichTextDefaults.codeBlockStyle?.copy( - textStyle = TextStyle( + listStyle = + RichTextDefaults.listStyle?.copy( + itemSpacing = 10.sp, + ), + codeBlockStyle = + RichTextDefaults.codeBlockStyle?.copy( + textStyle = + TextStyle( fontFamily = FontFamily.Monospace, - fontSize = Font14SP - ), - modifier = Modifier - .padding(0.dp) + fontSize = Font14SP, + ), + modifier = + Modifier.padding(0.dp) .fillMaxWidth() .clip(shape = QuoteBorder) .border(1.dp, LightSubtleBorder, QuoteBorder) - .background(DarkColorPalette.onSurface.copy(alpha = 0.05f)) - ), - stringStyle = RichTextDefaults.stringStyle?.copy( - linkStyle = SpanStyle( - color = LightColorPalette.primary - ), - codeStyle = SpanStyle( + .background(DarkColorPalette.onSurface.copy(alpha = 0.05f)), + ), + stringStyle = + RichTextDefaults.stringStyle?.copy( + linkStyle = + SpanStyle( + color = LightColorPalette.primary, + ), + codeStyle = + SpanStyle( fontFamily = FontFamily.Monospace, fontSize = Font14SP, - background = LightColorPalette.onSurface.copy(alpha = 0.22f) - ) - ) -) + background = LightColorPalette.onSurface.copy(alpha = 0.22f), + ), + ), + ) val ColorScheme.isLight: Boolean - get() = primary == Purple500 + get() = primary == Purple500 val ColorScheme.newItemBackgroundColor: Color - get() = if (isLight) LightNewItemBackground else DarkNewItemBackground + get() = if (isLight) LightNewItemBackground else DarkNewItemBackground val ColorScheme.replyBackground: Color - get() = if (isLight) LightReplyItemBackground else DarkReplyItemBackground + get() = if (isLight) LightReplyItemBackground else DarkReplyItemBackground val ColorScheme.selectedNote: Color - get() = if (isLight) LightSelectedNote else DarkSelectedNote + get() = if (isLight) LightSelectedNote else DarkSelectedNote val ColorScheme.secondaryButtonBackground: Color - get() = if (isLight) LightButtonBackground else DarkButtonBackground + get() = if (isLight) LightButtonBackground else DarkButtonBackground val ColorScheme.lessImportantLink: Color - get() = if (isLight) LightLessImportantLink else DarkLessImportantLink + get() = if (isLight) LightLessImportantLink else DarkLessImportantLink val ColorScheme.zapraiserBackground: Color - get() = if (isLight) LightZapraiserBackground else DarkZapraiserBackground + get() = if (isLight) LightZapraiserBackground else DarkZapraiserBackground val ColorScheme.mediumImportanceLink: Color - get() = if (isLight) LightMediumImportantLink else DarkMediumImportantLink + get() = if (isLight) LightMediumImportantLink else DarkMediumImportantLink val ColorScheme.veryImportantLink: Color - get() = if (isLight) LightVeryImportantLink else DarkVeryImportantLink + get() = if (isLight) LightVeryImportantLink else DarkVeryImportantLink val ColorScheme.placeholderText: Color - get() = if (isLight) LightPlaceholderText else DarkPlaceholderText + get() = if (isLight) LightPlaceholderText else DarkPlaceholderText val ColorScheme.nip05: Color - get() = if (isLight) Nip05EmailColorLight else Nip05EmailColorDark + get() = if (isLight) Nip05EmailColorLight else Nip05EmailColorDark val ColorScheme.placeholderTextColorFilter: ColorFilter - get() = if (isLight) LightPlaceholderTextColorFilter else DarkPlaceholderTextColorFilter + get() = if (isLight) LightPlaceholderTextColorFilter else DarkPlaceholderTextColorFilter val ColorScheme.onBackgroundColorFilter: ColorFilter - get() = if (isLight) LightOnBackgroundColorFilter else DarkOnBackgroundColorFilter + get() = if (isLight) LightOnBackgroundColorFilter else DarkOnBackgroundColorFilter val ColorScheme.grayText: Color - get() = if (isLight) LightGrayText else DarkGrayText + get() = if (isLight) LightGrayText else DarkGrayText val ColorScheme.subtleBorder: Color - get() = if (isLight) LightSubtleBorder else DarkSubtleBorder + get() = if (isLight) LightSubtleBorder else DarkSubtleBorder val ColorScheme.subtleButton: Color - get() = if (isLight) LightSubtleButton else DarkSubtleButton + get() = if (isLight) LightSubtleButton else DarkSubtleButton val ColorScheme.overPictureBackground: Color - get() = if (isLight) LightOverPictureBackground else DarkOverPictureBackground + get() = if (isLight) LightOverPictureBackground else DarkOverPictureBackground val ColorScheme.bitcoinColor: Color - get() = if (isLight) BitcoinLight else BitcoinDark + get() = if (isLight) BitcoinLight else BitcoinDark val ColorScheme.warningColor: Color - get() = if (isLight) LightWarningColor else DarkWarningColor + get() = if (isLight) LightWarningColor else DarkWarningColor val ColorScheme.allGoodColor: Color - get() = if (isLight) LightAllGoodColor else DarkAllGoodColor + get() = if (isLight) LightAllGoodColor else DarkAllGoodColor val ColorScheme.markdownStyle: RichTextStyle - get() = if (isLight) MarkDownStyleOnLight else MarkDownStyleOnDark + get() = if (isLight) MarkDownStyleOnLight else MarkDownStyleOnDark val ColorScheme.repostProfileBorder: Modifier - get() = if (isLight) RepostPictureBorderLight else RepostPictureBorderDark + get() = if (isLight) RepostPictureBorderLight else RepostPictureBorderDark val ColorScheme.imageModifier: Modifier - get() = if (isLight) LightImageModifier else DarkImageModifier + get() = if (isLight) LightImageModifier else DarkImageModifier val ColorScheme.profile35dpModifier: Modifier - get() = if (isLight) LightProfile35dpModifier else DarkProfile35dpModifier + get() = if (isLight) LightProfile35dpModifier else DarkProfile35dpModifier val ColorScheme.replyModifier: Modifier - get() = if (isLight) LightReplyBorderModifier else DarkReplyBorderModifier + get() = if (isLight) LightReplyBorderModifier else DarkReplyBorderModifier val ColorScheme.innerPostModifier: Modifier - get() = if (isLight) LightInnerPostBorderModifier else DarkInnerPostBorderModifier + get() = if (isLight) LightInnerPostBorderModifier else DarkInnerPostBorderModifier val ColorScheme.channelNotePictureModifier: Modifier - get() = if (isLight) LightChannelNotePictureModifier else DarkChannelNotePictureModifier + get() = if (isLight) LightChannelNotePictureModifier else DarkChannelNotePictureModifier val ColorScheme.relayIconModifier: Modifier - get() = if (isLight) LightRelayIconModifier else DarkRelayIconModifier + get() = if (isLight) LightRelayIconModifier else DarkRelayIconModifier val ColorScheme.largeRelayIconModifier: Modifier - get() = if (isLight) LightLargeRelayIconModifier else DarkLargeRelayIconModifier + get() = if (isLight) LightLargeRelayIconModifier else DarkLargeRelayIconModifier val ColorScheme.chartStyle: ChartStyle - get() { - val defaultColors = if (isLight) DefaultColors.Light else DefaultColors.Dark - return ChartStyle.fromColors( - axisLabelColor = Color(defaultColors.axisLabelColor), - axisGuidelineColor = Color(defaultColors.axisGuidelineColor), - axisLineColor = Color(defaultColors.axisLineColor), - entityColors = listOf( - defaultColors.entity1Color, - defaultColors.entity2Color, - defaultColors.entity3Color - ).map(::Color), - elevationOverlayColor = Color(defaultColors.elevationOverlayColor) - ) - } + get() { + val defaultColors = if (isLight) DefaultColors.Light else DefaultColors.Dark + return ChartStyle.fromColors( + axisLabelColor = Color(defaultColors.axisLabelColor), + axisGuidelineColor = Color(defaultColors.axisGuidelineColor), + axisLineColor = Color(defaultColors.axisLineColor), + entityColors = + listOf( + defaultColors.entity1Color, + defaultColors.entity2Color, + defaultColors.entity3Color, + ) + .map(::Color), + elevationOverlayColor = Color(defaultColors.elevationOverlayColor), + ) + } @Composable -fun AmethystTheme(sharedPrefsViewModel: SharedPreferencesViewModel, content: @Composable () -> Unit) { - val darkTheme = when (sharedPrefsViewModel.sharedPrefs.theme) { - ThemeType.DARK -> true - ThemeType.LIGHT -> false - else -> isSystemInDarkTheme() +fun AmethystTheme( + sharedPrefsViewModel: SharedPreferencesViewModel, + content: @Composable () -> Unit, +) { + val darkTheme = + when (sharedPrefsViewModel.sharedPrefs.theme) { + ThemeType.DARK -> true + ThemeType.LIGHT -> false + else -> isSystemInDarkTheme() } - val colors = if (darkTheme) DarkColorPalette else LightColorPalette + val colors = if (darkTheme) DarkColorPalette else LightColorPalette - MaterialTheme( - colorScheme = colors, - typography = Typography, - shapes = Shapes, - content = content - ) + MaterialTheme( + colorScheme = colors, + typography = Typography, + shapes = Shapes, + content = content, + ) - val view = LocalView.current - if (!view.isInEditMode) { - SideEffect { - val window = (view.context as Activity).window - val insetsController = WindowCompat.getInsetsController(window, view) - if (darkTheme) { - window.statusBarColor = colors.background.toArgb() - } else { - window.statusBarColor = colors.primary.toArgb() - } - window.navigationBarColor = colors.background.toArgb() - insetsController.isAppearanceLightNavigationBars = !darkTheme - } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + val insetsController = WindowCompat.getInsetsController(window, view) + if (darkTheme) { + window.statusBarColor = colors.background.toArgb() + } else { + window.statusBarColor = colors.primary.toArgb() + } + window.navigationBarColor = colors.background.toArgb() + insetsController.isAppearanceLightNavigationBars = !darkTheme } + } } @Composable fun ThemeComparison( - onDark: @Composable () -> Unit, - onLight: @Composable () -> Unit + onDark: @Composable () -> Unit, + onLight: @Composable () -> Unit, ) { - Column() { - val darkTheme: SharedPreferencesViewModel = viewModel() - darkTheme.updateTheme(ThemeType.DARK) - AmethystTheme(darkTheme) { - Surface(color = MaterialTheme.colorScheme.background) { - onDark() - } - } + Column { + val darkTheme: SharedPreferencesViewModel = viewModel() + darkTheme.updateTheme(ThemeType.DARK) + AmethystTheme(darkTheme) { Surface(color = MaterialTheme.colorScheme.background) { onDark() } } - val lightTheme: SharedPreferencesViewModel = viewModel() - lightTheme.updateTheme(ThemeType.LIGHT) - AmethystTheme(lightTheme) { - Surface(color = MaterialTheme.colorScheme.background) { - onLight() - } - } + val lightTheme: SharedPreferencesViewModel = viewModel() + lightTheme.updateTheme(ThemeType.LIGHT) + AmethystTheme(lightTheme) { + Surface(color = MaterialTheme.colorScheme.background) { onLight() } } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt index 09747ac3f..9bfc045cb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.theme import androidx.compose.material3.Typography @@ -10,13 +30,15 @@ import androidx.compose.ui.unit.sp import com.halilibo.richtext.ui.HeadingStyle // Set of Material typography styles to start with -val Typography = Typography( - bodyLarge = TextStyle( +val Typography = + Typography( + bodyLarge = + TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, - fontSize = 16.sp - ) - /* Other default text styles to override + fontSize = 16.sp, + ), + /* Other default text styles to override button = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.W500, @@ -27,8 +49,8 @@ val Typography = Typography( fontWeight = FontWeight.Normal, fontSize = 12.sp ) - */ -) + */ + ) val Font12SP = 12.sp val Font14SP = 14.sp @@ -40,29 +62,34 @@ val MarkdownTextStyle = TextStyle(lineHeight = 1.30.em) val DefaultParagraphSpacing: TextUnit = 16.sp internal val DefaultHeadingStyle: HeadingStyle = { level, textStyle -> - when (level) { - 0 -> Typography.displayLarge.copy( - fontSize = 32.sp, - lineHeight = 40.sp - ) - 1 -> Typography.displayMedium.copy( - fontSize = 28.sp, - lineHeight = 36.sp - ) - 2 -> Typography.displaySmall.copy( - fontSize = 24.sp, - lineHeight = 32.sp - ) - 3 -> Typography.headlineLarge.copy( - fontSize = 22.sp, - lineHeight = 26.sp - ) - 4 -> Typography.headlineMedium.copy( - fontSize = 20.sp, - lineHeight = 24.sp - ) - 5 -> Typography.headlineSmall - 6 -> Typography.titleLarge - else -> textStyle - } + when (level) { + 0 -> + Typography.displayLarge.copy( + fontSize = 32.sp, + lineHeight = 40.sp, + ) + 1 -> + Typography.displayMedium.copy( + fontSize = 28.sp, + lineHeight = 36.sp, + ) + 2 -> + Typography.displaySmall.copy( + fontSize = 24.sp, + lineHeight = 32.sp, + ) + 3 -> + Typography.headlineLarge.copy( + fontSize = 22.sp, + lineHeight = 26.sp, + ) + 4 -> + Typography.headlineMedium.copy( + fontSize = 20.sp, + lineHeight = 24.sp, + ) + 5 -> Typography.headlineSmall + 6 -> Typography.titleLarge + else -> textStyle + } } diff --git a/app/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt b/app/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt index ec5e4ceea..76ebba883 100644 --- a/app/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt +++ b/app/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service.lang import android.util.LruCache @@ -18,152 +38,176 @@ import java.util.regex.Pattern @Immutable data class ResultOrError( - val result: String?, - val sourceLang: String?, - val targetLang: String? + val result: String?, + val sourceLang: String?, + val targetLang: String?, ) object LanguageTranslatorService { - var executorService = Executors.newScheduledThreadPool(5) + var executorService = Executors.newScheduledThreadPool(5) - private val options = LanguageIdentificationOptions.Builder().setExecutor(executorService).setConfidenceThreshold(0.6f).build() - private val languageIdentification = LanguageIdentification.getClient(options) - val lnRegex = Pattern.compile("\\blnbc[a-z0-9]+\\b", Pattern.CASE_INSENSITIVE) - val tagRegex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)", Pattern.CASE_INSENSITIVE) + private val options = + LanguageIdentificationOptions.Builder() + .setExecutor(executorService) + .setConfidenceThreshold(0.6f) + .build() + private val languageIdentification = LanguageIdentification.getClient(options) + val lnRegex = Pattern.compile("\\blnbc[a-z0-9]+\\b", Pattern.CASE_INSENSITIVE) + val tagRegex = + Pattern.compile( + "(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)", + Pattern.CASE_INSENSITIVE, + ) - private val translators = - object : LruCache(3) { - override fun create(options: TranslatorOptions): Translator { - return Translation.getClient(options) - } + private val translators = + object : LruCache(3) { + override fun create(options: TranslatorOptions): Translator { + return Translation.getClient(options) + } - override fun entryRemoved( - evicted: Boolean, - key: TranslatorOptions, - oldValue: Translator, - newValue: Translator? - ) { - oldValue.close() - } - } - - fun clear() { - translators.evictAll() + override fun entryRemoved( + evicted: Boolean, + key: TranslatorOptions, + oldValue: Translator, + newValue: Translator?, + ) { + oldValue.close() + } } - fun identifyLanguage(text: String): Task { - return languageIdentification.identifyLanguage(text) + fun clear() { + translators.evictAll() + } + + fun identifyLanguage(text: String): Task { + return languageIdentification.identifyLanguage(text) + } + + fun translate( + text: String, + source: String, + target: String, + ): Task { + checkNotInMainThread() + val sourceLangCode = TranslateLanguage.fromLanguageTag(source) + val targetLangCode = TranslateLanguage.fromLanguageTag(target) + + if (sourceLangCode == null || targetLangCode == null) { + return Tasks.forCanceled() } - fun translate(text: String, source: String, target: String): Task { + val options = + TranslatorOptions.Builder() + .setExecutor(executorService) + .setSourceLanguage(sourceLangCode) + .setTargetLanguage(targetLangCode) + .build() + + val translator = translators[options] + + return translator.downloadModelIfNeeded().onSuccessTask(executorService) { + checkNotInMainThread() + + val tasks = mutableListOf>() + val dict = lnDictionary(text) + urlDictionary(text) + tagDictionary(text) + + for (paragraph in encodeDictionary(text, dict).split("\n")) { + tasks.add(translator.translate(paragraph)) + } + + Tasks.whenAll(tasks).continueWith(executorService) { checkNotInMainThread() - val sourceLangCode = TranslateLanguage.fromLanguageTag(source) - val targetLangCode = TranslateLanguage.fromLanguageTag(target) - if (sourceLangCode == null || targetLangCode == null) { - return Tasks.forCanceled() - } - - val options = TranslatorOptions.Builder() - .setExecutor(executorService) - .setSourceLanguage(sourceLangCode) - .setTargetLanguage(targetLangCode) - .build() - - val translator = translators[options] - - return translator.downloadModelIfNeeded().onSuccessTask(executorService) { - checkNotInMainThread() - - val tasks = mutableListOf>() - val dict = lnDictionary(text) + urlDictionary(text) + tagDictionary(text) - - for (paragraph in encodeDictionary(text, dict).split("\n")) { - tasks.add(translator.translate(paragraph)) - } - - Tasks.whenAll(tasks).continueWith(executorService) { - checkNotInMainThread() - - val results: MutableList = ArrayList() - for (task in tasks) { - val fixedText = task.result.replace("# [", "#[") // fixes tags that always return with a space - results.add(decodeDictionary(fixedText, dict)) - } - ResultOrError(results.joinToString("\n"), source, target) - } + val results: MutableList = ArrayList() + for (task in tasks) { + val fixedText = + task.result.replace("# [", "#[") // fixes tags that always return with a space + results.add(decodeDictionary(fixedText, dict)) } + ResultOrError(results.joinToString("\n"), source, target) + } } + } - private fun encodeDictionary(text: String, dict: Map): String { - var newText = text - for (pair in dict) { - newText = newText.replace(pair.value, pair.key, true) - } - return newText + private fun encodeDictionary( + text: String, + dict: Map, + ): String { + var newText = text + for (pair in dict) { + newText = newText.replace(pair.value, pair.key, true) } + return newText + } - private fun decodeDictionary(text: String, dict: Map): String { - var newText = text - for (pair in dict) { - newText = newText.replace(pair.key, pair.value, true) - } - return newText + private fun decodeDictionary( + text: String, + dict: Map, + ): String { + var newText = text + for (pair in dict) { + newText = newText.replace(pair.key, pair.value, true) } + return newText + } - private fun tagDictionary(text: String): Map { - val matcher = tagRegex.matcher(text) - val returningList = mutableMapOf() - var counter = 0 - while (matcher.find()) { - try { - val tag = matcher.group() - val short = "A$counter" - counter++ - returningList.put(short, tag) - } catch (_: Exception) { - } - } - return returningList + private fun tagDictionary(text: String): Map { + val matcher = tagRegex.matcher(text) + val returningList = mutableMapOf() + var counter = 0 + while (matcher.find()) { + try { + val tag = matcher.group() + val short = "A$counter" + counter++ + returningList.put(short, tag) + } catch (_: Exception) {} } + return returningList + } - private fun lnDictionary(text: String): Map { - val matcher = lnRegex.matcher(text) - val returningList = mutableMapOf() - var counter = 0 - while (matcher.find()) { - try { - val lnInvoice = matcher.group() - val short = "A$counter" - counter++ - returningList.put(short, lnInvoice) - } catch (_: Exception) { - } - } - return returningList + private fun lnDictionary(text: String): Map { + val matcher = lnRegex.matcher(text) + val returningList = mutableMapOf() + var counter = 0 + while (matcher.find()) { + try { + val lnInvoice = matcher.group() + val short = "A$counter" + counter++ + returningList.put(short, lnInvoice) + } catch (_: Exception) {} } + return returningList + } - private fun urlDictionary(text: String): Map { - val parser = UrlDetector(text, UrlDetectorOptions.Default) - val urlsInText = parser.detect() + private fun urlDictionary(text: String): Map { + val parser = UrlDetector(text, UrlDetectorOptions.Default) + val urlsInText = parser.detect() - var counter = 0 + var counter = 0 - return urlsInText.filter { !it.originalUrl.contains("๏ผŒ") && !it.originalUrl.contains("ใ€‚") }.associate { - counter++ - "A$counter" to it.originalUrl - } - } - - fun autoTranslate(text: String, dontTranslateFrom: Set, translateTo: String): Task { - return identifyLanguage(text).onSuccessTask(executorService) { - if (it.equals(translateTo, true)) { - Tasks.forCanceled() - } else if (it != "und" && !dontTranslateFrom.contains(it)) { - translate(text, it, translateTo) - } else { - Tasks.forCanceled() - } - } + return urlsInText + .filter { !it.originalUrl.contains("๏ผŒ") && !it.originalUrl.contains("ใ€‚") } + .associate { + counter++ + "A$counter" to it.originalUrl + } + } + + fun autoTranslate( + text: String, + dontTranslateFrom: Set, + translateTo: String, + ): Task { + return identifyLanguage(text).onSuccessTask(executorService) { + if (it.equals(translateTo, true)) { + Tasks.forCanceled() + } else if (it != "und" && !dontTranslateFrom.contains(it)) { + translate(text, it, translateTo) + } else { + Tasks.forCanceled() + } } + } } diff --git a/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt b/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt index dcd4dce81..456c0d0a5 100644 --- a/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt +++ b/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service.notifications import android.app.NotificationManager @@ -11,67 +31,65 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrC import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateZapChannel import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.GiftWrapEvent +import kotlin.time.measureTimedValue import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch -import kotlin.time.measureTimedValue class PushNotificationReceiverService : FirebaseMessagingService() { - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val eventCache = LruCache(100) + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val eventCache = LruCache(100) - // this is called when a message is received - override fun onMessageReceived(remoteMessage: RemoteMessage) { - Log.d("Time", "Notification received $remoteMessage") - scope.launch(Dispatchers.IO) { - val (value, elapsed) = measureTimedValue { - parseMessage(remoteMessage.data)?.let { - receiveIfNew(it) - } - } - Log.d("Time", "Notification processed in $elapsed") - } + // this is called when a message is received + override fun onMessageReceived(remoteMessage: RemoteMessage) { + Log.d("Time", "Notification received $remoteMessage") + scope.launch(Dispatchers.IO) { + val (value, elapsed) = + measureTimedValue { parseMessage(remoteMessage.data)?.let { receiveIfNew(it) } } + Log.d("Time", "Notification processed in $elapsed") } + } - private suspend fun parseMessage(params: Map): GiftWrapEvent? { - params["encryptedEvent"]?.let { eventStr -> - (Event.fromJson(eventStr) as? GiftWrapEvent)?.let { - return it - } - } - return null + private suspend fun parseMessage(params: Map): GiftWrapEvent? { + params["encryptedEvent"]?.let { eventStr -> + (Event.fromJson(eventStr) as? GiftWrapEvent)?.let { + return it + } } + return null + } - private suspend fun receiveIfNew(event: GiftWrapEvent) { - if (eventCache.get(event.id) == null) { - eventCache.put(event.id, event.id) - EventNotificationConsumer(applicationContext).consume(event) - } + private suspend fun receiveIfNew(event: GiftWrapEvent) { + if (eventCache.get(event.id) == null) { + eventCache.put(event.id, event.id) + EventNotificationConsumer(applicationContext).consume(event) } + } - override fun onCreate() { - super.onCreate() - Log.d("Lifetime Event", "PushNotificationReceiverService.onCreate") + override fun onCreate() { + super.onCreate() + Log.d("Lifetime Event", "PushNotificationReceiverService.onCreate") + } + + override fun onDestroy() { + Log.d("Lifetime Event", "PushNotificationReceiverService.onDestroy") + + scope.cancel() + super.onDestroy() + } + + override fun onNewToken(token: String) { + scope.launch(Dispatchers.IO) { + RegisterAccounts(LocalPreferences.allSavedAccounts()).go(token) + notificationManager().getOrCreateZapChannel(applicationContext) + notificationManager().getOrCreateDMChannel(applicationContext) } + } - override fun onDestroy() { - Log.d("Lifetime Event", "PushNotificationReceiverService.onDestroy") - - scope.cancel() - super.onDestroy() - } - - override fun onNewToken(token: String) { - scope.launch(Dispatchers.IO) { - RegisterAccounts(LocalPreferences.allSavedAccounts()).go(token) - notificationManager().getOrCreateZapChannel(applicationContext) - notificationManager().getOrCreateDMChannel(applicationContext) - } - } - - fun notificationManager(): NotificationManager { - return ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) as NotificationManager - } + fun notificationManager(): NotificationManager { + return ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) + as NotificationManager + } } diff --git a/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt b/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt index c9cee7d9f..2810af689 100644 --- a/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt +++ b/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service.notifications import android.util.Log @@ -7,16 +27,18 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.tasks.await object PushNotificationUtils { - var hasInit: Boolean = false - suspend fun init(accounts: List) = with(Dispatchers.IO) { - if (hasInit) { - return@with - } - // get user notification token provided by firebase - try { - RegisterAccounts(accounts).go(FirebaseMessaging.getInstance().token.await()) - } catch (e: Exception) { - Log.e("Firebase token", "failed to get firebase token", e) - } + var hasInit: Boolean = false + + suspend fun init(accounts: List) = + with(Dispatchers.IO) { + if (hasInit) { + return@with + } + // get user notification token provided by firebase + try { + RegisterAccounts(accounts).go(FirebaseMessaging.getInstance().token.await()) + } catch (e: Exception) { + Log.e("Firebase token", "failed to get firebase token", e) + } } } diff --git a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt index 4409fc683..769e03b9e 100644 --- a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt +++ b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import androidx.compose.runtime.Composable @@ -8,9 +28,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.CheckifItNeedsToRequestNoti @OptIn(ExperimentalPermissionsApi::class) @Composable fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesViewModel) { - CheckifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel) + CheckifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel) } @Composable -fun PushNotificationSettingsRow(sharedPreferencesViewModel: SharedPreferencesViewModel) { -} +fun PushNotificationSettingsRow(sharedPreferencesViewModel: SharedPreferencesViewModel) {} diff --git a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index b3d4e63a2..a95266ef4 100644 --- a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.ui.components import android.content.res.Resources @@ -42,307 +62,316 @@ import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.quartz.events.ImmutableListOfLists +import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import java.util.Locale @Composable fun TranslatableRichTextViewer( - content: String, - canPreview: Boolean, - modifier: Modifier = Modifier, - tags: ImmutableListOfLists, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + content: String, + canPreview: Boolean, + modifier: Modifier = Modifier, + tags: ImmutableListOfLists, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var translatedTextState by remember(content) { - mutableStateOf(TranslationConfig(content, null, null, false)) - } + var translatedTextState by + remember(content) { mutableStateOf(TranslationConfig(content, null, null, false)) } - TranslateAndWatchLanguageChanges(content, accountViewModel) { result -> - if (!translatedTextState.result.equals(result.result, true) || - translatedTextState.sourceLang != result.sourceLang || - translatedTextState.targetLang != result.targetLang - ) { - translatedTextState = result - } + TranslateAndWatchLanguageChanges(content, accountViewModel) { result -> + if ( + !translatedTextState.result.equals(result.result, true) || + translatedTextState.sourceLang != result.sourceLang || + translatedTextState.targetLang != result.targetLang + ) { + translatedTextState = result } + } - Crossfade(targetState = translatedTextState) { - RenderText( - it, - content, - canPreview, - modifier, - tags, - backgroundColor, - accountViewModel, - nav - ) - } + Crossfade(targetState = translatedTextState) { + RenderText( + it, + content, + canPreview, + modifier, + tags, + backgroundColor, + accountViewModel, + nav, + ) + } } @Composable private fun RenderText( - translatedTextState: TranslationConfig, - content: String, - canPreview: Boolean, - modifier: Modifier, - tags: ImmutableListOfLists, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit + translatedTextState: TranslationConfig, + content: String, + canPreview: Boolean, + modifier: Modifier, + tags: ImmutableListOfLists, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { - var showOriginal by remember(translatedTextState) { mutableStateOf(translatedTextState.showOriginal) } + var showOriginal by + remember(translatedTextState) { mutableStateOf(translatedTextState.showOriginal) } - val toBeViewed by remember(translatedTextState) { - derivedStateOf { - if (showOriginal) content else translatedTextState.result ?: content - } + val toBeViewed by + remember(translatedTextState) { + derivedStateOf { if (showOriginal) content else translatedTextState.result ?: content } } - Column { - ExpandableRichTextViewer( - toBeViewed, - canPreview, - modifier, - tags, - backgroundColor, - accountViewModel, - nav - ) + Column { + ExpandableRichTextViewer( + toBeViewed, + canPreview, + modifier, + tags, + backgroundColor, + accountViewModel, + nav, + ) - if (translatedTextState.sourceLang != null && - translatedTextState.targetLang != null && - translatedTextState.sourceLang != translatedTextState.targetLang - ) { - TranslationMessage( - translatedTextState.sourceLang, - translatedTextState.targetLang, - accountViewModel - ) { - showOriginal = it - } - } + if ( + translatedTextState.sourceLang != null && + translatedTextState.targetLang != null && + translatedTextState.sourceLang != translatedTextState.targetLang + ) { + TranslationMessage( + translatedTextState.sourceLang, + translatedTextState.targetLang, + accountViewModel, + ) { + showOriginal = it + } } + } } @Composable private fun TranslationMessage( - source: String, - target: String, - accountViewModel: AccountViewModel, - onChangeWhatToShow: (Boolean) -> Unit + source: String, + target: String, + accountViewModel: AccountViewModel, + onChangeWhatToShow: (Boolean) -> Unit, ) { - var langSettingsPopupExpanded by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() + var langSettingsPopupExpanded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 5.dp) - ) { - val clickableTextStyle = - SpanStyle(color = MaterialTheme.colorScheme.lessImportantLink) + Row( + modifier = Modifier.fillMaxWidth().padding(top = 5.dp), + ) { + val clickableTextStyle = SpanStyle(color = MaterialTheme.colorScheme.lessImportantLink) - val annotatedTranslationString = buildAnnotatedString { - withStyle(clickableTextStyle) { - pushStringAnnotation("langSettings", true.toString()) - append(stringResource(R.string.translations_auto)) - } + val annotatedTranslationString = buildAnnotatedString { + withStyle(clickableTextStyle) { + pushStringAnnotation("langSettings", true.toString()) + append(stringResource(R.string.translations_auto)) + } - append("-${stringResource(R.string.translations_translated_from)} ") + append("-${stringResource(R.string.translations_translated_from)} ") - withStyle(clickableTextStyle) { - pushStringAnnotation("showOriginal", true.toString()) - append(Locale(source).displayName) - } + withStyle(clickableTextStyle) { + pushStringAnnotation("showOriginal", true.toString()) + append(Locale(source).displayName) + } - append(" ${stringResource(R.string.translations_to)} ") + append(" ${stringResource(R.string.translations_to)} ") - withStyle(clickableTextStyle) { - pushStringAnnotation("showOriginal", false.toString()) - append(Locale(target).displayName) - } - } - - ClickableText( - text = annotatedTranslationString, - style = LocalTextStyle.current.copy( - color = MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f - ) - ), - overflow = TextOverflow.Visible, - maxLines = 3 - ) { spanOffset -> - annotatedTranslationString.getStringAnnotations(spanOffset, spanOffset) - .firstOrNull() - ?.also { span -> - if (span.tag == "showOriginal") { - onChangeWhatToShow(span.item.toBoolean()) - } else { - langSettingsPopupExpanded = !langSettingsPopupExpanded - } - } - } - - DropdownMenu( - expanded = langSettingsPopupExpanded, - onDismissRequest = { langSettingsPopupExpanded = false } - ) { - DropdownMenuItem( - text = { - if (source in accountViewModel.account.dontTranslateFrom) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(24.dp) - ) - } else { - Spacer(modifier = Modifier.size(24.dp)) - } - - Spacer(modifier = Modifier.size(10.dp)) - - Text( - stringResource( - R.string.translations_never_translate_from_lang, - Locale(source).displayName - ) - ) - }, - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.dontTranslateFrom(source) - langSettingsPopupExpanded = false - } - } - ) - Divider() - DropdownMenuItem( - text = { - if (accountViewModel.account.preferenceBetween(source, target) == source) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(24.dp) - ) - } else { - Spacer(modifier = Modifier.size(24.dp)) - } - - Spacer(modifier = Modifier.size(10.dp)) - - Text( - stringResource( - R.string.translations_show_in_lang_first, - Locale(source).displayName - ) - ) - }, - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.prefer(source, target, source) - langSettingsPopupExpanded = false - } - } - ) - DropdownMenuItem( - text = { - if (accountViewModel.account.preferenceBetween(source, target) == target) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(24.dp) - ) - } else { - Spacer(modifier = Modifier.size(24.dp)) - } - - Spacer(modifier = Modifier.size(10.dp)) - - Text( - stringResource( - R.string.translations_show_in_lang_first, - Locale(target).displayName - ) - ) - }, - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.prefer(source, target, target) - langSettingsPopupExpanded = false - } - } - ) - Divider() - - val languageList = - ConfigurationCompat.getLocales(Resources.getSystem().configuration) - for (i in 0 until languageList.size()) { - languageList.get(i)?.let { lang -> - DropdownMenuItem( - text = { - if (lang.language in accountViewModel.account.translateTo) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(24.dp) - ) - } else { - Spacer(modifier = Modifier.size(24.dp)) - } - - Spacer(modifier = Modifier.size(10.dp)) - - Text( - stringResource( - R.string.translations_always_translate_to_lang, - lang.displayName - ) - ) - }, - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.translateTo(lang) - langSettingsPopupExpanded = false - } - } - ) - } - } - } + withStyle(clickableTextStyle) { + pushStringAnnotation("showOriginal", false.toString()) + append(Locale(target).displayName) + } } + + ClickableText( + text = annotatedTranslationString, + style = + LocalTextStyle.current.copy( + color = + MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.32f, + ), + ), + overflow = TextOverflow.Visible, + maxLines = 3, + ) { spanOffset -> + annotatedTranslationString.getStringAnnotations(spanOffset, spanOffset).firstOrNull()?.also { + span -> + if (span.tag == "showOriginal") { + onChangeWhatToShow(span.item.toBoolean()) + } else { + langSettingsPopupExpanded = !langSettingsPopupExpanded + } + } + } + + DropdownMenu( + expanded = langSettingsPopupExpanded, + onDismissRequest = { langSettingsPopupExpanded = false }, + ) { + DropdownMenuItem( + text = { + if (source in accountViewModel.account.dontTranslateFrom) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + } else { + Spacer(modifier = Modifier.size(24.dp)) + } + + Spacer(modifier = Modifier.size(10.dp)) + + Text( + stringResource( + R.string.translations_never_translate_from_lang, + Locale(source).displayName, + ), + ) + }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.dontTranslateFrom(source) + langSettingsPopupExpanded = false + } + }, + ) + Divider() + DropdownMenuItem( + text = { + if (accountViewModel.account.preferenceBetween(source, target) == source) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + } else { + Spacer(modifier = Modifier.size(24.dp)) + } + + Spacer(modifier = Modifier.size(10.dp)) + + Text( + stringResource( + R.string.translations_show_in_lang_first, + Locale(source).displayName, + ), + ) + }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.prefer(source, target, source) + langSettingsPopupExpanded = false + } + }, + ) + DropdownMenuItem( + text = { + if (accountViewModel.account.preferenceBetween(source, target) == target) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + } else { + Spacer(modifier = Modifier.size(24.dp)) + } + + Spacer(modifier = Modifier.size(10.dp)) + + Text( + stringResource( + R.string.translations_show_in_lang_first, + Locale(target).displayName, + ), + ) + }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.prefer(source, target, target) + langSettingsPopupExpanded = false + } + }, + ) + Divider() + + val languageList = ConfigurationCompat.getLocales(Resources.getSystem().configuration) + for (i in 0 until languageList.size()) { + languageList.get(i)?.let { lang -> + DropdownMenuItem( + text = { + if (lang.language in accountViewModel.account.translateTo) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + } else { + Spacer(modifier = Modifier.size(24.dp)) + } + + Spacer(modifier = Modifier.size(10.dp)) + + Text( + stringResource( + R.string.translations_always_translate_to_lang, + lang.displayName, + ), + ) + }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.translateTo(lang) + langSettingsPopupExpanded = false + } + }, + ) + } + } + } + } } @Composable -fun TranslateAndWatchLanguageChanges(content: String, accountViewModel: AccountViewModel, onTranslated: (TranslationConfig) -> Unit) { - val accountState by accountViewModel.accountLanguagesLiveData.observeAsState() +fun TranslateAndWatchLanguageChanges( + content: String, + accountViewModel: AccountViewModel, + onTranslated: (TranslationConfig) -> Unit, +) { + val accountState by accountViewModel.accountLanguagesLiveData.observeAsState() - LaunchedEffect(accountState) { - // This takes some time. Launches as a Composition scope to make sure this gets cancel if this item gets out of view. - launch(Dispatchers.IO) { - LanguageTranslatorService.autoTranslate( - content, - accountViewModel.account.dontTranslateFrom, - accountViewModel.account.translateTo - ).addOnCompleteListener { task -> - if (task.isSuccessful && !content.equals(task.result.result, true)) { - if (task.result.sourceLang != null && task.result.targetLang != null) { - val preference = accountViewModel.account.preferenceBetween(task.result.sourceLang!!, task.result.targetLang!!) - val newConfig = TranslationConfig( - result = task.result.result, - sourceLang = task.result.sourceLang, - targetLang = task.result.targetLang, - showOriginal = preference == task.result.sourceLang - ) + LaunchedEffect(accountState) { + // This takes some time. Launches as a Composition scope to make sure this gets cancel if this + // item gets out of view. + launch(Dispatchers.IO) { + LanguageTranslatorService.autoTranslate( + content, + accountViewModel.account.dontTranslateFrom, + accountViewModel.account.translateTo, + ) + .addOnCompleteListener { task -> + if (task.isSuccessful && !content.equals(task.result.result, true)) { + if (task.result.sourceLang != null && task.result.targetLang != null) { + val preference = + accountViewModel.account.preferenceBetween( + task.result.sourceLang!!, + task.result.targetLang!!, + ) + val newConfig = + TranslationConfig( + result = task.result.result, + sourceLang = task.result.sourceLang, + targetLang = task.result.targetLang, + showOriginal = preference == task.result.sourceLang, + ) - onTranslated(newConfig) - } - } + onTranslated(newConfig) } + } } } + } } diff --git a/app/src/test/java/com/vitorpamplona/amethyst/CharsetTest.kt b/app/src/test/java/com/vitorpamplona/amethyst/CharsetTest.kt index 37bd5e10a..47c67de5c 100644 --- a/app/src/test/java/com/vitorpamplona/amethyst/CharsetTest.kt +++ b/app/src/test/java/com/vitorpamplona/amethyst/CharsetTest.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst import com.vitorpamplona.amethyst.service.firstFullChar @@ -5,63 +25,79 @@ import org.junit.Assert import org.junit.Test class CharsetTest { - @Test - fun testASCIIChar() { - Assert.assertEquals("H", "Hi".firstFullChar()) - } + @Test + fun testASCIIChar() { + Assert.assertEquals("H", "Hi".firstFullChar()) + } - @Test - fun testUTF16JoinChar() { - Assert.assertEquals("\uD83C\uDF48", "\uD83C\uDF48Hi".firstFullChar()) - } + @Test + fun testUTF16JoinChar() { + Assert.assertEquals("\uD83C\uDF48", "\uD83C\uDF48Hi".firstFullChar()) + } - @Test - fun testUTF32JoinChar() { - Assert.assertEquals("\uD83E\uDDD1\uD83C\uDFFE", "\uD83E\uDDD1\uD83C\uDFFEHi".firstFullChar()) - } + @Test + fun testUTF32JoinChar() { + Assert.assertEquals("\uD83E\uDDD1\uD83C\uDFFE", "\uD83E\uDDD1\uD83C\uDFFEHi".firstFullChar()) + } - @Test - fun testUTF32JoinChar2() { - Assert.assertEquals("\uD83E\uDDD1\uD83C\uDFFE", "\uD83E\uDDD1\uD83C\uDFFEHi".firstFullChar()) - } + @Test + fun testUTF32JoinChar2() { + Assert.assertEquals("\uD83E\uDDD1\uD83C\uDFFE", "\uD83E\uDDD1\uD83C\uDFFEHi".firstFullChar()) + } - @Test - fun testAsciiWithUTF32Char() { - Assert.assertEquals("H", "Hi\uD83E\uDDD1\uD83C\uDFFEHi".firstFullChar()) - } + @Test + fun testAsciiWithUTF32Char() { + Assert.assertEquals("H", "Hi\uD83E\uDDD1\uD83C\uDFFEHi".firstFullChar()) + } - @Test - fun testBlank() { - Assert.assertEquals("", "".firstFullChar()) - } + @Test + fun testBlank() { + Assert.assertEquals("", "".firstFullChar()) + } - @Test - fun testSpecialChars() { - Assert.assertEquals("=", "=x".firstFullChar()) - } + @Test + fun testSpecialChars() { + Assert.assertEquals("=", "=x".firstFullChar()) + } - @Test - fun test5CharEmoji() { - Assert.assertEquals("\uD83D\uDC68\u200D\uD83D\uDCBB", "\uD83D\uDC68\u200D\uD83D\uDCBBadsfasdf".firstFullChar()) - } + @Test + fun test5CharEmoji() { + Assert.assertEquals( + "\uD83D\uDC68\u200D\uD83D\uDCBB", + "\uD83D\uDC68\u200D\uD83D\uDCBBadsfasdf".firstFullChar(), + ) + } - @Test - fun testFamily() { - Assert.assertEquals("\uD83D\uDC68\u200d\uD83D\uDC69\u200d\uD83D\uDC67\u200d\uD83D\uDC67", "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC67adsfasdf".firstFullChar()) - } + @Test + fun testFamily() { + Assert.assertEquals( + "\uD83D\uDC68\u200d\uD83D\uDC69\u200d\uD83D\uDC67\u200d\uD83D\uDC67", + "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC67adsfasdf".firstFullChar(), + ) + } - @Test - fun testTeacher() { - Assert.assertEquals("\uD83E\uDDD1\uD83C\uDFFF\u200D\uD83C\uDFEB", "\uD83E\uDDD1\uD83C\uDFFF\u200D\uD83C\uDFEBasdf".firstFullChar()) - } + @Test + fun testTeacher() { + Assert.assertEquals( + "\uD83E\uDDD1\uD83C\uDFFF\u200D\uD83C\uDFEB", + "\uD83E\uDDD1\uD83C\uDFFF\u200D\uD83C\uDFEBasdf".firstFullChar(), + ) + } - @Test - fun testVariation() { - Assert.assertEquals("\uD83D\uDC68\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68", "\uD83D\uDC68\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68ddd".firstFullChar()) - } + @Test + fun testVariation() { + Assert.assertEquals( + "\uD83D\uDC68\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68", + "\uD83D\uDC68\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68ddd".firstFullChar(), + ) + } - @Test - fun testMultipleEmoji() { - Assert.assertEquals("\uD83E\uDEC2\uD83E\uDEC2", "\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2".firstFullChar()) - } + @Test + fun testMultipleEmoji() { + Assert.assertEquals( + "\uD83E\uDEC2\uD83E\uDEC2", + "\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2\uD83E\uDEC2" + .firstFullChar(), + ) + } } diff --git a/app/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt b/app/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt index 68c28a198..00ef2ad85 100644 --- a/app/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt +++ b/app/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst import com.vitorpamplona.amethyst.model.Note @@ -14,97 +34,156 @@ import org.junit.Test * See [testing documentation](http://d.android.com/tools/testing). */ class NewMessageTaggerKeyParseTest { - val dao: Dao = object : Dao { - override suspend fun getOrCreateUser(hex: String): User { - return User(hex) - } + val dao: Dao = + object : Dao { + override suspend fun getOrCreateUser(hex: String): User { + return User(hex) + } - override suspend fun getOrCreateNote(hex: String): Note { - return Note(hex) - } + override suspend fun getOrCreateNote(hex: String): Note { + return Note(hex) + } - override suspend fun checkGetOrCreateAddressableNote(hex: String): Note? { - return Note(hex) - } + override suspend fun checkGetOrCreateAddressableNote(hex: String): Note? { + return Note(hex) + } } - @Test - fun keyParseTestNote() { - val result = NewMessageTagger(message = "", dao = dao).parseDirtyWordForKey("note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn") - assertEquals(Nip19.Type.NOTE, result?.key?.type) - assertEquals("1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", result?.key?.hex) - assertEquals("", result?.restOfWord) - } + @Test + fun keyParseTestNote() { + val result = + NewMessageTagger(message = "", dao = dao) + .parseDirtyWordForKey("note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn") + assertEquals(Nip19.Type.NOTE, result?.key?.type) + assertEquals( + "1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", + result?.key?.hex, + ) + assertEquals("", result?.restOfWord) + } - @Test - fun keyParseTestPub() { - val result = NewMessageTagger(message = "", dao = dao).parseDirtyWordForKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z") - assertEquals(Nip19.Type.USER, result?.key?.type) - assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.key?.hex) - assertEquals("", result?.restOfWord) - } + @Test + fun keyParseTestPub() { + val result = + NewMessageTagger(message = "", dao = dao) + .parseDirtyWordForKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z") + assertEquals(Nip19.Type.USER, result?.key?.type) + assertEquals( + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + result?.key?.hex, + ) + assertEquals("", result?.restOfWord) + } - @Test - fun keyParseTestNoteWithExtraChars() { - val result = NewMessageTagger(message = "", dao = dao).parseDirtyWordForKey("note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,") - assertEquals(Nip19.Type.NOTE, result?.key?.type) - assertEquals("1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", result?.key?.hex) - assertEquals(",", result?.restOfWord) - } + @Test + fun keyParseTestNoteWithExtraChars() { + val result = + NewMessageTagger(message = "", dao = dao) + .parseDirtyWordForKey("note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,") + assertEquals(Nip19.Type.NOTE, result?.key?.type) + assertEquals( + "1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", + result?.key?.hex, + ) + assertEquals(",", result?.restOfWord) + } - @Test - fun keyParseTestPubWithExtraChars() { - val result = NewMessageTagger(message = "", dao = dao).parseDirtyWordForKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,") - assertEquals(Nip19.Type.USER, result?.key?.type) - assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.key?.hex) - assertEquals(",", result?.restOfWord) - } + @Test + fun keyParseTestPubWithExtraChars() { + val result = + NewMessageTagger(message = "", dao = dao) + .parseDirtyWordForKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,") + assertEquals(Nip19.Type.USER, result?.key?.type) + assertEquals( + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + result?.key?.hex, + ) + assertEquals(",", result?.restOfWord) + } - @Test - fun keyParseTestNoteWithExtraCharsAndAt() { - val result = NewMessageTagger(message = "", dao = dao).parseDirtyWordForKey("@note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,") - assertEquals(Nip19.Type.NOTE, result?.key?.type) - assertEquals("1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", result?.key?.hex) - assertEquals(",", result?.restOfWord) - } + @Test + fun keyParseTestNoteWithExtraCharsAndAt() { + val result = + NewMessageTagger(message = "", dao = dao) + .parseDirtyWordForKey("@note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,") + assertEquals(Nip19.Type.NOTE, result?.key?.type) + assertEquals( + "1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", + result?.key?.hex, + ) + assertEquals(",", result?.restOfWord) + } - @Test - fun keyParseTestPubWithExtraCharsAndAt() { - val result = NewMessageTagger(message = "", dao = dao).parseDirtyWordForKey("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,") - assertEquals(Nip19.Type.USER, result?.key?.type) - assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.key?.hex) - assertEquals(",", result?.restOfWord) - } + @Test + fun keyParseTestPubWithExtraCharsAndAt() { + val result = + NewMessageTagger(message = "", dao = dao) + .parseDirtyWordForKey("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,") + assertEquals(Nip19.Type.USER, result?.key?.type) + assertEquals( + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + result?.key?.hex, + ) + assertEquals(",", result?.restOfWord) + } - @Test - fun keyParseTestNoteWithExtraCharsAndNostrPrefix() { - val result = NewMessageTagger(message = "", dao = dao).parseDirtyWordForKey("nostr:note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,") - assertEquals(Nip19.Type.NOTE, result?.key?.type) - assertEquals("1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", result?.key?.hex) - assertEquals(",", result?.restOfWord) - } + @Test + fun keyParseTestNoteWithExtraCharsAndNostrPrefix() { + val result = + NewMessageTagger(message = "", dao = dao) + .parseDirtyWordForKey( + "nostr:note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,", + ) + assertEquals(Nip19.Type.NOTE, result?.key?.type) + assertEquals( + "1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", + result?.key?.hex, + ) + assertEquals(",", result?.restOfWord) + } - @Test - fun keyParseTestPubWithExtraCharsAndNostrPrefix() { - val result = NewMessageTagger(message = "", dao = dao).parseDirtyWordForKey("nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,") - assertEquals(Nip19.Type.USER, result?.key?.type) - assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.key?.hex) - assertEquals(",", result?.restOfWord) - } + @Test + fun keyParseTestPubWithExtraCharsAndNostrPrefix() { + val result = + NewMessageTagger(message = "", dao = dao) + .parseDirtyWordForKey( + "nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,", + ) + assertEquals(Nip19.Type.USER, result?.key?.type) + assertEquals( + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + result?.key?.hex, + ) + assertEquals(",", result?.restOfWord) + } - @Test - fun keyParseTestUppercaseNoteWithExtraCharsAndNostrPrefix() { - val result = NewMessageTagger(message = "", dao = dao).parseDirtyWordForKey("Nostr:note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,") - assertEquals(Nip19.Type.NOTE, result?.key?.type) - assertEquals("1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", result?.key?.hex) - assertEquals(",", result?.restOfWord) - } + @Test + fun keyParseTestUppercaseNoteWithExtraCharsAndNostrPrefix() { + val result = + NewMessageTagger(message = "", dao = dao) + .parseDirtyWordForKey( + "Nostr:note1z5e2m0smx6d7e2d0zaq8d3rnd7httm6j0uf8tf90yqqjrs842czshwtkmn,", + ) + assertEquals(Nip19.Type.NOTE, result?.key?.type) + assertEquals( + "1532adbe1b369beca9af174076c4736faeb5ef527f1275a4af200121c0f55605", + result?.key?.hex, + ) + assertEquals(",", result?.restOfWord) + } - @Test - fun keyParseTestUppercasePubWithExtraCharsAndNostrPrefix() { - val result = NewMessageTagger(message = "", dao = dao).parseDirtyWordForKey("nOstr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,") - assertEquals(Nip19.Type.USER, result?.key?.type) - assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.key?.hex) - assertEquals(",", result?.restOfWord) - } + @Test + fun keyParseTestUppercasePubWithExtraCharsAndNostrPrefix() { + val result = + NewMessageTagger(message = "", dao = dao) + .parseDirtyWordForKey( + "nOstr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z,", + ) + assertEquals(Nip19.Type.USER, result?.key?.type) + assertEquals( + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + result?.key?.hex, + ) + assertEquals(",", result?.restOfWord) + } } diff --git a/app/src/test/java/com/vitorpamplona/amethyst/SplitterTest.kt b/app/src/test/java/com/vitorpamplona/amethyst/SplitterTest.kt index 9c6275d48..9f5f6bee3 100644 --- a/app/src/test/java/com/vitorpamplona/amethyst/SplitterTest.kt +++ b/app/src/test/java/com/vitorpamplona/amethyst/SplitterTest.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst import android.os.Looper @@ -17,97 +37,96 @@ import org.junit.Before import org.junit.Test class SplitterTest { - @SpyK - var mySplit = Split() + @SpyK var mySplit = Split() - @Before - fun setUp() { - mockkStatic(Looper::class) - every { Looper.myLooper() } returns mockk() - every { Looper.getMainLooper() } returns mockk() - MockKAnnotations.init(this) - } + @Before + fun setUp() { + mockkStatic(Looper::class) + every { Looper.myLooper() } returns mockk() + every { Looper.getMainLooper() } returns mockk() + MockKAnnotations.init(this) + } - @After - fun tearDown() { - unmockkAll() - } + @After + fun tearDown() { + unmockkAll() + } - @Test - fun testSplit() = runBlocking { - val vitor = mySplit.addItem("Vitor") + @Test + fun testSplit() = runBlocking { + val vitor = mySplit.addItem("Vitor") - assertEquals(1f, mySplit.items[vitor].percentage, 0.01f) - assertTrue(mySplit.isEqualSplit()) + assertEquals(1f, mySplit.items[vitor].percentage, 0.01f) + assertTrue(mySplit.isEqualSplit()) - val pablo = mySplit.addItem("Pablo") + val pablo = mySplit.addItem("Pablo") - assertEquals(0.5f, mySplit.items[pablo].percentage, 0.01f) - assertEquals(0.5f, mySplit.items[vitor].percentage, 0.01f) - assertTrue(mySplit.isEqualSplit()) + assertEquals(0.5f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(0.5f, mySplit.items[vitor].percentage, 0.01f) + assertTrue(mySplit.isEqualSplit()) - val gigi = mySplit.addItem("Gigi") + val gigi = mySplit.addItem("Gigi") - assertEquals(0.33f, mySplit.items[vitor].percentage, 0.01f) - assertEquals(0.33f, mySplit.items[pablo].percentage, 0.01f) - assertEquals(0.33f, mySplit.items[gigi].percentage, 0.01f) - assertTrue(mySplit.isEqualSplit()) + assertEquals(0.33f, mySplit.items[vitor].percentage, 0.01f) + assertEquals(0.33f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(0.33f, mySplit.items[gigi].percentage, 0.01f) + assertTrue(mySplit.isEqualSplit()) - mySplit.updatePercentage(vitor, 0.5f) + mySplit.updatePercentage(vitor, 0.5f) - assertEquals(0.5f, mySplit.items[vitor].percentage, 0.01f) - assertEquals(0.33f, mySplit.items[pablo].percentage, 0.01f) - assertEquals(0.16f, mySplit.items[gigi].percentage, 0.01f) - assertFalse(mySplit.isEqualSplit()) + assertEquals(0.5f, mySplit.items[vitor].percentage, 0.01f) + assertEquals(0.33f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(0.16f, mySplit.items[gigi].percentage, 0.01f) + assertFalse(mySplit.isEqualSplit()) - mySplit.updatePercentage(vitor, 0.95f) + mySplit.updatePercentage(vitor, 0.95f) - assertEquals(0.95f, mySplit.items[vitor].percentage, 0.01f) - assertEquals(0.05f, mySplit.items[pablo].percentage, 0.01f) - assertEquals(0.0f, mySplit.items[gigi].percentage, 0.01f) - assertFalse(mySplit.isEqualSplit()) + assertEquals(0.95f, mySplit.items[vitor].percentage, 0.01f) + assertEquals(0.05f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(0.0f, mySplit.items[gigi].percentage, 0.01f) + assertFalse(mySplit.isEqualSplit()) - mySplit.updatePercentage(vitor, 0.15f) + mySplit.updatePercentage(vitor, 0.15f) - assertEquals(0.15f, mySplit.items[vitor].percentage, 0.01f) - assertEquals(0.05f, mySplit.items[pablo].percentage, 0.01f) - assertEquals(0.80f, mySplit.items[gigi].percentage, 0.01f) - assertFalse(mySplit.isEqualSplit()) + assertEquals(0.15f, mySplit.items[vitor].percentage, 0.01f) + assertEquals(0.05f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(0.80f, mySplit.items[gigi].percentage, 0.01f) + assertFalse(mySplit.isEqualSplit()) - mySplit.updatePercentage(pablo, 0.95f) + mySplit.updatePercentage(pablo, 0.95f) - assertEquals(0.05f, mySplit.items[vitor].percentage, 0.01f) - assertEquals(0.95f, mySplit.items[pablo].percentage, 0.01f) - assertEquals(0.00f, mySplit.items[gigi].percentage, 0.01f) + assertEquals(0.05f, mySplit.items[vitor].percentage, 0.01f) + assertEquals(0.95f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(0.00f, mySplit.items[gigi].percentage, 0.01f) - mySplit.updatePercentage(gigi, 1f) + mySplit.updatePercentage(gigi, 1f) - assertEquals(0.00f, mySplit.items[vitor].percentage, 0.01f) - assertEquals(0.00f, mySplit.items[pablo].percentage, 0.01f) - assertEquals(1.00f, mySplit.items[gigi].percentage, 0.01f) + assertEquals(0.00f, mySplit.items[vitor].percentage, 0.01f) + assertEquals(0.00f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(1.00f, mySplit.items[gigi].percentage, 0.01f) - mySplit.updatePercentage(vitor, 0.5f) + mySplit.updatePercentage(vitor, 0.5f) - assertEquals(0.50f, mySplit.items[vitor].percentage, 0.01f) - assertEquals(0.00f, mySplit.items[pablo].percentage, 0.01f) - assertEquals(0.50f, mySplit.items[gigi].percentage, 0.01f) + assertEquals(0.50f, mySplit.items[vitor].percentage, 0.01f) + assertEquals(0.00f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(0.50f, mySplit.items[gigi].percentage, 0.01f) - mySplit.updatePercentage(pablo, 0.3f) + mySplit.updatePercentage(pablo, 0.3f) - assertEquals(0.50f, mySplit.items[vitor].percentage, 0.01f) - assertEquals(0.30f, mySplit.items[pablo].percentage, 0.01f) - assertEquals(0.20f, mySplit.items[gigi].percentage, 0.01f) + assertEquals(0.50f, mySplit.items[vitor].percentage, 0.01f) + assertEquals(0.30f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(0.20f, mySplit.items[gigi].percentage, 0.01f) - mySplit.updatePercentage(gigi, 1f) + mySplit.updatePercentage(gigi, 1f) - assertEquals(0.00f, mySplit.items[vitor].percentage, 0.01f) - assertEquals(0.00f, mySplit.items[pablo].percentage, 0.01f) - assertEquals(1.00f, mySplit.items[gigi].percentage, 0.01f) + assertEquals(0.00f, mySplit.items[vitor].percentage, 0.01f) + assertEquals(0.00f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(1.00f, mySplit.items[gigi].percentage, 0.01f) - mySplit.updatePercentage(gigi, 0.5f) + mySplit.updatePercentage(gigi, 0.5f) - assertEquals(0.00f, mySplit.items[vitor].percentage, 0.01f) - assertEquals(0.50f, mySplit.items[pablo].percentage, 0.01f) - assertEquals(0.50f, mySplit.items[gigi].percentage, 0.01f) - } + assertEquals(0.00f, mySplit.items[vitor].percentage, 0.01f) + assertEquals(0.50f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(0.50f, mySplit.items[gigi].percentage, 0.01f) + } } diff --git a/app/src/test/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifierTest.kt b/app/src/test/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifierTest.kt index 54ad5873d..0303bcd8a 100644 --- a/app/src/test/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifierTest.kt +++ b/app/src/test/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifierTest.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import android.os.Looper @@ -17,115 +37,108 @@ import org.junit.Before import org.junit.Test class Nip05NostrAddressVerifierTest { + companion object { private val ALL_UPPER_CASE_USER_NAME = "ONETWO" private val ALL_LOWER_CASE_USER_NAME = "onetwo" + } - @SpyK - var nip05Verifier = Nip05NostrAddressVerifier() + @SpyK var nip05Verifier = Nip05NostrAddressVerifier() - @Before - fun setUp() { - mockkStatic(Looper::class) - every { Looper.myLooper() } returns mockk() - every { Looper.getMainLooper() } returns mockk() - MockKAnnotations.init(this) - } + @Before + fun setUp() { + mockkStatic(Looper::class) + every { Looper.myLooper() } returns mockk() + every { Looper.getMainLooper() } returns mockk() + MockKAnnotations.init(this) + } - @Test - fun `test with matching case on user name`() = runBlocking { - // Set-up - val userNameToTest = ALL_UPPER_CASE_USER_NAME - val expectedPubKey = "ca29c211f1c72d5b6622268ff43d2288ea2b2cb5b9aa196ff9f1704fc914b71b" + @Test + fun `test with matching case on user name`() = runBlocking { + // Set-up + val userNameToTest = ALL_UPPER_CASE_USER_NAME + val expectedPubKey = "ca29c211f1c72d5b6622268ff43d2288ea2b2cb5b9aa196ff9f1704fc914b71b" - val nostrJson = "{\n" + - " \"names\": {\n" + - " \"$userNameToTest\": \"$expectedPubKey\" \n" + - " }\n" + - "}" + val nostrJson = + "{\n" + " \"names\": {\n" + " \"$userNameToTest\": \"$expectedPubKey\" \n" + " }\n" + "}" - coEvery { nip05Verifier.fetchNip05Json(any(), any(), any()) } answers { - secondArg<(String) -> Unit>().invoke(nostrJson) - } + coEvery { nip05Verifier.fetchNip05Json(any(), any(), any()) } answers + { + secondArg<(String) -> Unit>().invoke(nostrJson) + } - val nip05 = "$userNameToTest@domain.com" - var actualPubkeyHex = "" + val nip05 = "$userNameToTest@domain.com" + var actualPubkeyHex = "" - // Execution - nip05Verifier.verifyNip05( - nip05, - onSuccess = { - actualPubkeyHex = it - }, - onError = { - fail("Test failure") - } - ) + // Execution + nip05Verifier.verifyNip05( + nip05, + onSuccess = { actualPubkeyHex = it }, + onError = { fail("Test failure") }, + ) - // Verification - assertEquals(expectedPubKey, actualPubkeyHex) - } + // Verification + assertEquals(expectedPubKey, actualPubkeyHex) + } - @Test - fun `test with NOT matching case on user name`() = runBlocking { - // Set-up - val expectedPubKey = "ca29c211f1c72d5b6622268ff43d2288ea2b2cb5b9aa196ff9f1704fc914b71b" + @Test + fun `test with NOT matching case on user name`() = runBlocking { + // Set-up + val expectedPubKey = "ca29c211f1c72d5b6622268ff43d2288ea2b2cb5b9aa196ff9f1704fc914b71b" - val nostrJson = "{\n" + - " \"names\": {\n" + - " \"$ALL_UPPER_CASE_USER_NAME\": \"$expectedPubKey\" \n" + - " }\n" + - "}" - coEvery { nip05Verifier.fetchNip05Json(any(), any(), any()) } answers { - secondArg<(String) -> Unit>().invoke(nostrJson) - } + val nostrJson = + "{\n" + + " \"names\": {\n" + + " \"$ALL_UPPER_CASE_USER_NAME\": \"$expectedPubKey\" \n" + + " }\n" + + "}" + coEvery { nip05Verifier.fetchNip05Json(any(), any(), any()) } answers + { + secondArg<(String) -> Unit>().invoke(nostrJson) + } - val nip05 = "$ALL_LOWER_CASE_USER_NAME@domain.com" - var actualPubkeyHex = "" + val nip05 = "$ALL_LOWER_CASE_USER_NAME@domain.com" + var actualPubkeyHex = "" - // Execution - nip05Verifier.verifyNip05( - nip05, - onSuccess = { - actualPubkeyHex = it - }, - onError = { - fail("Test failure") - } - ) + // Execution + nip05Verifier.verifyNip05( + nip05, + onSuccess = { actualPubkeyHex = it }, + onError = { fail("Test failure") }, + ) - // Verification - assertEquals(expectedPubKey, actualPubkeyHex) - } + // Verification + assertEquals(expectedPubKey, actualPubkeyHex) + } - @After - fun tearDown() { - unmockkAll() - } + @After + fun tearDown() { + unmockkAll() + } - @Test - fun `execute assemble url with invalid value returns null`() { - // given - val nip05address = "this@that@that.com" + @Test + fun `execute assemble url with invalid value returns null`() { + // given + val nip05address = "this@that@that.com" - // when - val actualValue = nip05Verifier.assembleUrl(nip05address) + // when + val actualValue = nip05Verifier.assembleUrl(nip05address) - // then - assertNull(actualValue) - } + // then + assertNull(actualValue) + } - @Test - fun `execute assemble url with valid value returns nip05 url`() { - // given - val userName = "TheUser" - val domain = "domain.com" - val nip05address = "$userName@$domain" - val expectedValue = "https://$domain/.well-known/nostr.json?name=$userName" + @Test + fun `execute assemble url with valid value returns nip05 url`() { + // given + val userName = "TheUser" + val domain = "domain.com" + val nip05address = "$userName@$domain" + val expectedValue = "https://$domain/.well-known/nostr.json?name=$userName" - // when - val actualValue = nip05Verifier.assembleUrl(nip05address) + // when + val actualValue = nip05Verifier.assembleUrl(nip05address) - // then - assertEquals(expectedValue, actualValue) - } + // then + assertEquals(expectedValue, actualValue) + } } diff --git a/app/src/test/java/com/vitorpamplona/amethyst/service/Nip30Test.kt b/app/src/test/java/com/vitorpamplona/amethyst/service/Nip30Test.kt index 45f2ad4f9..d36f73566 100644 --- a/app/src/test/java/com/vitorpamplona/amethyst/service/Nip30Test.kt +++ b/app/src/test/java/com/vitorpamplona/amethyst/service/Nip30Test.kt @@ -1,58 +1,78 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import junit.framework.TestCase.assertEquals import org.junit.Test class Nip30Test { - @Test() - fun parseEmoji() { - val input = "Alex Gleason :soapbox:" + @Test() + fun parseEmoji() { + val input = "Alex Gleason :soapbox:" - assertEquals( - listOf("Alex Gleason ", ":soapbox:", ""), - Nip30CustomEmoji().buildArray(input) - ) - } + assertEquals( + listOf("Alex Gleason ", ":soapbox:", ""), + Nip30CustomEmoji().buildArray(input), + ) + } - @Test() - fun parseEmojiInverted() { - val input = ":soapbox:Alex Gleason" + @Test() + fun parseEmojiInverted() { + val input = ":soapbox:Alex Gleason" - assertEquals( - listOf("", ":soapbox:", "Alex Gleason"), - Nip30CustomEmoji().buildArray(input) - ) - } + assertEquals( + listOf("", ":soapbox:", "Alex Gleason"), + Nip30CustomEmoji().buildArray(input), + ) + } - @Test() - fun parseEmoji2() { - val input = "Hello :gleasonator: \uD83D\uDE02 :ablobcatrainbow: :disputed: yolo" + @Test() + fun parseEmoji2() { + val input = "Hello :gleasonator: \uD83D\uDE02 :ablobcatrainbow: :disputed: yolo" - assertEquals( - listOf("Hello ", ":gleasonator:", " ๐Ÿ˜‚ ", ":ablobcatrainbow:", " ", ":disputed:", " yolo"), - Nip30CustomEmoji().buildArray(input) - ) + assertEquals( + listOf("Hello ", ":gleasonator:", " ๐Ÿ˜‚ ", ":ablobcatrainbow:", " ", ":disputed:", " yolo"), + Nip30CustomEmoji().buildArray(input), + ) - println(Nip30CustomEmoji().buildArray(input).joinToString(",")) - } + println(Nip30CustomEmoji().buildArray(input).joinToString(",")) + } - @Test() - fun parseEmoji3() { - val input = "hello vitor: how can I help:" + @Test() + fun parseEmoji3() { + val input = "hello vitor: how can I help:" - assertEquals( - listOf("hello vitor: how can I help:"), - Nip30CustomEmoji().buildArray(input) - ) - } + assertEquals( + listOf("hello vitor: how can I help:"), + Nip30CustomEmoji().buildArray(input), + ) + } - @Test() - fun parseJapanese() { - val input = "\uD883\uDEDE\uD883\uDEDE้บบใฎ:x30EDE:ใ€‚:\uD883\uDEDE:(Violation of NIP-30)" + @Test() + fun parseJapanese() { + val input = "\uD883\uDEDE\uD883\uDEDE้บบใฎ:x30EDE:ใ€‚:\uD883\uDEDE:(Violation of NIP-30)" - assertEquals( - listOf("\uD883\uDEDE\uD883\uDEDE้บบใฎ", ":x30EDE:", "ใ€‚:\uD883\uDEDE:(Violation of NIP-30)"), - Nip30CustomEmoji().buildArray(input) - ) - } + assertEquals( + listOf("\uD883\uDEDE\uD883\uDEDE้บบใฎ", ":x30EDE:", "ใ€‚:\uD883\uDEDE:(Violation of NIP-30)"), + Nip30CustomEmoji().buildArray(input), + ) + } } diff --git a/app/src/test/java/com/vitorpamplona/amethyst/service/Nip96Test.kt b/app/src/test/java/com/vitorpamplona/amethyst/service/Nip96Test.kt index 62648457c..dbe29dd65 100644 --- a/app/src/test/java/com/vitorpamplona/amethyst/service/Nip96Test.kt +++ b/app/src/test/java/com/vitorpamplona/amethyst/service/Nip96Test.kt @@ -1,11 +1,31 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service import junit.framework.TestCase.assertEquals import org.junit.Test class Nip96Test { - - val json = """ + val json = + """ { "api_url": "https://nostr.build/api/v2/nip96/upload", "download_url": "https://media.nostr.build", @@ -92,28 +112,29 @@ class Nip96Test { } } } - """.trimIndent() + """ + .trimIndent() - @Test() - fun parseNostrBuild() { - val info = Nip96Retriever().parse(json) + @Test() + fun parseNostrBuild() { + val info = Nip96Retriever().parse(json) - assertEquals("https://nostr.build/api/v2/nip96/upload", info.apiUrl) - assertEquals("https://media.nostr.build", info.downloadUrl) - assertEquals(listOf(94, 96, 98), info.supportedNips) - assertEquals("https://nostr.build/tos/", info.tosUrl) - assertEquals(listOf("image/*", "video/*", "audio/*"), info.contentTypes) + assertEquals("https://nostr.build/api/v2/nip96/upload", info.apiUrl) + assertEquals("https://media.nostr.build", info.downloadUrl) + assertEquals(listOf(94, 96, 98), info.supportedNips) + assertEquals("https://nostr.build/tos/", info.tosUrl) + assertEquals(listOf("image/*", "video/*", "audio/*"), info.contentTypes) - assertEquals(listOf("creator", "free", "professional"), info.plans.keys.sorted()) + assertEquals(listOf("creator", "free", "professional"), info.plans.keys.sorted()) - assertEquals("Free", info.plans["free"]?.name) - assertEquals(true, info.plans["free"]?.isNip98Required) - assertEquals("https://nostr.build", info.plans["free"]?.url) - assertEquals(26214400L, info.plans["free"]?.maxByteSize) - assertEquals(listOf(0, 0), info.plans["free"]?.fileExpiration) - assertEquals(listOf("image", "video"), info.plans["free"]?.mediaTransformations?.keys?.sorted()) + assertEquals("Free", info.plans["free"]?.name) + assertEquals(true, info.plans["free"]?.isNip98Required) + assertEquals("https://nostr.build", info.plans["free"]?.url) + assertEquals(26214400L, info.plans["free"]?.maxByteSize) + assertEquals(listOf(0, 0), info.plans["free"]?.fileExpiration) + assertEquals(listOf("image", "video"), info.plans["free"]?.mediaTransformations?.keys?.sorted()) - assertEquals(26843545600L, info.plans["creator"]?.maxByteSize) - assertEquals(10737418240L, info.plans["professional"]?.maxByteSize) - } + assertEquals(26843545600L, info.plans["creator"]?.maxByteSize) + assertEquals(10737418240L, info.plans["professional"]?.maxByteSize) + } } diff --git a/app/src/test/java/com/vitorpamplona/amethyst/service/zaps/UserZapsTest.kt b/app/src/test/java/com/vitorpamplona/amethyst/service/zaps/UserZapsTest.kt index e65681862..7aaf15d73 100644 --- a/app/src/test/java/com/vitorpamplona/amethyst/service/zaps/UserZapsTest.kt +++ b/app/src/test/java/com/vitorpamplona/amethyst/service/zaps/UserZapsTest.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.service.zaps import com.vitorpamplona.amethyst.model.Note @@ -6,50 +26,54 @@ import com.vitorpamplona.quartz.events.LnZapEventInterface import com.vitorpamplona.quartz.events.zaps.UserZaps import io.mockk.every import io.mockk.mockk +import java.math.BigDecimal import org.junit.Assert import org.junit.Test -import java.math.BigDecimal class UserZapsTest { - @Test - fun nothing() { - Assert.assertEquals(1, 1) - } + @Test + fun nothing() { + Assert.assertEquals(1, 1) + } - @Test - fun user_without_zaps() { - val actual = UserZaps.forProfileFeed(zaps = null) + @Test + fun user_without_zaps() { + val actual = UserZaps.forProfileFeed(zaps = null) - Assert.assertEquals(emptyList>(), actual) - } + Assert.assertEquals(emptyList>(), actual) + } - @Test - fun avoid_duplicates_with_same_zap_request() { - val zapRequest = mockk() + @Test + fun avoid_duplicates_with_same_zap_request() { + val zapRequest = mockk() - val zaps: Map = mapOf( - zapRequest to mockZapNoteWith("user-1", amount = 100), - zapRequest to mockZapNoteWith("user-1", amount = 200) - ) + val zaps: Map = + mapOf( + zapRequest to mockZapNoteWith("user-1", amount = 100), + zapRequest to mockZapNoteWith("user-1", amount = 200), + ) - val actual = UserZaps.forProfileFeed(zaps) + val actual = UserZaps.forProfileFeed(zaps) - Assert.assertEquals(1, actual.count()) - Assert.assertEquals(zapRequest, actual.first().zapRequest) - Assert.assertEquals( - BigDecimal(200), - (actual.first().zapEvent.event as LnZapEventInterface).amount() - ) - } + Assert.assertEquals(1, actual.count()) + Assert.assertEquals(zapRequest, actual.first().zapRequest) + Assert.assertEquals( + BigDecimal(200), + (actual.first().zapEvent.event as LnZapEventInterface).amount(), + ) + } - private fun mockZapNoteWith(pubkey: HexKey, amount: Int): Note { - val lnZapEvent = mockk() - every { lnZapEvent.amount() } returns amount.toBigDecimal() - every { lnZapEvent.pubKey() } returns pubkey + private fun mockZapNoteWith( + pubkey: HexKey, + amount: Int, + ): Note { + val lnZapEvent = mockk() + every { lnZapEvent.amount() } returns amount.toBigDecimal() + every { lnZapEvent.pubKey() } returns pubkey - val zapNote = mockk() - every { zapNote.event } returns lnZapEvent + val zapNote = mockk() + every { zapNote.event } returns lnZapEvent - return zapNote - } + return zapNote + } } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/BechBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/BechBenchmark.kt index c332fa76c..717065257 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/BechBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/BechBenchmark.kt @@ -1,13 +1,31 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.benchmark import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.encoders.Bech32 import com.vitorpamplona.quartz.encoders.Hex import com.vitorpamplona.quartz.encoders.bechToBytes import com.vitorpamplona.quartz.encoders.toNpub -import com.vitorpamplona.quartz.events.Event import junit.framework.TestCase.assertEquals import org.junit.Rule import org.junit.Test @@ -15,25 +33,58 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class BechBenchmark { + @get:Rule val benchmarkRule = BenchmarkRule() - @get:Rule - val benchmarkRule = BenchmarkRule() - - @Test - fun npubEncoding() { - val myUser = Hex.decode("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") - benchmarkRule.measureRepeated { - assertEquals("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",myUser.toNpub()) - } + @Test + fun npubEncoding() { + val myUser = Hex.decode("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") + benchmarkRule.measureRepeated { + assertEquals( + "npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", + myUser.toNpub(), + ) } + } - @Test - fun npubDecoding() { - val myUser = "npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z" - val expected = listOf(70, 12, 37, -26, -126, -3, -89, -125, 43, 82, -47, -14, 45, 61, 34, -77, 23, 109, -105, 47, 96, -36, -36, 50, 18, -19, -116, -110, -17, -123, 6, 92).map { it.toByte() } - benchmarkRule.measureRepeated { - assertEquals(expected, myUser.bechToBytes().toList()) - } - } - -} \ No newline at end of file + @Test + fun npubDecoding() { + val myUser = "npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z" + val expected = + listOf( + 70, + 12, + 37, + -26, + -126, + -3, + -89, + -125, + 43, + 82, + -47, + -14, + 45, + 61, + 34, + -77, + 23, + 109, + -105, + 47, + 96, + -36, + -36, + 50, + 18, + -19, + -116, + -110, + -17, + -123, + 6, + 92, + ) + .map { it.toByte() } + benchmarkRule.measureRepeated { assertEquals(expected, myUser.bechToBytes().toList()) } + } +} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/ContainsBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/ContainsBenchmark.kt index b6c75451e..75063b172 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/ContainsBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/ContainsBenchmark.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.benchmark import androidx.benchmark.junit4.BenchmarkRule @@ -5,7 +25,6 @@ import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.containsAny -import com.vitorpamplona.quartz.utils.containsIgnoreCase import junit.framework.TestCase.assertTrue import org.junit.Rule import org.junit.Test @@ -13,85 +32,71 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class ContainsBenchmark { - @get:Rule - val benchmarkRule = BenchmarkRule() + @get:Rule val benchmarkRule = BenchmarkRule() - private val test = """Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of "de Finibus Bonorum et Malorum" (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, "Lorem ipsum dolor sit amet..", comes from a line in section 1.10.32. + private val test = + """Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of "de Finibus Bonorum et Malorum" (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, "Lorem ipsum dolor sit amet..", comes from a line in section 1.10.32. The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from "de Finibus Bonorum et Malorum" by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham. -""".intern() +""" + .intern() - val atTheMiddle = DualCase("Lorem Ipsum".lowercase(), "Lorem Ipsum".uppercase()) - val atTheBeginning = DualCase("contrAry".lowercase(), "contrAry".uppercase()) + val atTheMiddle = DualCase("Lorem Ipsum".lowercase(), "Lorem Ipsum".uppercase()) + val atTheBeginning = DualCase("contrAry".lowercase(), "contrAry".uppercase()) - val atTheEndCase = DualCase("h. rackham".lowercase(), "h. rackham".uppercase()) + val atTheEndCase = DualCase("h. rackham".lowercase(), "h. rackham".uppercase()) - val lastCase = listOf( - DualCase("my mom".lowercase(), "my mom".uppercase()), - DualCase("my dad".lowercase(), "my dad".uppercase()), - DualCase("h. rackham".lowercase(), "h. rackham".uppercase()) + val lastCase = + listOf( + DualCase("my mom".lowercase(), "my mom".uppercase()), + DualCase("my dad".lowercase(), "my dad".uppercase()), + DualCase("h. rackham".lowercase(), "h. rackham".uppercase()), ) - @Test - fun middleCaseKotlin() { - benchmarkRule.measureRepeated { - assertTrue(test.contains(atTheMiddle.lowercase, true)) - } - } + @Test + fun middleCaseKotlin() { + benchmarkRule.measureRepeated { assertTrue(test.contains(atTheMiddle.lowercase, true)) } + } - @Test - fun middleCaseOurs() { - val list = listOf(atTheMiddle) - benchmarkRule.measureRepeated { - assertTrue(test.containsAny(list)) - } - } + @Test + fun middleCaseOurs() { + val list = listOf(atTheMiddle) + benchmarkRule.measureRepeated { assertTrue(test.containsAny(list)) } + } - @Test - fun atTheBeginningKotlin() { - benchmarkRule.measureRepeated { - assertTrue(test.contains(atTheBeginning.lowercase, true)) - } - } + @Test + fun atTheBeginningKotlin() { + benchmarkRule.measureRepeated { assertTrue(test.contains(atTheBeginning.lowercase, true)) } + } - @Test - fun atTheBeginningOurs() { - val list = listOf(atTheBeginning) - benchmarkRule.measureRepeated { - assertTrue(test.containsAny(list)) - } - } + @Test + fun atTheBeginningOurs() { + val list = listOf(atTheBeginning) + benchmarkRule.measureRepeated { assertTrue(test.containsAny(list)) } + } - @Test - fun atTheEndKotlin() { - benchmarkRule.measureRepeated { - assertTrue(test.contains(atTheEndCase.lowercase, true)) - } - } + @Test + fun atTheEndKotlin() { + benchmarkRule.measureRepeated { assertTrue(test.contains(atTheEndCase.lowercase, true)) } + } - @Test - fun atTheEndOurs() { - val list = listOf(atTheEndCase) - benchmarkRule.measureRepeated { - assertTrue(test.containsAny(list)) - } - } + @Test + fun atTheEndOurs() { + val list = listOf(atTheEndCase) + benchmarkRule.measureRepeated { assertTrue(test.containsAny(list)) } + } - @Test - fun theLastAtTheEndKotlin() { - benchmarkRule.measureRepeated { - assertTrue( - lastCase.any { - test.contains(it.lowercase, true) - } - ) - } + @Test + fun theLastAtTheEndKotlin() { + benchmarkRule.measureRepeated { + assertTrue( + lastCase.any { test.contains(it.lowercase, true) }, + ) } + } - @Test - fun theLastAtTheEndOurs() { - benchmarkRule.measureRepeated { - assertTrue(test.containsAny(lastCase)) - } - } -} \ No newline at end of file + @Test + fun theLastAtTheEndOurs() { + benchmarkRule.measureRepeated { assertTrue(test.containsAny(lastCase)) } + } +} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/CryptoBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/CryptoBenchmark.kt index 4c719cf1a..0e00b526b 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/CryptoBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/CryptoBenchmark.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.benchmark import androidx.benchmark.junit4.BenchmarkRule @@ -12,85 +32,76 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class CryptoBenchmark { + @get:Rule val benchmarkRule = BenchmarkRule() - @get:Rule - val benchmarkRule = BenchmarkRule() + @Test + fun getSharedKeyNip04() { + val keyPair1 = KeyPair() + val keyPair2 = KeyPair() - @Test - fun getSharedKeyNip04() { - val keyPair1 = KeyPair() - val keyPair2 = KeyPair() - - benchmarkRule.measureRepeated { - assertNotNull(CryptoUtils.getSharedSecretNIP04(keyPair1.privKey!!, keyPair2.pubKey)) - } + benchmarkRule.measureRepeated { + assertNotNull(CryptoUtils.getSharedSecretNIP04(keyPair1.privKey!!, keyPair2.pubKey)) } + } - @Test - fun getSharedKeyNip44() { - val keyPair1 = KeyPair() - val keyPair2 = KeyPair() + @Test + fun getSharedKeyNip44() { + val keyPair1 = KeyPair() + val keyPair2 = KeyPair() - benchmarkRule.measureRepeated { - assertNotNull(CryptoUtils.getSharedSecretNIP44v1(keyPair1.privKey!!, keyPair2.pubKey)) - } + benchmarkRule.measureRepeated { + assertNotNull(CryptoUtils.getSharedSecretNIP44v1(keyPair1.privKey!!, keyPair2.pubKey)) } + } - @Test - fun computeSharedKeyNip04() { - val keyPair1 = KeyPair() - val keyPair2 = KeyPair() + @Test + fun computeSharedKeyNip04() { + val keyPair1 = KeyPair() + val keyPair2 = KeyPair() - benchmarkRule.measureRepeated { - assertNotNull(CryptoUtils.computeSharedSecretNIP04(keyPair1.privKey!!, keyPair2.pubKey)) - } + benchmarkRule.measureRepeated { + assertNotNull(CryptoUtils.computeSharedSecretNIP04(keyPair1.privKey!!, keyPair2.pubKey)) } + } - @Test - fun computeSharedKeyNip44() { - val keyPair1 = KeyPair() - val keyPair2 = KeyPair() + @Test + fun computeSharedKeyNip44() { + val keyPair1 = KeyPair() + val keyPair2 = KeyPair() - benchmarkRule.measureRepeated { - assertNotNull(CryptoUtils.computeSharedSecretNIP44v1(keyPair1.privKey!!, keyPair2.pubKey)) - } + benchmarkRule.measureRepeated { + assertNotNull(CryptoUtils.computeSharedSecretNIP44v1(keyPair1.privKey!!, keyPair2.pubKey)) } + } - @Test - fun random() { - benchmarkRule.measureRepeated { - assertNotNull(CryptoUtils.random(1000)) - } + @Test + fun random() { + benchmarkRule.measureRepeated { assertNotNull(CryptoUtils.random(1000)) } + } + + @Test + fun sha256() { + val keyPair = KeyPair() + + benchmarkRule.measureRepeated { assertNotNull(CryptoUtils.sha256(keyPair.pubKey)) } + } + + @Test + fun sign() { + val keyPair = KeyPair() + val msg = CryptoUtils.sha256(CryptoUtils.random(1000)) + + benchmarkRule.measureRepeated { assertNotNull(CryptoUtils.sign(msg, keyPair.privKey!!)) } + } + + @Test + fun verify() { + val keyPair = KeyPair() + val msg = CryptoUtils.sha256(CryptoUtils.random(1000)) + val signature = CryptoUtils.sign(msg, keyPair.privKey!!) + + benchmarkRule.measureRepeated { + assertNotNull(CryptoUtils.verifySignature(signature, msg, keyPair.pubKey)) } - - @Test - fun sha256() { - val keyPair = KeyPair() - - benchmarkRule.measureRepeated { - assertNotNull(CryptoUtils.sha256(keyPair.pubKey)) - } - } - - @Test - fun sign() { - val keyPair = KeyPair() - val msg = CryptoUtils.sha256(CryptoUtils.random(1000)) - - benchmarkRule.measureRepeated { - assertNotNull(CryptoUtils.sign(msg, keyPair.privKey!!)) - } - } - - @Test - fun verify() { - val keyPair = KeyPair() - val msg = CryptoUtils.sha256(CryptoUtils.random(1000)) - val signature = CryptoUtils.sign(msg, keyPair.privKey!!) - - benchmarkRule.measureRepeated { - assertNotNull(CryptoUtils.verifySignature(signature, msg, keyPair.pubKey)) - } - } - -} \ No newline at end of file + } +} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/EventBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/EventBenchmark.kt index 7960b9947..cba07b542 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/EventBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/EventBenchmark.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.benchmark import androidx.benchmark.junit4.BenchmarkRule @@ -14,15 +34,16 @@ import org.junit.runner.RunWith /** * Benchmark, which will execute on an Android device. * - * The body of [BenchmarkRule.measureRepeated] is measured in a loop, and Studio will - * output the result. Modify your code to see how it affects performance. + * The body of [BenchmarkRule.measureRepeated] is measured in a loop, and Studio will output the + * result. Modify your code to see how it affects performance. */ @RunWith(AndroidJUnit4::class) class EventBenchmark { + val payload1 = + "[\"EVENT\",\"40b9\",{\"id\":\"48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf\",\"kind\":1,\"pubkey\":\"3d842afecd5e293f28b6627933704a3fb8ce153aa91d790ab11f6a752d44a42d\",\"created_at\":1677940007,\"content\":\"I got asked about follower count again today. Why does my follower count go down when I delete public relays (in our list) and replace them with filter.nostr.wine? \\n\\nIโ€™ll give you one final explanation to rule them all. First, letโ€™s go over how clients calculate your follower count.\\n\\n1. Your client sends a request to all your connected relays asking for accounts who follow you\\n2. Relays answer back with the events requested\\n3. The client aggregates the event total and displays it\\n\\nEach relay has a set limit on how many stored events it will return per request. For some relays itโ€™s 500, others 1000, some as high as 5000. Letโ€™s say for simplicity that all your public relays use 500 as their limit. If you ask 10 relays for your followers the max possible answer you can get is 5000. That wonโ€™t change if you have 20,000 followers or 100,000. You may get back a โ€œdifferentโ€ 5000 each time, but youโ€™ll still cap out at 5000 because that is the most events your client will receive.\u2028\u2028Our limit on filter.nostr.wine is 2000 events. If you replace 10 public relays with only filter.nostr.wine, the MOST followers you will ever get back from our filter relay is 2000. That doesnโ€™t mean you only have 2000 followers or that your reach is reduced in any way.\\n\\nAs long as you are writing to and reading from the same public relays, neither your reach nor any content was lost. That concludes my TED talk. I hope you all have a fantastic day and weekend.\",\"tags\":[],\"sig\":\"dcaf8ab98bb9179017b35bd814092850d1062b26c263dff89fb1ae8c019a324139d1729012d9d05ff0a517f76b1117d869b2cc7d36bea8aa5f4b94c5e2548aa8\"}]" - val payload1 = "[\"EVENT\",\"40b9\",{\"id\":\"48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf\",\"kind\":1,\"pubkey\":\"3d842afecd5e293f28b6627933704a3fb8ce153aa91d790ab11f6a752d44a42d\",\"created_at\":1677940007,\"content\":\"I got asked about follower count again today. Why does my follower count go down when I delete public relays (in our list) and replace them with filter.nostr.wine? \\n\\nIโ€™ll give you one final explanation to rule them all. First, letโ€™s go over how clients calculate your follower count.\\n\\n1. Your client sends a request to all your connected relays asking for accounts who follow you\\n2. Relays answer back with the events requested\\n3. The client aggregates the event total and displays it\\n\\nEach relay has a set limit on how many stored events it will return per request. For some relays itโ€™s 500, others 1000, some as high as 5000. Letโ€™s say for simplicity that all your public relays use 500 as their limit. If you ask 10 relays for your followers the max possible answer you can get is 5000. That wonโ€™t change if you have 20,000 followers or 100,000. You may get back a โ€œdifferentโ€ 5000 each time, but youโ€™ll still cap out at 5000 because that is the most events your client will receive.\u2028\u2028Our limit on filter.nostr.wine is 2000 events. If you replace 10 public relays with only filter.nostr.wine, the MOST followers you will ever get back from our filter relay is 2000. That doesnโ€™t mean you only have 2000 followers or that your reach is reduced in any way.\\n\\nAs long as you are writing to and reading from the same public relays, neither your reach nor any content was lost. That concludes my TED talk. I hope you all have a fantastic day and weekend.\",\"tags\":[],\"sig\":\"dcaf8ab98bb9179017b35bd814092850d1062b26c263dff89fb1ae8c019a324139d1729012d9d05ff0a517f76b1117d869b2cc7d36bea8aa5f4b94c5e2548aa8\"}]" - - val payload2 = """ + val payload2 = + """ { "content": "Astral:\n\nhttps://void.cat/d/A5Fba5B1bcxwEmeyoD9nBs.webp\n\nIris:\n\nhttps://void.cat/d/44hTcVvhRps6xYYs99QsqA.webp\n\nSnort:\n\nhttps://void.cat/d/4nJD5TRePuQChM5tzteYbU.webp\n\nAmethyst agrees with Astral which I suspect are both wrong. nostr:npub13sx6fp3pxq5rl70x0kyfmunyzaa9pzt5utltjm0p8xqyafndv95q3saapa nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49 nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z ", "created_at": 1683596206, @@ -49,74 +70,66 @@ class EventBenchmark { } """ - @get:Rule - val benchmarkRule = BenchmarkRule() + @get:Rule val benchmarkRule = BenchmarkRule() - @Test - fun parseREQString() { - benchmarkRule.measureRepeated { - Event.mapper.readTree(payload1) - } + @Test + fun parseREQString() { + benchmarkRule.measureRepeated { Event.mapper.readTree(payload1) } + } + + @Test + fun parseEvent() { + val msg = Event.mapper.readTree(payload1) + + benchmarkRule.measureRepeated { Event.fromJson(msg[2]) } + } + + @Test + fun checkSignature() { + val msg = Event.mapper.readTree(payload1) + val event = Event.fromJson(msg[2]) + benchmarkRule.measureRepeated { + // Should pass + assertTrue(event.hasVerifiedSignature()) } + } - @Test - fun parseEvent() { - val msg = Event.mapper.readTree(payload1) + @Test + fun checkIDHashPayload1() { + val msg = Event.mapper.readTree(payload1) + val event = Event.fromJson(msg[2]) - benchmarkRule.measureRepeated { - Event.fromJson(msg[2]) - } + benchmarkRule.measureRepeated { + // Should pass + assertTrue(event.hasCorrectIDHash()) } + } - @Test - fun checkSignature() { - val msg = Event.mapper.readTree(payload1) - val event = Event.fromJson(msg[2]) - benchmarkRule.measureRepeated { - // Should pass - assertTrue( event.hasVerifiedSignature() ) - } + @Test + fun checkIDHashPayload2() { + val event = Event.fromJson(payload2) + + benchmarkRule.measureRepeated { + // Should pass + assertTrue(event.hasCorrectIDHash()) } + } - @Test - fun checkIDHashPayload1() { - val msg = Event.mapper.readTree(payload1) - val event = Event.fromJson(msg[2]) + @Test + fun toMakeJsonForID() { + val event = Event.fromJson(payload2) - benchmarkRule.measureRepeated { - // Should pass - assertTrue( event.hasCorrectIDHash() ) - } + benchmarkRule.measureRepeated { assertNotNull(event.makeJsonForId()) } + } + + @Test + fun sha256() { + val event = Event.fromJson(payload2) + val byteArray = event.makeJsonForId().toByteArray() + + benchmarkRule.measureRepeated { + // Should pass + assertNotNull(CryptoUtils.sha256(byteArray)) } - - @Test - fun checkIDHashPayload2() { - val event = Event.fromJson(payload2) - - benchmarkRule.measureRepeated { - // Should pass - assertTrue( event.hasCorrectIDHash() ) - } - } - - @Test - fun toMakeJsonForID() { - val event = Event.fromJson(payload2) - - benchmarkRule.measureRepeated { - assertNotNull(event.makeJsonForId()) - } - } - - @Test - fun sha256() { - val event = Event.fromJson(payload2) - val byteArray = event.makeJsonForId().toByteArray() - - benchmarkRule.measureRepeated { - // Should pass - assertNotNull(CryptoUtils.sha256(byteArray)) - } - } - -} \ No newline at end of file + } +} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapBenchmark.kt index 2705b13bc..656c6a8b3 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapBenchmark.kt @@ -1,318 +1,194 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.benchmark import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.GiftWrapEvent import com.vitorpamplona.quartz.events.NIP24Factory import com.vitorpamplona.quartz.events.SealedGossipEvent import com.vitorpamplona.quartz.signers.NostrSignerInternal +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit import junit.framework.TestCase import org.junit.Assert import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine /** * Benchmark, which will execute on an Android device. * - * The body of [BenchmarkRule.measureRepeated] is measured in a loop, and Studio will - * output the result. Modify your code to see how it affects performance. + * The body of [BenchmarkRule.measureRepeated] is measured in a loop, and Studio will output the + * result. Modify your code to see how it affects performance. */ @RunWith(AndroidJUnit4::class) class GiftWrapBenchmark { + @get:Rule val benchmarkRule = BenchmarkRule() - @get:Rule - val benchmarkRule = BenchmarkRule() + fun basePerformanceTest( + message: String, + expectedLength: Int, + ) { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) - fun basePerformanceTest(message: String, expectedLength: Int) { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) + var events: NIP24Factory.Result? = null + val countDownLatch = CountDownLatch(1) - var events: NIP24Factory.Result? = null - val countDownLatch = CountDownLatch(1) - - NIP24Factory().createMsgNIP24( - message, - listOf(receiver.pubKey), - sender - ) { - events = it - countDownLatch.countDown() - } - - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - - val countDownLatch2 = CountDownLatch(1) - - Assert.assertEquals(expectedLength, events!!.wraps.map { println("TEST ${it.toJson()}"); it.toJson() }.joinToString("").length) - - // Simulate Receiver - events!!.wraps.forEach { - it.checkSignature() - - val keyToUse = if (it.recipientPubKey() == sender.pubKey) sender else receiver - - it.cachedGift(keyToUse) { event -> - event.checkSignature() - - if (event is SealedGossipEvent) { - event.cachedGossip(keyToUse) { innerData -> - Assert.assertEquals(message, innerData.content) - countDownLatch2.countDown() - } - } else { - Assert.fail("Wrong Event") - } - } - } - - assertTrue(countDownLatch2.await(1, TimeUnit.SECONDS)) + NIP24Factory().createMsgNIP24( + message, + listOf(receiver.pubKey), + sender, + ) { + events = it + countDownLatch.countDown() } - fun receivePerformanceTest(message: String) { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) + assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - var giftWrap: GiftWrapEvent? = null - val countDownLatch = CountDownLatch(1) + val countDownLatch2 = CountDownLatch(1) - NIP24Factory().createMsgNIP24( - message, - listOf(receiver.pubKey), - sender - ) { - giftWrap = it.wraps.first() - countDownLatch.countDown() + Assert.assertEquals( + expectedLength, + events!! + .wraps + .map { + println("TEST ${it.toJson()}") + it.toJson() } + .joinToString("") + .length, + ) - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + // Simulate Receiver + events!!.wraps.forEach { + it.checkSignature() - val keyToUse = if (giftWrap!!.recipientPubKey() == sender.pubKey) sender else receiver - val giftWrapJson = giftWrap!!.toJson() + val keyToUse = if (it.recipientPubKey() == sender.pubKey) sender else receiver - // Simulate Receiver - benchmarkRule.measureRepeated { - CryptoUtils.clearCache() - val counter = CountDownLatch(1) + it.cachedGift(keyToUse) { event -> + event.checkSignature() - val wrap = Event.fromJson(giftWrapJson) as GiftWrapEvent - wrap.checkSignature() - - wrap.cachedGift(keyToUse) {seal -> - seal.checkSignature() - - if (seal is SealedGossipEvent) { - seal.cachedGossip(keyToUse) { innerData -> - Assert.assertEquals(message, innerData.content) - counter.countDown() - } - } else { - Assert.fail("Wrong Event") - } - } - - TestCase.assertTrue(counter.await(1, TimeUnit.SECONDS)) + if (event is SealedGossipEvent) { + event.cachedGossip(keyToUse) { innerData -> + Assert.assertEquals(message, innerData.content) + countDownLatch2.countDown() + } + } else { + Assert.fail("Wrong Event") } + } } + assertTrue(countDownLatch2.await(1, TimeUnit.SECONDS)) + } - @Test - fun tinyMessageHardCoded() { - benchmarkRule.measureRepeated { - basePerformanceTest("Hola, que tal?", 3402) - } + fun receivePerformanceTest(message: String) { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) + + var giftWrap: GiftWrapEvent? = null + val countDownLatch = CountDownLatch(1) + + NIP24Factory().createMsgNIP24( + message, + listOf(receiver.pubKey), + sender, + ) { + giftWrap = it.wraps.first() + countDownLatch.countDown() } - @Test - fun regularMessageHardCoded() { - benchmarkRule.measureRepeated { - basePerformanceTest("Hi, honey, can you drop by the market and get some bread?", 3746) + assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + + val keyToUse = if (giftWrap!!.recipientPubKey() == sender.pubKey) sender else receiver + val giftWrapJson = giftWrap!!.toJson() + + // Simulate Receiver + benchmarkRule.measureRepeated { + CryptoUtils.clearCache() + val counter = CountDownLatch(1) + + val wrap = Event.fromJson(giftWrapJson) as GiftWrapEvent + wrap.checkSignature() + + wrap.cachedGift(keyToUse) { seal -> + seal.checkSignature() + + if (seal is SealedGossipEvent) { + seal.cachedGossip(keyToUse) { innerData -> + Assert.assertEquals(message, innerData.content) + counter.countDown() + } + } else { + Assert.fail("Wrong Event") } + } + + TestCase.assertTrue(counter.await(1, TimeUnit.SECONDS)) } + } - @Test - fun longMessageHardCoded() { - benchmarkRule.measureRepeated { - basePerformanceTest( - "My queen, you are nothing short of royalty to me. You possess more beauty in the nail of your pinkie toe than everything else in this world combined. I am astounded by your grace, generosity, and graciousness. I am so lucky to know you. ", - 5114 - ) - } + @Test + fun tinyMessageHardCoded() { + benchmarkRule.measureRepeated { basePerformanceTest("Hola, que tal?", 3402) } + } + + @Test + fun regularMessageHardCoded() { + benchmarkRule.measureRepeated { + basePerformanceTest("Hi, honey, can you drop by the market and get some bread?", 3746) } + } - @Test - fun receivesTinyMessage() { - receivePerformanceTest("Hola, que tal?") + @Test + fun longMessageHardCoded() { + benchmarkRule.measureRepeated { + basePerformanceTest( + "My queen, you are nothing short of royalty to me. You possess more beauty in the nail of your pinkie toe than everything else in this world combined. I am astounded by your grace, generosity, and graciousness. I am so lucky to know you. ", + 5114, + ) } + } - @Test - fun receivesRegularMessage() { - receivePerformanceTest("Hi, honey, can you drop by the market and get some bread?") - } + @Test + fun receivesTinyMessage() { + receivePerformanceTest("Hola, que tal?") + } - @Test - fun receivesLongMessageHardCoded() { - receivePerformanceTest( - "My queen, you are nothing short of royalty to me. You possess more beauty in the nail of your pinkie toe than everything else in this world combined. I am astounded by your grace, generosity, and graciousness. I am so lucky to know you. " - ) - } + @Test + fun receivesRegularMessage() { + receivePerformanceTest("Hi, honey, can you drop by the market and get some bread?") + } - -/* - @Test - fun tinyMessageHardCodedCompressed() { - benchmarkRule.measureRepeated { - basePerformanceTest("Hola, que tal?", 2318) - } - } - - @Test - fun regularMessageHardCodedCompressed() { - benchmarkRule.measureRepeated { - basePerformanceTest("Hi, honey, can you drop by the market and get some bread?", 2406) - } - } - - @Test - fun longMessageHardCodedCompressed() { - benchmarkRule.measureRepeated { - basePerformanceTest( - "My queen, you are nothing short of royalty to me. You possess more beauty in the nail of your pinkie toe than everything else in this world combined. I am astounded by your grace, generosity, and graciousness. I am so lucky to know you. ", - 2722 - ) - } - }*/ - -/* - @Test - fun tinyMessageJSONCompressed() { - benchmarkRule.measureRepeated { - basePerformanceTest("Hola, que tal?", 2318) - } - } - - @Test - fun regularMessageJSONCompressed() { - benchmarkRule.measureRepeated { - basePerformanceTest("Hi, honey, can you drop by the market and get some bread?", 2394) - } - } - - @Test - fun longMessageJSONCompressed() { - benchmarkRule.measureRepeated { - basePerformanceTest( - "My queen, you are nothing short of royalty to me. You possess more beauty in the nail of your pinkie toe than everything else in this world combined. I am astounded by your grace, generosity, and graciousness. I am so lucky to know you. ", - 2714 - ) - } - }*/ - -/* - @Test - fun tinyMessageJSON() { - benchmarkRule.measureRepeated { - basePerformanceTest("Hola, que tal?", 3154) - } - } - - @Test - fun regularMessageJSON() { - benchmarkRule.measureRepeated { - basePerformanceTest("Hi, honey, can you drop by the market and get some bread?", 3298) - } - } - - @Test - fun longMessageJSON() { - benchmarkRule.measureRepeated { - basePerformanceTest( - "My queen, you are nothing short of royalty to me. You possess more beauty in the nail of your pinkie toe than everything else in this world combined. I am astounded by your grace, generosity, and graciousness. I am so lucky to know you. ", - 3938 - ) - } - }*/ - -/* - @Test - fun tinyMessageJackson() { - benchmarkRule.measureRepeated { - basePerformanceTest("Hola, que tal?", 3154) - } - } - - @Test - fun regularMessageJackson() { - benchmarkRule.measureRepeated { - basePerformanceTest("Hi, honey, can you drop by the market and get some bread?", 3298) - } - } - - @Test - fun longMessageJackson() { - benchmarkRule.measureRepeated { - basePerformanceTest( - "My queen, you are nothing short of royalty to me. You possess more beauty in the nail of your pinkie toe than everything else in this world combined. I am astounded by your grace, generosity, and graciousness. I am so lucky to know you. ", - 3938 - ) - } - } */ -/* - @Test - fun tinyMessageKotlin() { - benchmarkRule.measureRepeated { - basePerformanceTest("Hola, que tal?", 3154) - } - } - - @Test - fun regularMessageKotlin() { - benchmarkRule.measureRepeated { - basePerformanceTest("Hi, honey, can you drop by the market and get some bread?", 3298) - } - } - - @Test - fun longMessageKotlin() { - benchmarkRule.measureRepeated { - basePerformanceTest( - "My queen, you are nothing short of royalty to me. You possess more beauty in the nail of your pinkie toe than everything else in this world combined. I am astounded by your grace, generosity, and graciousness. I am so lucky to know you. ", - 3938 - ) - } - }*/ -/* - @Test - fun tinyMessageCSV() { - benchmarkRule.measureRepeated { - basePerformanceTest("Hola, que tal?", 2960) - } - } - - @Test - fun regularMessageCSV() { - benchmarkRule.measureRepeated { - basePerformanceTest("Hi, honey, can you drop by the market and get some bread?", 3112) - } - } - - @Test - fun longMessageCSV() { - benchmarkRule.measureRepeated { - basePerformanceTest( - "My queen, you are nothing short of royalty to me. You possess more beauty in the nail of your pinkie toe than everything else in this world combined. I am astounded by your grace, generosity, and graciousness. I am so lucky to know you. ", - 3752 - ) - } - }*/ -} \ No newline at end of file + @Test + fun receivesLongMessageHardCoded() { + receivePerformanceTest( + "My queen, you are nothing short of royalty to me. You possess more beauty in the nail of your pinkie toe than everything else in this world combined. I am astounded by your grace, generosity, and graciousness. I am so lucky to know you. ", + ) + } +} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapReceivingBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapReceivingBenchmark.kt index 24463e95f..229f61ef2 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapReceivingBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapReceivingBenchmark.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.benchmark import androidx.benchmark.junit4.BenchmarkRule @@ -13,179 +33,194 @@ import com.vitorpamplona.quartz.events.Gossip import com.vitorpamplona.quartz.events.SealedGossipEvent import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.signers.NostrSignerInternal +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit import junit.framework.TestCase.assertNotNull import junit.framework.TestCase.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit /** * Benchmark, which will execute on an Android device. * - * The body of [BenchmarkRule.measureRepeated] is measured in a loop, and Studio will - * output the result. Modify your code to see how it affects performance. + * The body of [BenchmarkRule.measureRepeated] is measured in a loop, and Studio will output the + * result. Modify your code to see how it affects performance. */ @RunWith(AndroidJUnit4::class) class GiftWrapReceivingBenchmark { + @get:Rule val benchmarkRule = BenchmarkRule() - @get:Rule - val benchmarkRule = BenchmarkRule() + fun createWrap( + sender: NostrSigner, + receiver: NostrSigner, + ): GiftWrapEvent { + val countDownLatch = CountDownLatch(1) + var wrap: GiftWrapEvent? = null - fun createWrap(sender: NostrSigner, receiver: NostrSigner): GiftWrapEvent { - val countDownLatch = CountDownLatch(1) - var wrap: GiftWrapEvent? = null - - ChatMessageEvent.create( - msg = "Hi there! This is a test message", - to = listOf(receiver.pubKey), - subject = "Party Tonight", - replyTos = emptyList(), - mentions = emptyList(), - zapReceiver = null, - markAsSensitive = true, - zapRaiserAmount = 10000, - geohash = null, - signer = sender + ChatMessageEvent.create( + msg = "Hi there! This is a test message", + to = listOf(receiver.pubKey), + subject = "Party Tonight", + replyTos = emptyList(), + mentions = emptyList(), + zapReceiver = null, + markAsSensitive = true, + zapRaiserAmount = 10000, + geohash = null, + signer = sender, + ) { + SealedGossipEvent.create( + event = it, + encryptTo = receiver.pubKey, + signer = sender, + ) { + GiftWrapEvent.create( + event = it, + recipientPubKey = receiver.pubKey, ) { - SealedGossipEvent.create( - event = it, - encryptTo = receiver.pubKey, - signer = sender - ) { - GiftWrapEvent.create( - event = it, - recipientPubKey = receiver.pubKey - ) { - wrap = it - countDownLatch.countDown() - } - } + wrap = it + countDownLatch.countDown() } - - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - - return wrap!! + } } - fun createSeal(sender: NostrSigner, receiver: NostrSigner): SealedGossipEvent { - val countDownLatch = CountDownLatch(1) - var seal: SealedGossipEvent? = null + assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - ChatMessageEvent.create( - msg = "Hi there! This is a test message", - to = listOf(receiver.pubKey), - subject = "Party Tonight", - replyTos = emptyList(), - mentions = emptyList(), - zapReceiver = null, - markAsSensitive = true, - zapRaiserAmount = 10000, - geohash = null, - signer = sender - ) { - SealedGossipEvent.create( - event = it, - encryptTo = receiver.pubKey, - signer = sender - ) { - seal = it - countDownLatch.countDown() - } - } + return wrap!! + } - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + fun createSeal( + sender: NostrSigner, + receiver: NostrSigner, + ): SealedGossipEvent { + val countDownLatch = CountDownLatch(1) + var seal: SealedGossipEvent? = null - return seal!! + ChatMessageEvent.create( + msg = "Hi there! This is a test message", + to = listOf(receiver.pubKey), + subject = "Party Tonight", + replyTos = emptyList(), + mentions = emptyList(), + zapReceiver = null, + markAsSensitive = true, + zapRaiserAmount = 10000, + geohash = null, + signer = sender, + ) { + SealedGossipEvent.create( + event = it, + encryptTo = receiver.pubKey, + signer = sender, + ) { + seal = it + countDownLatch.countDown() + } } - @Test - fun parseWrapFromString() { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) + assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - val str = createWrap(sender, receiver).toJson() + return seal!! + } - benchmarkRule.measureRepeated { - Event.fromJson(str) - } + @Test + fun parseWrapFromString() { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) + + val str = createWrap(sender, receiver).toJson() + + benchmarkRule.measureRepeated { Event.fromJson(str) } + } + + @Test + fun checkId() { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) + + val wrap = createWrap(sender, receiver) + + benchmarkRule.measureRepeated { wrap.hasCorrectIDHash() } + } + + @Test + fun checkSignature() { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) + + val wrap = createWrap(sender, receiver) + + benchmarkRule.measureRepeated { wrap.hasVerifiedSignature() } + } + + @Test + fun decryptWrapEvent() { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) + + val wrap = createWrap(sender, receiver) + + benchmarkRule.measureRepeated { + assertNotNull( + CryptoUtils.decryptNIP44v2( + wrap.content, + receiver.keyPair.privKey!!, + wrap.pubKey.hexToByteArray(), + ), + ) } + } - @Test - fun checkId() { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) + @Test + fun parseWrappedEvent() { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) - val wrap = createWrap(sender, receiver) + val wrap = createWrap(sender, receiver) - benchmarkRule.measureRepeated { - wrap.hasCorrectIDHash() - } + val innerJson = + CryptoUtils.decryptNIP44v2( + wrap.content, + receiver.keyPair.privKey!!, + wrap.pubKey.hexToByteArray(), + ) + + benchmarkRule.measureRepeated { assertNotNull(innerJson?.let { Event.fromJson(it) }) } + } + + @Test + fun decryptSealedEvent() { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) + + val seal = createSeal(sender, receiver) + + benchmarkRule.measureRepeated { + assertNotNull( + CryptoUtils.decryptNIP44v2( + seal.content, + receiver.keyPair.privKey!!, + seal.pubKey.hexToByteArray(), + ), + ) } + } - @Test - fun checkSignature() { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) + @Test + fun parseSealedEvent() { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) - val wrap = createWrap(sender, receiver) + val seal = createSeal(sender, receiver) - benchmarkRule.measureRepeated { - wrap.hasVerifiedSignature() - } - } + val innerJson = + CryptoUtils.decryptNIP44v2( + seal.content, + receiver.keyPair.privKey!!, + seal.pubKey.hexToByteArray(), + ) - @Test - fun decryptWrapEvent() { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) - - val wrap = createWrap(sender, receiver) - - benchmarkRule.measureRepeated { - assertNotNull(CryptoUtils.decryptNIP44v2(wrap.content, receiver.keyPair.privKey!!, wrap.pubKey.hexToByteArray())) - } - } - - @Test - fun parseWrappedEvent() { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) - - val wrap = createWrap(sender, receiver) - - val innerJson = CryptoUtils.decryptNIP44v2(wrap.content, receiver.keyPair.privKey!!, wrap.pubKey.hexToByteArray()) - - benchmarkRule.measureRepeated { - assertNotNull(innerJson?.let { Event.fromJson(it) }) - } - } - - @Test - fun decryptSealedEvent() { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) - - val seal = createSeal(sender, receiver) - - benchmarkRule.measureRepeated { - assertNotNull(CryptoUtils.decryptNIP44v2(seal.content, receiver.keyPair.privKey!!, seal.pubKey.hexToByteArray())) - } - } - - @Test - fun parseSealedEvent() { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) - - val seal = createSeal(sender, receiver) - - val innerJson = CryptoUtils.decryptNIP44v2(seal.content, receiver.keyPair.privKey!!, seal.pubKey.hexToByteArray()) - - benchmarkRule.measureRepeated { - assertNotNull(innerJson?.let { Gossip.fromJson(it) }) - } - } - -} \ No newline at end of file + benchmarkRule.measureRepeated { assertNotNull(innerJson?.let { Gossip.fromJson(it) }) } + } +} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapSigningBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapSigningBenchmark.kt index 8d010d7db..cb0cf28f4 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapSigningBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/GiftWrapSigningBenchmark.kt @@ -1,190 +1,201 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.benchmark import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.events.ChatMessageEvent -import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.GiftWrapEvent -import com.vitorpamplona.quartz.events.Gossip import com.vitorpamplona.quartz.events.SealedGossipEvent import com.vitorpamplona.quartz.signers.NostrSignerInternal -import junit.framework.TestCase.assertNotNull +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit import junit.framework.TestCase.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit /** * Benchmark, which will execute on an Android device. * - * The body of [BenchmarkRule.measureRepeated] is measured in a loop, and Studio will - * output the result. Modify your code to see how it affects performance. + * The body of [BenchmarkRule.measureRepeated] is measured in a loop, and Studio will output the + * result. Modify your code to see how it affects performance. */ @RunWith(AndroidJUnit4::class) class GiftWrapSigningBenchmark { + @get:Rule val benchmarkRule = BenchmarkRule() - @get:Rule - val benchmarkRule = BenchmarkRule() + @Test + fun createMessageEvent() { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) - @Test - fun createMessageEvent() { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) + benchmarkRule.measureRepeated { + val countDownLatch = CountDownLatch(1) - benchmarkRule.measureRepeated { - val countDownLatch = CountDownLatch(1) + ChatMessageEvent.create( + msg = "Hi there! This is a test message", + to = listOf(receiver.pubKey), + subject = "Party Tonight", + replyTos = emptyList(), + mentions = emptyList(), + zapReceiver = null, + markAsSensitive = true, + zapRaiserAmount = 10000, + geohash = null, + signer = sender, + ) { + countDownLatch.countDown() + } - ChatMessageEvent.create( - msg = "Hi there! This is a test message", - to = listOf(receiver.pubKey), - subject = "Party Tonight", - replyTos = emptyList(), - mentions = emptyList(), - zapReceiver = null, - markAsSensitive = true, - zapRaiserAmount = 10000, - geohash = null, - signer = sender - ) { - countDownLatch.countDown() - } + assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + } + } - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - } + @Test + fun sealMessage() { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) + + val countDownLatch = CountDownLatch(1) + + var msg: ChatMessageEvent? = null + + ChatMessageEvent.create( + msg = "Hi there! This is a test message", + to = listOf(receiver.pubKey), + subject = "Party Tonight", + replyTos = emptyList(), + mentions = emptyList(), + zapReceiver = null, + markAsSensitive = true, + zapRaiserAmount = 10000, + geohash = null, + signer = sender, + ) { + msg = it + countDownLatch.countDown() } - @Test - fun sealMessage() { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) + assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - val countDownLatch = CountDownLatch(1) + benchmarkRule.measureRepeated { + val countDownLatch2 = CountDownLatch(1) + SealedGossipEvent.create( + event = msg!!, + encryptTo = receiver.pubKey, + signer = sender, + ) { + countDownLatch2.countDown() + } - var msg: ChatMessageEvent? = null + assertTrue(countDownLatch2.await(1, TimeUnit.SECONDS)) + } + } - ChatMessageEvent.create( - msg = "Hi there! This is a test message", - to = listOf(receiver.pubKey), - subject = "Party Tonight", - replyTos = emptyList(), - mentions = emptyList(), - zapReceiver = null, - markAsSensitive = true, - zapRaiserAmount = 10000, - geohash = null, - signer = sender + @Test + fun wrapSeal() { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) + + val countDownLatch = CountDownLatch(1) + + var seal: SealedGossipEvent? = null + + ChatMessageEvent.create( + msg = "Hi there! This is a test message", + to = listOf(receiver.pubKey), + subject = "Party Tonight", + replyTos = emptyList(), + mentions = emptyList(), + zapReceiver = null, + markAsSensitive = true, + zapRaiserAmount = 10000, + geohash = null, + signer = sender, + ) { + SealedGossipEvent.create( + event = it, + encryptTo = receiver.pubKey, + signer = sender, + ) { + seal = it + countDownLatch.countDown() + } + } + + assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + + benchmarkRule.measureRepeated { + val countDownLatch2 = CountDownLatch(1) + GiftWrapEvent.create( + event = seal!!, + recipientPubKey = receiver.pubKey, + ) { + countDownLatch2.countDown() + } + assertTrue(countDownLatch2.await(1, TimeUnit.SECONDS)) + } + } + + @Test + fun wrapToString() { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) + + val countDownLatch = CountDownLatch(1) + + var wrap: GiftWrapEvent? = null + + ChatMessageEvent.create( + msg = "Hi there! This is a test message", + to = listOf(receiver.pubKey), + subject = "Party Tonight", + replyTos = emptyList(), + mentions = emptyList(), + zapReceiver = null, + markAsSensitive = true, + zapRaiserAmount = 10000, + geohash = null, + signer = sender, + ) { + SealedGossipEvent.create( + event = it, + encryptTo = receiver.pubKey, + signer = sender, + ) { + GiftWrapEvent.create( + event = it, + recipientPubKey = receiver.pubKey, ) { - msg = it - countDownLatch.countDown() - } - - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - - benchmarkRule.measureRepeated { - val countDownLatch2 = CountDownLatch(1) - SealedGossipEvent.create( - event = msg!!, - encryptTo = receiver.pubKey, - signer = sender - ) { - countDownLatch2.countDown() - } - - assertTrue(countDownLatch2.await(1, TimeUnit.SECONDS)) + wrap = it + countDownLatch.countDown() } + } } - @Test - fun wrapSeal() { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) + assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - val countDownLatch = CountDownLatch(1) - - var seal: SealedGossipEvent? = null - - ChatMessageEvent.create( - msg = "Hi there! This is a test message", - to = listOf(receiver.pubKey), - subject = "Party Tonight", - replyTos = emptyList(), - mentions = emptyList(), - zapReceiver = null, - markAsSensitive = true, - zapRaiserAmount = 10000, - geohash = null, - signer = sender - ) { - SealedGossipEvent.create( - event = it, - encryptTo = receiver.pubKey, - signer = sender - ) { - seal = it - countDownLatch.countDown() - } - } - - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - - benchmarkRule.measureRepeated { - val countDownLatch2 = CountDownLatch(1) - GiftWrapEvent.create( - event = seal!!, - recipientPubKey = receiver.pubKey - ) { - countDownLatch2.countDown() - } - assertTrue(countDownLatch2.await(1, TimeUnit.SECONDS)) - } - } - - @Test - fun wrapToString() { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) - - val countDownLatch = CountDownLatch(1) - - var wrap: GiftWrapEvent? = null - - ChatMessageEvent.create( - msg = "Hi there! This is a test message", - to = listOf(receiver.pubKey), - subject = "Party Tonight", - replyTos = emptyList(), - mentions = emptyList(), - zapReceiver = null, - markAsSensitive = true, - zapRaiserAmount = 10000, - geohash = null, - signer = sender - ) { - SealedGossipEvent.create( - event = it, - encryptTo = receiver.pubKey, - signer = sender - ) { - GiftWrapEvent.create( - event = it, - recipientPubKey = receiver.pubKey - ) { - wrap = it - countDownLatch.countDown() - } - } - } - - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - - benchmarkRule.measureRepeated { - wrap!!.toJson() - } - } -} \ No newline at end of file + benchmarkRule.measureRepeated { wrap!!.toJson() } + } +} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/HexBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/HexBenchmark.kt index 08ef9c360..fa7e115b6 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/HexBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/HexBenchmark.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.benchmark import androidx.benchmark.junit4.BenchmarkRule @@ -11,46 +31,38 @@ import org.junit.runner.RunWith /** * Benchmark, which will execute on an Android device. * - * The body of [BenchmarkRule.measureRepeated] is measured in a loop, and Studio will - * output the result. Modify your code to see how it affects performance. + * The body of [BenchmarkRule.measureRepeated] is measured in a loop, and Studio will output the + * result. Modify your code to see how it affects performance. */ @RunWith(AndroidJUnit4::class) class HexBenchmark { + @get:Rule val benchmarkRule = BenchmarkRule() - @get:Rule - val benchmarkRule = BenchmarkRule() + val testHex = "48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf" - val TestHex = "48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf" + @Test + fun hexDecodeOurs() { + benchmarkRule.measureRepeated { com.vitorpamplona.quartz.encoders.Hex.decode(testHex) } + } - @Test - fun hexDecodeOurs() { - benchmarkRule.measureRepeated { - com.vitorpamplona.quartz.encoders.Hex.decode(TestHex) - } + @Test + fun hexEncodeOurs() { + val bytes = com.vitorpamplona.quartz.encoders.Hex.decode(testHex) + + benchmarkRule.measureRepeated { + assertEquals(testHex, com.vitorpamplona.quartz.encoders.Hex.encode(bytes)) } + } - @Test - fun hexEncodeOurs() { - val bytes = com.vitorpamplona.quartz.encoders.Hex.decode(TestHex) + @Test + fun hexDecodeBaseSecp() { + benchmarkRule.measureRepeated { fr.acinq.secp256k1.Hex.decode(testHex) } + } - benchmarkRule.measureRepeated { - assertEquals(TestHex, com.vitorpamplona.quartz.encoders.Hex.encode(bytes)) - } - } + @Test + fun hexEncodeBaseSecp() { + val bytes = fr.acinq.secp256k1.Hex.decode(testHex) - @Test - fun hexDecodeBaseSecp() { - benchmarkRule.measureRepeated { - fr.acinq.secp256k1.Hex.decode(TestHex) - } - } - - @Test - fun hexEncodeBaseSecp() { - val bytes = fr.acinq.secp256k1.Hex.decode(TestHex) - - benchmarkRule.measureRepeated { - assertEquals(TestHex, fr.acinq.secp256k1.Hex.encode(bytes)) - } - } -} \ No newline at end of file + benchmarkRule.measureRepeated { assertEquals(testHex, fr.acinq.secp256k1.Hex.encode(bytes)) } + } +} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RobohashBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RobohashBenchmark.kt index f897740e9..12dbd3e1b 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RobohashBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RobohashBenchmark.kt @@ -1,39 +1,178 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.amethyst.benchmark import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.encoders.Hex import com.vitorpamplona.quartz.utils.Robohash import junit.framework.TestCase.assertEquals import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import java.lang.StringBuilder /** * Benchmark, which will execute on an Android device. * - * The body of [BenchmarkRule.measureRepeated] is measured in a loop, and Studio will - * output the result. Modify your code to see how it affects performance. + * The body of [BenchmarkRule.measureRepeated] is measured in a loop, and Studio will output the + * result. Modify your code to see how it affects performance. */ @RunWith(AndroidJUnit4::class) class RobohashBenchmark { + @get:Rule val benchmarkRule = BenchmarkRule() - @get:Rule - val benchmarkRule = BenchmarkRule() + val warmHex = "f4f016c739b8ec0d6313540a8b12cf48a72b485d38338627ec9d427583551f9a" + val testHex = "48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf" + val resultingSVG = + """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """ + .trimIndent() - val WarmHex = "f4f016c739b8ec0d6313540a8b12cf48a72b485d38338627ec9d427583551f9a" - val TestHex = "48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf" - val ResultingSVG = """""" - - @Test - fun createSVG() { - // warm up - Robohash.assemble(WarmHex, true) - benchmarkRule.measureRepeated { - val result = Robohash.assemble(TestHex, true) - assertEquals(ResultingSVG, result) - } + @Test + fun createSVG() { + // warm up + Robohash.assemble(warmHex, true) + benchmarkRule.measureRepeated { + val result = Robohash.assemble(testHex, true) + assertEquals(resultingSVG, result) } -} \ No newline at end of file + } +} diff --git a/build.gradle b/build.gradle index f2fad81f3..147c08459 100644 --- a/build.gradle +++ b/build.gradle @@ -57,7 +57,7 @@ subprojects { ktlint("1.1.0") ktfmt().googleStyle() - licenseHeaderFile rootProject.file('spotless/copyright.txt'), "package|import|class|object|sealed|open|interface|abstract " + licenseHeaderFile rootProject.file('spotless/copyright.kt'), "package|import|class|object|sealed|open|interface|abstract " } groovyGradle { diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/ChatroomKeyTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/ChatroomKeyTest.kt index 8b234a64f..88ecdcd93 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/ChatroomKeyTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/ChatroomKeyTest.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -9,12 +29,12 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class ChatroomKeyTest { - @Test - fun testEquals() { - val k1 = ChatroomKey(persistentSetOf("Key1", "Key2")) - val k2 = ChatroomKey(persistentSetOf("Key1", "Key2")) + @Test + fun testEquals() { + val k1 = ChatroomKey(persistentSetOf("Key1", "Key2")) + val k2 = ChatroomKey(persistentSetOf("Key1", "Key2")) - assertEquals(k1, k2) - assertEquals(k1.hashCode(), k2.hashCode()) - } + assertEquals(k1, k2) + assertEquals(k1.hashCode(), k2.hashCode()) + } } diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/CitationTests.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/CitationTests.kt index 0ecd801bc..019aa0af0 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/CitationTests.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/CitationTests.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -9,7 +29,8 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class CitationTests { - val json = """ + val json = + """ { "content": "Astral:\n\nhttps://void.cat/d/A5Fba5B1bcxwEmeyoD9nBs.webp\n\nIris:\n\nhttps://void.cat/d/44hTcVvhRps6xYYs99QsqA.webp\n\nSnort:\n\nhttps://void.cat/d/4nJD5TRePuQChM5tzteYbU.webp\n\nAmethyst agrees with Astral which I suspect are both wrong. nostr:npub13sx6fp3pxq5rl70x0kyfmunyzaa9pzt5utltjm0p8xqyafndv95q3saapa nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49 nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z ", "created_at": 1683596206, @@ -73,17 +94,18 @@ class CitationTests { } """ - @Test - fun parseEvent() { - val event = Event.fromJson(json) as TextNoteEvent + @Test + fun parseEvent() { + val event = Event.fromJson(json) as TextNoteEvent - val expectedCitations = setOf( - "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168", - "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed", - "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0", - "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" - ) + val expectedCitations = + setOf( + "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed", + "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + ) - assertEquals(expectedCitations, event.citedUsers()) - } + assertEquals(expectedCitations, event.citedUsers()) + } } diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/CryptoUtilsTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/CryptoUtilsTest.kt index 2ebdc9fd3..5db4fd3a7 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/CryptoUtilsTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/CryptoUtilsTest.kt @@ -1,104 +1,125 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.encoders.hexToByteArray -import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.crypto.KeyPair -import com.vitorpamplona.quartz.encoders.Hex -import com.vitorpamplona.quartz.encoders.decodePublicKey +import com.vitorpamplona.quartz.encoders.hexToByteArray +import com.vitorpamplona.quartz.encoders.toHexKey import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class CryptoUtilsTest { + @Test + fun testGetPublicFromPrivateKey() { + val privateKey = + "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray() + val publicKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey() + assertEquals("7d4b8806f1fd713c287235411bf95aa81b7242ead892733ec84b3f2719845be6", publicKey) + } - @Test - fun testGetPublicFromPrivateKey() { - val privateKey = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray() - val publicKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey() - assertEquals("7d4b8806f1fd713c287235411bf95aa81b7242ead892733ec84b3f2719845be6", publicKey) - } + @Test + fun testSharedSecretCompatibilityWithCoracle() { + val privateKey = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561" + val publicKey = "765cd7cf91d3ad07423d114d5a39c61d52b2cdbc18ba055ddbbeec71fbe2aa2f" - @Test - fun testSharedSecretCompatibilityWithCoracle() { - val privateKey = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561" - val publicKey = "765cd7cf91d3ad07423d114d5a39c61d52b2cdbc18ba055ddbbeec71fbe2aa2f" + val key = + CryptoUtils.getSharedSecretNIP44v1( + privateKey = privateKey.hexToByteArray(), + pubKey = publicKey.hexToByteArray(), + ) - val key = CryptoUtils.getSharedSecretNIP44v1(privateKey = privateKey.hexToByteArray(), pubKey = publicKey.hexToByteArray()) + assertEquals("577c966f499dddd8e8dcc34e8f352e283cc177e53ae372794947e0b8ede7cfd8", key.toHexKey()) + } - assertEquals("577c966f499dddd8e8dcc34e8f352e283cc177e53ae372794947e0b8ede7cfd8", key.toHexKey()) - } + @Test + fun testSharedSecret() { + val sender = KeyPair() + val receiver = KeyPair() - @Test - fun testSharedSecret() { - val sender = KeyPair() - val receiver = KeyPair() + val sharedSecret1 = CryptoUtils.getSharedSecretNIP44v1(sender.privKey!!, receiver.pubKey) + val sharedSecret2 = CryptoUtils.getSharedSecretNIP44v1(receiver.privKey!!, sender.pubKey) - val sharedSecret1 = CryptoUtils.getSharedSecretNIP44v1(sender.privKey!!, receiver.pubKey) - val sharedSecret2 = CryptoUtils.getSharedSecretNIP44v1(receiver.privKey!!, sender.pubKey) + assertEquals(sharedSecret1.toHexKey(), sharedSecret2.toHexKey()) - assertEquals(sharedSecret1.toHexKey(), sharedSecret2.toHexKey()) + val secretKey1 = KeyPair(privKey = sharedSecret1) + val secretKey2 = KeyPair(privKey = sharedSecret2) - val secretKey1 = KeyPair(privKey = sharedSecret1) - val secretKey2 = KeyPair(privKey = sharedSecret2) + assertEquals(secretKey1.pubKey.toHexKey(), secretKey2.pubKey.toHexKey()) + assertEquals(secretKey1.privKey?.toHexKey(), secretKey2.privKey?.toHexKey()) + } - assertEquals(secretKey1.pubKey.toHexKey(), secretKey2.pubKey.toHexKey()) - assertEquals(secretKey1.privKey?.toHexKey(), secretKey2.privKey?.toHexKey()) - } + @Test + fun encryptDecryptNIP4Test() { + val msg = "Hi" - @Test - fun encryptDecryptNIP4Test() { - val msg = "Hi" + val privateKey = CryptoUtils.privkeyCreate() + val publicKey = CryptoUtils.pubkeyCreate(privateKey) - val privateKey = CryptoUtils.privkeyCreate() - val publicKey = CryptoUtils.pubkeyCreate(privateKey) + val encrypted = CryptoUtils.encryptNIP04(msg, privateKey, publicKey) + val decrypted = CryptoUtils.decryptNIP04(encrypted, privateKey, publicKey) - val encrypted = CryptoUtils.encryptNIP04(msg, privateKey, publicKey) - val decrypted = CryptoUtils.decryptNIP04(encrypted, privateKey, publicKey) + assertEquals(msg, decrypted) + } - assertEquals(msg, decrypted) - } + @Test + fun encryptDecryptNIP44v1Test() { + val msg = "Hi" + val privateKey = CryptoUtils.privkeyCreate() + val publicKey = CryptoUtils.pubkeyCreate(privateKey) - @Test - fun encryptDecryptNIP44v1Test() { - val msg = "Hi" + val encrypted = CryptoUtils.encryptNIP44v1(msg, privateKey, publicKey) + val decrypted = CryptoUtils.decryptNIP44v1(encrypted, privateKey, publicKey) - val privateKey = CryptoUtils.privkeyCreate() - val publicKey = CryptoUtils.pubkeyCreate(privateKey) + assertEquals(msg, decrypted) + } - val encrypted = CryptoUtils.encryptNIP44v1(msg, privateKey, publicKey) - val decrypted = CryptoUtils.decryptNIP44v1(encrypted, privateKey, publicKey) + @Test + fun encryptSharedSecretDecryptNIP4Test() { + val msg = "Hi" - assertEquals(msg, decrypted) - } + val privateKey = CryptoUtils.privkeyCreate() + val publicKey = CryptoUtils.pubkeyCreate(privateKey) - @Test - fun encryptSharedSecretDecryptNIP4Test() { - val msg = "Hi" + val encrypted = CryptoUtils.encryptNIP04(msg, privateKey, publicKey) + val decrypted = CryptoUtils.decryptNIP04(encrypted, privateKey, publicKey) - val privateKey = CryptoUtils.privkeyCreate() - val publicKey = CryptoUtils.pubkeyCreate(privateKey) + assertEquals(msg, decrypted) + } - val encrypted = CryptoUtils.encryptNIP04(msg, privateKey, publicKey) - val decrypted = CryptoUtils.decryptNIP04(encrypted, privateKey, publicKey) + @Test + fun encryptSharedSecretDecryptNIP44v1Test() { + val msg = "Hi" - assertEquals(msg, decrypted) - } + val privateKey = CryptoUtils.privkeyCreate() + val publicKey = CryptoUtils.pubkeyCreate(privateKey) + val sharedSecret = CryptoUtils.getSharedSecretNIP44v1(privateKey, publicKey) - @Test - fun encryptSharedSecretDecryptNIP44v1Test() { - val msg = "Hi" + val encrypted = CryptoUtils.encryptNIP44v1(msg, sharedSecret) + val decrypted = CryptoUtils.decryptNIP44v1(encrypted, sharedSecret) - val privateKey = CryptoUtils.privkeyCreate() - val publicKey = CryptoUtils.pubkeyCreate(privateKey) - val sharedSecret = CryptoUtils.getSharedSecretNIP44v1(privateKey, publicKey) - - val encrypted = CryptoUtils.encryptNIP44v1(msg, sharedSecret) - val decrypted = CryptoUtils.decryptNIP44v1(encrypted, sharedSecret) - - assertEquals(msg, decrypted) - } + assertEquals(msg, decrypted) + } } diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/EventSigCheck.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/EventSigCheck.kt index 7794c4509..507b0b495 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/EventSigCheck.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/EventSigCheck.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -7,16 +27,15 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class EventSigCheck { + val payload1 = + "[\"EVENT\",\"40b9\",{\"id\":\"48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf\",\"kind\":1,\"pubkey\":\"3d842afecd5e293f28b6627933704a3fb8ce153aa91d790ab11f6a752d44a42d\",\"created_at\":1677940007,\"content\":\"I got asked about follower count again today. Why does my follower count go down when I delete public relays (in our list) and replace them with filter.nostr.wine? \\n\\nIโ€™ll give you one final explanation to rule them all. First, letโ€™s go over how clients calculate your follower count.\\n\\n1. Your client sends a request to all your connected relays asking for accounts who follow you\\n2. Relays answer back with the events requested\\n3. The client aggregates the event total and displays it\\n\\nEach relay has a set limit on how many stored events it will return per request. For some relays itโ€™s 500, others 1000, some as high as 5000. Letโ€™s say for simplicity that all your public relays use 500 as their limit. If you ask 10 relays for your followers the max possible answer you can get is 5000. That wonโ€™t change if you have 20,000 followers or 100,000. You may get back a โ€œdifferentโ€ 5000 each time, but youโ€™ll still cap out at 5000 because that is the most events your client will receive.\u2028\u2028Our limit on filter.nostr.wine is 2000 events. If you replace 10 public relays with only filter.nostr.wine, the MOST followers you will ever get back from our filter relay is 2000. That doesnโ€™t mean you only have 2000 followers or that your reach is reduced in any way.\\n\\nAs long as you are writing to and reading from the same public relays, neither your reach nor any content was lost. That concludes my TED talk. I hope you all have a fantastic day and weekend.\",\"tags\":[],\"sig\":\"dcaf8ab98bb9179017b35bd814092850d1062b26c263dff89fb1ae8c019a324139d1729012d9d05ff0a517f76b1117d869b2cc7d36bea8aa5f4b94c5e2548aa8\"}]" - val payload1 = "[\"EVENT\",\"40b9\",{\"id\":\"48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf\",\"kind\":1,\"pubkey\":\"3d842afecd5e293f28b6627933704a3fb8ce153aa91d790ab11f6a752d44a42d\",\"created_at\":1677940007,\"content\":\"I got asked about follower count again today. Why does my follower count go down when I delete public relays (in our list) and replace them with filter.nostr.wine? \\n\\nIโ€™ll give you one final explanation to rule them all. First, letโ€™s go over how clients calculate your follower count.\\n\\n1. Your client sends a request to all your connected relays asking for accounts who follow you\\n2. Relays answer back with the events requested\\n3. The client aggregates the event total and displays it\\n\\nEach relay has a set limit on how many stored events it will return per request. For some relays itโ€™s 500, others 1000, some as high as 5000. Letโ€™s say for simplicity that all your public relays use 500 as their limit. If you ask 10 relays for your followers the max possible answer you can get is 5000. That wonโ€™t change if you have 20,000 followers or 100,000. You may get back a โ€œdifferentโ€ 5000 each time, but youโ€™ll still cap out at 5000 because that is the most events your client will receive.\u2028\u2028Our limit on filter.nostr.wine is 2000 events. If you replace 10 public relays with only filter.nostr.wine, the MOST followers you will ever get back from our filter relay is 2000. That doesnโ€™t mean you only have 2000 followers or that your reach is reduced in any way.\\n\\nAs long as you are writing to and reading from the same public relays, neither your reach nor any content was lost. That concludes my TED talk. I hope you all have a fantastic day and weekend.\",\"tags\":[],\"sig\":\"dcaf8ab98bb9179017b35bd814092850d1062b26c263dff89fb1ae8c019a324139d1729012d9d05ff0a517f76b1117d869b2cc7d36bea8aa5f4b94c5e2548aa8\"}]" - - @Test - fun testUnicode2028and2029ShouldNotBeEscaped() { - val msg = Event.mapper.readTree(payload1) - val event = Event.fromJson(msg[2]) - - // Should pass - event.checkSignature() - } + @Test + fun testUnicode2028and2029ShouldNotBeEscaped() { + val msg = Event.mapper.readTree(payload1) + val event = Event.fromJson(msg[2]) + // Should pass + event.checkSignature() + } } diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/GiftWrapEventTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/GiftWrapEventTest.kt index fdbb83752..124be15bd 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/GiftWrapEventTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/GiftWrapEventTest.kt @@ -1,692 +1,747 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.encoders.hexToByteArray -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.Hex import com.vitorpamplona.quartz.encoders.HexKey -import com.vitorpamplona.quartz.encoders.decodePublicKey +import com.vitorpamplona.quartz.encoders.hexToByteArray import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.GiftWrapEvent -import com.vitorpamplona.quartz.events.Gossip import com.vitorpamplona.quartz.events.NIP24Factory import com.vitorpamplona.quartz.events.SealedGossipEvent import com.vitorpamplona.quartz.signers.NostrSignerInternal +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Assert.fail import org.junit.Test import org.junit.runner.RunWith -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import kotlin.math.sign @RunWith(AndroidJUnit4::class) class GiftWrapEventTest { - @Test() - fun testNip24Utils() { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) - val message = "Hola, que tal?" + @Test() + fun testNip24Utils() { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) + val message = "Hola, que tal?" - // Requires 3 tests - val countDownLatch = CountDownLatch(3) + // Requires 3 tests + val countDownLatch = CountDownLatch(3) - NIP24Factory().createMsgNIP24( - message, - listOf(receiver.pubKey), - sender - ) { events -> - countDownLatch.countDown() + NIP24Factory().createMsgNIP24( + message, + listOf(receiver.pubKey), + sender, + ) { events -> + countDownLatch.countDown() - // Simulate Receiver - val eventsReceiverGets = events.wraps.filter { it.isTaggedUser(receiver.pubKey) } - eventsReceiverGets.forEach { - it.cachedGift(receiver) { event -> - if (event is SealedGossipEvent) { - event.cachedGossip(receiver) { innerData -> - countDownLatch.countDown() - assertEquals(message, innerData.content) - } - } else { - fail("Wrong Event") - } - } - } - - // Simulate Sender - val eventsSenderGets = events.wraps.filter { it.isTaggedUser(sender.pubKey) } - eventsSenderGets.forEach { - it.cachedGift(sender) { event -> - if (event is SealedGossipEvent) { - event.cachedGossip(sender) { innerData -> - countDownLatch.countDown() - assertEquals(message, innerData.content) - } - } else { - fail("Wrong Event") - } - } + // Simulate Receiver + val eventsReceiverGets = events.wraps.filter { it.isTaggedUser(receiver.pubKey) } + eventsReceiverGets.forEach { + it.cachedGift(receiver) { event -> + if (event is SealedGossipEvent) { + event.cachedGossip(receiver) { innerData -> + countDownLatch.countDown() + assertEquals(message, innerData.content) } + } else { + fail("Wrong Event") + } } + } - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + // Simulate Sender + val eventsSenderGets = events.wraps.filter { it.isTaggedUser(sender.pubKey) } + eventsSenderGets.forEach { + it.cachedGift(sender) { event -> + if (event is SealedGossipEvent) { + event.cachedGossip(sender) { innerData -> + countDownLatch.countDown() + assertEquals(message, innerData.content) + } + } else { + fail("Wrong Event") + } + } + } } - @Test() - fun testNip24UtilsForGroups() { - val sender = NostrSignerInternal(KeyPair()) - val receiver1 = NostrSignerInternal(KeyPair()) - val receiver2 = NostrSignerInternal(KeyPair()) - val receiver3 = NostrSignerInternal(KeyPair()) - val receiver4 = NostrSignerInternal(KeyPair()) - val message = "Hola, que tal?" - - val receivers = listOf( - receiver1, - receiver2, - receiver3, - receiver4 - ) - - val countDownLatch = CountDownLatch(receivers.size + 2) - - NIP24Factory().createMsgNIP24( - message, - receivers.map { it.pubKey }, - sender - ) { events -> - countDownLatch.countDown() - - // Simulate Receiver - receivers.forEach { receiver -> - val eventsReceiverGets = events.wraps.filter { it.isTaggedUser(receiver.pubKey) } - eventsReceiverGets.forEach { - it.cachedGift(receiver) { event -> - if (event is SealedGossipEvent) { - event.cachedGossip(receiver) { innerData -> - countDownLatch.countDown() - assertEquals(message, innerData.content) - } - } else { - fail("Wrong Event") - } - } - } - } - - // Simulate Sender - val eventsSenderGets = events.wraps.filter { it.isTaggedUser(sender.pubKey) } - eventsSenderGets.forEach { - it.cachedGift(sender) { event -> - if (event is SealedGossipEvent) { - event.cachedGossip(sender) { innerData -> - countDownLatch.countDown() - assertEquals(message, innerData.content) - } - } else { - fail("Wrong Event") - } - } - } - } - - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - } - - @Test() - fun testInternalsSimpleMessage() { - val sender = NostrSignerInternal(KeyPair()) - val receiver = NostrSignerInternal(KeyPair()) - - val countDownLatch = CountDownLatch(2) - - var giftWrapEventToSender: GiftWrapEvent? = null - var giftWrapEventToReceiver: GiftWrapEvent? = null - - ChatMessageEvent.create( - msg = "Hi There!", - to = listOf(receiver.pubKey), - signer = sender - ) { senderMessage -> - // MsgFor the Receiver - - SealedGossipEvent.create( - event = senderMessage, - encryptTo = receiver.pubKey, - signer = sender - ) { encMsgFromSenderToReceiver -> - // Should expose sender - assertEquals(encMsgFromSenderToReceiver.pubKey, sender.pubKey) - // Should not expose receiver - assertTrue(encMsgFromSenderToReceiver.tags.isEmpty()) - - GiftWrapEvent.create( - event = encMsgFromSenderToReceiver, - recipientPubKey = receiver.pubKey - ) { giftWrapToReceiver -> - // Should not be signed by neither sender nor receiver - assertNotEquals(giftWrapToReceiver.pubKey, sender.pubKey) - assertNotEquals(giftWrapToReceiver.pubKey, receiver.pubKey) - - // Should not include sender as recipient - assertNotEquals(giftWrapToReceiver.recipientPubKey(), sender.pubKey) - - // Should be addressed to the receiver - assertEquals(giftWrapToReceiver.recipientPubKey(), receiver.pubKey) - - giftWrapEventToReceiver = giftWrapToReceiver - - countDownLatch.countDown() - } - } - - - // MsgFor the Sender - SealedGossipEvent.create( - event = senderMessage, - encryptTo = sender.pubKey, - signer = sender - ) { encMsgFromSenderToSender -> - // Should expose sender - assertEquals(encMsgFromSenderToSender.pubKey, sender.pubKey) - // Should not expose receiver - assertTrue(encMsgFromSenderToSender.tags.isEmpty()) - - GiftWrapEvent.create( - event = encMsgFromSenderToSender, - recipientPubKey = sender.pubKey - ) { giftWrapToSender -> - // Should not be signed by neither the sender, not the receiver - assertNotEquals(giftWrapToSender.pubKey, sender.pubKey) - assertNotEquals(giftWrapToSender.pubKey, receiver.pubKey) - - // Should not be addressed to the receiver - assertNotEquals(giftWrapToSender.recipientPubKey(), receiver.pubKey) - // Should be addressed to the sender - assertEquals(giftWrapToSender.recipientPubKey(), sender.pubKey) - - giftWrapEventToSender = giftWrapToSender - - countDownLatch.countDown() - } - } - } - - // Done - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - - // Receiver's side - // Makes sure it can only be decrypted by the target user - - assertNotNull(giftWrapEventToSender) - assertNotNull(giftWrapEventToReceiver) - - val countDownDecryptLatch = CountDownLatch(2) - - giftWrapEventToSender!!.cachedGift(sender) { unwrappedMsgForSenderBySender -> - assertEquals(SealedGossipEvent.kind, unwrappedMsgForSenderBySender.kind) - assertTrue(unwrappedMsgForSenderBySender is SealedGossipEvent) - - if (unwrappedMsgForSenderBySender is SealedGossipEvent) { - unwrappedMsgForSenderBySender.cachedGossip(sender) { unwrappedGossipToSenderBySender -> - assertEquals("Hi There!", unwrappedGossipToSenderBySender.content) - countDownDecryptLatch.countDown() - } - - unwrappedMsgForSenderBySender.cachedGossip(receiver) { _ -> - fail("Should not be able to decrypt msg for the sender by the sender but decrypted with receiver") - } - } - } - - giftWrapEventToReceiver!!.cachedGift(sender) { _ -> - fail("Should not be able to decrypt msg for the receiver decrypted by the sender") - } - - giftWrapEventToSender!!.cachedGift(receiver) { _ -> - fail("Should not be able to decrypt msg for the sender decrypted by the receiver") - } - - giftWrapEventToReceiver!!.cachedGift(receiver) { unwrappedMsgForReceiverByReceiver -> - assertEquals(SealedGossipEvent.kind, unwrappedMsgForReceiverByReceiver.kind) - assertTrue(unwrappedMsgForReceiverByReceiver is SealedGossipEvent) - - if (unwrappedMsgForReceiverByReceiver is SealedGossipEvent) { - unwrappedMsgForReceiverByReceiver.cachedGossip(receiver) { unwrappedGossipToReceiverByReceiver -> - assertEquals("Hi There!", unwrappedGossipToReceiverByReceiver?.content) - countDownDecryptLatch.countDown() - } - - unwrappedMsgForReceiverByReceiver.cachedGossip(sender) { unwrappedGossipToReceiverBySender -> - fail("Should not be able to decrypt msg for the receiver by the receiver but decrypted with the sender") - } - } - } - - assertTrue(countDownDecryptLatch.await(1, TimeUnit.SECONDS)) - } - - @Test() - fun testInternalsGroupMessage() { - val sender = NostrSignerInternal(KeyPair()) - val receiverA = NostrSignerInternal(KeyPair()) - val receiverB = NostrSignerInternal(KeyPair()) - - val countDownLatch = CountDownLatch(3) - - var giftWrapEventToSender: GiftWrapEvent? = null - var giftWrapEventToReceiverA: GiftWrapEvent? = null - var giftWrapEventToReceiverB: GiftWrapEvent? = null - - ChatMessageEvent.create( - msg = "Who is going to the party tonight?", - to = listOf(receiverA.pubKey, receiverB.pubKey), - signer = sender - ) { senderMessage -> - SealedGossipEvent.create( - event = senderMessage, - encryptTo = receiverA.pubKey, - signer = sender - ) { msgFromSenderToReceiverA -> - // Should expose sender - assertEquals(msgFromSenderToReceiverA.pubKey, sender.pubKey) - // Should not expose receiver - assertTrue(msgFromSenderToReceiverA.tags.isEmpty()) - - GiftWrapEvent.create( - event = msgFromSenderToReceiverA, - recipientPubKey = receiverA.pubKey - ) { giftWrapForReceiverA -> - // Should not be signed by neither sender nor receiver - assertNotEquals(giftWrapForReceiverA.pubKey, sender.pubKey) - assertNotEquals(giftWrapForReceiverA.pubKey, receiverA.pubKey) - assertNotEquals(giftWrapForReceiverA.pubKey, receiverB.pubKey) - - // Should not include sender as recipient - assertNotEquals(giftWrapForReceiverA.recipientPubKey(), sender.pubKey) - - // Should be addressed to the receiver - assertEquals(giftWrapForReceiverA.recipientPubKey(), receiverA.pubKey) - - giftWrapEventToReceiverA = giftWrapForReceiverA - - countDownLatch.countDown() - } - } - - SealedGossipEvent.create( - event = senderMessage, - encryptTo = receiverB.pubKey, - signer = sender - ) { msgFromSenderToReceiverB -> - // Should expose sender - assertEquals(msgFromSenderToReceiverB.pubKey, sender.pubKey) - // Should not expose receiver - assertTrue(msgFromSenderToReceiverB.tags.isEmpty()) - - GiftWrapEvent.create( - event = msgFromSenderToReceiverB, - recipientPubKey = receiverB.pubKey - ) { giftWrapForReceiverB -> - // Should not be signed by neither sender nor receiver - assertNotEquals(giftWrapForReceiverB.pubKey, sender.pubKey) - assertNotEquals(giftWrapForReceiverB.pubKey, receiverA.pubKey) - assertNotEquals(giftWrapForReceiverB.pubKey, receiverB.pubKey) - - // Should not include sender as recipient - assertNotEquals(giftWrapForReceiverB.recipientPubKey(), sender.pubKey) - - // Should be addressed to the receiver - assertEquals(giftWrapForReceiverB.recipientPubKey(), receiverB.pubKey) - - giftWrapEventToReceiverB = giftWrapForReceiverB - - countDownLatch.countDown() - } - } - - SealedGossipEvent.create( - event = senderMessage, - encryptTo = sender.pubKey, - signer = sender - ) { msgFromSenderToSender -> - // Should expose sender - assertEquals(msgFromSenderToSender.pubKey, sender.pubKey) - // Should not expose receiver - assertTrue(msgFromSenderToSender.tags.isEmpty()) - - GiftWrapEvent.create( - event = msgFromSenderToSender, - recipientPubKey = sender.pubKey - ) { giftWrapToSender -> - // Should not be signed by neither the sender, not the receiver - assertNotEquals(giftWrapToSender.pubKey, sender.pubKey) - assertNotEquals(giftWrapToSender.pubKey, receiverA.pubKey) - assertNotEquals(giftWrapToSender.pubKey, receiverB.pubKey) - - // Should not be addressed to the receiver - assertNotEquals(giftWrapToSender.recipientPubKey(), receiverA.pubKey) - assertNotEquals(giftWrapToSender.recipientPubKey(), receiverB.pubKey) - // Should be addressed to the sender - assertEquals(giftWrapToSender.recipientPubKey(), sender.pubKey) - - giftWrapEventToSender = giftWrapToSender - - countDownLatch.countDown() - } - } - } - - // Done - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) - - // Receiver's side - // Makes sure it can only be decrypted by the target user - - assertNotNull(giftWrapEventToSender) - assertNotNull(giftWrapEventToReceiverA) - assertNotNull(giftWrapEventToReceiverB) - - val countDownDecryptLatch = CountDownLatch(3) - - giftWrapEventToSender?.cachedGift(sender) { unwrappedMsgForSenderBySender -> - assertEquals(SealedGossipEvent.kind, unwrappedMsgForSenderBySender.kind) - - if (unwrappedMsgForSenderBySender is SealedGossipEvent) { - unwrappedMsgForSenderBySender.cachedGossip(receiverA) { unwrappedGossipToSenderByReceiverA -> - fail() - } - - unwrappedMsgForSenderBySender.cachedGossip(receiverB) { unwrappedGossipToSenderByReceiverB -> - fail() - } - - unwrappedMsgForSenderBySender.cachedGossip(sender) { unwrappedGossipToSenderBySender -> - assertEquals("Who is going to the party tonight?", unwrappedGossipToSenderBySender.content) - } - } - - countDownDecryptLatch.countDown() - } - - giftWrapEventToReceiverA!!.cachedGift(sender) { unwrappedMsgForReceiverBySenderA -> - fail("Should not be able to decode msg to the receiver A with the sender's key") - } - - giftWrapEventToReceiverB!!.cachedGift(sender) { unwrappedMsgForReceiverBySenderB -> - fail("Should not be able to decode msg to the receiver B with the sender's key") - } - - - - giftWrapEventToSender!!.cachedGift(receiverA) { - fail("Should not be able to decode msg to sender with the receiver A's key") - } - - giftWrapEventToReceiverA!!.cachedGift(receiverA) { unwrappedMsgForReceiverAByReceiverA -> - assertEquals(SealedGossipEvent.kind, unwrappedMsgForReceiverAByReceiverA.kind) - - if (unwrappedMsgForReceiverAByReceiverA is SealedGossipEvent) { - unwrappedMsgForReceiverAByReceiverA.cachedGossip(receiverA) { unwrappedGossipToReceiverAByReceiverA -> - assertEquals("Who is going to the party tonight?", unwrappedGossipToReceiverAByReceiverA.content) - } - - unwrappedMsgForReceiverAByReceiverA.cachedGossip(sender) { unwrappedGossipToReceiverABySender -> - fail() - } - - unwrappedMsgForReceiverAByReceiverA.cachedGossip(receiverB) { unwrappedGossipToReceiverAByReceiverB -> - fail() - } - } - - countDownDecryptLatch.countDown() - } - - giftWrapEventToReceiverB!!.cachedGift(receiverA) { - fail("Should not be able to decode msg to sender with the receiver A's key") - } - - - giftWrapEventToSender!!.cachedGift(receiverB) { unwrappedMsgForSenderByReceiverB -> - fail("Should not be able to decode msg to sender with the receiver B's key") - } - giftWrapEventToReceiverA!!.cachedGift(receiverB) { unwrappedMsgForReceiverAByReceiverB -> - fail("Should not be able to decode msg to receiver A with the receiver B's key") - } - giftWrapEventToReceiverB!!.cachedGift(receiverB) { unwrappedMsgForReceiverBByReceiverB -> - assertEquals(SealedGossipEvent.kind, unwrappedMsgForReceiverBByReceiverB.kind) - - if (unwrappedMsgForReceiverBByReceiverB is SealedGossipEvent) { - unwrappedMsgForReceiverBByReceiverB.cachedGossip(receiverA) { unwrappedGossipToReceiverBByReceiverA -> - fail() - } - - unwrappedMsgForReceiverBByReceiverB.cachedGossip(receiverB) { unwrappedGossipToReceiverBByReceiverB -> - assertEquals("Who is going to the party tonight?", unwrappedGossipToReceiverBByReceiverB.content) - - countDownDecryptLatch.countDown() - } - - unwrappedMsgForReceiverBByReceiverB.cachedGossip(sender) { unwrappedGossipToReceiverBBySender -> - fail() - } - } - } - - assertTrue(countDownDecryptLatch.await(1, TimeUnit.SECONDS)) - } - - @Test - fun testCaseFromAmethyst1() { - val json = """ - { - "content":"{\"ciphertext\":\"AaTN5Mt7AOeMosjHeLfai89kmvW/qJ7W2VMttAwuh6hwRGV+ylJhpDbdVRhVmkCotbDjBgS6xioLrSDcdSngFOiVMHS5dTAP0MkQv09aZlBh/NgdmyfHHd24YlHPkDuF5Yb4Vmz7kq/vmjsNZvDrTen3TG2DcEoTV9GKexdMEqyBA4LsB2DLnWfpvOi0olDkGjPGSteTaU1nCdOtN8knoEKumrxwevvbygKphorvKX/j3ojMMb0AceJM6Cr6TRIvSsQnKGEv5V8qbC/uIrQoH3N108Fd/2SY2MWuyLKRnuak9F/w82MV13elq8ngyjcktLYM5yrPg5nrxZlyJsV8D7V/g/bvhoL+UmWe0XoCR5LXzy77SfIkgA1ePKEfGp5sD2CVIzXt9zHdFwGxAKZuyB4qwrRaAFrS2xx+Bw4nnEmF6V9NhfheSCmGzTILuTePx4ubvnYw/j8Hmqd6UvM3DBNnlJ3D6po0blirfWvMe/ea+Em4CMXfq8Iq+7r4gRx8azADygKeJ+C89GTBEvS9EvgrXCVfTMVTcFc44YAZhekOqYY1BOZgfxIV4gUiJfpMMd4B9MQv/tmnewrpTsq1reSQQcEW/mXT2cnMeCZbAIJSPg8usZ30QlrH+np+YSzFKWYDP1kThcV0ElEE2Ne8KaUUFIRE5KmhBQc/qtORefCpne5s7V7J5vLjT5rinsDzzENB1XVlmY1Icx42raP5tGAL1gOK5gRHLvtcgFQR3WcDRYaNqELiYxx41j9w9lz5e00Ttla255rZkb760KSLaBFBss6wYGiYCabVgtBNpkExpCFPPEd5eAZa5rNK2QrnojYsdxEnlicF6A+zSChLy/TbzxYwyQywDfoF9F8kBakPZkAhsciQViCii2KlieRq4OgJFZGndmnS82hyPqsoJIm22vWr1iqMvSBHo/9cLj/r+lfmGVOdgM62JHckPZjOLS0QWIb9gQiT+zXZG22+eZElMYbGXVpR1dyMaQtde8ivEVVLas6kMCVKaDTHEFglaCBXjJ3RNJv73HsG1kb0rMmOj8ltbBakjHpv7M59amavuu6SReYt\",\"nonce\":\"6anNjUdNwW6MNfoKzRZcz1R09N1h8G4L\",\"v\":1}", - "created_at":1690660515, - "id":"d90739741c2f5a8c1a03aab5dc219c0b708ed6b0566044495731cd0307cf19a5", - "kind":1059, - "pubkey":"a79b7162f8ebb9c9f7aa65a48977ab7f32aa097520bc543e4d625812154ff6af", - "sig":"9b012504e779632a2a1f55562fa9a85f8ae6245cbc149b83d25b2971249053abc77f65cc068e5d025b871d743678265fede70de4eaf5af642e675a5b6210077d", - "tags":[ - [ - "p", - "c55f0b0cb4dd180dd4395867b28dd4c208b709144a69fb7c31a46c369a5ad1c6" - ] - ] - } - """.trimIndent() - - var gossip: Event? = null - - wait1SecondForResult { onDone -> - val privateKey = "de6152a85a0dea3b09a08a6f8139a314d498a7b52f7e5c28858b64270abd4c70" - unwrapUnsealGossip(json, privateKey) { - gossip = it - onDone() - } - } - - assertNotNull(gossip) - assertEquals("Hola, que tal?", gossip?.content) - } - - @Test - fun testCaseFromAmethyst2() { - val json = """ -{ - "content":"{\"ciphertext\":\"Zb0ZNYAcDG5y7BiCWgbxY/i7rN7TxPwr3Oaste6em4VcetuenaMu2SyH6OuCCxxmIa7kFennJD8ZCrev0086azsPNutl9I6OCoOfDQb2GoFaLoJAkE/FuW0uEoEJuN72KsKj05HEjOM6nqL2KiW0pxTCNmlGpweMwpXQdm2ItWkybNpq8+b4NJUDee2czBUd9Kr2ELbPISTYzA17z1IzPXGQw8c73NL+QX9I/QZjM/agqX2x5q11SU52xiRyVd9zHf7TMctZI4QEsqDB6xi54D1bAeZlMhVdcpQRpGDfqRz3KXFlhB3Bwdc8GLgY0aLTn6tJs4qrHP3mQkxFYk0mju0afoc0rloMEUHcBVtM18S9OrTPqfmSqFTQsjaT8g+PkmeiLBo1sXsMCS62w0abSZD9OzQtciMz70ZpcWoLjx5f8panjFClvg4tJ8czMURIHM/IFS1uKAUHBArGN8QpCw8MXQBblpyLDiEkFcSX334Zdps0OIw4z328JSdeejyRh4ks+NHDt9FcjC4iicEqfEh8OTkXuKqEAVkRyfAioNQxWQPnXDzMX0Q+BXvKzBA7NaEBDpbV36H/KnrpBBQwokV9/Byb6Seh3g6GSqRAWD3U6Nk2aBMXkD0xY8vnIqMckBeYHxn8BW7k1FdXFC9lE5xCxWZHkmksJ4f0NVaF37O6d8qOe6RK7bfUeF8/SouJEu+eEX1f4KCMboslwkdk8QA8bThGcRGn8GQBMrPKrpZwHYNyyH8jwt9pywigXJejRLDDnDp3FH/3dbZy5CfuNH6KGydf/O5xx1r316so1UPO1mL5LHJUFZVIaMaMMUsgq12gpI0lLEh5NJPpsi9e3ibkzEZGf7FlAJjJQURbQ8xacN7R+w3GWKbJNHiQbUZ2lXo6fwz33t0DrSqEW970yWPHlqxcpd27EI+qqb5IqfklQZ3RObZZBhzDvImaCPG+U7SmgLhPxnilpGjd5lw/ttiqJhPG9mYFMf1eJXSG+Q9VVkGzN7jxXYtx0q0WGjVq98ZGv5RSnF1d9+QVGCd1fiPS3rsaWdYWly8l0y2quYObJ6Mv3Wh3\",\"nonce\":\"/Q2UTTjVZthm/atcCuDjU1e4reF+ZSgZ\",\"v\":1}", - "created_at":1690660515, - "id":"087d9627d63135a5050758a69222e566c86702e930c9905f0b93ccd6bebeca3f", - "kind":1059, - "pubkey":"e59c00796ae2aa9077fc8bcd57fe8d32c0fc363f7c8b93d055c70804ffff3772", - "sig":"807cb641c314ca6910aaeefadcf87d859137520be1039eb40e39832ed59d456fdd800c5f88bba09e1b395ee90c66d5330847bdd010b63be9919bf091adbc2c2a", - "tags":[ - [ - "p", - "f85f315c06aaf19c2b30a96ca80d9644720655ee8d3ec43b84657a7c98f36a23" - ] - ] -} - """.trimIndent() - - val privateKey = "409ff7654141eaa16cd2161fe5bd127aeaef71f270c67587474b78998a8e3533" - - var gossip: Event? = null - - wait1SecondForResult { onDone -> - unwrapUnsealGossip(json, privateKey) { - gossip = it - onDone() - } - } - - assertNotNull(gossip) - assertEquals("Hola, que tal?", gossip?.content) - } - - @Test - fun testDecryptFromCoracle() { - val json = """ -{ - "content": "{\"ciphertext\":\"fo0/Ywyfu86cXKoOOeFFlMRv+LYM6GJUL+F/J4ARv6EcAufOZP46vimlurPBLPjNgzuGemGjlTFfC3vtq84AqIsqFo3dqKunq8Vp+mmubvxIQUDzOGYvM0WE/XOiW5LEe3U3Vq399Dq07xRpXELcp4EZxGyu4Fowv2Ppb4SKpH8g+9N3z2+bwYcSxBvI6SrL+hgmVMrRlgvsUHN1d53ni9ehRseBqrLj/DqyyEiygsKm6vnEZAPKnl1MrBaVOBZmGsyjAa/G4SBVVmk78sW7xWWvo4cV+C22FluDWUOdj/bYabH4aR4scnBX3GLYqfLuzGnuQlRNsb5unXVX41+39uXzROrmNP6iYVyYxy5tfoyN7PPZ4osoKpLDUGldmXHD6RjMcAFuou4hXt2JlTPmXpj/x8qInXId5mkmU4nTGiasvsCIpJljbCujwCjbjLTcD4QrjuhMdtSsAzjT0CDv5Lmc632eKRYtDu/9B+lkqBBkp7amhzbqp8suNTnybkvbGFQQGEQnsLfNJw/GGopAuthfi8zkTgUZR/LxFR7ZKAX73G+5PQSDSjPuGH/dQEnsFo45zsh1Xro8SfUQBsPphbX2GS31Lwu5vA30O922T4UiWuU+EdNgZR0JankQ5NPgvr1uS56C3v84VwdrNWQUCwC4eYJl4Mb/OdpEy9qwsisisppq6uuzxmxd1qx3JfocnGsvB7h2g2sG+0lyZADDSobOEZEKHaBP3w+dRcJW9D95EmzPym9GO0n+33OfqFQbda7G0rzUWfPDV0gXIuZcKs/HmDqepgIZN8FG7JhRBeAv0bCbKQACre0c8tzVEn5yCYemltScdKop3pC/r6gH50jRhAlFAiIKx8R+XwuMmJRqOcH4WfkpZlfVU85/I0XJOCHWKk6BnJi/NPP9zYiZiJe+5LecqMUVjtO0YAlv138+U/3FIT/anQ4H5bjVWBZmajwf\",\"nonce\":\"Mv70S6jgrs4D1rlqV9b5DddiymGAcVVe\",\"v\":1}", - "created_at": 1690528373, - "id": "6b108e4236c3c338236ee589388ce0f91f921e1532ae52e75d1d2add6f8e691a", - "kind": 1059, - "pubkey": "627dc0248335e2bf9adac14be9494139ebbeb12c422d7df5b0e3cd72d04c209c", - "sig": "be11da487196db298e4ffb7a03e74176c37441c88b39c95b518fadce6fd02f23c58b2c435ca38c24d512713935ab719dae80bf952267630809e1f84be8e95174", - "tags": [ - [ - "p", - "e7764a227c12ac1ef2db79ae180392c90903b2cec1e37f5c1a4afed38117185e" - ] - ], - "seenOn": [ - "wss://relay.damus.io/" - ] -} - """.trimIndent() - - val privateKey = "09e0051fdf5fdd9dd7a54713583006442cbdbf87bdcdab1a402f26e527d56771" - var gossip: Event? = null - - wait1SecondForResult { onDone -> - unwrapUnsealGossip(json, privateKey) { - gossip = it - onDone() - } - } - - assertNotNull(gossip) - assertEquals("test", gossip?.content) - } - - @Test - fun testFromCoracle2() { - val json = """ -{ - "content": "{\"ciphertext\":\"Hn/dHo/I8Qk6QWWAiKyo/SfKJqQfHdV0O5tMmgqMyfHrsFoDY6IhGQP2EgCJ/6HsNQyO/8EMAmLW8w0PbDKlBKYGKGpaMwCA6B1r0rLjvu+149RJZuggRNm9rd7tNVNkNs38iqt1KYD++bohePm52q+VhAQikbX2gTONV82ROwZylAg9vjvMnYkDt45g6N97s9FRB6V7YMiUEtJnneMixa6klucpUuenQ4569tyt5vnUMD2VNhKYCc2jit2hf7k0DIhvZrVC3OdopUvxIuYYWr3r7XpuEB3HJ6Ji3ajHPzgGeFcItBR7uKZ9s6XU34F3keyZbxrv3yWHFM5NrOctAdZexSGpqWRW93M0KZUAp9HgQh3YzMLl8xt0mcrVywCgjU6Kx8IwkI0bjPU+Am8acY3cItted6hZQ4Vy1xFITdKVfPWDl3Ab59iBg9+IkY5C31wqsKPgPVVycwQE6UpaGW74gy3qZshwyoo01owvEIbVvrSJWXH7EUVvndDPvUbo+f+EVa84IEwVjPmY2oR7VsxVfqRBdmPg23OSw/9rzVybmruqaQHd3xrTTEcnG0qBc/ugCXsiuILTeScOovEnqIlKKK3KB36jMtdScdJB+b4YrzJInY1AvqU7IAgqe0vmo1LdbMtj7kjuxkXJhhQsunAbTvPigTrsOfJ08P9l7r/95kpxudgagEaW7XAjYVfLphseJT3Iy1IuQEyG5sshQ+pl/CYvkGide7ykHwm9pjSBVkD9Mdcn5X6lSnLNJEcwY43pz43r6Kq3L09qneILY3DSKyQ16Zcu1MiAMAM5r6JGvpAHqcMmixi9ORuiryjteTmY4L0vI7b3W/0RSUblXxUrb8IpeysBrFmiKJBgCoU0r/D/8tgR+Eewyp1qxKI4SfKG5GFH40zZ2oVvKyoHAR4x1oVDp/MttcnxkzAsCFL6QuJC9A/vImjsumpmYB/EChcZCOAsfqkuzH4VSjZx\",\"nonce\":\"K537d+7m5tUcXZfkr3Qk2J2G86vdBMmY\",\"v\":1}", - "created_at": 1690655012, - "id": "c4f97c6332b0a63912c44c9e1f8c7b23581dc67a8489ec1522ec205fea7133db", - "kind": 1059, - "pubkey": "8def03a22b1039256a3883d46c7ccd5562f61743100db401344284547de7ec61", - "sig": "25dcf24bdda99c04abc72274d9f7a30538a4a00a70ac4b39db4082b73823979858df93cd649c25edfb759857eac46ed70bb9ad0598f2e011d733a5a382bc4def", - "tags": [ - [ - "p", - "e7764a227c12ac1ef2db79ae180392c90903b2cec1e37f5c1a4afed38117185e" - ] - ] -} - """.trimIndent() - - val privateKey = "09e0051fdf5fdd9dd7a54713583006442cbdbf87bdcdab1a402f26e527d56771" - - var gossip: Event? = null - - wait1SecondForResult { onDone -> - unwrapUnsealGossip(json, privateKey) { - gossip = it - onDone() - } - } - - assertEquals("asdfasdfasdf", gossip?.content) - assertEquals(1690659269L, gossip?.createdAt) - assertEquals("827ba09d32ab81d62c60f657b350198c8aaba84372dab9ad3f4f6b8b7274b707", gossip?.id) - assertEquals(14, gossip?.kind) - assertEquals("subject", gossip?.tags?.firstOrNull()?.get(0)) - assertEquals("test", gossip?.tags?.firstOrNull()?.get(1)) - } - - @Test - fun testFromCoracle3() { - val json = """ -{ - "content": "{\"ciphertext\":\"PGCodiacmCB/sClw0C6DQRpP/XIfNAKCVZdKLQpOqytgbFryhs9Z+cPldq2FajXzxs3jQsF7/EVQyWpuV8pXetvFT9tvzjg4Xcm7ZcooLUnAeAo2xZNcJontN4cGubuDqKuXy5n59yXP1fIxfnJxRTRRdCZ2edhsKeNR5NSByUi+StjV10rnfHt8AhZCpiXiZ/giTOsC4wdaeONPgMzMeljaJWLvl6n11VjmXhkx1mXIQt43CNB1hIqO3p89Mbd9p+nlLrOsR+Xs0TB4DCh4XTPbvgf7B7Z+PgOfl3GZfJy9x6TciLcF4E3Ba1zrPe4f79czCIEiJ1yrIKrzzYvv+it35DZQ8fgveFXpyHnNL29hml8PNjyOsFbCHVYLMGw88evI5PijOcpe1TtdoioX8kX5kVEQSKJXuoSjTorvbRPCgGzaa1m0J0uTpzri5VD22a/Jh2CcAnubg6w4JDdUWCogdSV3NqiJllo7ZF7WnZ3apPdRD23MEfphVBJrcLBUNlmwajnY5IvVTKTkZOP50r9dBapvMWXIo6M6zhy/5vVWJz57863pelYCRG4upaXZuNK9sMBtbiphxmFR83i8RML8KN8Q391Cd/xBN7TxJNo5p2YU25VeGZUAmHY8DYlMQDm8Br0nStAXp3T+DzTRL8FTECa8DJV+KTAPoCxqhv3B28Ehr0XAP75CsHoLU00G48cR7h3vQ0CnfKh6KXU6nnDA5OWfpMYpirACCpsnpSD0OaCQ3gkQp3zZNMS3HcOpnPK/IY7R0esbzgAkvNhkyxaIfPDdf+eRUSOA9+2Ji28MwjjY8Dw3SLdUqCOzIDjQeR/T5oNmaQJm3lZ8G0FxxC6ejD4VJX/NI/x+STeB9jWHWmHZvqKzV6JHNh6qmZb6TKSIPOHpafWFoeJFOmiiigf46sju9vRXmVEAx59HXWnvnvCBNJg877yCMulB6xyQuSdVDuotQU4tQZwCKedTHJ6GqjesM98UlJrDtdWQURwwW1qc7N8tS6PukmUVEf0jmbIWVIBmUlkcVuiSs1g1h1kjt8c4MnGTz3CSgpOd1MqxLrl9WwrTqM+YnE+yeZYUjFoewyKZIQ==\",\"nonce\":\"OdCZczJiUGR4bOGIElQ4UUH4dQmG5U/3\",\"v\":1}", - "kind": 1059, - "created_at": 1690772945, - "pubkey": "e01475e87896800b7285eb0daf263c59f811c8fc5bc8daa105d2c98b6d7c4952", - "tags": [ - [ - "p", - "b08d8857a92b4d6aa580ff55cc3c18c4edf313c83388c34abc118621f74f1a78" - ] - ], - "id": "d9fc85ece892ce45ffa737b3ddc0f8b752623181d75363b966191f8c03d2debe", - "sig": "1b20416b83f4b5b8eead11e29c185f46b5e76d1960e4505210ddd00f7a6973cc11268f52a8989e3799b774d5f3a55db95bed4d66a1b6e88ab54becec5c771c17" -} - """.trimIndent() - - val privateKey = "7dd22cafc512c0bc363a259f6dcda515b13ae3351066d7976fd0bb79cbd0d700" - - var gossip: Event? = null - - wait1SecondForResult { onDone -> - unwrapUnsealGossip(json, privateKey) { - gossip = it - onDone() - } - } - - assertEquals("8d1a56008d4e31dae2fb8bef36b3efea519eff75f57033107e2aa16702466ef2", gossip?.id) - assertEquals("Howdy", gossip?.content) - assertEquals(1690833960L, gossip?.createdAt) - assertEquals(14, gossip?.kind) - assertEquals("p", gossip?.tags?.firstOrNull()?.get(0)) - assertEquals("b08d8857a92b4d6aa580ff55cc3c18c4edf313c83388c34abc118621f74f1a78", gossip?.tags?.firstOrNull()?.get(1)) - assertEquals("subject", gossip?.tags?.getOrNull(1)?.get(0)) - assertEquals("Stuff", gossip?.tags?.getOrNull(1)?.get(1)) - } - - fun unwrapUnsealGossip(json: String, privateKey: HexKey, onReady: (Event) -> Unit) { - val pkBytes = NostrSignerInternal(KeyPair(privateKey.hexToByteArray())) - - val wrap = Event.fromJson(json) as GiftWrapEvent - wrap.checkSignature() - - assertEquals(pkBytes.pubKey, wrap.recipientPubKey()) - - wrap.cachedGift(pkBytes) { event -> + assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + } + + @Test() + fun testNip24UtilsForGroups() { + val sender = NostrSignerInternal(KeyPair()) + val receiver1 = NostrSignerInternal(KeyPair()) + val receiver2 = NostrSignerInternal(KeyPair()) + val receiver3 = NostrSignerInternal(KeyPair()) + val receiver4 = NostrSignerInternal(KeyPair()) + val message = "Hola, que tal?" + + val receivers = + listOf( + receiver1, + receiver2, + receiver3, + receiver4, + ) + + val countDownLatch = CountDownLatch(receivers.size + 2) + + NIP24Factory().createMsgNIP24( + message, + receivers.map { it.pubKey }, + sender, + ) { events -> + countDownLatch.countDown() + + // Simulate Receiver + receivers.forEach { receiver -> + val eventsReceiverGets = events.wraps.filter { it.isTaggedUser(receiver.pubKey) } + eventsReceiverGets.forEach { + it.cachedGift(receiver) { event -> if (event is SealedGossipEvent) { - event.cachedGossip(pkBytes, onReady) + event.cachedGossip(receiver) { innerData -> + countDownLatch.countDown() + assertEquals(message, innerData.content) + } } else { - println(event.toJson()) - fail("Event is not a Sealed Gossip") + fail("Wrong Event") } + } } + } + + // Simulate Sender + val eventsSenderGets = events.wraps.filter { it.isTaggedUser(sender.pubKey) } + eventsSenderGets.forEach { + it.cachedGift(sender) { event -> + if (event is SealedGossipEvent) { + event.cachedGossip(sender) { innerData -> + countDownLatch.countDown() + assertEquals(message, innerData.content) + } + } else { + fail("Wrong Event") + } + } + } } - @Test - fun decryptMsgFromNostrTools() { - val receiversPrivateKey = NostrSignerInternal(KeyPair(Hex.decode("df51ec558372612918a83446d279d683039bece79b7a721274b1d3cb612dc6af"))) - val msg = """ + assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + } + + @Test() + fun testInternalsSimpleMessage() { + val sender = NostrSignerInternal(KeyPair()) + val receiver = NostrSignerInternal(KeyPair()) + + val countDownLatch = CountDownLatch(2) + + var giftWrapEventToSender: GiftWrapEvent? = null + var giftWrapEventToReceiver: GiftWrapEvent? = null + + ChatMessageEvent.create( + msg = "Hi There!", + to = listOf(receiver.pubKey), + signer = sender, + ) { senderMessage -> + // MsgFor the Receiver + + SealedGossipEvent.create( + event = senderMessage, + encryptTo = receiver.pubKey, + signer = sender, + ) { encMsgFromSenderToReceiver -> + // Should expose sender + assertEquals(encMsgFromSenderToReceiver.pubKey, sender.pubKey) + // Should not expose receiver + assertTrue(encMsgFromSenderToReceiver.tags.isEmpty()) + + GiftWrapEvent.create( + event = encMsgFromSenderToReceiver, + recipientPubKey = receiver.pubKey, + ) { giftWrapToReceiver -> + // Should not be signed by neither sender nor receiver + assertNotEquals(giftWrapToReceiver.pubKey, sender.pubKey) + assertNotEquals(giftWrapToReceiver.pubKey, receiver.pubKey) + + // Should not include sender as recipient + assertNotEquals(giftWrapToReceiver.recipientPubKey(), sender.pubKey) + + // Should be addressed to the receiver + assertEquals(giftWrapToReceiver.recipientPubKey(), receiver.pubKey) + + giftWrapEventToReceiver = giftWrapToReceiver + + countDownLatch.countDown() + } + } + + // MsgFor the Sender + SealedGossipEvent.create( + event = senderMessage, + encryptTo = sender.pubKey, + signer = sender, + ) { encMsgFromSenderToSender -> + // Should expose sender + assertEquals(encMsgFromSenderToSender.pubKey, sender.pubKey) + // Should not expose receiver + assertTrue(encMsgFromSenderToSender.tags.isEmpty()) + + GiftWrapEvent.create( + event = encMsgFromSenderToSender, + recipientPubKey = sender.pubKey, + ) { giftWrapToSender -> + // Should not be signed by neither the sender, not the receiver + assertNotEquals(giftWrapToSender.pubKey, sender.pubKey) + assertNotEquals(giftWrapToSender.pubKey, receiver.pubKey) + + // Should not be addressed to the receiver + assertNotEquals(giftWrapToSender.recipientPubKey(), receiver.pubKey) + // Should be addressed to the sender + assertEquals(giftWrapToSender.recipientPubKey(), sender.pubKey) + + giftWrapEventToSender = giftWrapToSender + + countDownLatch.countDown() + } + } + } + + // Done + assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + + // Receiver's side + // Makes sure it can only be decrypted by the target user + + assertNotNull(giftWrapEventToSender) + assertNotNull(giftWrapEventToReceiver) + + val countDownDecryptLatch = CountDownLatch(2) + + giftWrapEventToSender!!.cachedGift(sender) { unwrappedMsgForSenderBySender -> + assertEquals(SealedGossipEvent.KIND, unwrappedMsgForSenderBySender.kind) + assertTrue(unwrappedMsgForSenderBySender is SealedGossipEvent) + + if (unwrappedMsgForSenderBySender is SealedGossipEvent) { + unwrappedMsgForSenderBySender.cachedGossip(sender) { unwrappedGossipToSenderBySender -> + assertEquals("Hi There!", unwrappedGossipToSenderBySender.content) + countDownDecryptLatch.countDown() + } + + unwrappedMsgForSenderBySender.cachedGossip(receiver) { _ -> + fail( + "Should not be able to decrypt msg for the sender by the sender but decrypted with receiver", + ) + } + } + } + + giftWrapEventToReceiver!!.cachedGift(sender) { _ -> + fail("Should not be able to decrypt msg for the receiver decrypted by the sender") + } + + giftWrapEventToSender!!.cachedGift(receiver) { _ -> + fail("Should not be able to decrypt msg for the sender decrypted by the receiver") + } + + giftWrapEventToReceiver!!.cachedGift(receiver) { unwrappedMsgForReceiverByReceiver -> + assertEquals(SealedGossipEvent.KIND, unwrappedMsgForReceiverByReceiver.kind) + assertTrue(unwrappedMsgForReceiverByReceiver is SealedGossipEvent) + + if (unwrappedMsgForReceiverByReceiver is SealedGossipEvent) { + unwrappedMsgForReceiverByReceiver.cachedGossip(receiver) { + unwrappedGossipToReceiverByReceiver -> + assertEquals("Hi There!", unwrappedGossipToReceiverByReceiver?.content) + countDownDecryptLatch.countDown() + } + + unwrappedMsgForReceiverByReceiver.cachedGossip(sender) { unwrappedGossipToReceiverBySender, + -> + fail( + "Should not be able to decrypt msg for the receiver by the receiver but decrypted with the sender", + ) + } + } + } + + assertTrue(countDownDecryptLatch.await(1, TimeUnit.SECONDS)) + } + + @Test() + fun testInternalsGroupMessage() { + val sender = NostrSignerInternal(KeyPair()) + val receiverA = NostrSignerInternal(KeyPair()) + val receiverB = NostrSignerInternal(KeyPair()) + + val countDownLatch = CountDownLatch(3) + + var giftWrapEventToSender: GiftWrapEvent? = null + var giftWrapEventToReceiverA: GiftWrapEvent? = null + var giftWrapEventToReceiverB: GiftWrapEvent? = null + + ChatMessageEvent.create( + msg = "Who is going to the party tonight?", + to = listOf(receiverA.pubKey, receiverB.pubKey), + signer = sender, + ) { senderMessage -> + SealedGossipEvent.create( + event = senderMessage, + encryptTo = receiverA.pubKey, + signer = sender, + ) { msgFromSenderToReceiverA -> + // Should expose sender + assertEquals(msgFromSenderToReceiverA.pubKey, sender.pubKey) + // Should not expose receiver + assertTrue(msgFromSenderToReceiverA.tags.isEmpty()) + + GiftWrapEvent.create( + event = msgFromSenderToReceiverA, + recipientPubKey = receiverA.pubKey, + ) { giftWrapForReceiverA -> + // Should not be signed by neither sender nor receiver + assertNotEquals(giftWrapForReceiverA.pubKey, sender.pubKey) + assertNotEquals(giftWrapForReceiverA.pubKey, receiverA.pubKey) + assertNotEquals(giftWrapForReceiverA.pubKey, receiverB.pubKey) + + // Should not include sender as recipient + assertNotEquals(giftWrapForReceiverA.recipientPubKey(), sender.pubKey) + + // Should be addressed to the receiver + assertEquals(giftWrapForReceiverA.recipientPubKey(), receiverA.pubKey) + + giftWrapEventToReceiverA = giftWrapForReceiverA + + countDownLatch.countDown() + } + } + + SealedGossipEvent.create( + event = senderMessage, + encryptTo = receiverB.pubKey, + signer = sender, + ) { msgFromSenderToReceiverB -> + // Should expose sender + assertEquals(msgFromSenderToReceiverB.pubKey, sender.pubKey) + // Should not expose receiver + assertTrue(msgFromSenderToReceiverB.tags.isEmpty()) + + GiftWrapEvent.create( + event = msgFromSenderToReceiverB, + recipientPubKey = receiverB.pubKey, + ) { giftWrapForReceiverB -> + // Should not be signed by neither sender nor receiver + assertNotEquals(giftWrapForReceiverB.pubKey, sender.pubKey) + assertNotEquals(giftWrapForReceiverB.pubKey, receiverA.pubKey) + assertNotEquals(giftWrapForReceiverB.pubKey, receiverB.pubKey) + + // Should not include sender as recipient + assertNotEquals(giftWrapForReceiverB.recipientPubKey(), sender.pubKey) + + // Should be addressed to the receiver + assertEquals(giftWrapForReceiverB.recipientPubKey(), receiverB.pubKey) + + giftWrapEventToReceiverB = giftWrapForReceiverB + + countDownLatch.countDown() + } + } + + SealedGossipEvent.create( + event = senderMessage, + encryptTo = sender.pubKey, + signer = sender, + ) { msgFromSenderToSender -> + // Should expose sender + assertEquals(msgFromSenderToSender.pubKey, sender.pubKey) + // Should not expose receiver + assertTrue(msgFromSenderToSender.tags.isEmpty()) + + GiftWrapEvent.create( + event = msgFromSenderToSender, + recipientPubKey = sender.pubKey, + ) { giftWrapToSender -> + // Should not be signed by neither the sender, not the receiver + assertNotEquals(giftWrapToSender.pubKey, sender.pubKey) + assertNotEquals(giftWrapToSender.pubKey, receiverA.pubKey) + assertNotEquals(giftWrapToSender.pubKey, receiverB.pubKey) + + // Should not be addressed to the receiver + assertNotEquals(giftWrapToSender.recipientPubKey(), receiverA.pubKey) + assertNotEquals(giftWrapToSender.recipientPubKey(), receiverB.pubKey) + // Should be addressed to the sender + assertEquals(giftWrapToSender.recipientPubKey(), sender.pubKey) + + giftWrapEventToSender = giftWrapToSender + + countDownLatch.countDown() + } + } + } + + // Done + assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) + + // Receiver's side + // Makes sure it can only be decrypted by the target user + + assertNotNull(giftWrapEventToSender) + assertNotNull(giftWrapEventToReceiverA) + assertNotNull(giftWrapEventToReceiverB) + + val countDownDecryptLatch = CountDownLatch(3) + + giftWrapEventToSender?.cachedGift(sender) { unwrappedMsgForSenderBySender -> + assertEquals(SealedGossipEvent.KIND, unwrappedMsgForSenderBySender.kind) + + if (unwrappedMsgForSenderBySender is SealedGossipEvent) { + unwrappedMsgForSenderBySender.cachedGossip(receiverA) { unwrappedGossipToSenderByReceiverA, + -> + fail() + } + + unwrappedMsgForSenderBySender.cachedGossip(receiverB) { unwrappedGossipToSenderByReceiverB, + -> + fail() + } + + unwrappedMsgForSenderBySender.cachedGossip(sender) { unwrappedGossipToSenderBySender -> + assertEquals( + "Who is going to the party tonight?", + unwrappedGossipToSenderBySender.content, + ) + } + } + + countDownDecryptLatch.countDown() + } + + giftWrapEventToReceiverA!!.cachedGift(sender) { unwrappedMsgForReceiverBySenderA -> + fail("Should not be able to decode msg to the receiver A with the sender's key") + } + + giftWrapEventToReceiverB!!.cachedGift(sender) { unwrappedMsgForReceiverBySenderB -> + fail("Should not be able to decode msg to the receiver B with the sender's key") + } + + giftWrapEventToSender!!.cachedGift(receiverA) { + fail("Should not be able to decode msg to sender with the receiver A's key") + } + + giftWrapEventToReceiverA!!.cachedGift(receiverA) { unwrappedMsgForReceiverAByReceiverA -> + assertEquals(SealedGossipEvent.KIND, unwrappedMsgForReceiverAByReceiverA.kind) + + if (unwrappedMsgForReceiverAByReceiverA is SealedGossipEvent) { + unwrappedMsgForReceiverAByReceiverA.cachedGossip(receiverA) { + unwrappedGossipToReceiverAByReceiverA -> + assertEquals( + "Who is going to the party tonight?", + unwrappedGossipToReceiverAByReceiverA.content, + ) + } + + unwrappedMsgForReceiverAByReceiverA.cachedGossip(sender) { + unwrappedGossipToReceiverABySender -> + fail() + } + + unwrappedMsgForReceiverAByReceiverA.cachedGossip(receiverB) { + unwrappedGossipToReceiverAByReceiverB -> + fail() + } + } + + countDownDecryptLatch.countDown() + } + + giftWrapEventToReceiverB!!.cachedGift(receiverA) { + fail("Should not be able to decode msg to sender with the receiver A's key") + } + + giftWrapEventToSender!!.cachedGift(receiverB) { unwrappedMsgForSenderByReceiverB -> + fail("Should not be able to decode msg to sender with the receiver B's key") + } + giftWrapEventToReceiverA!!.cachedGift(receiverB) { unwrappedMsgForReceiverAByReceiverB -> + fail("Should not be able to decode msg to receiver A with the receiver B's key") + } + giftWrapEventToReceiverB!!.cachedGift(receiverB) { unwrappedMsgForReceiverBByReceiverB -> + assertEquals(SealedGossipEvent.KIND, unwrappedMsgForReceiverBByReceiverB.kind) + + if (unwrappedMsgForReceiverBByReceiverB is SealedGossipEvent) { + unwrappedMsgForReceiverBByReceiverB.cachedGossip(receiverA) { + unwrappedGossipToReceiverBByReceiverA -> + fail() + } + + unwrappedMsgForReceiverBByReceiverB.cachedGossip(receiverB) { + unwrappedGossipToReceiverBByReceiverB -> + assertEquals( + "Who is going to the party tonight?", + unwrappedGossipToReceiverBByReceiverB.content, + ) + + countDownDecryptLatch.countDown() + } + + unwrappedMsgForReceiverBByReceiverB.cachedGossip(sender) { + unwrappedGossipToReceiverBBySender -> + fail() + } + } + } + + assertTrue(countDownDecryptLatch.await(1, TimeUnit.SECONDS)) + } + + @Test + fun testCaseFromAmethyst1() { + val json = + """ + { + "content":"{\"ciphertext\":\"AaTN5Mt7AOeMosjHeLfai89kmvW/qJ7W2VMttAwuh6hwRGV+ylJhpDbdVRhVmkCotbDjBgS6xioLrSDcdSngFOiVMHS5dTAP0MkQv09aZlBh/NgdmyfHHd24YlHPkDuF5Yb4Vmz7kq/vmjsNZvDrTen3TG2DcEoTV9GKexdMEqyBA4LsB2DLnWfpvOi0olDkGjPGSteTaU1nCdOtN8knoEKumrxwevvbygKphorvKX/j3ojMMb0AceJM6Cr6TRIvSsQnKGEv5V8qbC/uIrQoH3N108Fd/2SY2MWuyLKRnuak9F/w82MV13elq8ngyjcktLYM5yrPg5nrxZlyJsV8D7V/g/bvhoL+UmWe0XoCR5LXzy77SfIkgA1ePKEfGp5sD2CVIzXt9zHdFwGxAKZuyB4qwrRaAFrS2xx+Bw4nnEmF6V9NhfheSCmGzTILuTePx4ubvnYw/j8Hmqd6UvM3DBNnlJ3D6po0blirfWvMe/ea+Em4CMXfq8Iq+7r4gRx8azADygKeJ+C89GTBEvS9EvgrXCVfTMVTcFc44YAZhekOqYY1BOZgfxIV4gUiJfpMMd4B9MQv/tmnewrpTsq1reSQQcEW/mXT2cnMeCZbAIJSPg8usZ30QlrH+np+YSzFKWYDP1kThcV0ElEE2Ne8KaUUFIRE5KmhBQc/qtORefCpne5s7V7J5vLjT5rinsDzzENB1XVlmY1Icx42raP5tGAL1gOK5gRHLvtcgFQR3WcDRYaNqELiYxx41j9w9lz5e00Ttla255rZkb760KSLaBFBss6wYGiYCabVgtBNpkExpCFPPEd5eAZa5rNK2QrnojYsdxEnlicF6A+zSChLy/TbzxYwyQywDfoF9F8kBakPZkAhsciQViCii2KlieRq4OgJFZGndmnS82hyPqsoJIm22vWr1iqMvSBHo/9cLj/r+lfmGVOdgM62JHckPZjOLS0QWIb9gQiT+zXZG22+eZElMYbGXVpR1dyMaQtde8ivEVVLas6kMCVKaDTHEFglaCBXjJ3RNJv73HsG1kb0rMmOj8ltbBakjHpv7M59amavuu6SReYt\",\"nonce\":\"6anNjUdNwW6MNfoKzRZcz1R09N1h8G4L\",\"v\":1}", + "created_at":1690660515, + "id":"d90739741c2f5a8c1a03aab5dc219c0b708ed6b0566044495731cd0307cf19a5", + "kind":1059, + "pubkey":"a79b7162f8ebb9c9f7aa65a48977ab7f32aa097520bc543e4d625812154ff6af", + "sig":"9b012504e779632a2a1f55562fa9a85f8ae6245cbc149b83d25b2971249053abc77f65cc068e5d025b871d743678265fede70de4eaf5af642e675a5b6210077d", + "tags":[ + [ + "p", + "c55f0b0cb4dd180dd4395867b28dd4c208b709144a69fb7c31a46c369a5ad1c6" + ] + ] + } + """ + .trimIndent() + + var gossip: Event? = null + + wait1SecondForResult { onDone -> + val privateKey = "de6152a85a0dea3b09a08a6f8139a314d498a7b52f7e5c28858b64270abd4c70" + unwrapUnsealGossip(json, privateKey) { + gossip = it + onDone() + } + } + + assertNotNull(gossip) + assertEquals("Hola, que tal?", gossip?.content) + } + + @Test + fun testCaseFromAmethyst2() { + val json = + """ + { + "content":"{\"ciphertext\":\"Zb0ZNYAcDG5y7BiCWgbxY/i7rN7TxPwr3Oaste6em4VcetuenaMu2SyH6OuCCxxmIa7kFennJD8ZCrev0086azsPNutl9I6OCoOfDQb2GoFaLoJAkE/FuW0uEoEJuN72KsKj05HEjOM6nqL2KiW0pxTCNmlGpweMwpXQdm2ItWkybNpq8+b4NJUDee2czBUd9Kr2ELbPISTYzA17z1IzPXGQw8c73NL+QX9I/QZjM/agqX2x5q11SU52xiRyVd9zHf7TMctZI4QEsqDB6xi54D1bAeZlMhVdcpQRpGDfqRz3KXFlhB3Bwdc8GLgY0aLTn6tJs4qrHP3mQkxFYk0mju0afoc0rloMEUHcBVtM18S9OrTPqfmSqFTQsjaT8g+PkmeiLBo1sXsMCS62w0abSZD9OzQtciMz70ZpcWoLjx5f8panjFClvg4tJ8czMURIHM/IFS1uKAUHBArGN8QpCw8MXQBblpyLDiEkFcSX334Zdps0OIw4z328JSdeejyRh4ks+NHDt9FcjC4iicEqfEh8OTkXuKqEAVkRyfAioNQxWQPnXDzMX0Q+BXvKzBA7NaEBDpbV36H/KnrpBBQwokV9/Byb6Seh3g6GSqRAWD3U6Nk2aBMXkD0xY8vnIqMckBeYHxn8BW7k1FdXFC9lE5xCxWZHkmksJ4f0NVaF37O6d8qOe6RK7bfUeF8/SouJEu+eEX1f4KCMboslwkdk8QA8bThGcRGn8GQBMrPKrpZwHYNyyH8jwt9pywigXJejRLDDnDp3FH/3dbZy5CfuNH6KGydf/O5xx1r316so1UPO1mL5LHJUFZVIaMaMMUsgq12gpI0lLEh5NJPpsi9e3ibkzEZGf7FlAJjJQURbQ8xacN7R+w3GWKbJNHiQbUZ2lXo6fwz33t0DrSqEW970yWPHlqxcpd27EI+qqb5IqfklQZ3RObZZBhzDvImaCPG+U7SmgLhPxnilpGjd5lw/ttiqJhPG9mYFMf1eJXSG+Q9VVkGzN7jxXYtx0q0WGjVq98ZGv5RSnF1d9+QVGCd1fiPS3rsaWdYWly8l0y2quYObJ6Mv3Wh3\",\"nonce\":\"/Q2UTTjVZthm/atcCuDjU1e4reF+ZSgZ\",\"v\":1}", + "created_at":1690660515, + "id":"087d9627d63135a5050758a69222e566c86702e930c9905f0b93ccd6bebeca3f", + "kind":1059, + "pubkey":"e59c00796ae2aa9077fc8bcd57fe8d32c0fc363f7c8b93d055c70804ffff3772", + "sig":"807cb641c314ca6910aaeefadcf87d859137520be1039eb40e39832ed59d456fdd800c5f88bba09e1b395ee90c66d5330847bdd010b63be9919bf091adbc2c2a", + "tags":[ + [ + "p", + "f85f315c06aaf19c2b30a96ca80d9644720655ee8d3ec43b84657a7c98f36a23" + ] + ] + } + """ + .trimIndent() + + val privateKey = "409ff7654141eaa16cd2161fe5bd127aeaef71f270c67587474b78998a8e3533" + + var gossip: Event? = null + + wait1SecondForResult { onDone -> + unwrapUnsealGossip(json, privateKey) { + gossip = it + onDone() + } + } + + assertNotNull(gossip) + assertEquals("Hola, que tal?", gossip?.content) + } + + @Test + fun testDecryptFromCoracle() { + val json = + """ + { + "content": "{\"ciphertext\":\"fo0/Ywyfu86cXKoOOeFFlMRv+LYM6GJUL+F/J4ARv6EcAufOZP46vimlurPBLPjNgzuGemGjlTFfC3vtq84AqIsqFo3dqKunq8Vp+mmubvxIQUDzOGYvM0WE/XOiW5LEe3U3Vq399Dq07xRpXELcp4EZxGyu4Fowv2Ppb4SKpH8g+9N3z2+bwYcSxBvI6SrL+hgmVMrRlgvsUHN1d53ni9ehRseBqrLj/DqyyEiygsKm6vnEZAPKnl1MrBaVOBZmGsyjAa/G4SBVVmk78sW7xWWvo4cV+C22FluDWUOdj/bYabH4aR4scnBX3GLYqfLuzGnuQlRNsb5unXVX41+39uXzROrmNP6iYVyYxy5tfoyN7PPZ4osoKpLDUGldmXHD6RjMcAFuou4hXt2JlTPmXpj/x8qInXId5mkmU4nTGiasvsCIpJljbCujwCjbjLTcD4QrjuhMdtSsAzjT0CDv5Lmc632eKRYtDu/9B+lkqBBkp7amhzbqp8suNTnybkvbGFQQGEQnsLfNJw/GGopAuthfi8zkTgUZR/LxFR7ZKAX73G+5PQSDSjPuGH/dQEnsFo45zsh1Xro8SfUQBsPphbX2GS31Lwu5vA30O922T4UiWuU+EdNgZR0JankQ5NPgvr1uS56C3v84VwdrNWQUCwC4eYJl4Mb/OdpEy9qwsisisppq6uuzxmxd1qx3JfocnGsvB7h2g2sG+0lyZADDSobOEZEKHaBP3w+dRcJW9D95EmzPym9GO0n+33OfqFQbda7G0rzUWfPDV0gXIuZcKs/HmDqepgIZN8FG7JhRBeAv0bCbKQACre0c8tzVEn5yCYemltScdKop3pC/r6gH50jRhAlFAiIKx8R+XwuMmJRqOcH4WfkpZlfVU85/I0XJOCHWKk6BnJi/NPP9zYiZiJe+5LecqMUVjtO0YAlv138+U/3FIT/anQ4H5bjVWBZmajwf\",\"nonce\":\"Mv70S6jgrs4D1rlqV9b5DddiymGAcVVe\",\"v\":1}", + "created_at": 1690528373, + "id": "6b108e4236c3c338236ee589388ce0f91f921e1532ae52e75d1d2add6f8e691a", + "kind": 1059, + "pubkey": "627dc0248335e2bf9adac14be9494139ebbeb12c422d7df5b0e3cd72d04c209c", + "sig": "be11da487196db298e4ffb7a03e74176c37441c88b39c95b518fadce6fd02f23c58b2c435ca38c24d512713935ab719dae80bf952267630809e1f84be8e95174", + "tags": [ + [ + "p", + "e7764a227c12ac1ef2db79ae180392c90903b2cec1e37f5c1a4afed38117185e" + ] + ], + "seenOn": [ + "wss://relay.damus.io/" + ] + } + """ + .trimIndent() + + val privateKey = "09e0051fdf5fdd9dd7a54713583006442cbdbf87bdcdab1a402f26e527d56771" + var gossip: Event? = null + + wait1SecondForResult { onDone -> + unwrapUnsealGossip(json, privateKey) { + gossip = it + onDone() + } + } + + assertNotNull(gossip) + assertEquals("test", gossip?.content) + } + + @Test + fun testFromCoracle2() { + val json = + """ + { + "content": "{\"ciphertext\":\"Hn/dHo/I8Qk6QWWAiKyo/SfKJqQfHdV0O5tMmgqMyfHrsFoDY6IhGQP2EgCJ/6HsNQyO/8EMAmLW8w0PbDKlBKYGKGpaMwCA6B1r0rLjvu+149RJZuggRNm9rd7tNVNkNs38iqt1KYD++bohePm52q+VhAQikbX2gTONV82ROwZylAg9vjvMnYkDt45g6N97s9FRB6V7YMiUEtJnneMixa6klucpUuenQ4569tyt5vnUMD2VNhKYCc2jit2hf7k0DIhvZrVC3OdopUvxIuYYWr3r7XpuEB3HJ6Ji3ajHPzgGeFcItBR7uKZ9s6XU34F3keyZbxrv3yWHFM5NrOctAdZexSGpqWRW93M0KZUAp9HgQh3YzMLl8xt0mcrVywCgjU6Kx8IwkI0bjPU+Am8acY3cItted6hZQ4Vy1xFITdKVfPWDl3Ab59iBg9+IkY5C31wqsKPgPVVycwQE6UpaGW74gy3qZshwyoo01owvEIbVvrSJWXH7EUVvndDPvUbo+f+EVa84IEwVjPmY2oR7VsxVfqRBdmPg23OSw/9rzVybmruqaQHd3xrTTEcnG0qBc/ugCXsiuILTeScOovEnqIlKKK3KB36jMtdScdJB+b4YrzJInY1AvqU7IAgqe0vmo1LdbMtj7kjuxkXJhhQsunAbTvPigTrsOfJ08P9l7r/95kpxudgagEaW7XAjYVfLphseJT3Iy1IuQEyG5sshQ+pl/CYvkGide7ykHwm9pjSBVkD9Mdcn5X6lSnLNJEcwY43pz43r6Kq3L09qneILY3DSKyQ16Zcu1MiAMAM5r6JGvpAHqcMmixi9ORuiryjteTmY4L0vI7b3W/0RSUblXxUrb8IpeysBrFmiKJBgCoU0r/D/8tgR+Eewyp1qxKI4SfKG5GFH40zZ2oVvKyoHAR4x1oVDp/MttcnxkzAsCFL6QuJC9A/vImjsumpmYB/EChcZCOAsfqkuzH4VSjZx\",\"nonce\":\"K537d+7m5tUcXZfkr3Qk2J2G86vdBMmY\",\"v\":1}", + "created_at": 1690655012, + "id": "c4f97c6332b0a63912c44c9e1f8c7b23581dc67a8489ec1522ec205fea7133db", + "kind": 1059, + "pubkey": "8def03a22b1039256a3883d46c7ccd5562f61743100db401344284547de7ec61", + "sig": "25dcf24bdda99c04abc72274d9f7a30538a4a00a70ac4b39db4082b73823979858df93cd649c25edfb759857eac46ed70bb9ad0598f2e011d733a5a382bc4def", + "tags": [ + [ + "p", + "e7764a227c12ac1ef2db79ae180392c90903b2cec1e37f5c1a4afed38117185e" + ] + ] + } + """ + .trimIndent() + + val privateKey = "09e0051fdf5fdd9dd7a54713583006442cbdbf87bdcdab1a402f26e527d56771" + + var gossip: Event? = null + + wait1SecondForResult { onDone -> + unwrapUnsealGossip(json, privateKey) { + gossip = it + onDone() + } + } + + assertEquals("asdfasdfasdf", gossip?.content) + assertEquals(1690659269L, gossip?.createdAt) + assertEquals("827ba09d32ab81d62c60f657b350198c8aaba84372dab9ad3f4f6b8b7274b707", gossip?.id) + assertEquals(14, gossip?.kind) + assertEquals("subject", gossip?.tags?.firstOrNull()?.get(0)) + assertEquals("test", gossip?.tags?.firstOrNull()?.get(1)) + } + + @Test + fun testFromCoracle3() { + val json = + """ + { + "content": "{\"ciphertext\":\"PGCodiacmCB/sClw0C6DQRpP/XIfNAKCVZdKLQpOqytgbFryhs9Z+cPldq2FajXzxs3jQsF7/EVQyWpuV8pXetvFT9tvzjg4Xcm7ZcooLUnAeAo2xZNcJontN4cGubuDqKuXy5n59yXP1fIxfnJxRTRRdCZ2edhsKeNR5NSByUi+StjV10rnfHt8AhZCpiXiZ/giTOsC4wdaeONPgMzMeljaJWLvl6n11VjmXhkx1mXIQt43CNB1hIqO3p89Mbd9p+nlLrOsR+Xs0TB4DCh4XTPbvgf7B7Z+PgOfl3GZfJy9x6TciLcF4E3Ba1zrPe4f79czCIEiJ1yrIKrzzYvv+it35DZQ8fgveFXpyHnNL29hml8PNjyOsFbCHVYLMGw88evI5PijOcpe1TtdoioX8kX5kVEQSKJXuoSjTorvbRPCgGzaa1m0J0uTpzri5VD22a/Jh2CcAnubg6w4JDdUWCogdSV3NqiJllo7ZF7WnZ3apPdRD23MEfphVBJrcLBUNlmwajnY5IvVTKTkZOP50r9dBapvMWXIo6M6zhy/5vVWJz57863pelYCRG4upaXZuNK9sMBtbiphxmFR83i8RML8KN8Q391Cd/xBN7TxJNo5p2YU25VeGZUAmHY8DYlMQDm8Br0nStAXp3T+DzTRL8FTECa8DJV+KTAPoCxqhv3B28Ehr0XAP75CsHoLU00G48cR7h3vQ0CnfKh6KXU6nnDA5OWfpMYpirACCpsnpSD0OaCQ3gkQp3zZNMS3HcOpnPK/IY7R0esbzgAkvNhkyxaIfPDdf+eRUSOA9+2Ji28MwjjY8Dw3SLdUqCOzIDjQeR/T5oNmaQJm3lZ8G0FxxC6ejD4VJX/NI/x+STeB9jWHWmHZvqKzV6JHNh6qmZb6TKSIPOHpafWFoeJFOmiiigf46sju9vRXmVEAx59HXWnvnvCBNJg877yCMulB6xyQuSdVDuotQU4tQZwCKedTHJ6GqjesM98UlJrDtdWQURwwW1qc7N8tS6PukmUVEf0jmbIWVIBmUlkcVuiSs1g1h1kjt8c4MnGTz3CSgpOd1MqxLrl9WwrTqM+YnE+yeZYUjFoewyKZIQ==\",\"nonce\":\"OdCZczJiUGR4bOGIElQ4UUH4dQmG5U/3\",\"v\":1}", + "kind": 1059, + "created_at": 1690772945, + "pubkey": "e01475e87896800b7285eb0daf263c59f811c8fc5bc8daa105d2c98b6d7c4952", + "tags": [ + [ + "p", + "b08d8857a92b4d6aa580ff55cc3c18c4edf313c83388c34abc118621f74f1a78" + ] + ], + "id": "d9fc85ece892ce45ffa737b3ddc0f8b752623181d75363b966191f8c03d2debe", + "sig": "1b20416b83f4b5b8eead11e29c185f46b5e76d1960e4505210ddd00f7a6973cc11268f52a8989e3799b774d5f3a55db95bed4d66a1b6e88ab54becec5c771c17" + } + """ + .trimIndent() + + val privateKey = "7dd22cafc512c0bc363a259f6dcda515b13ae3351066d7976fd0bb79cbd0d700" + + var gossip: Event? = null + + wait1SecondForResult { onDone -> + unwrapUnsealGossip(json, privateKey) { + gossip = it + onDone() + } + } + + assertEquals("8d1a56008d4e31dae2fb8bef36b3efea519eff75f57033107e2aa16702466ef2", gossip?.id) + assertEquals("Howdy", gossip?.content) + assertEquals(1690833960L, gossip?.createdAt) + assertEquals(14, gossip?.kind) + assertEquals("p", gossip?.tags?.firstOrNull()?.get(0)) + assertEquals( + "b08d8857a92b4d6aa580ff55cc3c18c4edf313c83388c34abc118621f74f1a78", + gossip?.tags?.firstOrNull()?.get(1), + ) + assertEquals("subject", gossip?.tags?.getOrNull(1)?.get(0)) + assertEquals("Stuff", gossip?.tags?.getOrNull(1)?.get(1)) + } + + fun unwrapUnsealGossip( + json: String, + privateKey: HexKey, + onReady: (Event) -> Unit, + ) { + val pkBytes = NostrSignerInternal(KeyPair(privateKey.hexToByteArray())) + + val wrap = Event.fromJson(json) as GiftWrapEvent + wrap.checkSignature() + + assertEquals(pkBytes.pubKey, wrap.recipientPubKey()) + + wrap.cachedGift(pkBytes) { event -> + if (event is SealedGossipEvent) { + event.cachedGossip(pkBytes, onReady) + } else { + println(event.toJson()) + fail("Event is not a Sealed Gossip") + } + } + } + + @Test + fun decryptMsgFromNostrTools() { + val receiversPrivateKey = + NostrSignerInternal( + KeyPair(Hex.decode("df51ec558372612918a83446d279d683039bece79b7a721274b1d3cb612dc6af")), + ) + val msg = + """ { "tags": [], "content": "AUC1i3lHsEOYQZaqav8jAw/Dv25r6BpUX4r7ARaj/7JEqvtHkbtaWXEx3LvMlDJstNX1C90RIelgYTzxb4Xnql7zFmXtxGGd/gXOZzW/OCNWECTrhFTruZUcsyn2ssJMgEMBZKY3PgbAKykHlGCuWR3KI9bo+IA5sTqHlrwDGAysxBypRuAxTdtEApw1LSu2A+1UQsdHK/4HcW/fQLPguWGyPv09dftJIJkFWM8VYBQT7b5FeAEMhjlUM+lEmLMnx6qb07Ji/YMESkhzFlgGjHNVl1Q/BT4i6X+Skogl6Si3lWQzlS9oebUim1BQW+RO0IOyQLalZwjzGP+eE7Ry62ukQg7cPiqk62p7NNula17SF2Q8aVFLxr8WjbLXoWhZOWY25uFbTl7OPGGQb5TewRsjHoFeU4h05Ien3Ymf1VPqJVJCMIxU+yFZ1IMZh/vQW4BSx8VotRdNA05fz03ST88GzGxUvqEm4VW/Yp5q4UUkCDQTKmUImaSFmTser39WmvS5+dHY6ne4RwnrZR0ZYrG1bthRHycnPmaJiYsHn9Ox37EzgLR07pmNxr2+86NR3S3TLAVfTDN3XaXRee/7UfW/MXULVyuyweksIHOYBvANC0PxmGSs4UiFoCbwNi45DT2y0SwP6CxzDuM=", @@ -696,32 +751,29 @@ class GiftWrapEventTest { "id": "ae625fd43612127d63bfd1967ba32ae915100842a205fc2c3b3fc02ab3827f08", "sig": "2807a7ab5728984144676fd34686267cbe6fe38bc2f65a3640ba9243c13e8a1ae5a9a051e8852aa0c997a3623d7fa066cf2073a233c6d7db46fb1a0d4c01e5a3" } - """.trimIndent() + """ + .trimIndent() - val wrap = Event.fromJson(msg) as GiftWrapEvent - wrap.checkSignature() + val wrap = Event.fromJson(msg) as GiftWrapEvent + wrap.checkSignature() - var event: Event? = null + var event: Event? = null - wait1SecondForResult { onDone -> - wrap.cachedGift(receiversPrivateKey) { - event = it - onDone() - } - } - - assertNotNull(event) + wait1SecondForResult { onDone -> + wrap.cachedGift(receiversPrivateKey) { + event = it + onDone() + } } + + assertNotNull(event) + } } -fun wait1SecondForResult( - run: (onDone: () -> Unit) -> Unit -) { - val countDownLatch = CountDownLatch(1) +fun wait1SecondForResult(run: (onDone: () -> Unit) -> Unit) { + val countDownLatch = CountDownLatch(1) - run { - countDownLatch.countDown() - } + run { countDownLatch.countDown() } - assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) -} \ No newline at end of file + assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/HexEncodingTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/HexEncodingTest.kt index eb9164a81..6bc74b6c2 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/HexEncodingTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/HexEncodingTest.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz import com.vitorpamplona.quartz.crypto.CryptoUtils @@ -5,43 +25,41 @@ import org.junit.Assert.assertEquals import org.junit.Test class HexEncodingTest { + val testHex = "48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf" - val TestHex = "48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf" + @Test + fun testHexEncodeDecodeOurs() { + assertEquals( + testHex, + com.vitorpamplona.quartz.encoders.Hex.encode( + com.vitorpamplona.quartz.encoders.Hex.decode(testHex), + ), + ) + } - @Test - fun testHexEncodeDecodeOurs() { - assertEquals( - TestHex, - com.vitorpamplona.quartz.encoders.Hex.encode( - com.vitorpamplona.quartz.encoders.Hex.decode(TestHex) - ) - ) + @Test + fun testHexEncodeDecodeSecp256k1() { + assertEquals( + testHex, + fr.acinq.secp256k1.Hex.encode( + fr.acinq.secp256k1.Hex.decode(testHex), + ), + ) + } + + @Test + fun testRandoms() { + for (i in 0..1000) { + val bytes = CryptoUtils.privkeyCreate() + val hex = fr.acinq.secp256k1.Hex.encode(bytes) + assertEquals( + fr.acinq.secp256k1.Hex.encode(bytes), + com.vitorpamplona.quartz.encoders.Hex.encode(bytes), + ) + assertEquals( + bytes.toList(), + com.vitorpamplona.quartz.encoders.Hex.decode(hex).toList(), + ) } - - @Test - fun testHexEncodeDecodeSecp256k1() { - assertEquals( - TestHex, - fr.acinq.secp256k1.Hex.encode( - fr.acinq.secp256k1.Hex.decode(TestHex) - ) - ) - } - - @Test - fun testRandoms() { - for (i in 0..1000) { - val bytes = CryptoUtils.privkeyCreate() - val hex = fr.acinq.secp256k1.Hex.encode(bytes) - assertEquals( - fr.acinq.secp256k1.Hex.encode(bytes), - com.vitorpamplona.quartz.encoders.Hex.encode(bytes) - ) - assertEquals( - bytes.toList(), - com.vitorpamplona.quartz.encoders.Hex.decode(hex).toList() - ) - } - } - -} \ No newline at end of file + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/LargeDBSignatureCheck.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/LargeDBSignatureCheck.kt index a52cf5172..0c201effb 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/LargeDBSignatureCheck.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/LargeDBSignatureCheck.kt @@ -1,34 +1,53 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.quartz.events.Event +import java.io.InputStreamReader import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertTrue import kotlinx.coroutines.runBlocking import org.junit.Test import org.junit.runner.RunWith -import java.io.InputStreamReader @RunWith(AndroidJUnit4::class) class LargeDBSignatureCheck { + @Test + fun insertDatabaseSample() = runBlocking { + val fullDBInputStream = getInstrumentation().context.assets.open("nostr_vitor_short.json") - @Test - fun insertDatabaseSample() = runBlocking { - val fullDBInputStream = getInstrumentation().context.assets.open("nostr_vitor_short.json") + val eventArray = + Event.mapper.readValue>( + InputStreamReader(fullDBInputStream), + ) as List - val eventArray = Event.mapper.readValue>( - InputStreamReader(fullDBInputStream) - ) as List - - var counter = 0 - eventArray.forEach { - assertTrue(it.hasValidSignature()) - counter ++ - } - - assertEquals(eventArray.size, counter) + var counter = 0 + eventArray.forEach { + assertTrue(it.hasValidSignature()) + counter++ } -} \ No newline at end of file + assertEquals(eventArray.size, counter) + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/LnInvoiceUtilTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/LnInvoiceUtilTest.kt index 2f76c5fc4..b2c0803de 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/LnInvoiceUtilTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/LnInvoiceUtilTest.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -8,17 +28,19 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class LnInvoiceUtilTest { - @Test - fun test100KAmountCalculation() { - val bolt11 = "lnbc1m1pjt9u0qsp553q90pj5mafzv20w45eqavned9tgwhl4q99n9s5ppcw24nzw3zeqpp5002kd3ktym67du86kj665fgaev7ka8ys7j5yz5fg686lr5e2gfkshp5dkk27nnuax05az3pk2r6ytxtvwn5j4xzsq9ajprhc7crjkmgvr3qxqyjw5qcqpjrzjqtzxvfsuxe4l92pf97tt4rcgpy2xalkmlwexh899wqxf83l8nwv4xzh0gvqq89qqqqqqqqlgqqqqq0gqvs9qxpqysgqx5mz04wd7kqu5zhhel9enr036hjrp4gga0nz084p2asjl36a0zmrk6mhqa249zsgqref2rlvhffm73u7rxgr47gden6rugup4ksvpzsqvds4pz" - // Context of the app under test. - Assert.assertEquals(100000, LnInvoiceUtil.getAmountInSats(bolt11).toLong()) - } + @Test + fun test100KAmountCalculation() { + val bolt11 = + "lnbc1m1pjt9u0qsp553q90pj5mafzv20w45eqavned9tgwhl4q99n9s5ppcw24nzw3zeqpp5002kd3ktym67du86kj665fgaev7ka8ys7j5yz5fg686lr5e2gfkshp5dkk27nnuax05az3pk2r6ytxtvwn5j4xzsq9ajprhc7crjkmgvr3qxqyjw5qcqpjrzjqtzxvfsuxe4l92pf97tt4rcgpy2xalkmlwexh899wqxf83l8nwv4xzh0gvqq89qqqqqqqqlgqqqqq0gqvs9qxpqysgqx5mz04wd7kqu5zhhel9enr036hjrp4gga0nz084p2asjl36a0zmrk6mhqa249zsgqref2rlvhffm73u7rxgr47gden6rugup4ksvpzsqvds4pz" + // Context of the app under test. + Assert.assertEquals(100000, LnInvoiceUtil.getAmountInSats(bolt11).toLong()) + } - @Test - fun test100GAmountCalculation() { - val bolt11 = "lnbc1000000000000000p1pjtxqf0pp5myqxhcufqy56elfsg9dd4dthnqptusnnpwnul7u86l95xzjgqd8shp5gueg34sgm3u3nxqjqyunvvqdu0pr6jz6mwh4ew4886f2lpf4cmrqxqztgsp5w0cdfd45dfnqwex5gn85x7fru3jcrxhlcx3enx835477m3gdfcuq9qyyssqelrcmm7p9qazgjuxtdg7sd8nq5cscl2tratjlclt5rk5mc7uq2lphq3r2a43j5ua4leakc4emq8yp2yxdnzvzszpw6u2afac0kgl7hspfj67ta" - // Context of the app under test. - Assert.assertEquals(100000000000, LnInvoiceUtil.getAmountInSats(bolt11).toLong()) - } + @Test + fun test100GAmountCalculation() { + val bolt11 = + "lnbc1000000000000000p1pjtxqf0pp5myqxhcufqy56elfsg9dd4dthnqptusnnpwnul7u86l95xzjgqd8shp5gueg34sgm3u3nxqjqyunvvqdu0pr6jz6mwh4ew4886f2lpf4cmrqxqztgsp5w0cdfd45dfnqwex5gn85x7fru3jcrxhlcx3enx835477m3gdfcuq9qyyssqelrcmm7p9qazgjuxtdg7sd8nq5cscl2tratjlclt5rk5mc7uq2lphq3r2a43j5ua4leakc4emq8yp2yxdnzvzszpw6u2afac0kgl7hspfj67ta" + // Context of the app under test. + Assert.assertEquals(100000000000, LnInvoiceUtil.getAmountInSats(bolt11).toLong()) + } } diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/NIP44v2Test.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/NIP44v2Test.kt index 6f3cfad96..6c01cc975 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/NIP44v2Test.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/NIP44v2Test.kt @@ -1,4 +1,24 @@ -package com.vitorpamplona.quartz; +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation @@ -8,195 +28,205 @@ import com.vitorpamplona.quartz.crypto.Nip44v2 import com.vitorpamplona.quartz.encoders.hexToByteArray import com.vitorpamplona.quartz.encoders.toHexKey import fr.acinq.secp256k1.Secp256k1 +import java.security.MessageDigest +import java.security.SecureRandom import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertNotNull import junit.framework.TestCase.assertNull import junit.framework.TestCase.fail import org.junit.Test import org.junit.runner.RunWith -import java.security.MessageDigest -import java.security.SecureRandom - @RunWith(AndroidJUnit4::class) public class NIP44v2Test { - val vectors: VectorFile = jacksonObjectMapper().readValue( + val vectors: VectorFile = + jacksonObjectMapper() + .readValue( getInstrumentation().context.assets.open("nip44.vectors.json"), - VectorFile::class.java - ) + VectorFile::class.java, + ) - val random = SecureRandom() - val nip44v2 = Nip44v2(Secp256k1.get(), random) + val random = SecureRandom() + val nip44v2 = Nip44v2(Secp256k1.get(), random) - @Test - fun conversationKeyTest() { - for (v in vectors.v2?.valid?.getConversationKey!!) { - val conversationKey = nip44v2.getConversationKey(v.sec1!!.hexToByteArray(), v.pub2!!.hexToByteArray()) + @Test + fun conversationKeyTest() { + for (v in vectors.v2?.valid?.getConversationKey!!) { + val conversationKey = + nip44v2.getConversationKey(v.sec1!!.hexToByteArray(), v.pub2!!.hexToByteArray()) - assertEquals(v.conversationKey, conversationKey.toHexKey()) - } + assertEquals(v.conversationKey, conversationKey.toHexKey()) } + } - @Test - fun paddingTest() { - for (v in vectors.v2?.valid?.calcPaddedLen!!) { - val actual = nip44v2.calcPaddedLen(v[0]) - assertEquals(v[1], actual) - } + @Test + fun paddingTest() { + for (v in vectors.v2?.valid?.calcPaddedLen!!) { + val actual = nip44v2.calcPaddedLen(v[0]) + assertEquals(v[1], actual) } + } - @Test - fun encryptDecryptTest() { - for (v in vectors.v2?.valid?.encryptDecrypt!!) { - val pub2 = com.vitorpamplona.quartz.crypto.KeyPair(v.sec2!!.hexToByteArray()) - val conversationKey = nip44v2.getConversationKey(v.sec1!!.hexToByteArray(), pub2.pubKey) - assertEquals(v.conversationKey, conversationKey.toHexKey()) + @Test + fun encryptDecryptTest() { + for (v in vectors.v2?.valid?.encryptDecrypt!!) { + val pub2 = com.vitorpamplona.quartz.crypto.KeyPair(v.sec2!!.hexToByteArray()) + val conversationKey = nip44v2.getConversationKey(v.sec1!!.hexToByteArray(), pub2.pubKey) + assertEquals(v.conversationKey, conversationKey.toHexKey()) - val ciphertext = nip44v2.encryptWithNonce( - v.plaintext!!, - conversationKey, - v.nonce!!.hexToByteArray() - ).encodePayload() + val ciphertext = + nip44v2 + .encryptWithNonce( + v.plaintext!!, + conversationKey, + v.nonce!!.hexToByteArray(), + ) + .encodePayload() - assertEquals(v.payload, ciphertext) + assertEquals(v.payload, ciphertext) - val decrypted = nip44v2.decrypt(v.payload!!, conversationKey) - assertEquals(v.plaintext, decrypted) - } + val decrypted = nip44v2.decrypt(v.payload!!, conversationKey) + assertEquals(v.plaintext, decrypted) } + } - @Test - fun encryptDecryptLongTest() { - for (v in vectors.v2?.valid?.encryptDecryptLongMsg!!) { - val conversationKey = v.conversationKey!!.hexToByteArray() - val plaintext = v.pattern!!.repeat(v.repeat!!) + @Test + fun encryptDecryptLongTest() { + for (v in vectors.v2?.valid?.encryptDecryptLongMsg!!) { + val conversationKey = v.conversationKey!!.hexToByteArray() + val plaintext = v.pattern!!.repeat(v.repeat!!) - assertEquals(v.plaintextSha256, sha256Hex(plaintext.toByteArray(Charsets.UTF_8))) + assertEquals(v.plaintextSha256, sha256Hex(plaintext.toByteArray(Charsets.UTF_8))) - val ciphertext = nip44v2.encryptWithNonce( - plaintext, - conversationKey, - v.nonce!!.hexToByteArray() - ).encodePayload() + val ciphertext = + nip44v2 + .encryptWithNonce( + plaintext, + conversationKey, + v.nonce!!.hexToByteArray(), + ) + .encodePayload() - assertEquals(v.payloadSha256, sha256Hex(ciphertext.toByteArray(Charsets.UTF_8))) + assertEquals(v.payloadSha256, sha256Hex(ciphertext.toByteArray(Charsets.UTF_8))) - val decrypted = nip44v2.decrypt(ciphertext, conversationKey) + val decrypted = nip44v2.decrypt(ciphertext, conversationKey) - assertEquals(plaintext, decrypted) - } + assertEquals(plaintext, decrypted) } + } - @Test - fun invalidMessageLenghts() { - for (v in vectors.v2?.invalid?.encryptMsgLengths!!) { - val key = ByteArray(32) - random.nextBytes(key) - try { - nip44v2.encrypt("a".repeat(v), key) - fail("Should Throw for ${v}") - } catch (e: Exception) { - assertNotNull(e) - } - } + @Test + fun invalidMessageLenghts() { + for (v in vectors.v2?.invalid?.encryptMsgLengths!!) { + val key = ByteArray(32) + random.nextBytes(key) + try { + nip44v2.encrypt("a".repeat(v), key) + fail("Should Throw for $v") + } catch (e: Exception) { + assertNotNull(e) + } } + } - @Test - fun invalidDecrypt() { - for (v in vectors.v2?.invalid?.decrypt!!) { - try { - val result = nip44v2.decrypt(v.payload!!, v.conversationKey!!.hexToByteArray()) - assertNull(result) - //fail("Should Throw for ${v.note}") - } catch (e: Exception) { - assertNotNull(e) - } - } + @Test + fun invalidDecrypt() { + for (v in vectors.v2?.invalid?.decrypt!!) { + try { + val result = nip44v2.decrypt(v.payload!!, v.conversationKey!!.hexToByteArray()) + assertNull(result) + // fail("Should Throw for ${v.note}") + } catch (e: Exception) { + assertNotNull(e) + } } + } - @Test - fun invalidConversationKey() { - for (v in vectors.v2?.invalid?.getConversationKey!!) { - try { - nip44v2.getConversationKey(v.sec1!!.hexToByteArray(), v.pub2!!.hexToByteArray()) - fail("Should Throw for ${v.note}") - } catch (e: Exception) { - assertNotNull(e) - } - } + @Test + fun invalidConversationKey() { + for (v in vectors.v2?.invalid?.getConversationKey!!) { + try { + nip44v2.getConversationKey(v.sec1!!.hexToByteArray(), v.pub2!!.hexToByteArray()) + fail("Should Throw for ${v.note}") + } catch (e: Exception) { + assertNotNull(e) + } } + } - fun sha256Hex(data: ByteArray): String { - // Creates a new buffer every time - return MessageDigest.getInstance("SHA-256").digest(data).toHexKey() - } + fun sha256Hex(data: ByteArray): String { + // Creates a new buffer every time + return MessageDigest.getInstance("SHA-256").digest(data).toHexKey() + } } -data class VectorFile ( - val v2 : V2? = V2() +data class VectorFile( + val v2: V2? = V2(), ) -data class V2 ( - val valid : Valid? = Valid(), - val invalid : Invalid? = Invalid() +data class V2( + val valid: Valid? = Valid(), + val invalid: Invalid? = Invalid(), ) -data class Valid ( - @JsonProperty("get_conversation_key" ) val getConversationKey : ArrayList = arrayListOf(), - @JsonProperty("get_message_keys" ) val getMessageKeys : GetMessageKeys? = GetMessageKeys(), - @JsonProperty("calc_padded_len" ) val calcPaddedLen : ArrayList> = arrayListOf(), - @JsonProperty("encrypt_decrypt" ) val encryptDecrypt : ArrayList = arrayListOf(), - @JsonProperty("encrypt_decrypt_long_msg" ) val encryptDecryptLongMsg : ArrayList = arrayListOf() +data class Valid( + @JsonProperty("get_conversation_key") + val getConversationKey: ArrayList = arrayListOf(), + @JsonProperty("get_message_keys") val getMessageKeys: GetMessageKeys? = GetMessageKeys(), + @JsonProperty("calc_padded_len") val calcPaddedLen: ArrayList> = arrayListOf(), + @JsonProperty("encrypt_decrypt") val encryptDecrypt: ArrayList = arrayListOf(), + @JsonProperty("encrypt_decrypt_long_msg") + val encryptDecryptLongMsg: ArrayList = arrayListOf(), ) -data class Invalid ( - @JsonProperty("encrypt_msg_lengths" ) val encryptMsgLengths : ArrayList = arrayListOf(), - @JsonProperty("get_conversation_key" ) val getConversationKey : ArrayList = arrayListOf(), - @JsonProperty("decrypt" ) val decrypt : ArrayList = arrayListOf() +data class Invalid( + @JsonProperty("encrypt_msg_lengths") val encryptMsgLengths: ArrayList = arrayListOf(), + @JsonProperty("get_conversation_key") + val getConversationKey: ArrayList = arrayListOf(), + @JsonProperty("decrypt") val decrypt: ArrayList = arrayListOf(), ) -data class GetConversationKey ( - val sec1 : String? = null, - val pub2 : String? = null, - val note : String? = null, - @JsonProperty("conversation_key" ) val conversationKey : String? = null +data class GetConversationKey( + val sec1: String? = null, + val pub2: String? = null, + val note: String? = null, + @JsonProperty("conversation_key") val conversationKey: String? = null, ) -data class GetMessageKeys ( - @JsonProperty("conversation_key" ) val conversationKey : String? = null, - val keys : ArrayList = arrayListOf() +data class GetMessageKeys( + @JsonProperty("conversation_key") val conversationKey: String? = null, + val keys: ArrayList = arrayListOf(), ) -data class Keys ( - @JsonProperty("nonce" ) val nonce : String? = null, - @JsonProperty("chacha_key" ) val chachaKey : String? = null, - @JsonProperty("chacha_nonce" ) val chachaNonce : String? = null, - @JsonProperty("hmac_key" ) val hmacKey : String? = null +data class Keys( + @JsonProperty("nonce") val nonce: String? = null, + @JsonProperty("chacha_key") val chachaKey: String? = null, + @JsonProperty("chacha_nonce") val chachaNonce: String? = null, + @JsonProperty("hmac_key") val hmacKey: String? = null, ) -data class EncryptDecrypt ( - val sec1 : String? = null, - val sec2 : String? = null, - @JsonProperty("conversation_key" ) val conversationKey : String? = null, - val nonce : String? = null, - val plaintext : String? = null, - val payload : String? = null +data class EncryptDecrypt( + val sec1: String? = null, + val sec2: String? = null, + @JsonProperty("conversation_key") val conversationKey: String? = null, + val nonce: String? = null, + val plaintext: String? = null, + val payload: String? = null, ) -data class EncryptDecryptLongMsg ( - @JsonProperty("conversation_key" ) val conversationKey : String? = null, - val nonce : String? = null, - val pattern : String? = null, - val repeat : Int? = null, - @JsonProperty("plaintext_sha256" ) val plaintextSha256 : String? = null, - @JsonProperty("payload_sha256" ) val payloadSha256 : String? = null +data class EncryptDecryptLongMsg( + @JsonProperty("conversation_key") val conversationKey: String? = null, + val nonce: String? = null, + val pattern: String? = null, + val repeat: Int? = null, + @JsonProperty("plaintext_sha256") val plaintextSha256: String? = null, + @JsonProperty("payload_sha256") val payloadSha256: String? = null, ) -data class Decrypt ( - @JsonProperty("conversation_key" ) val conversationKey : String? = null, - val nonce : String? = null, - val plaintext : String? = null, - val payload : String? = null, - val note : String? = null +data class Decrypt( + @JsonProperty("conversation_key") val conversationKey: String? = null, + val nonce: String? = null, + val plaintext: String? = null, + val payload: String? = null, + val note: String? = null, ) - diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/PrivateZapTests.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/PrivateZapTests.kt index a6f64640d..276bfb890 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/PrivateZapTests.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/PrivateZapTests.kt @@ -1,13 +1,33 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.quartz.crypto.KeyPair +import com.vitorpamplona.quartz.encoders.Hex import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.LnZapRequestEvent import com.vitorpamplona.quartz.events.LnZapRequestEvent.Companion.createEncryptionPrivateKey -import com.vitorpamplona.quartz.encoders.Hex import com.vitorpamplona.quartz.signers.NostrSignerInternal import junit.framework.TestCase.assertNotNull import junit.framework.TestCase.fail @@ -16,11 +36,11 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class PrivateZapTests { - - @Test - fun testPollZap() { - val poll = Event.fromJson( - """{ + @Test + fun testPollZap() { + val poll = + Event.fromJson( + """{ "content": "New poll \n\n #zappoll", "created_at": 1682440713, "id": "16291ba452bb0786a4bf5c278d38de73c96b58c056ed75c5ea466b0795197288", @@ -59,52 +79,57 @@ class PrivateZapTests { "wss://relay.damus.io/" ] } -""") +""", + ) - val loggedIn = NostrSignerInternal(KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598"))) + val loggedIn = + NostrSignerInternal( + KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")), + ) - var resultPrivateZap: Event? = null + var resultPrivateZap: Event? = null - wait1SecondForResult { onDone -> - LnZapRequestEvent.create( - originalNote = poll, - relays = setOf("wss://relay.damus.io/"), - signer = loggedIn, - pollOption = 0, - message = "", - zapType = LnZapEvent.ZapType.PRIVATE, - toUserPubHex = null - ) { privateZapRequest -> - val recepientPK = privateZapRequest.zappedAuthor().firstOrNull() - val recepientPost = privateZapRequest.zappedPost().firstOrNull() + wait1SecondForResult { onDone -> + LnZapRequestEvent.create( + originalNote = poll, + relays = setOf("wss://relay.damus.io/"), + signer = loggedIn, + pollOption = 0, + message = "", + zapType = LnZapEvent.ZapType.PRIVATE, + toUserPubHex = null, + ) { privateZapRequest -> + val recepientPK = privateZapRequest.zappedAuthor().firstOrNull() + val recepientPost = privateZapRequest.zappedPost().firstOrNull() - if (recepientPK != null && recepientPost != null) { - val privateKey = createEncryptionPrivateKey( - loggedIn.keyPair.privKey!!.toHexKey(), - recepientPost, - privateZapRequest.createdAt - ) - val decodedPrivateZap = - privateZapRequest.getPrivateZapEvent(privateKey, recepientPK) + if (recepientPK != null && recepientPost != null) { + val privateKey = + createEncryptionPrivateKey( + loggedIn.keyPair.privKey!!.toHexKey(), + recepientPost, + privateZapRequest.createdAt, + ) + val decodedPrivateZap = privateZapRequest.getPrivateZapEvent(privateKey, recepientPK) - println(decodedPrivateZap?.toJson()) + println(decodedPrivateZap?.toJson()) - resultPrivateZap = decodedPrivateZap + resultPrivateZap = decodedPrivateZap - onDone() - } else { - fail("Should not be null") - } - } + onDone() + } else { + fail("Should not be null") } - - assertNotNull(resultPrivateZap) + } } - @Test - fun testKind1PrivateZap() { - val textNote = Event.fromJson( - """{ + assertNotNull(resultPrivateZap) + } + + @Test + fun testKind1PrivateZap() { + val textNote = + Event.fromJson( + """{ "content": "Testing copied author. \n\nnostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", "created_at": 1682369982, "id": "c757e1371d715c711ec9ef9740a3df6475d64b3d0af45ffcbfca08d273baf1c1", @@ -121,45 +146,49 @@ class PrivateZapTests { "wss://relay.damus.io/" ] } -""") +""", + ) - val loggedIn = NostrSignerInternal(KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598"))) + val loggedIn = + NostrSignerInternal( + KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")), + ) - var resultPrivateZap: Event? = null + var resultPrivateZap: Event? = null - wait1SecondForResult { onDone -> - LnZapRequestEvent.create( - originalNote = textNote, - relays = setOf("wss://relay.damus.io/", "wss://relay.damus2.io/", "wss://relay.damus3.io/"), - signer = loggedIn, - pollOption = null, - message = "test", - zapType = LnZapEvent.ZapType.PRIVATE, - toUserPubHex = null - ) { privateZapRequest -> - val recepientPK = privateZapRequest.zappedAuthor().firstOrNull() - val recepientPost = privateZapRequest.zappedPost().firstOrNull() + wait1SecondForResult { onDone -> + LnZapRequestEvent.create( + originalNote = textNote, + relays = setOf("wss://relay.damus.io/", "wss://relay.damus2.io/", "wss://relay.damus3.io/"), + signer = loggedIn, + pollOption = null, + message = "test", + zapType = LnZapEvent.ZapType.PRIVATE, + toUserPubHex = null, + ) { privateZapRequest -> + val recepientPK = privateZapRequest.zappedAuthor().firstOrNull() + val recepientPost = privateZapRequest.zappedPost().firstOrNull() - if (recepientPK != null && recepientPost != null) { - val privateKey = createEncryptionPrivateKey( - loggedIn.keyPair.privKey!!.toHexKey(), - recepientPost, - privateZapRequest.createdAt - ) - val decodedPrivateZap = - privateZapRequest.getPrivateZapEvent(privateKey, recepientPK) + if (recepientPK != null && recepientPost != null) { + val privateKey = + createEncryptionPrivateKey( + loggedIn.keyPair.privKey!!.toHexKey(), + recepientPost, + privateZapRequest.createdAt, + ) + val decodedPrivateZap = privateZapRequest.getPrivateZapEvent(privateKey, recepientPK) - println(decodedPrivateZap?.toJson()) + println(decodedPrivateZap?.toJson()) - resultPrivateZap = decodedPrivateZap + resultPrivateZap = decodedPrivateZap - onDone() - } else { - fail("Should not be null") - } - } + onDone() + } else { + fail("Should not be null") } - - assertNotNull(resultPrivateZap) + } } + + assertNotNull(resultPrivateZap) + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/CryptoUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/CryptoUtils.kt index 54b7fbfaa..b48bb4d6d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/CryptoUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/CryptoUtils.kt @@ -1,230 +1,338 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.crypto import android.util.Log -import com.vitorpamplona.quartz.encoders.hexToByteArray import com.vitorpamplona.quartz.events.Event import fr.acinq.secp256k1.Secp256k1 import java.security.MessageDigest import java.security.SecureRandom import java.util.Base64 - object CryptoUtils { - private val secp256k1 = Secp256k1.get() - private val random = SecureRandom() + private val secp256k1 = Secp256k1.get() + private val random = SecureRandom() - private val nip04 = Nip04(secp256k1, random) - private val nip44v1 = Nip44v1(secp256k1, random) - private val nip44v2 = Nip44v2(secp256k1, random) + private val nip04 = Nip04(secp256k1, random) + private val nip44v1 = Nip44v1(secp256k1, random) + private val nip44v2 = Nip44v2(secp256k1, random) - fun clearCache() { - nip04.clearCache() - nip44v1.clearCache() - nip44v2.clearCache() + fun clearCache() { + nip04.clearCache() + nip44v1.clearCache() + nip44v2.clearCache() + } + + fun randomInt(bound: Int): Int { + return random.nextInt(bound) + } + + /** Provides a 32B "private key" aka random number */ + fun privkeyCreate() = random(32) + + fun random(size: Int): ByteArray { + val bytes = ByteArray(size) + random.nextBytes(bytes) + return bytes + } + + fun pubkeyCreate(privKey: ByteArray) = + secp256k1.pubKeyCompress(secp256k1.pubkeyCreate(privKey)).copyOfRange(1, 33) + + fun sign( + data: ByteArray, + privKey: ByteArray, + ): ByteArray = secp256k1.signSchnorr(data, privKey, null) + + fun verifySignature( + signature: ByteArray, + hash: ByteArray, + pubKey: ByteArray, + ): Boolean { + return secp256k1.verifySchnorr(signature, hash, pubKey) + } + + fun sha256(data: ByteArray): ByteArray { + // Creates a new buffer every time + return MessageDigest.getInstance("SHA-256").digest(data) + } + + /** NIP 04 Utils */ + fun encryptNIP04( + msg: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): String { + return nip04.encrypt(msg, privateKey, pubKey) + } + + fun encryptNIP04( + msg: String, + sharedSecret: ByteArray, + ): Nip04.EncryptedInfo { + return nip04.encrypt(msg, sharedSecret) + } + + fun decryptNIP04( + msg: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): String { + return nip04.decrypt(msg, privateKey, pubKey) + } + + fun decryptNIP04( + encryptedInfo: Nip04.EncryptedInfo, + privateKey: ByteArray, + pubKey: ByteArray, + ): String { + return nip04.decrypt(encryptedInfo, privateKey, pubKey) + } + + fun decryptNIP04( + msg: String, + sharedSecret: ByteArray, + ): String { + return nip04.decrypt(msg, sharedSecret) + } + + private fun decryptNIP04( + cipher: String, + nonce: String, + sharedSecret: ByteArray, + ): String { + return nip04.decrypt(cipher, nonce, sharedSecret) + } + + private fun decryptNIP04( + encryptedMsg: ByteArray, + iv: ByteArray, + sharedSecret: ByteArray, + ): String { + return nip04.decrypt(encryptedMsg, iv, sharedSecret) + } + + fun getSharedSecretNIP04( + privateKey: ByteArray, + pubKey: ByteArray, + ): ByteArray { + return nip04.getSharedSecret(privateKey, pubKey) + } + + fun computeSharedSecretNIP04( + privateKey: ByteArray, + pubKey: ByteArray, + ): ByteArray { + return nip04.computeSharedSecret(privateKey, pubKey) + } + + /** NIP 44v1 Utils */ + fun encryptNIP44v1( + msg: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): Nip44v1.EncryptedInfo { + return nip44v1.encrypt(msg, privateKey, pubKey) + } + + fun encryptNIP44v1( + msg: String, + sharedSecret: ByteArray, + ): Nip44v1.EncryptedInfo { + return nip44v1.encrypt(msg, sharedSecret) + } + + fun decryptNIP44v1( + encryptedInfo: Nip44v1.EncryptedInfo, + privateKey: ByteArray, + pubKey: ByteArray, + ): String? { + return nip44v1.decrypt(encryptedInfo, privateKey, pubKey) + } + + fun decryptNIP44v1( + encryptedInfo: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): String? { + return nip44v1.decrypt(encryptedInfo, privateKey, pubKey) + } + + fun decryptNIP44v1( + encryptedInfo: Nip44v1.EncryptedInfo, + sharedSecret: ByteArray, + ): String? { + return nip44v1.decrypt(encryptedInfo, sharedSecret) + } + + fun getSharedSecretNIP44v1( + privateKey: ByteArray, + pubKey: ByteArray, + ): ByteArray { + return nip44v1.getSharedSecret(privateKey, pubKey) + } + + fun computeSharedSecretNIP44v1( + privateKey: ByteArray, + pubKey: ByteArray, + ): ByteArray { + return nip44v1.computeSharedSecret(privateKey, pubKey) + } + + /** NIP 44v2 Utils */ + fun encryptNIP44v2( + msg: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): Nip44v2.EncryptedInfo { + return nip44v2.encrypt(msg, privateKey, pubKey) + } + + fun encryptNIP44v2( + msg: String, + sharedSecret: ByteArray, + ): Nip44v2.EncryptedInfo { + return nip44v2.encrypt(msg, sharedSecret) + } + + fun decryptNIP44v2( + encryptedInfo: Nip44v2.EncryptedInfo, + privateKey: ByteArray, + pubKey: ByteArray, + ): String? { + return nip44v2.decrypt(encryptedInfo, privateKey, pubKey) + } + + fun decryptNIP44v2( + encryptedInfo: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): String? { + return nip44v2.decrypt(encryptedInfo, privateKey, pubKey) + } + + fun decryptNIP44v2( + encryptedInfo: Nip44v2.EncryptedInfo, + sharedSecret: ByteArray, + ): String? { + return nip44v2.decrypt(encryptedInfo, sharedSecret) + } + + fun getSharedSecretNIP44v2( + privateKey: ByteArray, + pubKey: ByteArray, + ): ByteArray { + return nip44v2.getConversationKey(privateKey, pubKey) + } + + fun computeSharedSecretNIP44v2( + privateKey: ByteArray, + pubKey: ByteArray, + ): ByteArray { + return nip44v2.computeConversationKey(privateKey, pubKey) + } + + fun decryptNIP44( + payload: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): String? { + if (payload.isEmpty()) return null + return if (payload[0] == '{') { + decryptNIP44FromJackson(payload, privateKey, pubKey) + } else { + decryptNIP44FromBase64(payload, privateKey, pubKey) } + } - fun randomInt(bound: Int): Int { - return random.nextInt(bound) - } + data class EncryptedInfoString( + val ciphertext: String, + val nonce: String, + val v: Int, + val mac: String?, + ) - /** - * Provides a 32B "private key" aka random number - */ - fun privkeyCreate() = random(32) + fun decryptNIP44FromJackson( + json: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): String? { + return try { + val info = Event.mapper.readValue(json, EncryptedInfoString::class.java) - fun random(size: Int): ByteArray { - val bytes = ByteArray(size) - random.nextBytes(bytes) - return bytes - } - - fun pubkeyCreate(privKey: ByteArray) = - secp256k1.pubKeyCompress(secp256k1.pubkeyCreate(privKey)).copyOfRange(1, 33) - - fun sign(data: ByteArray, privKey: ByteArray): ByteArray = - secp256k1.signSchnorr(data, privKey, null) - - fun verifySignature( - signature: ByteArray, - hash: ByteArray, - pubKey: ByteArray - ): Boolean { - return secp256k1.verifySchnorr(signature, hash, pubKey) - } - - fun sha256(data: ByteArray): ByteArray { - // Creates a new buffer every time - return MessageDigest.getInstance("SHA-256").digest(data) - } - - /** - * NIP 04 Utils - */ - fun encryptNIP04(msg: String, privateKey: ByteArray, pubKey: ByteArray): String { - return nip04.encrypt(msg, privateKey, pubKey) - } - - fun encryptNIP04(msg: String, sharedSecret: ByteArray): Nip04.EncryptedInfo { - return nip04.encrypt(msg, sharedSecret) - } - - fun decryptNIP04(msg: String, privateKey: ByteArray, pubKey: ByteArray): String { - return nip04.decrypt(msg, privateKey, pubKey) - } - - fun decryptNIP04(encryptedInfo: Nip04.EncryptedInfo, privateKey: ByteArray, pubKey: ByteArray): String { - return nip04.decrypt(encryptedInfo, privateKey, pubKey) - } - - fun decryptNIP04(msg: String, sharedSecret: ByteArray): String { - return nip04.decrypt(msg, sharedSecret) - } - - private fun decryptNIP04(cipher: String, nonce: String, sharedSecret: ByteArray): String { - return nip04.decrypt(cipher, nonce, sharedSecret) - } - - private fun decryptNIP04(encryptedMsg: ByteArray, iv: ByteArray, sharedSecret: ByteArray): String { - return nip04.decrypt(encryptedMsg, iv, sharedSecret) - } - - fun getSharedSecretNIP04(privateKey: ByteArray, pubKey: ByteArray): ByteArray { - return nip04.getSharedSecret(privateKey, pubKey) - } - - fun computeSharedSecretNIP04(privateKey: ByteArray, pubKey: ByteArray): ByteArray { - return nip04.computeSharedSecret(privateKey, pubKey) - } - - - /** - * NIP 44v1 Utils - */ - - fun encryptNIP44v1(msg: String, privateKey: ByteArray, pubKey: ByteArray): Nip44v1.EncryptedInfo { - return nip44v1.encrypt(msg, privateKey, pubKey) - } - - fun encryptNIP44v1(msg: String, sharedSecret: ByteArray): Nip44v1.EncryptedInfo { - return nip44v1.encrypt(msg, sharedSecret) - } - - fun decryptNIP44v1(encryptedInfo: Nip44v1.EncryptedInfo, privateKey: ByteArray, pubKey: ByteArray): String? { - return nip44v1.decrypt(encryptedInfo, privateKey, pubKey) - } - - fun decryptNIP44v1(encryptedInfo: String, privateKey: ByteArray, pubKey: ByteArray): String? { - return nip44v1.decrypt(encryptedInfo, privateKey, pubKey) - } - - fun decryptNIP44v1(encryptedInfo: Nip44v1.EncryptedInfo, sharedSecret: ByteArray): String? { - return nip44v1.decrypt(encryptedInfo, sharedSecret) - } - - fun getSharedSecretNIP44v1(privateKey: ByteArray, pubKey: ByteArray): ByteArray { - return nip44v1.getSharedSecret(privateKey, pubKey) - } - - fun computeSharedSecretNIP44v1(privateKey: ByteArray, pubKey: ByteArray): ByteArray { - return nip44v1.computeSharedSecret(privateKey, pubKey) - } - - /** - * NIP 44v2 Utils - */ - - fun encryptNIP44v2(msg: String, privateKey: ByteArray, pubKey: ByteArray): Nip44v2.EncryptedInfo { - return nip44v2.encrypt(msg, privateKey, pubKey) - } - - fun encryptNIP44v2(msg: String, sharedSecret: ByteArray): Nip44v2.EncryptedInfo { - return nip44v2.encrypt(msg, sharedSecret) - } - - fun decryptNIP44v2(encryptedInfo: Nip44v2.EncryptedInfo, privateKey: ByteArray, pubKey: ByteArray): String? { - return nip44v2.decrypt(encryptedInfo, privateKey, pubKey) - } - - fun decryptNIP44v2(encryptedInfo: String, privateKey: ByteArray, pubKey: ByteArray): String? { - return nip44v2.decrypt(encryptedInfo, privateKey, pubKey) - } - - fun decryptNIP44v2(encryptedInfo: Nip44v2.EncryptedInfo, sharedSecret: ByteArray): String? { - return nip44v2.decrypt(encryptedInfo, sharedSecret) - } - - fun getSharedSecretNIP44v2(privateKey: ByteArray, pubKey: ByteArray): ByteArray { - return nip44v2.getConversationKey(privateKey, pubKey) - } - - fun computeSharedSecretNIP44v2(privateKey: ByteArray, pubKey: ByteArray): ByteArray { - return nip44v2.computeConversationKey(privateKey, pubKey) - } - - fun decryptNIP44(payload: String, privateKey: ByteArray, pubKey: ByteArray): String? { - if (payload.isEmpty()) return null - return if (payload[0] == '{') { - decryptNIP44FromJackson(payload, privateKey, pubKey) - } else { - decryptNIP44FromBase64(payload, privateKey, pubKey) + when (info.v) { + Nip04.EncryptedInfo.V -> { + val encryptedInfo = + Nip04.EncryptedInfo( + ciphertext = Base64.getDecoder().decode(info.ciphertext), + nonce = Base64.getDecoder().decode(info.nonce), + ) + decryptNIP04(encryptedInfo, privateKey, pubKey) } - } - - data class EncryptedInfoString(val ciphertext: String, val nonce: String, val v: Int, val mac: String?) - - fun decryptNIP44FromJackson(json: String, privateKey: ByteArray, pubKey: ByteArray): String? { - return try { - val info = Event.mapper.readValue(json, EncryptedInfoString::class.java) - - when (info.v) { - Nip04.EncryptedInfo.v -> { - val encryptedInfo = Nip04.EncryptedInfo( - ciphertext = Base64.getDecoder().decode(info.ciphertext), - nonce = Base64.getDecoder().decode(info.nonce) - ) - decryptNIP04(encryptedInfo, privateKey, pubKey) - } - Nip44v1.EncryptedInfo.v -> { - val encryptedInfo = Nip44v1.EncryptedInfo( - ciphertext = Base64.getDecoder().decode(info.ciphertext), - nonce = Base64.getDecoder().decode(info.nonce) - ) - decryptNIP44v1(encryptedInfo, privateKey, pubKey) - } - Nip44v2.EncryptedInfo.v -> { - val encryptedInfo = Nip44v2.EncryptedInfo( - ciphertext = Base64.getDecoder().decode(info.ciphertext), - nonce = Base64.getDecoder().decode(info.nonce), - mac = Base64.getDecoder().decode(info.mac) - ) - decryptNIP44v2(encryptedInfo, privateKey, pubKey) - } - else -> null - } - } catch (e: Exception) { - Log.e("CryptoUtils", "Could not identify the version for NIP44 payload ${json}") - e.printStackTrace() - null + Nip44v1.EncryptedInfo.V -> { + val encryptedInfo = + Nip44v1.EncryptedInfo( + ciphertext = Base64.getDecoder().decode(info.ciphertext), + nonce = Base64.getDecoder().decode(info.nonce), + ) + decryptNIP44v1(encryptedInfo, privateKey, pubKey) } - } - - fun decryptNIP44FromBase64(payload: String, privateKey: ByteArray, pubKey: ByteArray): String? { - if (payload.isEmpty()) return null - - return try { - val byteArray = Base64.getDecoder().decode(payload) - - when (byteArray[0].toInt()) { - Nip04.EncryptedInfo.v -> decryptNIP04(payload, privateKey, pubKey) - Nip44v1.EncryptedInfo.v -> decryptNIP44v1(payload, privateKey, pubKey) - Nip44v2.EncryptedInfo.v -> decryptNIP44v2(payload, privateKey, pubKey) - else -> null - } - } catch (e: Exception) { - Log.e("CryptoUtils", "Could not identify the version for NIP44 payload ${payload}") - e.printStackTrace() - null + Nip44v2.EncryptedInfo.V -> { + val encryptedInfo = + Nip44v2.EncryptedInfo( + ciphertext = Base64.getDecoder().decode(info.ciphertext), + nonce = Base64.getDecoder().decode(info.nonce), + mac = Base64.getDecoder().decode(info.mac), + ) + decryptNIP44v2(encryptedInfo, privateKey, pubKey) } + else -> null + } + } catch (e: Exception) { + Log.e("CryptoUtils", "Could not identify the version for NIP44 payload $json") + e.printStackTrace() + null } + } -} \ No newline at end of file + fun decryptNIP44FromBase64( + payload: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): String? { + if (payload.isEmpty()) return null + + return try { + val byteArray = Base64.getDecoder().decode(payload) + + when (byteArray[0].toInt()) { + Nip04.EncryptedInfo.V -> decryptNIP04(payload, privateKey, pubKey) + Nip44v1.EncryptedInfo.V -> decryptNIP44v1(payload, privateKey, pubKey) + Nip44v2.EncryptedInfo.V -> decryptNIP44v2(payload, privateKey, pubKey) + else -> null + } + } catch (e: Exception) { + Log.e("CryptoUtils", "Could not identify the version for NIP44 payload $payload") + e.printStackTrace() + null + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Hkdf.kt b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Hkdf.kt index f0bded67d..0ca592c2a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Hkdf.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Hkdf.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.crypto import java.nio.ByteBuffer @@ -5,33 +25,40 @@ import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec class Hkdf(val algorithm: String = "HmacSHA256", val hashLen: Int = 32) { - fun extract(key: ByteArray, salt: ByteArray): ByteArray { - val mac = Mac.getInstance(algorithm) - mac.init(SecretKeySpec(salt, algorithm)) - return mac.doFinal(key) - } + fun extract( + key: ByteArray, + salt: ByteArray, + ): ByteArray { + val mac = Mac.getInstance(algorithm) + mac.init(SecretKeySpec(salt, algorithm)) + return mac.doFinal(key) + } - fun expand(key: ByteArray, nonce: ByteArray, outputLength: Int): ByteArray { - check(key.size == hashLen) - check(nonce.size == hashLen) + fun expand( + key: ByteArray, + nonce: ByteArray, + outputLength: Int, + ): ByteArray { + check(key.size == hashLen) + check(nonce.size == hashLen) - val n = if (outputLength % hashLen == 0) outputLength / hashLen else outputLength / hashLen + 1 - var hashRound = ByteArray(0) - val generatedBytes = ByteBuffer.allocate(Math.multiplyExact(n, hashLen)) - val mac = Mac.getInstance(algorithm) - mac.init(SecretKeySpec(key, algorithm)) - for (roundNum in 1..n) { - mac.reset() - val t = ByteBuffer.allocate(hashRound.size + nonce.size + 1) - t.put(hashRound) - t.put(nonce) - t.put(roundNum.toByte()) - hashRound = mac.doFinal(t.array()) - generatedBytes.put(hashRound) - } - val result = ByteArray(outputLength) - generatedBytes.rewind() - generatedBytes[result, 0, outputLength] - return result + val n = if (outputLength % hashLen == 0) outputLength / hashLen else outputLength / hashLen + 1 + var hashRound = ByteArray(0) + val generatedBytes = ByteBuffer.allocate(Math.multiplyExact(n, hashLen)) + val mac = Mac.getInstance(algorithm) + mac.init(SecretKeySpec(key, algorithm)) + for (roundNum in 1..n) { + mac.reset() + val t = ByteBuffer.allocate(hashRound.size + nonce.size + 1) + t.put(hashRound) + t.put(nonce) + t.put(roundNum.toByte()) + hashRound = mac.doFinal(t.array()) + generatedBytes.put(hashRound) } -} \ No newline at end of file + val result = ByteArray(outputLength) + generatedBytes.rewind() + generatedBytes[result, 0, outputLength] + return result + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/KeyPair.kt b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/KeyPair.kt index adb67b990..cf89e01c6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/KeyPair.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/KeyPair.kt @@ -1,34 +1,54 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.crypto import com.vitorpamplona.quartz.encoders.toHexKey class KeyPair( - privKey: ByteArray? = null, - pubKey: ByteArray? = null + privKey: ByteArray? = null, + pubKey: ByteArray? = null, ) { - val privKey: ByteArray? - val pubKey: ByteArray + val privKey: ByteArray? + val pubKey: ByteArray - init { - if (privKey == null) { - if (pubKey == null) { - // create new, random keys - this.privKey = CryptoUtils.privkeyCreate() - this.pubKey = CryptoUtils.pubkeyCreate(this.privKey) - } else { - // this is a read-only account - check(pubKey.size == 32) - this.privKey = null - this.pubKey = pubKey - } - } else { - // as private key is provided, ignore the public key and set keys according to private key - this.privKey = privKey - this.pubKey = CryptoUtils.pubkeyCreate(privKey) - } + init { + if (privKey == null) { + if (pubKey == null) { + // create new, random keys + this.privKey = CryptoUtils.privkeyCreate() + this.pubKey = CryptoUtils.pubkeyCreate(this.privKey) + } else { + // this is a read-only account + check(pubKey.size == 32) + this.privKey = null + this.pubKey = pubKey + } + } else { + // as private key is provided, ignore the public key and set keys according to private key + this.privKey = privKey + this.pubKey = CryptoUtils.pubkeyCreate(privKey) } + } - override fun toString(): String { - return "KeyPair(privateKey=${privKey?.toHexKey()}, publicKey=${pubKey.toHexKey()}" - } + override fun toString(): String { + return "KeyPair(privateKey=${privKey?.toHexKey()}, publicKey=${pubKey.toHexKey()}" + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Nip04.kt b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Nip04.kt index f6a85c812..8555db6ae 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Nip04.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Nip04.kt @@ -1,10 +1,28 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.crypto import android.util.Log -import android.util.LruCache import com.vitorpamplona.quartz.encoders.Hex import fr.acinq.secp256k1.Secp256k1 -import java.security.MessageDigest import java.security.SecureRandom import java.util.Base64 import javax.crypto.Cipher @@ -12,119 +30,147 @@ import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec class Nip04(val secp256k1: Secp256k1, val random: SecureRandom) { - private val sharedKeyCache = SharedKeyCache() - private val h02 = Hex.decode("02") + private val sharedKeyCache = SharedKeyCache() + private val h02 = Hex.decode("02") - fun clearCache() { - sharedKeyCache.clearCache() - } + fun clearCache() { + sharedKeyCache.clearCache() + } - fun encrypt(msg: String, privateKey: ByteArray, pubKey: ByteArray): String { - return encrypt(msg, getSharedSecret(privateKey, pubKey)).encodeToNIP04() - } + fun encrypt( + msg: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): String { + return encrypt(msg, getSharedSecret(privateKey, pubKey)).encodeToNIP04() + } - fun encrypt(msg: String, sharedSecret: ByteArray): EncryptedInfo { - val iv = ByteArray(16) - random.nextBytes(iv) + fun encrypt( + msg: String, + sharedSecret: ByteArray, + ): EncryptedInfo { + val iv = ByteArray(16) + random.nextBytes(iv) - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(iv)) - //val ivBase64 = Base64.getEncoder().encodeToString(iv) - val encryptedMsg = cipher.doFinal(msg.toByteArray()) - //val encryptedMsgBase64 = Base64.getEncoder().encodeToString(encryptedMsg) - return EncryptedInfo(encryptedMsg, iv) - } + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(iv)) + // val ivBase64 = Base64.getEncoder().encodeToString(iv) + val encryptedMsg = cipher.doFinal(msg.toByteArray()) + // val encryptedMsgBase64 = Base64.getEncoder().encodeToString(encryptedMsg) + return EncryptedInfo(encryptedMsg, iv) + } - fun decrypt(msg: String, privateKey: ByteArray, pubKey: ByteArray): String { - val sharedSecret = getSharedSecret(privateKey, pubKey) - return decrypt(msg, sharedSecret) - } + fun decrypt( + msg: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): String { + val sharedSecret = getSharedSecret(privateKey, pubKey) + return decrypt(msg, sharedSecret) + } - fun decrypt(encryptedInfo: EncryptedInfo, privateKey: ByteArray, pubKey: ByteArray): String { - val sharedSecret = getSharedSecret(privateKey, pubKey) - return decrypt(encryptedInfo.ciphertext, encryptedInfo.nonce, sharedSecret) - } + fun decrypt( + encryptedInfo: EncryptedInfo, + privateKey: ByteArray, + pubKey: ByteArray, + ): String { + val sharedSecret = getSharedSecret(privateKey, pubKey) + return decrypt(encryptedInfo.ciphertext, encryptedInfo.nonce, sharedSecret) + } - fun decrypt(msg: String, sharedSecret: ByteArray): String { - val decoded = EncryptedInfo.decodeFromNIP04(msg) - check(decoded != null) { - "Unable to decode msg $msg as NIP04" + fun decrypt( + msg: String, + sharedSecret: ByteArray, + ): String { + val decoded = EncryptedInfo.decodeFromNIP04(msg) + check(decoded != null) { "Unable to decode msg $msg as NIP04" } + return decrypt(decoded.ciphertext, decoded.nonce, sharedSecret) + } + + fun decrypt( + cipher: String, + nonce: String, + sharedSecret: ByteArray, + ): String { + val iv = Base64.getDecoder().decode(nonce) + val encryptedMsg = Base64.getDecoder().decode(cipher) + return decrypt(encryptedMsg, iv, sharedSecret) + } + + fun decrypt( + encryptedMsg: ByteArray, + iv: ByteArray, + sharedSecret: ByteArray, + ): String { + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(iv)) + return String(cipher.doFinal(encryptedMsg)) + } + + fun getSharedSecret( + privateKey: ByteArray, + pubKey: ByteArray, + ): ByteArray { + val preComputed = sharedKeyCache.get(privateKey, pubKey) + if (preComputed != null) return preComputed + + val computed = computeSharedSecret(privateKey, pubKey) + sharedKeyCache.add(privateKey, pubKey, computed) + return computed + } + + /** @return 32B shared secret */ + fun computeSharedSecret( + privateKey: ByteArray, + pubKey: ByteArray, + ): ByteArray = secp256k1.pubKeyTweakMul(h02 + pubKey, privateKey).copyOfRange(1, 33) + + class EncryptedInfo( + val ciphertext: ByteArray, + val nonce: ByteArray, + ) { + companion object { + const val V: Int = 0 + + fun decodePayload(payload: String): EncryptedInfo? { + return try { + val byteArray = Base64.getDecoder().decode(payload) + check(byteArray[0].toInt() == Nip44v1.EncryptedInfo.V) + return EncryptedInfo( + nonce = byteArray.copyOfRange(1, 25), + ciphertext = byteArray.copyOfRange(25, byteArray.size), + ) + } catch (e: Exception) { + Log.w("NIP04", "Unable to Parse encrypted payload: $payload") + null } - return decrypt(decoded.ciphertext, decoded.nonce, sharedSecret) - } + } - fun decrypt(cipher: String, nonce: String, sharedSecret: ByteArray): String { - val iv = Base64.getDecoder().decode(nonce) - val encryptedMsg = Base64.getDecoder().decode(cipher) - return decrypt(encryptedMsg, iv, sharedSecret) - } - - fun decrypt(encryptedMsg: ByteArray, iv: ByteArray, sharedSecret: ByteArray): String { - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(iv)) - return String(cipher.doFinal(encryptedMsg)) - } - - fun getSharedSecret(privateKey: ByteArray, pubKey: ByteArray): ByteArray { - val preComputed = sharedKeyCache.get(privateKey, pubKey) - if (preComputed != null) return preComputed - - val computed = computeSharedSecret(privateKey, pubKey) - sharedKeyCache.add(privateKey, pubKey, computed) - return computed - } - - /** - * @return 32B shared secret - */ - fun computeSharedSecret(privateKey: ByteArray, pubKey: ByteArray): ByteArray = - secp256k1.pubKeyTweakMul(h02 + pubKey, privateKey).copyOfRange(1, 33) - - class EncryptedInfo( - val ciphertext: ByteArray, - val nonce: ByteArray - ) { - companion object { - const val v: Int = 0 - - fun decodePayload(payload: String): EncryptedInfo? { - return try { - val byteArray = Base64.getDecoder().decode(payload) - check(byteArray[0].toInt() == Nip44v1.EncryptedInfo.v) - return EncryptedInfo( - nonce = byteArray.copyOfRange(1, 25), - ciphertext = byteArray.copyOfRange(25, byteArray.size) - ) - } catch (e: Exception) { - Log.w("NIP04", "Unable to Parse encrypted payload: ${payload}") - null - } - } - - fun decodeFromNIP04(payload: String): EncryptedInfo? { - return try { - val parts = payload.split("?iv=") - EncryptedInfo( - ciphertext = Base64.getDecoder().decode(parts[0]), - nonce = Base64.getDecoder().decode(parts[1]) - ) - } catch (e: Exception) { - Log.w("NIP04", "Unable to Parse encrypted payload: ${payload}") - null - } - } - } - - fun encodePayload(): String { - return Base64.getEncoder().encodeToString( - byteArrayOf(v.toByte()) + nonce + ciphertext - ) - } - - fun encodeToNIP04(): String { - val nonce = Base64.getEncoder().encodeToString(nonce) - val ciphertext = Base64.getEncoder().encodeToString(ciphertext) - return "${ciphertext}?iv=${nonce}" + fun decodeFromNIP04(payload: String): EncryptedInfo? { + return try { + val parts = payload.split("?iv=") + EncryptedInfo( + ciphertext = Base64.getDecoder().decode(parts[0]), + nonce = Base64.getDecoder().decode(parts[1]), + ) + } catch (e: Exception) { + Log.w("NIP04", "Unable to Parse encrypted payload: $payload") + null } + } } -} \ No newline at end of file + + fun encodePayload(): String { + return Base64.getEncoder() + .encodeToString( + byteArrayOf(V.toByte()) + nonce + ciphertext, + ) + } + + fun encodeToNIP04(): String { + val nonce = Base64.getEncoder().encodeToString(nonce) + val ciphertext = Base64.getEncoder().encodeToString(ciphertext) + return "$ciphertext?iv=$nonce" + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Nip44v1.kt b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Nip44v1.kt index 4be632923..3509dd6af 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Nip44v1.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Nip44v1.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.crypto import android.util.Log @@ -10,108 +30,136 @@ import java.security.SecureRandom import java.util.Base64 class Nip44v1(val secp256k1: Secp256k1, val random: SecureRandom) { - private val sharedKeyCache = SharedKeyCache() - private val h02 = Hex.decode("02") - private val libSodium = SodiumAndroid() + private val sharedKeyCache = SharedKeyCache() + private val h02 = Hex.decode("02") + private val libSodium = SodiumAndroid() - fun clearCache() { - sharedKeyCache.clearCache() - } + fun clearCache() { + sharedKeyCache.clearCache() + } - fun encrypt(msg: String, privateKey: ByteArray, pubKey: ByteArray): EncryptedInfo { - val sharedSecret = getSharedSecret(privateKey, pubKey) - return encrypt(msg, sharedSecret) - } + fun encrypt( + msg: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): EncryptedInfo { + val sharedSecret = getSharedSecret(privateKey, pubKey) + return encrypt(msg, sharedSecret) + } - fun encrypt(msg: String, sharedSecret: ByteArray): EncryptedInfo { - val nonce = ByteArray(24) - random.nextBytes(nonce) + fun encrypt( + msg: String, + sharedSecret: ByteArray, + ): EncryptedInfo { + val nonce = ByteArray(24) + random.nextBytes(nonce) - val cipher = cryptoStreamXChaCha20Xor( - libSodium = libSodium, - messageBytes = msg.toByteArray(), - nonce = nonce, - key = Key.fromBytes(sharedSecret) - ) + val cipher = + cryptoStreamXChaCha20Xor( + libSodium = libSodium, + messageBytes = msg.toByteArray(), + nonce = nonce, + key = Key.fromBytes(sharedSecret), + ) - return EncryptedInfo( - ciphertext = cipher ?: ByteArray(0), - nonce = nonce, - ) - } + return EncryptedInfo( + ciphertext = cipher ?: ByteArray(0), + nonce = nonce, + ) + } - fun decrypt(payload: String, privateKey: ByteArray, pubKey: ByteArray): String? { - val sharedSecret = getSharedSecret(privateKey, pubKey) - return decrypt(payload, sharedSecret) - } + fun decrypt( + payload: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): String? { + val sharedSecret = getSharedSecret(privateKey, pubKey) + return decrypt(payload, sharedSecret) + } - fun decrypt(encryptedInfo: EncryptedInfo, privateKey: ByteArray, pubKey: ByteArray): String? { - val sharedSecret = getSharedSecret(privateKey, pubKey) - return decrypt(encryptedInfo, sharedSecret) - } + fun decrypt( + encryptedInfo: EncryptedInfo, + privateKey: ByteArray, + pubKey: ByteArray, + ): String? { + val sharedSecret = getSharedSecret(privateKey, pubKey) + return decrypt(encryptedInfo, sharedSecret) + } - fun decrypt(payload: String, sharedSecret: ByteArray): String? { - val encryptedInfo = EncryptedInfo.decodePayload(payload) ?: return null - return decrypt(encryptedInfo, sharedSecret) - } + fun decrypt( + payload: String, + sharedSecret: ByteArray, + ): String? { + val encryptedInfo = EncryptedInfo.decodePayload(payload) ?: return null + return decrypt(encryptedInfo, sharedSecret) + } - fun decrypt(encryptedInfo: EncryptedInfo, sharedSecret: ByteArray): String? { - return cryptoStreamXChaCha20Xor( - libSodium = libSodium, - messageBytes = encryptedInfo.ciphertext, - nonce = encryptedInfo.nonce, - key = Key.fromBytes(sharedSecret) - )?.decodeToString() - } + fun decrypt( + encryptedInfo: EncryptedInfo, + sharedSecret: ByteArray, + ): String? { + return cryptoStreamXChaCha20Xor( + libSodium = libSodium, + messageBytes = encryptedInfo.ciphertext, + nonce = encryptedInfo.nonce, + key = Key.fromBytes(sharedSecret), + ) + ?.decodeToString() + } - fun getSharedSecret(privateKey: ByteArray, pubKey: ByteArray): ByteArray { - val preComputed = sharedKeyCache.get(privateKey, pubKey) - if (preComputed != null) return preComputed + fun getSharedSecret( + privateKey: ByteArray, + pubKey: ByteArray, + ): ByteArray { + val preComputed = sharedKeyCache.get(privateKey, pubKey) + if (preComputed != null) return preComputed - val computed = computeSharedSecret(privateKey, pubKey) - sharedKeyCache.add(privateKey, pubKey, computed) - return computed - } + val computed = computeSharedSecret(privateKey, pubKey) + sharedKeyCache.add(privateKey, pubKey, computed) + return computed + } - /** - * @return 32B shared secret - */ - fun computeSharedSecret(privateKey: ByteArray, pubKey: ByteArray): ByteArray = - sha256( - secp256k1.pubKeyTweakMul(h02 + pubKey, privateKey).copyOfRange(1, 33) - ) + /** @return 32B shared secret */ + fun computeSharedSecret( + privateKey: ByteArray, + pubKey: ByteArray, + ): ByteArray = + sha256( + secp256k1.pubKeyTweakMul(h02 + pubKey, privateKey).copyOfRange(1, 33), + ) - fun sha256(data: ByteArray): ByteArray { - // Creates a new buffer every time - return MessageDigest.getInstance("SHA-256").digest(data) - } + fun sha256(data: ByteArray): ByteArray { + // Creates a new buffer every time + return MessageDigest.getInstance("SHA-256").digest(data) + } - class EncryptedInfo( - val ciphertext: ByteArray, - val nonce: ByteArray - ) { - companion object { - const val v: Int = 1 + class EncryptedInfo( + val ciphertext: ByteArray, + val nonce: ByteArray, + ) { + companion object { + const val V: Int = 1 - fun decodePayload(payload: String): EncryptedInfo? { - return try { - val byteArray = Base64.getDecoder().decode(payload) - check(byteArray[0].toInt() == v) - return EncryptedInfo( - nonce = byteArray.copyOfRange(1, 25), - ciphertext = byteArray.copyOfRange(25, byteArray.size) - ) - } catch (e: Exception) { - Log.w("NIP44v1", "Unable to Parse encrypted payload: ${payload}") - null - } - } - } - - fun encodePayload(): String { - return Base64.getEncoder().encodeToString( - byteArrayOf(v.toByte()) + nonce + ciphertext - ) + fun decodePayload(payload: String): EncryptedInfo? { + return try { + val byteArray = Base64.getDecoder().decode(payload) + check(byteArray[0].toInt() == V) + return EncryptedInfo( + nonce = byteArray.copyOfRange(1, 25), + ciphertext = byteArray.copyOfRange(25, byteArray.size), + ) + } catch (e: Exception) { + Log.w("NIP44v1", "Unable to Parse encrypted payload: $payload") + null } + } } -} \ No newline at end of file + + fun encodePayload(): String { + return Base64.getEncoder() + .encodeToString( + byteArrayOf(V.toByte()) + nonce + ciphertext, + ) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Nip44v2.kt b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Nip44v2.kt index a677d2887..f5bb4a116 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Nip44v2.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Nip44v2.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.crypto import android.util.Log @@ -10,215 +30,254 @@ import java.nio.ByteBuffer import java.nio.ByteOrder import java.security.SecureRandom import java.util.Base64 -import kotlin.experimental.and import kotlin.math.floor import kotlin.math.log2 - class Nip44v2(val secp256k1: Secp256k1, val random: SecureRandom) { - private val sharedKeyCache = SharedKeyCache() + private val sharedKeyCache = SharedKeyCache() - private val libSodium = SodiumAndroid() - private val lazySodium = LazySodiumAndroid(libSodium) - private val hkdf = Hkdf() + private val libSodium = SodiumAndroid() + private val lazySodium = LazySodiumAndroid(libSodium) + private val hkdf = Hkdf() - private val h02 = Hex.decode("02") - private val saltPrefix = "nip44-v2".toByteArray(Charsets.UTF_8) - private val hashLength = 32 + private val h02 = Hex.decode("02") + private val saltPrefix = "nip44-v2".toByteArray(Charsets.UTF_8) + private val hashLength = 32 - private val minPlaintextSize: Int = 0x0001 // 1b msg => padded to 32b - private val maxPlaintextSize: Int = 0xffff // 65535 (64kb-1) => padded to 64kb + private val minPlaintextSize: Int = 0x0001 // 1b msg => padded to 32b + private val maxPlaintextSize: Int = 0xffff // 65535 (64kb-1) => padded to 64kb - fun clearCache() { - sharedKeyCache.clearCache() - } + fun clearCache() { + sharedKeyCache.clearCache() + } - fun encrypt(msg: String, privateKey: ByteArray, pubKey: ByteArray): EncryptedInfo { - return encrypt(msg, getConversationKey(privateKey, pubKey)) - } + fun encrypt( + msg: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): EncryptedInfo { + return encrypt(msg, getConversationKey(privateKey, pubKey)) + } - fun encrypt(plaintext: String, conversationKey: ByteArray): EncryptedInfo { - val nonce = ByteArray(hashLength) - random.nextBytes(nonce) - return encryptWithNonce(plaintext, conversationKey, nonce) - } + fun encrypt( + plaintext: String, + conversationKey: ByteArray, + ): EncryptedInfo { + val nonce = ByteArray(hashLength) + random.nextBytes(nonce) + return encryptWithNonce(plaintext, conversationKey, nonce) + } - fun encryptWithNonce(plaintext: String, conversationKey: ByteArray, nonce: ByteArray): EncryptedInfo { - val messageKeys = getMessageKeys(conversationKey, nonce) - val padded = pad(plaintext) + fun encryptWithNonce( + plaintext: String, + conversationKey: ByteArray, + nonce: ByteArray, + ): EncryptedInfo { + val messageKeys = getMessageKeys(conversationKey, nonce) + val padded = pad(plaintext) - val ciphertext = ByteArray(padded.size) + val ciphertext = ByteArray(padded.size) - lazySodium.cryptoStreamChaCha20IetfXor( - ciphertext, padded, padded.size.toLong(), messageKeys.chachaNonce, messageKeys.chachaKey - ) - - val mac = hmacAad(messageKeys.hmacKey, ciphertext, nonce) - - return EncryptedInfo( - nonce = nonce, - ciphertext = ciphertext, - mac = mac - ) - } - - - fun decrypt(payload: String, privateKey: ByteArray, pubKey: ByteArray): String? { - return decrypt(payload, getConversationKey(privateKey, pubKey)) - } - - fun decrypt(decoded: EncryptedInfo, privateKey: ByteArray, pubKey: ByteArray): String? { - return decrypt(decoded, getConversationKey(privateKey, pubKey)) - } - - fun decrypt(payload: String, conversationKey: ByteArray): String? { - val decoded = EncryptedInfo.decodePayload(payload) ?: return null - return decrypt(decoded, conversationKey) - } - - fun decrypt(decoded: EncryptedInfo, conversationKey: ByteArray): String? { - val messageKey = getMessageKeys(conversationKey, decoded.nonce) - val calculatedMac = hmacAad(messageKey.hmacKey, decoded.ciphertext, decoded.nonce) - - check(calculatedMac.contentEquals(decoded.mac)) { - "Invalid Mac: Calculated ${calculatedMac.toHexKey()}, decoded: ${decoded.mac.toHexKey()}" - } - - val mLen = decoded.ciphertext.size.toLong() - val padded = ByteArray(decoded.ciphertext.size) - - lazySodium.cryptoStreamChaCha20IetfXor( - padded, decoded.ciphertext, mLen, messageKey.chachaNonce, messageKey.chachaKey - ) - - return unpad(padded) - } - - fun getConversationKey(privateKey: ByteArray, pubKey: ByteArray): ByteArray { - val preComputed = sharedKeyCache.get(privateKey, pubKey) - if (preComputed != null) return preComputed - - val computed = computeConversationKey(privateKey, pubKey) - sharedKeyCache.add(privateKey, pubKey, computed) - return computed - } - - fun calcPaddedLen(len: Int): Int { - check(len > 0) { - "expected positive integer" - } - if (len <= 32) return 32 - val nextPower = 1 shl (floor(log2(len - 1f)) + 1).toInt() - val chunk = if (nextPower <= 256) 32 else nextPower / 8 - return chunk * (floor((len - 1f) / chunk).toInt() + 1) - } - - fun pad(plaintext: String): ByteArray { - val unpadded = plaintext.toByteArray(Charsets.UTF_8) - val unpaddedLen = unpadded.size - - check(unpaddedLen > 0) { - "Message is empty ($unpaddedLen): $plaintext" - } - - check(unpaddedLen <= maxPlaintextSize) { - "Message is too long ($unpaddedLen): $plaintext" - } - - val prefix = ByteBuffer.allocate(2).order(ByteOrder.BIG_ENDIAN).putShort(unpaddedLen.toShort()).array() - val suffix = ByteArray(calcPaddedLen(unpaddedLen) - unpaddedLen) - return ByteBuffer.wrap(prefix + unpadded + suffix).array() - } - - private fun bytesToInt(byte1: Byte, byte2: Byte, bigEndian: Boolean): Int { - return if (bigEndian) - (byte1.toInt() and 0xFF shl 8 or (byte2.toInt() and 0xFF)) - else - (byte2.toInt() and 0xFF shl 8 or (byte1.toInt() and 0xFF)) - } - - fun unpad(padded: ByteArray): String { - val unpaddedLen: Int = bytesToInt(padded[0], padded[1], true) - val unpadded = padded.sliceArray(2 until 2 + unpaddedLen) - - check( - unpaddedLen in minPlaintextSize..maxPlaintextSize - && unpadded.size == unpaddedLen - && padded.size == 2 + calcPaddedLen(unpaddedLen)) { - "invalid padding ${unpadded.size} != $unpaddedLen" - } - - return unpadded.decodeToString() - } - - fun hmacAad(key: ByteArray, message: ByteArray, aad: ByteArray): ByteArray { - check (aad.size == hashLength) { - "AAD associated data must be 32 bytes, but it was ${aad.size} bytes" - } - - return hkdf.extract(aad + message, key) - } - - - fun getMessageKeys(conversationKey: ByteArray, nonce: ByteArray): MessageKey { - val keys = hkdf.expand(conversationKey, nonce, 76) - return MessageKey( - chachaKey = keys.copyOfRange(0, 32), - chachaNonce = keys.copyOfRange(32, 44), - hmacKey = keys.copyOfRange(44, 76), - ) - } - - class MessageKey( - val chachaKey: ByteArray, - val chachaNonce: ByteArray, - val hmacKey: ByteArray + lazySodium.cryptoStreamChaCha20IetfXor( + ciphertext, + padded, + padded.size.toLong(), + messageKeys.chachaNonce, + messageKeys.chachaKey, ) - /** - * @return 32B shared secret - */ - fun computeConversationKey(privateKey: ByteArray, pubKey: ByteArray): ByteArray { - val sharedX = secp256k1.pubKeyTweakMul(h02 + pubKey, privateKey).copyOfRange(1, 33) - return hkdf.extract(sharedX, saltPrefix) + val mac = hmacAad(messageKeys.hmacKey, ciphertext, nonce) + + return EncryptedInfo( + nonce = nonce, + ciphertext = ciphertext, + mac = mac, + ) + } + + fun decrypt( + payload: String, + privateKey: ByteArray, + pubKey: ByteArray, + ): String? { + return decrypt(payload, getConversationKey(privateKey, pubKey)) + } + + fun decrypt( + decoded: EncryptedInfo, + privateKey: ByteArray, + pubKey: ByteArray, + ): String? { + return decrypt(decoded, getConversationKey(privateKey, pubKey)) + } + + fun decrypt( + payload: String, + conversationKey: ByteArray, + ): String? { + val decoded = EncryptedInfo.decodePayload(payload) ?: return null + return decrypt(decoded, conversationKey) + } + + fun decrypt( + decoded: EncryptedInfo, + conversationKey: ByteArray, + ): String? { + val messageKey = getMessageKeys(conversationKey, decoded.nonce) + val calculatedMac = hmacAad(messageKey.hmacKey, decoded.ciphertext, decoded.nonce) + + check(calculatedMac.contentEquals(decoded.mac)) { + "Invalid Mac: Calculated ${calculatedMac.toHexKey()}, decoded: ${decoded.mac.toHexKey()}" } - class EncryptedInfo( - val nonce: ByteArray, - val ciphertext: ByteArray, - val mac: ByteArray + val mLen = decoded.ciphertext.size.toLong() + val padded = ByteArray(decoded.ciphertext.size) + + lazySodium.cryptoStreamChaCha20IetfXor( + padded, + decoded.ciphertext, + mLen, + messageKey.chachaNonce, + messageKey.chachaKey, + ) + + return unpad(padded) + } + + fun getConversationKey( + privateKey: ByteArray, + pubKey: ByteArray, + ): ByteArray { + val preComputed = sharedKeyCache.get(privateKey, pubKey) + if (preComputed != null) return preComputed + + val computed = computeConversationKey(privateKey, pubKey) + sharedKeyCache.add(privateKey, pubKey, computed) + return computed + } + + fun calcPaddedLen(len: Int): Int { + check(len > 0) { "expected positive integer" } + if (len <= 32) return 32 + val nextPower = 1 shl (floor(log2(len - 1f)) + 1).toInt() + val chunk = if (nextPower <= 256) 32 else nextPower / 8 + return chunk * (floor((len - 1f) / chunk).toInt() + 1) + } + + fun pad(plaintext: String): ByteArray { + val unpadded = plaintext.toByteArray(Charsets.UTF_8) + val unpaddedLen = unpadded.size + + check(unpaddedLen > 0) { "Message is empty ($unpaddedLen): $plaintext" } + + check(unpaddedLen <= maxPlaintextSize) { "Message is too long ($unpaddedLen): $plaintext" } + + val prefix = + ByteBuffer.allocate(2).order(ByteOrder.BIG_ENDIAN).putShort(unpaddedLen.toShort()).array() + val suffix = ByteArray(calcPaddedLen(unpaddedLen) - unpaddedLen) + return ByteBuffer.wrap(prefix + unpadded + suffix).array() + } + + private fun bytesToInt( + byte1: Byte, + byte2: Byte, + bigEndian: Boolean, + ): Int { + return if (bigEndian) { + (byte1.toInt() and 0xFF shl 8 or (byte2.toInt() and 0xFF)) + } else { + (byte2.toInt() and 0xFF shl 8 or (byte1.toInt() and 0xFF)) + } + } + + fun unpad(padded: ByteArray): String { + val unpaddedLen: Int = bytesToInt(padded[0], padded[1], true) + val unpadded = padded.sliceArray(2 until 2 + unpaddedLen) + + check( + unpaddedLen in minPlaintextSize..maxPlaintextSize && + unpadded.size == unpaddedLen && + padded.size == 2 + calcPaddedLen(unpaddedLen), ) { - companion object { - const val v: Int = 2 - - fun decodePayload(payload: String): EncryptedInfo? { - check(payload.length >= 132 || payload.length <= 87472) { - "Invalid payload length ${payload.length} for ${payload}" - } - check(payload[0] != '#') { - "Unknown encryption version ${payload.get(0)}" - } - - return try { - val byteArray = Base64.getDecoder().decode(payload) - check(byteArray[0].toInt() == v) - return EncryptedInfo( - nonce = byteArray.copyOfRange(1, 33), - ciphertext = byteArray.copyOfRange(33, byteArray.size-32), - mac = byteArray.copyOfRange(byteArray.size-32, byteArray.size) - ) - } catch (e: Exception) { - Log.w("NIP44v2", "Unable to Parse encrypted payload: $payload") - null - } - } - } - - - fun encodePayload(): String { - return Base64.getEncoder().encodeToString( - byteArrayOf(v.toByte()) + nonce + ciphertext + mac - ) - } + "invalid padding ${unpadded.size} != $unpaddedLen" } -} \ No newline at end of file + + return unpadded.decodeToString() + } + + fun hmacAad( + key: ByteArray, + message: ByteArray, + aad: ByteArray, + ): ByteArray { + check(aad.size == hashLength) { + "AAD associated data must be 32 bytes, but it was ${aad.size} bytes" + } + + return hkdf.extract(aad + message, key) + } + + fun getMessageKeys( + conversationKey: ByteArray, + nonce: ByteArray, + ): MessageKey { + val keys = hkdf.expand(conversationKey, nonce, 76) + return MessageKey( + chachaKey = keys.copyOfRange(0, 32), + chachaNonce = keys.copyOfRange(32, 44), + hmacKey = keys.copyOfRange(44, 76), + ) + } + + class MessageKey( + val chachaKey: ByteArray, + val chachaNonce: ByteArray, + val hmacKey: ByteArray, + ) + + /** @return 32B shared secret */ + fun computeConversationKey( + privateKey: ByteArray, + pubKey: ByteArray, + ): ByteArray { + val sharedX = secp256k1.pubKeyTweakMul(h02 + pubKey, privateKey).copyOfRange(1, 33) + return hkdf.extract(sharedX, saltPrefix) + } + + class EncryptedInfo( + val nonce: ByteArray, + val ciphertext: ByteArray, + val mac: ByteArray, + ) { + companion object { + const val V: Int = 2 + + fun decodePayload(payload: String): EncryptedInfo? { + check(payload.length >= 132 || payload.length <= 87472) { + "Invalid payload length ${payload.length} for $payload" + } + check(payload[0] != '#') { "Unknown encryption version ${payload.get(0)}" } + + return try { + val byteArray = Base64.getDecoder().decode(payload) + check(byteArray[0].toInt() == V) + return EncryptedInfo( + nonce = byteArray.copyOfRange(1, 33), + ciphertext = byteArray.copyOfRange(33, byteArray.size - 32), + mac = byteArray.copyOfRange(byteArray.size - 32, byteArray.size), + ) + } catch (e: Exception) { + Log.w("NIP44v2", "Unable to Parse encrypted payload: $payload") + null + } + } + } + + fun encodePayload(): String { + return Base64.getEncoder() + .encodeToString( + byteArrayOf(V.toByte()) + nonce + ciphertext + mac, + ) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/SharedKeyCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/SharedKeyCache.kt index 3014f8234..b96f4829a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/SharedKeyCache.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/SharedKeyCache.kt @@ -1,26 +1,56 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.crypto import android.util.LruCache class SharedKeyCache { - private val sharedKeyCache = LruCache(200) + private val sharedKeyCache = LruCache(200) - fun clearCache() { - sharedKeyCache.evictAll() - } + fun clearCache() { + sharedKeyCache.evictAll() + } - fun combinedHashCode(a: ByteArray, b: ByteArray): Int { - var result = 1 - for (element in a) result = 31 * result + element - for (element in b) result = 31 * result + element - return result - } + fun combinedHashCode( + a: ByteArray, + b: ByteArray, + ): Int { + var result = 1 + for (element in a) result = 31 * result + element + for (element in b) result = 31 * result + element + return result + } - fun get(privateKey: ByteArray, pubKey: ByteArray): ByteArray? { - return sharedKeyCache[combinedHashCode(privateKey, pubKey)] - } + fun get( + privateKey: ByteArray, + pubKey: ByteArray, + ): ByteArray? { + return sharedKeyCache[combinedHashCode(privateKey, pubKey)] + } - fun add(privateKey: ByteArray, pubKey: ByteArray, secret: ByteArray) { - sharedKeyCache.put(combinedHashCode(privateKey, pubKey), secret) - } -} \ No newline at end of file + fun add( + privateKey: ByteArray, + pubKey: ByteArray, + secret: ByteArray, + ) { + sharedKeyCache.put(combinedHashCode(privateKey, pubKey), secret) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/SodiumUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/SodiumUtils.kt index d2ccbae8d..4a603d055 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/SodiumUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/SodiumUtils.kt @@ -1,85 +1,105 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.crypto import com.goterl.lazysodium.SodiumAndroid import com.goterl.lazysodium.utils.Key /** - * I initially extended these methods from the Sodium and SodiumAndroid classes - * But JNI doesn't like it. There is some native method overriding bug - * when using Kotlin extensions - **/ - -fun /*Sodium.*/crypto_stream_xchacha20_xor_ic( - libSodium: SodiumAndroid, - cipher: ByteArray, - message: ByteArray, - messageLen: Long, - nonce: ByteArray, - ic: Long, - key: ByteArray + * I initially extended these methods from the Sodium and SodiumAndroid classes But JNI doesn't like + * it. There is some native method overriding bug when using Kotlin extensions + */ +fun cryptoStreamXchacha20XorIc( + libSodium: SodiumAndroid, + cipher: ByteArray, + message: ByteArray, + messageLen: Long, + nonce: ByteArray, + ic: Long, + key: ByteArray, ): Int { - /** - * C++ Code: - * - * unsigned char k2[crypto_core_hchacha20_OUTPUTBYTES]; - * crypto_core_hchacha20(k2, n, k, NULL); - * return crypto_stream_chacha20_xor_ic( - * c, m, mlen, n + crypto_core_hchacha20_INPUTBYTES, ic, k2); - */ - val k2 = ByteArray(32) + /** + * C++ Code: + * + * unsigned char k2[crypto_core_hchacha20_OUTPUTBYTES]; crypto_core_hchacha20(k2, n, k, NULL); + * return crypto_stream_chacha20_xor_ic( c, m, mlen, n + crypto_core_hchacha20_INPUTBYTES, ic, + * k2); + */ + val k2 = ByteArray(32) - val nonceChaCha = nonce.drop(16).toByteArray() - assert(nonceChaCha.size == 8) + val nonceChaCha = nonce.drop(16).toByteArray() + assert(nonceChaCha.size == 8) - libSodium.crypto_core_hchacha20(k2, nonce, key, null) - return libSodium.crypto_stream_chacha20_xor_ic( - cipher, - message, - messageLen, - nonceChaCha, - ic, - k2 - ) + libSodium.crypto_core_hchacha20(k2, nonce, key, null) + return libSodium.crypto_stream_chacha20_xor_ic( + cipher, + message, + messageLen, + nonceChaCha, + ic, + k2, + ) } -fun /*Sodium.*/crypto_stream_xchacha20_xor( - libSodium: SodiumAndroid, - cipher: ByteArray, - message: ByteArray, - messageLen: Long, - nonce: ByteArray, - key: ByteArray +fun cryptoStreamXchacha20Xor( + libSodium: SodiumAndroid, + cipher: ByteArray, + message: ByteArray, + messageLen: Long, + nonce: ByteArray, + key: ByteArray, ): Int { - return crypto_stream_xchacha20_xor_ic(libSodium, cipher, message, messageLen, nonce, 0, key) + return cryptoStreamXchacha20XorIc(libSodium, cipher, message, messageLen, nonce, 0, key) } -fun /*SodiumAndroid.*/cryptoStreamXChaCha20Xor( - libSodium: SodiumAndroid, - cipher: ByteArray, - message: ByteArray, - messageLen: Long, - nonce: ByteArray, - key: ByteArray +fun cryptoStreamXChaCha20Xor( + libSodium: SodiumAndroid, + cipher: ByteArray, + message: ByteArray, + messageLen: Long, + nonce: ByteArray, + key: ByteArray, ): Boolean { - require(!(messageLen < 0 || messageLen > message.size)) { "messageLen out of bounds: $messageLen" } - return crypto_stream_xchacha20_xor( - libSodium, - cipher, - message, - messageLen, - nonce, - key - ) == 0 + require(!(messageLen < 0 || messageLen > message.size)) { + "messageLen out of bounds: $messageLen" + } + return cryptoStreamXchacha20Xor( + libSodium, + cipher, + message, + messageLen, + nonce, + key, + ) == 0 } -fun /*SodiumAndroid.*/cryptoStreamXChaCha20Xor( - libSodium: SodiumAndroid, - messageBytes: ByteArray, - nonce: ByteArray, - key: Key +fun cryptoStreamXChaCha20Xor( + libSodium: SodiumAndroid, + messageBytes: ByteArray, + nonce: ByteArray, + key: Key, ): ByteArray? { - val mLen = messageBytes.size - val cipher = ByteArray(mLen) - val successful = cryptoStreamXChaCha20Xor(libSodium, cipher, messageBytes, mLen.toLong(), nonce, key.asBytes) - return if (successful) cipher else null + val mLen = messageBytes.size + val cipher = ByteArray(mLen) + val successful = + cryptoStreamXChaCha20Xor(libSodium, cipher, messageBytes, mLen.toLong(), nonce, key.asBytes) + return if (successful) cipher else null } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/ATag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/ATag.kt index 8d0c78c79..25f9165dd 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/ATag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/ATag.kt @@ -1,68 +1,96 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.encoders import android.util.Log import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.encoders.Hex @Immutable data class ATag(val kind: Int, val pubKeyHex: String, val dTag: String, val relay: String?) { - fun toTag() = "$kind:$pubKeyHex:$dTag" + fun toTag() = "$kind:$pubKeyHex:$dTag" - fun toNAddr(): String { - return TlvBuilder().apply { - addString(Nip19.TlvTypes.SPECIAL, dTag) - addStringIfNotNull(Nip19.TlvTypes.RELAY, relay) - addHex(Nip19.TlvTypes.AUTHOR, pubKeyHex) - addInt(Nip19.TlvTypes.KIND, kind) - }.build().toNAddress() + fun toNAddr(): String { + return TlvBuilder() + .apply { + addString(Nip19.TlvTypes.SPECIAL, dTag) + addStringIfNotNull(Nip19.TlvTypes.RELAY, relay) + addHex(Nip19.TlvTypes.AUTHOR, pubKeyHex) + addInt(Nip19.TlvTypes.KIND, kind) + } + .build() + .toNAddress() + } + + companion object { + fun isATag(key: String): Boolean { + return key.startsWith("naddr1") || key.contains(":") } - companion object { - fun isATag(key: String): Boolean { - return key.startsWith("naddr1") || key.contains(":") - } - - fun parse(address: String, relay: String?): ATag? { - return if (address.startsWith("naddr") || address.startsWith("nostr:naddr")) { - parseNAddr(address) - } else { - parseAtag(address, relay) - } - } - - fun parseAtag(atag: String, relay: String?): ATag? { - return try { - val parts = atag.split(":") - Hex.decode(parts[1]) - ATag(parts[0].toInt(), parts[1], parts[2], relay) - } catch (t: Throwable) { - Log.w("ATag", "Error parsing A Tag: $atag: ${t.message}") - null - } - } - - fun parseNAddr(naddr: String): ATag? { - try { - val key = naddr.removePrefix("nostr:") - - if (key.startsWith("naddr")) { - val tlv = Tlv.parse(key.bechToBytes()) - - val d = tlv.firstAsString(Nip19.TlvTypes.SPECIAL) ?: "" - val relay = tlv.firstAsString(Nip19.TlvTypes.RELAY) - val author = tlv.firstAsHex(Nip19.TlvTypes.AUTHOR) - val kind = tlv.firstAsInt(Nip19.TlvTypes.KIND) - - if (kind != null && author != null) { - return ATag(kind, author, d, relay) - } - } - } catch (e: Throwable) { - Log.w("ATag", "Issue trying to Decode NIP19 $this: ${e.message}") - // e.printStackTrace() - } - - return null - } + fun parse( + address: String, + relay: String?, + ): ATag? { + return if (address.startsWith("naddr") || address.startsWith("nostr:naddr")) { + parseNAddr(address) + } else { + parseAtag(address, relay) + } } + + fun parseAtag( + atag: String, + relay: String?, + ): ATag? { + return try { + val parts = atag.split(":") + Hex.decode(parts[1]) + ATag(parts[0].toInt(), parts[1], parts[2], relay) + } catch (t: Throwable) { + Log.w("ATag", "Error parsing A Tag: $atag: ${t.message}") + null + } + } + + fun parseNAddr(naddr: String): ATag? { + try { + val key = naddr.removePrefix("nostr:") + + if (key.startsWith("naddr")) { + val tlv = Tlv.parse(key.bechToBytes()) + + val d = tlv.firstAsString(Nip19.TlvTypes.SPECIAL) ?: "" + val relay = tlv.firstAsString(Nip19.TlvTypes.RELAY) + val author = tlv.firstAsHex(Nip19.TlvTypes.AUTHOR) + val kind = tlv.firstAsInt(Nip19.TlvTypes.KIND) + + if (kind != null && author != null) { + return ATag(kind, author, d, relay) + } + } + } catch (e: Throwable) { + Log.w("ATag", "Issue trying to Decode NIP19 $this: ${e.message}") + // e.printStackTrace() + } + + return null + } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Bech32Util.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Bech32Util.kt index f8fc487ba..a4dcda64f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Bech32Util.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Bech32Util.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.encoders /* @@ -19,224 +39,258 @@ package com.vitorpamplona.quartz.encoders import kotlin.jvm.JvmStatic /** - * Bech32 works with 5 bits values, we use this type to make it explicit: whenever you see Int5 it means 5 bits values, - * and whenever you see Byte it means 8 bits values. + * Bech32 works with 5 bits values, we use this type to make it explicit: whenever you see Int5 it + * means 5 bits values, and whenever you see Byte it means 8 bits values. */ private typealias Int5 = Byte /** - * Bech32 and Bech32m address formats. - * See https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki and https://github.com/bitcoin/bips/blob/master/bip-0350.mediawiki. + * Bech32 and Bech32m address formats. See + * https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki and + * https://github.com/bitcoin/bips/blob/master/bip-0350.mediawiki. */ object Bech32 { - const val alphabet: String = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" - const val alphabetUpperCase: String = "QPZRY9X8GF2TVDW0S3JN54KHCE6MUA7L" + const val ALPHABET: String = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + const val ALPHABET_UPPERCASE: String = "QPZRY9X8GF2TVDW0S3JN54KHCE6MUA7L" - enum class Encoding(public val constant: Int) { - Bech32(1), - Bech32m(0x2bc830a3), - Beck32WithoutChecksum(0) + enum class Encoding(public val constant: Int) { + Bech32(1), + Bech32m(0x2bc830a3), + Beck32WithoutChecksum(0), + } + + // char -> 5 bits value + private val map = Array(255) { -1 } + + init { + for (i in 0..ALPHABET.lastIndex) { + map[ALPHABET[i].code] = i.toByte() + } + for (i in 0..ALPHABET_UPPERCASE.lastIndex) { + map[ALPHABET_UPPERCASE[i].code] = i.toByte() + } + } + + fun expand(hrp: String): Array { + val half = hrp.length + 1 + val size = half + hrp.length + return Array(size) { + when (it) { + in hrp.indices -> hrp[it].code.shr(5).toByte() + in half until size -> (hrp[it - half].code and 31).toByte() + else -> 0 + } + } + } + + private val GEN = arrayOf(0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3) + + fun polymod( + values: Array, + values1: Array, + ): Int { + var chk = 1 + values.forEach { v -> + val b = chk shr 25 + chk = ((chk and 0x1ffffff) shl 5) xor v.toInt() + for (i in 0..4) { + if (((b shr i) and 1) != 0) chk = chk xor GEN[i] + } + } + values1.forEach { v -> + val b = chk shr 25 + chk = ((chk and 0x1ffffff) shl 5) xor v.toInt() + for (i in 0..4) { + if (((b shr i) and 1) != 0) chk = chk xor GEN[i] + } + } + return chk + } + + /** + * @param hrp human readable prefix + * @param int5s 5-bit data + * @param encoding encoding to use (bech32 or bech32m) + * @return hrp + data encoded as a Bech32 string + */ + @JvmStatic + public fun encode( + hrp: String, + int5s: ArrayList, + encoding: Encoding, + ): String { + require(hrp.lowercase() == hrp || hrp.uppercase() == hrp) { + "mixed case strings are not valid bech32 prefixes" + } + val dataWithChecksum = + when (encoding) { + Encoding.Beck32WithoutChecksum -> int5s + else -> addChecksum(hrp, int5s, encoding) + } + + val charArray = + CharArray(dataWithChecksum.size) { ALPHABET[dataWithChecksum[it].toInt()] }.concatToString() + + return hrp + "1" + charArray + } + + /** + * @param hrp human readable prefix + * @param data data to encode + * @param encoding encoding to use (bech32 or bech32m) + * @return hrp + data encoded as a Bech32 string + */ + @JvmStatic + public fun encodeBytes( + hrp: String, + data: ByteArray, + encoding: Encoding, + ): String = encode(hrp, eight2five(data), encoding) + + /** + * decodes a bech32 string + * + * @param bech32 bech32 string + * @param noChecksum if true, the bech32 string doesn't have a checksum + * @return a (hrp, data, encoding) tuple + */ + @JvmStatic + public fun decode( + bech32: String, + noChecksum: Boolean = false, + ): Triple, Encoding> { + var pos = 0 + bech32.forEachIndexed { index, char -> + require(char.code in 33..126) { "invalid character $char" } + if (char == '1') { + pos = index + } } - // char -> 5 bits value - private val map = Array(255) { -1 } + val hrp = bech32.take(pos).lowercase() // strings must be lower case + require(hrp.length in 1..83) { "hrp must contain 1 to 83 characters" } - init { - for (i in 0..alphabet.lastIndex) { - map[alphabet[i].code] = i.toByte() - } - for (i in 0..alphabetUpperCase.lastIndex) { - map[alphabetUpperCase[i].code] = i.toByte() + val data = Array(bech32.length - pos - 1) { map[bech32[pos + 1 + it].code] } + + return if (noChecksum) { + Triple(hrp, data, Encoding.Beck32WithoutChecksum) + } else { + val encoding = + when (polymod(expand(hrp), data)) { + Encoding.Bech32.constant -> Encoding.Bech32 + Encoding.Bech32m.constant -> Encoding.Bech32m + else -> throw IllegalArgumentException("invalid checksum for $bech32") } + Triple(hrp, data.copyOfRange(0, data.size - 6), encoding) + } + } + + /** + * decodes a bech32 string + * + * @param bech32 bech32 string + * @param noChecksum if true, the bech32 string doesn't have a checksum + * @return a (hrp, data, encoding) tuple + */ + @JvmStatic + public fun decodeBytes( + bech32: String, + noChecksum: Boolean = false, + ): Triple { + val (hrp, int5s, encoding) = decode(bech32, noChecksum) + return Triple(hrp, five2eight(int5s, 0), encoding) + } + + val ZEROS = arrayOf(0.toByte(), 0.toByte(), 0.toByte(), 0.toByte(), 0.toByte(), 0.toByte()) + + /** + * @param hrp Human Readable Part + * @param data data (a sequence of 5 bits integers) + * @param encoding encoding to use (bech32 or bech32m) + * @return a checksum computed over hrp and data + */ + private fun addChecksum( + hrp: String, + data: ArrayList, + encoding: Encoding, + ): ArrayList { + val values = expand(hrp) + data + val poly = polymod(values, ZEROS) xor encoding.constant + + for (i in 0 until 6) { + data.add((poly.shr(5 * (5 - i)) and 31).toByte()) } - fun expand(hrp: String): Array { - val half = hrp.length + 1 - val size = half + hrp.length - return Array(size) { - when (it) { - in hrp.indices -> hrp[it].code.shr(5).toByte() - in half until size -> (hrp[it - half].code and 31).toByte() - else -> 0 - } - } + return data + } + + /** + * @param input a sequence of 8 bits integers + * @return a sequence of 5 bits integers + */ + @JvmStatic + public fun eight2five(input: ByteArray): ArrayList { + var buffer = 0L + val output = + ArrayList(input.size * 2) // larger array on purpose. Checksum is added later. + var count = 0 + input.forEach { b -> + buffer = (buffer shl 8) or (b.toLong() and 0xff) + count += 8 + while (count >= 5) { + output.add(((buffer shr (count - 5)) and 31).toByte()) + count -= 5 + } } + if (count > 0) output.add(((buffer shl (5 - count)) and 31).toByte()) + return output + } - private val GEN = arrayOf(0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3) - - fun polymod(values: Array, values1: Array): Int { - var chk = 1 - values.forEach { v -> - val b = chk shr 25 - chk = ((chk and 0x1ffffff) shl 5) xor v.toInt() - for (i in 0..4) { - if (((b shr i) and 1) != 0) chk = chk xor GEN[i] - } - } - values1.forEach { v -> - val b = chk shr 25 - chk = ((chk and 0x1ffffff) shl 5) xor v.toInt() - for (i in 0..4) { - if (((b shr i) and 1) != 0) chk = chk xor GEN[i] - } - } - return chk - } - - /** - * @param hrp human readable prefix - * @param int5s 5-bit data - * @param encoding encoding to use (bech32 or bech32m) - * @return hrp + data encoded as a Bech32 string - */ - @JvmStatic - public fun encode(hrp: String, int5s: ArrayList, encoding: Encoding): String { - require(hrp.lowercase() == hrp || hrp.uppercase() == hrp) { "mixed case strings are not valid bech32 prefixes" } - val dataWithChecksum = when (encoding) { - Encoding.Beck32WithoutChecksum -> int5s - else -> addChecksum(hrp, int5s, encoding) - } - - val charArray = CharArray(dataWithChecksum.size) { - alphabet[dataWithChecksum[it].toInt()] - }.concatToString() - - return hrp + "1" + charArray - } - - /** - * @param hrp human readable prefix - * @param data data to encode - * @param encoding encoding to use (bech32 or bech32m) - * @return hrp + data encoded as a Bech32 string - */ - @JvmStatic - public fun encodeBytes(hrp: String, data: ByteArray, encoding: Encoding): String = encode(hrp, eight2five(data), encoding) - - /** - * decodes a bech32 string - * @param bech32 bech32 string - * @param noChecksum if true, the bech32 string doesn't have a checksum - * @return a (hrp, data, encoding) tuple - */ - @JvmStatic - public fun decode(bech32: String, noChecksum: Boolean = false): Triple, Encoding> { - var pos = 0 - bech32.forEachIndexed { index, char -> - require( char.code in 33..126 ) { "invalid character ${char}" } - if (char == '1') { - pos = index - } - } - - val hrp = bech32.take(pos).lowercase() // strings must be lower case - require(hrp.length in 1..83) { "hrp must contain 1 to 83 characters" } - - val data = Array(bech32.length - pos - 1) { - map[bech32[pos + 1 + it].code] - } - - return if (noChecksum) { - Triple(hrp, data, Encoding.Beck32WithoutChecksum) - } else { - val encoding = when (polymod(expand(hrp), data)) { - Encoding.Bech32.constant -> Encoding.Bech32 - Encoding.Bech32m.constant -> Encoding.Bech32m - else -> throw IllegalArgumentException("invalid checksum for $bech32") - } - Triple(hrp, data.copyOfRange(0, data.size-6), encoding) - } - } - - /** - * decodes a bech32 string - * @param bech32 bech32 string - * @param noChecksum if true, the bech32 string doesn't have a checksum - * @return a (hrp, data, encoding) tuple - */ - @JvmStatic - public fun decodeBytes(bech32: String, noChecksum: Boolean = false): Triple { - val (hrp, int5s, encoding) = decode(bech32, noChecksum) - return Triple(hrp, five2eight(int5s, 0), encoding) - } - - val ZEROS = arrayOf(0.toByte(), 0.toByte(), 0.toByte(), 0.toByte(), 0.toByte(), 0.toByte()) - - /** - * @param hrp Human Readable Part - * @param data data (a sequence of 5 bits integers) - * @param encoding encoding to use (bech32 or bech32m) - * @return a checksum computed over hrp and data - */ - private fun addChecksum(hrp: String, data: ArrayList, encoding: Encoding): ArrayList { - val values = expand(hrp) + data - val poly = polymod(values, ZEROS) xor encoding.constant - - for (i in 0 until 6) { - data.add((poly.shr(5 * (5 - i)) and 31).toByte()) - } - - return data - } - - /** - * @param input a sequence of 8 bits integers - * @return a sequence of 5 bits integers - */ - @JvmStatic - public fun eight2five(input: ByteArray): ArrayList { - var buffer = 0L - val output = ArrayList(input.size * 2) //larger array on purpose. Checksum is added later. - var count = 0 - input.forEach { b -> - buffer = (buffer shl 8) or (b.toLong() and 0xff) - count += 8 - while (count >= 5) { - output.add(((buffer shr (count - 5)) and 31).toByte()) - count -= 5 - } - } - if (count > 0) output.add(((buffer shl (5 - count)) and 31).toByte()) - return output - } - - /** - * @param input a sequence of 5 bits integers - * @return a sequence of 8 bits integers - */ - @JvmStatic - public fun five2eight(input: Array, offset: Int): ByteArray { - var buffer = 0L - val output = ArrayList(input.size) - var count = 0 - for (i in offset..input.lastIndex) { - val b = input[i] - buffer = (buffer shl 5) or (b.toLong() and 31) - count += 5 - while (count >= 8) { - output.add(((buffer shr (count - 8)) and 0xff).toByte()) - count -= 8 - } - } - require(count <= 4) { "Zero-padding of more than 4 bits" } - require((buffer and ((1L shl count) - 1L)) == 0L) { "Non-zero padding in 8-to-5 conversion" } - return output.toByteArray() + /** + * @param input a sequence of 5 bits integers + * @return a sequence of 8 bits integers + */ + @JvmStatic + public fun five2eight( + input: Array, + offset: Int, + ): ByteArray { + var buffer = 0L + val output = ArrayList(input.size) + var count = 0 + for (i in offset..input.lastIndex) { + val b = input[i] + buffer = (buffer shl 5) or (b.toLong() and 31) + count += 5 + while (count >= 8) { + output.add(((buffer shr (count - 8)) and 0xff).toByte()) + count -= 8 + } } + require(count <= 4) { "Zero-padding of more than 4 bits" } + require((buffer and ((1L shl count) - 1L)) == 0L) { "Non-zero padding in 8-to-5 conversion" } + return output.toByteArray() + } } fun ByteArray.toNsec() = Bech32.encodeBytes(hrp = "nsec", this, Bech32.Encoding.Bech32) + fun ByteArray.toNpub() = Bech32.encodeBytes(hrp = "npub", this, Bech32.Encoding.Bech32) + fun ByteArray.toNote() = Bech32.encodeBytes(hrp = "note", this, Bech32.Encoding.Bech32) + fun ByteArray.toNEvent() = Bech32.encodeBytes(hrp = "nevent", this, Bech32.Encoding.Bech32) + fun ByteArray.toNAddress() = Bech32.encodeBytes(hrp = "naddr", this, Bech32.Encoding.Bech32) + fun ByteArray.toLnUrl() = Bech32.encodeBytes(hrp = "lnurl", this, Bech32.Encoding.Bech32) fun String.bechToBytes(hrp: String? = null): ByteArray { - val decodedForm = Bech32.decodeBytes(this) - hrp?.also { - if (it != decodedForm.first) { - throw IllegalArgumentException("Expected $it but obtained ${decodedForm.first}") - } + val decodedForm = Bech32.decodeBytes(this) + hrp?.also { + if (it != decodedForm.first) { + throw IllegalArgumentException("Expected $it but obtained ${decodedForm.first}") } - return decodedForm.second + } + return decodedForm.second } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/HexUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/HexUtils.kt index cfead27e4..6d546970d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/HexUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/HexUtils.kt @@ -1,72 +1,115 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.encoders -/** Makes the distinction between String and Hex **/ +/** Makes the distinction between String and Hex * */ typealias HexKey = String fun ByteArray.toHexKey(): HexKey { - return Hex.encode(this) + return Hex.encode(this) } fun HexKey.hexToByteArray(): ByteArray { - return Hex.decode(this) + return Hex.decode(this) } object HexValidator { - private fun isHexChar(c: Char): Boolean { - return when (c) { - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'A', 'B', 'C', 'D', 'E', 'F' -> true - else -> false - } + private fun isHexChar(c: Char): Boolean { + return when (c) { + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', -> true + else -> false } + } - fun isHex(hex: String?): Boolean { - if (hex == null) return false - if (hex.length % 2 != 0) return false // must be even - var isHex = true + fun isHex(hex: String?): Boolean { + if (hex == null) return false + if (hex.length % 2 != 0) return false // must be even + var isHex = true - for (c in hex) { - if (!isHexChar(c)) { - isHex = false - break - } - } - return isHex + for (c in hex) { + if (!isHexChar(c)) { + isHex = false + break + } } + return isHex + } } object Hex { - val hexCode = arrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f') + val hexCode = + arrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f') - // Faster if no calculations are needed. - private fun hexToBin(ch: Char): Int = when (ch) { - in '0'..'9' -> ch - '0' - in 'a'..'f' -> ch - 'a' + 10 - in 'A'..'F' -> ch - 'A' + 10 - else -> throw IllegalArgumentException("illegal hex character: $ch") + // Faster if no calculations are needed. + private fun hexToBin(ch: Char): Int = + when (ch) { + in '0'..'9' -> ch - '0' + in 'a'..'f' -> ch - 'a' + 10 + in 'A'..'F' -> ch - 'A' + 10 + else -> throw IllegalArgumentException("illegal hex character: $ch") } - @JvmStatic - fun decode(hex: String): ByteArray { - // faster version of hex decoder - require(hex.length % 2 == 0) - val outSize = hex.length / 2 - val out = ByteArray(outSize) + @JvmStatic + fun decode(hex: String): ByteArray { + // faster version of hex decoder + require(hex.length % 2 == 0) + val outSize = hex.length / 2 + val out = ByteArray(outSize) - for (i in 0 until outSize) { - out[i] = (hexToBin(hex[2 * i]) * 16 + hexToBin(hex[2 * i + 1])).toByte() - } - - return out + for (i in 0 until outSize) { + out[i] = (hexToBin(hex[2 * i]) * 16 + hexToBin(hex[2 * i + 1])).toByte() } - @JvmStatic - fun encode(input: ByteArray): String { - val len = input.size - val out = CharArray(len * 2) - for (i in 0 until len) { - out[i*2] = hexCode[(input[i].toInt() shr 4) and 0xF] - out[i*2+1] = hexCode[input[i].toInt() and 0xF] - } - return String(out) + return out + } + + @JvmStatic + fun encode(input: ByteArray): String { + val len = input.size + val out = CharArray(len * 2) + for (i in 0 until len) { + out[i * 2] = hexCode[(input[i].toInt() shr 4) and 0xF] + out[i * 2 + 1] = hexCode[input[i].toInt() and 0xF] } -} \ No newline at end of file + return String(out) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/LnInvoiceUtil.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/LnInvoiceUtil.kt index 0c23a93de..0f5ffb3c4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/LnInvoiceUtil.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/LnInvoiceUtil.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.encoders import java.math.BigDecimal @@ -6,174 +26,305 @@ import java.util.regex.Pattern /** based on litecoinj */ object LnInvoiceUtil { - private val invoicePattern = Pattern.compile("lnbc((\\d+)([munp])?)?1[^1\\s]+", Pattern.CASE_INSENSITIVE) + private val invoicePattern = + Pattern.compile("lnbc((\\d+)([munp])?)?1[^1\\s]+", Pattern.CASE_INSENSITIVE) - /** The Bech32 character set for encoding. */ - private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + /** The Bech32 character set for encoding. */ + private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" - /** The Bech32 character set for decoding. */ - private val CHARSET_REV = byteArrayOf( - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - 15, -1, 10, 17, 21, 20, 26, 30, 7, 5, -1, -1, -1, -1, -1, -1, - -1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1, - 1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1, - -1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1, - 1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1 + /** The Bech32 character set for decoding. */ + private val CHARSET_REV = + byteArrayOf( + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 15, + -1, + 10, + 17, + 21, + 20, + 26, + 30, + 7, + 5, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + 29, + -1, + 24, + 13, + 25, + 9, + 8, + 23, + -1, + 18, + 22, + 31, + 27, + 19, + -1, + 1, + 0, + 3, + 16, + 11, + 28, + 12, + 14, + 6, + 4, + 2, + -1, + -1, + -1, + -1, + -1, + -1, + 29, + -1, + 24, + 13, + 25, + 9, + 8, + 23, + -1, + 18, + 22, + 31, + 27, + 19, + -1, + 1, + 0, + 3, + 16, + 11, + 28, + 12, + 14, + 6, + 4, + 2, + -1, + -1, + -1, + -1, + -1, ) - /** Find the polynomial with value coefficients mod the generator as 30-bit. */ - private fun polymod(values: ByteArray): Int { - var c = 1 - for (v_i in values) { - val c0 = c ushr 25 and 0xff - c = c and 0x1ffffff shl 5 xor (v_i.toInt() and 0xff) - if (c0 and 1 != 0) c = c xor 0x3b6a57b2 - if (c0 and 2 != 0) c = c xor 0x26508e6d - if (c0 and 4 != 0) c = c xor 0x1ea119fa - if (c0 and 8 != 0) c = c xor 0x3d4233dd - if (c0 and 16 != 0) c = c xor 0x2a1462b3 - } - return c + /** Find the polynomial with value coefficients mod the generator as 30-bit. */ + private fun polymod(values: ByteArray): Int { + var c = 1 + for (v_i in values) { + val c0 = c ushr 25 and 0xff + c = c and 0x1ffffff shl 5 xor (v_i.toInt() and 0xff) + if (c0 and 1 != 0) c = c xor 0x3b6a57b2 + if (c0 and 2 != 0) c = c xor 0x26508e6d + if (c0 and 4 != 0) c = c xor 0x1ea119fa + if (c0 and 8 != 0) c = c xor 0x3d4233dd + if (c0 and 16 != 0) c = c xor 0x2a1462b3 + } + return c + } + + /** Expand a HRP for use in checksum computation. */ + private fun expandHrp(hrp: String): ByteArray { + val hrpLength = hrp.length + val ret = ByteArray(hrpLength * 2 + 1) + for (i in 0 until hrpLength) { + val c = hrp[i].code and 0x7f // Limit to standard 7-bit ASCII + ret[i] = (c ushr 5 and 0x07).toByte() + ret[i + hrpLength + 1] = (c and 0x1f).toByte() + } + ret[hrpLength] = 0 + return ret + } + + /** Verify a checksum. */ + private fun verifyChecksum( + hrp: String, + values: ByteArray, + ): Boolean { + val hrpExpanded: ByteArray = expandHrp(hrp) + val combined = ByteArray(hrpExpanded.size + values.size) + System.arraycopy(hrpExpanded, 0, combined, 0, hrpExpanded.size) + System.arraycopy(values, 0, combined, hrpExpanded.size, values.size) + return polymod(combined) == 1 + } + + class AddressFormatException(message: String) : Exception(message) + + fun decodeUnlimitedLength(invoice: String): Boolean { + var lower = false + var upper = false + for (i in 0 until invoice.length) { + val c = invoice[i] + if (c.code < 33 || c.code > 126) { + throw AddressFormatException("Invalid character: $c, pos: $i") + } + if (c in 'a'..'z') { + if (upper) throw AddressFormatException("Invalid character: $c, pos: $i") + lower = true + } + if (c in 'A'..'Z') { + if (lower) throw AddressFormatException("Invalid character: $c, pos: $i") + upper = true + } + } + val pos = invoice.lastIndexOf('1') + if (pos < 1) throw AddressFormatException("Missing human-readable part") + val dataPartLength = invoice.length - 1 - pos + if (dataPartLength < 6) throw AddressFormatException("Data part too short: $dataPartLength") + val values = ByteArray(dataPartLength) + for (i in 0 until dataPartLength) { + val c = invoice[i + pos + 1] + if (CHARSET_REV.get(c.code).toInt() == -1) { + throw AddressFormatException("Invalid character: " + c + ", pos: " + (i + pos + 1)) + } + values[i] = CHARSET_REV.get(c.code) + } + val hrp = invoice.substring(0, pos).lowercase(Locale.ROOT) + if (!verifyChecksum(hrp, values)) throw AddressFormatException("Invalid Checksum") + return true + } + + /** + * Parses invoice amount according to + * https://github.com/lightningnetwork/lightning-rfc/blob/master/11-payment-encoding.md#human-readable-part + * + * @return invoice amount in bitcoins, zero if the invoice has no amount + * @throws RuntimeException if invoice format is incorrect + */ + private fun getAmount(invoice: String): BigDecimal { + try { + decodeUnlimitedLength(invoice) // checksum must match + } catch (e: AddressFormatException) { + throw IllegalArgumentException("Cannot decode invoice: $invoice", e) } - /** Expand a HRP for use in checksum computation. */ - private fun expandHrp(hrp: String): ByteArray { - val hrpLength = hrp.length - val ret = ByteArray(hrpLength * 2 + 1) - for (i in 0 until hrpLength) { - val c = hrp[i].code and 0x7f // Limit to standard 7-bit ASCII - ret[i] = (c ushr 5 and 0x07).toByte() - ret[i + hrpLength + 1] = (c and 0x1f).toByte() - } - ret[hrpLength] = 0 - return ret + val matcher = invoicePattern.matcher(invoice) + require(matcher.matches()) { "Failed to match HRP pattern" } + val amountGroup = matcher.group(2) + val multiplierGroup = matcher.group(3) + if (amountGroup == null) { + return BigDecimal.ZERO } - - /** Verify a checksum. */ - private fun verifyChecksum(hrp: String, values: ByteArray): Boolean { - val hrpExpanded: ByteArray = expandHrp(hrp) - val combined = ByteArray(hrpExpanded.size + values.size) - System.arraycopy(hrpExpanded, 0, combined, 0, hrpExpanded.size) - System.arraycopy(values, 0, combined, hrpExpanded.size, values.size) - return polymod(combined) == 1 + val amount = BigDecimal(amountGroup) + if (multiplierGroup == null) { + return amount } - - class AddressFormatException(message: String) : Exception(message) - - fun decodeUnlimitedLength(invoice: String): Boolean { - var lower = false - var upper = false - for (i in 0 until invoice.length) { - val c = invoice[i] - if (c.code < 33 || c.code > 126) throw AddressFormatException("Invalid character: $c, pos: $i") - if (c in 'a'..'z') { - if (upper) throw AddressFormatException("Invalid character: $c, pos: $i") - lower = true - } - if (c in 'A'..'Z') { - if (lower) throw AddressFormatException("Invalid character: $c, pos: $i") - upper = true - } - } - val pos = invoice.lastIndexOf('1') - if (pos < 1) throw AddressFormatException("Missing human-readable part") - val dataPartLength = invoice.length - 1 - pos - if (dataPartLength < 6) throw AddressFormatException("Data part too short: $dataPartLength") - val values = ByteArray(dataPartLength) - for (i in 0 until dataPartLength) { - val c = invoice[i + pos + 1] - if (CHARSET_REV.get(c.code).toInt() == -1) throw AddressFormatException("Invalid character: " + c + ", pos: " + (i + pos + 1)) - values[i] = CHARSET_REV.get(c.code) - } - val hrp = invoice.substring(0, pos).lowercase(Locale.ROOT) - if (!verifyChecksum(hrp, values)) throw AddressFormatException("Invalid Checksum") - return true + require(!(multiplierGroup == "p" && amountGroup[amountGroup.length - 1] != '0')) { + "sub-millisatoshi amount" } + return amount.multiply(multiplier(multiplierGroup)) + } - /** - * Parses invoice amount according to - * https://github.com/lightningnetwork/lightning-rfc/blob/master/11-payment-encoding.md#human-readable-part - * @return invoice amount in bitcoins, zero if the invoice has no amount - * @throws RuntimeException if invoice format is incorrect - */ - private fun getAmount(invoice: String): BigDecimal { - try { - decodeUnlimitedLength(invoice) // checksum must match - } catch (e: AddressFormatException) { - throw IllegalArgumentException("Cannot decode invoice: $invoice", e) - } + val OneHundredK = BigDecimal(100000000) + val OneMili = BigDecimal("0.001") + val OneMicro = BigDecimal("0.000001") + val OneNano = BigDecimal("0.000000001") + val OnePico = BigDecimal("0.000000000001") - val matcher = invoicePattern.matcher(invoice) - require(matcher.matches()) { "Failed to match HRP pattern" } - val amountGroup = matcher.group(2) - val multiplierGroup = matcher.group(3) - if (amountGroup == null) { - return BigDecimal.ZERO - } - val amount = BigDecimal(amountGroup) - if (multiplierGroup == null) { - return amount - } - require(!(multiplierGroup == "p" && amountGroup[amountGroup.length - 1] != '0')) { "sub-millisatoshi amount" } - return amount.multiply(multiplier(multiplierGroup)) + fun getAmountInSats(invoice: String): BigDecimal { + return getAmount(invoice).multiply(OneHundredK) + } + + private fun multiplier(multiplier: String): BigDecimal { + return when (multiplier.lowercase()) { + "m" -> OneMili + "u" -> OneMicro + "n" -> OneNano + "p" -> OnePico + else -> throw IllegalArgumentException("Invalid multiplier: $multiplier") } + } - val OneHundredK = BigDecimal(100000000) - val OneMili = BigDecimal("0.001") - val OneMicro = BigDecimal("0.000001") - val OneNano = BigDecimal("0.000000001") - val OnePico = BigDecimal("0.000000000001") - - fun getAmountInSats(invoice: String): BigDecimal { - return getAmount(invoice).multiply(OneHundredK) + /** + * Finds LN invoice in the provided input string and returns it. For example for input = "aaa bbb + * lnbc1xxx ccc" it will return "lnbc1xxx" It will only return the first invoice found in the + * input. + * + * @return the invoice if it was found. null for null input or if no invoice is found + */ + fun findInvoice(input: String?): String? { + if (input == null) { + return null } - - private fun multiplier(multiplier: String): BigDecimal { - return when (multiplier.lowercase()) { - "m" -> OneMili - "u" -> OneMicro - "n" -> OneNano - "p" -> OnePico - else -> throw IllegalArgumentException("Invalid multiplier: $multiplier") - } + val matcher = invoicePattern.matcher(input) + return if (matcher.find()) { + matcher.group() + } else { + null } + } - /** - * Finds LN invoice in the provided input string and returns it. - * For example for input = "aaa bbb lnbc1xxx ccc" it will return "lnbc1xxx" - * It will only return the first invoice found in the input. - * - * @return the invoice if it was found. null for null input or if no invoice is found - */ - fun findInvoice(input: String?): String? { - if (input == null) { - return null - } - val matcher = invoicePattern.matcher(input) - return if (matcher.find()) { - matcher.group() - } else { - null - } + /** + * If the string contains an LN invoice, returns a Pair of the start and end positions of the + * invoice in the string. Otherwise, returns (0, 0). This is used to ensure we don't accidentally + * cut an invoice in the middle when taking only a portion of the available text. + */ + fun locateInvoice(input: String?): Pair { + if (input == null) { + return Pair(0, 0) } - - /** - * If the string contains an LN invoice, returns a Pair of the start and end - * positions of the invoice in the string. Otherwise, returns (0, 0). This is - * used to ensure we don't accidentally cut an invoice in the middle when taking - * only a portion of the available text. - */ - fun locateInvoice(input: String?): Pair { - if (input == null) { - return Pair(0, 0) - } - val matcher = invoicePattern.matcher(input) - return if (matcher.find()) { - Pair(matcher.start(), matcher.end()) - } else { - Pair(0, 0) - } + val matcher = invoicePattern.matcher(input) + return if (matcher.find()) { + Pair(matcher.start(), matcher.end()) + } else { + Pair(0, 0) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/LnWithdrawalUtil.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/LnWithdrawalUtil.kt index c93209059..ead017ac0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/LnWithdrawalUtil.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/LnWithdrawalUtil.kt @@ -1,29 +1,50 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.encoders import java.util.regex.Pattern object LnWithdrawalUtil { - private val withdrawalPattern = Pattern.compile( - "lnurl1[02-9ac-hj-np-z]+", - Pattern.CASE_INSENSITIVE + private val withdrawalPattern = + Pattern.compile( + "lnurl1[02-9ac-hj-np-z]+", + Pattern.CASE_INSENSITIVE, ) - /** - * Finds LN withdrawal in the provided input string and returns it. - * For example for input = "aaa bbb lnbc1xxx ccc" it will return "lnbc1xxx" - * It will only return the first withdrawal found in the input. - * - * @return the invoice if it was found. null for null input or if no invoice is found - */ - fun findWithdrawal(input: String?): String? { - if (input == null) { - return null - } - val matcher = withdrawalPattern.matcher(input) - return if (matcher.find()) { - matcher.group() - } else { - null - } + /** + * Finds LN withdrawal in the provided input string and returns it. For example for input = "aaa + * bbb lnbc1xxx ccc" it will return "lnbc1xxx" It will only return the first withdrawal found in + * the input. + * + * @return the invoice if it was found. null for null input or if no invoice is found + */ + fun findWithdrawal(input: String?): String? { + if (input == null) { + return null } + val matcher = withdrawalPattern.matcher(input) + return if (matcher.find()) { + matcher.group() + } else { + null + } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Lud06.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Lud06.kt index 0cbb8d876..978c8648c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Lud06.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Lud06.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.encoders import android.util.Log @@ -6,30 +26,30 @@ import java.util.regex.Pattern val lnurlpPattern = Pattern.compile("(?i:http|https):\\/\\/((.+)\\/)*\\.well-known\\/lnurlp\\/(.*)") class Lud06 { - fun toLud16(str: String): String? { - return try { - val url = toLnUrlp(str) + fun toLud16(str: String): String? { + return try { + val url = toLnUrlp(str) - val matcher = lnurlpPattern.matcher(url) - matcher.find() - val domain = matcher.group(2) - val username = matcher.group(3) + val matcher = lnurlpPattern.matcher(url) + matcher.find() + val domain = matcher.group(2) + val username = matcher.group(3) - "$username@$domain" - } catch (t: Throwable) { - t.printStackTrace() - Log.w("Lud06ToLud16","Fail to convert LUD06 to LUD16",t) - null - } + "$username@$domain" + } catch (t: Throwable) { + t.printStackTrace() + Log.w("Lud06ToLud16", "Fail to convert LUD06 to LUD16", t) + null } + } - fun toLnUrlp(str: String): String? { - return try { - String(Bech32.decodeBytes(str, false).second) - } catch (t: Throwable) { - t.printStackTrace() - Log.w("Lud06ToLud16","Fail to convert LUD06 to LUD16",t) - null - } + fun toLnUrlp(str: String): String? { + return try { + String(Bech32.decodeBytes(str, false).second) + } catch (t: Throwable) { + t.printStackTrace() + Log.w("Lud06ToLud16", "Fail to convert LUD06 to LUD16", t) + null } -} \ No newline at end of file + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19.kt index 229a4f84e..682e024e3 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19.kt @@ -1,178 +1,234 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.encoders import android.util.Log import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.crypto.KeyPair -import com.vitorpamplona.quartz.encoders.Hex import java.util.regex.Pattern object Nip19 { - enum class Type { - USER, NOTE, EVENT, RELAY, ADDRESS - } + enum class Type { + USER, + NOTE, + EVENT, + RELAY, + ADDRESS, + } - enum class TlvTypes(val id: Byte) { - SPECIAL(0), - RELAY(1), - AUTHOR(2), - KIND(3); - } + enum class TlvTypes(val id: Byte) { + SPECIAL(0), + RELAY(1), + AUTHOR(2), + KIND(3), + } - val nip19regex = Pattern.compile( - "(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)([\\S]*)", - Pattern.CASE_INSENSITIVE + val nip19regex = + Pattern.compile( + "(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)([\\S]*)", + Pattern.CASE_INSENSITIVE, ) - @Immutable - data class Return( - val type: Type, - val hex: String, - val relay: String? = null, - val author: String? = null, - val kind: Int? = null, - val additionalChars: String = "" - ) + @Immutable + data class Return( + val type: Type, + val hex: String, + val relay: String? = null, + val author: String? = null, + val kind: Int? = null, + val additionalChars: String = "", + ) - fun uriToRoute(uri: String?): Return? { - if (uri == null) return null - - try { - val matcher = nip19regex.matcher(uri) - if (!matcher.find()) { - return null - } - - val uriScheme = matcher.group(1) // nostr: - val type = matcher.group(2) // npub1 - val key = matcher.group(3) // bech32 - val additionalChars = matcher.group(4) // additional chars - - return parseComponents(uriScheme, type, key, additionalChars) - } catch (e: Throwable) { - Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}", e) - } + fun uriToRoute(uri: String?): Return? { + if (uri == null) return null + try { + val matcher = nip19regex.matcher(uri) + if (!matcher.find()) { return null + } + + val uriScheme = matcher.group(1) // nostr: + val type = matcher.group(2) // npub1 + val key = matcher.group(3) // bech32 + val additionalChars = matcher.group(4) // additional chars + + return parseComponents(uriScheme, type, key, additionalChars) + } catch (e: Throwable) { + Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}", e) } - fun parseComponents( - uriScheme: String?, - type: String, - key: String?, - additionalChars: String? - ): Return? { - return try { - val bytes = (type + key).bechToBytes() - val parsed = when (type.lowercase()) { - "npub1" -> npub(bytes) - "note1" -> note(bytes) - "nprofile1" -> nprofile(bytes) - "nevent1" -> nevent(bytes) - "nrelay1" -> nrelay(bytes) - "naddr1" -> naddr(bytes) - else -> null - } - parsed?.copy(additionalChars = additionalChars ?: "") - } catch (e: Throwable) { - Log.w("NIP19 Parser", "Issue trying to Decode NIP19 $key: ${e.message}", e) - null + return null + } + + fun parseComponents( + uriScheme: String?, + type: String, + key: String?, + additionalChars: String?, + ): Return? { + return try { + val bytes = (type + key).bechToBytes() + val parsed = + when (type.lowercase()) { + "npub1" -> npub(bytes) + "note1" -> note(bytes) + "nprofile1" -> nprofile(bytes) + "nevent1" -> nevent(bytes) + "nrelay1" -> nrelay(bytes) + "naddr1" -> naddr(bytes) + else -> null } + parsed?.copy(additionalChars = additionalChars ?: "") + } catch (e: Throwable) { + Log.w("NIP19 Parser", "Issue trying to Decode NIP19 $key: ${e.message}", e) + null } + } - private fun npub(bytes: ByteArray): Return { - return Return(Type.USER, bytes.toHexKey()) - } + private fun npub(bytes: ByteArray): Return { + return Return(Type.USER, bytes.toHexKey()) + } - private fun note(bytes: ByteArray): Return { - return Return(Type.NOTE, bytes.toHexKey()) - } + private fun note(bytes: ByteArray): Return { + return Return(Type.NOTE, bytes.toHexKey()) + } - private fun nprofile(bytes: ByteArray): Return? { - val tlv = Tlv.parse(bytes) + private fun nprofile(bytes: ByteArray): Return? { + val tlv = Tlv.parse(bytes) - val hex = tlv.firstAsHex(TlvTypes.SPECIAL) ?: return null - val relay = tlv.firstAsString(TlvTypes.RELAY) + val hex = tlv.firstAsHex(TlvTypes.SPECIAL) ?: return null + val relay = tlv.firstAsString(TlvTypes.RELAY) - return Return(Type.USER, hex, relay) - } + return Return(Type.USER, hex, relay) + } - private fun nevent(bytes: ByteArray): Return? { - val tlv = Tlv.parse(bytes) + private fun nevent(bytes: ByteArray): Return? { + val tlv = Tlv.parse(bytes) - val hex = tlv.firstAsHex(TlvTypes.SPECIAL) ?: return null - val relay = tlv.firstAsString(TlvTypes.RELAY) - val author = tlv.firstAsHex(TlvTypes.AUTHOR) - val kind = tlv.firstAsInt(TlvTypes.KIND.id) + val hex = tlv.firstAsHex(TlvTypes.SPECIAL) ?: return null + val relay = tlv.firstAsString(TlvTypes.RELAY) + val author = tlv.firstAsHex(TlvTypes.AUTHOR) + val kind = tlv.firstAsInt(TlvTypes.KIND.id) - return Return(Type.EVENT, hex, relay, author, kind) - } + return Return(Type.EVENT, hex, relay, author, kind) + } - private fun nrelay(bytes: ByteArray): Return? { - val relayUrl = Tlv.parse(bytes).firstAsString(TlvTypes.SPECIAL.id) ?: return null + private fun nrelay(bytes: ByteArray): Return? { + val relayUrl = Tlv.parse(bytes).firstAsString(TlvTypes.SPECIAL.id) ?: return null - return Return(Type.RELAY, relayUrl) - } + return Return(Type.RELAY, relayUrl) + } - private fun naddr(bytes: ByteArray): Return? { - val tlv = Tlv.parse(bytes) + private fun naddr(bytes: ByteArray): Return? { + val tlv = Tlv.parse(bytes) - val d = tlv.firstAsString(TlvTypes.SPECIAL.id) ?: "" - val relay = tlv.firstAsString(TlvTypes.RELAY.id) - val author = tlv.firstAsHex(TlvTypes.AUTHOR.id) ?: return null - val kind = tlv.firstAsInt(TlvTypes.KIND.id) ?: return null + val d = tlv.firstAsString(TlvTypes.SPECIAL.id) ?: "" + val relay = tlv.firstAsString(TlvTypes.RELAY.id) + val author = tlv.firstAsHex(TlvTypes.AUTHOR.id) ?: return null + val kind = tlv.firstAsInt(TlvTypes.KIND.id) ?: return null - return Return(Type.ADDRESS, "$kind:$author:$d", relay, author, kind) - } + return Return(Type.ADDRESS, "$kind:$author:$d", relay, author, kind) + } - public fun createNEvent(idHex: String, author: String?, kind: Int?, relay: String?): String { - return TlvBuilder().apply { - addHex(TlvTypes.SPECIAL, idHex) - addStringIfNotNull(TlvTypes.RELAY, relay) - addHexIfNotNull(TlvTypes.AUTHOR, author) - addIntIfNotNull(TlvTypes.KIND, kind) - }.build().toNEvent() - } + public fun createNEvent( + idHex: String, + author: String?, + kind: Int?, + relay: String?, + ): String { + return TlvBuilder() + .apply { + addHex(TlvTypes.SPECIAL, idHex) + addStringIfNotNull(TlvTypes.RELAY, relay) + addHexIfNotNull(TlvTypes.AUTHOR, author) + addIntIfNotNull(TlvTypes.KIND, kind) + } + .build() + .toNEvent() + } } fun decodePublicKey(key: String): ByteArray { - val parsed = Nip19.uriToRoute(key) - val pubKeyParsed = parsed?.hex?.hexToByteArray() + val parsed = Nip19.uriToRoute(key) + val pubKeyParsed = parsed?.hex?.hexToByteArray() - return if (key.startsWith("nsec")) { - KeyPair(privKey = key.bechToBytes()).pubKey - } else if (pubKeyParsed != null) { - pubKeyParsed - } else { - Hex.decode(key) - } + return if (key.startsWith("nsec")) { + KeyPair(privKey = key.bechToBytes()).pubKey + } else if (pubKeyParsed != null) { + pubKeyParsed + } else { + Hex.decode(key) + } } fun decodePublicKeyAsHexOrNull(key: String): HexKey? { - return try { - val parsed = Nip19.uriToRoute(key) - val pubKeyParsed = parsed?.hex + return try { + val parsed = Nip19.uriToRoute(key) + val pubKeyParsed = parsed?.hex - if (key.startsWith("nsec")) { - KeyPair(privKey = key.bechToBytes()).pubKey.toHexKey() - } else if (pubKeyParsed != null) { - pubKeyParsed - } else { - Hex.decode(key).toHexKey() - } - } catch (e: Exception) { - null + if (key.startsWith("nsec")) { + KeyPair(privKey = key.bechToBytes()).pubKey.toHexKey() + } else if (pubKeyParsed != null) { + pubKeyParsed + } else { + Hex.decode(key).toHexKey() } + } catch (e: Exception) { + null + } } +fun TlvBuilder.addString( + type: Nip19.TlvTypes, + string: String, +) = addString(type.id, string) -fun TlvBuilder.addString(type: Nip19.TlvTypes, string: String) = addString(type.id, string) -fun TlvBuilder.addHex(type: Nip19.TlvTypes, key: HexKey) = addHex(type.id, key) -fun TlvBuilder.addInt(type: Nip19.TlvTypes, data: Int) = addInt(type.id, data) +fun TlvBuilder.addHex( + type: Nip19.TlvTypes, + key: HexKey, +) = addHex(type.id, key) -fun TlvBuilder.addStringIfNotNull(type: Nip19.TlvTypes, data: String?) = addStringIfNotNull(type.id, data) -fun TlvBuilder.addHexIfNotNull(type: Nip19.TlvTypes, data: HexKey?) = addHexIfNotNull(type.id, data) -fun TlvBuilder.addIntIfNotNull(type: Nip19.TlvTypes, data: Int?) = addIntIfNotNull(type.id, data) +fun TlvBuilder.addInt( + type: Nip19.TlvTypes, + data: Int, +) = addInt(type.id, data) + +fun TlvBuilder.addStringIfNotNull( + type: Nip19.TlvTypes, + data: String?, +) = addStringIfNotNull(type.id, data) + +fun TlvBuilder.addHexIfNotNull( + type: Nip19.TlvTypes, + data: HexKey?, +) = addHexIfNotNull(type.id, data) + +fun TlvBuilder.addIntIfNotNull( + type: Nip19.TlvTypes, + data: Int?, +) = addIntIfNotNull(type.id, data) fun Tlv.firstAsInt(type: Nip19.TlvTypes) = firstAsInt(type.id) + fun Tlv.firstAsHex(type: Nip19.TlvTypes) = firstAsHex(type.id) -fun Tlv.firstAsString(type: Nip19.TlvTypes) = firstAsString(type.id) \ No newline at end of file + +fun Tlv.firstAsString(type: Nip19.TlvTypes) = firstAsString(type.id) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Tlv.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Tlv.kt index 02e4d80de..28047e53a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Tlv.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Tlv.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.encoders import java.io.ByteArrayOutputStream @@ -5,65 +25,92 @@ import java.nio.ByteBuffer import java.nio.ByteOrder class TlvBuilder() { - val outputStream = ByteArrayOutputStream() + val outputStream = ByteArrayOutputStream() - private fun add(type: Byte, byteArray: ByteArray) { - outputStream.write(byteArrayOf(type, byteArray.size.toByte())) - outputStream.write(byteArray) - } + private fun add( + type: Byte, + byteArray: ByteArray, + ) { + outputStream.write(byteArrayOf(type, byteArray.size.toByte())) + outputStream.write(byteArray) + } - fun addString(type: Byte, string: String) = add(type, string.toByteArray(Charsets.UTF_8)) - fun addHex(type: Byte, key: HexKey) = add(type, key.hexToByteArray()) - fun addInt(type: Byte, data: Int) = add(type, data.to32BitByteArray()) + fun addString( + type: Byte, + string: String, + ) = add(type, string.toByteArray(Charsets.UTF_8)) - fun addStringIfNotNull(type: Byte, data: String?) = data?.let { addString(type, it) } - fun addHexIfNotNull(type: Byte, data: HexKey?) = data?.let { addHex(type, it) } - fun addIntIfNotNull(type: Byte, data: Int?) = data?.let { addInt(type, it) } + fun addHex( + type: Byte, + key: HexKey, + ) = add(type, key.hexToByteArray()) - fun build(): ByteArray { - return outputStream.toByteArray() - } + fun addInt( + type: Byte, + data: Int, + ) = add(type, data.to32BitByteArray()) + + fun addStringIfNotNull( + type: Byte, + data: String?, + ) = data?.let { addString(type, it) } + + fun addHexIfNotNull( + type: Byte, + data: HexKey?, + ) = data?.let { addHex(type, it) } + + fun addIntIfNotNull( + type: Byte, + data: Int?, + ) = data?.let { addInt(type, it) } + + fun build(): ByteArray { + return outputStream.toByteArray() + } } fun Int.to32BitByteArray(): ByteArray { - val bytes = ByteArray(4) - (0..3).forEach { - bytes[3 - it] = ((this ushr (8 * it)) and 0xFFFF).toByte() - } - return bytes + val bytes = ByteArray(4) + (0..3).forEach { bytes[3 - it] = ((this ushr (8 * it)) and 0xFFFF).toByte() } + return bytes } fun ByteArray.toInt32(): Int? { - if (size != 4) return null - return ByteBuffer.wrap(this, 0, 4).order(ByteOrder.BIG_ENDIAN).int + if (size != 4) return null + return ByteBuffer.wrap(this, 0, 4).order(ByteOrder.BIG_ENDIAN).int } class Tlv(val data: Map>) { - fun asInt(type: Byte) = data[type]?.mapNotNull { it.toInt32() } - fun asHex(type: Byte) = data[type]?.map { it.toHexKey().intern() } - fun asString(type: Byte) = data[type]?.map { it.toString(Charsets.UTF_8) } + fun asInt(type: Byte) = data[type]?.mapNotNull { it.toInt32() } - fun firstAsInt(type: Byte) = data[type]?.firstOrNull()?.toInt32() - fun firstAsHex(type: Byte) = data[type]?.firstOrNull()?.toHexKey()?.intern() - fun firstAsString(type: Byte) = data[type]?.firstOrNull()?.toString(Charsets.UTF_8) + fun asHex(type: Byte) = data[type]?.map { it.toHexKey().intern() } - companion object { - fun parse(data: ByteArray): Tlv { - val result = mutableMapOf>() - var rest = data - while (rest.isNotEmpty()) { - val t = rest[0] - val l = rest[1].toUByte().toInt() - val v = rest.sliceArray(IntRange(2, (2 + l) - 1)) - rest = rest.sliceArray(IntRange(2 + l, rest.size - 1)) - if (v.size < l) continue + fun asString(type: Byte) = data[type]?.map { it.toString(Charsets.UTF_8) } - if (!result.containsKey(t)) { - result[t] = mutableListOf() - } - result[t]?.add(v) - } - return Tlv(result) + fun firstAsInt(type: Byte) = data[type]?.firstOrNull()?.toInt32() + + fun firstAsHex(type: Byte) = data[type]?.firstOrNull()?.toHexKey()?.intern() + + fun firstAsString(type: Byte) = data[type]?.firstOrNull()?.toString(Charsets.UTF_8) + + companion object { + fun parse(data: ByteArray): Tlv { + val result = mutableMapOf>() + var rest = data + while (rest.isNotEmpty()) { + val t = rest[0] + val l = rest[1].toUByte().toInt() + val v = rest.sliceArray(IntRange(2, (2 + l) - 1)) + rest = rest.sliceArray(IntRange(2 + l, rest.size - 1)) + if (v.size < l) continue + + if (!result.containsKey(t)) { + result[t] = mutableListOf() } + result[t]?.add(v) + } + return Tlv(result) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/AdvertisedRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/AdvertisedRelayListEvent.kt index 24dd2174c..8f77dc9ca 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/AdvertisedRelayListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/AdvertisedRelayListEvent.kt @@ -1,70 +1,91 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class AdvertisedRelayListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { - override fun dTag() = fixedDTag + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + override fun dTag() = FIXED_D_TAG - fun relays(): List { - return tags.mapNotNull { - if (it.size > 1 && it[0] == "r") { - val type = when (it.getOrNull(2)) { - "read" -> AdvertisedRelayType.READ - "write" -> AdvertisedRelayType.WRITE - else -> AdvertisedRelayType.BOTH - } + fun relays(): List { + return tags.mapNotNull { + if (it.size > 1 && it[0] == "r") { + val type = + when (it.getOrNull(2)) { + "read" -> AdvertisedRelayType.READ + "write" -> AdvertisedRelayType.WRITE + else -> AdvertisedRelayType.BOTH + } - AdvertisedRelayInfo(it[1], type) + AdvertisedRelayInfo(it[1], type) + } else { + null + } + } + } + + companion object { + const val KIND = 10002 + const val FIXED_D_TAG = "" + + fun create( + list: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (AdvertisedRelayListEvent) -> Unit, + ) { + val tags = + list + .map { + if (it.type == AdvertisedRelayType.BOTH) { + arrayOf(it.relayUrl) } else { - null + arrayOf(it.relayUrl, it.type.code) } - } + } + .plusElement(arrayOf("alt", "Relay list event with ${list.size} relays")) + .toTypedArray() + val msg = "" + + signer.sign(createdAt, KIND, tags, msg, onReady) } + } - companion object { - const val kind = 10002 - const val fixedDTag = "" + @Immutable data class AdvertisedRelayInfo(val relayUrl: String, val type: AdvertisedRelayType) - fun create( - list: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (AdvertisedRelayListEvent) -> Unit - ) { - val tags = list.map { - if (it.type == AdvertisedRelayType.BOTH) { - arrayOf(it.relayUrl) - } else { - arrayOf(it.relayUrl, it.type.code) - } - }.plusElement(arrayOf("alt", "Relay list event with ${list.size} relays")).toTypedArray() - val msg = "" - - signer.sign(createdAt, kind, tags, msg, onReady) - } - } - - @Immutable - data class AdvertisedRelayInfo(val relayUrl: String, val type: AdvertisedRelayType) - - @Immutable - enum class AdvertisedRelayType(val code: String) { - BOTH(""), - READ("read"), - WRITE("write") - } + @Immutable + enum class AdvertisedRelayType(val code: String) { + BOTH(""), + READ("read"), + WRITE("write"), + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/AppDefinitionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/AppDefinitionEvent.kt index 230cbe098..03b06becf 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/AppDefinitionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/AppDefinitionEvent.kt @@ -1,8 +1,27 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import android.util.Log import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -10,55 +29,57 @@ import java.io.ByteArrayInputStream @Immutable class AppDefinitionEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { - @Transient - private var cachedMetadata: UserMetadata? = null + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + @Transient private var cachedMetadata: UserMetadata? = null - fun appMetaData() = - if (cachedMetadata != null) { - cachedMetadata - } else { - try { - val newMetadata = mapper.readValue( - ByteArrayInputStream(content.toByteArray(Charsets.UTF_8)), - UserMetadata::class.java - ) + fun appMetaData() = + if (cachedMetadata != null) { + cachedMetadata + } else { + try { + val newMetadata = + mapper.readValue( + ByteArrayInputStream(content.toByteArray(Charsets.UTF_8)), + UserMetadata::class.java, + ) - cachedMetadata = newMetadata + cachedMetadata = newMetadata - newMetadata - } catch (e: Exception) { - e.printStackTrace() - Log.w("MT", "Content Parse Error ${e.localizedMessage} $content") - null - } - } - - fun supportedKinds() = tags.filter { it.size > 1 && it[0] == "k" }.mapNotNull { - runCatching { it[1].toInt() }.getOrNull() + newMetadata + } catch (e: Exception) { + e.printStackTrace() + Log.w("MT", "Content Parse Error ${e.localizedMessage} $content") + null + } } - fun publishedAt() = tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1) + fun supportedKinds() = + tags + .filter { it.size > 1 && it[0] == "k" } + .mapNotNull { runCatching { it[1].toInt() }.getOrNull() } - companion object { - const val kind = 31990 + fun publishedAt() = tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1) - fun create( - details: UserMetadata, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (AppDefinitionEvent) -> Unit - ) { - val tags = arrayOf( - arrayOf("alt", "App definition event for ${details.name}") - ) - signer.sign(createdAt, kind, tags, "", onReady) - } + companion object { + const val KIND = 31990 + + fun create( + details: UserMetadata, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (AppDefinitionEvent) -> Unit, + ) { + val tags = + arrayOf( + arrayOf("alt", "App definition event for ${details.name}"), + ) + signer.sign(createdAt, KIND, tags, "", onReady) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/AppRecommendationEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/AppRecommendationEvent.kt index e6bbfb27b..5fe75fa37 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/AppRecommendationEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/AppRecommendationEvent.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable @@ -8,30 +28,30 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class AppRecommendationEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { - fun recommendations() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull { - ATag.parse(it[1], it.getOrNull(2)) - } + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun recommendations() = + tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull { ATag.parse(it[1], it.getOrNull(2)) } - companion object { - const val kind = 31989 - const val alt = "App recommendations by the author" + companion object { + const val KIND = 31989 + const val ALT = "App recommendations by the author" - fun create( - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (AppRecommendationEvent) -> Unit - ) { - val tags = arrayOf( - arrayOf("alt", alt) - ) - signer.sign(createdAt, kind, tags, "", onReady) - } + fun create( + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (AppRecommendationEvent) -> Unit, + ) { + val tags = + arrayOf( + arrayOf("alt", ALT), + ) + signer.sign(createdAt, KIND, tags, "", onReady) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/AudioHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/AudioHeaderEvent.kt index a455f3177..3f12e25ef 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/AudioHeaderEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/AudioHeaderEvent.kt @@ -1,60 +1,85 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable import com.fasterxml.jackson.module.kotlin.readValue -import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class AudioHeaderEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun download() = tags.firstOrNull { it.size > 1 && it[0] == DOWNLOAD_URL }?.get(1) - fun download() = tags.firstOrNull { it.size > 1 && it[0] == DOWNLOAD_URL }?.get(1) - fun stream() = tags.firstOrNull { it.size > 1 && it[0] == STREAM_URL }?.get(1) - fun wavefrom() = tags.firstOrNull { it.size > 1 && it[0] == WAVEFORM }?.get(1)?.let { - mapper.readValue>(it) - } - - companion object { - const val kind = 1808 - const val alt = "Audio header" - - private const val DOWNLOAD_URL = "download_url" - private const val STREAM_URL = "stream_url" - private const val WAVEFORM = "waveform" - - fun create( - description: String, - downloadUrl: String, - streamUrl: String? = null, - wavefront: String? = null, - sensitiveContent: Boolean? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (AudioHeaderEvent) -> Unit - ) { - val tags = listOfNotNull( - downloadUrl.let { arrayOf(DOWNLOAD_URL, it) }, - streamUrl?.let { arrayOf(STREAM_URL, it) }, - wavefront?.let { arrayOf(WAVEFORM, it) }, - sensitiveContent?.let { - if (it) { - arrayOf("content-warning", "") - } else { - null - } - }, - arrayOf("alt", alt) - ).toTypedArray() - - signer.sign(createdAt, kind, tags, description, onReady) - } + fun stream() = tags.firstOrNull { it.size > 1 && it[0] == STREAM_URL }?.get(1) + + fun wavefrom() = + tags + .firstOrNull { it.size > 1 && it[0] == WAVEFORM } + ?.get(1) + ?.let { mapper.readValue>(it) } + + companion object { + const val KIND = 1808 + const val ALT = "Audio header" + + private const val DOWNLOAD_URL = "download_url" + private const val STREAM_URL = "stream_url" + private const val WAVEFORM = "waveform" + + fun create( + description: String, + downloadUrl: String, + streamUrl: String? = null, + wavefront: String? = null, + sensitiveContent: Boolean? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (AudioHeaderEvent) -> Unit, + ) { + val tags = + listOfNotNull( + downloadUrl.let { arrayOf(DOWNLOAD_URL, it) }, + streamUrl?.let { arrayOf(STREAM_URL, it) }, + wavefront?.let { arrayOf(WAVEFORM, it) }, + sensitiveContent?.let { + if (it) { + arrayOf("content-warning", "") + } else { + null + } + }, + arrayOf("alt", ALT), + ) + .toTypedArray() + + signer.sign(createdAt, KIND, tags, description, onReady) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/AudioTrackEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/AudioTrackEvent.kt index 6cf46e1c4..bef52308a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/AudioTrackEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/AudioTrackEvent.kt @@ -1,64 +1,85 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class AudioTrackEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun participants() = + tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(2)) } - fun participants() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(2)) } - fun type() = tags.firstOrNull { it.size > 1 && it[0] == TYPE }?.get(1) - fun price() = tags.firstOrNull { it.size > 1 && it[0] == PRICE }?.get(1) - fun cover() = tags.firstOrNull { it.size > 1 && it[0] == COVER }?.get(1) + fun type() = tags.firstOrNull { it.size > 1 && it[0] == TYPE }?.get(1) - // fun subject() = tags.firstOrNull { it.size > 1 && it[0] == SUBJECT }?.get(1) - fun media() = tags.firstOrNull { it.size > 1 && it[0] == MEDIA }?.get(1) + fun price() = tags.firstOrNull { it.size > 1 && it[0] == PRICE }?.get(1) - companion object { - const val kind = 31337 - const val alt = "Audio track" + fun cover() = tags.firstOrNull { it.size > 1 && it[0] == COVER }?.get(1) - private const val TYPE = "c" - private const val PRICE = "price" - private const val COVER = "cover" - private const val SUBJECT = "subject" - private const val MEDIA = "media" + // fun subject() = tags.firstOrNull { it.size > 1 && it[0] == SUBJECT }?.get(1) + fun media() = tags.firstOrNull { it.size > 1 && it[0] == MEDIA }?.get(1) - fun create( - type: String, - media: String, - price: String? = null, - cover: String? = null, - subject: String? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (AudioTrackEvent) -> Unit - ) { - val tags = listOfNotNull( - arrayOf(MEDIA, media), - arrayOf(TYPE, type), - price?.let { arrayOf(PRICE, it) }, - cover?.let { arrayOf(COVER, it) }, - subject?.let { arrayOf(SUBJECT, it) }, - arrayOf("alt", alt) - ).toTypedArray() + companion object { + const val KIND = 31337 + const val ALT = "Audio track" - signer.sign(createdAt, kind, tags, "", onReady) - } + private const val TYPE = "c" + private const val PRICE = "price" + private const val COVER = "cover" + private const val SUBJECT = "subject" + private const val MEDIA = "media" + + fun create( + type: String, + media: String, + price: String? = null, + cover: String? = null, + subject: String? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (AudioTrackEvent) -> Unit, + ) { + val tags = + listOfNotNull( + arrayOf(MEDIA, media), + arrayOf(TYPE, type), + price?.let { arrayOf(PRICE, it) }, + cover?.let { arrayOf(COVER, it) }, + subject?.let { arrayOf(SUBJECT, it) }, + arrayOf("alt", ALT), + ) + .toTypedArray() + + signer.sign(createdAt, KIND, tags, "", onReady) } + } } -@Immutable -data class Participant(val key: String, val role: String?) +@Immutable data class Participant(val key: String, val role: String?) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeAwardEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeAwardEvent.kt index 3a8cc25f2..995fdee39 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeAwardEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeAwardEvent.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable @@ -5,19 +25,19 @@ import com.vitorpamplona.quartz.encoders.HexKey @Immutable class BadgeAwardEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun awardees() = taggedUsers() + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun awardees() = taggedUsers() - fun awardDefinition() = taggedAddresses() + fun awardDefinition() = taggedAddresses() - companion object { - const val kind = 8 - const val alt = "Badge award" - } + companion object { + const val KIND = 8 + const val ALT = "Badge award" + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeDefinitionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeDefinitionEvent.kt index 5a5ff9555..7888a7264 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeDefinitionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeDefinitionEvent.kt @@ -1,25 +1,47 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey @Immutable class BadgeDefinitionEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { - fun name() = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1) - fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == "thumb" }?.get(1) - fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1) - fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1) + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun name() = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1) - companion object { - const val kind = 30009 - const val alt = "Badge definition" - } + fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == "thumb" }?.get(1) + + fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1) + + fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1) + + companion object { + const val KIND = 30009 + const val ALT = "Badge definition" + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeProfilesEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeProfilesEvent.kt index a7275269a..e3d6076b8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeProfilesEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/BadgeProfilesEvent.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable @@ -6,24 +26,28 @@ import com.vitorpamplona.quartz.encoders.HexKey @Immutable class BadgeProfilesEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { - fun badgeAwardEvents() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } - fun badgeAwardDefinitions() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun badgeAwardEvents() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } + + fun badgeAwardDefinitions() = + tags + .filter { it.firstOrNull() == "a" } + .mapNotNull { val aTagValue = it.getOrNull(1) val relay = it.getOrNull(2) if (aTagValue != null) ATag.parse(aTagValue, relay) else null - } + } - companion object { - const val kind = 30008 - const val standardDTAg = "profile_badges" - const val alt = "List of accepted badges by the author" - } + companion object { + const val KIND = 30008 + const val STANDARD_D_TAG = "profile_badges" + const val ALT = "List of accepted badges by the author" + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt index e27c86812..7d69db19c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import android.util.Log @@ -12,144 +32,144 @@ val hashtagSearch = Pattern.compile("(?:\\s|\\A)#([^\\s!@#\$%^&*()=+./,\\[{\\]}; @Immutable open class BaseTextNoteEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - sig: HexKey + id: HexKey, + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + sig: HexKey, ) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun mentions() = taggedUsers() - open fun replyTos() = taggedEvents() + fun mentions() = taggedUsers() - fun replyingTo(): HexKey? { - val oldStylePositional = tags.lastOrNull() { it.size > 1 && it[0] == "e" }?.get(1) - val newStyle = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "reply" }?.get(1) + open fun replyTos() = taggedEvents() - return newStyle ?: oldStylePositional + fun replyingTo(): HexKey? { + val oldStylePositional = tags.lastOrNull { it.size > 1 && it[0] == "e" }?.get(1) + val newStyle = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "reply" }?.get(1) + + return newStyle ?: oldStylePositional + } + + @Transient private var citedUsersCache: Set? = null + + @Transient private var citedNotesCache: Set? = null + + fun citedUsers(): Set { + citedUsersCache?.let { + return it } - @Transient - private var citedUsersCache: Set? = null - - @Transient - private var citedNotesCache: Set? = null - - fun citedUsers(): Set { - citedUsersCache?.let { return it } - - val matcher = tagSearch.matcher(content) - val returningList = mutableSetOf() - while (matcher.find()) { - try { - val tag = matcher.group(1)?.let { tags[it.toInt()] } - if (tag != null && tag.size > 1 && tag[0] == "p") { - returningList.add(tag[1]) - } - } catch (e: Exception) { - } + val matcher = tagSearch.matcher(content) + val returningList = mutableSetOf() + while (matcher.find()) { + try { + val tag = matcher.group(1)?.let { tags[it.toInt()] } + if (tag != null && tag.size > 1 && tag[0] == "p") { + returningList.add(tag[1]) } - - val matcher2 = nip19regex.matcher(content) - while (matcher2.find()) { - val uriScheme = matcher2.group(1) // nostr: - val type = matcher2.group(2) // npub1 - val key = matcher2.group(3) // bech32 - val additionalChars = matcher2.group(4) // additional chars - - try { - val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars) - - if (parsed != null) { - val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex } - - if (tag != null && tag[0] == "p") { - returningList.add(tag[1]) - } - } - } catch (e: Exception) { - Log.w("Unable to parse cited users that matched a NIP19 regex", e) - } - } - - citedUsersCache = returningList - return returningList + } catch (e: Exception) {} } - fun findCitations(): Set { - citedNotesCache?.let { return it } + val matcher2 = nip19regex.matcher(content) + while (matcher2.find()) { + val uriScheme = matcher2.group(1) // nostr: + val type = matcher2.group(2) // npub1 + val key = matcher2.group(3) // bech32 + val additionalChars = matcher2.group(4) // additional chars - val citations = mutableSetOf() - // Removes citations from replies: - val matcher = tagSearch.matcher(content) - while (matcher.find()) { - try { - val tag = matcher.group(1)?.let { tags[it.toInt()] } - if (tag != null && tag.size > 1 && tag[0] == "e") { - citations.add(tag[1]) - } - if (tag != null && tag.size > 1 && tag[0] == "a") { - citations.add(tag[1]) - } - } catch (e: Exception) { - } + try { + val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars) + + if (parsed != null) { + val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex } + + if (tag != null && tag[0] == "p") { + returningList.add(tag[1]) + } } - - val matcher2 = nip19regex.matcher(content) - while (matcher2.find()) { - val uriScheme = matcher2.group(1) // nostr: - val type = matcher2.group(2) // npub1 - val key = matcher2.group(3) // bech32 - val additionalChars = matcher2.group(4) // additional chars - - val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars) - - if (parsed != null) { - try { - val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex } - - if (tag != null && tag[0] == "e") { - citations.add(tag[1]) - } - if (tag != null && tag[0] == "a") { - citations.add(tag[1]) - } - } catch (e: Exception) { - } - } - } - - citedNotesCache = citations - return citations + } catch (e: Exception) { + Log.w("Unable to parse cited users that matched a NIP19 regex", e) + } } - fun tagsWithoutCitations(): List { - val repliesTo = replyTos() - val tagAddresses = taggedAddresses().filter { it.kind != CommunityDefinitionEvent.kind }.map { it.toTag() } - if (repliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList() + citedUsersCache = returningList + return returningList + } - val citations = findCitations() - - return if (citations.isEmpty()) { - repliesTo + tagAddresses - } else { - repliesTo.filter { it !in citations } - } + fun findCitations(): Set { + citedNotesCache?.let { + return it } + + val citations = mutableSetOf() + // Removes citations from replies: + val matcher = tagSearch.matcher(content) + while (matcher.find()) { + try { + val tag = matcher.group(1)?.let { tags[it.toInt()] } + if (tag != null && tag.size > 1 && tag[0] == "e") { + citations.add(tag[1]) + } + if (tag != null && tag.size > 1 && tag[0] == "a") { + citations.add(tag[1]) + } + } catch (e: Exception) {} + } + + val matcher2 = nip19regex.matcher(content) + while (matcher2.find()) { + val uriScheme = matcher2.group(1) // nostr: + val type = matcher2.group(2) // npub1 + val key = matcher2.group(3) // bech32 + val additionalChars = matcher2.group(4) // additional chars + + val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars) + + if (parsed != null) { + try { + val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex } + + if (tag != null && tag[0] == "e") { + citations.add(tag[1]) + } + if (tag != null && tag[0] == "a") { + citations.add(tag[1]) + } + } catch (e: Exception) {} + } + } + + citedNotesCache = citations + return citations + } + + fun tagsWithoutCitations(): List { + val repliesTo = replyTos() + val tagAddresses = + taggedAddresses().filter { it.kind != CommunityDefinitionEvent.KIND }.map { it.toTag() } + if (repliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList() + + val citations = findCitations() + + return if (citations.isEmpty()) { + repliesTo + tagAddresses + } else { + repliesTo.filter { it !in citations } + } + } } fun findHashtags(content: String): List { - val matcher = hashtagSearch.matcher(content) - val returningList = mutableSetOf() - while (matcher.find()) { - try { - val tag = matcher.group(1) - if (tag != null && tag.isNotBlank()) { - returningList.add(tag) - } - } catch (e: Exception) { - } - } - return returningList.toList() -} \ No newline at end of file + val matcher = hashtagSearch.matcher(content) + val returningList = mutableSetOf() + while (matcher.find()) { + try { + val tag = matcher.group(1) + if (tag != null && tag.isNotBlank()) { + returningList.add(tag) + } + } catch (e: Exception) {} + } + return returningList.toList() +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/BookmarkListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/BookmarkListEvent.kt index 1d5fb9b01..2224d2282 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/BookmarkListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/BookmarkListEvent.kt @@ -1,213 +1,232 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class BookmarkListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) { - companion object { - const val kind = 30001 - const val alt = "List of bookmarks" + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 30001 + const val ALT = "List of bookmarks" - fun addEvent( - earlierVersion: BookmarkListEvent?, - eventId: HexKey, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit - ) = addTag(earlierVersion, "e", eventId, isPrivate, signer, createdAt, onReady) + fun addEvent( + earlierVersion: BookmarkListEvent?, + eventId: HexKey, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (BookmarkListEvent) -> Unit, + ) = addTag(earlierVersion, "e", eventId, isPrivate, signer, createdAt, onReady) - fun addReplaceable( - earlierVersion: BookmarkListEvent?, - aTag: ATag, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit - ) = addTag(earlierVersion, "a", aTag.toTag(), isPrivate, signer, createdAt, onReady) + fun addReplaceable( + earlierVersion: BookmarkListEvent?, + aTag: ATag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (BookmarkListEvent) -> Unit, + ) = addTag(earlierVersion, "a", aTag.toTag(), isPrivate, signer, createdAt, onReady) - fun addTag( - earlierVersion: BookmarkListEvent?, - tagName: String, - tagValue: HexKey, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit - ) { - add( - earlierVersion, - arrayOf(arrayOf(tagName, tagValue)), - isPrivate, - signer, - createdAt, - onReady - ) - } - - fun add( - earlierVersion: BookmarkListEvent?, - listNewTags: Array>, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit - ) { - if (isPrivate) { - if (earlierVersion != null) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = privateTags.plus(listNewTags), - signer = signer - ) { encryptedTags -> - create( - content = encryptedTags, - tags = earlierVersion.tags, - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - } - } else { - encryptTags( - privateTags = listNewTags, - signer = signer - ) { encryptedTags -> - create( - content = encryptedTags, - tags = emptyArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - } - } else { - create( - content = earlierVersion?.content ?: "", - tags = (earlierVersion?.tags ?: emptyArray()).plus(listNewTags), - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - } - - fun removeEvent( - earlierVersion: BookmarkListEvent, - eventId: HexKey, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit - ) = removeTag(earlierVersion, "e", eventId, isPrivate, signer, createdAt, onReady) - - fun removeReplaceable( - earlierVersion: BookmarkListEvent, - aTag: ATag, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit - ) = removeTag(earlierVersion, "a", aTag.toTag(), isPrivate, signer, createdAt, onReady) - - private fun removeTag( - earlierVersion: BookmarkListEvent, - tagName: String, - tagValue: HexKey, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit - ) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = privateTags.filter { it.size <= 1 || !(it[0] == tagName && it[1] == tagValue) }.toTypedArray(), - signer = signer - ) { encryptedTags -> - create( - content = encryptedTags, - tags = earlierVersion.tags.filter { it.size <= 1 || !(it[0] == tagName && it[1] == tagValue) }.toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = earlierVersion.tags.filter { it.size <= 1 || !(it[0] == tagName && it[1] == tagValue) }.toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - } - - fun create( - content: String, - tags: Array>, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit - ) { - val newTags = if (tags.any { it.size > 1 && it[0] == "alt" }) { - tags - } else { - tags + arrayOf("alt", alt) - } - - signer.sign(createdAt, kind, newTags, content, onReady) - } - - fun create( - name: String = "", - - events: List? = null, - users: List? = null, - addresses: List? = null, - - privEvents: List? = null, - privUsers: List? = null, - privAddresses: List? = null, - - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (BookmarkListEvent) -> Unit - ) { - val tags = mutableListOf>() - tags.add(arrayOf("d", name)) - - events?.forEach { - tags.add(arrayOf("e", it)) - } - users?.forEach { - tags.add(arrayOf("p", it)) - } - addresses?.forEach { - tags.add(arrayOf("a", it.toTag())) - } - tags.add(arrayOf("alt", alt)) - - createPrivateTags(privEvents, privUsers, privAddresses, signer) { content -> - signer.sign(createdAt, kind, tags.toTypedArray(), content, onReady) - } - } + fun addTag( + earlierVersion: BookmarkListEvent?, + tagName: String, + tagValue: HexKey, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (BookmarkListEvent) -> Unit, + ) { + add( + earlierVersion, + arrayOf(arrayOf(tagName, tagValue)), + isPrivate, + signer, + createdAt, + onReady, + ) } + + fun add( + earlierVersion: BookmarkListEvent?, + listNewTags: Array>, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (BookmarkListEvent) -> Unit, + ) { + if (isPrivate) { + if (earlierVersion != null) { + earlierVersion.privateTagsOrEmpty(signer) { privateTags -> + encryptTags( + privateTags = privateTags.plus(listNewTags), + signer = signer, + ) { encryptedTags -> + create( + content = encryptedTags, + tags = earlierVersion.tags, + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } else { + encryptTags( + privateTags = listNewTags, + signer = signer, + ) { encryptedTags -> + create( + content = encryptedTags, + tags = emptyArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } else { + create( + content = earlierVersion?.content ?: "", + tags = (earlierVersion?.tags ?: emptyArray()).plus(listNewTags), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + + fun removeEvent( + earlierVersion: BookmarkListEvent, + eventId: HexKey, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (BookmarkListEvent) -> Unit, + ) = removeTag(earlierVersion, "e", eventId, isPrivate, signer, createdAt, onReady) + + fun removeReplaceable( + earlierVersion: BookmarkListEvent, + aTag: ATag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (BookmarkListEvent) -> Unit, + ) = removeTag(earlierVersion, "a", aTag.toTag(), isPrivate, signer, createdAt, onReady) + + private fun removeTag( + earlierVersion: BookmarkListEvent, + tagName: String, + tagValue: HexKey, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (BookmarkListEvent) -> Unit, + ) { + if (isPrivate) { + earlierVersion.privateTagsOrEmpty(signer) { privateTags -> + encryptTags( + privateTags = + privateTags + .filter { it.size <= 1 || !(it[0] == tagName && it[1] == tagValue) } + .toTypedArray(), + signer = signer, + ) { encryptedTags -> + create( + content = encryptedTags, + tags = + earlierVersion.tags + .filter { it.size <= 1 || !(it[0] == tagName && it[1] == tagValue) } + .toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } else { + create( + content = earlierVersion.content, + tags = + earlierVersion.tags + .filter { it.size <= 1 || !(it[0] == tagName && it[1] == tagValue) } + .toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + + fun create( + content: String, + tags: Array>, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (BookmarkListEvent) -> Unit, + ) { + val newTags = + if (tags.any { it.size > 1 && it[0] == "alt" }) { + tags + } else { + tags + arrayOf("alt", ALT) + } + + signer.sign(createdAt, KIND, newTags, content, onReady) + } + + fun create( + name: String = "", + events: List? = null, + users: List? = null, + addresses: List? = null, + privEvents: List? = null, + privUsers: List? = null, + privAddresses: List? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (BookmarkListEvent) -> Unit, + ) { + val tags = mutableListOf>() + tags.add(arrayOf("d", name)) + + events?.forEach { tags.add(arrayOf("e", it)) } + users?.forEach { tags.add(arrayOf("p", it)) } + addresses?.forEach { tags.add(arrayOf("a", it.toTag())) } + tags.add(arrayOf("alt", ALT)) + + createPrivateTags(privEvents, privUsers, privAddresses, signer) { content -> + signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + } + } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarDateSlotEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarDateSlotEvent.kt index 0125afe56..bbd7fcecb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarDateSlotEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarDateSlotEvent.kt @@ -1,41 +1,59 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class CalendarDateSlotEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun location() = tags.firstOrNull { it.size > 1 && it[0] == "location" }?.get(1) - fun location() = tags.firstOrNull { it.size > 1 && it[0] == "location" }?.get(1) - fun start() = tags.firstOrNull { it.size > 1 && it[0] == "start" }?.get(1) - fun end() = tags.firstOrNull { it.size > 1 && it[0] == "end" }?.get(1) + fun start() = tags.firstOrNull { it.size > 1 && it[0] == "start" }?.get(1) - // ["start", ""], - // ["end", ""], + fun end() = tags.firstOrNull { it.size > 1 && it[0] == "end" }?.get(1) - companion object { - const val kind = 31922 - const val alt = "Full-day calendar event" + // ["start", ""], + // ["end", ""], - fun create( - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CalendarDateSlotEvent) -> Unit - ) { - val tags = arrayOf(arrayOf("alt", alt)) - signer.sign(createdAt, kind, tags, "", onReady) - } + companion object { + const val KIND = 31922 + const val ALT = "Full-day calendar event" + + fun create( + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CalendarDateSlotEvent) -> Unit, + ) { + val tags = arrayOf(arrayOf("alt", ALT)) + signer.sign(createdAt, KIND, tags, "", onReady) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarEvent.kt index b6ab5bd54..dd6ff430a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarEvent.kt @@ -1,33 +1,50 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class CalendarEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { - companion object { - const val kind = 31924 - const val alt = "Calendar" + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 31924 + const val ALT = "Calendar" - fun create( - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CalendarEvent) -> Unit - ) { - val tags = arrayOf(arrayOf("alt", alt)) - signer.sign(createdAt, kind, tags, "", onReady) - } + fun create( + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CalendarEvent) -> Unit, + ) { + val tags = arrayOf(arrayOf("alt", ALT)) + signer.sign(createdAt, KIND, tags, "", onReady) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarRSVPEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarRSVPEvent.kt index a1eedf370..179ed09bf 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarRSVPEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarRSVPEvent.kt @@ -1,43 +1,61 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class CalendarRSVPEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun status() = tags.firstOrNull { it.size > 1 && it[0] == "location" }?.get(1) - fun status() = tags.firstOrNull { it.size > 1 && it[0] == "location" }?.get(1) - fun start() = tags.firstOrNull { it.size > 1 && it[0] == "start" }?.get(1) - fun end() = tags.firstOrNull { it.size > 1 && it[0] == "end" }?.get(1) + fun start() = tags.firstOrNull { it.size > 1 && it[0] == "start" }?.get(1) - // ["L", "status"], - // ["l", "", "status"], - // ["L", "freebusy"], - // ["l", "", "freebusy"] + fun end() = tags.firstOrNull { it.size > 1 && it[0] == "end" }?.get(1) - companion object { - const val kind = 31925 - const val alt = "Calendar event's invitation response" + // ["L", "status"], + // ["l", "", "status"], + // ["L", "freebusy"], + // ["l", "", "freebusy"] - fun create( - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CalendarRSVPEvent) -> Unit - ) { - val tags = arrayOf(arrayOf("alt", alt)) - signer.sign(createdAt, kind, tags, "", onReady) - } + companion object { + const val KIND = 31925 + const val ALT = "Calendar event's invitation response" + + fun create( + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CalendarRSVPEvent) -> Unit, + ) { + val tags = arrayOf(arrayOf("alt", ALT)) + signer.sign(createdAt, KIND, tags, "", onReady) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarTimeSlotEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarTimeSlotEvent.kt index 376fda943..31ef819b8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarTimeSlotEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/CalendarTimeSlotEvent.kt @@ -1,45 +1,65 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class CalendarTimeSlotEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { - fun location() = tags.firstOrNull { it.size > 1 && it[0] == "location" }?.get(1) - fun start() = tags.firstOrNull { it.size > 1 && it[0] == "start" }?.get(1)?.toLongOrNull() - fun end() = tags.firstOrNull { it.size > 1 && it[0] == "end" }?.get(1)?.toLongOrNull() + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun location() = tags.firstOrNull { it.size > 1 && it[0] == "location" }?.get(1) - fun startTmz() = tags.firstOrNull { it.size > 1 && it[0] == "start_tzid" }?.get(1)?.toLongOrNull() - fun endTmz() = tags.firstOrNull { it.size > 1 && it[0] == "end_tzid" }?.get(1)?.toLongOrNull() + fun start() = tags.firstOrNull { it.size > 1 && it[0] == "start" }?.get(1)?.toLongOrNull() - // ["start", ""], - // ["end", ""], - // ["start_tzid", ""], - // ["end_tzid", ""], + fun end() = tags.firstOrNull { it.size > 1 && it[0] == "end" }?.get(1)?.toLongOrNull() - companion object { - const val kind = 31923 - const val alt = "Calendar time-slot event" + fun startTmz() = tags.firstOrNull { it.size > 1 && it[0] == "start_tzid" }?.get(1)?.toLongOrNull() - fun create( - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CalendarTimeSlotEvent) -> Unit - ) { - val tags = arrayOf(arrayOf("alt", alt)) - signer.sign(createdAt, kind, tags, "", onReady) - } + fun endTmz() = tags.firstOrNull { it.size > 1 && it[0] == "end_tzid" }?.get(1)?.toLongOrNull() + + // ["start", ""], + // ["end", ""], + // ["start_tzid", ""], + // ["end_tzid", ""], + + companion object { + const val KIND = 31923 + const val ALT = "Calendar time-slot event" + + fun create( + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CalendarTimeSlotEvent) -> Unit, + ) { + val tags = arrayOf(arrayOf("alt", ALT)) + signer.sign(createdAt, KIND, tags, "", onReady) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelCreateEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelCreateEvent.kt index 71305b9b5..4cfd91143 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelCreateEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelCreateEvent.kt @@ -1,78 +1,98 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import android.util.Log import androidx.compose.runtime.Immutable -import androidx.compose.runtime.key import com.fasterxml.jackson.module.kotlin.readValue -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class ChannelCreateEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun channelInfo(): ChannelData = try { - mapper.readValue(content) + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun channelInfo(): ChannelData = + try { + mapper.readValue(content) } catch (e: Exception) { - Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e) - ChannelData(null, null, null) + Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e) + ChannelData(null, null, null) } - companion object { - const val kind = 40 + companion object { + const val KIND = 40 - fun create( - name: String?, - about: String?, - picture: String?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChannelCreateEvent) -> Unit - ) { - return create( - ChannelData( - name, about, picture - ), - signer, - createdAt, - onReady - ) - } - - fun create( - channelInfo: ChannelData?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChannelCreateEvent) -> Unit - ) { - val content = try { - if (channelInfo != null) { - mapper.writeValueAsString(channelInfo) - } else { - "" - } - } catch (t: Throwable) { - Log.e("ChannelCreateEvent", "Couldn't parse channel information", t) - "" - } - - val tags = arrayOf( - arrayOf("alt", "Public chat creation event ${channelInfo?.name?.let { "about ${it}" }}") - ) - - signer.sign(createdAt, kind, tags, content, onReady) - } + fun create( + name: String?, + about: String?, + picture: String?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChannelCreateEvent) -> Unit, + ) { + return create( + ChannelData( + name, + about, + picture, + ), + signer, + createdAt, + onReady, + ) } - @Immutable - data class ChannelData(val name: String?, val about: String?, val picture: String?) + fun create( + channelInfo: ChannelData?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChannelCreateEvent) -> Unit, + ) { + val content = + try { + if (channelInfo != null) { + mapper.writeValueAsString(channelInfo) + } else { + "" + } + } catch (t: Throwable) { + Log.e("ChannelCreateEvent", "Couldn't parse channel information", t) + "" + } + + val tags = + arrayOf( + arrayOf("alt", "Public chat creation event ${channelInfo?.name?.let { "about $it" }}"), + ) + + signer.sign(createdAt, KIND, tags, content, onReady) + } + } + + @Immutable data class ChannelData(val name: String?, val about: String?, val picture: String?) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelHideMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelHideMessageEvent.kt index 6704a2840..2c0444814 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelHideMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelHideMessageEvent.kt @@ -1,46 +1,61 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class ChannelHideMessageEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig), IsInPublicChatChannel { - override fun channel() = tags.firstOrNull { - it.size > 3 && it[0] == "e" && it[3] == "root" - }?.get(1) ?: tags.firstOrNull { - it.size > 1 && it[0] == "e" - }?.get(1) + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), IsInPublicChatChannel { + override fun channel() = + tags.firstOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1) + ?: tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) - fun eventsToHide() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } + fun eventsToHide() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } - companion object { - const val kind = 43 - const val alt = "Hide message instruction for public chats" + companion object { + const val KIND = 43 + const val ALT = "Hide message instruction for public chats" - fun create( - reason: String, - messagesToHide: List?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChannelHideMessageEvent) -> Unit - ) { - val tags = - (messagesToHide?.map { - arrayOf("e", it) - }?.toTypedArray() ?: emptyArray()) + arrayOf(arrayOf("alt", alt)) + fun create( + reason: String, + messagesToHide: List?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChannelHideMessageEvent) -> Unit, + ) { + val tags = + (messagesToHide?.map { arrayOf("e", it) }?.toTypedArray() + ?: emptyArray()) + arrayOf(arrayOf("alt", ALT)) - signer.sign(createdAt, kind, tags, reason, onReady) - } + signer.sign(createdAt, KIND, tags, reason, onReady) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt index b561d20c6..d17f00438 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt @@ -1,81 +1,94 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class ChannelMessageEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig), IsInPublicChatChannel { + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseTextNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig), IsInPublicChatChannel { + override fun channel() = + tags.firstOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1) + ?: tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) - override fun channel() = tags.firstOrNull { - it.size > 3 && it[0] == "e" && it[3] == "root" - }?.get(1) ?: tags.firstOrNull { - it.size > 1 && it[0] == "e" - }?.get(1) + override fun replyTos() = + tags + .filter { it.firstOrNull() == "e" && it.getOrNull(1) != channel() } + .mapNotNull { it.getOrNull(1) } - override fun replyTos() = tags.filter { it.firstOrNull() == "e" && it.getOrNull(1) != channel() }.mapNotNull { it.getOrNull(1) } + companion object { + const val KIND = 42 + const val ALT = "Public chat message" - companion object { - const val kind = 42 - const val alt = "Public chat message" - - fun create( - message: String, - channel: String, - replyTos: List? = null, - mentions: List? = null, - zapReceiver: List? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - markAsSensitive: Boolean, - zapRaiserAmount: Long?, - geohash: String? = null, - nip94attachments: List? = null, - onReady: (ChannelMessageEvent) -> Unit - ) { - val tags = mutableListOf( - arrayOf("e", channel, "", "root") - ) - replyTos?.forEach { - tags.add(arrayOf("e", it)) - } - mentions?.forEach { - tags.add(arrayOf("p", it)) - } - zapReceiver?.forEach { - tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) - } - if (markAsSensitive) { - tags.add(arrayOf("content-warning", "")) - } - zapRaiserAmount?.let { - tags.add(arrayOf("zapraiser", "$it")) - } - geohash?.let { - tags.addAll(geohashMipMap(it)) - } - nip94attachments?.let { - it.forEach { - //tags.add(arrayOf("nip94", it.toJson())) - } - } - tags.add( - arrayOf("alt", alt) - ) - - signer.sign(createdAt, kind, tags.toTypedArray(), message, onReady) + fun create( + message: String, + channel: String, + replyTos: List? = null, + mentions: List? = null, + zapReceiver: List? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + markAsSensitive: Boolean, + zapRaiserAmount: Long?, + geohash: String? = null, + nip94attachments: List? = null, + onReady: (ChannelMessageEvent) -> Unit, + ) { + val tags = + mutableListOf( + arrayOf("e", channel, "", "root"), + ) + replyTos?.forEach { tags.add(arrayOf("e", it)) } + mentions?.forEach { tags.add(arrayOf("p", it)) } + zapReceiver?.forEach { + tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) + } + if (markAsSensitive) { + tags.add(arrayOf("content-warning", "")) + } + zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } + geohash?.let { tags.addAll(geohashMipMap(it)) } + nip94attachments?.let { + it.forEach { + // tags.add(arrayOf("nip94", it.toJson())) } + } + tags.add( + arrayOf("alt", ALT), + ) + + signer.sign(createdAt, KIND, tags.toTypedArray(), message, onReady) } + } } interface IsInPublicChatChannel { - fun channel(): String? + fun channel(): String? } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMetadataEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMetadataEvent.kt index 6c53095b8..20f53cc41 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMetadataEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMetadataEvent.kt @@ -1,76 +1,96 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import android.util.Log import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class ChannelMetadataEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig), IsInPublicChatChannel { + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), IsInPublicChatChannel { + override fun channel() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) - override fun channel() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) - fun channelInfo() = - try { - mapper.readValue(content, ChannelCreateEvent.ChannelData::class.java) - } catch (e: Exception) { - Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e) - ChannelCreateEvent.ChannelData(null, null, null) - } - - companion object { - const val kind = 41 - const val alt = "This is a public chat definition update" - - fun create( - name: String?, - about: String?, - picture: String?, - originalChannelIdHex: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChannelMetadataEvent) -> Unit - ) { - create( - ChannelCreateEvent.ChannelData( - name, about, picture - ), - originalChannelIdHex, - signer, - createdAt, - onReady - ) - } - - fun create( - newChannelInfo: ChannelCreateEvent.ChannelData?, - originalChannelIdHex: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChannelMetadataEvent) -> Unit - ) { - val content = - if (newChannelInfo != null) { - mapper.writeValueAsString(newChannelInfo) - } else { - "" - } - - val tags = listOf( - arrayOf("e", originalChannelIdHex, "", "root"), - arrayOf("alt", "Public chat update to ${newChannelInfo?.name}") - ) - signer.sign(createdAt, kind, tags.toTypedArray(), content, onReady) - } + fun channelInfo() = + try { + mapper.readValue(content, ChannelCreateEvent.ChannelData::class.java) + } catch (e: Exception) { + Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e) + ChannelCreateEvent.ChannelData(null, null, null) } + + companion object { + const val KIND = 41 + const val ALT = "This is a public chat definition update" + + fun create( + name: String?, + about: String?, + picture: String?, + originalChannelIdHex: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChannelMetadataEvent) -> Unit, + ) { + create( + ChannelCreateEvent.ChannelData( + name, + about, + picture, + ), + originalChannelIdHex, + signer, + createdAt, + onReady, + ) + } + + fun create( + newChannelInfo: ChannelCreateEvent.ChannelData?, + originalChannelIdHex: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChannelMetadataEvent) -> Unit, + ) { + val content = + if (newChannelInfo != null) { + mapper.writeValueAsString(newChannelInfo) + } else { + "" + } + + val tags = + listOf( + arrayOf("e", originalChannelIdHex, "", "root"), + arrayOf("alt", "Public chat update to ${newChannelInfo?.name}"), + ) + signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMuteUserEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMuteUserEvent.kt index 03f0beff3..63e6f995d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMuteUserEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMuteUserEvent.kt @@ -1,46 +1,62 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class ChannelMuteUserEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig), IsInPublicChatChannel { - override fun channel() = tags.firstOrNull { - it.size > 3 && it[0] == "e" && it[3] == "root" - }?.get(1) ?: tags.firstOrNull { - it.size > 1 && it[0] == "e" - }?.get(1) + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), IsInPublicChatChannel { + override fun channel() = + tags.firstOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1) + ?: tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) - fun usersToMute() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } + fun usersToMute() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } - companion object { - const val kind = 44 - const val alt = "Mute user instruction for public chats" + companion object { + const val KIND = 44 + const val ALT = "Mute user instruction for public chats" - fun create( - reason: String, - usersToMute: List?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChannelMuteUserEvent) -> Unit - ) { - val content = reason - val tags = (usersToMute?.map { - arrayOf("p", it) - }?.toTypedArray() ?: emptyArray()) + arrayOf(arrayOf("alt", alt)) + fun create( + reason: String, + usersToMute: List?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChannelMuteUserEvent) -> Unit, + ) { + val content = reason + val tags = + (usersToMute?.map { arrayOf("p", it) }?.toTypedArray() + ?: emptyArray()) + arrayOf(arrayOf("alt", ALT)) - signer.sign(createdAt, kind, tags, content, onReady) - } + signer.sign(createdAt, KIND, tags, content, onReady) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt index a5572d2a9..0600d65eb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt @@ -1,107 +1,112 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable -import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.toImmutableSet @Immutable class ChatMessageEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : WrappedEvent(id, pubKey, createdAt, kind, tags, content, sig), ChatroomKeyable { - /** - * Recipients intended to receive this conversation - */ - fun recipientsPubKey() = tags.mapNotNull { - if (it.size > 1 && it[0] == "p") it[1] else null + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : WrappedEvent(id, pubKey, createdAt, KIND, tags, content, sig), ChatroomKeyable { + /** Recipients intended to receive this conversation */ + fun recipientsPubKey() = tags.mapNotNull { if (it.size > 1 && it[0] == "p") it[1] else null } + + fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) + + fun talkingWith(oneSideHex: String): Set { + val listedPubKeys = recipientsPubKey() + + val result = + if (pubKey == oneSideHex) { + listedPubKeys.toSet().minus(oneSideHex) + } else { + listedPubKeys.plus(pubKey).toSet().minus(oneSideHex) + } + + if (result.isEmpty()) { + // talking to myself + return setOf(pubKey) } - fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) + return result + } - fun talkingWith(oneSideHex: String): Set { - val listedPubKeys = recipientsPubKey() + override fun chatroomKey(toRemove: String): ChatroomKey { + return ChatroomKey(talkingWith(toRemove).toImmutableSet()) + } - val result = if (pubKey == oneSideHex) { - listedPubKeys.toSet().minus(oneSideHex) - } else { - listedPubKeys.plus(pubKey).toSet().minus(oneSideHex) - } + companion object { + const val KIND = 14 + const val ALT = "Direct message" - if (result.isEmpty()) { - // talking to myself - return setOf(pubKey) - } + fun create( + msg: String, + to: List? = null, + subject: String? = null, + replyTos: List? = null, + mentions: List? = null, + zapReceiver: List? = null, + markAsSensitive: Boolean = false, + zapRaiserAmount: Long? = null, + geohash: String? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChatMessageEvent) -> Unit, + ) { + val tags = mutableListOf>() + to?.forEach { tags.add(arrayOf("p", it)) } + replyTos?.forEach { tags.add(arrayOf("e", it)) } + mentions?.forEach { tags.add(arrayOf("p", it, "", "mention")) } + zapReceiver?.forEach { + tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) + } + if (markAsSensitive) { + tags.add(arrayOf("content-warning", "")) + } + zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } + geohash?.let { tags.addAll(geohashMipMap(it)) } + subject?.let { tags.add(arrayOf("subject", it)) } + // tags.add(arrayOf("alt", alt)) - return result - } - - override fun chatroomKey(toRemove: String): ChatroomKey { - return ChatroomKey(talkingWith(toRemove).toImmutableSet()) - } - - companion object { - const val kind = 14 - const val alt = "Direct message" - - fun create( - msg: String, - to: List? = null, - subject: String? = null, - replyTos: List? = null, - mentions: List? = null, - zapReceiver: List? = null, - markAsSensitive: Boolean = false, - zapRaiserAmount: Long? = null, - geohash: String? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ChatMessageEvent) -> Unit - ) { - val tags = mutableListOf>() - to?.forEach { - tags.add(arrayOf("p", it)) - } - replyTos?.forEach { - tags.add(arrayOf("e", it)) - } - mentions?.forEach { - tags.add(arrayOf("p", it, "", "mention")) - } - zapReceiver?.forEach { - tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) - } - if (markAsSensitive) { - tags.add(arrayOf("content-warning", "")) - } - zapRaiserAmount?.let { - tags.add(arrayOf("zapraiser", "$it")) - } - geohash?.let { - tags.addAll(geohashMipMap(it)) - } - subject?.let { - tags.add(arrayOf("subject", it)) - } - //tags.add(arrayOf("alt", alt)) - - signer.sign(createdAt, kind, tags.toTypedArray(), msg, onReady) - } + signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) } + } } interface ChatroomKeyable { - fun chatroomKey(toRemove: HexKey): ChatroomKey + fun chatroomKey(toRemove: HexKey): ChatroomKey } @Stable data class ChatroomKey( - val users: ImmutableSet -) \ No newline at end of file + val users: ImmutableSet, +) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt index 4f0cd15d0..51f6e812c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt @@ -1,156 +1,180 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class ClassifiedsEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { - fun title() = tags.firstOrNull { it.size > 1 && it[0] == "title" }?.get(1) - fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1) - fun condition() = tags.firstOrNull { it.size > 1 && it[0] == "condition" }?.get(1) - fun images() = tags.filter { it.size > 1 && it[0] == "image" }.map { it[1] } - fun summary() = tags.firstOrNull { it.size > 1 && it[0] == "summary" }?.get(1) - fun price() = tags.firstOrNull { it.size > 1 && it[0] == "price" }?.let { - Price(it[1], it.getOrNull(2), it.getOrNull(3)) - } - fun location() = tags.firstOrNull { it.size > 1 && it[0] == "location" }?.get(1) + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun title() = tags.firstOrNull { it.size > 1 && it[0] == "title" }?.get(1) - fun publishedAt() = try { - tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1)?.toLongOrNull() + fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1) + + fun condition() = tags.firstOrNull { it.size > 1 && it[0] == "condition" }?.get(1) + + fun images() = tags.filter { it.size > 1 && it[0] == "image" }.map { it[1] } + + fun summary() = tags.firstOrNull { it.size > 1 && it[0] == "summary" }?.get(1) + + fun price() = + tags + .firstOrNull { it.size > 1 && it[0] == "price" } + ?.let { Price(it[1], it.getOrNull(2), it.getOrNull(3)) } + + fun location() = tags.firstOrNull { it.size > 1 && it[0] == "location" }?.get(1) + + fun publishedAt() = + try { + tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1)?.toLongOrNull() } catch (_: Exception) { - null + null } - enum class CONDITION(val value: String){ - NEW("new"), - USED_LIKE_NEW("like new"), - USED_GOOD("good"), - USED_FAIR("fair"), - } + enum class CONDITION(val value: String) { + NEW("new"), + USED_LIKE_NEW("like new"), + USED_GOOD("good"), + USED_FAIR("fair"), + } - companion object { - const val kind = 30402 - private val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg") - const val alt = "Classifieds listing" + companion object { + const val KIND = 30402 + private val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg") + const val ALT = "Classifieds listing" - fun create( - dTag: String, - title: String?, - image: String?, - summary: String?, - message: String, - price: Price?, - location: String?, - category: String?, - condition: ClassifiedsEvent.CONDITION?, - publishedAt: Long? = TimeUtils.now(), - replyTos: List?, - addresses: List?, - mentions: List?, - directMentions: Set, - zapReceiver: List? = null, - markAsSensitive: Boolean, - zapRaiserAmount: Long?, - geohash: String? = null, - nip94attachments: List? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ClassifiedsEvent) -> Unit - ) { - val tags = mutableListOf>() + fun create( + dTag: String, + title: String?, + image: String?, + summary: String?, + message: String, + price: Price?, + location: String?, + category: String?, + condition: ClassifiedsEvent.CONDITION?, + publishedAt: Long? = TimeUtils.now(), + replyTos: List?, + addresses: List?, + mentions: List?, + directMentions: Set, + zapReceiver: List? = null, + markAsSensitive: Boolean, + zapRaiserAmount: Long?, + geohash: String? = null, + nip94attachments: List? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ClassifiedsEvent) -> Unit, + ) { + val tags = mutableListOf>() - replyTos?.forEach { - if (it in directMentions) { - tags.add(arrayOf("e", it, "", "mention")) - } else { - tags.add(arrayOf("e", it)) - } - } - mentions?.forEach { - if (it in directMentions) { - tags.add(arrayOf("p", it, "", "mention")) - } else { - tags.add(arrayOf("p", it)) - } - } - addresses?.forEach { - val aTag = it.toTag() - if (aTag in directMentions) { - tags.add(arrayOf("a", aTag, "", "mention")) - } else { - tags.add(arrayOf("a", aTag)) - } - } - - tags.add(arrayOf("d", dTag)) - title?.let { tags.add(arrayOf("title", it)) } - image?.let { tags.add(arrayOf("image", it)) } - summary?.let { tags.add(arrayOf("summary", it)) } - price?.let { - if (it.frequency != null && it.currency != null) { - tags.add(arrayOf("price", it.amount, it.currency, it.frequency)) - } else if (it.currency != null) { - tags.add(arrayOf("price", it.amount, it.currency)) - } else { - tags.add(arrayOf("price", it.amount)) - } - } - category?.let { tags.add(arrayOf("t", it)) } - location?.let { tags.add(arrayOf("location", it)) } - publishedAt?.let { tags.add(arrayOf("publishedAt", it.toString())) } - condition?.let { tags.add(arrayOf("condition", it.value)) } - - findHashtags(message).forEach { - tags.add(arrayOf("t", it)) - tags.add(arrayOf("t", it.lowercase())) - } - zapReceiver?.forEach { - tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) - } - findURLs(message).forEach { - 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) }) { - tags.add(arrayOf("image", it)) - } - tags.add(arrayOf("r", it)) - } - if (markAsSensitive) { - tags.add(arrayOf("content-warning", "")) - } - zapRaiserAmount?.let { - tags.add(arrayOf("zapraiser", "$it")) - } - geohash?.let { - tags.addAll(geohashMipMap(it)) - } - nip94attachments?.let { - it.forEach { - //tags.add(arrayOf("nip94", it.toJson())) - } - } - tags.add(arrayOf("alt", alt)) - - signer.sign(createdAt, kind, tags.toTypedArray(), message, onReady) + replyTos?.forEach { + if (it in directMentions) { + tags.add(arrayOf("e", it, "", "mention")) + } else { + tags.add(arrayOf("e", it)) } + } + mentions?.forEach { + if (it in directMentions) { + tags.add(arrayOf("p", it, "", "mention")) + } else { + tags.add(arrayOf("p", it)) + } + } + addresses?.forEach { + val aTag = it.toTag() + if (aTag in directMentions) { + tags.add(arrayOf("a", aTag, "", "mention")) + } else { + tags.add(arrayOf("a", aTag)) + } + } + + tags.add(arrayOf("d", dTag)) + title?.let { tags.add(arrayOf("title", it)) } + image?.let { tags.add(arrayOf("image", it)) } + summary?.let { tags.add(arrayOf("summary", it)) } + price?.let { + if (it.frequency != null && it.currency != null) { + tags.add(arrayOf("price", it.amount, it.currency, it.frequency)) + } else if (it.currency != null) { + tags.add(arrayOf("price", it.amount, it.currency)) + } else { + tags.add(arrayOf("price", it.amount)) + } + } + category?.let { tags.add(arrayOf("t", it)) } + location?.let { tags.add(arrayOf("location", it)) } + publishedAt?.let { tags.add(arrayOf("publishedAt", it.toString())) } + condition?.let { tags.add(arrayOf("condition", it.value)) } + + findHashtags(message).forEach { + tags.add(arrayOf("t", it)) + tags.add(arrayOf("t", it.lowercase())) + } + zapReceiver?.forEach { + tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) + } + findURLs(message).forEach { + 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) }) { + tags.add(arrayOf("image", it)) + } + tags.add(arrayOf("r", it)) + } + if (markAsSensitive) { + tags.add(arrayOf("content-warning", "")) + } + zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } + geohash?.let { tags.addAll(geohashMipMap(it)) } + nip94attachments?.let { + it.forEach { + // tags.add(arrayOf("nip94", it.toJson())) + } + } + tags.add(arrayOf("alt", ALT)) + + signer.sign(createdAt, KIND, tags.toTypedArray(), message, onReady) } + } } data class Price(val amount: String, val currency: String?, val frequency: String?) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/CommunityDefinitionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/CommunityDefinitionEvent.kt index b847de624..4b7436ebe 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/CommunityDefinitionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/CommunityDefinitionEvent.kt @@ -1,40 +1,60 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class CommunityDefinitionEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { - fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1) - fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1) - fun rules() = tags.firstOrNull { it.size > 1 && it[0] == "rules" }?.get(1) + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1) - fun moderators() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(3)) } + fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1) - companion object { - const val kind = 34550 - const val alt = "Community definition" + fun rules() = tags.firstOrNull { it.size > 1 && it[0] == "rules" }?.get(1) - fun create( - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityDefinitionEvent) -> Unit - ) { - val tags = mutableListOf>() - tags.add(arrayOf("alt", alt)) - signer.sign(createdAt, kind, tags.toTypedArray(), "", onReady) - } + fun moderators() = + tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(3)) } + + companion object { + const val KIND = 34550 + const val ALT = "Community definition" + + fun create( + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommunityDefinitionEvent) -> Unit, + ) { + val tags = mutableListOf>() + tags.add(arrayOf("alt", ALT)) + signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/CommunityPostApprovalEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/CommunityPostApprovalEvent.kt index 5ced06d7f..81a888dbf 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/CommunityPostApprovalEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/CommunityPostApprovalEvent.kt @@ -1,70 +1,97 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import android.util.Log import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class CommunityPostApprovalEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun containedPost(): Event? = try { - content.ifBlank { null }?.let { - fromJson(it) - } + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun containedPost(): Event? = + try { + content.ifBlank { null }?.let { fromJson(it) } } catch (e: Exception) { - Log.w("CommunityPostEvent", "Failed to Parse Community Approval Contained Post of $id with $content") - null + Log.w( + "CommunityPostEvent", + "Failed to Parse Community Approval Contained Post of $id with $content", + ) + null } - fun communities() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull { + fun communities() = + tags + .filter { it.size > 1 && it[0] == "a" } + .mapNotNull { val aTag = ATag.parse(it[1], it.getOrNull(2)) - if (aTag?.kind == CommunityDefinitionEvent.kind) { - aTag + if (aTag?.kind == CommunityDefinitionEvent.KIND) { + aTag } else { - null - } - } - - fun approvedEvents() = tags.filter { - it.size > 1 && (it[0] == "e" || (it[0] == "a" && ATag.parse(it[1], null)?.kind != CommunityDefinitionEvent.kind)) - }.map { - it[1] - } - - companion object { - const val kind = 4550 - const val alt = "Community post approval" - - fun create( - approvedPost: Event, - community: CommunityDefinitionEvent, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityPostApprovalEvent) -> Unit - ) { - val content = approvedPost.toJson() - - val communities = arrayOf("a", community.address().toTag()) - val replyToPost = arrayOf("e", approvedPost.id()) - val replyToAuthor = arrayOf("p", approvedPost.pubKey()) - val innerKind = arrayOf("k", "${approvedPost.kind()}") - val alt = arrayOf("alt", alt) - - val tags: Array> = arrayOf(communities, replyToPost, replyToAuthor, innerKind, alt) - - signer.sign(createdAt, kind, tags, content, onReady) + null } + } + + fun approvedEvents() = + tags + .filter { + it.size > 1 && + (it[0] == "e" || + (it[0] == "a" && ATag.parse(it[1], null)?.kind != CommunityDefinitionEvent.KIND)) + } + .map { it[1] } + + companion object { + const val KIND = 4550 + const val ALT = "Community post approval" + + fun create( + approvedPost: Event, + community: CommunityDefinitionEvent, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommunityPostApprovalEvent) -> Unit, + ) { + val content = approvedPost.toJson() + + val communities = arrayOf("a", community.address().toTag()) + val replyToPost = arrayOf("e", approvedPost.id()) + val replyToAuthor = arrayOf("p", approvedPost.pubKey()) + val innerKind = arrayOf("k", "${approvedPost.kind()}") + val alt = arrayOf("alt", ALT) + + val tags: Array> = + arrayOf(communities, replyToPost, replyToAuthor, innerKind, alt) + + signer.sign(createdAt, KIND, tags, content, onReady) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt index 4b5452f90..607256333 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt @@ -1,362 +1,455 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import android.util.Log import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable +import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.module.kotlin.readValue -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.decodePublicKey +import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils -@Immutable -data class Contact(val pubKeyHex: String, val relayUri: String?) +@Immutable data class Contact(val pubKeyHex: String, val relayUri: String?) @Stable class ContactListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - // This function is only used by the user logged in - // But it is used all the time. + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + // This function is only used by the user logged in + // But it is used all the time. - @delegate:Transient - val verifiedFollowKeySet: Set by lazy { - tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull { - try { - decodePublicKey(it[1]).toHexKey() - } catch (e: Exception) { - Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e) - null - } - }.toSet() - } - - @delegate:Transient - val verifiedFollowTagSet: Set by lazy { - unverifiedFollowTagSet().map { it.lowercase() }.toSet() - } - - @delegate:Transient - val verifiedFollowGeohashSet: Set by lazy { - unverifiedFollowGeohashSet().map { it.lowercase() }.toSet() - } - - @delegate:Transient - val verifiedFollowCommunitySet: Set by lazy { - unverifiedFollowAddressSet().toSet() - } - - @delegate:Transient - val verifiedFollowKeySetAndMe: Set by lazy { - verifiedFollowKeySet + pubKey - } - - fun unverifiedFollowKeySet() = tags.filter { it[0] == "p" }.mapNotNull { it.getOrNull(1) } - fun unverifiedFollowTagSet() = tags.filter { it[0] == "t" }.mapNotNull { it.getOrNull(1) } - fun unverifiedFollowGeohashSet() = tags.filter { it[0] == "g" }.mapNotNull { it.getOrNull(1) } - - fun unverifiedFollowAddressSet() = tags.filter { it[0] == "a" }.mapNotNull { it.getOrNull(1) } - - fun follows() = tags.filter { it[0] == "p" }.mapNotNull { + @delegate:Transient + val verifiedFollowKeySet: Set by lazy { + tags + .filter { it.size > 1 && it[0] == "p" } + .mapNotNull { try { - Contact(decodePublicKey(it[1]).toHexKey(), it.getOrNull(2)) + decodePublicKey(it[1]).toHexKey() } catch (e: Exception) { - Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e) - null + Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e) + null } - } + } + .toSet() + } - fun followsTags() = tags.filter { it[0] == "t" }.mapNotNull { - it.getOrNull(2) - } + @delegate:Transient + val verifiedFollowTagSet: Set by lazy { + unverifiedFollowTagSet().map { it.lowercase() }.toSet() + } - fun relays(): Map? = try { - if (content.isNotEmpty()) { - mapper.readValue>(content) - } else { - null + @delegate:Transient + val verifiedFollowGeohashSet: Set by lazy { + unverifiedFollowGeohashSet().map { it.lowercase() }.toSet() + } + + @delegate:Transient + val verifiedFollowCommunitySet: Set by lazy { unverifiedFollowAddressSet().toSet() } + + @delegate:Transient + val verifiedFollowKeySetAndMe: Set by lazy { verifiedFollowKeySet + pubKey } + + fun unverifiedFollowKeySet() = tags.filter { it[0] == "p" }.mapNotNull { it.getOrNull(1) } + + fun unverifiedFollowTagSet() = tags.filter { it[0] == "t" }.mapNotNull { it.getOrNull(1) } + + fun unverifiedFollowGeohashSet() = tags.filter { it[0] == "g" }.mapNotNull { it.getOrNull(1) } + + fun unverifiedFollowAddressSet() = tags.filter { it[0] == "a" }.mapNotNull { it.getOrNull(1) } + + fun follows() = + tags + .filter { it[0] == "p" } + .mapNotNull { + try { + Contact(decodePublicKey(it[1]).toHexKey(), it.getOrNull(2)) + } catch (e: Exception) { + Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e) + null } - } catch (e: Exception) { - Log.w("ContactListEvent", "Can't parse content as relay lists: $content", e) + } + + fun followsTags() = tags.filter { it[0] == "t" }.mapNotNull { it.getOrNull(2) } + + fun relays(): Map? = + try { + if (content.isNotEmpty()) { + mapper.readValue>(content) + } else { null + } + } catch (e: Exception) { + Log.w("ContactListEvent", "Can't parse content as relay lists: $content", e) + null } - companion object { - const val kind = 3 - const val alt = "Follow List" + companion object { + const val KIND = 3 + const val ALT = "Follow List" - fun createFromScratch( - followUsers: List, - followTags: List, - followGeohashes: List, - followCommunities: List, - followEvents: List, - relayUse: Map?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit - ) { - val content = if (relayUse != null) { - mapper.writeValueAsString(relayUse) + fun createFromScratch( + followUsers: List, + followTags: List, + followGeohashes: List, + followCommunities: List, + followEvents: List, + relayUse: Map?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ContactListEvent) -> Unit, + ) { + val content = + if (relayUse != null) { + mapper.writeValueAsString(relayUse) + } else { + "" + } + + val tags = + followUsers.map { + if (it.relayUri != null) { + arrayOf("p", it.pubKeyHex, it.relayUri) + } else { + arrayOf("p", it.pubKeyHex) + } + } + + followTags.map { arrayOf("t", it) } + + followEvents.map { arrayOf("e", it) } + + followCommunities.map { + if (it.relay != null) { + arrayOf("a", it.toTag(), it.relay) } else { - "" + arrayOf("a", it.toTag()) } + } + + followGeohashes.map { arrayOf("g", it) } - val tags = followUsers.map { - if (it.relayUri != null) { - arrayOf("p", it.pubKeyHex, it.relayUri) - } else { - arrayOf("p", it.pubKeyHex) - } - } + followTags.map { - arrayOf("t", it) - } + followEvents.map { - arrayOf("e", it) - } + followCommunities.map { - if (it.relay != null) { - arrayOf("a", it.toTag(), it.relay) - } else { - arrayOf("a", it.toTag()) - } - } + followGeohashes.map { - arrayOf("g", it) - } - - return create( - content = content, - tags = tags.toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - - fun followUser(earlierVersion: ContactListEvent, pubKeyHex: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (ContactListEvent) -> Unit) { - if (earlierVersion.isTaggedUser(pubKeyHex)) return - - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.plus(element = arrayOf("p", pubKeyHex)), - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - - fun unfollowUser(earlierVersion: ContactListEvent, pubKeyHex: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (ContactListEvent) -> Unit) { - if (!earlierVersion.isTaggedUser(pubKeyHex)) return - - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.filter { it.size > 1 && it[1] != pubKeyHex }.toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - - fun followHashtag(earlierVersion: ContactListEvent, hashtag: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (ContactListEvent) -> Unit) { - if (earlierVersion.isTaggedHash(hashtag)) return - - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.plus(element = arrayOf("t", hashtag)), - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - - fun unfollowHashtag(earlierVersion: ContactListEvent, hashtag: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (ContactListEvent) -> Unit) { - if (!earlierVersion.isTaggedHash(hashtag)) return - - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.filter { it.size > 1 && !it[1].equals(hashtag, true) }.toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - - fun followGeohash(earlierVersion: ContactListEvent, hashtag: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (ContactListEvent) -> Unit) { - if (earlierVersion.isTaggedGeoHash(hashtag)) return - - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.plus(element = arrayOf("g", hashtag)), - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - - fun unfollowGeohash(earlierVersion: ContactListEvent, hashtag: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (ContactListEvent) -> Unit) { - if (!earlierVersion.isTaggedGeoHash(hashtag)) return - - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.filter { it.size > 1 && it[1] != hashtag }.toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - - fun followEvent(earlierVersion: ContactListEvent, idHex: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (ContactListEvent) -> Unit) { - if (earlierVersion.isTaggedEvent(idHex)) return - - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.plus(element = arrayOf("e", idHex)), - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - - fun unfollowEvent(earlierVersion: ContactListEvent, idHex: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (ContactListEvent) -> Unit) { - if (!earlierVersion.isTaggedEvent(idHex)) return - - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.filter { it.size > 1 && it[1] != idHex }.toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - - fun followAddressableEvent(earlierVersion: ContactListEvent, aTag: ATag, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (ContactListEvent) -> Unit) { - if (earlierVersion.isTaggedAddressableNote(aTag.toTag())) return - - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.plus(element = listOfNotNull("a", aTag.toTag(), aTag.relay).toTypedArray()), - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - - fun unfollowAddressableEvent(earlierVersion: ContactListEvent, aTag: ATag, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (ContactListEvent) -> Unit) { - if (!earlierVersion.isTaggedAddressableNote(aTag.toTag())) return - - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.filter { it.size > 1 && it[1] != aTag.toTag() }.toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - - fun updateRelayList(earlierVersion: ContactListEvent, relayUse: Map?, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (ContactListEvent) -> Unit) { - val content = if (relayUse != null) { - mapper.writeValueAsString(relayUse) - } else { - "" - } - - return create( - content = content, - tags = earlierVersion.tags, - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - - fun create( - content: String, - tags: Array>, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit - ) { - val newTags = if (tags.any { it.size > 1 && it[0] == "alt" }) { - tags - } else { - tags + arrayOf("alt", alt) - } - - signer.sign(createdAt, kind, newTags, content, onReady) - } + return create( + content = content, + tags = tags.toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) } - data class ReadWrite(val read: Boolean, val write: Boolean) + fun followUser( + earlierVersion: ContactListEvent, + pubKeyHex: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ContactListEvent) -> Unit, + ) { + if (earlierVersion.isTaggedUser(pubKeyHex)) return + + return create( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(element = arrayOf("p", pubKeyHex)), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + + fun unfollowUser( + earlierVersion: ContactListEvent, + pubKeyHex: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ContactListEvent) -> Unit, + ) { + if (!earlierVersion.isTaggedUser(pubKeyHex)) return + + return create( + content = earlierVersion.content, + tags = earlierVersion.tags.filter { it.size > 1 && it[1] != pubKeyHex }.toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + + fun followHashtag( + earlierVersion: ContactListEvent, + hashtag: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ContactListEvent) -> Unit, + ) { + if (earlierVersion.isTaggedHash(hashtag)) return + + return create( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(element = arrayOf("t", hashtag)), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + + fun unfollowHashtag( + earlierVersion: ContactListEvent, + hashtag: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ContactListEvent) -> Unit, + ) { + if (!earlierVersion.isTaggedHash(hashtag)) return + + return create( + content = earlierVersion.content, + tags = + earlierVersion.tags.filter { it.size > 1 && !it[1].equals(hashtag, true) }.toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + + fun followGeohash( + earlierVersion: ContactListEvent, + hashtag: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ContactListEvent) -> Unit, + ) { + if (earlierVersion.isTaggedGeoHash(hashtag)) return + + return create( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(element = arrayOf("g", hashtag)), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + + fun unfollowGeohash( + earlierVersion: ContactListEvent, + hashtag: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ContactListEvent) -> Unit, + ) { + if (!earlierVersion.isTaggedGeoHash(hashtag)) return + + return create( + content = earlierVersion.content, + tags = earlierVersion.tags.filter { it.size > 1 && it[1] != hashtag }.toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + + fun followEvent( + earlierVersion: ContactListEvent, + idHex: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ContactListEvent) -> Unit, + ) { + if (earlierVersion.isTaggedEvent(idHex)) return + + return create( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(element = arrayOf("e", idHex)), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + + fun unfollowEvent( + earlierVersion: ContactListEvent, + idHex: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ContactListEvent) -> Unit, + ) { + if (!earlierVersion.isTaggedEvent(idHex)) return + + return create( + content = earlierVersion.content, + tags = earlierVersion.tags.filter { it.size > 1 && it[1] != idHex }.toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + + fun followAddressableEvent( + earlierVersion: ContactListEvent, + aTag: ATag, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ContactListEvent) -> Unit, + ) { + if (earlierVersion.isTaggedAddressableNote(aTag.toTag())) return + + return create( + content = earlierVersion.content, + tags = + earlierVersion.tags.plus( + element = listOfNotNull("a", aTag.toTag(), aTag.relay).toTypedArray(), + ), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + + fun unfollowAddressableEvent( + earlierVersion: ContactListEvent, + aTag: ATag, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ContactListEvent) -> Unit, + ) { + if (!earlierVersion.isTaggedAddressableNote(aTag.toTag())) return + + return create( + content = earlierVersion.content, + tags = earlierVersion.tags.filter { it.size > 1 && it[1] != aTag.toTag() }.toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + + fun updateRelayList( + earlierVersion: ContactListEvent, + relayUse: Map?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ContactListEvent) -> Unit, + ) { + val content = + if (relayUse != null) { + mapper.writeValueAsString(relayUse) + } else { + "" + } + + return create( + content = content, + tags = earlierVersion.tags, + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + + fun create( + content: String, + tags: Array>, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ContactListEvent) -> Unit, + ) { + val newTags = + if (tags.any { it.size > 1 && it[0] == "alt" }) { + tags + } else { + tags + arrayOf("alt", ALT) + } + + signer.sign(createdAt, KIND, newTags, content, onReady) + } + } + + data class ReadWrite(val read: Boolean, val write: Boolean) } @Stable class UserMetadata { - var name: String? = null - var username: String? = null - var display_name: String? = null - var displayName: String? = null - var picture: String? = null - var banner: String? = null - var website: String? = null - var about: String? = null + var name: String? = null + var username: String? = null - var nip05: String? = null - var nip05Verified: Boolean = false - var nip05LastVerificationTime: Long? = 0 + @JsonProperty("display_name") var displayName: String? = null + var picture: String? = null + var banner: String? = null + var website: String? = null + var about: String? = null - var domain: String? = null - var lud06: String? = null - var lud16: String? = null + var nip05: String? = null + var nip05Verified: Boolean = false + var nip05LastVerificationTime: Long? = 0 - var twitter: String? = null + var domain: String? = null + var lud06: String? = null + var lud16: String? = null - var updatedMetadataAt: Long = 0 - var latestMetadata: MetadataEvent? = null - var tags: ImmutableListOfLists? = null + var twitter: String? = null - fun anyName(): String? { - return display_name ?: displayName ?: name ?: username + var updatedMetadataAt: Long = 0 + var latestMetadata: MetadataEvent? = null + var tags: ImmutableListOfLists? = null + + fun anyName(): String? { + return displayName ?: name ?: username + } + + fun anyNameStartsWith(prefix: String): Boolean { + return listOfNotNull(name, username, displayName, nip05, lud06, lud16).any { + it.contains(prefix, true) } + } - fun anyNameStartsWith(prefix: String): Boolean { - return listOfNotNull(name, username, display_name, displayName, nip05, lud06, lud16) - .any { it.contains(prefix, true) } - } + fun lnAddress(): String? { + return (lud16?.trim() ?: lud06?.trim())?.ifBlank { null } + } - fun lnAddress(): String? { - return (lud16?.trim() ?: lud06?.trim())?.ifBlank { null } - } + fun bestUsername(): String? { + return name?.ifBlank { null } ?: username?.ifBlank { null } + } - fun bestUsername(): String? { - return name?.ifBlank { null } ?: username?.ifBlank { null } - } + fun bestDisplayName(): String? { + return displayName?.ifBlank { null } + } - fun bestDisplayName(): String? { - return displayName?.ifBlank { null } ?: display_name?.ifBlank { null } - } + fun nip05(): String? { + return nip05?.ifBlank { null } + } - fun nip05(): String? { - return nip05?.ifBlank { null } - } - - fun profilePicture(): String? { - if (picture.isNullOrBlank()) picture = null - return picture - } + fun profilePicture(): String? { + if (picture.isNullOrBlank()) picture = null + return picture + } } -@Stable -class ImmutableListOfLists(val lists: Array>) +@Stable class ImmutableListOfLists(val lists: Array>) val EmptyTagList = ImmutableListOfLists(emptyArray()) fun Array>.toImmutableListOfLists(): ImmutableListOfLists { - return ImmutableListOfLists(this) -} \ No newline at end of file + return ImmutableListOfLists(this) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/DeletionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/DeletionEvent.kt index eb90bac8a..3b8f42b10 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/DeletionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/DeletionEvent.kt @@ -1,32 +1,55 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class DeletionEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun deleteEvents() = tags.map { it[1] } + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun deleteEvents() = tags.map { it[1] } - companion object { - const val kind = 5 - const val alt = "Deletion event" + companion object { + const val KIND = 5 + const val ALT = "Deletion event" - fun create(deleteEvents: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (DeletionEvent) -> Unit) { - val content = "" - val tags = deleteEvents.map { arrayOf("e", it) }.plusElement(arrayOf("alt", alt)).toTypedArray() - signer.sign(createdAt, kind, tags, content, onReady) - } + fun create( + deleteEvents: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (DeletionEvent) -> Unit, + ) { + val content = "" + val tags = + deleteEvents.map { arrayOf("e", it) }.plusElement(arrayOf("alt", ALT)).toTypedArray() + signer.sign(createdAt, KIND, tags, content, onReady) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EmojiPackEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EmojiPackEvent.kt index c5f162598..af732fa38 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EmojiPackEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EmojiPackEvent.kt @@ -1,57 +1,74 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class EmojiPackEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) { + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 30030 + const val ALT = "Emoji pack" - companion object { - const val kind = 30030 - const val alt = "Emoji pack" + fun create( + name: String = "", + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (EmojiPackEvent) -> Unit, + ) { + val content = "" - fun create( - name: String = "", - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (EmojiPackEvent) -> Unit - ) { - val content = "" + val tags = mutableListOf>() + tags.add(arrayOf("d", name)) + tags.add(arrayOf("alt", ALT)) - val tags = mutableListOf>() - tags.add(arrayOf("d", name)) - tags.add(arrayOf("alt", alt)) - - signer.sign(createdAt, kind, tags.toTypedArray(), content, onReady) - } + signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) } + } } @Immutable data class EmojiUrl(val code: String, val url: String) { - fun encode(): String { - return ":$code:$url" - } + fun encode(): String { + return ":$code:$url" + } - companion object { - fun decode(encodedEmojiSetup: String): EmojiUrl? { - val emojiParts = encodedEmojiSetup.split(":", limit = 3) - return if (emojiParts.size > 2) { - EmojiUrl(emojiParts[1], emojiParts[2]) - } else { - null - } - } + companion object { + fun decode(encodedEmojiSetup: String): EmojiUrl? { + val emojiParts = encodedEmojiSetup.split(":", limit = 3) + return if (emojiParts.size > 2) { + EmojiUrl(emojiParts[1], emojiParts[2]) + } else { + null + } } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EmojiPackSelectionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EmojiPackSelectionEvent.kt index 52486a1a2..1ff4cc91c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EmojiPackSelectionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EmojiPackSelectionEvent.kt @@ -1,46 +1,61 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class EmojiPackSelectionEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { - override fun dTag() = fixedDTag + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + override fun dTag() = FIXED_D_TAG - companion object { - const val kind = 10030 - const val fixedDTag = "" - const val alt = "Emoji selection" + companion object { + const val KIND = 10030 + const val FIXED_D_TAG = "" + const val ALT = "Emoji selection" - fun create( - listOfEmojiPacks: List?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (EmojiPackSelectionEvent) -> Unit - ) { - val msg = "" - val tags = mutableListOf>() + fun create( + listOfEmojiPacks: List?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (EmojiPackSelectionEvent) -> Unit, + ) { + val msg = "" + val tags = mutableListOf>() - listOfEmojiPacks?.forEach { - tags.add(arrayOf("a", it.toTag())) - } + listOfEmojiPacks?.forEach { tags.add(arrayOf("a", it.toTag())) } - tags.add(arrayOf("alt", alt)) + tags.add(arrayOf("alt", ALT)) - signer.sign(createdAt, kind, tags.toTypedArray(), msg, onReady) - } + signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt index 9bc1eb6b1..4e3f564af 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import android.util.Log @@ -11,7 +31,6 @@ import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.SerializerProvider import com.fasterxml.jackson.databind.deser.std.StdDeserializer import com.fasterxml.jackson.databind.module.SimpleModule -import com.fasterxml.jackson.databind.node.ArrayNode import com.fasterxml.jackson.databind.ser.std.StdSerializer import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.quartz.crypto.CryptoUtils @@ -23,448 +42,502 @@ import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils import java.math.BigDecimal -import java.util.* - @Immutable open class Event( - val id: HexKey, - @JsonProperty("pubkey") - val pubKey: HexKey, - @JsonProperty("created_at") - val createdAt: Long, - val kind: Int, - val tags: Array>, - val content: String, - val sig: HexKey + val id: HexKey, + @JsonProperty("pubkey") val pubKey: HexKey, + @JsonProperty("created_at") val createdAt: Long, + val kind: Int, + val tags: Array>, + val content: String, + val sig: HexKey, ) : EventInterface { + override fun countMemory(): Long { + return 12L + + id.bytesUsedInMemory() + + pubKey.bytesUsedInMemory() + + tags.sumOf { it.sumOf { it.bytesUsedInMemory() } } + + content.bytesUsedInMemory() + + sig.bytesUsedInMemory() + } - override fun countMemory(): Long { - return 12L + - id.bytesUsedInMemory() + - pubKey.bytesUsedInMemory() + - tags.sumOf { it.sumOf { it.bytesUsedInMemory() } } + - content.bytesUsedInMemory() + - sig.bytesUsedInMemory() - } + override fun id(): HexKey = id - override fun id(): HexKey = id + override fun pubKey(): HexKey = pubKey - override fun pubKey(): HexKey = pubKey + override fun createdAt(): Long = createdAt - override fun createdAt(): Long = createdAt + override fun kind(): Int = kind - override fun kind(): Int = kind + override fun tags(): Array> = tags - override fun tags(): Array> = tags + override fun content(): String = content - override fun content(): String = content + override fun sig(): HexKey = sig - override fun sig(): HexKey = sig + override fun toJson(): String = mapper.writeValueAsString(toJsonObject()) - override fun toJson(): String = mapper.writeValueAsString(toJsonObject()) + override fun hasAnyTaggedUser() = hasTagWithContent("p") - override fun hasAnyTaggedUser() = hasTagWithContent("p") + override fun hasTagWithContent(tagName: String) = tags.any { it.size > 1 && it[0] == tagName } - override fun hasTagWithContent(tagName: String) = tags.any { it.size > 1 && it[0] == tagName } + override fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } - override fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } - override fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } + override fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } - override fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] } + override fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] } - override fun firstTaggedUser() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.let { it[1] } - override fun firstTaggedEvent() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.let { it[1] } - override fun firstTaggedUrl() = tags.firstOrNull { it.size > 1 && it[0] == "r" }?.let { it[1] } - override fun firstTaggedAddress() = tags.firstOrNull { it.size > 1 && it[0] == "a" }?.let { + override fun firstTaggedUser() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.let { it[1] } + + override fun firstTaggedEvent() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.let { it[1] } + + override fun firstTaggedUrl() = tags.firstOrNull { it.size > 1 && it[0] == "r" }?.let { it[1] } + + override fun firstTaggedAddress() = + tags + .firstOrNull { it.size > 1 && it[0] == "a" } + ?.let { val aTagValue = it[1] val relay = it.getOrNull(2) ATag.parse(aTagValue, relay) + } + + override fun taggedEmojis() = + tags.filter { it.size > 2 && it[0] == "emoji" }.map { EmojiUrl(it[1], it[2]) } + + override fun isSensitive() = + tags.any { + (it.size > 0 && it[0].equals("content-warning", true)) || + (it.size > 1 && it[0] == "t" && it[1].equals("nsfw", true)) || + (it.size > 1 && it[0] == "t" && it[1].equals("nude", true)) } - override fun taggedEmojis() = tags.filter { it.size > 2 && it[0] == "emoji" }.map { EmojiUrl(it[1], it[2]) } + override fun subject() = tags.firstOrNull { it.size > 1 && it[0] == "subject" }?.get(1) - override fun isSensitive() = tags.any { - (it.size > 0 && it[0].equals("content-warning", true)) || - (it.size > 1 && it[0] == "t" && it[1].equals("nsfw", true)) || - (it.size > 1 && it[0] == "t" && it[1].equals("nude", true)) - } + override fun zapraiserAmount() = + tags.firstOrNull { (it.size > 1 && it[0] == "zapraiser") }?.get(1)?.toLongOrNull() - override fun subject() = tags.firstOrNull() { it.size > 1 && it[0] == "subject" }?.get(1) + override fun hasZapSplitSetup() = tags.any { it.size > 1 && it[0] == "zap" } - override fun zapraiserAmount() = tags.firstOrNull() { - (it.size > 1 && it[0] == "zapraiser") - }?.get(1)?.toLongOrNull() + override fun zapSplitSetup(): List { + return tags + .filter { it.size > 1 && it[0] == "zap" } + .mapNotNull { + val isLnAddress = it[0].contains("@") || it[0].startsWith("LNURL", true) + val weight = if (isLnAddress) 1.0 else (it.getOrNull(3)?.toDoubleOrNull() ?: 0.0) - override fun hasZapSplitSetup() = tags.any { it.size > 1 && it[0] == "zap" } - - override fun zapSplitSetup(): List { - return tags.filter { it.size > 1 && it[0] == "zap" }.mapNotNull { - val isLnAddress = it[0].contains("@") || it[0].startsWith("LNURL", true) - val weight = if (isLnAddress) 1.0 else (it.getOrNull(3)?.toDoubleOrNull() ?: 0.0) - - if (weight > 0) { - ZapSplitSetup( - it[1], - it.getOrNull(2), - weight, - isLnAddress - ) - } else { - null - } - } - } - - override fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull { - val aTagValue = it[1] - val relay = it.getOrNull(2) - - ATag.parse(aTagValue, relay) - } - - override fun hasHashtags() = tags.any { it.size > 1 && it[0] == "t" } - override fun hasGeohashes() = tags.any { it.size > 1 && it[0] == "g" } - - override fun hashtags() = tags.filter { it.size > 1 && it[0] == "t" }.map { it[1] } - override fun geohashes() = tags.filter { it.size > 1 && it[0] == "g" }.map { it[1] } - - override fun matchTag1With(text: String) = tags.any { it.size > 1 && it[1].contains(text, true) } - - override fun isTagged(key: String, tag: String) = tags.any { it.size > 1 && it[0] == key && it[1] == tag } - - override fun isAnyTagged(key: String, tags: Set) = this.tags.any { it.size > 1 && it[0] == key && it[1] in tags } - - override fun isTaggedWord(word: String) = isTagged("word", word) - - override fun isTaggedUser(idHex: String) = isTagged("p", idHex) - override fun isTaggedUsers(idHexes: Set) = isAnyTagged("p", idHexes) - - override fun isTaggedEvent(idHex: String) = isTagged("e", idHex) - - override fun isTaggedAddressableNote(idHex: String) = isTagged("a", idHex) - - override fun isTaggedAddressableNotes(idHexes: Set) = isAnyTagged( "a", idHexes) - - override fun isTaggedHash(hashtag: String) = tags.any { it.size > 1 && it[0] == "t" && it[1].equals(hashtag, true) } - - override fun isTaggedGeoHash(hashtag: String) = tags.any { it.size > 1 && it[0] == "g" && it[1].startsWith(hashtag, true) } - override fun isTaggedHashes(hashtags: Set) = tags.any { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags } - override fun isTaggedGeoHashes(hashtags: Set) = tags.any { it.size > 1 && it[0] == "g" && it[1].lowercase() in hashtags } - override fun firstIsTaggedHashes(hashtags: Set) = tags.firstOrNull { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }?.getOrNull(1) - - override fun firstIsTaggedAddressableNote(addressableNotes: Set) = tags.firstOrNull { it.size > 1 && it[0] == "a" && it[1] in addressableNotes }?.getOrNull(1) - - override fun isTaggedAddressableKind(kind: Int): Boolean { - val kindStr = kind.toString() - return tags.any { it.size > 1 && it[0] == "a" && it[1].startsWith(kindStr) } - } - - override fun expiration() = try { - tags.firstOrNull { it.size > 1 && it[0] == "expiration" }?.get(1)?.toLongOrNull() - } catch (_: Exception) { - null - } - - override fun isExpired() = (expiration() ?: Long.MAX_VALUE) < TimeUtils.now() - - override fun getTagOfAddressableKind(kind: Int): ATag? { - val kindStr = kind.toString() - val aTag = tags - .firstOrNull { it.size > 1 && it[0] == "a" && it[1].startsWith(kindStr) } - ?.getOrNull(1) - ?: return null - - return ATag.parse(aTag, null) - } - - override fun getPoWRank(): Int { - var rank = 0 - for (i in 0..id.length) { - if (id[i] == '0') { - rank += 4 - } else if (id[i] in '4'..'7') { - rank += 1 - break - } else if (id[i] in '2'..'3') { - rank += 2 - break - } else if (id[i] == '1') { - rank += 3 - break - } else { - break - } - } - return rank - } - - override fun getGeoHash(): String? { - return tags.firstOrNull { it.size > 1 && it[0] == "g" }?.get(1)?.ifBlank { null } - } - - override fun getReward(): BigDecimal? { - return try { - tags.firstOrNull { it.size > 1 && it[0] == "reward" }?.get(1)?.let { BigDecimal(it) } - } catch (e: Exception) { - null - } - } - - open fun toNIP19(): String { - return if (this is AddressableEvent) { - ATag(kind, pubKey, dTag(), null).toNAddr() + if (weight > 0) { + ZapSplitSetup( + it[1], + it.getOrNull(2), + weight, + isLnAddress, + ) } else { - Nip19.createNEvent(id, pubKey, kind, null) + null } + } + } + + override fun taggedAddresses() = + tags + .filter { it.size > 1 && it[0] == "a" } + .mapNotNull { + val aTagValue = it[1] + val relay = it.getOrNull(2) + + ATag.parse(aTagValue, relay) + } + + override fun hasHashtags() = tags.any { it.size > 1 && it[0] == "t" } + + override fun hasGeohashes() = tags.any { it.size > 1 && it[0] == "g" } + + override fun hashtags() = tags.filter { it.size > 1 && it[0] == "t" }.map { it[1] } + + override fun geohashes() = tags.filter { it.size > 1 && it[0] == "g" }.map { it[1] } + + override fun matchTag1With(text: String) = tags.any { it.size > 1 && it[1].contains(text, true) } + + override fun isTagged( + key: String, + tag: String, + ) = tags.any { it.size > 1 && it[0] == key && it[1] == tag } + + override fun isAnyTagged( + key: String, + tags: Set, + ) = this.tags.any { it.size > 1 && it[0] == key && it[1] in tags } + + override fun isTaggedWord(word: String) = isTagged("word", word) + + override fun isTaggedUser(idHex: String) = isTagged("p", idHex) + + override fun isTaggedUsers(idHexes: Set) = isAnyTagged("p", idHexes) + + override fun isTaggedEvent(idHex: String) = isTagged("e", idHex) + + override fun isTaggedAddressableNote(idHex: String) = isTagged("a", idHex) + + override fun isTaggedAddressableNotes(idHexes: Set) = isAnyTagged("a", idHexes) + + override fun isTaggedHash(hashtag: String) = + tags.any { it.size > 1 && it[0] == "t" && it[1].equals(hashtag, true) } + + override fun isTaggedGeoHash(hashtag: String) = + tags.any { it.size > 1 && it[0] == "g" && it[1].startsWith(hashtag, true) } + + override fun isTaggedHashes(hashtags: Set) = + tags.any { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags } + + override fun isTaggedGeoHashes(hashtags: Set) = + tags.any { it.size > 1 && it[0] == "g" && it[1].lowercase() in hashtags } + + override fun firstIsTaggedHashes(hashtags: Set) = + tags.firstOrNull { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }?.getOrNull(1) + + override fun firstIsTaggedAddressableNote(addressableNotes: Set) = + tags.firstOrNull { it.size > 1 && it[0] == "a" && it[1] in addressableNotes }?.getOrNull(1) + + override fun isTaggedAddressableKind(kind: Int): Boolean { + val kindStr = kind.toString() + return tags.any { it.size > 1 && it[0] == "a" && it[1].startsWith(kindStr) } + } + + override fun expiration() = + try { + tags.firstOrNull { it.size > 1 && it[0] == "expiration" }?.get(1)?.toLongOrNull() + } catch (_: Exception) { + null } - fun toNostrUri(): String { - return "nostr:${toNIP19()}" - } + override fun isExpired() = (expiration() ?: Long.MAX_VALUE) < TimeUtils.now() - fun hasCorrectIDHash(): Boolean { - if (id.isEmpty()) return false - return id.equals(generateId()) - } + override fun getTagOfAddressableKind(kind: Int): ATag? { + val kindStr = kind.toString() + val aTag = + tags.firstOrNull { it.size > 1 && it[0] == "a" && it[1].startsWith(kindStr) }?.getOrNull(1) + ?: return null - fun hasVerifiedSignature(): Boolean { - if (id.isEmpty() || sig.isEmpty()) return false - return CryptoUtils.verifySignature(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey)) - } + return ATag.parse(aTag, null) + } - /** - * Checks if the ID is correct and then if the pubKey's secret key signed the event. - */ - override fun checkSignature() { - if (!hasCorrectIDHash()) { - throw Exception( - """|Unexpected ID. - | Event: ${toJson()} - | Actual ID: $id - | Generated: ${generateId()} - """.trimIndent() + override fun getPoWRank(): Int { + var rank = 0 + for (i in 0..id.length) { + if (id[i] == '0') { + rank += 4 + } else if (id[i] in '4'..'7') { + rank += 1 + break + } else if (id[i] in '2'..'3') { + rank += 2 + break + } else if (id[i] == '1') { + rank += 3 + break + } else { + break + } + } + return rank + } + + override fun getGeoHash(): String? { + return tags.firstOrNull { it.size > 1 && it[0] == "g" }?.get(1)?.ifBlank { null } + } + + override fun getReward(): BigDecimal? { + return try { + tags.firstOrNull { it.size > 1 && it[0] == "reward" }?.get(1)?.let { BigDecimal(it) } + } catch (e: Exception) { + null + } + } + + open fun toNIP19(): String { + return if (this is AddressableEvent) { + ATag(kind, pubKey, dTag(), null).toNAddr() + } else { + Nip19.createNEvent(id, pubKey, kind, null) + } + } + + fun toNostrUri(): String { + return "nostr:${toNIP19()}" + } + + fun hasCorrectIDHash(): Boolean { + if (id.isEmpty()) return false + return id.equals(generateId()) + } + + fun hasVerifiedSignature(): Boolean { + if (id.isEmpty() || sig.isEmpty()) return false + return CryptoUtils.verifySignature(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey)) + } + + /** Checks if the ID is correct and then if the pubKey's secret key signed the event. */ + override fun checkSignature() { + if (!hasCorrectIDHash()) { + throw Exception( + """ + |Unexpected ID. + | Event: ${toJson()} + | Actual ID: $id + | Generated: ${generateId()} + """ + .trimIndent(), + ) + } + if (!hasVerifiedSignature()) { + throw Exception("""Bad signature!""") + } + } + + override fun hasValidSignature(): Boolean { + return try { + hasCorrectIDHash() && hasVerifiedSignature() + } catch (e: Exception) { + Log.w("Event", "Event $id does not have a valid signature: ${toJson()}", e) + false + } + } + + fun makeJsonForId(): String { + return makeJsonForId(pubKey, createdAt, kind, tags, content) + } + + private fun generateId(): String { + return CryptoUtils.sha256(makeJsonForId().toByteArray()).toHexKey() + } + + private class EventDeserializer : StdDeserializer(Event::class.java) { + override fun deserialize( + jp: JsonParser, + ctxt: DeserializationContext, + ): Event { + return fromJson(jp.codec.readTree(jp)) + } + } + + private class GossipDeserializer : StdDeserializer(Gossip::class.java) { + override fun deserialize( + jp: JsonParser, + ctxt: DeserializationContext, + ): Gossip { + val jsonObject: JsonNode = jp.codec.readTree(jp) + return Gossip( + id = jsonObject.get("id")?.asText()?.intern(), + pubKey = jsonObject.get("pubkey")?.asText()?.intern(), + createdAt = jsonObject.get("created_at")?.asLong(), + kind = jsonObject.get("kind")?.asInt(), + tags = + jsonObject.get("tags").toTypedArray { + it.toTypedArray { s -> if (s.isNull) "" else s.asText().intern() } + }, + content = jsonObject.get("content")?.asText(), + ) + } + } + + private class EventSerializer : StdSerializer(Event::class.java) { + override fun serialize( + event: Event, + gen: JsonGenerator, + provider: SerializerProvider, + ) { + gen.writeStartObject() + gen.writeStringField("id", event.id) + gen.writeStringField("pubkey", event.pubKey) + gen.writeNumberField("created_at", event.createdAt) + gen.writeNumberField("kind", event.kind) + gen.writeArrayFieldStart("tags") + event.tags.forEach { tag -> gen.writeArray(tag, 0, tag.size) } + gen.writeEndArray() + gen.writeStringField("content", event.content) + gen.writeStringField("sig", event.sig) + gen.writeEndObject() + } + } + + private class GossipSerializer : StdSerializer(Gossip::class.java) { + override fun serialize( + event: Gossip, + gen: JsonGenerator, + provider: SerializerProvider, + ) { + gen.writeStartObject() + event.id?.let { gen.writeStringField("id", it) } + event.pubKey?.let { gen.writeStringField("pubkey", it) } + event.createdAt?.let { gen.writeNumberField("created_at", it) } + event.kind?.let { gen.writeNumberField("kind", it) } + event.tags?.let { + gen.writeArrayFieldStart("tags") + event.tags.forEach { tag -> gen.writeArray(tag, 0, tag.size) } + gen.writeEndArray() + } + event.content?.let { gen.writeStringField("content", it) } + gen.writeEndObject() + } + } + + fun toJsonObject(): JsonNode { + val factory = mapper.nodeFactory + + return factory.objectNode().apply { + put("id", id) + put("pubkey", pubKey) + put("created_at", createdAt) + put("kind", kind) + put( + "tags", + factory.arrayNode(tags.size).apply { + tags.forEach { tag -> + add( + factory.arrayNode(tag.size).apply { tag.forEach { add(it) } }, ) - } - if (!hasVerifiedSignature()) { - throw Exception("""Bad signature!""") - } + } + }, + ) + put("content", content) + put("sig", sig) + } + } + + companion object { + val mapper = + jacksonObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .registerModule( + SimpleModule() + .addSerializer(Event::class.java, EventSerializer()) + .addDeserializer(Event::class.java, EventDeserializer()) + .addSerializer(Gossip::class.java, GossipSerializer()) + .addDeserializer(Gossip::class.java, GossipDeserializer()) + .addDeserializer(Response::class.java, ResponseDeserializer()) + .addDeserializer(Request::class.java, RequestDeserializer()), + ) + + fun fromJson(jsonObject: JsonNode): Event { + return EventFactory.create( + id = jsonObject.get("id").asText().intern(), + pubKey = jsonObject.get("pubkey").asText().intern(), + createdAt = jsonObject.get("created_at").asLong(), + kind = jsonObject.get("kind").asInt(), + tags = + jsonObject.get("tags").toTypedArray { + it.toTypedArray { s -> if (s.isNull) "" else s.asText().intern() } + }, + content = jsonObject.get("content").asText(), + sig = jsonObject.get("sig").asText(), + ) } - override fun hasValidSignature(): Boolean { - return try { - hasCorrectIDHash() && hasVerifiedSignature() - } catch (e: Exception) { - Log.w("Event", "Event $id does not have a valid signature: ${toJson()}", e) - false - } + private inline fun JsonNode.toTypedArray(transform: (JsonNode) -> R): Array { + return Array(size()) { transform(get(it)) } } - fun makeJsonForId(): String { - return makeJsonForId(pubKey, createdAt, kind, tags, content) - } + fun fromJson(json: String): Event = mapper.readValue(json, Event::class.java) - private fun generateId(): String { - return CryptoUtils.sha256(makeJsonForId().toByteArray()).toHexKey() - } + fun toJson(event: Event): String = mapper.writeValueAsString(event) - private class EventDeserializer : StdDeserializer(Event::class.java) { - override fun deserialize(jp: JsonParser, ctxt: DeserializationContext): Event { - return fromJson(jp.codec.readTree(jp)) - } - } - - private class GossipDeserializer : StdDeserializer(Gossip::class.java) { - override fun deserialize(jp: JsonParser, ctxt: DeserializationContext): Gossip { - val jsonObject: JsonNode = jp.codec.readTree(jp) - return Gossip( - id = jsonObject.get("id")?.asText()?.intern(), - pubKey = jsonObject.get("pubkey")?.asText()?.intern(), - createdAt = jsonObject.get("created_at")?.asLong(), - kind = jsonObject.get("kind")?.asInt(), - tags = jsonObject.get("tags").toTypedArray { - it.toTypedArray { s -> if (s.isNull) "" else s.asText().intern() } - }, - content = jsonObject.get("content")?.asText() - ) - } - } - - private class EventSerializer: StdSerializer(Event::class.java) { - override fun serialize(event: Event, gen: JsonGenerator, provider: SerializerProvider) { - gen.writeStartObject() - gen.writeStringField("id", event.id) - gen.writeStringField("pubkey", event.pubKey) - gen.writeNumberField("created_at", event.createdAt) - gen.writeNumberField("kind", event.kind) - gen.writeArrayFieldStart("tags") - event.tags.forEach { tag -> - gen.writeArray(tag, 0, tag.size) - } - gen.writeEndArray() - gen.writeStringField("content", event.content) - gen.writeStringField("sig", event.sig) - gen.writeEndObject() - } - } - - private class GossipSerializer: StdSerializer(Gossip::class.java) { - override fun serialize(event: Gossip, gen: JsonGenerator, provider: SerializerProvider) { - gen.writeStartObject() - event.id?.let { gen.writeStringField("id", it) } - event.pubKey?.let { gen.writeStringField("pubkey", it) } - event.createdAt?.let { gen.writeNumberField("created_at", it) } - event.kind?.let { gen.writeNumberField("kind", it) } - event.tags?.let { - gen.writeArrayFieldStart("tags") - event.tags.forEach { tag -> - gen.writeArray(tag, 0, tag.size) - } - gen.writeEndArray() - } - event.content?.let { gen.writeStringField("content", it) } - gen.writeEndObject() - } - } - - fun toJsonObject(): JsonNode { - val factory = mapper.nodeFactory - - return factory.objectNode().apply { - put("id", id) - put("pubkey", pubKey) - put("created_at", createdAt) - put("kind", kind) - put( - "tags", - factory.arrayNode(tags.size).apply { - tags.forEach { tag -> - add( - factory.arrayNode(tag.size).apply { - tag.forEach { add(it) } - } - ) - } - } - ) - put("content", content) - put("sig", sig) - } - } - - companion object { - val mapper = jacksonObjectMapper() - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - .registerModule(SimpleModule() - .addSerializer(Event::class.java, EventSerializer()) - .addDeserializer(Event::class.java, EventDeserializer()) - .addSerializer(Gossip::class.java, GossipSerializer()) - .addDeserializer(Gossip::class.java, GossipDeserializer()) - .addDeserializer(Response::class.java, ResponseDeserializer()) - .addDeserializer(Request::class.java, RequestDeserializer()) - ) - - fun fromJson(jsonObject: JsonNode): Event { - return EventFactory.create( - id = jsonObject.get("id").asText().intern(), - pubKey = jsonObject.get("pubkey").asText().intern(), - createdAt = jsonObject.get("created_at").asLong(), - kind = jsonObject.get("kind").asInt(), - tags = jsonObject.get("tags").toTypedArray { - it.toTypedArray { s -> if (s.isNull) "" else s.asText().intern() } - }, - content = jsonObject.get("content").asText(), - sig = jsonObject.get("sig").asText() - ) - } - - private inline fun JsonNode.toTypedArray(transform: (JsonNode) -> R): Array { - return Array(size()) { - transform(get(it)) - } - } - - fun fromJson(json: String): Event = mapper.readValue(json, Event::class.java) - fun toJson(event: Event): String = mapper.writeValueAsString(event) - - fun makeJsonForId(pubKey: HexKey, createdAt: Long, kind: Int, tags: Array>, content: String): String { - val factory = mapper.nodeFactory - val rawEvent = factory.arrayNode(6).apply { - add(0) - add(pubKey) - add(createdAt) - add(kind) + fun makeJsonForId( + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): String { + val factory = mapper.nodeFactory + val rawEvent = + factory.arrayNode(6).apply { + add(0) + add(pubKey) + add(createdAt) + add(kind) + add( + factory.arrayNode(tags.size).apply { + tags.forEach { tag -> add( - factory.arrayNode(tags.size).apply { - tags.forEach { tag -> - add( - factory.arrayNode(tag.size).apply { - tag.forEach { add(it) } - } - ) - } - } + factory.arrayNode(tag.size).apply { tag.forEach { add(it) } }, ) - add(content) - } - - return mapper.writeValueAsString(rawEvent) + } + }, + ) + add(content) } - fun generateId(pubKey: HexKey, createdAt: Long, kind: Int, tags: Array>, content: String): ByteArray { - return CryptoUtils.sha256(makeJsonForId(pubKey, createdAt, kind, tags, content).toByteArray()) - } - - fun create(signer: NostrSigner, kind: Int, tags: Array> = emptyArray(), content: String = "", createdAt: Long = TimeUtils.now(), onReady: (Event) -> Unit) { - return signer.sign(createdAt, kind, tags, content, onReady) - } + return mapper.writeValueAsString(rawEvent) } + + fun generateId( + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): ByteArray { + return CryptoUtils.sha256(makeJsonForId(pubKey, createdAt, kind, tags, content).toByteArray()) + } + + fun create( + signer: NostrSigner, + kind: Int, + tags: Array> = emptyArray(), + content: String = "", + createdAt: Long = TimeUtils.now(), + onReady: (Event) -> Unit, + ) { + return signer.sign(createdAt, kind, tags, content, onReady) + } + } } @Immutable open class WrappedEvent( - id: HexKey, - @JsonProperty("pubkey") - pubKey: HexKey, - @JsonProperty("created_at") - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - sig: HexKey + id: HexKey, + @JsonProperty("pubkey") pubKey: HexKey, + @JsonProperty("created_at") createdAt: Long, + kind: Int, + tags: Array>, + content: String, + sig: HexKey, ) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - @Transient - var host: Event? = null // host event to broadcast when needed + @Transient var host: Event? = null // host event to broadcast when needed } @Immutable interface AddressableEvent { - fun dTag(): String - fun address(): ATag + fun dTag(): String + + fun address(): ATag } @Immutable open class BaseAddressableEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - sig: HexKey -): Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent { - override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: "" - override fun address() = ATag(kind, pubKey, dTag(), null) + id: HexKey, + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent { + override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: "" + + override fun address() = ATag(kind, pubKey, dTag(), null) } fun String.bytesUsedInMemory(): Int { - return (8 * ((((this.length) * 2) + 45) / 8)) + return (8 * ((((this.length) * 2) + 45) / 8)) } data class ZapSplitSetup( - val lnAddressOrPubKeyHex: String, - val relay: String?, - val weight: Double, - val isLnAddress: Boolean, -) \ No newline at end of file + val lnAddressOrPubKeyHex: String, + val relay: String?, + val weight: Double, + val isLnAddress: Boolean, +) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt index 08f8defd5..78ed0b92f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt @@ -1,92 +1,129 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events -import android.widget.VideoView import com.vitorpamplona.quartz.encoders.toHexKey class EventFactory { - companion object { - - fun create( - id: String, - pubKey: String, - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - sig: String - ) = when (kind) { - AdvertisedRelayListEvent.kind -> AdvertisedRelayListEvent(id, pubKey, createdAt, tags, content, sig) - AppDefinitionEvent.kind -> AppDefinitionEvent(id, pubKey, createdAt, tags, content, sig) - AppRecommendationEvent.kind -> AppRecommendationEvent(id, pubKey, createdAt, tags, content, sig) - AudioHeaderEvent.kind -> AudioHeaderEvent(id, pubKey, createdAt, tags, content, sig) - AudioTrackEvent.kind -> AudioTrackEvent(id, pubKey, createdAt, tags, content, sig) - BadgeAwardEvent.kind -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig) - BadgeDefinitionEvent.kind -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig) - BadgeProfilesEvent.kind -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig) - BookmarkListEvent.kind -> BookmarkListEvent(id, pubKey, createdAt, tags, content, sig) - CalendarDateSlotEvent.kind -> CalendarDateSlotEvent(id, pubKey, createdAt, tags, content, sig) - CalendarEvent.kind -> CalendarEvent(id, pubKey, createdAt, tags, content, sig) - CalendarTimeSlotEvent.kind -> CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig) - CalendarRSVPEvent.kind -> CalendarRSVPEvent(id, pubKey, createdAt, tags, content, sig) - ChannelCreateEvent.kind -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig) - ChannelHideMessageEvent.kind -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig) - ChannelMessageEvent.kind -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig) - ChannelMetadataEvent.kind -> ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig) - ChannelMuteUserEvent.kind -> ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig) - ChatMessageEvent.kind -> { - if (id.isBlank()) { - val newId = Event.generateId(pubKey, createdAt, kind, tags, content).toHexKey() - ChatMessageEvent( - newId, pubKey, createdAt, tags, content, sig) - } else { - ChatMessageEvent(id, pubKey, createdAt, tags, content, sig) - } - } - ClassifiedsEvent.kind -> ClassifiedsEvent(id, pubKey, createdAt, tags, content, sig) - CommunityDefinitionEvent.kind -> CommunityDefinitionEvent(id, pubKey, createdAt, tags, content, sig) - CommunityPostApprovalEvent.kind -> CommunityPostApprovalEvent(id, pubKey, createdAt, tags, content, sig) - ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig) - DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig) - EmojiPackEvent.kind -> EmojiPackEvent(id, pubKey, createdAt, tags, content, sig) - EmojiPackSelectionEvent.kind -> EmojiPackSelectionEvent(id, pubKey, createdAt, tags, content, sig) - - FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig) - FileServersEvent.kind -> FileServersEvent(id, pubKey, createdAt, tags, content, sig) - FileStorageEvent.kind -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig) - FileStorageHeaderEvent.kind -> FileStorageHeaderEvent(id, pubKey, createdAt, tags, content, sig) - GenericRepostEvent.kind -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig) - GiftWrapEvent.kind -> GiftWrapEvent(id, pubKey, createdAt, tags, content, sig) - GoalEvent.kind -> GoalEvent(id, pubKey, createdAt, tags, content, sig) - HighlightEvent.kind -> HighlightEvent(id, pubKey, createdAt, tags, content, sig) - HTTPAuthorizationEvent.kind -> HTTPAuthorizationEvent(id, pubKey, createdAt, tags, content, sig) - LiveActivitiesChatMessageEvent.kind -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig) - LiveActivitiesEvent.kind -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig) - LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) - LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig) - LnZapPaymentResponseEvent.kind -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig) - LnZapPrivateEvent.kind -> LnZapPrivateEvent(id, pubKey, createdAt, tags, content, sig) - LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig) - LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig) - MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig) - MuteListEvent.kind -> MuteListEvent(id, pubKey, createdAt, tags, content, sig) - NNSEvent.kind -> NNSEvent(id, pubKey, createdAt, tags, content, sig) - PeopleListEvent.kind -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig) - PinListEvent.kind -> PinListEvent(id, pubKey, createdAt, tags, content, sig) - PollNoteEvent.kind -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig) - PrivateDmEvent.kind -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig) - ReactionEvent.kind -> ReactionEvent(id, pubKey, createdAt, tags, content, sig) - RecommendRelayEvent.kind -> RecommendRelayEvent(id, pubKey, createdAt, tags, content, sig) - RelayAuthEvent.kind -> RelayAuthEvent(id, pubKey, createdAt, tags, content, sig) - RelaySetEvent.kind -> RelaySetEvent(id, pubKey, createdAt, tags, content, sig) - ReportEvent.kind -> ReportEvent(id, pubKey, createdAt, tags, content, sig) - RepostEvent.kind -> RepostEvent(id, pubKey, createdAt, tags, content, sig) - SealedGossipEvent.kind -> SealedGossipEvent(id, pubKey, createdAt, tags, content, sig) - StatusEvent.kind -> StatusEvent(id, pubKey, createdAt, tags, content, sig) - TextNoteEvent.kind -> TextNoteEvent(id, pubKey, createdAt, tags, content, sig) - VideoHorizontalEvent.kind -> VideoHorizontalEvent(id, pubKey, createdAt, tags, content, sig) - VideoVerticalEvent.kind -> VideoVerticalEvent(id, pubKey, createdAt, tags, content, sig) - VideoViewEvent.kind -> VideoViewEvent(id, pubKey, createdAt, tags, content, sig) - else -> Event(id, pubKey, createdAt, kind, tags, content, sig) + companion object { + fun create( + id: String, + pubKey: String, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + sig: String, + ) = + when (kind) { + AdvertisedRelayListEvent.KIND -> + AdvertisedRelayListEvent(id, pubKey, createdAt, tags, content, sig) + AppDefinitionEvent.KIND -> AppDefinitionEvent(id, pubKey, createdAt, tags, content, sig) + AppRecommendationEvent.KIND -> + AppRecommendationEvent(id, pubKey, createdAt, tags, content, sig) + AudioHeaderEvent.KIND -> AudioHeaderEvent(id, pubKey, createdAt, tags, content, sig) + AudioTrackEvent.KIND -> AudioTrackEvent(id, pubKey, createdAt, tags, content, sig) + BadgeAwardEvent.KIND -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig) + BadgeDefinitionEvent.KIND -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig) + BadgeProfilesEvent.KIND -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig) + BookmarkListEvent.KIND -> BookmarkListEvent(id, pubKey, createdAt, tags, content, sig) + CalendarDateSlotEvent.KIND -> + CalendarDateSlotEvent(id, pubKey, createdAt, tags, content, sig) + CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig) + CalendarTimeSlotEvent.KIND -> + CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig) + CalendarRSVPEvent.KIND -> CalendarRSVPEvent(id, pubKey, createdAt, tags, content, sig) + ChannelCreateEvent.KIND -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig) + ChannelHideMessageEvent.KIND -> + ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig) + ChannelMessageEvent.KIND -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig) + ChannelMetadataEvent.KIND -> ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig) + ChannelMuteUserEvent.KIND -> ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig) + ChatMessageEvent.KIND -> { + if (id.isBlank()) { + val newId = Event.generateId(pubKey, createdAt, kind, tags, content).toHexKey() + ChatMessageEvent( + newId, + pubKey, + createdAt, + tags, + content, + sig, + ) + } else { + ChatMessageEvent(id, pubKey, createdAt, tags, content, sig) + } } - } + ClassifiedsEvent.KIND -> ClassifiedsEvent(id, pubKey, createdAt, tags, content, sig) + CommunityDefinitionEvent.KIND -> + CommunityDefinitionEvent(id, pubKey, createdAt, tags, content, sig) + CommunityPostApprovalEvent.KIND -> + CommunityPostApprovalEvent(id, pubKey, createdAt, tags, content, sig) + ContactListEvent.KIND -> ContactListEvent(id, pubKey, createdAt, tags, content, sig) + DeletionEvent.KIND -> DeletionEvent(id, pubKey, createdAt, tags, content, sig) + EmojiPackEvent.KIND -> EmojiPackEvent(id, pubKey, createdAt, tags, content, sig) + EmojiPackSelectionEvent.KIND -> + EmojiPackSelectionEvent(id, pubKey, createdAt, tags, content, sig) + FileHeaderEvent.KIND -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig) + FileServersEvent.KIND -> FileServersEvent(id, pubKey, createdAt, tags, content, sig) + FileStorageEvent.KIND -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig) + FileStorageHeaderEvent.KIND -> + FileStorageHeaderEvent(id, pubKey, createdAt, tags, content, sig) + GenericRepostEvent.KIND -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig) + GiftWrapEvent.KIND -> GiftWrapEvent(id, pubKey, createdAt, tags, content, sig) + GoalEvent.KIND -> GoalEvent(id, pubKey, createdAt, tags, content, sig) + HighlightEvent.KIND -> HighlightEvent(id, pubKey, createdAt, tags, content, sig) + HTTPAuthorizationEvent.KIND -> + HTTPAuthorizationEvent(id, pubKey, createdAt, tags, content, sig) + LiveActivitiesChatMessageEvent.KIND -> + LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig) + LiveActivitiesEvent.KIND -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig) + LnZapEvent.KIND -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) + LnZapPaymentRequestEvent.KIND -> + LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig) + LnZapPaymentResponseEvent.KIND -> + LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig) + LnZapPrivateEvent.KIND -> LnZapPrivateEvent(id, pubKey, createdAt, tags, content, sig) + LnZapRequestEvent.KIND -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig) + LongTextNoteEvent.KIND -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig) + MetadataEvent.KIND -> MetadataEvent(id, pubKey, createdAt, tags, content, sig) + MuteListEvent.KIND -> MuteListEvent(id, pubKey, createdAt, tags, content, sig) + NNSEvent.KIND -> NNSEvent(id, pubKey, createdAt, tags, content, sig) + PeopleListEvent.KIND -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig) + PinListEvent.KIND -> PinListEvent(id, pubKey, createdAt, tags, content, sig) + PollNoteEvent.KIND -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig) + PrivateDmEvent.KIND -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig) + ReactionEvent.KIND -> ReactionEvent(id, pubKey, createdAt, tags, content, sig) + RecommendRelayEvent.KIND -> RecommendRelayEvent(id, pubKey, createdAt, tags, content, sig) + RelayAuthEvent.KIND -> RelayAuthEvent(id, pubKey, createdAt, tags, content, sig) + RelaySetEvent.KIND -> RelaySetEvent(id, pubKey, createdAt, tags, content, sig) + ReportEvent.KIND -> ReportEvent(id, pubKey, createdAt, tags, content, sig) + RepostEvent.KIND -> RepostEvent(id, pubKey, createdAt, tags, content, sig) + SealedGossipEvent.KIND -> SealedGossipEvent(id, pubKey, createdAt, tags, content, sig) + StatusEvent.KIND -> StatusEvent(id, pubKey, createdAt, tags, content, sig) + TextNoteEvent.KIND -> TextNoteEvent(id, pubKey, createdAt, tags, content, sig) + VideoHorizontalEvent.KIND -> VideoHorizontalEvent(id, pubKey, createdAt, tags, content, sig) + VideoVerticalEvent.KIND -> VideoVerticalEvent(id, pubKey, createdAt, tags, content, sig) + VideoViewEvent.KIND -> VideoViewEvent(id, pubKey, createdAt, tags, content, sig) + else -> Event(id, pubKey, createdAt, kind, tags, content, sig) + } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt index e5de40569..eb50526b1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable @@ -7,84 +27,115 @@ import java.math.BigDecimal @Immutable interface EventInterface { - fun countMemory(): Long + fun countMemory(): Long - fun id(): HexKey + fun id(): HexKey - fun pubKey(): HexKey + fun pubKey(): HexKey - fun createdAt(): Long + fun createdAt(): Long - fun kind(): Int + fun kind(): Int - fun tags(): Array> + fun tags(): Array> - fun content(): String + fun content(): String - fun sig(): HexKey + fun sig(): HexKey - fun toJson(): String + fun toJson(): String - fun checkSignature() + fun checkSignature() - fun hasValidSignature(): Boolean + fun hasValidSignature(): Boolean - fun isTagged(key: String, tag: String): Boolean - fun isAnyTagged(key: String, tags: Set): Boolean - fun isTaggedWord(word: String): Boolean + fun isTagged( + key: String, + tag: String, + ): Boolean - fun isTaggedUser(idHex: String): Boolean - fun isTaggedUsers(idHex: Set): Boolean + fun isAnyTagged( + key: String, + tags: Set, + ): Boolean - fun isTaggedEvent(idHex: String): Boolean + fun isTaggedWord(word: String): Boolean - fun isTaggedAddressableNote(idHex: String): Boolean - fun isTaggedAddressableNotes(idHexes: Set): Boolean + fun isTaggedUser(idHex: String): Boolean - fun isTaggedHash(hashtag: String): Boolean - fun isTaggedGeoHash(hashtag: String): Boolean + fun isTaggedUsers(idHex: Set): Boolean - fun isTaggedHashes(hashtags: Set): Boolean - fun isTaggedGeoHashes(hashtags: Set): Boolean + fun isTaggedEvent(idHex: String): Boolean - fun firstIsTaggedHashes(hashtags: Set): String? - fun firstIsTaggedAddressableNote(addressableNotes: Set): String? + fun isTaggedAddressableNote(idHex: String): Boolean - fun isTaggedAddressableKind(kind: Int): Boolean - fun getTagOfAddressableKind(kind: Int): ATag? + fun isTaggedAddressableNotes(idHexes: Set): Boolean - fun expiration(): Long? + fun isTaggedHash(hashtag: String): Boolean - fun hasHashtags(): Boolean - fun hasGeohashes(): Boolean + fun isTaggedGeoHash(hashtag: String): Boolean - fun hashtags(): List - fun geohashes(): List + fun isTaggedHashes(hashtags: Set): Boolean - fun getReward(): BigDecimal? - fun getPoWRank(): Int - fun getGeoHash(): String? + fun isTaggedGeoHashes(hashtags: Set): Boolean - fun zapSplitSetup(): List - fun isSensitive(): Boolean - fun subject(): String? - fun zapraiserAmount(): Long? + fun firstIsTaggedHashes(hashtags: Set): String? - fun hasAnyTaggedUser(): Boolean - fun hasTagWithContent(tagName: String): Boolean + fun firstIsTaggedAddressableNote(addressableNotes: Set): String? - fun taggedAddresses(): List - fun taggedUsers(): List - fun taggedEvents(): List - fun taggedUrls(): List + fun isTaggedAddressableKind(kind: Int): Boolean - fun firstTaggedAddress(): ATag? - fun firstTaggedUser(): HexKey? - fun firstTaggedEvent(): HexKey? - fun firstTaggedUrl(): String? + fun getTagOfAddressableKind(kind: Int): ATag? - fun taggedEmojis(): List - fun matchTag1With(text: String): Boolean - fun isExpired(): Boolean - fun hasZapSplitSetup(): Boolean + fun expiration(): Long? + + fun hasHashtags(): Boolean + + fun hasGeohashes(): Boolean + + fun hashtags(): List + + fun geohashes(): List + + fun getReward(): BigDecimal? + + fun getPoWRank(): Int + + fun getGeoHash(): String? + + fun zapSplitSetup(): List + + fun isSensitive(): Boolean + + fun subject(): String? + + fun zapraiserAmount(): Long? + + fun hasAnyTaggedUser(): Boolean + + fun hasTagWithContent(tagName: String): Boolean + + fun taggedAddresses(): List + + fun taggedUsers(): List + + fun taggedEvents(): List + + fun taggedUrls(): List + + fun firstTaggedAddress(): ATag? + + fun firstTaggedUser(): HexKey? + + fun firstTaggedEvent(): HexKey? + + fun firstTaggedUrl(): String? + + fun taggedEmojis(): List + + fun matchTag1With(text: String): Boolean + + fun isExpired(): Boolean + + fun hasZapSplitSetup(): Boolean } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt index 1a5214430..93a4d972a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt @@ -1,99 +1,125 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class FileHeaderEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + 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 url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1) - fun urls() = tags.filter { it.size > 1 && it[0] == URL }.map { it[1] } + fun urls() = tags.filter { it.size > 1 && it[0] == URL }.map { it[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 alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1) - fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.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) + fun encryptionKey() = + tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) } - fun hasUrl() = tags.any { it.size > 1 && it[0] == URL } + fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1) - companion object { - const val kind = 1063 - const val altDescription = "Verifiable file url" + fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1) - 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 DIMENSION = "dim" - private const val HASH = "x" - private const val MAGNET_URI = "magnet" - private const val TORRENT_INFOHASH = "i" - private const val BLUR_HASH = "blurhash" - private const val ORIGINAL_HASH = "ox" - private const val ALT = "alt" + fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1) - fun create( - url: String, - magnetUri: String? = null, - mimeType: String? = null, - alt: String? = null, - hash: String? = null, - size: String? = null, - dimensions: String? = null, - blurhash: String? = null, - originalHash: String? = null, - magnetURI: String? = null, - torrentInfoHash: String? = null, - encryptionKey: AESGCM? = null, - sensitiveContent: Boolean? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (FileHeaderEvent) -> Unit - ) { - val tags = listOfNotNull( - arrayOf(URL, url), - magnetUri?.let { arrayOf(MAGNET_URI, it) }, - mimeType?.let { arrayOf(MIME_TYPE, it) }, - alt?.ifBlank { null }?.let { arrayOf(ALT, it) } ?: arrayOf("alt", altDescription), - hash?.let { arrayOf(HASH, it) }, - size?.let { arrayOf(FILE_SIZE, it) }, - dimensions?.let { arrayOf(DIMENSION, it) }, - blurhash?.let { arrayOf(BLUR_HASH, it) }, - originalHash?.let { arrayOf(ORIGINAL_HASH, it) }, - magnetURI?.let { arrayOf(MAGNET_URI, it) }, + fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1) - torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) }, - encryptionKey?.let { arrayOf(ENCRYPTION_KEY, it.key, it.nonce) }, - sensitiveContent?.let { - if (it) { - arrayOf("content-warning", "") - } else { - null - } - } - ) + fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1) - val content = alt ?: "" - signer.sign(createdAt, kind, tags.toTypedArray(), content, onReady) - } + 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) + + fun hasUrl() = tags.any { it.size > 1 && it[0] == URL } + + companion object { + const val KIND = 1063 + const val ALT_DESCRIPTION = "Verifiable file url" + + 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 DIMENSION = "dim" + private const val HASH = "x" + private const val MAGNET_URI = "magnet" + private const val TORRENT_INFOHASH = "i" + private const val BLUR_HASH = "blurhash" + private const val ORIGINAL_HASH = "ox" + private const val ALT = "alt" + + fun create( + url: String, + magnetUri: String? = null, + mimeType: String? = null, + alt: String? = null, + hash: String? = null, + size: String? = null, + dimensions: String? = null, + blurhash: String? = null, + originalHash: String? = null, + magnetURI: String? = null, + torrentInfoHash: String? = null, + encryptionKey: AESGCM? = null, + sensitiveContent: Boolean? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (FileHeaderEvent) -> Unit, + ) { + val tags = + listOfNotNull( + arrayOf(URL, url), + magnetUri?.let { arrayOf(MAGNET_URI, it) }, + mimeType?.let { arrayOf(MIME_TYPE, it) }, + alt?.ifBlank { null }?.let { arrayOf(ALT, it) } ?: arrayOf("alt", ALT_DESCRIPTION), + hash?.let { arrayOf(HASH, it) }, + size?.let { arrayOf(FILE_SIZE, it) }, + dimensions?.let { arrayOf(DIMENSION, it) }, + blurhash?.let { arrayOf(BLUR_HASH, it) }, + originalHash?.let { arrayOf(ORIGINAL_HASH, it) }, + magnetURI?.let { arrayOf(MAGNET_URI, it) }, + torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) }, + encryptionKey?.let { arrayOf(ENCRYPTION_KEY, it.key, it.nonce) }, + sensitiveContent?.let { + if (it) { + arrayOf("content-warning", "") + } else { + null + } + }, + ) + + val content = alt ?: "" + signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) } + } } data class AESGCM(val key: String, val nonce: String) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileServersEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileServersEvent.kt index 0e6846a05..7a76c3189 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileServersEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileServersEvent.kt @@ -1,45 +1,59 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.crypto.KeyPair -import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class FileServersEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { - override fun dTag() = fixedDTag + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + override fun dTag() = FIXED_D_TAG - companion object { - const val kind = 10096 - const val fixedDTag = "" - const val alt = "File servers used by the author" + companion object { + const val KIND = 10096 + const val FIXED_D_TAG = "" + const val ALT = "File servers used by the author" - fun create( - listOfServers: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (FileServersEvent) -> Unit - ) { - val msg = "" - val tags = mutableListOf>() + fun create( + listOfServers: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (FileServersEvent) -> Unit, + ) { + val msg = "" + val tags = mutableListOf>() - listOfServers.forEach { - tags.add(arrayOf("server", it)) - } - tags.add(arrayOf("alt", alt)) + listOfServers.forEach { tags.add(arrayOf("server", it)) } + tags.add(arrayOf("alt", ALT)) - signer.sign(createdAt, kind, tags.toTypedArray(), msg, onReady) - } + signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageEvent.kt index 646d85680..8a7d515cc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageEvent.kt @@ -1,61 +1,81 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import android.util.Log import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils import java.util.Base64 @Immutable class FileStorageEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun type() = tags.firstOrNull { it.size > 1 && it[0] == TYPE }?.get(1) - fun type() = tags.firstOrNull { it.size > 1 && it[0] == TYPE }?.get(1) - fun decryptKey() = tags.firstOrNull { it.size > 2 && it[0] == DECRYPT }?.let { AESGCM(it[1], it[2]) } + fun decryptKey() = + tags.firstOrNull { it.size > 2 && it[0] == DECRYPT }?.let { AESGCM(it[1], it[2]) } - fun decode(): ByteArray? { - return try { - Base64.getDecoder().decode(content) - } catch (e: Exception) { - Log.e("FileStorageEvent", "Unable to decode base 64 ${e.message} $content") - null - } + fun decode(): ByteArray? { + return try { + Base64.getDecoder().decode(content) + } catch (e: Exception) { + Log.e("FileStorageEvent", "Unable to decode base 64 ${e.message} $content") + null + } + } + + companion object { + const val KIND = 1064 + const val ALT = "Binary data" + + private const val TYPE = "type" + private const val DECRYPT = "decrypt" + + fun encode(bytes: ByteArray): String { + return Base64.getEncoder().encodeToString(bytes) } - companion object { - const val kind = 1064 - const val alt = "Binary data" + fun create( + mimeType: String, + data: ByteArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (FileStorageEvent) -> Unit, + ) { + val tags = + listOfNotNull( + arrayOf(TYPE, mimeType), + arrayOf("alt", ALT), + ) - private const val TYPE = "type" - private const val DECRYPT = "decrypt" - - fun encode(bytes: ByteArray): String { - return Base64.getEncoder().encodeToString(bytes) - } - - fun create( - mimeType: String, - data: ByteArray, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (FileStorageEvent) -> Unit - ) { - val tags = listOfNotNull( - arrayOf(TYPE, mimeType), - arrayOf("alt", alt) - ) - - val content = encode(data) - signer.sign(createdAt, kind, tags.toTypedArray(), content, onReady) - } + val content = encode(data) + signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageHeaderEvent.kt index be079f93d..13057cce0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageHeaderEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageHeaderEvent.kt @@ -1,86 +1,113 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class FileStorageHeaderEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun dataEventId() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) - fun dataEventId() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) + fun encryptionKey() = + tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) } - 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 alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1) - fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.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) + fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1) - companion object { - const val kind = 1065 - const val altDescription = "Descriptors for a binary file" + fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1) - private const val ENCRYPTION_KEY = "aes-256-gcm" - private const val MIME_TYPE = "m" - private const val FILE_SIZE = "size" - private const val DIMENSION = "dim" - private const val HASH = "x" - private const val MAGNET_URI = "magnet" - private const val TORRENT_INFOHASH = "i" - private const val BLUR_HASH = "blurhash" - private const val ALT = "alt" + fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1) - fun create( - storageEvent: FileStorageEvent, - mimeType: String? = null, - alt: String? = null, - hash: String? = null, - size: String? = null, - dimensions: String? = null, - blurhash: String? = null, - magnetURI: String? = null, - torrentInfoHash: String? = null, - encryptionKey: AESGCM? = null, - sensitiveContent: Boolean? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (FileStorageHeaderEvent) -> Unit - ) { - val tags = listOfNotNull( - arrayOf("e", storageEvent.id), - mimeType?.let { arrayOf(MIME_TYPE, mimeType) }, - hash?.let { arrayOf(HASH, it) }, - alt?.let { arrayOf(ALT, it) } ?: arrayOf("alt", altDescription), - size?.let { arrayOf(FILE_SIZE, it) }, - dimensions?.let { arrayOf(DIMENSION, it) }, - blurhash?.let { arrayOf(BLUR_HASH, it) }, - magnetURI?.let { arrayOf(MAGNET_URI, it) }, - torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) }, - encryptionKey?.let { arrayOf(ENCRYPTION_KEY, it.key, it.nonce) }, - sensitiveContent?.let { - if (it) { - arrayOf("content-warning", "") - } else { - null - } - } - ) + fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1) - val content = alt ?: "" - signer.sign(createdAt, kind, tags.toTypedArray(), content, onReady) - } + fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.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 = 1065 + const val ALT_DESCRIPTION = "Descriptors for a binary file" + + private const val ENCRYPTION_KEY = "aes-256-gcm" + private const val MIME_TYPE = "m" + private const val FILE_SIZE = "size" + private const val DIMENSION = "dim" + private const val HASH = "x" + private const val MAGNET_URI = "magnet" + private const val TORRENT_INFOHASH = "i" + private const val BLUR_HASH = "blurhash" + private const val ALT = "alt" + + fun create( + storageEvent: FileStorageEvent, + mimeType: String? = null, + alt: String? = null, + hash: String? = null, + size: String? = null, + dimensions: String? = null, + blurhash: String? = null, + magnetURI: String? = null, + torrentInfoHash: String? = null, + encryptionKey: AESGCM? = null, + sensitiveContent: Boolean? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (FileStorageHeaderEvent) -> Unit, + ) { + val tags = + listOfNotNull( + arrayOf("e", storageEvent.id), + mimeType?.let { arrayOf(MIME_TYPE, mimeType) }, + hash?.let { arrayOf(HASH, it) }, + alt?.let { arrayOf(ALT, it) } ?: arrayOf("alt", ALT_DESCRIPTION), + size?.let { arrayOf(FILE_SIZE, it) }, + dimensions?.let { arrayOf(DIMENSION, it) }, + blurhash?.let { arrayOf(BLUR_HASH, it) }, + magnetURI?.let { arrayOf(MAGNET_URI, it) }, + torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) }, + encryptionKey?.let { arrayOf(ENCRYPTION_KEY, it.key, it.nonce) }, + sensitiveContent?.let { + if (it) { + arrayOf("content-warning", "") + } else { + null + } + }, + ) + + val content = alt ?: "" + signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GeneralListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GeneralListEvent.kt index ea2797d62..eff2fc154 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/GeneralListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GeneralListEvent.kt @@ -1,10 +1,28 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import android.util.Log import androidx.compose.runtime.Immutable import com.fasterxml.jackson.module.kotlin.readValue -import com.vitorpamplona.quartz.encoders.hexToByteArray -import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner @@ -13,153 +31,173 @@ import kotlinx.collections.immutable.toImmutableSet @Immutable abstract class GeneralListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - sig: HexKey + id: HexKey, + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { - @Transient - private var privateTagsCache: Array>? = null + @Transient private var privateTagsCache: Array>? = null - fun category() = dTag() - fun bookmarkedPosts() = taggedEvents() - fun bookmarkedPeople() = taggedUsers() + fun category() = dTag() - fun name() = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1) - fun title() = tags.firstOrNull { it.size > 1 && it[0] == "title" }?.get(1) - fun nameOrTitle() = name() ?: title() + fun bookmarkedPosts() = taggedEvents() - fun cachedPrivateTags(): Array>? { - return privateTagsCache + fun bookmarkedPeople() = taggedUsers() + + fun name() = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1) + + fun title() = tags.firstOrNull { it.size > 1 && it[0] == "title" }?.get(1) + + fun nameOrTitle() = name() ?: title() + + fun cachedPrivateTags(): Array>? { + return privateTagsCache + } + + fun filterTagList( + key: String, + privateTags: Array>?, + ): ImmutableSet { + val privateUserList = + privateTags?.let { it.filter { it.size > 1 && it[0] == key }.map { it[1] }.toSet() } + ?: emptySet() + val publicUserList = tags.filter { it.size > 1 && it[0] == key }.map { it[1] }.toSet() + + return (privateUserList + publicUserList).toImmutableSet() + } + + fun isTagged( + key: String, + tag: String, + isPrivate: Boolean, + signer: NostrSigner, + onReady: (Boolean) -> Unit, + ) { + return if (isPrivate) { + privateTagsOrEmpty(signer = signer) { + onReady( + it.any { it.size > 1 && it[0] == key && it[1] == tag }, + ) + } + } else { + onReady(isTagged(key, tag)) + } + } + + fun privateTags( + signer: NostrSigner, + onReady: (Array>) -> Unit, + ) { + if (content.isEmpty()) { + onReady(emptyArray()) + return } - fun filterTagList(key: String, privateTags: Array>?): ImmutableSet { - val privateUserList = privateTags?.let { - it.filter { it.size > 1 && it[0] == key }.map { it[1] }.toSet() - } ?: emptySet() - val publicUserList = tags.filter { it.size > 1 && it[0] == key }.map { it[1] }.toSet() - - return (privateUserList + publicUserList).toImmutableSet() + privateTagsCache?.let { + onReady(it) + return } - fun isTagged(key: String, tag: String, isPrivate: Boolean, signer: NostrSigner, onReady: (Boolean) -> Unit) { - return if (isPrivate) { - privateTagsOrEmpty(signer = signer) { - onReady( - it.any { it.size > 1 && it[0] == key && it[1] == tag } - ) - } - } else { - onReady(isTagged(key, tag)) - } + try { + signer.nip04Decrypt(content, pubKey) { + privateTagsCache = mapper.readValue>>(it) + privateTagsCache?.let { onReady(it) } + } + } catch (e: Throwable) { + Log.w("GeneralList", "Error parsing the JSON ${e.message}") + } + } + + fun privateTagsOrEmpty( + signer: NostrSigner, + onReady: (Array>) -> Unit, + ) { + privateTags(signer, onReady) + } + + fun privateTaggedUsers( + signer: NostrSigner, + onReady: (List) -> Unit, + ) = privateTags(signer) { onReady(filterUsers(it)) } + + fun privateHashtags( + signer: NostrSigner, + onReady: (List) -> Unit, + ) = privateTags(signer) { onReady(filterHashtags(it)) } + + fun privateGeohashes( + signer: NostrSigner, + onReady: (List) -> Unit, + ) = privateTags(signer) { onReady(filterGeohashes(it)) } + + fun privateTaggedEvents( + signer: NostrSigner, + onReady: (List) -> Unit, + ) = privateTags(signer) { onReady(filterEvents(it)) } + + fun privateTaggedAddresses( + signer: NostrSigner, + onReady: (List) -> Unit, + ) = privateTags(signer) { onReady(filterAddresses(it)) } + + fun filterUsers(tags: Array>): List { + return tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } + } + + fun filterHashtags(tags: Array>): List { + return tags.filter { it.size > 1 && it[0] == "t" }.map { it[1] } + } + + fun filterGeohashes(tags: Array>): List { + return tags.filter { it.size > 1 && it[0] == "g" }.map { it[1] } + } + + fun filterEvents(tags: Array>): List { + return tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } + } + + fun filterAddresses(tags: Array>): List { + return tags + .filter { it.firstOrNull() == "a" } + .mapNotNull { + val aTagValue = it.getOrNull(1) + val relay = it.getOrNull(2) + + if (aTagValue != null) ATag.parse(aTagValue, relay) else null + } + } + + companion object { + fun createPrivateTags( + privEvents: List? = null, + privUsers: List? = null, + privAddresses: List? = null, + signer: NostrSigner, + onReady: (String) -> Unit, + ) { + val privTags = mutableListOf>() + privEvents?.forEach { privTags.add(arrayOf("e", it)) } + privUsers?.forEach { privTags.add(arrayOf("p", it)) } + privAddresses?.forEach { privTags.add(arrayOf("a", it.toTag())) } + + return encryptTags(privTags.toTypedArray(), signer, onReady) } - fun privateTags(signer: NostrSigner, onReady: (Array>) -> Unit) { - if (content.isEmpty()) { - onReady(emptyArray()) - return - } + fun encryptTags( + privateTags: Array>? = null, + signer: NostrSigner, + onReady: (String) -> Unit, + ) { + val msg = mapper.writeValueAsString(privateTags) - privateTagsCache?.let { - onReady(it) - return - } - - try { - signer.nip04Decrypt(content, pubKey) { - privateTagsCache = mapper.readValue>>(it) - privateTagsCache?.let { - onReady(it) - } - } - } catch (e: Throwable) { - Log.w("GeneralList", "Error parsing the JSON ${e.message}") - } - } - - fun privateTagsOrEmpty(signer: NostrSigner, onReady: (Array>) -> Unit) { - privateTags(signer, onReady) - } - - fun privateTaggedUsers(signer: NostrSigner, onReady: (List) -> Unit) = privateTags(signer) { - onReady(filterUsers(it)) - } - fun privateHashtags(signer: NostrSigner, onReady: (List) -> Unit) = privateTags(signer) { - onReady(filterHashtags(it)) - } - fun privateGeohashes(signer: NostrSigner, onReady: (List) -> Unit) = privateTags(signer) { - onReady(filterGeohashes(it)) - } - fun privateTaggedEvents(signer: NostrSigner, onReady: (List) -> Unit) = privateTags(signer) { - onReady(filterEvents(it)) - } - fun privateTaggedAddresses(signer: NostrSigner, onReady: (List) -> Unit) = privateTags(signer) { - onReady(filterAddresses(it)) - } - - fun filterUsers(tags: Array>): List { - return tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } - } - - fun filterHashtags(tags: Array>): List { - return tags.filter { it.size > 1 && it[0] == "t" }.map { it[1] } - } - - fun filterGeohashes(tags: Array>): List { - return tags.filter { it.size > 1 && it[0] == "g" }.map { it[1] } - } - - fun filterEvents(tags: Array>): List { - return tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } - } - - fun filterAddresses(tags: Array>): List { - return tags.filter { it.firstOrNull() == "a" }.mapNotNull { - val aTagValue = it.getOrNull(1) - val relay = it.getOrNull(2) - - if (aTagValue != null) ATag.parse(aTagValue, relay) else null - } - } - - companion object { - fun createPrivateTags( - privEvents: List? = null, - privUsers: List? = null, - privAddresses: List? = null, - - signer: NostrSigner, - onReady: (String) -> Unit - ) { - val privTags = mutableListOf>() - privEvents?.forEach { - privTags.add(arrayOf("e", it)) - } - privUsers?.forEach { - privTags.add(arrayOf("p", it)) - } - privAddresses?.forEach { - privTags.add(arrayOf("a", it.toTag())) - } - - return encryptTags(privTags.toTypedArray(), signer, onReady) - } - - fun encryptTags( - privateTags: Array>? = null, - signer: NostrSigner, - onReady: (String) -> Unit - ) { - val msg = mapper.writeValueAsString(privateTags) - - signer.nip04Encrypt( - msg, - signer.pubKey, - onReady - ) - } + signer.nip04Encrypt( + msg, + signer.pubKey, + onReady, + ) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GenericRepostEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GenericRepostEvent.kt index 6815ee2a9..06baf8870 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/GenericRepostEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GenericRepostEvent.kt @@ -1,57 +1,76 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class GenericRepostEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun boostedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } - fun boostedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } - fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } + fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } - fun containedPost() = try { - fromJson(content) + fun containedPost() = + try { + fromJson(content) } catch (e: Exception) { - null + null } - companion object { - const val kind = 16 - const val alt = "Generic repost" + companion object { + const val KIND = 16 + const val ALT = "Generic repost" - fun create( - boostedPost: EventInterface, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (GenericRepostEvent) -> Unit - ) { - val content = boostedPost.toJson() + fun create( + boostedPost: EventInterface, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (GenericRepostEvent) -> Unit, + ) { + val content = boostedPost.toJson() - val tags = mutableListOf( - arrayOf("e", boostedPost.id()), - arrayOf("p", boostedPost.pubKey()) - ) + val tags = + mutableListOf( + arrayOf("e", boostedPost.id()), + arrayOf("p", boostedPost.pubKey()), + ) - if (boostedPost is AddressableEvent) { - tags.add(arrayOf("a", boostedPost.address().toTag())) - } + if (boostedPost is AddressableEvent) { + tags.add(arrayOf("a", boostedPost.address().toTag())) + } - tags.add(arrayOf("k", "${boostedPost.kind()}")) - tags.add(arrayOf("alt", alt)) + tags.add(arrayOf("k", "${boostedPost.kind()}")) + tags.add(arrayOf("alt", ALT)) - signer.sign(createdAt, kind, tags.toTypedArray(), content, onReady) - } + signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GiftWrapEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GiftWrapEvent.kt index daed2b25d..1e8875719 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/GiftWrapEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GiftWrapEvent.kt @@ -1,74 +1,100 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.signers.NostrSignerInternal +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class GiftWrapEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - @Transient - private var cachedInnerEvent: Map = mapOf() + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + @Transient private var cachedInnerEvent: Map = mapOf() - fun cachedGift(signer: NostrSigner, onReady: (Event) -> Unit) { - cachedInnerEvent[signer.pubKey]?.let { - onReady(it) - return - } - unwrap(signer) { gift -> - if (gift is WrappedEvent) { - gift.host = this - } - cachedInnerEvent = cachedInnerEvent + Pair(signer.pubKey, gift) - - onReady(gift) - } + fun cachedGift( + signer: NostrSigner, + onReady: (Event) -> Unit, + ) { + cachedInnerEvent[signer.pubKey]?.let { + onReady(it) + return } + unwrap(signer) { gift -> + if (gift is WrappedEvent) { + gift.host = this + } + cachedInnerEvent = cachedInnerEvent + Pair(signer.pubKey, gift) - private fun unwrap(signer: NostrSigner, onReady: (Event) -> Unit) { - try { - plainContent(signer) { - onReady(fromJson(it)) - } - } catch (e: Exception) { - // Log.e("UnwrapError", "Couldn't Decrypt the content", e) - } + onReady(gift) } + } - private fun plainContent(signer: NostrSigner, onReady: (String) -> Unit) { - if (content.isEmpty()) return - - signer.nip44Decrypt(content, pubKey, onReady) + private fun unwrap( + signer: NostrSigner, + onReady: (Event) -> Unit, + ) { + try { + plainContent(signer) { onReady(fromJson(it)) } + } catch (e: Exception) { + // Log.e("UnwrapError", "Couldn't Decrypt the content", e) } + } - fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1) + private fun plainContent( + signer: NostrSigner, + onReady: (String) -> Unit, + ) { + if (content.isEmpty()) return - companion object { - const val kind = 1059 - const val alt = "Encrypted event" + signer.nip44Decrypt(content, pubKey, onReady) + } - fun create( - event: Event, - recipientPubKey: HexKey, - createdAt: Long = TimeUtils.randomWithinAWeek(), - onReady: (GiftWrapEvent) -> Unit - ) { - val signer = NostrSignerInternal(KeyPair()) // GiftWrap is always a random key - val serializedContent = toJson(event) - val tags = arrayOf(arrayOf("p", recipientPubKey)) + fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1) - signer.nip44Encrypt(serializedContent, recipientPubKey) { - signer.sign(createdAt, kind, tags, it, onReady) - } - } + companion object { + const val KIND = 1059 + const val ALT = "Encrypted event" + + fun create( + event: Event, + recipientPubKey: HexKey, + createdAt: Long = TimeUtils.randomWithinAWeek(), + onReady: (GiftWrapEvent) -> Unit, + ) { + val signer = NostrSignerInternal(KeyPair()) // GiftWrap is always a random key + val serializedContent = toJson(event) + val tags = arrayOf(arrayOf("p", recipientPubKey)) + + signer.nip44Encrypt(serializedContent, recipientPubKey) { + signer.sign(createdAt, KIND, tags, it, onReady) + } } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GoalEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GoalEvent.kt index 799dd3a33..b81bbd09d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/GoalEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GoalEvent.kt @@ -1,62 +1,80 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class GoalEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { - companion object { - const val kind = 9041 - const val alt = "Zap Goal" + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 9041 + const val ALT = "Zap Goal" - private const val SUMMARY = "summary" - private const val CLOSED_AT = "closed_at" - private const val IMAGE = "image" - private const val AMOUNT = "amount" + private const val SUMMARY = "summary" + private const val CLOSED_AT = "closed_at" + private const val IMAGE = "image" + private const val AMOUNT = "amount" - fun create( - description: String, - amount: Long, - relays: Set, - closedAt: Long? = null, - image: String? = null, - summary: String? = null, - websiteUrl: String? = null, - linkedEvent: Event? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (GoalEvent) -> Unit - ) { - val tags = mutableListOf( - arrayOf(AMOUNT, amount.toString()), - arrayOf("relays") + relays, - arrayOf("alt", alt) - ) + fun create( + description: String, + amount: Long, + relays: Set, + closedAt: Long? = null, + image: String? = null, + summary: String? = null, + websiteUrl: String? = null, + linkedEvent: Event? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (GoalEvent) -> Unit, + ) { + val tags = + mutableListOf( + arrayOf(AMOUNT, amount.toString()), + arrayOf("relays") + relays, + arrayOf("alt", ALT), + ) - if (linkedEvent is AddressableEvent) { - tags.add(arrayOf("a", linkedEvent.address().toTag())) - } else if (linkedEvent is Event) { - tags.add(arrayOf("e", linkedEvent.id)) - } + if (linkedEvent is AddressableEvent) { + tags.add(arrayOf("a", linkedEvent.address().toTag())) + } else if (linkedEvent is Event) { + tags.add(arrayOf("e", linkedEvent.id)) + } - closedAt?.let { tags.add(arrayOf(CLOSED_AT, it.toString())) } - summary?.let { tags.add(arrayOf(SUMMARY, it)) } - image?.let { tags.add(arrayOf(IMAGE, it)) } - websiteUrl?.let { tags.add(arrayOf("r", it)) } + closedAt?.let { tags.add(arrayOf(CLOSED_AT, it.toString())) } + summary?.let { tags.add(arrayOf(SUMMARY, it)) } + image?.let { tags.add(arrayOf(IMAGE, it)) } + websiteUrl?.let { tags.add(arrayOf("r", it)) } - signer.sign(createdAt, kind, tags.toTypedArray(), description, onReady) - } + signer.sign(createdAt, KIND, tags.toTypedArray(), description, onReady) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/HTTPAuthorizationEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/HTTPAuthorizationEvent.kt index cfc3f5312..c976a287f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/HTTPAuthorizationEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/HTTPAuthorizationEvent.kt @@ -1,46 +1,63 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class HTTPAuthorizationEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 27235 - companion object { - const val kind = 27235 + fun create( + url: String, + method: String, + file: ByteArray? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (HTTPAuthorizationEvent) -> Unit, + ) { + var hash = "" + file?.let { hash = CryptoUtils.sha256(file).toHexKey() } - fun create( - url: String, - method: String, - file: ByteArray? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (HTTPAuthorizationEvent) -> Unit - ) { - var hash = "" - file?.let { - hash = CryptoUtils.sha256(file).toHexKey() - } + val tags = + listOfNotNull( + arrayOf("u", url), + arrayOf("method", method), + arrayOf("payload", hash), + ) - val tags = listOfNotNull( - arrayOf("u", url), - arrayOf("method", method), - arrayOf("payload", hash) - ) - - signer.sign(createdAt, kind, tags.toTypedArray(), "", onReady) - } + signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/HighlightEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/HighlightEvent.kt index 3e29e5cfa..bed441749 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/HighlightEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/HighlightEvent.kt @@ -1,39 +1,58 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class HighlightEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) { + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseTextNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun inUrl() = taggedUrls().firstOrNull() - fun inUrl() = taggedUrls().firstOrNull() - fun author() = taggedUsers().firstOrNull() - fun quote() = content + fun author() = taggedUsers().firstOrNull() - fun inPost() = taggedAddresses().firstOrNull() + fun quote() = content - companion object { - const val kind = 9802 - const val alt = "Highlight/quote event" + fun inPost() = taggedAddresses().firstOrNull() - fun create( - msg: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (HighlightEvent) -> Unit - ) { - signer.sign(createdAt, kind, arrayOf(arrayOf("alt", alt)), msg, onReady) - } + companion object { + const val KIND = 9802 + const val ALT = "Highlight/quote event" + + fun create( + msg: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (HighlightEvent) -> Unit, + ) { + signer.sign(createdAt, KIND, arrayOf(arrayOf("alt", ALT)), msg, onReady) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt index b52c62ddd..8b5ade013 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt @@ -1,95 +1,101 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class LiveActivitiesChatMessageEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) { + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseTextNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + private fun innerActivity() = + tags.firstOrNull { it.size > 3 && it[0] == "a" && it[3] == "root" } + ?: tags.firstOrNull { it.size > 1 && it[0] == "a" } - private fun innerActivity() = tags.firstOrNull { - it.size > 3 && it[0] == "a" && it[3] == "root" - } ?: tags.firstOrNull { - it.size > 1 && it[0] == "a" + private fun activityHex() = innerActivity()?.let { it.getOrNull(1) } + + fun activity() = + innerActivity()?.let { + if (it.size > 1) { + val aTagValue = it[1] + val relay = it.getOrNull(2) + + ATag.parse(aTagValue, relay) + } else { + null + } } - private fun activityHex() = innerActivity()?.let { - it.getOrNull(1) - } + override fun replyTos() = taggedEvents().minus(activityHex() ?: "") - fun activity() = innerActivity()?.let { - if (it.size > 1) { - val aTagValue = it[1] - val relay = it.getOrNull(2) + companion object { + const val KIND = 1311 + const val ALT = "Live activity chat message" - ATag.parse(aTagValue, relay) - } else { - null - } - } - - override fun replyTos() = taggedEvents().minus(activityHex() ?: "") - - companion object { - const val kind = 1311 - const val alt = "Live activity chat message" - - fun create( - message: String, - activity: ATag, - replyTos: List? = null, - mentions: List? = null, - zapReceiver: List? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - markAsSensitive: Boolean, - zapRaiserAmount: Long?, - geohash: String? = null, - nip94attachments: List? = null, - onReady: (LiveActivitiesChatMessageEvent) -> Unit - ) { - val content = message - val tags = mutableListOf( - arrayOf("a", activity.toTag(), "", "root") - ) - replyTos?.forEach { - tags.add(arrayOf("e", it)) - } - mentions?.forEach { - tags.add(arrayOf("p", it)) - } - zapReceiver?.forEach { - tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) - } - if (markAsSensitive) { - tags.add(arrayOf("content-warning", "")) - } - zapRaiserAmount?.let { - tags.add(arrayOf("zapraiser", "$it")) - } - geohash?.let { - tags.addAll(geohashMipMap(it)) - } - nip94attachments?.let { - it.forEach { - //tags.add(arrayOf("nip94", it.toJson())) - } - } - tags.add(arrayOf("alt", alt)) - - signer.sign(createdAt, kind, tags.toTypedArray(), content, onReady) + fun create( + message: String, + activity: ATag, + replyTos: List? = null, + mentions: List? = null, + zapReceiver: List? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + markAsSensitive: Boolean, + zapRaiserAmount: Long?, + geohash: String? = null, + nip94attachments: List? = null, + onReady: (LiveActivitiesChatMessageEvent) -> Unit, + ) { + val content = message + val tags = + mutableListOf( + arrayOf("a", activity.toTag(), "", "root"), + ) + replyTos?.forEach { tags.add(arrayOf("e", it)) } + mentions?.forEach { tags.add(arrayOf("p", it)) } + zapReceiver?.forEach { + tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) + } + if (markAsSensitive) { + tags.add(arrayOf("content-warning", "")) + } + zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } + geohash?.let { tags.addAll(geohashMipMap(it)) } + nip94attachments?.let { + it.forEach { + // tags.add(arrayOf("nip94", it.toJson())) } + } + tags.add(arrayOf("alt", ALT)) + + signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesEvent.kt index 7d533b6b3..d0d0035de 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesEvent.kt @@ -1,68 +1,97 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class LiveActivitiesEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { - fun title() = tags.firstOrNull { it.size > 1 && it[0] == "title" }?.get(1) - fun summary() = tags.firstOrNull { it.size > 1 && it[0] == "summary" }?.get(1) - fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1) - fun streaming() = tags.firstOrNull { it.size > 1 && it[0] == "streaming" }?.get(1) - fun starts() = tags.firstOrNull { it.size > 1 && it[0] == "starts" }?.get(1)?.toLongOrNull() - fun ends() = tags.firstOrNull { it.size > 1 && it[0] == "ends" }?.get(1) - fun status() = checkStatus(tags.firstOrNull { it.size > 1 && it[0] == "status" }?.get(1)) - fun currentParticipants() = tags.firstOrNull { it.size > 1 && it[0] == "current_participants" }?.get(1) - fun totalParticipants() = tags.firstOrNull { it.size > 1 && it[0] == "total_participants" }?.get(1) + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun title() = tags.firstOrNull { it.size > 1 && it[0] == "title" }?.get(1) - fun participants() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(3)) } + fun summary() = tags.firstOrNull { it.size > 1 && it[0] == "summary" }?.get(1) - fun hasHost() = tags.any { it.size > 3 && it[0] == "p" && it[3].equals("Host", true) } + fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1) - fun host() = tags.firstOrNull { it.size > 3 && it[0] == "p" && it[3].equals("Host", true) }?.get(1) + fun streaming() = tags.firstOrNull { it.size > 1 && it[0] == "streaming" }?.get(1) - fun hosts() = tags.filter { it.size > 3 && it[0] == "p" && it[3].equals("Host", true) }.map { it[1] } + fun starts() = tags.firstOrNull { it.size > 1 && it[0] == "starts" }?.get(1)?.toLongOrNull() + fun ends() = tags.firstOrNull { it.size > 1 && it[0] == "ends" }?.get(1) - fun checkStatus(eventStatus: String?): String? { - return if (eventStatus == STATUS_LIVE && createdAt < TimeUtils.eightHoursAgo()) { - STATUS_ENDED - } else { - eventStatus - } + fun status() = checkStatus(tags.firstOrNull { it.size > 1 && it[0] == "status" }?.get(1)) + + fun currentParticipants() = + tags.firstOrNull { it.size > 1 && it[0] == "current_participants" }?.get(1) + + fun totalParticipants() = + tags.firstOrNull { it.size > 1 && it[0] == "total_participants" }?.get(1) + + fun participants() = + tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(3)) } + + fun hasHost() = tags.any { it.size > 3 && it[0] == "p" && it[3].equals("Host", true) } + + fun host() = + tags.firstOrNull { it.size > 3 && it[0] == "p" && it[3].equals("Host", true) }?.get(1) + + fun hosts() = + tags.filter { it.size > 3 && it[0] == "p" && it[3].equals("Host", true) }.map { it[1] } + + fun checkStatus(eventStatus: String?): String? { + return if (eventStatus == STATUS_LIVE && createdAt < TimeUtils.eightHoursAgo()) { + STATUS_ENDED + } else { + eventStatus } + } - fun participantsIntersect(keySet: Set): Boolean { - return tags.any { it.size > 1 && it[0] == "p" && it[1] in keySet } - } - - companion object { - const val kind = 30311 - const val alt = "Live activity event" - - const val STATUS_LIVE = "live" - const val STATUS_PLANNED = "planned" - const val STATUS_ENDED = "ended" - - fun create( - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (LiveActivitiesEvent) -> Unit - ) { - val tags = arrayOf(arrayOf("alt", alt)) - signer.sign(createdAt, kind, tags, "", onReady) - } + fun participantsIntersect(keySet: Set): Boolean { + return tags.any { it.size > 1 && it[0] == "p" && it[1] in keySet } + } + + companion object { + const val KIND = 30311 + const val ALT = "Live activity event" + + const val STATUS_LIVE = "live" + const val STATUS_PLANNED = "planned" + const val STATUS_ENDED = "ended" + + fun create( + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (LiveActivitiesEvent) -> Unit, + ) { + val tags = arrayOf(arrayOf("alt", ALT)) + signer.sign(createdAt, KIND, tags, "", onReady) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapEvent.kt index ae8dc8266..8d57ed0e7 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapEvent.kt @@ -1,75 +1,97 @@ -package com.vitorpamplona.quartz.events - -import android.util.Log -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.encoders.HexKey -import com.vitorpamplona.quartz.encoders.LnInvoiceUtil - -@Immutable -class LnZapEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : LnZapEventInterface, Event(id, pubKey, createdAt, kind, tags, content, sig) { - // This event is also kept in LocalCache (same object) - @Transient val zapRequest: LnZapRequestEvent? - override fun containedPost(): LnZapRequestEvent? = try { - description()?.ifBlank { null }?.let { - fromJson(it) - } as? LnZapRequestEvent - } catch (e: Exception) { - Log.w("LnZapEvent", "Failed to Parse Contained Post ${description()} in event ${id}", e) - null - } - - init { - zapRequest = containedPost() - } - - override fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } - - override fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } - - override fun zappedPollOption(): Int? = try { - zapRequest?.tags?.firstOrNull { it.size > 1 && it[0] == POLL_OPTION }?.get(1)?.toInt() - } catch (e: Exception) { - Log.e("LnZapEvent", "ZappedPollOption failed to parse", e) - null - } - - override fun zappedRequestAuthor(): String? = zapRequest?.pubKey() - - override fun amount() = amount - - // Keeps this as a field because it's a heavier function used everywhere. - val amount by lazy { - try { - lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) } - } catch (e: Exception) { - Log.e("LnZapEvent", "Failed to Parse LnInvoice ${lnInvoice()}", e) - null - } - } - override fun content(): String { - return content - } - - fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1) - - private fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1) - - companion object { - const val kind = 9735 - const val alt = "Zap event" - } - - enum class ZapType() { - PUBLIC, - PRIVATE, - ANONYMOUS, - NONZAP - } -} +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.events + +import android.util.Log +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.LnInvoiceUtil + +@Immutable +class LnZapEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : LnZapEventInterface, Event(id, pubKey, createdAt, KIND, tags, content, sig) { + // This event is also kept in LocalCache (same object) + @Transient val zapRequest: LnZapRequestEvent? + + override fun containedPost(): LnZapRequestEvent? = + try { + description()?.ifBlank { null }?.let { fromJson(it) } as? LnZapRequestEvent + } catch (e: Exception) { + Log.w("LnZapEvent", "Failed to Parse Contained Post ${description()} in event $id", e) + null + } + + init { + zapRequest = containedPost() + } + + override fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } + + override fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } + + override fun zappedPollOption(): Int? = + try { + zapRequest?.tags?.firstOrNull { it.size > 1 && it[0] == POLL_OPTION }?.get(1)?.toInt() + } catch (e: Exception) { + Log.e("LnZapEvent", "ZappedPollOption failed to parse", e) + null + } + + override fun zappedRequestAuthor(): String? = zapRequest?.pubKey() + + override fun amount() = amount + + // Keeps this as a field because it's a heavier function used everywhere. + val amount by lazy { + try { + lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) } + } catch (e: Exception) { + Log.e("LnZapEvent", "Failed to Parse LnInvoice ${lnInvoice()}", e) + null + } + } + + override fun content(): String { + return content + } + + fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1) + + private fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1) + + companion object { + const val KIND = 9735 + const val ALT = "Zap event" + } + + enum class ZapType() { + PUBLIC, + PRIVATE, + ANONYMOUS, + NONZAP, + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapEventInterface.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapEventInterface.kt index e65ac6946..92e4ab78e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapEventInterface.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapEventInterface.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable @@ -5,16 +25,15 @@ import java.math.BigDecimal @Immutable interface LnZapEventInterface : EventInterface { + fun zappedPost(): List - fun zappedPost(): List + fun zappedPollOption(): Int? - fun zappedPollOption(): Int? + fun zappedAuthor(): List - fun zappedAuthor(): List + fun zappedRequestAuthor(): String? - fun zappedRequestAuthor(): String? + fun amount(): BigDecimal? - fun amount(): BigDecimal? - - fun containedPost(): Event? + fun containedPost(): Event? } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPaymentRequestEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPaymentRequestEvent.kt index 4eeb6ab50..914fa7b35 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPaymentRequestEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPaymentRequestEvent.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import android.util.Log @@ -6,77 +26,73 @@ import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.deser.std.StdDeserializer -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.hexToByteArray -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class LnZapPaymentRequestEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + // Once one of an app user decrypts the payment, all users else can see it. + @Transient private var lnInvoice: String? = null - // Once one of an app user decrypts the payment, all users else can see it. - @Transient - private var lnInvoice: String? = null + fun walletServicePubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1) - fun walletServicePubKey() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1) + fun talkingWith(oneSideHex: String): HexKey { + return if (pubKey == oneSideHex) walletServicePubKey() ?: pubKey else pubKey + } - fun talkingWith(oneSideHex: String): HexKey { - return if (pubKey == oneSideHex) walletServicePubKey() ?: pubKey else pubKey + fun lnInvoice( + signer: NostrSigner, + onReady: (String) -> Unit, + ) { + lnInvoice?.let { + onReady(it) + return } - fun lnInvoice(signer: NostrSigner, onReady: (String) -> Unit) { - lnInvoice?.let { - onReady(it) - return - } + try { + signer.nip04Decrypt(content, talkingWith(signer.pubKey)) { jsonText -> + val payInvoiceMethod = mapper.readValue(jsonText, Request::class.java) - try { - signer.nip04Decrypt(content, talkingWith(signer.pubKey)) { jsonText -> - val payInvoiceMethod = mapper.readValue(jsonText, Request::class.java) + lnInvoice = (payInvoiceMethod as? PayInvoiceMethod)?.params?.invoice - lnInvoice = (payInvoiceMethod as? PayInvoiceMethod)?.params?.invoice - - lnInvoice?.let { - onReady(it) - } - } - } catch (e: Exception) { - Log.w("BookmarkList", "Error decrypting the message ${e.message}") - } + lnInvoice?.let { onReady(it) } + } + } catch (e: Exception) { + Log.w("BookmarkList", "Error decrypting the message ${e.message}") } + } - companion object { - const val kind = 23194 - const val alt = "Zap payment request" + companion object { + const val KIND = 23194 + const val ALT = "Zap payment request" - fun create( - lnInvoice: String, - walletServicePubkey: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (LnZapPaymentRequestEvent) -> Unit - ) { - val serializedRequest = mapper.writeValueAsString(PayInvoiceMethod.create(lnInvoice)) + fun create( + lnInvoice: String, + walletServicePubkey: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (LnZapPaymentRequestEvent) -> Unit, + ) { + val serializedRequest = mapper.writeValueAsString(PayInvoiceMethod.create(lnInvoice)) - val tags = arrayOf(arrayOf("p", walletServicePubkey), arrayOf("alt", alt)) + val tags = arrayOf(arrayOf("p", walletServicePubkey), arrayOf("alt", ALT)) - signer.nip04Encrypt( - serializedRequest, - walletServicePubkey - ) { content -> - signer.sign(createdAt, kind, tags, content, onReady) - } - } + signer.nip04Encrypt( + serializedRequest, + walletServicePubkey, + ) { content -> + signer.sign(createdAt, KIND, tags, content, onReady) + } } + } } // REQUEST OBJECTS @@ -87,23 +103,24 @@ abstract class Request(var method: String? = null) class PayInvoiceParams(var invoice: String? = null) class PayInvoiceMethod(var params: PayInvoiceParams? = null) : Request("pay_invoice") { - - companion object { - fun create(bolt11: String): PayInvoiceMethod { - return PayInvoiceMethod(PayInvoiceParams(bolt11)) - } + companion object { + fun create(bolt11: String): PayInvoiceMethod { + return PayInvoiceMethod(PayInvoiceParams(bolt11)) } + } } - class RequestDeserializer : StdDeserializer(Request::class.java) { - override fun deserialize(jp: JsonParser, ctxt: DeserializationContext): Request? { - val jsonObject: JsonNode = jp.codec.readTree(jp) - val method = jsonObject.get("method")?.asText() + override fun deserialize( + jp: JsonParser, + ctxt: DeserializationContext, + ): Request? { + val jsonObject: JsonNode = jp.codec.readTree(jp) + val method = jsonObject.get("method")?.asText() - if (method == "pay_invoice") { - return jp.codec.treeToValue(jsonObject, PayInvoiceMethod::class.java) - } - return null + if (method == "pay_invoice") { + return jp.codec.treeToValue(jsonObject, PayInvoiceMethod::class.java) } + return null + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPaymentResponseEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPaymentResponseEvent.kt index ed28ddb26..c22ee2054 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPaymentResponseEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPaymentResponseEvent.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import android.util.Log @@ -7,127 +27,136 @@ import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.deser.std.StdDeserializer -import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner @Immutable class LnZapPaymentResponseEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - // Once one of an app user decrypts the payment, all users else can see it. - @Transient - private var response: Response? = null + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + // Once one of an app user decrypts the payment, all users else can see it. + @Transient private var response: Response? = null - fun requestAuthor() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1) - fun requestId() = tags.firstOrNull() { it.size > 1 && it[0] == "e" }?.get(1) + fun requestAuthor() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1) - fun talkingWith(oneSideHex: String): HexKey { - return if (pubKey == oneSideHex) requestAuthor() ?: pubKey else pubKey + fun requestId() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) + + fun talkingWith(oneSideHex: String): HexKey { + return if (pubKey == oneSideHex) requestAuthor() ?: pubKey else pubKey + } + + private fun plainContent( + signer: NostrSigner, + onReady: (String) -> Unit, + ) { + try { + signer.nip04Decrypt(content, talkingWith(signer.pubKey)) { content -> onReady(content) } + } catch (e: Exception) { + Log.w("PrivateDM", "Error decrypting the message ${e.message}") + } + } + + fun response( + signer: NostrSigner, + onReady: (Response) -> Unit, + ) { + response?.let { + onReady(it) + return } - private fun plainContent(signer: NostrSigner, onReady: (String) -> Unit) { - try { - signer.nip04Decrypt(content, talkingWith(signer.pubKey)) { content -> - onReady(content) - } - } catch (e: Exception) { - Log.w("PrivateDM", "Error decrypting the message ${e.message}") - } - } - - fun response(signer: NostrSigner, onReady: (Response) -> Unit) { - response?.let { + try { + if (content.isNotEmpty()) { + plainContent(signer) { + mapper.readValue(it, Response::class.java)?.let { + response = it onReady(it) - return - } - - try { - if (content.isNotEmpty()) { - plainContent(signer) { - mapper.readValue(it, Response::class.java)?.let { - response = it - onReady(it) - } - } - } - } catch (e: Exception) { - Log.w("LnZapPaymentResponseEvent", "Can't parse content as a payment response: $content", e) + } } + } + } catch (e: Exception) { + Log.w("LnZapPaymentResponseEvent", "Can't parse content as a payment response: $content", e) } + } - companion object { - const val kind = 23195 - const val alt = "Zap payment response" - } + companion object { + const val KIND = 23195 + const val ALT = "Zap payment response" + } } // RESPONSE OBJECTS abstract class Response( - @JsonProperty("result_type") - val resultType: String + @JsonProperty("result_type") val resultType: String, ) // PayInvoice Call class PayInvoiceSuccessResponse(val result: PayInvoiceResultParams? = null) : - Response("pay_invoice") { - class PayInvoiceResultParams(val preimage: String) + Response("pay_invoice") { + class PayInvoiceResultParams(val preimage: String) } -class PayInvoiceErrorResponse(val error: PayInvoiceErrorParams? = null) : - Response("pay_invoice") { - class PayInvoiceErrorParams(val code: ErrorType?, val message: String?) +class PayInvoiceErrorResponse(val error: PayInvoiceErrorParams? = null) : Response("pay_invoice") { + class PayInvoiceErrorParams(val code: ErrorType?, val message: String?) - enum class ErrorType { - @JsonProperty(value = "RATE_LIMITED") - RATE_LIMITED, // The client is sending commands too fast. It should retry in a few seconds. - @JsonProperty(value = "NOT_IMPLEMENTED") - NOT_IMPLEMENTED, // The command is not known or is intentionally not implemented. - @JsonProperty(value = "INSUFFICIENT_BALANCE") - INSUFFICIENT_BALANCE, // The wallet does not have enough funds to cover a fee reserve or the payment amount. - @JsonProperty(value = "QUOTA_EXCEEDED") - QUOTA_EXCEEDED, // The wallet has exceeded its spending quota. - @JsonProperty(value = "RESTRICTED") - RESTRICTED, // This public key is not allowed to do this operation. - @JsonProperty(value = "UNAUTHORIZED") - UNAUTHORIZED, // This public key has no wallet connected. - @JsonProperty(value = "INTERNAL") - INTERNAL, // An internal error. - @JsonProperty(value = "OTHER") - OTHER // Other error. - } + enum class ErrorType { + @JsonProperty(value = "RATE_LIMITED") RATE_LIMITED, + + // The client is sending commands too fast. It should retry in a few seconds. + @JsonProperty(value = "NOT_IMPLEMENTED") NOT_IMPLEMENTED, + + // The command is not known or is intentionally not implemented. + @JsonProperty(value = "INSUFFICIENT_BALANCE") INSUFFICIENT_BALANCE, + + // The wallet does not have enough funds to cover a fee reserve or the payment amount. + @JsonProperty(value = "QUOTA_EXCEEDED") QUOTA_EXCEEDED, + + // The wallet has exceeded its spending quota. + @JsonProperty(value = "RESTRICTED") RESTRICTED, + + // This public key is not allowed to do this operation. + @JsonProperty(value = "UNAUTHORIZED") UNAUTHORIZED, + + // This public key has no wallet connected. + @JsonProperty(value = "INTERNAL") INTERNAL, + + // An internal error. + @JsonProperty(value = "OTHER") OTHER, // Other error. + } } - class ResponseDeserializer : StdDeserializer(Response::class.java) { - override fun deserialize(jp: JsonParser, ctxt: DeserializationContext): Response? { - val jsonObject: JsonNode = jp.codec.readTree(jp) - val resultType = jsonObject.get("result_type")?.asText() + override fun deserialize( + jp: JsonParser, + ctxt: DeserializationContext, + ): Response? { + val jsonObject: JsonNode = jp.codec.readTree(jp) + val resultType = jsonObject.get("result_type")?.asText() - if (resultType == "pay_invoice") { - val result = jsonObject.get("result") - val error = jsonObject.get("error") - if (result != null) { - return jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java) - } - if (error != null) { - return jp.codec.treeToValue(jsonObject, PayInvoiceErrorResponse::class.java) - } - } else { - // tries to guess - if (jsonObject.get("result")?.get("preimage") != null) { - return jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java) - } - if (jsonObject.get("error")?.get("code") != null) { - return jp.codec.treeToValue(jsonObject, PayInvoiceErrorResponse::class.java) - } - } - return null + if (resultType == "pay_invoice") { + val result = jsonObject.get("result") + val error = jsonObject.get("error") + if (result != null) { + return jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java) + } + if (error != null) { + return jp.codec.treeToValue(jsonObject, PayInvoiceErrorResponse::class.java) + } + } else { + // tries to guess + if (jsonObject.get("result")?.get("preimage") != null) { + return jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java) + } + if (jsonObject.get("error")?.get("code") != null) { + return jp.codec.treeToValue(jsonObject, PayInvoiceErrorResponse::class.java) + } } -} \ No newline at end of file + return null + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPrivateEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPrivateEvent.kt index a3600583a..e0a9c81b8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPrivateEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPrivateEvent.kt @@ -1,43 +1,51 @@ -package com.vitorpamplona.quartz.events - -import android.util.Log -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.encoders.Bech32 -import com.vitorpamplona.quartz.encoders.HexKey -import com.vitorpamplona.quartz.encoders.LnInvoiceUtil -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.signers.NostrSigner -import com.vitorpamplona.quartz.utils.TimeUtils -import java.nio.charset.Charset -import java.security.SecureRandom -import javax.crypto.BadPaddingException -import javax.crypto.Cipher -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec - -@Immutable -class LnZapPrivateEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - - companion object { - const val kind = 9733 - const val alt = "Private zap" - - fun create( - signer: NostrSigner, - tags: Array> = emptyArray(), - content: String = "", - createdAt: Long = TimeUtils.now(), - onReady: (LnZapPrivateEvent) -> Unit - ) { - signer.sign(createdAt, kind, tags, content, onReady) - } - } -} +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class LnZapPrivateEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 9733 + const val ALT = "Private zap" + + fun create( + signer: NostrSigner, + tags: Array> = emptyArray(), + content: String = "", + createdAt: Long = TimeUtils.now(), + onReady: (LnZapPrivateEvent) -> Unit, + ) { + signer.sign(createdAt, KIND, tags, content, onReady) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapRequestEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapRequestEvent.kt index 83d494182..10f00c04d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapRequestEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapRequestEvent.kt @@ -1,195 +1,235 @@ -package com.vitorpamplona.quartz.events - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.* -import com.vitorpamplona.quartz.encoders.Bech32 -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.crypto.KeyPair -import com.vitorpamplona.quartz.encoders.HexKey -import com.vitorpamplona.quartz.encoders.hexToByteArray -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.signers.NostrSigner -import com.vitorpamplona.quartz.signers.NostrSignerInternal -import java.nio.charset.Charset -import java.security.SecureRandom -import javax.crypto.BadPaddingException -import javax.crypto.Cipher -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec - -@Immutable -class LnZapRequestEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - - @Transient - private var privateZapEvent: LnZapPrivateEvent? = null - - fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } - - fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } - - fun isPrivateZap() = tags.any { t -> t.size >= 2 && t[0] == "anon" && t[1].isNotBlank() } - - fun getPrivateZapEvent(loggedInUserPrivKey: ByteArray, pubKey: HexKey): LnZapPrivateEvent? { - val anonTag = tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" } - if (anonTag != null) { - val encnote = anonTag[1] - if (encnote.isNotBlank()) { - try { - val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, pubKey.hexToByteArray()) - val decryptedEvent = fromJson(note) - if (decryptedEvent.kind == 9733) { - return decryptedEvent as LnZapPrivateEvent - } - } catch (e: Exception) { - e.printStackTrace() - } - } - } - return null - } - - fun cachedPrivateZap(): LnZapPrivateEvent? { - return privateZapEvent - } - - fun decryptPrivateZap(signer: NostrSigner, onReady: (Event) -> Unit) { - privateZapEvent?.let { - onReady(it) - return - } - - signer.decryptZapEvent(this) { - // caches it - privateZapEvent = it - onReady(it) - } - } - - companion object { - const val kind = 9734 - const val alt = "Zap request" - - fun create( - originalNote: EventInterface, - relays: Set, - signer: NostrSigner, - pollOption: Int?, - message: String, - zapType: LnZapEvent.ZapType, - toUserPubHex: String?, // Overrides in case of Zap Splits - createdAt: Long = TimeUtils.now(), - onReady: (LnZapRequestEvent) -> Unit - ) { - if (zapType == LnZapEvent.ZapType.NONZAP) return - - var tags = listOf( - arrayOf("e", originalNote.id()), - arrayOf("p", toUserPubHex ?: originalNote.pubKey()), - arrayOf("relays") + relays, - arrayOf("alt", alt) - ) - if (originalNote is AddressableEvent) { - tags = tags + listOf(arrayOf("a", originalNote.address().toTag())) - } - if (pollOption != null && pollOption >= 0) { - tags = tags + listOf(arrayOf(POLL_OPTION, pollOption.toString())) - } - - if (zapType == LnZapEvent.ZapType.ANONYMOUS) { - tags = tags + listOf(arrayOf("anon")) - NostrSignerInternal(KeyPair()).sign(createdAt, kind, tags.toTypedArray(), message, onReady) - } else if (zapType == LnZapEvent.ZapType.PRIVATE) { - tags = tags + listOf(arrayOf("anon", "")) - signer.sign(createdAt, kind, tags.toTypedArray(), message, onReady) - } else { - signer.sign(createdAt, kind, tags.toTypedArray(), message, onReady) - } - } - - fun create( - userHex: String, - relays: Set, - signer: NostrSigner, - message: String, - zapType: LnZapEvent.ZapType, - createdAt: Long = TimeUtils.now(), - onReady: (LnZapRequestEvent) -> Unit - ) { - if (zapType == LnZapEvent.ZapType.NONZAP) return - - var tags = arrayOf( - arrayOf("p", userHex), - arrayOf("relays") + relays - ) - - if (zapType == LnZapEvent.ZapType.ANONYMOUS) { - tags += arrayOf(arrayOf("anon", "")) - NostrSignerInternal(KeyPair()).sign(createdAt, kind, tags, message, onReady) - } else if (zapType == LnZapEvent.ZapType.PRIVATE) { - tags += arrayOf(arrayOf("anon", "")) - signer.sign(createdAt, kind, tags, message, onReady) - } else { - signer.sign(createdAt, kind, tags, message, onReady) - } - } - - - fun createEncryptionPrivateKey(privkey: String, id: String, createdAt: Long): ByteArray { - val str = privkey + id + createdAt.toString() - val strbyte = str.toByteArray(Charset.forName("utf-8")) - return CryptoUtils.sha256(strbyte) - } - - fun encryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String { - val sharedSecret = CryptoUtils.getSharedSecretNIP04(privkey, pubkey) - val iv = ByteArray(16) - SecureRandom().nextBytes(iv) - - val keySpec = SecretKeySpec(sharedSecret, "AES") - val ivSpec = IvParameterSpec(iv) - - val utf8message = msg.toByteArray(Charset.forName("utf-8")) - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec) - val encryptedMsg = cipher.doFinal(utf8message) - - val encryptedMsgBech32 = Bech32.encode("pzap", Bech32.eight2five(encryptedMsg), Bech32.Encoding.Bech32) - val ivBech32 = Bech32.encode("iv", Bech32.eight2five(iv), Bech32.Encoding.Bech32) - - return encryptedMsgBech32 + "_" + ivBech32 - } - - private fun decryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String { - val sharedSecret = CryptoUtils.getSharedSecretNIP04(privkey, pubkey) - if (sharedSecret.size != 16 && sharedSecret.size != 32) { - throw IllegalArgumentException("Invalid shared secret size") - } - val parts = msg.split("_") - if (parts.size != 2) { - throw IllegalArgumentException("Invalid message format") - } - val iv = parts[1].run { Bech32.decode(this).second } - val encryptedMsg = parts.first().run { Bech32.decode(this).second } - val encryptedBytes = Bech32.five2eight(encryptedMsg, 0) - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - cipher.init( - Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec( - Bech32.five2eight(iv, 0)) - ) - - try { - val decryptedMsgBytes = cipher.doFinal(encryptedBytes) - return String(decryptedMsgBytes) - } catch (ex: BadPaddingException) { - throw IllegalArgumentException("Bad padding: ${ex.message}") - } - } - } -} +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.crypto.CryptoUtils +import com.vitorpamplona.quartz.crypto.KeyPair +import com.vitorpamplona.quartz.encoders.Bech32 +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.hexToByteArray +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.signers.NostrSignerInternal +import com.vitorpamplona.quartz.utils.TimeUtils +import java.nio.charset.Charset +import java.security.SecureRandom +import javax.crypto.BadPaddingException +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +@Immutable +class LnZapRequestEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + @Transient private var privateZapEvent: LnZapPrivateEvent? = null + + fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } + + fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } + + fun isPrivateZap() = tags.any { t -> t.size >= 2 && t[0] == "anon" && t[1].isNotBlank() } + + fun getPrivateZapEvent( + loggedInUserPrivKey: ByteArray, + pubKey: HexKey, + ): LnZapPrivateEvent? { + val anonTag = tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" } + if (anonTag != null) { + val encnote = anonTag[1] + if (encnote.isNotBlank()) { + try { + val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, pubKey.hexToByteArray()) + val decryptedEvent = fromJson(note) + if (decryptedEvent.kind == 9733) { + return decryptedEvent as LnZapPrivateEvent + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + return null + } + + fun cachedPrivateZap(): LnZapPrivateEvent? { + return privateZapEvent + } + + fun decryptPrivateZap( + signer: NostrSigner, + onReady: (Event) -> Unit, + ) { + privateZapEvent?.let { + onReady(it) + return + } + + signer.decryptZapEvent(this) { + // caches it + privateZapEvent = it + onReady(it) + } + } + + companion object { + const val KIND = 9734 + const val ALT = "Zap request" + + fun create( + originalNote: EventInterface, + relays: Set, + signer: NostrSigner, + pollOption: Int?, + message: String, + zapType: LnZapEvent.ZapType, + toUserPubHex: String?, + createdAt: Long = TimeUtils.now(), + onReady: (LnZapRequestEvent) -> Unit, + ) { + if (zapType == LnZapEvent.ZapType.NONZAP) return + + var tags = + listOf( + arrayOf("e", originalNote.id()), + arrayOf("p", toUserPubHex ?: originalNote.pubKey()), + arrayOf("relays") + relays, + arrayOf("alt", ALT), + ) + if (originalNote is AddressableEvent) { + tags = tags + listOf(arrayOf("a", originalNote.address().toTag())) + } + if (pollOption != null && pollOption >= 0) { + tags = tags + listOf(arrayOf(POLL_OPTION, pollOption.toString())) + } + + if (zapType == LnZapEvent.ZapType.ANONYMOUS) { + tags = tags + listOf(arrayOf("anon")) + NostrSignerInternal(KeyPair()).sign(createdAt, KIND, tags.toTypedArray(), message, onReady) + } else if (zapType == LnZapEvent.ZapType.PRIVATE) { + tags = tags + listOf(arrayOf("anon", "")) + signer.sign(createdAt, KIND, tags.toTypedArray(), message, onReady) + } else { + signer.sign(createdAt, KIND, tags.toTypedArray(), message, onReady) + } + } + + fun create( + userHex: String, + relays: Set, + signer: NostrSigner, + message: String, + zapType: LnZapEvent.ZapType, + createdAt: Long = TimeUtils.now(), + onReady: (LnZapRequestEvent) -> Unit, + ) { + if (zapType == LnZapEvent.ZapType.NONZAP) return + + var tags = + arrayOf( + arrayOf("p", userHex), + arrayOf("relays") + relays, + ) + + if (zapType == LnZapEvent.ZapType.ANONYMOUS) { + tags += arrayOf(arrayOf("anon", "")) + NostrSignerInternal(KeyPair()).sign(createdAt, KIND, tags, message, onReady) + } else if (zapType == LnZapEvent.ZapType.PRIVATE) { + tags += arrayOf(arrayOf("anon", "")) + signer.sign(createdAt, KIND, tags, message, onReady) + } else { + signer.sign(createdAt, KIND, tags, message, onReady) + } + } + + fun createEncryptionPrivateKey( + privkey: String, + id: String, + createdAt: Long, + ): ByteArray { + val str = privkey + id + createdAt.toString() + val strbyte = str.toByteArray(Charset.forName("utf-8")) + return CryptoUtils.sha256(strbyte) + } + + fun encryptPrivateZapMessage( + msg: String, + privkey: ByteArray, + pubkey: ByteArray, + ): String { + val sharedSecret = CryptoUtils.getSharedSecretNIP04(privkey, pubkey) + val iv = ByteArray(16) + SecureRandom().nextBytes(iv) + + val keySpec = SecretKeySpec(sharedSecret, "AES") + val ivSpec = IvParameterSpec(iv) + + val utf8message = msg.toByteArray(Charset.forName("utf-8")) + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec) + val encryptedMsg = cipher.doFinal(utf8message) + + val encryptedMsgBech32 = + Bech32.encode("pzap", Bech32.eight2five(encryptedMsg), Bech32.Encoding.Bech32) + val ivBech32 = Bech32.encode("iv", Bech32.eight2five(iv), Bech32.Encoding.Bech32) + + return encryptedMsgBech32 + "_" + ivBech32 + } + + private fun decryptPrivateZapMessage( + msg: String, + privkey: ByteArray, + pubkey: ByteArray, + ): String { + val sharedSecret = CryptoUtils.getSharedSecretNIP04(privkey, pubkey) + if (sharedSecret.size != 16 && sharedSecret.size != 32) { + throw IllegalArgumentException("Invalid shared secret size") + } + val parts = msg.split("_") + if (parts.size != 2) { + throw IllegalArgumentException("Invalid message format") + } + val iv = parts[1].run { Bech32.decode(this).second } + val encryptedMsg = parts.first().run { Bech32.decode(this).second } + val encryptedBytes = Bech32.five2eight(encryptedMsg, 0) + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init( + Cipher.DECRYPT_MODE, + SecretKeySpec(sharedSecret, "AES"), + IvParameterSpec( + Bech32.five2eight(iv, 0), + ), + ) + + try { + val decryptedMsgBytes = cipher.doFinal(encryptedBytes) + return String(decryptedMsgBytes) + } catch (ex: BadPaddingException) { + throw IllegalArgumentException("Bad padding: ${ex.message}") + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LongTextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LongTextNoteEvent.kt index c26b8abcc..90b332cdb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LongTextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LongTextNoteEvent.kt @@ -1,60 +1,81 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class LongTextNoteEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent { - override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" - override fun address() = ATag(kind, pubKey, dTag(), null) + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseTextNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig), AddressableEvent { + override fun dTag() = + tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" - fun topics() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) } - fun title() = tags.filter { it.firstOrNull() == "title" }.mapNotNull { it.getOrNull(1) }.firstOrNull() - fun image() = tags.filter { it.firstOrNull() == "image" }.mapNotNull { it.getOrNull(1) }.firstOrNull() - fun summary() = tags.filter { it.firstOrNull() == "summary" }.mapNotNull { it.getOrNull(1) }.firstOrNull() + override fun address() = ATag(kind, pubKey, dTag(), null) - fun publishedAt() = try { - tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1)?.toLongOrNull() + fun topics() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) } + + fun title() = + tags.filter { it.firstOrNull() == "title" }.mapNotNull { it.getOrNull(1) }.firstOrNull() + + fun image() = + tags.filter { it.firstOrNull() == "image" }.mapNotNull { it.getOrNull(1) }.firstOrNull() + + fun summary() = + tags.filter { it.firstOrNull() == "summary" }.mapNotNull { it.getOrNull(1) }.firstOrNull() + + fun publishedAt() = + try { + tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1)?.toLongOrNull() } catch (_: Exception) { - null + null } - companion object { - const val kind = 30023 + companion object { + const val KIND = 30023 - fun create( - msg: String, - title: String?, - replyTos: List?, - mentions: List?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (LongTextNoteEvent) -> Unit - ) { - val tags = mutableListOf>() - replyTos?.forEach { - tags.add(arrayOf("e", it)) - } - mentions?.forEach { - tags.add(arrayOf("p", it)) - } - title?.let { - tags.add(arrayOf("title", it)) - } - tags.add(arrayOf("alt","Blog post: $title")) - signer.sign(createdAt, kind, tags.toTypedArray(), msg, onReady) - } + fun create( + msg: String, + title: String?, + replyTos: List?, + mentions: List?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (LongTextNoteEvent) -> Unit, + ) { + val tags = mutableListOf>() + replyTos?.forEach { tags.add(arrayOf("e", it)) } + mentions?.forEach { tags.add(arrayOf("p", it)) } + title?.let { tags.add(arrayOf("title", it)) } + tags.add(arrayOf("alt", "Blog post: $title")) + signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/MetadataEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/MetadataEvent.kt index 7406539c5..cc5a6c9f1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/MetadataEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/MetadataEvent.kt @@ -1,174 +1,198 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import android.util.Log import androidx.compose.runtime.Stable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils import java.io.ByteArrayInputStream @Stable abstract class IdentityClaim( - val identity: String, - val proof: String + val identity: String, + val proof: String, ) { - abstract fun toProofUrl(): String - abstract fun platform(): String + abstract fun toProofUrl(): String - fun platformIdentity() = "${platform()}:$identity" + abstract fun platform(): String - companion object { - fun create(platformIdentity: String, proof: String): IdentityClaim? { - val (platform, identity) = platformIdentity.split(':') + fun platformIdentity() = "${platform()}:$identity" - 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") - } - } + companion object { + fun create( + platformIdentity: String, + proof: String, + ): IdentityClaim? { + val (platform, identity) = platformIdentity.split(':') + + 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 + identity: String, + proof: String, ) : IdentityClaim(identity, proof) { - override fun toProofUrl() = "https://gist.github.com/$identity/$proof" + override fun toProofUrl() = "https://gist.github.com/$identity/$proof" - override fun platform() = platform + override fun platform() = platform - companion object { - val platform = "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("/") + 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 - } - } + GitHubIdentity(path[0], path[1]) + } catch (e: Exception) { + null + } } + } } class TwitterIdentity( - identity: String, - proof: String + identity: String, + proof: String, ) : IdentityClaim(identity, proof) { - override fun toProofUrl() = "https://twitter.com/$identity/status/$proof" + override fun toProofUrl() = "https://twitter.com/$identity/status/$proof" - override fun platform() = platform + override fun platform() = platform - companion object { - val platform = "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("/") + 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 - } - } + TwitterIdentity(path[0], path[2]) + } catch (e: Exception) { + null + } } + } } class TelegramIdentity( - identity: String, - proof: String + identity: String, + proof: String, ) : IdentityClaim(identity, proof) { - override fun toProofUrl() = "https://t.me/$proof" + override fun toProofUrl() = "https://t.me/$proof" - override fun platform() = platform + override fun platform() = platform - companion object { - val platform = "telegram" - } + companion object { + val platform = "telegram" + } } class MastodonIdentity( - identity: String, - proof: String + identity: String, + proof: String, ) : IdentityClaim(identity, proof) { - override fun toProofUrl() = "https://$identity/$proof" + override fun toProofUrl() = "https://$identity/$proof" - override fun platform() = platform + override fun platform() = platform - companion object { - val platform = "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("/") + 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]}", path[2]) - } catch (e: Exception) { - null - } - } + return MastodonIdentity("${path[0]}/${path[1]}", path[2]) + } catch (e: Exception) { + null + } } + } } class MetadataEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun contactMetaData() = try { - mapper.readValue( - ByteArrayInputStream(content.toByteArray(Charsets.UTF_8)), - UserMetadata::class.java - ) + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun contactMetaData() = + try { + mapper.readValue( + ByteArrayInputStream(content.toByteArray(Charsets.UTF_8)), + UserMetadata::class.java, + ) } catch (e: Exception) { - // e.printStackTrace() - Log.w("MT", "Content Parse Error: ${e.localizedMessage} $content") - null + // e.printStackTrace() + Log.w("MT", "Content Parse Error: ${e.localizedMessage} $content") + null } - fun identityClaims() = tags.filter { it.firstOrNull() == "i" }.mapNotNull { + fun identityClaims() = + tags + .filter { it.firstOrNull() == "i" } + .mapNotNull { try { - IdentityClaim.create(it.get(1), it.get(2)) + 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 - - fun create( - contactMetaData: String, - newName: String, - identities: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MetadataEvent) -> Unit - ) { - val tags = mutableListOf>() - - tags.add( - arrayOf("alt", "User profile for ${newName}") - ) - - identities.forEach { - tags.add(arrayOf("i", it.platformIdentity(), it.proof)) - } - - signer.sign(createdAt, kind, tags.toTypedArray(), contactMetaData, onReady) + Log.e("MetadataEvent", "Can't parse identity [${it.joinToString { "," }}]", e) + null } + } + + companion object { + const val KIND = 0 + + fun create( + contactMetaData: String, + newName: String, + identities: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (MetadataEvent) -> Unit, + ) { + val tags = mutableListOf>() + + tags.add( + arrayOf("alt", "User profile for $newName"), + ) + + identities.forEach { tags.add(arrayOf("i", it.platformIdentity(), it.proof)) } + + signer.sign(createdAt, KIND, tags.toTypedArray(), contactMetaData, onReady) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/MuteListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/MuteListEvent.kt index 280c1a8db..7ef2db84f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/MuteListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/MuteListEvent.kt @@ -1,222 +1,315 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events -import android.util.Log import androidx.compose.runtime.Immutable -import com.fasterxml.jackson.module.kotlin.readValue -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.hexToByteArray -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableSet -import java.util.UUID @Immutable class MuteListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) { - @Transient - var publicAndPrivateUserCache: ImmutableSet? = null - @Transient - var publicAndPrivateWordCache: ImmutableSet? = null + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + @Transient var publicAndPrivateUserCache: ImmutableSet? = null - override fun dTag() = fixedDTag + @Transient var publicAndPrivateWordCache: ImmutableSet? = null - fun publicAndPrivateUsersAndWords(signer: NostrSigner, onReady: (PeopleListEvent.UsersAndWords) -> Unit) { - publicAndPrivateUserCache?.let { userList -> - publicAndPrivateWordCache?.let { wordList -> - onReady(PeopleListEvent.UsersAndWords(userList, wordList)) - return - } - } + override fun dTag() = FIXED_D_TAG - privateTagsOrEmpty(signer) { - publicAndPrivateUserCache = filterTagList("p", it) - publicAndPrivateWordCache = filterTagList("word", it) - - publicAndPrivateUserCache?.let { userList -> - publicAndPrivateWordCache?.let { wordList -> - onReady( - PeopleListEvent.UsersAndWords(userList, wordList) - ) - } - } - } + fun publicAndPrivateUsersAndWords( + signer: NostrSigner, + onReady: (PeopleListEvent.UsersAndWords) -> Unit, + ) { + publicAndPrivateUserCache?.let { userList -> + publicAndPrivateWordCache?.let { wordList -> + onReady(PeopleListEvent.UsersAndWords(userList, wordList)) + return + } } - companion object { - const val kind = 10000 - const val fixedDTag = "" - const val alt = "Mute List" + privateTagsOrEmpty(signer) { + publicAndPrivateUserCache = filterTagList("p", it) + publicAndPrivateWordCache = filterTagList("word", it) - fun blockListFor(pubKeyHex: HexKey): String { - return "10000:$pubKeyHex:" - } - - fun createListWithTag(key: String, tag: String, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (MuteListEvent) -> Unit) { - if (isPrivate) { - encryptTags(arrayOf(arrayOf(key, tag)), signer) { encryptedTags -> - create( - content = encryptedTags, - tags = emptyArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - } else { - create( - content = "", - tags = arrayOf(arrayOf(key, tag)), - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - } - - fun createListWithUser(pubKeyHex: String, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (MuteListEvent) -> Unit) { - return createListWithTag("p", pubKeyHex, isPrivate, signer, createdAt, onReady) - } - - fun createListWithWord(word: String, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (MuteListEvent) -> Unit) { - return createListWithTag("word", word, isPrivate, signer, createdAt, onReady) - } - - fun addUsers(earlierVersion: MuteListEvent, listPubKeyHex: List, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (MuteListEvent) -> Unit) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = privateTags.plus( - listPubKeyHex.map { - arrayOf("p", it) - } - ), - signer = signer - ) { encryptedTags -> - create( - content = encryptedTags, - tags = earlierVersion.tags, - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = earlierVersion.tags.plus( - listPubKeyHex.map { - arrayOf("p", it) - } - ), - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - } - - fun addWord(earlierVersion: MuteListEvent, word: String, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (MuteListEvent) -> Unit) { - return addTag(earlierVersion, "word", word, isPrivate, signer, createdAt, onReady) - } - - fun addUser(earlierVersion: MuteListEvent, pubKeyHex: String, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (MuteListEvent) -> Unit) { - return addTag(earlierVersion, "p", pubKeyHex, isPrivate, signer, createdAt, onReady) - } - - fun addTag(earlierVersion: MuteListEvent, key: String, tag: String, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (MuteListEvent) -> Unit) { - earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> - if (!isTagged) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = privateTags.plus(element = arrayOf(key, tag)), - signer = signer - ) { encryptedTags -> - create( - content = encryptedTags, - tags = earlierVersion.tags, - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = earlierVersion.tags.plus(element = arrayOf(key, tag)), - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - } - } - } - - fun removeWord(earlierVersion: MuteListEvent, word: String, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (MuteListEvent) -> Unit) { - return removeTag(earlierVersion, "word", word, isPrivate, signer, createdAt, onReady) - } - - fun removeUser(earlierVersion: MuteListEvent, pubKeyHex: String, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (MuteListEvent) -> Unit) { - return removeTag(earlierVersion, "p", pubKeyHex, isPrivate, signer, createdAt, onReady) - } - - fun removeTag(earlierVersion: MuteListEvent, key: String, tag: String, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (MuteListEvent) -> Unit) { - earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> - if (isTagged) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = privateTags.filter { it.size > 1 && !(it[0] == key && it[1] == tag) }.toTypedArray(), - signer = signer - ) { encryptedTags -> - create( - content = encryptedTags, - tags = earlierVersion.tags.filter { it.size > 1 && !(it[0] == key && it[1] == tag) }.toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = earlierVersion.tags.filter { it.size > 1 && !(it[0] == key && it[1] == tag) }.toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - } - } - } - - fun create( - content: String, - tags: Array>, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (MuteListEvent) -> Unit - ) { - val newTags = if (tags.any { it.size > 1 && it[0] == "alt" }) { - tags - } else { - tags + arrayOf("alt", alt) - } - - signer.sign(createdAt, kind, newTags, content, onReady) + publicAndPrivateUserCache?.let { userList -> + publicAndPrivateWordCache?.let { wordList -> + onReady( + PeopleListEvent.UsersAndWords(userList, wordList), + ) } + } } + } + + companion object { + const val KIND = 10000 + const val FIXED_D_TAG = "" + const val ALT = "Mute List" + + fun blockListFor(pubKeyHex: HexKey): String { + return "10000:$pubKeyHex:" + } + + fun createListWithTag( + key: String, + tag: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (MuteListEvent) -> Unit, + ) { + if (isPrivate) { + encryptTags(arrayOf(arrayOf(key, tag)), signer) { encryptedTags -> + create( + content = encryptedTags, + tags = emptyArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } else { + create( + content = "", + tags = arrayOf(arrayOf(key, tag)), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + + fun createListWithUser( + pubKeyHex: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (MuteListEvent) -> Unit, + ) { + return createListWithTag("p", pubKeyHex, isPrivate, signer, createdAt, onReady) + } + + fun createListWithWord( + word: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (MuteListEvent) -> Unit, + ) { + return createListWithTag("word", word, isPrivate, signer, createdAt, onReady) + } + + fun addUsers( + earlierVersion: MuteListEvent, + listPubKeyHex: List, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (MuteListEvent) -> Unit, + ) { + if (isPrivate) { + earlierVersion.privateTagsOrEmpty(signer) { privateTags -> + encryptTags( + privateTags = + privateTags.plus( + listPubKeyHex.map { arrayOf("p", it) }, + ), + signer = signer, + ) { encryptedTags -> + create( + content = encryptedTags, + tags = earlierVersion.tags, + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } else { + create( + content = earlierVersion.content, + tags = + earlierVersion.tags.plus( + listPubKeyHex.map { arrayOf("p", it) }, + ), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + + fun addWord( + earlierVersion: MuteListEvent, + word: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (MuteListEvent) -> Unit, + ) { + return addTag(earlierVersion, "word", word, isPrivate, signer, createdAt, onReady) + } + + fun addUser( + earlierVersion: MuteListEvent, + pubKeyHex: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (MuteListEvent) -> Unit, + ) { + return addTag(earlierVersion, "p", pubKeyHex, isPrivate, signer, createdAt, onReady) + } + + fun addTag( + earlierVersion: MuteListEvent, + key: String, + tag: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (MuteListEvent) -> Unit, + ) { + earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> + if (!isTagged) { + if (isPrivate) { + earlierVersion.privateTagsOrEmpty(signer) { privateTags -> + encryptTags( + privateTags = privateTags.plus(element = arrayOf(key, tag)), + signer = signer, + ) { encryptedTags -> + create( + content = encryptedTags, + tags = earlierVersion.tags, + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } else { + create( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(element = arrayOf(key, tag)), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } + } + + fun removeWord( + earlierVersion: MuteListEvent, + word: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (MuteListEvent) -> Unit, + ) { + return removeTag(earlierVersion, "word", word, isPrivate, signer, createdAt, onReady) + } + + fun removeUser( + earlierVersion: MuteListEvent, + pubKeyHex: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (MuteListEvent) -> Unit, + ) { + return removeTag(earlierVersion, "p", pubKeyHex, isPrivate, signer, createdAt, onReady) + } + + fun removeTag( + earlierVersion: MuteListEvent, + key: String, + tag: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (MuteListEvent) -> Unit, + ) { + earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> + if (isTagged) { + if (isPrivate) { + earlierVersion.privateTagsOrEmpty(signer) { privateTags -> + encryptTags( + privateTags = + privateTags + .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } + .toTypedArray(), + signer = signer, + ) { encryptedTags -> + create( + content = encryptedTags, + tags = + earlierVersion.tags + .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } + .toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } else { + create( + content = earlierVersion.content, + tags = + earlierVersion.tags + .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } + .toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } + } + + fun create( + content: String, + tags: Array>, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (MuteListEvent) -> Unit, + ) { + val newTags = + if (tags.any { it.size > 1 && it[0] == "alt" }) { + tags + } else { + tags + arrayOf("alt", ALT) + } + + signer.sign(createdAt, KIND, newTags, content, onReady) + } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP24Factory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP24Factory.kt index efdff156d..8444c9aee 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP24Factory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP24Factory.kt @@ -1,168 +1,201 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner -import kotlin.math.sign class NIP24Factory { - data class Result(val msg: Event, val wraps: List) + data class Result(val msg: Event, val wraps: List) - private fun recursiveGiftWrapCreation( - event: Event, - remainingTos: List, - signer: NostrSigner, - output: MutableList, - onReady: (List) -> Unit - ) { - if (remainingTos.isEmpty()) { - onReady(output) - return - } - - val next = remainingTos.first() - - SealedGossipEvent.create( - event = event, - encryptTo = next, - signer = signer - ) { seal -> - GiftWrapEvent.create( - event = seal, - recipientPubKey = next - ) { giftWrap -> - output.add(giftWrap) - recursiveGiftWrapCreation(event, remainingTos.minus(next), signer, output, onReady) - } - } + private fun recursiveGiftWrapCreation( + event: Event, + remainingTos: List, + signer: NostrSigner, + output: MutableList, + onReady: (List) -> Unit, + ) { + if (remainingTos.isEmpty()) { + onReady(output) + return } - private fun createWraps(event: Event, to: Set, signer: NostrSigner, onReady: (List) -> Unit) { - val wraps = mutableListOf() - recursiveGiftWrapCreation(event, to.toList(), signer, wraps, onReady) + val next = remainingTos.first() + + SealedGossipEvent.create( + event = event, + encryptTo = next, + signer = signer, + ) { seal -> + GiftWrapEvent.create( + event = seal, + recipientPubKey = next, + ) { giftWrap -> + output.add(giftWrap) + recursiveGiftWrapCreation(event, remainingTos.minus(next), signer, output, onReady) + } } + } - fun createMsgNIP24( - msg: String, - to: List, - signer: NostrSigner, - subject: String? = null, - replyTos: List? = null, - mentions: List? = null, - zapReceiver: List? = null, - markAsSensitive: Boolean = false, - zapRaiserAmount: Long? = null, - geohash: String? = null, - onReady: (Result) -> Unit - ) { - val senderPublicKey = signer.pubKey + private fun createWraps( + event: Event, + to: Set, + signer: NostrSigner, + onReady: (List) -> Unit, + ) { + val wraps = mutableListOf() + recursiveGiftWrapCreation(event, to.toList(), signer, wraps, onReady) + } - ChatMessageEvent.create( - msg = msg, - to = to, - signer = signer, - subject = subject, - replyTos = replyTos, - mentions = mentions, - zapReceiver = zapReceiver, - markAsSensitive = markAsSensitive, - zapRaiserAmount = zapRaiserAmount, - geohash = geohash - ) { senderMessage -> - createWraps(senderMessage, to.plus(senderPublicKey).toSet(), signer) { wraps -> - onReady( - Result( - msg = senderMessage, - wraps = wraps - ) - ) - } - } + fun createMsgNIP24( + msg: String, + to: List, + signer: NostrSigner, + subject: String? = null, + replyTos: List? = null, + mentions: List? = null, + zapReceiver: List? = null, + markAsSensitive: Boolean = false, + zapRaiserAmount: Long? = null, + geohash: String? = null, + onReady: (Result) -> Unit, + ) { + val senderPublicKey = signer.pubKey + + ChatMessageEvent.create( + msg = msg, + to = to, + signer = signer, + subject = subject, + replyTos = replyTos, + mentions = mentions, + zapReceiver = zapReceiver, + markAsSensitive = markAsSensitive, + zapRaiserAmount = zapRaiserAmount, + geohash = geohash, + ) { senderMessage -> + createWraps(senderMessage, to.plus(senderPublicKey).toSet(), signer) { wraps -> + onReady( + Result( + msg = senderMessage, + wraps = wraps, + ), + ) + } } + } - fun createReactionWithinGroup(content: String, originalNote: EventInterface, to: List, signer: NostrSigner, onReady: (Result) -> Unit) { - val senderPublicKey = signer.pubKey + fun createReactionWithinGroup( + content: String, + originalNote: EventInterface, + to: List, + signer: NostrSigner, + onReady: (Result) -> Unit, + ) { + val senderPublicKey = signer.pubKey - ReactionEvent.create( - content, - originalNote, - signer - ) { senderReaction -> - createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer) { wraps-> - onReady( - Result( - msg = senderReaction, - wraps = wraps - ) - ) - } - } + ReactionEvent.create( + content, + originalNote, + signer, + ) { senderReaction -> + createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer) { wraps -> + onReady( + Result( + msg = senderReaction, + wraps = wraps, + ), + ) + } } + } - fun createReactionWithinGroup(emojiUrl: EmojiUrl, originalNote: EventInterface, to: List, signer: NostrSigner, onReady: (Result) -> Unit) { - val senderPublicKey = signer.pubKey + fun createReactionWithinGroup( + emojiUrl: EmojiUrl, + originalNote: EventInterface, + to: List, + signer: NostrSigner, + onReady: (Result) -> Unit, + ) { + val senderPublicKey = signer.pubKey - ReactionEvent.create( - emojiUrl, - originalNote, - signer - ) { senderReaction -> - createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer) { wraps -> - onReady( - Result( - msg = senderReaction, - wraps = wraps - ) - ) - } - } + ReactionEvent.create( + emojiUrl, + originalNote, + signer, + ) { senderReaction -> + createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer) { wraps -> + onReady( + Result( + msg = senderReaction, + wraps = wraps, + ), + ) + } } + } - fun createTextNoteNIP24( - msg: String, - to: List, - signer: NostrSigner, - replyTos: List? = null, - mentions: List? = null, - addresses: List?, - extraTags: List?, - zapReceiver: List? = null, - markAsSensitive: Boolean = false, - replyingTo: String?, - root: String?, - directMentions: Set, - zapRaiserAmount: Long? = null, - geohash: String? = null, - onReady: (Result) -> Unit - ) { - val senderPublicKey = signer.pubKey + fun createTextNoteNIP24( + msg: String, + to: List, + signer: NostrSigner, + replyTos: List? = null, + mentions: List? = null, + addresses: List?, + extraTags: List?, + zapReceiver: List? = null, + markAsSensitive: Boolean = false, + replyingTo: String?, + root: String?, + directMentions: Set, + zapRaiserAmount: Long? = null, + geohash: String? = null, + onReady: (Result) -> Unit, + ) { + val senderPublicKey = signer.pubKey - TextNoteEvent.create( - msg = msg, - signer = signer, - replyTos = replyTos, - mentions = mentions, - zapReceiver = zapReceiver, - root = root, - extraTags = extraTags, - addresses = addresses, - directMentions = directMentions, - replyingTo = replyingTo, - markAsSensitive = markAsSensitive, - zapRaiserAmount = zapRaiserAmount, - geohash = geohash - ) { senderMessage -> - createWraps(senderMessage, to.plus(senderPublicKey).toSet(), signer) { wraps -> - onReady( - Result( - msg = senderMessage, - wraps = wraps - ) - ) - } - } + TextNoteEvent.create( + msg = msg, + signer = signer, + replyTos = replyTos, + mentions = mentions, + zapReceiver = zapReceiver, + root = root, + extraTags = extraTags, + addresses = addresses, + directMentions = directMentions, + replyingTo = replyingTo, + markAsSensitive = markAsSensitive, + zapRaiserAmount = zapRaiserAmount, + geohash = geohash, + ) { senderMessage -> + createWraps(senderMessage, to.plus(senderPublicKey).toSet(), signer) { wraps -> + onReady( + Result( + msg = senderMessage, + wraps = wraps, + ), + ) + } } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/NNSEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/NNSEvent.kt index 50ae3aa40..e70e42a9c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/NNSEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/NNSEvent.kt @@ -1,37 +1,56 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class NNSEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { - fun ip4() = tags.firstOrNull { it.size > 1 && it[0] == "ip4" }?.get(1) - fun ip6() = tags.firstOrNull { it.size > 1 && it[0] == "ip6" }?.get(1) - fun version() = tags.firstOrNull { it.size > 1 && it[0] == "version" }?.get(1) + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun ip4() = tags.firstOrNull { it.size > 1 && it[0] == "ip4" }?.get(1) - companion object { - const val kind = 30053 - const val alt = "DNS records" + fun ip6() = tags.firstOrNull { it.size > 1 && it[0] == "ip6" }?.get(1) - fun create( - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (NNSEvent) -> Unit - ) { - val tags = arrayOf(arrayOf("alt", alt)) - signer.sign(createdAt, kind, tags, "", onReady) - } + fun version() = tags.firstOrNull { it.size > 1 && it[0] == "version" }?.get(1) + + companion object { + const val KIND = 30053 + const val ALT = "DNS records" + + fun create( + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (NNSEvent) -> Unit, + ) { + val tags = arrayOf(arrayOf("alt", ALT)) + signer.sign(createdAt, KIND, tags, "", onReady) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/PeopleListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/PeopleListEvent.kt index c9d689791..a1c8760b9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/PeopleListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/PeopleListEvent.kt @@ -1,255 +1,367 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf -import kotlinx.collections.immutable.toImmutableSet @Immutable class PeopleListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) { - @Transient - var publicAndPrivateUserCache: ImmutableSet? = null - @Transient - var publicAndPrivateWordCache: ImmutableSet? = null + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + @Transient var publicAndPrivateUserCache: ImmutableSet? = null - fun publicAndPrivateWords(signer: NostrSigner, onReady: (ImmutableSet) -> Unit) { - publicAndPrivateWordCache?.let { - onReady(it) - return - } + @Transient var publicAndPrivateWordCache: ImmutableSet? = null - privateTagsOrEmpty(signer) { - publicAndPrivateWordCache = filterTagList("word", it) - publicAndPrivateWordCache?.let { - onReady(it) - } - } + fun publicAndPrivateWords( + signer: NostrSigner, + onReady: (ImmutableSet) -> Unit, + ) { + publicAndPrivateWordCache?.let { + onReady(it) + return } - fun publicAndPrivateUsers(signer: NostrSigner, onReady: (ImmutableSet) -> Unit) { - publicAndPrivateUserCache?.let { - onReady(it) - return - } + privateTagsOrEmpty(signer) { + publicAndPrivateWordCache = filterTagList("word", it) + publicAndPrivateWordCache?.let { onReady(it) } + } + } - privateTagsOrEmpty(signer) { - publicAndPrivateUserCache = filterTagList("p", it) - publicAndPrivateUserCache?.let { - onReady(it) - } - } + fun publicAndPrivateUsers( + signer: NostrSigner, + onReady: (ImmutableSet) -> Unit, + ) { + publicAndPrivateUserCache?.let { + onReady(it) + return } - @Immutable - data class UsersAndWords( - val users: ImmutableSet = persistentSetOf(), - val words: ImmutableSet = persistentSetOf() - ) + privateTagsOrEmpty(signer) { + publicAndPrivateUserCache = filterTagList("p", it) + publicAndPrivateUserCache?.let { onReady(it) } + } + } - fun publicAndPrivateUsersAndWords(signer: NostrSigner, onReady: (UsersAndWords) -> Unit) { - publicAndPrivateUserCache?.let { userList -> - publicAndPrivateWordCache?.let { wordList -> - onReady(UsersAndWords(userList, wordList)) - return - } - } + @Immutable + data class UsersAndWords( + val users: ImmutableSet = persistentSetOf(), + val words: ImmutableSet = persistentSetOf(), + ) - privateTagsOrEmpty(signer) { - publicAndPrivateUserCache = filterTagList("p", it) - publicAndPrivateWordCache = filterTagList("word", it) - - publicAndPrivateUserCache?.let { userList -> - publicAndPrivateWordCache?.let { wordList -> - onReady( - UsersAndWords(userList, wordList) - ) - } - } - } + fun publicAndPrivateUsersAndWords( + signer: NostrSigner, + onReady: (UsersAndWords) -> Unit, + ) { + publicAndPrivateUserCache?.let { userList -> + publicAndPrivateWordCache?.let { wordList -> + onReady(UsersAndWords(userList, wordList)) + return + } } - fun isTaggedWord(word: String, isPrivate: Boolean, signer: NostrSigner, onReady: (Boolean) -> Unit) = isTagged( "word", word, isPrivate, signer, onReady) + privateTagsOrEmpty(signer) { + publicAndPrivateUserCache = filterTagList("p", it) + publicAndPrivateWordCache = filterTagList("word", it) - fun isTaggedUser(idHex: String, isPrivate: Boolean, signer: NostrSigner, onReady: (Boolean) -> Unit) = isTagged( "p", idHex, isPrivate, signer, onReady) - - companion object { - const val kind = 30000 - const val blockList = "mute" - const val alt = "List of people" - - fun blockListFor(pubKeyHex: HexKey): String { - return "30000:$pubKeyHex:$blockList" + publicAndPrivateUserCache?.let { userList -> + publicAndPrivateWordCache?.let { wordList -> + onReady( + UsersAndWords(userList, wordList), + ) } + } + } + } - fun createListWithTag(name: String, key: String, tag: String, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (PeopleListEvent) -> Unit) { - if (isPrivate) { - encryptTags(arrayOf(arrayOf(key, tag)), signer) { encryptedTags -> - create( - content = encryptedTags, - tags = arrayOf(arrayOf("d", name)), - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - } else { + fun isTaggedWord( + word: String, + isPrivate: Boolean, + signer: NostrSigner, + onReady: (Boolean) -> Unit, + ) = isTagged("word", word, isPrivate, signer, onReady) + + fun isTaggedUser( + idHex: String, + isPrivate: Boolean, + signer: NostrSigner, + onReady: (Boolean) -> Unit, + ) = isTagged("p", idHex, isPrivate, signer, onReady) + + companion object { + const val KIND = 30000 + const val BLOCK_LIST_D_TAG = "mute" + const val ALT = "List of people" + + fun blockListFor(pubKeyHex: HexKey): String { + return "30000:$pubKeyHex:$BLOCK_LIST_D_TAG" + } + + fun createListWithTag( + name: String, + key: String, + tag: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PeopleListEvent) -> Unit, + ) { + if (isPrivate) { + encryptTags(arrayOf(arrayOf(key, tag)), signer) { encryptedTags -> + create( + content = encryptedTags, + tags = arrayOf(arrayOf("d", name)), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } else { + create( + content = "", + tags = arrayOf(arrayOf("d", name), arrayOf(key, tag)), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + + fun createListWithUser( + name: String, + pubKeyHex: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PeopleListEvent) -> Unit, + ) { + return createListWithTag(name, "p", pubKeyHex, isPrivate, signer, createdAt, onReady) + } + + fun createListWithWord( + name: String, + word: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PeopleListEvent) -> Unit, + ) { + return createListWithTag(name, "word", word, isPrivate, signer, createdAt, onReady) + } + + fun addUsers( + earlierVersion: PeopleListEvent, + listPubKeyHex: List, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PeopleListEvent) -> Unit, + ) { + if (isPrivate) { + earlierVersion.privateTagsOrEmpty(signer) { privateTags -> + encryptTags( + privateTags = + privateTags.plus( + listPubKeyHex.map { arrayOf("p", it) }, + ), + signer = signer, + ) { encryptedTags -> + create( + content = encryptedTags, + tags = earlierVersion.tags, + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } else { + create( + content = earlierVersion.content, + tags = + earlierVersion.tags.plus( + listPubKeyHex.map { arrayOf("p", it) }, + ), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + + fun addWord( + earlierVersion: PeopleListEvent, + word: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PeopleListEvent) -> Unit, + ) { + return addTag(earlierVersion, "word", word, isPrivate, signer, createdAt, onReady) + } + + fun addUser( + earlierVersion: PeopleListEvent, + pubKeyHex: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PeopleListEvent) -> Unit, + ) { + return addTag(earlierVersion, "p", pubKeyHex, isPrivate, signer, createdAt, onReady) + } + + fun addTag( + earlierVersion: PeopleListEvent, + key: String, + tag: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PeopleListEvent) -> Unit, + ) { + earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> + if (!isTagged) { + if (isPrivate) { + earlierVersion.privateTagsOrEmpty(signer) { privateTags -> + encryptTags( + privateTags = privateTags.plus(element = arrayOf(key, tag)), + signer = signer, + ) { encryptedTags -> create( - content = "", - tags = arrayOf(arrayOf("d", name), arrayOf(key, tag)), - signer = signer, - createdAt = createdAt, - onReady = onReady + content = encryptedTags, + tags = earlierVersion.tags, + signer = signer, + createdAt = createdAt, + onReady = onReady, ) + } } + } else { + create( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(element = arrayOf(key, tag)), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } } - - fun createListWithUser(name: String, pubKeyHex: String, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (PeopleListEvent) -> Unit) { - return createListWithTag(name, "p", pubKeyHex, isPrivate, signer, createdAt, onReady) - } - - fun createListWithWord(name: String, word: String, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (PeopleListEvent) -> Unit) { - return createListWithTag(name, "word", word, isPrivate, signer, createdAt, onReady) - } - - fun addUsers(earlierVersion: PeopleListEvent, listPubKeyHex: List, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (PeopleListEvent) -> Unit) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = privateTags.plus( - listPubKeyHex.map { - arrayOf("p", it) - } - ), - signer = signer - ) { encryptedTags -> - create( - content = encryptedTags, - tags = earlierVersion.tags, - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = earlierVersion.tags.plus( - listPubKeyHex.map { - arrayOf("p", it) - } - ), - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - } - - fun addWord(earlierVersion: PeopleListEvent, word: String, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (PeopleListEvent) -> Unit) { - return addTag(earlierVersion, "word", word, isPrivate, signer, createdAt, onReady) - } - - fun addUser(earlierVersion: PeopleListEvent, pubKeyHex: String, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (PeopleListEvent) -> Unit) { - return addTag(earlierVersion, "p", pubKeyHex, isPrivate, signer, createdAt, onReady) - } - - fun addTag(earlierVersion: PeopleListEvent, key: String, tag: String, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (PeopleListEvent) -> Unit) { - earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> - if (!isTagged) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = privateTags.plus(element = arrayOf(key, tag)), - signer = signer - ) { encryptedTags -> - create( - content = encryptedTags, - tags = earlierVersion.tags, - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = earlierVersion.tags.plus(element = arrayOf(key, tag)), - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - } - } - } - - fun removeWord(earlierVersion: PeopleListEvent, word: String, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (PeopleListEvent) -> Unit) { - return removeTag(earlierVersion, "word", word, isPrivate, signer, createdAt, onReady) - } - - fun removeUser(earlierVersion: PeopleListEvent, pubKeyHex: String, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (PeopleListEvent) -> Unit) { - return removeTag(earlierVersion, "p", pubKeyHex, isPrivate, signer, createdAt, onReady) - } - - fun removeTag(earlierVersion: PeopleListEvent, key: String, tag: String, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (PeopleListEvent) -> Unit) { - earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> - if (isTagged) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = privateTags.filter { it.size > 1 && !(it[0] == key && it[1] == tag) }.toTypedArray(), - signer = signer - ) { encryptedTags -> - create( - content = encryptedTags, - tags = earlierVersion.tags.filter { it.size > 1 && !(it[0] == key && it[1] == tag) }.toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = earlierVersion.tags.filter { it.size > 1 && !(it[0] == key && it[1] == tag) }.toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady - ) - } - } - } - } - - fun create( - content: String, - tags: Array>, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit - ) { - val newTags = if (tags.any { it.size > 1 && it[0] == "alt" }) { - tags - } else { - tags + arrayOf("alt", alt) - } - - signer.sign(createdAt, kind, newTags, content, onReady) - } + } } + + fun removeWord( + earlierVersion: PeopleListEvent, + word: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PeopleListEvent) -> Unit, + ) { + return removeTag(earlierVersion, "word", word, isPrivate, signer, createdAt, onReady) + } + + fun removeUser( + earlierVersion: PeopleListEvent, + pubKeyHex: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PeopleListEvent) -> Unit, + ) { + return removeTag(earlierVersion, "p", pubKeyHex, isPrivate, signer, createdAt, onReady) + } + + fun removeTag( + earlierVersion: PeopleListEvent, + key: String, + tag: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PeopleListEvent) -> Unit, + ) { + earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> + if (isTagged) { + if (isPrivate) { + earlierVersion.privateTagsOrEmpty(signer) { privateTags -> + encryptTags( + privateTags = + privateTags + .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } + .toTypedArray(), + signer = signer, + ) { encryptedTags -> + create( + content = encryptedTags, + tags = + earlierVersion.tags + .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } + .toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } else { + create( + content = earlierVersion.content, + tags = + earlierVersion.tags + .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } + .toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } + } + + fun create( + content: String, + tags: Array>, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PeopleListEvent) -> Unit, + ) { + val newTags = + if (tags.any { it.size > 1 && it[0] == "alt" }) { + tags + } else { + tags + arrayOf("alt", ALT) + } + + signer.sign(createdAt, KIND, newTags, content, onReady) + } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/PinListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/PinListEvent.kt index 4b08a8c87..93b973bbd 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/PinListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/PinListEvent.kt @@ -1,42 +1,56 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class PinListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun pins() = tags.filter { it.size > 1 && it[0] == "pin" }.map { it[1] } - fun pins() = tags.filter { it.size > 1 && it[0] == "pin" }.map { it[1] } + companion object { + const val KIND = 33888 + const val ALT = "Pinned Posts" - companion object { - const val kind = 33888 - const val alt = "Pinned Posts" + fun create( + pins: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PinListEvent) -> Unit, + ) { + val tags = mutableListOf>() + pins.forEach { tags.add(arrayOf("pin", it)) } + tags.add(arrayOf("alt", ALT)) - fun create( - pins: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (PinListEvent) -> Unit - ) { - val tags = mutableListOf>() - pins.forEach { - tags.add(arrayOf("pin", it)) - } - tags.add(arrayOf("alt", alt)) - - signer.sign(createdAt, kind, tags.toTypedArray(), "", onReady) - } + signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt index 0d8968581..653eae085 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt @@ -1,12 +1,30 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils const val POLL_OPTION = "poll_option" const val VALUE_MAXIMUM = "value_maximum" @@ -16,101 +34,87 @@ const val CLOSED_AT = "closed_at" @Immutable class PollNoteEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - // ots: String?, TODO implement OTS: https://github.com/opentimestamps/java-opentimestamps - content: String, - sig: HexKey -) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) { - fun pollOptions() = - tags.filter { it.size > 2 && it[0] == POLL_OPTION } - .associate { it[1].toInt() to it[2] } + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + // ots: String?, TODO implement OTS: https://github.com/opentimestamps/java-opentimestamps + content: String, + sig: HexKey, +) : BaseTextNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun pollOptions() = + tags.filter { it.size > 2 && it[0] == POLL_OPTION }.associate { it[1].toInt() to it[2] } - fun minimumAmount() = tags.firstOrNull() { it.size > 1 && it[0] == VALUE_MINIMUM }?.getOrNull(1)?.toLongOrNull() - fun maximumAmount() = tags.firstOrNull() { it.size > 1 && it[0] == VALUE_MAXIMUM }?.getOrNull(1)?.toLongOrNull() + fun minimumAmount() = + tags.firstOrNull { it.size > 1 && it[0] == VALUE_MINIMUM }?.getOrNull(1)?.toLongOrNull() - fun getTagLong(property: String): Long? { - val number = tags.firstOrNull() { it.size > 1 && it[0] == property }?.get(1) + fun maximumAmount() = + tags.firstOrNull { it.size > 1 && it[0] == VALUE_MAXIMUM }?.getOrNull(1)?.toLongOrNull() - return if (number.isNullOrBlank() || number == "null") { - null - } else { - number.toLong() - } + fun getTagLong(property: String): Long? { + val number = tags.firstOrNull { it.size > 1 && it[0] == property }?.get(1) + + return if (number.isNullOrBlank() || number == "null") { + null + } else { + number.toLong() } + } - companion object { - const val kind = 6969 - const val alt = "Poll event" + companion object { + const val KIND = 6969 + const val ALT = "Poll event" - fun create( - msg: String, - replyTos: List?, - mentions: List?, - addresses: List?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - pollOptions: Map, - valueMaximum: Int?, - valueMinimum: Int?, - consensusThreshold: Int?, - closedAt: Int?, - zapReceiver: List? = null, - markAsSensitive: Boolean, - zapRaiserAmount: Long?, - geohash: String? = null, - nip94attachments: List? = null, - onReady: (PollNoteEvent) -> Unit - ) { - val tags = mutableListOf>() - replyTos?.forEach { - tags.add(arrayOf("e", it)) - } - mentions?.forEach { - tags.add(arrayOf("p", it)) - } - addresses?.forEach { - tags.add(arrayOf("a", it.toTag())) - } - pollOptions.forEach { poll_op -> - tags.add(arrayOf(POLL_OPTION, poll_op.key.toString(), poll_op.value)) - } - valueMaximum?.let { - tags.add(arrayOf(VALUE_MAXIMUM, valueMaximum.toString())) - } - valueMinimum?.let { - tags.add(arrayOf(VALUE_MINIMUM, valueMinimum.toString())) - } - consensusThreshold?.let { - tags.add(arrayOf(CONSENSUS_THRESHOLD, consensusThreshold.toString())) - } - closedAt?.let { - tags.add(arrayOf(CLOSED_AT, closedAt.toString())) - } - zapReceiver?.forEach { - tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) - } - if (markAsSensitive) { - tags.add(arrayOf("content-warning", "")) - } - zapRaiserAmount?.let { - tags.add(arrayOf("zapraiser", "$it")) - } - geohash?.let { - tags.addAll(geohashMipMap(it)) - } - nip94attachments?.let { - it.forEach { - //tags.add(arrayOf("nip94", it.toJson())) - } - } - tags.add(arrayOf("alt", alt)) - - signer.sign(createdAt, kind, tags.toTypedArray(), msg, onReady) + fun create( + msg: String, + replyTos: List?, + mentions: List?, + addresses: List?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + pollOptions: Map, + valueMaximum: Int?, + valueMinimum: Int?, + consensusThreshold: Int?, + closedAt: Int?, + zapReceiver: List? = null, + markAsSensitive: Boolean, + zapRaiserAmount: Long?, + geohash: String? = null, + nip94attachments: List? = null, + onReady: (PollNoteEvent) -> Unit, + ) { + val tags = mutableListOf>() + replyTos?.forEach { tags.add(arrayOf("e", it)) } + mentions?.forEach { tags.add(arrayOf("p", it)) } + addresses?.forEach { tags.add(arrayOf("a", it.toTag())) } + pollOptions.forEach { poll_op -> + tags.add(arrayOf(POLL_OPTION, poll_op.key.toString(), poll_op.value)) + } + valueMaximum?.let { tags.add(arrayOf(VALUE_MAXIMUM, valueMaximum.toString())) } + valueMinimum?.let { tags.add(arrayOf(VALUE_MINIMUM, valueMinimum.toString())) } + consensusThreshold?.let { + tags.add(arrayOf(CONSENSUS_THRESHOLD, consensusThreshold.toString())) + } + closedAt?.let { tags.add(arrayOf(CLOSED_AT, closedAt.toString())) } + zapReceiver?.forEach { + tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) + } + if (markAsSensitive) { + tags.add(arrayOf("content-warning", "")) + } + zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } + geohash?.let { tags.addAll(geohashMipMap(it)) } + nip94attachments?.let { + it.forEach { + // tags.add(arrayOf("nip94", it.toJson())) } + } + tags.add(arrayOf("alt", ALT)) + + signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) } + } } /* diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt index 26ef0e412..da2f6a251 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt @@ -1,147 +1,161 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events -import android.util.Log import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.crypto.KeyPair +import com.vitorpamplona.quartz.encoders.Hex import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.HexValidator -import com.vitorpamplona.quartz.encoders.Hex -import com.vitorpamplona.quartz.encoders.hexToByteArray import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.persistentSetOf -import java.util.UUID @Immutable class PrivateDmEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig), ChatroomKeyable { - @Transient - private var decryptedContent: Map = mapOf() + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), ChatroomKeyable { + @Transient private var decryptedContent: Map = mapOf() - /** - * This may or may not be the actual recipient's pub key. The event is intended to look like a - * nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used - * for initial messages. - */ - private fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1) + /** + * This may or may not be the actual recipient's pub key. The event is intended to look like a + * nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used for + * initial messages. + */ + private fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1) - fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this) }?.getOrNull() + fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this) }?.getOrNull() - fun verifiedRecipientPubKey(): HexKey? { - val recipient = recipientPubKey() - return if (HexValidator.isHex(recipient)) { - recipient + fun verifiedRecipientPubKey(): HexKey? { + val recipient = recipientPubKey() + return if (HexValidator.isHex(recipient)) { + recipient + } else { + null + } + } + + fun talkingWith(oneSideHex: String): HexKey { + return if (pubKey == oneSideHex) verifiedRecipientPubKey() ?: pubKey else pubKey + } + + override fun chatroomKey(toRemove: String): ChatroomKey { + return ChatroomKey(persistentSetOf(talkingWith(toRemove))) + } + + /** + * To be fully compatible with nip-04, we read e-tags that are in violation to nip-18. + * + * Nip-18 messages should refer to other events by inline references in the content like + * `[](e/c06f795e1234a9a1aecc731d768d4f3ca73e80031734767067c82d67ce82e506). + */ + fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) + + fun with(pubkeyHex: String): Boolean { + return pubkeyHex == pubKey || tags.any { it.size > 1 && it[0] == "p" && it[1] == pubkeyHex } + } + + fun cachedContentFor(signer: NostrSigner): String? { + return decryptedContent[signer.pubKey] + } + + fun plainContent( + signer: NostrSigner, + onReady: (String) -> Unit, + ) { + decryptedContent[signer.pubKey]?.let { + onReady(it) + return + } + + signer.nip04Decrypt(content, talkingWith(signer.pubKey)) { retVal -> + val content = + if (retVal.startsWith(NIP_18_ADVERTISEMENT)) { + retVal.substring(16) } else { - null - } - } - - fun talkingWith(oneSideHex: String): HexKey { - return if (pubKey == oneSideHex) verifiedRecipientPubKey() ?: pubKey else pubKey - } - - override fun chatroomKey(toRemove: String): ChatroomKey { - return ChatroomKey(persistentSetOf(talkingWith(toRemove))) - } - - /** - * To be fully compatible with nip-04, we read e-tags that are in violation to nip-18. - * - * Nip-18 messages should refer to other events by inline references in the content like - * `[](e/c06f795e1234a9a1aecc731d768d4f3ca73e80031734767067c82d67ce82e506). - */ - fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) - - fun with(pubkeyHex: String): Boolean { - return pubkeyHex == pubKey || - tags.any { it.size > 1 && it[0] == "p" && it[1] == pubkeyHex } - } - - fun cachedContentFor(signer: NostrSigner): String? { - return decryptedContent[signer.pubKey] - } - - fun plainContent(signer: NostrSigner, onReady: (String) -> Unit) { - decryptedContent[signer.pubKey]?.let { - onReady(it) - return + retVal } - signer.nip04Decrypt(content, talkingWith(signer.pubKey)) { retVal -> - val content = if (retVal.startsWith(nip18Advertisement)) { - retVal.substring(16) - } else { - retVal - } + decryptedContent = decryptedContent + Pair(signer.pubKey, content) - decryptedContent = decryptedContent + Pair(signer.pubKey, content) - - onReady(content) - } + onReady(content) } + } - companion object { - const val kind = 4 - const val alt = "Private Message" - const val nip18Advertisement = "[//]: # (nip18)\n" + companion object { + const val KIND = 4 + const val ALT = "Private Message" + const val NIP_18_ADVERTISEMENT = "[//]: # (nip18)\n" - fun create( - recipientPubKey: HexKey, - msg: String, - replyTos: List? = null, - mentions: List? = null, - zapReceiver: List? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - publishedRecipientPubKey: HexKey? = null, - advertiseNip18: Boolean = true, - markAsSensitive: Boolean, - zapRaiserAmount: Long?, - geohash: String? = null, - onReady: (PrivateDmEvent) -> Unit - ) { - val message = if (advertiseNip18) { nip18Advertisement } else { "" } + msg - val tags = mutableListOf>() - publishedRecipientPubKey?.let { - tags.add(arrayOf("p", publishedRecipientPubKey)) - } - replyTos?.forEach { - tags.add(arrayOf("e", it)) - } - mentions?.forEach { - tags.add(arrayOf("p", it)) - } - zapReceiver?.forEach { - tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) - } - if (markAsSensitive) { - tags.add(arrayOf("content-warning", "")) - } - zapRaiserAmount?.let { - tags.add(arrayOf("zapraiser", "$it")) - } - geohash?.let { - tags.addAll(geohashMipMap(it)) - } - tags.add(arrayOf("alt", alt)) + fun create( + recipientPubKey: HexKey, + msg: String, + replyTos: List? = null, + mentions: List? = null, + zapReceiver: List? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + publishedRecipientPubKey: HexKey? = null, + advertiseNip18: Boolean = true, + markAsSensitive: Boolean, + zapRaiserAmount: Long?, + geohash: String? = null, + onReady: (PrivateDmEvent) -> Unit, + ) { + val message = + if (advertiseNip18) { + NIP_18_ADVERTISEMENT + } else { + "" + } + msg + val tags = mutableListOf>() + publishedRecipientPubKey?.let { tags.add(arrayOf("p", publishedRecipientPubKey)) } + replyTos?.forEach { tags.add(arrayOf("e", it)) } + mentions?.forEach { tags.add(arrayOf("p", it)) } + zapReceiver?.forEach { + tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) + } + if (markAsSensitive) { + tags.add(arrayOf("content-warning", "")) + } + zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } + geohash?.let { tags.addAll(geohashMipMap(it)) } + tags.add(arrayOf("alt", ALT)) - signer.nip04Encrypt(message, recipientPubKey) { content -> - signer.sign(createdAt, kind, tags.toTypedArray(), content, onReady) - } - } + signer.nip04Encrypt(message, recipientPubKey) { content -> + signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + } } + } } fun geohashMipMap(geohash: String): Array> { - return geohash.indices.asSequence().map { - arrayOf("g", geohash.substring(0, it+1)) - }.toList().reversed().toTypedArray() -} \ No newline at end of file + return geohash.indices + .asSequence() + .map { arrayOf("g", geohash.substring(0, it + 1)) } + .toList() + .reversed() + .toTypedArray() +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ReactionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ReactionEvent.kt index 7663705d9..acfdb6577 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ReactionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ReactionEvent.kt @@ -1,64 +1,105 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class ReactionEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun originalPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } - fun originalPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } - fun originalAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } + fun originalAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } - companion object { - const val kind = 7 + companion object { + const val KIND = 7 - fun createWarning(originalNote: EventInterface, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (ReactionEvent) -> Unit) { - return create("\u26A0\uFE0F", originalNote, signer, createdAt, onReady) - } - - fun createLike(originalNote: EventInterface, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (ReactionEvent) -> Unit) { - return create("+", originalNote, signer, createdAt, onReady) - } - - fun create(content: String, originalNote: EventInterface, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (ReactionEvent) -> Unit) { - var tags = listOf( - arrayOf("e", originalNote.id()), - arrayOf("p", originalNote.pubKey()), - arrayOf("k", originalNote.kind().toString()) - ) - if (originalNote is AddressableEvent) { - tags = tags + listOf(arrayOf("a", originalNote.address().toTag())) - } - - return signer.sign(createdAt, kind, tags.toTypedArray(), content, onReady) - } - - fun create(emojiUrl: EmojiUrl, originalNote: EventInterface, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (ReactionEvent) -> Unit) { - val content = ":${emojiUrl.code}:" - - var tags = arrayOf( - arrayOf("e", originalNote.id()), - arrayOf("p", originalNote.pubKey()), - arrayOf("emoji", emojiUrl.code, emojiUrl.url) - ) - - if (originalNote is AddressableEvent) { - tags += arrayOf(arrayOf("a", originalNote.address().toTag())) - } - - signer.sign(createdAt, kind, tags, content, onReady) - } + fun createWarning( + originalNote: EventInterface, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ReactionEvent) -> Unit, + ) { + return create("\u26A0\uFE0F", originalNote, signer, createdAt, onReady) } + + fun createLike( + originalNote: EventInterface, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ReactionEvent) -> Unit, + ) { + return create("+", originalNote, signer, createdAt, onReady) + } + + fun create( + content: String, + originalNote: EventInterface, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ReactionEvent) -> Unit, + ) { + var tags = + listOf( + arrayOf("e", originalNote.id()), + arrayOf("p", originalNote.pubKey()), + arrayOf("k", originalNote.kind().toString()), + ) + if (originalNote is AddressableEvent) { + tags = tags + listOf(arrayOf("a", originalNote.address().toTag())) + } + + return signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + } + + fun create( + emojiUrl: EmojiUrl, + originalNote: EventInterface, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ReactionEvent) -> Unit, + ) { + val content = ":${emojiUrl.code}:" + + var tags = + arrayOf( + arrayOf("e", originalNote.id()), + arrayOf("p", originalNote.pubKey()), + arrayOf("emoji", emojiUrl.code, emojiUrl.url), + ) + + if (originalNote is AddressableEvent) { + tags += arrayOf(arrayOf("a", originalNote.address().toTag())) + } + + signer.sign(createdAt, KIND, tags, content, onReady) + } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/RecommendRelayEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/RecommendRelayEvent.kt index 783502ee5..e53d51719 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/RecommendRelayEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/RecommendRelayEvent.kt @@ -1,36 +1,53 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils import java.net.URI @Immutable class RecommendRelayEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun relay() = URI.create(content.trim()) - fun relay() = URI.create(content.trim()) + companion object { + const val KIND = 2 - companion object { - const val kind = 2 - - fun create( - relay: URI, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (RecommendRelayEvent) -> Unit - ) { - val content = relay.toString() - signer.sign(createdAt, kind, emptyArray(), content, onReady) - } + fun create( + relay: URI, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (RecommendRelayEvent) -> Unit, + ) { + val content = relay.toString() + signer.sign(createdAt, KIND, emptyArray(), content, onReady) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/RelayAuthEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/RelayAuthEvent.kt index eaf88c323..82d1efb1a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/RelayAuthEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/RelayAuthEvent.kt @@ -1,40 +1,60 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class RelayAuthEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun relay() = tags.firstOrNull() { it.size > 1 && it[0] == "relay" }?.get(1) - fun challenge() = tags.firstOrNull() { it.size > 1 && it[0] == "challenge" }?.get(1) + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun relay() = tags.firstOrNull { it.size > 1 && it[0] == "relay" }?.get(1) - companion object { - const val kind = 22242 + fun challenge() = tags.firstOrNull { it.size > 1 && it[0] == "challenge" }?.get(1) - fun create( - relay: String, - challenge: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (RelayAuthEvent) -> Unit - ) { - val content = "" - val tags = arrayOf( - arrayOf("relay", relay), - arrayOf("challenge", challenge) - ) - signer.sign(createdAt, kind, tags, content, onReady) - } + companion object { + const val KIND = 22242 + + fun create( + relay: String, + challenge: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (RelayAuthEvent) -> Unit, + ) { + val content = "" + val tags = + arrayOf( + arrayOf("relay", relay), + arrayOf("challenge", challenge), + ) + signer.sign(createdAt, KIND, tags, content, onReady) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/RelaySetEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/RelaySetEvent.kt index c462fa14b..ecf2e76f6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/RelaySetEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/RelaySetEvent.kt @@ -1,42 +1,58 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class RelaySetEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { - fun relays() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] } - fun description() = tags.firstOrNull() { it.size > 1 && it[0] == "description" }?.get(1) + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun relays() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] } - companion object { - const val kind = 30022 - const val alt = "Relay list" + fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1) - fun create( - relays: List, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (RelaySetEvent) -> Unit - ) { - val tags = mutableListOf>() - relays.forEach { - tags.add(arrayOf("r", it)) - } - tags.add(arrayOf("alt", alt)) + companion object { + const val KIND = 30022 + const val ALT = "Relay list" - signer.sign(createdAt, kind, tags.toTypedArray(), "", onReady) - } + fun create( + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (RelaySetEvent) -> Unit, + ) { + val tags = mutableListOf>() + relays.forEach { tags.add(arrayOf("r", it)) } + tags.add(arrayOf("alt", ALT)) + + signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ReportEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ReportEvent.kt index 7e84fa2dd..bb3e54fc1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ReportEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ReportEvent.kt @@ -1,105 +1,130 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils -@Immutable -data class ReportedKey(val key: String, val reportType: ReportEvent.ReportType) +@Immutable data class ReportedKey(val key: String, val reportType: ReportEvent.ReportType) // NIP 56 event. @Immutable class ReportEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + private fun defaultReportType(): ReportType { + // Works with old and new structures for report. + var reportType = + tags + .filter { it.firstOrNull() == "report" } + .mapNotNull { it.getOrNull(1) } + .map { ReportType.valueOf(it.uppercase()) } + .firstOrNull() + if (reportType == null) { + reportType = + tags.mapNotNull { it.getOrNull(2) }.map { ReportType.valueOf(it.uppercase()) }.firstOrNull() + } + if (reportType == null) { + reportType = ReportType.SPAM + } + return reportType + } - private fun defaultReportType(): ReportType { - // Works with old and new structures for report. - var reportType = tags.filter { it.firstOrNull() == "report" }.mapNotNull { it.getOrNull(1) }.map { ReportType.valueOf(it.uppercase()) }.firstOrNull() - if (reportType == null) { - reportType = tags.mapNotNull { it.getOrNull(2) }.map { ReportType.valueOf(it.uppercase()) }.firstOrNull() - } - if (reportType == null) { - reportType = ReportType.SPAM - } - return reportType + fun reportedPost() = + tags + .filter { it.size > 1 && it[0] == "e" } + .map { + ReportedKey( + it[1], + it.getOrNull(2)?.uppercase()?.let { it1 -> ReportType.valueOf(it1) } + ?: defaultReportType(), + ) + } + + fun reportedAuthor() = + tags + .filter { it.size > 1 && it[0] == "p" } + .map { + ReportedKey( + it[1], + it.getOrNull(2)?.uppercase()?.let { it1 -> ReportType.valueOf(it1) } + ?: defaultReportType(), + ) + } + + companion object { + const val KIND = 1984 + + fun create( + reportedPost: EventInterface, + type: ReportType, + signer: NostrSigner, + content: String = "", + createdAt: Long = TimeUtils.now(), + onReady: (ReportEvent) -> Unit, + ) { + val reportPostTag = arrayOf("e", reportedPost.id(), type.name.lowercase()) + val reportAuthorTag = arrayOf("p", reportedPost.pubKey(), type.name.lowercase()) + + var tags: Array> = arrayOf(reportPostTag, reportAuthorTag) + + if (reportedPost is AddressableEvent) { + tags += listOf(arrayOf("a", reportedPost.address().toTag())) + } + + tags += listOf(arrayOf("alt", "Report for ${type.name}")) + + signer.sign(createdAt, KIND, tags, content, onReady) } - fun reportedPost() = tags - .filter { it.size > 1 && it[0] == "e" } - .map { - ReportedKey( - it[1], - it.getOrNull(2)?.uppercase()?.let { it1 -> ReportType.valueOf(it1) } ?: defaultReportType() - ) - } + fun create( + reportedUser: String, + type: ReportType, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ReportEvent) -> Unit, + ) { + val content = "" - fun reportedAuthor() = tags - .filter { it.size > 1 && it[0] == "p" } - .map { - ReportedKey( - it[1], - it.getOrNull(2)?.uppercase()?.let { it1 -> ReportType.valueOf(it1) } ?: defaultReportType() - ) - } + val reportAuthorTag = arrayOf("p", reportedUser, type.name.lowercase()) + val alt = arrayOf("alt", "Report for ${type.name}") - companion object { - const val kind = 1984 - - fun create( - reportedPost: EventInterface, - type: ReportType, - signer: NostrSigner, - content: String = "", - createdAt: Long = TimeUtils.now(), - onReady: (ReportEvent) -> Unit - ) { - val reportPostTag = arrayOf("e", reportedPost.id(), type.name.lowercase()) - val reportAuthorTag = arrayOf("p", reportedPost.pubKey(), type.name.lowercase()) - - var tags: Array> = arrayOf(reportPostTag, reportAuthorTag) - - if (reportedPost is AddressableEvent) { - tags += listOf(arrayOf("a", reportedPost.address().toTag())) - } - - tags += listOf(arrayOf("alt", "Report for ${type.name}")) - - signer.sign(createdAt, kind, tags, content, onReady) - } - - fun create( - reportedUser: String, - type: ReportType, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ReportEvent) -> Unit - ) { - val content = "" - - val reportAuthorTag = arrayOf("p", reportedUser, type.name.lowercase()) - val alt = arrayOf("alt", "Report for ${type.name}") - - val tags: Array> = arrayOf(reportAuthorTag, alt) - signer.sign(createdAt, kind, tags, content, onReady) - } + val tags: Array> = arrayOf(reportAuthorTag, alt) + signer.sign(createdAt, KIND, tags, content, onReady) } + } - enum class ReportType() { - EXPLICIT, // Not used anymore. - ILLEGAL, - SPAM, - IMPERSONATION, - NUDITY, - PROFANITY - } + enum class ReportType() { + EXPLICIT, // Not used anymore. + ILLEGAL, + SPAM, + IMPERSONATION, + NUDITY, + PROFANITY, + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/RepostEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/RepostEvent.kt index 0722bf053..1e021627e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/RepostEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/RepostEvent.kt @@ -1,56 +1,74 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class RepostEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun boostedPost() = taggedEvents() - fun boostedPost() = taggedEvents() - fun originalAuthor() = taggedUsers() + fun originalAuthor() = taggedUsers() - fun containedPost() = try { - fromJson(content) + fun containedPost() = + try { + fromJson(content) } catch (e: Exception) { - null + null } - companion object { - const val kind = 6 - const val alt = "Repost event" + companion object { + const val KIND = 6 + const val ALT = "Repost event" - fun create( - boostedPost: EventInterface, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (RepostEvent) -> Unit - ) { - val content = boostedPost.toJson() + fun create( + boostedPost: EventInterface, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (RepostEvent) -> Unit, + ) { + val content = boostedPost.toJson() - val replyToPost = arrayOf("e", boostedPost.id()) - val replyToAuthor = arrayOf("p", boostedPost.pubKey()) + val replyToPost = arrayOf("e", boostedPost.id()) + val replyToAuthor = arrayOf("p", boostedPost.pubKey()) - var tags: Array> = arrayOf(replyToPost, replyToAuthor) + var tags: Array> = arrayOf(replyToPost, replyToAuthor) - if (boostedPost is AddressableEvent) { - tags += listOf(arrayOf("a", boostedPost.address().toTag())) - } + if (boostedPost is AddressableEvent) { + tags += listOf(arrayOf("a", boostedPost.address().toTag())) + } - tags += listOf(arrayOf("alt", alt)) + tags += listOf(arrayOf("alt", ALT)) - signer.sign(createdAt, kind, tags, content, onReady) - } + signer.sign(createdAt, KIND, tags, content, onReady) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/SealedGossipEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/SealedGossipEvent.kt index bffc87fe0..19075ad8d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/SealedGossipEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/SealedGossipEvent.kt @@ -1,120 +1,149 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import android.util.Log import androidx.compose.runtime.Immutable import com.fasterxml.jackson.annotation.JsonProperty -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class SealedGossipEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -): WrappedEvent(id, pubKey, createdAt, kind, tags, content, sig) { - @Transient - private var cachedInnerEvent: Map = mapOf() + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : WrappedEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + @Transient private var cachedInnerEvent: Map = mapOf() - fun cachedGossip(signer: NostrSigner, onReady: (Event) -> Unit) { - cachedInnerEvent[signer.pubKey]?.let { - onReady(it) - return - } - - unseal(signer) { gossip -> - val event = gossip.mergeWith(this) - if (event is WrappedEvent) { - event.host = host ?: this - } - - cachedInnerEvent = cachedInnerEvent + Pair(signer.pubKey, event) - onReady(event) - } + fun cachedGossip( + signer: NostrSigner, + onReady: (Event) -> Unit, + ) { + cachedInnerEvent[signer.pubKey]?.let { + onReady(it) + return } - private fun unseal(signer: NostrSigner, onReady: (Gossip) -> Unit) { + unseal(signer) { gossip -> + val event = gossip.mergeWith(this) + if (event is WrappedEvent) { + event.host = host ?: this + } + + cachedInnerEvent = cachedInnerEvent + Pair(signer.pubKey, event) + onReady(event) + } + } + + private fun unseal( + signer: NostrSigner, + onReady: (Gossip) -> Unit, + ) { + try { + plainContent(signer) { try { - plainContent(signer) { - try { - onReady(Gossip.fromJson(it)) - } catch (e: Exception) { - Log.w("GossipEvent", "Fail to decrypt or parse Gossip", e) - } - } + onReady(Gossip.fromJson(it)) } catch (e: Exception) { - Log.w("GossipEvent", "Fail to decrypt or parse Gossip", e) + Log.w("GossipEvent", "Fail to decrypt or parse Gossip", e) } + } + } catch (e: Exception) { + Log.w("GossipEvent", "Fail to decrypt or parse Gossip", e) + } + } + + private fun plainContent( + signer: NostrSigner, + onReady: (String) -> Unit, + ) { + if (content.isEmpty()) return + + signer.nip44Decrypt(content, pubKey, onReady) + } + + companion object { + const val KIND = 13 + + fun create( + event: Event, + encryptTo: HexKey, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (SealedGossipEvent) -> Unit, + ) { + val gossip = Gossip.create(event) + create(gossip, encryptTo, signer, createdAt, onReady) } - private fun plainContent(signer: NostrSigner, onReady: (String) -> Unit) { - if (content.isEmpty()) return + fun create( + gossip: Gossip, + encryptTo: HexKey, + signer: NostrSigner, + createdAt: Long = TimeUtils.randomWithinAWeek(), + onReady: (SealedGossipEvent) -> Unit, + ) { + val msg = Gossip.toJson(gossip) - signer.nip44Decrypt(content, pubKey, onReady) - } - - companion object { - const val kind = 13 - - fun create( - event: Event, - encryptTo: HexKey, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (SealedGossipEvent) -> Unit - ) { - val gossip = Gossip.create(event) - create(gossip, encryptTo, signer, createdAt, onReady) - } - - fun create( - gossip: Gossip, - encryptTo: HexKey, - signer: NostrSigner, - createdAt: Long = TimeUtils.randomWithinAWeek(), - onReady: (SealedGossipEvent) -> Unit - ) { - val msg = Gossip.toJson(gossip) - - signer.nip44Encrypt(msg, encryptTo) { content -> - signer.sign(createdAt, kind, emptyArray(), content, onReady) - } - } + signer.nip44Encrypt(msg, encryptTo) { content -> + signer.sign(createdAt, KIND, emptyArray(), content, onReady) + } } + } } class Gossip( - val id: HexKey?, - @JsonProperty("pubkey") - val pubKey: HexKey?, - @JsonProperty("created_at") - val createdAt: Long?, - val kind: Int?, - val tags: Array>?, - val content: String? + val id: HexKey?, + @JsonProperty("pubkey") val pubKey: HexKey?, + @JsonProperty("created_at") val createdAt: Long?, + val kind: Int?, + val tags: Array>?, + val content: String?, ) { - fun mergeWith(event: SealedGossipEvent): Event { - val newPubKey = pubKey?.ifBlank { null } ?: event.pubKey - val newCreatedAt = if (createdAt != null && createdAt > 1000) createdAt else event.createdAt - val newKind = kind ?: -1 - val newTags = (tags ?: emptyArray()).plus(event.tags) - val newContent = content ?: "" - val newID = id?.ifBlank { null } ?: Event.generateId(newPubKey, newCreatedAt, newKind, newTags, newContent).toHexKey() - val sig = "" + fun mergeWith(event: SealedGossipEvent): Event { + val newPubKey = pubKey?.ifBlank { null } ?: event.pubKey + val newCreatedAt = if (createdAt != null && createdAt > 1000) createdAt else event.createdAt + val newKind = kind ?: -1 + val newTags = (tags ?: emptyArray()).plus(event.tags) + val newContent = content ?: "" + val newID = + id?.ifBlank { null } + ?: Event.generateId(newPubKey, newCreatedAt, newKind, newTags, newContent).toHexKey() + val sig = "" - return EventFactory.create(newID, newPubKey, newCreatedAt, newKind, newTags, newContent, sig) - } - - companion object { - fun fromJson(json: String): Gossip = Event.mapper.readValue(json, Gossip::class.java) - fun toJson(event: Gossip): String = Event.mapper.writeValueAsString(event) - - fun create(event: Event): Gossip { - return Gossip(event.id, event.pubKey, event.createdAt, event.kind, event.tags, event.content) - } + return EventFactory.create(newID, newPubKey, newCreatedAt, newKind, newTags, newContent, sig) + } + + companion object { + fun fromJson(json: String): Gossip = Event.mapper.readValue(json, Gossip::class.java) + + fun toJson(event: Gossip): String = Event.mapper.writeValueAsString(event) + + fun create(event: Event): Gossip { + return Gossip(event.id, event.pubKey, event.createdAt, event.kind, event.tags, event.content) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/StatusEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/StatusEvent.kt index bc7c961b0..c47de3d78 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/StatusEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/StatusEvent.kt @@ -1,63 +1,78 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.crypto.KeyPair -import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class StatusEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 30315 - companion object { - const val kind = 30315 + fun create( + msg: String, + type: String, + expiration: Long?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (StatusEvent) -> Unit, + ) { + val tags = mutableListOf>() - fun create( - msg: String, - type: String, - expiration: Long?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (StatusEvent) -> Unit - ) { - val tags = mutableListOf>() + tags.add(arrayOf("d", type)) + expiration?.let { tags.add(arrayOf("expiration", it.toString())) } - tags.add(arrayOf("d", type)) - expiration?.let { tags.add(arrayOf("expiration", it.toString())) } - - signer.sign(createdAt, kind, tags.toTypedArray(), msg, onReady) - } - - fun update( - event: StatusEvent, - newStatus: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (StatusEvent) -> Unit - ) { - val tags = event.tags - signer.sign(createdAt, kind, tags, newStatus, onReady) - } - - fun clear( - event: StatusEvent, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (StatusEvent) -> Unit - ) { - val msg = "" - val tags = event.tags.filter { it.size > 1 && it[0] == "d" } - signer.sign(createdAt, kind, tags.toTypedArray(), msg, onReady) - } + signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) } + + fun update( + event: StatusEvent, + newStatus: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (StatusEvent) -> Unit, + ) { + val tags = event.tags + signer.sign(createdAt, KIND, tags, newStatus, onReady) + } + + fun clear( + event: StatusEvent, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (StatusEvent) -> Unit, + ) { + val msg = "" + val tags = event.tags.filter { it.size > 1 && it[0] == "d" } + signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt index 32d008e2e..5ded187a2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt @@ -1,142 +1,155 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable import com.linkedin.urls.detection.UrlDetector import com.linkedin.urls.detection.UrlDetectorOptions -import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class TextNoteEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) { + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseTextNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun root() = tags.firstOrNull { it.size > 3 && it[3] == "root" }?.get(1) - fun root() = tags.firstOrNull() { it.size > 3 && it[3] == "root" }?.get(1) + companion object { + const val KIND = 1 - companion object { - const val kind = 1 - - fun create( - msg: String, - replyTos: List?, - mentions: List?, - addresses: List?, - extraTags: List?, - zapReceiver: List? = null, - markAsSensitive: Boolean, - zapRaiserAmount: Long?, - replyingTo: String?, - root: String?, - directMentions: Set, - geohash: String? = null, - nip94attachments: List? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (TextNoteEvent) -> Unit - ) { - val tags = mutableListOf>() - replyTos?.let { - tags.addAll( - it.positionalMarkedTags( - tagName = "e", - root = root, - replyingTo = replyingTo, - directMentions = directMentions - ) - ) - } - mentions?.forEach { - if (it in directMentions) { - tags.add(arrayOf("p", it, "", "mention")) - } else { - tags.add(arrayOf("p", it)) - } - } - addresses?.map { it.toTag() }?.let { - tags.addAll( - it.positionalMarkedTags( - tagName = "a", - root = root, - replyingTo = replyingTo, - directMentions = directMentions - ) - ) - } - findHashtags(msg).forEach { - tags.add(arrayOf("t", it)) - tags.add(arrayOf("t", it.lowercase())) - } - extraTags?.forEach { - tags.add(arrayOf("t", it)) - } - zapReceiver?.forEach { - tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) - } - findURLs(msg).forEach { - tags.add(arrayOf("r", it)) - } - if (markAsSensitive) { - tags.add(arrayOf("content-warning", "")) - } - zapRaiserAmount?.let { - tags.add(arrayOf("zapraiser", "$it")) - } - geohash?.let { - tags.addAll(geohashMipMap(it)) - } - nip94attachments?.let { - it.forEach { - //tags.add(arrayOf("nip94", it.toJson())) - } - } - - signer.sign(createdAt, kind, tags.toTypedArray(), msg, onReady) + fun create( + msg: String, + replyTos: List?, + mentions: List?, + addresses: List?, + extraTags: List?, + zapReceiver: List? = null, + markAsSensitive: Boolean, + zapRaiserAmount: Long?, + replyingTo: String?, + root: String?, + directMentions: Set, + geohash: String? = null, + nip94attachments: List? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (TextNoteEvent) -> Unit, + ) { + val tags = mutableListOf>() + replyTos?.let { + tags.addAll( + it.positionalMarkedTags( + tagName = "e", + root = root, + replyingTo = replyingTo, + directMentions = directMentions, + ), + ) + } + mentions?.forEach { + if (it in directMentions) { + tags.add(arrayOf("p", it, "", "mention")) + } else { + tags.add(arrayOf("p", it)) } + } + addresses + ?.map { it.toTag() } + ?.let { + tags.addAll( + it.positionalMarkedTags( + tagName = "a", + root = root, + replyingTo = replyingTo, + directMentions = directMentions, + ), + ) + } + findHashtags(msg).forEach { + tags.add(arrayOf("t", it)) + tags.add(arrayOf("t", it.lowercase())) + } + extraTags?.forEach { tags.add(arrayOf("t", it)) } + zapReceiver?.forEach { + tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) + } + findURLs(msg).forEach { tags.add(arrayOf("r", it)) } + if (markAsSensitive) { + tags.add(arrayOf("content-warning", "")) + } + zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } + geohash?.let { tags.addAll(geohashMipMap(it)) } + nip94attachments?.let { + it.forEach { + // tags.add(arrayOf("nip94", it.toJson())) + } + } - /** - * Returns a list of NIP-10 marked tags that are also ordered at best effort - * to support the deprecated method of positional tags to maximize backwards compatibility - * with clients that support replies but have not been updated to understand tag markers. - * - * https://github.com/nostr-protocol/nips/blob/master/10.md - * - * The tag to the root of the reply chain goes first. - * The tag to the reply event being responded to goes last. - * The order for any other tag does not matter, so keep the relative order. - */ - private fun List.positionalMarkedTags( - tagName: String, - root: String?, - replyingTo: String?, - directMentions: Set - ) = - sortedWith { o1, o2 -> - when { - o1 == o2 -> 0 - o1 == root -> -1 // root goes first - o2 == root -> 1 // root goes first - o1 == replyingTo -> 1 // reply event being responded to goes last - o2 == replyingTo -> -1 // reply event being responded to goes last - else -> 0 // keep the relative order for any other tag - } - }.map { - when (it) { - root -> arrayOf(tagName, it, "", "root") - replyingTo -> arrayOf(tagName, it, "", "reply") - in directMentions -> arrayOf(tagName, it, "", "mention") - else -> arrayOf(tagName, it) - } - } + signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) } + + /** + * Returns a list of NIP-10 marked tags that are also ordered at best effort to support the + * deprecated method of positional tags to maximize backwards compatibility with clients that + * support replies but have not been updated to understand tag markers. + * + * https://github.com/nostr-protocol/nips/blob/master/10.md + * + * The tag to the root of the reply chain goes first. The tag to the reply event being responded + * to goes last. The order for any other tag does not matter, so keep the relative order. + */ + private fun List.positionalMarkedTags( + tagName: String, + root: String?, + replyingTo: String?, + directMentions: Set, + ) = + sortedWith { o1, o2 -> + when { + o1 == o2 -> 0 + o1 == root -> -1 // root goes first + o2 == root -> 1 // root goes first + o1 == replyingTo -> 1 // reply event being responded to goes last + o2 == replyingTo -> -1 // reply event being responded to goes last + else -> 0 // keep the relative order for any other tag + } + } + .map { + when (it) { + root -> arrayOf(tagName, it, "", "root") + replyingTo -> arrayOf(tagName, it, "", "reply") + in directMentions -> arrayOf(tagName, it, "", "mention") + else -> arrayOf(tagName, it) + } + } + } } fun findURLs(text: String): List { - return UrlDetector(text, UrlDetectorOptions.Default).detect().map { it.originalUrl } + return UrlDetector(text, UrlDetectorOptions.Default).detect().map { it.originalUrl } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt index 705bfc290..797f0c67c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt @@ -1,104 +1,134 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable abstract class VideoEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - sig: HexKey + id: HexKey, + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { + fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1) - fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1) - fun urls() = tags.filter { it.size > 1 && it[0] == URL }.map { it[1] } + fun urls() = tags.filter { it.size > 1 && it[0] == URL }.map { it[1] } - 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 alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1) - fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.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) + fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1) - fun title() = tags.firstOrNull { it.size > 1 && it[0] == TITLE }?.get(1) - fun summary() = tags.firstOrNull { it.size > 1 && it[0] == SUMMARY }?.get(1) - fun image() = tags.firstOrNull { it.size > 1 && it[0] == IMAGE }?.get(1) - fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == THUMB }?.get(1) + fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1) - fun hasUrl() = tags.any { it.size > 1 && it[0] == URL } + fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1) - companion object { - 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 DIMENSION = "dim" - private const val HASH = "x" - private const val MAGNET_URI = "magnet" - private const val TORRENT_INFOHASH = "i" - private const val BLUR_HASH = "blurhash" - private const val ORIGINAL_HASH = "ox" - private const val ALT = "alt" - private const val TITLE = "title" - private const val PUBLISHED_AT = "published_at" - private const val SUMMARY = "summary" - private const val DURATION = "duration" - private const val IMAGE = "image" - private const val THUMB = "thumb" + fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1) - fun create( - kind: Int, - url: String, - magnetUri: String? = null, - mimeType: String? = null, - alt: String? = null, - hash: String? = null, - size: String? = null, - dimensions: String? = null, - blurhash: String? = null, - originalHash: String? = null, - magnetURI: String? = null, - torrentInfoHash: String? = null, - encryptionKey: AESGCM? = null, - sensitiveContent: Boolean? = null, - altDescription: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (FileHeaderEvent) -> Unit - ) { - val tags = listOfNotNull( - arrayOf(URL, url), - magnetUri?.let { arrayOf(MAGNET_URI, it) }, - mimeType?.let { arrayOf(MIME_TYPE, it) }, - alt?.ifBlank { null }?.let { arrayOf(ALT, it) } ?: arrayOf("alt", altDescription), - hash?.let { arrayOf(HASH, it) }, - size?.let { arrayOf(FILE_SIZE, it) }, - dimensions?.let { arrayOf(DIMENSION, it) }, - blurhash?.let { arrayOf(BLUR_HASH, it) }, - originalHash?.let { arrayOf(ORIGINAL_HASH, it) }, - magnetURI?.let { arrayOf(MAGNET_URI, it) }, + fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1) - torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) }, - encryptionKey?.let { arrayOf(ENCRYPTION_KEY, it.key, it.nonce) }, - sensitiveContent?.let { - if (it) { - arrayOf("content-warning", "") - } else { - null - } - } - ) + fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1) - val content = alt ?: "" - signer.sign(createdAt, kind, tags.toTypedArray(), content, onReady) - } + 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) + + fun title() = tags.firstOrNull { it.size > 1 && it[0] == TITLE }?.get(1) + + fun summary() = tags.firstOrNull { it.size > 1 && it[0] == SUMMARY }?.get(1) + + fun image() = tags.firstOrNull { it.size > 1 && it[0] == IMAGE }?.get(1) + + fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == THUMB }?.get(1) + + fun hasUrl() = tags.any { it.size > 1 && it[0] == URL } + + companion object { + 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 DIMENSION = "dim" + private const val HASH = "x" + private const val MAGNET_URI = "magnet" + private const val TORRENT_INFOHASH = "i" + private const val BLUR_HASH = "blurhash" + private const val ORIGINAL_HASH = "ox" + private const val ALT = "alt" + private const val TITLE = "title" + private const val PUBLISHED_AT = "published_at" + private const val SUMMARY = "summary" + private const val DURATION = "duration" + private const val IMAGE = "image" + private const val THUMB = "thumb" + + fun create( + kind: Int, + url: String, + magnetUri: String? = null, + mimeType: String? = null, + alt: String? = null, + hash: String? = null, + size: String? = null, + dimensions: String? = null, + blurhash: String? = null, + originalHash: String? = null, + magnetURI: String? = null, + torrentInfoHash: String? = null, + encryptionKey: AESGCM? = null, + sensitiveContent: Boolean? = null, + altDescription: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (FileHeaderEvent) -> Unit, + ) { + val tags = + listOfNotNull( + arrayOf(URL, url), + magnetUri?.let { arrayOf(MAGNET_URI, it) }, + mimeType?.let { arrayOf(MIME_TYPE, it) }, + alt?.ifBlank { null }?.let { arrayOf(ALT, it) } ?: arrayOf("alt", altDescription), + hash?.let { arrayOf(HASH, it) }, + size?.let { arrayOf(FILE_SIZE, it) }, + dimensions?.let { arrayOf(DIMENSION, it) }, + blurhash?.let { arrayOf(BLUR_HASH, it) }, + originalHash?.let { arrayOf(ORIGINAL_HASH, it) }, + magnetURI?.let { arrayOf(MAGNET_URI, it) }, + torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) }, + encryptionKey?.let { arrayOf(ENCRYPTION_KEY, it.key, it.nonce) }, + sensitiveContent?.let { + if (it) { + arrayOf("content-warning", "") + } else { + null + } + }, + ) + + val content = alt ?: "" + signer.sign(createdAt, kind, tags.toTypedArray(), content, onReady) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoHorizontalEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoHorizontalEvent.kt index 42a6cfa04..f580a188a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoHorizontalEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoHorizontalEvent.kt @@ -1,62 +1,81 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class VideoHorizontalEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : VideoEvent(id, pubKey, createdAt, kind, tags, content, sig) { + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : VideoEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 34235 + const val ALT_DESCRIPTION = "Horizontal Video" - companion object { - const val kind = 34235 - const val altDescription = "Horizontal Video" - - fun create( - url: String, - magnetUri: String? = null, - mimeType: String? = null, - alt: String? = null, - hash: String? = null, - size: String? = null, - dimensions: String? = null, - blurhash: String? = null, - originalHash: String? = null, - magnetURI: String? = null, - torrentInfoHash: String? = null, - encryptionKey: AESGCM? = null, - sensitiveContent: Boolean? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (FileHeaderEvent) -> Unit - ) { - create( - kind, - url, - magnetUri, - mimeType, - alt, - hash, - size, - dimensions, - blurhash, - originalHash, - magnetURI, - torrentInfoHash, - encryptionKey, - sensitiveContent, - altDescription, - signer, - createdAt, - onReady - ) - } + fun create( + url: String, + magnetUri: String? = null, + mimeType: String? = null, + alt: String? = null, + hash: String? = null, + size: String? = null, + dimensions: String? = null, + blurhash: String? = null, + originalHash: String? = null, + magnetURI: String? = null, + torrentInfoHash: String? = null, + encryptionKey: AESGCM? = null, + sensitiveContent: Boolean? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (FileHeaderEvent) -> Unit, + ) { + create( + KIND, + url, + magnetUri, + mimeType, + alt, + hash, + size, + dimensions, + blurhash, + originalHash, + magnetURI, + torrentInfoHash, + encryptionKey, + sensitiveContent, + ALT_DESCRIPTION, + signer, + createdAt, + onReady, + ) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoVerticalEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoVerticalEvent.kt index 9f87a0696..2b7e1b195 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoVerticalEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoVerticalEvent.kt @@ -1,61 +1,81 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class VideoVerticalEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : VideoEvent(id, pubKey, createdAt, kind, tags, content, sig) { - companion object { - const val kind = 34236 - const val altDescription = "Vertical Video" + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : VideoEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 34236 + const val ALT_DESCRIPTION = "Vertical Video" - fun create( - url: String, - magnetUri: String? = null, - mimeType: String? = null, - alt: String? = null, - hash: String? = null, - size: String? = null, - dimensions: String? = null, - blurhash: String? = null, - originalHash: String? = null, - magnetURI: String? = null, - torrentInfoHash: String? = null, - encryptionKey: AESGCM? = null, - sensitiveContent: Boolean? = null, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (FileHeaderEvent) -> Unit - ) { - create( - kind, - url, - magnetUri, - mimeType, - alt, - hash, - size, - dimensions, - blurhash, - originalHash, - magnetURI, - torrentInfoHash, - encryptionKey, - sensitiveContent, - altDescription, - signer, - createdAt, - onReady - ) - } + fun create( + url: String, + magnetUri: String? = null, + mimeType: String? = null, + alt: String? = null, + hash: String? = null, + size: String? = null, + dimensions: String? = null, + blurhash: String? = null, + originalHash: String? = null, + magnetURI: String? = null, + torrentInfoHash: String? = null, + encryptionKey: AESGCM? = null, + sensitiveContent: Boolean? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (FileHeaderEvent) -> Unit, + ) { + create( + KIND, + url, + magnetUri, + mimeType, + alt, + hash, + size, + dimensions, + blurhash, + originalHash, + magnetURI, + torrentInfoHash, + encryptionKey, + sensitiveContent, + ALT_DESCRIPTION, + signer, + createdAt, + onReady, + ) } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoViewEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoViewEvent.kt index 57c20b8cc..aeddeadcb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoViewEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoViewEvent.kt @@ -1,63 +1,81 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class VideoViewEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey -) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 34237 - companion object { - const val kind = 34237 + fun create( + video: ATag, + signer: NostrSigner, + viewStart: Long?, + viewEnd: Long?, + createdAt: Long = TimeUtils.now(), + onReady: (VideoViewEvent) -> Unit, + ) { + val tags = mutableListOf>() - fun create( - video: ATag, - signer: NostrSigner, - viewStart: Long?, - viewEnd: Long?, - createdAt: Long = TimeUtils.now(), - onReady: (VideoViewEvent) -> Unit - ) { - val tags = mutableListOf>() + val aTag = video.toTag() + tags.add(arrayOf("d", aTag)) + tags.add(arrayOf("a", aTag)) + if (viewEnd != null) { + tags.add(arrayOf("viewed", viewStart?.toString() ?: "0", viewEnd.toString())) + } else { + tags.add(arrayOf("viewed", viewStart?.toString() ?: "0")) + } - val aTag = video.toTag() - tags.add(arrayOf("d", aTag)) - tags.add(arrayOf("a", aTag)) - if (viewEnd != null) - tags.add(arrayOf("viewed", viewStart?.toString() ?: "0", viewEnd.toString())) - else - tags.add(arrayOf("viewed", viewStart?.toString() ?: "0")) - - signer.sign(createdAt, kind, tags.toTypedArray(), "", onReady) - } - - fun addViewedTime( - event: VideoViewEvent, - viewStart: Long?, - viewEnd: Long?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (VideoViewEvent) -> Unit - ) { - val tags = event.tags.toMutableList() - if (viewEnd != null) - tags.add(arrayOf("viewed", viewStart?.toString() ?: "0", viewEnd.toString())) - else - tags.add(arrayOf("viewed", viewStart?.toString() ?: "0")) - - signer.sign(createdAt, kind, tags.toTypedArray(), "", onReady) - } + signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) } + + fun addViewedTime( + event: VideoViewEvent, + viewStart: Long?, + viewEnd: Long?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (VideoViewEvent) -> Unit, + ) { + val tags = event.tags.toMutableList() + if (viewEnd != null) { + tags.add(arrayOf("viewed", viewStart?.toString() ?: "0", viewEnd.toString())) + } else { + tags.add(arrayOf("viewed", viewStart?.toString() ?: "0")) + } + + signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady) + } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/signers/ExternalSignerLauncher.kt b/quartz/src/main/java/com/vitorpamplona/quartz/signers/ExternalSignerLauncher.kt index e719e9c0b..300e348c1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/signers/ExternalSignerLauncher.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/signers/ExternalSignerLauncher.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.signers import android.content.ContentResolver @@ -18,305 +38,343 @@ import com.vitorpamplona.quartz.events.EventInterface import com.vitorpamplona.quartz.events.LnZapRequestEvent import org.json.JSONArray - enum class SignerType { - SIGN_EVENT, - NIP04_ENCRYPT, - NIP04_DECRYPT, - NIP44_ENCRYPT, - NIP44_DECRYPT, - GET_PUBLIC_KEY, - DECRYPT_ZAP_EVENT + SIGN_EVENT, + NIP04_ENCRYPT, + NIP04_DECRYPT, + NIP44_ENCRYPT, + NIP44_DECRYPT, + GET_PUBLIC_KEY, + DECRYPT_ZAP_EVENT, } class Permission( - val type: String, - val kind: Int? = null + val type: String, + val kind: Int? = null, ) { - fun toJson(): String { - return "{\"type\":\"${type}\",\"kind\":${kind}}" - } + fun toJson(): String { + return "{\"type\":\"${type}\",\"kind\":$kind}" + } } class Result( - @JsonProperty("package") - val `package`: String?, - @JsonProperty("signature") - val signature: String?, - @JsonProperty("id") - val id: String? + @JsonProperty("package") val `package`: String?, + @JsonProperty("signature") val signature: String?, + @JsonProperty("id") val id: String?, ) { - companion object { - val mapper = jacksonObjectMapper() - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - .registerModule( - SimpleModule() - .addDeserializer(Result::class.java, ResultDeserializer()) - ) + companion object { + val mapper = + jacksonObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .registerModule( + SimpleModule().addDeserializer(Result::class.java, ResultDeserializer()), + ) - private class ResultDeserializer : StdDeserializer(Result::class.java) { - override fun deserialize(jp: JsonParser, ctxt: DeserializationContext): Result { - val jsonObject: JsonNode = jp.codec.readTree(jp) - return Result( - jsonObject.get("package").asText().intern(), - jsonObject.get("signature").asText().intern(), - jsonObject.get("id").asText().intern() - ) - } - } - - fun fromJson(json: String): Result = mapper.readValue(json, Result::class.java) - - fun fromJsonArray(json: String): Array { - val result: MutableList = mutableListOf() - val array = JSONArray(json) - (0 until array.length()).forEach { - val resultJson = array.getJSONObject(it) - val localResult = fromJson(resultJson.toString()) - result.add(localResult) - } - return result.toTypedArray() - } + private class ResultDeserializer : StdDeserializer(Result::class.java) { + override fun deserialize( + jp: JsonParser, + ctxt: DeserializationContext, + ): Result { + val jsonObject: JsonNode = jp.codec.readTree(jp) + return Result( + jsonObject.get("package").asText().intern(), + jsonObject.get("signature").asText().intern(), + jsonObject.get("id").asText().intern(), + ) + } } + + fun fromJson(json: String): Result = mapper.readValue(json, Result::class.java) + + fun fromJsonArray(json: String): Array { + val result: MutableList = mutableListOf() + val array = JSONArray(json) + (0 until array.length()).forEach { + val resultJson = array.getJSONObject(it) + val localResult = fromJson(resultJson.toString()) + result.add(localResult) + } + return result.toTypedArray() + } + } } class ExternalSignerLauncher( - private val npub: String, - val signerPackageName: String = "com.greenart7c3.nostrsigner" + private val npub: String, + val signerPackageName: String = "com.greenart7c3.nostrsigner", ) { - private val contentCache = LruCache Unit>(20) + private val contentCache = LruCache Unit>(20) - private var signerAppLauncher: ((Intent) -> Unit)? = null - private var contentResolver: (() -> ContentResolver)? = null + private var signerAppLauncher: ((Intent) -> Unit)? = null + private var contentResolver: (() -> ContentResolver)? = null - /** - * Call this function when the launcher becomes available on activity, fragment or compose - */ - fun registerLauncher( - launcher: ((Intent) -> Unit), - contentResolver: (() -> ContentResolver), - ) { - this.signerAppLauncher = launcher - this.contentResolver = contentResolver - } + /** Call this function when the launcher becomes available on activity, fragment or compose */ + fun registerLauncher( + launcher: ((Intent) -> Unit), + contentResolver: (() -> ContentResolver), + ) { + this.signerAppLauncher = launcher + this.contentResolver = contentResolver + } - /** - * Call this function when the activity is destroyed or is about to be replaced. - */ - fun clearLauncher() { - this.signerAppLauncher = null - this.contentResolver = null - } + /** Call this function when the activity is destroyed or is about to be replaced. */ + fun clearLauncher() { + this.signerAppLauncher = null + this.contentResolver = null + } - fun newResult(data: Intent) { - val results = data.getStringExtra("results") - if (results != null) { - val localResults: Array = Result.fromJsonArray(results) - localResults.forEach { - val signature = it.signature ?: "" - val packageName = it.`package` ?: "" - val id = it.id ?: "" - if (id.isNotBlank()) { - val result = if (packageName.isNotBlank()) "$signature-$packageName" else signature - val contentCache = contentCache.get(id) - contentCache?.invoke(result) - } - } - } else { - val signature = data.getStringExtra("signature") ?: "" - val packageName = data.getStringExtra("package") ?: "" - val id = data.getStringExtra("id") ?: "" - if (id.isNotBlank()) { - val result = if (packageName.isNotBlank()) "$signature-$packageName" else signature - val contentCache = contentCache.get(id) - contentCache?.invoke(result) - } + fun newResult(data: Intent) { + val results = data.getStringExtra("results") + if (results != null) { + val localResults: Array = Result.fromJsonArray(results) + localResults.forEach { + val signature = it.signature ?: "" + val packageName = it.`package` ?: "" + val id = it.id ?: "" + if (id.isNotBlank()) { + val result = if (packageName.isNotBlank()) "$signature-$packageName" else signature + val contentCache = contentCache.get(id) + contentCache?.invoke(result) } + } + } else { + val signature = data.getStringExtra("signature") ?: "" + val packageName = data.getStringExtra("package") ?: "" + val id = data.getStringExtra("id") ?: "" + if (id.isNotBlank()) { + val result = if (packageName.isNotBlank()) "$signature-$packageName" else signature + val contentCache = contentCache.get(id) + contentCache?.invoke(result) + } + } + } + + fun openSignerApp( + data: String, + type: SignerType, + pubKey: HexKey, + id: String, + onReady: (String) -> Unit, + ) { + signerAppLauncher?.let { + openSignerApp( + data, + type, + it, + pubKey, + id, + onReady, + ) + } + } + + private fun defaultPermissions(): String { + val permissions = + listOf( + Permission( + "sign_event", + 22242, + ), + Permission( + "nip04_encrypt", + ), + Permission( + "nip04_decrypt", + ), + Permission( + "nip44_encrypt", + ), + Permission( + "nip44_decrypt", + ), + Permission( + "decrypt_zap_event", + ), + ) + val jsonArray = StringBuilder("[") + permissions.forEachIndexed { index, permission -> + jsonArray.append(permission.toJson()) + if (index < permissions.size - 1) { + jsonArray.append(",") + } + } + jsonArray.append("]") + + return jsonArray.toString() + } + + private fun openSignerApp( + data: String, + type: SignerType, + intentLauncher: (Intent) -> Unit, + pubKey: HexKey, + id: String, + onReady: (String) -> Unit, + ) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:$data")) + val signerType = + when (type) { + SignerType.SIGN_EVENT -> "sign_event" + SignerType.NIP04_ENCRYPT -> "nip04_encrypt" + SignerType.NIP04_DECRYPT -> "nip04_decrypt" + SignerType.NIP44_ENCRYPT -> "nip44_encrypt" + SignerType.NIP44_DECRYPT -> "nip44_decrypt" + SignerType.GET_PUBLIC_KEY -> "get_public_key" + SignerType.DECRYPT_ZAP_EVENT -> "decrypt_zap_event" + } + intent.putExtra("type", signerType) + intent.putExtra("pubKey", pubKey) + intent.putExtra("id", id) + if (type !== SignerType.GET_PUBLIC_KEY) { + intent.putExtra("current_user", npub) + } else { + intent.putExtra("permissions", defaultPermissions()) + } + if (signerPackageName.isNotBlank()) { + intent.`package` = signerPackageName } - fun openSignerApp( - data: String, - type: SignerType, - pubKey: HexKey, - id: String, - onReady: (String)-> Unit - ) { - signerAppLauncher?.let { - openSignerApp( - data, type, it, pubKey, id, onReady - ) - } - } + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) - private fun defaultPermissions(): String { - val permissions = listOf( - Permission( - "sign_event", - 22242 - ), - Permission( - "nip04_encrypt" - ), - Permission( - "nip04_decrypt" - ), - Permission( - "nip44_encrypt" - ), - Permission( - "nip44_decrypt" - ), - Permission( - "decrypt_zap_event" - ), + contentCache.put(id, onReady) + + intentLauncher(intent) + } + + fun openSigner( + event: EventInterface, + columnName: String = "signature", + onReady: (String) -> Unit, + ) { + val result = + getDataFromResolver( + SignerType.SIGN_EVENT, + arrayOf(event.toJson(), event.pubKey()), + columnName, + ) + if (result == null) { + openSignerApp( + event.toJson(), + SignerType.SIGN_EVENT, + "", + event.id(), + onReady, + ) + } else { + onReady(result) + } + } + + fun getDataFromResolver( + signerType: SignerType, + data: Array, + columnName: String = "signature", + ): String? { + return getDataFromResolver(signerType, data, columnName, contentResolver) + } + + fun getDataFromResolver( + signerType: SignerType, + data: Array, + columnName: String = "signature", + contentResolver: (() -> ContentResolver)? = null, + ): String? { + val localData = + if (signerType !== SignerType.GET_PUBLIC_KEY) { + data.toList().plus(npub).toTypedArray() + } else { + data + } + + try { + contentResolver + ?.let { it() } + ?.query( + Uri.parse("content://$signerPackageName.$signerType"), + localData, + null, + null, + null, ) - val jsonArray = StringBuilder("[") - permissions.forEachIndexed { index, permission -> - jsonArray.append(permission.toJson()) - if (index < permissions.size - 1) { - jsonArray.append(",") - } - } - jsonArray.append("]") - - return jsonArray.toString() - } - - private fun openSignerApp( - data: String, - type: SignerType, - intentLauncher: (Intent) -> Unit, - pubKey: HexKey, - id: String, - onReady: (String)-> Unit - ) { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:$data")) - val signerType = when (type) { - SignerType.SIGN_EVENT -> "sign_event" - SignerType.NIP04_ENCRYPT -> "nip04_encrypt" - SignerType.NIP04_DECRYPT -> "nip04_decrypt" - SignerType.NIP44_ENCRYPT -> "nip44_encrypt" - SignerType.NIP44_DECRYPT -> "nip44_decrypt" - SignerType.GET_PUBLIC_KEY -> "get_public_key" - SignerType.DECRYPT_ZAP_EVENT -> "decrypt_zap_event" - } - intent.putExtra("type", signerType) - intent.putExtra("pubKey", pubKey) - intent.putExtra("id", id) - if (type !== SignerType.GET_PUBLIC_KEY) { - intent.putExtra("current_user", npub) - } else { - intent.putExtra("permissions", defaultPermissions()) - } - if (signerPackageName.isNotBlank()) { - intent.`package` = signerPackageName - } - - intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) - - contentCache.put(id, onReady) - - intentLauncher(intent) - } - - fun openSigner(event: EventInterface, columnName: String = "signature", onReady: (String)-> Unit) { - val result = getDataFromResolver(SignerType.SIGN_EVENT, arrayOf(event.toJson(), event.pubKey()), columnName) - if (result == null) { - openSignerApp( - event.toJson(), - SignerType.SIGN_EVENT, - "", - event.id(), - onReady - ) - } else { - onReady(result) - } - } - - fun getDataFromResolver(signerType: SignerType, data: Array, columnName: String = "signature"): String? { - return getDataFromResolver(signerType, data, columnName, contentResolver) - } - - fun getDataFromResolver(signerType: SignerType, data: Array, columnName: String = "signature", contentResolver: (() -> ContentResolver)? = null): String? { - val localData = if (signerType !== SignerType.GET_PUBLIC_KEY) { - data.toList().plus(npub).toTypedArray() - } else { - data - } - - try { - contentResolver?.let { it() }?.query( - Uri.parse("content://${signerPackageName}.$signerType"), - localData, - null, - null, - null - ).use { - if (it == null) { - return null - } - if (it.moveToFirst()) { - val index = it.getColumnIndex(columnName) - if (index < 0) { - Log.d("getDataFromResolver", "column '$columnName' not found") - return null - } - return it.getString(index) - } - } - } catch (e: Exception) { - Log.e("ExternalSignerLauncher", "Failed to query the Signer app in the background") + .use { + if (it == null) { return null + } + if (it.moveToFirst()) { + val index = it.getColumnIndex(columnName) + if (index < 0) { + Log.d("getDataFromResolver", "column '$columnName' not found") + return null + } + return it.getString(index) + } } - - return null + } catch (e: Exception) { + Log.e("ExternalSignerLauncher", "Failed to query the Signer app in the background") + return null } - fun decrypt(encryptedContent: String, pubKey: HexKey, signerType: SignerType = SignerType.NIP04_DECRYPT, onReady: (String)-> Unit) { - val id = (encryptedContent + pubKey + onReady.toString()).hashCode().toString() - val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey)) - if (result == null) { - openSignerApp( - encryptedContent, - signerType, - pubKey, - id, - onReady - ) - } else { - onReady(result) - } - } + return null + } - fun encrypt(decryptedContent: String, pubKey: HexKey, signerType: SignerType = SignerType.NIP04_ENCRYPT, onReady: (String)-> Unit) { - val id = (decryptedContent + pubKey + onReady.toString()).hashCode().toString() - val result = getDataFromResolver(signerType, arrayOf(decryptedContent, pubKey)) - if (result == null) { - openSignerApp( - decryptedContent, - signerType, - pubKey, - id, - onReady - ) - } else { - onReady(result) - } + fun decrypt( + encryptedContent: String, + pubKey: HexKey, + signerType: SignerType = SignerType.NIP04_DECRYPT, + onReady: (String) -> Unit, + ) { + val id = (encryptedContent + pubKey + onReady.toString()).hashCode().toString() + val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey)) + if (result == null) { + openSignerApp( + encryptedContent, + signerType, + pubKey, + id, + onReady, + ) + } else { + onReady(result) } + } - fun decryptZapEvent(event: LnZapRequestEvent, onReady: (String)-> Unit) { - val result = getDataFromResolver(SignerType.DECRYPT_ZAP_EVENT, arrayOf(event.toJson(), event.pubKey)) - if (result == null) { - openSignerApp( - event.toJson(), - SignerType.DECRYPT_ZAP_EVENT, - event.pubKey, - event.id, - onReady - ) - } else { - onReady(result) - } + fun encrypt( + decryptedContent: String, + pubKey: HexKey, + signerType: SignerType = SignerType.NIP04_ENCRYPT, + onReady: (String) -> Unit, + ) { + val id = (decryptedContent + pubKey + onReady.toString()).hashCode().toString() + val result = getDataFromResolver(signerType, arrayOf(decryptedContent, pubKey)) + if (result == null) { + openSignerApp( + decryptedContent, + signerType, + pubKey, + id, + onReady, + ) + } else { + onReady(result) } + } + + fun decryptZapEvent( + event: LnZapRequestEvent, + onReady: (String) -> Unit, + ) { + val result = + getDataFromResolver(SignerType.DECRYPT_ZAP_EVENT, arrayOf(event.toJson(), event.pubKey)) + if (result == null) { + openSignerApp( + event.toJson(), + SignerType.DECRYPT_ZAP_EVENT, + event.pubKey, + event.id, + onReady, + ) + } else { + onReady(result) + } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSigner.kt b/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSigner.kt index ea469a21e..fa1e39249 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSigner.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSigner.kt @@ -1,24 +1,65 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.signers import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.events.Event -import com.vitorpamplona.quartz.events.EventFactory import com.vitorpamplona.quartz.events.LnZapPrivateEvent import com.vitorpamplona.quartz.events.LnZapRequestEvent -import com.vitorpamplona.quartz.events.PeopleListEvent -import kotlinx.collections.immutable.ImmutableSet -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext abstract class NostrSigner(val pubKey: HexKey) { + abstract fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + onReady: (T) -> Unit, + ) - abstract fun sign(createdAt: Long, kind: Int, tags: Array>, content: String, onReady: (T) -> Unit) + abstract fun nip04Encrypt( + decryptedContent: String, + toPublicKey: HexKey, + onReady: (String) -> Unit, + ) - abstract fun nip04Encrypt(decryptedContent: String, toPublicKey: HexKey, onReady: (String)-> Unit) - abstract fun nip04Decrypt(encryptedContent: String, fromPublicKey: HexKey, onReady: (String)-> Unit) + abstract fun nip04Decrypt( + encryptedContent: String, + fromPublicKey: HexKey, + onReady: (String) -> Unit, + ) - abstract fun nip44Encrypt(decryptedContent: String, toPublicKey: HexKey, onReady: (String)-> Unit) - abstract fun nip44Decrypt(encryptedContent: String, fromPublicKey: HexKey, onReady: (String)-> Unit) + abstract fun nip44Encrypt( + decryptedContent: String, + toPublicKey: HexKey, + onReady: (String) -> Unit, + ) - abstract fun decryptZapEvent(event: LnZapRequestEvent, onReady: (LnZapPrivateEvent)-> Unit) -} \ No newline at end of file + abstract fun nip44Decrypt( + encryptedContent: String, + fromPublicKey: HexKey, + onReady: (String) -> Unit, + ) + + abstract fun decryptZapEvent( + event: LnZapRequestEvent, + onReady: (LnZapPrivateEvent) -> Unit, + ) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerExternal.kt b/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerExternal.kt index 59cafcc06..ae63c2774 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerExternal.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerExternal.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.signers import android.util.Log @@ -11,117 +31,130 @@ import com.vitorpamplona.quartz.events.LnZapPrivateEvent import com.vitorpamplona.quartz.events.LnZapRequestEvent class NostrSignerExternal( - pubKey: HexKey, - val launcher: ExternalSignerLauncher = ExternalSignerLauncher(pubKey.hexToByteArray().toNpub()), -): NostrSigner(pubKey) { + pubKey: HexKey, + val launcher: ExternalSignerLauncher = ExternalSignerLauncher(pubKey.hexToByteArray().toNpub()), +) : NostrSigner(pubKey) { + override fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + onReady: (T) -> Unit, + ) { + val id = Event.generateId(pubKey, createdAt, kind, tags, content).toHexKey() - override fun sign( - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - onReady: (T) -> Unit - ) { - val id = Event.generateId(pubKey, createdAt, kind, tags, content).toHexKey() + val event = + Event( + id = id, + pubKey = pubKey, + createdAt = createdAt, + kind = kind, + tags = tags, + content = content, + sig = "", + ) - val event = Event( - id = id, - pubKey = pubKey, - createdAt = createdAt, - kind = kind, - tags = tags, - content = content, - sig = "" - ) + launcher.openSigner(event) { signature -> + if (signature.startsWith("{")) { + val localEvent = Event.fromJson(signature) + (EventFactory.create( + localEvent.id, + localEvent.pubKey, + localEvent.createdAt, + localEvent.kind, + localEvent.tags, + localEvent.content, + localEvent.sig, + ) as? T?) + ?.let { onReady(it) } + } else { + (EventFactory.create( + event.id, + event.pubKey, + event.createdAt, + event.kind, + event.tags, + event.content, + signature, + ) as? T?) + ?.let { onReady(it) } + } + } + } - launcher.openSigner(event) { signature -> - if (signature.startsWith("{")) { - val localEvent = Event.fromJson(signature) - (EventFactory.create( - localEvent.id, - localEvent.pubKey, - localEvent.createdAt, - localEvent.kind, - localEvent.tags, - localEvent.content, - localEvent.sig - ) as? T?)?.let { - onReady(it) - } - } else { - (EventFactory.create( - event.id, - event.pubKey, - event.createdAt, - event.kind, - event.tags, - event.content, - signature - ) as? T?)?.let { - onReady(it) - } - } + override fun nip04Encrypt( + decryptedContent: String, + toPublicKey: HexKey, + onReady: (String) -> Unit, + ) { + Log.d("NostrExternalSigner", "Encrypt NIP04 Event: $decryptedContent") + return launcher.encrypt( + decryptedContent, + toPublicKey, + SignerType.NIP04_ENCRYPT, + onReady, + ) + } + + override fun nip04Decrypt( + encryptedContent: String, + fromPublicKey: HexKey, + onReady: (String) -> Unit, + ) { + Log.d("NostrExternalSigner", "Decrypt NIP04 Event: $encryptedContent") + + return launcher.decrypt( + encryptedContent, + fromPublicKey, + SignerType.NIP04_DECRYPT, + onReady, + ) + } + + override fun nip44Encrypt( + decryptedContent: String, + toPublicKey: HexKey, + onReady: (String) -> Unit, + ) { + Log.d("NostrExternalSigner", "Encrypt NIP44 Event: $decryptedContent") + + return launcher.encrypt( + decryptedContent, + toPublicKey, + SignerType.NIP44_ENCRYPT, + onReady, + ) + } + + override fun nip44Decrypt( + encryptedContent: String, + fromPublicKey: HexKey, + onReady: (String) -> Unit, + ) { + Log.d("NostrExternalSigner", "Decrypt NIP44 Event: $encryptedContent") + + return launcher.decrypt( + encryptedContent, + fromPublicKey, + SignerType.NIP44_DECRYPT, + onReady, + ) + } + + override fun decryptZapEvent( + event: LnZapRequestEvent, + onReady: (LnZapPrivateEvent) -> Unit, + ) { + return launcher.decryptZapEvent(event) { + val event = + try { + Event.fromJson(it) + } catch (e: Exception) { + Log.e("NostrExternalSigner", "Unable to parse returned decrypted Zap: $it") + null } + (event as? LnZapPrivateEvent)?.let { onReady(event) } } - - override fun nip04Encrypt(decryptedContent: String, toPublicKey: HexKey, onReady: (String)-> Unit) { - Log.d("NostrExternalSigner", "Encrypt NIP04 Event: ${decryptedContent}") - - return launcher.encrypt( - decryptedContent, - toPublicKey, - SignerType.NIP04_ENCRYPT, - onReady - ) - } - - override fun nip04Decrypt(encryptedContent: String, fromPublicKey: HexKey, onReady: (String)-> Unit) { - Log.d("NostrExternalSigner", "Decrypt NIP04 Event: ${encryptedContent}") - - return launcher.decrypt( - encryptedContent, - fromPublicKey, - SignerType.NIP04_DECRYPT, - onReady - ) - } - - override fun nip44Encrypt(decryptedContent: String, toPublicKey: HexKey, onReady: (String)-> Unit) { - Log.d("NostrExternalSigner", "Encrypt NIP44 Event: ${decryptedContent}") - - return launcher.encrypt( - decryptedContent, - toPublicKey, - SignerType.NIP44_ENCRYPT, - onReady - ) - } - - override fun nip44Decrypt(encryptedContent: String, fromPublicKey: HexKey, onReady: (String)-> Unit) { - Log.d("NostrExternalSigner", "Decrypt NIP44 Event: ${encryptedContent}") - - return launcher.decrypt( - encryptedContent, - fromPublicKey, - SignerType.NIP44_DECRYPT, - onReady - ) - } - - override fun decryptZapEvent(event: LnZapRequestEvent, onReady: (LnZapPrivateEvent)-> Unit) { - return launcher.decryptZapEvent(event) { - val event = try { - Event.fromJson(it) - } catch( e: Exception) { - Log.e("NostrExternalSigner", "Unable to parse returned decrypted Zap: ${it}") - null - } - (event as? LnZapPrivateEvent)?.let { - onReady(event) - } - } - } - - -} \ No newline at end of file + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerInternal.kt b/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerInternal.kt index c9799cdaa..a749df1a4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerInternal.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerInternal.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.signers import android.util.Log @@ -11,197 +31,225 @@ import com.vitorpamplona.quartz.events.EventFactory import com.vitorpamplona.quartz.events.LnZapPrivateEvent import com.vitorpamplona.quartz.events.LnZapRequestEvent -class NostrSignerInternal(val keyPair: KeyPair): NostrSigner(keyPair.pubKey.toHexKey()) { - override fun sign( - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - onReady: (T)-> Unit - ) { - if (keyPair.privKey == null) return +class NostrSignerInternal(val keyPair: KeyPair) : NostrSigner(keyPair.pubKey.toHexKey()) { + override fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + onReady: (T) -> Unit, + ) { + if (keyPair.privKey == null) return - if (isUnsignedPrivateEvent(kind, tags)) { - // this is a private zap - signPrivateZap(createdAt, kind, tags, content, onReady) - } else { - signNormal(createdAt, kind, tags, content, onReady) - } + if (isUnsignedPrivateEvent(kind, tags)) { + // this is a private zap + signPrivateZap(createdAt, kind, tags, content, onReady) + } else { + signNormal(createdAt, kind, tags, content, onReady) } + } - fun isUnsignedPrivateEvent( - kind: Int, - tags: Array>, - ): Boolean { - return kind == LnZapRequestEvent.kind && tags.any { t -> t.size > 1 && t[0] == "anon" && t[1].isBlank() } + fun isUnsignedPrivateEvent( + kind: Int, + tags: Array>, + ): Boolean { + return kind == LnZapRequestEvent.KIND && + tags.any { t -> t.size > 1 && t[0] == "anon" && t[1].isBlank() } + } + + fun signNormal( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + onReady: (T) -> Unit, + ) { + if (keyPair.privKey == null) return + + val id = Event.generateId(pubKey, createdAt, kind, tags, content) + val sig = CryptoUtils.sign(id, keyPair.privKey).toHexKey() + + onReady( + EventFactory.create( + id.toHexKey(), + pubKey, + createdAt, + kind, + tags, + content, + sig, + ) as T, + ) + } + + override fun nip04Encrypt( + decryptedContent: String, + toPublicKey: HexKey, + onReady: (String) -> Unit, + ) { + if (keyPair.privKey == null) return + + onReady( + CryptoUtils.encryptNIP04( + decryptedContent, + keyPair.privKey, + toPublicKey.hexToByteArray(), + ), + ) + } + + override fun nip04Decrypt( + encryptedContent: String, + fromPublicKey: HexKey, + onReady: (String) -> Unit, + ) { + if (keyPair.privKey == null) return + + try { + val sharedSecret = + CryptoUtils.getSharedSecretNIP04(keyPair.privKey, fromPublicKey.hexToByteArray()) + + onReady(CryptoUtils.decryptNIP04(encryptedContent, sharedSecret)) + } catch (e: Exception) { + Log.w("NIP04Decrypt", "Error decrypting the message ${e.message} on $encryptedContent") } + } - fun signNormal( - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - onReady: (T)-> Unit - ) { - if (keyPair.privKey == null) return + override fun nip44Encrypt( + decryptedContent: String, + toPublicKey: HexKey, + onReady: (String) -> Unit, + ) { + if (keyPair.privKey == null) return - val id = Event.generateId(pubKey, createdAt, kind, tags, content) - val sig = CryptoUtils.sign(id, keyPair.privKey).toHexKey() - - onReady( - EventFactory.create( - id.toHexKey(), - pubKey, - createdAt, - kind, - tags, - content, - sig - ) as T // Must never crash + onReady( + CryptoUtils.encryptNIP44v2( + decryptedContent, + keyPair.privKey, + toPublicKey.hexToByteArray(), ) + .encodePayload(), + ) + } + + override fun nip44Decrypt( + encryptedContent: String, + fromPublicKey: HexKey, + onReady: (String) -> Unit, + ) { + if (keyPair.privKey == null) return + + CryptoUtils.decryptNIP44( + payload = encryptedContent, + privateKey = keyPair.privKey, + pubKey = fromPublicKey.hexToByteArray(), + ) + ?.let { onReady(it) } + } + + private fun signPrivateZap( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + onReady: (T) -> Unit, + ) { + if (keyPair.privKey == null) return + + val zappedEvent = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.let { it[1] } + val userHex = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.let { it[1] } ?: return + + // if it is a Zap for an Event, use event.id if not, use the user's pubkey + val idToGeneratePrivateKey = zappedEvent ?: userHex + + val encryptionPrivateKey = + LnZapRequestEvent.createEncryptionPrivateKey( + keyPair.privKey.toHexKey(), + idToGeneratePrivateKey, + createdAt, + ) + + val fullTagsNoAnon = tags.filter { t -> t.getOrNull(0) != "anon" }.toTypedArray() + + LnZapPrivateEvent.create(this, fullTagsNoAnon, content) { + val noteJson = it.toJson() + val encryptedContent = + LnZapRequestEvent.encryptPrivateZapMessage( + noteJson, + encryptionPrivateKey, + userHex.hexToByteArray(), + ) + + val newTags = + tags.filter { t -> t.getOrNull(0) != "anon" } + listOf(arrayOf("anon", encryptedContent)) + val newContent = "" + + NostrSignerInternal(KeyPair(encryptionPrivateKey)) + .signNormal(createdAt, kind, newTags.toTypedArray(), newContent, onReady) } + } - override fun nip04Encrypt(decryptedContent: String, toPublicKey: HexKey, onReady: (String)-> Unit) { - if (keyPair.privKey == null) return + override fun decryptZapEvent( + event: LnZapRequestEvent, + onReady: (LnZapPrivateEvent) -> Unit, + ) { + if (keyPair.privKey == null) return - onReady( - CryptoUtils.encryptNIP04( - decryptedContent, - keyPair.privKey, - toPublicKey.hexToByteArray() + val recipientPK = event.zappedAuthor().firstOrNull() + val recipientPost = event.zappedPost().firstOrNull() + val privateEvent = + if (recipientPK == pubKey) { + // if the receiver is logged in, these are the params. + val privateKeyToUse = keyPair.privKey + val pubkeyToUse = event.pubKey + + event.getPrivateZapEvent(privateKeyToUse, pubkeyToUse) + } else { + // if the sender is logged in, these are the params + val altPubkeyToUse = recipientPK + val altPrivateKeyToUse = + if (recipientPost != null) { + LnZapRequestEvent.createEncryptionPrivateKey( + keyPair.privKey.toHexKey(), + recipientPost, + event.createdAt, ) - ) - } - - override fun nip04Decrypt(encryptedContent: String, fromPublicKey: HexKey, onReady: (String)-> Unit) { - if (keyPair.privKey == null) return + } else if (recipientPK != null) { + LnZapRequestEvent.createEncryptionPrivateKey( + keyPair.privKey.toHexKey(), + recipientPK, + event.createdAt, + ) + } else { + null + } try { - val sharedSecret = CryptoUtils.getSharedSecretNIP04(keyPair.privKey, fromPublicKey.hexToByteArray()) + if (altPrivateKeyToUse != null && altPubkeyToUse != null) { + val altPubKeyFromPrivate = CryptoUtils.pubkeyCreate(altPrivateKeyToUse).toHexKey() - onReady(CryptoUtils.decryptNIP04(encryptedContent, sharedSecret)) - } catch (e: Exception) { - Log.w("NIP04Decrypt", "Error decrypting the message ${e.message} on ${encryptedContent}") - } - } + if (altPubKeyFromPrivate == event.pubKey) { + val result = event.getPrivateZapEvent(altPrivateKeyToUse, altPubkeyToUse) - override fun nip44Encrypt(decryptedContent: String, toPublicKey: HexKey, onReady: (String)-> Unit) { - if (keyPair.privKey == null) return - - onReady( - CryptoUtils.encryptNIP44v2( - decryptedContent, - keyPair.privKey, - toPublicKey.hexToByteArray() - ).encodePayload() - ) - } - - override fun nip44Decrypt(encryptedContent: String, fromPublicKey: HexKey, onReady: (String)-> Unit) { - if (keyPair.privKey == null) return - - CryptoUtils.decryptNIP44( - payload = encryptedContent, - privateKey = keyPair.privKey, - pubKey = fromPublicKey.hexToByteArray() - )?.let { - onReady(it) - } - } - - private fun signPrivateZap( - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - onReady: (T)-> Unit - ) { - if (keyPair.privKey == null) return - - val zappedEvent = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.let { it[1] } - val userHex = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.let { it[1] } ?: return - - // if it is a Zap for an Event, use event.id if not, use the user's pubkey - val idToGeneratePrivateKey = zappedEvent ?: userHex - - val encryptionPrivateKey = - LnZapRequestEvent.createEncryptionPrivateKey(keyPair.privKey.toHexKey(), idToGeneratePrivateKey, createdAt) - - val fullTagsNoAnon = tags.filter { t -> t.getOrNull(0) != "anon" }.toTypedArray() - - LnZapPrivateEvent.create(this, fullTagsNoAnon, content) { - val noteJson = it.toJson() - val encryptedContent = LnZapRequestEvent.encryptPrivateZapMessage( - noteJson, - encryptionPrivateKey, - userHex.hexToByteArray() - ) - - val newTags = tags.filter { t -> t.getOrNull(0) != "anon" } + listOf(arrayOf("anon", encryptedContent)) - val newContent = "" - - NostrSignerInternal(KeyPair(encryptionPrivateKey)).signNormal(createdAt, kind, newTags.toTypedArray(), newContent, onReady) - } - } - - override fun decryptZapEvent(event: LnZapRequestEvent, onReady: (LnZapPrivateEvent)-> Unit) { - if (keyPair.privKey == null) return - - val recipientPK = event.zappedAuthor().firstOrNull() - val recipientPost = event.zappedPost().firstOrNull() - val privateEvent = if (recipientPK == pubKey) { - // if the receiver is logged in, these are the params. - val privateKeyToUse = keyPair.privKey - val pubkeyToUse = event.pubKey - - event.getPrivateZapEvent(privateKeyToUse, pubkeyToUse) - } else { - // if the sender is logged in, these are the params - val altPubkeyToUse = recipientPK - val altPrivateKeyToUse = if (recipientPost != null) { - LnZapRequestEvent.createEncryptionPrivateKey( - keyPair.privKey.toHexKey(), - recipientPost, - event.createdAt - ) - } else if (recipientPK != null) { - LnZapRequestEvent.createEncryptionPrivateKey( - keyPair.privKey.toHexKey(), - recipientPK, - event.createdAt + if (result == null) { + Log.w( + "Private ZAP Decrypt", + "Fail to decrypt Zap from ${event.id}", ) + } + result } else { - null - } - - try { - if (altPrivateKeyToUse != null && altPubkeyToUse != null) { - val altPubKeyFromPrivate = CryptoUtils.pubkeyCreate(altPrivateKeyToUse).toHexKey() - - if (altPubKeyFromPrivate == event.pubKey) { - val result = event.getPrivateZapEvent(altPrivateKeyToUse, altPubkeyToUse) - - if (result == null) { - Log.w( - "Private ZAP Decrypt", - "Fail to decrypt Zap from ${event.id}" - ) - } - result - } else { - null - } - } else { - null - } - } catch (e: Exception) { - Log.e("Account", "Failed to create pubkey for ZapRequest ${event.id}", e) - null + null } + } else { + null + } + } catch (e: Exception) { + Log.e("Account", "Failed to create pubkey for ZapRequest ${event.id}", e) + null } + } - privateEvent?.let { - onReady(it) - } - } -} \ No newline at end of file + privateEvent?.let { onReady(it) } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/Robohash.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/Robohash.kt index aa7e0404c..f3f7082ab 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/Robohash.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/Robohash.kt @@ -1,325 +1,376 @@ -package com.vitorpamplona.quartz.utils - -import android.util.Log -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.crypto.CryptoUtils -import com.vitorpamplona.quartz.encoders.Hex -import com.vitorpamplona.quartz.encoders.HexValidator -import java.lang.StringBuilder - -object Robohash { - private fun encodeColor(r: Int, g: Int, b: Int): String { - return String(CharArray(7) { - when (it) { - 0 -> '#' - 1 -> Hex.hexCode[(r shr 4) and 0xF] - 2 -> Hex.hexCode[r and 0xF] - 3 -> Hex.hexCode[(g shr 4) and 0xF] - 4 -> Hex.hexCode[g and 0xF] - 5 -> Hex.hexCode[(b shr 4) and 0xF] - 6 -> Hex.hexCode[b and 0xF] - else -> ' ' - } - }) - } - - private fun byteMod10(byte: Byte): Int { - return byte.toUByte().toInt() % 10 - } - - private fun reduce(start: Int, channel: Byte): Int { - return (start + (channel.toUByte().toInt() * 0.3906f)).toInt() - } - - private fun bytesToRGB(r: Byte, g: Byte, b: Byte, makeLight: Boolean): String { - return if (makeLight) - // > 150-256 color channels - encodeColor(reduce(150, r), reduce(150, g), reduce(150, b)) - else - // < 50-100 color channels - encodeColor(reduce(50, r), reduce(50, g), reduce(50, b)) - } - - fun assemble(msg: String, isLightTheme: Boolean): String { - val hash = if (HexValidator.isHex(msg)) { - Hex.decode(msg) - } else { - Log.w("Robohash", "$msg is not a hex") - CryptoUtils.sha256(msg.toByteArray()) - } - val bgColor = bytesToRGB(hash[0], hash[1], hash[2], isLightTheme) - val fgColor = bytesToRGB(hash[3], hash[4], hash[5], !isLightTheme) - val body = bodies[byteMod10(hash[6])] - val face = faces[byteMod10(hash[7])] - val eye = eyes[byteMod10(hash[8])] - val mouth = mouths[byteMod10(hash[9])] - val accessory = accessories[byteMod10(hash[10])] - - val capacity = header.length + 74 + - body.style.length + body.paths.length + - face.style.length + face.paths.length + - eye.style.length + eye.paths.length + - mouth.style.length + mouth.paths.length + - accessory.style.length + accessory.paths.length + - mid.length + background.length + end.length - - val result = StringBuilder(capacity) - - result.append(header) - - result.append(".cls-bg{fill:") - result.append(bgColor) - result.append(";}.cls-fill-1{fill:") - result.append(fgColor) - result.append(";}.cls-fill-2{fill:") - result.append(fgColor) - result.append(";}") - - result.append(body.style) - result.append(face.style) - result.append(eye.style) - result.append(mouth.style) - result.append(accessory.style) - - result.append(mid) - - result.append(background) - result.append(body.paths) - result.append(face.paths) - result.append(eye.paths) - result.append(mouth.paths) - result.append(accessory.paths) - - result.append(end) - - val resultStr = result.toString() - check(resultStr.length == capacity) { - "${resultStr.length} was different from $capacity" - } - return resultStr - } - - @Immutable - private data class Part(val style: String, val paths: String) - - const val header = "" - const val end = "" - - private const val background = """""" - - private val accessories: List = listOf( - Part( - """.cls-00-2{fill:none;}.cls-00-2,.cls-00-3,.cls-00-4{stroke:#000;}.cls-00-2,.cls-00-3{stroke-linecap:round;stroke-linejoin:round;}.cls-00-3{fill-opacity:0.4;stroke-width:0.75px;}.cls-00-4{fill-opacity:0.2;stroke-miterlimit:10;stroke-width:0.5px;}""", - """""" - ), - Part( - """.cls-01-2{fill:#fff;fill-opacity:0.2;}.cls-01-3,.cls-01-4{fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-01-4{stroke-width:0.75px;}""", - """""" - ), - Part( - """.cls-02-2{fill:#be1e2d;}.cls-02-11,.cls-02-2,.cls-02-5,.cls-02-6,.cls-02-7,.cls-02-9{stroke:#000;}.cls-02-11,.cls-02-2,.cls-02-5,.cls-02-9{stroke-miterlimit:10;}.cls-02-3{fill:#561317;}.cls-02-4{fill:#ed293b;}.cls-02-11,.cls-02-5,.cls-02-7{fill:none;}.cls-02-5,.cls-02-6{stroke-width:0.75px;}.cls-02-6{fill:#fff;}.cls-02-6,.cls-02-8{fill-opacity:0.2;}.cls-02-6,.cls-02-7{stroke-linecap:round;stroke-linejoin:round;}.cls-02-9{fill:#e6e7e8;}.cls-02-10{fill:#d0d2d3;}""", - """""" - ), - Part( - """.cls-03-2,.cls-03-3,.cls-03-8{fill:#fff;}.cls-03-2,.cls-03-3,.cls-03-7{fill-opacity:0.2;}.cls-03-2,.cls-03-4,.cls-03-5{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-03-2{stroke-width:0.75px;}.cls-03-4{fill:none;}.cls-03-5{fill:#ec1c24;}.cls-03-6{fill-opacity:0.1;}.cls-03-8{fill-opacity:0.4;}""", - """""" - ), - Part( - """.cls-04-2,.cls-04-3{fill:none;stroke-linecap:round;stroke-linejoin:round;}.cls-04-2,.cls-04-3,.cls-04-5{stroke:#000;}.cls-04-3,.cls-04-5{stroke-width:0.75px;}.cls-04-4,.cls-04-6{fill:#fff;}.cls-04-4{fill-opacity:0.4;}.cls-04-5{fill:#ec1c24;stroke-miterlimit:10;}.cls-04-6,.cls-04-7{fill-opacity:0.2;}""", - """""" - ), - Part( - """.cls-05-2{fill:#fff;fill-opacity:0.4;}.cls-05-2,.cls-05-3{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-05-3{fill:none;stroke-width:0.75px;}""", - """""" - ), - Part( - """.cls-06-2,.cls-06-4{fill-opacity:0.2;}.cls-06-3,.cls-06-6{fill:none;}.cls-06-3,.cls-06-5,.cls-06-6{stroke:#000;}.cls-06-3,.cls-06-5{stroke-miterlimit:10;}.cls-06-4{fill:#fff;}.cls-06-5{fill:#ec1c24;stroke-width:0.75px;}.cls-06-6{stroke-linecap:round;stroke-linejoin:round;}""", - """""" - ), - Part( - """.cls-07-10,.cls-07-2,.cls-07-4,.cls-07-8,.cls-07-9{fill:none;}.cls-07-10,.cls-07-2,.cls-07-3,.cls-07-4,.cls-07-5,.cls-07-6,.cls-07-8,.cls-07-9{stroke:#000;}.cls-07-2,.cls-07-3,.cls-07-4,.cls-07-5,.cls-07-6,.cls-07-8{stroke-linecap:round;}.cls-07-2,.cls-07-3,.cls-07-4,.cls-07-5,.cls-07-6,.cls-07-8,.cls-07-9{stroke-linejoin:round;}.cls-07-3,.cls-07-5{fill:#fff;}.cls-07-3,.cls-07-5,.cls-07-6,.cls-07-7{fill-opacity:0.2;}.cls-07-3,.cls-07-4{stroke-width:0.75px;}.cls-07-10,.cls-07-8,.cls-07-9{stroke-width:1.5px;}.cls-07-10{stroke-miterlimit:10;}""", - """""" - ), - Part( - """.cls-08-2{fill:none;}.cls-08-2,.cls-08-3,.cls-08-5,.cls-08-6,.cls-08-7{stroke:#000;stroke-miterlimit:10;}.cls-08-3,.cls-08-4{fill:#fff;}.cls-08-3,.cls-08-4,.cls-08-8{fill-opacity:0.2;}.cls-08-5{fill:#716558;}.cls-08-6{fill:#9a8479;}.cls-08-7{fill:#c1b49a;}""", - """""" - ), - Part( - """.cls-09-2{fill:none;}.cls-09-2,.cls-09-4{stroke:#000;stroke-miterlimit:10;}.cls-09-3,.cls-09-4{fill:#fff;}.cls-09-3{fill-opacity:0.2;}.cls-09-5{fill:#d0d2d3;}.cls-09-6{fill-opacity:0.4;}""", - """""" - ) - ) - - private val bodies: List = listOf( - Part( - """.cls-10-2{fill:#fff;fill-opacity:0.4;}.cls-10-3,.cls-10-5,.cls-10-7{fill:none;}.cls-10-3,.cls-10-4,.cls-10-5,.cls-10-6,.cls-10-7,.cls-10-8{stroke:#000;stroke-miterlimit:10;}.cls-10-3{stroke-width:1.2px;}.cls-10-4,.cls-10-6,.cls-10-9{fill-opacity:0.2;}.cls-10-5{stroke-width:1.25px;}.cls-10-6{stroke-width:0.75px;}.cls-10-8{fill:#6d6e70;}""", - """""" - ), - Part( - """.cls-11-2{fill:#fff;}.cls-11-2,.cls-11-6{fill-opacity:0.2;}.cls-11-3{fill:none;}.cls-11-3,.cls-11-4{stroke:#000;stroke-miterlimit:10;}.cls-11-4{fill:#6d6e70;}.cls-11-5{opacity:0.2;}""", - """""" - ), - Part( - """.cls-12-2{fill:#fff;fill-opacity:0.4;}.cls-12-3,.cls-12-7{fill:none;}.cls-12-3,.cls-12-5,.cls-12-6,.cls-12-7{stroke:#000;}.cls-12-3,.cls-12-5,.cls-12-6{stroke-miterlimit:10;}.cls-12-4,.cls-12-6{fill-opacity:0.2;}.cls-12-5{fill:#6d6e70;}.cls-12-7{stroke-linecap:round;stroke-linejoin:round;}""", - """""" - ), - Part( - """.cls-13-2{fill:none;}.cls-13-2,.cls-13-5{stroke:#000;stroke-miterlimit:10;}.cls-13-3{fill:#fff;fill-opacity:0.4;}.cls-13-4{fill-opacity:0.2;}.cls-13-5{fill:#6d6e70;}""", - """""" - ), - Part( - """.cls-14-2{fill:none;}.cls-14-2,.cls-14-4{stroke:#000;stroke-miterlimit:10;}.cls-14-3,.cls-14-6{fill-opacity:0.2;}.cls-14-4{fill:#6d6e70;}.cls-14-5,.cls-14-6{fill:#fff;}.cls-14-5{fill-opacity:0.4;}""", - """""" - ), - Part( - """.cls-15-2{fill:#fff;}.cls-15-2,.cls-15-4{fill-opacity:0.2;}.cls-15-2,.cls-15-3,.cls-15-5{stroke:#000;stroke-miterlimit:10;}.cls-15-3{fill:none;}.cls-15-5{fill:#6d6e70;}""", - """""" - ), - Part( - """.cls-16-2,.cls-16-5{fill:none;}.cls-16-2,.cls-16-5,.cls-16-6,.cls-16-7,.cls-16-8,.cls-16-9{stroke:#000;}.cls-16-2{stroke-linecap:round;stroke-linejoin:round;}.cls-16-3,.cls-16-6,.cls-16-7{fill-opacity:0.2;}.cls-16-4,.cls-16-6{fill:#fff;}.cls-16-4{fill-opacity:0.4;}.cls-16-5,.cls-16-6,.cls-16-7,.cls-16-8,.cls-16-9{stroke-miterlimit:10;}.cls-16-8{fill:#f9ec31;}.cls-16-9{fill:#6d6e70;}""", - """""" - ), - Part( - """.cls-17-2{fill:#020202;}.cls-17-2,.cls-17-4,.cls-17-6,.cls-17-7{fill-opacity:0.4;}.cls-17-3{fill-opacity:0.2;}.cls-17-4,.cls-17-6{fill:#fff;}.cls-17-4,.cls-17-5,.cls-17-8{stroke:#000;stroke-miterlimit:10;}.cls-17-5{fill:none;}.cls-17-8{fill:#6d6e70;}""", - """""" - ), - Part( - """.cls-fill-2,.cls-18-3,.cls-18-5,.cls-18-6{stroke:#000;stroke-miterlimit:10;}.cls-18-3{fill:none;}.cls-18-4{fill:#fff;}.cls-18-4,.cls-18-5{fill-opacity:0.4;}.cls-18-6{fill:#6d6e70;}.cls-18-7{opacity:0.2;}.cls-18-8{fill-opacity:0.2;}""", - """""" - ), - Part( - """.cls-19-2{fill-opacity:0.6;}.cls-19-11,.cls-19-13,.cls-19-14,.cls-19-2,.cls-19-3,.cls-19-4,.cls-19-6,.cls-19-8{stroke:#000;}.cls-19-11,.cls-19-13,.cls-19-2,.cls-19-4,.cls-19-6,.cls-19-8{stroke-miterlimit:10;}.cls-19-11,.cls-19-14,.cls-19-3,.cls-19-8{fill:none;}.cls-19-14,.cls-19-3{stroke-linecap:round;stroke-linejoin:round;}.cls-19-10,.cls-19-12,.cls-19-4,.cls-19-5{fill:#fff;}.cls-19-12,.cls-19-4{fill-opacity:0.2;}.cls-19-4{stroke-opacity:0;}.cls-19-5{fill-opacity:0.1;}.cls-19-6{fill:#6d6e70;}.cls-19-7{fill:#58595b;}.cls-19-13,.cls-19-9{fill-opacity:0.4;}.cls-19-10{fill-opacity:0.5;}.cls-19-11,.cls-19-14{stroke-width:0.75px;}""", - """""" - ) - ) - - private val eyes: List = listOf( - Part( - """.cls-20-2{fill-opacity:0.4;}.cls-20-2,.cls-20-3{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-20-3{fill:#461917;stroke-width:0.5px;}""", - """""" - ), - Part( - """.cls-21-2{fill-opacity:0.2;}.cls-21-2,.cls-21-3,.cls-21-4,.cls-21-5{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-21-2,.cls-21-4,.cls-21-5{stroke-width:0.5px;}.cls-21-3,.cls-21-4{fill-opacity:0.4;}.cls-21-5{fill:#461917;}.cls-21-6{fill:#faaf40;}""", - """""" - ), - Part( - """.cls-22-2{opacity:0.4;}.cls-22-3{fill:#461917;}.cls-22-3,.cls-22-4,.cls-22-5{stroke:#000;}.cls-22-3,.cls-22-5{stroke-linecap:round;stroke-linejoin:round;}.cls-22-3,.cls-22-4{stroke-width:0.5px;}.cls-22-4{fill:#ec1c24;stroke-miterlimit:10;}.cls-22-5{fill:none;stroke-width:0.75px;}""", - """""" - ), - Part( - """.cls-23-2,.cls-23-3{fill:#fff;}.cls-23-2{fill-opacity:0.4;}.cls-23-3{fill-opacity:0.2;}.cls-23-3,.cls-23-4,.cls-23-5{stroke:#000;stroke-miterlimit:10;}.cls-23-4{fill:none;}.cls-23-4,.cls-23-5{stroke-width:0.75px;}.cls-23-5{fill:red;}""", - """""" - ), - Part( - """.cls-24-2{fill-opacity:0.4;stroke-miterlimit:10;}.cls-24-2,.cls-24-3,.cls-24-5{stroke:#000;}.cls-24-2,.cls-24-5{stroke-width:0.75px;}.cls-24-3,.cls-24-5{fill:#461917;stroke-linecap:round;stroke-linejoin:round;}.cls-24-3{stroke-width:0.5px;}.cls-24-4{fill:#ec1c24;}""", - """""" - ), - Part( - """.cls-25-2{fill-opacity:0.55;stroke-miterlimit:10;stroke-width:0.75px;}.cls-25-2,.cls-25-3{stroke:#000;}.cls-25-3{fill:#461917;stroke-linecap:round;stroke-linejoin:round;stroke-width:0.5px;}.cls-25-4{fill:#ec1c24;}""", - """""" - ), - Part( - """.cls-26-2{fill-opacity:0.6;}.cls-26-2,.cls-26-3,.cls-26-4,.cls-26-5,.cls-26-6{stroke:#000;}.cls-26-2,.cls-26-3,.cls-26-4,.cls-26-6{stroke-linecap:round;stroke-linejoin:round;}.cls-26-3{fill:#461917;}.cls-26-3,.cls-26-4,.cls-26-5{stroke-width:0.5px;}.cls-26-4,.cls-26-5{fill:#f9ec31;}.cls-26-5{stroke-miterlimit:10;}.cls-26-6{fill:none;}""", - """""" - ), - Part( - """.cls-27-2{fill-opacity:0.4;stroke-miterlimit:10;}.cls-27-2,.cls-27-3,.cls-27-4{stroke:#000;}.cls-27-3,.cls-27-4{fill:#461917;stroke-linecap:round;stroke-linejoin:round;}.cls-27-3{stroke-width:0.5px;}.cls-27-4{stroke-width:0.75px;}""", - """""" - ), - Part( - """.cls-28-2{fill:none;}.cls-28-2,.cls-28-3,.cls-28-4,.cls-28-6{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-28-2,.cls-28-4,.cls-28-6{stroke-width:0.75px;}.cls-28-3{fill:#461917;stroke-width:0.5px;}.cls-28-4{fill-opacity:0.4;}.cls-28-5{fill:#fff100;}.cls-28-6{fill:#fff;fill-opacity:0.2;}""", - """""" - ), - Part( - """.cls-29-2{fill:#fff;}.cls-29-2,.cls-29-4{fill-opacity:0.4;}.cls-29-3{fill:none;}.cls-29-3,.cls-29-4{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-29-4{stroke-width:0.75px;}""", - """""" - ) - ) - - private val faces: List = listOf( - Part( - """.cls-30-2{fill:#fff;fill-opacity:0.4;}.cls-30-3,.cls-30-4{fill:none;}.cls-30-3,.cls-30-4,.cls-30-6{stroke:#000;}.cls-30-3,.cls-30-6{stroke-linecap:round;stroke-linejoin:round;}.cls-30-4{stroke-miterlimit:10;}.cls-30-5,.cls-30-6{fill-opacity:0.2;}.cls-30-6{stroke-width:0.75px;}""", - """""" - ), - Part( - """.cls-31-2,.cls-31-4{fill-opacity:0.2;}.cls-31-3{fill:none;}.cls-31-3,.cls-31-4{stroke:#000;stroke-miterlimit:10;}.cls-31-4,.cls-31-5{fill:#fff;}.cls-31-4{stroke-width:0.75px;}.cls-31-5{fill-opacity:0.4;}""", - """""" - ), - Part( - """.cls-32-2{fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-32-3{fill-opacity:0.2;}.cls-32-4{fill:#fff;fill-opacity:0.4;}""", - """""" - ), - Part( - """.cls-33-2{fill-opacity:0.2;}.cls-33-3{fill:#fff;fill-opacity:0.4;}.cls-33-4{fill:none;stroke:#000;stroke-miterlimit:10;}""", - """""" - ), - Part( - """.cls-34-2,.cls-34-3{fill:#fff;}.cls-34-2{fill-opacity:0.4;}.cls-34-3,.cls-34-5{fill-opacity:0.2;}.cls-34-3,.cls-34-4{stroke:#000;stroke-miterlimit:10;}.cls-34-4{fill:none;}""", - """""" - ), - Part( - """.cls-35-2{fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-35-3{fill-opacity:0.2;}.cls-35-4{fill:#fff;fill-opacity:0.4;}""", - """""" - ), - Part( - """.cls-36-2,.cls-36-6{fill:#fff;}.cls-36-2{fill-opacity:0.4;}.cls-36-3,.cls-36-4{fill:none;stroke:#000;stroke-miterlimit:10;}.cls-36-3{stroke-width:2px;}.cls-36-5,.cls-36-6{fill-opacity:0.2;}""", - """""" - ), - Part( - """.cls-37-2{fill:#fff;fill-opacity:0.4;}.cls-37-3{fill:none;}.cls-37-3,.cls-37-4{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-37-4{fill-opacity:0.2;}""", - """""" - ), - Part( - """.cls-38-2{fill:none;stroke:#000;stroke-miterlimit:10;}.cls-38-3,.cls-38-6{fill:#fff;}.cls-38-3,.cls-38-4{fill-opacity:0.2;}.cls-38-5{fill-opacity:0.1;}.cls-38-6{fill-opacity:0.4;}""", - """""" - ), - Part( - """.cls-39-2{fill:#fff;fill-opacity:0.4;}.cls-39-3{fill:none;}.cls-39-3,.cls-39-4{stroke:#000;stroke-miterlimit:10;}.cls-39-4{fill-opacity:0.2;stroke-width:0.75px;}""", - """""" - ) - ) - - private val mouths: List = listOf( - Part( - """.cls-40-2{fill-opacity:0.4;stroke:#000;stroke-linecap:round;stroke-linejoin:round;}""", - """""" - ), - Part( - """.cls-41-2,.cls-41-4{fill:none;}.cls-41-2,.cls-41-3,.cls-41-4{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-41-3{fill-opacity:0.4;}.cls-41-3,.cls-41-4{stroke-width:0.75px;}""", - """""" - ), - Part( - """.cls-42-2{fill-opacity:0.4;}.cls-42-2,.cls-42-5,.cls-42-6{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-42-3{fill:#ee4036;}.cls-42-4{fill:#f05a28;}.cls-42-5{fill:#faaf40;stroke-width:0.75px;}.cls-42-6{fill:none;}""", - """""" - ), - Part( - """.cls-43-2{fill:#f9ec31;}.cls-43-3{fill:#faaf40;}.cls-43-4,.cls-43-6{fill:none;}.cls-43-4,.cls-43-5,.cls-43-6{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-43-5{fill-opacity:0.4;}.cls-43-5,.cls-43-6{stroke-width:0.75px;}""", - """""" - ), - Part( - """.cls-44-2{fill:none;}.cls-44-2,.cls-44-5{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-44-3{opacity:0.4;}.cls-44-4{fill:#461917;}.cls-44-5{fill-opacity:0.4;stroke-width:0.75px;}""", - """""" - ), - Part( - """.cls-45-2{fill-opacity:0.4;stroke-miterlimit:10;}.cls-45-2,.cls-45-3{stroke:#000;}.cls-45-3{fill:#461917;stroke-linecap:round;stroke-linejoin:round;stroke-width:0.5px;}""", - """""" - ), - Part( - """.cls-46-2{fill-opacity:0.4;}.cls-46-3{fill:none;}.cls-46-3,.cls-46-4{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-46-4{fill:#461917;stroke-width:0.5px;}""", - """""" - ), - Part( - """.cls-47-2{fill-opacity:0.4;}.cls-47-2,.cls-47-3,.cls-47-4,.cls-47-5{stroke:#000;stroke-miterlimit:10;}.cls-47-3{fill:#f6921e;}.cls-47-4{fill:#f9ec31;stroke-width:0.75px;}.cls-47-5{fill:none;}""", - """""" - ), - Part( - """.cls-48-2{opacity:0.4;}.cls-48-3,.cls-48-4,.cls-48-5{fill:none;}.cls-48-3,.cls-48-4,.cls-48-5,.cls-48-6,.cls-48-7,.cls-48-8{stroke:#000;}.cls-48-3,.cls-48-5,.cls-48-7{stroke-miterlimit:10;}.cls-48-4,.cls-48-6,.cls-48-8{stroke-linecap:round;stroke-linejoin:round;}.cls-48-4,.cls-48-5{stroke-width:0.5px;}.cls-48-6{fill:#f6921e;stroke-width:1.2px;}.cls-48-7{fill:#f9ec31;}.cls-48-7,.cls-48-8{stroke-width:0.75px;}.cls-48-8{fill:#f05a27;}""", - """""" - ), - Part( - """.cls-49-2{fill:none;}.cls-49-2,.cls-49-3,.cls-49-5{stroke:#000;}.cls-49-2,.cls-49-3{stroke-linecap:round;stroke-linejoin:round;}.cls-49-3{opacity:0.1;}.cls-49-4{opacity:0.4;}.cls-49-5{fill-opacity:0.4;stroke-miterlimit:10;stroke-width:0.75px;}""", - """""" - ) - ) -} +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +import android.util.Log +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.crypto.CryptoUtils +import com.vitorpamplona.quartz.encoders.Hex +import com.vitorpamplona.quartz.encoders.HexValidator +import java.lang.StringBuilder + +object Robohash { + private fun encodeColor( + r: Int, + g: Int, + b: Int, + ): String { + return String( + CharArray(7) { + when (it) { + 0 -> '#' + 1 -> Hex.hexCode[(r shr 4) and 0xF] + 2 -> Hex.hexCode[r and 0xF] + 3 -> Hex.hexCode[(g shr 4) and 0xF] + 4 -> Hex.hexCode[g and 0xF] + 5 -> Hex.hexCode[(b shr 4) and 0xF] + 6 -> Hex.hexCode[b and 0xF] + else -> ' ' + } + }, + ) + } + + private fun byteMod10(byte: Byte): Int { + return byte.toUByte().toInt() % 10 + } + + private fun reduce( + start: Int, + channel: Byte, + ): Int { + return (start + (channel.toUByte().toInt() * 0.3906f)).toInt() + } + + private fun bytesToRGB( + r: Byte, + g: Byte, + b: Byte, + makeLight: Boolean, + ): String { + return if (makeLight) { + // > 150-256 color channels + encodeColor(reduce(150, r), reduce(150, g), reduce(150, b)) + } else { + // < 50-100 color channels + encodeColor(reduce(50, r), reduce(50, g), reduce(50, b)) + } + } + + fun assemble( + msg: String, + isLightTheme: Boolean, + ): String { + val hash = + if (HexValidator.isHex(msg)) { + Hex.decode(msg) + } else { + Log.w("Robohash", "$msg is not a hex") + CryptoUtils.sha256(msg.toByteArray()) + } + val bgColor = bytesToRGB(hash[0], hash[1], hash[2], isLightTheme) + val fgColor = bytesToRGB(hash[3], hash[4], hash[5], !isLightTheme) + val body = bodies[byteMod10(hash[6])] + val face = faces[byteMod10(hash[7])] + val eye = eyes[byteMod10(hash[8])] + val mouth = mouths[byteMod10(hash[9])] + val accessory = accessories[byteMod10(hash[10])] + + val capacity = + HEADER.length + + 74 + + body.style.length + + body.paths.length + + face.style.length + + face.paths.length + + eye.style.length + + eye.paths.length + + mouth.style.length + + mouth.paths.length + + accessory.style.length + + accessory.paths.length + + MID.length + + BACKGROUND.length + + END.length + + val result = StringBuilder(capacity) + + result.append(HEADER) + + result.append(".cls-bg{fill:") + result.append(bgColor) + result.append(";}.cls-fill-1{fill:") + result.append(fgColor) + result.append(";}.cls-fill-2{fill:") + result.append(fgColor) + result.append(";}") + + result.append(body.style) + result.append(face.style) + result.append(eye.style) + result.append(mouth.style) + result.append(accessory.style) + + result.append(MID) + + result.append(BACKGROUND) + result.append(body.paths) + result.append(face.paths) + result.append(eye.paths) + result.append(mouth.paths) + result.append(accessory.paths) + + result.append(END) + + val resultStr = result.toString() + check(resultStr.length == capacity) { "${resultStr.length} was different from $capacity" } + return resultStr + } + + @Immutable private data class Part(val style: String, val paths: String) + + const val HEADER = + "" + const val END = "" + + private const val BACKGROUND = """""" + + private val accessories: List = + listOf( + Part( + """.cls-00-2{fill:none;}.cls-00-2,.cls-00-3,.cls-00-4{stroke:#000;}.cls-00-2,.cls-00-3{stroke-linecap:round;stroke-linejoin:round;}.cls-00-3{fill-opacity:0.4;stroke-width:0.75px;}.cls-00-4{fill-opacity:0.2;stroke-miterlimit:10;stroke-width:0.5px;}""", + """""", + ), + Part( + """.cls-01-2{fill:#fff;fill-opacity:0.2;}.cls-01-3,.cls-01-4{fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-01-4{stroke-width:0.75px;}""", + """""", + ), + Part( + """.cls-02-2{fill:#be1e2d;}.cls-02-11,.cls-02-2,.cls-02-5,.cls-02-6,.cls-02-7,.cls-02-9{stroke:#000;}.cls-02-11,.cls-02-2,.cls-02-5,.cls-02-9{stroke-miterlimit:10;}.cls-02-3{fill:#561317;}.cls-02-4{fill:#ed293b;}.cls-02-11,.cls-02-5,.cls-02-7{fill:none;}.cls-02-5,.cls-02-6{stroke-width:0.75px;}.cls-02-6{fill:#fff;}.cls-02-6,.cls-02-8{fill-opacity:0.2;}.cls-02-6,.cls-02-7{stroke-linecap:round;stroke-linejoin:round;}.cls-02-9{fill:#e6e7e8;}.cls-02-10{fill:#d0d2d3;}""", + """""", + ), + Part( + """.cls-03-2,.cls-03-3,.cls-03-8{fill:#fff;}.cls-03-2,.cls-03-3,.cls-03-7{fill-opacity:0.2;}.cls-03-2,.cls-03-4,.cls-03-5{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-03-2{stroke-width:0.75px;}.cls-03-4{fill:none;}.cls-03-5{fill:#ec1c24;}.cls-03-6{fill-opacity:0.1;}.cls-03-8{fill-opacity:0.4;}""", + """""", + ), + Part( + """.cls-04-2,.cls-04-3{fill:none;stroke-linecap:round;stroke-linejoin:round;}.cls-04-2,.cls-04-3,.cls-04-5{stroke:#000;}.cls-04-3,.cls-04-5{stroke-width:0.75px;}.cls-04-4,.cls-04-6{fill:#fff;}.cls-04-4{fill-opacity:0.4;}.cls-04-5{fill:#ec1c24;stroke-miterlimit:10;}.cls-04-6,.cls-04-7{fill-opacity:0.2;}""", + """""", + ), + Part( + """.cls-05-2{fill:#fff;fill-opacity:0.4;}.cls-05-2,.cls-05-3{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-05-3{fill:none;stroke-width:0.75px;}""", + """""", + ), + Part( + """.cls-06-2,.cls-06-4{fill-opacity:0.2;}.cls-06-3,.cls-06-6{fill:none;}.cls-06-3,.cls-06-5,.cls-06-6{stroke:#000;}.cls-06-3,.cls-06-5{stroke-miterlimit:10;}.cls-06-4{fill:#fff;}.cls-06-5{fill:#ec1c24;stroke-width:0.75px;}.cls-06-6{stroke-linecap:round;stroke-linejoin:round;}""", + """""", + ), + Part( + """.cls-07-10,.cls-07-2,.cls-07-4,.cls-07-8,.cls-07-9{fill:none;}.cls-07-10,.cls-07-2,.cls-07-3,.cls-07-4,.cls-07-5,.cls-07-6,.cls-07-8,.cls-07-9{stroke:#000;}.cls-07-2,.cls-07-3,.cls-07-4,.cls-07-5,.cls-07-6,.cls-07-8{stroke-linecap:round;}.cls-07-2,.cls-07-3,.cls-07-4,.cls-07-5,.cls-07-6,.cls-07-8,.cls-07-9{stroke-linejoin:round;}.cls-07-3,.cls-07-5{fill:#fff;}.cls-07-3,.cls-07-5,.cls-07-6,.cls-07-7{fill-opacity:0.2;}.cls-07-3,.cls-07-4{stroke-width:0.75px;}.cls-07-10,.cls-07-8,.cls-07-9{stroke-width:1.5px;}.cls-07-10{stroke-miterlimit:10;}""", + """""", + ), + Part( + """.cls-08-2{fill:none;}.cls-08-2,.cls-08-3,.cls-08-5,.cls-08-6,.cls-08-7{stroke:#000;stroke-miterlimit:10;}.cls-08-3,.cls-08-4{fill:#fff;}.cls-08-3,.cls-08-4,.cls-08-8{fill-opacity:0.2;}.cls-08-5{fill:#716558;}.cls-08-6{fill:#9a8479;}.cls-08-7{fill:#c1b49a;}""", + """""", + ), + Part( + """.cls-09-2{fill:none;}.cls-09-2,.cls-09-4{stroke:#000;stroke-miterlimit:10;}.cls-09-3,.cls-09-4{fill:#fff;}.cls-09-3{fill-opacity:0.2;}.cls-09-5{fill:#d0d2d3;}.cls-09-6{fill-opacity:0.4;}""", + """""", + ), + ) + + private val bodies: List = + listOf( + Part( + """.cls-10-2{fill:#fff;fill-opacity:0.4;}.cls-10-3,.cls-10-5,.cls-10-7{fill:none;}.cls-10-3,.cls-10-4,.cls-10-5,.cls-10-6,.cls-10-7,.cls-10-8{stroke:#000;stroke-miterlimit:10;}.cls-10-3{stroke-width:1.2px;}.cls-10-4,.cls-10-6,.cls-10-9{fill-opacity:0.2;}.cls-10-5{stroke-width:1.25px;}.cls-10-6{stroke-width:0.75px;}.cls-10-8{fill:#6d6e70;}""", + """""", + ), + Part( + """.cls-11-2{fill:#fff;}.cls-11-2,.cls-11-6{fill-opacity:0.2;}.cls-11-3{fill:none;}.cls-11-3,.cls-11-4{stroke:#000;stroke-miterlimit:10;}.cls-11-4{fill:#6d6e70;}.cls-11-5{opacity:0.2;}""", + """""", + ), + Part( + """.cls-12-2{fill:#fff;fill-opacity:0.4;}.cls-12-3,.cls-12-7{fill:none;}.cls-12-3,.cls-12-5,.cls-12-6,.cls-12-7{stroke:#000;}.cls-12-3,.cls-12-5,.cls-12-6{stroke-miterlimit:10;}.cls-12-4,.cls-12-6{fill-opacity:0.2;}.cls-12-5{fill:#6d6e70;}.cls-12-7{stroke-linecap:round;stroke-linejoin:round;}""", + """""", + ), + Part( + """.cls-13-2{fill:none;}.cls-13-2,.cls-13-5{stroke:#000;stroke-miterlimit:10;}.cls-13-3{fill:#fff;fill-opacity:0.4;}.cls-13-4{fill-opacity:0.2;}.cls-13-5{fill:#6d6e70;}""", + """""", + ), + Part( + """.cls-14-2{fill:none;}.cls-14-2,.cls-14-4{stroke:#000;stroke-miterlimit:10;}.cls-14-3,.cls-14-6{fill-opacity:0.2;}.cls-14-4{fill:#6d6e70;}.cls-14-5,.cls-14-6{fill:#fff;}.cls-14-5{fill-opacity:0.4;}""", + """""", + ), + Part( + """.cls-15-2{fill:#fff;}.cls-15-2,.cls-15-4{fill-opacity:0.2;}.cls-15-2,.cls-15-3,.cls-15-5{stroke:#000;stroke-miterlimit:10;}.cls-15-3{fill:none;}.cls-15-5{fill:#6d6e70;}""", + """""", + ), + Part( + """.cls-16-2,.cls-16-5{fill:none;}.cls-16-2,.cls-16-5,.cls-16-6,.cls-16-7,.cls-16-8,.cls-16-9{stroke:#000;}.cls-16-2{stroke-linecap:round;stroke-linejoin:round;}.cls-16-3,.cls-16-6,.cls-16-7{fill-opacity:0.2;}.cls-16-4,.cls-16-6{fill:#fff;}.cls-16-4{fill-opacity:0.4;}.cls-16-5,.cls-16-6,.cls-16-7,.cls-16-8,.cls-16-9{stroke-miterlimit:10;}.cls-16-8{fill:#f9ec31;}.cls-16-9{fill:#6d6e70;}""", + """""", + ), + Part( + """.cls-17-2{fill:#020202;}.cls-17-2,.cls-17-4,.cls-17-6,.cls-17-7{fill-opacity:0.4;}.cls-17-3{fill-opacity:0.2;}.cls-17-4,.cls-17-6{fill:#fff;}.cls-17-4,.cls-17-5,.cls-17-8{stroke:#000;stroke-miterlimit:10;}.cls-17-5{fill:none;}.cls-17-8{fill:#6d6e70;}""", + """""", + ), + Part( + """.cls-fill-2,.cls-18-3,.cls-18-5,.cls-18-6{stroke:#000;stroke-miterlimit:10;}.cls-18-3{fill:none;}.cls-18-4{fill:#fff;}.cls-18-4,.cls-18-5{fill-opacity:0.4;}.cls-18-6{fill:#6d6e70;}.cls-18-7{opacity:0.2;}.cls-18-8{fill-opacity:0.2;}""", + """""", + ), + Part( + """.cls-19-2{fill-opacity:0.6;}.cls-19-11,.cls-19-13,.cls-19-14,.cls-19-2,.cls-19-3,.cls-19-4,.cls-19-6,.cls-19-8{stroke:#000;}.cls-19-11,.cls-19-13,.cls-19-2,.cls-19-4,.cls-19-6,.cls-19-8{stroke-miterlimit:10;}.cls-19-11,.cls-19-14,.cls-19-3,.cls-19-8{fill:none;}.cls-19-14,.cls-19-3{stroke-linecap:round;stroke-linejoin:round;}.cls-19-10,.cls-19-12,.cls-19-4,.cls-19-5{fill:#fff;}.cls-19-12,.cls-19-4{fill-opacity:0.2;}.cls-19-4{stroke-opacity:0;}.cls-19-5{fill-opacity:0.1;}.cls-19-6{fill:#6d6e70;}.cls-19-7{fill:#58595b;}.cls-19-13,.cls-19-9{fill-opacity:0.4;}.cls-19-10{fill-opacity:0.5;}.cls-19-11,.cls-19-14{stroke-width:0.75px;}""", + """""", + ), + ) + + private val eyes: List = + listOf( + Part( + """.cls-20-2{fill-opacity:0.4;}.cls-20-2,.cls-20-3{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-20-3{fill:#461917;stroke-width:0.5px;}""", + """""", + ), + Part( + """.cls-21-2{fill-opacity:0.2;}.cls-21-2,.cls-21-3,.cls-21-4,.cls-21-5{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-21-2,.cls-21-4,.cls-21-5{stroke-width:0.5px;}.cls-21-3,.cls-21-4{fill-opacity:0.4;}.cls-21-5{fill:#461917;}.cls-21-6{fill:#faaf40;}""", + """""", + ), + Part( + """.cls-22-2{opacity:0.4;}.cls-22-3{fill:#461917;}.cls-22-3,.cls-22-4,.cls-22-5{stroke:#000;}.cls-22-3,.cls-22-5{stroke-linecap:round;stroke-linejoin:round;}.cls-22-3,.cls-22-4{stroke-width:0.5px;}.cls-22-4{fill:#ec1c24;stroke-miterlimit:10;}.cls-22-5{fill:none;stroke-width:0.75px;}""", + """""", + ), + Part( + """.cls-23-2,.cls-23-3{fill:#fff;}.cls-23-2{fill-opacity:0.4;}.cls-23-3{fill-opacity:0.2;}.cls-23-3,.cls-23-4,.cls-23-5{stroke:#000;stroke-miterlimit:10;}.cls-23-4{fill:none;}.cls-23-4,.cls-23-5{stroke-width:0.75px;}.cls-23-5{fill:red;}""", + """""", + ), + Part( + """.cls-24-2{fill-opacity:0.4;stroke-miterlimit:10;}.cls-24-2,.cls-24-3,.cls-24-5{stroke:#000;}.cls-24-2,.cls-24-5{stroke-width:0.75px;}.cls-24-3,.cls-24-5{fill:#461917;stroke-linecap:round;stroke-linejoin:round;}.cls-24-3{stroke-width:0.5px;}.cls-24-4{fill:#ec1c24;}""", + """""", + ), + Part( + """.cls-25-2{fill-opacity:0.55;stroke-miterlimit:10;stroke-width:0.75px;}.cls-25-2,.cls-25-3{stroke:#000;}.cls-25-3{fill:#461917;stroke-linecap:round;stroke-linejoin:round;stroke-width:0.5px;}.cls-25-4{fill:#ec1c24;}""", + """""", + ), + Part( + """.cls-26-2{fill-opacity:0.6;}.cls-26-2,.cls-26-3,.cls-26-4,.cls-26-5,.cls-26-6{stroke:#000;}.cls-26-2,.cls-26-3,.cls-26-4,.cls-26-6{stroke-linecap:round;stroke-linejoin:round;}.cls-26-3{fill:#461917;}.cls-26-3,.cls-26-4,.cls-26-5{stroke-width:0.5px;}.cls-26-4,.cls-26-5{fill:#f9ec31;}.cls-26-5{stroke-miterlimit:10;}.cls-26-6{fill:none;}""", + """""", + ), + Part( + """.cls-27-2{fill-opacity:0.4;stroke-miterlimit:10;}.cls-27-2,.cls-27-3,.cls-27-4{stroke:#000;}.cls-27-3,.cls-27-4{fill:#461917;stroke-linecap:round;stroke-linejoin:round;}.cls-27-3{stroke-width:0.5px;}.cls-27-4{stroke-width:0.75px;}""", + """""", + ), + Part( + """.cls-28-2{fill:none;}.cls-28-2,.cls-28-3,.cls-28-4,.cls-28-6{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-28-2,.cls-28-4,.cls-28-6{stroke-width:0.75px;}.cls-28-3{fill:#461917;stroke-width:0.5px;}.cls-28-4{fill-opacity:0.4;}.cls-28-5{fill:#fff100;}.cls-28-6{fill:#fff;fill-opacity:0.2;}""", + """""", + ), + Part( + """.cls-29-2{fill:#fff;}.cls-29-2,.cls-29-4{fill-opacity:0.4;}.cls-29-3{fill:none;}.cls-29-3,.cls-29-4{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-29-4{stroke-width:0.75px;}""", + """""", + ), + ) + + private val faces: List = + listOf( + Part( + """.cls-30-2{fill:#fff;fill-opacity:0.4;}.cls-30-3,.cls-30-4{fill:none;}.cls-30-3,.cls-30-4,.cls-30-6{stroke:#000;}.cls-30-3,.cls-30-6{stroke-linecap:round;stroke-linejoin:round;}.cls-30-4{stroke-miterlimit:10;}.cls-30-5,.cls-30-6{fill-opacity:0.2;}.cls-30-6{stroke-width:0.75px;}""", + """""", + ), + Part( + """.cls-31-2,.cls-31-4{fill-opacity:0.2;}.cls-31-3{fill:none;}.cls-31-3,.cls-31-4{stroke:#000;stroke-miterlimit:10;}.cls-31-4,.cls-31-5{fill:#fff;}.cls-31-4{stroke-width:0.75px;}.cls-31-5{fill-opacity:0.4;}""", + """""", + ), + Part( + """.cls-32-2{fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-32-3{fill-opacity:0.2;}.cls-32-4{fill:#fff;fill-opacity:0.4;}""", + """""", + ), + Part( + """.cls-33-2{fill-opacity:0.2;}.cls-33-3{fill:#fff;fill-opacity:0.4;}.cls-33-4{fill:none;stroke:#000;stroke-miterlimit:10;}""", + """""", + ), + Part( + """.cls-34-2,.cls-34-3{fill:#fff;}.cls-34-2{fill-opacity:0.4;}.cls-34-3,.cls-34-5{fill-opacity:0.2;}.cls-34-3,.cls-34-4{stroke:#000;stroke-miterlimit:10;}.cls-34-4{fill:none;}""", + """""", + ), + Part( + """.cls-35-2{fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-35-3{fill-opacity:0.2;}.cls-35-4{fill:#fff;fill-opacity:0.4;}""", + """""", + ), + Part( + """.cls-36-2,.cls-36-6{fill:#fff;}.cls-36-2{fill-opacity:0.4;}.cls-36-3,.cls-36-4{fill:none;stroke:#000;stroke-miterlimit:10;}.cls-36-3{stroke-width:2px;}.cls-36-5,.cls-36-6{fill-opacity:0.2;}""", + """""", + ), + Part( + """.cls-37-2{fill:#fff;fill-opacity:0.4;}.cls-37-3{fill:none;}.cls-37-3,.cls-37-4{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-37-4{fill-opacity:0.2;}""", + """""", + ), + Part( + """.cls-38-2{fill:none;stroke:#000;stroke-miterlimit:10;}.cls-38-3,.cls-38-6{fill:#fff;}.cls-38-3,.cls-38-4{fill-opacity:0.2;}.cls-38-5{fill-opacity:0.1;}.cls-38-6{fill-opacity:0.4;}""", + """""", + ), + Part( + """.cls-39-2{fill:#fff;fill-opacity:0.4;}.cls-39-3{fill:none;}.cls-39-3,.cls-39-4{stroke:#000;stroke-miterlimit:10;}.cls-39-4{fill-opacity:0.2;stroke-width:0.75px;}""", + """""", + ), + ) + + private val mouths: List = + listOf( + Part( + """.cls-40-2{fill-opacity:0.4;stroke:#000;stroke-linecap:round;stroke-linejoin:round;}""", + """""", + ), + Part( + """.cls-41-2,.cls-41-4{fill:none;}.cls-41-2,.cls-41-3,.cls-41-4{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-41-3{fill-opacity:0.4;}.cls-41-3,.cls-41-4{stroke-width:0.75px;}""", + """""", + ), + Part( + """.cls-42-2{fill-opacity:0.4;}.cls-42-2,.cls-42-5,.cls-42-6{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-42-3{fill:#ee4036;}.cls-42-4{fill:#f05a28;}.cls-42-5{fill:#faaf40;stroke-width:0.75px;}.cls-42-6{fill:none;}""", + """""", + ), + Part( + """.cls-43-2{fill:#f9ec31;}.cls-43-3{fill:#faaf40;}.cls-43-4,.cls-43-6{fill:none;}.cls-43-4,.cls-43-5,.cls-43-6{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-43-5{fill-opacity:0.4;}.cls-43-5,.cls-43-6{stroke-width:0.75px;}""", + """""", + ), + Part( + """.cls-44-2{fill:none;}.cls-44-2,.cls-44-5{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-44-3{opacity:0.4;}.cls-44-4{fill:#461917;}.cls-44-5{fill-opacity:0.4;stroke-width:0.75px;}""", + """""", + ), + Part( + """.cls-45-2{fill-opacity:0.4;stroke-miterlimit:10;}.cls-45-2,.cls-45-3{stroke:#000;}.cls-45-3{fill:#461917;stroke-linecap:round;stroke-linejoin:round;stroke-width:0.5px;}""", + """""", + ), + Part( + """.cls-46-2{fill-opacity:0.4;}.cls-46-3{fill:none;}.cls-46-3,.cls-46-4{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-46-4{fill:#461917;stroke-width:0.5px;}""", + """""", + ), + Part( + """.cls-47-2{fill-opacity:0.4;}.cls-47-2,.cls-47-3,.cls-47-4,.cls-47-5{stroke:#000;stroke-miterlimit:10;}.cls-47-3{fill:#f6921e;}.cls-47-4{fill:#f9ec31;stroke-width:0.75px;}.cls-47-5{fill:none;}""", + """""", + ), + Part( + """.cls-48-2{opacity:0.4;}.cls-48-3,.cls-48-4,.cls-48-5{fill:none;}.cls-48-3,.cls-48-4,.cls-48-5,.cls-48-6,.cls-48-7,.cls-48-8{stroke:#000;}.cls-48-3,.cls-48-5,.cls-48-7{stroke-miterlimit:10;}.cls-48-4,.cls-48-6,.cls-48-8{stroke-linecap:round;stroke-linejoin:round;}.cls-48-4,.cls-48-5{stroke-width:0.5px;}.cls-48-6{fill:#f6921e;stroke-width:1.2px;}.cls-48-7{fill:#f9ec31;}.cls-48-7,.cls-48-8{stroke-width:0.75px;}.cls-48-8{fill:#f05a27;}""", + """""", + ), + Part( + """.cls-49-2{fill:none;}.cls-49-2,.cls-49-3,.cls-49-5{stroke:#000;}.cls-49-2,.cls-49-3{stroke-linecap:round;stroke-linejoin:round;}.cls-49-3{opacity:0.1;}.cls-49-4{opacity:0.4;}.cls-49-5{fill-opacity:0.4;stroke-miterlimit:10;stroke-width:0.75px;}""", + """""", + ), + ) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/StringUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/StringUtils.kt index ade4abc90..8da44fbb4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/StringUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/StringUtils.kt @@ -1,48 +1,71 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.utils import kotlin.math.min fun String.containsIgnoreCase(term: String): Boolean { - if (term.isEmpty()) return true // Empty string is contained + if (term.isEmpty()) return true // Empty string is contained - val whatUppercase = term.uppercase() - val whatLowercase = term.lowercase() + val whatUppercase = term.uppercase() + val whatLowercase = term.lowercase() - return containsIgnoreCase(whatLowercase, whatUppercase) + return containsIgnoreCase(whatLowercase, whatUppercase) } -fun String.containsIgnoreCase(whatLowercase: String, whatUppercase: String): Boolean { - var myOffset: Int - var whatOffset: Int - val termLength = min(whatUppercase.length, whatLowercase.length) +fun String.containsIgnoreCase( + whatLowercase: String, + whatUppercase: String, +): Boolean { + var myOffset: Int + var whatOffset: Int + val termLength = min(whatUppercase.length, whatLowercase.length) - for (i in 0 .. this.length - termLength) { - if (this[i] != whatLowercase[0] && this[i] != whatUppercase[0]) continue + for (i in 0..this.length - termLength) { + if (this[i] != whatLowercase[0] && this[i] != whatUppercase[0]) continue - myOffset = i+1 - whatOffset = 1 - while (whatOffset < termLength) { - if (this[myOffset] != whatUppercase[whatOffset] && this[myOffset] != whatLowercase[whatOffset]) { - break - } - myOffset++ - whatOffset++ - } - if (whatOffset == termLength) return true + myOffset = i + 1 + whatOffset = 1 + while (whatOffset < termLength) { + if ( + this[myOffset] != whatUppercase[whatOffset] && this[myOffset] != whatLowercase[whatOffset] + ) { + break + } + myOffset++ + whatOffset++ } - return false + if (whatOffset == termLength) return true + } + return false } fun String.containsAny(terms: List): Boolean { - if (terms.isEmpty()) return true // Empty string is contained + if (terms.isEmpty()) return true // Empty string is contained - if (terms.size == 1) { - return containsIgnoreCase(terms[0].lowercase, terms[0].uppercase) - } + if (terms.size == 1) { + return containsIgnoreCase(terms[0].lowercase, terms[0].uppercase) + } - return terms.any { - containsIgnoreCase(it.lowercase, it.uppercase) - } + return terms.any { containsIgnoreCase(it.lowercase, it.uppercase) } } -class DualCase(val lowercase: String, val uppercase: String) \ No newline at end of file +class DualCase(val lowercase: String, val uppercase: String) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/TimeUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/TimeUtils.kt index 9f1694463..e1ef6b7d0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/TimeUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/TimeUtils.kt @@ -1,24 +1,52 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.utils import com.vitorpamplona.quartz.crypto.CryptoUtils object TimeUtils { - const val oneMinute = 60 - const val fiveMinutes = 5 * oneMinute - const val oneHour = 60 * oneMinute - const val eightHours = 8 * oneHour - const val oneDay = 24 * oneHour - const val oneWeek = 7 * oneDay - const val oneMonth = 30 * oneDay - const val oneYear = 365 * oneDay + const val ONE_MINUTE = 60 + const val FIVE_MINUTES = 5 * ONE_MINUTE + const val ONE_HOUR = 60 * ONE_MINUTE + const val EIGHT_HOURS = 8 * ONE_HOUR + const val ONE_DAY = 24 * ONE_HOUR + const val ONE_WEEK = 7 * ONE_DAY + const val ONE_MONTH = 30 * ONE_DAY + const val ONE_YEAR = 365 * ONE_DAY - fun now() = System.currentTimeMillis() / 1000 - fun oneMinuteAgo() = now() - oneMinute - fun fiveMinutesAgo() = now() - fiveMinutes - fun oneHourAgo() = now() - oneHour - fun oneHourAhead() = now() + oneHour - fun oneDayAgo() = now() - oneDay - fun eightHoursAgo() = now() - eightHours - fun oneWeekAgo() = now() - oneWeek - fun randomWithinAWeek() = System.currentTimeMillis() / 1000 - CryptoUtils.randomInt(oneWeek) -} \ No newline at end of file + fun now() = System.currentTimeMillis() / 1000 + + fun oneMinuteAgo() = now() - ONE_MINUTE + + fun fiveMinutesAgo() = now() - FIVE_MINUTES + + fun oneHourAgo() = now() - ONE_HOUR + + fun oneHourAhead() = now() + ONE_HOUR + + fun oneDayAgo() = now() - ONE_DAY + + fun eightHoursAgo() = now() - EIGHT_HOURS + + fun oneWeekAgo() = now() - ONE_WEEK + + fun randomWithinAWeek() = System.currentTimeMillis() / 1000 - CryptoUtils.randomInt(ONE_WEEK) +} diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Lud06Test.kt b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Lud06Test.kt index 99db42c1d..037975c09 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Lud06Test.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Lud06Test.kt @@ -1,13 +1,35 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.encoders import org.junit.Assert.assertEquals import org.junit.Test class Lud06Test { - val lnTips = "LNURL1DP68GURN8GHJ7MRW9E6XJURN9UH8WETVDSKKKMN0WAHZ7MRWW4EXCUP0XPURXEFEX9SKGCT9V5ER2V33X4NRGP2NE42" - @Test() - fun parseLnUrlp() { - assertEquals("https://ln.tips/.well-known/lnurlp/0x3e91adaee25215f4", Lud06().toLnUrlp(lnTips)) - assertEquals("0x3e91adaee25215f4@ln.tips", Lud06().toLud16(lnTips)) - } -} \ No newline at end of file + val lnTips = + "LNURL1DP68GURN8GHJ7MRW9E6XJURN9UH8WETVDSKKKMN0WAHZ7MRWW4EXCUP0XPURXEFEX9SKGCT9V5ER2V33X4NRGP2NE42" + + @Test() + fun parseLnUrlp() { + assertEquals("https://ln.tips/.well-known/lnurlp/0x3e91adaee25215f4", Lud06().toLnUrlp(lnTips)) + assertEquals("0x3e91adaee25215f4@ln.tips", Lud06().toLud16(lnTips)) + } +} diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/encoders/NIP19ParserTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/NIP19ParserTest.kt index b4578acda..7f05c4864 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/encoders/NIP19ParserTest.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/NIP19ParserTest.kt @@ -1,173 +1,286 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.encoders import org.junit.Assert.assertEquals import org.junit.Test class NIP19ParserTest { - @Test - fun nAddrParser() { - val result = Nip19.uriToRoute("nostr:naddr1qqqqygzxpsj7dqha57pjk5k37gkn6g4nzakewtmqmnwryyhd3jfwlpgxtspsgqqqw4rs3xyxus") - assertEquals("30023:460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c:", result?.hex) - } + @Test + fun nAddrParser() { + val result = + Nip19.uriToRoute( + "nostr:naddr1qqqqygzxpsj7dqha57pjk5k37gkn6g4nzakewtmqmnwryyhd3jfwlpgxtspsgqqqw4rs3xyxus", + ) + assertEquals( + "30023:460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c:", + result?.hex, + ) + } - @Test - fun nAddrParser2() { - val result = Nip19.uriToRoute("nostr:naddr1qq8kwatfv3jj6amfwfjkwatpwfjqygxsm6lelvfda7qlg0tud9pfhduysy4vrexj65azqtdk4tr75j6xdspsgqqqw4rsg32ag8") - assertEquals("30023:d0debf9fb12def81f43d7c69429bb784812ac1e4d2d53a202db6aac7ea4b466c:guide-wireguard", result?.hex) - } + @Test + fun nAddrParser2() { + val result = + Nip19.uriToRoute( + "nostr:naddr1qq8kwatfv3jj6amfwfjkwatpwfjqygxsm6lelvfda7qlg0tud9pfhduysy4vrexj65azqtdk4tr75j6xdspsgqqqw4rsg32ag8", + ) + assertEquals( + "30023:d0debf9fb12def81f43d7c69429bb784812ac1e4d2d53a202db6aac7ea4b466c:guide-wireguard", + result?.hex, + ) + } - @Test - fun nAddrParse3() { - val result = Nip19.uriToRoute("naddr1qqyrswtyv5mnjv3sqy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsygx3uczxts4hwue9ayfn7ggq62anzstde2qs749pm9tx2csuthhpjvpsgqqqw4rs8pmj38") - assertEquals(Nip19.Type.ADDRESS, result?.type) - assertEquals("30023:d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193:89de7920", result?.hex) - assertEquals("wss://relay.damus.io", result?.relay) - } + @Test + fun nAddrParse3() { + val result = + Nip19.uriToRoute( + "naddr1qqyrswtyv5mnjv3sqy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsygx3uczxts4hwue9ayfn7ggq62anzstde2qs749pm9tx2csuthhpjvpsgqqqw4rs8pmj38", + ) + assertEquals(Nip19.Type.ADDRESS, result?.type) + assertEquals( + "30023:d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193:89de7920", + result?.hex, + ) + assertEquals("wss://relay.damus.io", result?.relay) + } - @Test - fun nAddrATagParse3() { - val address = ATag.parse("30023:d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193:89de7920", "wss://relay.damus.io") - assertEquals(30023, address?.kind) - assertEquals("d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193", address?.pubKeyHex) - assertEquals("89de7920", address?.dTag) - assertEquals("wss://relay.damus.io", address?.relay) - assertEquals("naddr1qqyrswtyv5mnjv3sqy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsygx3uczxts4hwue9ayfn7ggq62anzstde2qs749pm9tx2csuthhpjvpsgqqqw4rs8pmj38", address?.toNAddr()) - } + @Test + fun nAddrATagParse3() { + val address = + ATag.parse( + "30023:d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193:89de7920", + "wss://relay.damus.io", + ) + assertEquals(30023, address?.kind) + assertEquals( + "d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193", + address?.pubKeyHex, + ) + assertEquals("89de7920", address?.dTag) + assertEquals("wss://relay.damus.io", address?.relay) + assertEquals( + "naddr1qqyrswtyv5mnjv3sqy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsygx3uczxts4hwue9ayfn7ggq62anzstde2qs749pm9tx2csuthhpjvpsgqqqw4rs8pmj38", + address?.toNAddr(), + ) + } - @Test - fun nAddrFormatter() { - val address = ATag( - 30023, - "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", - "", - null - ) - assertEquals("naddr1qqqqygzxpsj7dqha57pjk5k37gkn6g4nzakewtmqmnwryyhd3jfwlpgxtspsgqqqw4rs3xyxus", address.toNAddr()) - } + @Test + fun nAddrFormatter() { + val address = + ATag( + 30023, + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + "", + null, + ) + assertEquals( + "naddr1qqqqygzxpsj7dqha57pjk5k37gkn6g4nzakewtmqmnwryyhd3jfwlpgxtspsgqqqw4rs3xyxus", + address.toNAddr(), + ) + } - @Test - fun nAddrFormatter2() { - val address = ATag( - 30023, - "d0debf9fb12def81f43d7c69429bb784812ac1e4d2d53a202db6aac7ea4b466c", - "guide-wireguard", - null - ) - assertEquals("naddr1qq8kwatfv3jj6amfwfjkwatpwfjqygxsm6lelvfda7qlg0tud9pfhduysy4vrexj65azqtdk4tr75j6xdspsgqqqw4rsg32ag8", address.toNAddr()) - } + @Test + fun nAddrFormatter2() { + val address = + ATag( + 30023, + "d0debf9fb12def81f43d7c69429bb784812ac1e4d2d53a202db6aac7ea4b466c", + "guide-wireguard", + null, + ) + assertEquals( + "naddr1qq8kwatfv3jj6amfwfjkwatpwfjqygxsm6lelvfda7qlg0tud9pfhduysy4vrexj65azqtdk4tr75j6xdspsgqqqw4rsg32ag8", + address.toNAddr(), + ) + } - @Test - fun nAddrFormatter3() { - val address = ATag( - 30023, - "d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193", - "89de7920", - "wss://relay.damus.io" - ) - assertEquals("naddr1qqyrswtyv5mnjv3sqy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsygx3uczxts4hwue9ayfn7ggq62anzstde2qs749pm9tx2csuthhpjvpsgqqqw4rs8pmj38", address.toNAddr()) - } + @Test + fun nAddrFormatter3() { + val address = + ATag( + 30023, + "d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193", + "89de7920", + "wss://relay.damus.io", + ) + assertEquals( + "naddr1qqyrswtyv5mnjv3sqy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsygx3uczxts4hwue9ayfn7ggq62anzstde2qs749pm9tx2csuthhpjvpsgqqqw4rs8pmj38", + address.toNAddr(), + ) + } - @Test - fun nAddrParserPablo() { - val result = Nip19.uriToRoute("naddr1qq2hs7p30p6kcunxxamkgcnyd33xxve3veshyq3qyujphdcz69z6jafxpnldae3xtymdekfeatkt3r4qusr3w5krqspqxpqqqpaxjlg805f") - assertEquals(Nip19.Type.ADDRESS, result?.type) - assertEquals("31337:27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402:xx1xulrf7wdbdlbc31far", result?.hex) - assertEquals(null, result?.relay) - assertEquals("27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402", result?.author) - assertEquals(31337, result?.kind) - } + @Test + fun nAddrParserPablo() { + val result = + Nip19.uriToRoute( + "naddr1qq2hs7p30p6kcunxxamkgcnyd33xxve3veshyq3qyujphdcz69z6jafxpnldae3xtymdekfeatkt3r4qusr3w5krqspqxpqqqpaxjlg805f", + ) + assertEquals(Nip19.Type.ADDRESS, result?.type) + assertEquals( + "31337:27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402:xx1xulrf7wdbdlbc31far", + result?.hex, + ) + assertEquals(null, result?.relay) + assertEquals("27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402", result?.author) + assertEquals(31337, result?.kind) + } - @Test - fun nAddrParserGizmo() { - val result = Nip19.uriToRoute("naddr1qpqrvvfnvccrzdryxgunzvtxvgukge34xfjnqdpcv9sk2desxgmrscesvserzd3h8ycrywphvg6nsvf58ycnqef3v5mnsvt98pjnqdfs8ypzq3huhccxt6h34eupz3jeynjgjgek8lel2f4adaea0svyk94a3njdqvzqqqr4gudhrkyk") - assertEquals(Nip19.Type.ADDRESS, result?.type) - assertEquals("30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:613f014d2911fb9df52e048aae70268c0d216790287b5814910e1e781e8e0509", result?.hex) - assertEquals(null, result?.relay) - assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result?.author) - assertEquals(30023, result?.kind) - } + @Test + fun nAddrParserGizmo() { + val result = + Nip19.uriToRoute( + "naddr1qpqrvvfnvccrzdryxgunzvtxvgukge34xfjnqdpcv9sk2desxgmrscesvserzd3h8ycrywphvg6nsvf58ycnqef3v5mnsvt98pjnqdfs8ypzq3huhccxt6h34eupz3jeynjgjgek8lel2f4adaea0svyk94a3njdqvzqqqr4gudhrkyk", + ) + assertEquals(Nip19.Type.ADDRESS, result?.type) + assertEquals( + "30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:613f014d2911fb9df52e048aae70268c0d216790287b5814910e1e781e8e0509", + result?.hex, + ) + assertEquals(null, result?.relay) + assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result?.author) + assertEquals(30023, result?.kind) + } - @Test - fun nAddrParserGizmo2() { - val result = Nip19.uriToRoute("naddr1qq9rzd3h8y6nqwf5xyuqygzxljlrqe027xh8sy2xtyjwfzfrxcll8afxh4hh847psjckhkxwf5psgqqqw4rsty50fx") - assertEquals(Nip19.Type.ADDRESS, result?.type) - assertEquals("30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:1679509418", result?.hex) - assertEquals(null, result?.relay) - assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result?.author) - assertEquals(30023, result?.kind) - } + @Test + fun nAddrParserGizmo2() { + val result = + Nip19.uriToRoute( + "naddr1qq9rzd3h8y6nqwf5xyuqygzxljlrqe027xh8sy2xtyjwfzfrxcll8afxh4hh847psjckhkxwf5psgqqqw4rsty50fx", + ) + assertEquals(Nip19.Type.ADDRESS, result?.type) + assertEquals( + "30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:1679509418", + result?.hex, + ) + assertEquals(null, result?.relay) + assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result?.author) + assertEquals(30023, result?.kind) + } - @Test - fun nEventParserTest() { - val result = Nip19.uriToRoute("nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy") - assertEquals(Nip19.Type.EVENT, result?.type) - assertEquals("f5c1c7bcbb8855210a1a8f2684ba1ce4d89ced4d8844792b9d60daca0679addc", result?.hex) - assertEquals(null, result?.relay) - assertEquals(null, result?.author) - assertEquals(null, result?.kind) - } + @Test + fun nEventParserTest() { + val result = + Nip19.uriToRoute("nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy") + assertEquals(Nip19.Type.EVENT, result?.type) + assertEquals("f5c1c7bcbb8855210a1a8f2684ba1ce4d89ced4d8844792b9d60daca0679addc", result?.hex) + assertEquals(null, result?.relay) + assertEquals(null, result?.author) + assertEquals(null, result?.kind) + } - @Test - fun nEventParser() { - val result = Nip19.uriToRoute("nostr:nevent1qqstvrl6wftd8ht4g0vrp6m30tjs6pdxcvk977g769dcvlptkzu4ftqppamhxue69uhkummnw3ezumt0d5pzp78lz8r60568sd2a8dx3wnj6gume02gxaf92vx4fk67qv5kpagt6qvzqqqqqqygqr86c") - assertEquals(Nip19.Type.EVENT, result?.type) - assertEquals("b60ffa7256d3dd7543d830eb717ae50d05a6c32c5f791ed15b867c2bb0b954ac", result?.hex) - assertEquals("wss://nostr.mom", result?.relay) - assertEquals("f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", result?.author) - assertEquals(1, result?.kind) - } + @Test + fun nEventParser() { + val result = + Nip19.uriToRoute( + "nostr:nevent1qqstvrl6wftd8ht4g0vrp6m30tjs6pdxcvk977g769dcvlptkzu4ftqppamhxue69uhkummnw3ezumt0d5pzp78lz8r60568sd2a8dx3wnj6gume02gxaf92vx4fk67qv5kpagt6qvzqqqqqqygqr86c", + ) + assertEquals(Nip19.Type.EVENT, result?.type) + assertEquals("b60ffa7256d3dd7543d830eb717ae50d05a6c32c5f791ed15b867c2bb0b954ac", result?.hex) + assertEquals("wss://nostr.mom", result?.relay) + assertEquals("f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", result?.author) + assertEquals(1, result?.kind) + } - @Test - fun nEventParser2() { - val result = Nip19.uriToRoute("nostr:nevent1qqsplpuwsgrrmq85rfup6w3w777rxmcmadu590emfx6z4msj2844euqpz3mhxue69uhhyetvv9ujuerpd46hxtnfdupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqvzqqqqqqye3a70w") + @Test + fun nEventParser2() { + val result = + Nip19.uriToRoute( + "nostr:nevent1qqsplpuwsgrrmq85rfup6w3w777rxmcmadu590emfx6z4msj2844euqpz3mhxue69uhhyetvv9ujuerpd46hxtnfdupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqvzqqqqqqye3a70w", + ) - assertEquals(Nip19.Type.EVENT, result?.type) - assertEquals("1f878e82063d80f41a781d3a2ef7bc336f1beb7942bf3b49b42aee1251eb5cf0", result?.hex) - assertEquals("wss://relay.damus.io", result?.relay) - assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author) - assertEquals(1, result?.kind) - } + assertEquals(Nip19.Type.EVENT, result?.type) + assertEquals("1f878e82063d80f41a781d3a2ef7bc336f1beb7942bf3b49b42aee1251eb5cf0", result?.hex) + assertEquals("wss://relay.damus.io", result?.relay) + assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author) + assertEquals(1, result?.kind) + } - @Test - fun nEventParser3() { - val result = Nip19.uriToRoute("nostr:nevent1qqsg6gechd3dhzx38n4z8a2lylzgsmmgeamhmtzz72m9ummsnf0xjfspsdmhxue69uhkummn9ekx7mpvwaehxw309ahx7um5wghx77r5wghxgetk93mhxue69uhhyetvv9ujumn0wd68ytnzvuk8wumn8ghj7mn0wd68ytn9d9h82mny0fmkzmn6d9njuumsv93k2trhwden5te0wfjkccte9ehx7um5wghxyctwvsk8wumn8ghj7un9d3shjtnyv9kh2uewd9hs3kqsdn") + @Test + fun nEventParser3() { + val result = + Nip19.uriToRoute( + "nostr:nevent1qqsg6gechd3dhzx38n4z8a2lylzgsmmgeamhmtzz72m9ummsnf0xjfspsdmhxue69uhkummn9ekx7mpvwaehxw309ahx7um5wghx77r5wghxgetk93mhxue69uhhyetvv9ujumn0wd68ytnzvuk8wumn8ghj7mn0wd68ytn9d9h82mny0fmkzmn6d9njuumsv93k2trhwden5te0wfjkccte9ehx7um5wghxyctwvsk8wumn8ghj7un9d3shjtnyv9kh2uewd9hs3kqsdn", + ) - assertEquals(Nip19.Type.EVENT, result?.type) - assertEquals("8d2338bb62db88d13cea23f55f27c4886f68cf777dac42f2b65e6f709a5e6926", result?.hex) - assertEquals("wss://nos.lol,wss://nostr.oxtr.dev,wss://relay.nostr.bg,wss://nostr.einundzwanzig.space,wss://relay.nostr.band,wss://relay.damus.io", result?.relay) - } + assertEquals(Nip19.Type.EVENT, result?.type) + assertEquals("8d2338bb62db88d13cea23f55f27c4886f68cf777dac42f2b65e6f709a5e6926", result?.hex) + assertEquals( + "wss://nos.lol,wss://nostr.oxtr.dev,wss://relay.nostr.bg,wss://nostr.einundzwanzig.space,wss://relay.nostr.band,wss://relay.damus.io", + result?.relay, + ) + } - @Test - fun nEventParserInvalidChecksum() { - val result = Nip19.uriToRoute("nostr:nevent1qqsyxq8v0730nz38dupnjzp5jegkyz4gu2ptwcps4v32hjnrap0q0espz3mhxue69uhhyetvv9ujuerpd46hxtnfdupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqvzqqqqqqyn3t9gj") + @Test + fun nEventParserInvalidChecksum() { + val result = + Nip19.uriToRoute( + "nostr:nevent1qqsyxq8v0730nz38dupnjzp5jegkyz4gu2ptwcps4v32hjnrap0q0espz3mhxue69uhhyetvv9ujuerpd46hxtnfdupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqvzqqqqqqyn3t9gj", + ) - assertEquals(Nip19.Type.EVENT, result?.type) - assertEquals("4300ec7fa2f98a276f033908349651620aa8e282b76030ab22abca63e85e07e6", result?.hex) - assertEquals("wss://relay.damus.io", result?.relay) - assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author) - assertEquals(1, result?.kind) - } + assertEquals(Nip19.Type.EVENT, result?.type) + assertEquals("4300ec7fa2f98a276f033908349651620aa8e282b76030ab22abca63e85e07e6", result?.hex) + assertEquals("wss://relay.damus.io", result?.relay) + assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author) + assertEquals(1, result?.kind) + } - @Test - fun nEventFormatter() { - val nevent = Nip19.createNEvent("f5c1c7bcbb8855210a1a8f2684ba1ce4d89ced4d8844792b9d60daca0679addc", null, null, null) - assertEquals("nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy", nevent) - } + @Test + fun nEventFormatter() { + val nevent = + Nip19.createNEvent( + "f5c1c7bcbb8855210a1a8f2684ba1ce4d89ced4d8844792b9d60daca0679addc", + null, + null, + null, + ) + assertEquals("nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy", nevent) + } - @Test - fun nEventFormatterWithExtraInfo() { - val nevent = Nip19.createNEvent("f5c1c7bcbb8855210a1a8f2684ba1ce4d89ced4d8844792b9d60daca0679addc", "7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194", 40, null) - assertEquals("nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhqzypl62m6ad932k83u6sjwwkxrqq4cve0hkrvdem5la83g34m4rtqegqcyqqqqq2qh26va4", nevent) - } + @Test + fun nEventFormatterWithExtraInfo() { + val nevent = + Nip19.createNEvent( + "f5c1c7bcbb8855210a1a8f2684ba1ce4d89ced4d8844792b9d60daca0679addc", + "7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194", + 40, + null, + ) + assertEquals( + "nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhqzypl62m6ad932k83u6sjwwkxrqq4cve0hkrvdem5la83g34m4rtqegqcyqqqqq2qh26va4", + nevent, + ) + } - @Test - fun nEventFormatterWithFullInfo() { - val nevent = Nip19.createNEvent( - "1f878e82063d80f41a781d3a2ef7bc336f1beb7942bf3b49b42aee1251eb5cf0", - "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", - 1, - "wss://relay.damus.io" - ) - assertEquals("nevent1qqsplpuwsgrrmq85rfup6w3w777rxmcmadu590emfx6z4msj2844euqpz3mhxue69uhhyetvv9ujuerpd46hxtnfdupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqvzqqqqqqye3a70w", nevent) - } + @Test + fun nEventFormatterWithFullInfo() { + val nevent = + Nip19.createNEvent( + "1f878e82063d80f41a781d3a2ef7bc336f1beb7942bf3b49b42aee1251eb5cf0", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + 1, + "wss://relay.damus.io", + ) + assertEquals( + "nevent1qqsplpuwsgrrmq85rfup6w3w777rxmcmadu590emfx6z4msj2844euqpz3mhxue69uhhyetvv9ujuerpd46hxtnfdupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqvzqqqqqqye3a70w", + nevent, + ) + } } diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Nip19Test.kt b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Nip19Test.kt index 6e11ece4f..6adccc3e5 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Nip19Test.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Nip19Test.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.encoders import org.junit.Assert @@ -5,77 +25,77 @@ import org.junit.Ignore import org.junit.Test class Nip19Test { - @Test() - fun uri_to_route_null() { - val actual = Nip19.uriToRoute(null) + @Test() + fun uri_to_route_null() { + val actual = Nip19.uriToRoute(null) - Assert.assertEquals(null, actual) - } + Assert.assertEquals(null, actual) + } - @Test() - fun uri_to_route_unknown() { - val actual = Nip19.uriToRoute("nostr:unknown") + @Test() + fun uri_to_route_unknown() { + val actual = Nip19.uriToRoute("nostr:unknown") - Assert.assertEquals(null, actual) - } + Assert.assertEquals(null, actual) + } - @Test() - fun uri_to_route_npub() { - val actual = - Nip19.uriToRoute("nostr:npub1hv7k2s755n697sptva8vkh9jz40lzfzklnwj6ekewfmxp5crwdjs27007y") + @Test() + fun uri_to_route_npub() { + val actual = + Nip19.uriToRoute("nostr:npub1hv7k2s755n697sptva8vkh9jz40lzfzklnwj6ekewfmxp5crwdjs27007y") - Assert.assertEquals(Nip19.Type.USER, actual?.type) - Assert.assertEquals( - "bb3d6543d4a4f45f402b674ecb5cb2155ff12456fcdd2d66d9727660d3037365", - actual?.hex - ) - } + Assert.assertEquals(Nip19.Type.USER, actual?.type) + Assert.assertEquals( + "bb3d6543d4a4f45f402b674ecb5cb2155ff12456fcdd2d66d9727660d3037365", + actual?.hex, + ) + } - @Test() - fun uri_to_route_note() { - val actual = - Nip19.uriToRoute("nostr:note1stqea6wmwezg9x6yyr6qkukw95ewtdukyaztycws65l8wppjmtpscawevv") + @Test() + fun uri_to_route_note() { + val actual = + Nip19.uriToRoute("nostr:note1stqea6wmwezg9x6yyr6qkukw95ewtdukyaztycws65l8wppjmtpscawevv") - Assert.assertEquals(Nip19.Type.NOTE, actual?.type) - Assert.assertEquals( - "82c19ee9db7644829b4420f40b72ce2d32e5b7962744b261d0d53e770432dac3", - actual?.hex - ) - } + Assert.assertEquals(Nip19.Type.NOTE, actual?.type) + Assert.assertEquals( + "82c19ee9db7644829b4420f40b72ce2d32e5b7962744b261d0d53e770432dac3", + actual?.hex, + ) + } - @Ignore("Test not implemented yet") - @Test() - fun uri_to_route_nprofile() { - val actual = Nip19.uriToRoute("nostr:nprofile") + @Ignore("Test not implemented yet") + @Test() + fun uri_to_route_nprofile() { + val actual = Nip19.uriToRoute("nostr:nprofile") - Assert.assertEquals(Nip19.Type.USER, actual?.type) - Assert.assertEquals("*", actual?.hex) - } + Assert.assertEquals(Nip19.Type.USER, actual?.type) + Assert.assertEquals("*", actual?.hex) + } - @Ignore("Test not implemented yet") - @Test() - fun uri_to_route_nevent() { - val actual = Nip19.uriToRoute("nostr:nevent") + @Ignore("Test not implemented yet") + @Test() + fun uri_to_route_nevent() { + val actual = Nip19.uriToRoute("nostr:nevent") - Assert.assertEquals(Nip19.Type.USER, actual?.type) - Assert.assertEquals("*", actual?.hex) - } + Assert.assertEquals(Nip19.Type.USER, actual?.type) + Assert.assertEquals("*", actual?.hex) + } - @Ignore("Test not implemented yet") - @Test() - fun uri_to_route_nrelay() { - val actual = Nip19.uriToRoute("nostr:nrelay") + @Ignore("Test not implemented yet") + @Test() + fun uri_to_route_nrelay() { + val actual = Nip19.uriToRoute("nostr:nrelay") - Assert.assertEquals(Nip19.Type.RELAY, actual?.type) - Assert.assertEquals("*", actual?.hex) - } + Assert.assertEquals(Nip19.Type.RELAY, actual?.type) + Assert.assertEquals("*", actual?.hex) + } - @Ignore("Test not implemented yet") - @Test() - fun uri_to_route_naddr() { - val actual = Nip19.uriToRoute("nostr:naddr") + @Ignore("Test not implemented yet") + @Test() + fun uri_to_route_naddr() { + val actual = Nip19.uriToRoute("nostr:naddr") - Assert.assertEquals(Nip19.Type.ADDRESS, actual?.type) - Assert.assertEquals("*", actual?.hex) - } + Assert.assertEquals(Nip19.Type.ADDRESS, actual?.type) + Assert.assertEquals("*", actual?.hex) + } } diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/encoders/TlvIntegerTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/TlvIntegerTest.kt index 83037aa8b..6412fe556 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/encoders/TlvIntegerTest.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/TlvIntegerTest.kt @@ -1,3 +1,23 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package com.vitorpamplona.quartz.encoders import org.junit.Assert @@ -5,34 +25,33 @@ import org.junit.Assert.assertEquals import org.junit.Test class TlvIntegerTest { - fun to_int_32_length_smaller_than_4() { - Assert.assertNull(byteArrayOfInts(1, 2, 3).toInt32()) - } + fun to_int_32_length_smaller_than_4() { + Assert.assertNull(byteArrayOfInts(1, 2, 3).toInt32()) + } - fun to_int_32_length_bigger_than_4() { - Assert.assertNull(byteArrayOfInts(1, 2, 3, 4, 5).toInt32()) - } + fun to_int_32_length_bigger_than_4() { + Assert.assertNull(byteArrayOfInts(1, 2, 3, 4, 5).toInt32()) + } - @Test() - fun to_int_32_length_4() { - val actual = byteArrayOfInts(1, 2, 3, 4).toInt32() + @Test() + fun to_int_32_length_4() { + val actual = byteArrayOfInts(1, 2, 3, 4).toInt32() - assertEquals(16909060, actual) - } + assertEquals(16909060, actual) + } - @Test() - fun backAndForth() { - assertEquals(234, 234.to32BitByteArray().toInt32()) - assertEquals(1, 1.to32BitByteArray().toInt32()) - assertEquals(0, 0.to32BitByteArray().toInt32()) - assertEquals(1000, 1000.to32BitByteArray().toInt32()) + @Test() + fun backAndForth() { + assertEquals(234, 234.to32BitByteArray().toInt32()) + assertEquals(1, 1.to32BitByteArray().toInt32()) + assertEquals(0, 0.to32BitByteArray().toInt32()) + assertEquals(1000, 1000.to32BitByteArray().toInt32()) - assertEquals(-234, (-234).to32BitByteArray().toInt32()) - assertEquals(-1, (-1).to32BitByteArray().toInt32()) - assertEquals(-0, (-0).to32BitByteArray().toInt32()) - assertEquals(-1000, (-1000).to32BitByteArray().toInt32()) - } + assertEquals(-234, (-234).to32BitByteArray().toInt32()) + assertEquals(-1, (-1).to32BitByteArray().toInt32()) + assertEquals(-0, (-0).to32BitByteArray().toInt32()) + assertEquals(-1000, (-1000).to32BitByteArray().toInt32()) + } - private fun byteArrayOfInts(vararg ints: Int) = - ByteArray(ints.size) { pos -> ints[pos].toByte() } + private fun byteArrayOfInts(vararg ints: Int) = ByteArray(ints.size) { pos -> ints[pos].toByte() } } diff --git a/spotless/copyright.kt b/spotless/copyright.kt index b1d2fa456..01566bc35 100644 --- a/spotless/copyright.kt +++ b/spotless/copyright.kt @@ -1,7 +1,20 @@ -Copyright (c) 2023 Vitor Pamplona - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */