Compare commits

...

63 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
83 changed files with 3699 additions and 556 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 233
versionName "0.64.2"
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'

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,8 +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.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
@@ -66,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()
@@ -86,8 +93,13 @@ object ServiceManager {
NostrAccountDataSource.stop()
NostrHomeDataSource.stop()
NostrChannelDataSource.stop()
NostrChatroomDataSource.stop()
NostrChatroomListDataSource.stop()
NostrDiscoveryDataSource.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 {

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

@@ -47,12 +47,15 @@ object CachedRichTextParser {
}
}
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%]*)$")
// Group 1 = url, group 4 additional chars
val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
// val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
// Android9 seems to have an issue starting this regex.
val noProtocolUrlValidator = try {
Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]*[^\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}])*\\/?)(.*)")
} catch (e: Exception) {
Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
}
class RichTextParser() {
fun parseText(
@@ -67,8 +70,15 @@ class RichTextParser() {
null
} else if (isNumber(it.originalUrl)) {
null
} else if (it.originalUrl.contains("")) {
null
} else {
it.originalUrl
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
}
}
}
@@ -172,7 +182,8 @@ class RichTextParser() {
} else if (schemelessMatcher.find()) {
val url = schemelessMatcher.group(1) // url
val additionalChars = schemelessMatcher.group(4) // additional chars
if (url != null) {
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)
@@ -223,6 +234,12 @@ class RichTextParser() {
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

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

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

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

@@ -69,6 +69,7 @@ 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
@@ -78,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
@@ -89,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()
@@ -178,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)
}
@@ -497,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(
@@ -512,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,6 +18,7 @@ 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
@@ -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

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

@@ -112,7 +112,7 @@ 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)
}
@@ -292,7 +292,7 @@ fun RenderCustomEmoji(word: String, state: RichTextViewerState) {
}
@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 ->
@@ -340,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()
}
}
@@ -389,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()
}
@@ -426,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()
}

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

@@ -75,7 +75,6 @@ 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.Font17SP
import com.vitorpamplona.amethyst.ui.theme.hashVerified
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -612,7 +611,7 @@ 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)
)
}

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

@@ -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,57 +0,0 @@
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.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 isGlobal = account.defaultHomeFollowList == GLOBAL_FOLLOWS
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())) &&
(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
}
.toSet()
}
override fun limit() = 2
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
}

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,6 +49,7 @@ val bottomNavigationItems = listOf(
Route.Home,
Route.Message,
Route.Video,
Route.Discover,
Route.Notification
)
@@ -100,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,7 +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.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
@@ -22,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
@@ -38,10 +42,12 @@ import kotlinx.coroutines.launch
fun AppNavigation(
homeFeedViewModel: NostrHomeFeedViewModel,
repliesFeedViewModel: NostrHomeRepliesFeedViewModel,
liveActivitiesViewModel: NostrHomeFeedLiveActivitiesViewModel,
knownFeedViewModel: NostrChatroomListKnownFeedViewModel,
newFeedViewModel: NostrChatroomListNewFeedViewModel,
videoFeedViewModel: NostrVideoFeedViewModel,
discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel,
discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel,
discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel,
notifFeedViewModel: NotificationViewModel,
userReactionsStatsModel: UserReactionsViewModel,
@@ -67,7 +73,6 @@ fun AppNavigation(
HomeScreen(
homeFeedViewModel = homeFeedViewModel,
repliesFeedViewModel = repliesFeedViewModel,
liveActivitiesViewModel = liveActivitiesViewModel,
accountViewModel = accountViewModel,
nav = nav,
nip47 = nip47
@@ -106,6 +111,18 @@ 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(
@@ -159,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

@@ -76,6 +76,7 @@ import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
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
@@ -115,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)
}
@@ -178,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) {
@@ -190,7 +211,7 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel,
fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, nav: (String) -> Unit, content: @Composable (AccountViewModel) -> Unit) {
val coroutineScope = rememberCoroutineScope()
Column(modifier = Modifier.height(50.dp)) {
Column(modifier = BottomTopHeight) {
TopAppBar(
elevation = 0.dp,
backgroundColor = MaterialTheme.colors.surface,

View File

@@ -582,7 +582,7 @@ fun IconRowRelays(relayViewModel: RelayPoolViewModel, onClick: () -> Unit) {
Text(
modifier = Modifier.padding(start = 16.dp),
text = stringResource(id = R.string.relays),
text = stringResource(id = R.string.relay_setup),
fontSize = 18.sp
)

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
@@ -57,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,
@@ -97,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,
@@ -181,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

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

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

View File

@@ -88,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
@@ -102,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
@@ -145,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
@@ -152,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
@@ -169,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
@@ -431,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)
@@ -455,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,
@@ -539,7 +831,7 @@ private fun CheckNewAndRenderNote(
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun ClickableNote(
fun ClickableNote(
baseNote: Note,
modifier: Modifier,
backgroundColor: MutableState<Color>,
@@ -550,7 +842,7 @@ private fun ClickableNote(
) {
val scope = rememberCoroutineScope()
val updatedModifier = remember(backgroundColor.value) {
val updatedModifier = remember(baseNote, backgroundColor.value) {
modifier
.combinedClickable(
onClick = {
@@ -588,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,
@@ -712,7 +1005,7 @@ private fun RenderNoteRow(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
when (remember { baseNote.event }) {
when (baseNote.event) {
is AppDefinitionEvent -> {
RenderAppDefinition(baseNote, accountViewModel, nav)
}
@@ -794,6 +1087,17 @@ private fun RenderNoteRow(
)
}
is CommunityPostApprovalEvent -> {
RenderPostApproval(
baseNote,
makeItShort,
canPreview,
backgroundColor,
accountViewModel,
nav
)
}
else -> {
RenderTextEvent(
baseNote,
@@ -820,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}"
}
@@ -848,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
@@ -1522,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,
@@ -1715,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)
@@ -1786,7 +2162,7 @@ private fun ReplyNoteComposition(
}
@Composable
private fun SecondUserInfoRow(
fun SecondUserInfoRow(
note: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -1810,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)
@@ -1833,6 +2215,8 @@ private fun FirstUserInfoRow(
if (isRepost) {
BoostedMark()
} else if (isCommunityPost) {
DisplayFollowingCommunityInPost(baseNote, accountViewModel, nav)
} else {
DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav)
}
@@ -1855,7 +2239,7 @@ private fun BoostedMark() {
}
@Composable
private fun MoreOptionsButton(
fun MoreOptionsButton(
baseNote: Note,
accountViewModel: AccountViewModel
) {
@@ -1883,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,
@@ -2141,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,
@@ -2191,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(
@@ -2662,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) }
@@ -2691,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)
}
}
}
@@ -2745,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,
@@ -2842,7 +3271,7 @@ private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, accountView
}
@Composable
private fun CreateImageHeader(
fun CreateImageHeader(
note: Note,
accountViewModel: AccountViewModel
) {
@@ -3220,10 +3649,10 @@ fun FollowingIcon(iconSize: Dp) {
}
Icon(
painter = painterResource(R.drawable.ic_verified),
stringResource(id = R.string.following),
painter = painterResource(R.drawable.verified_follow_shield),
contentDescription = stringResource(id = R.string.following),
modifier = modifier,
tint = Following
tint = Color.Unspecified
)
}

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

@@ -308,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
)
@@ -558,12 +560,17 @@ 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)
@Composable
private fun SlidingAnimation(baseCount: Int, textColor: Color) {
private fun SlidingAnimationCount(baseCount: Int, textColor: Color) {
AnimatedContent<Int>(
targetState = baseCount,
transitionSpec = AnimatedContentScope<Int>::transitionSpec
@@ -597,9 +604,9 @@ private fun TextCount(count: Int, textColor: Color) {
@Composable
@OptIn(ExperimentalAnimationApi::class)
private fun SlidingAnimation(amount: String, textColor: Color) {
private fun SlidingAnimationAmount(amount: MutableState<String>, textColor: Color) {
AnimatedContent(
targetState = amount,
targetState = amount.value,
transitionSpec = AnimatedContentScope<String>::transitionSpec
) { count ->
Text(
@@ -694,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)
@@ -771,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,
@@ -841,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(
@@ -1072,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),
@@ -1110,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

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

@@ -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) {
@@ -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,10 +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.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
@@ -65,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 {
@@ -88,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 {
@@ -120,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 {
@@ -223,7 +257,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
fun refreshFromOldState(newItems: Set<Note>) {
val oldNotesState = _feedContent.value
if (localFilter is AdditiveFeedFilter && lastFeedKey != localFilter.feedKey()) {
if (localFilter is AdditiveFeedFilter && lastFeedKey == localFilter.feedKey()) {
if (oldNotesState is FeedState.Loaded) {
val newList = localFilter.updateListWith(oldNotesState.feed.value, newItems.toSet()).toImmutableList()
if (!equalImmutableLists(newList, oldNotesState.feed.value)) {

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

@@ -44,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
@@ -75,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
@@ -82,33 +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(
@@ -516,6 +541,7 @@ fun ChannelHeader(
channelHex: String,
showVideo: Boolean,
showBottomDiviser: Boolean,
showFlag: Boolean = true,
modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -535,6 +561,7 @@ fun ChannelHeader(
it,
showVideo,
showBottomDiviser,
showFlag,
modifier,
accountViewModel,
nav
@@ -547,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) {
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)
.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 = 1,
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)
}
}
@@ -668,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()
@@ -693,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
}
}
@@ -716,7 +942,7 @@ private fun LiveChannelActionOptions(
}
note?.let {
if (isLive) {
if (showFlag && isLive) {
LiveFlag()
Spacer(modifier = StdHorzSpacer)
}
@@ -773,9 +999,12 @@ fun OfflineFlag() {
}
@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 {
@@ -853,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 = {
@@ -871,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

@@ -18,6 +18,7 @@ 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
@@ -29,61 +30,77 @@ 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
)
}
}
}
@@ -93,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

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
@@ -43,7 +44,9 @@ 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.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
@@ -88,16 +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 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)
@@ -136,6 +149,11 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
Route.Video.base -> {
videoFeedViewModel.sendToTop()
}
Route.Discover.base -> {
discoveryLiveFeedViewModel.sendToTop()
discoveryCommunityFeedViewModel.sendToTop()
discoveryChatFeedViewModel.sendToTop()
}
Route.Notification.base -> {
notifFeedViewModel.invalidateDataAndSendToTop()
}
@@ -178,17 +196,19 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
) {
Column(modifier = Modifier.padding(bottom = it.calculateBottomPadding())) {
AppNavigation(
homeFeedViewModel,
repliesFeedViewModel,
liveActivitiesViewModel,
knownFeedViewModel,
newFeedViewModel,
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
)
}
}
@@ -235,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

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

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

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#9c9c9c"
android:pathData="M142.11,817.89Q77.63,753.42 40.02,666.86 2.41,580.29 2.41,480q0,-100.29 37.61,-186.86 37.61,-86.56 102.08,-151.04l51.34,51.34q-54.92,54.92 -87.16,128.35 -32.24,73.43 -32.24,158.2 0,84.77 31.64,157.6 31.64,72.83 87.76,128.95zM277.03,682.97q-38.21,-38.21 -60.89,-90.14 -22.69,-51.94 -22.69,-112.83 0,-60.89 22.69,-112.83 22.69,-51.94 60.89,-90.14l51.34,51.34q-28.66,28.66 -45.97,67.46 -17.31,38.8 -17.31,84.17 0,45.37 16.72,83.58 16.72,38.21 46.56,68.06zM480,575.52q-39.4,0 -67.46,-28.06 -28.06,-28.06 -28.06,-67.46 0,-39.4 28.06,-67.46 28.06,-28.06 67.46,-28.06 39.4,0 67.46,28.06 28.06,28.06 28.06,67.46 0,39.4 -28.06,67.46 -28.06,28.06 -67.46,28.06zM682.97,682.97 L631.63,631.63q28.66,-28.66 45.97,-67.46 17.31,-38.8 17.31,-84.17 0,-45.37 -16.72,-83.58 -16.72,-38.21 -46.56,-68.06l51.34,-51.34q38.21,38.21 60.89,90.14 22.69,51.94 22.69,112.83 0,59.7 -22.69,111.64 -22.69,51.94 -60.89,91.34zM817.89,817.89 L766.55,766.55q54.92,-54.92 87.16,-128.35 32.24,-73.43 32.24,-158.2 0,-84.77 -31.64,-157.6 -31.64,-72.83 -87.76,-128.95l51.34,-51.34q64.47,65.67 102.08,152.23 37.61,86.56 37.61,185.66 0,99.1 -37.61,185.66 -37.61,86.56 -102.08,152.23z"
android:strokeWidth="1.19396"/>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -33,6 +33,8 @@
<string name="login_with_a_private_key_to_like_posts">Přihlásit se pomocí soukromého klíče pro označení příspěvků jako líbivé</string>
<string name="no_zap_amount_setup_long_press_to_change">Není nastaveno množství Zap. Dlouhým stisknutím změňte</string>
<string name="login_with_a_private_key_to_be_able_to_send_zaps">Přihlaste se pomocí soukromého klíče, abyste mohli odesílat Zapy</string>
<string name="login_with_a_private_key_to_be_able_to_follow">Přihlaste se pomocí soukromého klíče, abyste mohli následovat</string>
<string name="login_with_a_private_key_to_be_able_to_unfollow">Přihlaste se pomocí soukromého klíče, abyste mohli zrušit sledování</string>
<string name="zaps">Zapy</string>
<string name="view_count">Počet zobrazení</string>
<string name="boost">Boost</string>
@@ -442,4 +444,7 @@
<string name="live_stream_is_offline">Živé vysílání je offline</string>
<string name="live_stream_has_ended">Živé vysílání ukončeno</string>
<string name="are_you_sure_you_want_to_log_out">Odhlášení vymaže všechny vaše místní informace. Ujistěte se, že máte zálohované své privátní klíče, abyste se vyhnuli ztrátě účtu. Chcete pokračovat?</string>
<string name="followed_tags">Sledované značky</string>
<string name="relay_setup">Rele</string>
</resources>

View File

@@ -33,6 +33,8 @@
<string name="login_with_a_private_key_to_like_posts">Melden Sie sich mit einem privaten Schlüssel an, um Beiträge zu liken</string>
<string name="no_zap_amount_setup_long_press_to_change">Keine ZAP-Menge festgelegt. Drücken Sie lange, um zu ändern</string>
<string name="login_with_a_private_key_to_be_able_to_send_zaps">Melden Sie sich mit einem privaten Schlüssel an, um Zaps senden zu können</string>
<string name="login_with_a_private_key_to_be_able_to_follow">Melden Sie sich mit einem privaten Schlüssel an, um zu folgen</string>
<string name="login_with_a_private_key_to_be_able_to_unfollow">Melden Sie sich mit einem privaten Schlüssel an, um nicht mehr zu folgen</string>
<string name="zaps">Zaps</string>
<string name="view_count">Aufrufe</string>
<string name="boost">Boost</string>
@@ -451,4 +453,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="live_stream_is_offline">Livestream ist offline</string>
<string name="live_stream_has_ended">Livestream beendet</string>
<string name="are_you_sure_you_want_to_log_out">Das Abmelden löscht alle Ihre lokalen Informationen. Stellen Sie sicher, dass Sie Ihre privaten Schlüssel gesichert haben, um einen Kontoverlust zu vermeiden. Möchten Sie fortfahren?</string>
<string name="followed_tags">Gefolgte Tags</string>
<string name="relay_setup">Relais</string>
</resources>

View File

@@ -32,6 +32,8 @@
<string name="login_with_a_private_key_to_like_posts">Logga in med en privat nyckel för att gilla inlägg</string>
<string name="no_zap_amount_setup_long_press_to_change">Ingen konfiguration av Zap-belopp. Tryck länge för att ändra</string>
<string name="login_with_a_private_key_to_be_able_to_send_zaps">Logga in med en privat nyckel för att kunna skicka Zaps</string>
<string name="login_with_a_private_key_to_be_able_to_follow">Logga in med en privat nyckel för att kunna följa</string>
<string name="login_with_a_private_key_to_be_able_to_unfollow">Logga in med en privat nyckel för att kunna sluta följa</string>
<string name="zaps">Zaps</string>
<string name="view_count">Antal visningar</string>
<string name="boost">Boost</string>
@@ -439,6 +441,9 @@
<string name="live_stream_is_offline">Direktsändning är offline</string>
<string name="live_stream_has_ended">Direktsändning avslutad</string>
<string name="are_you_sure_you_want_to_log_out">Att logga ut raderar all din lokala information. Se till att ha dina privata nycklar säkerhetskopierade för att undvika att förlora ditt konto. Vill du fortsätta?</string>
<string name="followed_tags">Följda taggar</string>
<string name="relay_setup">Reläer</string>
</resources>

View File

@@ -23,7 +23,7 @@
<string name="copy_note_id">குறிப்பின் IDஐ நகலெடுக்கவும்</string>
<string name="broadcast">ஒளிபரப்பு</string>
<string name="request_deletion">நீக்குவதற்கு கோரிக்கை செய்</string>
<string name="block_hide_user">பயனரை முடக்கு + மறை</string>
<string name="block_hide_user">யூஸரை முடக்கு + மறை</string>
<string name="report_spam_scam">ஸ்பேம் / மோசடி என்று புகாரளிக்கவும்</string>
<string name="report_impersonation">ஆள்மாறாட்டத்தைப் புகாரளிக்கவும்</string>
<string name="report_explicit_content">வெளிப்படையான உள்ளடக்கத்தைப் புகாரளிக்கவும்</string>
@@ -83,8 +83,8 @@
<string name="add_a_relay">ஒரு ரிலேவை சேர்</string>
<string name="display_name">காட்சிப் பெயர்</string>
<string name="my_display_name">எனது காட்சிப் பெயர்</string>
<string name="username">பயனர் பெயர்</string>
<string name="my_username">எனது பயனர் பெயர்</string>
<string name="username">யூஸர் பெயர்</string>
<string name="my_username">எனது யூஸர் பெயர்</string>
<string name="about_me">என்னைப் பற்றி</string>
<string name="avatar_url">சுயவிவரப் படம் URL</string>
<string name="banner_url">பேனர் URL</string>
@@ -95,7 +95,7 @@
<string name="failed_to_save_the_image">படத்தை சேமிப்பது தோல்வியடைந்தது</string>
<string name="upload_image">படத்தை பதிவேற்று</string>
<string name="uploading">பதிவேற்றம்...</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">பயனர் ஸாட்கள் பெறுவதற்கு லைட்னிங் முகவரி அமைக்கவில்லை</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">யூஸர் ஸாட்கள் பெறுவதற்கு லைட்னிங் முகவரி அமைக்கவில்லை</string>
<string name="reply_here">"இங்கு பதிலளிக்கவும்.. "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">குறிப்பு ID ஐ கிளிப்போர்டில் பிரதி எடுக்கும்</string>
<string name="copy_channel_id_note_to_the_clipboard">அலைவரிசை ID ஐ கிளிப்போர்டில் பிரதி எடு</string>
@@ -103,7 +103,7 @@
<string name="join">சேர்</string>
<string name="known">தெரிந்த</string>
<string name="new_requests">புதிய கோரிக்கைகள்</string>
<string name="blocked_users">முடக்கப்பட்ட பயனர்கள்</string>
<string name="blocked_users">முடக்கப்பட்ட யூஸர்கள்</string>
<string name="new_threads">புதிய நூல்கள்</string>
<string name="conversations">உரையாடல்கள்</string>
<string name="notes">குறிப்புகள்</string>
@@ -119,13 +119,13 @@
<string name="copies_the_public_key_to_the_clipboard_for_sharing">பகிர்வுக்காக கிளிப்போர்டுக்கு பொதுசாவியை நகலெடுக்கிறது</string>
<string name="copy_public_key_npub_to_the_clipboard">கிளிப்போர்டுக்கு பொதுசாவியை (NPUB) நகலெடுக்கவும்</string>
<string name="send_a_direct_message">நேரடி செய்தியை அனுப்பவும்</string>
<string name="edits_the_user_s_metadata">பயனரின்தகவலை திருத்துகிறது</string>
<string name="edits_the_user_s_metadata">யூஸரின்தகவலை திருத்துகிறது</string>
<string name="follow">பின்தொடர்</string>
<string name="follow_back">பதிலுக்கு பின்தொடரவும்</string>
<string name="unblock">தடையை நீக்கு</string>
<string name="copy_user_id">பயனர் IDயை நகலெடுக்கவும்</string>
<string name="unblock_user">பயனரைத் தடைசெய்க</string>
<string name="npub_hex_username">"npub, hex, பயனர்பெயர் "</string>
<string name="copy_user_id">யூஸர் IDயை நகலெடுக்கவும்</string>
<string name="unblock_user">யூஸர் தடையை நீக்க</string>
<string name="npub_hex_username">"npub, hex, யூஸர்பெயர் "</string>
<string name="clear">அழி</string>
<string name="app_logo">பயன்பாட்டு லோகோ</string>
<string name="nsec_npub_hex_private_key">nsec / npub / hex தனிப்பட்ட சாவி</string>
@@ -226,8 +226,8 @@
<string name="report_dialog_impersonation">தீங்கிழைக்கும் ஆள்மாறாட்டம்</string>
<string name="report_dialog_nudity">நிர்வாணம் அல்லது கிராஃபிக் உள்ளடக்கம்</string>
<string name="report_dialog_illegal">சட்டவிரோத நடத்தை</string>
<string name="report_dialog_blocking_a_user">ஒரு பயனரைத் தடுப்பது உங்கள் பயன்பாட்டில் அவர்களின் உள்ளடக்கத்தை மறைக்கும். நீங்கள் தடுக்கும் நபர்கள் உட்பட உங்கள் குறிப்புகள் இன்னும் பகிரங்கமாகக் காணப்படுகின்றன. தடுக்கப்பட்ட பயனர்கள் பாதுகாப்பு வடிப்பான்கள் திரையில் பட்டியலிடப்பட்டுள்ளனர்.</string>
<string name="report_dialog_block_hide_user_btn">பயனரைத் தடுத்து மறைக்கவும்</string>
<string name="report_dialog_blocking_a_user">ஒரு யூஸரைத் தடுப்பது உங்கள் பயன்பாட்டில் அவர்களின் உள்ளடக்கத்தை மறைக்கும். நீங்கள் தடுக்கும் நபர்கள் உட்பட உங்கள் குறிப்புகள் இன்னும் பகிரங்கமாகக் காணப்படுகின்றன. தடுக்கப்பட்ட யூஸர்கள் பாதுகாப்பு வடிப்பான்கள் திரையில் பட்டியலிடப்பட்டுள்ளனர்.</string>
<string name="report_dialog_block_hide_user_btn">யூஸரைத் தடுத்து மறைக்கவும்</string>
<string name="report_dialog_report_btn">தகாதது என பதிவுசெய்</string>
<string name="report_dialog_reminder_public">இடுகையிடப்பட்ட அனைத்து அறிக்கைகளும் பகிரங்கமாக தெரியும்.</string>
<string name="report_dialog_additional_reason_placeholder">உங்கள் அறிக்கையைப் பற்றிய கூடுதல் சூழலை விருப்பமாக வழங்கவும்…</string>
@@ -267,7 +267,7 @@
<string name="poll_closing_time_days">நாட்களில்</string>
<string name="poll_is_closed">புதிய வாக்குகளுக்கு வாக்கெடுப்பு மூடப்பட்டுள்ளது</string>
<string name="poll_zap_amount">ஜாப் தொகை</string>
<string name="one_vote_per_user_on_atomic_votes">இந்த வகை வாக்கெடுப்பில் ஒரு பயனருக்கு ஒரு வாக்கு மட்டுமே அனுமதிக்கப்படுகிறது</string>
<string name="one_vote_per_user_on_atomic_votes">இந்த வகை வாக்கெடுப்பில் ஒரு யூஸருக்கு ஒரு வாக்கு மட்டுமே அனுமதிக்கப்படுகிறது</string>
<string name="looking_for_event">"நிகழ்வு %1$s ஐத் தேடி"</string>
<string name="custom_zaps_add_a_message">ஒரு பொது செய்தியைச் சேர்க்கவும்</string>
<string name="custom_zaps_add_a_message_private">ஒரு தனிப்பட்ட செய்தியைச் சேர்க்கவும்</string>
@@ -283,4 +283,163 @@
<string name="add_content">உருவாக்கி சேர்க்கவும்</string>
<string name="content_description">உள்ளடக்கங்களின் விளக்கம்</string>
<string name="content_description_example">சூரியன் மறையும் வேளையில் வெள்ளை மண் நிறைந்த கடற்கரையில் ஒரு நீலப் படகு</string>
<string name="zap_type">ஜாப் டைப்</string>
<string name="zap_type_explainer">அனைத்து வகைகளுக்கான ஜாப் டைப்</string>
<string name="zap_type_public">பொது</string>
<string name="zap_type_public_explainer">அனைவரும் ஜாப் பரிவர்த்தனையையும் செய்தியையும் பார்க்கமுடியும்</string>
<string name="zap_type_private">தனிப்பட்ட</string>
<string name="zap_type_private_explainer">பெறுபவரும் அனுப்புபவரும் மட்டும் ஒருவரை ஒருவர் மற்றும் ஜாப் செய்தியை பார்க்கமுடியும்</string>
<string name="zap_type_anonymous">பெயர் ரகசியமாக</string>
<string name="zap_type_anonymous_explainer">பெறுபவருக்கும் மற்றவருக்கும் ஜாப் அனுப்புபவர் யார் என்று தெரியாது</string>
<string name="zap_type_nonzap">அல்லாத-ஜாப்</string>
<string name="zap_type_nonzap_explainer">நாஸ்டரில் எந்த சுவடும் இன்றி, லைட்நிங்கில் மட்டும் உள்ளது</string>
<string name="file_server">ஃபைல் சர்வர்</string>
<string name="zap_forward_lnAddress">LN முகவரி அல்லது @யூஸர்</string>
<string name="upload_server_imgur">imgur.com - நம்பகமானது</string>
<string name="upload_server_imgur_explainer">imgur ஃபைலை மாற்றமுடியும்</string>
<string name="upload_server_nostrimg">nostrimg.com - நம்பகமானது</string>
<string name="upload_server_nostrimg_explainer">NostrImg ஃபைலை மாற்றமுடியும்</string>
<string name="upload_server_nostrbuild">nostr.build - நம்பகமானது</string>
<string name="upload_server_nostrbuild_explainer">nostr.build ஃபைலை மாற்றமுடியும்</string>
<string name="upload_server_nostrfilesdev">nostrfiles.dev - நம்பகமானது</string>
<string name="upload_server_nostrfilesdev_explainer">nostrfiles.dev ஃபைலை மாற்றமுடியும்</string>
<string name="upload_server_nostrcheckme">nostrcheck.me - நம்பகமானது</string>
<string name="upload_server_nostrcheckme_explainer">nostrcheck.me ஃபைலை மாற்றமுடியும்</string>
<string name="upload_server_imgur_nip94">சரிபார்க்கக்கூடிய Imgur (NIP-94)</string>
<string name="upload_server_imgur_nip94_explainer">Imgur ஃபைலை மாற்றியிருக்கிறதா என்று சரிபார்க்கப்படும். புதிய NIP: பிற கிளயண்ட்டுகளில் இதை பார்க்க முடியாமல் இருக்கலாம்.</string>
<string name="upload_server_nostrimg_nip94">சரிபார்க்கக்கூடிய NostrImg (NIP-94)</string>
<string name="upload_server_nostrimg_nip94_explainer">NostrImg ஃபைலை மாற்றியிருக்கிறதா என்று சரிபார்க்கப்படும். புதிய NIP: பிற கிளயண்ட்டுகளில் இதை பார்க்க முடியாமல் இருக்கலாம்.</string>
<string name="upload_server_nostrbuild_nip94">சரிபார்க்கக்கூடிய Nostr.build (NIP-94)</string>
<string name="upload_server_nostrbuild_nip94_explainer">Nostr.build ஃபைலை மாற்றியிருக்கிறதா என்று சரிபார்க்கப்படும். புதிய NIP: பிற கிளயண்ட்டுகளில் இதை பார்க்க முடியாமல் இருக்கலாம்.</string>
<string name="upload_server_nostrfilesdev_nip94">சரிபார்க்கக்கூடிய Nostrfiles.dev (NIP-94)</string>
<string name="upload_server_nostrfilesdev_nip94_explainer">Nostrfiles.dev ஃபைலை மாற்றியிருக்கிறதா என்று சரிபார்க்கப்படும். புதிய NIP: பிற கிளயண்ட்டுகளில் இதை பார்க்க முடியாமல் இருக்கலாம்.</string>
<string name="upload_server_nostrcheckme_nip94">சரிபார்க்கக்கூடிய Nostrcheck.me (NIP-94)</string>
<string name="upload_server_nostrcheckme_nip94_explainer">Nostrcheck.me ஃபைலை மாற்றியிருக்கிறதா என்று சரிபார்க்கப்படும். புதிய NIP: பிற கிளயண்ட்டுகளில் இதை பார்க்க முடியாமல் இருக்கலாம்.</string>
<string name="upload_server_relays_nip95">உங்கள் ரிலே (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">ஃபைல்ஸ் உங்கள் ரிலேக்களில் ஹோஸ்ட் செய்யப்படும். புதிய NIP: ரிலேயில் இச்சேவை உள்ளதா என சரிபார்க்கவும்</string>
<string name="connect_via_tor_short">Tor/Orbot அமைப்பு முறை</string>
<string name="connect_via_tor">Orbot அமைப்பு மூலம் இணைக்கவும்</string>
<string name="do_you_really_want_to_disable_tor_title">Tor/Orbot இணைப்பை துண்டிக்கவும்?</string>
<string name="do_you_really_want_to_disable_tor_text">உங்கள் டேட்டா உடனே வழக்கமான நெட்வொர்க்கிற்கு மாற்றப்படும்</string>
<string name="yes">ஆம்</string>
<string name="no">இல்லை</string>
<string name="follow_list_selection">பின்பற்றப் படுவோர் பட்டியல்</string>
<string name="follow_list_kind3follows">எல்லா பின்பற்றப் படுவோர்</string>
<string name="follow_list_global">முழுதளாவிய</string>
<string name="connect_through_your_orbot_setup_markdown">
## Tor மூலம் Orbot உடன் இணைக்க
\n\n1. [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android) இன்ஸ்டால் செய்க
\n2. Orbot-ஐ தொடங்குங்கள்
\n3. Orbot-இல் Socks போர்ட்டை செக் செய்க. இயல்புநிலையில் அது 9050
\n4. வேண்டுமானால் Orbot-இல் போர்ட்டை மாற்றுங்கள்
\n5. இந்த திரையில் Socks போர்டை கட்டமைக்க
\n6. ஆக்டிவேட் பொத்தானை அழுத்தி Orbot-ஐ proxy-ஆக பயன்படுத்துங்கள்
</string>
<string name="orbot_socks_port">Orbot Socks போர்ட்</string>
<string name="invalid_port_number">செல்லாத போர்ட் நம்பர்</string>
<string name="use_orbot">Orbot-ஐ பயன்படுத்துங்கள்</string>
<string name="disconnect_from_your_orbot_setup">Orbot இணைப்பது துண்டிக்க</string>
<string name="app_notification_dms_channel_name">தனிப்பட்ட செய்திகள்</string>
<string name="app_notification_dms_channel_description">தனிப்பட்ட செய்தி வருகையில் தெரிவிக்கவும்</string>
<string name="app_notification_zaps_channel_name">ஜாப் பெறப்பட்டது</string>
<string name="app_notification_zaps_channel_description">யாரேனும் உங்களுக்கு ஜாப் அனுப்பும்போது தெரிவிக்கவும்</string>
<string name="app_notification_zaps_channel_message">%1$s ஸாட்ஸ்</string>
<string name="app_notification_zaps_channel_message_from">%1$s -இடமிருந்து</string>
<string name="app_notification_zaps_channel_message_for">%1$s -க்கு</string>
<string name="reply_notify">தெரிவிக்க</string>
<string name="channel_list_join_conversation">உரையாடலில் இணைக</string>
<string name="channel_list_user_or_group_id">யூஸர் அல்லது குழுவின் ID</string>
<string name="channel_list_user_or_group_id_demo">npub, nevent அல்லது hex</string>
<string name="channel_list_create_channel">உருவாக்குக</string>
<string name="channel_list_join_channel">இணைக</string>
<string name="today">இன்று</string>
<string name="content_warning">உள்ளடக்க எச்சரிக்கை</string>
<string name="content_warning_explanation">இந்த குறிப்பின் உள்ளடக்கம் சிலருக்கு அவமதிப்பாகவோ அருவெறுப்பாகவோ இருக்கலாம்</string>
<string name="content_warning_hide_all_sensitive_content">எப்போதும் உணர்ச்சிபூர்வமான உள்ளடக்கத்தை மறைக்க</string>
<string name="content_warning_show_all_sensitive_content">எப்போதும் உணர்ச்சிபூர்வமான உள்ளடக்கத்தை காண்பிக்க</string>
<string name="content_warning_see_warnings">எப்போதும் உள்ளடக்க எச்சரிக்கையை காண்பிக்க</string>
<string name="recommended_apps">பரிந்துரைகள்</string>
<string name="filter_spam_from_strangers">தெரியாதவர்களிடமிருந்து ஸ்பேம்-மை வடிகட்டுக</string>
<string name="warn_when_posts_have_reports_from_your_follows">குறிப்புகளில் நீங்கள் பின்பற்றுபவரின் புகார் இருந்தால் எச்சரிக்க</string>
<string name="new_reaction_symbol">புதிய ரியாக்ஷன் குறி</string>
<string name="no_reaction_type_setup_long_press_to_change">எந்த ரியாக்ஷனும் தேர்ந்தெடுக்கவில்லை. மாற்ற நீண்ட நேரம் அழுத்தவும்</string>
<string name="zapraiser">ஜாப்-திரட்டுதல்</string>
<string name="zapraiser_explainer">இந்த குறிப்புடன் திரட்டுவதற்கான ஸாட்ஸ் இலக்குத் தொகை சேர்க்கப்படும். இப்பயன்பாட்டுள்ள கிளயண்ட்டுகள் ஜாப்-களை ஊக்குவிக்க ப்ரோக்ரேஸ் பார் மூலம் காண்பிக்கலாம்</string>
<string name="zapraiser_target_amount_in_sats">ஸாட்ஸ் இலக்குத் தொகை</string>
<string name="sats_to_complete">ஜாப்-திரட்டுதல் %1$s. இலக்கை அடைய %2$s</string>
<string name="read_from_relay">படிப்பதற்கான ரிலே</string>
<string name="write_to_relay">போஸ்ட் செய்வதற்கான ரிலே</string>
<string name="an_error_ocurred_trying_to_get_relay_information">%1$s ரிலே விவரங்கள் பெறுகையில் பிழை</string>
<string name="owner">உரிமையாளர்</string>
<string name="version">பதிப்பு</string>
<string name="software">மென்பொருள்</string>
<string name="contact">தொடர்புக்கு</string>
<string name="supports">ஆதரிக்கப்படும் NIPகள்</string>
<string name="admission_fees">அனுமதி கட்டணம்</string>
<string name="payments_url">கட்டண URL</string>
<string name="limitations">வரம்புகள்</string>
<string name="countries">நாடுகள்</string>
<string name="languages">மொழிகள்</string>
<string name="tags">டாகுகள்</string>
<string name="posting_policy">போஸ்ட் செய்வதற்கான கொள்கை</string>
<string name="message_length">குறிப்பு நீளம்</string>
<string name="subscriptions">சப்ஸ்க்ரிப்ஷன்கள்</string>
<string name="filters">வடிப்பான்கள்</string>
<string name="subscription_id_length">சப்ஸ்க்ரிப்ஷன் ID நீளம்</string>
<string name="minimum_prefix">குறைந்தபட்ச முன்னீடு</string>
<string name="maximum_event_tags">அதிகபட்ச நிகழ்வு டாகுகள்</string>
<string name="content_length">உள்ளடக்க நீளம்</string>
<string name="minimum_pow">குறைந்தபட்ச PoW</string>
<string name="auth">சான்றளிப்பு</string>
<string name="payment">கட்டணம்</string>
<string name="cashu">Cashu டோக்கன்</string>
<string name="cashu_redeem">மீட்க</string>
<string name="live_stream_live_tag">நேரலை</string>
<string name="live_stream_offline_tag">ஆஃப்லைன்</string>
<string name="live_stream_ended_tag">முடிந்தது</string>
<string name="live_stream_planned_tag">வரவிருக்கிறது</string>
<string name="live_stream_is_offline">லைவ்ஸ்ட்ரீம் ஆஃப்லைனில் உள்ளது</string>
<string name="live_stream_has_ended">லைவ்ஸ்ட்ரீம் முடிந்துவிட்டது</string>
<string name="are_you_sure_you_want_to_log_out">வெளியேறு உங்கள் அனைத்து லோக்கல் விவரங்களையும் நீக்கிவிடும். வெளியேறும் முன் உங்கள் அக்கவுண்ட் தொலையாமல் இருக்க ரகசியசாவியை பத்திரமாக பிரதி செய்து சேமிக்கப்பட்டுள்ளதா என்று சரிபார்க்கவும். வெளியேற்றம் தொடர வேண்டுமா?</string>
<string name="followed_tags">பின்பற்றப்படும் டாகுகள்</string>
<string name="relay_setup">ரிலேகள்</string>
</resources>

View File

@@ -472,4 +472,12 @@
<string name="followed_tags">Followed Tags</string>
<string name="relay_setup">Relays</string>
<string name="discover_live">Live</string>
<string name="discover_community">Community</string>
<string name="discover_chat">Chats</string>
<string name="community_approved_posts">Approved Posts</string>
<string name="groups_no_descriptor">This group does not have a description or rules. Talk to the owner to add one</string>
<string name="community_no_descriptor">This community does not have a description. Talk to the owner to add one</string>
</resources>

View File

@@ -142,7 +142,7 @@ object LanguageTranslatorService {
val counter = 0
return urlsInText.filter { !it.originalUrl.contains("") || !it.originalUrl.contains("") }.associate {
return urlsInText.filter { !it.originalUrl.contains("") && !it.originalUrl.contains("") }.associate {
"Amethysturlindexer$counter" to it.originalUrl
}
}

View File

@@ -60,7 +60,7 @@ fun TranslatableRichTextViewer(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var translatedTextState by remember {
var translatedTextState by remember(content) {
mutableStateOf(TranslationConfig(content, null, null, false))
}

View File

@@ -11,12 +11,12 @@ class CharsetTest {
}
@Test
fun testUTF16Char() {
fun testUTF16JoinChar() {
Assert.assertEquals("\uD83C\uDF48", "\uD83C\uDF48Hi".firstFullChar())
}
@Test
fun testUTF32Char() {
fun testUTF32JoinChar() {
Assert.assertEquals("\uD83E\uDDD1\uD83C\uDFFE", "\uD83E\uDDD1\uD83C\uDFFEHi".firstFullChar())
}
@@ -34,4 +34,24 @@ class CharsetTest {
fun testSpecialChars() {
Assert.assertEquals("=", "=x".firstFullChar())
}
@Test
fun test5CharEmoji() {
Assert.assertEquals("\uD83D\uDC68\u200D\uD83D\uDCBB", "\uD83D\uDC68\u200D\uD83D\uDCBBadsfasdf".firstFullChar())
}
@Test
fun testFamily() {
Assert.assertEquals("\uD83D\uDC68\u200d\uD83D\uDC69\u200d\uD83D\uDC67\u200d\uD83D\uDC67", "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC67adsfasdf".firstFullChar())
}
@Test
fun testTeacher() {
Assert.assertEquals("\uD83E\uDDD1\uD83C\uDFFF\u200D\uD83C\uDFEB", "\uD83E\uDDD1\uD83C\uDFFF\u200D\uD83C\uDFEBasdf".firstFullChar())
}
@Test
fun testVariation() {
Assert.assertEquals("\uD83D\uDC68\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68", "\uD83D\uDC68\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68ddd".firstFullChar())
}
}

View File

@@ -7,7 +7,7 @@ buildscript {
room_version = "2.4.3"
accompanist_version = '0.30.0'
coil_version = '2.4.0'
vico_version = '1.6.6'
vico_version = '1.7.0'
}
dependencies {
classpath 'com.google.gms:google-services:4.3.15'