mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-05 14:24:38 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bee5c1bfa6 | ||
|
|
c93a6dffc4 | ||
|
|
60dadc0088 | ||
|
|
a26c7e3d97 | ||
|
|
9e2b2cedd6 | ||
|
|
636d59659d | ||
|
|
63e8391893 | ||
|
|
2f354e4b81 | ||
|
|
b3a369d91b | ||
|
|
4406532994 | ||
|
|
8fd5176e22 | ||
|
|
e3396065c6 | ||
|
|
84600c016e | ||
|
|
64d96ab1d7 | ||
|
|
6d6720a511 | ||
|
|
17232a1909 | ||
|
|
eb02049507 | ||
|
|
ed7ad2f51b | ||
|
|
3e0202e6cb | ||
|
|
d59a68ec2e | ||
|
|
f249884907 | ||
|
|
c823d1689f | ||
|
|
b10f268e69 | ||
|
|
eff21c4f8f | ||
|
|
5537208abb | ||
|
|
53a5d3e88e | ||
|
|
20f840fd99 | ||
|
|
dc940c0608 | ||
|
|
f5c77d41f1 | ||
|
|
014c613577 | ||
|
|
d369c2c658 | ||
|
|
536868d3e5 | ||
|
|
5706738898 | ||
|
|
2947c2c7d0 | ||
|
|
18ac527629 | ||
|
|
6d80d14421 | ||
|
|
e60d8c5c20 |
@@ -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
|
||||
|
||||
+4
-4
@@ -13,8 +13,8 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 237
|
||||
versionName "0.65.1"
|
||||
versionCode 240
|
||||
versionName "0.66.2"
|
||||
|
||||
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'
|
||||
|
||||
@@ -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"
|
||||
@@ -205,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)
|
||||
@@ -247,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, "[]"),
|
||||
@@ -340,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,
|
||||
|
||||
@@ -13,9 +13,13 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
|
||||
import com.vitorpamplona.amethyst.service.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
|
||||
@@ -74,6 +78,7 @@ object ServiceManager {
|
||||
NostrHomeDataSource.start()
|
||||
NostrAccountDataSource.start()
|
||||
NostrChatroomListDataSource.start()
|
||||
NostrDiscoveryDataSource.start()
|
||||
|
||||
// More Info Data Sources
|
||||
NostrSingleEventDataSource.start()
|
||||
@@ -88,9 +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()
|
||||
|
||||
@@ -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(),
|
||||
@@ -107,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
|
||||
}
|
||||
@@ -857,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()
|
||||
@@ -1153,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) &&
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -72,11 +76,11 @@ object LocalCache {
|
||||
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
|
||||
@@ -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 Chat Note (${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)
|
||||
|
||||
|
||||
@@ -279,20 +279,55 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun publicZapAuthors(): Set<HexKey> {
|
||||
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<HexKey> {
|
||||
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 isReactedBy(user: User): String? {
|
||||
return reactions.filter {
|
||||
it.value.any { it.author?.pubkeyHex == user.pubkeyHex }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,11 @@ 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
|
||||
@@ -13,7 +16,10 @@ import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
fun createContextualFilter(): TypedFilter {
|
||||
val latestEOSEs = EOSEAccount()
|
||||
val dataSource = "Discovery"
|
||||
|
||||
fun createLiveStreamFilter(): TypedFilter {
|
||||
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
|
||||
|
||||
val followKeys = follows?.map {
|
||||
@@ -24,13 +30,50 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
authors = followKeys,
|
||||
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind, LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
|
||||
limit = 1000
|
||||
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowTagsFilter(): TypedFilter? {
|
||||
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
|
||||
@@ -38,13 +81,54 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind, LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
|
||||
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
|
||||
tags = mapOf(
|
||||
"t" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 1000
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -52,6 +136,13 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
val discoveryFeedChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
discoveryFeedChannel.typedFilters = listOfNotNull(createContextualFilter(), createFollowTagsFilter()).ifEmpty { null }
|
||||
discoveryFeedChannel.typedFilters = listOfNotNull(
|
||||
createLiveStreamFilter(),
|
||||
createPublicChatFilter(),
|
||||
createCommunitiesFilter(),
|
||||
createLiveStreamTagsFilter(),
|
||||
createPublicChatsTagsFilter(),
|
||||
createCommunitiesTagsFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
+7
-1
@@ -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 {
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
|
||||
|
||||
+42
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -23,13 +23,13 @@ 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() = 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 {
|
||||
|
||||
@@ -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
|
||||
@@ -177,6 +179,18 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(scrollState)
|
||||
) {
|
||||
postViewModel.originalNote?.let {
|
||||
NoteCompose(
|
||||
baseNote = it,
|
||||
makeItShort = true,
|
||||
unPackReply = false,
|
||||
isQuotedNote = true,
|
||||
modifier = MaterialTheme.colors.replyModifier,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
|
||||
Notifying(postViewModel.mentions?.toImmutableList()) {
|
||||
postViewModel.removeFromReplyList(it)
|
||||
}
|
||||
@@ -496,7 +510,9 @@ private fun AddZapraiserButton(
|
||||
Icon(
|
||||
imageVector = Icons.Default.ShowChart,
|
||||
null,
|
||||
modifier = Modifier.size(20.dp).align(Alignment.TopStart),
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.align(Alignment.TopStart),
|
||||
tint = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Icon(
|
||||
@@ -511,7 +527,9 @@ private fun AddZapraiserButton(
|
||||
Icon(
|
||||
imageVector = Icons.Default.ShowChart,
|
||||
null,
|
||||
modifier = Modifier.size(20.dp).align(Alignment.TopStart),
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.align(Alignment.TopStart),
|
||||
tint = BitcoinOrange
|
||||
)
|
||||
Icon(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ fun SensitivityWarning(
|
||||
accountViewModel: AccountViewModel,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val hasSensitiveContent = remember(note.event) { note.event?.isSensitive() ?: false }
|
||||
val hasSensitiveContent = remember(note) { note.event?.isSensitive() ?: false }
|
||||
|
||||
if (hasSensitiveContent) {
|
||||
SensitivityWarning(accountViewModel, content)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
+20
-7
@@ -4,18 +4,20 @@ 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
|
||||
|
||||
class DiscoverFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
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 allChannelNotes =
|
||||
LocalCache.channels.values.mapNotNull { LocalCache.getNoteIfExists(it.idHex) }
|
||||
val allMessageNotes = LocalCache.channels.values.map { it.notes.values }.flatten()
|
||||
|
||||
val notes = innerApplyFilter(allChannelNotes + allMessageNotes)
|
||||
@@ -27,18 +29,24 @@ class DiscoverFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
return innerApplyFilter(collection)
|
||||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
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 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 }
|
||||
.filter { account.isAcceptable(it) }
|
||||
.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()
|
||||
|
||||
@@ -46,9 +54,14 @@ class DiscoverFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
}
|
||||
|
||||
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.createdAt() },
|
||||
{ it.idHex }
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -1,58 +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 com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_LIVE
|
||||
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() == 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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -101,7 +102,7 @@ private fun RenderBottomMenu(
|
||||
navEntryState: State<NavBackStackEntry?>,
|
||||
nav: (Route, Boolean) -> Unit
|
||||
) {
|
||||
Column(modifier = Modifier.height(50.dp)) {
|
||||
Column(modifier = BottomTopHeight) {
|
||||
Divider(
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
|
||||
@@ -12,8 +12,9 @@ import androidx.navigation.compose.composable
|
||||
import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedLiveActivitiesViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverChatFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverCommunityFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverLiveFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
|
||||
@@ -23,6 +24,7 @@ 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
|
||||
@@ -40,11 +42,12 @@ import kotlinx.coroutines.launch
|
||||
fun AppNavigation(
|
||||
homeFeedViewModel: NostrHomeFeedViewModel,
|
||||
repliesFeedViewModel: NostrHomeRepliesFeedViewModel,
|
||||
liveActivitiesViewModel: NostrHomeFeedLiveActivitiesViewModel,
|
||||
knownFeedViewModel: NostrChatroomListKnownFeedViewModel,
|
||||
newFeedViewModel: NostrChatroomListNewFeedViewModel,
|
||||
videoFeedViewModel: NostrVideoFeedViewModel,
|
||||
discoveryFeedViewModel: NostrDiscoverFeedViewModel,
|
||||
discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel,
|
||||
discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel,
|
||||
discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel,
|
||||
notifFeedViewModel: NotificationViewModel,
|
||||
userReactionsStatsModel: UserReactionsViewModel,
|
||||
|
||||
@@ -70,7 +73,6 @@ fun AppNavigation(
|
||||
HomeScreen(
|
||||
homeFeedViewModel = homeFeedViewModel,
|
||||
repliesFeedViewModel = repliesFeedViewModel,
|
||||
liveActivitiesViewModel = liveActivitiesViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
nip47 = nip47
|
||||
@@ -112,7 +114,9 @@ fun AppNavigation(
|
||||
Route.Discover.let { route ->
|
||||
composable(route.route, route.arguments, content = {
|
||||
DiscoverScreen(
|
||||
discoveryFeedViewModel = discoveryFeedViewModel,
|
||||
discoveryLiveFeedViewModel = discoveryLiveFeedViewModel,
|
||||
discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel,
|
||||
discoveryChatFeedViewModel = discoveryChatFeedViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
@@ -172,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(
|
||||
|
||||
@@ -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,6 +116,8 @@ 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)
|
||||
@@ -208,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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
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
|
||||
@@ -59,7 +60,8 @@ sealed class Route(
|
||||
|
||||
object Discover : Route(
|
||||
route = "Discover",
|
||||
icon = R.drawable.ic_sensors
|
||||
icon = R.drawable.ic_sensors,
|
||||
hasNewItems = { accountViewModel, newNotes -> DiscoverLatestItem.hasNewItems(accountViewModel, newNotes) }
|
||||
)
|
||||
|
||||
object Notification : Route(
|
||||
@@ -102,6 +104,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,
|
||||
@@ -186,6 +194,21 @@ 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))
|
||||
|
||||
return (newestItem?.createdAt() ?: 0) > lastTime
|
||||
}
|
||||
}
|
||||
|
||||
object NotificationLatestItem : LatestItem() {
|
||||
fun hasNewItems(
|
||||
account: Account,
|
||||
|
||||
@@ -3,15 +3,20 @@ 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
|
||||
@@ -22,21 +27,31 @@ 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
|
||||
@@ -48,9 +63,14 @@ 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
|
||||
@@ -300,7 +320,7 @@ fun InnerChannelCardWithReactions(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
Column() {
|
||||
Column(StdPadding) {
|
||||
RenderNoteRow(
|
||||
baseNote,
|
||||
accountViewModel,
|
||||
@@ -319,10 +339,15 @@ private fun RenderNoteRow(
|
||||
is LiveActivitiesEvent -> {
|
||||
RenderLiveActivityThumb(baseNote, accountViewModel, nav)
|
||||
}
|
||||
is CommunityDefinitionEvent -> {
|
||||
RenderCommunitiesThumb(baseNote, accountViewModel, nav)
|
||||
}
|
||||
is ChannelCreateEvent -> {
|
||||
RenderChannelThumb(baseNote, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun RenderLiveActivityThumb(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val noteEvent = baseNote.event as? LiveActivitiesEvent ?: return
|
||||
@@ -339,6 +364,7 @@ fun RenderLiveActivityThumb(baseNote: Note, accountViewModel: AccountViewModel,
|
||||
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) }
|
||||
|
||||
@@ -359,29 +385,31 @@ fun RenderLiveActivityThumb(baseNote: Note, accountViewModel: AccountViewModel,
|
||||
|
||||
LaunchedEffect(key1 = eventUpdates) {
|
||||
launch(Dispatchers.IO) {
|
||||
val channel = LocalCache.getChannelIfExists(baseNote.idHex)
|
||||
val repliesByFollows = channel?.notes?.mapNotNull { it.value.author?.pubkeyHex }?.toSet() ?: emptySet()
|
||||
|
||||
val zappers = baseNote.publicZapAuthors()
|
||||
val likeAuthors = baseNote.reactionAuthors()
|
||||
|
||||
val allParticipants = (repliesByFollows + zappers + likeAuthors).filter { accountViewModel.isFollowing(it) }
|
||||
|
||||
val newParticipantUsers = (
|
||||
participants.mapNotNull { part ->
|
||||
if (part.key != baseNote.author?.pubkeyHex) {
|
||||
LocalCache.checkGetOrCreateUser(part.key)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} + allParticipants.mapNotNull {
|
||||
if (it != baseNote.author?.pubkeyHex) {
|
||||
LocalCache.checkGetOrCreateUser(it)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val hosts = participants.mapNotNull { part ->
|
||||
if (part.key != baseNote.author?.pubkeyHex) {
|
||||
LocalCache.checkGetOrCreateUser(part.key)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
).toImmutableList()
|
||||
}
|
||||
|
||||
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
|
||||
@@ -429,7 +457,7 @@ fun RenderLiveActivityThumb(baseNote: Note, accountViewModel: AccountViewModel,
|
||||
EndedFlag()
|
||||
}
|
||||
STATUS_PLANNED -> {
|
||||
ScheduledFlag()
|
||||
ScheduledFlag(starts)
|
||||
}
|
||||
else -> {
|
||||
EndedFlag()
|
||||
@@ -438,13 +466,13 @@ fun RenderLiveActivityThumb(baseNote: Note, accountViewModel: AccountViewModel,
|
||||
}
|
||||
}
|
||||
|
||||
Box(Modifier.padding(10.dp).align(BottomStart)) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(10.dp)
|
||||
.align(BottomStart)
|
||||
) {
|
||||
if (participantUsers.isNotEmpty()) {
|
||||
FlowRow() {
|
||||
participantUsers.forEach {
|
||||
ClickableUserPicture(it, Size35dp, accountViewModel)
|
||||
}
|
||||
}
|
||||
Gallery(participantUsers, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -463,6 +491,255 @@ fun RenderLiveActivityThumb(baseNote: Note, accountViewModel: AccountViewModel,
|
||||
}
|
||||
}
|
||||
|
||||
@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 {
|
||||
|
||||
@@ -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,6 +104,8 @@ 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
|
||||
@@ -147,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
|
||||
@@ -156,6 +162,7 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonBoxModifer
|
||||
@@ -170,6 +177,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdStartPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.UserNameMaxRowHeight
|
||||
@@ -432,6 +440,12 @@ fun NormalNote(
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
is CommunityDefinitionEvent -> CommunityHeader(
|
||||
baseNote = baseNote,
|
||||
showBottomDiviser = true,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote)
|
||||
is FileHeaderEvent -> FileHeaderDisplay(baseNote)
|
||||
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote)
|
||||
@@ -456,6 +470,253 @@ 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) {
|
||||
Text(
|
||||
text = summary ?: "This group does not have a description or rules. Talk to the owner to add one"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
) {
|
||||
Text(
|
||||
text = it
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -589,7 +850,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,
|
||||
@@ -713,7 +975,7 @@ private fun RenderNoteRow(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
when (remember { baseNote.event }) {
|
||||
when (baseNote.event) {
|
||||
is AppDefinitionEvent -> {
|
||||
RenderAppDefinition(baseNote, accountViewModel, nav)
|
||||
}
|
||||
@@ -795,6 +1057,17 @@ private fun RenderNoteRow(
|
||||
)
|
||||
}
|
||||
|
||||
is CommunityPostApprovalEvent -> {
|
||||
RenderPostApproval(
|
||||
baseNote,
|
||||
makeItShort,
|
||||
canPreview,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
RenderTextEvent(
|
||||
baseNote,
|
||||
@@ -821,6 +1094,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}"
|
||||
}
|
||||
@@ -849,7 +1124,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
|
||||
@@ -1523,6 +1798,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,
|
||||
@@ -1716,7 +2061,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)
|
||||
@@ -1818,12 +2163,18 @@ fun FirstUserInfoRow(
|
||||
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)
|
||||
@@ -1834,6 +2185,8 @@ fun FirstUserInfoRow(
|
||||
|
||||
if (isRepost) {
|
||||
BoostedMark()
|
||||
} else if (isCommunityPost) {
|
||||
DisplayFollowingCommunityInPost(baseNote, accountViewModel, nav)
|
||||
} else {
|
||||
DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav)
|
||||
}
|
||||
@@ -1884,14 +2237,14 @@ 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,
|
||||
@@ -2142,6 +2495,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,
|
||||
@@ -2192,6 +2558,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(
|
||||
@@ -2663,6 +3060,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) }
|
||||
|
||||
@@ -2698,7 +3096,7 @@ fun RenderLiveActivityEventInner(baseNote: Note, accountViewModel: AccountViewMo
|
||||
}
|
||||
}
|
||||
STATUS_PLANNED -> {
|
||||
ScheduledFlag()
|
||||
ScheduledFlag(starts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
@@ -21,11 +22,13 @@ import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.ChatroomFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.ChatroomListNewFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.DiscoverFeedFilter
|
||||
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
|
||||
@@ -67,10 +70,26 @@ class NostrVideoFeedViewModel(val account: Account) : FeedViewModel(VideoFeedFil
|
||||
}
|
||||
}
|
||||
|
||||
class NostrDiscoverFeedViewModel(val account: Account) : FeedViewModel(DiscoverFeedFilter(account)) {
|
||||
class NostrDiscoverLiveFeedViewModel(val account: Account) : FeedViewModel(DiscoverLiveFeedFilter(account)) {
|
||||
class Factory(val account: Account) : ViewModelProvider.Factory {
|
||||
override fun <NostrDiscoverFeedViewModel : ViewModel> create(modelClass: Class<NostrDiscoverFeedViewModel>): NostrDiscoverFeedViewModel {
|
||||
return NostrDiscoverFeedViewModel(account) as NostrDiscoverFeedViewModel
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,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 {
|
||||
@@ -130,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 {
|
||||
|
||||
@@ -20,10 +20,15 @@ object ScrollStateKeys {
|
||||
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
|
||||
|
||||
@@ -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),
|
||||
|
||||
+3
-1
@@ -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,
|
||||
|
||||
+329
-118
@@ -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,34 +85,50 @@ 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.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.components.LoadNote
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
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.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(
|
||||
@@ -555,117 +574,22 @@ fun ChannelHeader(
|
||||
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
|
||||
|
||||
if (showVideo) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
streamingUrl?.let {
|
||||
CheckIfUrlIsOnline(it.url) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = remember { Modifier.heightIn(max = 300.dp) }
|
||||
) {
|
||||
ZoomableContentView(
|
||||
content = it
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
if (showVideo && baseChannel is LiveActivitiesChannel) {
|
||||
ShowVideoStreaming(baseChannel)
|
||||
}
|
||||
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
|
||||
)
|
||||
}
|
||||
var expanded = remember { mutableStateOf(false) }
|
||||
|
||||
val summary = remember(channelState) {
|
||||
channel.summary()?.ifBlank { null }
|
||||
}
|
||||
Column(
|
||||
modifier = modifier.clickable {
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
) {
|
||||
ShortChannelHeader(baseChannel, expanded, accountViewModel, nav, showFlag)
|
||||
|
||||
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, showFlag, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
if (expanded.value) {
|
||||
LongChannelHeader(baseChannel, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -678,13 +602,264 @@ fun ChannelHeader(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelActionOptions(
|
||||
private fun ShowVideoStreaming(
|
||||
baseChannel: LiveActivitiesChannel
|
||||
) {
|
||||
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) {
|
||||
Text(
|
||||
text = summary ?: "This group does not have a description or rules. Talk to the owner to add one"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -703,9 +878,7 @@ private fun ChannelActionOptions(
|
||||
}
|
||||
|
||||
if (isFollowing) {
|
||||
LeaveButton(accountViewModel, channel, nav)
|
||||
} else {
|
||||
JoinButton(accountViewModel, channel, nav)
|
||||
LeaveChatButton(accountViewModel, channel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -784,9 +957,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 {
|
||||
@@ -864,7 +1040,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 = {
|
||||
@@ -882,12 +1058,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
|
||||
|
||||
+3
-2
@@ -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(
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.fillMaxHeight()) {
|
||||
CommunityHeader(baseNote = note, showBottomDiviser = true, accountViewModel = accountViewModel, nav = nav)
|
||||
RefresheableFeedView(
|
||||
feedViewModel,
|
||||
null,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
+102
-21
@@ -5,46 +5,75 @@ 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.setValue
|
||||
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 androidx.lifecycle.viewmodel.compose.viewModel
|
||||
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.NostrDiscoverFeedViewModel
|
||||
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(
|
||||
discoveryFeedViewModel: NostrDiscoverFeedViewModel,
|
||||
discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel,
|
||||
discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel,
|
||||
discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
WatchAccountForDiscoveryScreen(discoveryViewModel = discoveryFeedViewModel, accountViewModel = accountViewModel)
|
||||
val pagerState = rememberForeverPagerState(key = PagerStateKeys.DISCOVER_SCREEN)
|
||||
|
||||
WatchAccountForDiscoveryScreen(
|
||||
discoveryLiveFeedViewModel = discoveryLiveFeedViewModel,
|
||||
discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel,
|
||||
discoveryChatFeedViewModel = discoveryChatFeedViewModel,
|
||||
accountViewModel = accountViewModel
|
||||
)
|
||||
|
||||
DisposableEffect(accountViewModel) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
@@ -52,10 +81,6 @@ fun DiscoverScreen(
|
||||
println("Discovery Start")
|
||||
NostrDiscoveryDataSource.start()
|
||||
}
|
||||
if (event == Lifecycle.Event.ON_PAUSE) {
|
||||
println("Discovery Stop")
|
||||
NostrDiscoveryDataSource.stop()
|
||||
}
|
||||
}
|
||||
|
||||
lifeCycleOwner.lifecycle.addObserver(observer)
|
||||
@@ -64,14 +89,57 @@ fun DiscoverScreen(
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
) {
|
||||
RefresheableView(discoveryFeedViewModel, true) {
|
||||
SaveableFeedState(discoveryFeedViewModel, scrollStateKey = ScrollStateKeys.DISCOVER_SCREEN) { listState ->
|
||||
RenderDiscoverFeed(discoveryFeedViewModel, accountViewModel, listState, nav)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,6 +148,7 @@ fun DiscoverScreen(
|
||||
@Composable
|
||||
private fun RenderDiscoverFeed(
|
||||
viewModel: FeedViewModel,
|
||||
routeForLastRead: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
listState: LazyListState,
|
||||
nav: (String) -> Unit
|
||||
@@ -106,6 +175,7 @@ private fun RenderDiscoverFeed(
|
||||
is FeedState.Loaded -> {
|
||||
DiscoverFeedLoaded(
|
||||
state,
|
||||
routeForLastRead,
|
||||
listState,
|
||||
accountViewModel,
|
||||
nav
|
||||
@@ -120,12 +190,19 @@ private fun RenderDiscoverFeed(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WatchAccountForDiscoveryScreen(discoveryViewModel: NostrDiscoverFeedViewModel, accountViewModel: AccountViewModel) {
|
||||
fun WatchAccountForDiscoveryScreen(
|
||||
discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel,
|
||||
discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel,
|
||||
discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
|
||||
LaunchedEffect(accountViewModel, accountState?.account?.defaultDiscoveryFollowList) {
|
||||
NostrDiscoveryDataSource.resetFilters()
|
||||
discoveryViewModel.checkKeysInvalidateDataAndSendToTop()
|
||||
discoveryLiveFeedViewModel.checkKeysInvalidateDataAndSendToTop()
|
||||
discoveryCommunityFeedViewModel.checkKeysInvalidateDataAndSendToTop()
|
||||
discoveryChatFeedViewModel.checkKeysInvalidateDataAndSendToTop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +210,7 @@ fun WatchAccountForDiscoveryScreen(discoveryViewModel: NostrDiscoverFeedViewMode
|
||||
@Composable
|
||||
private fun DiscoverFeedLoaded(
|
||||
state: FeedState.Loaded,
|
||||
routeForLastRead: String?,
|
||||
listState: LazyListState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
@@ -146,15 +224,18 @@ private fun DiscoverFeedLoaded(
|
||||
) {
|
||||
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item ->
|
||||
val defaultModifier = remember {
|
||||
Modifier.padding(10.dp).animateItemPlacement()
|
||||
Modifier.fillMaxWidth().animateItemPlacement()
|
||||
}
|
||||
|
||||
ChannelCardCompose(
|
||||
item,
|
||||
modifier = defaultModifier,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
Row(defaultModifier) {
|
||||
ChannelCardCompose(
|
||||
baseNote = item,
|
||||
routeForLastRead = routeForLastRead,
|
||||
modifier = Modifier,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -33,23 +27,19 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.map
|
||||
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
|
||||
@@ -60,7 +50,6 @@ import kotlinx.coroutines.launch
|
||||
fun HomeScreen(
|
||||
homeFeedViewModel: NostrHomeFeedViewModel,
|
||||
repliesFeedViewModel: NostrHomeRepliesFeedViewModel,
|
||||
liveActivitiesViewModel: NostrHomeFeedLiveActivitiesViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
nip47: String? = null
|
||||
@@ -69,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)
|
||||
@@ -77,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()
|
||||
@@ -104,7 +91,7 @@ fun HomeScreen(
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
HomePages(pagerState, tabs, liveActivitiesViewModel, accountViewModel, nav)
|
||||
HomePages(pagerState, tabs, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,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()
|
||||
|
||||
@@ -137,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,73 +135,6 @@ 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 url by note.live().metadata.map {
|
||||
(note.event as? LiveActivitiesEvent)?.streaming()
|
||||
}.observeAsState()
|
||||
|
||||
url?.let {
|
||||
CheckIfUrlIsOnline(it, whenOnline)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CheckIfUrlIsOnline(url: String, whenOnline: @Composable () -> Unit) {
|
||||
var online by remember { mutableStateOf(false) }
|
||||
@@ -242,7 +156,6 @@ fun CheckIfUrlIsOnline(url: String, whenOnline: @Composable () -> Unit) {
|
||||
fun WatchAccountForHomeScreen(
|
||||
homeFeedViewModel: NostrHomeFeedViewModel,
|
||||
repliesFeedViewModel: NostrHomeRepliesFeedViewModel,
|
||||
liveActivitiesViewModel: NostrHomeFeedLiveActivitiesViewModel,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
@@ -253,7 +166,6 @@ fun WatchAccountForHomeScreen(
|
||||
NostrHomeDataSource.invalidateFilters()
|
||||
homeFeedViewModel.checkKeysInvalidateDataAndSendToTop()
|
||||
repliesFeedViewModel.checkKeysInvalidateDataAndSendToTop()
|
||||
liveActivitiesViewModel.checkKeysInvalidateDataAndSendToTop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,8 +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.NostrDiscoverFeedViewModel
|
||||
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
|
||||
@@ -89,19 +91,24 @@ 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 discoveryFeedViewModel: NostrDiscoverFeedViewModel = viewModel(
|
||||
key = accountViewModel.userProfile().pubkeyHex + "NostrDiscoveryFeedViewModel",
|
||||
factory = NostrDiscoverFeedViewModel.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(
|
||||
@@ -143,7 +150,9 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
|
||||
videoFeedViewModel.sendToTop()
|
||||
}
|
||||
Route.Discover.base -> {
|
||||
discoveryFeedViewModel.sendToTop()
|
||||
discoveryLiveFeedViewModel.sendToTop()
|
||||
discoveryCommunityFeedViewModel.sendToTop()
|
||||
discoveryChatFeedViewModel.sendToTop()
|
||||
}
|
||||
Route.Notification.base -> {
|
||||
notifFeedViewModel.invalidateDataAndSendToTop()
|
||||
@@ -187,18 +196,19 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
|
||||
) {
|
||||
Column(modifier = Modifier.padding(bottom = it.calculateBottomPadding())) {
|
||||
AppNavigation(
|
||||
homeFeedViewModel,
|
||||
repliesFeedViewModel,
|
||||
liveActivitiesViewModel,
|
||||
knownFeedViewModel,
|
||||
newFeedViewModel,
|
||||
videoFeedViewModel,
|
||||
discoveryFeedViewModel,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -245,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -472,4 +472,9 @@
|
||||
<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>
|
||||
</resources>
|
||||
|
||||
+1
-1
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user