Compare commits

..
39 Commits
Author SHA1 Message Date
Vitor Pamplona b3be4567e2 v0.52.2 2023-05-27 16:36:36 -04:00
Vitor Pamplona c5a586ec56 Fixes Missing Notifications 2023-05-27 16:36:27 -04:00
Vitor Pamplona ece3664e74 Merge remote-tracking branch 'origin/HEAD' 2023-05-27 16:34:42 -04:00
Vitor PamplonaandGitHub 2297c797dc Merge pull request #427 from ZsZolee/main
Sensitive content Hungarian translation
2023-05-27 16:24:48 -04:00
Vitor Pamplona 4517ca9fdd trimming urls as well 2023-05-27 16:16:52 -04:00
Vitor Pamplona 1146b8ad54 v0.52.1 2023-05-27 16:06:15 -04:00
Vitor Pamplona 545643d165 Fixes Relay Icon rendering. 2023-05-27 16:04:20 -04:00
ZoltanandGitHub 359e22ae42 Sensitive content Hungarian translation 2023-05-27 17:02:15 +02:00
Vitor Pamplona 69be28ec98 Making sure the UserReactonsRow in notifications get updated as new events come in. 2023-05-26 18:05:35 -04:00
Vitor Pamplona a8ffbb922d Moves relay color filters to a singleton 2023-05-26 14:03:57 -04:00
Vitor Pamplona bd4a450336 v0.52.0 2023-05-26 12:21:10 -04:00
Vitor Pamplona 39818268ab Support for NIP-36, declaration of sensitive content. 2023-05-26 12:20:17 -04:00
Vitor Pamplona d26b83a851 Updating kotlin dependencies. 2023-05-26 12:19:38 -04:00
Vitor Pamplona 8dc9c2ed2f 0.51.4 2023-05-26 09:37:51 -04:00
Vitor Pamplona 860f3bd096 Improves color contrast for Zap Amount notifications in light mode 2023-05-26 09:00:05 -04:00
Vitor Pamplona a3adadb251 0.51.3 2023-05-25 21:09:47 -04:00
Vitor Pamplona 755d89dc79 Only changing stats for the Reaction Row if the data actually changed. 2023-05-25 21:09:14 -04:00
Vitor Pamplona fa4463f1d7 Improves rendering of messages in the Notification feed 2023-05-25 21:08:20 -04:00
Vitor Pamplona 56a68b9100 Feed invalidation is necessary for lists 2023-05-25 20:40:04 -04:00
Vitor Pamplona fe317964b8 Simplifying Private message rendering scheme 2023-05-25 20:39:15 -04:00
Vitor Pamplona 07ccf75d44 updates jsoup and activity compose to the latest versions 2023-05-25 18:43:01 -04:00
Vitor Pamplona ff927d4830 v0.51.2 2023-05-25 18:38:34 -04:00
Vitor Pamplona 6461c3e498 Changes the structure of the notification view to increase speed. 2023-05-25 18:35:23 -04:00
Vitor Pamplona 2ed5eae029 Preserves invalidations of DataSources for Home, Videos and Notifications 2023-05-25 18:34:40 -04:00
Vitor Pamplona 1693455d0d Only removes scrollTop from Uri after 1 second and to avoid changing before composition finishes. 2023-05-25 18:33:22 -04:00
Vitor Pamplona 3bcda33fa6 Activates invalidations that can be ignored if another invalidation is already running. 2023-05-25 18:30:42 -04:00
Vitor Pamplona 9f394900a7 Only scrolls to the top if not at the top 2023-05-25 18:30:15 -04:00
Vitor Pamplona 4f2c78e73a Remembers formatting for the Notification Reaction Row 2023-05-25 18:29:41 -04:00
Vitor Pamplona 6a3600fd73 Updates Notification lists in intervals of 1 second. 2023-05-25 18:28:51 -04:00
Vitor Pamplona b40c6f1361 remember modifiers to avoid recomposition 2023-05-25 18:28:34 -04:00
Vitor Pamplona 5ce19474ae Moves Home and Video screen invalidations to follow the scrollTop parameter 2023-05-25 18:28:19 -04:00
Vitor Pamplona e9bdc045ba Adds @Stable tag to Notification Card models 2023-05-25 18:27:05 -04:00
Vitor Pamplona aed7f46ca0 remembering size of the robohash picture 2023-05-25 18:25:47 -04:00
Vitor Pamplona 310a93ef29 removes logs 2023-05-25 16:24:16 -04:00
Vitor Pamplona 1ccaa1a028 Removing the start of relays before logging in. 2023-05-25 08:56:04 -04:00
Vitor Pamplona 493888d121 v0.51.1 2023-05-24 18:12:46 -04:00
Vitor Pamplona e5aba62fdd Imports BahaUrlPreview library to add the proxy connection into it. 2023-05-24 18:11:51 -04:00
Vitor PamplonaandGitHub b643cedf8f Merge pull request #424 from KotlinGeekDev/retain-state
Retain state when rotating device.
2023-05-24 17:02:27 -04:00
KotlinGeek 75d56214d2 Use manifest to avoid Activity recreation. 2023-05-23 23:58:59 +01:00
58 changed files with 1169 additions and 491 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.8.10" />
<option name="version" value="1.8.21" />
</component>
</project>
+1 -1
View File
@@ -60,12 +60,12 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [x] Audio Tracks (zapstr.live) (NIP-TBD)
- [x] Push Notifications (Zaps and Messages)
- [x] Generic Tags (NIP-12)
- [x] Sensitive Content (NIP-36)
- [ ] Marketplace (NIP-15)
- [ ] Image/Video Capture in the app
- [ ] Local Database
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
- [ ] Sensitive Content (NIP-36)
- [ ] Relay Pages (NIP-11)
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
- [ ] Events with a Subject (NIP-14)
+7 -5
View File
@@ -13,8 +13,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 175
versionName "0.51.0"
versionCode 182
versionName "0.52.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -24,6 +24,7 @@ android {
buildTypes {
release {
// TODO: Make sure all of JSON parsers work when activating these.
//minifyEnabled true
//proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
resValue "string", "app_name", "@string/app_name_release"
@@ -71,7 +72,7 @@ android {
}
composeOptions {
kotlinCompilerExtensionVersion "1.4.3"
kotlinCompilerExtensionVersion "1.4.7"
}
packagingOptions {
@@ -86,7 +87,7 @@ android {
dependencies {
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.activity:activity-compose:1.7.1'
implementation 'androidx.activity:activity-compose:1.7.2'
implementation "androidx.compose.ui:ui:$compose_ui_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
@@ -131,7 +132,8 @@ dependencies {
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
// link preview
implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
implementation 'org.jsoup:jsoup:1.16.1'
//implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
// Encrypted Key Storage
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
+1
View File
@@ -35,6 +35,7 @@
android:name=".ui.MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout"
android:theme="@style/Theme.Amethyst">
<intent-filter android:label="Amethyst">
@@ -58,6 +58,7 @@ private object PrefKeys {
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
const val USE_PROXY = "use_proxy"
const val PROXY_PORT = "proxy_port"
const val SHOW_SENSITIVE_CONTENT = "show_sensitive_content"
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
}
@@ -214,6 +215,12 @@ object LocalPreferences {
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
putBoolean(PrefKeys.USE_PROXY, account.proxy != null)
putInt(PrefKeys.PROXY_PORT, account.proxyPort)
if (account.showSensitiveContent == null) {
remove(PrefKeys.SHOW_SENSITIVE_CONTENT)
} else {
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent!!)
}
}.apply()
}
@@ -292,6 +299,12 @@ object LocalPreferences {
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 a = Account(
Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
followingChannels,
@@ -311,7 +324,8 @@ object LocalPreferences {
hideBlockAlertDialog,
latestContactList,
proxy,
proxyPort
proxyPort,
showSensitiveContent
)
return a
@@ -15,7 +15,6 @@ import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Constants
object ServiceManager {
private var account: Account? = null
@@ -46,9 +45,6 @@ object ServiceManager {
NostrSingleEventDataSource.start()
NostrSingleChannelDataSource.start()
NostrSingleUserDataSource.start()
} else {
// if not logged in yet, start a basic service wit default relays
Client.connect(Constants.convertDefaultRelays())
}
}
@@ -65,7 +65,8 @@ class Account(
var hideBlockAlertDialog: Boolean = false,
var backupContactList: ContactListEvent? = null,
var proxy: Proxy?,
var proxyPort: Int
var proxyPort: Int,
var showSensitiveContent: Boolean? = null
) {
var transientHiddenUsers: Set<String> = setOf()
@@ -480,7 +481,14 @@ class Account(
return LocalCache.notes[signedEvent.id]
}
fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?, tags: List<String>? = null, zapReceiver: String? = null) {
fun sendPost(
message: String,
replyTo: List<Note>?,
mentions: List<User>?,
tags: List<String>? = null,
zapReceiver: String? = null,
wantsToMarkAsSensitive: Boolean
) {
if (!isWriteable()) return
val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex }
@@ -494,6 +502,7 @@ class Account(
addresses = addresses,
extraTags = tags,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
privateKey = loggedIn.privKey!!
)
@@ -510,7 +519,8 @@ class Account(
valueMinimum: Int?,
consensusThreshold: Int?,
closedAt: Int?,
zapReceiver: String? = null
zapReceiver: String? = null,
wantsToMarkAsSensitive: Boolean
) {
if (!isWriteable()) return
@@ -529,14 +539,15 @@ class Account(
valueMinimum = valueMinimum,
consensusThreshold = consensusThreshold,
closedAt = closedAt,
zapReceiver = zapReceiver
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive
)
// println("Sending new PollNoteEvent: %s".format(signedEvent.toJson()))
Client.send(signedEvent)
LocalCache.consume(signedEvent)
}
fun sendChannelMessage(message: String, toChannel: String, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: String? = null) {
fun sendChannelMessage(message: String, toChannel: String, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean) {
if (!isWriteable()) return
// val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
@@ -549,13 +560,14 @@ class Account(
replyTos = repliesToHex,
mentions = mentionsHex,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent, null)
}
fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null) {
fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean) {
if (!isWriteable()) return
val user = LocalCache.users[toUser] ?: return
@@ -569,6 +581,7 @@ class Account(
replyTos = repliesToHex,
mentions = mentionsHex,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
privateKey = loggedIn.privKey!!,
advertiseNip18 = false
)
@@ -1114,6 +1127,12 @@ class Account(
saveable.invalidateData()
}
fun updateShowSensitiveContent(show: Boolean?) {
showSensitiveContent = show
saveable.invalidateData()
live.invalidateData()
}
fun registerObservers() {
// Observes relays to restart connections
userProfile().live().relays.observeForever {
@@ -232,7 +232,7 @@ open class Note(val idHex: String) {
fun isZappedBy(user: User, account: Account): Boolean {
// Zaps who the requester was the user
return zaps.any {
it.key.author === user || account.decryptZapContentAuthor(it.key)?.pubKey == user.pubkeyHex
it.key.author?.pubkeyHex == user.pubkeyHex || account.decryptZapContentAuthor(it.key)?.pubKey == user.pubkeyHex
} || zapPayments.any {
val zapResponseEvent = it.value?.event as? LnZapPaymentResponseEvent
val response = if (zapResponseEvent != null) {
@@ -245,11 +245,11 @@ open class Note(val idHex: String) {
}
fun isReactedBy(user: User): Boolean {
return reactions.any { it.author === user }
return reactions.any { it.author?.pubkeyHex == user.pubkeyHex }
}
fun isBoostedBy(user: User): Boolean {
return boosts.any { it.author === user }
return boosts.any { it.author?.pubkeyHex == user.pubkeyHex }
}
fun reportsBy(user: User): Set<Note> {
@@ -1,8 +1,8 @@
package com.vitorpamplona.amethyst.model
import com.baha.url.preview.BahaUrlPreview
import com.baha.url.preview.IUrlPreviewCallback
import com.baha.url.preview.UrlInfoItem
import com.vitorpamplona.amethyst.service.previews.BahaUrlPreview
import com.vitorpamplona.amethyst.service.previews.IUrlPreviewCallback
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -299,7 +299,7 @@ class User(val pubkeyHex: String) {
fun hasSentMessagesTo(user: User?): Boolean {
val messagesToUser = privateChatrooms[user] ?: return false
return messagesToUser.roomMessages.any { this === it.author }
return messagesToUser.roomMessages.any { this.pubkeyHex == it.author?.pubkeyHex }
}
fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean {
@@ -22,7 +22,16 @@ class ChannelMessageEvent(
companion object {
const val kind = 42
fun create(message: String, channel: String, replyTos: List<String>? = null, mentions: List<String>? = null, zapReceiver: String?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMessageEvent {
fun create(
message: String,
channel: String,
replyTos: List<String>? = null,
mentions: List<String>? = null,
zapReceiver: String?,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000,
markAsSensitive: Boolean
): ChannelMessageEvent {
val content = message
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf(
@@ -37,6 +46,9 @@ class ChannelMessageEvent(
zapReceiver?.let {
tags.add(listOf("zap", it))
}
if (markAsSensitive) {
tags.add(listOf("content-warning", ""))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
@@ -48,6 +48,12 @@ open class Event(
fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[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 zapAddress() = tags.firstOrNull { it.size > 1 && it[0] == "zap" }?.get(1)
fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
@@ -37,4 +37,5 @@ interface EventInterface {
fun getPoWRank(): Int
fun zapAddress(): String?
fun isSensitive(): Boolean
}
@@ -51,7 +51,8 @@ class PollNoteEvent(
valueMinimum: Int?,
consensusThreshold: Int?,
closedAt: Int?,
zapReceiver: String?
zapReceiver: String?,
markAsSensitive: Boolean
): PollNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
@@ -75,6 +76,9 @@ class PollNoteEvent(
if (zapReceiver != null) {
tags.add(listOf("zap", zapReceiver))
}
if (markAsSensitive) {
tags.add(listOf("content-warning", ""))
}
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
@@ -39,7 +39,7 @@ class PrivateDmEvent(
fun with(pubkeyHex: String): Boolean {
return pubkeyHex == pubKey ||
tags.firstOrNull { it.size > 1 && it[0] == "p" }?.getOrNull(1) == pubkeyHex
tags.any { it.size > 1 && it[0] == "p" && it[1] == pubkeyHex }
}
fun plainContent(privKey: ByteArray, pubKey: ByteArray): String? {
@@ -73,7 +73,8 @@ class PrivateDmEvent(
privateKey: ByteArray,
createdAt: Long = Date().time / 1000,
publishedRecipientPubKey: ByteArray? = null,
advertiseNip18: Boolean = true
advertiseNip18: Boolean = true,
markAsSensitive: Boolean
): PrivateDmEvent {
val content = Utils.encrypt(
if (advertiseNip18) { nip18Advertisement } else { "" } + msg,
@@ -94,6 +95,9 @@ class PrivateDmEvent(
zapReceiver?.let {
tags.add(listOf("zap", it))
}
if (markAsSensitive) {
tags.add(listOf("content-warning", ""))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return PrivateDmEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
@@ -29,6 +29,7 @@ class TextNoteEvent(
addresses: List<ATag>?,
extraTags: List<String>?,
zapReceiver: String?,
markAsSensitive: Boolean,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): TextNoteEvent {
@@ -56,6 +57,9 @@ class TextNoteEvent(
findURLs(msg).forEach {
tags.add(listOf("r", it))
}
if (markAsSensitive) {
tags.add(listOf("content-warning", ""))
}
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
@@ -0,0 +1,48 @@
package com.vitorpamplona.amethyst.service.previews
import android.net.Uri
import kotlinx.coroutines.*
import java.util.*
class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
private val imageExtensionArray = arrayOf(".gif", ".png", ".jpg", ".jpeg", ".bmp", ".webp")
fun fetchUrlPreview(timeOut: Int = 30000) {
val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
callback?.onFailed(throwable)
}
scope.launch(exceptionHandler) {
fetch(timeOut)
}
}
private suspend fun fetch(timeOut: Int = 30000) {
lateinit var urlInfoItem: UrlInfoItem
if (checkIsImageUrl()) {
urlInfoItem = UrlInfoItem(url = url, image = url)
} else {
val document = getDocument(url, timeOut)
urlInfoItem = parseHtml(document)
urlInfoItem.url = url
}
callback?.onComplete(urlInfoItem)
}
private fun checkIsImageUrl(): Boolean {
val uri = Uri.parse(url)
var isImage = false
for (imageExtension in imageExtensionArray) {
if (uri.path != null && uri.path!!.toLowerCase(Locale.getDefault()).endsWith(imageExtension)) {
isImage = true
break
}
}
return isImage
}
fun cleanUp() {
scope.cancel()
callback = null
}
}
@@ -0,0 +1,6 @@
package com.vitorpamplona.amethyst.service.previews
interface IUrlPreviewCallback {
fun onComplete(urlInfo: UrlInfoItem)
fun onFailed(throwable: Throwable)
}
@@ -0,0 +1,12 @@
package com.vitorpamplona.amethyst.service.previews
data class UrlInfoItem(
var url: String = "",
var title: String = "",
var description: String = "",
var image: String = ""
) {
fun allFetchComplete(): Boolean {
return title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()
}
}
@@ -0,0 +1,116 @@
package com.vitorpamplona.amethyst.service.previews
import com.vitorpamplona.amethyst.service.HttpClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
private const val ELEMENT_TAG_META = "meta"
private const val ATTRIBUTE_VALUE_PROPERTY = "property"
private const val ATTRIBUTE_VALUE_NAME = "name"
private const val ATTRIBUTE_VALUE_ITEMPROP = "itemprop"
/* for <meta property="og:" to get title */
private val META_OG_TITLE = arrayOf("og:title", "\"og:title\"", "'og:title'")
/* for <meta property="og:" to get description */
private val META_OG_DESCRIPTION =
arrayOf("og:description", "\"og:description\"", "'og:description'")
/* for <meta property="og:" to get image */
private val META_OG_IMAGE = arrayOf("og:image", "\"og:image\"", "'og:image'")
/*for <meta name=... to get title */
private val META_NAME_TITLE = arrayOf(
"twitter:title",
"\"twitter:title\"",
"'twitter:title'",
"title",
"\"title\"",
"'title'"
)
/*for <meta name=... to get description */
private val META_NAME_DESCRIPTION = arrayOf(
"twitter:description",
"\"twitter:description\"",
"'twitter:description'",
"description",
"\"description\"",
"'description'"
)
/*for <meta name=... to get image */
private val META_NAME_IMAGE = arrayOf(
"twitter:image",
"\"twitter:image\"",
"'twitter:image'"
)
/*for <meta itemprop=... to get title */
private val META_ITEMPROP_TITLE = arrayOf("name", "\"name\"", "'name'")
/*for <meta itemprop=... to get description */
private val META_ITEMPROP_DESCRIPTION = arrayOf("description", "\"description\"", "'description'")
/*for <meta itemprop=... to get image */
private val META_ITEMPROP_IMAGE = arrayOf("image", "\"image\"", "'image'")
private const val CONTENT = "content"
suspend fun getDocument(url: String, timeOut: Int = 30000): Document =
withContext(Dispatchers.IO) {
return@withContext Jsoup.connect(url)
.proxy(HttpClient.getProxy())
.timeout(timeOut)
.get()
}
suspend fun parseHtml(document: Document): UrlInfoItem =
withContext(Dispatchers.IO) {
val metaTags = document.getElementsByTag(ELEMENT_TAG_META)
val urlInfo = UrlInfoItem()
metaTags.forEach {
val propertyTag = it.attr(ATTRIBUTE_VALUE_PROPERTY)
when (propertyTag) {
in META_OG_TITLE -> if (urlInfo.title.isEmpty()) urlInfo.title = it.attr(CONTENT)
in META_OG_DESCRIPTION -> if (urlInfo.description.isEmpty()) {
urlInfo.description =
it.attr(CONTENT)
}
in META_OG_IMAGE -> if (urlInfo.image.isEmpty()) urlInfo.image = it.attr(CONTENT)
}
when (it.attr(ATTRIBUTE_VALUE_NAME)) {
in META_NAME_TITLE -> if (urlInfo.title.isEmpty()) urlInfo.title = it.attr(CONTENT)
in META_NAME_DESCRIPTION -> if (urlInfo.description.isEmpty()) {
urlInfo.description =
it.attr(CONTENT)
}
in META_OG_IMAGE -> if (urlInfo.image.isEmpty()) urlInfo.image = it.attr(CONTENT)
}
when (it.attr(ATTRIBUTE_VALUE_ITEMPROP)) {
in META_ITEMPROP_TITLE -> if (urlInfo.title.isEmpty()) {
urlInfo.title =
it.attr(CONTENT)
}
in META_ITEMPROP_DESCRIPTION -> if (urlInfo.description.isEmpty()) {
urlInfo.description =
it.attr(
CONTENT
)
}
in META_ITEMPROP_IMAGE -> if (urlInfo.image.isEmpty()) {
urlInfo.image = it.attr(
CONTENT
)
}
}
if (urlInfo.allFetchComplete()) {
return@withContext urlInfo
}
}
return@withContext urlInfo
}
@@ -22,8 +22,12 @@ import androidx.compose.material.icons.filled.ArrowForwardIos
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.CurrencyBitcoin
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.outlined.ArrowForwardIos
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -83,7 +87,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
val scroolState = rememberScrollState()
val scrollState = rememberScrollState()
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
@@ -153,7 +157,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scroolState)
.verticalScroll(scrollState)
) {
Notifying(postViewModel.mentions) {
postViewModel.removeFromReplyList(it)
@@ -335,10 +339,11 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
}
if (postViewModel.canUsePoll) {
val hashtag = stringResource(R.string.poll_hashtag)
// 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
postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag)
}
}
@@ -348,6 +353,10 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
}
}
MarkAsSensitive(postViewModel) {
postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive
}
ForwardZapTo(postViewModel) {
postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo
}
@@ -537,6 +546,60 @@ private fun ForwardZapTo(
}
}
@Composable
private fun MarkAsSensitive(
postViewModel: NewPostViewModel,
onClick: () -> Unit
) {
IconButton(
onClick = {
onClick()
}
) {
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.colors.onBackground
)
Icon(
imageVector = Icons.Rounded.Warning,
contentDescription = stringResource(R.string.content_warning),
modifier = Modifier
.size(10.dp)
.align(Alignment.TopEnd),
tint = MaterialTheme.colors.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(onCancel: () -> Unit) {
Button(
@@ -67,6 +67,9 @@ open class NewPostViewModel : ViewModel() {
var forwardZapTo by mutableStateOf<User?>(null)
var forwardZapToEditting by mutableStateOf(TextFieldValue(""))
// NSFW, Sensitive
var wantsToMarkAsSensitive by mutableStateOf(false)
open fun load(account: Account, replyingTo: Note?, quote: Note?) {
originalNote = replyingTo
replyingTo?.let { replyNote ->
@@ -97,6 +100,7 @@ open class NewPostViewModel : ViewModel() {
contentToAddUrl = null
wantsForwardZapTo = false
wantsToMarkAsSensitive = false
forwardZapTo = null
forwardZapToEditting = TextFieldValue("")
@@ -118,13 +122,13 @@ open class NewPostViewModel : ViewModel() {
}
if (wantsPoll) {
account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt, zapReceiver)
account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt, zapReceiver, wantsToMarkAsSensitive)
} else if (originalNote?.channel() != null) {
account?.sendChannelMessage(tagger.message, tagger.channel!!.idHex, tagger.replyTos, tagger.mentions, zapReceiver)
account?.sendChannelMessage(tagger.message, tagger.channel!!.idHex, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive)
} else if (originalNote?.event is PrivateDmEvent) {
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!.pubkeyHex, originalNote!!, tagger.mentions, zapReceiver)
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!.pubkeyHex, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive)
} else {
account?.sendPost(tagger.message, tagger.replyTos, tagger.mentions, null, zapReceiver)
account?.sendPost(tagger.message, tagger.replyTos, tagger.mentions, null, zapReceiver, wantsToMarkAsSensitive)
}
cancel()
@@ -183,6 +187,7 @@ open class NewPostViewModel : ViewModel() {
wantsInvoice = false
wantsForwardZapTo = false
wantsToMarkAsSensitive = false
forwardZapTo = null
forwardZapToEditting = TextFieldValue("")
@@ -73,7 +73,7 @@ private fun DisplayEvent(
nip19: Nip19.Return,
nav: (String) -> Unit
) {
var noteBase by remember { mutableStateOf<Note?>(null) }
var noteBase by remember(nip19) { mutableStateOf<Note?>(null) }
LaunchedEffect(key1 = nip19.hex) {
withContext(Dispatchers.IO) {
@@ -129,7 +129,7 @@ private fun DisplayNote(
nip19: Nip19.Return,
nav: (String) -> Unit
) {
var noteBase by remember { mutableStateOf<Note?>(null) }
var noteBase by remember(nip19) { mutableStateOf<Note?>(null) }
LaunchedEffect(key1 = nip19.hex) {
withContext(Dispatchers.IO) {
@@ -185,7 +185,7 @@ private fun DisplayAddress(
nip19: Nip19.Return,
nav: (String) -> Unit
) {
var noteBase by remember { mutableStateOf<Note?>(null) }
var noteBase by remember(nip19) { mutableStateOf<Note?>(null) }
LaunchedEffect(key1 = nip19.hex) {
withContext(Dispatchers.IO) {
@@ -217,7 +217,7 @@ private fun DisplayUser(
nip19: Nip19.Return,
nav: (String) -> Unit
) {
var userBase by remember { mutableStateOf<User?>(null) }
var userBase by remember(nip19) { mutableStateOf<User?>(null) }
LaunchedEffect(key1 = nip19.hex) {
withContext(Dispatchers.IO) {
@@ -227,7 +227,7 @@ private fun DisplayUser(
userBase?.let {
val userState by it.live().metadata.observeAsState()
val route = remember { "User/${it.pubkeyHex}" }
val route = remember(userState) { "User/${it.pubkeyHex}" }
val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() }
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
@@ -95,7 +95,7 @@ fun RichTextViewer(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val isMarkdown = remember { isMarkdown(content) }
val isMarkdown = remember(content) { isMarkdown(content) }
Column(modifier = modifier) {
if (isMarkdown) {
@@ -124,7 +124,7 @@ private fun RenderRegular(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var state by remember {
var state by remember(content) {
mutableStateOf(RichTextViewerState(content, emptySet(), emptyMap(), emptyList(), emptyMap()))
}
@@ -344,9 +344,9 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color, tag
)
)
var markdownWithSpecialContent by remember { mutableStateOf<String?>(null) }
var nip19References by remember { mutableStateOf<List<Nip19.Return>>(emptyList()) }
var refresh by remember { mutableStateOf(0) }
var markdownWithSpecialContent by remember(content) { mutableStateOf<String?>(null) }
var nip19References by remember(content) { mutableStateOf<List<Nip19.Return>>(emptyList()) }
var refresh by remember(content) { mutableStateOf(0) }
LaunchedEffect(key1 = content) {
withContext(Dispatchers.IO) {
@@ -365,8 +365,8 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color, tag
}
nip19References.forEach {
var baseUser by remember { mutableStateOf<User?>(null) }
var baseNote by remember { mutableStateOf<Note?>(null) }
var baseUser by remember(it) { mutableStateOf<User?>(null) }
var baseNote by remember(it) { mutableStateOf<Note?>(null) }
LaunchedEffect(key1 = it.hex) {
withContext(Dispatchers.IO) {
@@ -34,7 +34,9 @@ fun RobohashAsyncImage(
) {
val context = LocalContext.current
val size = with(LocalDensity.current) {
robotSize.roundToPx()
remember {
robotSize.roundToPx()
}
}
val imageRequest = remember(robotSize, robot) {
@@ -0,0 +1,127 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
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.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.runtime.Composable
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.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun SensitivityWarning(
hasSensitiveContent: Boolean,
accountViewModel: AccountViewModel,
content: @Composable () -> Unit
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
var showContentWarningNote by remember(accountState) {
mutableStateOf(accountState?.account?.showSensitiveContent != true && hasSensitiveContent)
}
if (showContentWarningNote) {
ContentWarningNote() {
showContentWarningNote = false
}
} else {
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.colors.onBackground
)
Icon(
imageVector = Icons.Rounded.Warning,
contentDescription = stringResource(R.string.content_warning),
modifier = Modifier
.size(30.dp)
.align(Alignment.TopEnd),
tint = MaterialTheme.colors.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 = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(
text = stringResource(R.string.show_anyway),
color = Color.White
)
}
}
}
}
}
}
@@ -10,10 +10,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import com.baha.url.preview.IUrlPreviewCallback
import com.baha.url.preview.UrlInfoItem
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.service.previews.IUrlPreviewCallback
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -21,7 +21,7 @@ import kotlinx.coroutines.launch
fun UrlPreview(url: String, urlText: String) {
val context = LocalContext.current
var urlPreviewState by remember {
var urlPreviewState by remember(url) {
val default = UrlCachedPreviewer.cache[url]?.let {
if (it.allFetchComplete() && it.url == url) {
UrlPreviewState.Loaded(it)
@@ -20,8 +20,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.baha.url.preview.UrlInfoItem
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
import java.net.URL
@Composable
@@ -42,10 +42,10 @@ fun UrlPreviewCard(
)
) {
Column {
val validatedUrl = remember { URL(previewInfo.url) }
val validatedUrl = remember(url) { URL(previewInfo.url) }
// correctly treating relative images
val imageUrl = remember {
val imageUrl = remember(url) {
if (previewInfo.image.startsWith("/")) {
URL(validatedUrl, previewInfo.image).toString()
} else {
@@ -1,6 +1,6 @@
package com.vitorpamplona.amethyst.ui.components
import com.baha.url.preview.UrlInfoItem
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
sealed class UrlPreviewState {
object Loading : UrlPreviewState()
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.*
object NotificationFeedFilter : AdditiveFeedFilter<Note>() {
@@ -36,7 +36,7 @@ object NotificationFeedFilter : AdditiveFeedFilter<Note>() {
(isGlobal || it.author?.pubkeyHex in followingKeySet) &&
it.event?.isTaggedUser(loggedInUserHex) ?: false &&
(it.author == null || !account.isHidden(it.author!!.pubkeyHex)) &&
tagsAnEventByUser(it, loggedInUser)
tagsAnEventByUser(it, loggedInUserHex)
}.toSet()
}
@@ -44,27 +44,27 @@ object NotificationFeedFilter : AdditiveFeedFilter<Note>() {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
fun tagsAnEventByUser(note: Note, author: User): Boolean {
fun tagsAnEventByUser(note: Note, authorHex: HexKey): Boolean {
val event = note.event
if (event is BaseTextNoteEvent) {
val isAuthoredPostCited = event.findCitations().any {
LocalCache.notes[it]?.author === author || LocalCache.addressables[it]?.author === author
LocalCache.notes[it]?.author?.pubkeyHex == authorHex || LocalCache.addressables[it]?.author?.pubkeyHex == authorHex
}
return isAuthoredPostCited ||
(
event.citedUsers().contains(author.pubkeyHex) ||
note.replyTo?.any { it.author === author } == true
event.citedUsers().contains(authorHex) ||
note.replyTo?.any { it.author?.pubkeyHex == authorHex } == true
)
}
if (event is ReactionEvent) {
return note.replyTo?.lastOrNull()?.author === author
return note.replyTo?.lastOrNull()?.author?.pubkeyHex == authorHex
}
if (event is RepostEvent) {
return note.replyTo?.lastOrNull()?.author === author
return note.replyTo?.lastOrNull()?.author?.pubkeyHex == authorHex
}
return true
@@ -123,7 +123,6 @@ private fun RowScope.HasNewItemsIcon(
LaunchedEffect(key1 = notifState, key2 = accountState) {
scope.launch(Dispatchers.IO) {
val newHasNewItems = route.hasNewItems(account, notif, emptySet())
println("Notification Change ${route.route} $hasNewItems -> $newHasNewItems")
if (newHasNewItems != hasNewItems) {
hasNewItems = newHasNewItems
}
@@ -134,7 +133,6 @@ private fun RowScope.HasNewItemsIcon(
scope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect {
val newHasNewItems = route.hasNewItems(account, notif, it)
println("Notification From Base ${route.route} $hasNewItems -> $newHasNewItems")
if (newHasNewItems != hasNewItems) {
hasNewItems = newHasNewItems
}
@@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.VideoScreen
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@@ -103,8 +104,14 @@ fun AppNavigation(
)
// Avoids running scroll to top when back button is pressed
// Changes this on a thread to avoid changing before it finishes the composition
if (scrollToTop) {
it.arguments?.remove("scrollToTop")
LaunchedEffect(key1 = Unit) {
scope.launch {
delay(1000)
it.arguments?.remove("scrollToTop")
}
}
}
})
}
@@ -121,8 +128,14 @@ fun AppNavigation(
)
// Avoids running scroll to top when back button is pressed
// Changes this on a thread to avoid changing before it finishes the composition
if (scrollToTop) {
it.arguments?.remove("scrollToTop")
LaunchedEffect(key1 = Unit) {
scope.launch {
delay(1000)
it.arguments?.remove("scrollToTop")
}
}
}
})
}
@@ -144,10 +157,20 @@ fun AppNavigation(
// Avoids running scroll to top when back button is pressed
if (scrollToTop) {
it.arguments?.remove("scrollToTop")
LaunchedEffect(key1 = Unit) {
scope.launch {
delay(1000)
it.arguments?.remove("scrollToTop")
}
}
}
if (nip47 != null) {
it.arguments?.remove("nip47")
LaunchedEffect(key1 = Unit) {
scope.launch {
delay(1000)
it.arguments?.remove("nip47")
}
}
}
})
}
@@ -165,8 +188,14 @@ fun AppNavigation(
)
// Avoids running scroll to top when back button is pressed
// Changes this on a thread to avoid changing before it finishes the composition
if (scrollToTop) {
it.arguments?.remove("scrollToTop")
LaunchedEffect(key1 = Unit) {
scope.launch {
delay(1000)
it.arguments?.remove("scrollToTop")
}
}
}
})
}
@@ -55,10 +55,11 @@ class AddBountyAmountViewModel : ViewModel() {
if (newValue != null) {
account?.sendPost(
newValue.toString(),
listOfNotNull(bounty),
listOfNotNull(bounty?.author),
listOf("bounty-added-reward")
message = newValue.toString(),
replyTo = listOfNotNull(bounty),
mentions = listOfNotNull(bounty?.author),
tags = listOf("bounty-added-reward"),
wantsToMarkAsSensitive = false
)
nextAmount = TextFieldValue("")
@@ -37,8 +37,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.layout.onSizeChanged
@@ -63,8 +61,10 @@ import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -117,7 +117,7 @@ fun ChatroomMessageCompose(
LaunchedEffect(key1 = noteReportsState, key2 = accountState) {
withContext(Dispatchers.IO) {
account.userProfile().let { loggedIn ->
val newCanPreview = note.author === loggedIn ||
val newCanPreview = note.author?.pubkeyHex == loggedIn.pubkeyHex ||
(note.author?.let { loggedIn.isFollowingCached(it) } ?: true) ||
!(noteForReports.hasAnyReports())
@@ -369,15 +369,21 @@ private fun RenderRegularTextNote(
val modifier = remember { Modifier.padding(top = 5.dp) }
if (eventContent != null) {
TranslatableRichTextViewer(
content = eventContent,
canPreview = canPreview,
modifier = modifier,
tags = tags,
backgroundColor = backgroundBubbleColor,
accountViewModel = accountViewModel,
nav = nav
)
val hasSensitiveContent = remember(note.event) { note.event?.isSensitive() ?: false }
SensitivityWarning(
hasSensitiveContent = hasSensitiveContent,
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),
@@ -531,31 +537,27 @@ private fun RelayBadges(baseNote: Note) {
}
@Composable
private fun RenderRelay(dirtyUrl: String) {
fun RenderRelay(dirtyUrl: String) {
val uri = LocalUriHandler.current
val website = remember {
val cleanUrl = dirtyUrl.removePrefix("wss://").removePrefix("ws://")
val website = remember(dirtyUrl) {
val cleanUrl = dirtyUrl.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/")
"https://$cleanUrl"
}
val iconUrl = remember {
val cleanUrl = dirtyUrl.removePrefix("wss://").removePrefix("ws://")
val iconUrl = remember(dirtyUrl) {
val cleanUrl = dirtyUrl.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/")
"https://$cleanUrl/favicon.ico"
}
val clickableModifier = remember {
val clickableModifier = remember(dirtyUrl) {
Modifier
.size(15.dp)
.padding(1.dp)
.size(15.dp)
.clickable(onClick = { uri.openUri(website) })
}
val colorFilter = remember {
ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0.5f) })
}
val iconModifier = remember {
val iconModifier = remember(dirtyUrl) {
Modifier
.size(15.dp)
.size(13.dp)
.clip(shape = CircleShape)
}
@@ -564,10 +566,10 @@ private fun RenderRelay(dirtyUrl: String) {
) {
RobohashFallbackAsyncImage(
robot = iconUrl,
robotSize = 15.dp,
robotSize = 13.dp,
model = iconUrl,
contentDescription = stringResource(id = R.string.relay_icon),
colorFilter = colorFilter,
colorFilter = RelayIconFilter,
modifier = iconModifier.background(MaterialTheme.colors.background)
)
}
@@ -35,9 +35,14 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MessageSetCompose(messageSetCard: MessageSetCard, routeForLastRead: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val noteState by messageSetCard.note.live().metadata.observeAsState()
val baseNote = remember { messageSetCard.note }
val noteState by baseNote.live().metadata.observeAsState()
val note = remember(noteState) { noteState?.note }
val accountState by accountViewModel.accountLiveData.observeAsState()
val loggedIn = remember(accountState) { accountState?.account?.userProfile() } ?: return
var popupExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
@@ -78,8 +83,8 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, routeForLastRead: String,
onClick = {
scope.launch {
routeFor(
note,
accountViewModel.userProfile()
baseNote,
loggedIn
)?.let { nav(it) }
}
},
@@ -90,18 +95,11 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, routeForLastRead: String,
Column(columnModifier) {
Row(Modifier.fillMaxWidth()) {
Box(modifier = remember { Modifier.width(55.dp).padding(top = 5.dp, end = 5.dp) }) {
Icon(
painter = painterResource(R.drawable.ic_dm),
null,
modifier = remember { Modifier.size(16.dp).align(Alignment.TopEnd) },
tint = MaterialTheme.colors.primary
)
}
MessageIcon()
Column(modifier = remember { Modifier.padding(start = 10.dp) }) {
NoteCompose(
baseNote = messageSetCard.note,
baseNote = baseNote,
routeForLastRead = null,
isBoostedNote = true,
addMarginTop = false,
@@ -116,3 +114,25 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, routeForLastRead: String,
}
}
}
@Composable
private fun MessageIcon() {
Box(
modifier = remember {
Modifier
.width(55.dp)
.padding(top = 5.dp, end = 5.dp)
}
) {
Icon(
painter = painterResource(R.drawable.ic_dm),
null,
modifier = remember {
Modifier
.size(16.dp)
.align(Alignment.TopEnd)
},
tint = MaterialTheme.colors.primary
)
}
}
@@ -8,10 +8,12 @@ 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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
@@ -19,7 +21,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -29,6 +30,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.res.painterResource
@@ -39,11 +41,9 @@ import androidx.compose.ui.unit.sp
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
@@ -55,7 +55,6 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.math.BigDecimal
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -66,7 +65,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
val note = remember(noteState) { noteState?.note } ?: return
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
val loggedIn = remember(accountState) { accountState?.account?.userProfile() } ?: return
var popupExpanded by remember { mutableStateOf(false) }
@@ -92,55 +91,48 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
val primaryColor = MaterialTheme.colors.primary.copy(0.12f)
val defaultBackgroundColor = MaterialTheme.colors.background
val backgroundColor by remember(isNew) {
derivedStateOf {
if (isNew) {
primaryColor.compositeOver(defaultBackgroundColor)
} else {
defaultBackgroundColor
}
}
val backgroundColor = if (isNew) {
primaryColor.compositeOver(defaultBackgroundColor)
} else {
defaultBackgroundColor
}
val columnModifier by remember(isNew, backgroundColor) {
derivedStateOf {
Modifier
.background(backgroundColor)
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp
)
.combinedClickable(
onClick = {
scope.launch {
routeFor(
baseNote,
account.userProfile()
)?.let { nav(it) }
}
},
onLongClick = { popupExpanded = true }
)
.fillMaxWidth()
}
}
val columnModifier = Modifier
.background(backgroundColor)
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp
)
.combinedClickable(
onClick = {
scope.launch {
routeFor(baseNote, loggedIn)?.let { nav(it) }
}
},
onLongClick = { popupExpanded = true }
)
.fillMaxWidth()
val zapEvents = remember { multiSetCard.zapEvents }
val boostEvents = remember { multiSetCard.boostEvents }
val likeEvents = remember { multiSetCard.likeEvents }
val zapEvents by remember { derivedStateOf { multiSetCard.zapEvents } }
val boostEvents by remember { derivedStateOf { multiSetCard.boostEvents } }
val likeEvents by remember { derivedStateOf { multiSetCard.likeEvents } }
val hasZapEvents by remember { derivedStateOf { multiSetCard.zapEvents.isNotEmpty() } }
val hasBoostEvents by remember { derivedStateOf { multiSetCard.boostEvents.isNotEmpty() } }
val hasLikeEvents by remember { derivedStateOf { multiSetCard.likeEvents.isNotEmpty() } }
Column(modifier = columnModifier) {
if (zapEvents.isNotEmpty()) {
RenderZapGallery(zapEvents, backgroundColor, nav, account, accountViewModel)
if (hasZapEvents) {
RenderZapGallery(zapEvents, backgroundColor, nav, accountViewModel)
}
if (boostEvents.isNotEmpty()) {
RenderBoostGallery(boostEvents, backgroundColor, nav, account, accountViewModel)
if (hasBoostEvents) {
RenderBoostGallery(boostEvents, backgroundColor, nav, accountViewModel)
}
if (likeEvents.isNotEmpty()) {
RenderLikeGallery(likeEvents, backgroundColor, nav, account, accountViewModel)
if (hasLikeEvents) {
RenderLikeGallery(likeEvents, backgroundColor, nav, accountViewModel)
}
Row(Modifier.fillMaxWidth()) {
@@ -167,7 +159,6 @@ private fun RenderLikeGallery(
likeEvents: ImmutableList<Note>,
backgroundColor: Color,
nav: (String) -> Unit,
account: Account,
accountViewModel: AccountViewModel
) {
Row(Modifier.fillMaxWidth()) {
@@ -190,7 +181,7 @@ private fun RenderLikeGallery(
)
}
AuthorGallery(likeEvents, backgroundColor, nav, account, accountViewModel)
AuthorGallery(likeEvents, backgroundColor, nav, accountViewModel)
}
}
@@ -199,7 +190,6 @@ private fun RenderZapGallery(
zapEvents: ImmutableMap<Note, Note>,
backgroundColor: Color,
nav: (String) -> Unit,
account: Account,
accountViewModel: AccountViewModel
) {
Row(Modifier.fillMaxWidth()) {
@@ -222,7 +212,7 @@ private fun RenderZapGallery(
)
}
AuthorGalleryZaps(zapEvents, backgroundColor, nav, account, accountViewModel)
AuthorGalleryZaps(zapEvents, backgroundColor, nav, accountViewModel)
}
}
@@ -231,7 +221,6 @@ private fun RenderBoostGallery(
boostEvents: ImmutableList<Note>,
backgroundColor: Color,
nav: (String) -> Unit,
account: Account,
accountViewModel: AccountViewModel
) {
Row(
@@ -251,14 +240,14 @@ private fun RenderBoostGallery(
null,
modifier = remember {
Modifier
.size(18.dp)
.size(19.dp)
.align(Alignment.TopEnd)
},
tint = Color.Unspecified
)
}
AuthorGallery(boostEvents, backgroundColor, nav, account, accountViewModel)
AuthorGallery(boostEvents, backgroundColor, nav, accountViewModel)
}
}
@@ -267,15 +256,14 @@ fun AuthorGalleryZaps(
authorNotes: ImmutableMap<Note, Note>,
backgroundColor: Color,
nav: (String) -> Unit,
account: Account,
accountViewModel: AccountViewModel
) {
val accountState = account.userProfile().live().follows.observeAsState()
Column(modifier = Modifier.padding(start = 10.dp)) {
FlowRow() {
authorNotes.forEach {
AuthorPictureAndComment(it.key, it.value, backgroundColor, nav, accountState, accountViewModel)
Box() {
AuthorPictureAndComment(it.key, it.value, backgroundColor, nav, accountViewModel)
}
}
}
}
@@ -287,55 +275,45 @@ private fun AuthorPictureAndComment(
zapEvent: Note?,
backgroundColor: Color,
nav: (String) -> Unit,
accountUser: State<UserState?>,
accountViewModel: AccountViewModel
) {
val author = zapRequest.author ?: return
var content by remember { mutableStateOf<Triple<User, String?, BigDecimal?>>(Triple(author, null, null)) }
var content by remember { mutableStateOf<Triple<User?, String?, String?>>(Triple(zapRequest.author, null, null)) }
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = zapRequest.idHex, key2 = zapEvent?.idHex) {
scope.launch(Dispatchers.IO) {
scope.launch(Dispatchers.Default) {
(zapRequest.event as? LnZapRequestEvent)?.let {
val decryptedContent = accountViewModel.decryptZap(zapRequest)
val amount = (zapEvent?.event as? LnZapEvent)?.amount
if (decryptedContent != null) {
val newAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey)
content = Triple(newAuthor, decryptedContent.content.ifBlank { null }, amount)
content = Triple(newAuthor, decryptedContent.content.ifBlank { null }, showAmountAxis(amount))
} else {
if (!zapRequest.event?.content().isNullOrBlank() || amount != null) {
content = Triple(author, zapRequest.event?.content()?.ifBlank { null }, amount)
content = Triple(zapRequest.author, zapRequest.event?.content()?.ifBlank { null }, showAmountAxis(amount))
}
}
}
}
}
val modifier = remember(content.second) {
if (!content.second.isNullOrBlank()) {
Modifier
.fillMaxWidth()
.clickable {
nav("User/${author.pubkeyHex}")
}
} else {
Modifier.clickable {
nav("User/${author.pubkeyHex}")
content.first?.let {
val route by remember {
derivedStateOf {
"User/${it.pubkeyHex}"
}
}
}
AuthorPictureAndComment(
author = content.first,
comment = content.second,
amount = showAmountAxis(content.third),
backgroundColor = backgroundColor,
nav = nav,
accountUser = accountUser,
accountViewModel = accountViewModel,
modifier = modifier
)
AuthorPictureAndComment(
author = it,
comment = content.second,
amount = content.third,
route = route,
backgroundColor = backgroundColor,
nav = nav,
accountViewModel = accountViewModel
)
}
}
@Composable
@@ -343,12 +321,27 @@ private fun AuthorPictureAndComment(
author: User,
comment: String?,
amount: String?,
route: String,
backgroundColor: Color,
nav: (String) -> Unit,
accountUser: State<UserState?>,
accountViewModel: AccountViewModel,
modifier: Modifier
accountViewModel: AccountViewModel
) {
val authorPictureModifier = remember { Modifier }
val modifier = remember(comment) {
if (comment != null) {
Modifier
.fillMaxWidth()
.clickable {
nav(route)
}
} else {
Modifier.clickable {
nav(route)
}
}
}
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
@@ -356,23 +349,27 @@ private fun AuthorPictureAndComment(
Box(modifier = remember { Modifier.size(35.dp) }, contentAlignment = Alignment.BottomCenter) {
FastNoteAuthorPicture(
author = author,
userAccount = accountUser,
size = 35.dp
size = 35.dp,
accountViewModel = accountViewModel,
pictureModifier = authorPictureModifier
)
amount?.let {
Box(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colors.background.copy(0.52f)),
contentAlignment = Alignment.BottomCenter
) {
Text(
text = it,
fontWeight = FontWeight.Bold,
color = BitcoinOrange,
fontSize = 12.sp
)
Box(modifier = Modifier.fillMaxSize().clip(shape = CircleShape), contentAlignment = Alignment.BottomCenter) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colors.background.copy(0.62f)),
contentAlignment = Alignment.BottomCenter
) {
Text(
text = it,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.secondaryVariant,
fontSize = 12.sp,
modifier = Modifier.padding(bottom = 1.dp)
)
}
}
}
}
@@ -397,34 +394,47 @@ fun AuthorGallery(
authorNotes: ImmutableList<Note>,
backgroundColor: Color,
nav: (String) -> Unit,
account: Account,
accountViewModel: AccountViewModel
) {
val accountState = account.userProfile().live().follows.observeAsState()
Column(modifier = Modifier.padding(start = 10.dp)) {
FlowRow() {
authorNotes.forEach { note ->
note.author?.let { user ->
val modifier = remember {
Modifier.clickable {
nav("User/${user.pubkeyHex}")
}
}
AuthorPictureAndComment(user, null, null, backgroundColor, nav, accountState, accountViewModel, modifier)
Box(Modifier.size(35.dp)) {
NotePictureAndComment(note, backgroundColor, nav, accountViewModel)
}
}
}
}
}
@Composable
private fun NotePictureAndComment(
baseNote: Note,
backgroundColor: Color,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
val author by remember(baseNote) {
derivedStateOf {
baseNote.author
}
}
val route by remember(baseNote) {
derivedStateOf {
"User/${baseNote.author?.pubkeyHex}"
}
}
author?.let { AuthorPictureAndComment(it, null, null, route, backgroundColor, nav, accountViewModel) }
}
@Composable
fun FastNoteAuthorPicture(
author: User,
userAccount: State<UserState?>,
size: Dp,
pictureModifier: Modifier = Modifier
pictureModifier: Modifier = Modifier,
accountViewModel: AccountViewModel
) {
val userState by author.live().metadata.observeAsState()
val profilePicture = remember(userState) {
@@ -435,8 +445,15 @@ fun FastNoteAuthorPicture(
author.pubkeyHex
}
val showFollowingMark = remember(userAccount.value) {
userAccount.value?.user?.isFollowingCached(author) == true || (author === userAccount.value?.user)
val accountState by accountViewModel.accountLiveData.observeAsState()
val loggedInLiveFollows = remember(accountState) { accountState?.account?.userProfile()?.live()?.follows } ?: return
val accountFollowsState by loggedInLiveFollows.observeAsState()
val showFollowingMark by remember(accountFollowsState) {
derivedStateOf {
accountFollowsState?.user?.isFollowingCached(author) == true || (author.pubkeyHex == accountFollowsState?.user?.pubkeyHex)
}
}
UserPicture(
@@ -57,14 +57,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
@@ -115,7 +112,7 @@ import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.components.ZoomableContent
@@ -166,6 +163,7 @@ fun NoteCompose(
val noteEvent = remember(noteState) { note.event }
val baseChannel = remember(noteState) { note.channel() }
val isSensitive = remember(noteState) { note.event?.isSensitive() ?: false }
var popupExpanded by remember { mutableStateOf(false) }
@@ -183,7 +181,7 @@ fun NoteCompose(
note.let {
NoteQuickActionMenu(it, popupExpanded, { popupExpanded = false }, accountViewModel)
}
} else if (account.isHidden(noteForReports.author!!)) {
} else if (account.isHidden(noteForReports.author!!) || (isSensitive && account.showSensitiveContent == false)) {
// Does nothing
} else {
var showHiddenNote by remember { mutableStateOf(false) }
@@ -192,7 +190,7 @@ fun NoteCompose(
LaunchedEffect(key1 = noteReportsState, key2 = accountState) {
withContext(Dispatchers.IO) {
account.userProfile().let { loggedIn ->
val newCanPreview = note.author === loggedIn ||
val newCanPreview = note.author?.pubkeyHex == loggedIn.pubkeyHex ||
(note.author?.let { loggedIn.isFollowingCached(it) } ?: true) ||
!(noteForReports.hasAnyReports())
@@ -435,13 +433,10 @@ private fun RenderTextEvent(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val tags = remember(note.event?.id()) { note.event?.tags() }
val hashtags = remember(note.event?.id()) { note.event?.hashtags() ?: emptyList() }
val eventContent = remember(note.event?.id()) { accountViewModel.decrypt(note) }
val modifier = remember(note.event?.id()) { Modifier.fillMaxWidth() }
val isAuthorTheLoggedUser = remember(note.event?.id()) { accountViewModel.isLoggedUser(note.author) }
val eventContent = remember(note.event) { accountViewModel.decrypt(note) }
if (eventContent != null) {
val isAuthorTheLoggedUser = remember(note.event) { accountViewModel.isLoggedUser(note.author) }
if (makeItShort && isAuthorTheLoggedUser) {
Text(
text = eventContent,
@@ -450,16 +445,27 @@ private fun RenderTextEvent(
overflow = TextOverflow.Ellipsis
)
} else {
TranslatableRichTextViewer(
content = eventContent,
canPreview = canPreview && !makeItShort,
modifier = modifier,
tags = tags,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
val hasSensitiveContent = remember(note.event) { note.event?.isSensitive() ?: false }
SensitivityWarning(
hasSensitiveContent = hasSensitiveContent,
accountViewModel = accountViewModel
) {
val modifier = remember(note.event) { Modifier.fillMaxWidth() }
val tags = remember(note.event) { note.event?.tags() }
TranslatableRichTextViewer(
content = eventContent,
canPreview = canPreview && !makeItShort,
modifier = modifier,
tags = tags,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
}
val hashtags = remember(note.event) { note.event?.hashtags() ?: emptyList() }
DisplayUncitedHashtags(hashtags, eventContent, nav)
}
}
@@ -494,25 +500,31 @@ private fun RenderPoll(
overflow = TextOverflow.Ellipsis
)
} else {
TranslatableRichTextViewer(
eventContent,
canPreview = canPreview && !makeItShort,
Modifier.fillMaxWidth(),
noteEvent.tags(),
backgroundColor,
accountViewModel,
nav
)
val hasSensitiveContent = remember(note.event) { note.event?.isSensitive() ?: false }
SensitivityWarning(
hasSensitiveContent = hasSensitiveContent,
accountViewModel = accountViewModel
) {
TranslatableRichTextViewer(
eventContent,
canPreview = canPreview && !makeItShort,
Modifier.fillMaxWidth(),
noteEvent.tags(),
backgroundColor,
accountViewModel,
nav
)
PollNote(
note,
canPreview = canPreview && !makeItShort,
backgroundColor,
accountViewModel,
nav
)
}
DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, nav)
PollNote(
note,
canPreview = canPreview && !makeItShort,
backgroundColor,
accountViewModel,
nav
)
}
if (!makeItShort) {
@@ -567,13 +579,18 @@ private fun RenderPrivateMessage(
nav: (String) -> Unit
) {
val noteEvent = note.event as? PrivateDmEvent ?: return
val withMe = remember { noteEvent.with(accountViewModel.userProfile().pubkeyHex) }
val tags = remember(note.event?.id()) { note.event?.tags() }
val hashtags = remember(note.event?.id()) { note.event?.hashtags() ?: emptyList() }
val modifier = remember(note.event?.id()) { Modifier.fillMaxWidth() }
val isAuthorTheLoggedUser = remember(note.event?.id()) { accountViewModel.isLoggedUser(note.author) }
if (withMe) {
val eventContent = remember { accountViewModel.decrypt(note) }
if (eventContent != null) {
if (makeItShort && accountViewModel.isLoggedUser(note.author)) {
if (makeItShort && isAuthorTheLoggedUser) {
Text(
text = eventContent,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
@@ -581,17 +598,23 @@ private fun RenderPrivateMessage(
overflow = TextOverflow.Ellipsis
)
} else {
TranslatableRichTextViewer(
content = eventContent,
canPreview = canPreview && !makeItShort,
modifier = Modifier.fillMaxWidth(),
tags = noteEvent.tags(),
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
val hasSensitiveContent = remember(note.event) { note.event?.isSensitive() ?: false }
SensitivityWarning(
hasSensitiveContent = hasSensitiveContent,
accountViewModel = accountViewModel
) {
TranslatableRichTextViewer(
content = eventContent,
canPreview = canPreview && !makeItShort,
modifier = modifier,
tags = tags,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
}
DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, nav)
DisplayUncitedHashtags(hashtags, eventContent, nav)
}
}
} else {
@@ -1885,46 +1908,11 @@ private fun VerticalRelayPanelWithFlow(
// FlowRow Seems to be a lot faster than LazyVerticalGrid
FlowRow() {
relays.forEach { url ->
RelayIconCompose(url)
RenderRelay(url)
}
}
}
@Composable
@Stable
private fun RelayIconCompose(url: String) {
val uri = LocalUriHandler.current
val model = remember(url) { "https://$url/favicon.ico" }
val boxModifier = remember {
Modifier
.padding(1.dp)
.size(15.dp)
}
val iconModifier = remember(url) {
Modifier
.width(13.dp)
.height(13.dp)
.clip(shape = CircleShape)
.clickable(onClick = { uri.openUri("https://$url") })
}
val colorFilter = remember {
ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0.5f) })
}
Box(boxModifier) {
RobohashFallbackAsyncImage(
robot = model,
robotSize = 15.dp,
model = model,
contentDescription = stringResource(R.string.relay_icon),
colorFilter = colorFilter,
modifier = iconModifier.background(MaterialTheme.colors.background)
)
}
}
@Composable
private fun ShowMoreRelaysButton(onClick: () -> Unit) {
val boxModifier = remember {
@@ -2026,7 +2014,7 @@ fun UserPicture(
baseUser: User,
baseUserAccount: User,
size: Dp,
modifier: Modifier = Modifier,
modifier: Modifier = remember { Modifier },
onClick: ((User) -> Unit)? = null,
onLongClick: ((User) -> Unit)? = null
) {
@@ -2042,7 +2030,7 @@ fun UserPicture(
}
val showFollowingMark = remember(accountState) {
accountState?.user?.isFollowingCached(baseUser) == true || baseUser === accountState?.user
accountState?.user?.isFollowingCached(baseUser) == true || baseUser.pubkeyHex == accountState?.user?.pubkeyHex
}
// BaseUser is the same reference as accountState.user
@@ -2119,7 +2107,9 @@ fun UserPicture(
}
val myIconModifier = remember {
Modifier.width(size.div(3.5f)).height(size.div(3.5f))
Modifier
.width(size.div(3.5f))
.height(size.div(3.5f))
}
Box(myIconBoxModifier, contentAlignment = Alignment.Center) {
@@ -2142,7 +2132,9 @@ data class DropDownParams(
val isFollowingAuthor: Boolean,
val isPrivateBookmarkNote: Boolean,
val isPublicBookmarkNote: Boolean,
val isLoggedUser: Boolean
val isLoggedUser: Boolean,
val isSensitive: Boolean,
val showSensitiveContent: Boolean?
)
@Composable
@@ -2153,20 +2145,23 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
var reportDialogShowing by remember { mutableStateOf(false) }
val bookmarkState by accountViewModel.userProfile().live().bookmarks.observeAsState()
val accountState by accountViewModel.accountLiveData.observeAsState()
var state by remember {
mutableStateOf<DropDownParams>(
DropDownParams(false, false, false, false)
DropDownParams(false, false, false, false, false, null)
)
}
LaunchedEffect(key1 = note, key2 = bookmarkState) {
LaunchedEffect(key1 = note, key2 = bookmarkState, key3 = accountState) {
withContext(Dispatchers.IO) {
state = DropDownParams(
accountViewModel.isFollowing(note.author),
accountViewModel.isInPrivateBookmarks(note),
accountViewModel.isInPublicBookmarks(note),
accountViewModel.isLoggedUser(note.author)
isFollowingAuthor = accountViewModel.isFollowing(note.author),
isPrivateBookmarkNote = accountViewModel.isInPrivateBookmarks(note),
isPublicBookmarkNote = accountViewModel.isInPublicBookmarks(note),
isLoggedUser = accountViewModel.isLoggedUser(note.author),
isSensitive = note.event?.isSensitive() ?: false,
showSensitiveContent = accountState?.account?.showSensitiveContent
)
}
}
@@ -2244,6 +2239,24 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
Text("Block / Report")
}
}
// if (state.isSensitive) {
Divider()
if (state.showSensitiveContent == null || state.showSensitiveContent == true) {
DropdownMenuItem(onClick = { accountViewModel.hideSensitiveContent(); onDismiss() }) {
Text(stringResource(R.string.content_warning_hide_all_sensitive_content))
}
}
if (state.showSensitiveContent == null || state.showSensitiveContent == false) {
DropdownMenuItem(onClick = { accountViewModel.disableContentWarnings(); onDismiss() }) {
Text(stringResource(R.string.content_warning_show_all_sensitive_content))
}
}
if (state.showSensitiveContent != null) {
DropdownMenuItem(onClick = { accountViewModel.seeContentWarnings(); onDismiss() }) {
Text(stringResource(R.string.content_warning_see_warnings))
}
}
// }
}
if (reportDialogShowing) {
@@ -116,10 +116,10 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
val cardShape = RoundedCornerShape(5.dp)
val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope()
var showSelectTextDialog by remember { mutableStateOf(false) }
var showDeleteAlertDialog by remember { mutableStateOf(false) }
var showBlockAlertDialog by remember { mutableStateOf(false) }
var showReportDialog by remember { mutableStateOf(false) }
var showSelectTextDialog by remember(note) { mutableStateOf(false) }
var showDeleteAlertDialog by remember(note) { mutableStateOf(false) }
var showBlockAlertDialog by remember(note) { mutableStateOf(false) }
var showReportDialog by remember(note) { mutableStateOf(false) }
val backgroundColor = if (MaterialTheme.colors.isLight) {
MaterialTheme.colors.primary
@@ -51,7 +51,9 @@ fun PollNote(
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
val pollViewModel: PollNoteViewModel = viewModel()
val pollViewModel: PollNoteViewModel = viewModel(
key = baseNote.idHex
)
LaunchedEffect(key1 = baseNote) {
pollViewModel.load(account, baseNote)
@@ -28,6 +28,7 @@ import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.ripple.rememberRipple
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
@@ -62,6 +63,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.math.RoundingMode
import kotlin.math.abs
import kotlin.math.roundToInt
@Composable
@@ -124,6 +126,14 @@ fun ReplyReaction(
val repliesState by baseNote.live().replies.observeAsState()
val replies = remember(repliesState) { repliesState?.note?.replies } ?: emptySet()
val isWriteable = remember { accountViewModel.isWriteable() }
val replyCount by remember(repliesState) {
derivedStateOf {
showCount(replies.size)
}
}
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -138,7 +148,7 @@ fun ReplyReaction(
IconButton(
modifier = iconButtonModifier,
onClick = {
if (accountViewModel.isWriteable()) {
if (isWriteable) {
onPress()
} else {
scope.launch {
@@ -161,7 +171,7 @@ fun ReplyReaction(
if (showCounter) {
Text(
" ${showCount(replies.size)}",
" $replyCount",
fontSize = 14.sp,
color = grayTint
)
@@ -179,11 +189,25 @@ fun BoostReaction(
val boostsState by baseNote.live().boosts.observeAsState()
val boostedNote = remember(boostsState) { boostsState?.note } ?: return
val hasBoosted = remember(boostsState) { accountViewModel.hasBoosted(baseNote) }
val wasBoostedByLoggedIn = remember(boostsState) { boostedNote.isBoostedBy(accountViewModel.userProfile()) }
val hasBoosted by remember(boostsState) {
derivedStateOf {
accountViewModel.hasBoosted(baseNote)
}
}
val wasBoostedByLoggedIn by remember(boostsState) {
derivedStateOf {
boostedNote.isBoostedBy(accountViewModel.userProfile())
}
}
val isWriteable = remember { accountViewModel.isWriteable() }
val boostCount = remember(boostsState) { showCount(boostedNote.boosts.size) }
val boostCount by remember(boostsState) {
derivedStateOf {
showCount(boostedNote.boosts.size)
}
}
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -258,11 +282,25 @@ fun LikeReaction(
val reactionsState by baseNote.live().reactions.observeAsState()
val reactedNote = remember(reactionsState) { reactionsState?.note } ?: return
val hasReacted = remember(reactionsState) { accountViewModel.hasReactedTo(baseNote) }
val wasReactedByLoggedIn = remember(reactionsState) { reactedNote.isReactedBy(accountViewModel.userProfile()) }
val hasReacted by remember(reactionsState) {
derivedStateOf {
accountViewModel.hasReactedTo(baseNote)
}
}
val wasReactedByLoggedIn by remember(reactionsState) {
derivedStateOf {
reactedNote.isReactedBy(accountViewModel.userProfile())
}
}
val isWriteable = remember { accountViewModel.isWriteable() }
val reactionCount = remember(reactionsState) { showCount(reactedNote.reactions.size) }
val reactionCount by remember(reactionsState) {
derivedStateOf {
showCount(reactedNote.reactions.size)
}
}
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -350,13 +388,22 @@ fun ZapReaction(
LaunchedEffect(key1 = zapsState) {
scope.launch(Dispatchers.IO) {
if (!wasZappedByLoggedInUser) {
wasZappedByLoggedInUser = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote)
val newWasZapped = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote)
if (wasZappedByLoggedInUser != newWasZapped) {
wasZappedByLoggedInUser = newWasZapped
}
}
zapAmountTxt = showAmount(account.calculateZappedAmount(zappedNote))
val newZapAmount = showAmount(account.calculateZappedAmount(zappedNote))
if (newZapAmount != zapAmountTxt) {
zapAmountTxt = newZapAmount
}
if (wasZappedByLoggedInUser) {
zappingProgress = 1f
if (abs(zappingProgress - 1) < 0.001) {
zappingProgress = 1f
}
}
}
}
@@ -13,8 +13,10 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
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.Companion.CenterVertically
import androidx.compose.ui.Modifier
@@ -40,9 +42,9 @@ import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.RoyalBlue
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
@@ -53,7 +55,10 @@ import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Composable
fun UserReactionsRow(model: UserReactionsViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit, onClick: () -> Unit) {
fun UserReactionsRow(
model: UserReactionsViewModel,
onClick: () -> Unit
) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier
@@ -92,6 +97,7 @@ fun UserReactionsRow(model: UserReactionsViewModel, accountViewModel: AccountVie
}
}
@Stable
class UserReactionsViewModel : ViewModel() {
var user: User? = null
@@ -100,13 +106,12 @@ class UserReactionsViewModel : ViewModel() {
var zaps by mutableStateOf<Map<String, BigDecimal>>(emptyMap())
var replies by mutableStateOf<Map<String, Int>>(emptyMap())
var takenIntoAccount = setOf<HexKey>()
val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat()
var chartModel by mutableStateOf<ComposedChartEntryModel<ChartEntryModel>?>(null)
var axisLabels by mutableStateOf<List<String>>(emptyList())
private var takenIntoAccount = setOf<HexKey>()
private val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat()
fun load(baseUser: User) {
user = baseUser
reactions = emptyMap()
@@ -275,17 +280,19 @@ class UserReactionsViewModel : ViewModel() {
fun UserReplyReaction(
replyCount: Int?
) {
val showCounts = remember(replyCount) { showCount(replyCount) }
Icon(
painter = painterResource(R.drawable.ic_comment),
null,
modifier = Modifier.size(20.dp),
tint = Color.Cyan
tint = RoyalBlue
)
Spacer(modifier = Modifier.width(10.dp))
Text(
showCount(replyCount),
showCounts,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
@@ -295,6 +302,8 @@ fun UserReplyReaction(
fun UserBoostReaction(
boostCount: Int?
) {
val showCounts = remember(boostCount) { showCount(boostCount) }
Icon(
painter = painterResource(R.drawable.ic_retweeted),
null,
@@ -305,7 +314,7 @@ fun UserBoostReaction(
Spacer(modifier = Modifier.width(10.dp))
Text(
showCount(boostCount),
showCounts,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
@@ -315,6 +324,8 @@ fun UserBoostReaction(
fun UserLikeReaction(
likeCount: Int?
) {
val showCounts = remember(likeCount) { showCount(likeCount) }
Icon(
painter = painterResource(R.drawable.ic_liked),
null,
@@ -325,7 +336,7 @@ fun UserLikeReaction(
Spacer(modifier = Modifier.width(10.dp))
Text(
showCount(likeCount),
showCounts,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
@@ -335,6 +346,8 @@ fun UserLikeReaction(
fun UserZapReaction(
amount: BigDecimal?
) {
val showAmounts = remember(amount) { showAmountAxis(amount) }
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
@@ -345,7 +358,7 @@ fun UserZapReaction(
Spacer(modifier = Modifier.width(8.dp))
Text(
showAmountAxis(amount),
showAmounts,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import kotlinx.collections.immutable.ImmutableList
@@ -92,9 +93,17 @@ class MessageSetCard(val note: Note) : Card() {
override fun id() = note.idHex
}
@Immutable
sealed class CardFeedState {
@Immutable
object Loading : CardFeedState()
@Stable
class Loaded(val feed: MutableState<ImmutableList<Card>>) : CardFeedState()
@Immutable
object Empty : CardFeedState()
@Immutable
class FeedError(val errorMessage: String) : CardFeedState()
}
@@ -5,7 +5,12 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.ExperimentalMaterialApi
@@ -48,9 +53,14 @@ fun CardFeedView(
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)) { state ->
Box(Modifier.fillMaxSize().pullRefresh(pullRefreshState)) {
Column(modifier = Modifier.fillMaxSize()) {
Crossfade(
modifier = Modifier.fillMaxSize(),
targetState = feedState,
animationSpec = tween(durationMillis = 100)
) { state ->
when (state) {
is CardFeedState.Empty -> {
FeedEmpty {
@@ -104,72 +114,97 @@ private fun FeedLoaded(
if (scrollToTop) {
LaunchedEffect(Unit) {
listState.scrollToItem(index = 0)
if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0) {
listState.scrollToItem(index = 0)
}
}
}
LazyNotificationList(listState, state, routeForLastRead, accountViewModel, nav)
}
@Composable
private fun LazyNotificationList(
listState: LazyListState,
state: CardFeedState.Loaded,
routeForLastRead: String,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
modifier = remember { Modifier.fillMaxSize() },
contentPadding = remember {
PaddingValues(
top = 10.dp,
bottom = 10.dp
)
},
state = listState
) {
itemsIndexed(state.feed.value, key = { _, item -> item.id() }) { _, item ->
when (item) {
is NoteCard -> NoteCompose(
item.note,
isBoostedNote = false,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is ZapSetCard -> ZapSetCompose(
item,
isInnerNote = false,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is ZapUserSetCard -> ZapUserSetCompose(
item,
isInnerNote = false,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is LikeSetCard -> LikeSetCompose(
item,
isInnerNote = false,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is BoostSetCard -> BoostSetCompose(
item,
isInnerNote = false,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is MultiSetCard -> MultiSetCompose(
item,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is BadgeCard -> BadgeCompose(
item,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is MessageSetCard -> MessageSetCompose(
messageSetCard = item,
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
nav = nav
)
Row(Modifier.fillMaxWidth().defaultMinSize(minHeight = 100.dp)) {
when (item) {
is NoteCard -> NoteCompose(
item.note,
isBoostedNote = false,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is ZapSetCard -> ZapSetCompose(
item,
isInnerNote = false,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is ZapUserSetCard -> ZapUserSetCompose(
item,
isInnerNote = false,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is LikeSetCard -> LikeSetCompose(
item,
isInnerNote = false,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is BoostSetCard -> BoostSetCompose(
item,
isInnerNote = false,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is MultiSetCard -> MultiSetCompose(
item,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is BadgeCard -> BadgeCompose(
item,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is MessageSetCard -> MessageSetCompose(
messageSetCard = item,
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
nav = nav
)
}
}
}
}
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.screen
import android.util.Log
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -33,6 +34,7 @@ import kotlinx.coroutines.launch
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@Stable
class NotificationViewModel : CardFeedViewModel(NotificationFeedFilter)
open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
@@ -233,7 +235,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
@OptIn(ExperimentalTime::class)
private val bundler = BundledUpdate(250, Dispatchers.IO) {
private val bundler = BundledUpdate(1000, Dispatchers.IO) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
val (value, elapsed) = measureTimedValue {
@@ -241,7 +243,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed")
}
private val bundlerInsert = BundledInsert<Set<Note>>(250, Dispatchers.IO)
private val bundlerInsert = BundledInsert<Set<Note>>(1000, Dispatchers.IO)
fun invalidateData(ignoreIfDoing: Boolean = false) {
bundler.invalidate(ignoreIfDoing)
@@ -119,7 +119,9 @@ private fun FeedLoaded(
if (scrollToTop) {
LaunchedEffect(Unit) {
listState.scrollToItem(index = 0)
if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0) {
listState.scrollToItem(index = 0)
}
}
}
@@ -115,8 +115,8 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
private val bundlerInsert = BundledInsert<Set<Note>>(250, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate()
fun invalidateData(ignoreIfDoing: Boolean = false) {
bundler.invalidate(ignoreIfDoing)
}
fun invalidateInsertData(newItems: Set<Note>) {
@@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.service.model.PeopleListEvent
import com.vitorpamplona.amethyst.service.model.PinListEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.note.*
import com.vitorpamplona.amethyst.ui.note.BadgeDisplay
@@ -376,15 +377,22 @@ fun NoteMaster(
!noteForReports.hasAnyReports()
if (eventContent != null) {
TranslatableRichTextViewer(
eventContent,
canPreview,
Modifier.fillMaxWidth(),
note.event?.tags(),
MaterialTheme.colors.background,
accountViewModel,
nav
)
val hasSensitiveContent = remember(note.event) { note.event?.isSensitive() ?: false }
SensitivityWarning(
hasSensitiveContent = hasSensitiveContent,
accountViewModel = accountViewModel
) {
TranslatableRichTextViewer(
eventContent,
canPreview,
Modifier.fillMaxWidth(),
note.event?.tags(),
MaterialTheme.colors.background,
accountViewModel,
nav
)
}
DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, nav)
@@ -210,7 +210,7 @@ class AccountViewModel(private val account: Account) : ViewModel() {
}
fun isLoggedUser(user: User?): Boolean {
return account.userProfile() === user
return account.userProfile().pubkeyHex == user?.pubkeyHex
}
fun isFollowing(user: User?): Boolean {
@@ -232,6 +232,18 @@ class AccountViewModel(private val account: Account) : ViewModel() {
account.setHideBlockAlertDialog()
}
fun hideSensitiveContent() {
account.updateShowSensitiveContent(false)
}
fun disableContentWarnings() {
account.updateShowSensitiveContent(true)
}
fun seeContentWarnings() {
account.updateShowSensitiveContent(null)
}
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <AccountViewModel : ViewModel> create(modelClass: Class<AccountViewModel>): AccountViewModel {
return AccountViewModel(account) as AccountViewModel
@@ -218,7 +218,7 @@ fun ChannelScreen(
onPost = {
val tagger = NewMessageTagger(channel, listOfNotNull(replyTo.value?.author), listOfNotNull(replyTo.value), channelScreenModel.message.text)
tagger.run()
account.sendChannelMessage(tagger.message, channel.idHex, tagger.replyTos, tagger.mentions)
account.sendChannelMessage(tagger.message, channel.idHex, tagger.replyTos, tagger.mentions, wantsToMarkAsSensitive = false)
channelScreenModel.message = TextFieldValue("")
replyTo.value = null
feedViewModel.invalidateData() // Don't wait a full second before updating
@@ -178,7 +178,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, nav: (St
trailingIcon = {
PostButton(
onPost = {
account.sendPrivateMessage(chatRoomScreenModel.message.text, userId, replyTo.value, null)
account.sendPrivateMessage(chatRoomScreenModel.message.text, userId, replyTo.value, null, wantsToMarkAsSensitive = false)
chatRoomScreenModel.message = TextFieldValue("")
replyTo.value = null
feedViewModel.invalidateData() // Don't wait a full second before updating
@@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@@ -58,9 +59,9 @@ fun HomeScreen(
LaunchedEffect(accountViewModel, account.defaultHomeFollowList) {
HomeNewThreadFeedFilter.account = account
HomeConversationsFeedFilter.account = account
NostrHomeDataSource.resetFilters()
homeFeedViewModel.invalidateData()
repliesFeedViewModel.invalidateData()
NostrHomeDataSource.invalidateFilters()
homeFeedViewModel.invalidateData(true)
repliesFeedViewModel.invalidateData(true)
}
if (wantsToAddNip47 != null) {
@@ -73,9 +74,7 @@ fun HomeScreen(
if (event == Lifecycle.Event.ON_RESUME) {
HomeNewThreadFeedFilter.account = account
HomeConversationsFeedFilter.account = account
NostrHomeDataSource.resetFilters()
homeFeedViewModel.invalidateData()
repliesFeedViewModel.invalidateData()
NostrHomeDataSource.invalidateFilters()
}
}
@@ -85,6 +84,17 @@ fun HomeScreen(
}
}
if (scrollToTop) {
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = Unit) {
scope.launch(Dispatchers.IO) {
NostrHomeDataSource.invalidateFilters()
homeFeedViewModel.invalidateData(true)
repliesFeedViewModel.invalidateData(true)
}
}
}
val tabs by remember(homeFeedViewModel, repliesFeedViewModel) {
mutableStateOf(
listOf(
@@ -13,6 +13,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
@@ -51,6 +52,9 @@ import com.vitorpamplona.amethyst.ui.screen.CardFeedView
import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.RoyalBlue
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.math.RoundingMode
import kotlin.math.roundToInt
@@ -64,23 +68,20 @@ fun NotificationScreen(
scrollToTop: Boolean = false
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val account = remember(accountState) { accountState?.account } ?: return
if (scrollToTop) {
notifFeedViewModel.clear()
}
LaunchedEffect(account.userProfile().pubkeyHex, account.defaultNotificationFollowList) {
NostrAccountDataSource.resetFilters()
LaunchedEffect(accountViewModel, account.defaultNotificationFollowList) {
NostrAccountDataSource.invalidateFilters()
NotificationFeedFilter.account = account
notifFeedViewModel.invalidateData(true)
}
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
NostrAccountDataSource.invalidateFilters()
NotificationFeedFilter.account = account
notifFeedViewModel.invalidateData(true)
}
}
@@ -90,11 +91,23 @@ fun NotificationScreen(
}
}
if (scrollToTop) {
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = Unit) {
scope.launch(Dispatchers.IO) {
notifFeedViewModel.clear()
notifFeedViewModel.invalidateData(true)
}
}
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
SummaryBar(userReactionsStatsModel, accountViewModel, nav)
SummaryBar(
model = userReactionsStatsModel
)
CardFeedView(
viewModel = notifFeedViewModel,
accountViewModel = accountViewModel,
@@ -108,18 +121,18 @@ fun NotificationScreen(
}
@Composable
fun SummaryBar(model: UserReactionsViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
fun SummaryBar(model: UserReactionsViewModel) {
var showChart by remember {
mutableStateOf(false)
}
UserReactionsRow(model, accountViewModel, nav) {
UserReactionsRow(model) {
showChart = !showChart
}
val lineChartCount =
lineChart(
lines = listOf(Color.Cyan, Color.Green, Color.Red).map { lineChartColor ->
lines = listOf(RoyalBlue, Color.Green, Color.Red).map { lineChartColor ->
LineChart.LineSpec(
lineColor = lineChartColor.toArgb(),
lineBackgroundShader = DynamicShaders.fromBrush(
@@ -153,8 +166,8 @@ fun SummaryBar(model: UserReactionsViewModel, accountViewModel: AccountViewModel
targetVerticalAxisPosition = AxisPosition.Vertical.End
)
model.chartModel?.let {
if (showChart) {
if (showChart) {
model.chartModel?.let {
Row(
modifier = Modifier
.padding(vertical = 10.dp, horizontal = 20.dp)
@@ -7,7 +7,6 @@ import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -47,12 +46,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -73,7 +68,6 @@ import com.vitorpamplona.amethyst.ui.actions.NewMediaModel
import com.vitorpamplona.amethyst.ui.actions.NewMediaView
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay
@@ -82,6 +76,7 @@ import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteDropDownMenu
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.RenderRelay
import com.vitorpamplona.amethyst.ui.note.ViewCountReaction
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.screen.FeedEmpty
@@ -91,6 +86,7 @@ import com.vitorpamplona.amethyst.ui.screen.LoadingFeed
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -123,7 +119,6 @@ fun VideoScreen(
VideoFeedFilter.account = account
NostrVideoDataSource.account = account
NostrVideoDataSource.start()
videoFeedView.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Video Stop")
@@ -137,6 +132,16 @@ fun VideoScreen(
}
}
if (scrollToTop) {
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = Unit) {
scope.launch(Dispatchers.IO) {
NostrVideoDataSource.resetFilters()
videoFeedView.invalidateData()
}
}
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
@@ -307,35 +312,13 @@ private fun RenderVideoOrPictureNote(
@Composable
private fun RelayBadges(baseNote: Note) {
val noteRelaysState by baseNote.live().relays.observeAsState()
val noteRelays = noteRelaysState?.note?.relays ?: emptySet()
var expanded by remember { mutableStateOf(false) }
val relaysToDisplay = noteRelays
val uri = LocalUriHandler.current
val noteRelays = remember(noteRelaysState) {
noteRelaysState?.note?.relays ?: emptySet()
}
FlowRow() {
relaysToDisplay.forEach {
val url = it.removePrefix("wss://").removePrefix("ws://")
Box(
Modifier
.size(15.dp)
.padding(1.dp)
) {
RobohashFallbackAsyncImage(
robot = "https://$url/favicon.ico",
robotSize = 15.dp,
model = "https://$url/favicon.ico",
contentDescription = stringResource(id = R.string.relay_icon),
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
modifier = Modifier
.fillMaxSize(1f)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.clickable(onClick = { uri.openUri("https://$url") })
)
}
noteRelays.forEach { dirtyUrl ->
RenderRelay(dirtyUrl)
}
}
}
@@ -1,12 +1,15 @@
package com.vitorpamplona.amethyst.ui.theme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
val Purple200 = Color(0xFFBB86FC)
val Purple500 = Color(0xFF6200EE)
val Purple700 = Color(0xFF3700B3)
val Teal200 = Color(0xFF03DAC5)
val BitcoinOrange = Color(0xFFF7931A)
val RoyalBlue = Color(0xFF4169E1)
val Following = Color(0xFF03DAC5)
val Nip05 = Color(0xFF01BAFF)
@@ -14,3 +17,5 @@ val FollowsFollow = Color.Yellow
val NIP05Verified = Color.Blue
val WarningColor = Color(0xFFC62828)
val RelayIconFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0.5f) })
@@ -7,19 +7,22 @@ import androidx.compose.material.darkColors
import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
private val DarkColorPalette = darkColors(
primary = Purple200,
primaryVariant = Purple700,
secondary = Teal200
secondary = Teal200,
secondaryVariant = Color(0xFFF7931A)
)
private val LightColorPalette = lightColors(
primary = Purple500,
primaryVariant = Purple700,
secondary = Teal200
secondary = Teal200,
secondaryVariant = Color(0xFFB66605)
/* Other default colors to override
background = Color.White,
+6
View File
@@ -385,4 +385,10 @@
<string name="channel_list_join_channel">Csatlakozás</string>
<string name="today">Ma</string>
<string name="content_warning">Tartalomra vonatkozó figyelmeztetés</string>
<string name="content_warning_explanation">Ez a bejegyzés érzékeny tartalmat tartalmaz, amelyet egyesek sértőnek vagy zavarónak találhatnak</string>
<string name="content_warning_hide_all_sensitive_content">Az érzékeny tartalmat mindig rejtse el</string>
<string name="content_warning_show_all_sensitive_content">Az érzékeny tartalmat mindig jelenítse meg</string>
<string name="content_warning_see_warnings">A tartalomra vonatkozó figyelmeztetéseket mindig jelenítse meg</string>
</resources>
+6
View File
@@ -404,4 +404,10 @@
<string name="channel_list_join_channel">Join</string>
<string name="today">Today</string>
<string name="content_warning">Content warning</string>
<string name="content_warning_explanation">This post contains sensitive content which some people may find offensive or disturbing</string>
<string name="content_warning_hide_all_sensitive_content">Always hide sensitive content</string>
<string name="content_warning_show_all_sensitive_content">Always show sensitive content</string>
<string name="content_warning_see_warnings">Always show content warnings</string>
</resources>
+4 -4
View File
@@ -14,10 +14,10 @@ buildscript {
}
}// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '8.0.1' apply false
id 'com.android.library' version '8.0.1' apply false
id 'org.jetbrains.kotlin.android' version '1.8.10' apply false
id 'org.jetbrains.kotlin.jvm' version '1.8.10' apply false
id 'com.android.application' version '8.0.2' apply false
id 'com.android.library' version '8.0.2' apply false
id 'org.jetbrains.kotlin.android' version '1.8.21' apply false
id 'org.jetbrains.kotlin.jvm' version '1.8.21' apply false
}
task installGitHook(type: Copy) {