mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d5ff5bea9 | ||
|
|
1767d4a3a3 | ||
|
|
f2d87b78b1 | ||
|
|
4bf76d1b79 | ||
|
|
17fbf8c01f | ||
|
|
332490376b | ||
|
|
14e670cd9a | ||
|
|
d29e6b4d0b | ||
|
|
3558bab786 | ||
|
|
7c21f077db | ||
|
|
a2a9d04e1e | ||
|
|
fc56f3988e | ||
|
|
95ad33ace2 | ||
|
|
cf850415b8 | ||
|
|
bd77bb7780 | ||
|
|
6a696bfa39 | ||
|
|
d5fc97184c | ||
|
|
e0b9120e17 | ||
|
|
1523a29a4f | ||
|
|
1db6b4793f | ||
|
|
426a495eb4 | ||
|
|
e07b4d49ef | ||
|
|
595cab3630 | ||
|
|
817a9c3e8d | ||
|
|
32927040e3 | ||
|
|
fc1ee54548 | ||
|
|
754ac9894b | ||
|
|
e80c8b37b3 |
16
README.md
16
README.md
@@ -15,7 +15,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
|
||||
|
||||
# Current Features
|
||||
|
||||
- [x] Event Builders / WebSocket Subscriptions (NIP-01, NIP-15)
|
||||
- [x] Event Builders / WebSocket Subscriptions (NIP-01)
|
||||
- [x] Home Feed
|
||||
- [x] Notifications Feed
|
||||
- [x] Global Feed
|
||||
@@ -34,7 +34,8 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
|
||||
- [x] Lightning Tips
|
||||
- [x] Zaps (private, public, anon, non-zap) (NIP-57)
|
||||
- [x] URI Support (NIP-21)
|
||||
- [x] Event Deletion (NIP-09: like, boost, text notes and reports)
|
||||
- [x] Created_at Limits (NIP-22)
|
||||
- [x] Event Deletion (NIP-09)
|
||||
- [x] Identity Verification (NIP-05)
|
||||
- [x] Long-form Content (NIP-23)
|
||||
- [x] Parameterized Replaceable Events (NIP-33)
|
||||
@@ -54,13 +55,18 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
|
||||
- [x] Markdown Support
|
||||
- [x] Relay Authentication (NIP-42)
|
||||
- [x] Content stored in relays themselves (NIP-95)
|
||||
- [x] Custom Emoji (NIP-30)
|
||||
- [x] Text Note References (NIP-27)
|
||||
- [x] Audio Tracks (zapstr.live) (NIP-TBD)
|
||||
- [x] Push Notifications (Zaps and Messages)
|
||||
- [x] Generic Tags (NIP-12)
|
||||
- [ ] Marketplace (NIP-15)
|
||||
- [ ] Image/Video Capture in the app
|
||||
- [ ] Local Database
|
||||
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
|
||||
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
|
||||
- [ ] Sensitive Content (NIP-36)
|
||||
- [ ] Relay Pages (NIP-11)
|
||||
- [ ] Generic Tags (NIP-12)
|
||||
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
|
||||
- [ ] Events with a Subject (NIP-14)
|
||||
- [ ] Workspaces
|
||||
@@ -69,8 +75,10 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
|
||||
- [ ] Relay List Metadata (NIP-65)
|
||||
- [ ] Signing Requests (NIP-46)
|
||||
- [ ] Delegated Event Signing (NIP-26)
|
||||
- [ ] Account Creation / Backup Guidance (NIP-06)
|
||||
- [ ] Account Creation / Backup Guidance
|
||||
- [ ] Mnemonic seed phrase (NIP-06)
|
||||
- [ ] Message Sent feedback (NIP-20)
|
||||
- [ ] OpenTimestamps Attestations (NIP-03)
|
||||
|
||||
|
||||
# Development Overview
|
||||
|
||||
@@ -13,8 +13,8 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 160
|
||||
versionName "0.46.6"
|
||||
versionCode 165
|
||||
versionName "0.49.2"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.core.net.toUri
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
@@ -281,6 +280,30 @@ object LocalCache {
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
private fun consume(event: PinListEvent) {
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
private fun consume(event: AudioTrackEvent) {
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: BadgeDefinitionEvent) {
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
@@ -739,19 +762,18 @@ object LocalCache {
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
val file = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
|
||||
|
||||
if (!file.exists()) {
|
||||
try {
|
||||
val cachePath = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
|
||||
cachePath.mkdirs()
|
||||
val stream = FileOutputStream(File(cachePath, event.id))
|
||||
try {
|
||||
val cachePath = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
|
||||
cachePath.mkdirs()
|
||||
val file = File(cachePath, event.id)
|
||||
if (!file.exists()) {
|
||||
val stream = FileOutputStream(file)
|
||||
stream.write(event.decode())
|
||||
stream.close()
|
||||
Log.e("EventLogger", "Saved to disk as ${File(cachePath, event.id).toUri()}")
|
||||
} catch (e: IOException) {
|
||||
Log.e("FileSotrageEvent", "FileStorageEvent save to disk error: " + event.id, e)
|
||||
Log.i("FileStorageEvent", "NIP95 File received from ${relay?.url} and saved to disk as $file")
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e("FileStorageEvent", "FileStorageEvent save to disk error: " + event.id, e)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
@@ -920,6 +942,7 @@ object LocalCache {
|
||||
|
||||
try {
|
||||
when (event) {
|
||||
is AudioTrackEvent -> consume(event)
|
||||
is BadgeAwardEvent -> consume(event)
|
||||
is BadgeDefinitionEvent -> consume(event)
|
||||
is BadgeProfilesEvent -> consume(event)
|
||||
@@ -948,6 +971,7 @@ object LocalCache {
|
||||
is LongTextNoteEvent -> consume(event, relay)
|
||||
is MetadataEvent -> consume(event)
|
||||
is PrivateDmEvent -> consume(event, relay)
|
||||
is PinListEvent -> consume(event)
|
||||
is PeopleListEvent -> consume(event)
|
||||
is ReactionEvent -> consume(event)
|
||||
is RecommendRelayEvent -> consume(event)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import java.util.regex.Pattern
|
||||
|
||||
class NIP30Parser {
|
||||
val customEmojiPattern: Pattern = Pattern.compile("\\:([A-Za-z0-9_\\-]+)\\:", Pattern.CASE_INSENSITIVE)
|
||||
|
||||
fun buildArray(input: String): List<String> {
|
||||
val matcher = customEmojiPattern.matcher(input)
|
||||
val list = mutableListOf<String>()
|
||||
while (matcher.find()) {
|
||||
list.add(matcher.group())
|
||||
}
|
||||
|
||||
if (list.isEmpty()) {
|
||||
return listOf(input)
|
||||
}
|
||||
|
||||
val regularChars = input.split(customEmojiPattern.toRegex())
|
||||
|
||||
var finalList = mutableListOf<String>()
|
||||
var index = 0
|
||||
for (e in regularChars) {
|
||||
finalList.add(e)
|
||||
if (index < list.size) {
|
||||
finalList.add(list[index])
|
||||
}
|
||||
index++
|
||||
}
|
||||
return finalList
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.HighlightEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PinListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
@@ -13,7 +15,7 @@ object NostrGlobalDataSource : NostrDataSource("GlobalFeed") {
|
||||
fun createGlobalFilter() = TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
|
||||
kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, AudioTrackEvent.kind, PinListEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service
|
||||
import androidx.compose.ui.text.capitalize
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
@@ -25,7 +26,7 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
|
||||
hashToLoad.capitalize()
|
||||
)
|
||||
),
|
||||
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
|
||||
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2,8 +2,10 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.UserState
|
||||
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
|
||||
import com.vitorpamplona.amethyst.service.model.HighlightEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PinListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
@@ -56,7 +58,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
|
||||
authors = followSet,
|
||||
limit = 400,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
|
||||
@@ -72,7 +74,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
|
||||
tags = mapOf(
|
||||
"t" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
|
||||
@@ -29,7 +29,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind,
|
||||
LnZapEvent.kind, LnZapRequestEvent.kind,
|
||||
BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind,
|
||||
PollNoteEvent.kind
|
||||
PollNoteEvent.kind, AudioTrackEvent.kind, PinListEvent.kind
|
||||
),
|
||||
tags = mapOf("a" to listOf(aTag.toTag())),
|
||||
since = it.lastReactionsDownloadTime
|
||||
@@ -80,7 +80,9 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
LnZapEvent.kind,
|
||||
LnZapRequestEvent.kind,
|
||||
PollNoteEvent.kind,
|
||||
HighlightEvent.kind
|
||||
HighlightEvent.kind,
|
||||
AudioTrackEvent.kind,
|
||||
PinListEvent.kind
|
||||
),
|
||||
tags = mapOf("e" to listOf(it.idHex)),
|
||||
since = it.lastReactionsDownloadTime
|
||||
@@ -119,7 +121,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
|
||||
PrivateDmEvent.kind,
|
||||
FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind,
|
||||
HighlightEvent.kind
|
||||
HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind
|
||||
),
|
||||
ids = interestedEvents.toList()
|
||||
)
|
||||
|
||||
@@ -28,7 +28,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
|
||||
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, AudioTrackEvent.kind, PinListEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 200
|
||||
)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
class AudioTrackEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
|
||||
|
||||
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
|
||||
override fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
fun participants() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(2)) }
|
||||
fun type() = tags.firstOrNull { it.size > 1 && it[0] == TYPE }?.get(1)
|
||||
fun price() = tags.firstOrNull { it.size > 1 && it[0] == PRICE }?.get(1)
|
||||
fun cover() = tags.firstOrNull { it.size > 1 && it[0] == COVER }?.get(1)
|
||||
fun subject() = tags.firstOrNull { it.size > 1 && it[0] == SUBJECT }?.get(1)
|
||||
fun media() = tags.firstOrNull { it.size > 1 && it[0] == MEDIA }?.get(1)
|
||||
|
||||
companion object {
|
||||
const val kind = 31337
|
||||
|
||||
private const val TYPE = "c"
|
||||
private const val PRICE = "price"
|
||||
private const val COVER = "cover"
|
||||
private const val SUBJECT = "subject"
|
||||
private const val MEDIA = "media"
|
||||
|
||||
fun create(
|
||||
type: String,
|
||||
media: String,
|
||||
price: String? = null,
|
||||
cover: String? = null,
|
||||
subject: String? = null,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): AudioTrackEvent {
|
||||
val tags = listOfNotNull(
|
||||
listOf(MEDIA, media),
|
||||
listOf(TYPE, type),
|
||||
price?.let { listOf(PRICE, it) },
|
||||
cover?.let { listOf(COVER, it) },
|
||||
subject?.let { listOf(SUBJECT, it) }
|
||||
)
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, "")
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return AudioTrackEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Participant(val key: String, val role: String?)
|
||||
@@ -9,9 +9,9 @@ class BadgeDefinitionEvent(
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
|
||||
fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
|
||||
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
|
||||
override fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
fun name() = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1)
|
||||
fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == "thumb" }?.get(1)
|
||||
|
||||
@@ -9,7 +9,7 @@ class BadgeProfilesEvent(
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
|
||||
fun badgeAwardEvents() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
fun badgeAwardDefinitions() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
|
||||
val aTagValue = it.getOrNull(1)
|
||||
@@ -18,8 +18,8 @@ class BadgeProfilesEvent(
|
||||
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
|
||||
}
|
||||
|
||||
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
override fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
companion object {
|
||||
const val kind = 30008
|
||||
|
||||
@@ -220,6 +220,7 @@ open class Event(
|
||||
fun fromJson(json: JsonElement, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
|
||||
|
||||
fun Event.getRefinedEvent(lenient: Boolean = false): Event = when (kind) {
|
||||
AudioTrackEvent.kind -> AudioTrackEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
BadgeAwardEvent.kind -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
BadgeDefinitionEvent.kind -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
BadgeProfilesEvent.kind -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
@@ -243,6 +244,7 @@ open class Event(
|
||||
LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PeopleListEvent.kind -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PinListEvent.kind -> PinListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PollNoteEvent.kind -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PrivateDmEvent.kind -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ReactionEvent.kind -> ReactionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
@@ -282,3 +284,8 @@ open class Event(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface AddressableEvent {
|
||||
fun dTag(): String
|
||||
fun address(): ATag
|
||||
}
|
||||
|
||||
@@ -14,9 +14,9 @@ abstract class GeneralListEvent(
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
|
||||
override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
override fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
fun category() = dTag()
|
||||
fun bookmarkedPosts() = taggedEvents()
|
||||
|
||||
@@ -71,7 +71,7 @@ class LnZapRequestEvent(
|
||||
listOf("p", originalNote.pubKey()),
|
||||
listOf("relays") + relays
|
||||
)
|
||||
if (originalNote is LongTextNoteEvent) {
|
||||
if (originalNote is AddressableEvent) {
|
||||
tags = tags + listOf(listOf("a", originalNote.address().toTag()))
|
||||
}
|
||||
if (pollOption != null && pollOption >= 0) {
|
||||
|
||||
@@ -12,9 +12,9 @@ class LongTextNoteEvent(
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
|
||||
override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
override fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
fun topics() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) }
|
||||
fun title() = tags.filter { it.firstOrNull() == "title" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
|
||||
|
||||
@@ -15,9 +15,9 @@ class MuteListEvent(
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
|
||||
override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
override fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
fun plainContent(privKey: ByteArray): String? {
|
||||
return try {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
class PinListEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
|
||||
|
||||
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
|
||||
override fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
fun pins() = tags.filter { it.size > 1 && it[0] == "pin" }.map { it[1] }
|
||||
|
||||
companion object {
|
||||
const val kind = 33888
|
||||
|
||||
fun create(
|
||||
pins: List<String>,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): PinListEvent {
|
||||
val tags = mutableListOf<List<String>>()
|
||||
pins.forEach {
|
||||
tags.add(listOf("pin", it))
|
||||
}
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, "")
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return PinListEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ class ReactionEvent(
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
|
||||
var tags = listOf(listOf("e", originalNote.id()), listOf("p", originalNote.pubKey()))
|
||||
if (originalNote is LongTextNoteEvent) {
|
||||
if (originalNote is AddressableEvent) {
|
||||
tags = tags + listOf(listOf("a", originalNote.address().toTag()))
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ class ReportEvent(
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
var tags: List<List<String>> = listOf(reportPostTag, reportAuthorTag)
|
||||
|
||||
if (reportedPost is LongTextNoteEvent) {
|
||||
if (reportedPost is AddressableEvent) {
|
||||
tags = tags + listOf(listOf("a", reportedPost.address().toTag()))
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ class RepostEvent(
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
var tags: List<List<String>> = boostedPost.tags().plus(listOf(replyToPost, replyToAuthor))
|
||||
|
||||
if (boostedPost is LongTextNoteEvent) {
|
||||
if (boostedPost is AddressableEvent) {
|
||||
tags = tags + listOf(listOf("a", boostedPost.address().toTag()))
|
||||
}
|
||||
|
||||
|
||||
@@ -380,8 +380,9 @@ fun Notifying(baseMentions: List<User>?, onClick: (User) -> Unit) {
|
||||
onClick(myUser)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
"✖ ${myUser.toBestDisplayName()}",
|
||||
CreateTextWithEmoji(
|
||||
text = "✖ ${myUser.toBestDisplayName()}",
|
||||
tags = myUser.info?.latestMetadata?.tags,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material.LocalContentAlpha
|
||||
import androidx.compose.material.LocalContentColor
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
@@ -11,12 +19,30 @@ import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.takeOrElse
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NIP30Parser
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
@@ -63,31 +89,31 @@ private fun DisplayEvent(
|
||||
|
||||
if (note.event is ChannelCreateEvent) {
|
||||
CreateClickableText(
|
||||
note.idDisplayNote(),
|
||||
nip19.additionalChars,
|
||||
"Channel/${nip19.hex}",
|
||||
navController
|
||||
clickablePart = "@${note.idDisplayNote()}",
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Channel/${nip19.hex}",
|
||||
navController = navController
|
||||
)
|
||||
} else if (note.event is PrivateDmEvent) {
|
||||
CreateClickableText(
|
||||
note.idDisplayNote(),
|
||||
nip19.additionalChars,
|
||||
"Room/${note.author?.pubkeyHex}",
|
||||
navController
|
||||
clickablePart = "@${note.idDisplayNote()}",
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Room/${note.author?.pubkeyHex}",
|
||||
navController = navController
|
||||
)
|
||||
} else if (channel != null) {
|
||||
CreateClickableText(
|
||||
channel.toBestDisplayName(),
|
||||
nip19.additionalChars,
|
||||
"Channel/${note.channel()?.idHex}",
|
||||
navController
|
||||
clickablePart = channel.toBestDisplayName(),
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Channel/${note.channel()?.idHex}",
|
||||
navController = navController
|
||||
)
|
||||
} else {
|
||||
CreateClickableText(
|
||||
note.idDisplayNote(),
|
||||
nip19.additionalChars,
|
||||
"Event/${nip19.hex}",
|
||||
navController
|
||||
clickablePart = "@${note.idDisplayNote()}",
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Event/${nip19.hex}",
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -114,36 +140,36 @@ private fun DisplayNote(
|
||||
|
||||
noteBase?.let {
|
||||
val noteState by it.live().metadata.observeAsState()
|
||||
val note = noteState?.note ?: return
|
||||
val note = remember(noteState) { noteState?.note } ?: return
|
||||
val channel = note.channel()
|
||||
|
||||
if (note.event is ChannelCreateEvent) {
|
||||
CreateClickableText(
|
||||
note.idDisplayNote(),
|
||||
nip19.additionalChars,
|
||||
"Channel/${nip19.hex}",
|
||||
navController
|
||||
clickablePart = "@${note.idDisplayNote()}",
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Channel/${nip19.hex}",
|
||||
navController = navController
|
||||
)
|
||||
} else if (note.event is PrivateDmEvent) {
|
||||
CreateClickableText(
|
||||
note.idDisplayNote(),
|
||||
nip19.additionalChars,
|
||||
"Room/${note.author?.pubkeyHex}",
|
||||
navController
|
||||
clickablePart = "@${note.idDisplayNote()}",
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Room/${note.author?.pubkeyHex}",
|
||||
navController = navController
|
||||
)
|
||||
} else if (channel != null) {
|
||||
CreateClickableText(
|
||||
channel.toBestDisplayName(),
|
||||
nip19.additionalChars,
|
||||
"Channel/${note.channel()?.idHex}",
|
||||
navController
|
||||
clickablePart = channel.toBestDisplayName(),
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Channel/${note.channel()?.idHex}",
|
||||
navController = navController
|
||||
)
|
||||
} else {
|
||||
CreateClickableText(
|
||||
note.idDisplayNote(),
|
||||
nip19.additionalChars,
|
||||
"Note/${nip19.hex}",
|
||||
navController
|
||||
clickablePart = "@${note.idDisplayNote()}",
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Note/${nip19.hex}",
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -173,10 +199,10 @@ private fun DisplayAddress(
|
||||
val note = noteState?.note ?: return
|
||||
|
||||
CreateClickableText(
|
||||
note.idDisplayNote(),
|
||||
nip19.additionalChars,
|
||||
"Note/${nip19.hex}",
|
||||
navController
|
||||
clickablePart = "@${note.idDisplayNote()}",
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Note/${nip19.hex}",
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
|
||||
@@ -202,14 +228,19 @@ private fun DisplayUser(
|
||||
|
||||
userBase?.let {
|
||||
val userState by it.live().metadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
val route = remember { "User/${it.pubkeyHex}" }
|
||||
val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() }
|
||||
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
|
||||
|
||||
CreateClickableText(
|
||||
user.toBestDisplayName(),
|
||||
nip19.additionalChars,
|
||||
"User/${nip19.hex}",
|
||||
navController
|
||||
)
|
||||
if (userDisplayName != null) {
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = userDisplayName,
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
tags = userTags,
|
||||
route = route,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (userBase == null) {
|
||||
@@ -220,20 +251,281 @@ private fun DisplayUser(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateClickableText(clickablePart: String, suffix: String, route: String, navController: NavController) {
|
||||
fun CreateClickableText(
|
||||
clickablePart: String,
|
||||
suffix: String,
|
||||
overrideColor: Color? = null,
|
||||
fontWeight: FontWeight = FontWeight.Normal,
|
||||
route: String,
|
||||
navController: NavController
|
||||
) {
|
||||
ClickableText(
|
||||
text = buildAnnotatedString {
|
||||
withStyle(
|
||||
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
|
||||
LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.primary, fontWeight = fontWeight).toSpanStyle()
|
||||
) {
|
||||
append("@$clickablePart")
|
||||
append(clickablePart)
|
||||
}
|
||||
withStyle(
|
||||
LocalTextStyle.current.copy(color = MaterialTheme.colors.onBackground).toSpanStyle()
|
||||
LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.onBackground, fontWeight = fontWeight).toSpanStyle()
|
||||
) {
|
||||
append("$suffix ")
|
||||
append(suffix)
|
||||
}
|
||||
},
|
||||
onClick = { navController.navigate(route) }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateTextWithEmoji(
|
||||
text: String,
|
||||
tags: List<List<String>>?,
|
||||
color: Color = Color.Unspecified,
|
||||
textAlign: TextAlign? = null,
|
||||
fontWeight: FontWeight? = null,
|
||||
fontSize: TextUnit = TextUnit.Unspecified,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val emojis = remember {
|
||||
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
|
||||
}
|
||||
|
||||
CreateTextWithEmoji(text, emojis, color, textAlign, fontWeight, fontSize, maxLines, overflow, modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateTextWithEmoji(
|
||||
text: String,
|
||||
emojis: Map<String, String>,
|
||||
color: Color = Color.Unspecified,
|
||||
textAlign: TextAlign? = null,
|
||||
fontWeight: FontWeight? = null,
|
||||
fontSize: TextUnit = TextUnit.Unspecified,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val textColor = color.takeOrElse {
|
||||
LocalTextStyle.current.color.takeOrElse {
|
||||
LocalContentColor.current.copy(alpha = LocalContentAlpha.current)
|
||||
}
|
||||
}
|
||||
|
||||
if (emojis.isEmpty()) {
|
||||
Text(
|
||||
text = text,
|
||||
color = textColor,
|
||||
textAlign = textAlign,
|
||||
fontWeight = fontWeight,
|
||||
fontSize = fontSize,
|
||||
maxLines = maxLines,
|
||||
overflow = overflow,
|
||||
modifier = modifier
|
||||
)
|
||||
} else {
|
||||
val myList = remember {
|
||||
assembleAnnotatedList(text, emojis)
|
||||
}
|
||||
|
||||
val style = LocalTextStyle.current.merge(
|
||||
TextStyle(
|
||||
color = textColor,
|
||||
textAlign = textAlign,
|
||||
fontWeight = fontWeight,
|
||||
fontSize = fontSize
|
||||
)
|
||||
).toSpanStyle()
|
||||
|
||||
InLineIconRenderer(myList, style, maxLines, overflow, modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateClickableTextWithEmoji(
|
||||
clickablePart: String,
|
||||
tags: List<List<String>>?,
|
||||
style: TextStyle,
|
||||
onClick: (Int) -> Unit
|
||||
) {
|
||||
val emojis = remember(tags) {
|
||||
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
|
||||
}
|
||||
|
||||
if (emojis.isEmpty()) {
|
||||
ClickableText(
|
||||
AnnotatedString(clickablePart),
|
||||
style = style,
|
||||
onClick = onClick
|
||||
)
|
||||
} else {
|
||||
val myList = remember {
|
||||
assembleAnnotatedList(clickablePart, emojis)
|
||||
}
|
||||
|
||||
ClickableInLineIconRenderer(myList, style.toSpanStyle()) {
|
||||
onClick(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateClickableTextWithEmoji(
|
||||
clickablePart: String,
|
||||
suffix: String,
|
||||
tags: List<List<String>>?,
|
||||
overrideColor: Color? = null,
|
||||
fontWeight: FontWeight = FontWeight.Normal,
|
||||
route: String,
|
||||
navController: NavController
|
||||
) {
|
||||
val emojis = remember(tags) {
|
||||
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
|
||||
}
|
||||
|
||||
if (emojis.isEmpty()) {
|
||||
CreateClickableText(clickablePart, suffix, overrideColor, fontWeight, route, navController)
|
||||
} else {
|
||||
val myList = remember {
|
||||
assembleAnnotatedList(clickablePart, emojis)
|
||||
}
|
||||
|
||||
ClickableInLineIconRenderer(myList, LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.primary, fontWeight = fontWeight).toSpanStyle()) {
|
||||
navController.navigate(route)
|
||||
}
|
||||
|
||||
val myList2 = remember {
|
||||
assembleAnnotatedList(suffix, emojis)
|
||||
}
|
||||
|
||||
InLineIconRenderer(myList2, LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.onBackground, fontWeight = fontWeight).toSpanStyle())
|
||||
}
|
||||
}
|
||||
|
||||
fun assembleAnnotatedList(text: String, emojis: Map<String, String>): List<Renderable> {
|
||||
return NIP30Parser().buildArray(text).map {
|
||||
val url = emojis[it]
|
||||
if (url != null) {
|
||||
ImageUrlType(url)
|
||||
} else {
|
||||
TextType(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open class Renderable()
|
||||
class TextType(val text: String) : Renderable()
|
||||
class ImageUrlType(val url: String) : Renderable()
|
||||
|
||||
@Composable
|
||||
fun ClickableInLineIconRenderer(wordsInOrder: List<Renderable>, style: SpanStyle, onClick: (Int) -> Unit) {
|
||||
val inlineContent = wordsInOrder.mapIndexedNotNull { idx, value ->
|
||||
if (value is ImageUrlType) {
|
||||
Pair(
|
||||
"inlineContent$idx",
|
||||
InlineTextContent(
|
||||
Placeholder(
|
||||
width = 17.sp,
|
||||
height = 17.sp,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
|
||||
)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = value.url,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize().padding(1.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.associate { it.first to it.second }
|
||||
|
||||
val annotatedText = buildAnnotatedString {
|
||||
wordsInOrder.forEachIndexed { idx, value ->
|
||||
withStyle(
|
||||
style
|
||||
) {
|
||||
if (value is TextType) {
|
||||
append(value.text)
|
||||
} else if (value is ImageUrlType) {
|
||||
appendInlineContent("inlineContent$idx", "[icon]")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
val pressIndicator = Modifier.pointerInput(onClick) {
|
||||
detectTapGestures { pos ->
|
||||
layoutResult.value?.let { layoutResult ->
|
||||
onClick(layoutResult.getOffsetForPosition(pos))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BasicText(
|
||||
text = annotatedText,
|
||||
modifier = pressIndicator,
|
||||
inlineContent = inlineContent,
|
||||
onTextLayout = {
|
||||
layoutResult.value = it
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InLineIconRenderer(
|
||||
wordsInOrder: List<Renderable>,
|
||||
style: SpanStyle,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val inlineContent = wordsInOrder.mapIndexedNotNull { idx, value ->
|
||||
if (value is ImageUrlType) {
|
||||
Pair(
|
||||
"inlineContent$idx",
|
||||
InlineTextContent(
|
||||
Placeholder(
|
||||
width = 17.sp,
|
||||
height = 17.sp,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
|
||||
)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = value.url,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize().padding(1.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.associate { it.first to it.second }
|
||||
|
||||
val annotatedText = buildAnnotatedString {
|
||||
wordsInOrder.forEachIndexed { idx, value ->
|
||||
withStyle(
|
||||
style
|
||||
) {
|
||||
if (value is TextType) {
|
||||
append(value.text)
|
||||
} else if (value is ImageUrlType) {
|
||||
appendInlineContent("inlineContent$idx", "[icon]")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = annotatedText,
|
||||
inlineContent = inlineContent,
|
||||
maxLines = maxLines,
|
||||
overflow = overflow,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,7 +81,8 @@ fun isMarkdown(content: String): Boolean {
|
||||
content.startsWith("# ") ||
|
||||
content.contains("##") ||
|
||||
content.contains("__") ||
|
||||
content.contains("```")
|
||||
content.contains("```") ||
|
||||
content.contains("](")
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -109,7 +110,8 @@ class RichTextViewerState(
|
||||
val content: String,
|
||||
val urlSet: Set<String>,
|
||||
val imagesForPager: Map<String, ZoomableUrlContent>,
|
||||
val imageList: List<ZoomableUrlContent>
|
||||
val imageList: List<ZoomableUrlContent>,
|
||||
val customEmoji: Map<String, String>
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -122,7 +124,7 @@ private fun RenderRegular(
|
||||
navController: NavController
|
||||
) {
|
||||
var processedState by remember {
|
||||
mutableStateOf<RichTextViewerState?>(RichTextViewerState(content, emptySet(), emptyMap(), emptyList()))
|
||||
mutableStateOf<RichTextViewerState?>(RichTextViewerState(content, emptySet(), emptyMap(), emptyList(), emptyMap()))
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -143,8 +145,10 @@ private fun RenderRegular(
|
||||
}.associateBy { it.url }
|
||||
val imageList = imagesForPager.values.toList()
|
||||
|
||||
if (urlSet.isNotEmpty()) {
|
||||
processedState = RichTextViewerState(content, urlSet, imagesForPager, imageList)
|
||||
val emojiMap = tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
|
||||
|
||||
if (urlSet.isNotEmpty() || emojiMap.isNotEmpty()) {
|
||||
processedState = RichTextViewerState(content, urlSet, imagesForPager, imageList, emojiMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,6 +171,8 @@ private fun RenderRegular(
|
||||
ZoomableContentView(img, state.imageList)
|
||||
} else if (state.urlSet.contains(word)) {
|
||||
UrlPreview(word, "$word ")
|
||||
} else if (state.customEmoji.any { word.contains(it.key) }) {
|
||||
RenderCustomEmoji(word, state.customEmoji)
|
||||
} else if (word.startsWith("lnbc", true)) {
|
||||
MayBeInvoicePreview(word)
|
||||
} else if (word.startsWith("lnurl", true)) {
|
||||
@@ -235,6 +241,8 @@ private fun RenderRegular(
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
} else if (state.customEmoji.any { word.contains(it.key) }) {
|
||||
RenderCustomEmoji(word, state.customEmoji)
|
||||
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
|
||||
ClickableEmail(word)
|
||||
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
|
||||
@@ -293,6 +301,14 @@ private fun RenderRegular(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderCustomEmoji(word: String, customEmoji: Map<String, String>) {
|
||||
CreateTextWithEmoji(
|
||||
text = "$word ",
|
||||
emojis = customEmoji
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderContentAsMarkdown(content: String, backgroundColor: Color) {
|
||||
val myMarkDownStyle = richTextDefaults.copy(
|
||||
@@ -398,7 +414,7 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color) {
|
||||
|
||||
private fun getDisplayNameFromNip19(nip19: Nip19.Return): String? {
|
||||
if (nip19.type == Nip19.Type.USER) {
|
||||
return LocalCache.users[nip19.hex]?.bestDisplayName()
|
||||
return LocalCache.users[nip19.hex]?.toBestDisplayName()
|
||||
} else if (nip19.type == Nip19.Type.NOTE) {
|
||||
return LocalCache.notes[nip19.hex]?.idDisplayNote()
|
||||
} else if (nip19.type == Nip19.Type.ADDRESS) {
|
||||
@@ -600,8 +616,10 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
|
||||
var baseUserPair by remember { mutableStateOf<Pair<User, String?>?>(null) }
|
||||
var baseNotePair by remember { mutableStateOf<Pair<Note, String?>?>(null) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(key1 = word) {
|
||||
withContext(Dispatchers.IO) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val matcher = tagIndex.matcher(word)
|
||||
val (index, suffix) = try {
|
||||
matcher.find()
|
||||
@@ -630,8 +648,24 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
|
||||
}
|
||||
|
||||
baseUserPair?.let {
|
||||
ClickableUserTag(it.first, navController)
|
||||
Text(text = "${it.second} ")
|
||||
val innerUserState by it.first.live().metadata.observeAsState()
|
||||
val displayName = remember(innerUserState) {
|
||||
innerUserState?.user?.toBestDisplayName() ?: ""
|
||||
}
|
||||
val route = remember(innerUserState) {
|
||||
"User/${it.first.pubkeyHex}"
|
||||
}
|
||||
val userTags = remember(innerUserState) {
|
||||
innerUserState?.user?.info?.latestMetadata?.tags
|
||||
}
|
||||
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = displayName,
|
||||
suffix = "${it.second} ",
|
||||
tags = userTags,
|
||||
route = route,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
|
||||
baseNotePair?.let {
|
||||
|
||||
@@ -16,6 +16,7 @@ import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.size.Size
|
||||
import java.util.Date
|
||||
|
||||
@Composable
|
||||
fun RobohashAsyncImage(
|
||||
@@ -58,7 +59,7 @@ fun RobohashAsyncImage(
|
||||
)
|
||||
}
|
||||
|
||||
var imageErrors = setOf<String>()
|
||||
var imageErrors = mapOf<String, Long>()
|
||||
|
||||
@Composable
|
||||
fun RobohashFallbackAsyncImage(
|
||||
@@ -73,7 +74,9 @@ fun RobohashFallbackAsyncImage(
|
||||
colorFilter: ColorFilter? = null,
|
||||
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
|
||||
) {
|
||||
if (imageErrors.contains(model)) {
|
||||
val errorCache = remember(imageErrors) { imageErrors[model] }
|
||||
|
||||
if (errorCache != null && (Date().time / 1000) - errorCache < (60 * 5)) {
|
||||
RobohashAsyncImage(
|
||||
robot = robot,
|
||||
robotSize = robotSize,
|
||||
@@ -110,7 +113,7 @@ fun RobohashFallbackAsyncImage(
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality,
|
||||
onError = {
|
||||
imageErrors = imageErrors + model
|
||||
imageErrors = imageErrors + Pair(model, Date().time / 1000)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
@@ -28,6 +30,7 @@ import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -46,6 +49,8 @@ import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import coil.imageLoader
|
||||
import coil.request.ImageRequest
|
||||
import com.google.android.exoplayer2.C
|
||||
import com.google.android.exoplayer2.ExoPlayer
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
@@ -55,6 +60,8 @@ import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
|
||||
import com.google.android.exoplayer2.ui.StyledPlayerView
|
||||
import com.vitorpamplona.amethyst.VideoCache
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
public var muted = mutableStateOf(true)
|
||||
@@ -62,20 +69,55 @@ public var muted = mutableStateOf(true)
|
||||
@Composable
|
||||
fun VideoView(localFile: File?, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
if (localFile != null) {
|
||||
VideoView(localFile.toUri(), description, onDialog)
|
||||
VideoView(localFile.toUri(), description, null, onDialog)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideoView(videoUri: String, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
VideoView(Uri.parse(videoUri), description, onDialog)
|
||||
VideoView(Uri.parse(videoUri), description, null, onDialog)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
fun VideoView(videoUri: String, description: String? = null, thumbUri: String, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
var loadingFinished by remember { mutableStateOf<Pair<Boolean, Drawable?>>(Pair(false, null)) }
|
||||
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val request = ImageRequest.Builder(context).data(thumbUri).build()
|
||||
val myCover = context.imageLoader.execute(request).drawable
|
||||
if (myCover != null) {
|
||||
loadingFinished = Pair(true, myCover)
|
||||
} else {
|
||||
loadingFinished = Pair(true, null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("VideoView", "Fail to load cover $thumbUri", e)
|
||||
loadingFinished = Pair(true, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (loadingFinished.first) {
|
||||
if (loadingFinished.second != null) {
|
||||
VideoView(Uri.parse(videoUri), description, loadingFinished.second, onDialog)
|
||||
} else {
|
||||
VideoView(videoUri, description, onDialog)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideoView(videoUri: Uri, description: String? = null, thumb: Drawable? = null, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
|
||||
|
||||
println("loading audio with artwork $thumb")
|
||||
|
||||
val exoPlayer = remember(videoUri) {
|
||||
val mediaBuilder = MediaItem.Builder().setUri(videoUri)
|
||||
|
||||
@@ -130,6 +172,7 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
controllerAutoShow = false
|
||||
thumb?.let { defaultArtwork = thumb }
|
||||
hideController()
|
||||
resizeMode = if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
onDialog?.let { innerOnDialog ->
|
||||
@@ -146,7 +189,6 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
|
||||
muted.value = !muted.value
|
||||
}
|
||||
}
|
||||
|
||||
) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
|
||||
@@ -27,7 +27,7 @@ object GlobalFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
return collection
|
||||
.asSequence()
|
||||
.filter {
|
||||
it.event is BaseTextNoteEvent && it.replyTo.isNullOrEmpty()
|
||||
(it.event is BaseTextNoteEvent || it.event is AudioTrackEvent) && it.replyTo.isNullOrEmpty()
|
||||
}
|
||||
.filter {
|
||||
val channel = it.channelHex()
|
||||
|
||||
@@ -3,6 +3,7 @@ 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.AudioTrackEvent
|
||||
import com.vitorpamplona.amethyst.service.model.HighlightEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
@@ -30,7 +31,7 @@ object HomeNewThreadFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
return collection
|
||||
.asSequence()
|
||||
.filter { it ->
|
||||
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent || it.event is HighlightEvent) &&
|
||||
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent || it.event is HighlightEvent || it.event is AudioTrackEvent) &&
|
||||
(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.pubkeyHex) } ?: true &&
|
||||
|
||||
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.decodePublicKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.ResizeImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
@@ -149,7 +150,10 @@ fun AccountSwitchBottomSheet(
|
||||
val npubShortHex = acc.npub.toShortenHex()
|
||||
|
||||
user.bestDisplayName()?.let {
|
||||
Text(it)
|
||||
CreateTextWithEmoji(
|
||||
text = it,
|
||||
tags = user.info?.latestMetadata?.tags
|
||||
)
|
||||
}
|
||||
|
||||
Text(npubShortHex)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -56,6 +58,7 @@ import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.ResizeImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog
|
||||
@@ -166,8 +169,9 @@ fun ProfileContent(
|
||||
})
|
||||
)
|
||||
if (accountUser.bestDisplayName() != null) {
|
||||
Text(
|
||||
accountUser.bestDisplayName() ?: "",
|
||||
CreateTextWithEmoji(
|
||||
text = accountUser.bestDisplayName() ?: "",
|
||||
tags = accountUser.info?.latestMetadata?.tags,
|
||||
modifier = Modifier
|
||||
.padding(top = 7.dp)
|
||||
.clickable(onClick = {
|
||||
@@ -183,19 +187,22 @@ fun ProfileContent(
|
||||
)
|
||||
}
|
||||
if (accountUser.bestUsername() != null) {
|
||||
Text(
|
||||
" @${accountUser.bestUsername()}",
|
||||
CreateTextWithEmoji(
|
||||
text = " @${accountUser.bestUsername()}",
|
||||
tags = accountUser.info?.latestMetadata?.tags,
|
||||
color = Color.LightGray,
|
||||
modifier = Modifier
|
||||
.padding(top = 15.dp)
|
||||
.clickable(onClick = {
|
||||
accountUser.let {
|
||||
navController.navigate("User/${it.pubkeyHex}")
|
||||
.clickable(
|
||||
onClick = {
|
||||
accountUser.let {
|
||||
navController.navigate("User/${it.pubkeyHex}")
|
||||
}
|
||||
coroutineScope.launch {
|
||||
scaffoldState.drawerState.close()
|
||||
}
|
||||
}
|
||||
coroutineScope.launch {
|
||||
scaffoldState.drawerState.close()
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
Row(
|
||||
@@ -294,6 +301,12 @@ fun ListContent(
|
||||
title = textTorProxy,
|
||||
icon = R.drawable.ic_tor,
|
||||
tint = MaterialTheme.colors.onBackground,
|
||||
onLongClick = {
|
||||
coroutineScope.launch {
|
||||
scaffoldState.drawerState.close()
|
||||
}
|
||||
conectOrbotDialogOpen = true
|
||||
},
|
||||
onClick = {
|
||||
if (checked) {
|
||||
disconnectTorDialog = true
|
||||
@@ -401,12 +414,16 @@ fun NavigationRow(
|
||||
})
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun IconRow(title: String, icon: Int, tint: Color, onClick: () -> Unit) {
|
||||
fun IconRow(title: String, icon: Int, tint: Color, onClick: () -> Unit, onLongClick: (() -> Unit)? = null) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -58,6 +58,8 @@ 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.ui.components.CreateClickableTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.ResizeImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
@@ -218,14 +220,14 @@ fun ChatroomMessageCompose(
|
||||
})
|
||||
)
|
||||
|
||||
Text(
|
||||
" ${author.toBestDisplayName()}",
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = " ${author.toBestDisplayName()}",
|
||||
suffix = "",
|
||||
tags = author.info?.latestMetadata?.tags,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.clickable(onClick = {
|
||||
author.let {
|
||||
navController.navigate("User/${it.pubkeyHex}")
|
||||
}
|
||||
})
|
||||
overrideColor = MaterialTheme.colors.onBackground,
|
||||
route = "User/${author.pubkeyHex}",
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -251,33 +253,39 @@ fun ChatroomMessageCompose(
|
||||
val event = note.event
|
||||
if (event is ChannelCreateEvent) {
|
||||
val channelInfo = event.channelInfo()
|
||||
Text(
|
||||
text = note.author?.toBestDisplayName()
|
||||
.toString() + " ${stringResource(R.string.created)} " + (
|
||||
channelInfo.name
|
||||
?: ""
|
||||
) + " ${stringResource(R.string.with_description_of)} '" + (
|
||||
channelInfo.about
|
||||
?: ""
|
||||
) + "', ${stringResource(R.string.and_picture)} '" + (
|
||||
channelInfo.picture
|
||||
?: ""
|
||||
) + "'"
|
||||
val text = note.author?.toBestDisplayName()
|
||||
.toString() + " ${stringResource(R.string.created)} " + (
|
||||
channelInfo.name
|
||||
?: ""
|
||||
) + " ${stringResource(R.string.with_description_of)} '" + (
|
||||
channelInfo.about
|
||||
?: ""
|
||||
) + "', ${stringResource(R.string.and_picture)} '" + (
|
||||
channelInfo.picture
|
||||
?: ""
|
||||
) + "'"
|
||||
|
||||
CreateTextWithEmoji(
|
||||
text = text,
|
||||
tags = note.author?.info?.latestMetadata?.tags
|
||||
)
|
||||
} else if (event is ChannelMetadataEvent) {
|
||||
val channelInfo = event.channelInfo()
|
||||
Text(
|
||||
text = note.author?.toBestDisplayName()
|
||||
.toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + (
|
||||
channelInfo.name
|
||||
?: ""
|
||||
) + "$', {stringResource(R.string.description_to)} '" + (
|
||||
channelInfo.about
|
||||
?: ""
|
||||
) + "', ${stringResource(R.string.and_picture_to)} '" + (
|
||||
channelInfo.picture
|
||||
?: ""
|
||||
) + "'"
|
||||
val text = note.author?.toBestDisplayName()
|
||||
.toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + (
|
||||
channelInfo.name
|
||||
?: ""
|
||||
) + "', ${stringResource(R.string.description_to)} '" + (
|
||||
channelInfo.about
|
||||
?: ""
|
||||
) + "', ${stringResource(R.string.and_picture_to)} '" + (
|
||||
channelInfo.picture
|
||||
?: ""
|
||||
) + "'"
|
||||
|
||||
CreateTextWithEmoji(
|
||||
text = text,
|
||||
tags = note.author?.info?.latestMetadata?.tags
|
||||
)
|
||||
} else {
|
||||
val eventContent = accountViewModel.decrypt(note)
|
||||
|
||||
@@ -40,6 +40,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.PushPin
|
||||
import androidx.compose.material.lightColors
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -51,6 +52,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
@@ -87,6 +89,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
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.ChannelCreateEvent
|
||||
@@ -97,7 +100,9 @@ import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
|
||||
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.amethyst.service.model.HighlightEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.Participant
|
||||
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PinListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||
@@ -106,13 +111,14 @@ import com.vitorpamplona.amethyst.service.model.ReportedKey
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateClickableText
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
|
||||
import com.vitorpamplona.amethyst.ui.components.ResizeImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.components.VideoView
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableContent
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableLocalImage
|
||||
@@ -132,6 +138,7 @@ import nostr.postr.toNpub
|
||||
import java.io.File
|
||||
import java.math.BigDecimal
|
||||
import java.net.URL
|
||||
import java.util.Locale
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
@@ -376,6 +383,14 @@ fun NoteComposeInner(
|
||||
RenderPeopleList(noteState, backgroundColor, accountViewModel, navController)
|
||||
}
|
||||
|
||||
is AudioTrackEvent -> {
|
||||
RenderAudioTrack(note, loggedIn, accountViewModel, navController)
|
||||
}
|
||||
|
||||
is PinListEvent -> {
|
||||
RenderPinListEvent(noteState, backgroundColor, accountViewModel, navController)
|
||||
}
|
||||
|
||||
is PrivateDmEvent -> {
|
||||
RenderPrivateMessage(note, makeItShort, isAcceptableAndCanPreview.second, backgroundColor, accountViewModel, navController)
|
||||
}
|
||||
@@ -868,6 +883,135 @@ private fun RenderRepost(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderPinListEvent(
|
||||
noteState: NoteState?,
|
||||
backgroundColor: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
val noteEvent = noteState?.note?.event as? PinListEvent ?: return
|
||||
|
||||
PinListHeader(noteState, backgroundColor, accountViewModel, navController)
|
||||
|
||||
ReactionsRow(noteState?.note, accountViewModel, navController)
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PinListHeader(
|
||||
noteState: NoteState?,
|
||||
backgroundColor: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
val note = remember(noteState) { noteState?.note } ?: return
|
||||
val noteEvent = note.event as? PinListEvent ?: return
|
||||
|
||||
var pins by remember { mutableStateOf<List<String>>(noteEvent.pins()) }
|
||||
|
||||
var expanded by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
val pinsToShow = if (expanded) {
|
||||
pins
|
||||
} else {
|
||||
pins.take(3)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "#${noteEvent.dTag()}",
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(5.dp),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Box {
|
||||
FlowRow(modifier = Modifier.padding(top = 5.dp)) {
|
||||
pinsToShow.forEach { pin ->
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PushPin,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colors.onBackground.copy(0.32f),
|
||||
modifier = Modifier.size(15.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
|
||||
TranslatableRichTextViewer(
|
||||
content = pin,
|
||||
canPreview = true,
|
||||
tags = emptyList(),
|
||||
backgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pins.size > 3 && !expanded) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
backgroundColor.copy(alpha = 0f),
|
||||
backgroundColor
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Button(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
onClick = { expanded = !expanded },
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
|
||||
.compositeOver(MaterialTheme.colors.background)
|
||||
),
|
||||
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.show_more), color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderAudioTrack(
|
||||
note: Note,
|
||||
loggedIn: User,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
val noteEvent = note.event as? AudioTrackEvent ?: return
|
||||
|
||||
AudioTrackHeader(noteEvent, note, loggedIn, navController)
|
||||
|
||||
ReactionsRow(note, accountViewModel, navController)
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderLongFormContent(
|
||||
note: Note,
|
||||
@@ -1217,13 +1361,15 @@ fun DisplayHighlight(
|
||||
val userState by userBase.live().metadata.observeAsState()
|
||||
val route = remember { "User/${userBase.pubkeyHex}" }
|
||||
val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() }
|
||||
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
|
||||
|
||||
if (userDisplayName != null) {
|
||||
CreateClickableText(
|
||||
userDisplayName,
|
||||
"",
|
||||
route,
|
||||
navController
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = userDisplayName,
|
||||
suffix = "",
|
||||
tags = userTags,
|
||||
route = route,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1511,45 +1657,124 @@ fun FileStorageHeaderDisplay(baseNote: Note) {
|
||||
}
|
||||
}
|
||||
|
||||
val noteState = fileNote?.live()?.metadata?.observeAsState()
|
||||
val note = noteState?.value?.note
|
||||
fileNote?.let { fileNote2 ->
|
||||
val noteState by fileNote2.live().metadata.observeAsState()
|
||||
val note = remember(noteState) { noteState?.note }
|
||||
|
||||
var content by remember { mutableStateOf<ZoomableContent?>(null) }
|
||||
var content by remember { mutableStateOf<ZoomableContent?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = eventHeader.id, key2 = noteState, key3 = note?.event) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val uri = "nostr:" + baseNote.toNEvent()
|
||||
val localDir = note?.idHex?.let { File(File(appContext.externalCacheDir, "NIP95"), it) }
|
||||
val blurHash = eventHeader.blurhash()
|
||||
val dimensions = eventHeader.dimensions()
|
||||
val description = eventHeader.content
|
||||
val mimeType = eventHeader.mimeType()
|
||||
LaunchedEffect(key1 = eventHeader.id, key2 = noteState, key3 = note?.event) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val uri = "nostr:" + baseNote.toNEvent()
|
||||
val localDir = note?.idHex?.let { File(File(appContext.externalCacheDir, "NIP95"), it) }
|
||||
val blurHash = eventHeader.blurhash()
|
||||
val dimensions = eventHeader.dimensions()
|
||||
val description = eventHeader.content
|
||||
val mimeType = eventHeader.mimeType()
|
||||
|
||||
content = if (mimeType?.startsWith("image") == true) {
|
||||
ZoomableLocalImage(
|
||||
localFile = localDir,
|
||||
mimeType = mimeType,
|
||||
description = description,
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
isVerified = true,
|
||||
uri = uri
|
||||
)
|
||||
} else {
|
||||
ZoomableLocalVideo(
|
||||
localFile = localDir,
|
||||
mimeType = mimeType,
|
||||
description = description,
|
||||
dim = dimensions,
|
||||
isVerified = true,
|
||||
uri = uri
|
||||
)
|
||||
content = if (mimeType?.startsWith("image") == true) {
|
||||
ZoomableLocalImage(
|
||||
localFile = localDir,
|
||||
mimeType = mimeType,
|
||||
description = description,
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
isVerified = true,
|
||||
uri = uri
|
||||
)
|
||||
} else {
|
||||
ZoomableLocalVideo(
|
||||
localFile = localDir,
|
||||
mimeType = mimeType,
|
||||
description = description,
|
||||
dim = dimensions,
|
||||
isVerified = true,
|
||||
uri = uri
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
content?.let {
|
||||
ZoomableContentView(content = it, listOf(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AudioTrackHeader(noteEvent: AudioTrackEvent, note: Note, loggedIn: User, navController: NavController) {
|
||||
val media = remember { noteEvent.media() }
|
||||
val cover = remember { noteEvent.cover() }
|
||||
val subject = remember { noteEvent.subject() }
|
||||
val content = remember { noteEvent.content() }
|
||||
val participants = remember { noteEvent.participants() }
|
||||
|
||||
var participantUsers by remember { mutableStateOf<List<Pair<Participant, User>>>(emptyList()) }
|
||||
|
||||
LaunchedEffect(key1 = participants) {
|
||||
withContext(Dispatchers.IO) {
|
||||
participantUsers = participants.mapNotNull { part -> LocalCache.checkGetOrCreateUser(part.key)?.let { Pair(part, it) } }
|
||||
}
|
||||
}
|
||||
|
||||
content?.let {
|
||||
ZoomableContentView(content = it, listOf(it))
|
||||
Row(modifier = Modifier.padding(top = 5.dp)) {
|
||||
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Row() {
|
||||
subject?.let {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)) {
|
||||
Text(
|
||||
text = it,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
participantUsers.forEach {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(top = 5.dp, start = 10.dp, end = 10.dp)
|
||||
.clickable {
|
||||
navController.navigate("User/${it.second.pubkeyHex}")
|
||||
}
|
||||
) {
|
||||
UserPicture(it.second, loggedIn, 25.dp)
|
||||
Spacer(Modifier.width(5.dp))
|
||||
UsernameDisplay(it.second, Modifier.weight(1f))
|
||||
Spacer(Modifier.width(5.dp))
|
||||
it.first.role?.let {
|
||||
Text(
|
||||
text = it.capitalize(Locale.ROOT),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
media?.let { media ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
) {
|
||||
cover?.let { cover ->
|
||||
VideoView(
|
||||
videoUri = media,
|
||||
description = noteEvent.subject(),
|
||||
thumbUri = cover
|
||||
)
|
||||
}
|
||||
?: VideoView(
|
||||
videoUri = media,
|
||||
noteEvent.subject()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,9 @@ import androidx.core.graphics.ColorUtils
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
|
||||
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.SelectTextDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
|
||||
@@ -81,11 +84,19 @@ val externalLinkForNote = { note: Note ->
|
||||
if (note is AddressableNote) {
|
||||
if (note.event?.getReward() != null) {
|
||||
"https://nostrbounties.com/b/${note.address().toNAddr()}"
|
||||
} else if (note.event is PeopleListEvent) {
|
||||
"https://listr.lol/a/${note.address()?.toNAddr()}"
|
||||
} else if (note.event is AudioTrackEvent) {
|
||||
"https://zapstr.live/?track=${note.address()?.toNAddr()}"
|
||||
} else {
|
||||
"https://habla.news/a/${note.address().toNAddr()}"
|
||||
"https://habla.news/a/${note.address()?.toNAddr()}"
|
||||
}
|
||||
} else {
|
||||
"https://snort.social/e/${note.toNEvent()}"
|
||||
if (note.event is FileHeaderEvent) {
|
||||
"https://filestr.vercel.app/e/${note.toNEvent()}"
|
||||
} else {
|
||||
"https://snort.social/e/${note.toNEvent()}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +139,7 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
|
||||
|
||||
if (popupExpanded) {
|
||||
val isOwnNote = accountViewModel.isLoggedUser(note.author)
|
||||
val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author!!)
|
||||
val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author)
|
||||
|
||||
Popup(onDismissRequest = onDismiss) {
|
||||
Card(
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.navigation.NavController
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.*
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@@ -59,8 +60,9 @@ fun ReplyInformation(replyTo: List<Note>?, dupMentions: List<User>?, account: Ac
|
||||
val innerUser = innerUserState?.user
|
||||
|
||||
innerUser?.let { myUser ->
|
||||
ClickableText(
|
||||
AnnotatedString("$prefix@${myUser.toBestDisplayName()}"),
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = "$prefix@${myUser.toBestDisplayName()}",
|
||||
tags = myUser.info?.latestMetadata?.tags,
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp),
|
||||
onClick = { onUserTagClick(myUser) }
|
||||
)
|
||||
@@ -196,8 +198,9 @@ fun ReplyInformationChannel(
|
||||
val innerUser = innerUserState?.user
|
||||
|
||||
innerUser?.let { myUser ->
|
||||
ClickableText(
|
||||
AnnotatedString("$prefix@${myUser.toBestDisplayName()}"),
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = "$prefix@${myUser.toBestDisplayName()}",
|
||||
tags = myUser.info?.latestMetadata?.tags,
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp),
|
||||
onClick = { onUserTagClick(myUser) }
|
||||
)
|
||||
|
||||
@@ -5,34 +5,31 @@ import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
|
||||
@Composable
|
||||
fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier) {
|
||||
val noteState by baseNote.live().metadata.observeAsState()
|
||||
val note = noteState?.note ?: return
|
||||
val author = remember(noteState) { noteState?.note?.author } ?: return
|
||||
|
||||
val author = note.author
|
||||
|
||||
if (author != null) {
|
||||
UsernameDisplay(author, weight)
|
||||
}
|
||||
UsernameDisplay(author, weight)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) {
|
||||
val userState by baseUser.live().metadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
val bestUserName = remember(userState) { userState?.user?.bestUsername() }
|
||||
val bestDisplayName = remember(userState) { userState?.user?.bestDisplayName() }
|
||||
val npubDisplay = remember { baseUser.pubkeyDisplayHex() }
|
||||
val tags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
|
||||
|
||||
val bestUserName = user.bestUsername()
|
||||
val bestDisplayName = user.bestDisplayName()
|
||||
val npubDisplay = user.pubkeyDisplayHex()
|
||||
|
||||
UserNameDisplay(bestUserName, bestDisplayName, npubDisplay, weight)
|
||||
UserNameDisplay(bestUserName, bestDisplayName, npubDisplay, tags, weight)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -40,31 +37,36 @@ private fun UserNameDisplay(
|
||||
bestUserName: String?,
|
||||
bestDisplayName: String?,
|
||||
npubDisplay: String,
|
||||
tags: List<List<String>>?,
|
||||
modifier: Modifier
|
||||
) {
|
||||
if (bestUserName != null && bestDisplayName != null) {
|
||||
Text(
|
||||
bestDisplayName,
|
||||
CreateTextWithEmoji(
|
||||
text = bestDisplayName,
|
||||
tags = tags,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
"@$bestUserName",
|
||||
CreateTextWithEmoji(
|
||||
text = "@$bestUserName",
|
||||
tags = tags,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier
|
||||
)
|
||||
} else if (bestDisplayName != null) {
|
||||
Text(
|
||||
bestDisplayName,
|
||||
CreateTextWithEmoji(
|
||||
text = bestDisplayName,
|
||||
tags = tags,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier
|
||||
)
|
||||
} else if (bestUserName != null) {
|
||||
Text(
|
||||
"@$bestUserName",
|
||||
CreateTextWithEmoji(
|
||||
text = "@$bestUserName",
|
||||
tags = tags,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
|
||||
@@ -34,6 +34,7 @@ import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.ResizeImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.qrcode.NIP19QrCodeScanner
|
||||
@@ -83,10 +84,10 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
|
||||
.background(MaterialTheme.colors.background)
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
user.bestDisplayName() ?: "",
|
||||
modifier = Modifier.padding(top = 5.dp),
|
||||
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth().padding(top = 5.dp)) {
|
||||
CreateTextWithEmoji(
|
||||
text = user.bestDisplayName() ?: user.bestUsername() ?: "",
|
||||
tags = user.info?.latestMetadata?.tags,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
|
||||
@@ -53,10 +53,12 @@ import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.HighlightEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PinListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
@@ -352,6 +354,10 @@ fun NoteMaster(
|
||||
Column() {
|
||||
if (noteEvent is PeopleListEvent) {
|
||||
DisplayPeopleList(noteState, MaterialTheme.colors.background, accountViewModel, navController)
|
||||
} else if (noteEvent is AudioTrackEvent) {
|
||||
AudioTrackHeader(noteEvent, note, account.userProfile(), navController)
|
||||
} else if (noteEvent is PinListEvent) {
|
||||
PinListHeader(noteState, MaterialTheme.colors.background, accountViewModel, navController)
|
||||
} else if (noteEvent is HighlightEvent) {
|
||||
DisplayHighlight(
|
||||
noteEvent.quote(),
|
||||
|
||||
@@ -64,6 +64,7 @@ import com.vitorpamplona.amethyst.service.model.PayInvoiceErrorResponse
|
||||
import com.vitorpamplona.amethyst.service.model.PayInvoiceSuccessResponse
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.DisplayNip05ProfileStatus
|
||||
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
|
||||
import com.vitorpamplona.amethyst.ui.components.ResizeImage
|
||||
@@ -470,29 +471,36 @@ private fun ProfileActions(
|
||||
private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val userState by baseUser.live().metadata.observeAsState()
|
||||
val user = remember(userState) { userState?.user } ?: return
|
||||
val tags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
|
||||
|
||||
val uri = LocalUriHandler.current
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
user.bestDisplayName()?.let {
|
||||
Text(
|
||||
it,
|
||||
modifier = Modifier.padding(top = 7.dp),
|
||||
(user.bestDisplayName() ?: user.bestUsername())?.let {
|
||||
Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(top = 7.dp)) {
|
||||
CreateTextWithEmoji(
|
||||
text = it,
|
||||
tags = tags,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 25.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
|
||||
if (user.bestDisplayName() != null) {
|
||||
user.bestUsername()?.let {
|
||||
Text(
|
||||
"@$it",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp)
|
||||
)
|
||||
) {
|
||||
CreateTextWithEmoji(
|
||||
text = "@$it",
|
||||
tags = tags,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
408
app/src/main/res/values-sv
Normal file
408
app/src/main/res/values-sv
Normal file
@@ -0,0 +1,408 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="app_name_release" translatable="false">Amethyst</string>
|
||||
<string name="app_name_debug" translatable="false">Amethyst Debug</string>
|
||||
<string name="point_to_the_qr_code">Point to the QR Code</string>
|
||||
<string name="show_qr">Visa QR</string>
|
||||
<string name="profile_image">Profilbild</string>
|
||||
<string name="scan_qr">Scanna QR</string>
|
||||
<string name=”show_anyway">Visa ändå</string>
|
||||
<string name="post_was_flagged_as_inappropriate_by">Inlägget flaggades som olämpligt av</string>
|
||||
<string name=”post_not_found">Inlägg hittades inte</string>
|
||||
<string name=”channel_image">Kanal bild</string>
|
||||
<string name="referenced_event_not_found">Refererad händelse hittades inte</string>
|
||||
<string name="could_not_decrypt_the_message">Kunde inte dekryptera meddelandet</string>
|
||||
<string name=”group_picture">Grupp bild</string>
|
||||
<string name="explicit_content">Explicit Innehåll</string>
|
||||
<string name="spam">Spam</string>
|
||||
<string name="impersonation">Imitation</string>
|
||||
<string name="illegal_behavior">Olagligt beteende</string>
|
||||
<string name="unknown">Okänd</string>
|
||||
<string name=”relay_icon">Relä ikon</string>
|
||||
<string name="unknown_author">Okänd användare</string>
|
||||
<string name=”copy_text">Kopiera Text</string>
|
||||
<string name=”copy_user_pubkey">Kopiera användarens ID</string>
|
||||
<string name=”copy_note_id">Kopiera anteckningens ID</string>
|
||||
<string name=”broadcast">Sänd ut</string>
|
||||
<string name="request_deletion">Begär radering</string>
|
||||
<string name=”block_hide_user"><![CDATA[Blockera och göm användare]]></string>
|
||||
<string name="report_spam_scam">Rapportera Spam / Scam</string>
|
||||
<string name="report_impersonation">Rapportera identitetsstöld</string>
|
||||
<string name="report_explicit_content">Rapportera explicit innehåll</string>
|
||||
<string name="report_illegal_behaviour">Rapportera olagligt beteende</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_reply">Logga in med en privat nyckel för att kunna svara</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Logga in med en privat nyckel för att kunna boosta inlägg</string>
|
||||
<string name="login_with_a_private_key_to_like_posts">Logga in med en privat nyckel för att gilla inlägg</string>
|
||||
<string name="no_zap_amount_setup_long_press_to_change">Ingen konfiguration av Zap-belopp. Tryck länge för att ändra</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_send_zaps">Logga in med en privat nyckel för att kunna skicka Zaps</string>
|
||||
<string name="zaps">Zaps</string>
|
||||
<string name="view_count">Antal visningar</string>
|
||||
<string name="boost">Boost</string>
|
||||
<string name="boosted">boosted</string>
|
||||
<string name="quote">Citera</string>
|
||||
<string name="new_amount_in_sats">Nytt belopp i Sats</string>
|
||||
<string name=”add">Lägg till</string>
|
||||
<string name=”replying_to">"Svarar till "</string>
|
||||
<string name="and">" och "</string>
|
||||
<string name="in_channel">"i kanalen "</string>
|
||||
<string name="profile_banner">Profil Banner</string>
|
||||
<string name="following">" Följer"</string>
|
||||
<string name="followers">" Följare"</string>
|
||||
<string name="profile">Profil</string>
|
||||
<string name="security_filters">Säkerhets Filter</string>
|
||||
<string name=”log_out">Logga ut</string>
|
||||
<string name=”show_more">Visa mer</string>
|
||||
<string name="lightning_invoice">Lightning Faktura</string>
|
||||
<string name="pay">Betala</string>
|
||||
<string name="lightning_tips">Lightning Dricks</string>
|
||||
<string name="note_to_receiver">Notering till mottagaren</string>
|
||||
<string name=”thank_you_so_much">Tack så mycket!</string>
|
||||
<string name="amount_in_sats">Summa i Sats</string>
|
||||
<string name="send_sats">Skicka Sats</string>
|
||||
<string name="error_parsing_preview_for">"Fel vid analys av förhandsvisning för %1$s : %2$s"</string>
|
||||
<string name="preview_card_image_for">"Förhandsgranska kortbild för %1$s"</string>
|
||||
<string name=”new_channel">Ny kanal</string>
|
||||
<string name=”channel_name">Kanal Namn</string>
|
||||
<string name="my_awesome_group">Min fantastiska grupp</string>
|
||||
<string name="picture_url">Bild Url</string>
|
||||
<string name="description">Beskrivning</string>
|
||||
<string name=”about_us">"Om oss.. "</string>
|
||||
<string name=”what_s_on_your_mind">Vad tänker du på?</string>
|
||||
<string name="post">Dela</string>
|
||||
<string name="save">Spara</string>
|
||||
<string name="create">Skapa</string>
|
||||
<string name="cancel">Avbryt</string>
|
||||
<string name="failed_to_upload_the_image">Det gick inte att ladda upp bilden</string>
|
||||
<string name="relay_address">Relä Adress</string>
|
||||
<string name="posts">Inlägg</string>
|
||||
<string name="bytes">Bytes</string>
|
||||
<string name="errors">Errors</string>
|
||||
<string name=”home_feed">Hem Flöde</string>
|
||||
<string name=”private_message_feed">Privata Meddelande Flöde</string>
|
||||
<string name="public_chat_feed">Publik Chat Flöde</string>
|
||||
<string name="global_feed">Globalt Flöde</string>
|
||||
<string name="search_feed">Sök i Flödet</string>
|
||||
<string name=”add_a_relay">Lägg till Relä</string>
|
||||
<string name="display_name">Visningsnamn</string>
|
||||
<string name=”my_display_name">Mitt visningsnamn</string>
|
||||
<string name="username">Användarnamn</string>
|
||||
<string name=”my_username">Mitt användarnamn</string>
|
||||
<string name=”about_me">Om mig</string>
|
||||
<string name="avatar_url">Avatar URL</string>
|
||||
<string name="banner_url">Banner URL</string>
|
||||
<string name="website_url">Websida URL</string>
|
||||
<string name="ln_address">LN Adress</string>
|
||||
<string name="ln_url_outdated">LN URL (Utdaterad)</string>
|
||||
<string name=”image_saved_to_the_gallery">Bild sparad till galleriet</string>
|
||||
<string name=”failed_to_save_the_image">Misslyckades att spara bilden</string>
|
||||
<string name=”upload_image">Ladda upp bilden</string>
|
||||
<string name=”uploading">Laddar upp…</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Användaren har inte en Lightningadressinställning för att ta emot sats</string>
|
||||
<string name=”reply_here">"svara här.. "</string>
|
||||
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Kopierar antecknings-ID till urklipp för delning</string>
|
||||
<string name="copy_channel_id_note_to_the_clipboard">Kopiera kanal-ID (anteckningen) till Urklipp</string>
|
||||
<string name="edits_the_channel_metadata">Redigerar kanalmetadata</string>
|
||||
<string name=”join">Gå med</string>
|
||||
<string name="known">Känd</string>
|
||||
<string name="new_requests">Nya förfrågningar</string>
|
||||
<string name="blocked_users">Blockerade användare</string>
|
||||
<string name="new_threads">Nya trådar</string>
|
||||
<string name="conversations">konversationer</string>
|
||||
<string name="notes">Anteckningar</string>
|
||||
<string name="replies">Svar</string>
|
||||
<string name="follows">"Följer"</string>
|
||||
<string name="reports">"Rapporter"</string>
|
||||
<string name="more_options">Fler alternativ</string>
|
||||
<string name="relays">" Reläer"</string>
|
||||
<string name="website">Websida</string>
|
||||
<string name="lightning_address">Lightning Adress</string>
|
||||
<string name="copies_the_nsec_id_your_password_to_the_clipboard_for_backup">Kopierar Nsec ID (ditt lösenord) till urklipp för säkerhetskopiering</string>
|
||||
<string name="copy_private_key_to_the_clipboard">Kopiera hemlig nyckel till urklipp</string>
|
||||
<string name="copies_the_public_key_to_the_clipboard_for_sharing">Kopierar den publika nyckeln till urklipp för delning</string>
|
||||
<string name="copy_public_key_npub_to_the_clipboard">Kopiera Public Key (NPub) till Urklipp</string>
|
||||
<string name=”send_a_direct_message">Skicka ett direkt meddelande</string>
|
||||
<string name="edits_the_user_s_metadata">Redigerar användarens metadata</string>
|
||||
<string name="follow">Följ</string>
|
||||
<string name=”follow_back">Följ tillbaka</string>
|
||||
<string name=”unblock”>Sluta blockera</string>
|
||||
<string name="copy_user_id">Kopiera Användarens ID</string>
|
||||
<string name=”unblock_user”>Sluta blockera Användare</string>
|
||||
<string name="npub_hex_username">"npub, hex, användarnamn "</string>
|
||||
<string name="clear">Rensa</string>
|
||||
<string name="app_logo">App Logo</string>
|
||||
<string name="nsec_npub_hex_private_key">nsec / npub / hex privat nyckel</string>
|
||||
<string name=”show_password">Visa lösenord</string>
|
||||
<string name=”hide_password">Göm lösenord</string>
|
||||
<string name="invalid_key">Ogiltig nyckel</string>
|
||||
<string name=”i_accept_the">"Jag accepterar "</string>
|
||||
<string name="terms_of_use">villkor</string>
|
||||
<string name="acceptance_of_terms_is_required">Godkännande av villkor krävs</string>
|
||||
<string name="key_is_required">Nyckel krävs</string>
|
||||
<string name=”login">Inloggning</string>
|
||||
<string name="generate_a_new_key">Skapa en ny nyckel</string>
|
||||
<string name=”loading_feed">Ladda flöde</string>
|
||||
<string name="error_loading_replies">"Det gick inte att läsa in svar: "</string>
|
||||
<string name=”try_again">Försök igen</string>
|
||||
<string name=”feed_is_empty">Flöde är tomt.</string>
|
||||
<string name="refresh">Uppdatera</string>
|
||||
<string name="created">Skapad</string>
|
||||
<string name="with_description_of">med beskrivning av</string>
|
||||
<string name=”and_picture">och bild</string>
|
||||
<string name="changed_chat_name_to">ändra chat namn till</string>
|
||||
<string name="description_to">beskrivning till</string>
|
||||
<string name="and_picture_to">och bild till</string>
|
||||
<string name="leave">Lämna</string>
|
||||
<string name=”unfollow">Sluta följa</string>
|
||||
<string name=”channel_created">Kanal skapad</string>
|
||||
<string name="channel_information_changed_to">"Kanalinformation ändrad till"</string>
|
||||
<string name="public_chat">Publik Chat</string>
|
||||
<string name="posts_received">mottagna inlägg</string>
|
||||
<string name=”remove">Ta bort</string>
|
||||
<string name="sats" translatable="false">sats</string>
|
||||
<string name="translations_auto">Auto</string>
|
||||
<string name=”translations_translated_from">översatt från</string>
|
||||
<string name="translations_to">till</string>
|
||||
<string name=”translations_show_in_lang_first">Visa i %1$s first</string>
|
||||
<string name=”translations_always_translate_to_lang">Översätt alltid till %1$s</string>
|
||||
<string name=”translations_never_translate_from_lang">Översätt aldrig från %1$s</string>
|
||||
<string name="nip_05" translatable="false">NIP-05</string>
|
||||
<string name="lnurl" translatable="false">LNURL...</string>
|
||||
<string name="never">aldrig</string>
|
||||
<string name="now">nu</string>
|
||||
<string name="h">h</string>
|
||||
<string name="m">m</string>
|
||||
<string name="d">d</string>
|
||||
<string name="nudity">Naket</string>
|
||||
<string name="profanity_hateful_speech">Svordomar / Hatiskt tal</string>
|
||||
<string name="report_hateful_speech">Anmäl hattal</string>
|
||||
<string name="report_nudity_porn">Rapportera nakenhet/porr</string>
|
||||
<string name="others">andras</string>
|
||||
<string name="mark_all_known_as_read">Markera alla kända som lästa</string>
|
||||
<string name="mark_all_new_as_read">Markera alla nya som lästa</string>
|
||||
<string name="mark_all_as_read">Markera allt som läst</string>
|
||||
<string name="backup_keys">Backup-nycklar</string>
|
||||
<string name="account_backup_tips_md" tools:ignore="Typos">
|
||||
## Nyckelsäkerhetskopiering och säkerhetstips
|
||||
\n\nDitt konto är skyddat av en hemlig nyckel. Nyckeln är en lång slumpmässig sträng som börjar med **nsec1**. Alla som har tillgång till din hemliga nyckel kan publicera innehåll med din identitet.
|
||||
\n\n- Lägg **inte** in din hemliga nyckel på någon webbplats eller programvara som du inte litar på.
|
||||
\n- Amethyst-utvecklare kommer **aldrig** att be dig om din hemliga nyckel.
|
||||
\n- **Behåll en säker säkerhetskopia av din hemliga nyckel för kontoåterställning. Vi rekommenderar att du använder en lösenordshanterare. </string>
|
||||
<string name="secret_key_copied_to_clipboard">Hemlig nyckel (nsec) kopierad till urklipp</string>
|
||||
<string name="copy_my_secret_key">Kopiera min hemliga nyckel</string>
|
||||
<string name="biometric_authentication_failed">Autentisering misslyckades</string>
|
||||
<string name="biometric_error">Error</string>
|
||||
<string name=”badge_created_by">"Skapad av %1$s"</string>
|
||||
<string name=”badge_award_image_for”>"Tilldelad Badge för %1$s"</string>
|
||||
<string name=”new_badge_award_notif">Du har blivit tilldelad en ny Badge </string>
|
||||
<string name="award_granted_to">Badge utmärkelse tilldelas</string>
|
||||
<string name="copied_note_text_to_clipboard">Kopierade anteckningstext till urklipp</string>
|
||||
<string name="copied_user_id_to_clipboard" tools:ignore="Typos">Kopierade författarens @npub till urklipp</string>
|
||||
<string name="copied_note_id_to_clipboard" tools:ignore="Typos">Kopierade antecknings-ID (@note1) till urklipp</string>
|
||||
<string name="select_text_dialog_top">Välj Text</string>
|
||||
<string name="github" translatable="false">Github Gist med bevis</string>
|
||||
<string name="telegram" translatable="false">Telegram</string>
|
||||
<string name="mastodon" translatable="false">Mastodon antecknings ID med bevis</string>
|
||||
<string name="twitter" translatable="false">Twitter Status med bevis</string>
|
||||
<string name="github_proof_url_template" translatable="false">https://gist.github.com/<user>/<gist></string>
|
||||
<string name="telegram_proof_url_template" translatable="false">https://t.me/<proof post></string>
|
||||
<string name="mastodon_proof_url_template" translatable="false">https://<server>/<user>/<proof post></string>
|
||||
<string name="twitter_proof_url_template" translatable="false">https://twitter.com/<user>/status/<proof post></string>
|
||||
<string name="private_conversation_notification">"<Kan inte dekryptera privat meddelande>\n\nDu citerades i en privat/krypterad konversation mellan %1$s och %2$s."</string>
|
||||
<string name=”account_switch_add_account_dialog_title">Lägg till nytt konto</string>
|
||||
<string name="drawer_accounts">Konton</string>
|
||||
<string name=”account_switch_select_account">Välj konto</string>
|
||||
<string name=”account_switch_add_account_btn">Lägg till nytt konto</string>
|
||||
<string name=”account_switch_active_account">Aktivt konto</string>
|
||||
<string name=”account_switch_has_private_key">Har privat nyckel</string>
|
||||
<string name=”account_switch_pubkey_only">Läs bara, ingen privat nyckel</string>
|
||||
<string name="back">Back</string>
|
||||
<string name="quick_action_select">Välj</string>
|
||||
<string name="quick_action_share_browser_link">Dela webbläsarlänk</string>
|
||||
<string name="quick_action_share">Dela</string>
|
||||
<string name="quick_action_copy_user_id">Användar ID</string>
|
||||
<string name="quick_action_copy_note_id">Antecknings ID</string>
|
||||
<string name="quick_action_copy_text">Kopiera Text</string>
|
||||
<string name="quick_action_delete">Radera</string>
|
||||
<string name=”quick_action_unfollow">Sluta följa</string>
|
||||
<string name="quick_action_follow">Följ</string>
|
||||
<string name="quick_action_request_deletion_alert_title">Begär radering</string>
|
||||
<string name="quick_action_request_deletion_alert_body">Amethyst kommer att begära att din anteckning tas bort från de reläer du för närvarande är ansluten till. Det finns ingen garanti för att din anteckning kommer att raderas permanent från dessa reläer eller från andra reläer där den kan lagras.</string>
|
||||
<string name="quick_action_block_dialog_btn">Blockera</string>
|
||||
<string name="quick_action_delete_dialog_btn">Radera</string>
|
||||
<string name="quick_action_block">Blockera</string>
|
||||
<string name="quick_action_report">Rapportera</string>
|
||||
<string name="quick_action_delete_button">Radera</string>
|
||||
<string name=”quick_action_dont_show_again_button”>Visa inte igen</string>
|
||||
<string name="report_dialog_spam">Spam eller scams</string>
|
||||
<string name="report_dialog_profanity">Svordomar eller hatiskt beteende</string>
|
||||
<string name="report_dialog_impersonation">Skadlig identitetsstöld</string>
|
||||
<string name="report_dialog_nudity">Nakenhet eller grafiskt innehåll</string>
|
||||
<string name="report_dialog_illegal">Olagligt beteende</string>
|
||||
<string name="report_dialog_blocking_a_user">Blockering av en användare kommer att dölja deras innehåll i din app. Dina anteckningar är fortfarande offentliga, inklusive för personer du blockerar. Blockerade användare listas på skärmen Säkerhetsfilter.</string>
|
||||
<string name="report_dialog_block_hide_user_btn"><![CDATA[Blockera & Göm användare]]></string>
|
||||
<string name="report_dialog_report_btn">Rapportera missbruk</string>
|
||||
<string name="report_dialog_reminder_public">Alla rapporter som publiceras kommer att vara offentliga.</string>
|
||||
<string name="report_dialog_additional_reason_placeholder">Ge eventuellt ytterligare sammanhang om din rapport...</string>
|
||||
<string name="report_dialog_additional_reason_label">Ytterligare sammanhang</string>
|
||||
<string name="report_dialog_select_reason_label">Anledning</string>
|
||||
<string name=”report_dialog_select_reason_placeholder">Välj anledning…</string>
|
||||
<string name=”report_dialog_post_report_btn">Skicka rapport</string>
|
||||
<string name=”report_dialog_title">Blockera och rapportera</string>
|
||||
<string name="block_only">Blockera</string>
|
||||
|
||||
<string name="bookmarks">Bokmärken</string>
|
||||
<string name=”private_bookmarks">Privata Bokmärken</string>
|
||||
<string name="public_bookmarks">Publika Bokmärken</string>
|
||||
<string name="add_to_private_bookmarks">Lägg till i Privata Bokmärken</string>
|
||||
<string name=”add_to_public_bookmarks">Lägg till i Publika Bokmärken</string>
|
||||
<string name="remove_from_private_bookmarks">Ta bort från Privata Bokmärken</string>
|
||||
<string name="remove_from_public_bookmarks">Ta bort från Publika Bokmärken</string>
|
||||
|
||||
<string name="wallet_connect_service">Wallet Connect Service</string>
|
||||
<string name="wallet_connect_service_explainer">Tillåter en Nostr Secret att betala zaps utan att lämna appen. Håll hemligheten säker och använd ett privat relä om möjligt</string>
|
||||
<string name="wallet_connect_service_pubkey">Wallet Connect Pubkey</string>
|
||||
<string name="wallet_connect_service_relay">Wallet Connect Relay</string>
|
||||
<string name="wallet_connect_service_secret">Wallet Connect Secret</string>
|
||||
<string name="wallet_connect_service_show_secret">Visa secret key</string>
|
||||
<string name="wallet_connect_service_secret_placeholder">nsec / hex privat nyckel</string>
|
||||
|
||||
<string name="pledge_amount_in_sats">Pantbelopp i Sats</string>
|
||||
<string name="post_poll">Dela Poll</string>
|
||||
<string name="poll_heading_required">Obligatoriska fält:</string>
|
||||
<string name="poll_zap_recipients">Zap mottagare</string>
|
||||
<string name="poll_primary_description">Beskrivning av primär omröstning…</string>
|
||||
<string name="poll_option_index">Alternativ %s</string>
|
||||
<string name="poll_option_description">Beskrivning av omröstningsalternativ</string>
|
||||
<string name="poll_heading_optional">Valfria fält:</string>
|
||||
<string name=”poll_zap_value_min">Minsta Zap</string>
|
||||
<string name=”poll_zap_value_max”>Maximal Zap</string>
|
||||
<string name="poll_consensus_threshold">Konsensus</string>
|
||||
<string name="poll_consensus_threshold_percent">(0–100)%</string>
|
||||
<string name=”poll_closing_time">Avsluta efter</string>
|
||||
<string name="poll_closing_time_days">dagar</string>
|
||||
<string name="poll_is_closed">Omröstningen är stängd för nya röster</string>
|
||||
<string name="poll_zap_amount">Zap belopp</string>
|
||||
<string name="one_vote_per_user_on_atomic_votes">Endast en röst per användare tillåts i denna typ av omröstning</string>
|
||||
|
||||
<string name=”looking_for_event">"Letar efter Event %1$s"</string>
|
||||
|
||||
<string name="custom_zaps_add_a_message">Lägg till ett offentligt meddelande</string>
|
||||
<string name="custom_zaps_add_a_message_private">Lägg till ett privat meddelande</string>
|
||||
<string name="custom_zaps_add_a_message_nonzap">Lägg till ett fakturameddelande</string>
|
||||
|
||||
<string name="custom_zaps_add_a_message_example">Tack för allt ditt arbete!</string>
|
||||
|
||||
<string name="lightning_create_and_add_invoice">Skapa och lägg till</string>
|
||||
<string name="poll_author_no_vote">Omröstningsskapare kan inte rösta i sina egna omröstningar.</string>
|
||||
<string name="poll_hashtag" translatable="false">#zappoll</string>
|
||||
|
||||
<string name="hash_verification_passed">Bilden är densamma sedan inlägget</string>
|
||||
<string name="hash_verification_failed">Bilden har ändrats. Användaren kanske inte har sett förändringen</string>
|
||||
|
||||
<string name=”content_description_add_image">Lägg till Bild</string>
|
||||
<string name=”content_description_add_video">Lägg till Video</string>
|
||||
<string name=”content_description_add_document">Lägg till Dokument</string>
|
||||
|
||||
<string name=”add_content">Lägg till Meddelande</string>
|
||||
<string name="content_description">Beskrivning av innehållet</string>
|
||||
<string name="content_description_example">En blå båt på en vit sandstrand vid solnedgången</string>
|
||||
|
||||
<string name="zap_type">Zap Typ</string>
|
||||
<string name="zap_type_explainer">Zap Typ för alla alternativ</string>
|
||||
|
||||
<string name="zap_type_public">Publik</string>
|
||||
<string name="zap_type_public_explainer">Alla kan se transaktionen och meddelandet</string>
|
||||
|
||||
<string name="zap_type_private">Privat</string>
|
||||
<string name="zap_type_private_explainer">Avsändare och mottagare kan se varandra och läsa meddelandet</string>
|
||||
|
||||
<string name="zap_type_anonymous">Anonym</string>
|
||||
<string name="zap_type_anonymous_explainer">Mottagaren och allmänheten vet inte vem som skickade betalningen</string>
|
||||
|
||||
<string name=”zap_type_nonzap">Ingen Zap</string>
|
||||
<string name="zap_type_nonzap_explainer">Inga spår i Nostr, bara i Lightning</string>
|
||||
|
||||
|
||||
<string name="file_server">Fil Server</string>
|
||||
<string name="zap_forward_lnAddress">LnAdress or @Användare</string>
|
||||
|
||||
<string name="upload_server_imgur">imgur.com - betrodd</string>
|
||||
<string name="upload_server_imgur_explainer">Imgur kan ändra filen</string>
|
||||
|
||||
<string name="upload_server_nostrimg">nostrimg.com - betrodd</string>
|
||||
<string name=”upload_server_nostrimg_explainer">NostrImg kan ändra filen</string>
|
||||
|
||||
<string name="upload_server_nostrbuild">nostr.build - betrodd</string>
|
||||
<string name="upload_server_nostrbuild_explainer">Nostr.build kan ändra filen</string>
|
||||
|
||||
<string name="upload_server_nostrfilesdev">nostrfiles.dev - betrodd</string>
|
||||
<string name="upload_server_nostrfilesdev_explainer">Nostrfiles.dev kan ändra filen</string>
|
||||
|
||||
|
||||
<string name="upload_server_imgur_nip94">Verifierbar Imgur (NIP-94)</string>
|
||||
<string name="upload_server_imgur_nip94_explainer">Kontrollerar om Imgur modifierat filen. Nytt NIP: andra klienter kanske inte ser det</string>
|
||||
|
||||
<string name="upload_server_nostrimg_nip94">Verifierbar NostrImg (NIP-94)</string>
|
||||
<string name="upload_server_nostrimg_nip94_explainer">Kontrollerar om NostrImg modifierat filen. Nytt NIP: andra klienter kanske inte ser det</string>
|
||||
|
||||
<string name="upload_server_nostrbuild_nip94">Verifierbar Nostr.build (NIP-94)</string>
|
||||
<string name="upload_server_nostrbuild_nip94_explainer">Kontrollerar om Nostr.build modifierat filen. Nytt NIP: andra klienter kanske inte ser det</string>
|
||||
|
||||
<string name="upload_server_nostrfilesdev_nip94">Verifierbar Nostrfiles.dev (NIP-94)</string>
|
||||
<string name="upload_server_nostrfilesdev_nip94_explainer">Kontrollerar om Nostrfiles.dev modifierat filen. Nytt NIP: andra klienter kanske inte ser det</string>
|
||||
|
||||
|
||||
<string name=”upload_server_relays_nip95">Dina Reläer (NIP-95)</string>
|
||||
<string name="upload_server_relays_nip95_explainer">Filer är värd för dina reläer. Nytt NIP: kontrollera om de stöder</string>
|
||||
|
||||
<string name="connect_via_tor_short">Tor/Orbot Inställningar</string>
|
||||
<string name="connect_via_tor">Anslut via dina Orbot-inställiningar</string>
|
||||
|
||||
<string name="do_you_really_want_to_disable_tor_title">Koppla från Orbot/Tor?</string>
|
||||
<string name="do_you_really_want_to_disable_tor_text">Dina data kommer omedelbart att överföras till det vanliga nätverket</string>
|
||||
<string name="yes">Ja</string>
|
||||
<string name="no">Nej</string>
|
||||
|
||||
|
||||
<string name="follow_list_selection">Följ lista</string>
|
||||
<string name="follow_list_kind3follows">Alla följare</string>
|
||||
<string name="follow_list_global">Global</string>
|
||||
<string name="connect_through_your_orbot_setup_markdown">
|
||||
## Anslut genom Tor med Orbot
|
||||
\n\n1. Installera [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android)
|
||||
\n2. Starta Orbot
|
||||
\n3. Kontrollera Socks-porten i Orbot. Standarden använder 9050
|
||||
\n4. Ändra vid behov porten i Orbot
|
||||
\n5. Konfigurera Socks-porten på den här skärmen
|
||||
\n6. Tryck på knappen Aktivera för att använda Orbot som proxy</string>
|
||||
<string name="orbot_socks_port">Orbot Socks Port</string>
|
||||
<string name="invalid_port_number">Ogiltigt portnummer</string>
|
||||
<string name="use_orbot">Använd Orbot</string>
|
||||
<string name=”disconnect_from_your_orbot_setup">Koppla ifrån Tor/Orbot</string>
|
||||
|
||||
<string name="app_notification_channel_id" translatable="false">Standardkanal-ID</string>
|
||||
<string name="app_notification_private_message" translatable=”false">Ny notifikation har kommit</string>
|
||||
|
||||
<string name="app_notification_dms_channel_id" translatable="false">PrivataMeddelandID</string>
|
||||
<string name="app_notification_dms_channel_name">Privata Meddelade</string>
|
||||
<string name="app_notification_dms_channel_description">Meddelar dig när ett privat meddelande kommer</string>
|
||||
|
||||
<string name="app_notification_zaps_channel_id" translatable="false">ZapsID</string>
|
||||
<string name="app_notification_zaps_channel_name">Zaps Mottaget</string>
|
||||
<string name="app_notification_zaps_channel_description">Meddela dig när någon zappar dig</string>
|
||||
<string name="app_notification_zaps_channel_message">%1$s sats</string>
|
||||
<string name="app_notification_zaps_channel_message_from">Från %1$s</string>
|
||||
<string name="app_notification_zaps_channel_message_for">till %1$s</string>
|
||||
|
||||
<string name="reply_notify">Meddela: </string>
|
||||
|
||||
<string name="channel_list_join_conversation">Gå med i konversation</string>
|
||||
<string name="channel_list_user_or_group_id">Användare eller grupp ID</string>
|
||||
<string name="channel_list_user_or_group_id_demo">npub, nevent or hex</string>
|
||||
<string name="channel_list_create_channel">Skapa</string>
|
||||
<string name=”channel_list_join_channel">Gå med</string>
|
||||
</resources>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class Nip30Test {
|
||||
@Test()
|
||||
fun parseEmoji() {
|
||||
val input = "Alex Gleason :soapbox:"
|
||||
|
||||
assertEquals(
|
||||
listOf("Alex Gleason ", ":soapbox:", ""),
|
||||
NIP30Parser().buildArray(input)
|
||||
)
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun parseEmojiInverted() {
|
||||
val input = ":soapbox:Alex Gleason"
|
||||
|
||||
assertEquals(
|
||||
listOf("", ":soapbox:", "Alex Gleason"),
|
||||
NIP30Parser().buildArray(input)
|
||||
)
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun parseEmoji2() {
|
||||
val input = "Hello :gleasonator: \uD83D\uDE02 :ablobcatrainbow: :disputed: yolo"
|
||||
|
||||
assertEquals(
|
||||
listOf("Hello ", ":gleasonator:", " 😂 ", ":ablobcatrainbow:", " ", ":disputed:", " yolo"),
|
||||
NIP30Parser().buildArray(input)
|
||||
)
|
||||
|
||||
println(NIP30Parser().buildArray(input).joinToString(","))
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun parseEmoji3() {
|
||||
val input = "hello vitor: how can I help:"
|
||||
|
||||
assertEquals(
|
||||
listOf("hello vitor: how can I help:"),
|
||||
NIP30Parser().buildArray(input)
|
||||
)
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun parseJapanese() {
|
||||
val input = "\uD883\uDEDE\uD883\uDEDE麺の:x30EDE:。:\uD883\uDEDE:(Violation of NIP-30)"
|
||||
|
||||
assertEquals(
|
||||
listOf("\uD883\uDEDE\uD883\uDEDE麺の", ":x30EDE:", "。:\uD883\uDEDE:(Violation of NIP-30)"),
|
||||
NIP30Parser().buildArray(input)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user