Compare commits

...

90 Commits

Author SHA1 Message Date
Vitor Pamplona
cfbc4536c6 v0.66.3 2023-07-07 17:15:02 -04:00
Vitor Pamplona
dcf825c43e Slightly faster reactions and zap icons 2023-07-07 17:13:20 -04:00
Vitor Pamplona
8ae05d9964 Formatting 2023-07-07 17:11:47 -04:00
Vitor Pamplona
f11faa87b1 Fix from full height to full screen 2023-07-07 17:09:42 -04:00
Vitor Pamplona
bfbaabd7e8 Fixes translations from Japanese with urls breaking the url 2023-07-07 15:30:51 -04:00
Vitor Pamplona
d35d6a44fe Adding content-sensitivity warnings for LiveStreams 2023-07-07 15:17:20 -04:00
Vitor Pamplona
9c7265565e Avoids triggering the Notification bubble when the live event is simply updated. 2023-07-07 14:40:07 -04:00
Vitor Pamplona
293b023add - Adds hashtags to the description of communities and channels
- Displays descriptions with Translations.
2023-07-07 14:31:04 -04:00
Vitor Pamplona
bee5c1bfa6 v0.66.2 2023-07-07 12:29:22 -04:00
Vitor Pamplona
c93a6dffc4 Creates a fallback for Android9 on OPPO, which doesn't seem able to parse the Japanese regex. 2023-07-07 12:27:57 -04:00
Vitor Pamplona
60dadc0088 BugFix for invalid URLs crashing the app. 2023-07-07 12:15:28 -04:00
Vitor Pamplona
a26c7e3d97 Fixes caching of time display. 2023-07-07 09:45:21 -04:00
Vitor Pamplona
9e2b2cedd6 v0.66.1 2023-07-06 21:10:44 -04:00
Vitor Pamplona
636d59659d Activates reactions and zaps to the channel and community headers. 2023-07-06 21:04:07 -04:00
Vitor Pamplona
63e8391893 Allows notification to the channel headers 2023-07-06 21:03:51 -04:00
Vitor Pamplona
2f354e4b81 Adds EOSE treatment for the Discovery datasource 2023-07-06 21:03:36 -04:00
Vitor Pamplona
b3a369d91b Removes top bar for Community and Hashtag posts 2023-07-06 21:03:05 -04:00
Vitor Pamplona
4406532994 Loads the last 50 messages per following chat. 2023-07-06 21:02:47 -04:00
Vitor Pamplona
8fd5176e22 Makes sure all services stop when the app pauses. 2023-07-06 21:02:18 -04:00
Vitor Pamplona
e3396065c6 Reducing size of the discovery tab strings. 2023-07-06 17:38:55 -04:00
Vitor Pamplona
84600c016e Fixing for emoji families 2023-07-06 17:36:11 -04:00
Vitor Pamplona
64d96ab1d7 Fixes Emoji Joining issues. 2023-07-06 17:24:42 -04:00
Vitor Pamplona
6d6720a511 Adds reactions to Chats and Communities 2023-07-06 15:51:46 -04:00
Vitor Pamplona
17232a1909 Moves communities from /c/name to /n/name 2023-07-06 15:51:28 -04:00
Vitor Pamplona
eb02049507 Turns parsing failures into warnings. 2023-07-06 15:51:14 -04:00
Vitor Pamplona
ed7ad2f51b Adds communities to the readme 2023-07-06 15:50:52 -04:00
Vitor Pamplona
3e0202e6cb v0.66.0 2023-07-06 11:44:33 -04:00
Vitor Pamplona
d59a68ec2e Formatting 2023-07-06 11:41:11 -04:00
Vitor Pamplona
f249884907 Fixes the participants on a community index 2023-07-06 11:41:03 -04:00
Vitor Pamplona
c823d1689f Displays Approval Notes in the notifications 2023-07-06 11:21:35 -04:00
Vitor Pamplona
b10f268e69 - Adds a button to create community posts from the Community screen
- Adds a reference to the reply/community in the new post screen.
2023-07-06 10:57:41 -04:00
Vitor Pamplona
eff21c4f8f Minimizes the height of the tabs 2023-07-06 10:22:44 -04:00
Vitor Pamplona
5537208abb - Adds Basic Support for Communities (172)
- Adds Community discovery screens
- Adds Public Chat discovery
- Adds Community Follow/Unfollow
- Renders Community Posts in feed
- Creates a summary of verified participants in Communities/Chats/Streams
- Restructures Hashtag Screen to the new Screen Building structure
- Remembers scroll position in Live, Community and Chat discovery
2023-07-06 10:11:25 -04:00
Vitor Pamplona
53a5d3e88e Merge pull request #491 from ShinoharaTa/fix_broken_japanese_view
Fixed a bug in Japanese string display
2023-07-05 15:04:28 -04:00
Vitor Pamplona
20f840fd99 Merge pull request #489 from davotoula/translations_update
Updated se/cs/ge translations
2023-07-05 15:00:47 -04:00
tshinohara
dc940c0608 fix lint error. 2023-07-06 03:05:17 +09:00
tshinohara
f5c77d41f1 Bugfix: Url parse disabled in other texts. 2023-07-06 02:47:29 +09:00
tshinohara
014c613577 Bugfix: Change matching pattern. 2023-07-06 01:26:15 +09:00
tshinohara
d369c2c658 Bugfix: Japanese url export disable 2. 2023-07-06 01:23:31 +09:00
tshinohara
536868d3e5 Bugfix: Japanese url export disable. 2023-07-06 01:20:54 +09:00
Vitor Pamplona
5706738898 Initial Support for Communities 2023-07-05 11:22:03 -04:00
Vitor Pamplona
2947c2c7d0 Replaces the Live bar in the top of the feed for Notification dots in the LiveStream bottom button. 2023-07-05 10:28:21 -04:00
Vitor Pamplona
18ac527629 BugFix for invalid hexes in the hex index 2023-07-05 09:09:17 -04:00
Vitor Pamplona
6d80d14421 Updates dependencies 2023-07-05 09:04:42 -04:00
David Kaspar
e60d8c5c20 Updated se/cs/ge translations 2023-07-05 13:03:27 +01:00
Vitor Pamplona
51b4cf444a v0.65.1 2023-07-04 21:02:22 -04:00
Vitor Pamplona
f78ec5cc90 Performance updates 2023-07-04 20:55:01 -04:00
Vitor Pamplona
fd6a63a76c Fixes https://github.com/vitorpamplona/amethyst/issues/487 2023-07-04 19:25:21 -04:00
Vitor Pamplona
e0f186bb94 BugFix for not rendering images on markdown when there is no NIP19 reference in the text. 2023-07-04 12:19:56 -04:00
Vitor Pamplona
9fadab5bb9 v0.65.0 2023-07-04 11:45:35 -04:00
Vitor Pamplona
ea768fb560 Base Streaming view 2023-07-04 11:44:28 -04:00
Vitor Pamplona
71cb18f360 v0.64.4 2023-07-03 08:53:30 -04:00
Vitor Pamplona
3a1694ac9a Renames Hex Check method for better understanding. 2023-07-03 08:36:33 -04:00
Vitor Pamplona
91c47b6e87 BugFix for delayed update of the last messages in the Messages screen 2023-07-03 08:36:12 -04:00
Vitor Pamplona
fe4e03df39 v0.64.3 2023-07-02 16:38:28 -04:00
Vitor Pamplona
d110b9fbbf BugFix for IndexOutOfBoundsException 2023-07-02 15:50:07 -04:00
Vitor Pamplona
dc8c177f20 New Icon for verified follows 2023-07-02 15:47:07 -04:00
Vitor Pamplona
1beacd240e Fixing the lack of the checkmark in the verification icons 2023-07-02 15:46:41 -04:00
Vitor Pamplona
083394b928 Merge pull request #485 from believethehype/main
Added #Cashu
2023-07-02 14:31:54 -04:00
Believethehype
6de8d9d7e0 Update HashtagIcon.kt 2023-07-02 17:02:58 +02:00
Vitor Pamplona
fb4457e767 Fix for last PR 2023-07-01 18:58:01 -04:00
Vitor Pamplona
d4c1907349 Merge pull request #484 from AutumnSunshine/AutumnSunshine-updated-Tamil-translation
Update strings.xml for Tamil translation (values-ta)
2023-07-01 18:42:29 -04:00
AutumnSunshine
c92926f3ab Update strings.xml
Updated Tamil translations for newer additions, building on Vivek's earlier translation.
2023-07-02 03:22:15 +05:30
Vitor Pamplona
8f4f8f7953 v0.64.2 2023-07-01 17:27:00 -04:00
Vitor Pamplona
5a3ea1c258 Shows channel picture on the public chat channel's header instead of the creator's picture. 2023-07-01 17:24:54 -04:00
Vitor Pamplona
a567ec70ad v0.64.1 2023-07-01 17:19:42 -04:00
Vitor Pamplona
e402081777 Adds a Toast for not having a private key when following/unfollowing people 2023-07-01 17:16:56 -04:00
Vitor Pamplona
ed9c27341f BugFix for not changing background color on Notes 2023-07-01 17:16:32 -04:00
Vitor Pamplona
56d9c9a50f Moves away from drawBehind and uses background color instead. It feels faster at first glance. 2023-07-01 16:57:45 -04:00
Vitor Pamplona
d2a22f4ca0 Moves from drawBehind to .background because it's faster. 2023-07-01 15:52:51 -04:00
Vitor Pamplona
09582fd0b1 Removes the need to draw a background with the theme's background color on the verification symbol 2023-07-01 15:33:13 -04:00
Vitor Pamplona
1d5dfbfd29 Reduces the max grouped reactions on Notifications to 30 to increase speed of scroll. 2023-07-01 11:58:40 -04:00
Vitor Pamplona
044d47adad Merge remote-tracking branch 'origin/HEAD' 2023-07-01 11:52:51 -04:00
Vitor Pamplona
cd9465c0e7 Moving screen updates from 300ms to 500ms to avoid triggering updates while animations are running. 2023-07-01 11:52:40 -04:00
Vitor Pamplona
d179c93c0b New RichText engine to help with testing classes. 2023-07-01 11:51:46 -04:00
Vitor Pamplona
3c84eae647 Merge pull request #481 from greenart7c3/main
fix relay tab not working in profile page
2023-06-30 20:45:58 -04:00
greenart7c3
53a146d0e5 fix relay tab not working in profile page 2023-06-30 20:28:40 -03:00
Vitor Pamplona
57db7f0272 Moves some of the modifiers to singleton variables 2023-06-30 08:58:28 -04:00
Vitor Pamplona
b820b6564f BugFix Crashing relay screen on invalid URLs 2023-06-30 08:58:14 -04:00
Vitor Pamplona
3908c42c7f Reduces the use of hex 2023-06-30 08:57:57 -04:00
Vitor Pamplona
1793fe40bf v0.64.0 2023-06-29 18:13:48 -04:00
Vitor Pamplona
0857695332 Moves global to home as a list and search to the top bar. 2023-06-29 18:13:09 -04:00
Vitor Pamplona
a7b3bac8f8 Slims down the live headers 2023-06-29 18:12:22 -04:00
Vitor Pamplona
e6f122d8b7 BugFix for trimmed paragraphs losing indentation 2023-06-29 16:46:44 -04:00
Vitor Pamplona
b2cdc2ddef BugFix: Not showing display name on [n] tags 2023-06-29 16:28:47 -04:00
Vitor Pamplona
94057059bb Logging out the time to display reactions 2023-06-29 16:17:29 -04:00
Vitor Pamplona
927ef17896 Improved AsyncImage loading states. 2023-06-29 15:09:08 -04:00
Vitor Pamplona
33f915879b Makes NIP05 work in domain names only (No need for the _@ in the "_@domain" address) 2023-06-29 15:08:46 -04:00
Vitor Pamplona
98bca48359 Changes to not preview images when CanPreview is false. 2023-06-29 14:19:35 -04:00
Vitor Pamplona
171f763908 Additional checks for main thread in Notifications 2023-06-29 12:42:11 -04:00
122 changed files with 8955 additions and 1522 deletions

View File

@@ -71,10 +71,11 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Recommended Application Handlers (NIP-89)
- [x] Events with a Subject (NIP-14)
- [x] Generic Reposts (kind:16)
- [x] Live Activities & Live Chats (NIP-102)
- [x] Live Activities & Live Chats (NIP-53)
- [x] Relay Pages (NIP-11)
- [x] HTTP Auth (NIP-98)
- [x] Zapraiser (NIP-TBD)
- [x] Moderated Communities (NIP-172)
- [ ] Marketplace (NIP-15)
- [ ] Image/Video Capture in the app
- [ ] Local Database

View File

@@ -13,8 +13,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 230
versionName "0.63.0"
versionCode 241
versionName "0.66.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -116,7 +116,7 @@ dependencies {
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
// Bitcoin secp256k1 bindings to Android
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.10.0'
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.10.1'
// Nostr Base Protocol
implementation('com.github.vitorpamplona.NostrPostr:nostrpostrlib:master-SNAPSHOT') {
@@ -199,7 +199,7 @@ dependencies {
implementation 'id.zelory:compressor:3.0.1'
// Automatic memory leak detection
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
testImplementation 'junit:junit:4.13.2'
testImplementation 'io.mockk:mockk:1.13.5'

File diff suppressed because it is too large Load Diff

View File

@@ -11,17 +11,17 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class TranslationsTest {
fun translatePT(text: String, translateTo: String): String? {
fun translateTo(text: String, translateTo: String): String? {
val task = LanguageTranslatorService.autoTranslate(text, emptySet(), translateTo)
return Tasks.await(task).result
}
fun assertTranslate(expected: String, input: String, translateTo: String) {
assertEquals(null, expected, translatePT(input, translateTo))
assertEquals(null, expected, translateTo(input, translateTo))
}
fun assertTranslateContains(expected: String, input: String, translateTo: String) {
val translated = translatePT(input, translateTo)!!
val translated = translateTo(input, translateTo)!!
assertTrue("'$translated' does not contain '$expected'", translated.contains(expected))
}
@@ -86,4 +86,17 @@ class TranslationsTest {
"en"
)
}
@Test
fun testJapaneseTranslationsOfUrl() {
assertTranslateContains(
"https://youtu.be/wMYFmCDy_Eg",
"うちの会社の小さい先輩の話 第1話「うちの会社の先輩は小さくて可愛い」\n" +
"\n" +
"https://youtu.be/wMYFmCDy_Eg\n" +
"\n" +
"先輩がうざい後輩の話と似たような話かと思ったけど、もっとオタクの妄想あるある的なものを詰め込んだやつだ。ワードとかシチュエーションとか、ヒロインのサイズ感とか。知らんけど",
"en"
)
}
}

View File

@@ -43,6 +43,7 @@ private object PrefKeys {
const val NOSTR_PRIVKEY = "nostr_privkey"
const val NOSTR_PUBKEY = "nostr_pubkey"
const val FOLLOWING_CHANNELS = "following_channels"
const val FOLLOWING_COMMUNITIES = "following_communities"
const val HIDDEN_USERS = "hidden_users"
const val RELAYS = "relays"
const val DONT_TRANSLATE_FROM = "dontTranslateFrom"
@@ -55,6 +56,7 @@ private object PrefKeys {
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList"
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
const val LATEST_CONTACT_LIST = "latestContactList"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
@@ -204,6 +206,7 @@ object LocalPreferences {
account.loggedIn.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHex()) }
account.loggedIn.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHex()) }
putStringSet(PrefKeys.FOLLOWING_CHANNELS, account.followingChannels)
putStringSet(PrefKeys.FOLLOWING_COMMUNITIES, account.followingCommunities)
putStringSet(PrefKeys.HIDDEN_USERS, account.hiddenUsers)
putString(PrefKeys.RELAYS, gson.toJson(account.localRelays))
putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom)
@@ -216,6 +219,7 @@ object LocalPreferences {
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList)
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList)
putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, account.defaultNotificationFollowList)
putString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, account.defaultDiscoveryFollowList)
putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest))
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
@@ -245,6 +249,7 @@ object LocalPreferences {
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
val followingCommunities = getStringSet(PrefKeys.FOLLOWING_COMMUNITIES, null) ?: setOf()
val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf()
val localRelays = gson.fromJson(
getString(PrefKeys.RELAYS, "[]"),
@@ -256,6 +261,7 @@ object LocalPreferences {
val defaultHomeFollowList = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS
val defaultStoriesFollowList = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val defaultNotificationFollowList = getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val defaultDiscoveryFollowList = getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val zapAmountChoices = gson.fromJson(
getString(PrefKeys.ZAP_AMOUNTS, "[]"),
@@ -337,6 +343,7 @@ object LocalPreferences {
val a = Account(
loggedIn = Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
followingChannels = followingChannels,
followingCommunities = followingCommunities,
hiddenUsers = hiddenUsers,
localRelays = localRelays,
dontTranslateFrom = dontTranslateFrom,
@@ -349,6 +356,7 @@ object LocalPreferences {
defaultHomeFollowList = defaultHomeFollowList,
defaultStoriesFollowList = defaultStoriesFollowList,
defaultNotificationFollowList = defaultNotificationFollowList,
defaultDiscoveryFollowList = defaultDiscoveryFollowList,
zapPaymentRequest = zapPaymentRequestServer,
hideDeleteRequestDialog = hideDeleteRequestDialog,
hideBlockAlertDialog = hideBlockAlertDialog,

View File

@@ -13,9 +13,13 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrCommunityDataSource
import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
@@ -67,12 +71,14 @@ object ServiceManager {
NostrHomeDataSource.account = myAccount
NostrChatroomListDataSource.account = myAccount
NostrVideoDataSource.account = myAccount
NostrDiscoveryDataSource.account = myAccount
ImageUploader.account = myAccount
// Notification Elements
NostrHomeDataSource.start()
NostrAccountDataSource.start()
NostrChatroomListDataSource.start()
NostrDiscoveryDataSource.start()
// More Info Data Sources
NostrSingleEventDataSource.start()
@@ -87,9 +93,13 @@ object ServiceManager {
NostrAccountDataSource.stop()
NostrHomeDataSource.stop()
NostrChannelDataSource.stop()
NostrChatroomDataSource.stop()
NostrChatroomListDataSource.stop()
NostrDiscoveryDataSource.stop()
NostrGlobalDataSource.stop()
NostrCommunityDataSource.stop()
NostrHashtagDataSource.stop()
NostrSearchEventOrUserDataSource.stop()
NostrSingleChannelDataSource.stop()
NostrSingleEventDataSource.stop()
NostrSingleUserDataSource.stop()

View File

@@ -50,6 +50,7 @@ val KIND3_FOLLOWS = " All Follows "
class Account(
val loggedIn: Persona,
var followingChannels: Set<String> = DefaultChannels,
var followingCommunities: Set<String> = setOf(),
var hiddenUsers: Set<String> = setOf(),
var localRelays: Set<RelaySetupInfo> = Constants.defaultRelays.toSet(),
var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(),
@@ -62,6 +63,7 @@ class Account(
var defaultHomeFollowList: String = KIND3_FOLLOWS,
var defaultStoriesFollowList: String = GLOBAL_FOLLOWS,
var defaultNotificationFollowList: String = GLOBAL_FOLLOWS,
var defaultDiscoveryFollowList: String = GLOBAL_FOLLOWS,
var zapPaymentRequest: Nip47URI? = null,
var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false,
@@ -106,6 +108,10 @@ class Account(
return followingChannels.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
}
fun followingCommunities(): List<AddressableNote> {
return followingCommunities.mapNotNull { LocalCache.checkGetOrCreateAddressableNote(it) }
}
fun isWriteable(): Boolean {
return loggedIn.privKey != null
}
@@ -856,6 +862,20 @@ class Account(
saveable.invalidateData()
}
fun joinCommunity(idHex: String) {
followingCommunities = followingCommunities + idHex
live.invalidateData()
saveable.invalidateData()
}
fun leaveCommunity(idHex: String) {
followingCommunities = followingCommunities - idHex
live.invalidateData()
saveable.invalidateData()
}
fun hideUser(pubkeyHex: String) {
hiddenUsers = hiddenUsers + pubkeyHex
live.invalidateData()
@@ -899,6 +919,12 @@ class Account(
saveable.invalidateData()
}
fun changeDefaultDiscoveryFollowList(name: String) {
defaultDiscoveryFollowList = name
live.invalidateData()
saveable.invalidateData()
}
fun changeZapAmounts(newAmounts: List<Long>) {
zapAmountChoices = newAmounts
live.invalidateData()
@@ -1146,6 +1172,10 @@ class Account(
return user.pubkeyHex in followingKeySet()
}
fun isFollowing(user: HexKey): Boolean {
return user in followingKeySet()
}
fun isAcceptable(note: Note): Boolean {
return note.author?.let { isAcceptable(it) } ?: true && // if user hasn't hided this author
isAcceptableDirect(note) &&

View File

@@ -7,18 +7,19 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
fun checkForHashtagWithIcon(tag: String, primary: Color): HashtagIcon? {
return when (tag.lowercase()) {
"bitcoin", "btc", "timechain" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"bitcoin", "btc", "timechain", "bitcoiner", "bitcoiners" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"nostr", "nostrich", "nostriches" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp))
"lightning", "lightningnetwork" -> HashtagIcon(R.drawable.ht_lightning, "Lightning", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> HashtagIcon(R.drawable.zap, "Zap", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"amethyst" -> HashtagIcon(R.drawable.amethyst, "Amethyst", Color.Unspecified, Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp))
"onyx" -> HashtagIcon(R.drawable.black_heart, "Onyx", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"cashu", "ecash", "nut", "nuts", "deeznuts" -> HashtagIcon(R.drawable.cashu, "Cashu", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"plebs", "pleb", "plebchain" -> HashtagIcon(R.drawable.plebs, "Pleb", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp))
"coffee", "coffeechain", "cafe" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"skullofsatoshi" -> HashtagIcon(R.drawable.skull, "SkullofSatoshi", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp))
"grownostr", "gardening", "garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp))
"footstr" -> HashtagIcon(R.drawable.footstr, "Footstr", Color.Unspecified, Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp))
"tunestr", "music", "nowplaying" -> HashtagIcon(R.drawable.tunestr, "Tunestr", primary, Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp))
"tunestr", "music", "nowplaying" -> HashtagIcon(R.drawable.tunestr, "Tunestr", primary, Modifier.padding(0.dp, 3.dp, 0.dp, 1.dp))
"weed", "weedstr", "420", "cannabis", "marijuana" -> HashtagIcon(R.drawable.weed, "Weed", Color.Unspecified, Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp))
else -> null
}

View File

@@ -35,7 +35,7 @@ object LocalCache {
fun checkGetOrCreateUser(key: String): User? {
checkNotInMainThread()
if (isValidHexNpub(key)) {
if (isValidHex(key)) {
return getOrCreateUser(key)
}
return null
@@ -55,6 +55,10 @@ object LocalCache {
return users.get(key)
}
fun getAddressableNoteIfExists(key: String): AddressableNote? {
return addressables[key]
}
fun getNoteIfExists(key: String): Note? {
return addressables[key] ?: notes[key]
}
@@ -69,14 +73,14 @@ object LocalCache {
if (ATag.isATag(key)) {
return checkGetOrCreateAddressableNote(key)
}
if (isValidHexNpub(key)) {
if (isValidHex(key)) {
val note = getOrCreateNote(key)
val noteEvent = note.event
if (noteEvent is AddressableEvent) {
return if (noteEvent is AddressableEvent) {
// upgrade to the latest
return checkGetOrCreateAddressableNote(noteEvent.address().toTag())
checkGetOrCreateAddressableNote(noteEvent.address().toTag())
} else {
return note
note
}
}
return null
@@ -94,7 +98,7 @@ object LocalCache {
fun checkGetOrCreateChannel(key: String): Channel? {
checkNotInMainThread()
if (isValidHexNpub(key)) {
if (isValidHex(key)) {
return getOrCreateChannel(key) {
PublicChatChannel(key)
}
@@ -108,7 +112,7 @@ object LocalCache {
return null
}
private fun isValidHexNpub(key: String): Boolean {
private fun isValidHex(key: String): Boolean {
if (key.isBlank()) return false
if (key.contains(":")) return false
@@ -317,6 +321,25 @@ object LocalCache {
refreshObservers(note)
}
private fun consume(event: CommunityDefinitionEvent, 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: LiveActivitiesEvent, relay: Relay?) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
@@ -653,6 +676,34 @@ object LocalCache {
refreshObservers(note)
}
fun consume(event: CommunityPostApprovalEvent) {
val note = getOrCreateNote(event.id)
// Already processed this event.
if (note.event != null) return
// Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
val author = getOrCreateUser(event.pubKey)
val communities = event.communities()
val eventsApproved = event.approvedEvents().mapNotNull { checkGetOrCreateNote(it) }
val repliesTo = communities.map { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, eventsApproved)
// 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)
@@ -798,7 +849,7 @@ object LocalCache {
note.loadEvent(event, author, replyTo)
// Log.d("CM", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()} ${formattedDateTime(event.createdAt)}")
// Log.d("CM", "New Chat Note (${note.author?.toBestDisplayName()} ${note.event?.content()} ${formattedDateTime(event.createdAt)}")
// Counts the replies
replyTo.forEach {
@@ -1138,7 +1189,7 @@ object LocalCache {
}
}
println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.toBestDisplayName()}")
println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.toBestDisplayName()}. ${it.value.notes.size} kept")
}
}
@@ -1218,6 +1269,13 @@ object LocalCache {
is ChannelMessageEvent -> consume(event, relay)
is ChannelMetadataEvent -> consume(event)
is ChannelMuteUserEvent -> consume(event)
is CommunityDefinitionEvent -> consume(event, relay)
is CommunityPostApprovalEvent -> {
event.containedPost()?.let {
verifyAndConsume(it, relay)
}
consume(event)
}
is ContactListEvent -> consume(event)
is DeletionEvent -> consume(event)

View File

@@ -279,10 +279,63 @@ open class Note(val idHex: String) {
}
}
fun isReactedBy(user: User): String? {
return reactions.filter {
it.value.any { it.author?.pubkeyHex == user.pubkeyHex }
}.keys.firstOrNull()
fun publicZapAuthors(): Set<User> {
// Zaps who the requester was the user
return zaps.mapNotNull {
it.key.author
}.toSet()
}
fun publicZapAuthorHexes(): Set<HexKey> {
// Zaps who the requester was the user
return zaps.mapNotNull {
it.key.author?.pubkeyHex
}.toSet()
}
fun reactionAuthors(): Set<User> {
// Zaps who the requester was the user
return reactions.values.map {
it.mapNotNull { it.author }
}.flatten().toSet()
}
fun reactionAuthorHexes(): Set<HexKey> {
// Zaps who the requester was the user
return reactions.values.map {
it.mapNotNull { it.author?.pubkeyHex }
}.flatten().toSet()
}
fun replyAuthorHexes(): Set<HexKey> {
// Zaps who the requester was the user
return replies.mapNotNull {
it.author?.pubkeyHex
}.toSet()
}
fun replyAuthors(): Set<User> {
// Zaps who the requester was the user
return replies.mapNotNull {
it.author
}.toSet()
}
fun boostAuthors(): Set<User> {
// Zaps who the requester was the user
return boosts.mapNotNull {
it.author
}.toSet()
}
fun getReactionBy(user: User): String? {
return reactions.firstNotNullOfOrNull {
if (it.value.any { it.author?.pubkeyHex == user.pubkeyHex }) {
it.key
} else {
null
}
}
}
fun isBoostedBy(user: User): Boolean {
@@ -502,7 +555,7 @@ class NoteLiveSet(u: Note) {
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO)
private val bundler = BundledUpdate(500, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate() {

View File

@@ -0,0 +1,75 @@
package com.vitorpamplona.amethyst.model
class ParticipantListBuilder {
private fun addFollowsThatDirectlyParticipateOnToSet(baseNote: Note, followingSet: Set<HexKey>?, set: MutableSet<User>) {
baseNote.author?.let { author ->
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
}
// Breaks these searchers down to avoid the memory use of creating multiple lists
baseNote.replies.forEach { reply ->
reply.author?.let { author ->
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
}
}
baseNote.boosts.forEach { boost ->
boost.author?.let { author ->
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
}
}
baseNote.zaps.forEach { zapPair ->
zapPair.key.author?.let { author ->
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
}
}
baseNote.reactions.forEach { reactionSet ->
reactionSet.value.forEach { reaction ->
reaction.author?.let { author ->
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
}
}
}
}
fun followsThatParticipateOnDirect(baseNote: Note?, followingSet: Set<HexKey>?): Set<User> {
if (baseNote == null) return mutableSetOf()
val set = mutableSetOf<User>()
addFollowsThatDirectlyParticipateOnToSet(baseNote, followingSet, set)
return set
}
fun followsThatParticipateOn(baseNote: Note?, followingSet: Set<HexKey>?): Set<User> {
if (baseNote == null) return mutableSetOf()
val mySet = mutableSetOf<User>()
addFollowsThatDirectlyParticipateOnToSet(baseNote, followingSet, mySet)
baseNote.replies.forEach {
addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet)
}
baseNote.boosts.forEach {
it.replyTo?.forEach {
addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet)
}
}
LocalCache.getChannelIfExists(baseNote.idHex)?.notes?.values?.forEach {
addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet)
}
return mySet
}
fun countFollowsThatParticipateOn(baseNote: Note?, followingSet: Set<HexKey>?): Int {
if (baseNote == null) return 0
val list = followsThatParticipateOn(baseNote, followingSet)
return list.size
}
}

View File

@@ -1,7 +1,6 @@
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
@@ -43,16 +42,7 @@ class ThreadAssembler {
checkNotInMainThread()
val (result, elapsed) = measureTimedValue {
val note = if (noteId.contains(":")) {
val aTag = ATag.parse(noteId, null)
if (aTag != null) {
LocalCache.getOrCreateAddressableNote(aTag)
} else {
return emptySet()
}
} else {
LocalCache.getOrCreateNote(noteId)
}
val note = LocalCache.checkGetOrCreateNote(noteId) ?: return emptySet<Note>()
if (note.event != null) {
val thread = mutableSetOf<Note>()

View File

@@ -459,7 +459,7 @@ class UserMetadata {
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO)
private val bundler = BundledUpdate(500, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate() {

View File

@@ -0,0 +1,291 @@
package com.vitorpamplona.amethyst.service
import android.util.Log
import android.util.LruCache
import android.util.Patterns
import androidx.compose.runtime.Immutable
import com.linkedin.urls.detection.UrlDetector
import com.linkedin.urls.detection.UrlDetectorOptions
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlContent
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlImage
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo
import com.vitorpamplona.amethyst.ui.components.hashTagsPattern
import com.vitorpamplona.amethyst.ui.components.imageExtensions
import com.vitorpamplona.amethyst.ui.components.startsWithNIP19Scheme
import com.vitorpamplona.amethyst.ui.components.tagIndex
import com.vitorpamplona.amethyst.ui.components.videoExtensions
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.collections.immutable.toImmutableSet
import java.util.regex.Pattern
@Immutable
data class RichTextViewerState(
val urlSet: ImmutableSet<String>,
val imagesForPager: ImmutableMap<String, ZoomableUrlContent>,
val imageList: ImmutableList<ZoomableUrlContent>,
val customEmoji: ImmutableMap<String, String>,
val paragraphs: ImmutableList<ImmutableList<Segment>>
)
object CachedRichTextParser {
val richTextCache = LruCache<String, RichTextViewerState>(200)
fun parseText(content: String, tags: ImmutableListOfLists<String>): RichTextViewerState {
return if (richTextCache[content] != null) {
richTextCache[content]
} else {
val newUrls = RichTextParser().parseText(content, tags)
richTextCache.put(content, newUrls)
newUrls
}
}
}
// Group 1 = url, group 4 additional chars
// val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
// Android9 seems to have an issue starting this regex.
val noProtocolUrlValidator = try {
Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]*[^\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}])*\\/?)(.*)")
} catch (e: Exception) {
Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
}
class RichTextParser() {
fun parseText(
content: String,
tags: ImmutableListOfLists<String>
): RichTextViewerState {
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
val urlSet = urls.mapNotNullTo(LinkedHashSet(urls.size)) {
// removes e-mails
if (Patterns.EMAIL_ADDRESS.matcher(it.originalUrl).matches()) {
null
} else if (isNumber(it.originalUrl)) {
null
} else if (it.originalUrl.contains("")) {
null
} else {
val pattern = "^((http|https)://)?([A-Za-z0-9-]+(\\.[A-Za-z0-9]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?".toRegex(RegexOption.IGNORE_CASE)
if (pattern.matches(it.originalUrl)) {
it.originalUrl
} else {
null
}
}
}
val imagesForPager = urlSet.mapNotNull { fullUrl ->
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableUrlImage(fullUrl)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableUrlVideo(fullUrl)
} else {
null
}
}.associateBy { it.url }
val imageList = imagesForPager.values.toList()
val emojiMap =
tags.lists.filter { it.size > 2 && it[0] == "emoji" }.associate { ":${it[1]}:" to it[2] }
val segments = findTextSegments(content, imagesForPager.keys, urlSet, emojiMap, tags)
return RichTextViewerState(
urlSet.toImmutableSet(),
imagesForPager.toImmutableMap(),
imageList.toImmutableList(),
emojiMap.toImmutableMap(),
segments
)
}
private fun findTextSegments(content: String, images: Set<String>, urls: Set<String>, emojis: Map<String, String>, tags: ImmutableListOfLists<String>): ImmutableList<ImmutableList<Segment>> {
var paragraphSegments = persistentListOf<ImmutableList<Segment>>()
content.split('\n').forEach { paragraph ->
var segments = persistentListOf<Segment>()
var isDirty = false
val wordList = paragraph.split(' ')
wordList.forEach { word ->
val wordSegment = wordIdentifier(word, images, urls, emojis, tags)
if (wordSegment !is RegularTextSegment) {
isDirty = true
}
segments = segments.add(wordSegment)
}
val newSegments = if (isDirty) {
if (isArabic(paragraph)) {
segments.asReversed().toImmutableList()
} else {
segments
}
} else {
persistentListOf<Segment>(RegularTextSegment(paragraph))
}
paragraphSegments = paragraphSegments.add(newSegments)
}
return paragraphSegments
}
fun isNumber(word: String): Boolean {
return numberPattern.matcher(word).matches()
}
fun isDate(word: String): Boolean {
return shortDatePattern.matcher(word).matches() || longDatePattern.matcher(word).matches()
}
private fun isArabic(text: String): Boolean {
return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' }
}
private fun wordIdentifier(word: String, images: Set<String>, urls: Set<String>, emojis: Map<String, String>, tags: ImmutableListOfLists<String>): Segment {
val emailMatcher = Patterns.EMAIL_ADDRESS.matcher(word)
val phoneMatcher = Patterns.PHONE.matcher(word)
val schemelessMatcher = noProtocolUrlValidator.matcher(word)
return if (word.isEmpty()) {
RegularTextSegment(word)
} else if (images.contains(word)) {
ImageSegment(word)
} else if (urls.contains(word)) {
LinkSegment(word)
} else if (emojis.any { word.contains(it.key) }) {
EmojiSegment(word)
} else if (word.startsWith("lnbc", true)) {
InvoiceSegment(word)
} else if (word.startsWith("lnurl", true)) {
WithdrawSegment(word)
} else if (word.startsWith("cashuA", true)) {
CashuSegment(word)
} else if (emailMatcher.matches()) {
EmailSegment(word)
} else if (word.length in 7..14 && !isDate(word) && phoneMatcher.matches()) {
PhoneSegment(word)
} else if (startsWithNIP19Scheme(word)) {
BechSegment(word)
} else if (word.startsWith("#")) {
parseHash(word, tags)
} else if (schemelessMatcher.find()) {
val url = schemelessMatcher.group(1) // url
val additionalChars = schemelessMatcher.group(4) // additional chars
val pattern = "^([A-Za-z0-9-]+(\\.[A-Za-z0-9]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?".toRegex(RegexOption.IGNORE_CASE)
if (pattern.matches(word)) {
SchemelessUrlSegment(word, url, additionalChars)
} else {
RegularTextSegment(word)
}
} else {
RegularTextSegment(word)
}
}
private fun parseHash(word: String, tags: ImmutableListOfLists<String>): Segment {
// First #[n]
val matcher = tagIndex.matcher(word)
try {
if (matcher.find()) {
val index = matcher.group(1)?.toInt()
val suffix = matcher.group(2)
if (index != null && index >= 0 && index < tags.lists.size) {
val tag = tags.lists[index]
if (tag.size > 1) {
if (tag[0] == "p") {
return HashIndexUserSegment(word, tag[1], suffix)
} else if (tag[0] == "e" || tag[0] == "a") {
return HashIndexEventSegment(word, tag[1], suffix)
}
}
}
}
} catch (e: Exception) {
Log.w("Tag Parser", "Couldn't link tag $word", e)
}
// Second #Amethyst
val hashtagMatcher = hashTagsPattern.matcher(word)
try {
if (hashtagMatcher.find()) {
val hashtag = hashtagMatcher.group(1)
if (hashtag != null) {
return HashTagSegment(word, hashtag, hashtagMatcher.group(2))
}
}
} catch (e: Exception) {
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
}
return RegularTextSegment(word)
}
companion object {
val longDatePattern: Pattern = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$")
val shortDatePattern: Pattern = Pattern.compile("^\\d{2}-\\d{2}-\\d{2}$")
val numberPattern: Pattern = Pattern.compile("^(-?[\\d.]+)([a-zA-Z%]*)$")
}
}
@Immutable
open class Segment(val segmentText: String)
@Immutable
class ImageSegment(segment: String) : Segment(segment)
@Immutable
class LinkSegment(segment: String) : Segment(segment)
@Immutable
class EmojiSegment(segment: String) : Segment(segment)
@Immutable
class InvoiceSegment(segment: String) : Segment(segment)
@Immutable
class WithdrawSegment(segment: String) : Segment(segment)
@Immutable
class CashuSegment(segment: String) : Segment(segment)
@Immutable
class EmailSegment(segment: String) : Segment(segment)
@Immutable
class PhoneSegment(segment: String) : Segment(segment)
@Immutable
class BechSegment(segment: String) : Segment(segment)
@Immutable
open class HashIndexSegment(segment: String, val hex: String, val extras: String?) : Segment(segment)
@Immutable
class HashIndexUserSegment(segment: String, hex: String, extras: String?) : HashIndexSegment(segment, hex, extras)
@Immutable
class HashIndexEventSegment(segment: String, hex: String, extras: String?) : HashIndexSegment(segment, hex, extras)
@Immutable
class HashTagSegment(segment: String, val hashtag: String, val extras: String?) : Segment(segment)
@Immutable
class SchemelessUrlSegment(segment: String, val url: String, val extras: String?) : Segment(segment)
@Immutable
class RegularTextSegment(segment: String) : Segment(segment)

View File

@@ -53,7 +53,7 @@ class CashuProcessor {
LightningAddressResolver().lnAddressInvoice(
lnaddress = lud16,
milliSats = token.redeemInvoiceAmount * 1000, // Make invoice and leave room for fees
message = "Reedem Cashu",
message = "Redeem Cashu",
onSuccess = { invoice ->
meltInvoice(token, invoice, onSuccess, onError)
},

View File

@@ -19,6 +19,9 @@ class Nip05Verifier() {
if (parts.size == 2) {
return "https://${parts[1]}/.well-known/nostr.json?name=${parts[0]}"
}
if (parts.size == 1) {
return "https://${parts[0]}/.well-known/nostr.json?name=_"
}
return null
}
@@ -92,7 +95,12 @@ class Nip05Verifier() {
null
}
val user = nip05.split("@")[0].lowercase()
val parts = nip05.split("@")
val user = if (parts.size == 2) {
parts[0].lowercase()
} else {
"_"
}
val hexKey = nip05url?.get("names")?.get(user)?.asText()

View File

@@ -74,7 +74,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to listOf(it)),
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList,
limit = 25 // Remember to consider spam that is being removed from the UI
limit = 50 // Remember to consider spam that is being removed from the UI
)
)
}

View File

@@ -0,0 +1,41 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrCommunityDataSource : NostrDataSource("SingleCommunityFeed") {
private var communityToWatch: AddressableNote? = null
private fun createLoadCommunityFilter(): TypedFilter? {
val myCommunityToWatch = communityToWatch ?: return null
val community = myCommunityToWatch.event as? CommunityDefinitionEvent ?: return null
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
authors = community.moderators().map { it.key }.plus(listOfNotNull(myCommunityToWatch.author?.pubkeyHex)),
tags = mapOf(
"a" to listOf(myCommunityToWatch.address.toTag())
),
kinds = listOf(CommunityPostApprovalEvent.kind),
limit = 500
)
)
}
val loadCommunityChannel = requestNewChannel()
override fun updateChannelFilters() {
loadCommunityChannel.typedFilters = listOfNotNull(createLoadCommunityFilter()).ifEmpty { null }
}
fun loadCommunity(note: AddressableNote?) {
communityToWatch = note
invalidateFilters()
}
}

View File

@@ -0,0 +1,148 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
lateinit var account: Account
val latestEOSEs = EOSEAccount()
val dataSource = "Discovery"
fun createLiveStreamFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
val followKeys = follows?.map {
it.substring(0, 6)
}
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
authors = followKeys,
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
)
)
}
fun createPublicChatFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
val followKeys = follows?.map {
it.substring(0, 6)
}
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
authors = followKeys,
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
)
)
}
fun createCommunitiesFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
val followKeys = follows?.map {
it.substring(0, 6)
}
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
authors = followKeys,
kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
)
)
}
fun createLiveStreamTagsFilter(): TypedFilter? {
val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList)
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
)
)
}
fun createPublicChatsTagsFilter(): TypedFilter? {
val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList)
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
)
)
}
fun createCommunitiesTagsFilter(): TypedFilter? {
val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList)
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
)
)
}
val discoveryFeedChannel = requestNewChannel()
override fun updateChannelFilters() {
discoveryFeedChannel.typedFilters = listOfNotNull(
createLiveStreamFilter(),
createPublicChatFilter(),
createCommunitiesFilter(),
createLiveStreamTagsFilter(),
createPublicChatsTagsFilter(),
createCommunitiesTagsFilter()
).ifEmpty { null }
}
}

View File

@@ -1,28 +0,0 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
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.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrGlobalDataSource : NostrDataSource("GlobalFeed") {
fun createGlobalFilter() = TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, AudioTrackEvent.kind, PinListEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
limit = 200
)
)
val globalFeedChannel = requestNewChannel()
override fun updateChannelFilters() {
globalFeedChannel.typedFilters = listOf(createGlobalFilter()).ifEmpty { null }
}
}

View File

@@ -51,13 +51,13 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
}
fun createFollowAccountsFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
val follows = account.selectedUsersFollowList(account.defaultHomeFollowList)
val followKeys = follows.map {
val followKeys = follows?.map {
it.substring(0, 6)
}
val followSet = followKeys.plus(account.userProfile().pubkeyHex.substring(0, 6))
val followSet = followKeys?.plus(account.userProfile().pubkeyHex.substring(0, 6))
return TypedFilter(
types = setOf(FeedType.FOLLOWS),

View File

@@ -122,6 +122,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
}
val singleEventChannel = requestNewChannel { time, relayUrl ->
checkNotInMainThread()
eventsToWatch.forEach {
val eose = it.lastReactionsDownloadTime[relayUrl]
if (eose == null) {
@@ -159,16 +161,16 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
}
}
fun addAddress(aTag: Note) {
if (!addressesToWatch.contains(aTag)) {
addressesToWatch = addressesToWatch.plus(aTag)
fun addAddress(addressableNote: Note) {
if (!addressesToWatch.contains(addressableNote)) {
addressesToWatch = addressesToWatch.plus(addressableNote)
invalidateFilters()
}
}
fun removeAddress(aTag: Note) {
if (addressesToWatch.contains(aTag)) {
addressesToWatch = addressesToWatch.minus(aTag)
fun removeAddress(addressableNote: Note) {
if (addressesToWatch.contains(addressableNote)) {
addressesToWatch = addressesToWatch.minus(addressableNote)
invalidateFilters()
}
}

View File

@@ -10,7 +10,7 @@ import okhttp3.Request
data class OnlineCheckResult(val timeInMs: Long, val online: Boolean)
object OnlineChecker {
val checkOnlineCache = LruCache<String, OnlineCheckResult>(10)
val checkOnlineCache = LruCache<String, OnlineCheckResult>(100)
val fiveMinutes = 1000 * 60 * 5
fun isOnline(url: String?): Boolean {
@@ -21,13 +21,13 @@ object OnlineChecker {
return checkOnlineCache.get(url).online
}
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.get()
.build()
return try {
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.get()
.build()
val result = HttpClient.getHttpClient().newCall(request).execute().code == 200
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result))
result

View File

@@ -1,10 +1,11 @@
package com.vitorpamplona.amethyst.service
fun String.isUTF16Char(pos: Int): Boolean {
println("Test $pos ${Character.charCount(this.codePointAt(pos))}")
return Character.charCount(this.codePointAt(pos)) == 2
}
fun String.firstFullChar(): String {
fun String.firstFullCharOld(): String {
return when (this.length) {
0, 1 -> return this
2, 3 -> return if (isUTF16Char(0)) this.take(2) else this.take(1)
@@ -21,3 +22,58 @@ fun String.firstFullChar(): String {
}
}
}
fun String.firstFullChar(): String {
var isInJoin = false
var start = 0
var previousCharLength = 0
var next: Int
var codePoint: Int
var i = 0
while (i < this.length) {
codePoint = codePointAt(i)
// Skips if it starts with the join char 0x200D
if (codePoint == 0x200D && previousCharLength == 0) {
next = offsetByCodePoints(i, 1)
start = next
} else {
// If join, searches for the next char
if (codePoint == 0xFE0F) {
} else if (codePoint == 0x200D) {
isInJoin = true
} else {
// stops when two chars are not joined together
if ((previousCharLength > 0) && (!isInJoin) && Character.charCount(codePoint) == 1) {
break
}
isInJoin = false
}
// next char to evaluate
next = offsetByCodePoints(i, 1)
previousCharLength += (next - i)
}
i = next
}
// if ends in join, then seachers backwards until a char is found.
if (isInJoin) {
i = previousCharLength - 1
while (i > 0) {
if (this[i].code == 0x200D) {
previousCharLength -= 1
} else {
break
}
i -= 1
}
}
return substring(start, start + previousCharLength)
}

View File

@@ -113,7 +113,7 @@ open class BaseTextNoteEvent(
fun tagsWithoutCitations(): List<String> {
val repliesTo = replyTos()
val tagAddresses = taggedAddresses().map { it.toTag() }
val tagAddresses = taggedAddresses().filter { it.kind != CommunityDefinitionEvent.kind }.map { it.toTag() }
if (repliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList()
val citations = findCitations()

View File

@@ -14,7 +14,13 @@ class ChannelHideMessageEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
) : Event(id, pubKey, createdAt, kind, tags, content, sig), IsInPublicChatChannel {
override fun channel() = tags.firstOrNull {
it.size > 3 && it[0] == "e" && it[3] == "root"
}?.get(1) ?: tags.firstOrNull {
it.size > 1 && it[0] == "e"
}?.get(1)
fun eventsToHide() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
companion object {

View File

@@ -14,9 +14,9 @@ class ChannelMessageEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig), IsInPublicChatChannel {
fun channel() = tags.firstOrNull {
override fun channel() = tags.firstOrNull {
it.size > 3 && it[0] == "e" && it[3] == "root"
}?.get(1) ?: tags.firstOrNull {
it.size > 1 && it[0] == "e"
@@ -65,3 +65,7 @@ class ChannelMessageEvent(
}
}
}
interface IsInPublicChatChannel {
open fun channel(): String?
}

View File

@@ -15,8 +15,9 @@ class ChannelMetadataEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun channel() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
) : Event(id, pubKey, createdAt, kind, tags, content, sig), IsInPublicChatChannel {
override fun channel() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
fun channelInfo() =
try {
MetadataEvent.gson.fromJson(content, ChannelCreateEvent.ChannelData::class.java)

View File

@@ -14,7 +14,12 @@ class ChannelMuteUserEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
) : Event(id, pubKey, createdAt, kind, tags, content, sig), IsInPublicChatChannel {
override fun channel() = tags.firstOrNull {
it.size > 3 && it[0] == "e" && it[3] == "root"
}?.get(1) ?: tags.firstOrNull {
it.size > 1 && it[0] == "e"
}?.get(1)
fun usersToMute() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }

View File

@@ -0,0 +1,42 @@
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 CommunityDefinitionEvent(
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 description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1)
fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1)
fun rules() = tags.firstOrNull { it.size > 1 && it[0] == "rules" }?.get(1)
fun moderators() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(3)) }
companion object {
const val kind = 34550
fun create(
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): CommunityDefinitionEvent {
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 CommunityDefinitionEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
}
}
}

View File

@@ -0,0 +1,63 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
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 CommunityPostApprovalEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun containedPost(): Event? = try {
content.ifBlank { null }?.let {
fromJson(it, Client.lenient)
}
} catch (e: Exception) {
Log.w("CommunityPostEvent", "Failed to Parse Contained Post $content", e)
null
}
fun communities() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
val aTag = ATag.parse(it[1], it.getOrNull(2))
if (aTag?.kind == CommunityDefinitionEvent.kind) {
aTag
} else {
null
}
}
fun approvedEvents() = tags.filter {
it.size > 1 && (it[0] == "e" || (it[0] == "a" && ATag.parse(it[1], null)?.kind != CommunityDefinitionEvent.kind))
}.map {
it[1]
}
companion object {
const val kind = 4550
fun create(approvedPost: Event, community: CommunityDefinitionEvent, privateKey: ByteArray, createdAt: Long = Date().time / 1000): GenericRepostEvent {
val content = approvedPost.toJson()
val communities = listOf("a", community.address().toTag())
val replyToPost = listOf("e", approvedPost.id())
val replyToAuthor = listOf("p", approvedPost.pubKey())
val kind = listOf("k", "${approvedPost.kind()}")
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags: List<List<String>> = listOf(communities, replyToPost, replyToAuthor, kind)
val id = generateId(pubKey, createdAt, GenericRepostEvent.kind, tags, content)
val sig = Utils.sign(id, privateKey)
return GenericRepostEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}

View File

@@ -71,10 +71,29 @@ open class Event(
override fun isTaggedUser(idHex: String) = tags.any { it.size > 1 && it[0] == "p" && it[1] == idHex }
override fun isTaggedAddressableNote(idHex: String) = tags.any { it.size > 1 && it[0] == "a" && it[1] == idHex }
override fun isTaggedHash(hashtag: String) = tags.any { it.size > 1 && it[0] == "t" && it[1].equals(hashtag, true) }
override fun isTaggedHashes(hashtags: Set<String>) = tags.any { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }
override fun firstIsTaggedHashes(hashtags: Set<String>) = tags.firstOrNull { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }?.getOrNull(1)
override fun firstIsTaggedAddressableNote(addressableNotes: Set<String>) = tags.firstOrNull { it.size > 1 && it[0] == "a" && it[1] in addressableNotes }?.getOrNull(1)
override fun isTaggedAddressableKind(kind: Int): Boolean {
val kindStr = kind.toString()
return tags.any { it.size > 1 && it[0] == "a" && it[1].startsWith(kindStr) }
}
override fun getTagOfAddressableKind(kind: Int): ATag? {
val kindStr = kind.toString()
val aTag = tags
.firstOrNull { it.size > 1 && it[0] == "a" && it[1].startsWith(kindStr) }
?.getOrNull(1)
?: return null
return ATag.parse(aTag, null)
}
override fun getPoWRank(): Int {
var rank = 0
for (i in 0..id.length) {
@@ -243,6 +262,8 @@ open class Event(
ChannelMessageEvent.kind -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig)
ChannelMetadataEvent.kind -> ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig)
ChannelMuteUserEvent.kind -> ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig)
CommunityDefinitionEvent.kind -> CommunityDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
CommunityPostApprovalEvent.kind -> CommunityPostApprovalEvent(id, pubKey, createdAt, tags, content, sig)
ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig)
DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig)

View File

@@ -27,10 +27,16 @@ interface EventInterface {
fun hasValidSignature(): Boolean
fun isTaggedUser(idHex: String): Boolean
fun isTaggedAddressableNote(idHex: String): Boolean
fun isTaggedHash(hashtag: String): Boolean
fun isTaggedHashes(hashtags: Set<String>): Boolean
fun firstIsTaggedHashes(hashtags: Set<String>): String?
fun firstIsTaggedAddressableNote(addressableNotes: Set<String>): String?
fun isTaggedAddressableKind(kind: Int): Boolean
fun getTagOfAddressableKind(kind: Int): ATag?
fun hashtags(): List<String>
fun getReward(): BigDecimal?

View File

@@ -23,17 +23,29 @@ class LiveActivitiesEvent(
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 starts() = tags.firstOrNull { it.size > 1 && it[0] == "starts" }?.get(1)?.toLongOrNull()
fun ends() = tags.firstOrNull { it.size > 1 && it[0] == "ends" }?.get(1)
fun status() = tags.firstOrNull { it.size > 1 && it[0] == "status" }?.get(1)
fun status() = checkStatus(tags.firstOrNull { it.size > 1 && it[0] == "status" }?.get(1))
fun currentParticipants() = tags.firstOrNull { it.size > 1 && it[0] == "current_participants" }?.get(1)
fun totalParticipants() = tags.firstOrNull { it.size > 1 && it[0] == "total_participants" }?.get(1)
fun participants() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(2)) }
fun participants() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(3)) }
fun checkStatus(eventStatus: String?): String? {
return if (eventStatus == STATUS_LIVE && createdAt < Date().time / 1000 - (60 * 60 * 8)) { // 2 hours {
STATUS_ENDED
} else {
eventStatus
}
}
companion object {
const val kind = 30311
const val STATUS_LIVE = "live"
const val STATUS_PLANNED = "planned"
const val STATUS_ENDED = "ended"
fun create(
privateKey: ByteArray,
createdAt: Long = Date().time / 1000

View File

@@ -67,7 +67,9 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
@@ -77,6 +79,7 @@ import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -88,8 +91,7 @@ import kotlinx.coroutines.withContext
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
val account = remember(accountViewModel) { accountViewModel.account }
val postViewModel: NewPostViewModel = viewModel()
@@ -177,6 +179,18 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
.fillMaxWidth()
.verticalScroll(scrollState)
) {
postViewModel.originalNote?.let {
NoteCompose(
baseNote = it,
makeItShort = true,
unPackReply = false,
isQuotedNote = true,
modifier = MaterialTheme.colors.replyModifier,
accountViewModel = accountViewModel,
nav = nav
)
}
Notifying(postViewModel.mentions?.toImmutableList()) {
postViewModel.removeFromReplyList(it)
}
@@ -496,7 +510,9 @@ private fun AddZapraiserButton(
Icon(
imageVector = Icons.Default.ShowChart,
null,
modifier = Modifier.size(20.dp).align(Alignment.TopStart),
modifier = Modifier
.size(20.dp)
.align(Alignment.TopStart),
tint = MaterialTheme.colors.onBackground
)
Icon(
@@ -511,7 +527,9 @@ private fun AddZapraiserButton(
Icon(
imageVector = Icons.Default.ShowChart,
null,
modifier = Modifier.size(20.dp).align(Alignment.TopStart),
modifier = Modifier
.size(20.dp)
.align(Alignment.TopStart),
tint = BitcoinOrange
)
Icon(

View File

@@ -18,11 +18,12 @@ import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.model.AddressableEvent
import com.vitorpamplona.amethyst.service.model.BaseTextNoteEvent
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
@@ -89,15 +90,17 @@ open class NewPostViewModel() : ViewModel() {
this.replyTos = listOf(replyNote)
}
replyNote.author?.let { replyUser ->
val currentMentions = (replyNote.event as? TextNoteEvent)
?.mentions()
?.map { LocalCache.getOrCreateUser(it) } ?: emptyList()
if (replyNote.event !is CommunityDefinitionEvent) {
replyNote.author?.let { replyUser ->
val currentMentions = (replyNote.event as? TextNoteEvent)
?.mentions()
?.map { LocalCache.getOrCreateUser(it) } ?: emptyList()
if (currentMentions.contains(replyUser)) {
this.mentions = currentMentions
} else {
this.mentions = currentMentions.plus(replyUser)
if (currentMentions.contains(replyUser)) {
this.mentions = currentMentions
} else {
this.mentions = currentMentions.plus(replyUser)
}
}
}
} ?: run {

View File

@@ -292,32 +292,44 @@ fun loadRelayInfo(
scope: CoroutineScope,
onInfo: (RelayInformation) -> Unit
) {
val url = if (dirtyUrl.contains("://")) {
dirtyUrl
.replace("wss://", "https://")
.replace("ws://", "http://")
} else {
"https://$dirtyUrl"
}
try {
val url = if (dirtyUrl.contains("://")) {
dirtyUrl
.replace("wss://", "https://")
.replace("ws://", "http://")
} else {
"https://$dirtyUrl"
}
val request: Request = Request
.Builder()
.header("Accept", "application/nostr+json")
.url(url)
.build()
val request: Request = Request
.Builder()
.header("Accept", "application/nostr+json")
.url(url)
.build()
HttpClient.getHttpClient()
.newCall(request)
.enqueue(
object : Callback {
override fun onResponse(call: Call, response: Response) {
checkNotInMainThread()
response.use {
val body = it.body.string()
try {
if (it.isSuccessful) {
onInfo(RelayInformation.fromJson(body))
} else {
HttpClient.getHttpClient()
.newCall(request)
.enqueue(
object : Callback {
override fun onResponse(call: Call, response: Response) {
checkNotInMainThread()
response.use {
val body = it.body.string()
try {
if (it.isSuccessful) {
onInfo(RelayInformation.fromJson(body))
} else {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.an_error_ocurred_trying_to_get_relay_information, dirtyUrl),
Toast.LENGTH_SHORT
).show()
}
}
} catch (e: Exception) {
Log.e("RelayInfoFail", "Resulting Message from Relay $dirtyUrl in not parseable: $body", e)
scope.launch {
Toast
.makeText(
@@ -327,31 +339,31 @@ fun loadRelayInfo(
).show()
}
}
} catch (e: Exception) {
Log.e("RelayInfoFail", "Resulting Message from Relay in not parseable: $body", e)
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.an_error_ocurred_trying_to_get_relay_information, dirtyUrl),
Toast.LENGTH_SHORT
).show()
}
}
}
override fun onFailure(call: Call, e: IOException) {
Log.e("RelayInfoFail", "Resulting Message from Relay in not parseable $dirtyUrl", e)
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.an_error_ocurred_trying_to_get_relay_information, dirtyUrl),
Toast.LENGTH_SHORT
).show()
}
}
}
override fun onFailure(call: Call, e: IOException) {
Log.e("RelayInfoFail", "Resulting Message from Relay in not parseable", e)
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.an_error_ocurred_trying_to_get_relay_information, dirtyUrl),
Toast.LENGTH_SHORT
).show()
}
}
}
)
)
} catch (e: Exception) {
Log.e("RelayInfoFail", "Invalid URL $dirtyUrl", e)
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.an_error_ocurred_trying_to_get_relay_information, dirtyUrl),
Toast.LENGTH_SHORT
).show()
}
}
}

View File

@@ -0,0 +1,58 @@
package com.vitorpamplona.amethyst.ui.buttons
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun NewCommunityNoteButton(communityIdHex: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
LoadNote(baseNoteHex = communityIdHex) {
it?.let {
NewCommunityNoteButton(it, accountViewModel, nav)
}
}
}
@Composable
fun NewCommunityNoteButton(note: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
var wantsToPost by remember {
mutableStateOf(false)
}
if (wantsToPost) {
NewPostView({ wantsToPost = false }, note, accountViewModel = accountViewModel, nav = nav)
}
OutlinedButton(
onClick = { wantsToPost = true },
modifier = Modifier.size(55.dp),
shape = CircleShape,
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
contentPadding = PaddingValues(0.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_compose),
null,
modifier = Modifier.size(26.dp),
tint = Color.White
)
}
}

View File

@@ -7,6 +7,7 @@ import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
@@ -15,7 +16,7 @@ fun ClickableEmail(email: String) {
val context = LocalContext.current
ClickableText(
text = AnnotatedString("$email "),
text = remember { AnnotatedString(email) },
onClick = { runCatching { context.sendMail(email) } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)

View File

@@ -7,6 +7,7 @@ import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
@@ -15,7 +16,7 @@ fun ClickablePhone(phone: String) {
val context = LocalContext.current
ClickableText(
text = AnnotatedString("$phone "),
text = remember { AnnotatedString(phone) },
onClick = { runCatching { context.dial(phone) } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)

View File

@@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.note.LoadChannel
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentListOf
@@ -78,7 +79,7 @@ fun ClickableRoute(
else -> {
Text(
remember {
"@${nip19.hex}${nip19.additionalChars} "
"@${nip19.hex}${nip19.additionalChars}"
}
)
}
@@ -111,7 +112,16 @@ private fun DisplayEvent(
nav: (String) -> Unit
) {
LoadNote(nip19.hex) {
DisplayNoteLink(it, nip19, nav)
if (it != null) {
DisplayNoteLink(it, nip19, nav)
} else {
CreateClickableText(
clickablePart = remember(nip19) { "@${nip19.hex.toShortenHex()}" },
suffix = nip19.additionalChars,
route = remember(nip19) { "Event/${nip19.hex}" },
nav = nav
)
}
}
}
@@ -121,7 +131,16 @@ private fun DisplayNote(
nav: (String) -> Unit
) {
LoadNote(nip19.hex) {
DisplayNoteLink(it, nip19, nav)
if (it != null) {
DisplayNoteLink(it, nip19, nav)
} else {
CreateClickableText(
clickablePart = remember(nip19) { "@${nip19.hex.toShortenHex()}" },
suffix = nip19.additionalChars,
route = remember(nip19) { "Event/${nip19.hex}" },
nav = nav
)
}
}
}
@@ -137,7 +156,7 @@ private fun DisplayNoteLink(
val channelHex = remember(noteState) { note.channelHex() }
val noteIdDisplayNote = remember(noteState) { "@${note.idDisplayNote()}" }
val addedCharts = remember { "${nip19.additionalChars} " }
val addedCharts = remember { "${nip19.additionalChars}" }
if (note.event is ChannelCreateEvent || nip19.kind == ChannelCreateEvent.kind) {
CreateClickableText(
@@ -199,7 +218,7 @@ private fun DisplayAddress(
val route = remember(noteState) { "Note/${nip19.hex}" }
val displayName = remember(noteState) { "@${noteState?.note?.idDisplayNote()}" }
val addedCharts = remember { "${nip19.additionalChars} " }
val addedCharts = remember { "${nip19.additionalChars}" }
CreateClickableText(
clickablePart = displayName,
@@ -212,7 +231,7 @@ private fun DisplayAddress(
if (noteBase == null) {
Text(
remember {
"@${nip19.hex}${nip19.additionalChars} "
"@${nip19.hex}${nip19.additionalChars}"
}
)
}
@@ -240,7 +259,7 @@ private fun DisplayUser(
if (userBase == null) {
Text(
remember {
"@${nip19.hex}${nip19.additionalChars} "
"@${nip19.hex}${nip19.additionalChars}"
}
)
}
@@ -268,7 +287,7 @@ private fun RenderUserAsClickableText(
}
val addedCharts = remember(nip19) {
"${nip19.additionalChars} "
"${nip19.additionalChars}"
}
userDisplayName?.let {
@@ -286,7 +305,7 @@ private fun RenderUserAsClickableText(
@Composable
fun CreateClickableText(
clickablePart: String,
suffix: String,
suffix: String?,
maxLines: Int = Int.MAX_VALUE,
overrideColor: Color? = null,
fontWeight: FontWeight = FontWeight.Normal,
@@ -310,8 +329,10 @@ fun CreateClickableText(
withStyle(clickablePartStyle) {
append(clickablePart)
}
withStyle(nonClickablePartStyle) {
append(suffix)
if (!suffix.isNullOrBlank()) {
withStyle(nonClickablePartStyle) {
append(suffix)
}
}
}
}
@@ -488,7 +509,7 @@ data class DoubleEmojiList(
@Composable
fun CreateClickableTextWithEmoji(
clickablePart: String,
suffix: String,
suffix: String?,
maxLines: Int = Int.MAX_VALUE,
overrideColor: Color? = null,
fontWeight: FontWeight = FontWeight.Normal,
@@ -507,7 +528,7 @@ fun CreateClickableTextWithEmoji(
if (emojis.isNotEmpty()) {
val newEmojiList1 = assembleAnnotatedList(clickablePart, emojis)
val newEmojiList2 = assembleAnnotatedList(suffix, emojis)
val newEmojiList2 = suffix?.let { assembleAnnotatedList(it, emojis) } ?: emptyList<Renderable>()
if (newEmojiList1.isNotEmpty() || newEmojiList2.isNotEmpty()) {
emojiLists = DoubleEmojiList(newEmojiList1.toImmutableList(), newEmojiList2.toImmutableList())

View File

@@ -31,7 +31,7 @@ fun MayBeWithdrawal(lnurlWord: String) {
ClickableWithdrawal(withdrawalString = it)
} else {
Text(
text = "$lnurlWord ",
text = lnurlWord,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}

View File

@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
@@ -19,13 +20,12 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.note.getGradient
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.secondaryButtonBackground
@@ -80,16 +80,7 @@ fun ExpandableRichTextViewer(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.drawBehind {
drawRect(
Brush.verticalGradient(
colors = listOf(
backgroundColor.value.copy(alpha = 0f),
backgroundColor.value
)
)
)
}
.background(getGradient(backgroundColor))
) {
ShowMoreButton() {
showFullText = !showFullText

View File

@@ -56,7 +56,7 @@ fun MayBeInvoicePreview(lnbcWord: String) {
InvoicePreview(it.first, it.second)
} else {
Text(
text = "$lnbcWord ",
text = lnbcWord,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}

View File

@@ -1,14 +1,16 @@
package com.vitorpamplona.amethyst.ui.components
import android.util.Log
import android.util.LruCache
import android.util.Patterns
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.Icon
import androidx.compose.material.LocalContentAlpha
import androidx.compose.material.LocalContentColor
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.ProvideTextStyle
@@ -17,25 +19,47 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.em
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.halilibo.richtext.markdown.Markdown
import com.halilibo.richtext.markdown.MarkdownParseOptions
import com.halilibo.richtext.ui.material.MaterialRichText
import com.linkedin.urls.detection.UrlDetector
import com.linkedin.urls.detection.UrlDetectorOptions
import com.vitorpamplona.amethyst.model.HashtagIcon
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
import com.vitorpamplona.amethyst.service.BechSegment
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.service.CashuSegment
import com.vitorpamplona.amethyst.service.EmailSegment
import com.vitorpamplona.amethyst.service.EmojiSegment
import com.vitorpamplona.amethyst.service.HashIndexEventSegment
import com.vitorpamplona.amethyst.service.HashIndexUserSegment
import com.vitorpamplona.amethyst.service.HashTagSegment
import com.vitorpamplona.amethyst.service.ImageSegment
import com.vitorpamplona.amethyst.service.InvoiceSegment
import com.vitorpamplona.amethyst.service.LinkSegment
import com.vitorpamplona.amethyst.service.PhoneSegment
import com.vitorpamplona.amethyst.service.RegularTextSegment
import com.vitorpamplona.amethyst.service.RichTextViewerState
import com.vitorpamplona.amethyst.service.SchemelessUrlSegment
import com.vitorpamplona.amethyst.service.Segment
import com.vitorpamplona.amethyst.service.WithdrawSegment
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.note.LoadUser
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Font17SP
import com.vitorpamplona.amethyst.ui.theme.MarkdownTextStyle
@@ -43,15 +67,6 @@ import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
import com.vitorpamplona.amethyst.ui.theme.markdownStyle
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.amethyst.ui.uriToRoute
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.net.MalformedURLException
@@ -62,14 +77,8 @@ 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", "m3u8")
// Group 1 = url, group 4 additional chars
val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
val tagIndex = Pattern.compile("\\#\\[([0-9]+)\\](.*)")
val mentionsPattern: Pattern = Pattern.compile("@([A-Za-z0-9_\\-]+)")
val hashTagsPattern: Pattern = Pattern.compile("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", Pattern.CASE_INSENSITIVE)
val urlPattern: Pattern = Patterns.WEB_URL
fun isValidURL(url: String?): Boolean {
return try {
@@ -103,23 +112,14 @@ fun RichTextViewer(
) {
Column(modifier = modifier) {
if (remember(content) { isMarkdown(content) }) {
RenderContentAsMarkdown(content, backgroundColor, tags, nav)
RenderContentAsMarkdown(content, tags, nav)
} else {
RenderRegular(content, tags, canPreview, backgroundColor, accountViewModel, nav)
}
}
}
val urlSetCache = LruCache<String, RichTextViewerState>(200)
@Immutable
data class RichTextViewerState(
val urlSet: ImmutableSet<String>,
val imagesForPager: ImmutableMap<String, ZoomableUrlContent>,
val imageList: ImmutableList<ZoomableUrlContent>,
val customEmoji: ImmutableMap<String, String>
)
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun RenderRegular(
content: String,
@@ -130,218 +130,128 @@ private fun RenderRegular(
nav: (String) -> Unit
) {
val state by remember(content) {
if (urlSetCache[content] != null) {
mutableStateOf(urlSetCache[content])
} else {
val newUrls = parseUrls(content, tags)
urlSetCache.put(content, newUrls)
mutableStateOf(newUrls)
mutableStateOf(CachedRichTextParser.parseText(content, tags))
}
val currentTextStyle = LocalTextStyle.current
val textStyle = currentTextStyle.copy(
textDirection = TextDirection.Content,
lineHeight = 1.4.em,
color = currentTextStyle.color.takeOrElse {
LocalContentColor.current.copy(alpha = LocalContentAlpha.current)
}
}
)
val paragraphs = remember(content) {
content.split('\n').toImmutableList()
}
// FlowRow doesn't work well with paragraphs. So we need to split them
paragraphs.forEach { paragraph ->
RenderParagraph(paragraph, state, canPreview, backgroundColor, accountViewModel, nav, tags)
}
}
@Composable
@OptIn(ExperimentalLayoutApi::class)
private fun RenderParagraph(
paragraph: String,
state: RichTextViewerState,
canPreview: Boolean,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
tags: ImmutableListOfLists<String>
) {
val s = remember(paragraph) {
if (isArabic(paragraph)) {
paragraph.trim().split(' ').reversed().toImmutableList()
} else {
paragraph.trim().split(' ').toImmutableList()
}
}
FlowRow() {
s.forEach { word: String ->
RenderWord(
word,
state,
canPreview,
backgroundColor,
accountViewModel,
nav,
tags
)
}
}
}
private fun parseUrls(
content: String,
tags: ImmutableListOfLists<String>
): RichTextViewerState {
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl }
val imagesForPager = urlSet.mapNotNull { fullUrl ->
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableUrlImage(fullUrl)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableUrlVideo(fullUrl)
} else {
null
}
}.associateBy { it.url }
val imageList = imagesForPager.values.toList()
val emojiMap =
tags.lists.filter { it.size > 2 && it[0] == "emoji" }.associate { ":${it[1]}:" to it[2] }
return if (urlSet.isNotEmpty() || emojiMap.isNotEmpty()) {
RichTextViewerState(
urlSet.toImmutableSet(),
imagesForPager.toImmutableMap(),
imageList.toImmutableList(),
emojiMap.toImmutableMap()
)
} else {
RichTextViewerState(
persistentSetOf(),
persistentMapOf(),
persistentListOf(),
persistentMapOf()
)
}
}
enum class WordType {
IMAGE, LINK, EMOJI, INVOICE, WITHDRAW, CASHU, EMAIL, PHONE, BECH, HASH_INDEX, HASHTAG, SCHEMELESS_URL, OTHER
}
@Composable
private fun RenderWord(
word: String,
state: RichTextViewerState,
canPreview: Boolean,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
tags: ImmutableListOfLists<String>
) {
val type = remember(word) {
if (state.imagesForPager[word] != null) {
WordType.IMAGE
} else if (state.urlSet.contains(word)) {
WordType.LINK
} else if (state.customEmoji.any { word.contains(it.key) }) {
WordType.EMOJI
} else if (word.startsWith("lnbc", true)) {
WordType.INVOICE
} else if (word.startsWith("lnurl", true)) {
WordType.WITHDRAW
} else if (word.startsWith("cashuA", true)) {
WordType.CASHU
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
WordType.EMAIL
} else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) {
WordType.PHONE
} else if (startsWithNIP19Scheme(word)) {
WordType.BECH
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches()) {
if (tags.lists.isNotEmpty()) {
WordType.HASH_INDEX
} else {
WordType.OTHER
MeasureSpaceWidth() { spaceWidth ->
Column {
if (canPreview) {
// FlowRow doesn't work well with paragraphs. So we need to split them
state.paragraphs.forEach { paragraph ->
FlowRow(horizontalArrangement = Arrangement.spacedBy(spaceWidth)) {
paragraph.forEach { word ->
RenderWordWithPreview(
word,
state,
backgroundColor,
textStyle,
accountViewModel,
nav
)
}
}
}
} else if (hashTagsPattern.matcher(word).matches()) {
WordType.HASHTAG
} else {
WordType.OTHER
// FlowRow doesn't work well with paragraphs. So we need to split them
state.paragraphs.forEach { paragraph ->
FlowRow(horizontalArrangement = Arrangement.spacedBy(spaceWidth)) {
paragraph.forEach { word ->
RenderWordWithoutPreview(
word,
state,
backgroundColor,
textStyle,
accountViewModel,
nav
)
}
}
}
}
} else if (noProtocolUrlValidator.matcher(word).matches()) {
WordType.SCHEMELESS_URL
} else {
WordType.OTHER
}
}
}
if (canPreview) {
RenderWordWithPreview(type, word, state, tags, backgroundColor, accountViewModel, nav)
} else {
RenderWordWithoutPreview(type, word, state, tags, backgroundColor, accountViewModel, nav)
@Composable
fun MeasureSpaceWidth(
content: @Composable (measuredWidth: Dp) -> Unit
) {
SubcomposeLayout { constraints ->
val measuredWidth = subcompose("viewToMeasure", { Text(" ") })[0].measure(Constraints()).width.toDp()
val contentPlaceable = subcompose("content") {
content(measuredWidth)
}[0].measure(constraints)
layout(contentPlaceable.width, contentPlaceable.height) {
contentPlaceable.place(0, 0)
}
}
}
@Composable
private fun RenderWordWithoutPreview(
type: WordType,
word: String,
word: Segment,
state: RichTextViewerState,
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
style: TextStyle,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val wordSpace = remember(word) {
"$word "
}
when (type) {
when (word) {
// Don't preview Images
WordType.IMAGE -> UrlPreview(word, wordSpace)
WordType.LINK -> UrlPreview(word, wordSpace)
WordType.EMOJI -> RenderCustomEmoji(word, state)
is ImageSegment -> ClickableUrl(word.segmentText, word.segmentText)
is LinkSegment -> ClickableUrl(word.segmentText, word.segmentText)
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
// Don't offer to pay invoices
WordType.INVOICE -> NormalWord(wordSpace)
is InvoiceSegment -> NormalWord(word.segmentText, style)
// Don't offer to withdraw
WordType.WITHDRAW -> NormalWord(wordSpace)
WordType.CASHU -> NormalWord(wordSpace)
WordType.EMAIL -> ClickableEmail(word)
WordType.PHONE -> ClickablePhone(word)
WordType.BECH -> BechLink(word, false, backgroundColor, accountViewModel, nav)
WordType.HASHTAG -> HashTag(word, nav)
WordType.HASH_INDEX -> TagLink(word, tags, false, backgroundColor, accountViewModel, nav)
WordType.SCHEMELESS_URL -> NoProtocolUrlRenderer(word)
WordType.OTHER -> NormalWord(wordSpace)
is WithdrawSegment -> NormalWord(word.segmentText, style)
is CashuSegment -> NormalWord(word.segmentText, style)
is EmailSegment -> ClickableEmail(word.segmentText)
is PhoneSegment -> ClickablePhone(word.segmentText)
is BechSegment -> BechLink(word.segmentText, false, backgroundColor, accountViewModel, nav)
is HashTagSegment -> HashTag(word, nav)
is HashIndexUserSegment -> TagLink(word, nav)
is HashIndexEventSegment -> TagLink(word, false, backgroundColor, accountViewModel, nav)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
is RegularTextSegment -> NormalWord(word.segmentText, style)
}
}
@Composable
private fun RenderWordWithPreview(
type: WordType,
word: String,
word: Segment,
state: RichTextViewerState,
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
style: TextStyle,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val wordSpace = remember(word) {
"$word "
}
when (type) {
WordType.IMAGE -> ZoomableContentView(word, state)
WordType.LINK -> UrlPreview(word, wordSpace)
WordType.EMOJI -> RenderCustomEmoji(word, state)
WordType.INVOICE -> MayBeInvoicePreview(word)
WordType.WITHDRAW -> MayBeWithdrawal(word)
WordType.CASHU -> CashuPreview(word, accountViewModel)
WordType.EMAIL -> ClickableEmail(word)
WordType.PHONE -> ClickablePhone(word)
WordType.BECH -> BechLink(word, true, backgroundColor, accountViewModel, nav)
WordType.HASHTAG -> HashTag(word, nav)
WordType.HASH_INDEX -> TagLink(word, tags, true, backgroundColor, accountViewModel, nav)
WordType.SCHEMELESS_URL -> NoProtocolUrlRenderer(word)
WordType.OTHER -> NormalWord(wordSpace)
when (word) {
is ImageSegment -> ZoomableContentView(word.segmentText, state)
is LinkSegment -> UrlPreview(word.segmentText, word.segmentText)
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
is InvoiceSegment -> MayBeInvoicePreview(word.segmentText)
is WithdrawSegment -> MayBeWithdrawal(word.segmentText)
is CashuSegment -> CashuPreview(word.segmentText, accountViewModel)
is EmailSegment -> ClickableEmail(word.segmentText)
is PhoneSegment -> ClickablePhone(word.segmentText)
is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav)
is HashTagSegment -> HashTag(word, nav)
is HashIndexUserSegment -> TagLink(word, nav)
is HashIndexEventSegment -> TagLink(word, true, backgroundColor, accountViewModel, nav)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
is RegularTextSegment -> NormalWord(word.segmentText, style)
}
}
@@ -353,70 +263,36 @@ private fun ZoomableContentView(word: String, state: RichTextViewerState) {
}
@Composable
private fun NormalWord(word: String) {
Text(
private fun NormalWord(word: String, style: TextStyle) {
BasicText(
text = word,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
style = style
)
}
@Immutable
data class UrlWithExtraChars(val url: String, val extraChars: String)
@Composable
private fun NoProtocolUrlRenderer(word: String) {
val wordSpace = remember(word) {
"$word "
}
var linkedUrl by remember(word) {
mutableStateOf<UrlWithExtraChars?>(null)
}
LaunchedEffect(key1 = word) {
launch(Dispatchers.Default) {
val matcher = noProtocolUrlValidator.matcher(word)
if (matcher.find()) {
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
launch(Dispatchers.Main) {
linkedUrl = UrlWithExtraChars(url, additionalChars)
}
}
}
}
Crossfade(targetState = linkedUrl) {
if (it != null) {
RenderUrl(it)
} else {
Text(
text = wordSpace,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
}
private fun NoProtocolUrlRenderer(word: SchemelessUrlSegment) {
RenderUrl(word)
}
@Composable
private fun RenderUrl(it: UrlWithExtraChars) {
private fun RenderUrl(segment: SchemelessUrlSegment) {
Row() {
ClickableUrl(it.url, "https://${it.url}")
Text("${it.extraChars} ")
ClickableUrl(segment.url, "https://${segment.url}")
segment.extras?.let { it1 -> Text(it1) }
}
}
@Composable
fun RenderCustomEmoji(word: String, state: RichTextViewerState) {
CreateTextWithEmoji(
text = remember { "$word " },
text = word,
emojis = state.customEmoji
)
}
@Composable
private fun RenderContentAsMarkdown(content: String, backgroundColor: MutableState<Color>, tags: ImmutableListOfLists<String>?, nav: (String) -> Unit) {
private fun RenderContentAsMarkdown(content: String, tags: ImmutableListOfLists<String>?, nav: (String) -> Unit) {
val uri = LocalUriHandler.current
val onClick = remember {
{ link: String ->
@@ -464,9 +340,11 @@ private fun RefreshableContent(content: String, tags: ImmutableListOfLists<Strin
@Composable
fun ObserverAllNIP19References(content: String, tags: ImmutableListOfLists<String>?, onRefresh: () -> Unit) {
var nip19References by remember(content) { mutableStateOf<List<Nip19.Return>>(emptyList()) }
LaunchedEffect(key1 = content) {
launch(Dispatchers.IO) {
nip19References = returnNIP19References(content, tags)
onRefresh()
}
}
@@ -513,10 +391,12 @@ private fun ObserveNIP19Event(
@Composable
fun ObserveNote(note: Note, onRefresh: () -> Unit) {
val noteState by note.live().metadata.observeAsState()
val loadedNoteId by note.live().metadata.map {
it.note.event?.id()
}.distinctUntilChanged().observeAsState(note.event?.id())
LaunchedEffect(key1 = noteState) {
if (noteState?.note?.event != null) {
LaunchedEffect(key1 = loadedNoteId) {
if (loadedNoteId != null) {
launch(Dispatchers.IO) {
onRefresh()
}
@@ -550,10 +430,12 @@ private fun ObserveNIP19User(
@Composable
private fun ObserveUser(user: User, onRefresh: () -> Unit) {
val userState by user.live().metadata.observeAsState()
val loadedUserMetaId by user.live().metadata.map {
it.user.info?.latestMetadata?.id
}.distinctUntilChanged().observeAsState(user.info?.latestMetadata?.id)
LaunchedEffect(key1 = userState) {
if (userState?.user?.info != null) {
LaunchedEffect(key1 = loadedUserMetaId) {
if (loadedUserMetaId != null) {
launch(Dispatchers.IO) {
onRefresh()
}
@@ -702,10 +584,6 @@ private fun returnMarkdownWithSpecialContent(content: String, tags: ImmutableLis
return returnContent
}
private fun isArabic(text: String): Boolean {
return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' }
}
fun startsWithNIP19Scheme(word: String): Boolean {
val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@")
@@ -719,45 +597,51 @@ data class LoadedBechLink(val baseNote: Note?, val nip19: Nip19.Return)
fun BechLink(word: String, canPreview: Boolean, backgroundColor: MutableState<Color>, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
var loadedLink by remember { mutableStateOf<LoadedBechLink?>(null) }
LaunchedEffect(key1 = word) {
launch(Dispatchers.IO) {
Nip19.uriToRoute(word)?.let {
var returningNote: Note? = null
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
returningNote = note
if (loadedLink == null) {
LaunchedEffect(key1 = word) {
launch(Dispatchers.IO) {
Nip19.uriToRoute(word)?.let {
var returningNote: Note? = null
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
returningNote = note
}
}
}
val newLink = LoadedBechLink(returningNote, it)
val newLink = LoadedBechLink(returningNote, it)
launch(Dispatchers.Main) {
loadedLink = newLink
launch(Dispatchers.Main) {
loadedLink = newLink
}
}
}
}
}
Crossfade(targetState = loadedLink) {
if (canPreview && it?.baseNote != null) {
Row() {
DisplayFullNote(it.baseNote, accountViewModel, backgroundColor, nav, it)
}
} else if (it?.nip19 != null) {
Row() {
ClickableRoute(it.nip19, nav)
}
} else {
val text = remember {
if (word.length > 16) {
word.replaceRange(8, word.length - 8, ":")
} else {
"$word "
}
}
Text(text = text, maxLines = 1)
if (canPreview && loadedLink?.baseNote != null) {
Row() {
DisplayFullNote(
loadedLink?.baseNote!!,
accountViewModel,
backgroundColor,
nav,
loadedLink!!
)
}
} else if (loadedLink?.nip19 != null) {
Row() {
ClickableRoute(loadedLink?.nip19!!, nav)
}
} else {
val text = remember {
if (word.length > 16) {
word.replaceRange(8, word.length - 8, ":")
} else {
word
}
}
Text(text = text, maxLines = 1)
}
}
@@ -779,11 +663,7 @@ private fun DisplayFullNote(
)
val extraChars = remember(loadedLink) {
if (loadedLink.nip19.additionalChars.isNotBlank()) {
"${loadedLink.nip19.additionalChars} "
} else {
null
}
loadedLink.nip19.additionalChars.ifBlank { null }
}
extraChars?.let {
@@ -793,59 +673,29 @@ private fun DisplayFullNote(
}
}
data class HashWordWithExtra(val hashtag: String, val extras: String?)
@Composable
fun HashTag(word: String, nav: (String) -> Unit) {
var tagSuffixPair by remember { mutableStateOf<HashWordWithExtra?>(null) }
LaunchedEffect(key1 = word) {
launch(Dispatchers.IO) {
val hashtagMatcher = hashTagsPattern.matcher(word)
val (myTag, mySuffix) = try {
hashtagMatcher.find()
Pair(hashtagMatcher.group(1), hashtagMatcher.group(2))
} catch (e: Exception) {
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
Pair(null, null)
}
if (myTag != null) {
launch(Dispatchers.Main) {
tagSuffixPair = HashWordWithExtra(myTag, mySuffix)
}
}
}
}
Crossfade(targetState = tagSuffixPair) {
if (it != null) {
Row() {
RenderHashtag(it, nav)
}
} else {
Text(text = remember { "$word " })
}
fun HashTag(word: HashTagSegment, nav: (String) -> Unit) {
Row() {
RenderHashtag(word, nav)
}
}
@Composable
private fun RenderHashtag(
tagPair: HashWordWithExtra,
segment: HashTagSegment,
nav: (String) -> Unit
) {
val primary = MaterialTheme.colors.primary
val hashtagIcon = remember(tagPair.hashtag) { checkForHashtagWithIcon(tagPair.hashtag, primary) }
val hashtagIcon = remember(segment.hashtag) { checkForHashtagWithIcon(segment.hashtag, primary) }
ClickableText(
text = buildAnnotatedString {
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
) {
append("#${tagPair.hashtag}")
append("#${segment.hashtag}")
}
},
onClick = { nav("Hashtag/${tagPair.hashtag}") }
onClick = { nav("Hashtag/${segment.hashtag}") }
)
if (hashtagIcon != null) {
@@ -866,8 +716,8 @@ private fun RenderHashtag(
)
)
}
tagPair.extras?.ifBlank { "" }?.let {
Text(text = "$it ")
segment.extras?.ifBlank { "" }?.let {
Text(text = it)
}
}
@@ -888,63 +738,46 @@ private fun InlineIcon(hashtagIcon: HashtagIcon) =
)
}
data class LoadedTag(val user: User?, val note: Note?, val addedChars: String)
@Composable
fun TagLink(word: HashIndexUserSegment, nav: (String) -> Unit) {
LoadUser(baseUserHex = word.hex) {
if (it == null) {
Text(text = word.segmentText)
} else {
Row() {
DisplayUserFromTag(it, word.extras, nav)
}
}
}
}
@Composable
fun TagLink(word: String, tags: ImmutableListOfLists<String>, canPreview: Boolean, backgroundColor: MutableState<Color>, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
var loadedTag by remember { mutableStateOf<LoadedTag?>(null) }
fun LoadNote(baseNoteHex: String, content: @Composable (Note?) -> Unit) {
var note by remember(baseNoteHex) {
mutableStateOf<Note?>(LocalCache.getNoteIfExists(baseNoteHex))
}
LaunchedEffect(key1 = word) {
if (loadedTag == null) {
if (note == null) {
LaunchedEffect(key1 = baseNoteHex) {
launch(Dispatchers.IO) {
val matcher = tagIndex.matcher(word)
val (index, suffix) = try {
matcher.find()
Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "")
} catch (e: Exception) {
Log.w("Tag Parser", "Couldn't link tag $word", e)
Pair(null, "")
}
if (index != null && index >= 0 && index < tags.lists.size) {
val tag = tags.lists[index]
if (tag.size > 1) {
if (tag[0] == "p") {
LocalCache.checkGetOrCreateUser(tag[1])?.let {
launch(Dispatchers.Main) {
loadedTag = LoadedTag(it, null, suffix)
}
}
} else if (tag[0] == "e" || tag[0] == "a") {
LocalCache.checkGetOrCreateNote(tag[1])?.let {
launch(Dispatchers.Main) {
loadedTag = LoadedTag(null, it, suffix)
}
}
}
}
}
note = LocalCache.checkGetOrCreateNote(baseNoteHex)
}
}
}
Crossfade(targetState = loadedTag) {
content(note)
}
@Composable
fun TagLink(word: HashIndexEventSegment, canPreview: Boolean, backgroundColor: MutableState<Color>, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
LoadNote(baseNoteHex = word.hex) {
if (it == null) {
Text(
text = remember {
"$word "
}
)
} else if (it.user != null) {
Row() {
DisplayUserFromTag(it.user, loadedTag?.addedChars ?: "", nav)
}
} else if (it.note != null) {
Text(text = remember { word.segmentText.toShortenHex() })
} else {
Row() {
DisplayNoteFromTag(
it.note,
loadedTag?.addedChars ?: "",
it,
word.extras,
canPreview,
accountViewModel,
backgroundColor,
@@ -958,7 +791,7 @@ fun TagLink(word: String, tags: ImmutableListOfLists<String>, canPreview: Boolea
@Composable
private fun DisplayNoteFromTag(
baseNote: Note,
addedChars: String,
addedChars: String?,
canPreview: Boolean,
accountViewModel: AccountViewModel,
backgroundColor: MutableState<Color>,
@@ -973,23 +806,22 @@ private fun DisplayNoteFromTag(
isQuotedNote = true,
nav = nav
)
addedChars.ifBlank { null }?.let {
Text(text = remember { "$it " })
}
} else {
ClickableNoteTag(baseNote, nav)
Text(text = remember { "$addedChars " })
}
addedChars?.ifBlank { null }?.let {
Text(text = it)
}
}
@Composable
private fun DisplayUserFromTag(
baseUser: User,
addedChars: String,
addedChars: String?,
nav: (String) -> Unit
) {
val route = remember { "User/${baseUser.pubkeyHex}" }
val suffix = remember { "$addedChars " }
val hex = remember { baseUser.pubkeyDisplayHex() }
val meta by baseUser.live().metadata.map {
@@ -999,11 +831,11 @@ private fun DisplayUserFromTag(
Crossfade(targetState = meta) {
Row() {
val displayName = remember(it) {
it?.bestDisplayName() ?: hex
it?.bestDisplayName() ?: it?.bestUsername() ?: hex
}
CreateClickableTextWithEmoji(
clickablePart = displayName,
suffix = suffix,
suffix = addedChars,
maxLines = 1,
route = route,
nav = nav,

View File

@@ -34,6 +34,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.EventInterface
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
@@ -43,7 +44,18 @@ fun SensitivityWarning(
accountViewModel: AccountViewModel,
content: @Composable () -> Unit
) {
val hasSensitiveContent = remember(note.event) { note.event?.isSensitive() ?: false }
note.event?.let {
SensitivityWarning(it, accountViewModel, content)
}
}
@Composable
fun SensitivityWarning(
event: EventInterface,
accountViewModel: AccountViewModel,
content: @Composable () -> Unit
) {
val hasSensitiveContent = remember(event) { event.isSensitive() ?: false }
if (hasSensitiveContent) {
SensitivityWarning(accountViewModel, content)

View File

@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -22,12 +21,10 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.Icon
@@ -42,6 +39,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -49,7 +47,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
@@ -63,12 +60,12 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isFinite
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.net.toUri
import coil.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import coil.imageLoader
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.toHexKey
@@ -77,7 +74,7 @@ import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.theme.hashVerified
import com.vitorpamplona.amethyst.ui.theme.Font17SP
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -211,90 +208,57 @@ fun ZoomableContentView(content: ZoomableContent, images: ImmutableList<Zoomable
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun LocalImageView(
content: ZoomableLocalImage,
mainImageModifier: Modifier
) {
BoxWithConstraints(contentAlignment = Alignment.Center) {
// store the dialog open or close state
var imageLoadingState by remember {
mutableStateOf<Boolean?>(null)
}
if (content.localFile != null && content.localFile.exists()) {
BoxWithConstraints(contentAlignment = Alignment.Center) {
val myModifier = remember {
mainImageModifier
.widthIn(max = maxWidth)
.heightIn(max = maxHeight)
.run {
aspectRatio(content.dim)?.let { ratio ->
this.aspectRatio(ratio, false)
} ?: this
}
}
val myModifier = remember {
mainImageModifier
.widthIn(max = maxWidth)
.heightIn(max = maxHeight)
.run {
aspectRatio(content.dim)?.let { ratio ->
this.aspectRatio(ratio, false)
} ?: this
}
}
val contentScale = remember {
if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
}
val contentScale = remember {
if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
}
val verifierModifier = Modifier.align(Alignment.TopEnd)
val painterState = remember {
mutableStateOf<AsyncImagePainter.State?>(null)
}
if (content.localFile != null && content.localFile.exists()) {
AsyncImage(
model = content.localFile,
contentDescription = content.description,
contentScale = contentScale,
modifier = myModifier,
onError = {
if (imageLoadingState != false) {
imageLoadingState = false
}
},
onSuccess = {
if (imageLoadingState != true) {
imageLoadingState = true
}
onState = {
painterState.value = it
}
)
}
if (imageLoadingState == true) {
if (content.isVerified != null) {
HashVerificationSymbol(content.isVerified, Modifier.align(Alignment.TopEnd))
}
} else if (imageLoadingState == false || content.localFile == null || !content.localFile.exists()) {
BlankNote()
} else {
if (content.blurhash != null) {
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
} else {
FlowRow() {
DisplayUrlWithLoadingSymbol(content)
}
}
AddedImageFeatures(painterState, content, contentScale, myModifier, verifierModifier)
}
} else {
BlankNote()
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun UrlImageView(
content: ZoomableUrlImage,
mainImageModifier: Modifier
) {
val scope = rememberCoroutineScope()
BoxWithConstraints(contentAlignment = Alignment.Center) {
val context = LocalContext.current
// store the dialog open or close state
var imageLoadingStatus by remember {
mutableStateOf<Boolean?>(null)
}
var verifiedHash by remember {
mutableStateOf<Boolean?>(null)
}
val myModifier = remember {
mainImageModifier
.widthIn(max = maxWidth)
@@ -310,35 +274,37 @@ private fun UrlImageView(
if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
}
val verifierModifier = Modifier.align(Alignment.TopEnd)
val painterState = remember {
mutableStateOf<AsyncImagePainter.State?>(null)
}
AsyncImage(
model = content.url,
contentDescription = content.description,
contentScale = contentScale,
modifier = myModifier,
onError = {
if (imageLoadingStatus != false) {
imageLoadingStatus = false
}
},
onSuccess = {
if (verifiedHash == null) {
scope.launch(Dispatchers.IO) {
verifiedHash = verifyHash(content, context)
}
}
if (imageLoadingStatus != true) {
imageLoadingStatus = true
}
onState = {
painterState.value = it
}
)
if (imageLoadingStatus == true) {
verifiedHash?.let {
HashVerificationSymbol(it, Modifier.align(Alignment.TopEnd))
}
} else if (imageLoadingStatus == false) {
ClickableUrl(urlText = "${content.url} ", url = content.url)
} else {
AddedImageFeatures(painterState, content, contentScale, myModifier, verifierModifier)
}
}
@Composable
@OptIn(ExperimentalLayoutApi::class)
private fun AddedImageFeatures(
painter: MutableState<AsyncImagePainter.State?>,
content: ZoomableLocalImage,
contentScale: ContentScale,
myModifier: Modifier,
verifiedModifier: Modifier
) {
when (painter.value) {
null, is AsyncImagePainter.State.Loading -> {
if (content.blurhash != null) {
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
} else {
@@ -347,6 +313,70 @@ private fun UrlImageView(
}
}
}
is AsyncImagePainter.State.Error -> {
BlankNote()
}
is AsyncImagePainter.State.Success -> {
if (content.isVerified != null) {
HashVerificationSymbol(content.isVerified, verifiedModifier)
}
}
else -> {
}
}
}
@Composable
@OptIn(ExperimentalLayoutApi::class)
private fun AddedImageFeatures(
painter: MutableState<AsyncImagePainter.State?>,
content: ZoomableUrlImage,
contentScale: ContentScale,
myModifier: Modifier,
verifiedModifier: Modifier
) {
var verifiedHash by remember {
mutableStateOf<Boolean?>(null)
}
when (painter.value) {
null, is AsyncImagePainter.State.Loading -> {
if (content.blurhash != null) {
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
} else {
FlowRow() {
DisplayUrlWithLoadingSymbol(content)
}
}
}
is AsyncImagePainter.State.Error -> {
ClickableUrl(urlText = "${content.url} ", url = content.url)
}
is AsyncImagePainter.State.Success -> {
if (content.hash != null) {
val context = LocalContext.current
LaunchedEffect(key1 = content.url) {
launch(Dispatchers.IO) {
val newVerifiedHash = verifyHash(content, context)
if (newVerifiedHash != verifiedHash) {
verifiedHash = newVerifiedHash
}
}
}
}
verifiedHash?.let {
HashVerificationSymbol(it, verifiedModifier)
}
}
else -> {
}
}
}
@@ -382,7 +412,7 @@ private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
@Composable
private fun DisplayUrlWithLoadingSymbolWait(content: ZoomableContent) {
if (content is ZoomableUrlContent) {
ClickableUrl(urlText = "${content.url} ", url = content.url)
ClickableUrl(urlText = remember { "${content.url} " }, url = content.url)
} else {
Text("Loading content... ")
}
@@ -401,8 +431,8 @@ private fun DisplayUrlWithLoadingSymbolWait(content: ZoomableContent) {
myId,
InlineTextContent(
Placeholder(
width = 17.sp,
height = 17.sp,
width = Font17SP,
height = Font17SP,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
)
) {
@@ -566,15 +596,7 @@ private fun HashVerificationSymbol(verifiedHash: Boolean, modifier: Modifier) {
.height(40.dp)
.padding(10.dp)
) {
Box(
Modifier
.clip(CircleShape)
.fillMaxSize(0.6f)
.align(Alignment.Center)
.background(MaterialTheme.colors.background)
)
if (verifiedHash == true) {
if (verifiedHash) {
IconButton(
onClick = {
scope.launch {
@@ -589,11 +611,11 @@ private fun HashVerificationSymbol(verifiedHash: Boolean, modifier: Modifier) {
Icon(
painter = painterResource(R.drawable.ic_verified),
"Hash Verified",
tint = MaterialTheme.colors.hashVerified,
tint = Color.Unspecified,
modifier = Modifier.size(30.dp)
)
}
} else if (verifiedHash == false) {
} else {
IconButton(
onClick = {
scope.launch {

View File

@@ -0,0 +1,43 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent
class CommunityFeedFilter(val note: AddressableNote, val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + note.idHex
}
override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.notes.values))
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val myUnapprovedPosts = collection.asSequence()
.filter { it.author?.pubkeyHex == account.userProfile().pubkeyHex } // made by the logged in user
.filter { it.event?.isTaggedAddressableNote(note.idHex) == true } // for this community
.filter { it.isNewThread() } // check if it is a new thread
.toSet()
val approvedPosts = collection
.asSequence()
.filter { it.event is CommunityPostApprovalEvent } // Only Approvals
.filter { it.event?.isTaggedAddressableNote(note.idHex) == true } // Of the given community
.mapNotNull { it.replyTo }.flatten() // get approved posts
.filter { it.isNewThread() } // check if it is a new thread
.toSet()
return myUnapprovedPosts + approvedPosts
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
}

View File

@@ -0,0 +1,66 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ParticipantListBuilder
import com.vitorpamplona.amethyst.service.model.*
open class DiscoverChatFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList
}
override fun feed(): List<Note> {
val allChannelNotes = LocalCache.channels.values.mapNotNull { LocalCache.getNoteIfExists(it.idHex) }
val notes = innerApplyFilter(allChannelNotes)
return sort(notes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val now = System.currentTimeMillis() / 1000
val isGlobal = account.defaultDiscoveryFollowList == GLOBAL_FOLLOWS
val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
val createEvents = collection.filter { it.event is ChannelCreateEvent }
val anyOtherChannelEvent = collection
.asSequence()
.filter { it.event is IsInPublicChatChannel }
.mapNotNull { (it.event as? ChannelMessageEvent)?.channel() }
.mapNotNull { LocalCache.checkGetOrCreateNote(it) }
.toSet()
val activities = (createEvents + anyOtherChannelEvent)
.asSequence()
.filter { it.event is ChannelCreateEvent }
.filter { isGlobal || it.author?.pubkeyHex in followingKeySet || it.event?.isTaggedHashes(followingTagSet) == true }
.filter { it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true }
.filter { (it.createdAt() ?: 0) <= now }
.toSet()
return activities
}
override fun sort(collection: Set<Note>): List<Note> {
val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
val counter = ParticipantListBuilder()
return collection.sortedWith(
compareBy(
{ counter.countFollowsThatParticipateOn(it, followingKeySet) },
{ it.createdAt() },
{ it.idHex }
)
).reversed()
}
}

View File

@@ -0,0 +1,57 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ParticipantListBuilder
import com.vitorpamplona.amethyst.service.model.*
open class DiscoverCommunityFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList
}
override fun feed(): List<Note> {
val allNotes = LocalCache.addressables.values
val notes = innerApplyFilter(allNotes)
return sort(notes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val now = System.currentTimeMillis() / 1000
val isGlobal = account.defaultDiscoveryFollowList == GLOBAL_FOLLOWS
val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
val activities = collection
.asSequence()
.filter { it.event is CommunityDefinitionEvent }
.filter { isGlobal || it.author?.pubkeyHex in followingKeySet || it.event?.isTaggedHashes(followingTagSet) == true }
.filter { it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true }
.filter { (it.createdAt() ?: 0) <= now }
.toSet()
return activities
}
override fun sort(collection: Set<Note>): List<Note> {
val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
val counter = ParticipantListBuilder()
return collection.sortedWith(
compareBy(
{ counter.countFollowsThatParticipateOn(it, followingKeySet) },
{ it.createdAt() },
{ it.idHex }
)
).reversed()
}
}

View File

@@ -0,0 +1,79 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ParticipantListBuilder
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_ENDED
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_LIVE
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_PLANNED
open class DiscoverLiveFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList
}
override fun feed(): List<Note> {
val allChannelNotes =
LocalCache.channels.values.mapNotNull { LocalCache.getNoteIfExists(it.idHex) }
val allMessageNotes = LocalCache.channels.values.map { it.notes.values }.flatten()
val notes = innerApplyFilter(allChannelNotes + allMessageNotes)
return sort(notes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val now = System.currentTimeMillis() / 1000
val isGlobal = account.defaultDiscoveryFollowList == GLOBAL_FOLLOWS
val followingKeySet =
account.selectedUsersFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
val followingTagSet =
account.selectedTagsFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
val activities = collection
.asSequence()
.filter { it.event is LiveActivitiesEvent }
.filter {
isGlobal || it.author?.pubkeyHex in followingKeySet || it.event?.isTaggedHashes(
followingTagSet
) == true
}
.filter { it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true }
.filter { (it.createdAt() ?: 0) <= now }
.toSet()
return activities
}
override fun sort(collection: Set<Note>): List<Note> {
val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
val counter = ParticipantListBuilder()
return collection.sortedWith(
compareBy(
{ convertStatusToOrder((it.event as? LiveActivitiesEvent)?.status()) },
{ counter.countFollowsThatParticipateOn(it, followingKeySet) },
{ (it.event as? LiveActivitiesEvent)?.starts() ?: it.createdAt() },
{ it.idHex }
)
).reversed()
}
fun convertStatusToOrder(status: String?): Int {
return when (status) {
STATUS_LIVE -> 2
STATUS_PLANNED -> 1
STATUS_ENDED -> 0
else -> 0
}
}
}

View File

@@ -0,0 +1,20 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_LIVE
class DiscoverLiveNowFeedFilter(account: Account) : DiscoverLiveFeedFilter(account) {
override fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val allItems = super.innerApplyFilter(collection)
val onlineOnly = allItems.filter {
val noteEvent = it.event as? LiveActivitiesEvent
noteEvent?.status() == STATUS_LIVE && OnlineChecker.isOnline(noteEvent.streaming())
}
return onlineOnly.toSet()
}
}

View File

@@ -1,53 +0,0 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.*
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)
return sort(notes + longFormNotes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val followChannels = account.followingChannels
val followUsers = account.followingKeySet()
val now = System.currentTimeMillis() / 1000
return collection
.asSequence()
.filter {
(it.event is BaseTextNoteEvent || it.event is AudioTrackEvent) && it.replyTo.isNullOrEmpty()
}
.filter {
val channel = it.channelHex()
// does not show events already in the public chat list
(channel == null || channel !in followChannels) &&
// does not show people the user already follows
(it.author?.pubkeyHex !in followUsers)
}
.filter { account.isAcceptable(it) }
.filter {
// Do not show notes with the creation time exceeding the current time, as they will always stay at the top of the global feed, which is cheating.
it.createdAt()!! <= now
}
.toSet()
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
}

View File

@@ -8,19 +8,12 @@ import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object HashtagFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account
var tag: String? = null
class HashtagFeedFilter(val tag: String, val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + tag
}
fun loadHashtag(account: Account, tag: String?) {
this.account = account
this.tag = tag
}
override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.notes.values))
}

View File

@@ -6,7 +6,7 @@ import com.vitorpamplona.amethyst.model.User
class HiddenAccountsFeedFilter(val account: Account) : FeedFilter<User>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + HashtagFeedFilter.tag
return account.userProfile().pubkeyHex
}
override fun feed(): List<User> {

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
@@ -23,6 +24,8 @@ class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter<Not
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val isGlobal = account.defaultHomeFollowList == GLOBAL_FOLLOWS
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
@@ -32,7 +35,7 @@ class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter<Not
.asSequence()
.filter {
(it.event is TextNoteEvent || it.event is PollNoteEvent || it.event is ChannelMessageEvent || it.event is LiveActivitiesChatMessageEvent) &&
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
(isGlobal || it.author?.pubkeyHex in followingKeySet || it.event?.isTaggedHashes(followingTagSet) ?: false) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it) } ?: true &&
((it.event?.createdAt() ?: 0) < now) &&

View File

@@ -1,54 +0,0 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import java.util.Date
class HomeLiveActivitiesFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList)?.size ?: 0
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList)?.size ?: 0
return account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList + "-" + followingKeySet + "-" + followingTagSet
}
override fun feed(): List<Note> {
val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
return sort(longFormNotes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
checkNotInMainThread()
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
val twoHrs = (Date().time / 1000) - 60 * 60 * 2 // hrs
return collection
.asSequence()
.filter { it ->
val noteEvent = it.event
(noteEvent is LiveActivitiesEvent && noteEvent.createdAt > twoHrs && noteEvent.status() == "live" && OnlineChecker.isOnline(noteEvent.streaming())) &&
(it.author?.pubkeyHex in followingKeySet || (noteEvent.isTaggedHashes(followingTagSet))) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true
}
.toSet()
}
override fun limit() = 2
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
}

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
@@ -29,6 +30,8 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val isGlobal = account.defaultHomeFollowList == GLOBAL_FOLLOWS
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
@@ -40,7 +43,7 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
.filter { it ->
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))) &&
(isGlobal || 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.event?.createdAt() ?: 0) < oneMinuteInTheFuture) &&

View File

@@ -41,6 +41,7 @@ import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -48,7 +49,7 @@ val bottomNavigationItems = listOf(
Route.Home,
Route.Message,
Route.Video,
Route.Search,
Route.Discover,
Route.Notification
)
@@ -101,7 +102,7 @@ private fun RenderBottomMenu(
navEntryState: State<NavBackStackEntry?>,
nav: (Route, Boolean) -> Unit
) {
Column(modifier = Modifier.height(50.dp)) {
Column(modifier = BottomTopHeight) {
Divider(
thickness = 0.25.dp
)

View File

@@ -12,8 +12,9 @@ import androidx.navigation.compose.composable
import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedLiveActivitiesViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverChatFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverCommunityFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverLiveFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
@@ -23,6 +24,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.BookmarkListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CommunityScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.DiscoverScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HashtagScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HiddenUsersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HomeScreen
@@ -39,11 +42,12 @@ import kotlinx.coroutines.launch
fun AppNavigation(
homeFeedViewModel: NostrHomeFeedViewModel,
repliesFeedViewModel: NostrHomeRepliesFeedViewModel,
liveActivitiesViewModel: NostrHomeFeedLiveActivitiesViewModel,
knownFeedViewModel: NostrChatroomListKnownFeedViewModel,
newFeedViewModel: NostrChatroomListNewFeedViewModel,
searchFeedViewModel: NostrGlobalFeedViewModel,
videoFeedViewModel: NostrVideoFeedViewModel,
discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel,
discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel,
discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel,
notifFeedViewModel: NotificationViewModel,
userReactionsStatsModel: UserReactionsViewModel,
@@ -69,7 +73,6 @@ fun AppNavigation(
HomeScreen(
homeFeedViewModel = homeFeedViewModel,
repliesFeedViewModel = repliesFeedViewModel,
liveActivitiesViewModel = liveActivitiesViewModel,
accountViewModel = accountViewModel,
nav = nav,
nip47 = nip47
@@ -108,10 +111,21 @@ fun AppNavigation(
})
}
Route.Discover.let { route ->
composable(route.route, route.arguments, content = {
DiscoverScreen(
discoveryLiveFeedViewModel = discoveryLiveFeedViewModel,
discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel,
discoveryChatFeedViewModel = discoveryChatFeedViewModel,
accountViewModel = accountViewModel,
nav = nav
)
})
}
Route.Search.let { route ->
composable(route.route, route.arguments, content = {
SearchScreen(
searchFeedViewModel = searchFeedViewModel,
accountViewModel = accountViewModel,
nav = nav
)
@@ -162,6 +176,16 @@ fun AppNavigation(
})
}
Route.Community.let { route ->
composable(route.route, route.arguments, content = {
CommunityScreen(
aTagHex = it.arguments?.getString("id"),
accountViewModel = accountViewModel,
nav = nav
)
})
}
Route.Room.let { route ->
composable(route.route, route.arguments, content = {
ChatroomScreen(

View File

@@ -48,7 +48,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavHostController
import coil.Coil
@@ -61,7 +60,6 @@ import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
@@ -74,12 +72,11 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -119,9 +116,12 @@ private fun RenderTopRouteBar(
when (currentRoute) {
Route.Channel.base -> NoTopBar()
Route.Room.base -> NoTopBar()
Route.Community.base -> NoTopBar()
Route.Hashtag.base -> NoTopBar()
// Route.Profile.route -> TopBarWithBackButton(nav)
Route.Home.base -> HomeTopBar(followLists, scaffoldState, accountViewModel, nav)
Route.Video.base -> StoriesTopBar(followLists, scaffoldState, accountViewModel, nav)
Route.Discover.base -> DiscoveryTopBar(followLists, scaffoldState, accountViewModel, nav)
Route.Notification.base -> NotificationTopBar(followLists, scaffoldState, accountViewModel, nav)
else -> MainTopBar(scaffoldState, accountViewModel, nav)
}
@@ -158,7 +158,7 @@ fun HomeTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, a
FollowList(
followLists,
list,
false
true
) { listName ->
accountViewModel.account.changeDefaultHomeFollowList(listName)
}
@@ -182,6 +182,23 @@ fun NotificationTopBar(followLists: FollowListViewModel, scaffoldState: Scaffold
}
}
@Composable
fun DiscoveryTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
GenericTopBar(scaffoldState, accountViewModel, nav) { accountViewModel ->
val list by accountViewModel.accountLiveData.map {
it.account.defaultDiscoveryFollowList
}.observeAsState(GLOBAL_FOLLOWS)
FollowList(
followLists,
list,
true
) { listName ->
accountViewModel.account.changeDefaultDiscoveryFollowList(listName)
}
}
}
@Composable
fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
GenericTopBar(scaffoldState, accountViewModel, nav) {
@@ -194,15 +211,7 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel,
fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, nav: (String) -> Unit, content: @Composable (AccountViewModel) -> Unit) {
val coroutineScope = rememberCoroutineScope()
var wantsToEditRelays by remember {
mutableStateOf(false)
}
if (wantsToEditRelays) {
NewRelayListView({ wantsToEditRelays = false }, accountViewModel, nav = nav)
}
Column(modifier = Modifier.height(50.dp)) {
Column(modifier = BottomTopHeight) {
TopAppBar(
elevation = 0.dp,
backgroundColor = MaterialTheme.colors.surface,
@@ -222,10 +231,6 @@ fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewMod
) {
content(accountViewModel)
}
RelayStatus(
{ wantsToEditRelays = true }
)
}
}
},
@@ -237,7 +242,9 @@ fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewMod
}
},
actions = {
RelayIcon { wantsToEditRelays = true }
SearchIcon() {
nav(Route.Search.route)
}
}
)
Divider(thickness = 0.25.dp)
@@ -245,80 +252,14 @@ fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewMod
}
@Composable
private fun RelayStatus(
onClick: () -> Unit
) {
val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() }
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
horizontalAlignment = Alignment.End
) {
Row(
modifier = Modifier.fillMaxHeight(),
verticalAlignment = Alignment.CenterVertically
) {
RelayStatus(relayViewModel, onClick)
}
}
}
@Composable
private fun RelayStatus(
relayViewModel: RelayPoolViewModel,
onClick: () -> Unit
) {
val connectedRelaysLiveData = relayViewModel.connectedRelaysLiveData.observeAsState()
val availableRelaysLiveData = relayViewModel.availableRelaysLiveData.observeAsState()
val connectedRelaysText by remember(connectedRelaysLiveData, availableRelaysLiveData) {
derivedStateOf {
"${connectedRelaysLiveData.value ?: "--"}/${availableRelaysLiveData.value ?: "--"}"
}
}
val isConnected by remember(connectedRelaysLiveData) {
derivedStateOf {
(connectedRelaysLiveData.value ?: 0) > 0
}
}
RenderRelayStatus(connectedRelaysText, isConnected, onClick)
}
@Composable
private fun RenderRelayStatus(
connectedRelaysText: String,
isConnected: Boolean,
onClick: () -> Unit
) {
Text(
text = connectedRelaysText,
color = if (isConnected) {
MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
} else {
Color.Red
},
style = MaterialTheme.typography.subtitle1,
modifier = Modifier.clickable(
onClick = onClick
)
)
}
@Composable
private fun RelayIcon(onClick: () -> Unit) {
private fun SearchIcon(onClick: () -> Unit) {
IconButton(
onClick = onClick,
modifier = Modifier
onClick = onClick
) {
Icon(
painter = painterResource(R.drawable.relays),
null,
modifier = Modifier.size(24.dp),
painter = painterResource(R.drawable.ic_search),
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = Color.Unspecified
)
}
@@ -534,7 +475,6 @@ fun AmethystIcon() {
NostrChannelDataSource.printCounter()
NostrChatroomDataSource.printCounter()
NostrChatroomListDataSource.printCounter()
NostrGlobalDataSource.printCounter()
NostrHashtagDataSource.printCounter()
NostrHomeDataSource.printCounter()
NostrSearchEventOrUserDataSource.printCounter()

View File

@@ -34,6 +34,7 @@ import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -52,6 +53,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
@@ -60,13 +62,17 @@ import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel
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 com.vitorpamplona.amethyst.ui.theme.Size16dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -301,6 +307,12 @@ fun ListContent(
var conectOrbotDialogOpen by remember { mutableStateOf(false) }
var proxyPort = remember { mutableStateOf(account.proxyPort.toString()) }
val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() }
var wantsToEditRelays by remember {
mutableStateOf(false)
}
Column(
modifier = modifier
.fillMaxHeight()
@@ -324,6 +336,16 @@ fun ListContent(
route = Route.Bookmarks.route
)
IconRowRelays(
relayViewModel = relayViewModel,
onClick = {
coroutineScope.launch {
scaffoldState.drawerState.close()
}
wantsToEditRelays = true
}
)
NavigationRow(
title = stringResource(R.string.security_filters),
icon = Route.BlockedUsers.icon,
@@ -431,6 +453,10 @@ fun ListContent(
}
)
}
if (wantsToEditRelays) {
NewRelayListView({ wantsToEditRelays = false }, accountViewModel, nav = nav)
}
}
private fun enableTor(
@@ -446,6 +472,44 @@ private fun enableTor(
ServiceManager.start(context)
}
@Composable
private fun RelayStatus(
relayViewModel: RelayPoolViewModel
) {
val connectedRelaysLiveData = relayViewModel.connectedRelaysLiveData.observeAsState()
val availableRelaysLiveData = relayViewModel.availableRelaysLiveData.observeAsState()
val connectedRelaysText by remember(connectedRelaysLiveData, availableRelaysLiveData) {
derivedStateOf {
"${connectedRelaysLiveData.value ?: "--"}/${availableRelaysLiveData.value ?: "--"}"
}
}
val isConnected by remember(connectedRelaysLiveData) {
derivedStateOf {
(connectedRelaysLiveData.value ?: 0) > 0
}
}
RenderRelayStatus(connectedRelaysText, isConnected)
}
@Composable
private fun RenderRelayStatus(
connectedRelaysText: String,
isConnected: Boolean
) {
Text(
text = connectedRelaysText,
color = if (isConnected) {
MaterialTheme.colors.placeholderText
} else {
Color.Red
},
style = MaterialTheme.typography.subtitle1
)
}
@Composable
fun NavigationRow(
title: String,
@@ -496,6 +560,39 @@ fun IconRow(title: String, icon: Int, tint: Color, onClick: () -> Unit, onLongCl
}
}
@Composable
fun IconRowRelays(relayViewModel: RelayPoolViewModel, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() }
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 15.dp, horizontal = 25.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(R.drawable.relays),
null,
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colors.onSurface
)
Text(
modifier = Modifier.padding(start = 16.dp),
text = stringResource(id = R.string.relay_setup),
fontSize = 18.sp
)
Spacer(modifier = Modifier.width(Size16dp))
RelayStatus(relayViewModel = relayViewModel)
}
}
}
@Composable
fun BottomContent(user: User, scaffoldState: ScaffoldState, nav: (String) -> Unit) {
val coroutineScope = rememberCoroutineScope()

View File

@@ -14,9 +14,11 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DiscoverLiveNowFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
import kotlinx.collections.immutable.ImmutableList
@@ -42,9 +44,14 @@ sealed class Route(
hasNewItems = { accountViewModel, newNotes -> HomeLatestItem.hasNewItems(accountViewModel, newNotes) }
)
object Global : Route(
route = "Global",
icon = R.drawable.ic_globe
)
object Search : Route(
route = "Search",
icon = R.drawable.ic_globe
icon = R.drawable.ic_search
)
object Video : Route(
@@ -52,6 +59,12 @@ sealed class Route(
icon = R.drawable.ic_video
)
object Discover : Route(
route = "Discover",
icon = R.drawable.ic_sensors,
hasNewItems = { accountViewModel, newNotes -> DiscoverLatestItem.hasNewItems(accountViewModel, newNotes) }
)
object Notification : Route(
route = "Notification",
icon = R.drawable.ic_notifications,
@@ -92,6 +105,12 @@ sealed class Route(
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList()
)
object Community : Route(
route = "Community/{id}",
icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList()
)
object Room : Route(
route = "Room/{id}",
icon = R.drawable.ic_moments,
@@ -176,6 +195,29 @@ object HomeLatestItem : LatestItem() {
}
}
object DiscoverLatestItem : LatestItem() {
fun hasNewItems(
account: Account,
newNotes: Set<Note>
): Boolean {
checkNotInMainThread()
val lastTime = account.loadLastRead(Route.Discover.base + "Live")
val newestItem = updateNewestItem(newNotes, account, DiscoverLiveNowFeedFilter(account))
val noteEvent = newestItem?.event
val dateToUse = if (noteEvent is LiveActivitiesEvent) {
noteEvent.starts() ?: newestItem.createdAt()
} else {
newestItem?.createdAt()
}
return (dateToUse ?: 0) > lastTime
}
}
object NotificationLatestItem : LatestItem() {
fun hasNewItems(
account: Account,

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -26,7 +27,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.platform.LocalContext
@@ -79,9 +79,7 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL
Column(
modifier = Modifier
.drawBehind {
drawRect(backgroundColor.value)
}
.background(backgroundColor.value)
.combinedClickable(
onClick = {
scope.launch {

View File

@@ -0,0 +1,763 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.BottomStart
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Alignment.Companion.TopEnd
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ParticipantListBuilder
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_ENDED
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_LIVE
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_PLANNED
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EndedFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.OfflineFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ChannelCardCompose(
baseNote: Note,
routeForLastRead: String? = null,
modifier: Modifier = Modifier,
parentBackgroundColor: MutableState<Color>? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val isBlank by baseNote.live().metadata.map {
it.note.event == null
}.distinctUntilChanged().observeAsState(baseNote.event == null)
Crossfade(targetState = isBlank) {
if (it) {
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
BlankNote(
remember {
modifier.combinedClickable(
onClick = { },
onLongClick = showPopup
)
},
false
)
}
} else {
CheckHiddenChannelCardCompose(
baseNote,
routeForLastRead,
modifier,
parentBackgroundColor,
accountViewModel,
nav
)
}
}
}
@Composable
fun CheckHiddenChannelCardCompose(
note: Note,
routeForLastRead: String? = null,
modifier: Modifier = Modifier,
parentBackgroundColor: MutableState<Color>? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val isHidden by accountViewModel.accountLiveData.map {
accountViewModel.isNoteHidden(note)
}.distinctUntilChanged().observeAsState(accountViewModel.isNoteHidden(note))
Crossfade(targetState = isHidden) {
if (!it) {
LoadedChannelCardCompose(
note,
routeForLastRead,
modifier,
parentBackgroundColor,
accountViewModel,
nav
)
}
}
}
@Composable
fun LoadedChannelCardCompose(
note: Note,
routeForLastRead: String? = null,
modifier: Modifier = Modifier,
parentBackgroundColor: MutableState<Color>? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var state by remember {
mutableStateOf(
NoteComposeReportState(
isAcceptable = true,
canPreview = true,
relevantReports = persistentSetOf()
)
)
}
val scope = rememberCoroutineScope()
WatchForReports(note, accountViewModel) { newIsAcceptable, newCanPreview, newRelevantReports ->
if (newIsAcceptable != state.isAcceptable || newCanPreview != state.canPreview) {
val newState = NoteComposeReportState(newIsAcceptable, newCanPreview, newRelevantReports)
scope.launch(Dispatchers.Main) {
state = newState
}
}
}
Crossfade(targetState = state) {
RenderChannelCardReportState(
it,
note,
routeForLastRead,
modifier,
parentBackgroundColor,
accountViewModel,
nav
)
}
}
@Composable
fun RenderChannelCardReportState(
state: NoteComposeReportState,
note: Note,
routeForLastRead: String? = null,
modifier: Modifier = Modifier,
parentBackgroundColor: MutableState<Color>? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var showReportedNote by remember { mutableStateOf(false) }
Crossfade(targetState = !state.isAcceptable && !showReportedNote) { showHiddenNote ->
if (showHiddenNote) {
HiddenNote(
state.relevantReports,
accountViewModel,
modifier,
false,
nav,
onClick = { showReportedNote = true }
)
} else {
NormalChannelCard(
note,
routeForLastRead,
modifier,
parentBackgroundColor,
accountViewModel,
nav
)
}
}
}
@Composable
fun NormalChannelCard(
baseNote: Note,
routeForLastRead: String? = null,
modifier: Modifier = Modifier,
parentBackgroundColor: MutableState<Color>? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
CheckNewAndRenderChannelCard(
baseNote,
routeForLastRead,
modifier,
parentBackgroundColor,
accountViewModel,
showPopup,
nav
)
}
}
@Composable
private fun CheckNewAndRenderChannelCard(
baseNote: Note,
routeForLastRead: String? = null,
modifier: Modifier = Modifier,
parentBackgroundColor: MutableState<Color>? = null,
accountViewModel: AccountViewModel,
showPopup: () -> Unit,
nav: (String) -> Unit
) {
val newItemColor = MaterialTheme.colors.newItemBackgroundColor
val defaultBackgroundColor = MaterialTheme.colors.background
val backgroundColor = remember { mutableStateOf<Color>(defaultBackgroundColor) }
LaunchedEffect(key1 = routeForLastRead, key2 = parentBackgroundColor?.value) {
launch(Dispatchers.IO) {
routeForLastRead?.let {
val lastTime = accountViewModel.account.loadLastRead(it)
val createdAt = baseNote.createdAt()
if (createdAt != null) {
accountViewModel.account.markAsRead(it, createdAt)
val isNew = createdAt > lastTime
val newBackgroundColor = if (isNew) {
if (parentBackgroundColor != null) {
newItemColor.compositeOver(parentBackgroundColor.value)
} else {
newItemColor.compositeOver(defaultBackgroundColor)
}
} else {
parentBackgroundColor?.value ?: defaultBackgroundColor
}
if (newBackgroundColor != backgroundColor.value) {
launch(Dispatchers.Main) {
backgroundColor.value = newBackgroundColor
}
}
}
} ?: run {
val newBackgroundColor = parentBackgroundColor?.value ?: defaultBackgroundColor
if (newBackgroundColor != backgroundColor.value) {
launch(Dispatchers.Main) {
backgroundColor.value = newBackgroundColor
}
}
}
}
}
ClickableNote(
baseNote = baseNote,
backgroundColor = backgroundColor,
modifier = modifier,
accountViewModel = accountViewModel,
showPopup = showPopup,
nav = nav
) {
InnerChannelCardWithReactions(
baseNote = baseNote,
accountViewModel = accountViewModel,
nav = nav
)
}
}
@Composable
fun InnerChannelCardWithReactions(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
Column(StdPadding) {
SensitivityWarning(
note = baseNote,
accountViewModel = accountViewModel
) {
RenderNoteRow(
baseNote,
accountViewModel,
nav
)
}
}
}
@Composable
private fun RenderNoteRow(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
when (remember { baseNote.event }) {
is LiveActivitiesEvent -> {
RenderLiveActivityThumb(baseNote, accountViewModel, nav)
}
is CommunityDefinitionEvent -> {
RenderCommunitiesThumb(baseNote, accountViewModel, nav)
}
is ChannelCreateEvent -> {
RenderChannelThumb(baseNote, accountViewModel, nav)
}
}
}
@Composable
fun RenderLiveActivityThumb(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val noteEvent = baseNote.event as? LiveActivitiesEvent ?: return
val eventUpdates by baseNote.live().metadata.observeAsState()
val media = remember(eventUpdates) { noteEvent.streaming() }
val cover by remember(eventUpdates) {
derivedStateOf {
noteEvent.image()?.ifBlank { null }
}
}
val subject = remember(eventUpdates) { noteEvent.title()?.ifBlank { null } }
val content = remember(eventUpdates) { noteEvent.summary() }
val participants = remember(eventUpdates) { noteEvent.participants() }
val status = remember(eventUpdates) { noteEvent.status() }
val starts = remember(eventUpdates) { noteEvent.starts() }
var isOnline by remember { mutableStateOf(false) }
LaunchedEffect(key1 = media) {
launch(Dispatchers.IO) {
val newIsOnline = OnlineChecker.isOnline(media)
if (isOnline != newIsOnline) {
isOnline = newIsOnline
}
}
}
var participantUsers by remember {
mutableStateOf<ImmutableList<User>>(
persistentListOf()
)
}
LaunchedEffect(key1 = eventUpdates) {
launch(Dispatchers.IO) {
val hosts = participants.mapNotNull { part ->
if (part.key != baseNote.author?.pubkeyHex) {
LocalCache.checkGetOrCreateUser(part.key)
} else {
null
}
}
val hostsAuthor = hosts + (
baseNote.author?.let {
listOf(it)
} ?: emptyList<User>()
)
val followingKeySet = accountViewModel.account.selectedUsersFollowList(accountViewModel.account.defaultDiscoveryFollowList)
val allParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).minus(hostsAuthor)
val newParticipantUsers = if (followingKeySet == null) {
val allFollows = accountViewModel.account.selectedUsersFollowList(KIND3_FOLLOWS)
val followingParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).minus(hostsAuthor)
(hosts + followingParticipants + (allParticipants - followingParticipants)).toImmutableList()
} else {
(hosts + allParticipants).toImmutableList()
}
if (!equalImmutableLists(newParticipantUsers, participantUsers)) {
participantUsers = newParticipantUsers
}
}
}
Column(
modifier = Modifier.fillMaxWidth()
) {
Box(
contentAlignment = TopEnd,
modifier = Modifier
.aspectRatio(ratio = 16f / 9f)
.fillMaxWidth()
) {
cover?.let {
AsyncImage(
model = it,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.clip(QuoteBorder)
)
} ?: run {
baseNote.author?.let {
DisplayAuthorBanner(it)
}
}
Box(Modifier.padding(10.dp)) {
Crossfade(targetState = status) {
when (it) {
STATUS_LIVE -> {
if (media.isNullOrBlank()) {
LiveFlag()
} else if (isOnline) {
LiveFlag()
} else {
OfflineFlag()
}
}
STATUS_ENDED -> {
EndedFlag()
}
STATUS_PLANNED -> {
ScheduledFlag(starts)
}
else -> {
EndedFlag()
}
}
}
}
Box(
Modifier
.padding(10.dp)
.align(BottomStart)
) {
if (participantUsers.isNotEmpty()) {
Gallery(participantUsers, accountViewModel)
}
}
}
ChannelHeader(
channelHex = remember { baseNote.idHex },
showVideo = false,
showBottomDiviser = false,
showFlag = false,
modifier = remember {
Modifier.padding(start = 0.dp, end = 0.dp, top = 5.dp, bottom = 5.dp)
},
accountViewModel = accountViewModel,
nav = nav
)
}
}
@Composable
fun RenderCommunitiesThumb(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val noteEvent = baseNote.event as? CommunityDefinitionEvent ?: return
val eventUpdates by baseNote.live().metadata.observeAsState()
val name = remember(eventUpdates) { noteEvent.dTag() }
val description = remember(eventUpdates) { noteEvent.description() }
val cover by remember(eventUpdates) {
derivedStateOf {
noteEvent.image()?.ifBlank { null }
}
}
val moderators = remember(eventUpdates) { noteEvent.moderators() }
var participantUsers by remember {
mutableStateOf<ImmutableList<User>>(
persistentListOf()
)
}
LaunchedEffect(key1 = eventUpdates) {
launch(Dispatchers.IO) {
val hosts = moderators.mapNotNull { part ->
if (part.key != baseNote.author?.pubkeyHex) {
LocalCache.checkGetOrCreateUser(part.key)
} else {
null
}
}
val followingKeySet = accountViewModel.account.selectedUsersFollowList(accountViewModel.account.defaultDiscoveryFollowList)
val allParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).minus(hosts)
val newParticipantUsers = if (followingKeySet == null) {
val allFollows = accountViewModel.account.selectedUsersFollowList(KIND3_FOLLOWS)
val followingParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).minus(hosts)
(hosts + followingParticipants + (allParticipants - followingParticipants)).toImmutableList()
} else {
(hosts + allParticipants).toImmutableList()
}
if (!equalImmutableLists(newParticipantUsers, participantUsers)) {
participantUsers = newParticipantUsers
}
}
}
Row(Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth(0.3f)
.aspectRatio(ratio = 1f)
) {
cover?.let {
Box(contentAlignment = BottomStart) {
AsyncImage(
model = it,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.clip(QuoteBorder)
)
}
} ?: run {
baseNote.author?.let {
DisplayAuthorBanner(it)
}
}
}
Spacer(modifier = DoubleHorzSpacer)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = name,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Spacer(modifier = StdHorzSpacer)
LikeReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
}
description?.let {
Spacer(modifier = StdVertSpacer)
Row() {
Text(
text = it,
color = MaterialTheme.colors.placeholderText,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
fontSize = 14.sp
)
}
}
if (participantUsers.isNotEmpty()) {
Spacer(modifier = StdVertSpacer)
Row(modifier = Modifier.fillMaxWidth()) {
Gallery(participantUsers, accountViewModel)
}
}
}
}
}
@Composable
fun RenderChannelThumb(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val noteEvent = baseNote.event as? ChannelCreateEvent ?: return
LoadChannel(baseChannelHex = baseNote.idHex) {
RenderChannelThumb(baseNote = baseNote, channel = it, accountViewModel, nav)
}
}
@Composable
fun RenderChannelThumb(baseNote: Note, channel: Channel, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val channelUpdates by channel.live.observeAsState()
val name = remember(channelUpdates) { channel.toBestDisplayName() }
val description = remember(channelUpdates) { channel.summary() }
val cover by remember(channelUpdates) {
derivedStateOf {
channel.profilePicture()?.ifBlank { null }
}
}
var participantUsers by remember(baseNote) {
mutableStateOf<ImmutableList<User>>(
persistentListOf()
)
}
LaunchedEffect(key1 = channelUpdates) {
launch(Dispatchers.IO) {
val followingKeySet = accountViewModel.account.selectedUsersFollowList(accountViewModel.account.defaultDiscoveryFollowList)
val allParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).toImmutableList()
val newParticipantUsers = if (followingKeySet == null) {
val allFollows = accountViewModel.account.selectedUsersFollowList(KIND3_FOLLOWS)
val followingParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).toList()
(followingParticipants + (allParticipants - followingParticipants)).toImmutableList()
} else {
allParticipants.toImmutableList()
}
if (!equalImmutableLists(newParticipantUsers, participantUsers)) {
participantUsers = newParticipantUsers
}
}
}
Row(Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth(0.3f)
.aspectRatio(ratio = 1f)
) {
cover?.let {
Box(contentAlignment = BottomStart) {
AsyncImage(
model = it,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.clip(QuoteBorder)
)
}
} ?: run {
baseNote.author?.let {
DisplayAuthorBanner(it)
}
}
}
Spacer(modifier = DoubleHorzSpacer)
Column(
modifier = Modifier.fillMaxWidth().fillMaxHeight()
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = name,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Spacer(modifier = StdHorzSpacer)
LikeReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
}
description?.let {
Spacer(modifier = StdVertSpacer)
Row() {
Text(
text = it,
color = MaterialTheme.colors.placeholderText,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
fontSize = 14.sp
)
}
}
if (participantUsers.isNotEmpty()) {
Spacer(modifier = StdVertSpacer)
Row() {
Gallery(participantUsers, accountViewModel)
}
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun Gallery(users: List<User>, accountViewModel: AccountViewModel) {
FlowRow(verticalAlignment = CenterVertically) {
users.take(6).forEach {
ClickableUserPicture(it, Size35dp, accountViewModel)
}
if (users.size > 6) {
Text(
text = remember(users) { " + " + (showCount(users.size - 6)).toString() },
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface
)
}
}
}
@Composable
fun DisplayAuthorBanner(author: User) {
val picture by author.live().metadata.map {
it.user.info?.banner?.ifBlank { null } ?: it.user.info?.picture?.ifBlank { null }
}.observeAsState()
AsyncImage(
model = picture,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.clip(QuoteBorder)
)
}

View File

@@ -150,19 +150,19 @@ private fun ChannelRoomCompose(
nav: (String) -> Unit
) {
val authorState by note.author!!.live().metadata.observeAsState()
val authorName = remember(authorState) {
val authorName = remember(note, authorState) {
authorState?.user?.toBestDisplayName()
}
val chanHex = remember { channel.idHex }
val channelState by channel.live.observeAsState()
val channelPicture by remember(channelState) {
val channelPicture by remember(note, channelState) {
derivedStateOf {
channel.profilePicture()
}
}
val channelName by remember(channelState) {
val channelName by remember(note, channelState) {
derivedStateOf {
channel.toBestDisplayName()
}
@@ -182,7 +182,7 @@ private fun ChannelRoomCompose(
noteEvent?.content()
}
var hasNewMessages = remember { mutableStateOf<Boolean>(false) }
val hasNewMessages = remember { mutableStateOf<Boolean>(false) }
WatchNotificationChanges(note, route, accountViewModel) { newHasNewMessages ->
if (hasNewMessages.value != newHasNewMessages) {

View File

@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.note
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -40,7 +41,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.compositeOver
@@ -923,7 +923,7 @@ private fun RenderRelayIcon(iconUrl: String) {
Modifier
.size(Size13dp)
.clip(shape = CircleShape)
.drawBehind { drawRect(backgroundColor) }
.background(backgroundColor)
}
RobohashFallbackAsyncImage(

View File

@@ -22,7 +22,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.res.painterResource
@@ -70,11 +69,7 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, routeForLastRead: String,
val columnModifier = remember(backgroundColor.value) {
Modifier
.drawBehind {
drawRect(
backgroundColor.value
)
}
.background(backgroundColor.value)
.padding(
start = 12.dp,
end = 12.dp,

View File

@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.note
import android.util.Log
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
@@ -35,7 +36,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.layout.ContentScale
@@ -114,9 +114,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
val columnModifier = remember(backgroundColor.value) {
Modifier
.drawBehind {
drawRect(backgroundColor.value)
}
.background(backgroundColor.value)
.padding(
start = 12.dp,
end = 12.dp,
@@ -440,7 +438,7 @@ fun CrossfadeToDisplayAmount(authorComment: MutableState<ZapAmountCommentNotific
modifier = remember {
Modifier
.width(Size35dp)
.drawBehind { drawRect(backgroundColor) }
.background(backgroundColor)
},
contentAlignment = Alignment.BottomCenter
) {

View File

@@ -117,10 +117,7 @@ fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifie
Crossfade(targetState = nip05, modifier = columnModifier) {
if (it != null) {
val isValid = it.split("@").size == 2
if (isValid) {
DisplayNIP05Line(it, baseUser, columnModifier)
}
DisplayNIP05Line(it, baseUser, columnModifier)
}
}
}
@@ -144,7 +141,12 @@ private fun DisplayNIP05(
) {
val uri = LocalUriHandler.current
val (user, domain) = remember(nip05) {
nip05.split("@")
val parts = nip05.split("@")
if (parts.size == 1) {
listOf("_", parts[0])
} else {
listOf(parts[0], parts[1])
}
}
if (user != "_") {
@@ -180,7 +182,7 @@ private fun NIP05VerifiedSymbol(nip05Verified: MutableState<Boolean?>) {
)
} else if (nip05Verified.value == true) {
Icon(
painter = painterResource(R.drawable.ic_verified),
painter = painterResource(R.drawable.ic_verified_transparent),
"NIP-05 Verified",
tint = Nip05.copy(0.52f),
modifier = Modifier
@@ -204,7 +206,7 @@ fun DisplayNip05ProfileStatus(user: User) {
val uri = LocalUriHandler.current
user.nip05()?.let { nip05 ->
if (nip05.split("@").size == 2) {
if (nip05.split("@").size <= 2) {
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
Row(verticalAlignment = Alignment.CenterVertically) {
if (nip05Verified == null) {
@@ -216,7 +218,7 @@ fun DisplayNip05ProfileStatus(user: User) {
)
} else if (nip05Verified == true) {
Icon(
painter = painterResource(R.drawable.ic_verified),
painter = painterResource(R.drawable.ic_verified_transparent),
"NIP-05 Verified",
tint = Nip05,
modifier = Modifier.size(16.dp)
@@ -232,9 +234,18 @@ fun DisplayNip05ProfileStatus(user: User) {
var domainPadStart = 5.dp
if (nip05.split("@")[0] != "_") {
val (user, domain) = remember(nip05) {
val parts = nip05.split("@")
if (parts.size == 1) {
listOf("_", parts[0])
} else {
listOf(parts[0], parts[1])
}
}
if (user != "_") {
Text(
text = AnnotatedString(nip05.split("@")[0] + "@"),
text = remember { AnnotatedString(user + "@") },
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
@@ -243,7 +254,7 @@ fun DisplayNip05ProfileStatus(user: User) {
}
ClickableText(
text = AnnotatedString(nip05.split("@")[1]),
text = AnnotatedString(domain),
onClick = { nip05.let { runCatching { uri.openUri("https://${it.split("@")[1]}") } } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = domainPadStart),

View File

@@ -61,7 +61,6 @@ import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Alignment.Companion.TopEnd
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
@@ -89,12 +88,14 @@ import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import coil.request.SuccessResult
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Channel
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.OnlineChecker
import com.vitorpamplona.amethyst.service.model.ATag
import com.vitorpamplona.amethyst.service.model.AppDefinitionEvent
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
@@ -103,12 +104,16 @@ import com.vitorpamplona.amethyst.service.model.BaseTextNoteEvent
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_LIVE
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_PLANNED
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.Participant
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
@@ -146,6 +151,8 @@ import com.vitorpamplona.amethyst.ui.components.imageExtensions
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.JoinCommunityButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LeaveCommunityButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag
@@ -153,9 +160,9 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.Following
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonBoxModifer
@@ -170,6 +177,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.StdStartPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.UserNameMaxRowHeight
@@ -432,6 +440,12 @@ fun NormalNote(
accountViewModel = accountViewModel,
nav = nav
)
is CommunityDefinitionEvent -> CommunityHeader(
baseNote = baseNote,
showBottomDiviser = true,
accountViewModel = accountViewModel,
nav = nav
)
is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote)
is FileHeaderEvent -> FileHeaderDisplay(baseNote)
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote)
@@ -456,6 +470,283 @@ fun NormalNote(
}
}
@Composable
fun CommunityHeader(
baseNote: Note,
showBottomDiviser: Boolean,
modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var expanded = remember { mutableStateOf(false) }
Column(
modifier = modifier.clickable {
expanded.value = !expanded.value
}
) {
ShortCommunityHeader(baseNote, expanded, accountViewModel, nav)
if (expanded.value) {
LongCommunityHeader(baseNote, accountViewModel, nav)
}
}
if (showBottomDiviser) {
Divider(
thickness = 0.25.dp
)
}
}
@Composable
fun LongCommunityHeader(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val noteState by baseNote.live().metadata.observeAsState()
val noteEvent = remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return
Row(
Modifier
.fillMaxWidth()
.padding(top = 10.dp)
) {
val summary = remember(noteState) {
noteEvent.description()?.ifBlank { null }
}
Column(
Modifier
.weight(1f)
.padding(start = 10.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
val defaultBackground = MaterialTheme.colors.background
val background = remember {
mutableStateOf(defaultBackground)
}
TranslatableRichTextViewer(
content = summary ?: stringResource(id = R.string.community_no_descriptor),
canPreview = false,
tags = remember { ImmutableListOfLists(emptyList()) },
backgroundColor = background,
accountViewModel = accountViewModel,
nav = nav
)
}
val hashtags = remember(noteEvent) { noteEvent.hashtags().toImmutableList() }
DisplayUncitedHashtags(hashtags, summary ?: "", nav)
}
Column() {
Row() {
Spacer(DoubleHorzSpacer)
LongCommunityActionOptions(baseNote, accountViewModel, nav)
}
}
}
val rules = remember(noteState) {
noteEvent.rules()?.ifBlank { null }
}
rules?.let {
Spacer(DoubleVertSpacer)
Row(
Modifier
.fillMaxWidth()
.padding(start = 10.dp)
) {
val defaultBackground = MaterialTheme.colors.background
val background = remember {
mutableStateOf(defaultBackground)
}
val tags = remember(noteEvent) { noteEvent?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
TranslatableRichTextViewer(
content = it,
canPreview = false,
tags = tags,
backgroundColor = background,
accountViewModel = accountViewModel,
nav = nav
)
}
}
Spacer(DoubleVertSpacer)
Row(
Modifier
.fillMaxWidth()
.padding(start = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(id = R.string.owner),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(75.dp)
)
Spacer(DoubleHorzSpacer)
NoteAuthorPicture(baseNote, nav, accountViewModel, Size25dp)
Spacer(DoubleHorzSpacer)
NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) })
TimeAgo(baseNote)
MoreOptionsButton(baseNote, accountViewModel)
}
var participantUsers by remember(baseNote) {
mutableStateOf<ImmutableList<Pair<Participant, User>>>(
persistentListOf()
)
}
LaunchedEffect(key1 = noteState) {
launch(Dispatchers.IO) {
val noteEvent = (noteState?.note?.event as? CommunityDefinitionEvent)
val newParticipantUsers = noteEvent?.moderators()?.mapNotNull { part ->
LocalCache.checkGetOrCreateUser(part.key)?.let { Pair(part, it) }
}?.toImmutableList()
if (newParticipantUsers != null && !equalImmutableLists(newParticipantUsers, participantUsers)) {
participantUsers = newParticipantUsers
}
}
}
participantUsers.forEach {
Row(
Modifier
.fillMaxWidth()
.padding(start = 10.dp, top = 10.dp)
.clickable {
nav("User/${it.second.pubkeyHex}")
},
verticalAlignment = Alignment.CenterVertically
) {
it.first.role?.let { it1 ->
Text(
text = it1.capitalize(Locale.ROOT),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(75.dp)
)
}
Spacer(DoubleHorzSpacer)
ClickableUserPicture(it.second, Size25dp, accountViewModel)
Spacer(DoubleHorzSpacer)
UsernameDisplay(it.second, remember { Modifier.weight(1f) })
}
}
}
@Composable
fun ShortCommunityHeader(baseNote: Note, expanded: MutableState<Boolean>, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val noteState by baseNote.live().metadata.observeAsState()
val noteEvent = remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return
Row(verticalAlignment = Alignment.CenterVertically) {
noteEvent.image()?.let {
RobohashAsyncImageProxy(
robot = baseNote.idHex,
model = it,
contentDescription = stringResource(R.string.profile_image),
contentScale = ContentScale.Crop,
modifier = Modifier
.padding(start = 10.dp)
.width(Size35dp)
.height(Size35dp)
.clip(shape = CircleShape)
)
}
Column(
modifier = Modifier
.padding(start = 10.dp)
.height(Size35dp)
.weight(1f),
verticalArrangement = Arrangement.Center
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = remember(noteState) { noteEvent.dTag() },
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
val summary = remember(noteState) {
noteEvent.description()?.ifBlank { null }
}
if (summary != null && !expanded.value) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = summary,
color = MaterialTheme.colors.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontSize = 12.sp
)
}
}
}
Row(
modifier = Modifier
.height(Size35dp)
.padding(start = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
ShortCommunityActionOptions(baseNote, accountViewModel, nav)
}
}
}
@Composable
private fun ShortCommunityActionOptions(
note: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val isFollowing by remember(accountState) {
derivedStateOf {
accountState?.account?.followingCommunities?.contains(note.idHex) ?: false
}
}
Spacer(modifier = StdHorzSpacer)
LikeReaction(baseNote = note, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = note, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
if (!isFollowing) {
Spacer(modifier = StdHorzSpacer)
JoinCommunityButton(accountViewModel, note, nav)
}
}
@Composable
private fun LongCommunityActionOptions(
note: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val isFollowing by remember(accountState) {
derivedStateOf {
accountState?.account?.followingCommunities?.contains(note.idHex) ?: false
}
}
if (isFollowing) {
LeaveCommunityButton(accountViewModel, note, nav)
}
}
@Composable
private fun CheckNewAndRenderNote(
baseNote: Note,
@@ -540,7 +831,7 @@ private fun CheckNewAndRenderNote(
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun ClickableNote(
fun ClickableNote(
baseNote: Note,
modifier: Modifier,
backgroundColor: MutableState<Color>,
@@ -551,7 +842,7 @@ private fun ClickableNote(
) {
val scope = rememberCoroutineScope()
val updatedModifier = remember(backgroundColor) {
val updatedModifier = remember(baseNote, backgroundColor.value) {
modifier
.combinedClickable(
onClick = {
@@ -563,9 +854,7 @@ private fun ClickableNote(
},
onLongClick = showPopup
)
.drawBehind {
drawRect(backgroundColor.value)
}
.background(backgroundColor.value)
}
Column(modifier = updatedModifier) {
@@ -591,7 +880,8 @@ fun InnerNoteWithReactions(
Row(
modifier = remember {
Modifier.fillMaxWidth()
Modifier
.fillMaxWidth()
.padding(
start = if (!isBoostedNote) 12.dp else 0.dp,
end = if (!isBoostedNote) 12.dp else 0.dp,
@@ -715,7 +1005,7 @@ private fun RenderNoteRow(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
when (remember { baseNote.event }) {
when (baseNote.event) {
is AppDefinitionEvent -> {
RenderAppDefinition(baseNote, accountViewModel, nav)
}
@@ -797,6 +1087,17 @@ private fun RenderNoteRow(
)
}
is CommunityPostApprovalEvent -> {
RenderPostApproval(
baseNote,
makeItShort,
canPreview,
backgroundColor,
accountViewModel,
nav
)
}
else -> {
RenderTextEvent(
baseNote,
@@ -823,6 +1124,8 @@ fun routeFor(note: Note, loggedIn: User): String? {
}
} else if (noteEvent is PrivateDmEvent) {
return "Room/${noteEvent.talkingWith(loggedIn.pubkeyHex)}"
} else if (noteEvent is CommunityDefinitionEvent) {
return "Community/${note.idHex}"
} else {
return "Note/${note.idHex}"
}
@@ -851,7 +1154,7 @@ fun RenderTextEvent(
val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null }
val body = accountViewModel.decrypt(note)
if (subject != null) {
if (!subject.isNullOrBlank() && body?.split("\n")?.get(0)?.contains(subject) == false) {
"## $subject\n$body"
} else {
body
@@ -1293,16 +1596,7 @@ fun DisplayRelaySet(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.drawBehind {
drawRect(
Brush.verticalGradient(
colors = listOf(
backgroundColor.value.copy(alpha = 0f),
backgroundColor.value
)
)
)
}
.background(getGradient(backgroundColor))
) {
ShowMoreButton {
expanded = !expanded
@@ -1409,16 +1703,7 @@ fun DisplayPeopleList(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.drawBehind {
drawRect(
Brush.verticalGradient(
colors = listOf(
backgroundColor.value.copy(alpha = 0f),
backgroundColor.value
)
)
)
}
.background(getGradient(backgroundColor))
) {
ShowMoreButton {
expanded = !expanded
@@ -1543,6 +1828,76 @@ fun RenderRepost(
}
}
@Composable
fun RenderPostApproval(
note: Note,
makeItShort: Boolean,
canPreview: Boolean,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
if (note.replyTo.isNullOrEmpty()) return
val noteEvent = note.event as? CommunityPostApprovalEvent ?: return
Column(Modifier.fillMaxWidth()) {
noteEvent.communities().forEach {
LoadAddressableNote(it) {
it?.let {
NoteCompose(
it,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
}
}
}
Text(
text = stringResource(id = R.string.community_approved_posts),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.fillMaxWidth()
.padding(5.dp),
textAlign = TextAlign.Center
)
note.replyTo?.forEach {
NoteCompose(
it,
modifier = MaterialTheme.colors.replyModifier,
unPackReply = false,
makeItShort = true,
isQuotedNote = true,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
}
}
}
@Composable
fun LoadAddressableNote(aTag: ATag, content: @Composable (AddressableNote?) -> Unit) {
var note by remember(aTag) {
mutableStateOf<AddressableNote?>(LocalCache.getAddressableNoteIfExists(aTag.toTag()))
}
if (note == null) {
LaunchedEffect(key1 = aTag) {
launch(Dispatchers.IO) {
note = LocalCache.getOrCreateAddressableNote(aTag)
}
}
}
content(note)
}
@Composable
private fun RenderPinListEvent(
baseNote: Note,
@@ -1618,16 +1973,7 @@ fun PinListHeader(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.drawBehind {
drawRect(
Brush.verticalGradient(
colors = listOf(
backgroundColor.value.copy(alpha = 0f),
backgroundColor.value
)
)
)
}
.background(getGradient(backgroundColor))
) {
ShowMoreButton {
expanded = !expanded
@@ -1637,6 +1983,15 @@ fun PinListHeader(
}
}
fun getGradient(backgroundColor: MutableState<Color>): Brush {
return Brush.verticalGradient(
colors = listOf(
backgroundColor.value.copy(alpha = 0f),
backgroundColor.value
)
)
}
@Composable
private fun RenderAudioTrack(
note: Note,
@@ -1736,7 +2091,7 @@ private fun ReplyRow(
}
if (showReply) {
val replyingDirectlyTo = remember { note.replyTo?.lastOrNull() }
val replyingDirectlyTo = remember { note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.kind } }
if (replyingDirectlyTo != null && unPackReply) {
ReplyNoteComposition(replyingDirectlyTo, backgroundColor, accountViewModel, nav)
Spacer(modifier = StdVertSpacer)
@@ -1807,7 +2162,7 @@ private fun ReplyNoteComposition(
}
@Composable
private fun SecondUserInfoRow(
fun SecondUserInfoRow(
note: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -1831,19 +2186,25 @@ private fun SecondUserInfoRow(
}
@Composable
private fun FirstUserInfoRow(
fun FirstUserInfoRow(
baseNote: Note,
showAuthorPicture: Boolean,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
Row(verticalAlignment = CenterVertically, modifier = remember { UserNameRowHeight }) {
val isRepost by remember {
val isRepost by remember(baseNote) {
derivedStateOf {
baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent
}
}
val isCommunityPost by remember(baseNote) {
derivedStateOf {
baseNote.event?.isTaggedAddressableKind(CommunityDefinitionEvent.kind) == true
}
}
if (showAuthorPicture) {
NoteAuthorPicture(baseNote, nav, accountViewModel, Size25dp)
Spacer(HalfPadding)
@@ -1854,6 +2215,8 @@ private fun FirstUserInfoRow(
if (isRepost) {
BoostedMark()
} else if (isCommunityPost) {
DisplayFollowingCommunityInPost(baseNote, accountViewModel, nav)
} else {
DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav)
}
@@ -1876,7 +2239,7 @@ private fun BoostedMark() {
}
@Composable
private fun MoreOptionsButton(
fun MoreOptionsButton(
baseNote: Note,
accountViewModel: AccountViewModel
) {
@@ -1904,14 +2267,14 @@ private fun MoreOptionsButton(
@Composable
fun TimeAgo(note: Note) {
val time = remember { note.createdAt() } ?: return
val time = remember(note) { note.createdAt() } ?: return
TimeAgo(time)
}
@Composable
fun TimeAgo(time: Long) {
val context = LocalContext.current
val timeStr by remember { mutableStateOf(timeAgo(time, context = context)) }
val timeStr by remember(time) { mutableStateOf(timeAgo(time, context = context)) }
Text(
text = timeStr,
@@ -2017,7 +2380,7 @@ private fun ChannelNotePicture(baseChannel: Channel) {
.width(30.dp)
.height(30.dp)
.clip(shape = CircleShape)
.drawBehind { drawRect(backgroundColor) }
.background(backgroundColor)
.border(
2.dp,
backgroundColor,
@@ -2162,6 +2525,19 @@ private fun LoadAndDisplayUser(
}
}
@Composable
fun DisplayFollowingCommunityInPost(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
Column(HalfStartPadding) {
Row(verticalAlignment = CenterVertically) {
DisplayCommunity(baseNote, nav)
}
}
}
@Composable
fun DisplayFollowingHashtagsInPost(
baseNote: Note,
@@ -2212,6 +2588,37 @@ private fun DisplayTagList(firstTag: String, nav: (String) -> Unit) {
)
}
@Composable
private fun DisplayCommunity(note: Note, nav: (String) -> Unit) {
val communityTag = remember(note) {
note.event?.getTagOfAddressableKind(CommunityDefinitionEvent.kind)
} ?: return
val displayTag = remember(note) { AnnotatedString(getCommunityShortName(communityTag)) }
val route = remember(note) { "Community/${communityTag.toTag()}" }
ClickableText(
text = displayTag,
onClick = { nav(route) },
style = LocalTextStyle.current.copy(
color = MaterialTheme.colors.primary.copy(
alpha = 0.52f
)
),
maxLines = 1
)
}
private fun getCommunityShortName(communityTag: ATag): String {
val name = if (communityTag.dTag.length > 10) {
communityTag.dTag.take(10) + "..."
} else {
communityTag.dTag.take(10)
}
return "/n/$name"
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun DisplayUncitedHashtags(
@@ -2683,6 +3090,7 @@ fun RenderLiveActivityEventInner(baseNote: Note, accountViewModel: AccountViewMo
val content = remember(eventUpdates) { noteEvent.summary() }
val participants = remember(eventUpdates) { noteEvent.participants() }
val status = remember(eventUpdates) { noteEvent.status() }
val starts = remember(eventUpdates) { noteEvent.starts() }
var isOnline by remember { mutableStateOf(false) }
@@ -2712,13 +3120,13 @@ fun RenderLiveActivityEventInner(baseNote: Note, accountViewModel: AccountViewMo
Crossfade(targetState = status) {
when (it) {
"live" -> {
STATUS_LIVE -> {
if (isOnline) {
LiveFlag()
}
}
"planned" -> {
ScheduledFlag()
STATUS_PLANNED -> {
ScheduledFlag(starts)
}
}
}
@@ -2766,7 +3174,7 @@ fun RenderLiveActivityEventInner(baseNote: Note, accountViewModel: AccountViewMo
}
media?.let { media ->
if (status == "live") {
if (status == STATUS_LIVE) {
if (isOnline) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -2863,7 +3271,7 @@ private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, accountView
}
@Composable
private fun CreateImageHeader(
fun CreateImageHeader(
note: Note,
accountViewModel: AccountViewModel
) {
@@ -3049,7 +3457,7 @@ fun DisplayBlankAuthor(size: Dp, modifier: Modifier = Modifier) {
modifier
.size(size)
.clip(shape = CircleShape)
.drawBehind { drawRect(backgroundColor) }
.background(backgroundColor)
}
RobohashAsyncImage(
@@ -3191,7 +3599,7 @@ fun PictureAndFollowingMark(
modifier
.size(size)
.clip(shape = CircleShape)
.drawBehind { drawRect(backgroundColor) }
.background(backgroundColor)
}
RobohashAsyncImageProxy(
@@ -3240,30 +3648,12 @@ fun FollowingIcon(iconSize: Dp) {
Modifier.size(iconSize)
}
val backgroundColor = MaterialTheme.colors.background
val myIconBackgroundModifier = remember {
Modifier
.clip(CircleShape)
.size(iconSize.times(0.6f))
.drawBehind { drawRect(backgroundColor) }
}
FollowingIcon(modifier, myIconBackgroundModifier)
}
@Composable
fun FollowingIcon(modifier: Modifier, myIconBackgroundModifier: Modifier) {
Box(modifier = modifier, contentAlignment = Alignment.Center) {
Box(myIconBackgroundModifier)
Icon(
painter = painterResource(R.drawable.ic_verified),
stringResource(id = R.string.following),
modifier = modifier,
tint = Following
)
}
Icon(
painter = painterResource(R.drawable.verified_follow_shield),
contentDescription = stringResource(id = R.string.following),
modifier = modifier,
tint = Color.Unspecified
)
}
@Immutable

View File

@@ -7,5 +7,6 @@ fun ByteArray.toShortenHex(): String {
}
fun String.toShortenHex(): String {
if (length <= 16) return this
return replaceRange(8, length - 8, ":")
}

View File

@@ -1,11 +1,17 @@
package com.vitorpamplona.amethyst.ui.note
import android.content.Context
import android.util.Log
import android.widget.Toast
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.Crossfade
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.with
@@ -112,6 +118,8 @@ import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.math.RoundingMode
import kotlin.math.roundToInt
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@Composable
fun ReactionsRow(
@@ -137,6 +145,7 @@ fun ReactionsRow(
Spacer(modifier = HalfDoubleVertSpacer)
}
@OptIn(ExperimentalTime::class)
@Composable
private fun InnerReactionRow(
baseNote: Note,
@@ -163,49 +172,69 @@ private fun InnerReactionRow(
verticalArrangement = Arrangement.Center,
modifier = remember { Modifier.weight(1f) }
) {
Row(verticalAlignment = CenterVertically) {
ReplyReactionWithDialog(baseNote, MaterialTheme.colors.placeholderText, accountViewModel, nav)
val (value, elapsed) = measureTimedValue {
Row(verticalAlignment = CenterVertically) {
ReplyReactionWithDialog(baseNote, MaterialTheme.colors.placeholderText, accountViewModel, nav)
}
}
Log.d("Rendering Metrics", "Reaction Reply: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
}
Column(
verticalArrangement = Arrangement.Center,
modifier = remember { Modifier.weight(1f) }
) {
Row(verticalAlignment = CenterVertically) {
BoostWithDialog(baseNote, MaterialTheme.colors.placeholderText, accountViewModel, nav)
val (value, elapsed) = measureTimedValue {
Row(verticalAlignment = CenterVertically) {
BoostWithDialog(
baseNote,
MaterialTheme.colors.placeholderText,
accountViewModel,
nav
)
}
}
Log.d("Rendering Metrics", "Reaction Boost: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
}
Column(
verticalArrangement = Arrangement.Center,
modifier = remember { Modifier.weight(1f) }
) {
Row(verticalAlignment = CenterVertically) {
LikeReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel)
val (value, elapsed) = measureTimedValue {
Row(verticalAlignment = CenterVertically) {
LikeReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel)
}
}
Log.d("Rendering Metrics", "Reaction Likes: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
}
Column(
verticalArrangement = Arrangement.Center,
modifier = remember { Modifier.weight(1f) }
) {
Row(verticalAlignment = CenterVertically) {
ZapReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel)
val (value, elapsed) = measureTimedValue {
Row(verticalAlignment = CenterVertically) {
ZapReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel)
}
}
Log.d("Rendering Metrics", "Reaction Zaps: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
}
Column(
verticalArrangement = Arrangement.Center,
modifier = remember { Modifier.weight(1f) }
) {
Row(verticalAlignment = CenterVertically) {
ViewCountReaction(
note = baseNote,
grayTint = MaterialTheme.colors.placeholderText,
viewCountColorFilter = MaterialTheme.colors.placeholderTextColorFilter
)
val (value, elapsed) = measureTimedValue {
Row(verticalAlignment = CenterVertically) {
ViewCountReaction(
note = baseNote,
grayTint = MaterialTheme.colors.placeholderText,
viewCountColorFilter = MaterialTheme.colors.placeholderTextColorFilter
)
}
}
Log.d("Rendering Metrics", "Reaction Views: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
}
}
}
@@ -279,7 +308,9 @@ fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, acc
}
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth().height(if (details) 24.dp else 4.dp),
modifier = Modifier
.fillMaxWidth()
.height(if (details) 24.dp else 4.dp),
color = color,
progress = zapraiserProgress
)
@@ -492,16 +523,10 @@ fun ReplyReaction(
val context = LocalContext.current
val scope = rememberCoroutineScope()
val iconButtonModifier = remember {
Modifier.size(iconSize)
}
val iconModifier = remember {
Modifier.size(iconSize)
}
IconButton(
modifier = iconButtonModifier,
modifier = remember {
Modifier.size(iconSize)
},
onClick = {
if (accountViewModel.isWriteable()) {
onPress()
@@ -518,8 +543,8 @@ fun ReplyReaction(
) {
Icon(
painter = painterResource(R.drawable.ic_comment),
null,
modifier = iconModifier,
contentDescription = null,
modifier = remember { Modifier.size(iconSize) },
tint = grayTint
)
}
@@ -535,26 +560,37 @@ fun ReplyCounter(baseNote: Note, textColor: Color) {
it.note.replies.size
}.observeAsState(baseNote.replies.size)
SlidingAnimation(repliesState, textColor)
SlidingAnimationCount(repliesState, textColor)
}
@Composable
private fun SlidingAnimationCount(baseCount: MutableState<Int>, textColor: Color) {
SlidingAnimationCount(baseCount.value, textColor)
}
@OptIn(ExperimentalAnimationApi::class)
private fun SlidingAnimation(baseCount: Int, textColor: Color) {
@Composable
private fun SlidingAnimationCount(baseCount: Int, textColor: Color) {
AnimatedContent<Int>(
targetState = baseCount,
transitionSpec = {
if (targetState > initialState) {
slideInVertically { -it } with slideOutVertically { it }
} else {
slideInVertically { it } with slideOutVertically { -it }
}
}
transitionSpec = AnimatedContentScope<Int>::transitionSpec
) { count ->
TextCount(count, textColor)
}
}
@OptIn(ExperimentalAnimationApi::class)
private fun <S> AnimatedContentScope<S>.transitionSpec(): ContentTransform {
return slideAnimation
}
@ExperimentalAnimationApi
val slideAnimation: ContentTransform = slideInVertically(animationSpec = tween(durationMillis = 100)) { height -> height } + fadeIn(
animationSpec = tween(durationMillis = 100)
) with slideOutVertically(animationSpec = tween(durationMillis = 100)) { height -> -height } + fadeOut(
animationSpec = tween(durationMillis = 100)
)
@Composable
private fun TextCount(count: Int, textColor: Color) {
Text(
@@ -568,16 +604,10 @@ private fun TextCount(count: Int, textColor: Color) {
@Composable
@OptIn(ExperimentalAnimationApi::class)
private fun SlidingAnimation(amount: String, textColor: Color) {
AnimatedContent<String>(
targetState = amount,
transitionSpec = {
if (targetState > initialState) {
slideInVertically { -it } with slideOutVertically { it }
} else {
slideInVertically { it } with slideOutVertically { -it }
}
}
private fun SlidingAnimationAmount(amount: MutableState<String>, textColor: Color) {
AnimatedContent(
targetState = amount.value,
transitionSpec = AnimatedContentScope<String>::transitionSpec
) { count ->
Text(
text = count,
@@ -671,7 +701,7 @@ fun BoostText(baseNote: Note, grayTint: Color) {
it.note.boosts.size
}.distinctUntilChanged().observeAsState(baseNote.boosts.size)
SlidingAnimation(boostState, grayTint)
SlidingAnimationCount(boostState, grayTint)
}
@OptIn(ExperimentalFoundationApi::class)
@@ -748,32 +778,41 @@ fun LikeIcon(
grayTint: Color,
accountViewModel: AccountViewModel
) {
val reactionsState by baseNote.live().reactions.observeAsState()
var reactionType by remember(baseNote) {
val reactionType = remember(baseNote) {
mutableStateOf<String?>(null)
}
LaunchedEffect(key1 = reactionsState) {
launch(Dispatchers.Default) {
val newReactionType = reactionsState?.note?.isReactedBy(accountViewModel.userProfile())?.firstFullChar()
if (reactionType != newReactionType) {
launch(Dispatchers.Main) {
reactionType = newReactionType
}
val scope = rememberCoroutineScope()
WatchReactionTypeForNote(baseNote, accountViewModel) { newReactionType ->
if (reactionType.value != newReactionType) {
scope.launch(Dispatchers.Main) {
reactionType.value = newReactionType
}
}
}
Crossfade(targetState = reactionType) {
if (it != null) {
RenderReactionType(it, iconSize, iconFontSize)
val value = it.value
if (value != null) {
RenderReactionType(value, iconSize, iconFontSize)
} else {
RenderLikeIcon(iconSize, grayTint)
}
}
}
@Composable
private fun WatchReactionTypeForNote(baseNote: Note, accountViewModel: AccountViewModel, onNewReactionType: (String?) -> Unit) {
val reactionsState by baseNote.live().reactions.observeAsState()
LaunchedEffect(key1 = reactionsState) {
launch(Dispatchers.Default) {
onNewReactionType(reactionsState?.note?.getReactionBy(accountViewModel.userProfile())?.firstFullChar())
}
}
}
@Composable
private fun RenderLikeIcon(
iconSize: Dp = Size20dp,
@@ -818,24 +857,32 @@ private fun RenderReactionType(
@Composable
fun LikeText(baseNote: Note, grayTint: Color) {
val reactionsState by baseNote.live().reactions.observeAsState()
var reactionsCount by remember(baseNote) {
val reactionsCount = remember(baseNote) {
mutableStateOf(baseNote.reactions.size)
}
LaunchedEffect(key1 = reactionsState) {
launch(Dispatchers.Default) {
val newReactionsCount = reactionsState?.note?.countReactions() ?: 0
if (reactionsCount != newReactionsCount) {
launch(Dispatchers.Main) {
reactionsCount = newReactionsCount
}
val scope = rememberCoroutineScope()
WatchReactionCountForNote(baseNote) { newReactionsCount ->
if (reactionsCount.value != newReactionsCount) {
scope.launch(Dispatchers.Main) {
reactionsCount.value = newReactionsCount
}
}
}
SlidingAnimation(reactionsCount, grayTint)
SlidingAnimationCount(reactionsCount, grayTint)
}
@Composable
private fun WatchReactionCountForNote(baseNote: Note, onNewReactionCount: (Int) -> Unit) {
val reactionsState by baseNote.live().reactions.observeAsState()
LaunchedEffect(key1 = reactionsState) {
launch(Dispatchers.Default) {
onNewReactionCount(reactionsState?.note?.countReactions() ?: 0)
}
}
}
private fun likeClick(
@@ -1049,27 +1096,22 @@ private fun ZapIcon(
grayTint: Color,
accountViewModel: AccountViewModel
) {
var wasZappedByLoggedInUser by remember { mutableStateOf(false) }
val zapsState by baseNote.live().zaps.observeAsState()
val wasZappedByLoggedInUser = remember { mutableStateOf(false) }
LaunchedEffect(key1 = zapsState) {
launch(Dispatchers.Default) {
zapsState?.note?.let {
if (!wasZappedByLoggedInUser) {
val newWasZapped = accountViewModel.calculateIfNoteWasZappedByAccount(it)
val scope = rememberCoroutineScope()
if (wasZappedByLoggedInUser != newWasZapped) {
launch(Dispatchers.Main) {
wasZappedByLoggedInUser = newWasZapped
}
}
if (!wasZappedByLoggedInUser.value) {
WatchZapsForNote(baseNote, accountViewModel) { newWasZapped ->
if (wasZappedByLoggedInUser.value != newWasZapped) {
scope.launch(Dispatchers.Main) {
wasZappedByLoggedInUser.value = newWasZapped
}
}
}
}
Crossfade(targetState = wasZappedByLoggedInUser) {
if (it) {
if (it.value) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
@@ -1087,30 +1129,47 @@ private fun ZapIcon(
}
}
@Composable
private fun WatchZapsForNote(baseNote: Note, accountViewModel: AccountViewModel, onWasZapped: (Boolean) -> Unit) {
val zapsState by baseNote.live().zaps.observeAsState()
LaunchedEffect(key1 = zapsState) {
launch(Dispatchers.Default) {
onWasZapped(accountViewModel.calculateIfNoteWasZappedByAccount(baseNote))
}
}
}
@Composable
private fun ZapAmountText(
baseNote: Note,
grayTint: Color,
accountViewModel: AccountViewModel
) {
val zapsState by baseNote.live().zaps.observeAsState()
val zapAmountTxt = remember(baseNote) { mutableStateOf("") }
var zapAmountTxt by remember { mutableStateOf("") }
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = zapsState) {
launch(Dispatchers.Default) {
zapsState?.note?.let {
val newZapAmount = showAmount(accountViewModel.calculateZapAmount(it))
if (newZapAmount != zapAmountTxt) {
launch(Dispatchers.Main) {
zapAmountTxt = newZapAmount
}
}
WatchZapAmountsForNote(baseNote, accountViewModel) { newZapAmount ->
if (zapAmountTxt.value != newZapAmount) {
scope.launch(Dispatchers.Main) {
zapAmountTxt.value = newZapAmount
}
}
}
SlidingAnimation(zapAmountTxt, grayTint)
SlidingAnimationAmount(zapAmountTxt, grayTint)
}
@Composable
fun WatchZapAmountsForNote(baseNote: Note, accountViewModel: AccountViewModel, onZapAmount: (String) -> Unit) {
val zapsState by baseNote.live().zaps.observeAsState()
LaunchedEffect(key1 = zapsState) {
launch(Dispatchers.Default) {
onZapAmount(showAmount(accountViewModel.calculateZapAmount(baseNote)))
}
}
}
@Composable

View File

@@ -22,6 +22,7 @@ fun timeAgo(mills: Long?, context: Context): String {
.replace(" hr. ago", context.getString(R.string.h))
.replace(" min. ago", context.getString(R.string.m))
.replace(" days ago", context.getString(R.string.d))
.replace("Yesterday", "1" + context.getString(R.string.d))
}
fun timeAgoShort(mills: Long?, context: Context): String {

View File

@@ -5,7 +5,6 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
@@ -50,6 +49,9 @@ import com.vitorpamplona.amethyst.ui.components.BundledInsert
import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.RoyalBlue
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -82,7 +84,7 @@ fun UserReactionsRow(
Icon(
imageVector = Icons.Default.ExpandMore,
null,
modifier = Modifier.size(20.dp),
modifier = Size20Modifier,
tint = MaterialTheme.colors.placeholderText
)
}
@@ -163,6 +165,8 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
fun today() = sdf.format(LocalDateTime.now())
private suspend fun initializeSuspend() {
checkNotInMainThread()
val currentUser = user.pubkeyHex
val reactions = mutableMapOf<String, Int>()
@@ -212,6 +216,8 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
}
suspend fun addToStatsSuspend(newNotes: Set<Note>) {
checkNotInMainThread()
val currentUser = user.pubkeyHex
val reactions = this._reactions.value.toMutableMap()
@@ -268,6 +274,8 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
}
private suspend fun refreshChartModel() {
checkNotInMainThread()
val day = 24 * 60 * 60L
val now = LocalDateTime.now()
val displayAxisFormatter = DateTimeFormatter.ofPattern("EEE")
@@ -337,11 +345,11 @@ fun UserReplyReaction(
Icon(
painter = painterResource(R.drawable.ic_comment),
null,
modifier = Modifier.size(20.dp),
modifier = Size20Modifier,
tint = RoyalBlue
)
Spacer(modifier = Modifier.width(10.dp))
Spacer(modifier = StdHorzSpacer)
Text(
showCounts,
@@ -359,11 +367,11 @@ fun UserBoostReaction(
Icon(
painter = painterResource(R.drawable.ic_retweeted),
null,
modifier = Modifier.size(24.dp),
modifier = Size24Modifier,
tint = Color.Unspecified
)
Spacer(modifier = Modifier.width(10.dp))
Spacer(modifier = StdHorzSpacer)
Text(
showCounts,
@@ -381,14 +389,14 @@ fun UserLikeReaction(
Icon(
painter = painterResource(R.drawable.ic_liked),
null,
modifier = Modifier.size(20.dp),
modifier = Size20Modifier,
tint = Color.Unspecified
)
Spacer(modifier = Modifier.width(10.dp))
Spacer(modifier = StdHorzSpacer)
Text(
showCounts,
text = showCounts,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
@@ -401,7 +409,7 @@ fun UserZapReaction(
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = Modifier.size(24.dp),
modifier = Size24Modifier,
tint = BitcoinOrange
)

View File

@@ -16,6 +16,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
@@ -33,20 +34,20 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@Composable
fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier) {
fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier, showPlayButton: Boolean = true) {
val authorState by baseNote.live().metadata.map {
it.note.author
}.observeAsState(baseNote.author)
Crossfade(targetState = authorState, modifier = weight) {
it?.let {
UsernameDisplay(it, weight)
UsernameDisplay(it, weight, showPlayButton)
}
}
}
@Composable
fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) {
fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier, showPlayButton: Boolean = true) {
val npubDisplay by remember {
derivedStateOf {
baseUser.pubkeyDisplayHex()
@@ -59,7 +60,7 @@ fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) {
Crossfade(targetState = userMetadata, modifier = weight) {
if (it != null) {
UserNameDisplay(it.bestUsername(), it.bestDisplayName(), npubDisplay, it.tags, weight)
UserNameDisplay(it.bestUsername(), it.bestDisplayName(), npubDisplay, it.tags, weight, showPlayButton)
} else {
NPubDisplay(npubDisplay, weight)
}
@@ -72,14 +73,15 @@ private fun UserNameDisplay(
bestDisplayName: String?,
npubDisplay: String,
tags: ImmutableListOfLists<String>?,
modifier: Modifier
modifier: Modifier,
showPlayButton: Boolean = true
) {
if (bestUserName != null && bestDisplayName != null && bestDisplayName != bestUserName) {
UserAndUsernameDisplay(bestDisplayName, tags, bestUserName, modifier)
UserAndUsernameDisplay(bestDisplayName, tags, bestUserName, modifier, showPlayButton)
} else if (bestDisplayName != null) {
UserDisplay(bestDisplayName, tags, modifier)
UserDisplay(bestDisplayName, tags, modifier, showPlayButton)
} else if (bestUserName != null) {
UserDisplay(bestUserName, tags, modifier)
UserDisplay(bestUserName, tags, modifier, showPlayButton)
} else {
NPubDisplay(npubDisplay, modifier)
}
@@ -100,9 +102,10 @@ private fun NPubDisplay(npubDisplay: String, modifier: Modifier) {
private fun UserDisplay(
bestDisplayName: String,
tags: ImmutableListOfLists<String>?,
modifier: Modifier
modifier: Modifier,
showPlayButton: Boolean = true
) {
Row(modifier = modifier) {
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
CreateTextWithEmoji(
text = bestDisplayName,
tags = tags,
@@ -111,8 +114,10 @@ private fun UserDisplay(
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
Spacer(StdHorzSpacer)
DrawPlayName(bestDisplayName)
if (showPlayButton) {
Spacer(StdHorzSpacer)
DrawPlayName(bestDisplayName)
}
}
}
@@ -121,9 +126,10 @@ private fun UserAndUsernameDisplay(
bestDisplayName: String,
tags: ImmutableListOfLists<String>?,
bestUserName: String,
modifier: Modifier
modifier: Modifier,
showPlayButton: Boolean = true
) {
Row(modifier = modifier) {
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
CreateTextWithEmoji(
text = bestDisplayName,
tags = tags,
@@ -138,8 +144,10 @@ private fun UserAndUsernameDisplay(
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
Spacer(StdHorzSpacer)
DrawPlayName(bestDisplayName)
if (showPlayButton) {
Spacer(StdHorzSpacer)
DrawPlayName(bestDisplayName)
}
}
}

View File

@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.note
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -19,10 +20,12 @@ 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.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -194,6 +197,7 @@ fun ShowFollowingOrUnfollowingButton(
accountViewModel: AccountViewModel
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
var isFollowing by remember { mutableStateOf(false) }
val accountFollowsState by accountViewModel.account.userProfile().live().follows.observeAsState()
@@ -210,9 +214,41 @@ fun ShowFollowingOrUnfollowingButton(
}
if (isFollowing) {
UnfollowButton { scope.launch(Dispatchers.IO) { accountViewModel.unfollow(baseAuthor) } }
UnfollowButton {
if (!accountViewModel.isWriteable()) {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_unfollow),
Toast.LENGTH_SHORT
)
.show()
}
} else {
scope.launch(Dispatchers.IO) {
accountViewModel.unfollow(baseAuthor)
}
}
}
} else {
FollowButton({ scope.launch(Dispatchers.IO) { accountViewModel.follow(baseAuthor) } })
FollowButton {
if (!accountViewModel.isWriteable()) {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_follow),
Toast.LENGTH_SHORT
)
.show()
}
} else {
scope.launch(Dispatchers.IO) {
accountViewModel.follow(baseAuthor)
}
}
}
}
}

View File

@@ -21,7 +21,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.res.stringResource
@@ -60,9 +59,7 @@ fun ZapUserSetCompose(zapSetCard: ZapUserSetCard, isInnerNote: Boolean = false,
Column(
modifier = Modifier
.drawBehind {
drawRect(backgroundColor.value)
}
.background(backgroundColor.value)
.clickable {
nav("User/${zapSetCard.user.pubkeyHex}")
}

View File

@@ -141,7 +141,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
notes
.filter { it.event is LnZapEvent }
.forEach { zapEvent ->
val zappedPost = zapEvent.replyTo?.lastOrNull() { it.event !is ChannelMetadataEvent && it.event !is ChannelCreateEvent }
val zappedPost = zapEvent.replyTo?.lastOrNull()
if (zappedPost != null) {
val zapRequest = zappedPost.zaps.filter { it.value == zapEvent }.keys.firstOrNull()
if (zapRequest != null) {
@@ -184,7 +184,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
singleList.chunked(50).map { chunk ->
singleList.chunked(30).map { chunk ->
MultiSetCard(
baseNote,
boostsInCard.filter { it in chunk }.toImmutableList(),
@@ -235,9 +235,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
val thisAccount = (localFilter as? NotificationFeedFilter)?.account
val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null
if (lastNotesCopy != null && localFilter is AdditiveFeedFilter && oldNotesState is CardFeedState.Loaded) {
lastFeedKey = localFilter.feedKey()
if (lastNotesCopy != null && localFilter is AdditiveFeedFilter && oldNotesState is CardFeedState.Loaded && lastFeedKey == localFilter.feedKey()) {
val filteredNewList = localFilter.applyFilter(newItems)
if (filteredNewList.isEmpty()) return

View File

@@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -21,11 +22,13 @@ import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomListNewFeedFilter
import com.vitorpamplona.amethyst.ui.dal.CommunityFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DiscoverChatFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DiscoverCommunityFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DiscoverLiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeLiveActivitiesFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileAppRecommendationsFeedFilter
@@ -59,13 +62,6 @@ class NostrChatroomFeedViewModel(val user: User, val account: Account) : FeedVie
}
}
class NostrGlobalFeedViewModel(val account: Account) : FeedViewModel(GlobalFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NostrGlobalFeedViewModel : ViewModel> create(modelClass: Class<NostrGlobalFeedViewModel>): NostrGlobalFeedViewModel {
return NostrGlobalFeedViewModel(account) as NostrGlobalFeedViewModel
}
}
}
class NostrVideoFeedViewModel(val account: Account) : FeedViewModel(VideoFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NostrVideoFeedViewModel : ViewModel> create(modelClass: Class<NostrVideoFeedViewModel>): NostrVideoFeedViewModel {
@@ -73,6 +69,31 @@ class NostrVideoFeedViewModel(val account: Account) : FeedViewModel(VideoFeedFil
}
}
}
class NostrDiscoverLiveFeedViewModel(val account: Account) : FeedViewModel(DiscoverLiveFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NostrDiscoverLiveFeedViewModel : ViewModel> create(modelClass: Class<NostrDiscoverLiveFeedViewModel>): NostrDiscoverLiveFeedViewModel {
return NostrDiscoverLiveFeedViewModel(account) as NostrDiscoverLiveFeedViewModel
}
}
}
class NostrDiscoverCommunityFeedViewModel(val account: Account) : FeedViewModel(DiscoverCommunityFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NostrDiscoverCommunityFeedViewModel : ViewModel> create(modelClass: Class<NostrDiscoverCommunityFeedViewModel>): NostrDiscoverCommunityFeedViewModel {
return NostrDiscoverCommunityFeedViewModel(account) as NostrDiscoverCommunityFeedViewModel
}
}
}
class NostrDiscoverChatFeedViewModel(val account: Account) : FeedViewModel(DiscoverChatFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NostrDiscoverChatFeedViewModel : ViewModel> create(modelClass: Class<NostrDiscoverChatFeedViewModel>): NostrDiscoverChatFeedViewModel {
return NostrDiscoverChatFeedViewModel(account) as NostrDiscoverChatFeedViewModel
}
}
}
class NostrThreadFeedViewModel(val noteId: String) : FeedViewModel(ThreadFeedFilter(noteId)) {
class Factory(val noteId: String) : ViewModelProvider.Factory {
override fun <NostrThreadFeedViewModel : ViewModel> create(modelClass: Class<NostrThreadFeedViewModel>): NostrThreadFeedViewModel {
@@ -96,7 +117,21 @@ class NostrUserProfileConversationsFeedViewModel(val user: User, val account: Ac
}
}
class NostrHashtagFeedViewModel : FeedViewModel(HashtagFeedFilter)
class NostrHashtagFeedViewModel(val hashtag: String, val account: Account) : FeedViewModel(HashtagFeedFilter(hashtag, account)) {
class Factory(val hashtag: String, val account: Account) : ViewModelProvider.Factory {
override fun <NostrHashtagFeedViewModel : ViewModel> create(modelClass: Class<NostrHashtagFeedViewModel>): NostrHashtagFeedViewModel {
return NostrHashtagFeedViewModel(hashtag, account) as NostrHashtagFeedViewModel
}
}
}
class NostrCommunityFeedViewModel(val note: AddressableNote, val account: Account) : FeedViewModel(CommunityFeedFilter(note, account)) {
class Factory(val note: AddressableNote, val account: Account) : ViewModelProvider.Factory {
override fun <NostrCommunityFeedViewModel : ViewModel> create(modelClass: Class<NostrCommunityFeedViewModel>): NostrCommunityFeedViewModel {
return NostrCommunityFeedViewModel(note, account) as NostrCommunityFeedViewModel
}
}
}
class NostrUserProfileReportFeedViewModel(val user: User) : FeedViewModel(UserProfileReportsFeedFilter(user)) {
class Factory(val user: User) : ViewModelProvider.Factory {
@@ -128,15 +163,6 @@ class NostrChatroomListNewFeedViewModel(val account: Account) : FeedViewModel(Ch
}
}
@Stable
class NostrHomeFeedLiveActivitiesViewModel(val account: Account) : FeedViewModel(HomeLiveActivitiesFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NostrHomeFeedLiveActivitiesViewModel : ViewModel> create(modelClass: Class<NostrHomeFeedLiveActivitiesViewModel>): NostrHomeFeedLiveActivitiesViewModel {
return NostrHomeFeedLiveActivitiesViewModel(account) as NostrHomeFeedLiveActivitiesViewModel
}
}
}
@Stable
class NostrHomeFeedViewModel(val account: Account) : FeedViewModel(HomeNewThreadFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
@@ -201,8 +227,8 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
fun refreshSuspended() {
checkNotInMainThread()
val notes = localFilter.loadTop().toImmutableList()
lastFeedKey = localFilter.feedKey()
val notes = localFilter.loadTop().toImmutableList()
val oldNotesState = _feedContent.value
if (oldNotesState is FeedState.Loaded) {
@@ -231,16 +257,14 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
fun refreshFromOldState(newItems: Set<Note>) {
val oldNotesState = _feedContent.value
if (localFilter is AdditiveFeedFilter) {
if (localFilter is AdditiveFeedFilter && lastFeedKey == localFilter.feedKey()) {
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)
}

View File

@@ -17,12 +17,18 @@ object ScrollStateKeys {
const val GLOBAL_SCREEN = "Global"
const val NOTIFICATION_SCREEN = "Notifications"
const val VIDEO_SCREEN = "Video"
const val DISCOVER_SCREEN = "Discover"
val HOME_FOLLOWS = Route.Home.base + "Follows"
val HOME_REPLIES = Route.Home.base + "FollowsReplies"
val DISCOVER_LIVE = Route.Home.base + "Live"
val DISCOVER_COMMUNITY = Route.Home.base + "Communities"
val DISCOVER_CHATS = Route.Home.base + "Chats"
}
object PagerStateKeys {
const val HOME_SCREEN = "Home"
const val HOME_SCREEN = "PagerHome"
const val DISCOVER_SCREEN = "PagerDiscover"
}
@Composable

View File

@@ -33,6 +33,7 @@ import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -59,6 +60,7 @@ import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
@@ -295,7 +297,17 @@ fun NoteMaster(
Row(verticalAlignment = Alignment.CenterVertically) {
NoteUsernameDisplay(baseNote, Modifier.weight(1f))
DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav)
val isCommunityPost by remember(baseNote) {
derivedStateOf {
baseNote.event?.isTaggedAddressableKind(CommunityDefinitionEvent.kind) == true
}
}
if (isCommunityPost) {
DisplayFollowingCommunityInPost(baseNote, accountViewModel, nav)
} else {
DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav)
}
Text(
timeAgo(note.createdAt(), context = context),

View File

@@ -13,6 +13,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountState
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
@@ -241,6 +242,10 @@ class AccountViewModel(val account: Account) : ViewModel() {
return account.userProfile().isFollowingCached(user)
}
fun isFollowing(user: HexKey): Boolean {
return account.userProfile().isFollowingCached(user)
}
val hideDeleteRequestDialog: Boolean
get() = account.hideDeleteRequestDialog

View File

@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter
import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPrivateFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPublicFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@@ -54,7 +55,8 @@ fun BookmarkListScreen(accountViewModel: AccountViewModel, nav: (String) -> Unit
TabRow(
backgroundColor = MaterialTheme.colors.background,
selectedTabIndex = pagerState.currentPage
selectedTabIndex = pagerState.currentPage,
modifier = TabRowHeight
) {
Tab(
selected = pagerState.currentPage == 0,

View File

@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
@@ -43,6 +44,7 @@ import androidx.compose.material.icons.filled.Share
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -53,11 +55,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
@@ -74,6 +76,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Channel
@@ -81,32 +85,55 @@ import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_LIVE
import com.vitorpamplona.amethyst.service.model.Participant
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.actions.ServersAvailable
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.MoreOptionsButton
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.TimeAgo
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Locale
@Composable
fun ChannelScreen(
@@ -514,6 +541,7 @@ fun ChannelHeader(
channelHex: String,
showVideo: Boolean,
showBottomDiviser: Boolean,
showFlag: Boolean = true,
modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -533,6 +561,7 @@ fun ChannelHeader(
it,
showVideo,
showBottomDiviser,
showFlag,
modifier,
accountViewModel,
nav
@@ -545,115 +574,27 @@ fun ChannelHeader(
baseChannel: Channel,
showVideo: Boolean,
showBottomDiviser: Boolean,
showFlag: Boolean = true,
modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val scope = rememberCoroutineScope()
Column(
Modifier
.fillMaxWidth()
.clickable {
scope.launch {
nav("Channel/${baseChannel.idHex}")
}
}
) {
val channelState by baseChannel.live.observeAsState()
val channel = remember(channelState) { channelState?.channel } ?: return
val streamingUrl by remember(channelState) {
derivedStateOf {
val activity = channel as? LiveActivitiesChannel
val description = activity?.info?.title()
val url = activity?.info?.streaming()
if (url != null) {
ZoomableUrlVideo(url, description = description)
} else {
null
}
}
Column(Modifier.fillMaxWidth()) {
if (showVideo && baseChannel is LiveActivitiesChannel) {
ShowVideoStreaming(baseChannel, accountViewModel)
}
if (streamingUrl != null && showVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = remember { Modifier.heightIn(max = 300.dp) }) {
ZoomableContentView(
content = streamingUrl!!
)
val expanded = remember { mutableStateOf(false) }
Column(
modifier = modifier.clickable {
expanded.value = !expanded.value
}
}
) {
ShortChannelHeader(baseChannel, expanded, accountViewModel, nav, showFlag)
Column(modifier = modifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
channel.creator?.let {
UserPicture(
user = it,
size = Size35dp,
accountViewModel = accountViewModel,
nav = nav
)
}
/*
channel.profilePicture()?.let {
RobohashAsyncImageProxy(
robot = channel.idHex,
model = it,
contentDescription = stringResource(R.string.profile_image),
contentScale = ContentScale.Crop,
modifier = Modifier.padding(start = 10.dp)
.width(Size35dp)
.height(Size35dp)
.clip(shape = CircleShape)
)
}
*/
Column(
modifier = Modifier
.padding(start = 10.dp)
.weight(1f),
verticalArrangement = Arrangement.Center
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = remember(channelState) { channel.toBestDisplayName() },
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
val summary = remember(channelState) {
channel.summary()?.ifBlank { null }
}
if (summary != null) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = summary,
color = MaterialTheme.colors.placeholderText,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
fontSize = 12.sp
)
}
}
}
Row(
modifier = Modifier
.height(Size35dp)
.padding(start = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (channel is PublicChatChannel) {
ChannelActionOptions(channel, accountViewModel, nav)
}
if (channel is LiveActivitiesChannel) {
LiveChannelActionOptions(channel, accountViewModel, nav)
}
}
if (expanded.value) {
LongChannelHeader(baseChannel, accountViewModel, nav)
}
}
@@ -666,13 +607,301 @@ fun ChannelHeader(
}
@Composable
private fun ChannelActionOptions(
private fun ShowVideoStreaming(
baseChannel: LiveActivitiesChannel,
accountViewModel: AccountViewModel
) {
baseChannel.info?.let {
SensitivityWarning(
event = it,
accountViewModel = accountViewModel
) {
val streamingUrl by baseChannel.live.map {
val activity = it.channel as? LiveActivitiesChannel
activity?.info?.streaming()
}.distinctUntilChanged().observeAsState(baseChannel.info?.streaming())
streamingUrl?.let {
CheckIfUrlIsOnline(it) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = remember { Modifier.heightIn(max = 300.dp) }
) {
val zoomableUrlVideo = remember(it) {
ZoomableUrlVideo(url = it)
}
ZoomableContentView(
content = zoomableUrlVideo
)
}
}
}
}
}
}
@Composable
private fun ShortChannelHeader(
baseChannel: Channel,
expanded: MutableState<Boolean>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
showFlag: Boolean
) {
val channelState = baseChannel.live.observeAsState()
val channel = remember(channelState) {
channelState.value?.channel
} ?: return
Row(verticalAlignment = Alignment.CenterVertically) {
if (channel is LiveActivitiesChannel) {
channel.creator?.let {
UserPicture(
user = it,
size = Size35dp,
accountViewModel = accountViewModel,
nav = nav
)
}
} else {
channel.profilePicture()?.let {
RobohashAsyncImageProxy(
robot = channel.idHex,
model = it,
contentDescription = stringResource(R.string.profile_image),
contentScale = ContentScale.Crop,
modifier = Modifier
.padding(start = 10.dp)
.width(Size35dp)
.height(Size35dp)
.clip(shape = CircleShape)
)
}
}
Column(
modifier = Modifier
.padding(start = 10.dp)
.height(35.dp)
.weight(1f),
verticalArrangement = Arrangement.Center
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = remember(channelState) { channel.toBestDisplayName() },
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
val summary = remember(channelState) {
channel.summary()?.ifBlank { null }
}
if (summary != null && !expanded.value) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = summary,
color = MaterialTheme.colors.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontSize = 12.sp
)
}
}
}
Row(
modifier = Modifier
.height(Size35dp)
.padding(start = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (channel is PublicChatChannel) {
ShortChannelActionOptions(channel, accountViewModel, nav)
}
if (channel is LiveActivitiesChannel) {
LiveChannelActionOptions(channel, showFlag, accountViewModel, nav)
}
}
}
}
@Composable
private fun LongChannelHeader(
baseChannel: Channel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val channelState = baseChannel.live.observeAsState()
val channel = remember(channelState) {
channelState.value?.channel
} ?: return
Row(
Modifier
.fillMaxWidth()
.padding(top = 10.dp)
) {
val summary = remember(channelState) {
channel.summary()?.ifBlank { null }
}
Column(
Modifier
.weight(1f)
.padding(start = 10.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
val defaultBackground = MaterialTheme.colors.background
val background = remember {
mutableStateOf(defaultBackground)
}
val tags = remember(channelState) {
if (baseChannel is LiveActivitiesChannel) {
baseChannel.info?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists()
} else {
ImmutableListOfLists()
}
}
TranslatableRichTextViewer(
content = summary ?: stringResource(id = R.string.groups_no_descriptor),
canPreview = false,
tags = tags,
backgroundColor = background,
accountViewModel = accountViewModel,
nav = nav
)
}
if (baseChannel is LiveActivitiesChannel) {
val hashtags = remember(baseChannel.info) { baseChannel.info?.hashtags()?.toImmutableList() ?: persistentListOf() }
DisplayUncitedHashtags(hashtags, summary ?: "", nav)
}
}
Column() {
if (channel is PublicChatChannel) {
Row() {
Spacer(DoubleHorzSpacer)
LongChannelActionOptions(channel, accountViewModel, nav)
}
}
}
}
Spacer(DoubleVertSpacer)
Row(
Modifier
.fillMaxWidth()
.padding(start = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
LoadNote(baseNoteHex = channel.idHex) {
it?.let {
Text(
text = stringResource(id = R.string.owner),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(55.dp)
)
Spacer(DoubleHorzSpacer)
NoteAuthorPicture(it, nav, accountViewModel, Size25dp)
Spacer(DoubleHorzSpacer)
NoteUsernameDisplay(it, remember { Modifier.weight(1f) })
TimeAgo(it)
MoreOptionsButton(it, accountViewModel)
}
}
}
var participantUsers by remember(baseChannel) {
mutableStateOf<ImmutableList<Pair<Participant, User>>>(
persistentListOf()
)
}
if (channel is LiveActivitiesChannel) {
LaunchedEffect(key1 = channelState) {
launch(Dispatchers.IO) {
val newParticipantUsers = channel.info?.participants()?.mapNotNull { part ->
LocalCache.checkGetOrCreateUser(part.key)?.let { Pair(part, it) }
}?.toImmutableList()
if (newParticipantUsers != null && !equalImmutableLists(newParticipantUsers, participantUsers)) {
participantUsers = newParticipantUsers
}
}
}
participantUsers.forEach {
Row(
Modifier
.fillMaxWidth()
.padding(start = 10.dp, top = 10.dp)
.clickable {
nav("User/${it.second.pubkeyHex}")
},
verticalAlignment = Alignment.CenterVertically
) {
it.first.role?.let { it1 ->
Text(
text = it1.capitalize(Locale.ROOT),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(55.dp)
)
}
Spacer(DoubleHorzSpacer)
ClickableUserPicture(it.second, Size25dp, accountViewModel)
Spacer(DoubleHorzSpacer)
UsernameDisplay(it.second, remember { Modifier.weight(1f) })
}
}
}
}
@Composable
private fun ShortChannelActionOptions(
channel: PublicChatChannel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
NoteCopyButton(channel)
val accountState by accountViewModel.accountLiveData.observeAsState()
val isFollowing by remember(accountState) {
derivedStateOf {
accountState?.account?.followingChannels?.contains(channel.idHex) ?: false
}
}
LoadNote(baseNoteHex = channel.idHex) {
it?.let {
var popupExpanded by remember { mutableStateOf(false) }
Spacer(modifier = StdHorzSpacer)
LikeReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
Spacer(modifier = StdHorzSpacer)
}
}
if (!isFollowing) {
JoinChatButton(accountViewModel, channel, nav)
}
}
@Composable
private fun LongChannelActionOptions(
channel: PublicChatChannel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val isMe by remember(accountViewModel) {
derivedStateOf {
channel.creator == accountViewModel.account.userProfile()
@@ -691,21 +920,20 @@ private fun ChannelActionOptions(
}
if (isFollowing) {
LeaveButton(accountViewModel, channel, nav)
} else {
JoinButton(accountViewModel, channel, nav)
LeaveChatButton(accountViewModel, channel, nav)
}
}
@Composable
private fun LiveChannelActionOptions(
channel: LiveActivitiesChannel,
showFlag: Boolean = true,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val isLive by remember(channel) {
derivedStateOf {
channel.info?.status() == "live"
channel.info?.status() == STATUS_LIVE
}
}
@@ -714,7 +942,7 @@ private fun LiveChannelActionOptions(
}
note?.let {
if (isLive) {
if (showFlag && isLive) {
LiveFlag()
Spacer(modifier = StdHorzSpacer)
}
@@ -734,7 +962,7 @@ fun LiveFlag() {
modifier = remember {
Modifier
.clip(SmallBorder)
.drawBehind { drawRect(Color.Red) }
.background(Color.Red)
.padding(horizontal = 5.dp)
}
)
@@ -749,7 +977,7 @@ fun EndedFlag() {
modifier = remember {
Modifier
.clip(SmallBorder)
.drawBehind { drawRect(Color.Black) }
.background(Color.Black)
.padding(horizontal = 5.dp)
}
)
@@ -764,22 +992,25 @@ fun OfflineFlag() {
modifier = remember {
Modifier
.clip(SmallBorder)
.drawBehind { drawRect(Color.Black) }
.background(Color.Black)
.padding(horizontal = 5.dp)
}
)
}
@Composable
fun ScheduledFlag() {
fun ScheduledFlag(starts: Long?) {
val context = LocalContext.current
val startsIn = starts?.let { timeAgo(it, context) }
Text(
text = stringResource(id = R.string.live_stream_planned_tag),
text = startsIn ?: stringResource(id = R.string.live_stream_planned_tag),
color = Color.White,
fontWeight = FontWeight.Bold,
modifier = remember {
Modifier
.clip(SmallBorder)
.drawBehind { drawRect(Color.Black) }
.background(Color.Black)
.padding(horizontal = 5.dp)
}
)
@@ -851,7 +1082,7 @@ private fun EditButton(accountViewModel: AccountViewModel, channel: PublicChatCh
}
@Composable
private fun JoinButton(accountViewModel: AccountViewModel, channel: Channel, nav: (String) -> Unit) {
fun JoinChatButton(accountViewModel: AccountViewModel, channel: Channel, nav: (String) -> Unit) {
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
@@ -869,12 +1100,47 @@ private fun JoinButton(accountViewModel: AccountViewModel, channel: Channel, nav
}
@Composable
private fun LeaveButton(accountViewModel: AccountViewModel, channel: Channel, nav: (String) -> Unit) {
fun LeaveChatButton(accountViewModel: AccountViewModel, channel: Channel, nav: (String) -> Unit) {
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
accountViewModel.account.leaveChannel(channel.idHex)
nav(Route.Message.route)
},
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = stringResource(R.string.leave), color = Color.White)
}
}
@Composable
fun JoinCommunityButton(accountViewModel: AccountViewModel, note: Note, nav: (String) -> Unit) {
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
accountViewModel.account.joinCommunity(note.idHex)
},
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = stringResource(R.string.join), color = Color.White)
}
}
@Composable
fun LeaveCommunityButton(accountViewModel: AccountViewModel, note: Note, nav: (String) -> Unit) {
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
accountViewModel.account.leaveCommunity((note.idHex))
},
shape = ButtonBorder,
colors = ButtonDefaults

View File

@@ -38,13 +38,13 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.ui.screen.ChatroomListFeedView
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -97,7 +97,8 @@ fun ChatroomListScreen(
Box(Modifier.fillMaxWidth()) {
TabRow(
backgroundColor = MaterialTheme.colors.background,
selectedTabIndex = pagerState.currentPage
selectedTabIndex = pagerState.currentPage,
modifier = TabRowHeight
) {
tabs.forEachIndexed { index, tab ->
Tab(

View File

@@ -0,0 +1,105 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.NostrCommunityDataSource
import com.vitorpamplona.amethyst.ui.note.CommunityHeader
import com.vitorpamplona.amethyst.ui.screen.NostrCommunityFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun CommunityScreen(aTagHex: String?, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
if (aTagHex == null) return
var noteDefBase by remember { mutableStateOf<AddressableNote?>(LocalCache.getAddressableNoteIfExists(aTagHex)) }
if (noteDefBase == null) {
LaunchedEffect(aTagHex) {
// waits to resolve.
launch(Dispatchers.IO) {
val newNote = LocalCache.checkGetOrCreateAddressableNote(aTagHex)
if (newNote != noteDefBase) {
noteDefBase = newNote
}
}
}
}
noteDefBase?.let {
PrepareViewModelsCommunityScreen(
note = it,
accountViewModel = accountViewModel,
nav = nav
)
}
}
@Composable
fun PrepareViewModelsCommunityScreen(note: AddressableNote, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val followsFeedViewModel: NostrCommunityFeedViewModel = viewModel(
key = note.idHex + "CommunityFeedViewModel",
factory = NostrCommunityFeedViewModel.Factory(
note,
accountViewModel.account
)
)
CommunityScreen(note, followsFeedViewModel, accountViewModel, nav)
}
@Composable
fun CommunityScreen(note: AddressableNote, feedViewModel: NostrCommunityFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val lifeCycleOwner = LocalLifecycleOwner.current
NostrCommunityDataSource.loadCommunity(note)
LaunchedEffect(note) {
feedViewModel.invalidateData()
}
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Community Start")
NostrCommunityDataSource.start()
feedViewModel.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Community Stop")
NostrCommunityDataSource.loadCommunity(null)
NostrCommunityDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
Column(Modifier.fillMaxSize()) {
CommunityHeader(baseNote = note, showBottomDiviser = true, accountViewModel = accountViewModel, nav = nav)
RefresheableFeedView(
feedViewModel,
null,
accountViewModel = accountViewModel,
nav = nav
)
}
}

View File

@@ -0,0 +1,241 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Tab
import androidx.compose.material.TabRow
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.ChannelCardCompose
import com.vitorpamplona.amethyst.ui.screen.FeedEmpty
import com.vitorpamplona.amethyst.ui.screen.FeedError
import com.vitorpamplona.amethyst.ui.screen.FeedState
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
import com.vitorpamplona.amethyst.ui.screen.LoadingFeed
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverChatFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverCommunityFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverLiveFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.PagerStateKeys
import com.vitorpamplona.amethyst.ui.screen.RefresheableView
import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun DiscoverScreen(
discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel,
discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel,
discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val lifeCycleOwner = LocalLifecycleOwner.current
val pagerState = rememberForeverPagerState(key = PagerStateKeys.DISCOVER_SCREEN)
WatchAccountForDiscoveryScreen(
discoveryLiveFeedViewModel = discoveryLiveFeedViewModel,
discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel,
discoveryChatFeedViewModel = discoveryChatFeedViewModel,
accountViewModel = accountViewModel
)
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Discovery Start")
NostrDiscoveryDataSource.start()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
val tabs by remember(discoveryLiveFeedViewModel, discoveryCommunityFeedViewModel, discoveryChatFeedViewModel) {
mutableStateOf(
listOf(
TabItem(R.string.discover_live, discoveryLiveFeedViewModel, Route.Discover.base + "Live", ScrollStateKeys.DISCOVER_LIVE),
TabItem(R.string.discover_community, discoveryCommunityFeedViewModel, Route.Discover.base + "Community", ScrollStateKeys.DISCOVER_COMMUNITY),
TabItem(R.string.discover_chat, discoveryChatFeedViewModel, Route.Discover.base + "Chats", ScrollStateKeys.DISCOVER_CHATS)
).toImmutableList()
)
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
DiscoverPages(pagerState, tabs, accountViewModel, nav)
}
}
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun DiscoverPages(
pagerState: PagerState,
tabs: ImmutableList<TabItem>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
TabRow(
backgroundColor = MaterialTheme.colors.background,
selectedTabIndex = pagerState.currentPage,
modifier = TabRowHeight
) {
val coroutineScope = rememberCoroutineScope()
tabs.forEachIndexed { index, tab ->
Tab(
selected = pagerState.currentPage == index,
text = {
Text(text = stringResource(tab.resource))
},
onClick = {
coroutineScope.launch { pagerState.animateScrollToPage(index) }
}
)
}
}
HorizontalPager(pageCount = 3, state = pagerState) { page ->
RefresheableView(tabs[page].viewModel, true) {
SaveableFeedState(tabs[page].viewModel, scrollStateKey = tabs[page].scrollStateKey) { listState ->
RenderDiscoverFeed(tabs[page].viewModel, tabs[page].routeForLastRead, accountViewModel, listState, nav)
}
}
}
}
@Composable
private fun RenderDiscoverFeed(
viewModel: FeedViewModel,
routeForLastRead: String?,
accountViewModel: AccountViewModel,
listState: LazyListState,
nav: (String) -> Unit
) {
val feedState by viewModel.feedContent.collectAsState()
Crossfade(
targetState = feedState,
animationSpec = tween(durationMillis = 100)
) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty {
viewModel.invalidateData()
}
}
is FeedState.FeedError -> {
FeedError(state.errorMessage) {
viewModel.invalidateData()
}
}
is FeedState.Loaded -> {
DiscoverFeedLoaded(
state,
routeForLastRead,
listState,
accountViewModel,
nav
)
}
is FeedState.Loading -> {
LoadingFeed()
}
}
}
}
@Composable
fun WatchAccountForDiscoveryScreen(
discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel,
discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel,
discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel,
accountViewModel: AccountViewModel
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
LaunchedEffect(accountViewModel, accountState?.account?.defaultDiscoveryFollowList) {
NostrDiscoveryDataSource.resetFilters()
discoveryLiveFeedViewModel.checkKeysInvalidateDataAndSendToTop()
discoveryCommunityFeedViewModel.checkKeysInvalidateDataAndSendToTop()
discoveryChatFeedViewModel.checkKeysInvalidateDataAndSendToTop()
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun DiscoverFeedLoaded(
state: FeedState.Loaded,
routeForLastRead: String?,
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
state = listState
) {
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item ->
val defaultModifier = remember {
Modifier.fillMaxWidth().animateItemPlacement()
}
Row(defaultModifier) {
ChannelCardCompose(
baseNote = item,
routeForLastRead = routeForLastRead,
modifier = Modifier,
accountViewModel = accountViewModel,
nav = nav
)
}
}
}
}

View File

@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -17,70 +18,89 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
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.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter
import com.vitorpamplona.amethyst.ui.screen.NostrHashtagFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun HashtagScreen(tag: String?, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
if (tag == null) return
PrepareViewModelsHashtagScreen(tag, accountViewModel, nav)
}
@Composable
fun PrepareViewModelsHashtagScreen(tag: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val followsFeedViewModel: NostrHashtagFeedViewModel = viewModel(
key = tag + "HashtagFeedViewModel",
factory = NostrHashtagFeedViewModel.Factory(
tag,
accountViewModel.account
)
)
HashtagScreen(tag, followsFeedViewModel, accountViewModel, nav)
}
@Composable
fun HashtagScreen(tag: String, feedViewModel: NostrHashtagFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val lifeCycleOwner = LocalLifecycleOwner.current
if (tag != null) {
HashtagFeedFilter.loadHashtag(accountViewModel.account, tag)
val feedViewModel: NostrHashtagFeedViewModel = viewModel()
NostrHashtagDataSource.loadHashtag(tag)
LaunchedEffect(tag) {
HashtagFeedFilter.loadHashtag(accountViewModel.account, tag)
NostrHashtagDataSource.loadHashtag(tag)
feedViewModel.invalidateData()
}
LaunchedEffect(tag) {
NostrHashtagDataSource.start()
feedViewModel.invalidateData()
}
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Hashtag Start")
HashtagFeedFilter.loadHashtag(accountViewModel.account, tag)
NostrHashtagDataSource.loadHashtag(tag)
NostrHashtagDataSource.start()
feedViewModel.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Hashtag Stop")
HashtagFeedFilter.loadHashtag(accountViewModel.account, null)
NostrHashtagDataSource.loadHashtag(null)
NostrHashtagDataSource.stop()
}
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Hashtag Start")
NostrHashtagDataSource.loadHashtag(tag)
NostrHashtagDataSource.start()
feedViewModel.invalidateData()
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
if (event == Lifecycle.Event.ON_PAUSE) {
println("Hashtag Stop")
NostrHashtagDataSource.loadHashtag(null)
NostrHashtagDataSource.stop()
}
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
HashtagHeader(tag, accountViewModel)
RefresheableFeedView(
feedViewModel,
null,
accountViewModel = accountViewModel,
nav = nav
)
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
NostrHashtagDataSource.loadHashtag(null)
NostrHashtagDataSource.stop()
}
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
HashtagHeader(tag, accountViewModel)
RefresheableFeedView(
feedViewModel,
null,
accountViewModel = accountViewModel,
nav = nav
)
}
}
}
@@ -90,7 +110,7 @@ fun HashtagHeader(tag: String, account: AccountViewModel, onClick: () -> Unit =
Column(
Modifier.clickable { onClick() }
) {
Column(modifier = Modifier.padding(12.dp)) {
Column(modifier = HalfPadding) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(
modifier = Modifier
@@ -126,7 +146,8 @@ private fun HashtagActionOptions(
tag: String,
accountViewModel: AccountViewModel
) {
val coroutineScope = rememberCoroutineScope()
val scope = rememberCoroutineScope()
val context = LocalContext.current
val userState by accountViewModel.userProfile().live().follows.observeAsState()
val isFollowingTag by remember(userState) {
@@ -136,8 +157,40 @@ private fun HashtagActionOptions(
}
if (isFollowingTag) {
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { accountViewModel.account.unfollow(tag) } }
UnfollowButton {
if (!accountViewModel.isWriteable()) {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_unfollow),
Toast.LENGTH_SHORT
)
.show()
}
} else {
scope.launch(Dispatchers.IO) {
accountViewModel.account.unfollow(tag)
}
}
}
} else {
FollowButton({ coroutineScope.launch(Dispatchers.IO) { accountViewModel.account.follow(tag) } })
FollowButton {
if (!accountViewModel.isWriteable()) {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_follow),
Toast.LENGTH_SHORT
)
.show()
}
} else {
scope.launch(Dispatchers.IO) {
accountViewModel.account.follow(tag)
}
}
}
}
}

View File

@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.NostrHiddenAccountsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import kotlinx.coroutines.launch
@Composable
@@ -105,7 +106,8 @@ fun HiddenUsersScreen(
TabRow(
backgroundColor = MaterialTheme.colors.background,
selectedTabIndex = pagerState.currentPage
selectedTabIndex = pagerState.currentPage,
modifier = TabRowHeight
) {
Tab(
selected = pagerState.currentPage == 0,

View File

@@ -1,15 +1,10 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.material.MaterialTheme
@@ -20,7 +15,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -34,21 +28,18 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountDialog
import com.vitorpamplona.amethyst.ui.screen.FeedState
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedLiveActivitiesViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.PagerStateKeys
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
@@ -59,7 +50,6 @@ import kotlinx.coroutines.launch
fun HomeScreen(
homeFeedViewModel: NostrHomeFeedViewModel,
repliesFeedViewModel: NostrHomeRepliesFeedViewModel,
liveActivitiesViewModel: NostrHomeFeedLiveActivitiesViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nip47: String? = null
@@ -68,7 +58,7 @@ fun HomeScreen(
val pagerState = rememberForeverPagerState(key = PagerStateKeys.HOME_SCREEN)
WatchAccountForHomeScreen(homeFeedViewModel, repliesFeedViewModel, liveActivitiesViewModel, accountViewModel)
WatchAccountForHomeScreen(homeFeedViewModel, repliesFeedViewModel, accountViewModel)
if (wantsToAddNip47 != null) {
UpdateZapAmountDialog({ wantsToAddNip47 = null }, wantsToAddNip47, accountViewModel)
@@ -76,8 +66,6 @@ fun HomeScreen(
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(accountViewModel) {
liveActivitiesViewModel.invalidateData(true)
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
NostrHomeDataSource.invalidateFilters()
@@ -103,7 +91,7 @@ fun HomeScreen(
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
HomePages(pagerState, tabs, liveActivitiesViewModel, accountViewModel, nav)
HomePages(pagerState, tabs, accountViewModel, nav)
}
}
}
@@ -113,13 +101,13 @@ fun HomeScreen(
private fun HomePages(
pagerState: PagerState,
tabs: ImmutableList<TabItem>,
liveActivitiesViewModel: NostrHomeFeedLiveActivitiesViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
TabRow(
backgroundColor = MaterialTheme.colors.background,
selectedTabIndex = pagerState.currentPage
selectedTabIndex = pagerState.currentPage,
modifier = TabRowHeight
) {
val coroutineScope = rememberCoroutineScope()
@@ -136,12 +124,6 @@ private fun HomePages(
}
}
LiveActivities(
liveActivitiesViewModel = liveActivitiesViewModel,
accountViewModel = accountViewModel,
nav = nav
)
HorizontalPager(pageCount = 2, state = pagerState) { page ->
RefresheableFeedView(
viewModel = tabs[page].viewModel,
@@ -154,69 +136,12 @@ private fun HomePages(
}
@Composable
fun LiveActivities(
liveActivitiesViewModel: NostrHomeFeedLiveActivitiesViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val feedState by liveActivitiesViewModel.feedContent.collectAsState()
Crossfade(
targetState = feedState,
animationSpec = tween(durationMillis = 100)
) { state ->
when (state) {
is FeedState.Loaded -> {
FeedLoaded(
state,
accountViewModel,
nav
)
}
else -> {
}
}
}
}
@Composable
private fun FeedLoaded(
state: FeedState.Loaded,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val listState = rememberLazyListState()
LazyColumn(
contentPadding = PaddingValues(),
state = listState
) {
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item ->
CheckIfLiveActivityIsOnline(item) {
ChannelHeader(
channelHex = remember { item.idHex },
showVideo = false,
showBottomDiviser = true,
modifier = remember {
Modifier.padding(start = 10.dp, end = 10.dp, top = 5.dp, bottom = 5.dp)
},
accountViewModel = accountViewModel,
nav = nav
)
}
}
}
}
@Composable
fun CheckIfLiveActivityIsOnline(note: Note, whenOnline: @Composable () -> Unit) {
val noteState by note.live().metadata.observeAsState()
fun CheckIfUrlIsOnline(url: String, whenOnline: @Composable () -> Unit) {
var online by remember { mutableStateOf(false) }
LaunchedEffect(key1 = noteState) {
LaunchedEffect(key1 = url) {
launch(Dispatchers.IO) {
online = OnlineChecker.isOnline((note.event as? LiveActivitiesEvent)?.streaming())
online = OnlineChecker.isOnline(url)
}
}
@@ -231,7 +156,6 @@ fun CheckIfLiveActivityIsOnline(note: Note, whenOnline: @Composable () -> Unit)
fun WatchAccountForHomeScreen(
homeFeedViewModel: NostrHomeFeedViewModel,
repliesFeedViewModel: NostrHomeRepliesFeedViewModel,
liveActivitiesViewModel: NostrHomeFeedLiveActivitiesViewModel,
accountViewModel: AccountViewModel
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
@@ -242,7 +166,6 @@ fun WatchAccountForHomeScreen(
NostrHomeDataSource.invalidateFilters()
homeFeedViewModel.checkKeysInvalidateDataAndSendToTop()
repliesFeedViewModel.checkKeysInvalidateDataAndSendToTop()
liveActivitiesViewModel.checkKeysInvalidateDataAndSendToTop()
}
}
}

View File

@@ -30,6 +30,7 @@ import androidx.navigation.NavBackStackEntry
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.vitorpamplona.amethyst.ui.buttons.ChannelFabColumn
import com.vitorpamplona.amethyst.ui.buttons.NewCommunityNoteButton
import com.vitorpamplona.amethyst.ui.buttons.NewNoteButton
import com.vitorpamplona.amethyst.ui.navigation.*
import com.vitorpamplona.amethyst.ui.navigation.AccountSwitchBottomSheet
@@ -38,14 +39,14 @@ import com.vitorpamplona.amethyst.ui.navigation.AppNavigation
import com.vitorpamplona.amethyst.ui.navigation.AppTopBar
import com.vitorpamplona.amethyst.ui.navigation.DrawerContent
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.navigation.currentRoute
import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel
import com.vitorpamplona.amethyst.ui.screen.AccountState
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedLiveActivitiesViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverChatFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverCommunityFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverLiveFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
@@ -90,21 +91,26 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
factory = NostrHomeRepliesFeedViewModel.Factory(accountViewModel.account)
)
val liveActivitiesViewModel: NostrHomeFeedLiveActivitiesViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "NostrHomeLiveActivitiesFeedViewModel",
factory = NostrHomeFeedLiveActivitiesViewModel.Factory(accountViewModel.account)
)
val searchFeedViewModel: NostrGlobalFeedViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "NostrGlobalFeedViewModel",
factory = NostrGlobalFeedViewModel.Factory(accountViewModel.account)
)
val videoFeedViewModel: NostrVideoFeedViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "NostrVideoFeedViewModel",
factory = NostrVideoFeedViewModel.Factory(accountViewModel.account)
)
val discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "NostrDiscoveryLiveFeedViewModel",
factory = NostrDiscoverLiveFeedViewModel.Factory(accountViewModel.account)
)
val discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "NostrDiscoveryCommunityFeedViewModel",
factory = NostrDiscoverCommunityFeedViewModel.Factory(accountViewModel.account)
)
val discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "NostrDiscoveryChatFeedViewModel",
factory = NostrDiscoverChatFeedViewModel.Factory(accountViewModel.account)
)
val notifFeedViewModel: NotificationViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "NotificationViewModel",
factory = NotificationViewModel.Factory(accountViewModel.account)
@@ -140,12 +146,14 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
homeFeedViewModel.sendToTop()
repliesFeedViewModel.sendToTop()
}
Route.Search.base -> {
searchFeedViewModel.sendToTop()
}
Route.Video.base -> {
videoFeedViewModel.sendToTop()
}
Route.Discover.base -> {
discoveryLiveFeedViewModel.sendToTop()
discoveryCommunityFeedViewModel.sendToTop()
discoveryChatFeedViewModel.sendToTop()
}
Route.Notification.base -> {
notifFeedViewModel.invalidateDataAndSendToTop()
}
@@ -188,18 +196,19 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
) {
Column(modifier = Modifier.padding(bottom = it.calculateBottomPadding())) {
AppNavigation(
homeFeedViewModel,
repliesFeedViewModel,
liveActivitiesViewModel,
knownFeedViewModel,
newFeedViewModel,
searchFeedViewModel,
videoFeedViewModel,
notifFeedViewModel,
userReactionsStatsModel,
navController,
accountViewModel,
startingPage
homeFeedViewModel = homeFeedViewModel,
repliesFeedViewModel = repliesFeedViewModel,
knownFeedViewModel = knownFeedViewModel,
newFeedViewModel = newFeedViewModel,
videoFeedViewModel = videoFeedViewModel,
discoveryLiveFeedViewModel = discoveryLiveFeedViewModel,
discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel,
discoveryChatFeedViewModel = discoveryChatFeedViewModel,
notifFeedViewModel = notifFeedViewModel,
userReactionsStatsModel = userReactionsStatsModel,
navController = navController,
accountViewModel = accountViewModel,
nextPage = startingPage
)
}
}
@@ -246,5 +255,16 @@ private fun WritePermissionButtons(
Route.Home.base -> NewNoteButton(accountViewModel, nav)
Route.Message.base -> ChannelFabColumn(accountViewModel, nav)
Route.Video.base -> NewImageButton(accountViewModel, nav)
Route.Community.base -> {
val communityId by remember(navEntryState.value) {
derivedStateOf {
navEntryState.value?.arguments?.getString("id")
}
}
communityId?.let {
NewCommunityNoteButton(it, accountViewModel, nav)
}
}
}
}

View File

@@ -25,7 +25,6 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
@@ -384,7 +383,7 @@ private fun RenderScreen(
CreateAndRenderTabs(baseUser, pagerState)
}
HorizontalPager(
pageCount = 8,
pageCount = 9,
state = pagerState,
modifier = pagerModifier
) { page ->
@@ -699,6 +698,7 @@ private fun ProfileActions(
accountViewModel: AccountViewModel
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val accountLocalUserState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountLocalUserState) { accountLocalUserState?.account } ?: return
@@ -735,18 +735,60 @@ private fun ProfileActions(
account.showUser(baseUser.pubkeyHex)
}
} else if (isLoggedInFollowingUser) {
UnfollowButton { scope.launch(Dispatchers.IO) { account.unfollow(baseUser) } }
UnfollowButton {
if (!accountViewModel.isWriteable()) {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_unfollow),
Toast.LENGTH_SHORT
)
.show()
}
} else {
scope.launch(Dispatchers.IO) {
account.unfollow(baseUser)
}
}
}
} else {
if (isUserFollowingLoggedIn) {
FollowButton(
{ scope.launch(Dispatchers.IO) { account.follow(baseUser) } },
R.string.follow_back
)
FollowButton(R.string.follow_back) {
if (!accountViewModel.isWriteable()) {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_follow),
Toast.LENGTH_SHORT
)
.show()
}
} else {
scope.launch(Dispatchers.IO) {
account.follow(baseUser)
}
}
}
} else {
FollowButton(
{ scope.launch(Dispatchers.IO) { account.follow(baseUser) } },
R.string.follow
)
FollowButton(R.string.follow) {
if (!accountViewModel.isWriteable()) {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_follow),
Toast.LENGTH_SHORT
)
.show()
}
} else {
scope.launch(Dispatchers.IO) {
account.follow(baseUser)
}
}
}
}
}
}
@@ -1106,7 +1148,7 @@ private fun LoadAndRenderBadge(badgeAwardEventHex: String, nav: (String) -> Unit
LaunchedEffect(key1 = badgeAwardEventHex) {
if (baseNote == null) {
launch(Dispatchers.IO) {
baseNote = LocalCache.getOrCreateNote(badgeAwardEventHex)
baseNote = LocalCache.checkGetOrCreateNote(badgeAwardEventHex)
}
}
}
@@ -1189,7 +1231,7 @@ private fun WatchAndRenderBadgeImage(
pictureModifier
.width(size)
.height(size)
.drawBehind { drawRect(bgColor) }
.background(bgColor)
}
)
} else {
@@ -1202,7 +1244,7 @@ private fun WatchAndRenderBadgeImage(
.width(size)
.height(size)
.clip(shape = CircleShape)
.drawBehind { drawRect(bgColor) }
.background(bgColor)
.run {
if (onClick != null) {
this.clickable(onClick = { onClick(eventId) })
@@ -1540,7 +1582,7 @@ fun UnfollowButton(onClick: () -> Unit) {
}
@Composable
fun FollowButton(onClick: () -> Unit, text: Int = R.string.follow) {
fun FollowButton(text: Int = R.string.follow, onClick: () -> Unit) {
Button(
modifier = Modifier.padding(start = 3.dp),
onClick = onClick,

View File

@@ -7,6 +7,7 @@ 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -56,7 +57,6 @@ import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
@@ -66,9 +66,6 @@ import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
@@ -86,7 +83,6 @@ import kotlinx.coroutines.channels.Channel as CoroutineChannel
@Composable
fun SearchScreen(
searchFeedViewModel: NostrGlobalFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@@ -97,33 +93,29 @@ fun SearchScreen(
)
)
SearchScreen(searchFeedViewModel, searchBarViewModel, accountViewModel, nav)
SearchScreen(searchBarViewModel, accountViewModel, nav)
}
@Composable
fun SearchScreen(
searchFeedViewModel: NostrGlobalFeedViewModel,
searchBarViewModel: SearchBarViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val lifeCycleOwner = LocalLifecycleOwner.current
WatchAccountForSearchScreen(searchFeedViewModel, accountViewModel)
WatchAccountForSearchScreen(accountViewModel)
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Global Start")
NostrGlobalDataSource.start()
println("Search Start")
NostrSearchEventOrUserDataSource.start()
searchFeedViewModel.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Global Stop")
println("Search Stop")
NostrSearchEventOrUserDataSource.clear()
NostrSearchEventOrUserDataSource.stop()
NostrGlobalDataSource.stop()
}
}
@@ -133,29 +125,19 @@ fun SearchScreen(
}
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
SearchBar(searchBarViewModel, accountViewModel, nav)
RefresheableFeedView(
searchFeedViewModel,
null,
scrollStateKey = ScrollStateKeys.GLOBAL_SCREEN,
accountViewModel = accountViewModel,
nav = nav
)
}
val listState = rememberLazyListState()
Column(Modifier.fillMaxSize()) {
SearchBar(searchBarViewModel, listState)
DisplaySearchResults(searchBarViewModel, listState, nav, accountViewModel)
}
}
@Composable
fun WatchAccountForSearchScreen(searchFeedViewModel: NostrGlobalFeedViewModel, accountViewModel: AccountViewModel) {
fun WatchAccountForSearchScreen(accountViewModel: AccountViewModel) {
LaunchedEffect(accountViewModel) {
launch(Dispatchers.IO) {
NostrGlobalDataSource.resetFilters()
NostrSearchEventOrUserDataSource.start()
searchFeedViewModel.invalidateData(true)
}
}
}
@@ -228,11 +210,9 @@ class SearchBarViewModel(val account: Account) : ViewModel() {
@Composable
private fun SearchBar(
searchBarViewModel: SearchBarViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
listState: LazyListState
) {
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
// Create a channel for processing search queries.
val searchTextChanges = remember {
@@ -283,8 +263,6 @@ private fun SearchBar(
searchTextChanges.trySend(it)
}
}
DisplaySearchResults(listState, searchBarViewModel, nav, accountViewModel)
}
@Composable
@@ -352,8 +330,8 @@ private fun SearchTextField(
@Composable
private fun DisplaySearchResults(
listState: LazyListState,
searchBarViewModel: SearchBarViewModel,
listState: LazyListState,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {

View File

@@ -23,6 +23,9 @@ val Shapes = Shapes(
val RippleRadius45dp = 45.dp // Ripple should be +10.dp over the component size
val BottomTopHeight = Modifier.height(50.dp)
val TabRowHeight = Modifier.height(40.dp)
val SmallBorder = RoundedCornerShape(7.dp)
val QuoteBorder = RoundedCornerShape(15.dp)
val ButtonBorder = RoundedCornerShape(20.dp)
@@ -51,6 +54,7 @@ val Size17dp = 17.dp
val Size18dp = 18.dp
val Size19dp = 19.dp
val Size20dp = 20.dp
val Size22dp = 22.dp
val Size24dp = 24.dp
val Size25dp = 25.dp
val Size30dp = 30.dp

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.theme
import android.app.Activity
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxWidth
@@ -15,7 +16,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.compositeOver
@@ -123,12 +123,10 @@ val LightImageModifier = Modifier
val DarkProfile35dpModifier = Modifier
.size(Size35dp)
.clip(shape = CircleShape)
.drawBehind { drawRect(DarkColorPalette.background) }
val LightProfile35dpModifier = Modifier
.fillMaxWidth()
.clip(shape = CircleShape)
.drawBehind { drawRect(LightColorPalette.background) }
val DarkReplyBorderModifier = Modifier
.padding(top = 5.dp)
@@ -189,9 +187,7 @@ val MarkDownStyleOnDark = RichTextDefaults.copy(
DarkSubtleBorder,
QuoteBorder
)
.drawBehind {
drawRect(DarkColorPalette.onSurface.copy(alpha = 0.05f))
}
.background(DarkColorPalette.onSurface.copy(alpha = 0.05f))
),
stringStyle = RichTextDefaults.stringStyle?.copy(
linkStyle = SpanStyle(
@@ -222,9 +218,7 @@ val MarkDownStyleOnLight = RichTextDefaults.copy(
LightSubtleBorder,
QuoteBorder
)
.drawBehind {
drawRect(LightColorPalette.onSurface.copy(alpha = 0.05f))
}
.background(DarkColorPalette.onSurface.copy(alpha = 0.05f))
),
stringStyle = RichTextDefaults.stringStyle?.copy(
linkStyle = SpanStyle(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

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