mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-06 22:54:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
244e3f3b29 | ||
|
|
6a44d283f9 | ||
|
|
6addce3d20 | ||
|
|
8aae101ef6 | ||
|
|
d1bbdef5c4 | ||
|
|
b78967f8be | ||
|
|
01a2f746d0 | ||
|
|
d063c2990c | ||
|
|
e31ee031cb | ||
|
|
8fc489cc59 | ||
|
|
ffb2a19963 | ||
|
|
b271c1775c | ||
|
|
f937e95b35 | ||
|
|
1613e79860 | ||
|
|
dd40b4f205 | ||
|
|
2ad9401ee6 | ||
|
|
69ade2f6e6 | ||
|
|
4006e62a82 | ||
|
|
ec88a8b157 | ||
|
|
128211ecd2 | ||
|
|
8f53537ebc | ||
|
|
58c93e0cce | ||
|
|
c35b5e3703 | ||
|
|
be8848fb1b | ||
|
|
a3efd15b95 | ||
|
|
a1930c232b | ||
|
|
58a4fb18ae | ||
|
|
be4870da1a | ||
|
|
d2f6317d5c | ||
|
|
05fb408842 | ||
|
|
611305a406 | ||
|
|
5b856af19b | ||
|
|
ce5106684f | ||
|
|
f0fe2dfc17 | ||
|
|
fb5fad5d02 | ||
|
|
6314e3fcb2 | ||
|
|
da62760fd7 | ||
|
|
e3eae80d4c | ||
|
|
3cd0828cba | ||
|
|
806b86f950 | ||
|
|
c931e2cbee | ||
|
|
4e57eed5d8 | ||
|
|
8e725259eb | ||
|
|
b48d2df8d2 | ||
|
|
dbf34ae457 | ||
|
|
74be724835 | ||
|
|
012c7a7105 | ||
|
|
e50dff3b62 | ||
|
|
ad5a4d0b59 | ||
|
|
3116ea1ad9 | ||
|
|
72e474c9ec | ||
|
|
fe9e85c282 | ||
|
|
01ac90fbec | ||
|
|
dc079941e7 | ||
|
|
9a7a94cb77 | ||
|
|
a44703cb49 | ||
|
|
52bd6b4443 | ||
|
|
a9e1ce5aec | ||
|
|
639fdb174d | ||
|
|
3cd02ee6cf | ||
|
|
52199fe281 | ||
|
|
5616fa52d1 | ||
|
|
7241c8bd9f | ||
|
|
991131f8cd | ||
|
|
a24c95070c | ||
|
|
a346d31554 | ||
|
|
9b5c7ad23f | ||
|
|
a01a060abc | ||
|
|
1cd39e0d8f | ||
|
|
305ecc6a2e | ||
|
|
858f94ae74 | ||
|
|
fc224d99e3 | ||
|
|
b8584964d1 | ||
|
|
fbd0c96a8a | ||
|
|
e2251f9c86 | ||
|
|
eaaacbf218 | ||
|
|
243fbeed44 | ||
|
|
d62bab1b08 | ||
|
|
70e068ed17 | ||
|
|
824e8795d5 | ||
|
|
f768a09bd8 | ||
|
|
1a69a5a4c7 | ||
|
|
b9cc3781bb | ||
|
|
97964ed65c | ||
|
|
42a1d157bf | ||
|
|
e27efbc9c2 | ||
|
|
57a5d0b618 | ||
|
|
8f668cb5fb | ||
|
|
df5ea6f741 | ||
|
|
f0638b1766 |
@@ -47,7 +47,7 @@ jobs:
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
|
||||
- name: Build APK
|
||||
run: ./gradlew assembleRelease --stacktrace --no-daemon
|
||||
run: ./gradlew assembleRelease --stacktrace
|
||||
|
||||
- name: Sign APK (Google Play)
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
|
||||
+41
-6
@@ -1,13 +1,48 @@
|
||||
# Amethyst Privacy Policy & Terms of Use
|
||||
# Amethyst Privacy Policy and Terms of Use
|
||||
|
||||
Effective as of Jan 20, 2023 for the distributed applications in the Play Store and the F-Droid Catalogue
|
||||
## Privacy Policy
|
||||
|
||||
The Amethyst app for Android does not collect or process any personal information from its users. The app is used to connect to third-party Nostr servers (also called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them.
|
||||
Effective as of Jun 12, 2023
|
||||
|
||||
The app may collect a per-device token, your public key and a preferred Relay to connect to and provide push notification services through Google's Firebase Cloud Messaging. Other than that, the data from connected accounts is only stored locally on the device when it is required for functionality and performance of Amethyst. This data is strictly confidental and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app.
|
||||
The Amethyst app for Android does not collect or process any personal information from its users. The app is used to connect to third-party Nostr servers (also called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open-source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them.
|
||||
|
||||
You cannot use the Amethyst app for Android to submit Objectionable Content to relays. Objectionable Content includes, but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary right, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.
|
||||
The app may collect a per-device token, your public key, and a preferred Relay to connect to and provide push notification services through Google's Firebase Cloud Messaging. Other than that, the data from connected accounts are only stored locally on the device when it's required for the functionality and performance of Amethyst. This data is strictly confidential and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app.
|
||||
|
||||
We reserve the right to modify this Privacy Policy at any time. Any modifications to this Privacy Policy will be effective upon our posting the new terms and/or upon implementation of the new changes on the Service (or as otherwise indicated at the time of posting). In all cases, your continued use of the app after the posting of any modified Privacy Policy indicates your acceptance of the terms of the modified Privacy Policy.
|
||||
Amethyst offers a few options to upload pictures and videos in order to post them online. You can choose the server at your discretion. Similar to relays, such services are independent of the app and have their own privacy policy and terms of use.
|
||||
|
||||
### Privacy with Relays services
|
||||
|
||||
Your internet protocol (IP) address is exposed to the relays you connect to. If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g. a VPN) from trackers online.
|
||||
|
||||
The relay can also see which public keys you are using and what information you are requesting from the network. Your public key is tied to your IP address and your relay filters.
|
||||
|
||||
Relays have all your data in raw text. They know your IP, your name, your location (guessed from IP), your pub key, all your contacts, and other relays, and can read every action you do (post, like, boost, quote, report, etc) with the exception of the content inside Private Zaps and Private DMs.
|
||||
|
||||
While the content of direct messages (DMs) is only visible to you, and your DM Nostr counterparty, everyone can see when you and your counterparty are DM-ing each other. Image uploads in the DM screen use one of the chosen image servers and simply paste the image link on the DM text. Your uploaded pictures are available to anyone with that direct link.
|
||||
|
||||
### Visibility & Permanence of Your Content on Nostr Relays
|
||||
|
||||
#### Information Visibility
|
||||
|
||||
Content that you share can be shared with other relays by any user of the network.
|
||||
Information that you share is publicly visible to anyone reading from relays that have your information. Your information may also be visible to Nostr users who do not share relays with you.
|
||||
|
||||
#### Information Permanence
|
||||
|
||||
Information shared on Nostr should be assumed permanent for privacy purposes. There is no way to guarantee deleting or editing any content once posted.
|
||||
|
||||
## Terms of Use
|
||||
|
||||
### For versions downloaded from Google's Play Store
|
||||
|
||||
You cannot use the Amethyst app for Android to submit Objectionable Content to relays. Objectionable Content includes but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary rights, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.
|
||||
|
||||
### For versions downloaded from F-Droid
|
||||
|
||||
We do not control the distribution of the application in F-Droid. Legal matters should be resolved between the user and F-Droid.
|
||||
|
||||
## Other Notes
|
||||
|
||||
We reserve the right to modify this Privacy Policy and Terms of Use at any time. Any modifications to this document will be effective upon our posting of the new terms and/or upon implementation of the new changes on the Service (or as otherwise indicated at the time of posting). In all cases, your continued use of the app after the posting of any modified Privacy Policy and Terms of Use indicates your acceptance of the terms of the modified Privacy Policy and/or Terms of Use.
|
||||
|
||||
If you have any questions about Amethyst or this privacy policy, you can send a message to amethyst@vitorpamplona.com
|
||||
|
||||
@@ -64,6 +64,8 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
|
||||
- [x] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
|
||||
- [x] Recommended Application Handlers (NIP-89)
|
||||
- [x] Events with a Subject (NIP-14)
|
||||
- [x] Generic Reposts (kind:16)
|
||||
- [x] Live Activities (NIP-102)
|
||||
- [ ] Marketplace (NIP-15)
|
||||
- [ ] Image/Video Capture in the app
|
||||
- [ ] Local Database
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 205
|
||||
versionName "0.56.5"
|
||||
versionCode 218
|
||||
versionName "0.61.2"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
|
||||
@@ -49,6 +49,7 @@ private object PrefKeys {
|
||||
const val LANGUAGE_PREFS = "languagePreferences"
|
||||
const val TRANSLATE_TO = "translateTo"
|
||||
const val ZAP_AMOUNTS = "zapAmounts"
|
||||
const val REACTION_CHOICES = "reactionChoices"
|
||||
const val DEFAULT_ZAPTYPE = "defaultZapType"
|
||||
const val DEFAULT_FILE_SERVER = "defaultFileServer"
|
||||
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
|
||||
@@ -61,6 +62,9 @@ private object PrefKeys {
|
||||
const val USE_PROXY = "use_proxy"
|
||||
const val PROXY_PORT = "proxy_port"
|
||||
const val SHOW_SENSITIVE_CONTENT = "show_sensitive_content"
|
||||
const val WARN_ABOUT_REPORTS = "warn_about_reports"
|
||||
const val FILTER_SPAM_FROM_STRANGERS = "filter_spam_from_strangers"
|
||||
const val LAST_READ_PER_ROUTE = "last_read_route_per_route"
|
||||
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
|
||||
}
|
||||
|
||||
@@ -206,6 +210,7 @@ object LocalPreferences {
|
||||
putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(account.languagePreferences))
|
||||
putString(PrefKeys.TRANSLATE_TO, account.translateTo)
|
||||
putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices))
|
||||
putString(PrefKeys.REACTION_CHOICES, gson.toJson(account.reactionChoices))
|
||||
putString(PrefKeys.DEFAULT_ZAPTYPE, gson.toJson(account.defaultZapType))
|
||||
putString(PrefKeys.DEFAULT_FILE_SERVER, gson.toJson(account.defaultFileServer))
|
||||
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList)
|
||||
@@ -217,6 +222,9 @@ object LocalPreferences {
|
||||
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
|
||||
putBoolean(PrefKeys.USE_PROXY, account.proxy != null)
|
||||
putInt(PrefKeys.PROXY_PORT, account.proxyPort)
|
||||
putBoolean(PrefKeys.WARN_ABOUT_REPORTS, account.warnAboutPostsWithReports)
|
||||
putBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, account.filterSpamFromStrangers)
|
||||
putString(PrefKeys.LAST_READ_PER_ROUTE, gson.toJson(account.lastReadPerRoute))
|
||||
|
||||
if (account.showSensitiveContent == null) {
|
||||
remove(PrefKeys.SHOW_SENSITIVE_CONTENT)
|
||||
@@ -254,6 +262,11 @@ object LocalPreferences {
|
||||
object : TypeToken<List<Long>>() {}.type
|
||||
) ?: listOf(500L, 1000L, 5000L)
|
||||
|
||||
val reactionChoices = gson.fromJson<List<String>>(
|
||||
getString(PrefKeys.REACTION_CHOICES, "[]"),
|
||||
object : TypeToken<List<String>>() {}.type
|
||||
).ifEmpty { listOf("+") } ?: listOf("+")
|
||||
|
||||
val defaultZapType = gson.fromJson(
|
||||
getString(PrefKeys.DEFAULT_ZAPTYPE, "PUBLIC"),
|
||||
object : TypeToken<LnZapEvent.ZapType>() {}.type
|
||||
@@ -306,46 +319,52 @@ object LocalPreferences {
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val filterSpam = getBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, true)
|
||||
val warnAboutReports = getBoolean(PrefKeys.WARN_ABOUT_REPORTS, true)
|
||||
|
||||
val lastReadPerRoute = try {
|
||||
getString(PrefKeys.LAST_READ_PER_ROUTE, null)?.let {
|
||||
gson.fromJson(
|
||||
it,
|
||||
object : TypeToken<Map<String, Long>>() {}.type
|
||||
) as Map<String, Long>
|
||||
} ?: mapOf()
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
mapOf()
|
||||
}
|
||||
|
||||
val a = Account(
|
||||
Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
|
||||
followingChannels,
|
||||
hiddenUsers,
|
||||
localRelays,
|
||||
dontTranslateFrom,
|
||||
languagePreferences,
|
||||
translateTo,
|
||||
zapAmountChoices,
|
||||
defaultZapType,
|
||||
defaultFileServer,
|
||||
defaultHomeFollowList,
|
||||
defaultStoriesFollowList,
|
||||
defaultNotificationFollowList,
|
||||
zapPaymentRequestServer,
|
||||
hideDeleteRequestDialog,
|
||||
hideBlockAlertDialog,
|
||||
latestContactList,
|
||||
proxy,
|
||||
proxyPort,
|
||||
showSensitiveContent
|
||||
loggedIn = Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
|
||||
followingChannels = followingChannels,
|
||||
hiddenUsers = hiddenUsers,
|
||||
localRelays = localRelays,
|
||||
dontTranslateFrom = dontTranslateFrom,
|
||||
languagePreferences = languagePreferences,
|
||||
translateTo = translateTo,
|
||||
zapAmountChoices = zapAmountChoices,
|
||||
reactionChoices = reactionChoices,
|
||||
defaultZapType = defaultZapType,
|
||||
defaultFileServer = defaultFileServer,
|
||||
defaultHomeFollowList = defaultHomeFollowList,
|
||||
defaultStoriesFollowList = defaultStoriesFollowList,
|
||||
defaultNotificationFollowList = defaultNotificationFollowList,
|
||||
zapPaymentRequest = zapPaymentRequestServer,
|
||||
hideDeleteRequestDialog = hideDeleteRequestDialog,
|
||||
hideBlockAlertDialog = hideBlockAlertDialog,
|
||||
backupContactList = latestContactList,
|
||||
proxy = proxy,
|
||||
proxyPort = proxyPort,
|
||||
showSensitiveContent = showSensitiveContent,
|
||||
warnAboutPostsWithReports = warnAboutReports,
|
||||
filterSpamFromStrangers = filterSpam,
|
||||
lastReadPerRoute = lastReadPerRoute
|
||||
)
|
||||
|
||||
return a
|
||||
}
|
||||
}
|
||||
|
||||
fun saveLastRead(route: String, timestampInSecs: Long) {
|
||||
encryptedPreferences(currentAccount()).edit().apply {
|
||||
putLong(PrefKeys.LAST_READ(route), timestampInSecs)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun loadLastRead(route: String): Long {
|
||||
encryptedPreferences(currentAccount()).run {
|
||||
return getLong(PrefKeys.LAST_READ(route), 0)
|
||||
}
|
||||
}
|
||||
|
||||
fun migrateSingleUserPrefs() {
|
||||
if (currentAccount() != null) return
|
||||
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object NotificationCache {
|
||||
// TODO: This must be account-based
|
||||
val lastReadByRoute = mutableMapOf<String, Long>()
|
||||
|
||||
fun markAsRead(route: String, timestampInSecs: Long) {
|
||||
val lastTime = lastReadByRoute[route]
|
||||
if (lastTime == null || timestampInSecs > lastTime) {
|
||||
lastReadByRoute.put(route, timestampInSecs)
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
LocalPreferences.saveLastRead(route, timestampInSecs)
|
||||
live.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun load(route: String): Long {
|
||||
var lastTime = lastReadByRoute[route]
|
||||
if (lastTime == null) {
|
||||
lastTime = LocalPreferences.loadLastRead(route)
|
||||
lastReadByRoute[route] = lastTime
|
||||
}
|
||||
return lastTime
|
||||
}
|
||||
|
||||
// Observers line up here.
|
||||
val live: NotificationLiveData = NotificationLiveData(this)
|
||||
}
|
||||
|
||||
class NotificationLiveData(val cache: NotificationCache) : LiveData<NotificationState>(NotificationState(cache)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
|
||||
fun invalidateData() {
|
||||
bundler.invalidate() {
|
||||
if (hasActiveObservers()) {
|
||||
postValue(NotificationState(cache))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationState(val cache: NotificationCache)
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
object OptOutFromFilters {
|
||||
var warnAboutPostsWithReports: Boolean = true
|
||||
var filterSpamFromStrangers: Boolean = true
|
||||
|
||||
fun start(warnAboutReports: Boolean, filterSpam: Boolean) {
|
||||
warnAboutPostsWithReports = warnAboutReports
|
||||
filterSpamFromStrangers = filterSpam
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ object ServiceManager {
|
||||
|
||||
// Resets Proxy Use
|
||||
HttpClient.start(account)
|
||||
OptOutFromFilters.start(account?.warnAboutPostsWithReports ?: true, account?.filterSpamFromStrangers ?: true)
|
||||
Coil.setImageLoader {
|
||||
ImageLoader.Builder(context).components {
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.core.os.ConfigurationCompat
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.OptOutFromFilters
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
@@ -55,6 +56,7 @@ class Account(
|
||||
var languagePreferences: Map<String, String> = mapOf(),
|
||||
var translateTo: String = Locale.getDefault().language,
|
||||
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L),
|
||||
var reactionChoices: List<String> = listOf("+"),
|
||||
var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE,
|
||||
var defaultFileServer: ServersAvailable = ServersAvailable.NOSTR_BUILD,
|
||||
var defaultHomeFollowList: String = KIND3_FOLLOWS,
|
||||
@@ -66,17 +68,32 @@ class Account(
|
||||
var backupContactList: ContactListEvent? = null,
|
||||
var proxy: Proxy?,
|
||||
var proxyPort: Int,
|
||||
var showSensitiveContent: Boolean? = null
|
||||
var showSensitiveContent: Boolean? = null,
|
||||
var warnAboutPostsWithReports: Boolean = true,
|
||||
var filterSpamFromStrangers: Boolean = true,
|
||||
var lastReadPerRoute: Map<String, Long> = mapOf<String, Long>()
|
||||
) {
|
||||
var transientHiddenUsers: Set<String> = setOf()
|
||||
|
||||
// Observers line up here.
|
||||
val live: AccountLiveData = AccountLiveData(this)
|
||||
val liveLanguages: AccountLiveData = AccountLiveData(this)
|
||||
val liveLastRead: AccountLiveData = AccountLiveData(this)
|
||||
val saveable: AccountLiveData = AccountLiveData(this)
|
||||
|
||||
var userProfileCache: User? = null
|
||||
|
||||
fun updateOptOutOptions(warnReports: Boolean, filterSpam: Boolean) {
|
||||
warnAboutPostsWithReports = warnReports
|
||||
filterSpamFromStrangers = filterSpam
|
||||
OptOutFromFilters.start(warnAboutPostsWithReports, filterSpamFromStrangers)
|
||||
if (!filterSpamFromStrangers) {
|
||||
transientHiddenUsers = setOf()
|
||||
}
|
||||
live.invalidateData()
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun userProfile(): User {
|
||||
return userProfileCache ?: run {
|
||||
val myUser: User = LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey())
|
||||
@@ -86,7 +103,7 @@ class Account(
|
||||
}
|
||||
|
||||
fun followingChannels(): List<Channel> {
|
||||
return followingChannels.map { LocalCache.getOrCreateChannel(it) }
|
||||
return followingChannels.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
|
||||
}
|
||||
|
||||
fun isWriteable(): Boolean {
|
||||
@@ -129,8 +146,8 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun reactionTo(note: Note): List<Note> {
|
||||
return note.reactedBy(userProfile(), "+")
|
||||
fun reactionTo(note: Note, reaction: String): List<Note> {
|
||||
return note.reactedBy(userProfile(), reaction)
|
||||
}
|
||||
|
||||
fun hasBoosted(note: Note): Boolean {
|
||||
@@ -141,20 +158,20 @@ class Account(
|
||||
return note.boostedBy(userProfile())
|
||||
}
|
||||
|
||||
fun hasReacted(note: Note): Boolean {
|
||||
return note.hasReacted(userProfile(), "+")
|
||||
fun hasReacted(note: Note, reaction: String): Boolean {
|
||||
return note.hasReacted(userProfile(), reaction)
|
||||
}
|
||||
|
||||
fun reactTo(note: Note) {
|
||||
fun reactTo(note: Note, reaction: String) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
if (hasReacted(note)) {
|
||||
if (hasReacted(note, reaction)) {
|
||||
// has already liked this note
|
||||
return
|
||||
}
|
||||
|
||||
note.event?.let {
|
||||
val event = ReactionEvent.createLike(it, loggedIn.privKey!!)
|
||||
val event = ReactionEvent.create(reaction, it, loggedIn.privKey!!)
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
@@ -314,9 +331,15 @@ class Account(
|
||||
}
|
||||
|
||||
note.event?.let {
|
||||
val event = RepostEvent.create(it, loggedIn.privKey!!)
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
if (it.kind() == 1) {
|
||||
val event = RepostEvent.create(it, loggedIn.privKey!!)
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
} else {
|
||||
val event = GenericRepostEvent.create(it, loggedIn.privKey!!)
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,7 +506,11 @@ class Account(
|
||||
mentions: List<User>?,
|
||||
tags: List<String>? = null,
|
||||
zapReceiver: String? = null,
|
||||
wantsToMarkAsSensitive: Boolean
|
||||
wantsToMarkAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long? = null,
|
||||
replyingTo: String?,
|
||||
root: String?,
|
||||
directMentions: Set<HexKey>
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@@ -499,6 +526,10 @@ class Account(
|
||||
extraTags = tags,
|
||||
zapReceiver = zapReceiver,
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
replyingTo = replyingTo,
|
||||
root = root,
|
||||
directMentions = directMentions,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
|
||||
@@ -516,7 +547,8 @@ class Account(
|
||||
consensusThreshold: Int?,
|
||||
closedAt: Int?,
|
||||
zapReceiver: String? = null,
|
||||
wantsToMarkAsSensitive: Boolean
|
||||
wantsToMarkAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long? = null
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@@ -536,14 +568,15 @@ class Account(
|
||||
consensusThreshold = consensusThreshold,
|
||||
closedAt = closedAt,
|
||||
zapReceiver = zapReceiver,
|
||||
markAsSensitive = wantsToMarkAsSensitive
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount
|
||||
)
|
||||
// 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, wantsToMarkAsSensitive: Boolean) {
|
||||
fun sendChannelMessage(message: String, toChannel: String, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
// val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
|
||||
@@ -557,13 +590,35 @@ class Account(
|
||||
mentions = mentionsHex,
|
||||
zapReceiver = zapReceiver,
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
Client.send(signedEvent)
|
||||
LocalCache.consume(signedEvent, null)
|
||||
}
|
||||
|
||||
fun sendPrivateMessage(message: String, toUser: User, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean) {
|
||||
fun sendLiveMessage(message: String, toChannel: ATag, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
// val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
|
||||
val repliesToHex = replyTo?.map { it.idHex }
|
||||
val mentionsHex = mentions?.map { it.pubkeyHex }
|
||||
|
||||
val signedEvent = LiveActivitiesChatMessageEvent.create(
|
||||
message = message,
|
||||
activity = toChannel,
|
||||
replyTos = repliesToHex,
|
||||
mentions = mentionsHex,
|
||||
zapReceiver = zapReceiver,
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
Client.send(signedEvent)
|
||||
LocalCache.consume(signedEvent, null)
|
||||
}
|
||||
|
||||
fun sendPrivateMessage(message: String, toUser: User, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
|
||||
@@ -577,6 +632,7 @@ class Account(
|
||||
mentions = mentionsHex,
|
||||
zapReceiver = zapReceiver,
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
privateKey = loggedIn.privKey!!,
|
||||
advertiseNip18 = false
|
||||
)
|
||||
@@ -843,6 +899,12 @@ class Account(
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun changeReactionTypes(newTypes: List<String>) {
|
||||
reactionChoices = newTypes
|
||||
live.invalidateData()
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun changeZapPaymentRequest(newServer: Nip47URI?) {
|
||||
zapPaymentRequest = newServer
|
||||
live.invalidateData()
|
||||
@@ -1057,12 +1119,19 @@ class Account(
|
||||
}
|
||||
|
||||
fun isAcceptable(user: User): Boolean {
|
||||
if (!warnAboutPostsWithReports) {
|
||||
return !isHidden(user) && // if user hasn't hided this author
|
||||
user.reportsBy(userProfile()).isEmpty() // if user has not reported this post
|
||||
}
|
||||
return !isHidden(user) && // if user hasn't hided this author
|
||||
user.reportsBy(userProfile()).isEmpty() && // if user has not reported this post
|
||||
user.countReportAuthorsBy(followingKeySet()) < 5
|
||||
}
|
||||
|
||||
fun isAcceptableDirect(note: Note): Boolean {
|
||||
if (!warnAboutPostsWithReports) {
|
||||
return note.reportsBy(userProfile()).isEmpty()
|
||||
}
|
||||
return note.reportsBy(userProfile()).isEmpty() && // if user has not reported this post
|
||||
note.countReportAuthorsBy(followingKeySet()) < 5 // if it has 5 reports by reliable users
|
||||
}
|
||||
@@ -1075,7 +1144,7 @@ class Account(
|
||||
return note.author?.let { isAcceptable(it) } ?: true && // if user hasn't hided this author
|
||||
isAcceptableDirect(note) &&
|
||||
(
|
||||
note.event !is RepostEvent ||
|
||||
(note.event !is RepostEvent && note.event !is GenericRepostEvent) ||
|
||||
(note.replyTo?.firstOrNull { isAcceptableDirect(it) } != null)
|
||||
) // is not a reaction about a blocked post
|
||||
}
|
||||
@@ -1083,7 +1152,7 @@ class Account(
|
||||
fun getRelevantReports(note: Note): Set<Note> {
|
||||
val followsPlusMe = userProfile().latestContactList?.verifiedFollowKeySetAndMe ?: emptySet()
|
||||
|
||||
val innerReports = if (note.event is RepostEvent) {
|
||||
val innerReports = if (note.event is RepostEvent || note.event is GenericRepostEvent) {
|
||||
note.replyTo?.map { getRelevantReports(it) }?.flatten() ?: emptyList()
|
||||
} else {
|
||||
emptyList()
|
||||
@@ -1120,6 +1189,19 @@ class Account(
|
||||
live.invalidateData()
|
||||
}
|
||||
|
||||
fun markAsRead(route: String, timestampInSecs: Long) {
|
||||
val lastTime = lastReadPerRoute[route]
|
||||
if (lastTime == null || timestampInSecs > lastTime) {
|
||||
lastReadPerRoute = lastReadPerRoute + Pair(route, timestampInSecs)
|
||||
saveable.invalidateData()
|
||||
liveLastRead.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun loadLastRead(route: String): Long {
|
||||
return lastReadPerRoute[route] ?: 0
|
||||
}
|
||||
|
||||
fun registerObservers() {
|
||||
// Observes relays to restart connections
|
||||
userProfile().live().relays.observeForever {
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.model
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.OptOutFromFilters
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
@@ -20,6 +21,8 @@ class AntiSpamFilter {
|
||||
fun isSpam(event: Event, relay: Relay?): Boolean {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (!OptOutFromFilters.filterSpamFromStrangers) return false
|
||||
|
||||
val idHex = event.id
|
||||
|
||||
// if short message, ok
|
||||
|
||||
@@ -3,7 +3,9 @@ package com.vitorpamplona.amethyst.model
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.ATag
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
@@ -11,20 +13,96 @@ import kotlinx.coroutines.Dispatchers
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@Stable
|
||||
class Channel(val idHex: String) {
|
||||
var creator: User? = null
|
||||
class PublicChatChannel(idHex: String) : Channel(idHex) {
|
||||
var info = ChannelCreateEvent.ChannelData(null, null, null)
|
||||
|
||||
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
|
||||
this.creator = creator
|
||||
this.info = channelInfo
|
||||
this.updatedMetadataAt = updatedAt
|
||||
|
||||
live.invalidateData()
|
||||
}
|
||||
|
||||
override fun toBestDisplayName(): String {
|
||||
return info.name ?: super.toBestDisplayName()
|
||||
}
|
||||
|
||||
override fun summary(): String? {
|
||||
return info.about
|
||||
}
|
||||
|
||||
override fun profilePicture(): String? {
|
||||
if (info.picture.isNullOrBlank()) info.picture = null
|
||||
return info.picture ?: super.profilePicture()
|
||||
}
|
||||
|
||||
override fun anyNameStartsWith(prefix: String): Boolean {
|
||||
return listOfNotNull(info.name, info.about)
|
||||
.filter { it.contains(prefix, true) }.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class LiveActivitiesChannel(val address: ATag) : Channel(address.toTag()) {
|
||||
var info: LiveActivitiesEvent? = null
|
||||
|
||||
override fun idNote() = address.toNAddr()
|
||||
override fun idDisplayNote() = idNote().toShortenHex()
|
||||
fun address() = address
|
||||
|
||||
fun updateChannelInfo(creator: User, channelInfo: LiveActivitiesEvent, updatedAt: Long) {
|
||||
this.info = channelInfo
|
||||
super.updateChannelInfo(creator, updatedAt)
|
||||
}
|
||||
|
||||
override fun toBestDisplayName(): String {
|
||||
return info?.title() ?: super.toBestDisplayName()
|
||||
}
|
||||
|
||||
override fun summary(): String? {
|
||||
return info?.summary()
|
||||
}
|
||||
|
||||
override fun profilePicture(): String? {
|
||||
return info?.image()?.ifBlank { null } ?: super.profilePicture()
|
||||
}
|
||||
|
||||
override fun anyNameStartsWith(prefix: String): Boolean {
|
||||
return listOfNotNull(info?.title(), info?.summary())
|
||||
.filter { it.contains(prefix, true) }.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
abstract class Channel(val idHex: String) {
|
||||
var creator: User? = null
|
||||
|
||||
var updatedMetadataAt: Long = 0
|
||||
|
||||
val notes = ConcurrentHashMap<HexKey, Note>()
|
||||
|
||||
fun id() = Hex.decode(idHex)
|
||||
fun idNote() = id().toNote()
|
||||
fun idDisplayNote() = idNote().toShortenHex()
|
||||
open fun id() = Hex.decode(idHex)
|
||||
open fun idNote() = id().toNote()
|
||||
open fun idDisplayNote() = idNote().toShortenHex()
|
||||
|
||||
fun toBestDisplayName(): String {
|
||||
return info.name ?: idDisplayNote()
|
||||
open fun toBestDisplayName(): String {
|
||||
return idDisplayNote()
|
||||
}
|
||||
|
||||
open fun summary(): String? {
|
||||
return null
|
||||
}
|
||||
|
||||
open fun profilePicture(): String? {
|
||||
return creator?.profilePicture()
|
||||
}
|
||||
|
||||
open fun updateChannelInfo(creator: User, updatedAt: Long) {
|
||||
this.creator = creator
|
||||
this.updatedMetadataAt = updatedAt
|
||||
|
||||
live.invalidateData()
|
||||
}
|
||||
|
||||
fun addNote(note: Note) {
|
||||
@@ -39,23 +117,7 @@ class Channel(val idHex: String) {
|
||||
notes.remove(noteHex)
|
||||
}
|
||||
|
||||
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
|
||||
this.creator = creator
|
||||
this.info = channelInfo
|
||||
this.updatedMetadataAt = updatedAt
|
||||
|
||||
live.invalidateData()
|
||||
}
|
||||
|
||||
fun profilePicture(): String? {
|
||||
if (info.picture.isNullOrBlank()) info.picture = null
|
||||
return info.picture
|
||||
}
|
||||
|
||||
fun anyNameStartsWith(prefix: String): Boolean {
|
||||
return listOfNotNull(info.name, info.about)
|
||||
.filter { it.startsWith(prefix, true) }.isNotEmpty()
|
||||
}
|
||||
abstract fun anyNameStartsWith(prefix: String): Boolean
|
||||
|
||||
// Observers line up here.
|
||||
val live: ChannelLiveData = ChannelLiveData(this)
|
||||
@@ -92,12 +154,20 @@ class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelStat
|
||||
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
NostrSingleChannelDataSource.add(channel.idHex)
|
||||
if (channel is PublicChatChannel) {
|
||||
NostrSingleChannelDataSource.add(channel.idHex)
|
||||
} else {
|
||||
NostrSingleChannelDataSource.add(channel.idHex)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onInactive() {
|
||||
super.onInactive()
|
||||
NostrSingleChannelDataSource.remove(channel.idHex)
|
||||
if (channel is PublicChatChannel) {
|
||||
NostrSingleChannelDataSource.remove(channel.idHex)
|
||||
} else {
|
||||
NostrSingleChannelDataSource.remove(channel.idHex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.util.Log
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.model.ATag.Companion.isATag
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledInsert
|
||||
@@ -51,6 +52,18 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun getUserIfExists(key: String): User? {
|
||||
return users[key]
|
||||
}
|
||||
|
||||
fun getNoteIfExists(key: String): Note? {
|
||||
return addressables[key] ?: notes[key]
|
||||
}
|
||||
|
||||
fun getChannelIfExists(key: String): Channel? {
|
||||
return channels[key]
|
||||
}
|
||||
|
||||
fun checkGetOrCreateNote(key: String): Note? {
|
||||
checkNotInMainThread()
|
||||
|
||||
@@ -58,7 +71,14 @@ object LocalCache {
|
||||
return checkGetOrCreateAddressableNote(key)
|
||||
}
|
||||
if (isValidHexNpub(key)) {
|
||||
return getOrCreateNote(key)
|
||||
val note = getOrCreateNote(key)
|
||||
val noteEvent = note.event
|
||||
if (noteEvent is AddressableEvent) {
|
||||
// upgrade to the latest
|
||||
return checkGetOrCreateAddressableNote(noteEvent.address().toTag())
|
||||
} else {
|
||||
return note
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -78,7 +98,15 @@ object LocalCache {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (isValidHexNpub(key)) {
|
||||
return getOrCreateChannel(key)
|
||||
return getOrCreateChannel(key) {
|
||||
PublicChatChannel(key)
|
||||
}
|
||||
}
|
||||
val aTag = ATag.parse(key, null)
|
||||
if (aTag != null) {
|
||||
return getOrCreateChannel(aTag.toTag()) {
|
||||
LiveActivitiesChannel(aTag)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -94,11 +122,11 @@ object LocalCache {
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreateChannel(key: String): Channel {
|
||||
fun getOrCreateChannel(key: String, channelFactory: (String) -> Channel): Channel {
|
||||
checkNotInMainThread()
|
||||
|
||||
return channels[key] ?: run {
|
||||
val answer = Channel(key)
|
||||
val answer = channelFactory(key)
|
||||
channels.put(key, answer)
|
||||
answer
|
||||
}
|
||||
@@ -167,9 +195,15 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun consume(event: PeopleListEvent) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (version.event == null) {
|
||||
version.loadEvent(event, author, emptyList())
|
||||
version.moveAllReferencesTo(note)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
@@ -222,9 +256,15 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun consume(event: LongTextNoteEvent, relay: Relay?) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (version.event == null) {
|
||||
version.loadEvent(event, author, emptyList())
|
||||
version.moveAllReferencesTo(note)
|
||||
}
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
@@ -287,10 +327,40 @@ object LocalCache {
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
private fun consume(event: PinListEvent) {
|
||||
private fun consume(event: LiveActivitiesEvent, relay: Relay?) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (version.event == null) {
|
||||
version.loadEvent(event, author, emptyList())
|
||||
version.moveAllReferencesTo(note)
|
||||
}
|
||||
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
val channel = getOrCreateChannel(note.idHex) {
|
||||
LiveActivitiesChannel(note.address)
|
||||
} as? LiveActivitiesChannel
|
||||
channel?.updateChannelInfo(author, event, event.createdAt)
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
private fun consume(event: PinListEvent) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (version.event == null) {
|
||||
version.loadEvent(event, author, emptyList())
|
||||
version.moveAllReferencesTo(note)
|
||||
}
|
||||
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
@@ -301,9 +371,15 @@ object LocalCache {
|
||||
}
|
||||
|
||||
private fun consume(event: RelaySetEvent) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (version.event == null) {
|
||||
version.loadEvent(event, author, emptyList())
|
||||
version.moveAllReferencesTo(note)
|
||||
}
|
||||
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
@@ -314,9 +390,15 @@ object LocalCache {
|
||||
}
|
||||
|
||||
private fun consume(event: AudioTrackEvent) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (version.event == null) {
|
||||
version.loadEvent(event, author, emptyList())
|
||||
version.moveAllReferencesTo(note)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
@@ -328,9 +410,15 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun consume(event: BadgeDefinitionEvent) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (version.event == null) {
|
||||
version.loadEvent(event, author, emptyList())
|
||||
version.moveAllReferencesTo(note)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
@@ -342,9 +430,15 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun consume(event: BadgeProfilesEvent) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (version.event == null) {
|
||||
version.loadEvent(event, author, emptyList())
|
||||
version.moveAllReferencesTo(note)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
@@ -380,21 +474,35 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun consume(event: AppDefinitionEvent) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (version.event == null) {
|
||||
version.loadEvent(event, author, emptyList())
|
||||
version.moveAllReferencesTo(note)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: AppRecommendationEvent) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (version.event == null) {
|
||||
version.loadEvent(event, author, emptyList())
|
||||
version.moveAllReferencesTo(note)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
@@ -478,8 +586,11 @@ object LocalCache {
|
||||
}
|
||||
|
||||
deleteNote.channelHex()?.let {
|
||||
val channel = checkGetOrCreateChannel(it)
|
||||
channel?.removeNote(deleteNote)
|
||||
channels[it]?.removeNote(deleteNote)
|
||||
}
|
||||
|
||||
(deleteNote.event as? LiveActivitiesChatMessageEvent)?.activity()?.let {
|
||||
channels[it.toTag()]?.removeNote(deleteNote)
|
||||
}
|
||||
|
||||
if (deleteNote.event is PrivateDmEvent) {
|
||||
@@ -528,6 +639,31 @@ object LocalCache {
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: GenericRepostEvent) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
// Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
val repliesTo = event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } +
|
||||
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
|
||||
|
||||
note.loadEvent(event, author, repliesTo)
|
||||
|
||||
// Prepares user's profile view.
|
||||
author.addNote(note)
|
||||
|
||||
// Counts the replies
|
||||
repliesTo.forEach {
|
||||
it.addBoost(note)
|
||||
}
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: ReactionEvent) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
|
||||
@@ -542,17 +678,8 @@ object LocalCache {
|
||||
|
||||
// Log.d("RE", "New Reaction ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
if (
|
||||
event.content == "" ||
|
||||
event.content == "+" ||
|
||||
event.content == "\u2764\uFE0F" || // red heart
|
||||
event.content == "\uD83E\uDD19" || // call me hand
|
||||
event.content == "\uD83D\uDC4D" // thumbs up
|
||||
) {
|
||||
// Counts the replies
|
||||
repliesTo.forEach {
|
||||
it.addReaction(note)
|
||||
}
|
||||
repliesTo.forEach {
|
||||
it.addReaction(note)
|
||||
}
|
||||
|
||||
refreshObservers(note)
|
||||
@@ -598,7 +725,9 @@ object LocalCache {
|
||||
|
||||
fun consume(event: ChannelCreateEvent) {
|
||||
// Log.d("MT", "New Event ${event.content} ${event.id.toHex()}")
|
||||
val oldChannel = getOrCreateChannel(event.id)
|
||||
val oldChannel = getOrCreateChannel(event.id) {
|
||||
PublicChatChannel(it)
|
||||
}
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
val note = getOrCreateNote(event.id)
|
||||
@@ -613,7 +742,9 @@ object LocalCache {
|
||||
return // older data, does nothing
|
||||
}
|
||||
if (oldChannel.creator == null || oldChannel.creator == author) {
|
||||
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
|
||||
if (oldChannel is PublicChatChannel) {
|
||||
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -624,10 +755,13 @@ object LocalCache {
|
||||
|
||||
// new event
|
||||
val oldChannel = checkGetOrCreateChannel(channelId) ?: return
|
||||
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
if (event.createdAt > oldChannel.updatedMetadataAt) {
|
||||
if (oldChannel.creator == null || oldChannel.creator == author) {
|
||||
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
|
||||
if (oldChannel is PublicChatChannel) {
|
||||
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
|
||||
@@ -685,6 +819,47 @@ object LocalCache {
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: LiveActivitiesChatMessageEvent, relay: Relay?) {
|
||||
val activityId = event.activity() ?: return
|
||||
|
||||
val channel = getOrCreateChannel(activityId.toTag()) {
|
||||
LiveActivitiesChannel(activityId)
|
||||
}
|
||||
|
||||
val note = getOrCreateNote(event.id)
|
||||
channel.addNote(note)
|
||||
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
relay?.let {
|
||||
it.spamCounter++
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val replyTo = event.tagsWithoutCitations()
|
||||
.filter { it != event.activity()?.toTag() }
|
||||
.mapNotNull { checkGetOrCreateNote(it) }
|
||||
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
// Counts the replies
|
||||
replyTo.forEach {
|
||||
it.addReply(note)
|
||||
}
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun consume(event: ChannelHideMessageEvent) {
|
||||
}
|
||||
@@ -974,7 +1149,7 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.info.name}")
|
||||
println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.toBestDisplayName()}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1061,6 +1236,8 @@ object LocalCache {
|
||||
is FileStorageEvent -> consume(event, relay)
|
||||
is FileStorageHeaderEvent -> consume(event, relay)
|
||||
is HighlightEvent -> consume(event, relay)
|
||||
is LiveActivitiesEvent -> consume(event, relay)
|
||||
is LiveActivitiesChatMessageEvent -> consume(event, relay)
|
||||
is LnZapEvent -> {
|
||||
event.zapRequest?.let {
|
||||
verifyAndConsume(it, relay)
|
||||
@@ -1085,6 +1262,12 @@ object LocalCache {
|
||||
}
|
||||
consume(event)
|
||||
}
|
||||
is GenericRepostEvent -> {
|
||||
event.containedPost()?.let {
|
||||
verifyAndConsume(it, relay)
|
||||
}
|
||||
consume(event)
|
||||
}
|
||||
is TextNoteEvent -> consume(event, relay)
|
||||
is PollNoteEvent -> consume(event, relay)
|
||||
else -> {
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.actions.updated
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
@@ -43,7 +44,7 @@ open class Note(val idHex: String) {
|
||||
// These fields are updated every time an event related to this note is received.
|
||||
var replies = setOf<Note>()
|
||||
private set
|
||||
var reactions = setOf<Note>()
|
||||
var reactions = mapOf<String, Set<Note>>()
|
||||
private set
|
||||
var boosts = setOf<Note>()
|
||||
private set
|
||||
@@ -72,6 +73,8 @@ open class Note(val idHex: String) {
|
||||
return (event as? ChannelMessageEvent)?.channel()
|
||||
?: (event as? ChannelMetadataEvent)?.channel()
|
||||
?: (event as? ChannelCreateEvent)?.id
|
||||
?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag()
|
||||
?: (event as? LiveActivitiesEvent)?.address()?.toTag()
|
||||
}
|
||||
|
||||
open fun address(): ATag? = null
|
||||
@@ -96,7 +99,7 @@ open class Note(val idHex: String) {
|
||||
*/
|
||||
fun replyLevelSignature(cachedSignatures: MutableMap<Note, String> = mutableMapOf()): String {
|
||||
val replyTo = replyTo
|
||||
if (replyTo == null || replyTo.isEmpty()) {
|
||||
if (event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()) {
|
||||
return "/" + formattedDateTime(createdAt() ?: 0) + ";"
|
||||
}
|
||||
|
||||
@@ -109,7 +112,7 @@ open class Note(val idHex: String) {
|
||||
|
||||
fun replyLevel(cachedLevels: MutableMap<Note, Int> = mutableMapOf()): Int {
|
||||
val replyTo = replyTo
|
||||
if (replyTo == null || replyTo.isEmpty()) {
|
||||
if (event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()) {
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -134,8 +137,20 @@ open class Note(val idHex: String) {
|
||||
liveSet?.boosts?.invalidateData()
|
||||
}
|
||||
fun removeReaction(note: Note) {
|
||||
reactions = reactions - note
|
||||
liveSet?.reactions?.invalidateData()
|
||||
val reaction = note.event?.content() ?: "+"
|
||||
|
||||
if (reaction in reactions.keys && reactions[reaction]?.contains(note) == true) {
|
||||
reactions[reaction]?.let {
|
||||
val newList = it.minus(note)
|
||||
if (newList.isEmpty()) {
|
||||
reactions = reactions.minus(reaction)
|
||||
} else {
|
||||
reactions = reactions + Pair(reaction, newList)
|
||||
}
|
||||
|
||||
liveSet?.reactions?.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeReport(deleteNote: Note) {
|
||||
@@ -203,8 +218,13 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
|
||||
fun addReaction(note: Note) {
|
||||
if (note !in reactions) {
|
||||
reactions = reactions + note
|
||||
val reaction = note.event?.content() ?: "+"
|
||||
|
||||
if (reaction !in reactions.keys) {
|
||||
reactions = reactions + Pair(reaction, setOf(note))
|
||||
liveSet?.reactions?.invalidateData()
|
||||
} else if (reactions[reaction]?.contains(note) == false) {
|
||||
reactions = reactions + Pair(reaction, (reactions[reaction] ?: emptySet()) + note)
|
||||
liveSet?.reactions?.invalidateData()
|
||||
}
|
||||
}
|
||||
@@ -243,8 +263,10 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun isReactedBy(user: User): Boolean {
|
||||
return reactions.any { it.author?.pubkeyHex == user.pubkeyHex }
|
||||
fun isReactedBy(user: User): String? {
|
||||
return reactions.filter {
|
||||
it.value.any { it.author?.pubkeyHex == user.pubkeyHex }
|
||||
}.keys.firstOrNull()
|
||||
}
|
||||
|
||||
fun isBoostedBy(user: User): Boolean {
|
||||
@@ -269,6 +291,10 @@ open class Note(val idHex: String) {
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
fun countReactions(): Int {
|
||||
return reactions.values.sumOf { it.size }
|
||||
}
|
||||
|
||||
fun zappedAmount(privKey: ByteArray?, walletServicePubkey: ByteArray?): BigDecimal {
|
||||
// Regular Zap Receipts
|
||||
val completedZaps = zaps.asSequence()
|
||||
@@ -349,7 +375,14 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
|
||||
fun isNewThread(): Boolean {
|
||||
return event is RepostEvent || replyTo == null || replyTo?.size == 0
|
||||
return (
|
||||
event is RepostEvent ||
|
||||
event is GenericRepostEvent ||
|
||||
replyTo == null ||
|
||||
replyTo?.size == 0
|
||||
) &&
|
||||
event !is ChannelMessageEvent &&
|
||||
event !is LiveActivitiesChatMessageEvent
|
||||
}
|
||||
|
||||
fun hasZapped(loggedIn: User): Boolean {
|
||||
@@ -361,7 +394,11 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
|
||||
fun reactedBy(loggedIn: User, content: String): List<Note> {
|
||||
return reactions.filter { it.author == loggedIn && it.event?.content() == content }
|
||||
return reactions[content]?.filter { it.author == loggedIn && it.event?.content() == content } ?: emptyList()
|
||||
}
|
||||
|
||||
fun reactedBy(loggedIn: User): List<String> {
|
||||
return reactions.filter { it.value.any { it.author == loggedIn } }.mapNotNull { it.key }
|
||||
}
|
||||
|
||||
fun hasBoostedInTheLast5Minutes(loggedIn: User): Boolean {
|
||||
@@ -373,6 +410,42 @@ open class Note(val idHex: String) {
|
||||
return boosts.filter { it.author == loggedIn }
|
||||
}
|
||||
|
||||
fun moveAllReferencesTo(note: AddressableNote) {
|
||||
// migrates these comments to a new version
|
||||
replies.forEach {
|
||||
note.addReply(it)
|
||||
it.replyTo = it.replyTo?.updated(this, note)
|
||||
}
|
||||
reactions.forEach {
|
||||
it.value.forEach {
|
||||
note.addReaction(it)
|
||||
it.replyTo = it.replyTo?.updated(this, note)
|
||||
}
|
||||
}
|
||||
boosts.forEach {
|
||||
note.addBoost(it)
|
||||
it.replyTo = it.replyTo?.updated(this, note)
|
||||
}
|
||||
reports.forEach {
|
||||
it.value.forEach {
|
||||
note.addReport(it)
|
||||
it.replyTo = it.replyTo?.updated(this, note)
|
||||
}
|
||||
}
|
||||
zaps.forEach {
|
||||
note.addZap(it.key, it.value)
|
||||
it.key.replyTo = it.key.replyTo?.updated(this, note)
|
||||
it.value?.replyTo = it.value?.replyTo?.updated(this, note)
|
||||
}
|
||||
|
||||
replyTo = null
|
||||
replies = emptySet()
|
||||
reactions = emptyMap()
|
||||
boosts = emptySet()
|
||||
reports = emptyMap()
|
||||
zaps = emptyMap()
|
||||
}
|
||||
|
||||
var liveSet: NoteLiveSet? = null
|
||||
|
||||
fun live(): NoteLiveSet {
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.ATag
|
||||
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
@@ -10,6 +12,8 @@ class ThreadAssembler {
|
||||
private fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? {
|
||||
if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note
|
||||
|
||||
if (note.event is RepostEvent || note.event is GenericRepostEvent) return note
|
||||
|
||||
testedNotes.add(note)
|
||||
|
||||
val markedAsRoot = note.event?.tags()?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1)
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.LruCache
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.previews.BahaUrlPreview
|
||||
import com.vitorpamplona.amethyst.service.previews.IUrlPreviewCallback
|
||||
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
|
||||
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
|
||||
|
||||
object UrlCachedPreviewer {
|
||||
var cache = mapOf<String, UrlInfoItem>()
|
||||
private set
|
||||
var failures = mapOf<String, Throwable>()
|
||||
var cache = LruCache<String, UrlPreviewState>(100)
|
||||
private set
|
||||
|
||||
fun previewInfo(url: String, callback: IUrlPreviewCallback? = null) {
|
||||
fun previewInfo(url: String, onReady: (UrlPreviewState) -> Unit) {
|
||||
checkNotInMainThread()
|
||||
|
||||
cache[url]?.let {
|
||||
callback?.onComplete(it)
|
||||
return
|
||||
}
|
||||
|
||||
failures[url]?.let {
|
||||
callback?.onFailed(it)
|
||||
onReady(it)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -28,13 +23,32 @@ object UrlCachedPreviewer {
|
||||
url,
|
||||
object : IUrlPreviewCallback {
|
||||
override fun onComplete(urlInfo: UrlInfoItem) {
|
||||
cache = cache + Pair(url, urlInfo)
|
||||
callback?.onComplete(urlInfo)
|
||||
cache[url]?.let {
|
||||
if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) {
|
||||
onReady(it)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val state = if (urlInfo.allFetchComplete() && urlInfo.url == url) {
|
||||
UrlPreviewState.Loaded(urlInfo)
|
||||
} else {
|
||||
UrlPreviewState.Empty
|
||||
}
|
||||
|
||||
cache.put(url, state)
|
||||
onReady(state)
|
||||
}
|
||||
|
||||
override fun onFailed(throwable: Throwable) {
|
||||
failures = failures + Pair(url, throwable)
|
||||
callback?.onFailed(throwable)
|
||||
cache[url]?.let {
|
||||
onReady(it)
|
||||
return
|
||||
}
|
||||
|
||||
val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview")
|
||||
cache.put(url, state)
|
||||
onReady(state)
|
||||
}
|
||||
}
|
||||
).fetchUrlPreview()
|
||||
|
||||
@@ -96,10 +96,10 @@ class User(val pubkeyHex: String) {
|
||||
// Update Followers of the past user list
|
||||
// Update Followers of the new contact list
|
||||
(oldContactListEvent)?.unverifiedFollowKeySet()?.forEach {
|
||||
LocalCache.users[it]?.liveSet?.follows?.invalidateData()
|
||||
LocalCache.users[it]?.liveSet?.followers?.invalidateData()
|
||||
}
|
||||
(latestContactList)?.unverifiedFollowKeySet()?.forEach {
|
||||
LocalCache.users[it]?.liveSet?.follows?.invalidateData()
|
||||
LocalCache.users[it]?.liveSet?.followers?.invalidateData()
|
||||
}
|
||||
|
||||
liveSet?.relays?.invalidateData()
|
||||
@@ -340,6 +340,7 @@ class User(val pubkeyHex: String) {
|
||||
class UserLiveSet(u: User) {
|
||||
// UI Observers line up here.
|
||||
val follows: UserLiveData = UserLiveData(u)
|
||||
val followers: UserLiveData = UserLiveData(u)
|
||||
val reports: UserLiveData = UserLiveData(u)
|
||||
val messages: UserLiveData = UserLiveData(u)
|
||||
val relays: UserLiveData = UserLiveData(u)
|
||||
@@ -351,6 +352,7 @@ class UserLiveSet(u: User) {
|
||||
|
||||
fun isInUse(): Boolean {
|
||||
return follows.hasObservers() ||
|
||||
followers.hasObservers() ||
|
||||
reports.hasObservers() ||
|
||||
messages.hasObservers() ||
|
||||
relays.hasObservers() ||
|
||||
|
||||
@@ -13,8 +13,6 @@ import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
|
||||
class Nip05Verifier() {
|
||||
val client = HttpClient.getHttpClient()
|
||||
|
||||
fun assembleUrl(nip05address: String): String? {
|
||||
val parts = nip05address.trim().split("@")
|
||||
|
||||
@@ -49,7 +47,7 @@ class Nip05Verifier() {
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
HttpClient.getHttpClient().newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
|
||||
@@ -80,6 +80,16 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
)
|
||||
}
|
||||
|
||||
fun createAccountLastPostsListFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 400
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createNotificationFilter() = TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
@@ -88,6 +98,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
PollNoteEvent.kind,
|
||||
ReactionEvent.kind,
|
||||
RepostEvent.kind,
|
||||
GenericRepostEvent.kind,
|
||||
ReportEvent.kind,
|
||||
LnZapEvent.kind,
|
||||
LnZapPaymentResponseEvent.kind,
|
||||
@@ -112,7 +123,8 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
createNotificationFilter(),
|
||||
createAccountReportsFilter(),
|
||||
createAccountAcceptedAwardsFilter(),
|
||||
createAccountBookmarkListFilter()
|
||||
createAccountBookmarkListFilter(),
|
||||
createAccountLastPostsListFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.model.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
@@ -25,7 +28,7 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
|
||||
fun createMessagesByMeToChannelFilter(): TypedFilter? {
|
||||
val myAccount = account ?: return null
|
||||
|
||||
if (channel != null) {
|
||||
if (channel is PublicChatChannel) {
|
||||
// Brings on messages by the user from all other relays.
|
||||
// Since we ship with write to public, read from private only
|
||||
// this guarantees that messages from the author do not disappear.
|
||||
@@ -37,12 +40,24 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
|
||||
limit = 50
|
||||
)
|
||||
)
|
||||
} else if (channel is LiveActivitiesChannel) {
|
||||
// Brings on messages by the user from all other relays.
|
||||
// Since we ship with write to public, read from private only
|
||||
// this guarantees that messages from the author do not disappear.
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(LiveActivitiesChatMessageEvent.kind),
|
||||
authors = listOf(myAccount.userProfile().pubkeyHex),
|
||||
limit = 50
|
||||
)
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun createMessagesToChannelFilter(): TypedFilter? {
|
||||
if (channel != null) {
|
||||
if (channel is PublicChatChannel) {
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
@@ -51,6 +66,15 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
} else if (channel is LiveActivitiesChannel) {
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(LiveActivitiesChatMessageEvent.kind),
|
||||
tags = mapOf("a" to listOfNotNull(channel?.idHex)),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -3,10 +3,14 @@ package com.vitorpamplona.amethyst.service
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.UserState
|
||||
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
|
||||
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.HighlightEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PinListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
@@ -58,7 +62,18 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind,
|
||||
RepostEvent.kind,
|
||||
GenericRepostEvent.kind,
|
||||
LongTextNoteEvent.kind,
|
||||
PollNoteEvent.kind,
|
||||
HighlightEvent.kind,
|
||||
AudioTrackEvent.kind,
|
||||
PinListEvent.kind,
|
||||
LiveActivitiesChatMessageEvent.kind,
|
||||
LiveActivitiesEvent.kind
|
||||
),
|
||||
authors = followSet,
|
||||
limit = 400,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
|
||||
|
||||
+35
-3
@@ -1,6 +1,8 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
@@ -30,8 +32,8 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
|
||||
|
||||
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
|
||||
val directEventsToLoad = channelsToWatch
|
||||
.map { LocalCache.getOrCreateChannel(it) }
|
||||
.filter { it.notes.isEmpty() }
|
||||
.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
|
||||
.filter { it.notes.isEmpty() && it is PublicChatChannel }
|
||||
|
||||
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
|
||||
|
||||
@@ -49,13 +51,43 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
|
||||
)
|
||||
}
|
||||
|
||||
fun createLoadStreamingIfNotLoadedFilter(): List<TypedFilter>? {
|
||||
val directEventsToLoad = channelsToWatch
|
||||
.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
|
||||
.filterIsInstance<LiveActivitiesChannel>()
|
||||
.filter { it.info == null }
|
||||
|
||||
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
|
||||
|
||||
if (interestedEvents.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// downloads linked events to this event.
|
||||
return directEventsToLoad.map {
|
||||
it.address().let { aTag ->
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(aTag.kind),
|
||||
tags = mapOf("d" to listOf(aTag.dTag)),
|
||||
authors = listOf(aTag.pubKeyHex.substring(0, 8))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val singleChannelChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val reactions = createRepliesAndReactionsFilter()
|
||||
val missing = createLoadEventsIfNotLoadedFilter()
|
||||
val missingStreaming = createLoadStreamingIfNotLoadedFilter()
|
||||
|
||||
singleChannelChannel.typedFilters = listOfNotNull(reactions, missing).ifEmpty { null }
|
||||
singleChannelChannel.typedFilters = (
|
||||
(listOfNotNull(reactions, missing)) + (missingStreaming ?: emptyList())
|
||||
).ifEmpty { null }
|
||||
}
|
||||
|
||||
fun add(eventId: String) {
|
||||
|
||||
@@ -26,7 +26,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind, LongTextNoteEvent.kind,
|
||||
ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind,
|
||||
ReactionEvent.kind, RepostEvent.kind, GenericRepostEvent.kind, ReportEvent.kind,
|
||||
LnZapEvent.kind, LnZapRequestEvent.kind,
|
||||
BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind,
|
||||
PollNoteEvent.kind, AudioTrackEvent.kind, PinListEvent.kind,
|
||||
@@ -77,6 +77,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
LongTextNoteEvent.kind,
|
||||
ReactionEvent.kind,
|
||||
RepostEvent.kind,
|
||||
GenericRepostEvent.kind,
|
||||
ReportEvent.kind,
|
||||
LnZapEvent.kind,
|
||||
LnZapRequestEvent.kind,
|
||||
|
||||
@@ -28,7 +28,16 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, AudioTrackEvent.kind, PinListEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind,
|
||||
GenericRepostEvent.kind,
|
||||
RepostEvent.kind,
|
||||
LongTextNoteEvent.kind,
|
||||
AudioTrackEvent.kind,
|
||||
PinListEvent.kind,
|
||||
PollNoteEvent.kind,
|
||||
HighlightEvent.kind
|
||||
),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 200
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@ open class BaseTextNoteEvent(
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun mentions() = taggedUsers()
|
||||
open fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
open fun replyTos() = taggedEvents()
|
||||
|
||||
private var citedUsersCache: Set<HexKey>? = null
|
||||
private var citedNotesCache: Set<HexKey>? = null
|
||||
|
||||
@@ -35,7 +35,8 @@ class ChannelMessageEvent(
|
||||
zapReceiver: String?,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000,
|
||||
markAsSensitive: Boolean
|
||||
markAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long?
|
||||
): ChannelMessageEvent {
|
||||
val content = message
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
@@ -54,6 +55,9 @@ class ChannelMessageEvent(
|
||||
if (markAsSensitive) {
|
||||
tags.add(listOf("content-warning", ""))
|
||||
}
|
||||
zapRaiserAmount?.let {
|
||||
tags.add(listOf("zapraiser", "$it"))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
|
||||
@@ -54,6 +54,10 @@ open class Event(
|
||||
(it.size > 1 && it[0] == "t" && it[1].equals("nude", true))
|
||||
}
|
||||
|
||||
override fun zapraiserAmount() = tags.firstOrNull() {
|
||||
(it.size > 1 && it[0].equals("zapraiser", true))
|
||||
}?.get(1)?.toLongOrNull()
|
||||
|
||||
override fun zapAddress() = tags.firstOrNull { it.size > 1 && it[0] == "zap" }?.get(1)
|
||||
|
||||
fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
|
||||
@@ -245,7 +249,10 @@ open class Event(
|
||||
FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
FileStorageEvent.kind -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
FileStorageHeaderEvent.kind -> FileStorageHeaderEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
GenericRepostEvent.kind -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
HighlightEvent.kind -> HighlightEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LiveActivitiesEvent.kind -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LiveActivitiesChatMessageEvent.kind -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LnZapPaymentResponseEvent.kind -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
@@ -38,4 +38,5 @@ interface EventInterface {
|
||||
|
||||
fun zapAddress(): String?
|
||||
fun isSensitive(): Boolean
|
||||
fun zapraiserAmount(): Long?
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class GenericRepostEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun boostedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
fun containedPost() = try {
|
||||
fromJson(content, Client.lenient)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 16
|
||||
|
||||
fun create(boostedPost: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): GenericRepostEvent {
|
||||
val content = boostedPost.toJson()
|
||||
|
||||
val replyToPost = listOf("e", boostedPost.id())
|
||||
val replyToAuthor = listOf("p", boostedPost.pubKey())
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
var tags: List<List<String>> = listOf(replyToPost, replyToAuthor)
|
||||
|
||||
if (boostedPost is AddressableEvent) {
|
||||
tags = tags + listOf(listOf("a", boostedPost.address().toTag()))
|
||||
}
|
||||
|
||||
tags = tags + listOf(listOf("k", "${boostedPost.kind()}"))
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return GenericRepostEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class LiveActivitiesChatMessageEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
private fun innerActivity() = tags.firstOrNull {
|
||||
it.size > 3 && it[0] == "a" && it[3] == "root"
|
||||
} ?: tags.firstOrNull {
|
||||
it.size > 1 && it[0] == "a"
|
||||
}
|
||||
|
||||
private fun activityHex() = innerActivity()?.let {
|
||||
it.getOrNull(1)
|
||||
}
|
||||
|
||||
fun activity() = innerActivity()?.let {
|
||||
if (it.size > 1) {
|
||||
val aTagValue = it[1]
|
||||
val relay = it.getOrNull(2)
|
||||
|
||||
ATag.parse(aTagValue, relay)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun replyTos() = taggedEvents().minus(activityHex() ?: "")
|
||||
|
||||
companion object {
|
||||
const val kind = 1311
|
||||
|
||||
fun create(
|
||||
message: String,
|
||||
activity: ATag,
|
||||
replyTos: List<String>? = null,
|
||||
mentions: List<String>? = null,
|
||||
zapReceiver: String?,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000,
|
||||
markAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long?
|
||||
): LiveActivitiesChatMessageEvent {
|
||||
val content = message
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = mutableListOf(
|
||||
listOf("a", activity.toTag(), "", "root")
|
||||
)
|
||||
replyTos?.forEach {
|
||||
tags.add(listOf("e", it))
|
||||
}
|
||||
mentions?.forEach {
|
||||
tags.add(listOf("p", it))
|
||||
}
|
||||
zapReceiver?.let {
|
||||
tags.add(listOf("zap", it))
|
||||
}
|
||||
if (markAsSensitive) {
|
||||
tags.add(listOf("content-warning", ""))
|
||||
}
|
||||
zapRaiserAmount?.let {
|
||||
tags.add(listOf("zapraiser", "$it"))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return LiveActivitiesChatMessageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class LiveActivitiesEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
|
||||
|
||||
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
|
||||
override fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
fun title() = tags.firstOrNull { it.size > 1 && it[0] == "title" }?.get(1)
|
||||
fun summary() = tags.firstOrNull { it.size > 1 && it[0] == "summary" }?.get(1)
|
||||
fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1)
|
||||
fun streaming() = tags.firstOrNull { it.size > 1 && it[0] == "streaming" }?.get(1)
|
||||
fun starts() = tags.firstOrNull { it.size > 1 && it[0] == "starts" }?.get(1)
|
||||
fun ends() = tags.firstOrNull { it.size > 1 && it[0] == "ends" }?.get(1)
|
||||
fun status() = tags.firstOrNull { it.size > 1 && it[0] == "status" }?.get(1)
|
||||
fun currentParticipants() = tags.firstOrNull { it.size > 1 && it[0] == "current_participants" }?.get(1)
|
||||
fun totalParticipants() = tags.firstOrNull { it.size > 1 && it[0] == "total_participants" }?.get(1)
|
||||
|
||||
fun participants() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(2)) }
|
||||
|
||||
companion object {
|
||||
const val kind = 30311
|
||||
|
||||
fun create(
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): LiveActivitiesEvent {
|
||||
val tags = mutableListOf<List<String>>()
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, "")
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return LiveActivitiesEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,8 @@ class PollNoteEvent(
|
||||
consensusThreshold: Int?,
|
||||
closedAt: Int?,
|
||||
zapReceiver: String?,
|
||||
markAsSensitive: Boolean
|
||||
markAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long?
|
||||
): PollNoteEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = mutableListOf<List<String>>()
|
||||
@@ -79,6 +80,9 @@ class PollNoteEvent(
|
||||
if (markAsSensitive) {
|
||||
tags.add(listOf("content-warning", ""))
|
||||
}
|
||||
zapRaiserAmount?.let {
|
||||
tags.add(listOf("zapraiser", "$it"))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, msg)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
|
||||
@@ -78,7 +78,8 @@ class PrivateDmEvent(
|
||||
createdAt: Long = Date().time / 1000,
|
||||
publishedRecipientPubKey: ByteArray? = null,
|
||||
advertiseNip18: Boolean = true,
|
||||
markAsSensitive: Boolean
|
||||
markAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long?
|
||||
): PrivateDmEvent {
|
||||
val content = Utils.encrypt(
|
||||
if (advertiseNip18) { nip18Advertisement } else { "" } + msg,
|
||||
@@ -102,6 +103,9 @@ class PrivateDmEvent(
|
||||
if (markAsSensitive) {
|
||||
tags.add(listOf("content-warning", ""))
|
||||
}
|
||||
zapRaiserAmount?.let {
|
||||
tags.add(listOf("zapraiser", "$it"))
|
||||
}
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return PrivateDmEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
|
||||
@@ -36,7 +36,7 @@ class RepostEvent(
|
||||
val replyToAuthor = listOf("p", boostedPost.pubKey())
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
var tags: List<List<String>> = boostedPost.tags().plus(listOf(replyToPost, replyToAuthor))
|
||||
var tags: List<List<String>> = listOf(replyToPost, replyToAuthor)
|
||||
|
||||
if (boostedPost is AddressableEvent) {
|
||||
tags = tags + listOf(listOf("a", boostedPost.address().toTag()))
|
||||
|
||||
@@ -21,6 +21,8 @@ class TextNoteEvent(
|
||||
|
||||
fun subject() = tags.firstOrNull() { it.size > 1 && it[0] == "subject" }?.get(1)
|
||||
|
||||
fun root() = tags.firstOrNull() { it.size > 3 && it[3] == "root" }?.get(1)
|
||||
|
||||
companion object {
|
||||
const val kind = 1
|
||||
|
||||
@@ -32,19 +34,45 @@ class TextNoteEvent(
|
||||
extraTags: List<String>?,
|
||||
zapReceiver: String?,
|
||||
markAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long?,
|
||||
replyingTo: String?,
|
||||
root: String?,
|
||||
directMentions: Set<HexKey>,
|
||||
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): TextNoteEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = mutableListOf<List<String>>()
|
||||
replyTos?.forEach {
|
||||
tags.add(listOf("e", it))
|
||||
if (it == replyingTo) {
|
||||
tags.add(listOf("e", it, "", "reply"))
|
||||
} else if (it == root) {
|
||||
tags.add(listOf("e", it, "", "root"))
|
||||
} else if (it in directMentions) {
|
||||
tags.add(listOf("e", it, "", "mention"))
|
||||
} else {
|
||||
tags.add(listOf("e", it))
|
||||
}
|
||||
}
|
||||
mentions?.forEach {
|
||||
tags.add(listOf("p", it))
|
||||
if (it in directMentions) {
|
||||
tags.add(listOf("p", it, "", "mention"))
|
||||
} else {
|
||||
tags.add(listOf("p", it))
|
||||
}
|
||||
}
|
||||
addresses?.forEach {
|
||||
tags.add(listOf("a", it.toTag()))
|
||||
val aTag = it.toTag()
|
||||
if (aTag == replyingTo) {
|
||||
tags.add(listOf("a", aTag, "", "reply"))
|
||||
} else if (aTag == root) {
|
||||
tags.add(listOf("a", aTag, "", "root"))
|
||||
} else if (aTag in directMentions) {
|
||||
tags.add(listOf("a", aTag, "", "mention"))
|
||||
} else {
|
||||
tags.add(listOf("a", aTag))
|
||||
}
|
||||
}
|
||||
findHashtags(msg).forEach {
|
||||
tags.add(listOf("t", it))
|
||||
@@ -62,6 +90,9 @@ class TextNoteEvent(
|
||||
if (markAsSensitive) {
|
||||
tags.add(listOf("content-warning", ""))
|
||||
}
|
||||
zapRaiserAmount?.let {
|
||||
tags.add(listOf("zapraiser", "$it"))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, msg)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* Created by Ayaan on 02/02/23, 10:30 pm
|
||||
* Copyright (c) 2023 . All rights reserved.
|
||||
* Last modified 02/02/23, 9:56 pm
|
||||
*/
|
||||
|
||||
package com.vitorpamplona.amethyst.service.tts
|
||||
|
||||
import android.content.Context
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.speech.tts.UtteranceProgressListener
|
||||
import java.util.Locale
|
||||
|
||||
const val DEF_SPEECH_AND_PITCH = 0.8f
|
||||
|
||||
fun getErrorText(errorCode: Int): String = when (errorCode) {
|
||||
TextToSpeech.ERROR -> "ERROR"
|
||||
TextToSpeech.ERROR_INVALID_REQUEST -> "ERROR_INVALID_REQUEST"
|
||||
TextToSpeech.ERROR_NETWORK -> "ERROR_NETWORK"
|
||||
TextToSpeech.ERROR_NETWORK_TIMEOUT -> "ERROR_NETWORK_TIMEOUT"
|
||||
TextToSpeech.ERROR_SERVICE -> "ERROR_SERVICE"
|
||||
TextToSpeech.ERROR_SYNTHESIS -> "ERROR_SYNTHESIS"
|
||||
TextToSpeech.ERROR_NOT_INSTALLED_YET -> "ERROR_NOT_INSTALLED_YET"
|
||||
else -> "UNKNOWN"
|
||||
}
|
||||
|
||||
class TextToSpeechEngine private constructor() {
|
||||
private var tts: TextToSpeech? = null
|
||||
|
||||
private var defaultPitch = 0.8f
|
||||
private var defaultSpeed = 0.8f
|
||||
private var defLanguage = Locale.getDefault()
|
||||
private var onStartListener: (() -> Unit)? = null
|
||||
private var onDoneListener: (() -> Unit)? = null
|
||||
private var onErrorListener: ((String) -> Unit)? = null
|
||||
private var onHighlightListener: ((Int, Int) -> Unit)? = null
|
||||
private var message: String? = null
|
||||
|
||||
companion object {
|
||||
private var instance: TextToSpeechEngine? = null
|
||||
fun getInstance(): TextToSpeechEngine {
|
||||
if (instance == null) {
|
||||
instance = TextToSpeechEngine()
|
||||
}
|
||||
return instance!!
|
||||
}
|
||||
}
|
||||
|
||||
fun initTTS(context: Context, message: String) {
|
||||
tts = TextToSpeech(context) {
|
||||
if (it == TextToSpeech.SUCCESS) {
|
||||
tts!!.language = defLanguage
|
||||
tts!!.setPitch(defaultPitch)
|
||||
tts!!.setSpeechRate(defaultSpeed)
|
||||
tts!!.setListener(
|
||||
onStart = {
|
||||
onStartListener?.invoke()
|
||||
},
|
||||
onError = { e ->
|
||||
e?.let { error ->
|
||||
onErrorListener?.invoke(error)
|
||||
}
|
||||
},
|
||||
onRange = { start, end ->
|
||||
if (this@TextToSpeechEngine.message != null) {
|
||||
onHighlightListener?.invoke(start, end)
|
||||
}
|
||||
},
|
||||
onDone = {
|
||||
onStartListener?.invoke()
|
||||
}
|
||||
)
|
||||
speak(message)
|
||||
} else {
|
||||
onErrorListener?.invoke(getErrorText(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun speak(message: String): TextToSpeechEngine {
|
||||
tts!!.speak(
|
||||
message,
|
||||
TextToSpeech.QUEUE_FLUSH,
|
||||
null,
|
||||
TextToSpeech.ACTION_TTS_QUEUE_PROCESSING_COMPLETED
|
||||
)
|
||||
return this
|
||||
}
|
||||
|
||||
fun setPitchAndSpeed(pitch: Float, speed: Float) {
|
||||
defaultPitch = pitch
|
||||
defaultSpeed = speed
|
||||
}
|
||||
|
||||
fun resetPitchAndSpeed() {
|
||||
defaultPitch = DEF_SPEECH_AND_PITCH
|
||||
defaultSpeed = DEF_SPEECH_AND_PITCH
|
||||
}
|
||||
|
||||
fun setLanguage(local: Locale): TextToSpeechEngine {
|
||||
this.defLanguage = local
|
||||
return this
|
||||
}
|
||||
|
||||
fun setHighlightedMessage(message: String) {
|
||||
this.message = message
|
||||
}
|
||||
|
||||
fun setOnStartListener(onStartListener: (() -> Unit)): TextToSpeechEngine {
|
||||
this.onStartListener = onStartListener
|
||||
return this
|
||||
}
|
||||
|
||||
fun setOnCompletionListener(onDoneListener: () -> Unit): TextToSpeechEngine {
|
||||
this.onDoneListener = onDoneListener
|
||||
return this
|
||||
}
|
||||
|
||||
fun setOnErrorListener(onErrorListener: (String) -> Unit): TextToSpeechEngine {
|
||||
this.onErrorListener = onErrorListener
|
||||
return this
|
||||
}
|
||||
|
||||
fun setOnHighlightListener(onHighlightListener: (Int, Int) -> Unit): TextToSpeechEngine {
|
||||
this.onHighlightListener = onHighlightListener
|
||||
return this
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
tts?.stop()
|
||||
tts?.shutdown()
|
||||
tts = null
|
||||
instance = null
|
||||
}
|
||||
}
|
||||
|
||||
inline fun TextToSpeech.setListener(
|
||||
crossinline onStart: (String?) -> Unit = {},
|
||||
crossinline onError: (String?) -> Unit = {},
|
||||
crossinline onRange: (Int, Int) -> Unit = { _, _ -> },
|
||||
crossinline onDone: (String?) -> Unit
|
||||
) = this.apply {
|
||||
setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||
override fun onStart(p0: String?) {
|
||||
onStart.invoke(p0)
|
||||
}
|
||||
|
||||
override fun onDone(p0: String?) {
|
||||
onDone.invoke(p0)
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java", ReplaceWith("onError.invoke(p0)"))
|
||||
override fun onError(p0: String?) {
|
||||
onError.invoke(p0)
|
||||
}
|
||||
|
||||
override fun onRangeStart(utteranceId: String?, start: Int, end: Int, frame: Int) {
|
||||
super.onRangeStart(utteranceId, start, end, frame)
|
||||
onRange.invoke(start, end)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* Created by Ayaan on 02/02/23, 10:30 pm
|
||||
* Copyright (c) 2023 . All rights reserved.
|
||||
* Last modified 02/02/23, 10:19 pm
|
||||
*/
|
||||
|
||||
package com.vitorpamplona.amethyst.service.tts
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.Locale
|
||||
|
||||
class TextToSpeechHelper private constructor(private val context: WeakReference<Context>) :
|
||||
LifecycleEventObserver {
|
||||
private val appContext
|
||||
get() = context.get()!!.applicationContext
|
||||
|
||||
private var message: String? = null
|
||||
|
||||
private var tts: TextToSpeechEngine? = null
|
||||
|
||||
private var onStart: (() -> Unit)? = null
|
||||
|
||||
private var onDoneListener: (() -> Unit)? = null
|
||||
|
||||
private var onErrorListener: ((String) -> Unit)? = null
|
||||
|
||||
private var onHighlightListener: ((Pair<Int, Int>) -> Unit)? = null
|
||||
|
||||
private var customActionForDestroy: (() -> Unit)? = null
|
||||
|
||||
init {
|
||||
initTTS()
|
||||
}
|
||||
|
||||
fun registerLifecycle(owner: LifecycleOwner): TextToSpeechHelper {
|
||||
owner.lifecycle.addObserver(this)
|
||||
return this
|
||||
}
|
||||
|
||||
private fun initTTS() = context.get()?.run {
|
||||
tts = TextToSpeechEngine.getInstance()
|
||||
.setOnCompletionListener { onDoneListener?.invoke() }
|
||||
.setOnErrorListener { onErrorListener?.invoke(it) }
|
||||
.setOnStartListener { onStart?.invoke() }
|
||||
}
|
||||
|
||||
fun speak(message: String): TextToSpeechHelper {
|
||||
if (tts == null) {
|
||||
initTTS()
|
||||
}
|
||||
this.message = message
|
||||
|
||||
tts?.initTTS(
|
||||
appContext,
|
||||
message
|
||||
)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* This method will highlight the text in the textView
|
||||
*
|
||||
* @exception Exception("Message can't be null for highlighting !! Call speak() first")
|
||||
*/
|
||||
fun highlight(): TextToSpeechHelper {
|
||||
if (message == null) throw Exception("Message can't be null for highlighting !! Call speak() first")
|
||||
tts?.setHighlightedMessage(message!!)
|
||||
tts?.setOnHighlightListener { i, i2 ->
|
||||
onHighlightListener?.invoke(Pair(i, i2))
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun removeHighlight(): TextToSpeechHelper {
|
||||
message = null
|
||||
onHighlightListener = null
|
||||
return this
|
||||
}
|
||||
|
||||
fun destroy(
|
||||
action: (() -> Unit) = {}
|
||||
) {
|
||||
tts?.destroy()
|
||||
tts = null
|
||||
action.invoke()
|
||||
INSTANCE = null
|
||||
}
|
||||
|
||||
fun onStart(onStartListener: () -> Unit): TextToSpeechHelper {
|
||||
this.onStart = onStartListener
|
||||
return this
|
||||
}
|
||||
|
||||
fun onDone(onCompleteListener: () -> Unit): TextToSpeechHelper {
|
||||
this.onDoneListener = onCompleteListener
|
||||
return this
|
||||
}
|
||||
|
||||
fun onError(onErrorListener: (String) -> Unit): TextToSpeechHelper {
|
||||
this.onErrorListener = onErrorListener
|
||||
return this
|
||||
}
|
||||
|
||||
fun onHighlight(onHighlightListener: (Pair<Int, Int>) -> Unit): TextToSpeechHelper {
|
||||
this.onHighlightListener = onHighlightListener
|
||||
return this
|
||||
}
|
||||
|
||||
fun setCustomActionForDestroy(action: () -> Unit): TextToSpeechHelper {
|
||||
customActionForDestroy = action
|
||||
return this
|
||||
}
|
||||
|
||||
fun setLanguage(locale: Locale): TextToSpeechHelper {
|
||||
tts?.setLanguage(locale)
|
||||
return this
|
||||
}
|
||||
|
||||
fun setPitchAndSpeed(
|
||||
pitch: Float = DEF_SPEECH_AND_PITCH,
|
||||
speed: Float = DEF_SPEECH_AND_PITCH
|
||||
): TextToSpeechHelper {
|
||||
tts?.setPitchAndSpeed(pitch, speed)
|
||||
return this
|
||||
}
|
||||
|
||||
fun resetPitchAndSpeed(): TextToSpeechHelper {
|
||||
tts?.resetPitchAndSpeed()
|
||||
return this
|
||||
}
|
||||
|
||||
companion object {
|
||||
private var INSTANCE: TextToSpeechHelper? = null
|
||||
fun getInstance(context: Context): TextToSpeechHelper {
|
||||
synchronized(TextToSpeechHelper::class.java) {
|
||||
if (INSTANCE == null) {
|
||||
INSTANCE = TextToSpeechHelper(WeakReference(context))
|
||||
}
|
||||
return INSTANCE!!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
||||
if (event == Lifecycle.Event.ON_DESTROY ||
|
||||
event == Lifecycle.Event.ON_STOP ||
|
||||
event == Lifecycle.Event.ON_PAUSE
|
||||
) {
|
||||
destroy {
|
||||
customActionForDestroy?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -352,12 +352,12 @@ private fun RenderChannel(
|
||||
channelPicture = item.profilePicture(),
|
||||
channelTitle = {
|
||||
Text(
|
||||
"${item.info.name}",
|
||||
item.toBestDisplayName(),
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
channelLastTime = null,
|
||||
channelLastContent = item.info.about,
|
||||
channelLastContent = item.summary(),
|
||||
false,
|
||||
onClick = onClick
|
||||
)
|
||||
|
||||
@@ -25,14 +25,14 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, channel: Channel? = null) {
|
||||
fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, channel: PublicChatChannel? = null) {
|
||||
val postViewModel: NewChannelViewModel = viewModel()
|
||||
postViewModel.load(accountViewModel.account, channel)
|
||||
|
||||
|
||||
@@ -4,17 +4,17 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.PublicChatChannel
|
||||
|
||||
class NewChannelViewModel : ViewModel() {
|
||||
private var account: Account? = null
|
||||
private var originalChannel: Channel? = null
|
||||
private var originalChannel: PublicChatChannel? = null
|
||||
|
||||
val channelName = mutableStateOf(TextFieldValue())
|
||||
val channelPicture = mutableStateOf(TextFieldValue())
|
||||
val channelDescription = mutableStateOf(TextFieldValue())
|
||||
|
||||
fun load(account: Account, channel: Channel?) {
|
||||
fun load(account: Account, channel: PublicChatChannel?) {
|
||||
this.account = account
|
||||
if (channel != null) {
|
||||
originalChannel = channel
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
@@ -8,11 +9,16 @@ import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
|
||||
class NewMessageTagger(var channelHex: String?, var mentions: List<User>?, var replyTos: List<Note>?, var message: String) {
|
||||
|
||||
val directMentions = mutableSetOf<HexKey>()
|
||||
|
||||
fun addUserToMentions(user: User) {
|
||||
directMentions.add(user.pubkeyHex)
|
||||
mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user)
|
||||
}
|
||||
|
||||
fun addNoteToReplyTos(note: Note) {
|
||||
directMentions.add(note.idHex)
|
||||
|
||||
note.author?.let { addUserToMentions(it) }
|
||||
replyTos = if (replyTos?.contains(note) == true) replyTos else replyTos?.plus(note) ?: listOf(note)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ 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.ShowChart
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material.icons.outlined.ArrowForwardIos
|
||||
@@ -279,6 +280,15 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
}
|
||||
}
|
||||
|
||||
if (lud16 != null && postViewModel.wantsZapraiser) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
|
||||
ZapRaiserRequest(
|
||||
stringResource(id = R.string.zapraiser),
|
||||
postViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val myUrlPreview = postViewModel.urlPreview
|
||||
if (myUrlPreview != null) {
|
||||
Row(modifier = Modifier.padding(top = 5.dp)) {
|
||||
@@ -374,6 +384,12 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.canAddZapRaiser) {
|
||||
AddZapraiserButton(postViewModel.wantsZapraiser) {
|
||||
postViewModel.wantsZapraiser = !postViewModel.wantsZapraiser
|
||||
}
|
||||
}
|
||||
|
||||
MarkAsSensitive(postViewModel) {
|
||||
postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive
|
||||
}
|
||||
@@ -460,6 +476,56 @@ private fun AddPollButton(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddZapraiserButton(
|
||||
isLnInvoiceActive: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
onClick()
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.height(20.dp)
|
||||
.width(25.dp)
|
||||
) {
|
||||
if (!isLnInvoiceActive) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ShowChart,
|
||||
null,
|
||||
modifier = Modifier.size(20.dp).align(Alignment.TopStart),
|
||||
tint = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
contentDescription = stringResource(R.string.zaps),
|
||||
modifier = Modifier
|
||||
.size(13.dp)
|
||||
.align(Alignment.BottomEnd),
|
||||
tint = MaterialTheme.colors.onBackground
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ShowChart,
|
||||
null,
|
||||
modifier = Modifier.size(20.dp).align(Alignment.TopStart),
|
||||
tint = BitcoinOrange
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
contentDescription = stringResource(R.string.zaps),
|
||||
modifier = Modifier
|
||||
.size(13.dp)
|
||||
.align(Alignment.BottomEnd),
|
||||
tint = BitcoinOrange
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddLnInvoiceButton(
|
||||
isLnInvoiceActive: Boolean,
|
||||
@@ -657,7 +723,8 @@ fun PostButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier =
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
|
||||
)
|
||||
),
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.post), color = Color.White)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.*
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.AddressableEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BaseTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
@@ -74,6 +75,11 @@ open class NewPostViewModel() : ViewModel() {
|
||||
// NSFW, Sensitive
|
||||
var wantsToMarkAsSensitive by mutableStateOf(false)
|
||||
|
||||
// ZapRaiser
|
||||
var canAddZapRaiser by mutableStateOf(false)
|
||||
var wantsZapraiser by mutableStateOf(false)
|
||||
var zapRaiserAmount by mutableStateOf<Long?>(null)
|
||||
|
||||
open fun load(account: Account, replyingTo: Note?, quote: Note?) {
|
||||
originalNote = replyingTo
|
||||
replyingTo?.let { replyNote ->
|
||||
@@ -105,11 +111,14 @@ open class NewPostViewModel() : ViewModel() {
|
||||
}
|
||||
|
||||
canAddInvoice = account.userProfile().info?.lnAddress() != null
|
||||
canAddZapRaiser = account.userProfile().info?.lnAddress() != null
|
||||
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null
|
||||
contentToAddUrl = null
|
||||
|
||||
wantsForwardZapTo = false
|
||||
wantsToMarkAsSensitive = false
|
||||
wantsZapraiser = false
|
||||
zapRaiserAmount = null
|
||||
forwardZapTo = null
|
||||
forwardZapToEditting = TextFieldValue("")
|
||||
|
||||
@@ -130,14 +139,38 @@ open class NewPostViewModel() : ViewModel() {
|
||||
null
|
||||
}
|
||||
|
||||
val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount else null
|
||||
|
||||
if (wantsPoll) {
|
||||
account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt, zapReceiver, wantsToMarkAsSensitive)
|
||||
account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount)
|
||||
} else if (originalNote?.channelHex() != null) {
|
||||
account?.sendChannelMessage(tagger.message, tagger.channelHex!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive)
|
||||
if (originalNote is AddressableEvent && originalNote?.address() != null) {
|
||||
account?.sendLiveMessage(tagger.message, originalNote?.address()!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount)
|
||||
} else {
|
||||
account?.sendChannelMessage(tagger.message, tagger.channelHex!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount)
|
||||
}
|
||||
} else if (originalNote?.event is PrivateDmEvent) {
|
||||
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive)
|
||||
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount)
|
||||
} else {
|
||||
account?.sendPost(tagger.message, tagger.replyTos, tagger.mentions, null, zapReceiver, wantsToMarkAsSensitive)
|
||||
// adds markers
|
||||
val rootId =
|
||||
(originalNote?.event as? TextNoteEvent)?.root() // if it has a marker as root
|
||||
?: originalNote?.replyTo?.firstOrNull { it.event != null && it.replyTo?.isEmpty() == true }?.idHex // if it has loaded events with zero replies in the reply list
|
||||
?: originalNote?.replyTo?.firstOrNull()?.idHex // old rules, first item is root.
|
||||
val replyId = originalNote?.idHex
|
||||
|
||||
account?.sendPost(
|
||||
message = tagger.message,
|
||||
replyTo = tagger.replyTos,
|
||||
mentions = tagger.mentions,
|
||||
tags = null,
|
||||
zapReceiver = zapReceiver,
|
||||
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = localZapRaiserAmount,
|
||||
replyingTo = replyId,
|
||||
root = rootId,
|
||||
directMentions = tagger.directMentions
|
||||
)
|
||||
}
|
||||
|
||||
cancel()
|
||||
@@ -211,6 +244,8 @@ open class NewPostViewModel() : ViewModel() {
|
||||
closedAt = null
|
||||
|
||||
wantsInvoice = false
|
||||
wantsZapraiser = false
|
||||
zapRaiserAmount = null
|
||||
|
||||
wantsForwardZapTo = false
|
||||
wantsToMarkAsSensitive = false
|
||||
@@ -316,8 +351,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
}
|
||||
|
||||
fun canPost(): Boolean {
|
||||
return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice &&
|
||||
(!wantsPoll || pollOptions.values.all { it.isNotEmpty() }) && contentToAddUrl == null
|
||||
return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice && (!wantsZapraiser || zapRaiserAmount != null) && (!wantsPoll || pollOptions.values.all { it.isNotEmpty() }) && contentToAddUrl == null
|
||||
}
|
||||
|
||||
fun includePollHashtagInMessage(include: Boolean, hashtag: String) {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -35,10 +38,12 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -52,7 +57,9 @@ import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.coroutines.launch
|
||||
import java.lang.Math.round
|
||||
|
||||
@Composable
|
||||
@@ -202,6 +209,7 @@ fun ServerConfigHeader() {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ServerConfig(
|
||||
item: RelaySetupInfo,
|
||||
@@ -216,6 +224,8 @@ fun ServerConfig(
|
||||
|
||||
onDelete: (RelaySetupInfo) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -251,15 +261,28 @@ fun ServerConfig(
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(
|
||||
modifier = Modifier.size(30.dp),
|
||||
onClick = { onToggleFollows(item) }
|
||||
modifier = Modifier
|
||||
.size(30.dp),
|
||||
onClick = { }
|
||||
) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_home),
|
||||
stringResource(R.string.home_feed),
|
||||
modifier = Modifier
|
||||
.padding(end = 5.dp)
|
||||
.size(15.dp),
|
||||
.size(15.dp)
|
||||
.combinedClickable(
|
||||
onClick = { onToggleFollows(item) },
|
||||
onLongClick = {
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.home_feed),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
),
|
||||
tint = if (item.feedTypes.contains(FeedType.FOLLOWS)) {
|
||||
Color.Green
|
||||
} else {
|
||||
@@ -271,14 +294,26 @@ fun ServerConfig(
|
||||
}
|
||||
IconButton(
|
||||
modifier = Modifier.size(30.dp),
|
||||
onClick = { onTogglePrivateDMs(item) }
|
||||
onClick = { }
|
||||
) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_dm),
|
||||
stringResource(R.string.private_message_feed),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 5.dp)
|
||||
.size(15.dp),
|
||||
.size(15.dp)
|
||||
.combinedClickable(
|
||||
onClick = { onTogglePrivateDMs(item) },
|
||||
onLongClick = {
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.private_message_feed),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
),
|
||||
tint = if (item.feedTypes.contains(FeedType.PRIVATE_DMS)) {
|
||||
Color.Green
|
||||
} else {
|
||||
@@ -290,14 +325,26 @@ fun ServerConfig(
|
||||
}
|
||||
IconButton(
|
||||
modifier = Modifier.size(30.dp),
|
||||
onClick = { onTogglePublicChats(item) }
|
||||
onClick = { }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Groups,
|
||||
stringResource(R.string.public_chat_feed),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 5.dp)
|
||||
.size(15.dp),
|
||||
.size(15.dp)
|
||||
.combinedClickable(
|
||||
onClick = { onTogglePublicChats(item) },
|
||||
onLongClick = {
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.public_chat_feed),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
),
|
||||
tint = if (item.feedTypes.contains(FeedType.PUBLIC_CHATS)) {
|
||||
Color.Green
|
||||
} else {
|
||||
@@ -309,14 +356,26 @@ fun ServerConfig(
|
||||
}
|
||||
IconButton(
|
||||
modifier = Modifier.size(30.dp),
|
||||
onClick = { onToggleGlobal(item) }
|
||||
onClick = { }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Public,
|
||||
stringResource(R.string.global_feed),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 5.dp)
|
||||
.size(15.dp),
|
||||
.size(15.dp)
|
||||
.combinedClickable(
|
||||
onClick = { onToggleGlobal(item) },
|
||||
onLongClick = {
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.global_feed),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
),
|
||||
tint = if (item.feedTypes.contains(FeedType.GLOBAL)) {
|
||||
Color.Green
|
||||
} else {
|
||||
@@ -336,7 +395,19 @@ fun ServerConfig(
|
||||
stringResource(R.string.search_feed),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 5.dp)
|
||||
.size(15.dp),
|
||||
.size(15.dp)
|
||||
.combinedClickable(
|
||||
onClick = { onToggleSearch(item) },
|
||||
onLongClick = {
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.search_feed),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
),
|
||||
tint = if (item.feedTypes.contains(FeedType.SEARCH)) {
|
||||
Color.Green
|
||||
} else {
|
||||
@@ -353,14 +424,26 @@ fun ServerConfig(
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(
|
||||
modifier = Modifier.size(30.dp),
|
||||
onClick = { onToggleDownload(item) }
|
||||
onClick = { }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Download,
|
||||
null,
|
||||
stringResource(R.string.read_from_relay),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 5.dp)
|
||||
.size(15.dp),
|
||||
.size(15.dp)
|
||||
.combinedClickable(
|
||||
onClick = { onToggleDownload(item) },
|
||||
onLongClick = {
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.read_from_relay),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
),
|
||||
tint = if (item.read) {
|
||||
Color.Green
|
||||
} else {
|
||||
@@ -381,14 +464,26 @@ fun ServerConfig(
|
||||
|
||||
IconButton(
|
||||
modifier = Modifier.size(30.dp),
|
||||
onClick = { onToggleUpload(item) }
|
||||
onClick = { }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Upload,
|
||||
null,
|
||||
stringResource(R.string.write_to_relay),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 5.dp)
|
||||
.size(15.dp),
|
||||
.size(15.dp)
|
||||
.combinedClickable(
|
||||
onClick = { onToggleUpload(item) },
|
||||
onLongClick = {
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.write_to_relay),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
),
|
||||
tint = if (item.write) {
|
||||
Color.Green
|
||||
} else {
|
||||
@@ -409,10 +504,22 @@ fun ServerConfig(
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.SyncProblem,
|
||||
null,
|
||||
stringResource(R.string.errors),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 5.dp)
|
||||
.size(15.dp),
|
||||
.size(15.dp)
|
||||
.combinedClickable(
|
||||
onClick = { },
|
||||
onLongClick = {
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.errors),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
),
|
||||
tint = if (item.errorCount > 0) Color.Yellow else Color.Green
|
||||
)
|
||||
|
||||
@@ -426,8 +533,22 @@ fun ServerConfig(
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.DeleteSweep,
|
||||
null,
|
||||
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
|
||||
stringResource(R.string.spam),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 5.dp)
|
||||
.size(15.dp)
|
||||
.combinedClickable(
|
||||
onClick = { },
|
||||
onLongClick = {
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.spam),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
),
|
||||
tint = if (item.spamCount > 0) Color.Yellow else Color.Green
|
||||
)
|
||||
|
||||
@@ -477,7 +598,7 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (RelaySetupInfo) -> Uni
|
||||
imageVector = Icons.Default.Download,
|
||||
null,
|
||||
modifier = Modifier
|
||||
.size(35.dp)
|
||||
.size(Size35dp)
|
||||
.padding(horizontal = 5.dp),
|
||||
tint = if (read) Color.Green else MaterialTheme.colors.placeholderText
|
||||
)
|
||||
@@ -488,7 +609,7 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (RelaySetupInfo) -> Uni
|
||||
imageVector = Icons.Default.Upload,
|
||||
null,
|
||||
modifier = Modifier
|
||||
.size(35.dp)
|
||||
.size(Size35dp)
|
||||
.padding(horizontal = 5.dp),
|
||||
tint = if (write) Color.Green else MaterialTheme.colors.placeholderText
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -15,6 +16,7 @@ import java.util.concurrent.atomic.AtomicBoolean
|
||||
/**
|
||||
* This class is designed to have a waiting time between two calls of invalidate
|
||||
*/
|
||||
@Stable
|
||||
class BundledUpdate(
|
||||
val delay: Long,
|
||||
val dispatcher: CoroutineDispatcher = Dispatchers.Default
|
||||
@@ -51,6 +53,7 @@ class BundledUpdate(
|
||||
/**
|
||||
* This class is designed to have a waiting time between two calls of invalidate
|
||||
*/
|
||||
@Stable
|
||||
class BundledInsert<T>(
|
||||
val delay: Long,
|
||||
val dispatcher: CoroutineDispatcher = Dispatchers.Default
|
||||
|
||||
@@ -90,7 +90,7 @@ private fun LoadNote(
|
||||
hex: String,
|
||||
content: @Composable (Note) -> Unit
|
||||
) {
|
||||
var noteBase by remember(hex) { mutableStateOf<Note?>(null) }
|
||||
var noteBase by remember(hex) { mutableStateOf(LocalCache.getNoteIfExists(hex)) }
|
||||
|
||||
LaunchedEffect(key1 = hex) {
|
||||
if (noteBase == null) {
|
||||
@@ -184,11 +184,13 @@ private fun DisplayAddress(
|
||||
nip19: Nip19.Return,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
var noteBase by remember(nip19) { mutableStateOf<Note?>(null) }
|
||||
var noteBase by remember(nip19) { mutableStateOf(LocalCache.getNoteIfExists(nip19.hex)) }
|
||||
|
||||
LaunchedEffect(key1 = nip19.hex) {
|
||||
launch(Dispatchers.IO) {
|
||||
noteBase = LocalCache.checkGetOrCreateAddressableNote(nip19.hex)
|
||||
if (noteBase == null) {
|
||||
launch(Dispatchers.IO) {
|
||||
noteBase = LocalCache.checkGetOrCreateAddressableNote(nip19.hex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,32 +223,18 @@ private fun DisplayUser(
|
||||
nip19: Nip19.Return,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
var userBase by remember(nip19) { mutableStateOf<User?>(null) }
|
||||
var userBase by remember(nip19) { mutableStateOf(LocalCache.getUserIfExists(nip19.hex)) }
|
||||
|
||||
LaunchedEffect(key1 = nip19.hex) {
|
||||
launch(Dispatchers.IO) {
|
||||
userBase = LocalCache.checkGetOrCreateUser(nip19.hex)
|
||||
if (userBase == null) {
|
||||
launch(Dispatchers.IO) {
|
||||
userBase = LocalCache.checkGetOrCreateUser(nip19.hex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
userBase?.let {
|
||||
val userState by it.live().metadata.observeAsState()
|
||||
val route = remember(userState) { "User/${it.pubkeyHex}" }
|
||||
val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() }
|
||||
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() }
|
||||
val addedCharts = remember {
|
||||
"${nip19.additionalChars} "
|
||||
}
|
||||
|
||||
if (userDisplayName != null) {
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = userDisplayName,
|
||||
suffix = addedCharts,
|
||||
tags = userTags,
|
||||
route = route,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
RenderUserAsClickableText(it, nip19, nav)
|
||||
}
|
||||
|
||||
if (userBase == null) {
|
||||
@@ -258,6 +246,42 @@ private fun DisplayUser(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderUserAsClickableText(
|
||||
baseUser: User,
|
||||
nip19: Nip19.Return,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val userState by baseUser.live().metadata.observeAsState()
|
||||
val route = remember { "User/${baseUser.pubkeyHex}" }
|
||||
|
||||
val userDisplayName by remember(userState) {
|
||||
derivedStateOf {
|
||||
userState?.user?.toBestDisplayName()
|
||||
}
|
||||
}
|
||||
|
||||
val userTags by remember(userState) {
|
||||
derivedStateOf {
|
||||
userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists()
|
||||
}
|
||||
}
|
||||
|
||||
val addedCharts = remember(nip19) {
|
||||
"${nip19.additionalChars} "
|
||||
}
|
||||
|
||||
userDisplayName?.let {
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = it,
|
||||
suffix = addedCharts,
|
||||
tags = userTags,
|
||||
route = route,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateClickableText(
|
||||
clickablePart: String,
|
||||
|
||||
@@ -67,7 +67,7 @@ import java.net.URL
|
||||
import java.util.regex.Pattern
|
||||
|
||||
val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg")
|
||||
val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3")
|
||||
val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8")
|
||||
|
||||
// Group 1 = url, group 4 additional chars
|
||||
val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
|
||||
@@ -548,7 +548,7 @@ private fun ObserveNIP19Event(
|
||||
it: Nip19.Return,
|
||||
onRefresh: () -> Unit
|
||||
) {
|
||||
var baseNote by remember(it) { mutableStateOf<Note?>(null) }
|
||||
var baseNote by remember(it) { mutableStateOf<Note?>(LocalCache.getNoteIfExists(it.hex)) }
|
||||
|
||||
LaunchedEffect(key1 = it.hex) {
|
||||
if (baseNote == null) {
|
||||
@@ -563,13 +563,18 @@ private fun ObserveNIP19Event(
|
||||
}
|
||||
|
||||
baseNote?.let { note ->
|
||||
val noteState by note.live().metadata.observeAsState()
|
||||
ObserveNote(note, onRefresh)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = noteState) {
|
||||
if (noteState?.note?.event != null) {
|
||||
launch(Dispatchers.IO) {
|
||||
onRefresh()
|
||||
}
|
||||
@Composable
|
||||
fun ObserveNote(note: Note, onRefresh: () -> Unit) {
|
||||
val noteState by note.live().metadata.observeAsState()
|
||||
|
||||
LaunchedEffect(key1 = noteState) {
|
||||
if (noteState?.note?.event != null) {
|
||||
launch(Dispatchers.IO) {
|
||||
onRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -580,7 +585,7 @@ private fun ObserveNIP19User(
|
||||
it: Nip19.Return,
|
||||
onRefresh: () -> Unit
|
||||
) {
|
||||
var baseUser by remember(it) { mutableStateOf<User?>(null) }
|
||||
var baseUser by remember(it) { mutableStateOf<User?>(LocalCache.getUserIfExists(it.hex)) }
|
||||
|
||||
LaunchedEffect(key1 = it.hex) {
|
||||
if (baseUser == null) {
|
||||
@@ -595,13 +600,18 @@ private fun ObserveNIP19User(
|
||||
}
|
||||
|
||||
baseUser?.let { user ->
|
||||
val userState by user.live().metadata.observeAsState()
|
||||
ObserveUser(user, onRefresh)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = userState) {
|
||||
if (userState?.user?.info != null) {
|
||||
launch(Dispatchers.IO) {
|
||||
onRefresh()
|
||||
}
|
||||
@Composable
|
||||
private fun ObserveUser(user: User, onRefresh: () -> Unit) {
|
||||
val userState by user.live().metadata.observeAsState()
|
||||
|
||||
LaunchedEffect(key1 = userState) {
|
||||
if (userState?.user?.info != null) {
|
||||
launch(Dispatchers.IO) {
|
||||
onRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import coil.fetch.Fetcher
|
||||
import coil.fetch.SourceResult
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.Options
|
||||
import coil.size.Size
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import okio.Buffer
|
||||
import java.security.MessageDigest
|
||||
@@ -61,7 +60,6 @@ private fun svgString(msg: String): String {
|
||||
|
||||
class HashImageFetcher(
|
||||
private val context: Context,
|
||||
private val size: Size,
|
||||
private val data: Uri
|
||||
) : Fetcher {
|
||||
|
||||
@@ -81,17 +79,16 @@ class HashImageFetcher(
|
||||
|
||||
object Factory : Fetcher.Factory<Uri> {
|
||||
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
|
||||
return HashImageFetcher(options.context, options.size, data)
|
||||
return HashImageFetcher(options.context, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
object Robohash {
|
||||
fun imageRequest(context: Context, message: String, robotSize: Size): ImageRequest {
|
||||
fun imageRequest(context: Context, message: String): ImageRequest {
|
||||
return ImageRequest
|
||||
.Builder(context)
|
||||
.data("robohash:$message")
|
||||
.fetcherFactory(HashImageFetcher.Factory)
|
||||
.size(robotSize)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,10 @@ import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.size.Size
|
||||
import java.util.Date
|
||||
|
||||
@Composable
|
||||
@@ -33,18 +31,9 @@ fun RobohashAsyncImage(
|
||||
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val size = with(LocalDensity.current) {
|
||||
remember {
|
||||
robotSize.roundToPx()
|
||||
}
|
||||
}
|
||||
|
||||
val imageRequest = remember(robotSize, robot) {
|
||||
Robohash.imageRequest(
|
||||
context,
|
||||
robot,
|
||||
Size(size, size)
|
||||
)
|
||||
val imageRequest = remember(robot) {
|
||||
Robohash.imageRequest(context, robot)
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
@@ -92,15 +81,9 @@ fun RobohashFallbackAsyncImage(
|
||||
)
|
||||
} else {
|
||||
val context = LocalContext.current
|
||||
val painter = with(LocalDensity.current) {
|
||||
rememberAsyncImagePainter(
|
||||
model = Robohash.imageRequest(
|
||||
context,
|
||||
robot,
|
||||
Size(robotSize.roundToPx(), robotSize.roundToPx())
|
||||
)
|
||||
)
|
||||
}
|
||||
val painter = rememberAsyncImagePainter(
|
||||
model = Robohash.imageRequest(context, robot)
|
||||
)
|
||||
|
||||
AsyncImage(
|
||||
model = model,
|
||||
|
||||
@@ -8,56 +8,25 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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
|
||||
|
||||
@Composable
|
||||
fun UrlPreview(url: String, urlText: String) {
|
||||
val context = LocalContext.current
|
||||
|
||||
var urlPreviewState by remember(url) {
|
||||
val default = UrlCachedPreviewer.cache[url]?.let {
|
||||
if (it.allFetchComplete() && it.url == url) {
|
||||
UrlPreviewState.Loaded(it)
|
||||
} else {
|
||||
UrlPreviewState.Empty
|
||||
}
|
||||
} ?: UrlPreviewState.Loading
|
||||
|
||||
mutableStateOf(default)
|
||||
mutableStateOf(
|
||||
UrlCachedPreviewer.cache.get(url)?.let { it } ?: UrlPreviewState.Loading
|
||||
)
|
||||
}
|
||||
|
||||
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
|
||||
LaunchedEffect(url) {
|
||||
if (urlPreviewState == UrlPreviewState.Loading) {
|
||||
launch(Dispatchers.IO) {
|
||||
UrlCachedPreviewer.previewInfo(
|
||||
url,
|
||||
object : IUrlPreviewCallback {
|
||||
override fun onComplete(urlInfo: UrlInfoItem) {
|
||||
if (urlInfo.allFetchComplete() && urlInfo.url == url) {
|
||||
urlPreviewState = UrlPreviewState.Loaded(urlInfo)
|
||||
} else {
|
||||
urlPreviewState = UrlPreviewState.Empty
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailed(throwable: Throwable) {
|
||||
urlPreviewState = UrlPreviewState.Error(
|
||||
context.getString(
|
||||
R.string.error_parsing_preview_for,
|
||||
url,
|
||||
throwable.message
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
UrlCachedPreviewer.previewInfo(url) {
|
||||
urlPreviewState = it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,16 @@ import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
|
||||
|
||||
@Immutable
|
||||
sealed class UrlPreviewState {
|
||||
|
||||
@Immutable
|
||||
object Loading : UrlPreviewState()
|
||||
|
||||
@Immutable
|
||||
class Loaded(val previewInfo: UrlInfoItem) : UrlPreviewState()
|
||||
|
||||
@Immutable
|
||||
object Empty : UrlPreviewState()
|
||||
|
||||
@Immutable
|
||||
class Error(val errorMessage: String) : UrlPreviewState()
|
||||
}
|
||||
|
||||
@@ -52,10 +52,14 @@ import com.google.android.exoplayer2.C
|
||||
import com.google.android.exoplayer2.ExoPlayer
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSource
|
||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||
import com.google.android.exoplayer2.source.hls.HlsMediaSource
|
||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
|
||||
import com.google.android.exoplayer2.ui.StyledPlayerView
|
||||
import com.google.android.exoplayer2.upstream.DataSource
|
||||
import com.vitorpamplona.amethyst.VideoCache
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -101,6 +105,7 @@ fun VideoView(
|
||||
onDialog: ((Boolean) -> Unit)? = null
|
||||
) {
|
||||
var exoPlayerData by remember { mutableStateOf<VideoPlayer?>(null) }
|
||||
val defaultToStart by remember { mutableStateOf(DefaultMutedSetting.value) }
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(key1 = videoUri) {
|
||||
@@ -112,7 +117,7 @@ fun VideoView(
|
||||
}
|
||||
|
||||
exoPlayerData?.let {
|
||||
VideoView(videoUri, description, it, thumb, onDialog)
|
||||
VideoView(videoUri, description, it, defaultToStart, thumb, onDialog)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
@@ -127,6 +132,7 @@ fun VideoView(
|
||||
videoUri: String,
|
||||
description: String? = null,
|
||||
exoPlayerData: VideoPlayer,
|
||||
defaultToStart: Boolean = false,
|
||||
thumb: VideoThumb? = null,
|
||||
onDialog: ((Boolean) -> Unit)? = null
|
||||
) {
|
||||
@@ -137,9 +143,17 @@ fun VideoView(
|
||||
exoPlayerData.exoPlayer.apply {
|
||||
repeatMode = Player.REPEAT_MODE_ALL
|
||||
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
|
||||
volume = if (DefaultMutedSetting.value) 0f else 1f
|
||||
if (videoUri.startsWith("file") == true) {
|
||||
volume = if (defaultToStart) 0f else 1f
|
||||
if (videoUri.startsWith("file")) {
|
||||
setMediaItem(media)
|
||||
} else if (videoUri.endsWith("m3u8")) {
|
||||
// Should not use cache.
|
||||
val dataSourceFactory: DataSource.Factory = OkHttpDataSource.Factory(HttpClient.getHttpClient())
|
||||
setMediaSource(
|
||||
HlsMediaSource.Factory(dataSourceFactory).createMediaSource(
|
||||
media
|
||||
)
|
||||
)
|
||||
} else {
|
||||
setMediaSource(
|
||||
ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
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.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
|
||||
@Composable
|
||||
fun ZapRaiserRequest(
|
||||
titleText: String? = null,
|
||||
newPostViewModel: NewPostViewModel
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.lightning),
|
||||
null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
|
||||
Text(
|
||||
text = titleText ?: stringResource(R.string.zapraiser),
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier = Modifier.padding(start = 10.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.zapraiser_explainer),
|
||||
color = MaterialTheme.colors.placeholderText,
|
||||
modifier = Modifier.padding(vertical = 10.dp)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.zapraiser_target_amount_in_sats)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = if (newPostViewModel.zapRaiserAmount != null) {
|
||||
newPostViewModel.zapRaiserAmount.toString()
|
||||
} else {
|
||||
""
|
||||
},
|
||||
onValueChange = {
|
||||
runCatching {
|
||||
if (it.isEmpty()) {
|
||||
newPostViewModel.zapRaiserAmount = null
|
||||
} else {
|
||||
newPostViewModel.zapRaiserAmount = it.toLongOrNull()
|
||||
}
|
||||
}
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "1000",
|
||||
color = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Number
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,10 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
object BookmarkPrivateFeedFilter : FeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().latestBookmarkList?.id ?: ""
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val privKey = account.loggedIn.privKey ?: return emptyList()
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
object BookmarkPublicFeedFilter : FeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
|
||||
override fun feedKey(): String {
|
||||
return BookmarkPrivateFeedFilter.account.userProfile().latestBookmarkList?.id ?: ""
|
||||
}
|
||||
override fun feed(): List<Note> {
|
||||
val bookmarks = account.userProfile().latestBookmarkList
|
||||
|
||||
|
||||
@@ -5,6 +5,11 @@ import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
|
||||
class ChannelFeedFilter(val channel: Channel, val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
|
||||
override fun feedKey(): String {
|
||||
return channel.idHex
|
||||
}
|
||||
|
||||
// returns the last Note of each user.
|
||||
override fun feed(): List<Note> {
|
||||
return channel.notes
|
||||
|
||||
@@ -6,6 +6,10 @@ import com.vitorpamplona.amethyst.model.User
|
||||
|
||||
class ChatroomFeedFilter(val withUser: User, val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
// returns the last Note of each user.
|
||||
override fun feedKey(): String {
|
||||
return withUser.pubkeyHex
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val messages = account
|
||||
.userProfile()
|
||||
|
||||
@@ -11,6 +11,10 @@ import kotlin.time.measureTimedValue
|
||||
|
||||
class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex
|
||||
}
|
||||
|
||||
// returns the last Note of each user.
|
||||
override fun feed(): List<Note> {
|
||||
val me = account.userProfile()
|
||||
|
||||
@@ -10,6 +10,10 @@ import kotlin.time.measureTimedValue
|
||||
|
||||
class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex
|
||||
}
|
||||
|
||||
// returns the last Note of each user.
|
||||
override fun feed(): List<Note> {
|
||||
val me = account.userProfile()
|
||||
|
||||
@@ -18,6 +18,11 @@ abstract class FeedFilter<T> {
|
||||
return feed.take(1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string that serves as the key to invalidate the list if it changes.
|
||||
*/
|
||||
abstract fun feedKey(): String
|
||||
|
||||
abstract fun feed(): List<T>
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@ import com.vitorpamplona.amethyst.service.model.*
|
||||
|
||||
class GlobalFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val notes = innerApplyFilter(LocalCache.notes.values)
|
||||
val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
|
||||
|
||||
@@ -12,6 +12,10 @@ object HashtagFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
var tag: String? = null
|
||||
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex + "-" + tag
|
||||
}
|
||||
|
||||
fun loadHashtag(account: Account, tag: String?) {
|
||||
this.account = account
|
||||
this.tag = tag
|
||||
|
||||
@@ -5,6 +5,9 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
|
||||
class HiddenAccountsFeedFilter(val account: Account) : FeedFilter<User>() {
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex + "-" + HashtagFeedFilter.tag
|
||||
}
|
||||
|
||||
override fun feed(): List<User> {
|
||||
return (account.hiddenUsers + account.transientHiddenUsers)
|
||||
|
||||
@@ -3,10 +3,17 @@ package com.vitorpamplona.amethyst.ui.dal
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
|
||||
class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return sort(innerApplyFilter(LocalCache.notes.values))
|
||||
}
|
||||
@@ -22,7 +29,7 @@ class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter<Not
|
||||
return collection
|
||||
.asSequence()
|
||||
.filter {
|
||||
(it.event is TextNoteEvent || it.event is PollNoteEvent) &&
|
||||
(it.event is TextNoteEvent || it.event is PollNoteEvent || it.event is ChannelMessageEvent || it.event is LiveActivitiesChatMessageEvent) &&
|
||||
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
|
||||
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
|
||||
it.author?.let { !account.isHidden(it) } ?: true &&
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
|
||||
import okhttp3.Request
|
||||
import java.util.Date
|
||||
|
||||
class HomeLiveActivitiesFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
|
||||
|
||||
return sort(longFormNotes)
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
return innerApplyFilter(collection)
|
||||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
checkNotInMainThread()
|
||||
|
||||
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
|
||||
val fortyEightHrs = (Date().time / 1000) - 60 * 60 * 48 // hrs
|
||||
|
||||
return collection
|
||||
.asSequence()
|
||||
.filter { it ->
|
||||
val noteEvent = it.event
|
||||
(noteEvent is LiveActivitiesEvent && noteEvent.createdAt > fortyEightHrs && noteEvent.status() == "live" && checkIfOnline(noteEvent.streaming())) &&
|
||||
(it.author?.pubkeyHex in followingKeySet || (noteEvent.isTaggedHashes(followingTagSet))) &&
|
||||
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
|
||||
it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true
|
||||
}
|
||||
.toSet()
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
}
|
||||
}
|
||||
|
||||
fun checkIfOnline(url: String?): Boolean {
|
||||
if (url.isNullOrBlank()) return false
|
||||
|
||||
val request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
return try {
|
||||
HttpClient.getHttpClient().newCall(request).execute().code == 200
|
||||
} catch (e: Exception) {
|
||||
Log.e("LiveActivities", "Failed to check streaming url $url", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
|
||||
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.HighlightEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
@@ -11,6 +12,10 @@ import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
|
||||
class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val notes = innerApplyFilter(LocalCache.notes.values)
|
||||
val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
|
||||
@@ -26,14 +31,24 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
|
||||
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
|
||||
val oneHr = 60 * 60
|
||||
|
||||
return collection
|
||||
.asSequence()
|
||||
.filter { it ->
|
||||
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent || it.event is HighlightEvent || it.event is AudioTrackEvent) &&
|
||||
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
|
||||
val noteEvent = it.event
|
||||
(noteEvent is TextNoteEvent || noteEvent is RepostEvent || noteEvent is GenericRepostEvent || noteEvent is LongTextNoteEvent || noteEvent is PollNoteEvent || noteEvent is HighlightEvent || noteEvent is AudioTrackEvent) &&
|
||||
(it.author?.pubkeyHex in followingKeySet || (noteEvent.isTaggedHashes(followingTagSet))) &&
|
||||
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
|
||||
it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true &&
|
||||
it.isNewThread()
|
||||
it.isNewThread() &&
|
||||
(
|
||||
(noteEvent !is RepostEvent && noteEvent !is GenericRepostEvent) || // not a repost
|
||||
(
|
||||
it.replyTo?.lastOrNull()?.author?.pubkeyHex !in followingKeySet ||
|
||||
(noteEvent.createdAt() > (it.replyTo?.lastOrNull()?.createdAt() ?: 0) + oneHr)
|
||||
) // or a repost of by a non-follower's post (likely not seen yet)
|
||||
)
|
||||
}
|
||||
.toSet()
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
|
||||
class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex + "-" + account.defaultNotificationFollowList
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return sort(innerApplyFilter(LocalCache.notes.values))
|
||||
}
|
||||
@@ -61,7 +65,7 @@ class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
|
||||
return note.replyTo?.lastOrNull()?.author?.pubkeyHex == authorHex
|
||||
}
|
||||
|
||||
if (event is RepostEvent) {
|
||||
if (event is RepostEvent || event is GenericRepostEvent) {
|
||||
return note.replyTo?.lastOrNull()?.author?.pubkeyHex == authorHex
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ import com.vitorpamplona.amethyst.service.model.PeopleListEvent
|
||||
object PeopleListFeedFilter : FeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val privKey = account.loggedIn.privKey ?: return emptyList()
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@ import com.vitorpamplona.amethyst.model.ThreadAssembler
|
||||
@Immutable
|
||||
class ThreadFeedFilter(val noteId: String) : FeedFilter<Note>() {
|
||||
|
||||
override fun feedKey(): String {
|
||||
return noteId
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val cachedSignatures: MutableMap<Note, String> = mutableMapOf()
|
||||
val eventsToWatch = ThreadAssembler().findThreadFor(noteId) ?: emptySet()
|
||||
|
||||
+5
-1
@@ -6,13 +6,17 @@ import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.AppRecommendationEvent
|
||||
|
||||
class UserProfileAppRecommendationsFeedFilter(val user: User) : FeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
return user.pubkeyHex
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val recommendations = LocalCache.addressables.values.filter {
|
||||
(it.event as? AppRecommendationEvent)?.pubKey == user.pubkeyHex
|
||||
}.mapNotNull {
|
||||
(it.event as? AppRecommendationEvent)?.recommendations()
|
||||
}.flatten()
|
||||
.mapNotNull {
|
||||
.map {
|
||||
LocalCache.getOrCreateAddressableNote(it)
|
||||
}.toSet().toList()
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ object UserProfileBookmarksFeedFilter : FeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
var user: User? = null
|
||||
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex + "-" + user?.pubkeyHex
|
||||
}
|
||||
|
||||
fun loadUserProfile(accountLoggedIn: Account, user: User?) {
|
||||
account = accountLoggedIn
|
||||
this.user = user
|
||||
|
||||
+4
@@ -8,6 +8,10 @@ object UserProfileConversationsFeedFilter : FeedFilter<Note>() {
|
||||
var account: Account? = null
|
||||
var user: User? = null
|
||||
|
||||
override fun feedKey(): String {
|
||||
return account?.userProfile()?.pubkeyHex + "-" + user?.pubkeyHex
|
||||
}
|
||||
|
||||
fun loadUserProfile(accountLoggedIn: Account, user: User?) {
|
||||
account = accountLoggedIn
|
||||
this.user = user
|
||||
|
||||
@@ -6,6 +6,9 @@ import com.vitorpamplona.amethyst.model.User
|
||||
|
||||
class UserProfileFollowersFeedFilter(val user: User, val account: Account) : FeedFilter<User>() {
|
||||
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex + "-" + user.pubkeyHex
|
||||
}
|
||||
override fun feed(): List<User> {
|
||||
return LocalCache.users.values.filter { it.isFollowing(user) && !account.isHidden(it) }
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
|
||||
class UserProfileFollowsFeedFilter(val user: User, val account: Account) : FeedFilter<User>() {
|
||||
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile()?.pubkeyHex + "-" + user.pubkeyHex
|
||||
}
|
||||
|
||||
val cache: MutableMap<ContactListEvent, List<User>> = mutableMapOf()
|
||||
|
||||
override fun feed(): List<User> {
|
||||
|
||||
@@ -12,6 +12,10 @@ object UserProfileNewThreadFeedFilter : FeedFilter<Note>() {
|
||||
var account: Account? = null
|
||||
var user: User? = null
|
||||
|
||||
override fun feedKey(): String {
|
||||
return account?.userProfile()?.pubkeyHex + "-" + user?.pubkeyHex
|
||||
}
|
||||
|
||||
fun loadUserProfile(accountLoggedIn: Account, user: User) {
|
||||
account = accountLoggedIn
|
||||
this.user = user
|
||||
|
||||
@@ -8,6 +8,10 @@ import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
object UserProfileReportsFeedFilter : FeedFilter<Note>() {
|
||||
var user: User? = null
|
||||
|
||||
override fun feedKey(): String {
|
||||
return user?.pubkeyHex ?: ""
|
||||
}
|
||||
|
||||
fun loadUserProfile(user: User?) {
|
||||
this.user = user
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ import com.vitorpamplona.amethyst.service.model.zaps.UserZaps
|
||||
import com.vitorpamplona.amethyst.ui.screen.ZapReqResponse
|
||||
|
||||
class UserProfileZapsFeedFilter(val user: User) : FeedFilter<ZapReqResponse>() {
|
||||
|
||||
override fun feedKey(): String {
|
||||
return user.pubkeyHex
|
||||
}
|
||||
|
||||
override fun feed(): List<ZapReqResponse> {
|
||||
return UserZaps.forProfileFeed(user.zaps)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
|
||||
class VideoFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex + "-" + account.defaultStoriesFollowList
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val notes = innerApplyFilter(LocalCache.notes.values)
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -102,7 +101,7 @@ private fun RenderBottomMenu(
|
||||
navEntryState: State<NavBackStackEntry?>,
|
||||
nav: (Route, Boolean) -> Unit
|
||||
) {
|
||||
Column() {
|
||||
Column(modifier = Modifier.height(50.dp)) {
|
||||
Divider(
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
@@ -135,10 +134,17 @@ private fun RowScope.HasNewItemsIcon(
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val size = remember {
|
||||
if ("Home" == route.base) 25.dp else 23.dp
|
||||
}
|
||||
val iconSize = remember {
|
||||
if ("Home" == route.base) 24.dp else 20.dp
|
||||
}
|
||||
|
||||
BottomIcon(
|
||||
icon = route.icon,
|
||||
size = if ("Home" == route.base) 25.dp else 23.dp,
|
||||
iconSize = if ("Home" == route.base) 24.dp else 20.dp,
|
||||
size = size,
|
||||
iconSize = iconSize,
|
||||
base = route.base,
|
||||
hasNewItems = hasNewItems,
|
||||
navEntryState = navEntryState
|
||||
@@ -158,12 +164,11 @@ fun WatchPossibleNotificationChanges(
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = remember(accountState) { accountState?.account } ?: return
|
||||
|
||||
val notifState by NotificationCache.live.observeAsState()
|
||||
val notif = remember(notifState) { notifState?.cache } ?: return
|
||||
val notifState by accountViewModel.accountLastReadLiveData.observeAsState()
|
||||
|
||||
LaunchedEffect(key1 = notifState, key2 = accountState) {
|
||||
launch(Dispatchers.IO) {
|
||||
onChange(route.hasNewItems(account, notif, emptySet()))
|
||||
onChange(route.hasNewItems(account, emptySet()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +176,7 @@ fun WatchPossibleNotificationChanges(
|
||||
launch(Dispatchers.IO) {
|
||||
LocalCache.live.newEventBundles.collect {
|
||||
launch(Dispatchers.IO) {
|
||||
onChange(route.hasNewItems(account, notif, it))
|
||||
onChange(route.hasNewItems(account, it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedLiveActivitiesViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
|
||||
@@ -38,6 +39,7 @@ import kotlinx.coroutines.launch
|
||||
fun AppNavigation(
|
||||
homeFeedViewModel: NostrHomeFeedViewModel,
|
||||
repliesFeedViewModel: NostrHomeRepliesFeedViewModel,
|
||||
liveActivitiesViewModel: NostrHomeFeedLiveActivitiesViewModel,
|
||||
knownFeedViewModel: NostrChatroomListKnownFeedViewModel,
|
||||
newFeedViewModel: NostrChatroomListNewFeedViewModel,
|
||||
searchFeedViewModel: NostrGlobalFeedViewModel,
|
||||
@@ -60,54 +62,14 @@ fun AppNavigation(
|
||||
}
|
||||
|
||||
NavHost(navController, startDestination = Route.Home.route) {
|
||||
Route.Video.let { route ->
|
||||
composable(route.route, route.arguments, content = {
|
||||
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
|
||||
|
||||
if (scrollToTop) {
|
||||
videoFeedViewModel.sendToTop()
|
||||
it.arguments?.remove("scrollToTop")
|
||||
}
|
||||
|
||||
VideoScreen(
|
||||
videoFeedView = videoFeedViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
Route.Search.let { route ->
|
||||
composable(route.route, route.arguments, content = {
|
||||
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
|
||||
|
||||
if (scrollToTop) {
|
||||
searchFeedViewModel.sendToTop()
|
||||
it.arguments?.remove("scrollToTop")
|
||||
}
|
||||
|
||||
SearchScreen(
|
||||
searchFeedViewModel = searchFeedViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
Route.Home.let { route ->
|
||||
composable(route.route, route.arguments, content = { it ->
|
||||
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
|
||||
val nip47 = it.arguments?.getString("nip47")
|
||||
|
||||
if (scrollToTop) {
|
||||
homeFeedViewModel.sendToTop()
|
||||
repliesFeedViewModel.sendToTop()
|
||||
it.arguments?.remove("scrollToTop")
|
||||
}
|
||||
|
||||
HomeScreen(
|
||||
homeFeedViewModel = homeFeedViewModel,
|
||||
repliesFeedViewModel = repliesFeedViewModel,
|
||||
liveActivitiesViewModel = liveActivitiesViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
nip47 = nip47
|
||||
@@ -124,25 +86,6 @@ fun AppNavigation(
|
||||
})
|
||||
}
|
||||
|
||||
Route.Notification.let { route ->
|
||||
composable(route.route, route.arguments, content = {
|
||||
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
|
||||
|
||||
if (scrollToTop) {
|
||||
notifFeedViewModel.clear()
|
||||
notifFeedViewModel.invalidateDataAndSendToTop()
|
||||
it.arguments?.remove("scrollToTop")
|
||||
}
|
||||
|
||||
NotificationScreen(
|
||||
notifFeedViewModel = notifFeedViewModel,
|
||||
userReactionsStatsModel = userReactionsStatsModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
composable(
|
||||
Route.Message.route,
|
||||
content = {
|
||||
@@ -155,6 +98,37 @@ fun AppNavigation(
|
||||
}
|
||||
)
|
||||
|
||||
Route.Video.let { route ->
|
||||
composable(route.route, route.arguments, content = {
|
||||
VideoScreen(
|
||||
videoFeedView = videoFeedViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
Route.Search.let { route ->
|
||||
composable(route.route, route.arguments, content = {
|
||||
SearchScreen(
|
||||
searchFeedViewModel = searchFeedViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
Route.Notification.let { route ->
|
||||
composable(route.route, route.arguments, content = {
|
||||
NotificationScreen(
|
||||
notifFeedViewModel = notifFeedViewModel,
|
||||
userReactionsStatsModel = userReactionsStatsModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
composable(Route.BlockedUsers.route, content = { HiddenUsersScreen(accountViewModel, nav) })
|
||||
composable(Route.Bookmarks.route, content = { BookmarkListScreen(accountViewModel, nav) })
|
||||
|
||||
|
||||
@@ -115,6 +115,8 @@ private fun RenderTopRouteBar(
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
when (currentRoute) {
|
||||
Route.Channel.base -> NoTopBar()
|
||||
Route.Room.base -> NoTopBar()
|
||||
// Route.Profile.route -> TopBarWithBackButton(nav)
|
||||
Route.Home.base -> HomeTopBar(followLists, scaffoldState, accountViewModel)
|
||||
Route.Video.base -> StoriesTopBar(followLists, scaffoldState, accountViewModel)
|
||||
@@ -123,6 +125,10 @@ private fun RenderTopRouteBar(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NoTopBar() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StoriesTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
GenericTopBar(scaffoldState, accountViewModel) { accountViewModel ->
|
||||
@@ -206,7 +212,7 @@ fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewMod
|
||||
NewRelayListView({ wantsToEditRelays = false }, accountViewModel)
|
||||
}
|
||||
|
||||
Column() {
|
||||
Column(modifier = Modifier.height(50.dp)) {
|
||||
TopAppBar(
|
||||
elevation = 0.dp,
|
||||
backgroundColor = MaterialTheme.colors.surface,
|
||||
|
||||
@@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -229,38 +230,57 @@ fun ProfileContent(
|
||||
|
||||
@Composable
|
||||
private fun FollowingAndFollowerCounts(baseAccountUser: User) {
|
||||
val accountUserFollowsState by baseAccountUser.live().follows.observeAsState()
|
||||
|
||||
var followingCount by remember { mutableStateOf("--") }
|
||||
var followerCount by remember { mutableStateOf("--") }
|
||||
|
||||
LaunchedEffect(key1 = accountUserFollowsState) {
|
||||
launch(Dispatchers.IO) {
|
||||
val newFollowing = accountUserFollowsState?.user?.cachedFollowCount()?.toString() ?: "--"
|
||||
val newFollower = accountUserFollowsState?.user?.cachedFollowerCount()?.toString() ?: "--"
|
||||
|
||||
if (followingCount != newFollowing) {
|
||||
followingCount = newFollowing
|
||||
}
|
||||
if (followerCount != newFollower) {
|
||||
followerCount = newFollower
|
||||
}
|
||||
WatchFollow(baseAccountUser = baseAccountUser) { newFollowing ->
|
||||
if (followingCount != newFollowing) {
|
||||
followingCount = newFollowing
|
||||
}
|
||||
}
|
||||
|
||||
Row() {
|
||||
Text(
|
||||
text = followingCount,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(stringResource(R.string.following))
|
||||
WatchFollower(baseAccountUser = baseAccountUser) { newFollower ->
|
||||
if (followerCount != newFollower) {
|
||||
followerCount = newFollower
|
||||
}
|
||||
}
|
||||
Row(modifier = Modifier.padding(start = 10.dp)) {
|
||||
Text(
|
||||
text = followerCount,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(stringResource(R.string.followers))
|
||||
|
||||
Text(
|
||||
text = followingCount,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Text(stringResource(R.string.following))
|
||||
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
|
||||
Text(
|
||||
text = followerCount,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Text(stringResource(R.string.followers))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WatchFollow(baseAccountUser: User, onReady: (String) -> Unit) {
|
||||
val accountUserFollowsState by baseAccountUser.live().follows.observeAsState()
|
||||
|
||||
LaunchedEffect(key1 = accountUserFollowsState) {
|
||||
launch(Dispatchers.IO) {
|
||||
onReady(accountUserFollowsState?.user?.cachedFollowCount()?.toString() ?: "--")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WatchFollower(baseAccountUser: User, onReady: (String) -> Unit) {
|
||||
val accountUserFollowersState by baseAccountUser.live().followers.observeAsState()
|
||||
|
||||
LaunchedEffect(key1 = accountUserFollowersState) {
|
||||
launch(Dispatchers.IO) {
|
||||
onReady(accountUserFollowersState?.user?.cachedFollowerCount()?.toString() ?: "--")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.navArgument
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
@@ -27,45 +26,41 @@ import kotlinx.collections.immutable.toImmutableList
|
||||
sealed class Route(
|
||||
val route: String,
|
||||
val icon: Int,
|
||||
val hasNewItems: (Account, NotificationCache, Set<com.vitorpamplona.amethyst.model.Note>) -> Boolean = { _, _, _ -> false },
|
||||
val hasNewItems: (Account, Set<com.vitorpamplona.amethyst.model.Note>) -> Boolean = { _, _ -> false },
|
||||
val arguments: ImmutableList<NamedNavArgument> = persistentListOf()
|
||||
) {
|
||||
val base: String
|
||||
get() = route.substringBefore("?")
|
||||
|
||||
object Home : Route(
|
||||
route = "Home?scrollToTop={scrollToTop}&nip47={nip47}",
|
||||
route = "Home?nip47={nip47}",
|
||||
icon = R.drawable.ic_home,
|
||||
arguments = listOf(
|
||||
navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false },
|
||||
navArgument("nip47") { type = NavType.StringType; nullable = true; defaultValue = null }
|
||||
).toImmutableList(),
|
||||
hasNewItems = { accountViewModel, cache, newNotes -> HomeLatestItem.hasNewItems(accountViewModel, cache, newNotes) }
|
||||
hasNewItems = { accountViewModel, newNotes -> HomeLatestItem.hasNewItems(accountViewModel, newNotes) }
|
||||
)
|
||||
|
||||
object Search : Route(
|
||||
route = "Search?scrollToTop={scrollToTop}",
|
||||
icon = R.drawable.ic_globe,
|
||||
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }).toImmutableList()
|
||||
route = "Search",
|
||||
icon = R.drawable.ic_globe
|
||||
)
|
||||
|
||||
object Video : Route(
|
||||
route = "Video?scrollToTop={scrollToTop}",
|
||||
icon = R.drawable.ic_video,
|
||||
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }).toImmutableList()
|
||||
route = "Video",
|
||||
icon = R.drawable.ic_video
|
||||
)
|
||||
|
||||
object Notification : Route(
|
||||
route = "Notification?scrollToTop={scrollToTop}",
|
||||
route = "Notification",
|
||||
icon = R.drawable.ic_notifications,
|
||||
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }).toImmutableList(),
|
||||
hasNewItems = { accountViewModel, cache, newNotes -> NotificationLatestItem.hasNewItems(accountViewModel, cache, newNotes) }
|
||||
hasNewItems = { accountViewModel, newNotes -> NotificationLatestItem.hasNewItems(accountViewModel, newNotes) }
|
||||
)
|
||||
|
||||
object Message : Route(
|
||||
route = "Message",
|
||||
icon = R.drawable.ic_dm,
|
||||
hasNewItems = { accountViewModel, cache, newNotes -> MessagesLatestItem.hasNewItems(accountViewModel, cache, newNotes) }
|
||||
hasNewItems = { accountViewModel, newNotes -> MessagesLatestItem.hasNewItems(accountViewModel, newNotes) }
|
||||
)
|
||||
|
||||
object BlockedUsers : Route(
|
||||
@@ -127,29 +122,40 @@ fun currentRoute(navController: NavHostController): String? {
|
||||
open class LatestItem {
|
||||
var newestItemPerAccount: Map<String, Note?> = mapOf()
|
||||
|
||||
fun getNewestItem(account: Account): Note? {
|
||||
return newestItemPerAccount[account.userProfile().pubkeyHex]
|
||||
}
|
||||
|
||||
fun clearNewestItem(account: Account) {
|
||||
val userHex = account.userProfile().pubkeyHex
|
||||
if (newestItemPerAccount.contains(userHex)) {
|
||||
newestItemPerAccount = newestItemPerAccount - userHex
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNewestItem(newNotes: Set<Note>, account: Account, filter: AdditiveFeedFilter<Note>): Note? {
|
||||
val newestItem = newestItemPerAccount[account.userProfile().pubkeyHex]
|
||||
|
||||
if (newestItem == null) {
|
||||
newestItemPerAccount = newestItemPerAccount + Pair(
|
||||
account.userProfile().pubkeyHex,
|
||||
filterMore(filter.feed()).firstOrNull { it.createdAt() != null }
|
||||
filterMore(filter.feed(), account).firstOrNull { it.createdAt() != null }
|
||||
)
|
||||
} else {
|
||||
newestItemPerAccount = newestItemPerAccount + Pair(
|
||||
account.userProfile().pubkeyHex,
|
||||
filter.sort(filterMore(filter.applyFilter(newNotes)) + newestItem).first()
|
||||
filter.sort(filterMore(filter.applyFilter(newNotes), account) + newestItem).first()
|
||||
)
|
||||
}
|
||||
|
||||
return newestItemPerAccount[account.userProfile().pubkeyHex]
|
||||
}
|
||||
|
||||
open fun filterMore(newItems: Set<Note>): Set<Note> {
|
||||
open fun filterMore(newItems: Set<Note>, account: Account): Set<Note> {
|
||||
return newItems
|
||||
}
|
||||
|
||||
open fun filterMore(newItems: List<Note>): List<Note> {
|
||||
open fun filterMore(newItems: List<Note>, account: Account): List<Note> {
|
||||
return newItems
|
||||
}
|
||||
}
|
||||
@@ -157,10 +163,9 @@ open class LatestItem {
|
||||
object HomeLatestItem : LatestItem() {
|
||||
fun hasNewItems(
|
||||
account: Account,
|
||||
cache: NotificationCache,
|
||||
newNotes: Set<Note>
|
||||
): Boolean {
|
||||
val lastTime = cache.load("HomeFollows")
|
||||
val lastTime = account.loadLastRead("HomeFollows")
|
||||
|
||||
val newestItem = updateNewestItem(newNotes, account, HomeNewThreadFeedFilter(account))
|
||||
|
||||
@@ -171,10 +176,9 @@ object HomeLatestItem : LatestItem() {
|
||||
object NotificationLatestItem : LatestItem() {
|
||||
fun hasNewItems(
|
||||
account: Account,
|
||||
cache: NotificationCache,
|
||||
newNotes: Set<Note>
|
||||
): Boolean {
|
||||
val lastTime = cache.load("Notification")
|
||||
val lastTime = account.loadLastRead("Notification")
|
||||
|
||||
val newestItem = updateNewestItem(newNotes, account, NotificationFeedFilter(account))
|
||||
|
||||
@@ -185,24 +189,45 @@ object NotificationLatestItem : LatestItem() {
|
||||
object MessagesLatestItem : LatestItem() {
|
||||
fun hasNewItems(
|
||||
account: Account,
|
||||
cache: NotificationCache,
|
||||
newNotes: Set<Note>
|
||||
): Boolean {
|
||||
// Checks if the current newest item is still unread.
|
||||
// If so, there is no need to check anything else
|
||||
if (isNew(getNewestItem(account), account)) {
|
||||
return true
|
||||
}
|
||||
|
||||
clearNewestItem(account)
|
||||
|
||||
// gets the newest of the unread items
|
||||
val newestItem = updateNewestItem(newNotes, account, ChatroomListKnownFeedFilter(account))
|
||||
|
||||
val roomUserHex = (newestItem?.event as? PrivateDmEvent)?.talkingWith(account.userProfile().pubkeyHex)
|
||||
|
||||
val lastTime = cache.load("Room/$roomUserHex")
|
||||
|
||||
return (newestItem?.createdAt() ?: 0) > lastTime
|
||||
return isNew(newestItem, account)
|
||||
}
|
||||
|
||||
override fun filterMore(newItems: Set<Note>): Set<Note> {
|
||||
return newItems.filter { it.event is PrivateDmEvent }.toSet()
|
||||
fun isNew(it: Note?, account: Account): Boolean {
|
||||
if (it == null) return false
|
||||
|
||||
val currentUser = account.userProfile().pubkeyHex
|
||||
val room = (it.event as? PrivateDmEvent)?.talkingWith(currentUser)
|
||||
return if (room != null) {
|
||||
val lastRead = account.loadLastRead("Room/$room")
|
||||
(it.createdAt() ?: 0) > lastRead
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun filterMore(newItems: List<Note>): List<Note> {
|
||||
return newItems.filter { it.event is PrivateDmEvent }
|
||||
override fun filterMore(newItems: Set<Note>, account: Account): Set<Note> {
|
||||
return newItems.filter {
|
||||
isNew(it, account)
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
override fun filterMore(newItems: List<Note>, account: Account): List<Note> {
|
||||
return newItems.filter {
|
||||
isNew(it, account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,10 @@ class AddBountyAmountViewModel : ViewModel() {
|
||||
replyTo = listOfNotNull(bounty),
|
||||
mentions = listOfNotNull(bounty?.author),
|
||||
tags = listOf("bounty-added-reward"),
|
||||
wantsToMarkAsSensitive = false
|
||||
wantsToMarkAsSensitive = false,
|
||||
replyingTo = null,
|
||||
root = null,
|
||||
directMentions = setOf()
|
||||
)
|
||||
|
||||
nextAmount = TextFieldValue("")
|
||||
|
||||
@@ -33,7 +33,6 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.screen.BadgeCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -62,9 +61,9 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL
|
||||
|
||||
LaunchedEffect(key1 = likeSetCard) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val isNew = likeSetCard.createdAt() > NotificationCache.load(routeForLastRead)
|
||||
val isNew = likeSetCard.createdAt() > accountViewModel.account.loadLastRead(routeForLastRead)
|
||||
|
||||
NotificationCache.markAsRead(routeForLastRead, likeSetCard.createdAt())
|
||||
accountViewModel.account.markAsRead(routeForLastRead, likeSetCard.createdAt())
|
||||
|
||||
val newBackgroundColor = if (isNew) {
|
||||
newItemColor.compositeOver(defaultBackgroundColor)
|
||||
|
||||
@@ -22,6 +22,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
|
||||
@Composable
|
||||
@@ -88,7 +89,7 @@ fun HiddenNote(
|
||||
baseNote = it,
|
||||
nav = nav,
|
||||
accountViewModel = accountViewModel,
|
||||
size = 35.dp
|
||||
size = Size35dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
@@ -74,7 +73,7 @@ fun ChatroomCompose(
|
||||
BlankNote(Modifier)
|
||||
} else if (channelHex != null) {
|
||||
LoadChannel(baseChannelHex = channelHex!!) { channel ->
|
||||
ChannelRoomCompose(note, channel, nav)
|
||||
ChannelRoomCompose(note, channel, accountViewModel, nav)
|
||||
}
|
||||
} else {
|
||||
val userRoomHex = remember(noteState, accountViewModel) {
|
||||
@@ -91,6 +90,7 @@ fun ChatroomCompose(
|
||||
private fun ChannelRoomCompose(
|
||||
note: Note,
|
||||
channel: Channel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val authorState by note.author!!.live().metadata.observeAsState()
|
||||
@@ -108,7 +108,7 @@ private fun ChannelRoomCompose(
|
||||
}
|
||||
val channelName by remember(channelState) {
|
||||
derivedStateOf {
|
||||
channel.info.name
|
||||
channel.toBestDisplayName()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ private fun ChannelRoomCompose(
|
||||
|
||||
var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
|
||||
|
||||
WatchNotificationChanges(note, route) { newHasNewMessages ->
|
||||
WatchNotificationChanges(note, route, accountViewModel) { newHasNewMessages ->
|
||||
if (hasNewMessages != newHasNewMessages) {
|
||||
hasNewMessages = newHasNewMessages
|
||||
}
|
||||
@@ -182,7 +182,7 @@ private fun UserRoomCompose(
|
||||
"Room/${user.pubkeyHex}"
|
||||
}
|
||||
|
||||
WatchNotificationChanges(note, route) { newHasNewMessages ->
|
||||
WatchNotificationChanges(note, route, accountViewModel) { newHasNewMessages ->
|
||||
if (hasNewMessages != newHasNewMessages) {
|
||||
hasNewMessages = newHasNewMessages
|
||||
}
|
||||
@@ -208,14 +208,15 @@ private fun UserRoomCompose(
|
||||
private fun WatchNotificationChanges(
|
||||
note: Note,
|
||||
route: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
onNewStatus: (Boolean) -> Unit
|
||||
) {
|
||||
val cacheState by NotificationCache.live.observeAsState()
|
||||
val cacheState by accountViewModel.accountLastReadLiveData.observeAsState()
|
||||
|
||||
LaunchedEffect(key1 = note, cacheState) {
|
||||
launch(Dispatchers.IO) {
|
||||
note.event?.createdAt()?.let {
|
||||
val lastTime = NotificationCache.load(route)
|
||||
val lastTime = accountViewModel.account.loadLastRead(route)
|
||||
onNewStatus(it > lastTime)
|
||||
}
|
||||
}
|
||||
@@ -225,7 +226,7 @@ private fun WatchNotificationChanges(
|
||||
@Composable
|
||||
fun LoadUser(baseUserHex: String, content: @Composable (User) -> Unit) {
|
||||
var user by remember(baseUserHex) {
|
||||
mutableStateOf<User?>(null)
|
||||
mutableStateOf<User?>(LocalCache.getUserIfExists(baseUserHex))
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = baseUserHex) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -26,6 +25,7 @@ import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
@@ -33,7 +33,6 @@ 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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -51,12 +50,11 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
|
||||
@@ -69,13 +67,21 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeMe
|
||||
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeThem
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size13dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size16dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
@@ -88,243 +94,407 @@ fun ChatroomMessageCompose(
|
||||
nav: (String) -> Unit,
|
||||
onWantsToReply: (Note) -> Unit
|
||||
) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = remember(accountState) { accountState?.account } ?: return
|
||||
val loggedIn = remember(accountState) { accountState?.account?.userProfile() } ?: return
|
||||
|
||||
val noteState by baseNote.live().metadata.observeAsState()
|
||||
val note = remember(noteState) { noteState?.note } ?: return
|
||||
|
||||
val noteReportsState by baseNote.live().reports.observeAsState()
|
||||
val noteForReports = remember(noteReportsState) { noteReportsState?.note } ?: return
|
||||
|
||||
val noteEvent = remember(noteState) { note.event }
|
||||
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
val noteEvent = remember(noteState) { noteState?.note?.event }
|
||||
|
||||
if (noteEvent == null) {
|
||||
BlankNote(
|
||||
Modifier.combinedClickable(
|
||||
onClick = { },
|
||||
onLongClick = { popupExpanded = true }
|
||||
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
|
||||
BlankNote(
|
||||
remember {
|
||||
Modifier.combinedClickable(
|
||||
onClick = { },
|
||||
onLongClick = showPopup
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
CheckHiddenChatMessage(
|
||||
baseNote,
|
||||
routeForLastRead,
|
||||
innerQuote,
|
||||
parentBackgroundColor,
|
||||
accountViewModel,
|
||||
nav,
|
||||
onWantsToReply
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CheckHiddenChatMessage(
|
||||
baseNote: Note,
|
||||
routeForLastRead: String?,
|
||||
innerQuote: Boolean = false,
|
||||
parentBackgroundColor: MutableState<Color>? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
onWantsToReply: (Note) -> Unit
|
||||
) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
|
||||
val isHidden by remember(accountState) {
|
||||
derivedStateOf {
|
||||
val isSensitive = baseNote.event?.isSensitive() ?: false
|
||||
|
||||
accountState?.account?.isHidden(baseNote.author!!) == true || (isSensitive && accountState?.account?.showSensitiveContent == false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isHidden) {
|
||||
LoadedChatMessageCompose(
|
||||
baseNote,
|
||||
routeForLastRead,
|
||||
innerQuote,
|
||||
parentBackgroundColor,
|
||||
accountViewModel,
|
||||
nav,
|
||||
onWantsToReply
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadedChatMessageCompose(
|
||||
baseNote: Note,
|
||||
routeForLastRead: String?,
|
||||
innerQuote: Boolean = false,
|
||||
parentBackgroundColor: MutableState<Color>? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
onWantsToReply: (Note) -> Unit
|
||||
) {
|
||||
var state by remember {
|
||||
mutableStateOf(
|
||||
NoteComposeReportState(
|
||||
isAcceptable = true,
|
||||
canPreview = true,
|
||||
relevantReports = persistentSetOf()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
note.let {
|
||||
NoteQuickActionMenu(it, popupExpanded, { popupExpanded = false }, accountViewModel)
|
||||
WatchForReports(baseNote, accountViewModel) { newIsAcceptable, newCanPreview, newRelevantReports ->
|
||||
if (newIsAcceptable != state.isAcceptable || newCanPreview != state.canPreview) {
|
||||
state = NoteComposeReportState(newIsAcceptable, newCanPreview, newRelevantReports.toImmutableSet())
|
||||
}
|
||||
} else if (account.isHidden(noteForReports.author!!)) {
|
||||
// Does nothing
|
||||
}
|
||||
|
||||
var showReportedNote by remember { mutableStateOf(false) }
|
||||
|
||||
val showHiddenNote by remember(state, showReportedNote) {
|
||||
derivedStateOf {
|
||||
!state.isAcceptable && !showReportedNote
|
||||
}
|
||||
}
|
||||
|
||||
if (showHiddenNote) {
|
||||
HiddenNote(
|
||||
state.relevantReports,
|
||||
accountViewModel,
|
||||
Modifier,
|
||||
innerQuote,
|
||||
nav,
|
||||
onClick = { showReportedNote = true }
|
||||
)
|
||||
} else {
|
||||
var showHiddenNote by remember { mutableStateOf(false) }
|
||||
var isAcceptableAndCanPreview by remember { mutableStateOf(Pair(true, true)) }
|
||||
|
||||
LaunchedEffect(key1 = noteReportsState, key2 = accountState) {
|
||||
launch(Dispatchers.Default) {
|
||||
account.userProfile().let { loggedIn ->
|
||||
val newCanPreview = note.author?.pubkeyHex == loggedIn.pubkeyHex ||
|
||||
(note.author?.let { loggedIn.isFollowingCached(it) } ?: true) ||
|
||||
!(noteForReports.hasAnyReports())
|
||||
|
||||
val newIsAcceptable = account.isAcceptable(noteForReports)
|
||||
|
||||
if (newIsAcceptable != isAcceptableAndCanPreview.first && newCanPreview != isAcceptableAndCanPreview.second) {
|
||||
isAcceptableAndCanPreview = Pair(newIsAcceptable, newCanPreview)
|
||||
}
|
||||
}
|
||||
val canPreview by remember(state, showReportedNote) {
|
||||
derivedStateOf {
|
||||
(!state.isAcceptable && showReportedNote) || state.canPreview
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAcceptableAndCanPreview.first && !showHiddenNote) {
|
||||
val reports = remember {
|
||||
account.getRelevantReports(noteForReports).toImmutableSet()
|
||||
}
|
||||
HiddenNote(
|
||||
reports,
|
||||
accountViewModel,
|
||||
Modifier,
|
||||
innerQuote,
|
||||
nav,
|
||||
onClick = { showHiddenNote = true }
|
||||
)
|
||||
NormalChatNote(
|
||||
baseNote,
|
||||
routeForLastRead,
|
||||
innerQuote,
|
||||
canPreview,
|
||||
parentBackgroundColor,
|
||||
accountViewModel,
|
||||
nav,
|
||||
onWantsToReply
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun NormalChatNote(
|
||||
note: Note,
|
||||
routeForLastRead: String?,
|
||||
innerQuote: Boolean = false,
|
||||
canPreview: Boolean = true,
|
||||
parentBackgroundColor: MutableState<Color>? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
onWantsToReply: (Note) -> Unit
|
||||
) {
|
||||
val drawAuthorInfo by remember {
|
||||
derivedStateOf {
|
||||
(innerQuote || !accountViewModel.isLoggedUser(note.author)) && note.event !is PrivateDmEvent
|
||||
}
|
||||
}
|
||||
|
||||
val loggedInColors = MaterialTheme.colors.mediumImportanceLink
|
||||
val otherColors = MaterialTheme.colors.subtleBorder
|
||||
val defaultBackground = MaterialTheme.colors.background
|
||||
|
||||
val backgroundBubbleColor = remember {
|
||||
if (accountViewModel.isLoggedUser(note.author)) {
|
||||
mutableStateOf(loggedInColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground))
|
||||
} else {
|
||||
val loggedInColors = MaterialTheme.colors.mediumImportanceLink
|
||||
val otherColors = MaterialTheme.colors.subtleBorder
|
||||
val defaultBackground = MaterialTheme.colors.background
|
||||
mutableStateOf(otherColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground))
|
||||
}
|
||||
}
|
||||
val alignment: Arrangement.Horizontal = remember {
|
||||
if (accountViewModel.isLoggedUser(note.author)) {
|
||||
Arrangement.End
|
||||
} else {
|
||||
Arrangement.Start
|
||||
}
|
||||
}
|
||||
val shape: Shape = remember {
|
||||
if (accountViewModel.isLoggedUser(note.author)) {
|
||||
ChatBubbleShapeMe
|
||||
} else {
|
||||
ChatBubbleShapeThem
|
||||
}
|
||||
}
|
||||
|
||||
val backgroundBubbleColor = remember {
|
||||
if (note.author == loggedIn) {
|
||||
mutableStateOf(loggedInColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground))
|
||||
} else {
|
||||
mutableStateOf(otherColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground))
|
||||
}
|
||||
LaunchedEffect(key1 = routeForLastRead) {
|
||||
routeForLastRead?.let {
|
||||
val createdAt = note.createdAt()
|
||||
if (createdAt != null) {
|
||||
accountViewModel.account.markAsRead(it, createdAt)
|
||||
}
|
||||
val alignment: Arrangement.Horizontal = remember {
|
||||
if (note.author == loggedIn) {
|
||||
Arrangement.End
|
||||
} else {
|
||||
Arrangement.Start
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column() {
|
||||
val modif = remember {
|
||||
if (innerQuote) {
|
||||
Modifier.padding(top = 10.dp, end = 5.dp)
|
||||
} else {
|
||||
Modifier
|
||||
.fillMaxWidth(1f)
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 5.dp,
|
||||
bottom = 5.dp
|
||||
)
|
||||
}
|
||||
val shape: Shape = remember {
|
||||
if (note.author == loggedIn) {
|
||||
ChatBubbleShapeMe
|
||||
} else {
|
||||
ChatBubbleShapeThem
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modif,
|
||||
horizontalArrangement = alignment
|
||||
) {
|
||||
val availableBubbleSize = remember { mutableStateOf(IntSize.Zero) }
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val modif2 = remember {
|
||||
if (innerQuote) Modifier else Modifier.fillMaxWidth(0.85f)
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(key1 = routeForLastRead) {
|
||||
routeForLastRead?.let {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val lastTime = NotificationCache.load(it)
|
||||
|
||||
val createdAt = note.createdAt()
|
||||
if (createdAt != null) {
|
||||
NotificationCache.markAsRead(it, createdAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column() {
|
||||
val modif = remember {
|
||||
if (innerQuote) {
|
||||
Modifier.padding(top = 10.dp, end = 5.dp)
|
||||
} else {
|
||||
Modifier
|
||||
.fillMaxWidth(1f)
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 5.dp,
|
||||
bottom = 5.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modif,
|
||||
horizontalArrangement = alignment
|
||||
) {
|
||||
var availableBubbleSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
val modif2 = if (innerQuote) Modifier else Modifier.fillMaxWidth(0.85f)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = alignment,
|
||||
modifier = modif2.onSizeChanged {
|
||||
availableBubbleSize = it
|
||||
}
|
||||
) {
|
||||
Surface(
|
||||
color = backgroundBubbleColor.value,
|
||||
shape = shape,
|
||||
modifier = Modifier
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
if (noteEvent is ChannelCreateEvent) {
|
||||
nav("Channel/${note.idHex}")
|
||||
}
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
) {
|
||||
var bubbleSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(start = 10.dp, end = 5.dp, bottom = 5.dp)
|
||||
.onSizeChanged {
|
||||
bubbleSize = it
|
||||
}
|
||||
) {
|
||||
if ((innerQuote || note.author != loggedIn) && noteEvent is ChannelMessageEvent) {
|
||||
DrawAuthorInfo(
|
||||
baseNote,
|
||||
alignment,
|
||||
nav
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(5.dp))
|
||||
}
|
||||
|
||||
val replyTo = note.replyTo
|
||||
if (!innerQuote && !replyTo.isNullOrEmpty()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
replyTo.lastOrNull()?.let { note ->
|
||||
ChatroomMessageCompose(
|
||||
note,
|
||||
null,
|
||||
innerQuote = true,
|
||||
parentBackgroundColor = backgroundBubbleColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onWantsToReply = onWantsToReply
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
when (noteEvent) {
|
||||
is ChannelCreateEvent -> {
|
||||
RenderCreateChannelNote(note)
|
||||
}
|
||||
|
||||
is ChannelMetadataEvent -> {
|
||||
RenderChangeChannelMetadataNote(note)
|
||||
}
|
||||
|
||||
else -> {
|
||||
RenderRegularTextNote(
|
||||
note,
|
||||
isAcceptableAndCanPreview.second,
|
||||
backgroundBubbleColor,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.padding(top = 5.dp)
|
||||
.then(
|
||||
with(LocalDensity.current) {
|
||||
Modifier.widthIn(
|
||||
bubbleSize.width.toDp(),
|
||||
availableBubbleSize.width.toDp()
|
||||
)
|
||||
}
|
||||
)
|
||||
) {
|
||||
StatusRow(
|
||||
baseNote,
|
||||
accountViewModel,
|
||||
onWantsToReply
|
||||
)
|
||||
}
|
||||
val clickableModifier = remember {
|
||||
Modifier
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
if (note.event is ChannelCreateEvent) {
|
||||
nav("Channel/${note.idHex}")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
}
|
||||
|
||||
NoteQuickActionMenu(
|
||||
Row(
|
||||
horizontalArrangement = alignment,
|
||||
modifier = modif2.onSizeChanged {
|
||||
availableBubbleSize.value = it
|
||||
}
|
||||
) {
|
||||
Surface(
|
||||
color = backgroundBubbleColor.value,
|
||||
shape = shape,
|
||||
modifier = clickableModifier
|
||||
) {
|
||||
RenderBubble(
|
||||
note,
|
||||
popupExpanded,
|
||||
{ popupExpanded = false },
|
||||
accountViewModel
|
||||
drawAuthorInfo,
|
||||
alignment,
|
||||
innerQuote,
|
||||
backgroundBubbleColor,
|
||||
onWantsToReply,
|
||||
canPreview,
|
||||
availableBubbleSize,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
NoteQuickActionMenu(
|
||||
note = note,
|
||||
popupExpanded = popupExpanded,
|
||||
onDismiss = { popupExpanded = false },
|
||||
accountViewModel = accountViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderBubble(
|
||||
baseNote: Note,
|
||||
drawAuthorInfo: Boolean,
|
||||
alignment: Arrangement.Horizontal,
|
||||
innerQuote: Boolean,
|
||||
backgroundBubbleColor: MutableState<Color>,
|
||||
onWantsToReply: (Note) -> Unit,
|
||||
canPreview: Boolean,
|
||||
availableBubbleSize: MutableState<IntSize>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val bubbleSize = remember { mutableStateOf(IntSize.Zero) }
|
||||
|
||||
val bubbleModifier = remember {
|
||||
Modifier
|
||||
.padding(start = 10.dp, end = 5.dp, bottom = 5.dp)
|
||||
.onSizeChanged {
|
||||
bubbleSize.value = it
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = bubbleModifier) {
|
||||
if (drawAuthorInfo) {
|
||||
DrawAuthorInfo(
|
||||
baseNote,
|
||||
alignment,
|
||||
nav
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
}
|
||||
|
||||
RenderReplyRow(
|
||||
note = baseNote,
|
||||
innerQuote = innerQuote,
|
||||
backgroundBubbleColor = backgroundBubbleColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onWantsToReply = onWantsToReply
|
||||
)
|
||||
|
||||
NoteRow(
|
||||
note = baseNote,
|
||||
canPreview = canPreview,
|
||||
backgroundBubbleColor = backgroundBubbleColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
|
||||
ConstrainedStatusRow(
|
||||
bubbleSize = bubbleSize,
|
||||
availableBubbleSize = availableBubbleSize
|
||||
) {
|
||||
StatusRow(
|
||||
baseNote = baseNote,
|
||||
accountViewModel = accountViewModel,
|
||||
onWantsToReply = onWantsToReply
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderReplyRow(
|
||||
note: Note,
|
||||
innerQuote: Boolean,
|
||||
backgroundBubbleColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
onWantsToReply: (Note) -> Unit
|
||||
) {
|
||||
val replyTo by remember {
|
||||
derivedStateOf {
|
||||
note.replyTo?.lastOrNull()
|
||||
}
|
||||
}
|
||||
if (!innerQuote && replyTo != null) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
replyTo?.let { note ->
|
||||
ChatroomMessageCompose(
|
||||
note,
|
||||
null,
|
||||
innerQuote = true,
|
||||
parentBackgroundColor = backgroundBubbleColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onWantsToReply = onWantsToReply
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoteRow(
|
||||
note: Note,
|
||||
canPreview: Boolean,
|
||||
backgroundBubbleColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
when (remember(note) { note.event }) {
|
||||
is ChannelCreateEvent -> {
|
||||
RenderCreateChannelNote(note)
|
||||
}
|
||||
|
||||
is ChannelMetadataEvent -> {
|
||||
RenderChangeChannelMetadataNote(note)
|
||||
}
|
||||
|
||||
else -> {
|
||||
RenderRegularTextNote(
|
||||
note,
|
||||
canPreview,
|
||||
backgroundBubbleColor,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConstrainedStatusRow(
|
||||
bubbleSize: MutableState<IntSize>,
|
||||
availableBubbleSize: MutableState<IntSize>,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.padding(top = 5.dp)
|
||||
.then(
|
||||
with(LocalDensity.current) {
|
||||
Modifier.widthIn(
|
||||
bubbleSize.value.width.toDp(),
|
||||
availableBubbleSize.value.width.toDp()
|
||||
)
|
||||
}
|
||||
)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusRow(
|
||||
baseNote: Note,
|
||||
@@ -337,15 +507,15 @@ private fun StatusRow(
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ChatTimeAgo(time)
|
||||
RelayBadges(baseNote)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
LikeReaction(baseNote, grayTint, accountViewModel)
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
ZapReaction(baseNote, grayTint, accountViewModel)
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
ReplyReaction(baseNote, grayTint, accountViewModel, showCounter = false, iconSize = 16.dp) {
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
ReplyReaction(baseNote, grayTint, accountViewModel, showCounter = false, iconSize = Size16dp) {
|
||||
onWantsToReply(baseNote)
|
||||
}
|
||||
}
|
||||
@@ -355,13 +525,7 @@ private fun StatusRow(
|
||||
fun ChatTimeAgo(time: Long) {
|
||||
val context = LocalContext.current
|
||||
|
||||
var timeStr by remember { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(key1 = time) {
|
||||
launch(Dispatchers.IO) {
|
||||
timeStr = timeAgoShort(time, context = context)
|
||||
}
|
||||
}
|
||||
val timeStr = remember { timeAgoShort(time, context = context) }
|
||||
|
||||
Text(
|
||||
timeStr,
|
||||
@@ -494,22 +658,30 @@ private fun DrawAuthorInfo(
|
||||
}
|
||||
)
|
||||
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = remember { " $userDisplayName" },
|
||||
suffix = "",
|
||||
tags = userTags,
|
||||
fontWeight = FontWeight.Bold,
|
||||
overrideColor = MaterialTheme.colors.onBackground,
|
||||
route = route,
|
||||
nav = nav
|
||||
)
|
||||
userDisplayName?.let {
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = it,
|
||||
suffix = "",
|
||||
tags = userTags,
|
||||
fontWeight = FontWeight.Bold,
|
||||
overrideColor = MaterialTheme.colors.onBackground,
|
||||
route = route,
|
||||
nav = nav
|
||||
)
|
||||
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
DrawPlayName(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class RelayBadgesState(
|
||||
val shouldDisplayExpandButton: Boolean,
|
||||
val noteRelays: List<String>,
|
||||
val noteRelaysSimple: List<String>
|
||||
val noteRelays: ImmutableList<String>,
|
||||
val noteRelaysSimple: ImmutableList<String>
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@@ -519,8 +691,8 @@ private fun RelayBadges(baseNote: Note) {
|
||||
|
||||
val state: RelayBadgesState by remember(noteRelaysState) {
|
||||
val newShouldDisplayExpandButton = (noteRelaysState?.note?.relays?.size ?: 0) > 3
|
||||
val noteRelays = noteRelaysState?.note?.relays?.toList() ?: emptyList()
|
||||
val noteRelaysSimple = noteRelaysState?.note?.relays?.take(3)?.toList() ?: emptyList()
|
||||
val noteRelays = noteRelaysState?.note?.relays?.toImmutableList() ?: persistentListOf()
|
||||
val noteRelaysSimple = noteRelaysState?.note?.relays?.take(3)?.toImmutableList() ?: persistentListOf()
|
||||
|
||||
mutableStateOf(RelayBadgesState(newShouldDisplayExpandButton, noteRelays, noteRelaysSimple))
|
||||
}
|
||||
@@ -541,13 +713,13 @@ private fun RelayBadges(baseNote: Note) {
|
||||
|
||||
if (state.shouldDisplayExpandButton && !expanded) {
|
||||
IconButton(
|
||||
modifier = Modifier.then(Modifier.size(15.dp)),
|
||||
modifier = Size15Modifier,
|
||||
onClick = { expanded = true }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ChevronRight,
|
||||
null,
|
||||
modifier = Modifier.size(15.dp),
|
||||
modifier = Size15Modifier,
|
||||
tint = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
}
|
||||
@@ -587,7 +759,7 @@ fun RenderRelay(dirtyUrl: String) {
|
||||
) {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = iconUrl,
|
||||
robotSize = remember { 13.dp },
|
||||
robotSize = Size13dp,
|
||||
model = iconUrl,
|
||||
contentDescription = stringResource(id = R.string.relay_icon),
|
||||
colorFilter = RelayIconFilter,
|
||||
|
||||
@@ -27,9 +27,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.ui.screen.MessageSetCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
|
||||
@@ -54,9 +52,9 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, routeForLastRead: String,
|
||||
|
||||
LaunchedEffect(key1 = messageSetCard) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val isNew = messageSetCard.createdAt() > NotificationCache.load(routeForLastRead)
|
||||
val isNew = messageSetCard.createdAt() > accountViewModel.account.loadLastRead(routeForLastRead)
|
||||
|
||||
NotificationCache.markAsRead(routeForLastRead, messageSetCard.createdAt())
|
||||
accountViewModel.account.markAsRead(routeForLastRead, messageSetCard.createdAt())
|
||||
|
||||
val newBackgroundColor = if (isNew) {
|
||||
newItemColor.compositeOver(defaultBackgroundColor)
|
||||
@@ -101,16 +99,12 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, routeForLastRead: String,
|
||||
MessageIcon()
|
||||
|
||||
Column(modifier = remember { Modifier.padding(start = 10.dp) }) {
|
||||
val routeForLastRead = remember(baseNote) {
|
||||
"Room/${(baseNote.event as? PrivateDmEvent)?.talkingWith(loggedIn.pubkeyHex)}"
|
||||
}
|
||||
|
||||
NoteCompose(
|
||||
baseNote = baseNote,
|
||||
routeForLastRead = routeForLastRead,
|
||||
routeForLastRead = null,
|
||||
isBoostedNote = true,
|
||||
addMarginTop = false,
|
||||
parentBackgroundColor = null,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
@@ -42,23 +43,24 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.UserMetadata
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.screen.CombinedZap
|
||||
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
|
||||
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.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.overPictureBackground
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -76,9 +78,9 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
|
||||
|
||||
LaunchedEffect(key1 = multiSetCard) {
|
||||
launch(Dispatchers.IO) {
|
||||
val isNew = multiSetCard.maxCreatedAt > NotificationCache.load(routeForLastRead)
|
||||
val isNew = multiSetCard.maxCreatedAt > accountViewModel.account.loadLastRead(routeForLastRead)
|
||||
|
||||
NotificationCache.markAsRead(routeForLastRead, multiSetCard.maxCreatedAt)
|
||||
accountViewModel.account.markAsRead(routeForLastRead, multiSetCard.maxCreatedAt)
|
||||
|
||||
val newBackgroundColor = if (isNew) {
|
||||
newItemColor.compositeOver(defaultBackgroundColor)
|
||||
@@ -92,48 +94,32 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
|
||||
}
|
||||
}
|
||||
|
||||
val columnModifier = Modifier
|
||||
.drawBehind {
|
||||
drawRect(backgroundColor.value)
|
||||
}
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 10.dp
|
||||
)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
routeFor(baseNote, accountViewModel.userProfile())?.let { nav(it) }
|
||||
}
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
.fillMaxWidth()
|
||||
|
||||
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() } }
|
||||
val columnModifier = remember(backgroundColor.value) {
|
||||
Modifier
|
||||
.drawBehind {
|
||||
drawRect(backgroundColor.value)
|
||||
}
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 10.dp
|
||||
)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
routeFor(baseNote, accountViewModel.userProfile())?.let { nav(it) }
|
||||
}
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
.fillMaxWidth()
|
||||
}
|
||||
|
||||
Column(modifier = columnModifier) {
|
||||
if (hasZapEvents) {
|
||||
RenderZapGallery(zapEvents, backgroundColor, nav, accountViewModel)
|
||||
}
|
||||
Galeries(multiSetCard, backgroundColor, nav, accountViewModel)
|
||||
|
||||
if (hasBoostEvents) {
|
||||
RenderBoostGallery(boostEvents, backgroundColor, nav, accountViewModel)
|
||||
}
|
||||
|
||||
if (hasLikeEvents) {
|
||||
RenderLikeGallery(likeEvents, backgroundColor, nav, accountViewModel)
|
||||
}
|
||||
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Spacer(modifier = Modifier.width(65.dp))
|
||||
Row(remember { Modifier.fillMaxWidth() }) {
|
||||
Spacer(modifier = remember { Modifier.width(65.dp) })
|
||||
|
||||
NoteCompose(
|
||||
baseNote = baseNote,
|
||||
@@ -150,68 +136,121 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Galeries(
|
||||
multiSetCard: MultiSetCard,
|
||||
backgroundColor: MutableState<Color>,
|
||||
nav: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val zapEvents by remember { derivedStateOf { multiSetCard.zapEvents } }
|
||||
val boostEvents by remember { derivedStateOf { multiSetCard.boostEvents } }
|
||||
val likeEvents by remember { derivedStateOf { multiSetCard.likeEventsByType } }
|
||||
|
||||
val hasZapEvents by remember { derivedStateOf { multiSetCard.zapEvents.isNotEmpty() } }
|
||||
val hasBoostEvents by remember { derivedStateOf { multiSetCard.boostEvents.isNotEmpty() } }
|
||||
val hasLikeEvents by remember { derivedStateOf { multiSetCard.likeEvents.isNotEmpty() } }
|
||||
|
||||
if (hasZapEvents) {
|
||||
RenderZapGallery(zapEvents, backgroundColor, nav, accountViewModel)
|
||||
}
|
||||
|
||||
if (hasBoostEvents) {
|
||||
RenderBoostGallery(boostEvents, backgroundColor, nav, accountViewModel)
|
||||
}
|
||||
|
||||
if (hasLikeEvents) {
|
||||
likeEvents.forEach {
|
||||
RenderLikeGallery(it.key, it.value, backgroundColor, nav, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderLikeGallery(
|
||||
reactionType: String,
|
||||
likeEvents: ImmutableList<Note>,
|
||||
backgroundColor: MutableState<Color>,
|
||||
nav: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.width(55.dp)
|
||||
.padding(end = 5.dp)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_liked),
|
||||
null,
|
||||
val isNotEmpty = remember(likeEvents) {
|
||||
likeEvents.isNotEmpty()
|
||||
}
|
||||
|
||||
val shortReaction by remember {
|
||||
derivedStateOf {
|
||||
reactionType.take(2)
|
||||
}
|
||||
}
|
||||
|
||||
if (isNotEmpty) {
|
||||
Row(remember { Modifier.fillMaxWidth() }) {
|
||||
Box(
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.size(16.dp)
|
||||
.width(55.dp)
|
||||
.padding(end = 5.dp)
|
||||
}
|
||||
) {
|
||||
val modifier = remember {
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
},
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AuthorGallery(likeEvents, backgroundColor, nav, accountViewModel)
|
||||
when (shortReaction) {
|
||||
"+" -> Icon(
|
||||
painter = painterResource(R.drawable.ic_liked),
|
||||
null,
|
||||
modifier = remember { modifier.size(18.dp) },
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
"-" -> Text(text = "\uD83D\uDC4E", modifier = modifier)
|
||||
else -> Text(text = shortReaction, modifier = modifier)
|
||||
}
|
||||
}
|
||||
|
||||
AuthorGallery(likeEvents, backgroundColor, nav, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderZapGallery(
|
||||
zapEvents: ImmutableMap<Note, Note?>,
|
||||
zapEvents: ImmutableList<CombinedZap>,
|
||||
backgroundColor: MutableState<Color>,
|
||||
nav: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.width(55.dp)
|
||||
.padding(0.dp)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
contentDescription = "Zaps",
|
||||
tint = BitcoinOrange,
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.size(25.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
}
|
||||
)
|
||||
}
|
||||
ZapIcon()
|
||||
|
||||
AuthorGalleryZaps(zapEvents, backgroundColor, nav, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ZapIcon() {
|
||||
Box(
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.width(55.dp)
|
||||
.padding(0.dp)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
contentDescription = "Zaps",
|
||||
tint = BitcoinOrange,
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.size(25.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderBoostGallery(
|
||||
boostEvents: ImmutableList<Note>,
|
||||
@@ -250,20 +289,27 @@ fun RenderBoostGallery(
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun AuthorGalleryZaps(
|
||||
authorNotes: ImmutableMap<Note, Note?>,
|
||||
authorNotes: ImmutableList<CombinedZap>,
|
||||
backgroundColor: MutableState<Color>,
|
||||
nav: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
Column(modifier = remember { Modifier.padding(start = 10.dp) }) {
|
||||
FlowRow() {
|
||||
authorNotes.forEach {
|
||||
AuthorPictureAndComment(it.key, it.value, backgroundColor, nav, accountViewModel)
|
||||
AuthorPictureAndComment(it.request, it.response, backgroundColor, nav, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class ZapAmountCommentNotification(
|
||||
val user: User?,
|
||||
val comment: String?,
|
||||
val amount: String?
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun AuthorPictureAndComment(
|
||||
zapRequest: Note,
|
||||
@@ -272,7 +318,15 @@ private fun AuthorPictureAndComment(
|
||||
nav: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
var content by remember { mutableStateOf<Triple<User?, String?, String?>>(Triple(zapRequest.author, null, null)) }
|
||||
val content = remember {
|
||||
mutableStateOf(
|
||||
ZapAmountCommentNotification(
|
||||
user = zapRequest.author,
|
||||
comment = null,
|
||||
amount = null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = zapRequest.idHex, key2 = zapEvent?.idHex) {
|
||||
launch(Dispatchers.Default) {
|
||||
@@ -281,28 +335,24 @@ private fun AuthorPictureAndComment(
|
||||
val amount = (zapEvent?.event as? LnZapEvent)?.amount
|
||||
if (decryptedContent != null) {
|
||||
val newAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey)
|
||||
content = Triple(newAuthor, decryptedContent.content.ifBlank { null }, showAmountAxis(amount))
|
||||
content.value = ZapAmountCommentNotification(newAuthor, decryptedContent.content.ifBlank { null }, showAmountAxis(amount))
|
||||
} else {
|
||||
if (!zapRequest.event?.content().isNullOrBlank() || amount != null) {
|
||||
content = Triple(zapRequest.author, zapRequest.event?.content()?.ifBlank { null }, showAmountAxis(amount))
|
||||
content.value = ZapAmountCommentNotification(zapRequest.author, zapRequest.event?.content()?.ifBlank { null }, showAmountAxis(amount))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
content.first?.let {
|
||||
val route by remember {
|
||||
derivedStateOf {
|
||||
"User/${it.pubkeyHex}"
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.clickable {
|
||||
nav("User/${content.value.user?.pubkeyHex}")
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AuthorPictureAndComment(
|
||||
author = it,
|
||||
comment = content.second,
|
||||
amount = content.third,
|
||||
route = route,
|
||||
authorComment = content,
|
||||
backgroundColor = backgroundColor,
|
||||
nav = nav,
|
||||
accountViewModel = accountViewModel
|
||||
@@ -310,69 +360,92 @@ private fun AuthorPictureAndComment(
|
||||
}
|
||||
}
|
||||
|
||||
val amountBoxModifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(shape = CircleShape)
|
||||
|
||||
val textBoxModifier = Modifier.padding(start = 5.dp).fillMaxWidth()
|
||||
|
||||
val simpleModifier = Modifier
|
||||
|
||||
val sizedModifier = Modifier.size(Size35dp)
|
||||
|
||||
val bottomPadding1dp = Modifier.padding(bottom = 1.dp)
|
||||
|
||||
val commentTextSize = 12.sp
|
||||
|
||||
@Composable
|
||||
private fun AuthorPictureAndComment(
|
||||
author: User,
|
||||
comment: String?,
|
||||
amount: String?,
|
||||
route: String,
|
||||
authorComment: MutableState<ZapAmountCommentNotification>,
|
||||
backgroundColor: MutableState<Color>,
|
||||
nav: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val authorPictureModifier = remember { Modifier }
|
||||
|
||||
val modifier = remember {
|
||||
Modifier.clickable {
|
||||
nav(route)
|
||||
}
|
||||
Box(modifier = sizedModifier, contentAlignment = Alignment.BottomCenter) {
|
||||
DisplayPicture(authorComment, accountViewModel)
|
||||
DisplayAmount(authorComment)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(modifier = remember { Modifier.size(35.dp) }, contentAlignment = Alignment.BottomCenter) {
|
||||
FastNoteAuthorPicture(
|
||||
author = author,
|
||||
size = remember { 35.dp },
|
||||
accountViewModel = accountViewModel,
|
||||
pictureModifier = authorPictureModifier
|
||||
)
|
||||
DisplayComment(authorComment, backgroundColor, nav, accountViewModel)
|
||||
}
|
||||
|
||||
amount?.let {
|
||||
Box(modifier = Modifier.fillMaxSize().clip(shape = CircleShape), contentAlignment = Alignment.BottomCenter) {
|
||||
val backgroundColor = MaterialTheme.colors.overPictureBackground
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.drawBehind { drawRect(backgroundColor) },
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
Text(
|
||||
text = it,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colors.secondaryVariant,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.padding(bottom = 1.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
fun DisplayPicture(authorComment: MutableState<ZapAmountCommentNotification>, accountViewModel: AccountViewModel) {
|
||||
authorComment.value.user?.let {
|
||||
FastNoteAuthorPicture(
|
||||
author = it,
|
||||
size = Size35dp,
|
||||
accountViewModel = accountViewModel,
|
||||
pictureModifier = simpleModifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayAmount(authorComment: MutableState<ZapAmountCommentNotification>) {
|
||||
authorComment.value.amount?.let {
|
||||
Box(
|
||||
modifier = amountBoxModifier,
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
val backgroundColor = MaterialTheme.colors.overPictureBackground
|
||||
Box(
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.drawBehind { drawRect(backgroundColor) }
|
||||
},
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
Text(
|
||||
text = it,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colors.secondaryVariant,
|
||||
fontSize = commentTextSize,
|
||||
modifier = bottomPadding1dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
comment?.let {
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
TranslatableRichTextViewer(
|
||||
content = it,
|
||||
canPreview = true,
|
||||
tags = remember { ImmutableListOfLists() },
|
||||
modifier = remember { Modifier.fillMaxWidth() },
|
||||
backgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
@Composable
|
||||
fun DisplayComment(
|
||||
authorComment: MutableState<ZapAmountCommentNotification>,
|
||||
backgroundColor: MutableState<Color>,
|
||||
nav: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
authorComment.value.comment?.let {
|
||||
TranslatableRichTextViewer(
|
||||
content = it,
|
||||
canPreview = true,
|
||||
tags = remember { ImmutableListOfLists() },
|
||||
modifier = textBoxModifier,
|
||||
backgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,14 +460,24 @@ fun AuthorGallery(
|
||||
Column(modifier = remember { Modifier.padding(start = 10.dp) }) {
|
||||
FlowRow() {
|
||||
authorNotes.forEach { note ->
|
||||
Box(remember { Modifier.size(35.dp) }) {
|
||||
NotePictureAndComment(note, backgroundColor, nav, accountViewModel)
|
||||
}
|
||||
BoxedAuthor(note, backgroundColor, nav, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxedAuthor(
|
||||
note: Note,
|
||||
backgroundColor: MutableState<Color>,
|
||||
nav: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
Box(sizedModifier) {
|
||||
NotePictureAndComment(note, backgroundColor, nav, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotePictureAndComment(
|
||||
baseNote: Note,
|
||||
@@ -402,19 +485,28 @@ private fun NotePictureAndComment(
|
||||
nav: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val author by remember(baseNote) {
|
||||
derivedStateOf {
|
||||
baseNote.author
|
||||
val author = remember(baseNote) {
|
||||
mutableStateOf(
|
||||
ZapAmountCommentNotification(
|
||||
user = baseNote.author,
|
||||
comment = null,
|
||||
amount = null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val modifier = remember(baseNote) {
|
||||
Modifier.clickable {
|
||||
nav("User/${baseNote.author?.pubkeyHex}")
|
||||
}
|
||||
}
|
||||
|
||||
val route by remember(baseNote) {
|
||||
derivedStateOf {
|
||||
"User/${baseNote.author?.pubkeyHex}"
|
||||
}
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AuthorPictureAndComment(authorComment = author, backgroundColor, nav, accountViewModel)
|
||||
}
|
||||
|
||||
author?.let { AuthorPictureAndComment(it, null, null, route, backgroundColor, nav, accountViewModel) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -424,17 +516,20 @@ fun FastNoteAuthorPicture(
|
||||
pictureModifier: Modifier = Modifier,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val userState by author.live().metadata.observeAsState()
|
||||
val profilePicture by remember(userState) {
|
||||
derivedStateOf {
|
||||
userState?.user?.profilePicture()
|
||||
}
|
||||
var profilePicture by remember {
|
||||
mutableStateOf(author.info?.picture)
|
||||
}
|
||||
|
||||
val authorPubKey = remember {
|
||||
author.pubkeyHex
|
||||
}
|
||||
|
||||
WatchUserMetadata(author) {
|
||||
if (it.picture != profilePicture) {
|
||||
profilePicture = it.picture
|
||||
}
|
||||
}
|
||||
|
||||
UserPicture(
|
||||
userHex = authorPubKey,
|
||||
userPicture = profilePicture,
|
||||
@@ -443,3 +538,15 @@ fun FastNoteAuthorPicture(
|
||||
accountViewModel = accountViewModel
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WatchUserMetadata(userBase: User, onMetadataChanges: (UserMetadata) -> Unit) {
|
||||
val userState by userBase.live().metadata.observeAsState()
|
||||
LaunchedEffect(key1 = userState) {
|
||||
launch(Dispatchers.Default) {
|
||||
userState?.user?.info?.let {
|
||||
onMetadataChanges(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import androidx.compose.material.icons.filled.Downloading
|
||||
import androidx.compose.material.icons.filled.Report
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
@@ -40,7 +40,7 @@ import kotlinx.coroutines.launch
|
||||
import java.util.Date
|
||||
|
||||
@Composable
|
||||
fun nip05VerificationAsAState(user: UserMetadata, pubkeyHex: String): State<Boolean?> {
|
||||
fun nip05VerificationAsAState(user: UserMetadata, pubkeyHex: String): MutableState<Boolean?> {
|
||||
val nip05Verified = remember(user.nip05) {
|
||||
// starts with null if must verify or already filled in if verified in the last hour
|
||||
val default = if ((user.nip05LastVerificationTime ?: 0) > (Date().time / 1000 - 60 * 60)) { // 1hour
|
||||
@@ -132,7 +132,7 @@ fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifie
|
||||
private fun DisplayNIP05Line(nip05: String, baseUser: User, columnModifier: Modifier = Modifier) {
|
||||
Column(modifier = columnModifier) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val nip05Verified by nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex)
|
||||
val nip05Verified = nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex)
|
||||
DisplayNIP05(nip05, nip05Verified)
|
||||
}
|
||||
}
|
||||
@@ -141,7 +141,7 @@ private fun DisplayNIP05Line(nip05: String, baseUser: User, columnModifier: Modi
|
||||
@Composable
|
||||
private fun DisplayNIP05(
|
||||
nip05: String,
|
||||
nip05Verified: Boolean?
|
||||
nip05Verified: MutableState<Boolean?>
|
||||
) {
|
||||
val uri = LocalUriHandler.current
|
||||
val (user, domain) = remember(nip05) {
|
||||
@@ -157,7 +157,20 @@ private fun DisplayNIP05(
|
||||
)
|
||||
}
|
||||
|
||||
if (nip05Verified == null) {
|
||||
NIP05VerifiedSymbol(nip05Verified)
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString(domain),
|
||||
onClick = { runCatching { uri.openUri("https://$domain") } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(0.52f)),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Visible
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NIP05VerifiedSymbol(nip05Verified: MutableState<Boolean?>) {
|
||||
if (nip05Verified.value == null) {
|
||||
Icon(
|
||||
tint = Color.Yellow,
|
||||
imageVector = Icons.Default.Downloading,
|
||||
@@ -166,7 +179,7 @@ private fun DisplayNIP05(
|
||||
.size(14.dp)
|
||||
.padding(top = 1.dp)
|
||||
)
|
||||
} else if (nip05Verified == true) {
|
||||
} else if (nip05Verified.value == true) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_verified),
|
||||
"NIP-05 Verified",
|
||||
@@ -185,14 +198,6 @@ private fun DisplayNIP05(
|
||||
.padding(top = 1.dp)
|
||||
)
|
||||
}
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString(domain),
|
||||
onClick = { runCatching { uri.openUri("https://$domain") } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(0.52f)),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Visible
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -82,7 +82,6 @@ import androidx.core.graphics.get
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.request.SuccessResult
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
@@ -93,13 +92,17 @@ import com.vitorpamplona.amethyst.service.model.AppDefinitionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BaseTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.EventInterface
|
||||
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
|
||||
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.HighlightEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.Participant
|
||||
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
|
||||
@@ -142,6 +145,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.Following
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
|
||||
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
@@ -329,7 +333,7 @@ fun LoadedNoteCompose(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WatchForReports(
|
||||
fun WatchForReports(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
onChange: (Boolean, Boolean, Set<Note>) -> Unit
|
||||
@@ -376,7 +380,7 @@ fun NormalNote(
|
||||
val channelHex = remember { baseNote.channelHex() }
|
||||
|
||||
if ((noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) && channelHex != null) {
|
||||
ChannelHeader(channelHex = channelHex, accountViewModel = accountViewModel, nav = nav)
|
||||
ChannelHeader(channelHex = channelHex, showVideo = !makeItShort, showBottomDiviser = true, accountViewModel = accountViewModel, nav = nav)
|
||||
} else if (noteEvent is BadgeDefinitionEvent) {
|
||||
BadgeDisplay(baseNote = baseNote)
|
||||
} else if (noteEvent is FileHeaderEvent) {
|
||||
@@ -423,11 +427,11 @@ private fun CheckNewAndRenderNote(
|
||||
LaunchedEffect(key1 = routeForLastRead, key2 = parentBackgroundColor?.value) {
|
||||
launch(Dispatchers.IO) {
|
||||
routeForLastRead?.let {
|
||||
val lastTime = NotificationCache.load(it)
|
||||
val lastTime = accountViewModel.account.loadLastRead(it)
|
||||
|
||||
val createdAt = baseNote.createdAt()
|
||||
if (createdAt != null) {
|
||||
NotificationCache.markAsRead(it, createdAt)
|
||||
accountViewModel.account.markAsRead(it, createdAt)
|
||||
|
||||
val isNew = createdAt > lastTime
|
||||
|
||||
@@ -519,7 +523,7 @@ private fun RenderNoteWithReactions(
|
||||
|
||||
val showSecondRow by remember {
|
||||
derivedStateOf {
|
||||
baseNote.event !is RepostEvent && !isBoostedNote && !isQuotedNote
|
||||
baseNote.event !is RepostEvent && baseNote.event !is GenericRepostEvent && !isBoostedNote && !isQuotedNote
|
||||
}
|
||||
}
|
||||
|
||||
@@ -553,7 +557,7 @@ private fun RenderNoteWithReactions(
|
||||
)
|
||||
}
|
||||
|
||||
if (!makeItShort && baseNote.event !is RepostEvent) {
|
||||
if (!makeItShort && baseNote.event !is RepostEvent && baseNote.event !is GenericRepostEvent) {
|
||||
ReactionsRow(
|
||||
baseNote = baseNote,
|
||||
showReactionDetail = notBoostedNorQuote,
|
||||
@@ -561,7 +565,7 @@ private fun RenderNoteWithReactions(
|
||||
nav = nav
|
||||
)
|
||||
} else {
|
||||
if (!isBoostedNote && baseNote.event !is RepostEvent) {
|
||||
if (!isBoostedNote && baseNote.event !is RepostEvent && baseNote.event !is GenericRepostEvent) {
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
}
|
||||
}
|
||||
@@ -634,8 +638,7 @@ private fun RenderNoteRow(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val noteEvent = remember { baseNote.event }
|
||||
when (noteEvent) {
|
||||
when (remember { baseNote.event }) {
|
||||
is AppDefinitionEvent -> {
|
||||
RenderAppDefinition(baseNote, accountViewModel, nav)
|
||||
}
|
||||
@@ -648,6 +651,10 @@ private fun RenderNoteRow(
|
||||
RenderRepost(baseNote, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is GenericRepostEvent -> {
|
||||
RenderRepost(baseNote, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is ReportEvent -> {
|
||||
RenderReport(baseNote, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
@@ -676,6 +683,10 @@ private fun RenderNoteRow(
|
||||
RenderPinListEvent(baseNote, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is LiveActivitiesEvent -> {
|
||||
RenderLiveActivityEvent(baseNote, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is PrivateDmEvent -> {
|
||||
RenderPrivateMessage(
|
||||
baseNote,
|
||||
@@ -729,6 +740,10 @@ fun routeFor(note: Note, loggedIn: User): String? {
|
||||
note.channelHex()?.let {
|
||||
return "Channel/$it"
|
||||
}
|
||||
} else if (noteEvent is LiveActivitiesEvent || noteEvent is LiveActivitiesChatMessageEvent) {
|
||||
note.channelHex()?.let {
|
||||
return "Channel/$it"
|
||||
}
|
||||
} else if (noteEvent is PrivateDmEvent) {
|
||||
return "Room/${noteEvent.talkingWith(loggedIn.pubkeyHex)}"
|
||||
} else {
|
||||
@@ -842,7 +857,7 @@ fun RenderPoll(
|
||||
)
|
||||
}
|
||||
|
||||
var hashtags = remember { noteEvent.hashtags().toImmutableList() }
|
||||
val hashtags = remember { noteEvent.hashtags().toImmutableList() }
|
||||
DisplayUncitedHashtags(hashtags, eventContent, nav)
|
||||
}
|
||||
}
|
||||
@@ -948,7 +963,7 @@ fun RenderAppDefinition(
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(35.dp)
|
||||
.height(Size35dp)
|
||||
.padding(bottom = 3.dp)
|
||||
) {
|
||||
}
|
||||
@@ -1355,7 +1370,7 @@ private fun RenderBadgeAward(
|
||||
awardees.take(100).forEach { user ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.size(size = 35.dp)
|
||||
.size(size = Size35dp)
|
||||
.clickable {
|
||||
nav("User/${user.pubkeyHex}")
|
||||
},
|
||||
@@ -1364,7 +1379,7 @@ private fun RenderBadgeAward(
|
||||
UserPicture(
|
||||
baseUser = user,
|
||||
accountViewModel = accountViewModel,
|
||||
size = 35.dp
|
||||
size = Size35dp
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1417,7 +1432,7 @@ private fun RenderReaction(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderRepost(
|
||||
fun RenderRepost(
|
||||
note: Note,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
@@ -1634,7 +1649,8 @@ private fun ReplyRow(
|
||||
|
||||
val showChannelReply by remember {
|
||||
derivedStateOf {
|
||||
noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser())
|
||||
(noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser())) ||
|
||||
(noteEvent is LiveActivitiesChatMessageEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1649,11 +1665,19 @@ private fun ReplyRow(
|
||||
} else if (showChannelReply) {
|
||||
val channelHex = note.channelHex()
|
||||
channelHex?.let {
|
||||
val replies = remember { note.replyTo?.toImmutableList() }
|
||||
val mentions = remember { (note.event as? ChannelMessageEvent)?.mentions()?.toImmutableList() ?: persistentListOf() }
|
||||
ChannelHeader(
|
||||
channelHex = channelHex,
|
||||
showVideo = false,
|
||||
showBottomDiviser = false,
|
||||
modifier = remember { Modifier.padding(vertical = 5.dp) },
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
|
||||
ReplyInformationChannel(replies, mentions, it, accountViewModel, nav)
|
||||
Spacer(modifier = Modifier.height(5.dp))
|
||||
val replies = remember { note.replyTo?.toImmutableList() }
|
||||
val mentions = remember { (note.event as? BaseTextNoteEvent)?.mentions()?.toImmutableList() ?: persistentListOf() }
|
||||
|
||||
ReplyInformationChannel(replies, mentions, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1680,18 +1704,22 @@ private fun ReplyNoteComposition(
|
||||
}
|
||||
}
|
||||
|
||||
val borderColor = MaterialTheme.colors.subtleBorder
|
||||
|
||||
NoteCompose(
|
||||
baseNote = replyingDirectlyTo,
|
||||
isQuotedNote = true,
|
||||
modifier = Modifier
|
||||
.padding(top = 5.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = QuoteBorder)
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.subtleBorder,
|
||||
QuoteBorder
|
||||
),
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.padding(top = 5.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = QuoteBorder)
|
||||
.border(
|
||||
1.dp,
|
||||
borderColor,
|
||||
QuoteBorder
|
||||
)
|
||||
},
|
||||
unPackReply = false,
|
||||
makeItShort = true,
|
||||
parentBackgroundColor = replyBackgroundColor,
|
||||
@@ -1746,7 +1774,7 @@ private fun FirstUserInfoRow(
|
||||
NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) })
|
||||
}
|
||||
|
||||
if (eventNote is RepostEvent) {
|
||||
if (eventNote is RepostEvent || eventNote is GenericRepostEvent) {
|
||||
Text(
|
||||
" ${stringResource(id = R.string.boosted)}",
|
||||
fontWeight = FontWeight.Bold,
|
||||
@@ -1770,13 +1798,13 @@ private fun MoreOptionsButton(
|
||||
var moreActionsExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
IconButton(
|
||||
modifier = Modifier.size(24.dp),
|
||||
modifier = remember { Modifier.size(24.dp) },
|
||||
onClick = { moreActionsExpanded = true }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
null,
|
||||
modifier = Modifier.size(15.dp),
|
||||
modifier = remember { Modifier.size(15.dp) },
|
||||
tint = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
|
||||
@@ -1820,7 +1848,7 @@ private fun DrawAuthorImages(baseNote: Note, accountViewModel: AccountViewModel,
|
||||
Box(modifier = modifier, contentAlignment = Alignment.BottomEnd) {
|
||||
NoteAuthorPicture(baseNote, nav, accountViewModel, 55.dp)
|
||||
|
||||
if (baseNote.event is RepostEvent) {
|
||||
if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) {
|
||||
RepostNoteAuthorPicture(baseNote, accountViewModel, nav)
|
||||
}
|
||||
|
||||
@@ -1831,7 +1859,7 @@ private fun DrawAuthorImages(baseNote: Note, accountViewModel: AccountViewModel,
|
||||
}
|
||||
}
|
||||
|
||||
if (baseNote.event is RepostEvent) {
|
||||
if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) {
|
||||
val baseReply = remember {
|
||||
baseNote.replyTo?.lastOrNull()
|
||||
}
|
||||
@@ -1847,7 +1875,7 @@ private fun DrawAuthorImages(baseNote: Note, accountViewModel: AccountViewModel,
|
||||
@Composable
|
||||
fun LoadChannel(baseChannelHex: String, content: @Composable (Channel) -> Unit) {
|
||||
var channel by remember(baseChannelHex) {
|
||||
mutableStateOf<Channel?>(null)
|
||||
mutableStateOf<Channel?>(LocalCache.getChannelIfExists(baseChannelHex))
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = baseChannelHex) {
|
||||
@@ -2057,9 +2085,13 @@ fun DisplayUncitedHashtags(
|
||||
eventContent: String,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
if (hashtags.isNotEmpty()) {
|
||||
val hasHashtags = remember {
|
||||
hashtags.isNotEmpty()
|
||||
}
|
||||
|
||||
if (hasHashtags) {
|
||||
FlowRow(
|
||||
modifier = Modifier.padding(top = 5.dp)
|
||||
modifier = remember { Modifier.padding(top = 5.dp) }
|
||||
) {
|
||||
hashtags.forEach { hashtag ->
|
||||
if (!eventContent.contains(hashtag, true)) {
|
||||
@@ -2455,6 +2487,80 @@ fun AudioTrackHeader(noteEvent: AudioTrackEvent, accountViewModel: AccountViewMo
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderLiveActivityEvent(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val noteEvent = baseNote.event as? LiveActivitiesEvent ?: return
|
||||
|
||||
val media = remember { noteEvent.streaming() }
|
||||
val cover = remember { noteEvent.image() }
|
||||
val subject = remember { noteEvent.title() }
|
||||
val content = remember { noteEvent.summary() }
|
||||
val participants = remember { noteEvent.participants() }
|
||||
|
||||
var participantUsers by remember { mutableStateOf<List<Pair<Participant, User>>>(emptyList()) }
|
||||
|
||||
LaunchedEffect(key1 = participants) {
|
||||
launch(Dispatchers.IO) {
|
||||
participantUsers = participants.mapNotNull { part ->
|
||||
LocalCache.checkGetOrCreateUser(part.key)?.let { Pair(part, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.padding(top = 5.dp)) {
|
||||
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Row() {
|
||||
subject?.let {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)) {
|
||||
Text(
|
||||
text = it,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
participantUsers.forEach {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(top = 5.dp, start = 10.dp, end = 10.dp)
|
||||
.clickable {
|
||||
nav("User/${it.second.pubkeyHex}")
|
||||
}
|
||||
) {
|
||||
UserPicture(it.second, 25.dp, accountViewModel)
|
||||
Spacer(Modifier.width(5.dp))
|
||||
UsernameDisplay(it.second, Modifier.weight(1f))
|
||||
Spacer(Modifier.width(5.dp))
|
||||
it.first.role?.let {
|
||||
Text(
|
||||
text = it.capitalize(Locale.ROOT),
|
||||
color = MaterialTheme.colors.placeholderText,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
media?.let { media ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
) {
|
||||
VideoView(
|
||||
videoUri = media,
|
||||
description = subject
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, accountViewModel: AccountViewModel) {
|
||||
val image = remember(noteEvent) { noteEvent.image() }
|
||||
@@ -2569,7 +2675,7 @@ private fun RelayBadges(baseNote: Note) {
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Spacer(remember { Modifier.height(10.dp) })
|
||||
|
||||
if (expanded) {
|
||||
VerticalRelayPanelWithFlow(lazyRelayList)
|
||||
@@ -2613,29 +2719,25 @@ private fun VerticalRelayPanelWithFlow(
|
||||
}
|
||||
}
|
||||
|
||||
val showMoreRelaysButtonIconButtonModifier = Modifier.size(24.dp)
|
||||
val showMoreRelaysButtonIconModifier = Modifier.size(15.dp)
|
||||
val showMoreRelaysButtonBoxModifer = Modifier.fillMaxWidth().height(25.dp)
|
||||
|
||||
@Composable
|
||||
private fun ShowMoreRelaysButton(onClick: () -> Unit) {
|
||||
val boxModifier = remember {
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(25.dp)
|
||||
}
|
||||
val iconButtonModifier = remember { Modifier.size(24.dp) }
|
||||
val iconModifier = remember { Modifier.size(15.dp) }
|
||||
|
||||
Row(
|
||||
boxModifier,
|
||||
modifier = showMoreRelaysButtonBoxModifer,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
IconButton(
|
||||
modifier = iconButtonModifier,
|
||||
modifier = showMoreRelaysButtonIconButtonModifier,
|
||||
onClick = onClick
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ExpandMore,
|
||||
null,
|
||||
modifier = iconModifier,
|
||||
modifier = showMoreRelaysButtonIconModifier,
|
||||
tint = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
}
|
||||
@@ -2790,7 +2892,7 @@ fun UserPicture(
|
||||
|
||||
@Composable
|
||||
private fun ObserveAndDisplayFollowingMark(userHex: String, iconSize: Dp, accountViewModel: AccountViewModel) {
|
||||
val accountFollowsState by accountViewModel.account.userProfile().live().follows.observeAsState()
|
||||
val accountFollowsState by accountViewModel.userFollows.observeAsState()
|
||||
|
||||
var showFollowingMark by remember { mutableStateOf(false) }
|
||||
|
||||
|
||||
@@ -112,12 +112,12 @@ private fun VerticalDivider(color: Color) =
|
||||
|
||||
@Composable
|
||||
fun LongPressToQuickAction(baseNote: Note, accountViewModel: AccountViewModel, content: @Composable (() -> Unit) -> Unit) {
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
val showPopup = remember { { popupExpanded = true } }
|
||||
var popupExpanded = remember { mutableStateOf(false) }
|
||||
val showPopup = remember(popupExpanded) { { popupExpanded.value = true } }
|
||||
|
||||
content(showPopup)
|
||||
|
||||
NoteQuickActionMenu(baseNote, popupExpanded, { popupExpanded = false }, accountViewModel)
|
||||
NoteQuickActionMenu(baseNote, popupExpanded.value, { popupExpanded.value = false }, accountViewModel)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -332,23 +332,30 @@ fun NoteQuickActionItem(icon: ImageVector, label: String, onClick: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteAlertDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) =
|
||||
fun DeleteAlertDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
QuickActionAlertDialog(
|
||||
title = stringResource(R.string.quick_action_request_deletion_alert_title),
|
||||
textContent = stringResource(R.string.quick_action_request_deletion_alert_body),
|
||||
buttonIcon = Icons.Default.Delete,
|
||||
buttonText = stringResource(R.string.quick_action_delete_dialog_btn),
|
||||
onClickDoOnce = {
|
||||
accountViewModel.delete(note)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.delete(note)
|
||||
}
|
||||
onDismiss()
|
||||
},
|
||||
onClickDontShowAgain = {
|
||||
accountViewModel.delete(note)
|
||||
accountViewModel.dontShowDeleteRequestDialog()
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.delete(note)
|
||||
accountViewModel.dontShowDeleteRequestDialog()
|
||||
}
|
||||
onDismiss()
|
||||
},
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BlockAlertDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) =
|
||||
@@ -394,7 +401,9 @@ private fun QuickActionAlertDialog(
|
||||
},
|
||||
buttons = {
|
||||
Row(
|
||||
modifier = Modifier.padding(all = 8.dp).fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.padding(all = 8.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
TextButton(onClick = onClickDontShowAgain) {
|
||||
|
||||
@@ -7,11 +7,13 @@ import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
@@ -21,6 +23,7 @@ import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.LinearProgressIndicator
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.ProgressIndicatorDefaults
|
||||
import androidx.compose.material.Text
|
||||
@@ -54,6 +57,7 @@ import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Popup
|
||||
@@ -64,9 +68,11 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostView
|
||||
import com.vitorpamplona.amethyst.ui.screen.CombinedZap
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
@@ -81,10 +87,14 @@ import kotlin.math.roundToInt
|
||||
fun ReactionsRow(baseNote: Note, showReactionDetail: Boolean, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val grayTint = MaterialTheme.colors.placeholderText
|
||||
|
||||
var wantsToSeeReactions = remember {
|
||||
val wantsToSeeReactions = remember {
|
||||
mutableStateOf<Boolean>(false)
|
||||
}
|
||||
|
||||
val zapraiserAmount = remember {
|
||||
baseNote.event?.zapraiserAmount()
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(7.dp))
|
||||
|
||||
Row(verticalAlignment = CenterVertically, modifier = Modifier.padding(start = 10.dp)) {
|
||||
@@ -117,6 +127,19 @@ fun ReactionsRow(baseNote: Note, showReactionDetail: Boolean, accountViewModel:
|
||||
}
|
||||
}
|
||||
|
||||
if (zapraiserAmount != null && zapraiserAmount > 0) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = if (showReactionDetail) 75.dp else 0.dp),
|
||||
horizontalArrangement = Arrangement.Start
|
||||
) {
|
||||
RenderZapRaiser(baseNote, zapraiserAmount, wantsToSeeReactions.value, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
if (showReactionDetail && wantsToSeeReactions.value) {
|
||||
ReactionDetailGallery(baseNote, nav, accountViewModel)
|
||||
}
|
||||
@@ -124,6 +147,70 @@ fun ReactionsRow(baseNote: Note, showReactionDetail: Boolean, accountViewModel:
|
||||
Spacer(modifier = Modifier.height(7.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, accountViewModel: AccountViewModel) {
|
||||
val zapsState by baseNote.live().zaps.observeAsState()
|
||||
|
||||
var zapraiserProgress by remember { mutableStateOf(0F) }
|
||||
var zapraiserLeft by remember { mutableStateOf("$zapraiserAmount") }
|
||||
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
launch(Dispatchers.Default) {
|
||||
zapsState?.note?.let {
|
||||
val newZapAmount = accountViewModel.calculateZapAmount(it)
|
||||
var percentage = newZapAmount.div(zapraiserAmount.toBigDecimal()).toFloat()
|
||||
|
||||
if (percentage > 1) {
|
||||
percentage = 1f
|
||||
}
|
||||
|
||||
if (Math.abs(zapraiserProgress - percentage) > 0.001) {
|
||||
zapraiserProgress = percentage
|
||||
if (percentage > 0.99) {
|
||||
zapraiserLeft = "0"
|
||||
} else {
|
||||
zapraiserLeft = showAmount((zapraiserAmount * (1 - percentage)).toBigDecimal())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val color = if (zapraiserProgress > 0.99) {
|
||||
Color.Green.copy(alpha = 0.32f)
|
||||
} else {
|
||||
MaterialTheme.colors.mediumImportanceLink
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier.padding(end = 10.dp).fillMaxWidth()
|
||||
) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.matchParentSize(),
|
||||
color = color,
|
||||
progress = zapraiserProgress
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = Modifier.padding(2.dp)
|
||||
) {
|
||||
if (details) {
|
||||
val totalPercentage = remember(zapraiserProgress) {
|
||||
"${(zapraiserProgress * 100).roundToInt()}%"
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.sats_to_complete, totalPercentage, zapraiserLeft),
|
||||
modifier = Modifier.padding(start = 5.dp, end = 5.dp, top = 2.dp, bottom = 2.dp),
|
||||
color = MaterialTheme.colors.placeholderText,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExpandButton(baseNote: Note, wantsToSeeReactions: MutableState<Boolean>) {
|
||||
val zapsState by baseNote.live().zaps.observeAsState()
|
||||
@@ -188,9 +275,9 @@ private fun ReactionDetailGallery(
|
||||
if (hasReactions) {
|
||||
Row(verticalAlignment = CenterVertically, modifier = Modifier.padding(start = 10.dp, top = 5.dp)) {
|
||||
Column() {
|
||||
val zapEvents by remember(zapsState) { derivedStateOf { baseNote.zaps.toImmutableMap() } }
|
||||
val zapEvents by remember(zapsState) { derivedStateOf { baseNote.zaps.mapNotNull { it.value?.let { zapEvent -> CombinedZap(it.key, zapEvent) } }.toImmutableList() } }
|
||||
val boostEvents by remember(boostsState) { derivedStateOf { baseNote.boosts.toImmutableList() } }
|
||||
val likeEvents by remember(reactionsState) { derivedStateOf { baseNote.reactions.toImmutableList() } }
|
||||
val likeEvents by remember(reactionsState) { derivedStateOf { baseNote.reactions.toImmutableMap() } }
|
||||
|
||||
val hasZapEvents by remember(zapsState) { derivedStateOf { baseNote.zaps.isNotEmpty() } }
|
||||
val hasBoostEvents by remember(boostsState) { derivedStateOf { baseNote.boosts.isNotEmpty() } }
|
||||
@@ -215,12 +302,16 @@ private fun ReactionDetailGallery(
|
||||
}
|
||||
|
||||
if (hasLikeEvents) {
|
||||
RenderLikeGallery(
|
||||
likeEvents,
|
||||
backgroundColor,
|
||||
nav,
|
||||
accountViewModel
|
||||
)
|
||||
likeEvents.forEach {
|
||||
val reactions = remember(it.value) { it.value.toImmutableList() }
|
||||
RenderLikeGallery(
|
||||
it.key,
|
||||
reactions,
|
||||
backgroundColor,
|
||||
nav,
|
||||
accountViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -429,13 +520,15 @@ fun BoostText(baseNote: Note, grayTint: Color) {
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun LikeReaction(
|
||||
baseNote: Note,
|
||||
grayTint: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
iconSize: Dp = 20.dp,
|
||||
heartSize: Dp = 16.dp
|
||||
heartSize: Dp = 16.dp,
|
||||
iconFontSize: TextUnit = 14.sp
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -444,47 +537,75 @@ fun LikeReaction(
|
||||
Modifier.size(iconSize)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
modifier = iconButtonModifier,
|
||||
onClick = {
|
||||
if (accountViewModel.isWriteable()) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
if (accountViewModel.hasReactedTo(baseNote)) {
|
||||
accountViewModel.deleteReactionTo(baseNote)
|
||||
} else {
|
||||
accountViewModel.reactTo(baseNote)
|
||||
var wantsToChangeReactionSymbol by remember { mutableStateOf(false) }
|
||||
var wantsToReact by remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = iconButtonModifier.combinedClickable(
|
||||
role = Role.Button,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = rememberRipple(bounded = false, radius = 24.dp),
|
||||
onClick = {
|
||||
likeClick(
|
||||
baseNote,
|
||||
accountViewModel,
|
||||
scope,
|
||||
context,
|
||||
onMultipleChoices = {
|
||||
wantsToReact = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_like_posts),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
)
|
||||
},
|
||||
onLongClick = {
|
||||
wantsToChangeReactionSymbol = true
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
LikeIcon(baseNote, heartSize, grayTint, accountViewModel.userProfile())
|
||||
LikeIcon(baseNote, iconFontSize, heartSize, grayTint, accountViewModel.userProfile())
|
||||
|
||||
if (wantsToChangeReactionSymbol) {
|
||||
UpdateReactionTypeDialog({ wantsToChangeReactionSymbol = false }, accountViewModel = accountViewModel)
|
||||
}
|
||||
|
||||
if (wantsToReact) {
|
||||
ReactionChoicePopup(
|
||||
baseNote,
|
||||
accountViewModel,
|
||||
onDismiss = {
|
||||
wantsToReact = false
|
||||
},
|
||||
onChangeAmount = {
|
||||
wantsToReact = false
|
||||
wantsToChangeReactionSymbol = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LikeText(baseNote, grayTint)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LikeIcon(baseNote: Note, iconSize: Dp = 20.dp, grayTint: Color, loggedIn: User) {
|
||||
fun LikeIcon(
|
||||
baseNote: Note,
|
||||
iconFontSize: TextUnit = 14.sp,
|
||||
iconSize: Dp = 20.dp,
|
||||
grayTint: Color,
|
||||
loggedIn: User
|
||||
) {
|
||||
val reactionsState by baseNote.live().reactions.observeAsState()
|
||||
|
||||
var wasReactedByLoggedIn by remember(reactionsState) {
|
||||
mutableStateOf(false)
|
||||
var reactionType by remember(baseNote) {
|
||||
mutableStateOf<String?>(null)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = reactionsState) {
|
||||
launch(Dispatchers.Default) {
|
||||
val newWasReactedByLoggedIn = reactionsState?.note?.isReactedBy(loggedIn) == true
|
||||
if (wasReactedByLoggedIn != newWasReactedByLoggedIn) {
|
||||
wasReactedByLoggedIn = newWasReactedByLoggedIn
|
||||
val newReactionType = reactionsState?.note?.isReactedBy(loggedIn)
|
||||
if (reactionType != newReactionType) {
|
||||
reactionType = newReactionType?.take(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -493,13 +614,19 @@ fun LikeIcon(baseNote: Note, iconSize: Dp = 20.dp, grayTint: Color, loggedIn: Us
|
||||
Modifier.size(iconSize)
|
||||
}
|
||||
|
||||
if (wasReactedByLoggedIn) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_liked),
|
||||
null,
|
||||
modifier = iconModifier,
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
if (reactionType != null) {
|
||||
when (reactionType) {
|
||||
"+" -> {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_liked),
|
||||
null,
|
||||
modifier = iconModifier,
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
}
|
||||
"-" -> Text(text = "\uD83D\uDC4E", fontSize = iconFontSize)
|
||||
else -> Text(text = reactionType!!, fontSize = iconFontSize)
|
||||
}
|
||||
} else {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_like),
|
||||
@@ -520,7 +647,7 @@ fun LikeText(baseNote: Note, grayTint: Color) {
|
||||
|
||||
LaunchedEffect(key1 = reactionsState) {
|
||||
launch(Dispatchers.Default) {
|
||||
val newReactionsCount = " " + showCount(reactionsState?.note?.reactions?.size)
|
||||
val newReactionsCount = " " + showCount(reactionsState?.note?.countReactions())
|
||||
if (reactionsCount != newReactionsCount) {
|
||||
reactionsCount = newReactionsCount
|
||||
}
|
||||
@@ -534,6 +661,47 @@ fun LikeText(baseNote: Note, grayTint: Color) {
|
||||
)
|
||||
}
|
||||
|
||||
private fun likeClick(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
scope: CoroutineScope,
|
||||
context: Context,
|
||||
onMultipleChoices: () -> Unit
|
||||
) {
|
||||
if (accountViewModel.account.reactionChoices.isEmpty()) {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.no_reaction_type_setup_long_press_to_change),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
} else if (!accountViewModel.isWriteable()) {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_be_able_to_send_zaps),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
} else if (accountViewModel.account.reactionChoices.size == 1) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val reaction = accountViewModel.account.reactionChoices.first()
|
||||
if (accountViewModel.hasReactedTo(baseNote, reaction)) {
|
||||
accountViewModel.deleteReactionTo(baseNote, reaction)
|
||||
} else {
|
||||
accountViewModel.reactTo(baseNote, reaction)
|
||||
}
|
||||
}
|
||||
} else if (accountViewModel.account.reactionChoices.size > 1) {
|
||||
onMultipleChoices()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
fun ZapReaction(
|
||||
@@ -863,6 +1031,95 @@ private fun BoostTypeChoicePopup(baseNote: Note, accountViewModel: AccountViewMo
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ReactionChoicePopup(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
onChangeAmount: () -> Unit
|
||||
) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val toRemove = remember {
|
||||
baseNote.reactedBy(account.userProfile()).toSet()
|
||||
}
|
||||
|
||||
Popup(
|
||||
alignment = Alignment.BottomCenter,
|
||||
offset = IntOffset(0, -50),
|
||||
onDismissRequest = { onDismiss() }
|
||||
) {
|
||||
FlowRow(horizontalArrangement = Arrangement.Center) {
|
||||
account.reactionChoices.forEach { reactionType ->
|
||||
Button(
|
||||
modifier = Modifier.padding(horizontal = 3.dp),
|
||||
onClick = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.reactToOrDelete(
|
||||
baseNote,
|
||||
reactionType
|
||||
)
|
||||
onDismiss()
|
||||
}
|
||||
},
|
||||
shape = ButtonBorder,
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
)
|
||||
) {
|
||||
val thisModifier = remember(reactionType) {
|
||||
Modifier.combinedClickable(
|
||||
onClick = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.reactToOrDelete(
|
||||
baseNote,
|
||||
reactionType
|
||||
)
|
||||
onDismiss()
|
||||
}
|
||||
},
|
||||
onLongClick = {
|
||||
onChangeAmount()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val removeSymbol = remember(reactionType) {
|
||||
if (reactionType in toRemove) {
|
||||
" ✖"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
when (reactionType) {
|
||||
"+" -> {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_liked),
|
||||
null,
|
||||
modifier = remember { thisModifier.size(16.dp) },
|
||||
tint = Color.White
|
||||
)
|
||||
Text(text = removeSymbol, color = Color.White, textAlign = TextAlign.Center, modifier = thisModifier)
|
||||
}
|
||||
"-" -> Text(text = "\uD83D\uDC4E$removeSymbol", color = Color.White, textAlign = TextAlign.Center, modifier = thisModifier)
|
||||
else -> Text(
|
||||
"$reactionType$removeSymbol",
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = thisModifier
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ZapAmountChoicePopup(
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
@@ -21,6 +22,7 @@ import com.vitorpamplona.amethyst.model.*
|
||||
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -128,7 +130,6 @@ private fun ReplyInformation(
|
||||
fun ReplyInformationChannel(
|
||||
replyTo: ImmutableList<Note>?,
|
||||
mentions: ImmutableList<String>,
|
||||
channelHex: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
@@ -136,76 +137,40 @@ fun ReplyInformationChannel(
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
launch(Dispatchers.IO) {
|
||||
sortedMentions = mentions
|
||||
val newSortedMentions = mentions
|
||||
.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
|
||||
.toSet()
|
||||
.sortedBy { accountViewModel.account.isFollowing(it) }
|
||||
.toImmutableList()
|
||||
.ifEmpty { null }
|
||||
|
||||
if (newSortedMentions != sortedMentions) {
|
||||
sortedMentions = newSortedMentions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sortedMentions != null) {
|
||||
LoadChannel(channelHex) { channel ->
|
||||
ReplyInformationChannel(
|
||||
replyTo,
|
||||
sortedMentions,
|
||||
channel,
|
||||
onUserTagClick = {
|
||||
nav("User/${it.pubkeyHex}")
|
||||
},
|
||||
onChannelTagClick = {
|
||||
nav("Channel/${it.idHex}")
|
||||
}
|
||||
)
|
||||
}
|
||||
ReplyInformationChannel(
|
||||
replyTo,
|
||||
sortedMentions,
|
||||
onUserTagClick = {
|
||||
nav("User/${it.pubkeyHex}")
|
||||
}
|
||||
)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ReplyInformationChannel(replyTo: ImmutableList<Note>?, mentions: ImmutableList<User>?, channel: Channel, nav: (String) -> Unit) {
|
||||
ReplyInformationChannel(
|
||||
replyTo,
|
||||
mentions,
|
||||
channel,
|
||||
onUserTagClick = {
|
||||
nav("User/${it.pubkeyHex}")
|
||||
},
|
||||
onChannelTagClick = {
|
||||
nav("Channel/${it.idHex}")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ReplyInformationChannel(
|
||||
replyTo: ImmutableList<Note>?,
|
||||
mentions: ImmutableList<User>?,
|
||||
baseChannel: Channel,
|
||||
prefix: String = "",
|
||||
onUserTagClick: (User) -> Unit,
|
||||
onChannelTagClick: (Channel) -> Unit
|
||||
onUserTagClick: (User) -> Unit
|
||||
) {
|
||||
val channelState by baseChannel.live.observeAsState()
|
||||
val channel = channelState?.channel ?: return
|
||||
|
||||
val channelName = remember(channelState) {
|
||||
AnnotatedString("${channel.info.name} ")
|
||||
}
|
||||
|
||||
FlowRow() {
|
||||
Text(
|
||||
stringResource(R.string.in_channel),
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
|
||||
ClickableText(
|
||||
text = channelName,
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.lessImportantLink, fontSize = 13.sp),
|
||||
onClick = { onChannelTagClick(channel) }
|
||||
)
|
||||
|
||||
if (mentions != null && mentions.isNotEmpty()) {
|
||||
if (replyTo != null && replyTo.isNotEmpty()) {
|
||||
Text(
|
||||
@@ -225,7 +190,7 @@ fun ReplyInformationChannel(
|
||||
)
|
||||
} else if (idx < mentions.size - 1) {
|
||||
Text(
|
||||
" ${stringResource(id = R.string.add)} ",
|
||||
" ${stringResource(id = R.string.and)} ",
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.SaveButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
|
||||
class UpdateReactionTypeViewModel(val account: Account) : ViewModel() {
|
||||
var nextChoice by mutableStateOf(TextFieldValue(""))
|
||||
var reactionSet by mutableStateOf(listOf<String>())
|
||||
|
||||
fun load() {
|
||||
this.reactionSet = account.reactionChoices
|
||||
}
|
||||
|
||||
fun toListOfChoices(commaSeparatedAmounts: String): List<Long> {
|
||||
return commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 }
|
||||
}
|
||||
|
||||
fun addChoice() {
|
||||
val newValue = nextChoice.text.trim().take(2)
|
||||
reactionSet = reactionSet + newValue
|
||||
|
||||
nextChoice = TextFieldValue("")
|
||||
}
|
||||
|
||||
fun removeChoice(reaction: String) {
|
||||
reactionSet = reactionSet - reaction
|
||||
}
|
||||
|
||||
fun sendPost() {
|
||||
account.changeReactionTypes(reactionSet)
|
||||
nextChoice = TextFieldValue("")
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
nextChoice = TextFieldValue("")
|
||||
}
|
||||
|
||||
fun hasChanged(): Boolean {
|
||||
return reactionSet != account.reactionChoices
|
||||
}
|
||||
|
||||
class Factory(val account: Account) : ViewModelProvider.Factory {
|
||||
override fun <UpdateReactionTypeViewModel : ViewModel> create(modelClass: Class<UpdateReactionTypeViewModel>): UpdateReactionTypeViewModel {
|
||||
return UpdateReactionTypeViewModel(account) as UpdateReactionTypeViewModel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun UpdateReactionTypeDialog(onClose: () -> Unit, nip47uri: String? = null, accountViewModel: AccountViewModel) {
|
||||
val postViewModel: UpdateReactionTypeViewModel = viewModel(
|
||||
key = accountViewModel.userProfile().pubkeyHex,
|
||||
factory = UpdateReactionTypeViewModel.Factory(accountViewModel.account)
|
||||
)
|
||||
|
||||
LaunchedEffect(accountViewModel) {
|
||||
postViewModel.load()
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnClickOutside = false,
|
||||
decorFitsSystemWindows = false
|
||||
)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(10.dp).imePadding()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
postViewModel.cancel()
|
||||
onClose()
|
||||
})
|
||||
|
||||
SaveButton(
|
||||
onPost = {
|
||||
postViewModel.sendPost()
|
||||
onClose()
|
||||
},
|
||||
isActive = postViewModel.hasChanged()
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.animateContentSize()) {
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
postViewModel.reactionSet.forEach { reactionType ->
|
||||
Button(
|
||||
modifier = Modifier.padding(horizontal = 3.dp),
|
||||
shape = ButtonBorder,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
),
|
||||
onClick = {
|
||||
postViewModel.removeChoice(reactionType)
|
||||
}
|
||||
) {
|
||||
when (reactionType) {
|
||||
"+" -> {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_liked),
|
||||
null,
|
||||
modifier = remember { Modifier.size(16.dp) },
|
||||
tint = Color.White
|
||||
)
|
||||
Text(text = " ✖", color = Color.White, textAlign = TextAlign.Center)
|
||||
}
|
||||
"-" -> Text(text = "\uD83D\uDC4E ✖", color = Color.White, textAlign = TextAlign.Center)
|
||||
else -> Text(text = "$reactionType ✖", color = Color.White, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.new_reaction_symbol)) },
|
||||
value = postViewModel.nextChoice,
|
||||
onValueChange = {
|
||||
postViewModel.nextChoice = it
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
keyboardType = KeyboardType.Text
|
||||
),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "\uD83D\uDCAF, \uD83C\uDF89, \uD83D\uDC4E",
|
||||
color = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.padding(end = 10.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { postViewModel.addChoice() },
|
||||
shape = ButtonBorder,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
)
|
||||
) {
|
||||
Text(text = stringResource(R.string.add), color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
@@ -178,9 +179,9 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
|
||||
reactions[netDate] = (reactions[netDate] ?: 0) + 1
|
||||
takenIntoAccount.add(noteEvent.id())
|
||||
}
|
||||
} else if (noteEvent is RepostEvent) {
|
||||
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) {
|
||||
val netDate = formatDate(noteEvent.createdAt)
|
||||
} else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) {
|
||||
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey() != currentUser) {
|
||||
val netDate = formatDate(noteEvent.createdAt())
|
||||
boosts[netDate] = (boosts[netDate] ?: 0) + 1
|
||||
takenIntoAccount.add(noteEvent.id())
|
||||
}
|
||||
@@ -229,9 +230,9 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
|
||||
takenIntoAccount.add(noteEvent.id())
|
||||
hasNewElements = true
|
||||
}
|
||||
} else if (noteEvent is RepostEvent) {
|
||||
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) {
|
||||
val netDate = formatDate(noteEvent.createdAt)
|
||||
} else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) {
|
||||
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey() != currentUser) {
|
||||
val netDate = formatDate(noteEvent.createdAt())
|
||||
boosts[netDate] = (boosts[netDate] ?: 0) + 1
|
||||
takenIntoAccount.add(noteEvent.id())
|
||||
hasNewElements = true
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.PlayCircle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.tts.TextToSpeechHelper
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdButtonSizeModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
|
||||
@Composable
|
||||
@@ -59,6 +72,8 @@ private fun UserNameDisplay(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier
|
||||
)
|
||||
Spacer(StdHorzSpacer)
|
||||
DrawPlayName(bestDisplayName)
|
||||
} else if (bestDisplayName != null) {
|
||||
CreateTextWithEmoji(
|
||||
text = bestDisplayName,
|
||||
@@ -68,6 +83,8 @@ private fun UserNameDisplay(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier
|
||||
)
|
||||
Spacer(StdHorzSpacer)
|
||||
DrawPlayName(bestDisplayName)
|
||||
} else if (bestUserName != null) {
|
||||
CreateTextWithEmoji(
|
||||
text = remember { "@$bestUserName" },
|
||||
@@ -77,6 +94,8 @@ private fun UserNameDisplay(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier
|
||||
)
|
||||
Spacer(StdHorzSpacer)
|
||||
DrawPlayName(bestUserName)
|
||||
} else {
|
||||
Text(
|
||||
npubDisplay,
|
||||
@@ -87,3 +106,39 @@ private fun UserNameDisplay(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DrawPlayName(name: String) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
IconButton(
|
||||
onClick = { speak(name, context, lifecycleOwner) },
|
||||
modifier = StdButtonSizeModifier
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.PlayCircle,
|
||||
contentDescription = null,
|
||||
modifier = StdButtonSizeModifier,
|
||||
tint = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun speak(
|
||||
message: String,
|
||||
context: Context,
|
||||
owner: LifecycleOwner
|
||||
) {
|
||||
TextToSpeechHelper
|
||||
.getInstance(context)
|
||||
.registerLifecycle(owner)
|
||||
.speak(message)
|
||||
.highlight()
|
||||
.onDone {
|
||||
Log.d("TextToSpeak", "speak: done")
|
||||
}
|
||||
.onError {
|
||||
Log.d("TextToSpeak", "speak error: $it")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.screen.ZapUserSetCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -43,9 +42,9 @@ fun ZapUserSetCompose(zapSetCard: ZapUserSetCard, isInnerNote: Boolean = false,
|
||||
|
||||
LaunchedEffect(key1 = zapSetCard.createdAt()) {
|
||||
launch(Dispatchers.IO) {
|
||||
val isNew = zapSetCard.createdAt > NotificationCache.load(routeForLastRead)
|
||||
val isNew = zapSetCard.createdAt > accountViewModel.account.loadLastRead(routeForLastRead)
|
||||
|
||||
NotificationCache.markAsRead(routeForLastRead, zapSetCard.createdAt)
|
||||
accountViewModel.account.markAsRead(routeForLastRead, zapSetCard.createdAt)
|
||||
|
||||
val newBackgroundColor = if (isNew) {
|
||||
newItemColor.compositeOver(defaultBackgroundColor)
|
||||
|
||||
@@ -39,6 +39,7 @@ 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.qrcode.NIP19QrCodeScanner
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
|
||||
@Composable
|
||||
fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
|
||||
@@ -99,7 +100,7 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 35.dp)
|
||||
.padding(horizontal = Size35dp)
|
||||
) {
|
||||
QrCodeDrawer("nostr:${user.pubkeyNpub()}")
|
||||
}
|
||||
@@ -112,7 +113,7 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
|
||||
) {
|
||||
Button(
|
||||
onClick = { presenting = false },
|
||||
shape = RoundedCornerShape(35.dp),
|
||||
shape = RoundedCornerShape(Size35dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
|
||||
@@ -6,7 +6,8 @@ import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
|
||||
@Immutable
|
||||
abstract class Card() {
|
||||
@@ -33,8 +34,8 @@ class NoteCard(val note: Note) : Card() {
|
||||
}
|
||||
|
||||
@Immutable
|
||||
class ZapUserSetCard(val user: User, val zapEvents: ImmutableMap<Note, Note>) : Card() {
|
||||
val createdAt = zapEvents.maxOf { it.value.createdAt() ?: 0 }
|
||||
class ZapUserSetCard(val user: User, val zapEvents: ImmutableList<CombinedZap>) : Card() {
|
||||
val createdAt = zapEvents.maxOf { it.createdAt() ?: 0 }
|
||||
override fun createdAt(): Long {
|
||||
return createdAt
|
||||
}
|
||||
@@ -42,19 +43,28 @@ class ZapUserSetCard(val user: User, val zapEvents: ImmutableMap<Note, Note>) :
|
||||
}
|
||||
|
||||
@Immutable
|
||||
class MultiSetCard(val note: Note, val boostEvents: ImmutableList<Note>, val likeEvents: ImmutableList<Note>, val zapEvents: ImmutableMap<Note, Note>) : Card() {
|
||||
class MultiSetCard(
|
||||
val note: Note,
|
||||
val boostEvents: ImmutableList<Note>,
|
||||
val likeEvents: ImmutableList<Note>,
|
||||
val zapEvents: ImmutableList<CombinedZap>
|
||||
) : Card() {
|
||||
val maxCreatedAt = maxOf(
|
||||
zapEvents.maxOfOrNull { it.value.createdAt() ?: 0 } ?: 0,
|
||||
zapEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0,
|
||||
likeEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0,
|
||||
boostEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0
|
||||
)
|
||||
|
||||
val minCreatedAt = minOf(
|
||||
zapEvents.minOfOrNull { it.value.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE,
|
||||
zapEvents.minOfOrNull { it.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE,
|
||||
likeEvents.minOfOrNull { it.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE,
|
||||
boostEvents.minOfOrNull { it.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE
|
||||
)
|
||||
|
||||
val likeEventsByType = likeEvents.groupBy { it.event?.content() ?: "+" }.mapValues {
|
||||
it.value.toImmutableList()
|
||||
}.toImmutableMap()
|
||||
|
||||
override fun createdAt(): Long {
|
||||
return maxCreatedAt
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user