diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 4f1766124..399aedf0c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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()) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index d5d03abab..e70832230 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -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()) - fun loadEvent(event: Event, author: User, mentions: List, replyTo: MutableList) { 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 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt index 58992b735..1915868f7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -40,6 +40,7 @@ class User(val pubkey: ByteArray) { val messages = ConcurrentHashMap>() val reports = Collections.synchronizedSet(mutableSetOf()) + val relaysBeingUsed = mutableMapOf() 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, 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 } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt index 3fef5db61..1f735b432 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt @@ -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("") } +fun EditableServerConfig(relayToAdd: String, onNewRelay: (NewRelayListViewModel.Relay) -> Unit) { + var url by remember { mutableStateOf(relayToAdd) } var read by remember { mutableStateOf(true) } var write by remember { mutableStateOf(true) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt new file mode 100644 index 000000000..c4f10f686 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt @@ -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")) +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt index 6e80b29ec..55483454b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt @@ -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, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt new file mode 100644 index 000000000..334a19da5 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt @@ -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 { it.lastEvent }.thenByDescending { it.counter }.thenBy { it.url } + + private val _feedContent = MutableStateFlow>(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 } + ) + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index ecd41cf3b..7567db739 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -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