From 1812ec296ed42327a8e54ae0f8957f0f8c9da72e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 15 Mar 2023 17:02:49 -0400 Subject: [PATCH] Hashtags --- README.md | 1 + .../service/NostrHashtagDataSource.kt | 38 ++++++ .../amethyst/service/model/Event.kt | 4 + .../amethyst/service/model/EventInterface.kt | 2 + .../amethyst/service/model/TextNoteEvent.kt | 4 + .../amethyst/ui/components/RichTextViewer.kt | 33 +++++- .../amethyst/ui/dal/HashtagFeedFilter.kt | 39 +++++++ .../amethyst/ui/navigation/AppNavigation.kt | 11 ++ .../amethyst/ui/navigation/AppTopBar.kt | 2 + .../amethyst/ui/navigation/Routes.kt | 6 + .../amethyst/ui/screen/FeedViewModel.kt | 2 + .../ui/screen/loggedIn/HashtagScreen.kt | 109 ++++++++++++++++++ .../ui/screen/loggedIn/SearchScreen.kt | 69 ++++++++++- 13 files changed, 317 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt diff --git a/README.md b/README.md index ff038543..e31320ea 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Amethyst brings the best social network to your Android phone. Just insert your - [x] Online Relay Search (NIP-50) - [x] Internationalization - [x] Badges (NIP-58) +- [x] Hashtags - [ ] Local Database - [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post - [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt new file mode 100644 index 00000000..d3fe1e4c --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt @@ -0,0 +1,38 @@ +package com.vitorpamplona.amethyst.service + +import androidx.compose.ui.text.capitalize +import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent +import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent +import com.vitorpamplona.amethyst.service.model.TextNoteEvent +import com.vitorpamplona.amethyst.service.relays.FeedType +import com.vitorpamplona.amethyst.service.relays.JsonFilter +import com.vitorpamplona.amethyst.service.relays.TypedFilter + +object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") { + private var hashtagToWatch: String? = null + + fun createLoadHashtagFilter(): TypedFilter? { + val hashToLoad = hashtagToWatch ?: return null + + return TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + tags = mapOf("t" to listOf(hashToLoad, hashToLoad.lowercase(), hashToLoad.uppercase(), hashToLoad.capitalize())), + kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind), + limit = 200 + ) + ) + } + + val loadHashtagChannel = requestNewChannel() + + override fun updateChannelFilters() { + loadHashtagChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null } + } + + fun loadHashtag(tag: String?) { + hashtagToWatch = tag + + invalidateFilters() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt index c6190f38..e9cbeff1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt @@ -39,8 +39,12 @@ open class Event( fun taggedUsers() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } + fun hashtags() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) } + override fun isTaggedUser(idHex: String) = tags.any { it.getOrNull(0) == "p" && it.getOrNull(1) == idHex } + override fun isTaggedHash(hashtag: String) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1).equals(hashtag, true) } + /** * Checks if the ID is correct and then if the pubKey's secret key signed the event. */ diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt index c3015376..1412054b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt @@ -24,4 +24,6 @@ interface EventInterface { fun hasValidSignature(): Boolean fun isTaggedUser(loggedInUser: String): Boolean + + fun isTaggedHash(hashtag: String): Boolean } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt index 2a9b6085..fa0755ac 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service.model import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey +import com.vitorpamplona.amethyst.ui.screen.loggedIn.findHashtags import nostr.postr.Utils import java.util.Date @@ -29,6 +30,9 @@ class TextNoteEvent( addresses?.forEach { tags.add(listOf("a", it.toTag())) } + findHashtags(msg).forEach { + tags.add(listOf("t", it)) + } val id = generateId(pubKey, createdAt, kind, tags, msg) val sig = Utils.sign(id, privateKey) return TextNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey()) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index a1fc4505..29681a8a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -8,6 +8,7 @@ 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 @@ -17,6 +18,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily @@ -46,8 +48,8 @@ val videoExtension = Pattern.compile("(.*/)*.+\\.(mp4|avi|wmv|mpg|amv|webm|mov)$ 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 mentionsPattern: Pattern = Pattern.compile("@([A-Za-z0-9_-]+)") -val hashTagsPattern: Pattern = Pattern.compile("#([A-Za-z0-9_-]+)") +val mentionsPattern: Pattern = Pattern.compile("@([A-Za-z0-9_\\-]+)") +val hashTagsPattern: Pattern = Pattern.compile("#([A-Za-z0-9_\\-]+)") val urlPattern: Pattern = Patterns.WEB_URL fun isValidURL(url: String?): Boolean { @@ -144,6 +146,8 @@ fun RichTextViewer( UrlPreview("https://$word", word) } else if (tagIndex.matcher(word).matches() && tags != null) { TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController) + } else if (hashTagsPattern.matcher(word).matches()) { + HashTag(word, accountViewModel, navController) } else if (isBechLink(word)) { BechLink(word, navController) } else { @@ -163,6 +167,8 @@ fun RichTextViewer( ClickableUrl(word, "https://$word") } else if (tagIndex.matcher(word).matches() && tags != null) { TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController) + } else if (hashTagsPattern.matcher(word).matches()) { + HashTag(word, accountViewModel, navController) } else if (isBechLink(word)) { BechLink(word, navController) } else { @@ -212,6 +218,29 @@ fun BechLink(word: String, navController: NavController) { } } +@Composable +fun HashTag(word: String, accountViewModel: AccountViewModel, navController: NavController) { + val hashtagMatcher = hashTagsPattern.matcher(word) + + val tag = try { + hashtagMatcher.find() + hashtagMatcher.group(1) + } catch (e: Exception) { + println("Couldn't link hashtag $word") + null + } + + if (tag != null) { + ClickableText( + text = AnnotatedString("#$tag "), + onClick = { navController.navigate("Hashtag/$tag") }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary) + ) + } else { + Text(text = "$word ") + } +} + @Composable fun TagLink(word: String, tags: List>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) { val matcher = tagIndex.matcher(word) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt new file mode 100644 index 00000000..844505ff --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt @@ -0,0 +1,39 @@ +package com.vitorpamplona.amethyst.ui.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent +import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent +import com.vitorpamplona.amethyst.service.model.PrivateDmEvent +import com.vitorpamplona.amethyst.service.model.TextNoteEvent + +object HashtagFeedFilter : FeedFilter() { + lateinit var account: Account + var tag: String? = null + + override fun feed(): List { + val myTag = tag ?: return emptyList() + + return LocalCache.notes.values + .asSequence() + .filter { + ( + it.event is TextNoteEvent || + it.event is LongTextNoteEvent || + it.event is ChannelMessageEvent || + it.event is PrivateDmEvent + ) && + it.event?.isTaggedHash(myTag) == true + } + .filter { account.isAcceptable(it) } + .sortedBy { it.createdAt() } + .toList() + .reversed() + } + + fun loadHashtag(account: Account, tag: String?) { + this.account = account + this.tag = tag + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 48dca0bc..321ad9cf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -20,6 +20,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.FiltersScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.HashtagScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.HomeScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.NotificationScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen @@ -94,6 +95,16 @@ fun AppNavigation( }) } + Route.Hashtag.let { route -> + composable(route.route, route.arguments, content = { + HashtagScreen( + tag = it.arguments?.getString("id"), + accountViewModel = accountViewModel, + navController = navController + ) + }) + } + Route.Room.let { route -> composable(route.route, route.arguments, content = { ChatroomScreen( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt index 60bf0cc4..c2395f4d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt @@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.service.NostrChannelDataSource import com.vitorpamplona.amethyst.service.NostrChatroomDataSource import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource import com.vitorpamplona.amethyst.service.NostrGlobalDataSource +import com.vitorpamplona.amethyst.service.NostrHashtagDataSource import com.vitorpamplona.amethyst.service.NostrHomeDataSource import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource @@ -138,6 +139,7 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) NostrSingleChannelDataSource.printCounter() NostrSingleUserDataSource.printCounter() NostrThreadDataSource.printCounter() + NostrHashtagDataSource.printCounter() NostrUserProfileDataSource.printCounter() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index 2d9b4522..2fc3bdd3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -65,6 +65,12 @@ sealed class Route( arguments = listOf(navArgument("id") { type = NavType.StringType }) ) + object Hashtag : Route( + route = "Hashtag/{id}", + icon = R.drawable.ic_moments, + arguments = listOf(navArgument("id") { type = NavType.StringType }) + ) + object Room : Route( route = "Room/{id}", icon = R.drawable.ic_moments, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index d7727578..053ee1ef 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -11,6 +11,7 @@ import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter import com.vitorpamplona.amethyst.ui.dal.ChatroomListNewFeedFilter import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter +import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter @@ -33,6 +34,7 @@ class NostrChannelFeedViewModel : FeedViewModel(ChannelFeedFilter) class NostrChatRoomFeedViewModel : FeedViewModel(ChatroomFeedFilter) class NostrGlobalFeedViewModel : FeedViewModel(GlobalFeedFilter) class NostrThreadFeedViewModel : FeedViewModel(ThreadFeedFilter) +class NostrHashtagFeedViewModel : FeedViewModel(HashtagFeedFilter) class NostrUserProfileNewThreadsFeedViewModel : FeedViewModel(UserProfileNewThreadFeedFilter) class NostrUserProfileConversationsFeedViewModel : FeedViewModel(UserProfileConversationsFeedFilter) class NostrUserProfileReportFeedViewModel : FeedViewModel(UserProfileReportsFeedFilter) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt new file mode 100644 index 00000000..0f6e5510 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt @@ -0,0 +1,109 @@ +package com.vitorpamplona.amethyst.ui.screen.loggedIn + +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.material.Divider +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +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.platform.LocalLifecycleOwner +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import com.vitorpamplona.amethyst.service.NostrHashtagDataSource +import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter +import com.vitorpamplona.amethyst.ui.screen.FeedView +import com.vitorpamplona.amethyst.ui.screen.NostrHashtagFeedViewModel + +@Composable +fun HashtagScreen(tag: String?, accountViewModel: AccountViewModel, navController: NavController) { + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account ?: return + + val lifeCycleOwner = LocalLifecycleOwner.current + + if (tag != null) { + val feedViewModel: NostrHashtagFeedViewModel = viewModel() + + LaunchedEffect(tag) { + HashtagFeedFilter.loadHashtag(account, tag) + NostrHashtagDataSource.loadHashtag(tag) + feedViewModel.invalidateData() + } + + DisposableEffect(accountViewModel) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Hashtag Start") + HashtagFeedFilter.loadHashtag(account, tag) + NostrHashtagDataSource.loadHashtag(tag) + NostrHashtagDataSource.start() + feedViewModel.invalidateData() + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Hashtag Stop") + HashtagFeedFilter.loadHashtag(account, null) + NostrHashtagDataSource.loadHashtag(null) + NostrHashtagDataSource.stop() + } + } + + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { + lifeCycleOwner.lifecycle.removeObserver(observer) + } + } + + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp) + ) { + HashtagHeader(tag) + FeedView(feedViewModel, accountViewModel, navController, null) + } + } + } +} + +@Composable +fun HashtagHeader(tag: String) { + Column() { + Column(modifier = Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column( + modifier = Modifier + .padding(start = 10.dp) + .weight(1f) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + Text( + "#$tag", + fontWeight = FontWeight.Bold + ) + } + } + } + } + + Divider( + modifier = Modifier.padding(start = 12.dp, end = 12.dp), + thickness = 0.25.dp + ) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt index 5b3dba2f..a5fb0da6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.Divider @@ -72,6 +73,7 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.util.regex.Pattern import kotlinx.coroutines.channels.Channel as CoroutineChannel @Composable @@ -126,7 +128,9 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont val searchResults = remember { mutableStateOf>(emptyList()) } val searchResultsNotes = remember { mutableStateOf>(emptyList()) } val searchResultsChannels = remember { mutableStateOf>(emptyList()) } + val hashtagResults = remember { mutableStateOf>(emptyList()) } val scope = rememberCoroutineScope() + val listState = rememberLazyListState() val onlineSearch = NostrSearchEventOrUserDataSource @@ -149,6 +153,8 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont .distinctUntilChanged() .debounce(300) .collectLatest { + hashtagResults.value = findHashtags(it) + if (it.removePrefix("npub").removePrefix("note").length >= 4) { onlineSearch.search(it.trim()) } @@ -156,6 +162,9 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont searchResults.value = LocalCache.findUsersStartingWith(it) searchResultsNotes.value = LocalCache.findNotesStartingWith(it).sortedBy { it.createdAt() }.reversed() searchResultsChannels.value = LocalCache.findChannelsStartingWith(it) + + // makes sure to show the top of the search + scope.launch(Dispatchers.Main) { listState.animateScrollToItem(0) } } } } @@ -236,8 +245,15 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont contentPadding = PaddingValues( top = 10.dp, bottom = 10.dp - ) + ), + state = listState ) { + itemsIndexed(hashtagResults.value, key = { _, item -> "#" + item }) { _, item -> + HashtagLine(item) { + navController.navigate("Hashtag/$item") + } + } + itemsIndexed(searchResults.value, key = { _, item -> "u" + item.pubkeyHex }) { _, item -> UserCompose(item, accountViewModel = accountViewModel, navController = navController) } @@ -266,6 +282,57 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont } } +val hashtagSearch = Pattern.compile("(?:\\s|\\A)#([A-Za-z0-9_\\-]+)") + +fun findHashtags(content: String): List { + val matcher = hashtagSearch.matcher(content) + val returningList = mutableSetOf() + while (matcher.find()) { + try { + val tag = matcher.group(1) + if (tag != null && tag.isNotBlank()) { + returningList.add(tag) + } + } catch (e: Exception) { + } + } + return returningList.toList() +} + +@Composable +fun HashtagLine(tag: String, onClick: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + ) { + Row( + modifier = Modifier + .padding( + start = 12.dp, + end = 12.dp, + top = 10.dp + ) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + Text( + "Search hashtag: #$tag", + fontWeight = FontWeight.Bold + ) + } + } + + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = 0.25.dp + ) + } +} + @Composable fun UserLine( baseUser: User,