Compare commits

...

21 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 Pamplona
128211ecd2 Merge pull request #459 from greenart7c3/main
add toast message to show relay icon description
2023-06-19 15:32:46 -04:00
greenart7c3
c35b5e3703 add toast message for relay icons 2023-06-19 07:58:25 -03:00
42 changed files with 1432 additions and 435 deletions

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

View File

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

View File

@@ -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 {
@@ -597,6 +597,27 @@ class Account(
LocalCache.consume(signedEvent, null)
}
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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
@@ -143,7 +144,11 @@ open class NewPostViewModel() : ViewModel() {
if (wantsPoll) {
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, localZapRaiserAmount)
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, localZapRaiserAmount)
} else {

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,8 @@ 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
@@ -27,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 &&

View File

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

View File

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

View File

@@ -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,
@@ -67,6 +69,7 @@ fun AppNavigation(
HomeScreen(
homeFeedViewModel = homeFeedViewModel,
repliesFeedViewModel = repliesFeedViewModel,
liveActivitiesViewModel = liveActivitiesViewModel,
accountViewModel = accountViewModel,
nav = nav,
nip47 = nip47

View File

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

View File

@@ -108,7 +108,7 @@ private fun ChannelRoomCompose(
}
val channelName by remember(channelState) {
derivedStateOf {
channel.info.name
channel.toBestDisplayName()
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -283,6 +283,20 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
}
@OptIn(ExperimentalTime::class)
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 {
refreshSuspended()
sendToTop()
}
Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed")
}
}
@OptIn(ExperimentalTime::class)
fun checkKeysInvalidateDataAndSendToTop() {
if (lastFeedKey != localFilter.feedKey()) {

View File

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

View File

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

View File

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

View File

@@ -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,6 +131,12 @@ private fun HomePages(
}
}
LiveActivities(
liveActivitiesViewModel = liveActivitiesViewModel,
accountViewModel = accountViewModel,
nav = nav
)
HorizontalPager(pageCount = 2, state = pagerState) { page ->
RefresheableFeedView(
viewModel = tabs[page].viewModel,
@@ -131,6 +148,60 @@ private fun HomePages(
}
}
@Composable
fun LiveActivities(
liveActivitiesViewModel: NostrHomeFeedLiveActivitiesViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val feedState by liveActivitiesViewModel.feedContent.collectAsState()
Crossfade(
targetState = feedState,
animationSpec = tween(durationMillis = 100)
) { state ->
when (state) {
is FeedState.Loaded -> {
FeedLoaded(
state,
accountViewModel,
nav
)
}
else -> {
}
}
}
}
@Composable
private fun FeedLoaded(
state: FeedState.Loaded,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val listState = rememberLazyListState()
LazyColumn(
contentPadding = PaddingValues(
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,

View File

@@ -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
@@ -89,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)
@@ -141,8 +147,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
videoFeedViewModel.sendToTop()
}
Route.Notification.base -> {
notifFeedViewModel.clear()
notifFeedViewModel.sendToTop()
notifFeedViewModel.invalidateDataAndSendToTop()
}
}
@@ -185,6 +190,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
AppNavigation(
homeFeedViewModel,
repliesFeedViewModel,
liveActivitiesViewModel,
knownFeedViewModel,
newFeedViewModel,
searchFeedViewModel,

View File

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

View File

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

View File

@@ -423,4 +423,6 @@
<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>