Compare commits

...
25 Commits
Author SHA1 Message Date
Vitor Pamplona 2462b957a3 v0.5 2023-01-14 18:03:47 -05:00
Vitor Pamplona a8dd13e13b Supports Private Messages 2023-01-14 18:02:28 -05:00
Vitor Pamplona aa11bf212a Private message support 2023-01-14 17:56:18 -05:00
Vitor Pamplona f580fdd216 Storing and Counting followers of the main account. 2023-01-14 10:16:36 -05:00
Vitor Pamplona d130a43358 Sending confirmation events back to the Repository. 2023-01-13 21:35:28 -05:00
Vitor Pamplona 353046b451 Better info about relays in the Top Nav bar 2023-01-13 21:35:12 -05:00
Vitor Pamplona 5859ae2c52 Removing autoplay due to audio (need to figure out how to mute first) 2023-01-13 21:34:31 -05:00
Vitor Pamplona 66cfa9201c Simplifies the first call to Nostr relays 2023-01-13 20:17:24 -05:00
Vitor Pamplona 380c2e67cc Adds a click to zoom image. 2023-01-13 20:16:57 -05:00
Vitor Pamplona 0001ae441f Blocks double likes. 2023-01-13 17:28:23 -05:00
Vitor Pamplona 594795fc16 Version v0.4 2023-01-13 12:30:40 -05:00
Vitor Pamplona 9b95e1de51 Lightning invoice card 2023-01-13 12:30:13 -05:00
Vitor Pamplona 962bd9eb2d Dropdown menu to copy information from the note. 2023-01-13 10:20:54 -05:00
Vitor Pamplona 9d4f4c67f1 Better timeAgo formatting 2023-01-13 09:51:14 -05:00
Vitor Pamplona c1d6d965cd Improves Note's padding and click area. 2023-01-13 09:39:45 -05:00
Vitor Pamplona d676d57614 Improving documentation for the Note Class 2023-01-13 09:39:14 -05:00
Vitor Pamplona 582b55c6be Caching URL Infos 2023-01-13 09:39:03 -05:00
Vitor Pamplona fd1f8663e5 Version 0.3 2023-01-12 22:40:45 -05:00
Vitor Pamplona 69fdc4fcd4 Adding video playback to the timeline 2023-01-12 22:40:39 -05:00
Vitor Pamplona 815f044f77 Better permission handling for images. 2023-01-12 22:36:09 -05:00
Vitor Pamplona a336118d0d Removing unnecessary logs 2023-01-12 22:35:51 -05:00
Vitor Pamplona bf827fd1f4 Image uploading and Image/URL previews on new posts. 2023-01-12 21:14:44 -05:00
Vitor Pamplona 250e970aca v0.2 Thread Views 2023-01-12 12:48:24 -05:00
Vitor Pamplona 45a7a18ea7 Thread View 2023-01-12 12:47:31 -05:00
Vitor Pamplona 1d24cb411c adds a brief description of the architecture 2023-01-11 16:10:14 -05:00
65 changed files with 2259 additions and 294 deletions
+20 -1
View File
@@ -13,7 +13,7 @@ Amethyst brings the best social network to your Android phone. Just insert your
- [x] Reactions (like, boost, reply)
- [x] Image Preview
- [x] Url Preview
- [ ] View Threads
- [x] View Threads
- [ ] Private Messages
- [ ] Communities
- [ ] Profile Edit
@@ -21,6 +21,25 @@ Amethyst brings the best social network to your Android phone. Just insert your
# Development Overview
## Overall Architecture
This is a native Android app made with Kotlin and Jetpack Compose.
The app uses a modified version of the [nostrpostrlib](https://github.com/Giszmo/NostrPostr/tree/master/nostrpostrlib) to talk to Nostr relays.
The overall architecture consists in the UI, which uses the usual State/ViewModel/Composition, the service layer that connects with Nostr relays,
and the model/repository layer, which keeps all Nostr objects in memory, in a full OO graph.
The repository layer stores Nostr Events as Notes and Users separately. Those classes use LiveData objects to
allow the UI and other parts of the app to subscribe to each individual Note/User and receive updates when they happen.
They are also responsible for updating viewModels when needed. Filters react to changes in the screen. As the user
sees different Events, the Datasource classes are used to receive more information about those particular Events.
Most of the UI is reactive to changes in the repository classes. The service layer assembles Nostr filters for each need of the app,
receives the data from the Relay, and sends it to the repository. Connection with relays is never closed during the use of the app.
The UI receives a notification that objects were updated. Instances of User and Notes are mutable directly.
There will never be two Notes with the same ID or two User instances with the same pubkey.
Lastly, the user's account information (priv key/pub key) is stored in the Android KeyStore for security.
## Setup
Make sure to have the following pre-requisites installed:
+9 -3
View File
@@ -11,8 +11,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 1
versionName "0.1"
versionCode 5
versionName "0.5"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -90,7 +90,13 @@ dependencies {
// link preview
implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha03'
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha04'
// upload pictures:
implementation "com.google.accompanist:accompanist-permissions:0.28.0"
// view videos
implementation 'com.google.android.exoplayer:exoplayer:2.18.2'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
@@ -1,13 +1,11 @@
package com.vitorpamplona.amethyst
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
+1
View File
@@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<application
android:allowBackup="true"
@@ -5,6 +5,8 @@ import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.Client
import nostr.postr.Persona
import nostr.postr.Utils
import nostr.postr.events.PrivateDmEvent
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
@@ -22,6 +24,11 @@ class Account(val loggedIn: Persona) {
fun reactTo(note: Note) {
if (!isWriteable()) return
if (note.reactions.firstOrNull { it.author == userProfile() } != null) {
// has already liked this note
return
}
note.event?.let {
val event = ReactionEvent.create(it, loggedIn.privKey!!)
Client.send(event)
@@ -39,6 +46,12 @@ class Account(val loggedIn: Persona) {
}
}
fun broadcast(note: Note) {
note.event?.let {
Client.send(it)
}
}
fun sendPost(message: String, replyingTo: Note?) {
if (!isWriteable()) return
@@ -67,6 +80,48 @@ class Account(val loggedIn: Persona) {
}
}
fun sendPrivateMeesage(message: String, toUser: String, replyingTo: Note? = null) {
if (!isWriteable()) return
val user = LocalCache.users[toUser] ?: return
val signedEvent = PrivateDmEvent.create(
recipientPubKey = user.pubkey,
publishedRecipientPubKey = user.pubkey,
msg = message,
privateKey = loggedIn.privKey!!,
advertiseNip18 = false
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
}
fun decryptContent(note: Note): String? {
val event = note.event
return if (event is PrivateDmEvent && loggedIn.privKey != null) {
var pubkeyToUse = event.pubKey
if (note.author == userProfile())
pubkeyToUse = event.recipientPubKey!!
val sharedSecret = Utils.getSharedSecret(loggedIn.privKey!!, pubkeyToUse)
return try {
val retVal = Utils.decrypt(event.content, sharedSecret)
if (retVal.startsWith(PrivateDmEvent.nip18Advertisement)) {
retVal.substring(16)
} else {
retVal
}
} catch (e: Exception) {
e.printStackTrace()
null
}
} else {
event?.content
}
}
// Observers line up here.
val live: AccountLiveData = AccountLiveData(this)
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.ui.note.toDisplayHex
import fr.acinq.secp256k1.Hex
import java.util.regex.Pattern
import nostr.postr.Bech32
import nostr.postr.Persona
import nostr.postr.bechToBytes
import nostr.postr.toHex
@@ -10,6 +11,8 @@ import nostr.postr.toHex
/** Makes the distinction between String and Hex **/
typealias HexKey = String
fun ByteArray.toNote() = Bech32.encodeBytes(hrp = "note", this, Bech32.Encoding.Bech32)
fun ByteArray.toHexKey(): HexKey {
return toHex()
}
@@ -103,6 +103,8 @@ object LocalCache {
it.addReply(note)
}
UrlCachedPreviewer.preloadPreviewsFor(note)
refreshObservers()
}
@@ -112,9 +114,9 @@ object LocalCache {
fun consume(event: ContactListEvent) {
val user = getOrCreateUser(event.pubKey)
//Log.d("CL", "${user.toBestDisplayName()} ${event.follows}")
if (event.createdAt > user.updatedFollowsAt) {
//Log.d("CL", "${user.toBestDisplayName()} ${event.follows.size}")
user.updateFollows(
event.follows.map {
try {
@@ -135,7 +137,27 @@ object LocalCache {
}
fun consume(event: PrivateDmEvent) {
//Log.d("PM", event.toJson())
val note = getOrCreateNote(event.id.toHex())
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey)
val recipient = event.recipientPubKey?.let { getOrCreateUser(it) }
Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
val repliesTo = event.tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }.map { getOrCreateNote(it) }.toMutableList()
val mentions = event.tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }.map { getOrCreateUser(decodePublicKey(it)) }
note.loadEvent(event, author, mentions, repliesTo)
if (recipient != null) {
author.addMessage(recipient, note)
recipient.addMessage(author, note)
}
refreshObservers()
}
fun consume(event: DeletionEvent) {
@@ -4,18 +4,30 @@ import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.ui.note.toDisplayHex
import fr.acinq.secp256k1.Hex
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Collections
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import nostr.postr.events.Event
class Note(val idHex: String) {
// These fields are always available.
// They are immutable
val id = Hex.decode(idHex)
val idDisplayHex = id.toDisplayHex()
// These fields are only available after the Text Note event is received.
// They are immutable after that.
var event: Event? = null
var author: User? = null
var mentions: List<User>? = null
var replyTo: MutableList<Note>? = null
// These fields are updated every time an event related to this note is received.
val replies = Collections.synchronizedSet(mutableSetOf<Note>())
val reactions = Collections.synchronizedSet(mutableSetOf<Note>())
val boosts = Collections.synchronizedSet(mutableSetOf<Note>())
@@ -29,6 +41,33 @@ class Note(val idHex: String) {
refreshObservers()
}
fun formattedDateTime(timestamp: Long): String {
return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("uuuu-MM-dd-HH:mm:ss"))
}
fun replyLevelSignature(): String {
val replyTo = replyTo
if (replyTo == null || replyTo.isEmpty()) {
return "/" + formattedDateTime(event?.createdAt ?: 0) + ";"
}
return replyTo
.map { it.replyLevelSignature() }
.maxBy { it.length }.removeSuffix(";") + "/" + formattedDateTime(event?.createdAt ?: 0) + ";"
}
fun replyLevel(): Int {
val replyTo = replyTo
if (replyTo == null || replyTo.isEmpty()) {
return 0
}
return replyTo.maxOf {
it.replyLevel()
} + 1
}
fun addReply(note: Note) {
if (replies.add(note))
refreshObservers()
@@ -61,18 +100,24 @@ class Note(val idHex: String) {
}
class NoteLiveData(val note: Note): LiveData<NoteState>(NoteState(note)) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
fun refresh() {
postValue(NoteState(note))
}
override fun onActive() {
super.onActive()
NostrSingleEventDataSource.add(note.idHex)
scope.launch {
NostrSingleEventDataSource.add(note.idHex)
}
}
override fun onInactive() {
super.onInactive()
NostrSingleEventDataSource.remove(note.idHex)
scope.launch {
NostrSingleEventDataSource.remove(note.idHex)
}
}
}
@@ -0,0 +1,55 @@
package com.vitorpamplona.amethyst.model
import com.baha.url.preview.BahaUrlPreview
import com.baha.url.preview.IUrlPreviewCallback
import com.baha.url.preview.UrlInfoItem
import com.vitorpamplona.amethyst.ui.components.imageExtension
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import com.vitorpamplona.amethyst.ui.components.videoExtension
import java.util.concurrent.ConcurrentHashMap
object UrlCachedPreviewer {
val cache = ConcurrentHashMap<String, UrlInfoItem>()
fun previewInfo(url: String, callback: IUrlPreviewCallback? = null) {
cache[url]?.let {
callback?.onComplete(it)
return
}
BahaUrlPreview(url, object : IUrlPreviewCallback {
override fun onComplete(urlInfo: UrlInfoItem) {
cache.put(url, urlInfo)
callback?.onComplete(urlInfo)
}
override fun onFailed(throwable: Throwable) {
callback?.onFailed(throwable)
}
}).fetchUrlPreview()
}
fun findUrlsInMessage(message: String): List<String> {
return message.split('\n').map { paragraph ->
paragraph.split(' ').filter { word: String ->
isValidURL(word) || noProtocolUrlValidator.matcher(word).matches()
}
}.flatten()
}
fun preloadPreviewsFor(note: Note) {
note.event?.content?.let {
findUrlsInMessage(it).forEach {
val removedParamsFromUrl = it.split("?")[0].toLowerCase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
// Preload Images? Isn't this too heavy?
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
// Do nothing for now.
} else {
previewInfo(it)
}
}
}
}
}
@@ -4,6 +4,7 @@ import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.ui.note.toDisplayHex
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
class User(val pubkey: ByteArray) {
val pubkeyHex = pubkey.toHexKey()
@@ -18,7 +19,9 @@ class User(val pubkey: ByteArray) {
val follows = Collections.synchronizedSet(mutableSetOf<User>())
val taggedPosts = Collections.synchronizedSet(mutableSetOf<Note>())
var follower: Number? = null
val followers = Collections.synchronizedSet(mutableSetOf<User>())
val messages = ConcurrentHashMap<User, MutableSet<Note>>()
fun toBestDisplayName(): String {
return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex
@@ -37,9 +40,40 @@ class User(val pubkey: ByteArray) {
return info.picture ?: "https://robohash.org/${pubkeyHex}.png"
}
fun follow(user: User) {
follows.add(user)
user.followers.add(this)
}
fun unfollow(user: User) {
follows.remove(user)
user.followers.remove(this)
}
@Synchronized
fun getOrCreateChannel(user: User): MutableSet<Note> {
return messages[user] ?: run {
val channel = mutableSetOf<Note>()
messages[user] = channel
channel
}
}
fun addMessage(user: User, msg: Note) {
getOrCreateChannel(user).add(msg)
live.refresh()
}
fun updateFollows(newFollows: List<User>, updateAt: Long) {
follows.clear()
follows.addAll(newFollows)
val toBeAdded = newFollows - follows
val toBeRemoved = follows - newFollows
toBeAdded.forEach {
follow(it)
}
toBeRemoved.forEach {
unfollow(it)
}
updatedFollowsAt = updateAt
live.refresh()
@@ -6,8 +6,9 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.model.RepostEvent
import nostr.postr.JsonFilter
import nostr.postr.events.ContactListEvent
import nostr.postr.events.MetadataEvent
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
object NostrAccountDataSource: NostrDataSource("AccountData") {
lateinit var account: Account
@@ -30,8 +31,9 @@ object NostrAccountDataSource: NostrDataSource("AccountData") {
fun createAccountFilter(): JsonFilter {
return JsonFilter(
kinds = listOf(MetadataEvent.kind, ContactListEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 4), // 4 days
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 7), // 4 days
)
}
@@ -0,0 +1,45 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import nostr.postr.JsonFilter
import nostr.postr.events.PrivateDmEvent
object NostrChatRoomDataSource: NostrDataSource("ChatroomFeed") {
lateinit var account: Account
var withUser: User? = null
fun loadMessagesBetween(accountIn: Account, userId: String) {
account = accountIn
withUser = LocalCache.users[userId]
}
fun createMessagesToMeFilter() = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = withUser?.let { listOf(it.pubkeyHex) },
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
)
fun createMessagesFromMeFilter() = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
tags = withUser?.let { mapOf("p" to listOf(it.pubkeyHex)) }
)
val incomingChannel = requestNewChannel()
val outgoingChannel = requestNewChannel()
// returns the last Note of each user.
override fun feed(): List<Note> {
val messages = account.userProfile().messages[withUser]
return messages?.sortedBy { it.event!!.createdAt } ?: emptyList()
}
override fun updateChannelFilters() {
incomingChannel.filter = createMessagesToMeFilter()
outgoingChannel.filter = createMessagesFromMeFilter()
}
}
@@ -0,0 +1,38 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import nostr.postr.JsonFilter
import nostr.postr.events.PrivateDmEvent
object NostrChatroomListDataSource: NostrDataSource("MailBoxFeed") {
lateinit var account: Account
fun createMessagesToMeFilter() = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
)
fun createMessagesFromMeFilter() = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = listOf(account.userProfile().pubkeyHex)
)
val incomingChannel = requestNewChannel()
val outgoingChannel = requestNewChannel()
// returns the last Note of each user.
override fun feed(): List<Note> {
val messages = account.userProfile().messages
val messagingWith = messages.keys().toList()
return messagingWith.mapNotNull {
messages[it]?.sortedBy { it.event?.createdAt }?.last { it.event != null }
}.sortedBy { it.event?.createdAt }.reversed()
}
override fun updateChannelFilters() {
incomingChannel.filter = createMessagesToMeFilter()
outgoingChannel.filter = createMessagesFromMeFilter()
}
}
@@ -57,6 +57,10 @@ abstract class NostrDataSource(val debugName: String) {
resetFilters()
}*/
}
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
}
}
init {
@@ -29,11 +29,13 @@ object NostrHomeDataSource: NostrDataSource("HomeFeed") {
}
fun createFollowAccountsFilter(): JsonFilter? {
val follows = account.userProfile().follows?.map {
it.pubkey.toHex().substring(0, 6)
}
val follows = listOf(account.userProfile().pubkeyHex.substring(0, 6)).plus(
account.userProfile().follows?.map {
it.pubkey.toHex().substring(0, 6)
} ?: emptyList()
)
if (follows == null || follows.isEmpty()) return null
if (follows.isEmpty()) return null
return JsonFilter(
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind),
@@ -8,7 +8,7 @@ object NostrNotificationDataSource: NostrDataSource("GlobalFeed") {
lateinit var account: Account
fun createGlobalFilter() = JsonFilter(
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 7), // 2 days
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 7), // 7 days
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex).filterNotNull())
)
@@ -2,8 +2,11 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import java.util.Collections
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") {
val eventsToWatch = Collections.synchronizedList(mutableListOf<String>())
@@ -15,24 +18,35 @@ object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") {
return null
}
// downloads all the reactions to a given event.
return JsonFilter(
kinds = listOf(TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind),
tags = mapOf("e" to reactionsToWatch)
)
}
fun createLoadEventsIfNotLoadedFilter(): JsonFilter? {
val eventsToLoad = eventsToWatch
.map { LocalCache.notes[it] }
.filterNotNull()
val directEventsToLoad = eventsToWatch
.mapNotNull { LocalCache.notes[it] }
.filter { it.event == null }
val threadingEventsToLoad = eventsToWatch
.mapNotNull { LocalCache.notes[it] }
.mapNotNull { it.replyTo }
.flatten()
.filter { it.event == null }
val interestedEvents =
(directEventsToLoad + threadingEventsToLoad)
.map { it.idHex.substring(0, 8) }
if (eventsToLoad.isEmpty()) {
if (interestedEvents.isEmpty()) {
return null
}
// downloads linked events to this event.
return JsonFilter(
ids = eventsToLoad
ids = interestedEvents
)
}
@@ -0,0 +1,82 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import java.util.Collections
import nostr.postr.JsonFilter
object NostrThreadDataSource: NostrDataSource("SingleThreadFeed") {
val eventsToWatch = Collections.synchronizedList(mutableListOf<String>())
fun createRepliesAndReactionsFilter(): JsonFilter? {
val reactionsToWatch = eventsToWatch.map { it.substring(0, 8) }
if (reactionsToWatch.isEmpty()) {
return null
}
return JsonFilter(
tags = mapOf("e" to reactionsToWatch)
)
}
fun createLoadEventsIfNotLoadedFilter(): JsonFilter? {
val eventsToLoad = eventsToWatch
.map { LocalCache.notes[it] }
.filterNotNull()
.filter { it.event == null }
.map { it.idHex.substring(0, 8) }
if (eventsToLoad.isEmpty()) {
return null
}
return JsonFilter(
ids = eventsToLoad
)
}
val repliesAndReactionsChannel = requestNewChannel()
val loadEventsChannel = requestNewChannel()
override fun feed(): List<Note> {
return eventsToWatch.map {
LocalCache.notes[it]
}.filterNotNull()
}
override fun updateChannelFilters() {
repliesAndReactionsChannel.filter = createRepliesAndReactionsFilter()
loadEventsChannel.filter = createLoadEventsIfNotLoadedFilter()
}
fun loadThread(noteId: String) {
val note = LocalCache.notes[noteId] ?: return
val thread = mutableListOf<Note>()
val threadSet = mutableSetOf<Note>()
val threadRoot = note.replyTo?.firstOrNull() ?: note
loadDown(threadRoot, thread, threadSet)
// Currently orders by date of each event, descending, at each level of the reply stack
val order = compareByDescending<Note> { it.replyLevelSignature() }
eventsToWatch.clear()
eventsToWatch.addAll(thread.sortedWith(order).map { it.idHex })
resetFilters()
}
fun loadDown(note: Note, thread: MutableList<Note>, threadSet: MutableSet<Note>) {
if (note !in threadSet) {
thread.add(note)
threadSet.add(note)
note.replies.forEach {
loadDown(it, thread, threadSet)
}
}
}
}
@@ -85,6 +85,10 @@ object Client: RelayPool.Listener {
listeners.forEach { it.onRelayStateChange(type, relay) }
}
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
listeners.forEach { it.onSendResponse(eventId, success, message, relay) }
}
fun subscribe(listener: Listener) {
listeners.add(listener)
}
@@ -109,5 +113,10 @@ object Client: RelayPool.Listener {
* Connected to or disconnected from a relay
*/
open fun onRelayStateChange(type: Relay.Type, relay: Relay) = Unit
/**
* When an relay saves or rejects a new event.
*/
open fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) = Unit
}
}
@@ -59,8 +59,7 @@ class Relay(
it.onError(this@Relay, channel, Error("Relay sent notice: $channel"))
}
"OK" -> listeners.forEach {
// "channel" being the second string in the string array ...
// Event was saved correctly?
it.onSendResponse(this@Relay, msg[1].asString, msg[2].asBoolean, msg[3].asString)
}
else -> listeners.forEach {
it.onError(
@@ -155,6 +154,7 @@ class Relay(
fun onError(relay: Relay, subscriptionId: String, error: Error)
fun onSendResponse(relay: Relay, eventId: String, success: Boolean, message: String)
/**
* Connected to or disconnected from a relay
*
@@ -12,9 +12,12 @@ object RelayPool: Relay.Listener {
private val relays = Collections.synchronizedList(ArrayList<Relay>())
private val listeners = Collections.synchronizedSet(HashSet<Listener>())
fun report(): String {
val connected = relays.filter { it.isConnected() }
return "${connected.size}/${relays.size}"
fun availableRelays(): Int {
return relays.size
}
fun connectedRelays(): Int {
return relays.filter { it.isConnected() }.size
}
fun loadRelays(relayList: List<Relay>? = null){
@@ -79,6 +82,8 @@ object RelayPool: Relay.Listener {
fun onError(error: Error, subscriptionId: String, relay: Relay)
fun onRelayStateChange(type: Relay.Type, relay: Relay)
fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay)
}
@Synchronized
@@ -96,6 +101,10 @@ object RelayPool: Relay.Listener {
refreshObservers()
}
override fun onSendResponse(relay: Relay, eventId: String, success: Boolean, message: String) {
listeners.forEach { it.onSendResponse(eventId, success, message, relay) }
}
// Observers line up here.
val live: RelayPoolLiveData = RelayPoolLiveData(this)
@@ -10,11 +10,13 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.KeyStorage
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrNotificationDataSource
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
@@ -49,11 +51,13 @@ class MainActivity : ComponentActivity() {
override fun onPause() {
NostrAccountDataSource.stop()
NostrHomeDataSource.stop()
NostrChatroomListDataSource.stop()
NostrGlobalDataSource.stop()
NostrNotificationDataSource.stop()
NostrSingleEventDataSource.stop()
NostrSingleUserDataSource.stop()
NostrThreadDataSource.stop()
Client.disconnect()
super.onPause()
}
@@ -0,0 +1,57 @@
package com.vitorpamplona.amethyst.ui.actions
import android.graphics.Bitmap
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.util.UUID
import okhttp3.Call
import okhttp3.Callback
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
object ImageUploader {
private fun encodeImage(bitmap: Bitmap): ByteArray {
val byteArrayOutPutStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, byteArrayOutPutStream)
return byteArrayOutPutStream.toByteArray()
}
fun uploadImage(bitmap: Bitmap, onSuccess: (String) -> Unit) {
val client = OkHttpClient.Builder().build()
val body: RequestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
"image",
"${UUID.randomUUID()}.png",
encodeImage(bitmap).toRequestBody("image/png".toMediaType())
)
.build()
val request: Request = Request.Builder()
.url("https://api.imgur.com/3/image")
.header("Authorization", "Client-ID e6aea87296f3f96")
.post(body)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
val tree = jacksonObjectMapper().readTree(response.body!!.string())
val url = tree.get("data").get("link").asText()
onSuccess(url)
}
}
override fun onFailure(call: Call, e: IOException) {
e.printStackTrace()
}
})
}
}
@@ -17,48 +17,64 @@ import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.UrlPreview
import com.vitorpamplona.amethyst.ui.components.imageExtension
import com.vitorpamplona.amethyst.ui.navigation.UploadFromGallery
import kotlinx.coroutines.delay
import nostr.postr.events.TextNoteEvent
class PostViewModel: ViewModel() {
var account: Account? = null
var message by mutableStateOf("")
var replyingTo: Note? = null
fun sendPost() {
account?.sendPost(message, replyingTo)
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NewPostView(onClose: () -> Unit, replyingTo: Note? = null, account: Account) {
val postViewModel: PostViewModel = viewModel<PostViewModel>().apply {
val postViewModel: NewPostViewModel = viewModel<NewPostViewModel>().apply {
this.replyingTo = replyingTo
this.account = account
}
val dialogProperties = DialogProperties()
val context = LocalContext.current
// initialize focus reference to be able to request focus programmatically
val focusRequester = FocusRequester()
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
delay(100)
focusRequester.requestFocus()
}
Dialog(
onDismissRequest = { onClose() }, properties = dialogProperties
onDismissRequest = { onClose() },
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = false
)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.5f)
.fillMaxHeight()
) {
Column(
modifier = Modifier.padding(10.dp)
@@ -69,13 +85,21 @@ fun NewPostView(onClose: () -> Unit, replyingTo: Note? = null, account: Account)
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = onClose)
CloseButton(onCancel = {
postViewModel.cancel()
onClose()
})
UploadFromGallery {
postViewModel.upload(it, context)
}
PostButton(
onPost = {
postViewModel.sendPost()
onClose()
}
},
postViewModel.message.isNotBlank()
)
}
@@ -96,16 +120,26 @@ fun NewPostView(onClose: () -> Unit, replyingTo: Note? = null, account: Account)
OutlinedTextField(
value = postViewModel.message,
onValueChange = { postViewModel.message = it },
onValueChange = {
postViewModel.message = it
postViewModel.urlPreview = postViewModel.findUrlInMessage()
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
modifier = Modifier.fillMaxWidth().fillMaxHeight()
modifier = Modifier
.fillMaxWidth()
.border(
width = 1.dp,
color = MaterialTheme.colors.surface,
shape = RoundedCornerShape(8.dp)
),
)
.focusRequester(focusRequester)
.onFocusChanged {
if (it.isFocused) {
keyboardController?.show()
}
},
placeholder = {
Text(
text = "What's on your mind?",
@@ -117,15 +151,39 @@ fun NewPostView(onClose: () -> Unit, replyingTo: Note? = null, account: Account)
unfocusedBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent
)
)
val myUrlPreview = postViewModel.urlPreview
if (myUrlPreview != null) {
Column(modifier = Modifier.padding(top = 5.dp)) {
val removedParamsFromUrl = myUrlPreview.split("?")[0].toLowerCase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
AsyncImage(
model = myUrlPreview,
contentDescription = myUrlPreview,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
)
} else {
UrlPreview("https://$myUrlPreview", myUrlPreview, false)
}
}
}
}
}
}
}
@Composable
private fun CloseButton(onCancel: () -> Unit) {
fun CloseButton(onCancel: () -> Unit) {
Button(
onClick = {
onCancel()
@@ -141,15 +199,17 @@ private fun CloseButton(onCancel: () -> Unit) {
}
@Composable
private fun PostButton(onPost: () -> Unit = {}) {
fun PostButton(onPost: () -> Unit = {}, isActive: Boolean) {
Button(
onClick = {
onPost()
if (isActive) {
onPost()
}
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
)
) {
Text(text = "Post", color = Color.White)
@@ -0,0 +1,57 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
class NewPostViewModel: ViewModel() {
var account: Account? = null
var replyingTo: Note? = null
var message by mutableStateOf("")
var urlPreview by mutableStateOf<String?>(null)
fun sendPost() {
account?.sendPost(message, replyingTo)
message = ""
urlPreview = null
}
fun upload(it: Uri, context: Context) {
val img = if (Build.VERSION.SDK_INT < 28) {
MediaStore.Images.Media.getBitmap(context.contentResolver, it)
} else {
ImageDecoder.decodeBitmap(ImageDecoder.createSource(context.contentResolver, it))
}
img?.let {
ImageUploader.uploadImage(img) {
message = message + "\n\n" + it
urlPreview = findUrlInMessage()
}
}
}
fun cancel() {
message = ""
urlPreview = null
}
fun findUrlInMessage(): String? {
return message.split('\n').firstNotNullOfOrNull { paragraph ->
paragraph.split(' ').firstOrNull { word: String ->
isValidURL(word) || noProtocolUrlValidator.matcher(word).matches()
}
}
}
}
@@ -0,0 +1,84 @@
package com.vitorpamplona.amethyst.ui.navigation
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun UploadFromGallery(onImageChosen: (Uri) -> Unit) {
val cameraPermissionState = rememberPermissionState(
android.Manifest.permission.READ_MEDIA_IMAGES
)
if (cameraPermissionState.status.isGranted) {
var showGallerySelect by remember { mutableStateOf(false) }
if (showGallerySelect) {
GallerySelect(
onImageUri = { uri ->
showGallerySelect = false
if (uri != null)
onImageChosen(uri)
}
)
} else {
Box() {
Button(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(4.dp),
onClick = {
showGallerySelect = true
}
) {
Text("Add Image from Gallery")
}
}
}
} else {
Column {
Button(onClick = { cameraPermissionState.launchPermissionRequest() }) {
Text("Add Image from Gallery")
}
}
}
}
@Composable
fun GallerySelect(
onImageUri: (Uri?) -> Unit = { }
) {
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(),
onResult = { uri: Uri? ->
onImageUri(uri)
}
)
@Composable
fun LaunchGallery() {
SideEffect {
launcher.launch("image/*")
}
}
LaunchGallery()
}
@@ -0,0 +1,77 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.ui.actions.CloseButton
@Composable
@OptIn(ExperimentalComposeUiApi::class)
fun ExtendedImageView(word: String) {
// store the dialog open or close state
var dialogOpen by remember {
mutableStateOf(false)
}
AsyncImage(
model = word,
contentDescription = word,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
.clickable(
onClick = { dialogOpen = true }
)
)
if (dialogOpen) {
Dialog(
onDismissRequest = { dialogOpen = false },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
Column(
modifier = Modifier.padding(10.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
dialogOpen = false
})
}
ZoomableAsyncImage(word)
}
}
}
}
}
@@ -0,0 +1,99 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity
import com.vitorpamplona.amethyst.R
@Composable
fun InvoicePreview(lnInvoice: String) {
val amount = LnInvoiceUtil.getAmountInSats(lnInvoice)
val context = LocalContext.current
Column(modifier = Modifier
.fillMaxWidth()
.padding(start = 30.dp, end = 30.dp)
.clip(shape = RoundedCornerShape(10.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(30.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp)
) {
Icon(
painter = painterResource(R.drawable.lightning),
null,
modifier = Modifier.size(20.dp),
tint = Color.Unspecified
)
Text(
text = "Lightning Invoice",
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp)
)
}
Divider()
Text(
text = "${amount.toInt()} sats",
fontSize = 25.sp,
fontWeight = FontWeight.W500,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
)
Button(
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
onClick = {
runCatching {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning://$lnInvoice"))
startActivity(context, intent, null)
}
},
shape = RoundedCornerShape(15.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = "Pay", color = Color.White, fontSize = 20.sp)
}
}
}
}
@@ -0,0 +1,156 @@
package com.vitorpamplona.amethyst.ui.components
import java.math.BigDecimal
import java.util.Locale
import java.util.regex.Pattern
/** based on litecoinj */
object LnInvoiceUtil {
private val invoicePattern = Pattern.compile("lnbc((?<amount>\\d+)(?<multiplier>[munp])?)?1[^1\\s]+")
/** The Bech32 character set for encoding. */
private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
/** The Bech32 character set for decoding. */
private val CHARSET_REV = byteArrayOf(
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
15, -1, 10, 17, 21, 20, 26, 30, 7, 5, -1, -1, -1, -1, -1, -1,
-1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1,
1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1,
-1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1,
1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1
)
/** Find the polynomial with value coefficients mod the generator as 30-bit. */
private fun polymod(values: ByteArray): Int {
var c = 1
for (v_i in values) {
val c0 = c ushr 25 and 0xff
c = c and 0x1ffffff shl 5 xor (v_i.toInt() and 0xff)
if (c0 and 1 != 0) c = c xor 0x3b6a57b2
if (c0 and 2 != 0) c = c xor 0x26508e6d
if (c0 and 4 != 0) c = c xor 0x1ea119fa
if (c0 and 8 != 0) c = c xor 0x3d4233dd
if (c0 and 16 != 0) c = c xor 0x2a1462b3
}
return c
}
/** Expand a HRP for use in checksum computation. */
private fun expandHrp(hrp: String): ByteArray {
val hrpLength = hrp.length
val ret = ByteArray(hrpLength * 2 + 1)
for (i in 0 until hrpLength) {
val c = hrp[i].code and 0x7f // Limit to standard 7-bit ASCII
ret[i] = (c ushr 5 and 0x07).toByte()
ret[i + hrpLength + 1] = (c and 0x1f).toByte()
}
ret[hrpLength] = 0
return ret
}
/** Verify a checksum. */
private fun verifyChecksum(hrp: String, values: ByteArray): Boolean {
val hrpExpanded: ByteArray = expandHrp(hrp)
val combined = ByteArray(hrpExpanded.size + values.size)
System.arraycopy(hrpExpanded, 0, combined, 0, hrpExpanded.size)
System.arraycopy(values, 0, combined, hrpExpanded.size, values.size)
return polymod(combined) == 1
}
class AddressFormatException(message: String): Exception(message) {
}
fun decodeUnlimitedLength(invoice: String): Boolean {
var lower = false
var upper = false
for (i in 0 until invoice.length) {
val c = invoice[i]
if (c.code < 33 || c.code > 126) throw AddressFormatException("Invalid character: $c, pos: $i")
if (c in 'a'..'z') {
if (upper) throw AddressFormatException("Invalid character: $c, pos: $i")
lower = true
}
if (c in 'A'..'Z') {
if (lower) throw AddressFormatException("Invalid character: $c, pos: $i")
upper = true
}
}
val pos = invoice.lastIndexOf('1')
if (pos < 1) throw AddressFormatException("Missing human-readable part")
val dataPartLength = invoice.length - 1 - pos
if (dataPartLength < 6) throw AddressFormatException("Data part too short: $dataPartLength")
val values = ByteArray(dataPartLength)
for (i in 0 until dataPartLength) {
val c = invoice[i + pos + 1]
if (CHARSET_REV.get(c.code).toInt() == -1) throw AddressFormatException("Invalid character: " + c + ", pos: " + (i + pos + 1))
values[i] = CHARSET_REV.get(c.code)
}
val hrp = invoice.substring(0, pos).lowercase(Locale.ROOT)
if (!verifyChecksum(hrp, values)) throw AddressFormatException("Invalid Checksum")
return true
}
/**
* Parses invoice amount according to
* https://github.com/lightningnetwork/lightning-rfc/blob/master/11-payment-encoding.md#human-readable-part
* @return invoice amount in bitcoins, zero if the invoice has no amount
* @throws RuntimeException if invoice format is incorrect
*/
fun getAmount(invoice: String): BigDecimal {
try {
decodeUnlimitedLength(invoice) // checksum must match
} catch (e: AddressFormatException) {
throw IllegalArgumentException("Cannot decode invoice", e)
}
val matcher = invoicePattern.matcher(invoice)
require(matcher.matches()) { "Failed to match HRP pattern" }
val amountGroup = matcher.group("amount")
val multiplierGroup = matcher.group("multiplier")
if (amountGroup == null) {
return BigDecimal.ZERO
}
val amount = BigDecimal(amountGroup)
if (multiplierGroup == null) {
return amount
}
require(!(multiplierGroup == "p" && amountGroup[amountGroup.length - 1] != '0')) { "sub-millisatoshi amount" }
return amount.multiply(multiplier(multiplierGroup))
}
fun getAmountInSats(invoice: String): BigDecimal {
return getAmount(invoice).multiply(BigDecimal(100000000))
}
private fun multiplier(multiplier: String): BigDecimal {
return when (multiplier) {
"m" -> BigDecimal("0.001")
"u" -> BigDecimal("0.000001")
"n" -> BigDecimal("0.000000001")
"p" -> BigDecimal("0.000000000001")
else -> throw IllegalArgumentException("Invalid multiplier: $multiplier")
}
}
/**
* Finds LN invoice in the provided input string and returns it.
* For example for input = "aaa bbb lnbc1xxx ccc" it will return "lnbc1xxx"
* It will only return the first invoice found in the input.
*
* @return the invoice if it was found. null for null input or if no invoice is found
*/
fun findInvoice(input: String?): String? {
if (input == null) {
return null
}
val matcher = invoicePattern.matcher(input)
return if (matcher.find()) {
matcher.group()
} else null
}
}
@@ -1,30 +1,39 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.border
import android.util.Patterns
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.ui.note.toDisplayHex
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import java.net.MalformedURLException
import java.net.URISyntaxException
import java.net.URL
import java.util.regex.Pattern
val imageExtension = Pattern.compile("(.*/)*.+\\.(png|jpg|gif|bmp|jpeg|webp)$")
val videoExtension = Pattern.compile("(.*/)*.+\\.(mp4|avi|wmv|mpg|amv|webm)$")
val noProtocolUrlValidator = Pattern.compile("^[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&//=]*)$")
val tagIndex = Pattern.compile("\\#\\[([0-9]*)\\]")
val tagIndex = Pattern.compile(".*\\#\\[([0-9]+)\\].*")
val mentionsPattern: Pattern = Pattern.compile("@([A-Za-z0-9_-]+)")
val hashTagsPattern: Pattern = Pattern.compile("#([A-Za-z0-9_-]+)")
val urlPattern: Pattern = Patterns.WEB_URL
fun isValidURL(url: String?): Boolean {
return try {
@@ -37,8 +46,9 @@ fun isValidURL(url: String?): Boolean {
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun RichTextViewer(content: String, tags: List<List<String>>?) {
fun RichTextViewer(content: String, tags: List<List<String>>?, note: Note, accountViewModel: AccountViewModel, navController: NavController) {
Column(modifier = Modifier.padding(top = 5.dp)) {
// FlowRow doesn't work well with paragraphs. So we need to split them
content.split('\n').forEach { paragraph ->
@@ -46,26 +56,22 @@ fun RichTextViewer(content: String, tags: List<List<String>>?) {
FlowRow() {
paragraph.split(' ').forEach { word: String ->
// Explicit URL
if (isValidURL(word)) {
val lnInvoice = LnInvoiceUtil.findInvoice(word)
if (lnInvoice != null) {
InvoicePreview(lnInvoice)
} else if (isValidURL(word)) {
val removedParamsFromUrl = word.split("?")[0].toLowerCase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
AsyncImage(
model = word,
contentDescription = word,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
)
ExtendedImageView(word)
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
VideoView(word)
} else {
UrlPreview(word, word)
}
} else if (noProtocolUrlValidator.matcher(word).matches()) {
UrlPreview("https://$word", word)
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags)
TagLink(word, tags, navController)
} else {
Text(text = "$word ")
}
@@ -77,7 +83,7 @@ fun RichTextViewer(content: String, tags: List<List<String>>?) {
}
@Composable
fun TagLink(word: String, tags: List<List<String>>) {
fun TagLink(word: String, tags: List<List<String>>, navController: NavController) {
val matcher = tagIndex.matcher(word)
val index = try {
@@ -98,15 +104,17 @@ fun TagLink(word: String, tags: List<List<String>>) {
if (user != null) {
val innerUserState by user.live.observeAsState()
Text(
"${innerUserState?.user?.toBestDisplayName()}"
"@${innerUserState?.user?.toBestDisplayName()} "
)
}
} else if (tags[index][0] == "e") {
val note = LocalCache.notes[tags[index][1]]
if (note != null) {
val innerNoteState by note.live.observeAsState()
Text(
"${innerNoteState?.note?.idDisplayHex}"
ClickableText(
text = AnnotatedString("@${innerNoteState?.note?.id?.toNote()?.toDisplayHex()} "),
onClick = { navController.navigate("Note/${note.idHex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
}
} else
@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
@@ -19,6 +18,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
@@ -28,37 +28,34 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.baha.url.preview.BahaUrlPreview
import com.baha.url.preview.IUrlPreviewCallback
import com.baha.url.preview.UrlInfoItem
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun UrlPreview(url: String, urlText: String) {
fun UrlPreview(url: String, urlText: String, showUrlIfError: Boolean = true) {
var urlPreviewState by remember { mutableStateOf<UrlPreviewState>(UrlPreviewState.Loading) }
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
LaunchedEffect(urlPreviewState) {
if (urlPreviewState == UrlPreviewState.Loading) {
val urlPreview = BahaUrlPreview(url, object : IUrlPreviewCallback {
override fun onComplete(urlInfo: UrlInfoItem) {
if (urlInfo.allFetchComplete() && urlInfo.url == url)
urlPreviewState = UrlPreviewState.Loaded(urlInfo)
else
urlPreviewState = UrlPreviewState.Empty
}
override fun onFailed(throwable: Throwable) {
urlPreviewState = UrlPreviewState.Error("Error parsing preview for ${url}: ${throwable.message}")
}
})
urlPreview.fetchUrlPreview()
}
}
val uri = LocalUriHandler.current
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
LaunchedEffect(url) {
UrlCachedPreviewer.previewInfo(url, object : IUrlPreviewCallback {
override fun onComplete(urlInfo: UrlInfoItem) {
if (urlInfo.allFetchComplete() && urlInfo.url == url)
urlPreviewState = UrlPreviewState.Loaded(urlInfo)
else
urlPreviewState = UrlPreviewState.Empty
}
override fun onFailed(throwable: Throwable) {
urlPreviewState = UrlPreviewState.Error("Error parsing preview for ${url}: ${throwable.message}")
}
})
}
Crossfade(targetState = urlPreviewState) { state ->
when (state) {
is UrlPreviewState.Loaded -> {
@@ -97,11 +94,13 @@ fun UrlPreview(url: String, urlText: String) {
}
}
else -> {
ClickableText(
text = AnnotatedString("$urlText "),
onClick = { runCatching { uri.openUri(url) } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
)
if (showUrlIfError) {
ClickableText(
text = AnnotatedString("$urlText "),
onClick = { runCatching { uri.openUri(url) } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
)
}
}
}
}
@@ -0,0 +1,48 @@
package com.vitorpamplona.amethyst.ui.components
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
import com.google.android.exoplayer2.ui.StyledPlayerView
@Composable
fun VideoView(videoUri: String) {
val context = LocalContext.current
val exoPlayer = ExoPlayer.Builder(LocalContext.current).build().apply {
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
setMediaItem(MediaItem.fromUri(videoUri))
prepare()
}
DisposableEffect(exoPlayer) {
onDispose {
exoPlayer.release()
}
}
AndroidView(
modifier = Modifier
.fillMaxWidth(),
factory = {
StyledPlayerView(context).apply {
player = exoPlayer
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
}
})
}
@@ -0,0 +1,60 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.foundation.gestures.forEachGesture
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import coil.compose.AsyncImage
@Composable
fun ZoomableAsyncImage(imageUrl: String) {
var scale by remember { mutableStateOf(1f) }
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.pointerInput(Unit) {
forEachGesture {
awaitPointerEventScope {
awaitFirstDown()
do {
val event = awaitPointerEvent()
scale *= event.calculateZoom()
val offset = event.calculatePan()
offsetX += offset.x
offsetY += offset.y
} while (event.changes.any { it.pressed })
}
}
}
) {
AsyncImage(
model = imageUrl,
contentDescription = "Profile Image",
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxSize().graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offsetX,
translationY = offsetY
),
)
}
}
@@ -13,7 +13,7 @@ fun AppNavigation(
) {
NavHost(navController, startDestination = Route.Home.route) {
Routes.forEach {
composable(it.route, content = it.buildScreen(accountViewModel))
composable(it.route, it.arguments, content = it.buildScreen(accountViewModel, navController))
}
}
}
@@ -53,7 +53,8 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
val accountUser = accountUserState?.user
val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() }
val relayPoolLiveData by relayViewModel.relayPoolLiveData.observeAsState()
val connectedRelaysLiveData by relayViewModel.connectedRelaysLiveData.observeAsState()
val availableRelaysLiveData by relayViewModel.availableRelaysLiveData.observeAsState()
val coroutineScope = rememberCoroutineScope()
@@ -105,8 +106,8 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
},
actions = {
Text(
relayPoolLiveData ?: "--/--",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
"${connectedRelaysLiveData ?: "--"}/${availableRelaysLiveData ?: "--"}",
color = if (connectedRelaysLiveData == 0) Color.Red else MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
IconButton(
@@ -16,7 +16,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.ScaffoldState
import androidx.compose.material.Surface
@@ -118,11 +117,11 @@ fun ProfileContent(accountUser: User?, modifier: Modifier = Modifier) {
Text(" @${accountUser?.bestUsername()}", color = Color.LightGray)
Row(modifier = Modifier.padding(top = 15.dp)) {
Row() {
Text("${accountUser?.follows?.size}", fontWeight = FontWeight.Bold)
Text("${accountUser?.follows?.size ?: "--"}", fontWeight = FontWeight.Bold)
Text(" Following")
}
Row(modifier = Modifier.padding(start = 10.dp)) {
Text("${accountUser?.follower ?: "--"}", fontWeight = FontWeight.Bold)
Text("${accountUser?.followers?.size ?: "--"}", fontWeight = FontWeight.Bold)
Text(" Followers")
}
}
@@ -2,32 +2,49 @@ package com.vitorpamplona.amethyst.ui.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.navigation.NamedNavArgument
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.navArgument
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.ChatroomListScreen
import com.vitorpamplona.amethyst.ui.screen.ChatroomScreen
import com.vitorpamplona.amethyst.ui.screen.HomeScreen
import com.vitorpamplona.amethyst.ui.screen.MessageScreen
import com.vitorpamplona.amethyst.ui.screen.NotificationScreen
import com.vitorpamplona.amethyst.ui.screen.ProfileScreen
import com.vitorpamplona.amethyst.ui.screen.SearchScreen
import com.vitorpamplona.amethyst.ui.screen.ThreadScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
sealed class Route(
val route: String,
val icon: Int,
val buildScreen: (AccountViewModel) -> @Composable (NavBackStackEntry) -> Unit
val arguments: List<NamedNavArgument> = emptyList(),
val buildScreen: (AccountViewModel, NavController) -> @Composable (NavBackStackEntry) -> Unit
) {
object Home : Route("Home", R.drawable.ic_home, { acc -> { _ -> HomeScreen(acc) } })
object Search : Route("Search", R.drawable.ic_search, { acc -> { _ -> SearchScreen(acc) }})
object Notification : Route("Notification", R.drawable.ic_notifications, { acc -> { _ -> NotificationScreen(acc) }})
object Message : Route("Message", R.drawable.ic_dm, { acc -> { _ -> MessageScreen(acc) }})
object Profile : Route("Profile", R.drawable.ic_profile, { acc -> { _ -> ProfileScreen(acc) }})
object Lists : Route("Lists", R.drawable.ic_lists, { acc -> { _ -> ProfileScreen(acc) }})
object Topics : Route("Topics", R.drawable.ic_topics, { acc -> { _ -> ProfileScreen(acc) }})
object Bookmarks : Route("Bookmarks", R.drawable.ic_bookmarks, { acc -> { _ -> ProfileScreen(acc) }})
object Moments : Route("Moments", R.drawable.ic_moments, { acc -> { _ -> ProfileScreen(acc) }})
object Home : Route("Home", R.drawable.ic_home, buildScreen = { acc, nav -> { _ -> HomeScreen(acc, nav) } })
object Search : Route("Search", R.drawable.ic_search, buildScreen = { acc, nav -> { _ -> SearchScreen(acc, nav) }})
object Notification : Route("Notification", R.drawable.ic_notifications,buildScreen = { acc, nav -> { _ -> NotificationScreen(acc, nav) }})
object Message : Route("Message", R.drawable.ic_dm, buildScreen = { acc, nav -> { _ -> ChatroomListScreen(acc, nav) }})
object Profile : Route("Profile", R.drawable.ic_profile, buildScreen = { acc, nav -> { _ -> ProfileScreen(acc) }})
object Lists : Route("Lists", R.drawable.ic_lists, buildScreen = { acc, nav -> { _ -> ProfileScreen(acc) }})
object Topics : Route("Topics", R.drawable.ic_topics, buildScreen = { acc, nav -> { _ -> ProfileScreen(acc) }})
object Bookmarks : Route("Bookmarks", R.drawable.ic_bookmarks, buildScreen = { acc, nav -> { _ -> ProfileScreen(acc) }})
object Moments : Route("Moments", R.drawable.ic_moments, buildScreen = { acc, nav -> { _ -> ProfileScreen(acc) }})
object Note : Route("Note/{id}", R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType } ),
buildScreen = { acc, nav -> { ThreadScreen(it.arguments?.getString("id"), acc, nav) }}
)
object Room : Route("Room/{id}", R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType } ),
buildScreen = { acc, nav -> { ChatroomScreen(it.arguments?.getString("id"), acc, nav) }}
)
}
val Routes = listOf(
@@ -42,7 +59,11 @@ val Routes = listOf(
Route.Lists,
Route.Topics,
Route.Bookmarks,
Route.Moments
Route.Moments,
//inner
Route.Note,
Route.Room
)
@Composable
@@ -12,7 +12,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Composable
fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean) {
fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false) {
Column(modifier = modifier) {
Row(modifier = Modifier.padding(horizontal = if (!isQuote) 12.dp else 6.dp)) {
Column(modifier = Modifier.padding(start = if (!isQuote) 10.dp else 5.dp)) {
@@ -1,9 +1,5 @@
package com.vitorpamplona.amethyst.ui.note
import android.text.format.DateUtils
import android.text.format.DateUtils.getRelativeTimeSpanString
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -12,10 +8,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -24,30 +17,29 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.screen.BoostSetCard
import com.vitorpamplona.amethyst.ui.screen.LikeSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import nostr.postr.events.TextNoteEvent
@Composable
fun BoostSetCompose(likeSetCard: BoostSetCard, modifier: Modifier = Modifier, isInnerNote: Boolean = false, accountViewModel: AccountViewModel) {
fun BoostSetCompose(likeSetCard: BoostSetCard, isInnerNote: Boolean = false, accountViewModel: AccountViewModel, navController: NavController) {
val noteState by likeSetCard.note.live.observeAsState()
val note = noteState?.note
if (note?.event == null) {
BlankNote(modifier, isInnerNote)
BlankNote(Modifier, isInnerNote)
} else {
Column(modifier = modifier) {
Row(modifier = Modifier.padding(horizontal = if (!isInnerNote) 12.dp else 0.dp)) {
Column() {
Row(modifier = Modifier
.padding(
start = if (!isInnerNote) 12.dp else 0.dp,
end = if (!isInnerNote) 12.dp else 0.dp,
top = 10.dp)
) {
// Draws the like picture outside the boosted card.
if (!isInnerNote) {
@@ -84,7 +76,7 @@ fun BoostSetCompose(likeSetCard: BoostSetCard, modifier: Modifier = Modifier, is
}
}
NoteCompose(note, modifier = Modifier.padding(top = 5.dp), isInnerNote = true, accountViewModel = accountViewModel)
NoteCompose(note, Modifier.padding(top = 5.dp), true, accountViewModel, navController)
}
}
}
@@ -0,0 +1,98 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) {
val noteState by baseNote.live.observeAsState()
val note = noteState?.note
val accountUserState by accountViewModel.userLiveData.observeAsState()
val accountUser = accountUserState?.user
if (note?.event == null) {
BlankNote(Modifier)
} else {
val authorState by note.author!!.live.observeAsState()
val author = authorState?.user
val replyAuthorBase = note.mentions?.first()
var userToComposeOn = author
if ( replyAuthorBase != null ) {
val replyAuthorState by replyAuthorBase.live.observeAsState()
val replyAuthor = replyAuthorState?.user
if (author == accountUser) {
userToComposeOn = replyAuthor
}
}
Column(modifier =
Modifier.clickable(
onClick = { navController.navigate("Room/${userToComposeOn?.pubkeyHex}") }
)
) {
Row(
modifier = Modifier
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp)
) {
AsyncImage(
model = userToComposeOn?.profilePicture(),
contentDescription = "Profile Image",
modifier = Modifier
.width(55.dp)
.clip(shape = CircleShape)
)
Column(modifier = Modifier.padding(start = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (userToComposeOn != null)
UserDisplay(userToComposeOn)
Text(
timeAgo(note.event?.createdAt),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
val eventContent = accountViewModel.decrypt(note)
if (eventContent != null)
RichTextViewer(eventContent.take(100), note.event?.tags, note, accountViewModel, navController)
else
RichTextViewer("Referenced event not found", note.event?.tags, note, accountViewModel, navController)
}
}
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
}
}
}
@@ -0,0 +1,120 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
val ChatBubbleShapeMe = RoundedCornerShape(20.dp, 20.dp, 3.dp, 20.dp)
val ChatBubbleShapeThem = RoundedCornerShape(20.dp, 20.dp, 20.dp, 3.dp)
@Composable
fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) {
val noteState by baseNote.live.observeAsState()
val note = noteState?.note
val accountUserState by accountViewModel.userLiveData.observeAsState()
val accountUser = accountUserState?.user
if (note?.event == null) {
BlankNote(Modifier)
} else {
val authorState by note.author!!.live.observeAsState()
val author = authorState?.user
Column(modifier =
Modifier.clickable(
onClick = { navController.navigate("User/${note.idHex}") }
)
) {
var backgroundBubbleColor: Color
var alignment: Arrangement.Horizontal
var shape: Shape
if (author == accountUser) {
backgroundBubbleColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
alignment = Arrangement.End
shape = ChatBubbleShapeMe
} else {
backgroundBubbleColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f)
alignment = Arrangement.Start
shape = ChatBubbleShapeThem
}
Row(
horizontalArrangement = alignment,
modifier = Modifier.fillMaxWidth()
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Surface(
color = backgroundBubbleColor,
shape = shape
) {
Column(
modifier = Modifier.padding(10.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
val eventContent = accountViewModel.decrypt(note)
if (eventContent != null)
RichTextViewer(
eventContent,
note.event?.tags,
note,
accountViewModel,
navController
)
else
RichTextViewer(
"Could Not decrypt the message",
note.event?.tags,
note,
accountViewModel,
navController
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = alignment
) {
Text(
timeAgoLong(note.event?.createdAt),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
}
}
}
}
}
}
}
@@ -1,9 +1,5 @@
package com.vitorpamplona.amethyst.ui.note
import android.text.format.DateUtils
import android.text.format.DateUtils.getRelativeTimeSpanString
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -12,10 +8,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -24,29 +17,29 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.screen.LikeSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import nostr.postr.events.TextNoteEvent
@Composable
fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isInnerNote: Boolean = false, accountViewModel: AccountViewModel) {
fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isInnerNote: Boolean = false, accountViewModel: AccountViewModel, navController: NavController) {
val noteState by likeSetCard.note.live.observeAsState()
val note = noteState?.note
if (note?.event == null) {
BlankNote(modifier, isInnerNote)
BlankNote(Modifier, isInnerNote)
} else {
Column(modifier = modifier) {
Row(modifier = Modifier.padding(horizontal = if (!isInnerNote) 12.dp else 0.dp)) {
Row( modifier = Modifier
.padding(
start = if (!isInnerNote) 12.dp else 0.dp,
end = if (!isInnerNote) 12.dp else 0.dp,
top = 10.dp)
) {
// Draws the like picture outside the boosted card.
if (!isInnerNote) {
@@ -83,7 +76,7 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isIn
}
}
NoteCompose(note, modifier = Modifier.padding(top = 5.dp), isInnerNote = true, accountViewModel = accountViewModel)
NoteCompose(note, Modifier.padding(top = 5.dp), true, accountViewModel, navController)
}
}
}
@@ -1,9 +1,9 @@
package com.vitorpamplona.amethyst.ui.note
import android.text.format.DateUtils
import android.text.format.DateUtils.getRelativeTimeSpanString
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -11,37 +11,61 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Divider
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.MaterialTheme
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import nostr.postr.events.TextNoteEvent
import nostr.postr.toNpub
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Boolean = false, accountViewModel: AccountViewModel) {
fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Boolean = false, accountViewModel: AccountViewModel, navController: NavController) {
val noteState by baseNote.live.observeAsState()
val note = noteState?.note
var popupExpanded by remember { mutableStateOf(false) }
if (note?.event == null) {
BlankNote(modifier, isInnerNote)
} else {
val authorState by note.author!!.live.observeAsState()
val author = authorState?.user
Column(modifier = modifier) {
Row(modifier = Modifier.padding(horizontal = if (!isInnerNote) 12.dp else 0.dp)) {
Column(modifier =
modifier.combinedClickable(
onClick = { navController.navigate("Note/${note.idHex}") },
onLongClick = { popupExpanded = true },
)
) {
Row(
modifier = Modifier
.padding(
start = if (!isInnerNote) 12.dp else 0.dp,
end = if (!isInnerNote) 12.dp else 0.dp,
top = 10.dp)
) {
// Draws the boosted picture outside the boosted card.
if (!isInnerNote) {
@@ -78,7 +102,7 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
if (note.event !is RepostEvent) {
Text(
" " + timeAgo(note.event?.createdAt),
timeAgo(note.event?.createdAt),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
} else {
@@ -100,7 +124,8 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
note,
modifier = Modifier.padding(top = 5.dp),
isInnerNote = true,
accountViewModel = accountViewModel
accountViewModel = accountViewModel,
navController = navController
)
}
@@ -116,17 +141,43 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
} else {
val eventContent = note.event?.content
if (eventContent != null)
RichTextViewer(eventContent, note.event?.tags)
RichTextViewer(eventContent, note.event?.tags, note, accountViewModel, navController)
ReactionsRowState(note, accountViewModel)
Divider(
modifier = Modifier.padding(vertical = 10.dp),
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
}
NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
}
}
}
}
}
@Composable
fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, accountViewModel: AccountViewModel) {
val clipboardManager = LocalClipboardManager.current
DropdownMenu(
expanded = popupExpanded,
onDismissRequest = onDismiss
) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.event?.content ?: "")); onDismiss() }) {
Text("Copy Text")
}
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.author?.pubkey?.toNpub() ?: "")); onDismiss() }) {
Text("Copy User ID")
}
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.id.toNote())); onDismiss() }) {
Text("Copy Note ID")
}
Divider()
DropdownMenuItem(onClick = { accountViewModel.broadcast(note); onDismiss() }) {
Text("Broadcast")
}
}
}
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -35,7 +36,7 @@ fun ReactionsRow(note: Note, account: Account, boost: (Note) -> Unit, reactTo: (
NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, account)
Row(modifier = Modifier.padding(top = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { if (account.isWriteable()) wantsToReplyTo = note }
@@ -14,5 +14,25 @@ fun timeAgo(mills: Long?): String {
if (humanReadable.startsWith("In") || humanReadable.startsWith("0")) {
humanReadable = "now";
}
return "" + humanReadable
.replace(" hr. ago", "h")
.replace(" min. ago", "m")
.replace(" days. ago", "d")
}
fun timeAgoLong(mills: Long?): String {
if (mills == null) return " "
var humanReadable = DateUtils.getRelativeTimeSpanString(
mills * 1000,
System.currentTimeMillis(),
DateUtils.MINUTE_IN_MILLIS,
DateUtils.FORMAT_SHOW_TIME
).toString()
if (humanReadable.startsWith("In") || humanReadable.startsWith("0")) {
humanReadable = "now";
}
return humanReadable
}
@@ -9,14 +9,21 @@ import com.vitorpamplona.amethyst.model.User
@Composable
fun UserDisplay(user: User) {
if (user.bestUsername() != null || user.bestDisplayName() != null) {
Text(
user.bestDisplayName() ?: "",
fontWeight = FontWeight.Bold,
)
Text(
"@${(user.bestUsername() ?: "")}",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
if (user.bestDisplayName().isNullOrBlank()) {
Text(
"@${(user.bestUsername() ?: "")}",
fontWeight = FontWeight.Bold,
)
} else {
Text(
user.bestDisplayName() ?: "",
fontWeight = FontWeight.Bold,
)
Text(
"@${(user.bestUsername() ?: "")}",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
}
} else {
Text(
user.pubkeyDisplayHex,
@@ -5,11 +5,13 @@ import androidx.security.crypto.EncryptedSharedPreferences
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrNotificationDataSource
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import fr.acinq.secp256k1.Hex
import java.util.regex.Pattern
import kotlinx.coroutines.flow.MutableStateFlow
@@ -58,6 +60,7 @@ class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPre
NostrAccountDataSource.account = loggedIn
NostrHomeDataSource.account = loggedIn
NostrNotificationDataSource.account = loggedIn
NostrChatroomListDataSource.account = loggedIn
NostrAccountDataSource.start()
NostrGlobalDataSource.start()
@@ -65,6 +68,8 @@ class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPre
NostrNotificationDataSource.start()
NostrSingleEventDataSource.start()
NostrSingleUserDataSource.start()
NostrThreadDataSource.start()
NostrChatroomListDataSource.start()
}
fun newKey() {
@@ -1,28 +1,21 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Button
import androidx.compose.material.OutlinedButton
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.vitorpamplona.amethyst.ui.note.BoostSetCompose
@@ -32,7 +25,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
fun CardFeedView(viewModel: CardFeedViewModel, accountViewModel: AccountViewModel) {
fun CardFeedView(viewModel: CardFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
var isRefreshing by remember { mutableStateOf(false) }
@@ -76,9 +69,9 @@ fun CardFeedView(viewModel: CardFeedViewModel, accountViewModel: AccountViewMode
) {
itemsIndexed(state.feed) { index, item ->
when (item) {
is NoteCard -> NoteCompose(item.note, isInnerNote = false, accountViewModel = accountViewModel)
is LikeSetCard -> LikeSetCompose(item, isInnerNote = false, accountViewModel = accountViewModel)
is BoostSetCard -> BoostSetCompose(item, isInnerNote = false, accountViewModel = accountViewModel)
is NoteCard -> NoteCompose(item.note, isInnerNote = false, accountViewModel = accountViewModel, navController = navController)
is LikeSetCard -> LikeSetCompose(item, isInnerNote = false, accountViewModel = accountViewModel, navController = navController)
is BoostSetCard -> BoostSetCompose(item, isInnerNote = false, accountViewModel = accountViewModel, navController = navController)
}
}
}
@@ -0,0 +1,85 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
fun ChatroomFeedView(userId: String, viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
var isRefreshing by remember { mutableStateOf(false) }
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
val listState = rememberLazyListState()
LaunchedEffect(isRefreshing) {
if (isRefreshing) {
viewModel.refresh()
isRefreshing = false
}
}
SwipeRefresh(
state = swipeRefreshState,
onRefresh = {
isRefreshing = true
},
) {
Column() {
Crossfade(targetState = feedState) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty {
isRefreshing = true
}
}
is FeedState.FeedError -> {
FeedError(state.errorMessage) {
isRefreshing = true
}
}
is FeedState.Loaded -> {
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
state = listState
) {
var previousDate: String = ""
itemsIndexed(state.feed, key = { _, item -> item.idHex }) { index, item ->
ChatroomMessageCompose(item, accountViewModel = accountViewModel, navController = navController)
}
}
LaunchedEffect(Unit) {
listState.animateScrollToItem(state.feed.size-1, 0)
}
}
FeedState.Loading -> {
LoadingFeed()
}
}
}
}
}
}
@@ -0,0 +1,80 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.vitorpamplona.amethyst.ui.note.ChatroomCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
fun ChatroomListFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
var isRefreshing by remember { mutableStateOf(false) }
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
val listState = rememberLazyListState()
LaunchedEffect(isRefreshing) {
if (isRefreshing) {
viewModel.refresh()
isRefreshing = false
}
}
SwipeRefresh(
state = swipeRefreshState,
onRefresh = {
isRefreshing = true
},
) {
Column() {
Crossfade(targetState = feedState) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty {
isRefreshing = true
}
}
is FeedState.FeedError -> {
FeedError(state.errorMessage) {
isRefreshing = true
}
}
is FeedState.Loaded -> {
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
state = listState
) {
itemsIndexed(state.feed, key = { _, item -> item.idHex }) { index, item ->
ChatroomCompose(item, accountViewModel = accountViewModel, navController = navController)
}
}
}
FeedState.Loading -> {
LoadingFeed()
}
}
}
}
}
}
@@ -23,6 +23,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.vitorpamplona.amethyst.ui.note.NoteCompose
@@ -30,7 +31,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel) {
fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
var isRefreshing by remember { mutableStateOf(false) }
@@ -73,7 +74,7 @@ fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel) {
state = listState
) {
itemsIndexed(state.feed, key = { _, item -> item.idHex }) { index, item ->
NoteCompose(item, isInnerNote = false, accountViewModel = accountViewModel)
NoteCompose(item, isInnerNote = false, accountViewModel = accountViewModel, navController = navController)
}
}
}
@@ -132,57 +133,4 @@ fun FeedEmpty(onRefresh: () -> Unit) {
Text(text = "Refresh")
}
}
}
// Bosted code to be deleted:
/*
Boosted By: removed because it was ugly
if (item.event is RepostEvent) {
Row(
modifier = Modifier.padding(
start = 12.dp,
end = 12.dp,
bottom = 8.dp
),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(R.drawable.ic_retweet),
null,
modifier = Modifier.size(20.dp),
tint = Color.Gray
)
Text(
text = "Boosted by ${item.author.toBestDisplayName()}",
modifier = Modifier.padding(start = 10.dp),
fontWeight = FontWeight.Bold,
color = Color.Gray,
)
}
val refNote = item.replyTo.firstOrNull()
if (refNote != null) {
NoteCompose(index, refNote)
} else {
Row(
modifier = Modifier.padding(
start = 40.dp,
end = 40.dp,
bottom = 25.dp,
top = 15.dp
),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Could not find referenced event",
modifier = Modifier.padding(30.dp),
color = Color.Gray,
)
}
}
} else {
NoteCompose(index, item)
}*/
}
@@ -6,7 +6,6 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.LocalCacheState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrDataSource
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -6,7 +6,10 @@ import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.service.relays.RelayPool
class RelayPoolViewModel: ViewModel() {
val relayPoolLiveData: LiveData<String> = Transformations.map(RelayPool.live) {
it.relays.report()
val connectedRelaysLiveData: LiveData<Int> = Transformations.map(RelayPool.live) {
it.relays.connectedRelays()
}
val availableRelaysLiveData: LiveData<Int> = Transformations.map(RelayPool.live) {
it.relays.availableRelays()
}
}
@@ -0,0 +1,209 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.ReactionsRowState
import com.vitorpamplona.amethyst.ui.note.UserDisplay
import com.vitorpamplona.amethyst.ui.note.timeAgoLong
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
var isRefreshing by remember { mutableStateOf(false) }
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
val listState = rememberLazyListState()
LaunchedEffect(isRefreshing) {
if (isRefreshing) {
viewModel.refresh()
isRefreshing = false
}
}
SwipeRefresh(
state = swipeRefreshState,
onRefresh = {
isRefreshing = true
},
) {
Column() {
Crossfade(targetState = feedState) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty {
isRefreshing = true
}
}
is FeedState.FeedError -> {
FeedError(state.errorMessage) {
isRefreshing = true
}
}
is FeedState.Loaded -> {
var noteIdPositionInThread by remember { mutableStateOf(0) }
// only in the first transition
LaunchedEffect(noteIdPositionInThread) {
listState.animateScrollToItem(noteIdPositionInThread, 0)
}
val notePosition = state.feed.filter { it.idHex == noteId}.firstOrNull()
if (notePosition != null) {
noteIdPositionInThread = state.feed.indexOf(notePosition)
}
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
state = listState
) {
itemsIndexed(state.feed, key = { _, item -> item.idHex }) { index, item ->
if (index == 0)
NoteMaster(item, accountViewModel = accountViewModel, navController = navController)
else {
Column() {
Row() {
NoteCompose(
item,
Modifier.drawReplyLevel(item.replyLevel(), MaterialTheme.colors.onSurface.copy(alpha = 0.32f)),
isInnerNote = false,
accountViewModel = accountViewModel,
navController = navController,
)
}
}
}
}
}
}
FeedState.Loading -> {
LoadingFeed()
}
}
}
}
}
}
// Creates a Zebra pattern where each bar is a reply level.
fun Modifier.drawReplyLevel(level: Int, color: Color): Modifier = this
.drawBehind {
val paddingDp = 2
val strokeWidthDp = 2
val levelWidthDp = strokeWidthDp + 1
val padding = paddingDp.dp.toPx()
val strokeWidth = strokeWidthDp.dp.toPx()
val levelWidth = levelWidthDp.dp.toPx()
repeat(level) {
this.drawLine(
color,
Offset(padding + it * levelWidth, 0f),
Offset(padding + it * levelWidth, size.height),
strokeWidth = strokeWidth
)
}
return@drawBehind
}
.padding(start = (2 + (level * 3)).dp)
@Composable
fun NoteMaster(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) {
val noteState by baseNote.live.observeAsState()
val note = noteState?.note
if (note?.event == null) {
BlankNote()
} else {
val authorState by note.author!!.live.observeAsState()
val author = authorState?.user
Column(
Modifier
.fillMaxWidth()
.padding(top = 10.dp)) {
Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp)) {
// Draws the boosted picture outside the boosted card.
AsyncImage(
model = author?.profilePicture(),
contentDescription = "Profile Image",
modifier = Modifier
.width(55.dp)
.clip(shape = CircleShape)
)
Column(modifier = Modifier.padding(start = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (author != null)
UserDisplay(author)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
timeAgoLong(note.event?.createdAt),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
}
}
Row(modifier = Modifier.padding(horizontal = 12.dp)) {
Column() {
val eventContent = note.event?.content
if (eventContent != null)
RichTextViewer(eventContent, note.event?.tags, note, accountViewModel, navController)
ReactionsRowState(note, accountViewModel)
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
}
}
}
}
}
@@ -19,4 +19,12 @@ class AccountViewModel(private val account: Account): ViewModel() {
fun boost(note: Note) {
account.boost(note)
}
fun broadcast(note: Note) {
account.broadcast(note)
}
fun decrypt(note: Note): String? {
return account.decryptContent(note)
}
}
@@ -0,0 +1,32 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavController) {
val account by accountViewModel.accountLiveData.observeAsState()
if (account != null) {
val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrChatroomListDataSource ) }
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
ChatroomListFeedView(feedViewModel, accountViewModel, navController)
}
}
}
}
@@ -0,0 +1,131 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrChatRoomDataSource
import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.note.UserDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account
if (account != null && userId != null) {
val newPost = remember { mutableStateOf(TextFieldValue("")) }
NostrChatRoomDataSource.loadMessagesBetween(account, userId)
val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrChatRoomDataSource ) }
Column(Modifier.fillMaxHeight()) {
NostrChatRoomDataSource.withUser?.let {
ChatroomHeader(
it,
accountViewModel = accountViewModel,
navController = navController
)
}
Column(
modifier = Modifier.fillMaxHeight().padding(vertical = 0.dp).weight(1f, true)
) {
ChatroomFeedView(userId, feedViewModel, accountViewModel, navController)
}
//LAST ROW
Row(modifier = Modifier.padding(10.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = newPost.value,
onValueChange = { newPost.value = it },
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
modifier = Modifier.weight(1f, true).padding(end = 10.dp),
placeholder = {
Text(
text = "reply here.. ",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
)
PostButton(
onPost = {
account.sendPrivateMeesage(newPost.value.text, userId)
newPost.value = TextFieldValue("")
},
newPost.value.text.isNotBlank()
)
}
}
}
}
@Composable
fun ChatroomHeader(baseUser: User, accountViewModel: AccountViewModel, navController: NavController) {
val authorState by baseUser.live.observeAsState()
val author = authorState?.user
Column(modifier =
Modifier
.padding(12.dp)
//.clickable(
//onClick = { navController.navigate("User/${author?.pubkeyHex}") }
//)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
AsyncImage(
model = author?.profilePicture(),
contentDescription = "Profile Image",
modifier = Modifier
.width(35.dp)
.clip(shape = CircleShape)
)
Column(modifier = Modifier.padding(start = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (author != null)
UserDisplay(author)
}
}
}
Divider(
modifier = Modifier.padding(top = 12.dp, start = 12.dp, end = 12.dp),
thickness = 0.25.dp
)
}
}
@@ -10,12 +10,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
fun HomeScreen(accountViewModel: AccountViewModel) {
fun HomeScreen(accountViewModel: AccountViewModel, navController: NavController) {
val account by accountViewModel.accountLiveData.observeAsState()
if (account != null) {
@@ -25,7 +26,7 @@ fun HomeScreen(accountViewModel: AccountViewModel) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
FeedView(feedViewModel, accountViewModel)
FeedView(feedViewModel, accountViewModel, navController)
}
}
}
@@ -1,29 +0,0 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.Text
import androidx.compose.material.rememberScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun MessageScreen(accountViewModel: AccountViewModel) {
val state = rememberScaffoldState()
val scope = rememberCoroutineScope()
Column(
Modifier
.fillMaxHeight()
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text("Message Screen")
}
}
@@ -10,12 +10,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.service.NostrNotificationDataSource
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
fun NotificationScreen(accountViewModel: AccountViewModel) {
fun NotificationScreen(accountViewModel: AccountViewModel, navController: NavController) {
val account by accountViewModel.accountLiveData.observeAsState()
if (account != null) {
@@ -26,7 +27,7 @@ fun NotificationScreen(accountViewModel: AccountViewModel) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
CardFeedView(feedViewModel, accountViewModel = accountViewModel)
CardFeedView(feedViewModel, accountViewModel = accountViewModel, navController)
}
}
}
@@ -8,19 +8,20 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
fun SearchScreen(accountViewModel: AccountViewModel) {
fun SearchScreen(accountViewModel: AccountViewModel, navController: NavController) {
val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrGlobalDataSource ) }
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
FeedView(feedViewModel, accountViewModel)
FeedView(feedViewModel, accountViewModel, navController)
}
}
}
@@ -0,0 +1,33 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun ThreadScreen(noteId: String?, accountViewModel: AccountViewModel, navController: NavController) {
val account by accountViewModel.accountLiveData.observeAsState()
if (account != null && noteId != null) {
NostrThreadDataSource.loadThread(noteId)
val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrThreadDataSource ) }
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
ThreadFeedView(noteId, feedViewModel, accountViewModel, navController)
}
}
}
}
@@ -22,7 +22,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
+12
View File
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="280dp"
android:height="280dp"
android:viewportWidth="280"
android:viewportHeight="280">
<path
android:pathData="M7,140.5C7,66.77 66.77,7 140.5,7C214.23,7 274,66.77 274,140.5C274,214.23 214.23,274 140.5,274C66.77,274 7,214.23 7,140.5Z"
android:fillColor="#f7931a"/>
<path
android:pathData="M161.19,51.5C153.23,72.16 145.28,94.41 135.72,116.66C135.72,116.66 135.72,119.84 138.91,119.84L204.17,119.84C204.17,119.84 204.17,121.43 205.77,123.02L110.25,229.5C108.66,227.91 108.66,226.32 108.66,224.73L142.09,153.21L142.09,146.86L75.23,146.86L75.23,140.5L156.42,51.5L161.19,51.5Z"
android:fillColor="#ffffff"/>
</vector>
@@ -1,9 +1,8 @@
package com.vitorpamplona.amethyst
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*