mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-01 12:36:14 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2462b957a3 | ||
|
|
a8dd13e13b | ||
|
|
aa11bf212a | ||
|
|
f580fdd216 | ||
|
|
d130a43358 | ||
|
|
353046b451 | ||
|
|
5859ae2c52 | ||
|
|
66cfa9201c | ||
|
|
380c2e67cc | ||
|
|
0001ae441f |
+2
-2
@@ -11,8 +11,8 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 4
|
||||
versionName "0.4"
|
||||
versionCode 5
|
||||
versionName "0.5"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.baha.url.preview.UrlInfoItem
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||
@@ -115,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 {
|
||||
@@ -138,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) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.baha.url.preview.UrlInfoItem
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.ui.note.toDisplayHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
@@ -9,6 +8,10 @@ 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) {
|
||||
@@ -97,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 +31,7 @@ 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 * 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,7 +18,9 @@ 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)
|
||||
)
|
||||
}
|
||||
@@ -39,6 +44,7 @@ object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") {
|
||||
return null
|
||||
}
|
||||
|
||||
// downloads linked events to this event.
|
||||
return JsonFilter(
|
||||
ids = interestedEvents
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.google.android.exoplayer2.util.Util
|
||||
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
|
||||
@@ -51,6 +51,7 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onPause() {
|
||||
NostrAccountDataSource.stop()
|
||||
NostrHomeDataSource.stop()
|
||||
NostrChatroomListDataSource.stop()
|
||||
|
||||
NostrGlobalDataSource.stop()
|
||||
NostrNotificationDataSource.stop()
|
||||
|
||||
@@ -18,10 +18,6 @@ import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextFieldDefaults
|
||||
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.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -40,15 +36,10 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.TagLink
|
||||
import com.vitorpamplona.amethyst.ui.components.UrlPreview
|
||||
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.tagIndex
|
||||
import com.vitorpamplona.amethyst.ui.navigation.UploadFromGallery
|
||||
import kotlinx.coroutines.delay
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
@@ -107,7 +98,8 @@ fun NewPostView(onClose: () -> Unit, replyingTo: Note? = null, account: Account)
|
||||
onPost = {
|
||||
postViewModel.sendPost()
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
postViewModel.message.isNotBlank()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -191,7 +183,7 @@ fun NewPostView(onClose: () -> Unit, replyingTo: Note? = null, account: Account)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CloseButton(onCancel: () -> Unit) {
|
||||
fun CloseButton(onCancel: () -> Unit) {
|
||||
Button(
|
||||
onClick = {
|
||||
onCancel()
|
||||
@@ -207,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)
|
||||
|
||||
@@ -7,7 +7,6 @@ import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
|
||||
@@ -20,7 +20,6 @@ import androidx.compose.ui.unit.dp
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.google.accompanist.permissions.shouldShowRationale
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,8 @@ package com.vitorpamplona.amethyst.ui.components
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.util.Patterns
|
||||
import androidx.compose.foundation.border
|
||||
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
|
||||
@@ -26,7 +29,7 @@ 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_-]+)")
|
||||
@@ -43,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 ->
|
||||
@@ -58,16 +62,7 @@ fun RichTextViewer(content: String, tags: List<List<String>>?) {
|
||||
} 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 {
|
||||
@@ -76,7 +71,7 @@ fun RichTextViewer(content: String, tags: List<List<String>>?) {
|
||||
} 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 ")
|
||||
}
|
||||
@@ -88,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 {
|
||||
@@ -109,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,12 +28,12 @@ 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, showUrlIfError: Boolean = true) {
|
||||
var urlPreviewState by remember { mutableStateOf<UrlPreviewState>(UrlPreviewState.Loading) }
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.media.browse.MediaBrowser
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
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.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.google.android.exoplayer2.C
|
||||
import com.google.android.exoplayer2.ExoPlayer
|
||||
@@ -27,7 +20,6 @@ fun VideoView(videoUri: String) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val exoPlayer = ExoPlayer.Builder(LocalContext.current).build().apply {
|
||||
playWhenReady = true
|
||||
repeatMode = Player.REPEAT_MODE_ALL
|
||||
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
|
||||
setMediaItem(MediaItem.fromUri(videoUri))
|
||||
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,13 @@ 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.ThreadScreen
|
||||
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
|
||||
|
||||
|
||||
@@ -28,7 +29,7 @@ sealed class Route(
|
||||
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 -> { _ -> MessageScreen(acc) }})
|
||||
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) }})
|
||||
@@ -39,6 +40,11 @@ sealed class Route(
|
||||
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(
|
||||
@@ -56,7 +62,8 @@ val Routes = listOf(
|
||||
Route.Moments,
|
||||
|
||||
//inner
|
||||
Route.Note
|
||||
Route.Note,
|
||||
Route.Room
|
||||
)
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -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,20 +17,13 @@ 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, isInnerNote: Boolean = false, accountViewModel: AccountViewModel, navController: 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,19 +17,13 @@ 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, navController: NavController) {
|
||||
|
||||
+1
-5
@@ -1,10 +1,8 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
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.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -29,10 +27,8 @@ 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.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.toNote
|
||||
@@ -145,7 +141,7 @@ 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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -18,6 +18,7 @@ fun timeAgo(mills: Long?): String {
|
||||
return " • " + humanReadable
|
||||
.replace(" hr. ago", "h")
|
||||
.replace(" min. ago", "m")
|
||||
.replace(" days. ago", "d")
|
||||
}
|
||||
|
||||
fun timeAgoLong(mills: Long?): String {
|
||||
|
||||
@@ -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,6 +5,7 @@ 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
|
||||
@@ -59,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()
|
||||
@@ -67,6 +69,7 @@ class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPre
|
||||
NostrSingleEventDataSource.start()
|
||||
NostrSingleUserDataSource.start()
|
||||
NostrThreadDataSource.start()
|
||||
NostrChatroomListDataSource.start()
|
||||
}
|
||||
|
||||
fun newKey() {
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,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,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()
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,25 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import android.content.res.Resources.Theme
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.background
|
||||
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.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
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.foundation.shape.GenericShape
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedButton
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.rememberScaffoldState
|
||||
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.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -40,9 +27,6 @@ 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.graphics.Shape
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.platform.debugInspectorInfo
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -50,17 +34,14 @@ 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.LocalCache
|
||||
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.timeAgo
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAgoLong
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalLifecycleComposeApi::class)
|
||||
@Composable
|
||||
@@ -213,7 +194,7 @@ fun NoteMaster(baseNote: Note, accountViewModel: AccountViewModel, navController
|
||||
Column() {
|
||||
val eventContent = note.event?.content
|
||||
if (eventContent != null)
|
||||
RichTextViewer(eventContent, note.event?.tags)
|
||||
RichTextViewer(eventContent, note.event?.tags, note, accountViewModel, navController)
|
||||
|
||||
ReactionsRowState(note, accountViewModel)
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AccountState
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.UserState
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
|
||||
class AccountViewModel(private val account: Account): ViewModel() {
|
||||
val accountLiveData: LiveData<AccountState> = Transformations.map(account.live) { it }
|
||||
@@ -22,8 +21,10 @@ class AccountViewModel(private val account: Account): ViewModel() {
|
||||
}
|
||||
|
||||
fun broadcast(note: Note) {
|
||||
note.event?.let {
|
||||
Client.send(it)
|
||||
}
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user