Compare commits

...
28 Commits
Author SHA1 Message Date
Vitor Pamplona 244e3f3b29 v0.61.2 2023-06-20 17:13:24 -04:00
Vitor Pamplona 6a44d283f9 - Restructures Chat Message Rendering
- Adds Sensitive Content protections for chat renderings.
- Adjusts padding of the channel headers.
2023-06-20 16:53:36 -04:00
Vitor Pamplona 6addce3d20 Simplifying some of the chat rendering process 2023-06-20 15:16:29 -04:00
Vitor Pamplona 8aae101ef6 Adds live activities to the main feed. 2023-06-20 13:54:47 -04:00
Vitor Pamplona d1bbdef5c4 v0.61.1 2023-06-19 22:01:30 -04:00
Vitor Pamplona b78967f8be Screen diet 2023-06-19 21:58:12 -04:00
Vitor Pamplona 01a2f746d0 Adds space between the play button and the user's name in private messages 2023-06-19 21:10:01 -04:00
Vitor Pamplona d063c2990c Moves the label to the bottom of the video 2023-06-19 20:58:38 -04:00
Vitor Pamplona e31ee031cb Removes the cache from streaming files to make sure the stream keeps playing. 2023-06-19 20:53:38 -04:00
Vitor Pamplona 8fc489cc59 Formatting 2023-06-19 20:43:55 -04:00
Vitor Pamplona ffb2a19963 Adds Zaps and Reactions to the Zap Activity author 2023-06-19 20:43:48 -04:00
Vitor Pamplona b271c1775c BugFix for not updating Notifications quickly. 2023-06-19 20:30:13 -04:00
Vitor Pamplona f937e95b35 v0.61.0 2023-06-19 18:36:32 -04:00
Vitor Pamplona 1613e79860 Support for live chats in streaming events. 2023-06-19 18:34:52 -04:00
Vitor Pamplona dd40b4f205 Formatting 2023-06-19 16:24:58 -04:00
Vitor Pamplona 2ad9401ee6 Downloads last posts from the author. 2023-06-19 16:23:19 -04:00
Vitor Pamplona 69ade2f6e6 Merge remote-tracking branch 'origin/HEAD' 2023-06-19 16:02:23 -04:00
Vitor Pamplona 4006e62a82 Renders Channel headers, NIP94 and NIP95 in the thread head. 2023-06-19 16:01:51 -04:00
Vitor Pamplona ec88a8b157 Updates readme 2023-06-19 16:00:23 -04:00
Vitor PamplonaandGitHub 128211ecd2 Merge pull request #459 from greenart7c3/main
add toast message to show relay icon description
2023-06-19 15:32:46 -04:00
Vitor Pamplona 8f53537ebc v0.60.2 2023-06-19 14:43:34 -04:00
Vitor Pamplona 58c93e0cce - Redesign feed invalidations to account for changes in the follow list
- Redesign scroll to the top implementation to avoid using arguments in the navigator.
2023-06-19 14:30:15 -04:00
greenart7c3 c35b5e3703 add toast message for relay icons 2023-06-19 07:58:25 -03:00
Vitor Pamplona be8848fb1b Restructures Stable elements for minor performance gains. 2023-06-18 18:57:21 -04:00
Vitor Pamplona a3efd15b95 v0.60.1 2023-06-18 14:43:59 -04:00
Vitor Pamplona a1930c232b Formatting 2023-06-18 14:42:26 -04:00
Vitor Pamplona 58a4fb18ae BugFixes for NPE 2023-06-18 14:40:46 -04:00
Vitor Pamplona be4870da1a Support for Per-post Zap-raisers. 2023-06-18 14:24:52 -04:00
84 changed files with 2135 additions and 687 deletions
+2
View File
@@ -64,6 +64,8 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [x] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
- [x] Recommended Application Handlers (NIP-89)
- [x] Events with a Subject (NIP-14)
- [x] Generic Reposts (kind:16)
- [x] Live Activities (NIP-102)
- [ ] Marketplace (NIP-15)
- [ ] Image/Video Capture in the app
- [ ] Local Database
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 213
versionName "0.60.0"
versionCode 218
versionName "0.61.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -103,7 +103,7 @@ class Account(
}
fun followingChannels(): List<Channel> {
return followingChannels.map { LocalCache.getOrCreateChannel(it) }
return followingChannels.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
}
fun isWriteable(): Boolean {
@@ -507,6 +507,7 @@ class Account(
tags: List<String>? = null,
zapReceiver: String? = null,
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
replyingTo: String?,
root: String?,
directMentions: Set<HexKey>
@@ -525,6 +526,7 @@ class Account(
extraTags = tags,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
replyingTo = replyingTo,
root = root,
directMentions = directMentions,
@@ -545,7 +547,8 @@ class Account(
consensusThreshold: Int?,
closedAt: Int?,
zapReceiver: String? = null,
wantsToMarkAsSensitive: Boolean
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null
) {
if (!isWriteable()) return
@@ -565,14 +568,15 @@ class Account(
consensusThreshold = consensusThreshold,
closedAt = closedAt,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount
)
// println("Sending new PollNoteEvent: %s".format(signedEvent.toJson()))
Client.send(signedEvent)
LocalCache.consume(signedEvent)
}
fun sendChannelMessage(message: String, toChannel: String, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean) {
fun sendChannelMessage(message: String, toChannel: String, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null) {
if (!isWriteable()) return
// val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
@@ -586,13 +590,35 @@ class Account(
mentions = mentionsHex,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent, null)
}
fun sendPrivateMessage(message: String, toUser: User, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean) {
fun sendLiveMessage(message: String, toChannel: ATag, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null) {
if (!isWriteable()) return
// val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
val repliesToHex = replyTo?.map { it.idHex }
val mentionsHex = mentions?.map { it.pubkeyHex }
val signedEvent = LiveActivitiesChatMessageEvent.create(
message = message,
activity = toChannel,
replyTos = repliesToHex,
mentions = mentionsHex,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent, null)
}
fun sendPrivateMessage(message: String, toUser: User, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null) {
if (!isWriteable()) return
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
@@ -606,6 +632,7 @@ class Account(
mentions = mentionsHex,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
privateKey = loggedIn.privKey!!,
advertiseNip18 = false
)
@@ -3,7 +3,9 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.model.ATag
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
@@ -11,20 +13,96 @@ import kotlinx.coroutines.Dispatchers
import java.util.concurrent.ConcurrentHashMap
@Stable
class Channel(val idHex: String) {
var creator: User? = null
class PublicChatChannel(idHex: String) : Channel(idHex) {
var info = ChannelCreateEvent.ChannelData(null, null, null)
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
this.creator = creator
this.info = channelInfo
this.updatedMetadataAt = updatedAt
live.invalidateData()
}
override fun toBestDisplayName(): String {
return info.name ?: super.toBestDisplayName()
}
override fun summary(): String? {
return info.about
}
override fun profilePicture(): String? {
if (info.picture.isNullOrBlank()) info.picture = null
return info.picture ?: super.profilePicture()
}
override fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(info.name, info.about)
.filter { it.contains(prefix, true) }.isNotEmpty()
}
}
@Stable
class LiveActivitiesChannel(val address: ATag) : Channel(address.toTag()) {
var info: LiveActivitiesEvent? = null
override fun idNote() = address.toNAddr()
override fun idDisplayNote() = idNote().toShortenHex()
fun address() = address
fun updateChannelInfo(creator: User, channelInfo: LiveActivitiesEvent, updatedAt: Long) {
this.info = channelInfo
super.updateChannelInfo(creator, updatedAt)
}
override fun toBestDisplayName(): String {
return info?.title() ?: super.toBestDisplayName()
}
override fun summary(): String? {
return info?.summary()
}
override fun profilePicture(): String? {
return info?.image()?.ifBlank { null } ?: super.profilePicture()
}
override fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(info?.title(), info?.summary())
.filter { it.contains(prefix, true) }.isNotEmpty()
}
}
@Stable
abstract class Channel(val idHex: String) {
var creator: User? = null
var updatedMetadataAt: Long = 0
val notes = ConcurrentHashMap<HexKey, Note>()
fun id() = Hex.decode(idHex)
fun idNote() = id().toNote()
fun idDisplayNote() = idNote().toShortenHex()
open fun id() = Hex.decode(idHex)
open fun idNote() = id().toNote()
open fun idDisplayNote() = idNote().toShortenHex()
fun toBestDisplayName(): String {
return info.name ?: idDisplayNote()
open fun toBestDisplayName(): String {
return idDisplayNote()
}
open fun summary(): String? {
return null
}
open fun profilePicture(): String? {
return creator?.profilePicture()
}
open fun updateChannelInfo(creator: User, updatedAt: Long) {
this.creator = creator
this.updatedMetadataAt = updatedAt
live.invalidateData()
}
fun addNote(note: Note) {
@@ -39,23 +117,7 @@ class Channel(val idHex: String) {
notes.remove(noteHex)
}
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
this.creator = creator
this.info = channelInfo
this.updatedMetadataAt = updatedAt
live.invalidateData()
}
fun profilePicture(): String? {
if (info.picture.isNullOrBlank()) info.picture = null
return info.picture
}
fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(info.name, info.about)
.filter { it.startsWith(prefix, true) }.isNotEmpty()
}
abstract fun anyNameStartsWith(prefix: String): Boolean
// Observers line up here.
val live: ChannelLiveData = ChannelLiveData(this)
@@ -92,12 +154,20 @@ class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelStat
override fun onActive() {
super.onActive()
NostrSingleChannelDataSource.add(channel.idHex)
if (channel is PublicChatChannel) {
NostrSingleChannelDataSource.add(channel.idHex)
} else {
NostrSingleChannelDataSource.add(channel.idHex)
}
}
override fun onInactive() {
super.onInactive()
NostrSingleChannelDataSource.remove(channel.idHex)
if (channel is PublicChatChannel) {
NostrSingleChannelDataSource.remove(channel.idHex)
} else {
NostrSingleChannelDataSource.remove(channel.idHex)
}
}
}
@@ -4,6 +4,7 @@ import android.util.Log
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.model.ATag.Companion.isATag
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledInsert
@@ -97,7 +98,15 @@ object LocalCache {
checkNotInMainThread()
if (isValidHexNpub(key)) {
return getOrCreateChannel(key)
return getOrCreateChannel(key) {
PublicChatChannel(key)
}
}
val aTag = ATag.parse(key, null)
if (aTag != null) {
return getOrCreateChannel(aTag.toTag()) {
LiveActivitiesChannel(aTag)
}
}
return null
}
@@ -113,11 +122,11 @@ object LocalCache {
}
@Synchronized
fun getOrCreateChannel(key: String): Channel {
fun getOrCreateChannel(key: String, channelFactory: (String) -> Channel): Channel {
checkNotInMainThread()
return channels[key] ?: run {
val answer = Channel(key)
val answer = channelFactory(key)
channels.put(key, answer)
answer
}
@@ -333,6 +342,11 @@ object LocalCache {
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
val channel = getOrCreateChannel(note.idHex) {
LiveActivitiesChannel(note.address)
} as? LiveActivitiesChannel
channel?.updateChannelInfo(author, event, event.createdAt)
refreshObservers(note)
}
}
@@ -572,8 +586,11 @@ object LocalCache {
}
deleteNote.channelHex()?.let {
val channel = checkGetOrCreateChannel(it)
channel?.removeNote(deleteNote)
channels[it]?.removeNote(deleteNote)
}
(deleteNote.event as? LiveActivitiesChatMessageEvent)?.activity()?.let {
channels[it.toTag()]?.removeNote(deleteNote)
}
if (deleteNote.event is PrivateDmEvent) {
@@ -708,7 +725,9 @@ object LocalCache {
fun consume(event: ChannelCreateEvent) {
// Log.d("MT", "New Event ${event.content} ${event.id.toHex()}")
val oldChannel = getOrCreateChannel(event.id)
val oldChannel = getOrCreateChannel(event.id) {
PublicChatChannel(it)
}
val author = getOrCreateUser(event.pubKey)
val note = getOrCreateNote(event.id)
@@ -723,7 +742,9 @@ object LocalCache {
return // older data, does nothing
}
if (oldChannel.creator == null || oldChannel.creator == author) {
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
if (oldChannel is PublicChatChannel) {
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
}
}
}
@@ -734,10 +755,13 @@ object LocalCache {
// new event
val oldChannel = checkGetOrCreateChannel(channelId) ?: return
val author = getOrCreateUser(event.pubKey)
if (event.createdAt > oldChannel.updatedMetadataAt) {
if (oldChannel.creator == null || oldChannel.creator == author) {
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
if (oldChannel is PublicChatChannel) {
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
}
}
} else {
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
@@ -795,6 +819,47 @@ object LocalCache {
refreshObservers(note)
}
fun consume(event: LiveActivitiesChatMessageEvent, relay: Relay?) {
val activityId = event.activity() ?: return
val channel = getOrCreateChannel(activityId.toTag()) {
LiveActivitiesChannel(activityId)
}
val note = getOrCreateNote(event.id)
channel.addNote(note)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let {
it.spamCounter++
}
return
}
val replyTo = event.tagsWithoutCitations()
.filter { it != event.activity()?.toTag() }
.mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, replyTo)
// Counts the replies
replyTo.forEach {
it.addReply(note)
}
refreshObservers(note)
}
@Suppress("UNUSED_PARAMETER")
fun consume(event: ChannelHideMessageEvent) {
}
@@ -1084,7 +1149,7 @@ object LocalCache {
}
}
println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.info.name}")
println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.toBestDisplayName()}")
}
}
@@ -1172,6 +1237,7 @@ object LocalCache {
is FileStorageHeaderEvent -> consume(event, relay)
is HighlightEvent -> consume(event, relay)
is LiveActivitiesEvent -> consume(event, relay)
is LiveActivitiesChatMessageEvent -> consume(event, relay)
is LnZapEvent -> {
event.zapRequest?.let {
verifyAndConsume(it, relay)
@@ -73,6 +73,8 @@ open class Note(val idHex: String) {
return (event as? ChannelMessageEvent)?.channel()
?: (event as? ChannelMetadataEvent)?.channel()
?: (event as? ChannelCreateEvent)?.id
?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag()
?: (event as? LiveActivitiesEvent)?.address()?.toTag()
}
open fun address(): ATag? = null
@@ -373,7 +375,14 @@ open class Note(val idHex: String) {
}
fun isNewThread(): Boolean {
return event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo?.size == 0
return (
event is RepostEvent ||
event is GenericRepostEvent ||
replyTo == null ||
replyTo?.size == 0
) &&
event !is ChannelMessageEvent &&
event !is LiveActivitiesChatMessageEvent
}
fun hasZapped(loggedIn: User): Boolean {
@@ -13,8 +13,6 @@ import okhttp3.Request
import okhttp3.Response
class Nip05Verifier() {
val client = HttpClient.getHttpClient()
fun assembleUrl(nip05address: String): String? {
val parts = nip05address.trim().split("@")
@@ -49,7 +47,7 @@ class Nip05Verifier() {
.url(url)
.build()
client.newCall(request).enqueue(object : Callback {
HttpClient.getHttpClient().newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
if (it.isSuccessful) {
@@ -80,6 +80,16 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
)
}
fun createAccountLastPostsListFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
authors = listOf(account.userProfile().pubkeyHex),
limit = 400
)
)
}
fun createNotificationFilter() = TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
@@ -113,7 +123,8 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
createNotificationFilter(),
createAccountReportsFilter(),
createAccountAcceptedAwardsFilter(),
createAccountBookmarkListFilter()
createAccountBookmarkListFilter(),
createAccountLastPostsListFilter()
).ifEmpty { null }
}
@@ -2,7 +2,10 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
@@ -25,7 +28,7 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
fun createMessagesByMeToChannelFilter(): TypedFilter? {
val myAccount = account ?: return null
if (channel != null) {
if (channel is PublicChatChannel) {
// Brings on messages by the user from all other relays.
// Since we ship with write to public, read from private only
// this guarantees that messages from the author do not disappear.
@@ -37,12 +40,24 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
limit = 50
)
)
} else if (channel is LiveActivitiesChannel) {
// Brings on messages by the user from all other relays.
// Since we ship with write to public, read from private only
// this guarantees that messages from the author do not disappear.
return TypedFilter(
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
filter = JsonFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.kind),
authors = listOf(myAccount.userProfile().pubkeyHex),
limit = 50
)
)
}
return null
}
fun createMessagesToChannelFilter(): TypedFilter? {
if (channel != null) {
if (channel is PublicChatChannel) {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
@@ -51,6 +66,15 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
limit = 200
)
)
} else if (channel is LiveActivitiesChannel) {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.kind),
tags = mapOf("a" to listOfNotNull(channel?.idHex)),
limit = 200
)
)
}
return null
}
@@ -5,6 +5,8 @@ import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PinListEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
@@ -68,7 +70,9 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
PollNoteEvent.kind,
HighlightEvent.kind,
AudioTrackEvent.kind,
PinListEvent.kind
PinListEvent.kind,
LiveActivitiesChatMessageEvent.kind,
LiveActivitiesEvent.kind
),
authors = followSet,
limit = 400,
@@ -1,6 +1,8 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
@@ -30,8 +32,8 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
val directEventsToLoad = channelsToWatch
.map { LocalCache.getOrCreateChannel(it) }
.filter { it.notes.isEmpty() }
.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
.filter { it.notes.isEmpty() && it is PublicChatChannel }
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
@@ -49,13 +51,43 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
)
}
fun createLoadStreamingIfNotLoadedFilter(): List<TypedFilter>? {
val directEventsToLoad = channelsToWatch
.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
.filterIsInstance<LiveActivitiesChannel>()
.filter { it.info == null }
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
if (interestedEvents.isEmpty()) {
return null
}
// downloads linked events to this event.
return directEventsToLoad.map {
it.address().let { aTag ->
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex.substring(0, 8))
)
)
}
}
}
val singleChannelChannel = requestNewChannel()
override fun updateChannelFilters() {
val reactions = createRepliesAndReactionsFilter()
val missing = createLoadEventsIfNotLoadedFilter()
val missingStreaming = createLoadStreamingIfNotLoadedFilter()
singleChannelChannel.typedFilters = listOfNotNull(reactions, missing).ifEmpty { null }
singleChannelChannel.typedFilters = (
(listOfNotNull(reactions, missing)) + (missingStreaming ?: emptyList())
).ifEmpty { null }
}
fun add(eventId: String) {
@@ -18,7 +18,7 @@ open class BaseTextNoteEvent(
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun mentions() = taggedUsers()
open fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
open fun replyTos() = taggedEvents()
private var citedUsersCache: Set<HexKey>? = null
private var citedNotesCache: Set<HexKey>? = null
@@ -35,7 +35,8 @@ class ChannelMessageEvent(
zapReceiver: String?,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000,
markAsSensitive: Boolean
markAsSensitive: Boolean,
zapRaiserAmount: Long?
): ChannelMessageEvent {
val content = message
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
@@ -54,6 +55,9 @@ class ChannelMessageEvent(
if (markAsSensitive) {
tags.add(listOf("content-warning", ""))
}
zapRaiserAmount?.let {
tags.add(listOf("zapraiser", "$it"))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
@@ -54,6 +54,10 @@ open class Event(
(it.size > 1 && it[0] == "t" && it[1].equals("nude", true))
}
override fun zapraiserAmount() = tags.firstOrNull() {
(it.size > 1 && it[0].equals("zapraiser", true))
}?.get(1)?.toLongOrNull()
override fun zapAddress() = tags.firstOrNull { it.size > 1 && it[0] == "zap" }?.get(1)
fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
@@ -248,6 +252,7 @@ open class Event(
GenericRepostEvent.kind -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig)
HighlightEvent.kind -> HighlightEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesEvent.kind -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesChatMessageEvent.kind -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig)
LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentResponseEvent.kind -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig)
@@ -38,4 +38,5 @@ interface EventInterface {
fun zapAddress(): String?
fun isSensitive(): Boolean
fun zapraiserAmount(): Long?
}
@@ -0,0 +1,82 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class LiveActivitiesChatMessageEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
private fun innerActivity() = tags.firstOrNull {
it.size > 3 && it[0] == "a" && it[3] == "root"
} ?: tags.firstOrNull {
it.size > 1 && it[0] == "a"
}
private fun activityHex() = innerActivity()?.let {
it.getOrNull(1)
}
fun activity() = innerActivity()?.let {
if (it.size > 1) {
val aTagValue = it[1]
val relay = it.getOrNull(2)
ATag.parse(aTagValue, relay)
} else {
null
}
}
override fun replyTos() = taggedEvents().minus(activityHex() ?: "")
companion object {
const val kind = 1311
fun create(
message: String,
activity: ATag,
replyTos: List<String>? = null,
mentions: List<String>? = null,
zapReceiver: String?,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000,
markAsSensitive: Boolean,
zapRaiserAmount: Long?
): LiveActivitiesChatMessageEvent {
val content = message
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf(
listOf("a", activity.toTag(), "", "root")
)
replyTos?.forEach {
tags.add(listOf("e", it))
}
mentions?.forEach {
tags.add(listOf("p", it))
}
zapReceiver?.let {
tags.add(listOf("zap", it))
}
if (markAsSensitive) {
tags.add(listOf("content-warning", ""))
}
zapRaiserAmount?.let {
tags.add(listOf("zapraiser", "$it"))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return LiveActivitiesChatMessageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
@@ -52,7 +52,8 @@ class PollNoteEvent(
consensusThreshold: Int?,
closedAt: Int?,
zapReceiver: String?,
markAsSensitive: Boolean
markAsSensitive: Boolean,
zapRaiserAmount: Long?
): PollNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
@@ -79,6 +80,9 @@ class PollNoteEvent(
if (markAsSensitive) {
tags.add(listOf("content-warning", ""))
}
zapRaiserAmount?.let {
tags.add(listOf("zapraiser", "$it"))
}
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
@@ -78,7 +78,8 @@ class PrivateDmEvent(
createdAt: Long = Date().time / 1000,
publishedRecipientPubKey: ByteArray? = null,
advertiseNip18: Boolean = true,
markAsSensitive: Boolean
markAsSensitive: Boolean,
zapRaiserAmount: Long?
): PrivateDmEvent {
val content = Utils.encrypt(
if (advertiseNip18) { nip18Advertisement } else { "" } + msg,
@@ -102,6 +103,9 @@ class PrivateDmEvent(
if (markAsSensitive) {
tags.add(listOf("content-warning", ""))
}
zapRaiserAmount?.let {
tags.add(listOf("zapraiser", "$it"))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return PrivateDmEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
@@ -34,6 +34,7 @@ class TextNoteEvent(
extraTags: List<String>?,
zapReceiver: String?,
markAsSensitive: Boolean,
zapRaiserAmount: Long?,
replyingTo: String?,
root: String?,
directMentions: Set<HexKey>,
@@ -89,6 +90,9 @@ class TextNoteEvent(
if (markAsSensitive) {
tags.add(listOf("content-warning", ""))
}
zapRaiserAmount?.let {
tags.add(listOf("zapraiser", "$it"))
}
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
@@ -352,12 +352,12 @@ private fun RenderChannel(
channelPicture = item.profilePicture(),
channelTitle = {
Text(
"${item.info.name}",
item.toBestDisplayName(),
fontWeight = FontWeight.Bold
)
},
channelLastTime = null,
channelLastContent = item.info.about,
channelLastContent = item.summary(),
false,
onClick = onClick
)
@@ -25,14 +25,14 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, channel: Channel? = null) {
fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, channel: PublicChatChannel? = null) {
val postViewModel: NewChannelViewModel = viewModel()
postViewModel.load(accountViewModel.account, channel)
@@ -4,17 +4,17 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.PublicChatChannel
class NewChannelViewModel : ViewModel() {
private var account: Account? = null
private var originalChannel: Channel? = null
private var originalChannel: PublicChatChannel? = null
val channelName = mutableStateOf(TextFieldValue())
val channelPicture = mutableStateOf(TextFieldValue())
val channelDescription = mutableStateOf(TextFieldValue())
fun load(account: Account, channel: Channel?) {
fun load(account: Account, channel: PublicChatChannel?) {
this.account = account
if (channel != null) {
originalChannel = channel
@@ -22,6 +22,7 @@ import androidx.compose.material.icons.filled.ArrowForwardIos
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.CurrencyBitcoin
import androidx.compose.material.icons.filled.ShowChart
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material.icons.outlined.ArrowForwardIos
@@ -279,6 +280,15 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
}
}
if (lud16 != null && postViewModel.wantsZapraiser) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
ZapRaiserRequest(
stringResource(id = R.string.zapraiser),
postViewModel
)
}
}
val myUrlPreview = postViewModel.urlPreview
if (myUrlPreview != null) {
Row(modifier = Modifier.padding(top = 5.dp)) {
@@ -374,6 +384,12 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
}
}
if (postViewModel.canAddZapRaiser) {
AddZapraiserButton(postViewModel.wantsZapraiser) {
postViewModel.wantsZapraiser = !postViewModel.wantsZapraiser
}
}
MarkAsSensitive(postViewModel) {
postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive
}
@@ -460,6 +476,56 @@ private fun AddPollButton(
}
}
@Composable
private fun AddZapraiserButton(
isLnInvoiceActive: Boolean,
onClick: () -> Unit
) {
IconButton(
onClick = {
onClick()
}
) {
Box(
Modifier
.height(20.dp)
.width(25.dp)
) {
if (!isLnInvoiceActive) {
Icon(
imageVector = Icons.Default.ShowChart,
null,
modifier = Modifier.size(20.dp).align(Alignment.TopStart),
tint = MaterialTheme.colors.onBackground
)
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = Modifier
.size(13.dp)
.align(Alignment.BottomEnd),
tint = MaterialTheme.colors.onBackground
)
} else {
Icon(
imageVector = Icons.Default.ShowChart,
null,
modifier = Modifier.size(20.dp).align(Alignment.TopStart),
tint = BitcoinOrange
)
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = Modifier
.size(13.dp)
.align(Alignment.BottomEnd),
tint = BitcoinOrange
)
}
}
}
}
@Composable
private fun AddLnInvoiceButton(
isLnInvoiceActive: Boolean,
@@ -657,7 +723,8 @@ fun PostButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier =
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
)
),
contentPadding = PaddingValues(0.dp)
) {
Text(text = stringResource(R.string.post), color = Color.White)
}
@@ -16,6 +16,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.model.AddressableEvent
import com.vitorpamplona.amethyst.service.model.BaseTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
@@ -74,6 +75,11 @@ open class NewPostViewModel() : ViewModel() {
// NSFW, Sensitive
var wantsToMarkAsSensitive by mutableStateOf(false)
// ZapRaiser
var canAddZapRaiser by mutableStateOf(false)
var wantsZapraiser by mutableStateOf(false)
var zapRaiserAmount by mutableStateOf<Long?>(null)
open fun load(account: Account, replyingTo: Note?, quote: Note?) {
originalNote = replyingTo
replyingTo?.let { replyNote ->
@@ -105,11 +111,14 @@ open class NewPostViewModel() : ViewModel() {
}
canAddInvoice = account.userProfile().info?.lnAddress() != null
canAddZapRaiser = account.userProfile().info?.lnAddress() != null
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null
contentToAddUrl = null
wantsForwardZapTo = false
wantsToMarkAsSensitive = false
wantsZapraiser = false
zapRaiserAmount = null
forwardZapTo = null
forwardZapToEditting = TextFieldValue("")
@@ -130,12 +139,18 @@ open class NewPostViewModel() : ViewModel() {
null
}
val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount else null
if (wantsPoll) {
account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt, zapReceiver, wantsToMarkAsSensitive)
account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount)
} else if (originalNote?.channelHex() != null) {
account?.sendChannelMessage(tagger.message, tagger.channelHex!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive)
if (originalNote is AddressableEvent && originalNote?.address() != null) {
account?.sendLiveMessage(tagger.message, originalNote?.address()!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount)
} else {
account?.sendChannelMessage(tagger.message, tagger.channelHex!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount)
}
} else if (originalNote?.event is PrivateDmEvent) {
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive)
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount)
} else {
// adds markers
val rootId =
@@ -151,6 +166,7 @@ open class NewPostViewModel() : ViewModel() {
tags = null,
zapReceiver = zapReceiver,
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = localZapRaiserAmount,
replyingTo = replyId,
root = rootId,
directMentions = tagger.directMentions
@@ -228,6 +244,8 @@ open class NewPostViewModel() : ViewModel() {
closedAt = null
wantsInvoice = false
wantsZapraiser = false
zapRaiserAmount = null
wantsForwardZapTo = false
wantsToMarkAsSensitive = false
@@ -333,8 +351,7 @@ open class NewPostViewModel() : ViewModel() {
}
fun canPost(): Boolean {
return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice &&
(!wantsPoll || pollOptions.values.all { it.isNotEmpty() }) && contentToAddUrl == null
return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice && (!wantsZapraiser || zapRaiserAmount != null) && (!wantsPoll || pollOptions.values.all { it.isNotEmpty() }) && contentToAddUrl == null
}
fun includePollHashtagInMessage(include: Boolean, hashtag: String) {
@@ -1,5 +1,8 @@
package com.vitorpamplona.amethyst.ui.actions
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -35,10 +38,12 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
@@ -54,6 +59,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.launch
import java.lang.Math.round
@Composable
@@ -203,6 +209,7 @@ fun ServerConfigHeader() {
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ServerConfig(
item: RelaySetupInfo,
@@ -217,6 +224,8 @@ fun ServerConfig(
onDelete: (RelaySetupInfo) -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
Column(Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -252,15 +261,28 @@ fun ServerConfig(
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onToggleFollows(item) }
modifier = Modifier
.size(30.dp),
onClick = { }
) {
Icon(
painterResource(R.drawable.ic_home),
stringResource(R.string.home_feed),
modifier = Modifier
.padding(end = 5.dp)
.size(15.dp),
.size(15.dp)
.combinedClickable(
onClick = { onToggleFollows(item) },
onLongClick = {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.home_feed),
Toast.LENGTH_SHORT
).show()
}
}
),
tint = if (item.feedTypes.contains(FeedType.FOLLOWS)) {
Color.Green
} else {
@@ -272,14 +294,26 @@ fun ServerConfig(
}
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onTogglePrivateDMs(item) }
onClick = { }
) {
Icon(
painterResource(R.drawable.ic_dm),
stringResource(R.string.private_message_feed),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp),
.size(15.dp)
.combinedClickable(
onClick = { onTogglePrivateDMs(item) },
onLongClick = {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.private_message_feed),
Toast.LENGTH_SHORT
).show()
}
}
),
tint = if (item.feedTypes.contains(FeedType.PRIVATE_DMS)) {
Color.Green
} else {
@@ -291,14 +325,26 @@ fun ServerConfig(
}
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onTogglePublicChats(item) }
onClick = { }
) {
Icon(
imageVector = Icons.Default.Groups,
stringResource(R.string.public_chat_feed),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp),
.size(15.dp)
.combinedClickable(
onClick = { onTogglePublicChats(item) },
onLongClick = {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.public_chat_feed),
Toast.LENGTH_SHORT
).show()
}
}
),
tint = if (item.feedTypes.contains(FeedType.PUBLIC_CHATS)) {
Color.Green
} else {
@@ -310,14 +356,26 @@ fun ServerConfig(
}
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onToggleGlobal(item) }
onClick = { }
) {
Icon(
imageVector = Icons.Default.Public,
stringResource(R.string.global_feed),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp),
.size(15.dp)
.combinedClickable(
onClick = { onToggleGlobal(item) },
onLongClick = {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.global_feed),
Toast.LENGTH_SHORT
).show()
}
}
),
tint = if (item.feedTypes.contains(FeedType.GLOBAL)) {
Color.Green
} else {
@@ -337,7 +395,19 @@ fun ServerConfig(
stringResource(R.string.search_feed),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp),
.size(15.dp)
.combinedClickable(
onClick = { onToggleSearch(item) },
onLongClick = {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.search_feed),
Toast.LENGTH_SHORT
).show()
}
}
),
tint = if (item.feedTypes.contains(FeedType.SEARCH)) {
Color.Green
} else {
@@ -354,14 +424,26 @@ fun ServerConfig(
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onToggleDownload(item) }
onClick = { }
) {
Icon(
imageVector = Icons.Default.Download,
null,
stringResource(R.string.read_from_relay),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp),
.size(15.dp)
.combinedClickable(
onClick = { onToggleDownload(item) },
onLongClick = {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.read_from_relay),
Toast.LENGTH_SHORT
).show()
}
}
),
tint = if (item.read) {
Color.Green
} else {
@@ -382,14 +464,26 @@ fun ServerConfig(
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onToggleUpload(item) }
onClick = { }
) {
Icon(
imageVector = Icons.Default.Upload,
null,
stringResource(R.string.write_to_relay),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp),
.size(15.dp)
.combinedClickable(
onClick = { onToggleUpload(item) },
onLongClick = {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.write_to_relay),
Toast.LENGTH_SHORT
).show()
}
}
),
tint = if (item.write) {
Color.Green
} else {
@@ -410,10 +504,22 @@ fun ServerConfig(
Icon(
imageVector = Icons.Default.SyncProblem,
null,
stringResource(R.string.errors),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp),
.size(15.dp)
.combinedClickable(
onClick = { },
onLongClick = {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.errors),
Toast.LENGTH_SHORT
).show()
}
}
),
tint = if (item.errorCount > 0) Color.Yellow else Color.Green
)
@@ -427,8 +533,22 @@ fun ServerConfig(
Icon(
imageVector = Icons.Default.DeleteSweep,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
stringResource(R.string.spam),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp)
.combinedClickable(
onClick = { },
onLongClick = {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.spam),
Toast.LENGTH_SHORT
).show()
}
}
),
tint = if (item.spamCount > 0) Color.Yellow else Color.Green
)
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
@@ -15,6 +16,7 @@ import java.util.concurrent.atomic.AtomicBoolean
/**
* This class is designed to have a waiting time between two calls of invalidate
*/
@Stable
class BundledUpdate(
val delay: Long,
val dispatcher: CoroutineDispatcher = Dispatchers.Default
@@ -51,6 +53,7 @@ class BundledUpdate(
/**
* This class is designed to have a waiting time between two calls of invalidate
*/
@Stable
class BundledInsert<T>(
val delay: Long,
val dispatcher: CoroutineDispatcher = Dispatchers.Default
@@ -11,7 +11,6 @@ import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.request.ImageRequest
import coil.request.Options
import coil.size.Size
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import okio.Buffer
import java.security.MessageDigest
@@ -61,7 +60,6 @@ private fun svgString(msg: String): String {
class HashImageFetcher(
private val context: Context,
private val size: Size,
private val data: Uri
) : Fetcher {
@@ -81,17 +79,16 @@ class HashImageFetcher(
object Factory : Fetcher.Factory<Uri> {
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
return HashImageFetcher(options.context, options.size, data)
return HashImageFetcher(options.context, data)
}
}
}
object Robohash {
fun imageRequest(context: Context, message: String, robotSize: Size): ImageRequest {
fun imageRequest(context: Context, message: String): ImageRequest {
return ImageRequest
.Builder(context)
.data("robohash:$message")
.fetcherFactory(HashImageFetcher.Factory)
.size(robotSize)
.build()
}
}
@@ -10,12 +10,10 @@ import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter
import coil.size.Size
import java.util.Date
@Composable
@@ -33,18 +31,9 @@ fun RobohashAsyncImage(
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
) {
val context = LocalContext.current
val size = with(LocalDensity.current) {
remember {
robotSize.roundToPx()
}
}
val imageRequest = remember(robotSize, robot) {
Robohash.imageRequest(
context,
robot,
Size(size, size)
)
val imageRequest = remember(robot) {
Robohash.imageRequest(context, robot)
}
AsyncImage(
@@ -92,15 +81,9 @@ fun RobohashFallbackAsyncImage(
)
} else {
val context = LocalContext.current
val painter = with(LocalDensity.current) {
rememberAsyncImagePainter(
model = Robohash.imageRequest(
context,
robot,
Size(robotSize.roundToPx(), robotSize.roundToPx())
)
)
}
val painter = rememberAsyncImagePainter(
model = Robohash.imageRequest(context, robot)
)
AsyncImage(
model = model,
@@ -52,11 +52,14 @@ import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSource
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.source.hls.HlsMediaSource
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
import com.google.android.exoplayer2.ui.StyledPlayerView
import com.google.android.exoplayer2.upstream.DataSource
import com.vitorpamplona.amethyst.VideoCache
import com.vitorpamplona.amethyst.service.HttpClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -144,8 +147,10 @@ fun VideoView(
if (videoUri.startsWith("file")) {
setMediaItem(media)
} else if (videoUri.endsWith("m3u8")) {
// Should not use cache.
val dataSourceFactory: DataSource.Factory = OkHttpDataSource.Factory(HttpClient.getHttpClient())
setMediaSource(
HlsMediaSource.Factory(VideoCache.get()).createMediaSource(
HlsMediaSource.Factory(dataSourceFactory).createMediaSource(
media
)
)
@@ -0,0 +1,102 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@Composable
fun ZapRaiserRequest(
titleText: String? = null,
newPostViewModel: NewPostViewModel
) {
Column(
modifier = Modifier
.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp)
) {
Icon(
painter = painterResource(R.drawable.lightning),
null,
modifier = Modifier.size(20.dp),
tint = Color.Unspecified
)
Text(
text = titleText ?: stringResource(R.string.zapraiser),
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp)
)
}
Divider()
Text(
text = stringResource(R.string.zapraiser_explainer),
color = MaterialTheme.colors.placeholderText,
modifier = Modifier.padding(vertical = 10.dp)
)
OutlinedTextField(
label = { Text(text = stringResource(R.string.zapraiser_target_amount_in_sats)) },
modifier = Modifier.fillMaxWidth(),
value = if (newPostViewModel.zapRaiserAmount != null) {
newPostViewModel.zapRaiserAmount.toString()
} else {
""
},
onValueChange = {
runCatching {
if (it.isEmpty()) {
newPostViewModel.zapRaiserAmount = null
} else {
newPostViewModel.zapRaiserAmount = it.toLongOrNull()
}
}
},
placeholder = {
Text(
text = "1000",
color = MaterialTheme.colors.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Number
),
singleLine = true
)
}
}
}
@@ -7,6 +7,10 @@ import com.vitorpamplona.amethyst.model.Note
object BookmarkPrivateFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
override fun feedKey(): String {
return account.userProfile().latestBookmarkList?.id ?: ""
}
override fun feed(): List<Note> {
val privKey = account.loggedIn.privKey ?: return emptyList()
@@ -7,6 +7,9 @@ import com.vitorpamplona.amethyst.model.Note
object BookmarkPublicFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
override fun feedKey(): String {
return BookmarkPrivateFeedFilter.account.userProfile().latestBookmarkList?.id ?: ""
}
override fun feed(): List<Note> {
val bookmarks = account.userProfile().latestBookmarkList
@@ -5,6 +5,11 @@ import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
class ChannelFeedFilter(val channel: Channel, val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return channel.idHex
}
// returns the last Note of each user.
override fun feed(): List<Note> {
return channel.notes
@@ -6,6 +6,10 @@ import com.vitorpamplona.amethyst.model.User
class ChatroomFeedFilter(val withUser: User, val account: Account) : AdditiveFeedFilter<Note>() {
// returns the last Note of each user.
override fun feedKey(): String {
return withUser.pubkeyHex
}
override fun feed(): List<Note> {
val messages = account
.userProfile()
@@ -11,6 +11,10 @@ import kotlin.time.measureTimedValue
class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex
}
// returns the last Note of each user.
override fun feed(): List<Note> {
val me = account.userProfile()
@@ -10,6 +10,10 @@ import kotlin.time.measureTimedValue
class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex
}
// returns the last Note of each user.
override fun feed(): List<Note> {
val me = account.userProfile()
@@ -18,6 +18,11 @@ abstract class FeedFilter<T> {
return feed.take(1000)
}
/**
* Returns a string that serves as the key to invalidate the list if it changes.
*/
abstract fun feedKey(): String
abstract fun feed(): List<T>
}
@@ -7,6 +7,10 @@ import com.vitorpamplona.amethyst.service.model.*
class GlobalFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex
}
override fun feed(): List<Note> {
val notes = innerApplyFilter(LocalCache.notes.values)
val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
@@ -12,6 +12,10 @@ object HashtagFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account
var tag: String? = null
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + tag
}
fun loadHashtag(account: Account, tag: String?) {
this.account = account
this.tag = tag
@@ -5,6 +5,9 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
class HiddenAccountsFeedFilter(val account: Account) : FeedFilter<User>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + HashtagFeedFilter.tag
}
override fun feed(): List<User> {
return (account.hiddenUsers + account.transientHiddenUsers)
@@ -3,10 +3,17 @@ package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList
}
override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.notes.values))
}
@@ -22,7 +29,7 @@ class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter<Not
return collection
.asSequence()
.filter {
(it.event is TextNoteEvent || it.event is PollNoteEvent) &&
(it.event is TextNoteEvent || it.event is PollNoteEvent || it.event is ChannelMessageEvent || it.event is LiveActivitiesChatMessageEvent) &&
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it) } ?: true &&
@@ -0,0 +1,69 @@
package com.vitorpamplona.amethyst.ui.dal
import android.util.Log
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import okhttp3.Request
import java.util.Date
class HomeLiveActivitiesFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList
}
override fun feed(): List<Note> {
val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
return sort(longFormNotes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
checkNotInMainThread()
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
val fortyEightHrs = (Date().time / 1000) - 60 * 60 * 48 // hrs
return collection
.asSequence()
.filter { it ->
val noteEvent = it.event
(noteEvent is LiveActivitiesEvent && noteEvent.createdAt > fortyEightHrs && noteEvent.status() == "live" && checkIfOnline(noteEvent.streaming())) &&
(it.author?.pubkeyHex in followingKeySet || (noteEvent.isTaggedHashes(followingTagSet))) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true
}
.toSet()
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
}
fun checkIfOnline(url: String?): Boolean {
if (url.isNullOrBlank()) return false
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.get()
.build()
return try {
HttpClient.getHttpClient().newCall(request).execute().code == 200
} catch (e: Exception) {
Log.e("LiveActivities", "Failed to check streaming url $url", e)
false
}
}
@@ -12,6 +12,10 @@ import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList
}
override fun feed(): List<Note> {
val notes = innerApplyFilter(LocalCache.notes.values)
val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
@@ -8,6 +8,10 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.*
class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + account.defaultNotificationFollowList
}
override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.notes.values))
}
@@ -8,6 +8,10 @@ import com.vitorpamplona.amethyst.service.model.PeopleListEvent
object PeopleListFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
override fun feedKey(): String {
return account.userProfile().pubkeyHex
}
override fun feed(): List<Note> {
val privKey = account.loggedIn.privKey ?: return emptyList()
@@ -7,6 +7,10 @@ import com.vitorpamplona.amethyst.model.ThreadAssembler
@Immutable
class ThreadFeedFilter(val noteId: String) : FeedFilter<Note>() {
override fun feedKey(): String {
return noteId
}
override fun feed(): List<Note> {
val cachedSignatures: MutableMap<Note, String> = mutableMapOf()
val eventsToWatch = ThreadAssembler().findThreadFor(noteId) ?: emptySet()
@@ -6,6 +6,10 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.AppRecommendationEvent
class UserProfileAppRecommendationsFeedFilter(val user: User) : FeedFilter<Note>() {
override fun feedKey(): String {
return user.pubkeyHex
}
override fun feed(): List<Note> {
val recommendations = LocalCache.addressables.values.filter {
(it.event as? AppRecommendationEvent)?.pubKey == user.pubkeyHex
@@ -9,6 +9,10 @@ object UserProfileBookmarksFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
var user: User? = null
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + user?.pubkeyHex
}
fun loadUserProfile(accountLoggedIn: Account, user: User?) {
account = accountLoggedIn
this.user = user
@@ -8,6 +8,10 @@ object UserProfileConversationsFeedFilter : FeedFilter<Note>() {
var account: Account? = null
var user: User? = null
override fun feedKey(): String {
return account?.userProfile()?.pubkeyHex + "-" + user?.pubkeyHex
}
fun loadUserProfile(accountLoggedIn: Account, user: User?) {
account = accountLoggedIn
this.user = user
@@ -6,6 +6,9 @@ import com.vitorpamplona.amethyst.model.User
class UserProfileFollowersFeedFilter(val user: User, val account: Account) : FeedFilter<User>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + user.pubkeyHex
}
override fun feed(): List<User> {
return LocalCache.users.values.filter { it.isFollowing(user) && !account.isHidden(it) }
}
@@ -7,6 +7,10 @@ import com.vitorpamplona.amethyst.service.model.ContactListEvent
class UserProfileFollowsFeedFilter(val user: User, val account: Account) : FeedFilter<User>() {
override fun feedKey(): String {
return account.userProfile()?.pubkeyHex + "-" + user.pubkeyHex
}
val cache: MutableMap<ContactListEvent, List<User>> = mutableMapOf()
override fun feed(): List<User> {
@@ -12,6 +12,10 @@ object UserProfileNewThreadFeedFilter : FeedFilter<Note>() {
var account: Account? = null
var user: User? = null
override fun feedKey(): String {
return account?.userProfile()?.pubkeyHex + "-" + user?.pubkeyHex
}
fun loadUserProfile(accountLoggedIn: Account, user: User) {
account = accountLoggedIn
this.user = user
@@ -8,6 +8,10 @@ import com.vitorpamplona.amethyst.service.model.ReportEvent
object UserProfileReportsFeedFilter : FeedFilter<Note>() {
var user: User? = null
override fun feedKey(): String {
return user?.pubkeyHex ?: ""
}
fun loadUserProfile(user: User?) {
this.user = user
}
@@ -5,6 +5,11 @@ import com.vitorpamplona.amethyst.service.model.zaps.UserZaps
import com.vitorpamplona.amethyst.ui.screen.ZapReqResponse
class UserProfileZapsFeedFilter(val user: User) : FeedFilter<ZapReqResponse>() {
override fun feedKey(): String {
return user.pubkeyHex
}
override fun feed(): List<ZapReqResponse> {
return UserZaps.forProfileFeed(user.zaps)
}
@@ -7,6 +7,10 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.*
class VideoFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + account.defaultStoriesFollowList
}
override fun feed(): List<Note> {
val notes = innerApplyFilter(LocalCache.notes.values)
@@ -101,7 +101,7 @@ private fun RenderBottomMenu(
navEntryState: State<NavBackStackEntry?>,
nav: (Route, Boolean) -> Unit
) {
Column() {
Column(modifier = Modifier.height(50.dp)) {
Divider(
thickness = 0.25.dp
)
@@ -13,6 +13,7 @@ import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedLiveActivitiesViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
@@ -38,6 +39,7 @@ import kotlinx.coroutines.launch
fun AppNavigation(
homeFeedViewModel: NostrHomeFeedViewModel,
repliesFeedViewModel: NostrHomeRepliesFeedViewModel,
liveActivitiesViewModel: NostrHomeFeedLiveActivitiesViewModel,
knownFeedViewModel: NostrChatroomListKnownFeedViewModel,
newFeedViewModel: NostrChatroomListNewFeedViewModel,
searchFeedViewModel: NostrGlobalFeedViewModel,
@@ -60,54 +62,14 @@ fun AppNavigation(
}
NavHost(navController, startDestination = Route.Home.route) {
Route.Video.let { route ->
composable(route.route, route.arguments, content = {
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
if (scrollToTop) {
videoFeedViewModel.sendToTop()
it.arguments?.remove("scrollToTop")
}
VideoScreen(
videoFeedView = videoFeedViewModel,
accountViewModel = accountViewModel,
nav = nav
)
})
}
Route.Search.let { route ->
composable(route.route, route.arguments, content = {
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
if (scrollToTop) {
searchFeedViewModel.sendToTop()
it.arguments?.remove("scrollToTop")
}
SearchScreen(
searchFeedViewModel = searchFeedViewModel,
accountViewModel = accountViewModel,
nav = nav
)
})
}
Route.Home.let { route ->
composable(route.route, route.arguments, content = { it ->
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
val nip47 = it.arguments?.getString("nip47")
if (scrollToTop) {
homeFeedViewModel.sendToTop()
repliesFeedViewModel.sendToTop()
it.arguments?.remove("scrollToTop")
}
HomeScreen(
homeFeedViewModel = homeFeedViewModel,
repliesFeedViewModel = repliesFeedViewModel,
liveActivitiesViewModel = liveActivitiesViewModel,
accountViewModel = accountViewModel,
nav = nav,
nip47 = nip47
@@ -124,25 +86,6 @@ fun AppNavigation(
})
}
Route.Notification.let { route ->
composable(route.route, route.arguments, content = {
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
if (scrollToTop) {
notifFeedViewModel.clear()
notifFeedViewModel.invalidateDataAndSendToTop()
it.arguments?.remove("scrollToTop")
}
NotificationScreen(
notifFeedViewModel = notifFeedViewModel,
userReactionsStatsModel = userReactionsStatsModel,
accountViewModel = accountViewModel,
nav = nav
)
})
}
composable(
Route.Message.route,
content = {
@@ -155,6 +98,37 @@ fun AppNavigation(
}
)
Route.Video.let { route ->
composable(route.route, route.arguments, content = {
VideoScreen(
videoFeedView = videoFeedViewModel,
accountViewModel = accountViewModel,
nav = nav
)
})
}
Route.Search.let { route ->
composable(route.route, route.arguments, content = {
SearchScreen(
searchFeedViewModel = searchFeedViewModel,
accountViewModel = accountViewModel,
nav = nav
)
})
}
Route.Notification.let { route ->
composable(route.route, route.arguments, content = {
NotificationScreen(
notifFeedViewModel = notifFeedViewModel,
userReactionsStatsModel = userReactionsStatsModel,
accountViewModel = accountViewModel,
nav = nav
)
})
}
composable(Route.BlockedUsers.route, content = { HiddenUsersScreen(accountViewModel, nav) })
composable(Route.Bookmarks.route, content = { BookmarkListScreen(accountViewModel, nav) })
@@ -115,6 +115,8 @@ private fun RenderTopRouteBar(
accountViewModel: AccountViewModel
) {
when (currentRoute) {
Route.Channel.base -> NoTopBar()
Route.Room.base -> NoTopBar()
// Route.Profile.route -> TopBarWithBackButton(nav)
Route.Home.base -> HomeTopBar(followLists, scaffoldState, accountViewModel)
Route.Video.base -> StoriesTopBar(followLists, scaffoldState, accountViewModel)
@@ -123,6 +125,10 @@ private fun RenderTopRouteBar(
}
}
@Composable
fun NoTopBar() {
}
@Composable
fun StoriesTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
GenericTopBar(scaffoldState, accountViewModel) { accountViewModel ->
@@ -206,7 +212,7 @@ fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewMod
NewRelayListView({ wantsToEditRelays = false }, accountViewModel)
}
Column() {
Column(modifier = Modifier.height(50.dp)) {
TopAppBar(
elevation = 0.dp,
backgroundColor = MaterialTheme.colors.surface,
@@ -33,31 +33,27 @@ sealed class Route(
get() = route.substringBefore("?")
object Home : Route(
route = "Home?scrollToTop={scrollToTop}&nip47={nip47}",
route = "Home?nip47={nip47}",
icon = R.drawable.ic_home,
arguments = listOf(
navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false },
navArgument("nip47") { type = NavType.StringType; nullable = true; defaultValue = null }
).toImmutableList(),
hasNewItems = { accountViewModel, newNotes -> HomeLatestItem.hasNewItems(accountViewModel, newNotes) }
)
object Search : Route(
route = "Search?scrollToTop={scrollToTop}",
icon = R.drawable.ic_globe,
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }).toImmutableList()
route = "Search",
icon = R.drawable.ic_globe
)
object Video : Route(
route = "Video?scrollToTop={scrollToTop}",
icon = R.drawable.ic_video,
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }).toImmutableList()
route = "Video",
icon = R.drawable.ic_video
)
object Notification : Route(
route = "Notification?scrollToTop={scrollToTop}",
route = "Notification",
icon = R.drawable.ic_notifications,
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }).toImmutableList(),
hasNewItems = { accountViewModel, newNotes -> NotificationLatestItem.hasNewItems(accountViewModel, newNotes) }
)
@@ -108,7 +108,7 @@ private fun ChannelRoomCompose(
}
val channelName by remember(channelState) {
derivedStateOf {
channel.info.name
channel.toBestDisplayName()
}
}
@@ -1,7 +1,6 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
@@ -34,7 +33,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -55,8 +53,8 @@ import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
@@ -69,17 +67,21 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeMe
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeThem
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
import com.vitorpamplona.amethyst.ui.theme.Size13dp
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.Size16dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -92,243 +94,407 @@ fun ChatroomMessageCompose(
nav: (String) -> Unit,
onWantsToReply: (Note) -> Unit
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
val loggedIn = remember(accountState) { accountState?.account?.userProfile() } ?: return
val noteState by baseNote.live().metadata.observeAsState()
val note = remember(noteState) { noteState?.note } ?: return
val noteReportsState by baseNote.live().reports.observeAsState()
val noteForReports = remember(noteReportsState) { noteReportsState?.note } ?: return
val noteEvent = remember(noteState) { note.event }
var popupExpanded by remember { mutableStateOf(false) }
val noteEvent = remember(noteState) { noteState?.note?.event }
if (noteEvent == null) {
BlankNote(
Modifier.combinedClickable(
onClick = { },
onLongClick = { popupExpanded = true }
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
BlankNote(
remember {
Modifier.combinedClickable(
onClick = { },
onLongClick = showPopup
)
}
)
}
} else {
CheckHiddenChatMessage(
baseNote,
routeForLastRead,
innerQuote,
parentBackgroundColor,
accountViewModel,
nav,
onWantsToReply
)
}
}
@Composable
fun CheckHiddenChatMessage(
baseNote: Note,
routeForLastRead: String?,
innerQuote: Boolean = false,
parentBackgroundColor: MutableState<Color>? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
onWantsToReply: (Note) -> Unit
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val isHidden by remember(accountState) {
derivedStateOf {
val isSensitive = baseNote.event?.isSensitive() ?: false
accountState?.account?.isHidden(baseNote.author!!) == true || (isSensitive && accountState?.account?.showSensitiveContent == false)
}
}
if (!isHidden) {
LoadedChatMessageCompose(
baseNote,
routeForLastRead,
innerQuote,
parentBackgroundColor,
accountViewModel,
nav,
onWantsToReply
)
}
}
@Composable
fun LoadedChatMessageCompose(
baseNote: Note,
routeForLastRead: String?,
innerQuote: Boolean = false,
parentBackgroundColor: MutableState<Color>? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
onWantsToReply: (Note) -> Unit
) {
var state by remember {
mutableStateOf(
NoteComposeReportState(
isAcceptable = true,
canPreview = true,
relevantReports = persistentSetOf()
)
)
}
note.let {
NoteQuickActionMenu(it, popupExpanded, { popupExpanded = false }, accountViewModel)
WatchForReports(baseNote, accountViewModel) { newIsAcceptable, newCanPreview, newRelevantReports ->
if (newIsAcceptable != state.isAcceptable || newCanPreview != state.canPreview) {
state = NoteComposeReportState(newIsAcceptable, newCanPreview, newRelevantReports.toImmutableSet())
}
} else if (account.isHidden(noteForReports.author!!)) {
// Does nothing
}
var showReportedNote by remember { mutableStateOf(false) }
val showHiddenNote by remember(state, showReportedNote) {
derivedStateOf {
!state.isAcceptable && !showReportedNote
}
}
if (showHiddenNote) {
HiddenNote(
state.relevantReports,
accountViewModel,
Modifier,
innerQuote,
nav,
onClick = { showReportedNote = true }
)
} else {
var showHiddenNote by remember { mutableStateOf(false) }
var isAcceptableAndCanPreview by remember { mutableStateOf(Pair(true, true)) }
LaunchedEffect(key1 = noteReportsState, key2 = accountState) {
launch(Dispatchers.Default) {
account.userProfile().let { loggedIn ->
val newCanPreview = note.author?.pubkeyHex == loggedIn.pubkeyHex ||
(note.author?.let { loggedIn.isFollowingCached(it) } ?: true) ||
!(noteForReports.hasAnyReports())
val newIsAcceptable = account.isAcceptable(noteForReports)
if (newIsAcceptable != isAcceptableAndCanPreview.first && newCanPreview != isAcceptableAndCanPreview.second) {
isAcceptableAndCanPreview = Pair(newIsAcceptable, newCanPreview)
}
}
val canPreview by remember(state, showReportedNote) {
derivedStateOf {
(!state.isAcceptable && showReportedNote) || state.canPreview
}
}
if (!isAcceptableAndCanPreview.first && !showHiddenNote) {
val reports = remember {
account.getRelevantReports(noteForReports).toImmutableSet()
}
HiddenNote(
reports,
accountViewModel,
Modifier,
innerQuote,
nav,
onClick = { showHiddenNote = true }
)
NormalChatNote(
baseNote,
routeForLastRead,
innerQuote,
canPreview,
parentBackgroundColor,
accountViewModel,
nav,
onWantsToReply
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun NormalChatNote(
note: Note,
routeForLastRead: String?,
innerQuote: Boolean = false,
canPreview: Boolean = true,
parentBackgroundColor: MutableState<Color>? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
onWantsToReply: (Note) -> Unit
) {
val drawAuthorInfo by remember {
derivedStateOf {
(innerQuote || !accountViewModel.isLoggedUser(note.author)) && note.event !is PrivateDmEvent
}
}
val loggedInColors = MaterialTheme.colors.mediumImportanceLink
val otherColors = MaterialTheme.colors.subtleBorder
val defaultBackground = MaterialTheme.colors.background
val backgroundBubbleColor = remember {
if (accountViewModel.isLoggedUser(note.author)) {
mutableStateOf(loggedInColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground))
} else {
val loggedInColors = MaterialTheme.colors.mediumImportanceLink
val otherColors = MaterialTheme.colors.subtleBorder
val defaultBackground = MaterialTheme.colors.background
mutableStateOf(otherColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground))
}
}
val alignment: Arrangement.Horizontal = remember {
if (accountViewModel.isLoggedUser(note.author)) {
Arrangement.End
} else {
Arrangement.Start
}
}
val shape: Shape = remember {
if (accountViewModel.isLoggedUser(note.author)) {
ChatBubbleShapeMe
} else {
ChatBubbleShapeThem
}
}
val backgroundBubbleColor = remember {
if (note.author == loggedIn) {
mutableStateOf(loggedInColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground))
} else {
mutableStateOf(otherColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground))
}
LaunchedEffect(key1 = routeForLastRead) {
routeForLastRead?.let {
val createdAt = note.createdAt()
if (createdAt != null) {
accountViewModel.account.markAsRead(it, createdAt)
}
val alignment: Arrangement.Horizontal = remember {
if (note.author == loggedIn) {
Arrangement.End
} else {
Arrangement.Start
}
}
}
Column() {
val modif = remember {
if (innerQuote) {
Modifier.padding(top = 10.dp, end = 5.dp)
} else {
Modifier
.fillMaxWidth(1f)
.padding(
start = 12.dp,
end = 12.dp,
top = 5.dp,
bottom = 5.dp
)
}
val shape: Shape = remember {
if (note.author == loggedIn) {
ChatBubbleShapeMe
} else {
ChatBubbleShapeThem
}
}
Row(
modifier = modif,
horizontalArrangement = alignment
) {
val availableBubbleSize = remember { mutableStateOf(IntSize.Zero) }
var popupExpanded by remember { mutableStateOf(false) }
val modif2 = remember {
if (innerQuote) Modifier else Modifier.fillMaxWidth(0.85f)
}
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = routeForLastRead) {
routeForLastRead?.let {
scope.launch(Dispatchers.IO) {
val lastTime = accountViewModel.account.loadLastRead(it)
val createdAt = note.createdAt()
if (createdAt != null) {
accountViewModel.account.markAsRead(it, createdAt)
}
}
}
}
Column() {
val modif = remember {
if (innerQuote) {
Modifier.padding(top = 10.dp, end = 5.dp)
} else {
Modifier
.fillMaxWidth(1f)
.padding(
start = 12.dp,
end = 12.dp,
top = 5.dp,
bottom = 5.dp
)
}
}
Row(
modifier = modif,
horizontalArrangement = alignment
) {
var availableBubbleSize by remember { mutableStateOf(IntSize.Zero) }
val modif2 = if (innerQuote) Modifier else Modifier.fillMaxWidth(0.85f)
Row(
horizontalArrangement = alignment,
modifier = modif2.onSizeChanged {
availableBubbleSize = it
}
) {
Surface(
color = backgroundBubbleColor.value,
shape = shape,
modifier = Modifier
.combinedClickable(
onClick = {
if (noteEvent is ChannelCreateEvent) {
nav("Channel/${note.idHex}")
}
},
onLongClick = { popupExpanded = true }
)
) {
var bubbleSize by remember { mutableStateOf(IntSize.Zero) }
Column(
modifier = Modifier
.padding(start = 10.dp, end = 5.dp, bottom = 5.dp)
.onSizeChanged {
bubbleSize = it
}
) {
if ((innerQuote || note.author != loggedIn) && noteEvent is ChannelMessageEvent) {
DrawAuthorInfo(
baseNote,
alignment,
nav
)
} else {
Spacer(modifier = Modifier.height(5.dp))
}
val replyTo = note.replyTo
if (!innerQuote && !replyTo.isNullOrEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
replyTo.lastOrNull()?.let { note ->
ChatroomMessageCompose(
note,
null,
innerQuote = true,
parentBackgroundColor = backgroundBubbleColor,
accountViewModel = accountViewModel,
nav = nav,
onWantsToReply = onWantsToReply
)
}
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
when (noteEvent) {
is ChannelCreateEvent -> {
RenderCreateChannelNote(note)
}
is ChannelMetadataEvent -> {
RenderChangeChannelMetadataNote(note)
}
else -> {
RenderRegularTextNote(
note,
isAcceptableAndCanPreview.second,
backgroundBubbleColor,
accountViewModel,
nav
)
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.padding(top = 5.dp)
.then(
with(LocalDensity.current) {
Modifier.widthIn(
bubbleSize.width.toDp(),
availableBubbleSize.width.toDp()
)
}
)
) {
StatusRow(
baseNote,
accountViewModel,
onWantsToReply
)
}
val clickableModifier = remember {
Modifier
.combinedClickable(
onClick = {
if (note.event is ChannelCreateEvent) {
nav("Channel/${note.idHex}")
}
}
}
},
onLongClick = { popupExpanded = true }
)
}
NoteQuickActionMenu(
Row(
horizontalArrangement = alignment,
modifier = modif2.onSizeChanged {
availableBubbleSize.value = it
}
) {
Surface(
color = backgroundBubbleColor.value,
shape = shape,
modifier = clickableModifier
) {
RenderBubble(
note,
popupExpanded,
{ popupExpanded = false },
accountViewModel
drawAuthorInfo,
alignment,
innerQuote,
backgroundBubbleColor,
onWantsToReply,
canPreview,
availableBubbleSize,
accountViewModel,
nav
)
}
}
NoteQuickActionMenu(
note = note,
popupExpanded = popupExpanded,
onDismiss = { popupExpanded = false },
accountViewModel = accountViewModel
)
}
}
}
@Composable
private fun RenderBubble(
baseNote: Note,
drawAuthorInfo: Boolean,
alignment: Arrangement.Horizontal,
innerQuote: Boolean,
backgroundBubbleColor: MutableState<Color>,
onWantsToReply: (Note) -> Unit,
canPreview: Boolean,
availableBubbleSize: MutableState<IntSize>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val bubbleSize = remember { mutableStateOf(IntSize.Zero) }
val bubbleModifier = remember {
Modifier
.padding(start = 10.dp, end = 5.dp, bottom = 5.dp)
.onSizeChanged {
bubbleSize.value = it
}
}
Column(modifier = bubbleModifier) {
if (drawAuthorInfo) {
DrawAuthorInfo(
baseNote,
alignment,
nav
)
} else {
Spacer(modifier = StdVertSpacer)
}
RenderReplyRow(
note = baseNote,
innerQuote = innerQuote,
backgroundBubbleColor = backgroundBubbleColor,
accountViewModel = accountViewModel,
nav = nav,
onWantsToReply = onWantsToReply
)
NoteRow(
note = baseNote,
canPreview = canPreview,
backgroundBubbleColor = backgroundBubbleColor,
accountViewModel = accountViewModel,
nav = nav
)
ConstrainedStatusRow(
bubbleSize = bubbleSize,
availableBubbleSize = availableBubbleSize
) {
StatusRow(
baseNote = baseNote,
accountViewModel = accountViewModel,
onWantsToReply = onWantsToReply
)
}
}
}
@Composable
private fun RenderReplyRow(
note: Note,
innerQuote: Boolean,
backgroundBubbleColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
onWantsToReply: (Note) -> Unit
) {
val replyTo by remember {
derivedStateOf {
note.replyTo?.lastOrNull()
}
}
if (!innerQuote && replyTo != null) {
Row(verticalAlignment = Alignment.CenterVertically) {
replyTo?.let { note ->
ChatroomMessageCompose(
note,
null,
innerQuote = true,
parentBackgroundColor = backgroundBubbleColor,
accountViewModel = accountViewModel,
nav = nav,
onWantsToReply = onWantsToReply
)
}
}
}
}
@Composable
private fun NoteRow(
note: Note,
canPreview: Boolean,
backgroundBubbleColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
Row(verticalAlignment = Alignment.CenterVertically) {
when (remember(note) { note.event }) {
is ChannelCreateEvent -> {
RenderCreateChannelNote(note)
}
is ChannelMetadataEvent -> {
RenderChangeChannelMetadataNote(note)
}
else -> {
RenderRegularTextNote(
note,
canPreview,
backgroundBubbleColor,
accountViewModel,
nav
)
}
}
}
}
@Composable
private fun ConstrainedStatusRow(
bubbleSize: MutableState<IntSize>,
availableBubbleSize: MutableState<IntSize>,
content: @Composable () -> Unit
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.padding(top = 5.dp)
.then(
with(LocalDensity.current) {
Modifier.widthIn(
bubbleSize.value.width.toDp(),
availableBubbleSize.value.width.toDp()
)
}
)
) {
content()
}
}
@Composable
private fun StatusRow(
baseNote: Note,
@@ -341,15 +507,15 @@ private fun StatusRow(
Row(verticalAlignment = Alignment.CenterVertically) {
ChatTimeAgo(time)
RelayBadges(baseNote)
Spacer(modifier = Modifier.width(10.dp))
Spacer(modifier = DoubleHorzSpacer)
}
Row(verticalAlignment = Alignment.CenterVertically) {
LikeReaction(baseNote, grayTint, accountViewModel)
Spacer(modifier = Modifier.width(5.dp))
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote, grayTint, accountViewModel)
Spacer(modifier = Modifier.width(5.dp))
ReplyReaction(baseNote, grayTint, accountViewModel, showCounter = false, iconSize = 16.dp) {
Spacer(modifier = StdHorzSpacer)
ReplyReaction(baseNote, grayTint, accountViewModel, showCounter = false, iconSize = Size16dp) {
onWantsToReply(baseNote)
}
}
@@ -359,13 +525,7 @@ private fun StatusRow(
fun ChatTimeAgo(time: Long) {
val context = LocalContext.current
var timeStr by remember { mutableStateOf("") }
LaunchedEffect(key1 = time) {
launch(Dispatchers.IO) {
timeStr = timeAgoShort(time, context = context)
}
}
val timeStr = remember { timeAgoShort(time, context = context) }
Text(
timeStr,
@@ -553,13 +713,13 @@ private fun RelayBadges(baseNote: Note) {
if (state.shouldDisplayExpandButton && !expanded) {
IconButton(
modifier = Modifier.then(Modifier.size(15.dp)),
modifier = Size15Modifier,
onClick = { expanded = true }
) {
Icon(
imageVector = Icons.Default.ChevronRight,
null,
modifier = Modifier.size(15.dp),
modifier = Size15Modifier,
tint = MaterialTheme.colors.placeholderText
)
}
@@ -599,7 +759,7 @@ fun RenderRelay(dirtyUrl: String) {
) {
RobohashFallbackAsyncImage(
robot = iconUrl,
robotSize = remember { 13.dp },
robotSize = Size13dp,
model = iconUrl,
contentDescription = stringResource(id = R.string.relay_icon),
colorFilter = RelayIconFilter,
@@ -222,30 +222,35 @@ fun RenderZapGallery(
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
Row(remember { Modifier.fillMaxWidth() }) {
Box(
modifier = remember {
Modifier
.width(55.dp)
.padding(0.dp)
}
) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = "Zaps",
tint = BitcoinOrange,
modifier = remember {
Modifier
.size(25.dp)
.align(Alignment.TopEnd)
}
)
}
Row(Modifier.fillMaxWidth()) {
ZapIcon()
AuthorGalleryZaps(zapEvents, backgroundColor, nav, accountViewModel)
}
}
@Composable
private fun ZapIcon() {
Box(
modifier = remember {
Modifier
.width(55.dp)
.padding(0.dp)
}
) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = "Zaps",
tint = BitcoinOrange,
modifier = remember {
Modifier
.size(25.dp)
.align(Alignment.TopEnd)
}
)
}
}
@Composable
fun RenderBoostGallery(
boostEvents: ImmutableList<Note>,
@@ -313,7 +318,7 @@ private fun AuthorPictureAndComment(
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
var content by remember {
val content = remember {
mutableStateOf(
ZapAmountCommentNotification(
user = zapRequest.author,
@@ -330,28 +335,24 @@ private fun AuthorPictureAndComment(
val amount = (zapEvent?.event as? LnZapEvent)?.amount
if (decryptedContent != null) {
val newAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey)
content = ZapAmountCommentNotification(newAuthor, decryptedContent.content.ifBlank { null }, showAmountAxis(amount))
content.value = ZapAmountCommentNotification(newAuthor, decryptedContent.content.ifBlank { null }, showAmountAxis(amount))
} else {
if (!zapRequest.event?.content().isNullOrBlank() || amount != null) {
content = ZapAmountCommentNotification(zapRequest.author, zapRequest.event?.content()?.ifBlank { null }, showAmountAxis(amount))
content.value = ZapAmountCommentNotification(zapRequest.author, zapRequest.event?.content()?.ifBlank { null }, showAmountAxis(amount))
}
}
}
}
}
val route by remember {
derivedStateOf {
"User/${content.user?.pubkeyHex}"
}
}
content.user?.let { user ->
Row(
modifier = Modifier.clickable {
nav("User/${content.value.user?.pubkeyHex}")
},
verticalAlignment = Alignment.CenterVertically
) {
AuthorPictureAndComment(
author = user,
comment = content.comment,
amount = content.amount,
route = route,
authorComment = content,
backgroundColor = backgroundColor,
nav = nav,
accountViewModel = accountViewModel
@@ -375,69 +376,76 @@ val commentTextSize = 12.sp
@Composable
private fun AuthorPictureAndComment(
author: User,
comment: String?,
amount: String?,
route: String,
authorComment: MutableState<ZapAmountCommentNotification>,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
val modifier = remember {
Modifier.clickable {
nav(route)
}
Box(modifier = sizedModifier, contentAlignment = Alignment.BottomCenter) {
DisplayPicture(authorComment, accountViewModel)
DisplayAmount(authorComment)
}
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
Box(modifier = sizedModifier, contentAlignment = Alignment.BottomCenter) {
FastNoteAuthorPicture(
author = author,
size = Size35dp,
accountViewModel = accountViewModel,
pictureModifier = simpleModifier
)
DisplayComment(authorComment, backgroundColor, nav, accountViewModel)
}
amount?.let {
Box(
modifier = amountBoxModifier,
contentAlignment = Alignment.BottomCenter
) {
val backgroundColor = MaterialTheme.colors.overPictureBackground
Box(
modifier = remember {
Modifier
.fillMaxWidth()
.drawBehind { drawRect(backgroundColor) }
},
contentAlignment = Alignment.BottomCenter
) {
Text(
text = it,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.secondaryVariant,
fontSize = commentTextSize,
modifier = bottomPadding1dp
)
}
}
@Composable
fun DisplayPicture(authorComment: MutableState<ZapAmountCommentNotification>, accountViewModel: AccountViewModel) {
authorComment.value.user?.let {
FastNoteAuthorPicture(
author = it,
size = Size35dp,
accountViewModel = accountViewModel,
pictureModifier = simpleModifier
)
}
}
@Composable
fun DisplayAmount(authorComment: MutableState<ZapAmountCommentNotification>) {
authorComment.value.amount?.let {
Box(
modifier = amountBoxModifier,
contentAlignment = Alignment.BottomCenter
) {
val backgroundColor = MaterialTheme.colors.overPictureBackground
Box(
modifier = remember {
Modifier
.fillMaxWidth()
.drawBehind { drawRect(backgroundColor) }
},
contentAlignment = Alignment.BottomCenter
) {
Text(
text = it,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.secondaryVariant,
fontSize = commentTextSize,
modifier = bottomPadding1dp
)
}
}
}
}
comment?.let {
TranslatableRichTextViewer(
content = it,
canPreview = true,
tags = remember { ImmutableListOfLists() },
modifier = textBoxModifier,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
}
@Composable
fun DisplayComment(
authorComment: MutableState<ZapAmountCommentNotification>,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
authorComment.value.comment?.let {
TranslatableRichTextViewer(
content = it,
canPreview = true,
tags = remember { ImmutableListOfLists() },
modifier = textBoxModifier,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
}
}
@@ -477,19 +485,28 @@ private fun NotePictureAndComment(
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
val author by remember(baseNote) {
derivedStateOf {
baseNote.author
val author = remember(baseNote) {
mutableStateOf(
ZapAmountCommentNotification(
user = baseNote.author,
comment = null,
amount = null
)
)
}
val modifier = remember(baseNote) {
Modifier.clickable {
nav("User/${baseNote.author?.pubkeyHex}")
}
}
val route by remember(baseNote) {
derivedStateOf {
"User/${baseNote.author?.pubkeyHex}"
}
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
AuthorPictureAndComment(authorComment = author, backgroundColor, nav, accountViewModel)
}
author?.let { AuthorPictureAndComment(it, null, null, route, backgroundColor, nav, accountViewModel) }
}
@Composable
@@ -92,6 +92,7 @@ import com.vitorpamplona.amethyst.service.model.AppDefinitionEvent
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.BaseTextNoteEvent
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
@@ -100,6 +101,7 @@ import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.Participant
@@ -331,7 +333,7 @@ fun LoadedNoteCompose(
}
@Composable
private fun WatchForReports(
fun WatchForReports(
note: Note,
accountViewModel: AccountViewModel,
onChange: (Boolean, Boolean, Set<Note>) -> Unit
@@ -378,7 +380,7 @@ fun NormalNote(
val channelHex = remember { baseNote.channelHex() }
if ((noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) && channelHex != null) {
ChannelHeader(channelHex = channelHex, accountViewModel = accountViewModel, nav = nav)
ChannelHeader(channelHex = channelHex, showVideo = !makeItShort, showBottomDiviser = true, accountViewModel = accountViewModel, nav = nav)
} else if (noteEvent is BadgeDefinitionEvent) {
BadgeDisplay(baseNote = baseNote)
} else if (noteEvent is FileHeaderEvent) {
@@ -738,6 +740,10 @@ fun routeFor(note: Note, loggedIn: User): String? {
note.channelHex()?.let {
return "Channel/$it"
}
} else if (noteEvent is LiveActivitiesEvent || noteEvent is LiveActivitiesChatMessageEvent) {
note.channelHex()?.let {
return "Channel/$it"
}
} else if (noteEvent is PrivateDmEvent) {
return "Room/${noteEvent.talkingWith(loggedIn.pubkeyHex)}"
} else {
@@ -1643,7 +1649,8 @@ private fun ReplyRow(
val showChannelReply by remember {
derivedStateOf {
noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser())
(noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser())) ||
(noteEvent is LiveActivitiesChatMessageEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser()))
}
}
@@ -1658,11 +1665,19 @@ private fun ReplyRow(
} else if (showChannelReply) {
val channelHex = note.channelHex()
channelHex?.let {
val replies = remember { note.replyTo?.toImmutableList() }
val mentions = remember { (note.event as? ChannelMessageEvent)?.mentions()?.toImmutableList() ?: persistentListOf() }
ChannelHeader(
channelHex = channelHex,
showVideo = false,
showBottomDiviser = false,
modifier = remember { Modifier.padding(vertical = 5.dp) },
accountViewModel = accountViewModel,
nav = nav
)
ReplyInformationChannel(replies, mentions, it, accountViewModel, nav)
Spacer(modifier = Modifier.height(5.dp))
val replies = remember { note.replyTo?.toImmutableList() }
val mentions = remember { (note.event as? BaseTextNoteEvent)?.mentions()?.toImmutableList() ?: persistentListOf() }
ReplyInformationChannel(replies, mentions, accountViewModel, nav)
}
}
}
@@ -2877,7 +2892,7 @@ fun UserPicture(
@Composable
private fun ObserveAndDisplayFollowingMark(userHex: String, iconSize: Dp, accountViewModel: AccountViewModel) {
val accountFollowsState by accountViewModel.account.userProfile().live().follows.observeAsState()
val accountFollowsState by accountViewModel.userFollows.observeAsState()
var showFollowingMark by remember { mutableStateOf(false) }
@@ -332,23 +332,30 @@ fun NoteQuickActionItem(icon: ImageVector, label: String, onClick: () -> Unit) {
}
@Composable
fun DeleteAlertDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) =
fun DeleteAlertDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) {
val scope = rememberCoroutineScope()
QuickActionAlertDialog(
title = stringResource(R.string.quick_action_request_deletion_alert_title),
textContent = stringResource(R.string.quick_action_request_deletion_alert_body),
buttonIcon = Icons.Default.Delete,
buttonText = stringResource(R.string.quick_action_delete_dialog_btn),
onClickDoOnce = {
accountViewModel.delete(note)
scope.launch(Dispatchers.IO) {
accountViewModel.delete(note)
}
onDismiss()
},
onClickDontShowAgain = {
accountViewModel.delete(note)
accountViewModel.dontShowDeleteRequestDialog()
scope.launch(Dispatchers.IO) {
accountViewModel.delete(note)
accountViewModel.dontShowDeleteRequestDialog()
}
onDismiss()
},
onDismiss = onDismiss
)
}
@Composable
private fun BlockAlertDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) =
@@ -394,7 +401,9 @@ private fun QuickActionAlertDialog(
},
buttons = {
Row(
modifier = Modifier.padding(all = 8.dp).fillMaxWidth(),
modifier = Modifier
.padding(all = 8.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(onClick = onClickDontShowAgain) {
@@ -7,11 +7,13 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -21,6 +23,7 @@ import androidx.compose.material.ButtonDefaults
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material.MaterialTheme
import androidx.compose.material.ProgressIndicatorDefaults
import androidx.compose.material.Text
@@ -69,6 +72,7 @@ import com.vitorpamplona.amethyst.ui.screen.CombinedZap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
@@ -87,6 +91,10 @@ fun ReactionsRow(baseNote: Note, showReactionDetail: Boolean, accountViewModel:
mutableStateOf<Boolean>(false)
}
val zapraiserAmount = remember {
baseNote.event?.zapraiserAmount()
}
Spacer(modifier = Modifier.height(7.dp))
Row(verticalAlignment = CenterVertically, modifier = Modifier.padding(start = 10.dp)) {
@@ -119,6 +127,19 @@ fun ReactionsRow(baseNote: Note, showReactionDetail: Boolean, accountViewModel:
}
}
if (zapraiserAmount != null && zapraiserAmount > 0) {
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = if (showReactionDetail) 75.dp else 0.dp),
horizontalArrangement = Arrangement.Start
) {
RenderZapRaiser(baseNote, zapraiserAmount, wantsToSeeReactions.value, accountViewModel)
}
}
if (showReactionDetail && wantsToSeeReactions.value) {
ReactionDetailGallery(baseNote, nav, accountViewModel)
}
@@ -126,6 +147,70 @@ fun ReactionsRow(baseNote: Note, showReactionDetail: Boolean, accountViewModel:
Spacer(modifier = Modifier.height(7.dp))
}
@Composable
fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, accountViewModel: AccountViewModel) {
val zapsState by baseNote.live().zaps.observeAsState()
var zapraiserProgress by remember { mutableStateOf(0F) }
var zapraiserLeft by remember { mutableStateOf("$zapraiserAmount") }
LaunchedEffect(key1 = zapsState) {
launch(Dispatchers.Default) {
zapsState?.note?.let {
val newZapAmount = accountViewModel.calculateZapAmount(it)
var percentage = newZapAmount.div(zapraiserAmount.toBigDecimal()).toFloat()
if (percentage > 1) {
percentage = 1f
}
if (Math.abs(zapraiserProgress - percentage) > 0.001) {
zapraiserProgress = percentage
if (percentage > 0.99) {
zapraiserLeft = "0"
} else {
zapraiserLeft = showAmount((zapraiserAmount * (1 - percentage)).toBigDecimal())
}
}
}
}
}
val color = if (zapraiserProgress > 0.99) {
Color.Green.copy(alpha = 0.32f)
} else {
MaterialTheme.colors.mediumImportanceLink
}
Box(
Modifier.padding(end = 10.dp).fillMaxWidth()
) {
LinearProgressIndicator(
modifier = Modifier.matchParentSize(),
color = color,
progress = zapraiserProgress
)
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(2.dp)
) {
if (details) {
val totalPercentage = remember(zapraiserProgress) {
"${(zapraiserProgress * 100).roundToInt()}%"
}
Text(
text = stringResource(id = R.string.sats_to_complete, totalPercentage, zapraiserLeft),
modifier = Modifier.padding(start = 5.dp, end = 5.dp, top = 2.dp, bottom = 2.dp),
color = MaterialTheme.colors.placeholderText,
fontSize = 14.sp
)
}
}
}
}
@Composable
private fun ExpandButton(baseNote: Note, wantsToSeeReactions: MutableState<Boolean>) {
val zapsState by baseNote.live().zaps.observeAsState()
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
@@ -21,6 +22,7 @@ import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.ImmutableList
@@ -128,7 +130,6 @@ private fun ReplyInformation(
fun ReplyInformationChannel(
replyTo: ImmutableList<Note>?,
mentions: ImmutableList<String>,
channelHex: String,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@@ -136,76 +137,40 @@ fun ReplyInformationChannel(
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
sortedMentions = mentions
val newSortedMentions = mentions
.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
.toSet()
.sortedBy { accountViewModel.account.isFollowing(it) }
.toImmutableList()
.ifEmpty { null }
if (newSortedMentions != sortedMentions) {
sortedMentions = newSortedMentions
}
}
}
if (sortedMentions != null) {
LoadChannel(channelHex) { channel ->
ReplyInformationChannel(
replyTo,
sortedMentions,
channel,
onUserTagClick = {
nav("User/${it.pubkeyHex}")
},
onChannelTagClick = {
nav("Channel/${it.idHex}")
}
)
}
ReplyInformationChannel(
replyTo,
sortedMentions,
onUserTagClick = {
nav("User/${it.pubkeyHex}")
}
)
Spacer(modifier = StdVertSpacer)
}
}
@Composable
fun ReplyInformationChannel(replyTo: ImmutableList<Note>?, mentions: ImmutableList<User>?, channel: Channel, nav: (String) -> Unit) {
ReplyInformationChannel(
replyTo,
mentions,
channel,
onUserTagClick = {
nav("User/${it.pubkeyHex}")
},
onChannelTagClick = {
nav("Channel/${it.idHex}")
}
)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ReplyInformationChannel(
replyTo: ImmutableList<Note>?,
mentions: ImmutableList<User>?,
baseChannel: Channel,
prefix: String = "",
onUserTagClick: (User) -> Unit,
onChannelTagClick: (Channel) -> Unit
onUserTagClick: (User) -> Unit
) {
val channelState by baseChannel.live.observeAsState()
val channel = channelState?.channel ?: return
val channelName = remember(channelState) {
AnnotatedString("${channel.info.name} ")
}
FlowRow() {
Text(
stringResource(R.string.in_channel),
fontSize = 13.sp,
color = MaterialTheme.colors.placeholderText
)
ClickableText(
text = channelName,
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.lessImportantLink, fontSize = 13.sp),
onClick = { onChannelTagClick(channel) }
)
if (mentions != null && mentions.isNotEmpty()) {
if (replyTo != null && replyTo.isNotEmpty()) {
Text(
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.note
import android.content.Context
import android.util.Log
import androidx.compose.foundation.layout.Spacer
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
@@ -25,6 +26,7 @@ import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.theme.StdButtonSizeModifier
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@Composable
@@ -70,6 +72,7 @@ private fun UserNameDisplay(
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
Spacer(StdHorzSpacer)
DrawPlayName(bestDisplayName)
} else if (bestDisplayName != null) {
CreateTextWithEmoji(
@@ -80,6 +83,7 @@ private fun UserNameDisplay(
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
Spacer(StdHorzSpacer)
DrawPlayName(bestDisplayName)
} else if (bestUserName != null) {
CreateTextWithEmoji(
@@ -90,6 +94,7 @@ private fun UserNameDisplay(
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
Spacer(StdHorzSpacer)
DrawPlayName(bestUserName)
} else {
Text(
@@ -56,6 +56,8 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
val scrollToTop = _scrollToTop.asStateFlow()
var scrolltoTopPending = false
private var lastFeedKey: String? = null
fun sendToTop() {
if (scrolltoTopPending) return
@@ -84,6 +86,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
checkNotInMainThread()
val notes = localFilter.feed()
lastFeedKey = localFilter.feedKey()
val thisAccount = (localFilter as? NotificationFeedFilter)?.account
val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null
@@ -233,6 +236,8 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null
if (lastNotesCopy != null && localFilter is AdditiveFeedFilter && oldNotesState is CardFeedState.Loaded) {
lastFeedKey = localFilter.feedKey()
val filteredNewList = localFilter.applyFilter(newItems)
if (filteredNewList.isEmpty()) return
@@ -279,8 +284,9 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
@OptIn(ExperimentalTime::class)
fun invalidateDataAndSendToTop(ignoreIfDoing: Boolean = false) {
bundler.invalidate(ignoreIfDoing) {
fun invalidateDataAndSendToTop() {
clear()
bundler.invalidate(false) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
val (value, elapsed) = measureTimedValue {
@@ -291,6 +297,22 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
}
@OptIn(ExperimentalTime::class)
fun checkKeysInvalidateDataAndSendToTop() {
if (lastFeedKey != localFilter.feedKey()) {
clear()
bundler.invalidate(false) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
val (value, elapsed) = measureTimedValue {
refreshSuspended()
sendToTop()
}
Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed")
}
}
}
@OptIn(ExperimentalTime::class)
fun invalidateInsertData(newItems: Set<Note>) {
bundlerInsert.invalidateList(newItems) {
@@ -40,14 +40,13 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
fun RefresheableFeedView(
viewModel: FeedViewModel,
routeForLastRead: String?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
enablePullRefresh: Boolean = true,
scrollStateKey: String? = null,
enablePullRefresh: Boolean = true
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
RefresheableView(viewModel, enablePullRefresh) {
SaveableFeedState(viewModel, accountViewModel, nav, routeForLastRead, scrollStateKey)
SaveableFeedState(viewModel, routeForLastRead, scrollStateKey, accountViewModel, nav)
}
}
@@ -82,10 +81,10 @@ fun RefresheableView(
@Composable
private fun SaveableFeedState(
viewModel: FeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
routeForLastRead: String?,
scrollStateKey: String? = null
scrollStateKey: String? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
SaveableFeedState(viewModel, scrollStateKey) { listState ->
RenderFeed(viewModel, accountViewModel, listState, nav, routeForLastRead)
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeLiveActivitiesFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileAppRecommendationsFeedFilter
@@ -99,6 +100,15 @@ 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 {
@@ -138,6 +148,8 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
val scrollToTop = _scrollToTop.asStateFlow()
var scrolltoTopPending = false
private var lastFeedKey: String? = null
fun sendToTop() {
if (scrolltoTopPending) return
@@ -151,10 +163,6 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
scrolltoTopPending = false
}
fun newListFromDataSource(): ImmutableList<Note> {
return localFilter.loadTop().toImmutableList()
}
private fun refresh() {
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
@@ -165,7 +173,8 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
fun refreshSuspended() {
checkNotInMainThread()
val notes = newListFromDataSource()
val notes = localFilter.loadTop().toImmutableList()
lastFeedKey = localFilter.feedKey()
val oldNotesState = _feedContent.value
if (oldNotesState is FeedState.Loaded) {
@@ -197,11 +206,13 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
if (localFilter is AdditiveFeedFilter) {
if (oldNotesState is FeedState.Loaded) {
val newList = localFilter.updateListWith(oldNotesState.feed.value, newItems.toSet()).toImmutableList()
lastFeedKey = localFilter.feedKey()
if (!equalImmutableLists(newList, oldNotesState.feed.value)) {
updateFeed(newList)
}
} else if (oldNotesState is FeedState.Empty) {
val newList = localFilter.updateListWith(emptyList(), newItems.toSet()).toImmutableList()
lastFeedKey = localFilter.feedKey()
if (newList.isNotEmpty()) {
updateFeed(newList)
}
@@ -226,12 +237,14 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
}
}
fun invalidateDataAndSendToTop(ignoreIfDoing: Boolean = false) {
bundler.invalidate(ignoreIfDoing) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
sendToTop()
fun checkKeysInvalidateDataAndSendToTop() {
if (lastFeedKey != localFilter.feedKey()) {
bundler.invalidate(false) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
sendToTop()
}
}
}
@@ -57,6 +57,10 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.AppDefinitionEvent
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
@@ -81,6 +85,7 @@ import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.selectedNote
@@ -372,7 +377,13 @@ fun NoteMaster(
)
) {
Column() {
if (noteEvent is PeopleListEvent) {
if ((noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) && note.channelHex() != null) {
ChannelHeader(channelHex = note.channelHex()!!, showVideo = true, showBottomDiviser = false, accountViewModel = accountViewModel, nav = nav)
} else if (noteEvent is FileHeaderEvent) {
FileHeaderDisplay(baseNote)
} else if (noteEvent is FileStorageHeaderEvent) {
FileStorageHeaderDisplay(baseNote)
} else if (noteEvent is PeopleListEvent) {
DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav)
} else if (noteEvent is AudioTrackEvent) {
AudioTrackHeader(noteEvent, accountViewModel, nav)
@@ -15,6 +15,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.LnZapEvent
@@ -32,6 +33,8 @@ class AccountViewModel(val account: Account) : ViewModel() {
val accountLanguagesLiveData: LiveData<AccountState> = account.liveLanguages.map { it }
val accountLastReadLiveData: LiveData<AccountState> = account.liveLastRead.map { it }
val userFollows: LiveData<UserState> = account.userProfile().live().follows.map { it }
fun isWriteable(): Boolean {
return account.isWriteable()
}
@@ -73,8 +73,18 @@ fun BookmarkListScreen(accountViewModel: AccountViewModel, nav: (String) -> Unit
}
HorizontalPager(pageCount = 2, state = pagerState) { page ->
when (page) {
0 -> RefresheableFeedView(privateFeedViewModel, null, accountViewModel, nav)
1 -> RefresheableFeedView(publicFeedViewModel, null, accountViewModel, nav)
0 -> RefresheableFeedView(
privateFeedViewModel,
null,
accountViewModel = accountViewModel,
nav = nav
)
1 -> RefresheableFeedView(
publicFeedViewModel,
null,
accountViewModel = accountViewModel,
nav = nav
)
}
}
}
@@ -2,12 +2,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.widget.Toast
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -16,19 +19,23 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.material.TextFieldColors
import androidx.compose.material.TextFieldDefaults
import androidx.compose.material.TextFieldDefaults.indicatorLine
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.EditNote
@@ -46,15 +53,21 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -64,8 +77,10 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Channel
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.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
@@ -75,12 +90,18 @@ import com.vitorpamplona.amethyst.ui.actions.ServersAvailable
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -186,8 +207,10 @@ fun ChannelScreen(
Column(Modifier.fillMaxHeight()) {
ChannelHeader(
channel,
accountViewModel,
baseChannel = channel,
showVideo = true,
showBottomDiviser = true,
accountViewModel = accountViewModel,
nav = nav
)
@@ -232,13 +255,23 @@ fun ChannelScreen(
message = newPostModel.message.text
)
tagger.run()
accountViewModel.account.sendChannelMessage(
message = tagger.message,
toChannel = channel.idHex,
replyTo = tagger.replyTos,
mentions = tagger.mentions,
wantsToMarkAsSensitive = false
)
if (channel is PublicChatChannel) {
accountViewModel.account.sendChannelMessage(
message = tagger.message,
toChannel = channel.idHex,
replyTo = tagger.replyTos,
mentions = tagger.mentions,
wantsToMarkAsSensitive = false
)
} else if (channel is LiveActivitiesChannel) {
accountViewModel.account.sendLiveMessage(
message = tagger.message,
toChannel = channel.address,
replyTo = tagger.replyTos,
mentions = tagger.mentions,
wantsToMarkAsSensitive = false
)
}
newPostModel.message = TextFieldValue("")
replyTo.value = null
feedViewModel.sendToTop()
@@ -307,7 +340,7 @@ fun EditFieldRow(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
TextField(
MyTextField(
value = channelScreenModel.message,
onValueChange = {
channelScreenModel.updateMessage(it)
@@ -330,14 +363,14 @@ fun EditFieldRow(
onSendNewMessage()
},
isActive = channelScreenModel.message.text.isNotBlank() && !channelScreenModel.isUploadingImage,
modifier = Modifier.padding(end = 10.dp)
modifier = Modifier.height(32.dp).padding(end = 10.dp)
)
},
leadingIcon = {
UploadFromGallery(
isUploading = channelScreenModel.isUploadingImage,
tint = MaterialTheme.colors.placeholderText,
modifier = Modifier.padding(start = 5.dp)
modifier = Modifier.height(32.dp).padding(start = 2.dp)
) {
val fileServer = if (isPrivate) {
// TODO: Make private servers
@@ -366,8 +399,94 @@ fun EditFieldRow(
}
@Composable
fun ChannelHeader(channelHex: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
var baseChannel by remember { mutableStateOf<Channel?>(null) }
fun MyTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
readOnly: Boolean = false,
textStyle: TextStyle = LocalTextStyle.current,
label: @Composable (() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
isError: Boolean = false,
visualTransformation: VisualTransformation = VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions(),
singleLine: Boolean = false,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
minLines: Int = 1,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
shape: Shape = TextFieldDefaults.TextFieldShape,
colors: TextFieldColors = TextFieldDefaults.textFieldColors()
) {
// If color is not provided via the text style, use content color as a default
val textColor = textStyle.color.takeOrElse {
colors.textColor(enabled).value
}
val mergedTextStyle = textStyle.merge(TextStyle(color = textColor))
@OptIn(ExperimentalMaterialApi::class)
(
BasicTextField(
value = value,
modifier = modifier
.background(colors.backgroundColor(enabled).value, shape)
.indicatorLine(enabled, isError, interactionSource, colors)
.defaultMinSize(
minWidth = TextFieldDefaults.MinWidth,
minHeight = 36.dp
),
onValueChange = onValueChange,
enabled = enabled,
readOnly = readOnly,
textStyle = mergedTextStyle,
cursorBrush = SolidColor(colors.cursorColor(isError).value),
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
interactionSource = interactionSource,
singleLine = singleLine,
maxLines = maxLines,
minLines = minLines,
decorationBox = @Composable { innerTextField ->
// places leading icon, text field with label and placeholder, trailing icon
TextFieldDefaults.TextFieldDecorationBox(
value = value.text,
visualTransformation = visualTransformation,
innerTextField = innerTextField,
placeholder = placeholder,
label = label,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
singleLine = singleLine,
enabled = enabled,
isError = isError,
interactionSource = interactionSource,
colors = colors,
contentPadding = TextFieldDefaults.textFieldWithoutLabelPadding(
top = 12.dp,
bottom = 12.dp,
start = 10.dp,
end = 10.dp
)
)
}
)
)
}
@Composable
fun ChannelHeader(
channelHex: String,
showVideo: Boolean,
showBottomDiviser: Boolean,
modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var baseChannel by remember { mutableStateOf<Channel?>(LocalCache.channels[channelHex]) }
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = channelHex) {
@@ -377,12 +496,26 @@ fun ChannelHeader(channelHex: String, accountViewModel: AccountViewModel, nav: (
}
baseChannel?.let {
ChannelHeader(it, accountViewModel, nav)
ChannelHeader(
it,
showVideo,
showBottomDiviser,
modifier,
accountViewModel,
nav
)
}
}
@Composable
fun ChannelHeader(baseChannel: Channel, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
fun ChannelHeader(
baseChannel: Channel,
showVideo: Boolean,
showBottomDiviser: Boolean,
modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val channelState by baseChannel.live.observeAsState()
val channel = remember(channelState) { channelState?.channel } ?: return
@@ -393,7 +526,24 @@ fun ChannelHeader(baseChannel: Channel, accountViewModel: AccountViewModel, nav:
nav("Channel/${baseChannel.idHex}")
}
) {
Column(modifier = Modifier.padding(12.dp)) {
if (channel is LiveActivitiesChannel) {
val streamingUrl by remember(channelState) {
derivedStateOf {
channel.info?.streaming()
}
}
if (streamingUrl != null && showVideo) {
Row(verticalAlignment = Alignment.CenterVertically) {
VideoView(
videoUri = streamingUrl!!,
description = null
)
}
}
}
Column(modifier = modifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
RobohashAsyncImageProxy(
robot = channel.idHex,
@@ -412,14 +562,16 @@ fun ChannelHeader(baseChannel: Channel, accountViewModel: AccountViewModel, nav:
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"${channel.info.name}",
fontWeight = FontWeight.Bold
channel.toBestDisplayName(),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"${channel.info.about}",
"${channel.summary()}",
color = MaterialTheme.colors.placeholderText,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
@@ -433,21 +585,27 @@ fun ChannelHeader(baseChannel: Channel, accountViewModel: AccountViewModel, nav:
.height(Size35dp)
.padding(bottom = 3.dp)
) {
ChannelActionOptions(channel, accountViewModel, nav)
if (channel is PublicChatChannel) {
ChannelActionOptions(channel, accountViewModel, nav)
}
if (channel is LiveActivitiesChannel) {
LiveChannelActionOptions(channel, accountViewModel, nav)
}
}
}
}
Divider(
modifier = Modifier.padding(start = 12.dp, end = 12.dp),
thickness = 0.25.dp
)
if (showBottomDiviser) {
Divider(
thickness = 0.25.dp
)
}
}
}
@Composable
private fun ChannelActionOptions(
channel: Channel,
channel: PublicChatChannel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@@ -477,6 +635,55 @@ private fun ChannelActionOptions(
}
}
@Composable
private fun LiveChannelActionOptions(
channel: LiveActivitiesChannel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val isMe by remember(accountViewModel) {
derivedStateOf {
channel.creator == accountViewModel.account.userProfile()
}
}
val accountState by accountViewModel.accountLiveData.observeAsState()
val isFollowing by remember(accountState) {
derivedStateOf {
accountState?.account?.followingChannels?.contains(channel.idHex) ?: false
}
}
val status by remember {
derivedStateOf {
channel.info?.status()
}
}
if (isMe) {
// EditButton(accountViewModel, channel)
} else {
LocalCache.addressables[channel.idHex]?.let {
if (status == "live") {
Text(
text = "LIVE",
color = Color.White,
fontWeight = FontWeight.Bold,
modifier = Modifier
.clip(SmallBorder)
.drawBehind { drawRect(Color.Red) }
.padding(horizontal = 5.dp)
)
Spacer(modifier = StdHorzSpacer)
}
LikeReaction(it, MaterialTheme.colors.onSurface, accountViewModel)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
}
}
}
@Composable
private fun NoteCopyButton(
note: Channel
@@ -514,7 +721,7 @@ private fun NoteCopyButton(
}
@Composable
private fun EditButton(accountViewModel: AccountViewModel, channel: Channel) {
private fun EditButton(accountViewModel: AccountViewModel, channel: PublicChatChannel) {
var wantsToPost by remember {
mutableStateOf(false)
}
@@ -73,7 +73,12 @@ fun HashtagScreen(tag: String?, accountViewModel: AccountViewModel, nav: (String
modifier = Modifier.padding(vertical = 0.dp)
) {
HashtagHeader(tag, accountViewModel)
RefresheableFeedView(feedViewModel, null, accountViewModel, nav)
RefresheableFeedView(
feedViewModel,
null,
accountViewModel = accountViewModel,
nav = nav
)
}
}
}
@@ -1,9 +1,15 @@
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
@@ -14,6 +20,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -30,7 +37,9 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
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
@@ -47,6 +56,7 @@ import kotlinx.coroutines.launch
fun HomeScreen(
homeFeedViewModel: NostrHomeFeedViewModel,
repliesFeedViewModel: NostrHomeRepliesFeedViewModel,
liveActivitiesViewModel: NostrHomeFeedLiveActivitiesViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
nip47: String? = null
@@ -88,7 +98,7 @@ fun HomeScreen(
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
HomePages(pagerState, tabs, accountViewModel, nav)
HomePages(pagerState, tabs, liveActivitiesViewModel, accountViewModel, nav)
}
}
}
@@ -98,6 +108,7 @@ fun HomeScreen(
private fun HomePages(
pagerState: PagerState,
tabs: ImmutableList<TabItem>,
liveActivitiesViewModel: NostrHomeFeedLiveActivitiesViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@@ -120,17 +131,77 @@ private fun HomePages(
}
}
LiveActivities(
liveActivitiesViewModel = liveActivitiesViewModel,
accountViewModel = accountViewModel,
nav = nav
)
HorizontalPager(pageCount = 2, state = pagerState) { page ->
RefresheableFeedView(
viewModel = tabs[page].viewModel,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = tabs[page].routeForLastRead,
scrollStateKey = tabs[page].scrollStateKey
scrollStateKey = tabs[page].scrollStateKey,
accountViewModel = accountViewModel,
nav = nav
)
}
}
@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(
top = 10.dp
),
state = listState
) {
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item ->
ChannelHeader(
channelHex = item.idHex,
showVideo = false,
showBottomDiviser = true,
modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
accountViewModel = accountViewModel,
nav = nav
)
}
}
}
@Composable
fun WatchAccountForHomeScreen(
homeFeedViewModel: NostrHomeFeedViewModel,
@@ -143,16 +214,14 @@ fun WatchAccountForHomeScreen(
LaunchedEffect(accountViewModel, accountState?.account?.defaultHomeFollowList) {
launch(Dispatchers.IO) {
NostrHomeDataSource.invalidateFilters()
homeFeedViewModel.invalidateDataAndSendToTop(true)
repliesFeedViewModel.invalidateDataAndSendToTop(true)
homeFeedViewModel.checkKeysInvalidateDataAndSendToTop()
repliesFeedViewModel.checkKeysInvalidateDataAndSendToTop()
}
}
LaunchedEffect(followState) {
launch(Dispatchers.IO) {
NostrHomeDataSource.invalidateFilters()
homeFeedViewModel.invalidateData(true)
repliesFeedViewModel.invalidateData(true)
}
}
}
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedLiveActivitiesViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
@@ -73,23 +74,6 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
}
}
val navBottomRow = remember(navController) {
{ route: Route, selected: Boolean ->
if (!selected) {
navController.navigate(route.base) {
popUpTo(Route.Home.route)
launchSingleTop = true
}
} else {
val newRoute = route.route.replace("{scrollToTop}", "true")
navController.navigate(newRoute) {
popUpTo(Route.Home.route)
launchSingleTop = true
}
}
}
}
val followLists: FollowListViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "FollowListViewModel",
factory = FollowListViewModel.Factory(accountViewModel.account)
@@ -106,6 +90,11 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
factory = NostrHomeRepliesFeedViewModel.Factory(accountViewModel.account)
)
val liveActivitiesViewModel: NostrHomeFeedLiveActivitiesViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "NostrHomeLiveActivitiesFeedViewModel",
factory = NostrHomeFeedLiveActivitiesViewModel.Factory(accountViewModel.account)
)
val searchFeedViewModel: NostrGlobalFeedViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "NostrGlobalFeedViewModel",
factory = NostrGlobalFeedViewModel.Factory(accountViewModel.account)
@@ -136,6 +125,40 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
factory = NostrChatroomListNewFeedViewModel.Factory(accountViewModel.account)
)
val navBottomRow = remember(navController) {
{ route: Route, selected: Boolean ->
if (!selected) {
navController.navigate(route.base) {
popUpTo(Route.Home.route)
launchSingleTop = true
}
} else {
// deals with scroll to top here to avoid passing as parameter
// and having to deal with all recompositions with scroll to top true
when (route.base) {
Route.Home.base -> {
homeFeedViewModel.sendToTop()
repliesFeedViewModel.sendToTop()
}
Route.Search.base -> {
searchFeedViewModel.sendToTop()
}
Route.Video.base -> {
videoFeedViewModel.sendToTop()
}
Route.Notification.base -> {
notifFeedViewModel.invalidateDataAndSendToTop()
}
}
navController.navigate(route.route) {
popUpTo(route.route)
launchSingleTop = true
}
}
}
}
ModalBottomSheetLayout(
sheetState = sheetState,
sheetContent = {
@@ -167,6 +190,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
AppNavigation(
homeFeedViewModel,
repliesFeedViewModel,
liveActivitiesViewModel,
knownFeedViewModel,
newFeedViewModel,
searchFeedViewModel,
@@ -102,16 +102,9 @@ fun WatchAccountForNotifications(
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
var firstTime by remember(accountViewModel) { mutableStateOf(true) }
LaunchedEffect(accountViewModel, accountState?.account?.defaultNotificationFollowList) {
if (firstTime) {
firstTime = false
} else {
NostrAccountDataSource.invalidateFilters()
notifFeedViewModel.clear()
notifFeedViewModel.invalidateDataAndSendToTop(true)
}
NostrAccountDataSource.invalidateFilters()
notifFeedViewModel.checkKeysInvalidateDataAndSendToTop()
}
}
@@ -1130,7 +1130,13 @@ fun TabNotesNewThreads(accountViewModel: AccountViewModel, nav: (String) -> Unit
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
RefresheableFeedView(feedViewModel, null, accountViewModel, nav, enablePullRefresh = false)
RefresheableFeedView(
feedViewModel,
null,
enablePullRefresh = false,
accountViewModel = accountViewModel,
nav = nav
)
}
}
}
@@ -1147,7 +1153,13 @@ fun TabNotesConversations(accountViewModel: AccountViewModel, nav: (String) -> U
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
RefresheableFeedView(feedViewModel, null, accountViewModel, nav, enablePullRefresh = false)
RefresheableFeedView(
feedViewModel,
null,
enablePullRefresh = false,
accountViewModel = accountViewModel,
nav = nav
)
}
}
}
@@ -1164,7 +1176,13 @@ fun TabBookmarks(baseUser: User, accountViewModel: AccountViewModel, nav: (Strin
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
RefresheableFeedView(feedViewModel, null, accountViewModel, nav, enablePullRefresh = false)
RefresheableFeedView(
feedViewModel,
null,
enablePullRefresh = false,
accountViewModel = accountViewModel,
nav = nav
)
}
}
}
@@ -1246,7 +1264,13 @@ fun TabReports(baseUser: User, accountViewModel: AccountViewModel, nav: (String)
Column(Modifier.fillMaxHeight()) {
Column() {
RefresheableFeedView(feedViewModel, null, accountViewModel, nav, enablePullRefresh = false)
RefresheableFeedView(
feedViewModel,
null,
enablePullRefresh = false,
accountViewModel = accountViewModel,
nav = nav
)
}
}
}
@@ -138,7 +138,13 @@ fun SearchScreen(
modifier = Modifier.padding(vertical = 0.dp)
) {
SearchBar(searchBarViewModel, accountViewModel, nav)
RefresheableFeedView(searchFeedViewModel, null, accountViewModel, nav, ScrollStateKeys.GLOBAL_SCREEN)
RefresheableFeedView(
searchFeedViewModel,
null,
scrollStateKey = ScrollStateKeys.GLOBAL_SCREEN,
accountViewModel = accountViewModel,
nav = nav
)
}
}
}
@@ -393,12 +399,12 @@ private fun DisplaySearchResults(
channelPicture = item.profilePicture(),
channelTitle = {
Text(
"${item.info.name}",
"${item.toBestDisplayName()}",
fontWeight = FontWeight.Bold
)
},
channelLastTime = null,
channelLastContent = item.info.about,
channelLastContent = item.summary(),
false,
onClick = { nav("Channel/${item.idHex}") }
)
@@ -127,7 +127,12 @@ fun VideoScreen(
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
SaveableFeedState(videoFeedView, accountViewModel, nav, ScrollStateKeys.VIDEO_SCREEN)
SaveableFeedState(
videoFeedView = videoFeedView,
accountViewModel = accountViewModel,
nav = nav,
scrollStateKey = ScrollStateKeys.VIDEO_SCREEN
)
}
}
}
@@ -135,17 +140,10 @@ fun VideoScreen(
@Composable
fun WatchAccountForVideoScreen(videoFeedView: NostrVideoFeedViewModel, accountViewModel: AccountViewModel) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
var firstTime by remember(accountViewModel) { mutableStateOf(true) }
LaunchedEffect(accountViewModel, account.defaultStoriesFollowList) {
if (firstTime) {
firstTime = false
} else {
NostrVideoDataSource.resetFilters()
videoFeedView.invalidateDataAndSendToTop(true)
}
LaunchedEffect(accountViewModel, accountState?.account?.defaultStoriesFollowList) {
NostrVideoDataSource.resetFilters()
videoFeedView.checkKeysInvalidateDataAndSendToTop()
}
}
@@ -155,7 +153,6 @@ private fun SaveableFeedState(
videoFeedView: NostrVideoFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
routeForLastRead: String?,
scrollStateKey: String? = null
) {
val pagerState = if (scrollStateKey != null) {
@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst.ui.theme
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -13,6 +15,7 @@ val Shapes = Shapes(
large = RoundedCornerShape(0.dp)
)
val SmallBorder = RoundedCornerShape(7.dp)
val QuoteBorder = RoundedCornerShape(15.dp)
val ButtonBorder = RoundedCornerShape(20.dp)
@@ -21,6 +24,13 @@ val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp)
val StdButtonSizeModifier = Modifier.size(20.dp)
val StdHorzSpacer = Modifier.width(5.dp)
val StdVertSpacer = Modifier.height(5.dp)
val DoubleHorzSpacer = Modifier.width(10.dp)
val Size35dp = 35.dp
val Size13dp = 13.dp
val Size16dp = 16.dp
val StdPadding = Modifier.padding(10.dp)
val Size15Modifier = Modifier.size(15.dp)
@@ -54,6 +54,9 @@ private val LightPlaceholderText = LightColorPalette.onSurface.copy(alpha = 0.32
private val DarkSubtleBorder = DarkColorPalette.onSurface.copy(alpha = 0.12f)
private val LightSubtleBorder = LightColorPalette.onSurface.copy(alpha = 0.12f)
private val DarkZapraiserBackground = BitcoinOrange.copy(0.52f).compositeOver(DarkColorPalette.background)
private val LightZapraiserBackground = BitcoinOrange.copy(0.52f).compositeOver(LightColorPalette.background)
private val DarkImageVerifier = Nip05.copy(0.52f).compositeOver(DarkColorPalette.background)
private val LightImageVerifier = Nip05.copy(0.52f).compositeOver(LightColorPalette.background)
@@ -75,6 +78,9 @@ val Colors.secondaryButtonBackground: Color
val Colors.lessImportantLink: Color
get() = if (isLight) LightLessImportantLink else DarkLessImportantLink
val Colors.zapraiserBackground: Color
get() = if (isLight) LightZapraiserBackground else DarkZapraiserBackground
val Colors.mediumImportanceLink: Color
get() = if (isLight) LightMediumImportantLink else DarkMediumImportantLink
val Colors.veryImportantLink: Color
+8
View File
@@ -417,4 +417,12 @@
<string name="new_reaction_symbol">New Reaction Symbol</string>
<string name="no_reaction_type_setup_long_press_to_change">No reaction types selected. Long Press to change</string>
<string name="zapraiser">Zapraiser</string>
<string name="zapraiser_explainer">Adds a target amount of sats to raise for this post. Supporting clients may show this as a progress bar to incentivize donations</string>
<string name="zapraiser_target_amount_in_sats">Target Amount in Sats</string>
<string name="sats_to_complete">Zapraiser at %1$s. %2$s sats to goal</string>
<string name="read_from_relay">Read from Relay</string>
<string name="write_to_relay">Write to Relay</string>
</resources>