mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2024-09-29 00:10:45 +00:00
Hashtags
This commit is contained in:
parent
59cdd81aec
commit
1812ec296e
@ -32,6 +32,7 @@ Amethyst brings the best social network to your Android phone. Just insert your
|
|||||||
- [x] Online Relay Search (NIP-50)
|
- [x] Online Relay Search (NIP-50)
|
||||||
- [x] Internationalization
|
- [x] Internationalization
|
||||||
- [x] Badges (NIP-58)
|
- [x] Badges (NIP-58)
|
||||||
|
- [x] Hashtags
|
||||||
- [ ] Local Database
|
- [ ] Local Database
|
||||||
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
|
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
|
||||||
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
|
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
|
||||||
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
@ -39,8 +39,12 @@ open class Event(
|
|||||||
|
|
||||||
fun taggedUsers() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
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 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.
|
* Checks if the ID is correct and then if the pubKey's secret key signed the event.
|
||||||
*/
|
*/
|
||||||
|
@ -24,4 +24,6 @@ interface EventInterface {
|
|||||||
fun hasValidSignature(): Boolean
|
fun hasValidSignature(): Boolean
|
||||||
|
|
||||||
fun isTaggedUser(loggedInUser: String): Boolean
|
fun isTaggedUser(loggedInUser: String): Boolean
|
||||||
|
|
||||||
|
fun isTaggedHash(hashtag: String): Boolean
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service.model
|
|||||||
|
|
||||||
import com.vitorpamplona.amethyst.model.HexKey
|
import com.vitorpamplona.amethyst.model.HexKey
|
||||||
import com.vitorpamplona.amethyst.model.toHexKey
|
import com.vitorpamplona.amethyst.model.toHexKey
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.findHashtags
|
||||||
import nostr.postr.Utils
|
import nostr.postr.Utils
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
@ -29,6 +30,9 @@ class TextNoteEvent(
|
|||||||
addresses?.forEach {
|
addresses?.forEach {
|
||||||
tags.add(listOf("a", it.toTag()))
|
tags.add(listOf("a", it.toTag()))
|
||||||
}
|
}
|
||||||
|
findHashtags(msg).forEach {
|
||||||
|
tags.add(listOf("t", it))
|
||||||
|
}
|
||||||
val id = generateId(pubKey, createdAt, kind, tags, msg)
|
val id = generateId(pubKey, createdAt, kind, tags, msg)
|
||||||
val sig = Utils.sign(id, privateKey)
|
val sig = Utils.sign(id, privateKey)
|
||||||
return TextNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
|
return TextNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
|
||||||
|
@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.ClickableText
|
||||||
import androidx.compose.material.LocalTextStyle
|
import androidx.compose.material.LocalTextStyle
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.Text
|
import androidx.compose.material.Text
|
||||||
@ -17,6 +18,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.compositeOver
|
import androidx.compose.ui.graphics.compositeOver
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
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 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 mentionsPattern: Pattern = Pattern.compile("@([A-Za-z0-9_\\-]+)")
|
||||||
val hashTagsPattern: Pattern = Pattern.compile("#([A-Za-z0-9_-]+)")
|
val hashTagsPattern: Pattern = Pattern.compile("#([A-Za-z0-9_\\-]+)")
|
||||||
val urlPattern: Pattern = Patterns.WEB_URL
|
val urlPattern: Pattern = Patterns.WEB_URL
|
||||||
|
|
||||||
fun isValidURL(url: String?): Boolean {
|
fun isValidURL(url: String?): Boolean {
|
||||||
@ -144,6 +146,8 @@ fun RichTextViewer(
|
|||||||
UrlPreview("https://$word", word)
|
UrlPreview("https://$word", word)
|
||||||
} else if (tagIndex.matcher(word).matches() && tags != null) {
|
} else if (tagIndex.matcher(word).matches() && tags != null) {
|
||||||
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
|
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
|
||||||
|
} else if (hashTagsPattern.matcher(word).matches()) {
|
||||||
|
HashTag(word, accountViewModel, navController)
|
||||||
} else if (isBechLink(word)) {
|
} else if (isBechLink(word)) {
|
||||||
BechLink(word, navController)
|
BechLink(word, navController)
|
||||||
} else {
|
} else {
|
||||||
@ -163,6 +167,8 @@ fun RichTextViewer(
|
|||||||
ClickableUrl(word, "https://$word")
|
ClickableUrl(word, "https://$word")
|
||||||
} else if (tagIndex.matcher(word).matches() && tags != null) {
|
} else if (tagIndex.matcher(word).matches() && tags != null) {
|
||||||
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
|
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
|
||||||
|
} else if (hashTagsPattern.matcher(word).matches()) {
|
||||||
|
HashTag(word, accountViewModel, navController)
|
||||||
} else if (isBechLink(word)) {
|
} else if (isBechLink(word)) {
|
||||||
BechLink(word, navController)
|
BechLink(word, navController)
|
||||||
} else {
|
} 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
|
@Composable
|
||||||
fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) {
|
fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) {
|
||||||
val matcher = tagIndex.matcher(word)
|
val matcher = tagIndex.matcher(word)
|
||||||
|
@ -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<Note>() {
|
||||||
|
lateinit var account: Account
|
||||||
|
var tag: String? = null
|
||||||
|
|
||||||
|
override fun feed(): List<Note> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
@ -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.ChatroomListScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.FiltersScreen
|
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.HomeScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.NotificationScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.NotificationScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen
|
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 ->
|
Route.Room.let { route ->
|
||||||
composable(route.route, route.arguments, content = {
|
composable(route.route, route.arguments, content = {
|
||||||
ChatroomScreen(
|
ChatroomScreen(
|
||||||
|
@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
|||||||
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
|
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
|
||||||
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
|
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
|
||||||
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
|
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
|
||||||
|
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
|
||||||
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
|
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
|
||||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||||
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
|
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
|
||||||
@ -138,6 +139,7 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
|
|||||||
NostrSingleChannelDataSource.printCounter()
|
NostrSingleChannelDataSource.printCounter()
|
||||||
NostrSingleUserDataSource.printCounter()
|
NostrSingleUserDataSource.printCounter()
|
||||||
NostrThreadDataSource.printCounter()
|
NostrThreadDataSource.printCounter()
|
||||||
|
NostrHashtagDataSource.printCounter()
|
||||||
|
|
||||||
NostrUserProfileDataSource.printCounter()
|
NostrUserProfileDataSource.printCounter()
|
||||||
|
|
||||||
|
@ -65,6 +65,12 @@ sealed class Route(
|
|||||||
arguments = listOf(navArgument("id") { type = NavType.StringType })
|
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(
|
object Room : Route(
|
||||||
route = "Room/{id}",
|
route = "Room/{id}",
|
||||||
icon = R.drawable.ic_moments,
|
icon = R.drawable.ic_moments,
|
||||||
|
@ -11,6 +11,7 @@ import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
|
|||||||
import com.vitorpamplona.amethyst.ui.dal.ChatroomListNewFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.ChatroomListNewFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
|
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.HomeConversationsFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter
|
||||||
@ -33,6 +34,7 @@ class NostrChannelFeedViewModel : FeedViewModel(ChannelFeedFilter)
|
|||||||
class NostrChatRoomFeedViewModel : FeedViewModel(ChatroomFeedFilter)
|
class NostrChatRoomFeedViewModel : FeedViewModel(ChatroomFeedFilter)
|
||||||
class NostrGlobalFeedViewModel : FeedViewModel(GlobalFeedFilter)
|
class NostrGlobalFeedViewModel : FeedViewModel(GlobalFeedFilter)
|
||||||
class NostrThreadFeedViewModel : FeedViewModel(ThreadFeedFilter)
|
class NostrThreadFeedViewModel : FeedViewModel(ThreadFeedFilter)
|
||||||
|
class NostrHashtagFeedViewModel : FeedViewModel(HashtagFeedFilter)
|
||||||
class NostrUserProfileNewThreadsFeedViewModel : FeedViewModel(UserProfileNewThreadFeedFilter)
|
class NostrUserProfileNewThreadsFeedViewModel : FeedViewModel(UserProfileNewThreadFeedFilter)
|
||||||
class NostrUserProfileConversationsFeedViewModel : FeedViewModel(UserProfileConversationsFeedFilter)
|
class NostrUserProfileConversationsFeedViewModel : FeedViewModel(UserProfileConversationsFeedFilter)
|
||||||
class NostrUserProfileReportFeedViewModel : FeedViewModel(UserProfileReportsFeedFilter)
|
class NostrUserProfileReportFeedViewModel : FeedViewModel(UserProfileReportsFeedFilter)
|
||||||
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.Divider
|
import androidx.compose.material.Divider
|
||||||
@ -72,6 +73,7 @@ import kotlinx.coroutines.flow.filter
|
|||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.util.regex.Pattern
|
||||||
import kotlinx.coroutines.channels.Channel as CoroutineChannel
|
import kotlinx.coroutines.channels.Channel as CoroutineChannel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -126,7 +128,9 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
|
|||||||
val searchResults = remember { mutableStateOf<List<User>>(emptyList()) }
|
val searchResults = remember { mutableStateOf<List<User>>(emptyList()) }
|
||||||
val searchResultsNotes = remember { mutableStateOf<List<Note>>(emptyList()) }
|
val searchResultsNotes = remember { mutableStateOf<List<Note>>(emptyList()) }
|
||||||
val searchResultsChannels = remember { mutableStateOf<List<Channel>>(emptyList()) }
|
val searchResultsChannels = remember { mutableStateOf<List<Channel>>(emptyList()) }
|
||||||
|
val hashtagResults = remember { mutableStateOf<List<String>>(emptyList()) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
val onlineSearch = NostrSearchEventOrUserDataSource
|
val onlineSearch = NostrSearchEventOrUserDataSource
|
||||||
|
|
||||||
@ -149,6 +153,8 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
|
|||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.debounce(300)
|
.debounce(300)
|
||||||
.collectLatest {
|
.collectLatest {
|
||||||
|
hashtagResults.value = findHashtags(it)
|
||||||
|
|
||||||
if (it.removePrefix("npub").removePrefix("note").length >= 4) {
|
if (it.removePrefix("npub").removePrefix("note").length >= 4) {
|
||||||
onlineSearch.search(it.trim())
|
onlineSearch.search(it.trim())
|
||||||
}
|
}
|
||||||
@ -156,6 +162,9 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
|
|||||||
searchResults.value = LocalCache.findUsersStartingWith(it)
|
searchResults.value = LocalCache.findUsersStartingWith(it)
|
||||||
searchResultsNotes.value = LocalCache.findNotesStartingWith(it).sortedBy { it.createdAt() }.reversed()
|
searchResultsNotes.value = LocalCache.findNotesStartingWith(it).sortedBy { it.createdAt() }.reversed()
|
||||||
searchResultsChannels.value = LocalCache.findChannelsStartingWith(it)
|
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(
|
contentPadding = PaddingValues(
|
||||||
top = 10.dp,
|
top = 10.dp,
|
||||||
bottom = 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 ->
|
itemsIndexed(searchResults.value, key = { _, item -> "u" + item.pubkeyHex }) { _, item ->
|
||||||
UserCompose(item, accountViewModel = accountViewModel, navController = navController)
|
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<String> {
|
||||||
|
val matcher = hashtagSearch.matcher(content)
|
||||||
|
val returningList = mutableSetOf<String>()
|
||||||
|
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
|
@Composable
|
||||||
fun UserLine(
|
fun UserLine(
|
||||||
baseUser: User,
|
baseUser: User,
|
||||||
|
Loading…
Reference in New Issue
Block a user