Compare commits

...

69 Commits

Author SHA1 Message Date
Vitor Pamplona
8f53537ebc v0.60.2 2023-06-19 14:43:34 -04:00
Vitor Pamplona
58c93e0cce - Redesign feed invalidations to account for changes in the follow list
- Redesign scroll to the top implementation to avoid using arguments in the navigator.
2023-06-19 14:30:15 -04:00
Vitor Pamplona
be8848fb1b Restructures Stable elements for minor performance gains. 2023-06-18 18:57:21 -04:00
Vitor Pamplona
a3efd15b95 v0.60.1 2023-06-18 14:43:59 -04:00
Vitor Pamplona
a1930c232b Formatting 2023-06-18 14:42:26 -04:00
Vitor Pamplona
58a4fb18ae BugFixes for NPE 2023-06-18 14:40:46 -04:00
Vitor Pamplona
be4870da1a Support for Per-post Zap-raisers. 2023-06-18 14:24:52 -04:00
Vitor Pamplona
d2f6317d5c v0.60.0 2023-06-17 21:09:11 -04:00
Vitor Pamplona
05fb408842 - Adds Support for Kind 16
- Migrates citations to `e` when they were supposed to be `a`
- Removes original tags from Reposts
-
2023-06-17 21:08:33 -04:00
Vitor Pamplona
611305a406 Basic Support for live activities 2023-06-17 18:37:34 -04:00
Vitor Pamplona
5b856af19b - Activates support for m3u8 streaming
- BugFix on restarting the video when pressing mute.
2023-06-17 18:07:06 -04:00
Vitor Pamplona
ce5106684f Moves the last seen time saved per route to an account property in order to speed up loading and facilitate multithreading in the app. 2023-06-17 17:28:14 -04:00
Vitor Pamplona
f0fe2dfc17 Don't move the feed up when new follow list is updated 2023-06-17 15:56:46 -04:00
Vitor Pamplona
fb5fad5d02 Fixing background color for messages when it's considered a new notification. 2023-06-17 10:53:37 -04:00
Vitor Pamplona
6314e3fcb2 Formatting 2023-06-16 19:59:12 -04:00
Vitor Pamplona
da62760fd7 Moving static sizes to the Shape class 2023-06-16 19:57:25 -04:00
Vitor Pamplona
e3eae80d4c Increasing performance of the follow/follower count 2023-06-16 19:11:14 -04:00
Vitor Pamplona
3cd0828cba Bugfix for home feed scrolling to the top when a new follower updates the cache. 2023-06-16 18:47:10 -04:00
Vitor Pamplona
806b86f950 Testing a release build script with deamon 2023-06-16 16:36:20 -04:00
Vitor Pamplona
c931e2cbee v0.59.1 2023-06-16 16:21:23 -04:00
Vitor Pamplona
4e57eed5d8 Adds Name Playback on messages as well. 2023-06-16 16:18:06 -04:00
Vitor Pamplona
8e725259eb BugFix for Messages being marked as read from Notifications. 2023-06-16 16:17:42 -04:00
Vitor Pamplona
b48d2df8d2 v0.59.0 2023-06-16 15:23:44 -04:00
Vitor Pamplona
dbf34ae457 Text to speech for usernames 2023-06-16 15:22:44 -04:00
Vitor Pamplona
74be724835 Hide reposts of posts from people you already follow if the repost time is within the hour of the original post (not that far in the feed). 2023-06-16 14:31:52 -04:00
Vitor Pamplona
012c7a7105 BugFix for feed not updating after change in follows. 2023-06-16 14:30:22 -04:00
Vitor Pamplona
e50dff3b62 BigFix for some reposts not showing up in the feed. 2023-06-16 14:15:53 -04:00
Vitor Pamplona
ad5a4d0b59 v0.58.3 2023-06-15 18:51:39 -04:00
Vitor Pamplona
3116ea1ad9 Fixes reaction sizes and alignments. 2023-06-15 12:21:44 -04:00
Vitor Pamplona
72e474c9ec Bug fix for when deleting the reaction event reverting to the old like heart. 2023-06-15 09:37:09 -04:00
Vitor Pamplona
fe9e85c282 v0.58.2 2023-06-14 20:58:51 -04:00
Vitor Pamplona
01ac90fbec BugFix for active like all the time 2023-06-14 20:55:46 -04:00
Vitor Pamplona
dc079941e7 v0.58.1 2023-06-14 20:28:47 -04:00
Vitor Pamplona
9a7a94cb77 Cropping long reactions to two chars for emojis 2023-06-14 20:27:46 -04:00
Vitor Pamplona
a44703cb49 v0.58.0 2023-06-14 16:50:54 -04:00
Vitor Pamplona
52bd6b4443 Custom reactions 2023-06-14 16:49:51 -04:00
Vitor Pamplona
a9e1ce5aec Removing modifiers from remembers and into global singletons. 2023-06-14 14:36:10 -04:00
Vitor Pamplona
639fdb174d BugFix Add -> And 2023-06-14 14:32:55 -04:00
Vitor Pamplona
3cd02ee6cf Merge remote-tracking branch 'origin/HEAD' 2023-06-14 13:49:10 -04:00
Vitor Pamplona
52199fe281 Starts to use getXIfExists functions to preload objects 2023-06-14 13:48:58 -04:00
Vitor Pamplona
5616fa52d1 Simplifies URL Preview call 2023-06-14 13:48:35 -04:00
Vitor Pamplona
7241c8bd9f Merge pull request #454 from ZsZolee/main
Small Hungarian translation
2023-06-14 10:55:16 -04:00
Zoltan
991131f8cd Small Hungarian translation 2023-06-14 09:35:36 +02:00
Vitor Pamplona
a24c95070c v0.57.0 2023-06-13 17:16:20 -04:00
Vitor Pamplona
a346d31554 Finally adds markers to kind 1s 2023-06-13 17:08:50 -04:00
Vitor Pamplona
9b5c7ad23f - Moves zaps from a Map<Request,Response> to a List<CustomClass> to avoid recompositions due to the use of the unstable Pair object
- Creates the class ZapAmountCommentNotification to be used instead of the unstable Triple class.
2023-06-13 16:33:52 -04:00
Vitor Pamplona
a01a060abc Uses animateItemPlacement to show new Notes and update the size of current ones. 2023-06-13 16:31:23 -04:00
Vitor Pamplona
1cd39e0d8f Removes the use of by on the quick action menu's state because it was triggering recompositions 2023-06-13 16:30:37 -04:00
Vitor Pamplona
305ecc6a2e New update removes access to the backQueue 2023-06-13 16:29:21 -04:00
Vitor Pamplona
858f94ae74 Moves icon sizes in the bottom row to remember functions 2023-06-13 16:28:42 -04:00
Vitor Pamplona
fc224d99e3 Updates dependencies. 2023-06-13 10:52:29 -04:00
Vitor Pamplona
b8584964d1 Activates build cache 2023-06-13 10:47:08 -04:00
Vitor Pamplona
fbd0c96a8a Merge remote-tracking branch 'origin/HEAD' 2023-06-12 16:58:32 -04:00
Vitor Pamplona
e2251f9c86 Removing the minimum size for rows in the feed due to Hidden posts from the user's block list. 2023-06-12 16:58:20 -04:00
Vitor Pamplona
eaaacbf218 Turns NIP-05 verification symbol into a State variable to help correctly rendering updates. 2023-06-12 16:56:24 -04:00
Vitor Pamplona
243fbeed44 Remembers some variables on NoteCompose 2023-06-12 16:55:51 -04:00
Vitor Pamplona
d62bab1b08 Makes relay badge list in chatroom Immutable 2023-06-12 16:55:27 -04:00
Vitor Pamplona
70e068ed17 Breaks down user renderer compositions 2023-06-12 16:55:08 -04:00
Vitor Pamplona
824e8795d5 Formatting 2023-06-12 16:54:45 -04:00
Vitor Pamplona
f768a09bd8 Removes unused variable from Translation results 2023-06-12 16:54:15 -04:00
Vitor Pamplona
1a69a5a4c7 Merge pull request #449 from greenart7c3/main
add option to opt-out from automatic spam and report filters
2023-06-12 16:37:03 -04:00
Vitor Pamplona
b9cc3781bb Merge pull request #451 from licaon-kter/patch-1
Typo fix
2023-06-12 11:10:22 -04:00
Licaon_Kter
97964ed65c Typo fix 2023-06-12 15:04:25 +00:00
Vitor Pamplona
42a1d157bf Fixes draft note in privacy.md 2023-06-12 10:25:03 -04:00
Vitor Pamplona
e27efbc9c2 Privacy Policy:
- Removes restrictions of use from the F-Droid version given that F-Droid is taking that legal liability.
- Adds information about the Image Servers offered through the app.
- Adds privacy warnings from the readme to this document.
2023-06-12 10:23:29 -04:00
greenart7c3
57a5d0b618 Merge branch 'main' into main 2023-06-12 07:39:30 -03:00
greenart7c3
8f668cb5fb separated spam and report filter options 2023-06-12 07:38:32 -03:00
Vitor Pamplona
df5ea6f741 Adjusts the size and alignment of relay icons in the video feed 2023-06-11 10:59:18 -04:00
greenart7c3
f0638b1766 add option to opt-out from automatic spam and report filters 2023-06-09 08:28:05 -03:00
116 changed files with 3085 additions and 960 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -13,8 +13,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 205
versionName "0.56.5"
versionCode 215
versionName "0.60.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

View File

@@ -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

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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) {

View File

@@ -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())
@@ -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,14 @@ 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 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 +611,7 @@ class Account(
mentions = mentionsHex,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
privateKey = loggedIn.privKey!!,
advertiseNip18 = false
)
@@ -843,6 +878,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 +1098,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 +1123,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 +1131,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 +1168,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 {

View File

@@ -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

View File

@@ -51,6 +51,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 +70,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
}
@@ -167,9 +186,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 +247,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 +318,35 @@ 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())
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 +357,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 +376,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 +396,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 +416,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 +460,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
@@ -528,6 +622,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 +661,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)
@@ -1061,6 +1171,7 @@ object LocalCache {
is FileStorageEvent -> consume(event, relay)
is FileStorageHeaderEvent -> consume(event, relay)
is HighlightEvent -> consume(event, relay)
is LiveActivitiesEvent -> consume(event, relay)
is LnZapEvent -> {
event.zapRequest?.let {
verifyAndConsume(it, relay)
@@ -1085,6 +1196,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 -> {

View File

@@ -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
@@ -96,7 +97,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 +110,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 +135,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 +216,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 +261,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 +289,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 +373,7 @@ 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
}
fun hasZapped(loggedIn: User): Boolean {
@@ -361,7 +385,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 +401,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 {

View File

@@ -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)

View File

@@ -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()

View File

@@ -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() ||

View File

@@ -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) {

View File

@@ -88,6 +88,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
PollNoteEvent.kind,
ReactionEvent.kind,
RepostEvent.kind,
GenericRepostEvent.kind,
ReportEvent.kind,
LnZapEvent.kind,
LnZapPaymentResponseEvent.kind,

View File

@@ -3,10 +3,12 @@ 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.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 +60,16 @@ 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
),
authors = followSet,
limit = 400,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList

View File

@@ -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,

View File

@@ -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
)

View File

@@ -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)

View File

@@ -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,9 @@ 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)
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)

View File

@@ -38,4 +38,5 @@ interface EventInterface {
fun zapAddress(): String?
fun isSensitive(): Boolean
fun zapraiserAmount(): Long?
}

View File

@@ -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())
}
}
}

View File

@@ -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())
}
}
}

View File

@@ -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)

View File

@@ -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())

View File

@@ -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()))

View File

@@ -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)

View File

@@ -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)
}
})
}

View File

@@ -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()
}
}
}
}

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -74,6 +74,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 +110,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 +138,34 @@ 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)
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 +239,8 @@ open class NewPostViewModel() : ViewModel() {
closedAt = null
wantsInvoice = false
wantsZapraiser = false
zapRaiserAmount = null
wantsForwardZapTo = false
wantsToMarkAsSensitive = false
@@ -316,8 +346,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) {

View File

@@ -52,6 +52,7 @@ 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 java.lang.Math.round
@@ -477,7 +478,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 +489,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
)

View File

@@ -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

View File

@@ -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,

View File

@@ -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()
}
}
}

View File

@@ -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
}
}
}
}

View File

@@ -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()
}

View File

@@ -53,6 +53,7 @@ import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.Player
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.vitorpamplona.amethyst.VideoCache
@@ -101,6 +102,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 +114,7 @@ fun VideoView(
}
exoPlayerData?.let {
VideoView(videoUri, description, it, thumb, onDialog)
VideoView(videoUri, description, it, defaultToStart, thumb, onDialog)
}
DisposableEffect(Unit) {
@@ -127,6 +129,7 @@ fun VideoView(
videoUri: String,
description: String? = null,
exoPlayerData: VideoPlayer,
defaultToStart: Boolean = false,
thumb: VideoThumb? = null,
onDialog: ((Boolean) -> Unit)? = null
) {
@@ -137,9 +140,15 @@ 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")) {
setMediaSource(
HlsMediaSource.Factory(VideoCache.get()).createMediaSource(
media
)
)
} else {
setMediaSource(
ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(

View File

@@ -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
)
}
}
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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>
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -7,6 +7,11 @@ 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))
}

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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) }
}

View File

@@ -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> {

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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
@@ -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))
}
}
}

View File

@@ -60,51 +60,10 @@ 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,
@@ -124,25 +83,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 +95,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) })

View File

@@ -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() ?: "--")
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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("")

View File

@@ -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)

View File

@@ -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
)
}
}

View File

@@ -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()
@@ -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) {

View File

@@ -26,6 +26,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
@@ -51,7 +52,6 @@ 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
@@ -70,9 +70,13 @@ 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.RelayIconFilter
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
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.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -179,11 +183,11 @@ fun ChatroomMessageCompose(
LaunchedEffect(key1 = routeForLastRead) {
routeForLastRead?.let {
scope.launch(Dispatchers.IO) {
val lastTime = NotificationCache.load(it)
val lastTime = accountViewModel.account.loadLastRead(it)
val createdAt = note.createdAt()
if (createdAt != null) {
NotificationCache.markAsRead(it, createdAt)
accountViewModel.account.markAsRead(it, createdAt)
}
}
}
@@ -494,22 +498,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 +531,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))
}

View File

@@ -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
)

View File

@@ -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)
}
}
}
}

View File

@@ -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

View File

@@ -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
@@ -99,7 +98,9 @@ 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.LiveActivitiesEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.Participant
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
@@ -142,6 +143,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
@@ -423,11 +425,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 +521,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 +555,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 +563,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 +636,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 +649,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 +681,10 @@ private fun RenderNoteRow(
RenderPinListEvent(baseNote, backgroundColor, accountViewModel, nav)
}
is LiveActivitiesEvent -> {
RenderLiveActivityEvent(baseNote, accountViewModel, nav)
}
is PrivateDmEvent -> {
RenderPrivateMessage(
baseNote,
@@ -842,7 +851,7 @@ fun RenderPoll(
)
}
var hashtags = remember { noteEvent.hashtags().toImmutableList() }
val hashtags = remember { noteEvent.hashtags().toImmutableList() }
DisplayUncitedHashtags(hashtags, eventContent, nav)
}
}
@@ -948,7 +957,7 @@ fun RenderAppDefinition(
Row(
modifier = Modifier
.height(35.dp)
.height(Size35dp)
.padding(bottom = 3.dp)
) {
}
@@ -1355,7 +1364,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 +1373,7 @@ private fun RenderBadgeAward(
UserPicture(
baseUser = user,
accountViewModel = accountViewModel,
size = 35.dp
size = Size35dp
)
}
}
@@ -1417,7 +1426,7 @@ private fun RenderReaction(
}
@Composable
private fun RenderRepost(
fun RenderRepost(
note: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
@@ -1680,18 +1689,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 +1759,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 +1783,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 +1833,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 +1844,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 +1860,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 +2070,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 +2472,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 +2660,7 @@ private fun RelayBadges(baseNote: Note) {
}
}
Spacer(Modifier.height(10.dp))
Spacer(remember { Modifier.height(10.dp) })
if (expanded) {
VerticalRelayPanelWithFlow(lazyRelayList)
@@ -2613,29 +2704,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 +2877,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) }

View File

@@ -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

View File

@@ -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(

View File

@@ -225,7 +225,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
)

View File

@@ -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)
}
}
}
}
}
}
}
}

View File

@@ -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

View File

@@ -1,19 +1,30 @@
package com.vitorpamplona.amethyst.ui.note
import android.content.Context
import android.util.Log
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.placeholderText
@Composable
@@ -59,6 +70,7 @@ private fun UserNameDisplay(
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
DrawPlayName(bestDisplayName)
} else if (bestDisplayName != null) {
CreateTextWithEmoji(
text = bestDisplayName,
@@ -68,6 +80,7 @@ private fun UserNameDisplay(
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
DrawPlayName(bestDisplayName)
} else if (bestUserName != null) {
CreateTextWithEmoji(
text = remember { "@$bestUserName" },
@@ -77,6 +90,7 @@ private fun UserNameDisplay(
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
DrawPlayName(bestUserName)
} else {
Text(
npubDisplay,
@@ -87,3 +101,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")
}
}

View File

@@ -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)

View File

@@ -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),

View File

@@ -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
}

View File

@@ -2,11 +2,11 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
@@ -153,6 +153,7 @@ fun RenderCardFeed(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun FeedLoaded(
state: CardFeedState.Loaded,
@@ -161,12 +162,6 @@ private fun FeedLoaded(
nav: (String) -> Unit,
routeForLastRead: String
) {
val defaultModifier = remember {
Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 100.dp)
}
LazyColumn(
modifier = remember { Modifier.fillMaxSize() },
contentPadding = remember {
@@ -178,6 +173,11 @@ private fun FeedLoaded(
state = listState
) {
itemsIndexed(state.feed.value, key = { _, item -> item.id() }) { _, item ->
val defaultModifier = remember {
Modifier
.fillMaxWidth().animateItemPlacement()
}
Row(defaultModifier) {
RenderCardItem(item, accountViewModel, nav, routeForLastRead)
}

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.screen
import android.util.Log
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
@@ -14,6 +15,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
@@ -25,7 +27,6 @@ import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -55,6 +56,8 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
val scrollToTop = _scrollToTop.asStateFlow()
var scrolltoTopPending = false
private var lastFeedKey: String? = null
fun sendToTop() {
if (scrolltoTopPending) return
@@ -83,6 +86,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
checkNotInMainThread()
val notes = localFilter.feed()
lastFeedKey = localFilter.feedKey()
val thisAccount = (localFilter as? NotificationFeedFilter)?.account
val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null
@@ -132,8 +136,8 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
// val reactionCards = reactionsPerEvent.map { LikeSetCard(it.key, it.value) }
val zapsPerUser = mutableMapOf<User, MutableMap<Note, Note>>()
val zapsPerEvent = mutableMapOf<Note, MutableMap<Note, Note>>()
val zapsPerUser = mutableMapOf<User, MutableList<CombinedZap>>()
val zapsPerEvent = mutableMapOf<Note, MutableList<CombinedZap>>()
notes
.filter { it.event is LnZapEvent }
.forEach { zapEvent ->
@@ -143,30 +147,26 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
if (zapRequest != null) {
// var newZapRequestEvent = LocalCache.checkPrivateZap(zapRequest.event as Event)
// zapRequest.event = newZapRequestEvent
zapsPerEvent.getOrPut(zappedPost, { mutableMapOf() }).put(zapRequest, zapEvent)
zapsPerEvent.getOrPut(zappedPost, { mutableListOf() }).add(CombinedZap(zapRequest, zapEvent))
}
} else {
val event = (zapEvent.event as LnZapEvent)
val author = event.zappedAuthor().mapNotNull {
LocalCache.checkGetOrCreateUser(
it
)
}.firstOrNull()
val author = event.zappedAuthor().firstNotNullOfOrNull {
LocalCache.users[it] // don't create user if it doesn't exist
}
if (author != null) {
val zapRequest = author.zaps.filter { it.value == zapEvent }.keys.firstOrNull()
if (zapRequest != null) {
zapsPerUser.getOrPut(author, { mutableMapOf() })
.put(zapRequest, zapEvent)
zapsPerUser.getOrPut(author, { mutableListOf() })
.add(CombinedZap(zapRequest, zapEvent))
}
}
}
}
// val zapCards = zapsPerEvent.map { ZapSetCard(it.key, it.value) }
val boostsPerEvent = mutableMapOf<Note, MutableList<Note>>()
notes
.filter { it.event is RepostEvent }
.filter { it.event is RepostEvent || it.event is GenericRepostEvent }
.forEach {
val boostedPost = it.replyTo?.lastOrNull() { it.event !is ChannelMetadataEvent && it.event !is ChannelCreateEvent }
if (boostedPost != null) {
@@ -178,9 +178,9 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
val multiCards = allBaseNotes.map { baseNote ->
val boostsInCard = boostsPerEvent[baseNote] ?: emptyList()
val reactionsInCard = reactionsPerEvent[baseNote] ?: emptyList()
val zapsInCard = zapsPerEvent[baseNote] ?: emptyMap()
val zapsInCard = zapsPerEvent[baseNote] ?: emptyList()
val singleList = (boostsInCard + zapsInCard.values + reactionsInCard)
val singleList = (boostsInCard + zapsInCard.map { it.response } + reactionsInCard)
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
@@ -189,7 +189,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
baseNote,
boostsInCard.filter { it in chunk }.toImmutableList(),
reactionsInCard.filter { it in chunk }.toImmutableList(),
zapsInCard.filter { it.value in chunk }.toImmutableMap()
zapsInCard.filter { it.response in chunk }.toImmutableList()
)
}
}.flatten()
@@ -197,11 +197,11 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
val userZaps = zapsPerUser.map {
ZapUserSetCard(
it.key,
it.value.toImmutableMap()
it.value.toImmutableList()
)
}
val textNoteCards = notes.filter { it.event !is ReactionEvent && it.event !is RepostEvent && it.event !is LnZapEvent }.map {
val textNoteCards = notes.filter { it.event !is ReactionEvent && it.event !is RepostEvent && it.event !is GenericRepostEvent && it.event !is LnZapEvent }.map {
if (it.event is PrivateDmEvent) {
MessageSetCard(it)
} else if (it.event is BadgeAwardEvent) {
@@ -236,6 +236,8 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null
if (lastNotesCopy != null && localFilter is AdditiveFeedFilter && oldNotesState is CardFeedState.Loaded) {
lastFeedKey = localFilter.feedKey()
val filteredNewList = localFilter.applyFilter(newItems)
if (filteredNewList.isEmpty()) return
@@ -282,15 +284,18 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
@OptIn(ExperimentalTime::class)
fun invalidateDataAndSendToTop(ignoreIfDoing: Boolean = false) {
bundler.invalidate(ignoreIfDoing) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
val (value, elapsed) = measureTimedValue {
refreshSuspended()
sendToTop()
fun checkKeysInvalidateDataAndSendToTop() {
if (lastFeedKey != localFilter.feedKey()) {
clear()
bundler.invalidate(false) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
val (value, elapsed) = measureTimedValue {
refreshSuspended()
sendToTop()
}
Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed")
}
Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed")
}
}
@@ -346,3 +351,9 @@ fun <T> equalImmutableLists(list1: ImmutableList<T>, list2: ImmutableList<T>): B
}
return true
}
@Immutable
data class CombinedZap(val request: Note, val response: Note) {
fun createdAt() = response.createdAt()
fun idHex() = response.idHex
}

View File

@@ -26,7 +26,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.note.ChatroomCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -103,7 +102,7 @@ private fun FeedLoaded(
"Room/$roomUser"
}
NotificationCache.markAsRead(route, it.createdAt())
accountViewModel.account.markAsRead(route, it.createdAt())
}
}
markAsRead.value = false

View File

@@ -2,12 +2,12 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
@@ -40,14 +40,13 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
fun RefresheableFeedView(
viewModel: FeedViewModel,
routeForLastRead: String?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
enablePullRefresh: Boolean = true,
scrollStateKey: String? = null,
enablePullRefresh: Boolean = true
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
RefresheableView(viewModel, enablePullRefresh) {
SaveableFeedState(viewModel, accountViewModel, nav, routeForLastRead, scrollStateKey)
SaveableFeedState(viewModel, routeForLastRead, scrollStateKey, accountViewModel, nav)
}
}
@@ -82,10 +81,10 @@ fun RefresheableView(
@Composable
private fun SaveableFeedState(
viewModel: FeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
routeForLastRead: String?,
scrollStateKey: String? = null
scrollStateKey: String? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
SaveableFeedState(viewModel, scrollStateKey) { listState ->
RenderFeed(viewModel, accountViewModel, listState, nav, routeForLastRead)
@@ -168,6 +167,7 @@ private fun WatchScrollToTop(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun FeedLoaded(
state: FeedState.Loaded,
@@ -188,7 +188,12 @@ private fun FeedLoaded(
state = listState
) {
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item ->
Row(Modifier.fillMaxWidth().defaultMinSize(minHeight = 75.dp)) {
val defaultModifier = remember {
Modifier
.fillMaxWidth().animateItemPlacement()
}
Row(defaultModifier) {
NoteCompose(
item,
routeForLastRead = routeForLastRead,

View File

@@ -138,6 +138,8 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
val scrollToTop = _scrollToTop.asStateFlow()
var scrolltoTopPending = false
private var lastFeedKey: String? = null
fun sendToTop() {
if (scrolltoTopPending) return
@@ -151,10 +153,6 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
scrolltoTopPending = false
}
fun newListFromDataSource(): ImmutableList<Note> {
return localFilter.loadTop().toImmutableList()
}
private fun refresh() {
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
@@ -165,7 +163,8 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
fun refreshSuspended() {
checkNotInMainThread()
val notes = newListFromDataSource()
val notes = localFilter.loadTop().toImmutableList()
lastFeedKey = localFilter.feedKey()
val oldNotesState = _feedContent.value
if (oldNotesState is FeedState.Loaded) {
@@ -197,11 +196,13 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
if (localFilter is AdditiveFeedFilter) {
if (oldNotesState is FeedState.Loaded) {
val newList = localFilter.updateListWith(oldNotesState.feed.value, newItems.toSet()).toImmutableList()
lastFeedKey = localFilter.feedKey()
if (!equalImmutableLists(newList, oldNotesState.feed.value)) {
updateFeed(newList)
}
} else if (oldNotesState is FeedState.Empty) {
val newList = localFilter.updateListWith(emptyList(), newItems.toSet()).toImmutableList()
lastFeedKey = localFilter.feedKey()
if (newList.isNotEmpty()) {
updateFeed(newList)
}
@@ -226,12 +227,14 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
}
}
fun invalidateDataAndSendToTop(ignoreIfDoing: Boolean = false) {
bundler.invalidate(ignoreIfDoing) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
sendToTop()
fun checkKeysInvalidateDataAndSendToTop() {
if (lastFeedKey != localFilter.feedKey()) {
bundler.invalidate(false) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
sendToTop()
}
}
}

View File

@@ -57,12 +57,14 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.AppDefinitionEvent
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
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.PeopleListEvent
import com.vitorpamplona.amethyst.service.model.PinListEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.RelaySetEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.note.*
import com.vitorpamplona.amethyst.ui.note.BadgeDisplay
@@ -401,6 +403,8 @@ fun NoteMaster(
accountViewModel,
nav
)
} else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) {
RenderRepost(baseNote, backgroundColor, accountViewModel, nav)
} else if (noteEvent is PollNoteEvent) {
val canPreview = note.author == account.userProfile() ||
(note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) ||

View File

@@ -15,6 +15,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.LnZapEvent
@@ -30,6 +31,9 @@ import java.util.Locale
class AccountViewModel(val account: Account) : ViewModel() {
val accountLiveData: LiveData<AccountState> = account.live.map { it }
val accountLanguagesLiveData: LiveData<AccountState> = account.liveLanguages.map { it }
val accountLastReadLiveData: LiveData<AccountState> = account.liveLastRead.map { it }
val userFollows: LiveData<UserState> = account.userProfile().live().follows.map { it }
fun isWriteable(): Boolean {
return account.isWriteable()
@@ -39,16 +43,25 @@ class AccountViewModel(val account: Account) : ViewModel() {
return account.userProfile()
}
fun reactTo(note: Note) {
account.reactTo(note)
fun reactTo(note: Note, reaction: String) {
account.reactTo(note, reaction)
}
fun hasReactedTo(baseNote: Note): Boolean {
return account.hasReacted(baseNote)
fun reactToOrDelete(note: Note, reaction: String) {
val currentReactions = account.reactionTo(note, reaction)
if (currentReactions.isNotEmpty()) {
account.delete(currentReactions)
} else {
account.reactTo(note, reaction)
}
}
fun deleteReactionTo(note: Note) {
account.delete(account.reactionTo(note))
fun hasReactedTo(baseNote: Note, reaction: String): Boolean {
return account.hasReacted(baseNote, reaction)
}
fun deleteReactionTo(note: Note, reaction: String) {
account.delete(account.reactionTo(note, reaction))
}
fun hasBoosted(baseNote: Note): Boolean {

View File

@@ -73,8 +73,18 @@ fun BookmarkListScreen(accountViewModel: AccountViewModel, nav: (String) -> Unit
}
HorizontalPager(pageCount = 2, state = pagerState) { page ->
when (page) {
0 -> RefresheableFeedView(privateFeedViewModel, null, accountViewModel, nav)
1 -> RefresheableFeedView(publicFeedViewModel, null, accountViewModel, nav)
0 -> RefresheableFeedView(
privateFeedViewModel,
null,
accountViewModel = accountViewModel,
nav = nav
)
1 -> RefresheableFeedView(
publicFeedViewModel,
null,
accountViewModel = accountViewModel,
nav = nav
)
}
}
}

View File

@@ -80,6 +80,7 @@ import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -396,11 +397,11 @@ fun ChannelHeader(baseChannel: Channel, accountViewModel: AccountViewModel, nav:
Row(verticalAlignment = Alignment.CenterVertically) {
RobohashAsyncImageProxy(
robot = channel.idHex,
model = ResizeImage(channel.profilePicture(), 35.dp),
model = ResizeImage(channel.profilePicture(), Size35dp),
contentDescription = context.getString(R.string.profile_image),
modifier = Modifier
.width(35.dp)
.height(35.dp)
.width(Size35dp)
.height(Size35dp)
.clip(shape = CircleShape)
)
@@ -429,7 +430,7 @@ fun ChannelHeader(baseChannel: Channel, accountViewModel: AccountViewModel, nav:
Row(
modifier = Modifier
.height(35.dp)
.height(Size35dp)
.padding(bottom = 3.dp)
) {
ChannelActionOptions(channel, accountViewModel, nav)

View File

@@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -198,7 +199,7 @@ fun ChatroomHeader(baseUser: User, accountViewModel: AccountViewModel, nav: (Str
UserPicture(
baseUser = baseUser,
accountViewModel = accountViewModel,
size = 35.dp
size = Size35dp
)
Column(modifier = Modifier.padding(start = 10.dp)) {

View File

@@ -73,7 +73,12 @@ fun HashtagScreen(tag: String?, accountViewModel: AccountViewModel, nav: (String
modifier = Modifier.padding(vertical = 0.dp)
) {
HashtagHeader(tag, accountViewModel)
RefresheableFeedView(feedViewModel, null, accountViewModel, nav)
RefresheableFeedView(
feedViewModel,
null,
accountViewModel = accountViewModel,
nav = nav
)
}
}
}

View File

@@ -2,10 +2,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.Checkbox
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Tab
import androidx.compose.material.TabRow
@@ -15,7 +17,11 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
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
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
@@ -23,6 +29,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.NostrHiddenAccountsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView
@@ -67,6 +74,34 @@ fun HiddenUsersScreen(
Column(modifier = Modifier.padding(start = 10.dp, end = 10.dp)) {
val pagerState = rememberPagerState()
val coroutineScope = rememberCoroutineScope()
var warnAboutReports by remember { mutableStateOf(accountViewModel.account.warnAboutPostsWithReports) }
var filterSpam by remember { mutableStateOf(accountViewModel.account.filterSpamFromStrangers) }
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = warnAboutReports,
onCheckedChange = {
warnAboutReports = it
accountViewModel.account.updateOptOutOptions(warnAboutReports, filterSpam)
LocalPreferences.saveToEncryptedStorage(accountViewModel.account)
}
)
Text(stringResource(R.string.warn_when_posts_have_reports_from_your_follows))
}
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = filterSpam,
onCheckedChange = {
filterSpam = it
accountViewModel.account.updateOptOutOptions(warnAboutReports, filterSpam)
LocalPreferences.saveToEncryptedStorage(accountViewModel.account)
}
)
Text(stringResource(R.string.filter_spam_from_strangers))
}
TabRow(
backgroundColor = MaterialTheme.colors.background,

View File

@@ -123,10 +123,10 @@ private fun HomePages(
HorizontalPager(pageCount = 2, state = pagerState) { page ->
RefresheableFeedView(
viewModel = tabs[page].viewModel,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = tabs[page].routeForLastRead,
scrollStateKey = tabs[page].scrollStateKey
scrollStateKey = tabs[page].scrollStateKey,
accountViewModel = accountViewModel,
nav = nav
)
}
}
@@ -138,22 +138,27 @@ fun WatchAccountForHomeScreen(
accountViewModel: AccountViewModel
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
var firstTime by remember(accountViewModel) { mutableStateOf(true) }
val followState by accountViewModel.account.userProfile().live().follows.observeAsState()
LaunchedEffect(accountViewModel, accountState?.account?.defaultHomeFollowList) {
// Only invalidate when things change. Not in the first run
if (firstTime) {
firstTime = false
} else {
launch(Dispatchers.IO) {
NostrHomeDataSource.invalidateFilters()
homeFeedViewModel.invalidateDataAndSendToTop(true)
repliesFeedViewModel.invalidateDataAndSendToTop(true)
}
launch(Dispatchers.IO) {
NostrHomeDataSource.invalidateFilters()
homeFeedViewModel.checkKeysInvalidateDataAndSendToTop()
repliesFeedViewModel.checkKeysInvalidateDataAndSendToTop()
}
}
LaunchedEffect(followState) {
launch(Dispatchers.IO) {
NostrHomeDataSource.invalidateFilters()
}
}
}
@Immutable
class TabItem(val resource: Int, val viewModel: FeedViewModel, val routeForLastRead: String, val scrollStateKey: String)
class TabItem(
val resource: Int,
val viewModel: FeedViewModel,
val routeForLastRead: String,
val scrollStateKey: String
)

Some files were not shown because too many files have changed in this diff Show More