User Relay information in Profiles

This commit is contained in:
Vitor Pamplona 2023-01-30 22:05:48 -03:00
parent 3c52ff6e8d
commit d94f35de0e
8 changed files with 323 additions and 20 deletions

View File

@ -98,13 +98,14 @@ object LocalCache {
fun consume(event: TextNoteEvent, relay: Relay? = null) {
val note = getOrCreateNote(event.id.toHex())
val author = getOrCreateUser(event.pubKey)
if (relay != null)
note.addRelay(relay)
author.addRelay(relay, event.createdAt)
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey)
val mentions = Collections.synchronizedList(event.mentions.map { getOrCreateUser(decodePublicKey(it)) })
val replyTo = Collections.synchronizedList(event.replyTos.map { getOrCreateNote(it) }.toMutableList())

View File

@ -1,7 +1,5 @@
package com.vitorpamplona.amethyst.model
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.ui.note.toShortenHex
@ -10,7 +8,6 @@ import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Collections
import java.util.Date
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -40,8 +37,6 @@ class Note(val idHex: String) {
var channel: Channel? = null
val relays = Collections.synchronizedSet(mutableSetOf<Relay>())
fun loadEvent(event: Event, author: User, mentions: List<User>, replyTo: MutableList<Note>) {
this.event = event
this.author = author
@ -93,11 +88,6 @@ class Note(val idHex: String) {
invalidateData(liveReactions)
}
fun addRelay(relay: Relay) {
if (relays.add(relay))
invalidateData(liveRelays)
}
fun addReport(note: Note) {
if (reports.add(note))
invalidateData(liveReports)
@ -134,7 +124,6 @@ class Note(val idHex: String) {
val liveBoosts: NoteLiveData = NoteLiveData(this)
val liveReplies: NoteLiveData = NoteLiveData(this)
val liveReports: NoteLiveData = NoteLiveData(this)
val liveRelays: NoteLiveData = NoteLiveData(this)
// Refreshes observers in batches.
var handlerWaiting = false

View File

@ -40,6 +40,7 @@ class User(val pubkey: ByteArray) {
val messages = ConcurrentHashMap<User, MutableSet<Note>>()
val reports = Collections.synchronizedSet(mutableSetOf<Note>())
val relaysBeingUsed = mutableMapOf<String, RelayInfo>()
fun toBestDisplayName(): String {
return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex
@ -120,6 +121,26 @@ class User(val pubkey: ByteArray) {
updateSubscribers { it.onNewMessage() }
}
data class RelayInfo (
val url: String,
var lastEvent: Long,
var counter: Long
)
fun addRelay(relay: Relay, eventTime: Long) {
val here = relaysBeingUsed.get(relay.url)
if (here == null) {
relaysBeingUsed.put(relay.url, RelayInfo(relay.url, eventTime, 1) )
} else {
if (eventTime > here.lastEvent) {
here.lastEvent = eventTime
}
here.counter++
}
updateSubscribers { it.onNewRelayInfo() }
}
fun updateFollows(newFollows: Set<User>, updateAt: Long) {
val toBeAdded = synchronized(follows) {
newFollows - follows
@ -191,6 +212,7 @@ class User(val pubkey: ByteArray) {
open fun onFollowsChange() = Unit
open fun onNewPosts() = Unit
open fun onNewMessage() = Unit
open fun onNewRelayInfo() = Unit
open fun onNewReports() = Unit
}

View File

@ -60,7 +60,7 @@ import java.lang.Math.round
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NewRelayListView(onClose: () -> Unit, account: Account) {
fun NewRelayListView(onClose: () -> Unit, account: Account, relayToAdd: String = "") {
val postViewModel: NewRelayListViewModel = viewModel()
val feedState by postViewModel.relays.collectAsState()
@ -131,7 +131,7 @@ fun NewRelayListView(onClose: () -> Unit, account: Account) {
Spacer(modifier = Modifier.height(10.dp))
EditableServerConfig() {
EditableServerConfig(relayToAdd) {
postViewModel.addRelay(it)
}
}
@ -295,8 +295,8 @@ fun ServerConfig(
}
@Composable
fun EditableServerConfig(onNewRelay: (NewRelayListViewModel.Relay) -> Unit) {
var url by remember { mutableStateOf<String>("") }
fun EditableServerConfig(relayToAdd: String, onNewRelay: (NewRelayListViewModel.Relay) -> Unit) {
var url by remember { mutableStateOf<String>(relayToAdd) }
var read by remember { mutableStateOf(true) }
var write by remember { mutableStateOf(true) }

View File

@ -0,0 +1,130 @@
package com.vitorpamplona.amethyst.ui.note
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.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
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.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Composable
fun RelayCompose(
relay: User.RelayInfo,
accountViewModel: AccountViewModel,
navController: NavController,
onAddRelay: () -> Unit,
onRemoveRelay: () -> Unit
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val ctx = LocalContext.current.applicationContext
Column() {
Row(
modifier = Modifier
.padding(start = 12.dp, end = 12.dp, top = 10.dp)
) {
//UserPicture(user, navController, account.userProfile(), 55.dp)
Column(modifier = Modifier
.padding(start = 10.dp)
.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
relay.url.trim().removePrefix("wss://"),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
timeAgo(relay.lastEvent),
maxLines = 1
)
}
Text(
"${relay.counter} events received",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Column(modifier = Modifier.padding(start = 10.dp)) {
if (account.activeRelays()?.filter { it.url == relay.url }?.isEmpty() == true) {
AddRelayButton { onAddRelay() }
} else {
RemoveRelayButton {
onRemoveRelay()
}
}
}
}
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
}
}
@Composable
fun AddRelayButton(onClick: () -> Unit) {
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = onClick,
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = "Add", color = Color.White)
}
}
@Composable
fun RemoveRelayButton(onClick: () -> Unit) {
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = onClick,
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = "Remove", color = Color.White)
}
}
fun formattedDateTime(timestamp: Long): String {
return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("MMM d, uuuu hh:mm a"))
}

View File

@ -4,6 +4,7 @@ import android.text.format.DateUtils
fun timeAgo(mills: Long?): String {
if (mills == null) return " "
if (mills == 0L) return " • never"
var humanReadable = DateUtils.getRelativeTimeSpanString(
mills * 1000,

View File

@ -0,0 +1,130 @@
package com.vitorpamplona.amethyst.ui.screen
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.collectAsState
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.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavController
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
import com.vitorpamplona.amethyst.ui.note.RelayCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class RelayFeedViewModel: ViewModel() {
val order = compareByDescending<User.RelayInfo> { it.lastEvent }.thenByDescending { it.counter }.thenBy { it.url }
private val _feedContent = MutableStateFlow<List<User.RelayInfo>>(emptyList())
val feedContent = _feedContent.asStateFlow()
var currentUser: User? = null
fun refresh() {
val beingUsed = currentUser?.relaysBeingUsed?.values?.toList() ?: emptyList()
val beingUsedSet = currentUser?.relaysBeingUsed?.keys ?: emptySet()
val newRelaysFromRecord = currentUser?.relays?.entries?.mapNotNull {
if (it.key !in beingUsedSet) {
User.RelayInfo(it.key, 0, 0)
} else {
null
}
} ?: emptyList()
viewModelScope.launch {
withContext(Dispatchers.Main) {
_feedContent.update { (beingUsed + newRelaysFromRecord).sortedWith(order) }
}
}
}
inner class CacheListener: User.Listener() {
override fun onNewRelayInfo() { refresh() }
override fun onRelayChange() { refresh() }
}
val listener = CacheListener()
fun subscribeTo(user: User) {
currentUser = user
user.subscribe(listener)
refresh()
}
fun unsubscribeTo(user: User) {
user.unsubscribe(listener)
currentUser = null
}
}
@Composable
fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val feedState by viewModel.feedContent.collectAsState()
var isRefreshing by remember { mutableStateOf(false) }
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
var wantsToAddRelay by remember {
mutableStateOf( "")
}
if (wantsToAddRelay.isNotEmpty())
NewRelayListView({ wantsToAddRelay = "" }, account, wantsToAddRelay)
LaunchedEffect(isRefreshing) {
if (isRefreshing) {
viewModel.refresh()
isRefreshing = false
}
}
SwipeRefresh(
state = swipeRefreshState,
onRefresh = {
isRefreshing = true
},
) {
Column() {
val listState = rememberLazyListState()
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
state = listState
) {
itemsIndexed(feedState, key = { _, item -> item.url }) { index, item ->
RelayCompose(item,
accountViewModel = accountViewModel,
navController = navController,
onAddRelay = { wantsToAddRelay = item.url },
onRemoveRelay = { wantsToAddRelay = item.url }
)
}
}
}
}
}

View File

@ -78,6 +78,7 @@ import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@ -144,7 +145,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
selected = pagerState.currentPage == 1,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } },
text = {
Text(text = "${user.follows?.size ?: "--"} Following")
Text(text = "${user.follows?.size ?: "--"} Follows")
}
)
@ -152,15 +153,24 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
selected = pagerState.currentPage == 2,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } },
text = {
Text(text = "${user.followers?.size ?: "--"} Followers")
Text(text = "${user.followers?.size ?: "--"} Follower")
}
)
Tab(
selected = pagerState.currentPage == 3,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(3) } },
text = {
Text(text = "${user.relaysBeingUsed.size ?: "--"} / ${user.relays?.size ?: "--"} Relays")
}
)
}
HorizontalPager(count = 3, state = pagerState) {
HorizontalPager(count = 4, state = pagerState) {
when (pagerState.currentPage) {
0 -> TabNotes(user, accountViewModel, navController)
1 -> TabFollows(user, accountViewModel, navController)
2 -> TabFollowers(user, accountViewModel, navController)
3 -> TabRelays(user, accountViewModel, navController)
}
}
}
@ -344,6 +354,26 @@ fun TabFollowers(user: User, accountViewModel: AccountViewModel, navController:
}
}
@Composable
fun TabRelays(user: User, accountViewModel: AccountViewModel, navController: NavController) {
val feedViewModel: RelayFeedViewModel = viewModel()
DisposableEffect(key1 = user) {
feedViewModel.subscribeTo(user)
onDispose {
feedViewModel.unsubscribeTo(user)
}
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
RelayFeedView(feedViewModel, accountViewModel, navController)
}
}
}
@Composable
private fun NSecCopyButton(
account: Account