- Support for Emoji Packs

- Support for Personal Emoji Lists
- Support for Custom emoji Reactions
This commit is contained in:
Vitor Pamplona
2023-07-12 17:20:06 -04:00
parent 3ee743ce1a
commit f090bc82ae
22 changed files with 810 additions and 154 deletions
@@ -176,6 +176,19 @@ class Account(
return return
} }
if (reaction.startsWith(":")) {
val emojiUrl = EmojiUrl.decode(reaction)
if (emojiUrl != null) {
note.event?.let {
val event = ReactionEvent.create(emojiUrl, it, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
return
}
}
note.event?.let { note.event?.let {
val event = ReactionEvent.create(reaction, it, loggedIn.privKey!!) val event = ReactionEvent.create(reaction, it, loggedIn.privKey!!)
Client.send(event) Client.send(event)
@@ -674,6 +687,51 @@ class Account(
joinChannel(event.id) joinChannel(event.id)
} }
fun removeEmojiPack(usersEmojiList: Note, emojiList: Note) {
if (!isWriteable()) return
val noteEvent = usersEmojiList.event
if (noteEvent !is EmojiPackSelectionEvent) return
val emojiListEvent = emojiList.event
if (emojiListEvent !is EmojiPackEvent) return
val event = EmojiPackSelectionEvent.create(
noteEvent.taggedAddresses().filter { it != emojiListEvent.address() },
loggedIn.privKey!!
)
Client.send(event)
LocalCache.consume(event)
}
fun addEmojiPack(usersEmojiList: Note, emojiList: Note) {
if (!isWriteable()) return
val emojiListEvent = emojiList.event
if (emojiListEvent !is EmojiPackEvent) return
val event = if (usersEmojiList.event == null) {
EmojiPackSelectionEvent.create(
listOf(emojiListEvent.address()),
loggedIn.privKey!!
)
} else {
val noteEvent = usersEmojiList.event
if (noteEvent !is EmojiPackSelectionEvent) return
if (noteEvent.taggedAddresses().any { it == emojiListEvent.address() }) {
return
}
EmojiPackSelectionEvent.create(
noteEvent.taggedAddresses().plus(emojiListEvent.address()),
loggedIn.privKey!!
)
}
Client.send(event)
LocalCache.consume(event)
}
fun addPrivateBookmark(note: Note) { fun addPrivateBookmark(note: Note) {
if (!isWriteable()) return if (!isWriteable()) return
@@ -364,6 +364,44 @@ object LocalCache {
} }
} }
fun consume(event: EmojiPackSelectionEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: EmojiPackEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: PinListEvent) { private fun consume(event: PinListEvent) {
val version = getOrCreateNote(event.id) val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address()) val note = getOrCreateAddressableNote(event.address())
@@ -1137,8 +1175,7 @@ object LocalCache {
it.idNote().startsWith(text, true) it.idNote().startsWith(text, true)
} + addressables.values.filter { } + addressables.values.filter {
(it.event as? LongTextNoteEvent)?.content?.contains(text, true) ?: false || (it.event as? LongTextNoteEvent)?.content?.contains(text, true) ?: false ||
(it.event as? LongTextNoteEvent)?.title()?.contains(text, true) ?: false || it.event?.matchTag1With(text) ?: false ||
(it.event as? LongTextNoteEvent)?.summary()?.contains(text, true) ?: false ||
it.idHex.startsWith(text, true) it.idHex.startsWith(text, true)
} }
} }
@@ -1278,6 +1315,8 @@ object LocalCache {
} }
is ContactListEvent -> consume(event) is ContactListEvent -> consume(event)
is DeletionEvent -> consume(event) is DeletionEvent -> consume(event)
is EmojiPackEvent -> consume(event)
is EmojiPackSelectionEvent -> consume(event)
is FileHeaderEvent -> consume(event, relay) is FileHeaderEvent -> consume(event, relay)
is FileStorageEvent -> consume(event, relay) is FileStorageEvent -> consume(event, relay)
@@ -5,11 +5,13 @@ import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.EOSETime import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.updated import com.vitorpamplona.amethyst.ui.actions.updated
import com.vitorpamplona.amethyst.ui.components.BundledUpdate import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.toShortenHex import com.vitorpamplona.amethyst.ui.note.toShortenHex
@@ -152,7 +154,8 @@ open class Note(val idHex: String) {
liveSet?.boosts?.invalidateData() liveSet?.boosts?.invalidateData()
} }
fun removeReaction(note: Note) { fun removeReaction(note: Note) {
val reaction = note.event?.content() ?: "+" val tags = note.event?.tags() ?: emptyList()
val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
if (reaction in reactions.keys && reactions[reaction]?.contains(note) == true) { if (reaction in reactions.keys && reactions[reaction]?.contains(note) == true) {
reactions[reaction]?.let { reactions[reaction]?.let {
@@ -233,7 +236,8 @@ open class Note(val idHex: String) {
} }
fun addReaction(note: Note) { fun addReaction(note: Note) {
val reaction = note.event?.content() ?: "+" val tags = note.event?.tags() ?: emptyList()
val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
if (reaction !in reactions.keys) { if (reaction !in reactions.keys) {
reactions = reactions + Pair(reaction, setOf(note)) reactions = reactions + Pair(reaction, setOf(note))
@@ -462,7 +466,7 @@ open class Note(val idHex: String) {
} }
fun reactedBy(loggedIn: User, content: String): List<Note> { fun reactedBy(loggedIn: User, content: String): List<Note> {
return reactions[content]?.filter { it.author == loggedIn && it.event?.content() == content } ?: emptyList() return reactions[content]?.filter { it.author == loggedIn } ?: emptyList()
} }
fun reactedBy(loggedIn: User): List<String> { fun reactedBy(loggedIn: User): List<String> {
@@ -31,6 +31,7 @@ class User(val pubkeyHex: String) {
var latestContactList: ContactListEvent? = null var latestContactList: ContactListEvent? = null
var latestBookmarkList: BookmarkListEvent? = null var latestBookmarkList: BookmarkListEvent? = null
var latestAcceptedBadges: AddressableNote? = null
var notes = setOf<Note>() var notes = setOf<Note>()
private set private set
@@ -49,8 +50,6 @@ class User(val pubkeyHex: String) {
var privateChatrooms = mapOf<User, Chatroom>() var privateChatrooms = mapOf<User, Chatroom>()
private set private set
var acceptedBadges: AddressableNote? = null
fun pubkey() = Hex.decode(pubkeyHex) fun pubkey() = Hex.decode(pubkeyHex)
fun pubkeyNpub() = pubkey().toNpub() fun pubkeyNpub() = pubkey().toNpub()
@@ -146,8 +145,8 @@ class User(val pubkeyHex: String) {
} }
fun updateAcceptedBadges(note: AddressableNote) { fun updateAcceptedBadges(note: AddressableNote) {
if (acceptedBadges?.idHex != note.idHex) { if (latestAcceptedBadges?.idHex != note.idHex) {
acceptedBadges = note latestAcceptedBadges = note
liveSet?.badges?.invalidateData() liveSet?.badges?.invalidateData()
} }
} }
@@ -51,9 +51,9 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
return TypedFilter( return TypedFilter(
types = COMMON_FEED_TYPES, types = COMMON_FEED_TYPES,
filter = JsonFilter( filter = JsonFilter(
kinds = listOf(BadgeProfilesEvent.kind), kinds = listOf(BadgeProfilesEvent.kind, EmojiPackSelectionEvent.kind),
authors = listOf(account.userProfile().pubkeyHex), authors = listOf(account.userProfile().pubkeyHex),
limit = 1 limit = 10
) )
) )
} }
@@ -50,14 +50,24 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
return addressesToWatch.mapNotNull { return addressesToWatch.mapNotNull {
it.address()?.let { aTag -> it.address()?.let { aTag ->
TypedFilter( if (aTag.kind < 25000 && aTag.dTag.isBlank()) {
types = COMMON_FEED_TYPES, TypedFilter(
filter = JsonFilter( types = COMMON_FEED_TYPES,
kinds = listOf(aTag.kind), filter = JsonFilter(
tags = mapOf("d" to listOf(aTag.dTag)), kinds = listOf(aTag.kind),
authors = listOf(aTag.pubKeyHex) authors = listOf(aTag.pubKeyHex)
)
) )
) } else {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex)
)
)
}
} }
} }
} }
@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst.service package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
fun String.isUTF16Char(pos: Int): Boolean { fun String.isUTF16Char(pos: Int): Boolean {
println("Test $pos ${Character.charCount(this.codePointAt(pos))}") println("Test $pos ${Character.charCount(this.codePointAt(pos))}")
return Character.charCount(this.codePointAt(pos)) == 2 return Character.charCount(this.codePointAt(pos)) == 2
@@ -77,3 +79,23 @@ fun String.firstFullChar(): String {
return substring(start, start + previousCharLength) return substring(start, start + previousCharLength)
} }
fun String.firstFullCharOrEmoji(tags: ImmutableListOfLists<String>): String {
if (length <= 2) {
return firstFullChar()
}
if (this[0] == ':') {
// makes sure an emoji exists
val emojiParts = this.split(":", limit = 3)
if (emojiParts.size >= 2) {
val emojiName = emojiParts[1]
val emojiUrl = tags.lists.firstOrNull() { it.size > 1 && it[1] == emojiName }?.getOrNull(2)
if (emojiUrl != null) {
return ":$emojiName:$emojiUrl"
}
}
}
return firstFullChar()
}
@@ -0,0 +1,56 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
@Immutable
class EmojiPackEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) {
companion object {
const val kind = 30030
fun create(
name: String = "",
privateKey: ByteArray,
createdAt: Long = TimeUtils.now()
): EmojiPackEvent {
val content = ""
val pubKey = Utils.pubkeyCreate(privateKey)
val tags = mutableListOf<List<String>>()
tags.add(listOf("d", name))
val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return EmojiPackEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
}
}
}
@Immutable
data class EmojiUrl(val code: String, val url: String) {
fun encode(): String {
return ":$code:$url"
}
companion object {
fun decode(encodedEmojiSetup: String): EmojiUrl? {
val emojiParts = encodedEmojiSetup.split(":", limit = 3)
return if (emojiParts.size > 2) {
EmojiUrl(emojiParts[1], emojiParts[2])
} else {
null
}
}
}
}
@@ -0,0 +1,43 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
@Immutable
class EmojiPackSelectionEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
override fun dTag() = ""
override fun address() = ATag(kind, pubKey, dTag(), null)
companion object {
const val kind = 10030
fun create(
listOfEmojiPacks: List<ATag>?,
privateKey: ByteArray,
createdAt: Long = TimeUtils.now()
): EmojiPackSelectionEvent {
val msg = ""
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
listOfEmojiPacks?.forEach {
tags.add(listOf("a", it.toTag()))
}
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
return EmojiPackSelectionEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
}
}
}
@@ -49,6 +49,8 @@ open class Event(
fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] } fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] }
override fun taggedEmojis() = tags.filter { it.size > 2 && it[0] == "emoji" }.map { EmojiUrl(it[1], it[2]) }
override fun isSensitive() = tags.any { override fun isSensitive() = tags.any {
(it.size > 0 && it[0].equals("content-warning", true)) || (it.size > 0 && it[0].equals("content-warning", true)) ||
(it.size > 1 && it[0] == "t" && it[1].equals("nsfw", true)) || (it.size > 1 && it[0] == "t" && it[1].equals("nsfw", true)) ||
@@ -70,6 +72,8 @@ open class Event(
override fun hashtags() = tags.filter { it.size > 1 && it[0] == "t" }.map { it[1] } override fun hashtags() = tags.filter { it.size > 1 && it[0] == "t" }.map { it[1] }
override fun matchTag1With(text: String) = tags.any { it.size > 1 && it[1].contains(text, true) }
override fun isTaggedUser(idHex: String) = tags.any { it.size > 1 && it[0] == "p" && it[1] == idHex } override fun isTaggedUser(idHex: String) = tags.any { it.size > 1 && it[0] == "p" && it[1] == idHex }
override fun isTaggedAddressableNote(idHex: String) = tags.any { it.size > 1 && it[0] == "a" && it[1] == idHex } override fun isTaggedAddressableNote(idHex: String) = tags.any { it.size > 1 && it[0] == "a" && it[1] == idHex }
@@ -267,6 +271,8 @@ open class Event(
CommunityPostApprovalEvent.kind -> CommunityPostApprovalEvent(id, pubKey, createdAt, tags, content, sig) CommunityPostApprovalEvent.kind -> CommunityPostApprovalEvent(id, pubKey, createdAt, tags, content, sig)
ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig) ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig)
DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig) DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig)
EmojiPackEvent.kind -> EmojiPackEvent(id, pubKey, createdAt, tags, content, sig)
EmojiPackSelectionEvent.kind -> EmojiPackSelectionEvent(id, pubKey, createdAt, tags, content, sig)
FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig) FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
FileStorageEvent.kind -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig) FileStorageEvent.kind -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig)
@@ -45,4 +45,7 @@ interface EventInterface {
fun zapAddress(): String? fun zapAddress(): String?
fun isSensitive(): Boolean fun isSensitive(): Boolean
fun zapraiserAmount(): Long? fun zapraiserAmount(): Long?
fun taggedEmojis(): List<EmojiUrl>
fun matchTag1With(text: String): Boolean
} }
@@ -42,5 +42,24 @@ class ReactionEvent(
val sig = Utils.sign(id, privateKey) val sig = Utils.sign(id, privateKey)
return ReactionEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) return ReactionEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
} }
fun create(emojiUrl: EmojiUrl, originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ReactionEvent {
val content = ":${emojiUrl.code}:"
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags = listOf(
listOf("e", originalNote.id()),
listOf("p", originalNote.pubKey()),
listOf("emoji", emojiUrl.code, emojiUrl.url)
)
if (originalNote is AddressableEvent) {
tags = tags + listOf(listOf("a", originalNote.address().toTag()))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ReactionEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
} }
} }
@@ -399,7 +399,7 @@ fun CreateTextWithEmoji(
) )
).toSpanStyle() ).toSpanStyle()
InLineIconRenderer(emojiList, style, maxLines, overflow, modifier) InLineIconRenderer(emojiList, style, fontSize, maxLines, overflow, modifier)
} }
} }
@@ -458,7 +458,7 @@ fun CreateTextWithEmoji(
).toSpanStyle() ).toSpanStyle()
} }
InLineIconRenderer(emojiList, style, maxLines, overflow, modifier) InLineIconRenderer(emojiList, style, fontSize, maxLines, overflow, modifier)
} }
} }
@@ -551,7 +551,7 @@ fun CreateClickableTextWithEmoji(
InLineIconRenderer( InLineIconRenderer(
emojiLists!!.part2, emojiLists!!.part2,
LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.onBackground, fontWeight = fontWeight).toSpanStyle(), LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.onBackground, fontWeight = fontWeight).toSpanStyle(),
maxLines maxLines = maxLines
) )
} }
} }
@@ -646,6 +646,7 @@ fun ClickableInLineIconRenderer(
fun InLineIconRenderer( fun InLineIconRenderer(
wordsInOrder: ImmutableList<Renderable>, wordsInOrder: ImmutableList<Renderable>,
style: SpanStyle, style: SpanStyle,
fontSize: TextUnit = TextUnit.Unspecified,
maxLines: Int = Int.MAX_VALUE, maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip, overflow: TextOverflow = TextOverflow.Clip,
modifier: Modifier = Modifier modifier: Modifier = Modifier
@@ -692,6 +693,7 @@ fun InLineIconRenderer(
Text( Text(
text = annotatedText, text = annotatedText,
inlineContent = inlineContent, inlineContent = inlineContent,
fontSize = fontSize,
maxLines = maxLines, maxLines = maxLines,
overflow = overflow, overflow = overflow,
modifier = modifier modifier = modifier
@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.ui.note
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -41,6 +41,7 @@ import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -50,11 +51,11 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.firstFullChar
import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.ImageUrlType
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.CombinedZap import com.vitorpamplona.amethyst.ui.screen.CombinedZap
@@ -77,6 +78,7 @@ import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import com.vitorpamplona.amethyst.ui.theme.overPictureBackground import com.vitorpamplona.amethyst.ui.theme.overPictureBackground
import com.vitorpamplona.amethyst.ui.theme.profile35dpModifier import com.vitorpamplona.amethyst.ui.theme.profile35dpModifier
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
@@ -207,18 +209,6 @@ fun RenderLikeGallery(
nav: (String) -> Unit, nav: (String) -> Unit,
accountViewModel: AccountViewModel accountViewModel: AccountViewModel
) { ) {
val tags = remember(likeEvents) {
if (reactionType.startsWith(":")) {
ImmutableListOfLists<String>(
likeEvents.mapNotNull {
it.event?.tags()?.filter { it.size > 2 && it[0] == "emoji" }
}.flatten()
)
} else {
ImmutableListOfLists<String>()
}
}
if (likeEvents.isNotEmpty()) { if (likeEvents.isNotEmpty()) {
Row(Modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth()) {
Box( Box(
@@ -228,19 +218,32 @@ fun RenderLikeGallery(
Modifier.align(Alignment.TopEnd) Modifier.align(Alignment.TopEnd)
} }
when (val shortReaction = reactionType.firstFullChar()) { if (reactionType.startsWith(":")) {
"+" -> Icon( val noStartColon = reactionType.removePrefix(":")
painter = painterResource(R.drawable.ic_liked), val url = noStartColon.substringAfter(":")
null,
modifier = modifier.size(Size18dp), val renderable = listOf(
tint = Color.Unspecified ImageUrlType(url)
) ).toImmutableList()
"-" -> Text(text = "\uD83D\uDC4E", modifier = modifier)
else -> CreateTextWithEmoji( InLineIconRenderer(
text = shortReaction, renderable,
modifier = modifier, style = SpanStyle(color = Color.White),
tags = tags maxLines = 1,
modifier = modifier
) )
} else {
when (val shortReaction = reactionType) {
"+" -> Icon(
painter = painterResource(R.drawable.ic_liked),
null,
modifier = modifier.size(Size18dp),
tint = Color.Unspecified
)
"-" -> Text(text = "\uD83D\uDC4E", modifier = modifier)
else -> Text(text = shortReaction, modifier = modifier)
}
} }
} }
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -25,6 +26,8 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CutCornerShape import androidx.compose.foundation.shape.CutCornerShape
import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider import androidx.compose.material.Divider
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.material.IconButton import androidx.compose.material.IconButton
@@ -96,6 +99,9 @@ import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent
import com.vitorpamplona.amethyst.service.model.EmojiPackEvent
import com.vitorpamplona.amethyst.service.model.EmojiPackSelectionEvent
import com.vitorpamplona.amethyst.service.model.EmojiUrl
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
@@ -145,6 +151,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.LeaveCommunityButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
@@ -158,6 +165,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
import com.vitorpamplona.amethyst.ui.theme.Size25dp import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
import com.vitorpamplona.amethyst.ui.theme.Size30dp import com.vitorpamplona.amethyst.ui.theme.Size30dp
import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.Size55dp
@@ -1035,6 +1043,10 @@ private fun RenderNoteRow(
RenderPinListEvent(baseNote, backgroundColor, accountViewModel, nav) RenderPinListEvent(baseNote, backgroundColor, accountViewModel, nav)
} }
is EmojiPackEvent -> {
RenderEmojiPack(baseNote, true, backgroundColor, accountViewModel)
}
is LiveActivitiesEvent -> { is LiveActivitiesEvent -> {
RenderLiveActivityEvent(baseNote, accountViewModel, nav) RenderLiveActivityEvent(baseNote, accountViewModel, nav)
} }
@@ -1884,18 +1896,188 @@ fun LoadAddressableNote(aTag: ATag, content: @Composable (AddressableNote?) -> U
} }
@Composable @Composable
private fun RenderPinListEvent( public fun RenderEmojiPack(
baseNote: Note, baseNote: Note,
actionable: Boolean,
backgroundColor: MutableState<Color>, backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: (String) -> Unit onClick: ((EmojiUrl) -> Unit)? = null
) { ) {
PinListHeader(baseNote, backgroundColor, accountViewModel, nav) val noteEvent by baseNote.live().metadata.map {
it.note.event
}.distinctUntilChanged().observeAsState(baseNote.event)
if (noteEvent == null || noteEvent !is EmojiPackEvent) return
(noteEvent as? EmojiPackEvent)?.let {
RenderEmojiPack(
noteEvent = it,
baseNote = baseNote,
actionable = actionable,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
onClick = onClick
)
}
} }
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun PinListHeader( public fun RenderEmojiPack(
noteEvent: EmojiPackEvent,
baseNote: Note,
actionable: Boolean,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
onClick: ((EmojiUrl) -> Unit)? = null
) {
var expanded by remember {
mutableStateOf(false)
}
val allEmojis = remember(noteEvent) {
noteEvent.taggedEmojis()
}
val emojisToShow = if (expanded) {
allEmojis
} else {
allEmojis.take(60)
}
Row(verticalAlignment = CenterVertically) {
Text(
text = remember(noteEvent) { "#${noteEvent.dTag()}" },
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1F)
.padding(5.dp),
textAlign = TextAlign.Center
)
if (actionable) {
EmojiListOptions(accountViewModel, baseNote)
}
}
Box {
FlowRow(modifier = Modifier.padding(top = 5.dp)) {
emojisToShow.forEach { emoji ->
if (onClick != null) {
IconButton(onClick = { onClick(emoji) }, modifier = Size35Modifier) {
AsyncImage(
model = emoji.url,
contentDescription = null,
modifier = Size35Modifier
)
}
} else {
Box(
modifier = Size35Modifier,
contentAlignment = Alignment.Center
) {
AsyncImage(
model = emoji.url,
contentDescription = null,
modifier = Size35Modifier
)
}
}
}
}
if (allEmojis.size > 60 && !expanded) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(getGradient(backgroundColor))
) {
ShowMoreButton {
expanded = !expanded
}
}
}
}
}
@Composable
private fun EmojiListOptions(
accountViewModel: AccountViewModel,
emojiPackNote: Note
) {
LoadAddressableNote(
aTag = ATag(
EmojiPackSelectionEvent.kind,
accountViewModel.userProfile().pubkeyHex,
"",
null
)
) {
it?.let { usersEmojiList ->
val hasAddedThis by usersEmojiList.live().metadata.map {
usersEmojiList.event?.isTaggedAddressableNote(emojiPackNote.idHex)
}.distinctUntilChanged().observeAsState()
Crossfade(targetState = hasAddedThis) {
val scope = rememberCoroutineScope()
if (it != true) {
AddButton() {
scope.launch(Dispatchers.IO) {
accountViewModel.addEmojiPack(usersEmojiList, emojiPackNote)
}
}
} else {
RemoveButton {
scope.launch(Dispatchers.IO) {
accountViewModel.removeEmojiPack(usersEmojiList, emojiPackNote)
}
}
}
}
}
}
}
@Composable
fun RemoveButton(onClick: () -> Unit) {
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = onClick,
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = stringResource(R.string.remove), color = Color.White)
}
}
@Composable
fun AddButton(text: Int = R.string.add, onClick: () -> Unit) {
Button(
modifier = Modifier.padding(start = 3.dp),
onClick = onClick,
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = stringResource(text), color = Color.White, textAlign = TextAlign.Center)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RenderPinListEvent(
baseNote: Note, baseNote: Note,
backgroundColor: MutableState<Color>, backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
@@ -66,6 +66,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
@@ -81,8 +82,10 @@ import coil.request.CachePolicy
import coil.request.ImageRequest import coil.request.ImageRequest
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.firstFullChar
import com.vitorpamplona.amethyst.ui.actions.NewPostView import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.components.ImageUrlType
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
import com.vitorpamplona.amethyst.ui.components.TextType
import com.vitorpamplona.amethyst.ui.screen.CombinedZap import com.vitorpamplona.amethyst.ui.screen.CombinedZap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
@@ -808,7 +811,8 @@ private fun WatchReactionTypeForNote(baseNote: Note, accountViewModel: AccountVi
LaunchedEffect(key1 = reactionsState) { LaunchedEffect(key1 = reactionsState) {
launch(Dispatchers.Default) { launch(Dispatchers.Default) {
onNewReactionType(reactionsState?.note?.getReactionBy(accountViewModel.userProfile())?.firstFullChar()) val reactionNote = reactionsState?.note?.getReactionBy(accountViewModel.userProfile())
onNewReactionType(reactionNote)
} }
} }
} }
@@ -840,18 +844,34 @@ private fun RenderReactionType(
Modifier.size(iconSize) Modifier.size(iconSize)
} }
when (reactionType) { if (reactionType.startsWith(":")) {
"+" -> { val noStartColon = reactionType.removePrefix(":")
Icon( val url = noStartColon.substringAfter(":")
painter = painterResource(R.drawable.ic_liked),
null,
modifier = iconModifier,
tint = Color.Unspecified
)
}
"-" -> Text(text = "\uD83D\uDC4E", fontSize = iconFontSize) val renderable = listOf(
else -> Text(text = reactionType, fontSize = iconFontSize) ImageUrlType(url)
).toImmutableList()
InLineIconRenderer(
renderable,
style = SpanStyle(color = Color.White),
fontSize = iconFontSize,
maxLines = 1
)
} else {
when (reactionType) {
"+" -> {
Icon(
painter = painterResource(R.drawable.ic_liked),
null,
modifier = iconModifier,
tint = Color.Unspecified
)
}
"-" -> Text(text = "\uD83D\uDC4E", fontSize = iconFontSize)
else -> Text(text = reactionType, fontSize = iconFontSize)
}
} }
} }
@@ -1269,7 +1289,7 @@ private fun BoostTypeChoicePopup(baseNote: Note, accountViewModel: AccountViewMo
} }
} }
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun ReactionChoicePopup( fun ReactionChoicePopup(
baseNote: Note, baseNote: Note,
@@ -1279,7 +1299,6 @@ fun ReactionChoicePopup(
) { ) {
val accountState by accountViewModel.accountLiveData.observeAsState() val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return val account = accountState?.account ?: return
val scope = rememberCoroutineScope()
val toRemove = remember { val toRemove = remember {
baseNote.reactedBy(account.userProfile()).toSet() baseNote.reactedBy(account.userProfile()).toSet()
@@ -1292,67 +1311,117 @@ fun ReactionChoicePopup(
) { ) {
FlowRow(horizontalArrangement = Arrangement.Center) { FlowRow(horizontalArrangement = Arrangement.Center) {
account.reactionChoices.forEach { reactionType -> account.reactionChoices.forEach { reactionType ->
Button( ActionableReactionButton(
modifier = Modifier.padding(horizontal = 3.dp), baseNote,
onClick = { reactionType,
scope.launch(Dispatchers.IO) { accountViewModel,
accountViewModel.reactToOrDelete( onDismiss,
baseNote, onChangeAmount,
reactionType toRemove
) )
onDismiss() }
} }
}, }
shape = ButtonBorder, }
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
val thisModifier = remember(reactionType) {
Modifier.combinedClickable(
onClick = {
scope.launch(Dispatchers.IO) {
accountViewModel.reactToOrDelete(
baseNote,
reactionType
)
onDismiss()
}
},
onLongClick = {
onChangeAmount()
}
)
}
val removeSymbol = remember(reactionType) { @Composable
if (reactionType in toRemove) { @OptIn(ExperimentalFoundationApi::class)
"" private fun ActionableReactionButton(
} else { baseNote: Note,
"" reactionType: String,
} accountViewModel: AccountViewModel,
} onDismiss: () -> Unit,
onChangeAmount: () -> Unit,
toRemove: Set<String>
) {
val scope = rememberCoroutineScope()
when (reactionType) { Button(
"+" -> { modifier = Modifier.padding(horizontal = 3.dp),
Icon( onClick = {
painter = painterResource(R.drawable.ic_liked), scope.launch(Dispatchers.IO) {
null, accountViewModel.reactToOrDelete(
modifier = remember { thisModifier.size(16.dp) }, baseNote,
tint = Color.White reactionType
) )
Text(text = removeSymbol, color = Color.White, textAlign = TextAlign.Center, modifier = thisModifier) onDismiss()
} }
"-" -> Text(text = "\uD83D\uDC4E$removeSymbol", color = Color.White, textAlign = TextAlign.Center, modifier = thisModifier) },
else -> Text( shape = ButtonBorder,
"$reactionType$removeSymbol", colors = ButtonDefaults
color = Color.White, .buttonColors(
textAlign = TextAlign.Center, backgroundColor = MaterialTheme.colors.primary
modifier = thisModifier )
) {
val thisModifier = remember(reactionType) {
Modifier.combinedClickable(
onClick = {
scope.launch(Dispatchers.IO) {
accountViewModel.reactToOrDelete(
baseNote,
reactionType
) )
onDismiss()
} }
},
onLongClick = {
onChangeAmount()
} }
)
}
val removeSymbol = remember(reactionType) {
if (reactionType in toRemove) {
""
} else {
""
}
}
if (reactionType.startsWith(":")) {
val noStartColon = reactionType.removePrefix(":")
val url = noStartColon.substringAfter(":")
val renderable = listOf(
ImageUrlType(url),
TextType(removeSymbol)
).toImmutableList()
InLineIconRenderer(
renderable,
style = SpanStyle(color = Color.White),
maxLines = 1
)
} else {
when (reactionType) {
"+" -> {
Icon(
painter = painterResource(R.drawable.ic_liked),
null,
modifier = remember { thisModifier.size(16.dp) },
tint = Color.White
)
Text(
text = removeSymbol,
color = Color.White,
textAlign = TextAlign.Center,
modifier = thisModifier
)
}
"-" -> Text(
text = "\uD83D\uDC4E$removeSymbol",
color = Color.White,
textAlign = TextAlign.Center,
modifier = thisModifier
)
else -> Text(
"$reactionType$removeSymbol",
color = Color.White,
textAlign = TextAlign.Center,
modifier = thisModifier
)
} }
} }
} }
@@ -12,6 +12,9 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@@ -24,7 +27,9 @@ import androidx.compose.material.Surface
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -33,6 +38,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
@@ -42,15 +48,25 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.firstFullChar import com.vitorpamplona.amethyst.service.firstFullChar
import com.vitorpamplona.amethyst.service.model.ATag
import com.vitorpamplona.amethyst.service.model.EmojiPackSelectionEvent
import com.vitorpamplona.amethyst.service.model.EmojiUrl
import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SaveButton import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.ui.components.ImageUrlType
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
import com.vitorpamplona.amethyst.ui.components.TextType
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.toImmutableList
class UpdateReactionTypeViewModel(val account: Account) : ViewModel() { class UpdateReactionTypeViewModel(val account: Account) : ViewModel() {
var nextChoice by mutableStateOf(TextFieldValue("")) var nextChoice by mutableStateOf(TextFieldValue(""))
@@ -71,6 +87,10 @@ class UpdateReactionTypeViewModel(val account: Account) : ViewModel() {
nextChoice = TextFieldValue("") nextChoice = TextFieldValue("")
} }
fun addChoice(customEmoji: EmojiUrl) {
reactionSet = reactionSet + (customEmoji.encode())
}
fun removeChoice(reaction: String) { fun removeChoice(reaction: String) {
reactionSet = reactionSet - reaction reactionSet = reactionSet - reaction
} }
@@ -116,10 +136,13 @@ fun UpdateReactionTypeDialog(onClose: () -> Unit, nip47uri: String? = null, acco
) )
) { ) {
Surface( Surface(
modifier = Modifier modifier = Modifier.fillMaxWidth()
.fillMaxWidth()
) { ) {
Column(modifier = Modifier.padding(10.dp).imePadding()) { Column(
modifier = Modifier
.padding(10.dp)
.imePadding()
) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
@@ -155,30 +178,7 @@ fun UpdateReactionTypeDialog(onClose: () -> Unit, nip47uri: String? = null, acco
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
postViewModel.reactionSet.forEach { reactionType -> postViewModel.reactionSet.forEach { reactionType ->
Button( RenderReactionOption(reactionType, postViewModel)
modifier = Modifier.padding(horizontal = 3.dp),
shape = ButtonBorder,
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
onClick = {
postViewModel.removeChoice(reactionType)
}
) {
when (reactionType) {
"+" -> {
Icon(
painter = painterResource(R.drawable.ic_liked),
null,
modifier = remember { Modifier.size(16.dp) },
tint = Color.White
)
Text(text = "", color = Color.White, textAlign = TextAlign.Center)
}
"-" -> Text(text = "\uD83D\uDC4E", color = Color.White, textAlign = TextAlign.Center)
else -> Text(text = "$reactionType", color = Color.White, textAlign = TextAlign.Center)
}
}
} }
} }
} }
@@ -226,7 +226,130 @@ fun UpdateReactionTypeDialog(onClose: () -> Unit, nip47uri: String? = null, acco
} }
} }
} }
EmojiSelector(accountViewModel) {
postViewModel.addChoice(it)
}
} }
} }
} }
} }
@Composable
private fun RenderReactionOption(
reactionType: String,
postViewModel: UpdateReactionTypeViewModel
) {
Button(
modifier = Modifier.padding(horizontal = 3.dp),
shape = ButtonBorder,
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
onClick = {
postViewModel.removeChoice(reactionType)
}
) {
if (reactionType.startsWith(":")) {
val noStartColon = reactionType.removePrefix(":")
val url = noStartColon.substringAfter(":")
val renderable = listOf(
ImageUrlType(url),
TextType("")
).toImmutableList()
InLineIconRenderer(
renderable,
style = SpanStyle(color = Color.White),
maxLines = 1
)
} else {
when (reactionType) {
"+" -> {
Icon(
painter = painterResource(R.drawable.ic_liked),
null,
modifier = remember { Modifier.size(16.dp) },
tint = Color.White
)
Text(
text = "",
color = Color.White,
textAlign = TextAlign.Center
)
}
"-" -> Text(
text = "\uD83D\uDC4E",
color = Color.White,
textAlign = TextAlign.Center
)
else -> Text(
text = "$reactionType",
color = Color.White,
textAlign = TextAlign.Center
)
}
}
}
}
@Composable
private fun EmojiSelector(accountViewModel: AccountViewModel, onClick: ((EmojiUrl) -> Unit)? = null) {
LoadAddressableNote(
aTag = ATag(
EmojiPackSelectionEvent.kind,
accountViewModel.userProfile().pubkeyHex,
"",
null
)
) { emptyNote ->
emptyNote?.let { usersEmojiList ->
val collections by usersEmojiList.live().metadata.map {
(it.note.event as? EmojiPackSelectionEvent)?.taggedAddresses()
}.distinctUntilChanged().observeAsState((usersEmojiList.event as? EmojiPackSelectionEvent)?.taggedAddresses())
collections?.let {
EmojiCollectionGallery(it, accountViewModel, onClick)
}
}
}
}
@Composable
fun EmojiCollectionGallery(emojiCollections: List<ATag>, accountViewModel: AccountViewModel, onClick: ((EmojiUrl) -> Unit)? = null) {
val color = MaterialTheme.colors.background
val bgColor = remember { mutableStateOf(color) }
val listState = rememberLazyListState()
LazyColumn(
state = listState
) {
itemsIndexed(emojiCollections, key = { _, item -> item.toTag() }) { _, item ->
LoadAddressableNote(aTag = item) {
it?.let {
WatchAndRenderNote(it, bgColor, accountViewModel, onClick)
}
}
}
}
}
@Composable
private fun WatchAndRenderNote(
it: AddressableNote,
bgColor: MutableState<Color>,
accountViewModel: AccountViewModel,
onClick: ((EmojiUrl) -> Unit)?
) {
RenderEmojiPack(
baseNote = it,
actionable = false,
backgroundColor = bgColor,
accountViewModel = accountViewModel,
onClick = onClick
)
}
@@ -5,6 +5,8 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toImmutableMap
@@ -61,7 +63,9 @@ class MultiSetCard(
boostEvents.minOfOrNull { it.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE boostEvents.minOfOrNull { it.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE
) )
val likeEventsByType = likeEvents.groupBy { it.event?.content() ?: "+" }.mapValues { val likeEventsByType = likeEvents.groupBy {
it.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(it.event?.tags() ?: emptyList())) ?: "+"
}.mapValues {
it.value.toImmutableList() it.value.toImmutableList()
}.toImmutableMap() }.toImmutableMap()
@@ -23,7 +23,6 @@ import androidx.compose.material.Icon
import androidx.compose.material.IconButton import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.ProvideTextStyle import androidx.compose.material.ProvideTextStyle
import androidx.compose.material.SnackbarDefaults.backgroundColor
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
@@ -62,6 +61,7 @@ import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent
import com.vitorpamplona.amethyst.service.model.EmojiPackEvent
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
@@ -417,12 +417,19 @@ fun NoteMaster(
nav nav
) )
} else if (noteEvent is PinListEvent) { } else if (noteEvent is PinListEvent) {
PinListHeader( RenderPinListEvent(
baseNote, baseNote,
backgroundColor, backgroundColor,
accountViewModel, accountViewModel,
nav nav
) )
} else if (noteEvent is EmojiPackEvent) {
RenderEmojiPack(
baseNote,
true,
backgroundColor,
accountViewModel
)
} else if (noteEvent is RelaySetEvent) { } else if (noteEvent is RelaySetEvent) {
DisplayRelaySet( DisplayRelaySet(
baseNote, baseNote,
@@ -165,6 +165,14 @@ class AccountViewModel(val account: Account) : ViewModel() {
account.boost(note) account.boost(note)
} }
fun removeEmojiPack(usersEmojiList: Note, emojiList: Note) {
account.removeEmojiPack(usersEmojiList, emojiList)
}
fun addEmojiPack(usersEmojiList: Note, emojiList: Note) {
account.addEmojiPack(usersEmojiList, emojiList)
}
fun addPrivateBookmark(note: Note) { fun addPrivateBookmark(note: Note) {
account.addPrivateBookmark(note) account.addPrivateBookmark(note)
} }
@@ -1118,7 +1118,7 @@ private fun DisplayBadges(
val userBadgeState by baseUser.live().badges.observeAsState() val userBadgeState by baseUser.live().badges.observeAsState()
val badgeList by remember(userBadgeState) { val badgeList by remember(userBadgeState) {
derivedStateOf { derivedStateOf {
val list = (userBadgeState?.user?.acceptedBadges?.event as? BadgeProfilesEvent)?.badgeAwardEvents() val list = (userBadgeState?.user?.latestAcceptedBadges?.event as? BadgeProfilesEvent)?.badgeAwardEvents()
if (list.isNullOrEmpty()) { if (list.isNullOrEmpty()) {
null null
} else { } else {