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) { fun consume(event: TextNoteEvent, relay: Relay? = null) {
val note = getOrCreateNote(event.id.toHex()) val note = getOrCreateNote(event.id.toHex())
val author = getOrCreateUser(event.pubKey)
if (relay != null) if (relay != null)
note.addRelay(relay) author.addRelay(relay, event.createdAt)
// Already processed this event. // Already processed this event.
if (note.event != null) return if (note.event != null) return
val author = getOrCreateUser(event.pubKey)
val mentions = Collections.synchronizedList(event.mentions.map { getOrCreateUser(decodePublicKey(it)) }) val mentions = Collections.synchronizedList(event.mentions.map { getOrCreateUser(decodePublicKey(it)) })
val replyTo = Collections.synchronizedList(event.replyTos.map { getOrCreateNote(it) }.toMutableList()) val replyTo = Collections.synchronizedList(event.replyTos.map { getOrCreateNote(it) }.toMutableList())

View File

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

View File

@ -40,6 +40,7 @@ class User(val pubkey: ByteArray) {
val messages = ConcurrentHashMap<User, MutableSet<Note>>() val messages = ConcurrentHashMap<User, MutableSet<Note>>()
val reports = Collections.synchronizedSet(mutableSetOf<Note>()) val reports = Collections.synchronizedSet(mutableSetOf<Note>())
val relaysBeingUsed = mutableMapOf<String, RelayInfo>()
fun toBestDisplayName(): String { fun toBestDisplayName(): String {
return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex
@ -120,6 +121,26 @@ class User(val pubkey: ByteArray) {
updateSubscribers { it.onNewMessage() } 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) { fun updateFollows(newFollows: Set<User>, updateAt: Long) {
val toBeAdded = synchronized(follows) { val toBeAdded = synchronized(follows) {
newFollows - follows newFollows - follows
@ -191,6 +212,7 @@ class User(val pubkey: ByteArray) {
open fun onFollowsChange() = Unit open fun onFollowsChange() = Unit
open fun onNewPosts() = Unit open fun onNewPosts() = Unit
open fun onNewMessage() = Unit open fun onNewMessage() = Unit
open fun onNewRelayInfo() = Unit
open fun onNewReports() = Unit open fun onNewReports() = Unit
} }

View File

@ -60,7 +60,7 @@ import java.lang.Math.round
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
fun NewRelayListView(onClose: () -> Unit, account: Account) { fun NewRelayListView(onClose: () -> Unit, account: Account, relayToAdd: String = "") {
val postViewModel: NewRelayListViewModel = viewModel() val postViewModel: NewRelayListViewModel = viewModel()
val feedState by postViewModel.relays.collectAsState() val feedState by postViewModel.relays.collectAsState()
@ -131,7 +131,7 @@ fun NewRelayListView(onClose: () -> Unit, account: Account) {
Spacer(modifier = Modifier.height(10.dp)) Spacer(modifier = Modifier.height(10.dp))
EditableServerConfig() { EditableServerConfig(relayToAdd) {
postViewModel.addRelay(it) postViewModel.addRelay(it)
} }
} }
@ -295,8 +295,8 @@ fun ServerConfig(
} }
@Composable @Composable
fun EditableServerConfig(onNewRelay: (NewRelayListViewModel.Relay) -> Unit) { fun EditableServerConfig(relayToAdd: String, onNewRelay: (NewRelayListViewModel.Relay) -> Unit) {
var url by remember { mutableStateOf<String>("") } var url by remember { mutableStateOf<String>(relayToAdd) }
var read by remember { mutableStateOf(true) } var read by remember { mutableStateOf(true) }
var write 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 { fun timeAgo(mills: Long?): String {
if (mills == null) return " " if (mills == null) return " "
if (mills == 0L) return " • never"
var humanReadable = DateUtils.getRelativeTimeSpanString( var humanReadable = DateUtils.getRelativeTimeSpanString(
mills * 1000, 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.NostrUserProfileFollowsDataSource
import com.vitorpamplona.amethyst.service.model.ReportEvent import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.ui.actions.NewChannelView 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.actions.NewUserMetadataView
import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@ -144,7 +145,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
selected = pagerState.currentPage == 1, selected = pagerState.currentPage == 1,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } }, onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } },
text = { 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, selected = pagerState.currentPage == 2,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } }, onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } },
text = { 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) { when (pagerState.currentPage) {
0 -> TabNotes(user, accountViewModel, navController) 0 -> TabNotes(user, accountViewModel, navController)
1 -> TabFollows(user, accountViewModel, navController) 1 -> TabFollows(user, accountViewModel, navController)
2 -> TabFollowers(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 @Composable
private fun NSecCopyButton( private fun NSecCopyButton(
account: Account account: Account