Merge pull request #3793 from vitorpamplona/claude/highlight-events-browser-ywz5wr

Add NIP-84 highlights support: composer, feed, and share intent
This commit is contained in:
Vitor Pamplona
2026-07-28 23:53:41 -04:00
committed by GitHub
39 changed files with 2774 additions and 6 deletions
+18
View File
@@ -295,6 +295,24 @@
</intent-filter>
</activity-alias>
<!-- "New Highlight" share target: a browser (or reader) shares a selected passage of
text and it opens the NIP-84 highlight composer. Text-only — a highlight is a text
passage, so no image/video filters here. The android:name simple class
("ShareAsHighlightAlias") is matched at runtime by
ShareIntentRouting.SHARE_AS_HIGHLIGHT_ALIAS_SIMPLE_NAME; keep the two in sync. -->
<activity-alias
android:name=".ui.ShareAsHighlightAlias"
android:exported="true"
android:label="@string/share_target_as_highlight"
android:targetActivity=".ui.MainActivity">
<intent-filter android:label="@string/share_target_as_highlight">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity-alias>
<!-- Health Connect privacy-policy rationale on Android 14+. Without this activity-alias
the permission request fails silently (no dialog appears). The system launches it,
guarded by START_VIEW_PERMISSION_USAGE, to show our privacy policy; it routes into
@@ -132,6 +132,7 @@ private object PrefKeys {
const val DEFAULT_NSITES_FOLLOW_LIST = "defaultNsitesFollowList"
const val DEFAULT_WORKOUTS_FOLLOW_LIST = "defaultWorkoutsFollowList"
const val DEFAULT_GIT_REPOSITORIES_FOLLOW_LIST = "defaultGitRepositoriesFollowList"
const val DEFAULT_HIGHLIGHTS_FOLLOW_LIST = "defaultHighlightsFollowList"
const val DEFAULT_CALENDARS_FOLLOW_LIST = "defaultCalendarsFollowList"
const val DEFAULT_PRODUCTS_FOLLOW_LIST = "defaultProductsFollowList"
const val DEFAULT_SHORTS_FOLLOW_LIST = "defaultShortsFollowList"
@@ -522,6 +523,7 @@ object LocalPreferences {
putString(PrefKeys.DEFAULT_NSITES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNsitesFollowList.value))
putString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultWorkoutsFollowList.value))
putString(PrefKeys.DEFAULT_GIT_REPOSITORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultGitRepositoriesFollowList.value))
putString(PrefKeys.DEFAULT_HIGHLIGHTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHighlightsFollowList.value))
putString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultCalendarsFollowList.value))
putString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultProductsFollowList.value))
putString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultShortsFollowList.value))
@@ -935,6 +937,7 @@ object LocalPreferences {
defaultNsitesFollowList = MutableStateFlow(followListPrefs.nsites),
defaultWorkoutsFollowList = MutableStateFlow(followListPrefs.workouts),
defaultGitRepositoriesFollowList = MutableStateFlow(followListPrefs.gitRepositories),
defaultHighlightsFollowList = MutableStateFlow(followListPrefs.highlights),
defaultCalendarsFollowList = MutableStateFlow(followListPrefs.calendars),
defaultProductsFollowList = MutableStateFlow(followListPrefs.products),
defaultShortsFollowList = MutableStateFlow(followListPrefs.shorts),
@@ -1044,6 +1047,7 @@ object LocalPreferences {
val nsites: TopFilter,
val workouts: TopFilter,
val gitRepositories: TopFilter,
val highlights: TopFilter,
val calendars: TopFilter,
val products: TopFilter,
val shorts: TopFilter,
@@ -1101,6 +1105,7 @@ object LocalPreferences {
nsites = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NSITES_FOLLOW_LIST, null), TopFilter.Global),
workouts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, null), TopFilter.Global),
gitRepositories = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_GIT_REPOSITORIES_FOLLOW_LIST, null), TopFilter.Global),
highlights = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_HIGHLIGHTS_FOLLOW_LIST, null), TopFilter.Global),
calendars = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, null), TopFilter.Global),
products = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, null), TopFilter.AroundMe),
shorts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, null), TopFilter.Global),
@@ -873,6 +873,9 @@ class Account(
val liveGitRepositoriesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultGitRepositoriesFollowList)
val liveGitRepositoriesFollowListsPerRelay = OutboxLoaderState(liveGitRepositoriesFollowLists, cache, scope).flow
val liveHighlightsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultHighlightsFollowList)
val liveHighlightsFollowListsPerRelay = OutboxLoaderState(liveHighlightsFollowLists, cache, scope).flow
val liveCalendarsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultCalendarsFollowList)
val liveCalendarsFollowListsPerRelay = OutboxLoaderState(liveCalendarsFollowLists, cache, scope).flow
@@ -251,6 +251,7 @@ class AccountSettings(
val defaultNsitesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultWorkoutsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultGitRepositoriesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultHighlightsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultCalendarsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultProductsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AroundMe),
val defaultShortsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
@@ -895,6 +896,17 @@ class AccountSettings(
}
}
fun changeDefaultHighlightsFollowList(name: FeedDefinition) {
changeDefaultHighlightsFollowList(name.code)
}
fun changeDefaultHighlightsFollowList(name: TopFilter) {
if (defaultHighlightsFollowList.value != name) {
defaultHighlightsFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultCalendarsFollowList(name: FeedDefinition) {
changeDefaultCalendarsFollowList(name.code)
}
@@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashF
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.datasource.RepositoryFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.GitRepositoriesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.HighlightsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.datasource.LiveStreamsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.LongsFilterAssembler
@@ -170,6 +171,7 @@ class RelaySubscriptionsCoordinator(
val pictures = PicturesFilterAssembler(client)
val workouts = WorkoutsFilterAssembler(client)
val gitRepositories = GitRepositoriesFilterAssembler(client)
val highlights = HighlightsFilterAssembler(client)
val calendars = CalendarsFilterAssembler(client)
val products = ProductsFilterAssembler(client)
val shorts = ShortsFilterAssembler(client)
@@ -235,6 +237,7 @@ class RelaySubscriptionsCoordinator(
pictures,
workouts,
gitRepositories,
highlights,
calendars,
products,
shorts,
@@ -69,6 +69,7 @@ object ScrollStateKeys {
const val PICTURES_SCREEN = "PicturesFeed"
const val WORKOUTS_SCREEN = "WorkoutsFeed"
const val GIT_REPOSITORIES_SCREEN = "GitRepositoriesFeed"
const val HIGHLIGHTS_SCREEN = "HighlightsFeed"
const val RELAY_GROUPS_DISCOVERY_SCREEN = "RelayGroupsDiscoveryFeed"
const val CALENDARS_SCREEN = "CalendarsFeed"
const val CALENDAR_COLLECTIONS_SCREEN = "CalendarCollectionsFeed"
@@ -193,6 +193,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.GitRepositoriesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.HighlightsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.NewHighlightScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.VoiceReplyScreen
@@ -310,6 +312,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog
import com.vitorpamplona.amethyst.ui.uriToRoute
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip84Highlights.parse.SharedHighlightParser
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.net.URI
@@ -439,6 +442,8 @@ fun BuildNavigation(
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
composableFromEnd<Route.Workouts> { WorkoutsScreen(accountViewModel, nav) }
composableFromEnd<Route.GitRepositories> { GitRepositoriesScreen(accountViewModel, nav) }
composableFromEnd<Route.Highlights> { HighlightsScreen(accountViewModel, nav) }
composableFromEnd<Route.SoftwareApps> { SoftwareAppsScreen(accountViewModel, nav) }
composableFromEnd<Route.Napplets> { NappletsScreen(accountViewModel, nav) }
composableFromEnd<Route.Nsites> { NsitesScreen(accountViewModel, nav) }
@@ -935,6 +940,22 @@ fun BuildNavigation(
)
}
composableFromBottomArgs<Route.NewHighlight> {
NewHighlightScreen(
quote = it.quote,
url = it.url,
prefix = it.prefix,
suffix = it.suffix,
comment = it.comment,
context = it.context,
sourceAddress = it.sourceAddress,
sourceEventId = it.sourceEventId,
author = it.author,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromBottomArgs<Route.VoiceReply> {
VoiceReplyScreen(
replyToNoteId = it.replyToNoteId,
@@ -967,10 +988,13 @@ private fun NavigateIfIntentRequested(
if (activity.intent.action == Intent.ACTION_SEND) {
val isShareAsDm = ShareIntentRouting.isShareAsDm(activity.intent.component?.className)
val isShareAsHighlight = ShareIntentRouting.isShareAsHighlight(activity.intent.component?.className)
// avoids restarting the destination screen when the intent is for the screen.
// Microsoft's swift key sends Gifs as new actions
if (isShareAsDm) {
if (isShareAsHighlight) {
if (isBaseRoute<Route.NewHighlight>(nav.controller)) return
} else if (isShareAsDm) {
if (isBaseRoute<Route.ShareToDM>(nav.controller)) return
} else {
if (isBaseRoute<Route.NewShortNote>(nav.controller)) return
@@ -991,7 +1015,17 @@ private fun NavigateIfIntentRequested(
)
}
if (isShareAsDm) {
if (isShareAsHighlight) {
val parsed = message?.let { SharedHighlightParser.parse(it) }
nav.newStack(
Route.NewHighlight(
quote = parsed?.quote,
url = parsed?.url,
prefix = parsed?.prefix,
suffix = parsed?.suffix,
),
)
} else if (isShareAsDm) {
nav.newStack(Route.ShareToDM(message = message, attachment = media?.toString()))
} else {
nav.newStack(Route.NewShortNote(message = message, attachment = media.toString()))
@@ -1063,9 +1097,22 @@ private fun NavigateIfIntentRequested(
Consumer<Intent> { intent ->
if (intent.action == Intent.ACTION_SEND) {
val isShareAsDm = ShareIntentRouting.isShareAsDm(intent.component?.className)
val isShareAsHighlight = ShareIntentRouting.isShareAsHighlight(intent.component?.className)
// avoids restarting the destination screen when the intent is for the screen.
// Microsoft's swift key sends Gifs as new actions
if (isShareAsDm) {
if (isShareAsHighlight) {
if (!isBaseRoute<Route.NewHighlight>(nav.controller)) {
val parsed = intent.getStringExtra(Intent.EXTRA_TEXT)?.ifBlank { null }?.let { SharedHighlightParser.parse(it) }
nav.newStack(
Route.NewHighlight(
quote = parsed?.quote,
url = parsed?.url,
prefix = parsed?.prefix,
suffix = parsed?.suffix,
),
)
}
} else if (isShareAsDm) {
if (!isBaseRoute<Route.ShareToDM>(nav.controller)) {
val message = intent.getStringExtra(Intent.EXTRA_TEXT)?.ifBlank { null }
val attachment =
@@ -21,9 +21,9 @@
package com.vitorpamplona.amethyst.ui.navigation
/**
* Distinguishes the "Send as DM" share target from the default "New Post" share
* target. Both intent-filters resolve to MainActivity; they are told apart by the
* component class name of the launching intent (the activity-alias name).
* Distinguishes the extra share targets ("Send as DM", "New Highlight") from the default
* "New Post" share target. Every SEND intent-filter resolves to MainActivity; they are told
* apart by the component class name of the launching intent (the activity-alias name).
*/
object ShareIntentRouting {
/**
@@ -34,5 +34,14 @@ object ShareIntentRouting {
*/
const val SHARE_AS_DM_ALIAS_SIMPLE_NAME = "ShareAsDMAlias"
/**
* Simple class name of the `<activity-alias>` declared in AndroidManifest.xml
* (android:name=".ui.ShareAsHighlightAlias"). MUST stay in sync with the manifest —
* see the caveat on [SHARE_AS_DM_ALIAS_SIMPLE_NAME].
*/
const val SHARE_AS_HIGHLIGHT_ALIAS_SIMPLE_NAME = "ShareAsHighlightAlias"
fun isShareAsDm(componentClassName: String?): Boolean = componentClassName?.endsWith(".$SHARE_AS_DM_ALIAS_SIMPLE_NAME") == true
fun isShareAsHighlight(componentClassName: String?): Boolean = componentClassName?.endsWith(".$SHARE_AS_HIGHLIGHT_ALIAS_SIMPLE_NAME") == true
}
@@ -55,6 +55,7 @@ enum class NavBarItem {
PICTURES,
WORKOUTS,
GIT_REPOSITORIES,
HIGHLIGHTS,
SOFTWARE_APPS,
NAPPLETS,
NSITES,
@@ -246,6 +247,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
icon = MaterialSymbols.Code,
resolveRoute = { Route.GitRepositories },
),
NavBarItem.HIGHLIGHTS to
NavBarItemDef(
id = NavBarItem.HIGHLIGHTS,
labelRes = R.string.highlights,
icon = MaterialSymbols.FormatQuote,
resolveRoute = { Route.Highlights },
),
NavBarItem.SOFTWARE_APPS to
NavBarItemDef(
id = NavBarItem.SOFTWARE_APPS,
@@ -534,6 +542,7 @@ val BottomBarCategories: List<NavBarCategory> =
NavBarItem.PRODUCTS,
NavBarItem.WORKOUTS,
NavBarItem.GIT_REPOSITORIES,
NavBarItem.HIGHLIGHTS,
NavBarItem.COMMUNITIES,
NavBarItem.FOLLOW_PACKS,
NavBarItem.CALENDARS,
@@ -574,6 +583,7 @@ val DrawerFeedsItems: List<NavBarItem> =
NavBarItem.PRODUCTS,
NavBarItem.WORKOUTS,
NavBarItem.GIT_REPOSITORIES,
NavBarItem.HIGHLIGHTS,
NavBarItem.LIVE_STREAMS,
NavBarItem.NESTS,
NavBarItem.COMMUNITIES,
@@ -87,6 +87,8 @@ sealed class Route {
@Serializable object GitRepositories : Route()
@Serializable object Highlights : Route()
@Serializable object SoftwareApps : Route()
@Serializable object Napplets : Route()
@@ -987,6 +989,26 @@ sealed class Route {
val draft: String? = null,
) : Route()
/**
* The NIP-84 highlight composer. Opened by the "Add highlight" action (all fields null) or
* when a browser shares a text selection to Amethyst — in which case the shared string has
* already been run through SharedHighlightParser and the pieces arrive pre-split here.
*/
@Serializable
data class NewHighlight(
val quote: String? = null,
val url: String? = null,
val prefix: String? = null,
val suffix: String? = null,
val comment: String? = null,
val context: String? = null,
// A nostr source (set when highlighting a nostr article/note rather than a web page):
// an addressable coordinate (`a`), a specific event id (`e`) and the author (`p`).
val sourceAddress: String? = null,
val sourceEventId: String? = null,
val author: String? = null,
) : Route()
@Serializable data object NewHlsVideo : Route()
@Serializable
@@ -171,6 +171,25 @@ fun noteActionSections(
}
},
)
// Highlight this note/article as its source: opens the NIP-84 composer with the
// nostr source pre-tagged (`a` for an addressable article, else `e`) plus the author,
// and the passage left for the user to type or paste. Prose kinds only, and never a
// private rumor (a public highlight would e-tag the unsigned rumor onto relays).
if (!isPrivateRumor && (note.event is TextNoteEvent || note.event is LongTextNoteEvent)) {
add(
NoteAction(MaterialSymbols.FormatQuote, stringRes(R.string.highlight_action)) {
val author = note.author?.pubkeyHex
val route =
if (note is AddressableNote) {
Route.NewHighlight(sourceAddress = note.address.toValue(), author = author)
} else {
Route.NewHighlight(sourceEventId = note.idHex, author = author)
}
nav.nav(route)
handlers.onDismiss()
},
)
}
if (!isPrivateRumor) {
add(NoteAction(MaterialSymbols.Share, stringRes(R.string.quick_action_share), onClick = handlers.onShare))
}
@@ -337,6 +337,24 @@ class TopNavFilterState(
)
}
private val _highlightsRoutes =
combineTransform(
livePeopleListsFlow,
liveInterestFlows,
) { peopleLists, interests ->
checkNotInMainThread()
emit(
listOf(
// Highlights can be narrowed by author, hashtag and geohash, so this mirrors
// the kind3 catalog plus "Mine" — the user's own highlights.
listOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, mineFollow),
peopleLists,
interests,
listOf(muteListFollow),
).flatten().toImmutableList(),
)
}
private val _relayGroupsDiscoveryRoutes =
combineTransform(
livePeopleListsFlow,
@@ -470,6 +488,11 @@ class TopNavFilterState(
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, mineFollow, muteListFollow))
val highlightsRoutes =
_highlightsRoutes
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, mineFollow, muteListFollow))
val relayGroupsDiscoveryRoutes =
_relayGroupsDiscoveryRoutes
.flowOn(Dispatchers.IO)
@@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.dal.DraftEventsFeedF
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.dal.BrowseEmojiSetsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.list.dal.FollowPacksFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.dal.GitRepositoriesFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.dal.HighlightsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeEverythingFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeLiveFilter
@@ -117,6 +118,7 @@ class AccountFeedContentStates(
val picturesFeed = FeedContentState(PictureFeedFilter(account), scope, LocalCache)
val workoutsFeed = FeedContentState(WorkoutFeedFilter(account), scope, LocalCache)
val gitRepositoriesFeed = FeedContentState(GitRepositoriesFeedFilter(account), scope, LocalCache)
val highlightsFeed = FeedContentState(HighlightsFeedFilter(account), scope, LocalCache)
val relayGroupsDiscoveryFeed = FeedContentState(RelayGroupDiscoveryFeedFilter(account), scope, LocalCache)
val calendarAppointmentsFeed = FeedContentState(CalendarAppointmentsFeedFilter(account), scope, LocalCache)
val calendarCollectionsFeed = FeedContentState(CalendarCollectionsFeedFilter(account), scope, LocalCache)
@@ -327,6 +329,7 @@ class AccountFeedContentStates(
picturesFeed.updateFeedWith(newNotes)
workoutsFeed.updateFeedWith(newNotes)
gitRepositoriesFeed.updateFeedWith(newNotes)
highlightsFeed.updateFeedWith(newNotes)
relayGroupsDiscoveryFeed.updateFeedWith(newNotes)
productsFeed.updateFeedWith(newNotes)
shortsFeed.updateFeedWith(newNotes)
@@ -390,6 +393,7 @@ class AccountFeedContentStates(
picturesFeed.deleteFromFeed(newNotes)
workoutsFeed.deleteFromFeed(newNotes)
gitRepositoriesFeed.deleteFromFeed(newNotes)
highlightsFeed.deleteFromFeed(newNotes)
relayGroupsDiscoveryFeed.deleteFromFeed(newNotes)
productsFeed.deleteFromFeed(newNotes)
shortsFeed.deleteFromFeed(newNotes)
@@ -449,6 +453,7 @@ class AccountFeedContentStates(
picturesFeed.trimToSize(maxItems)
workoutsFeed.trimToSize(maxItems)
gitRepositoriesFeed.trimToSize(maxItems)
highlightsFeed.trimToSize(maxItems)
relayGroupsDiscoveryFeed.trimToSize(maxItems)
calendarAppointmentsFeed.trimToSize(maxItems)
calendarCollectionsFeed.trimToSize(maxItems)
@@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.Discove
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasource.BrowseEmojiSetsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.list.datasource.FollowPacksFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.GitRepositoriesFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.HighlightsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.datasource.LiveStreamsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.LongsFilterAssemblerSubscription
@@ -100,6 +101,8 @@ private fun PreloadFor(
NavBarItem.GIT_REPOSITORIES -> GitRepositoriesFilterAssemblerSubscription(accountViewModel)
NavBarItem.HIGHLIGHTS -> HighlightsFilterAssemblerSubscription(accountViewModel)
NavBarItem.SOFTWARE_APPS -> SoftwareAppsFilterAssemblerSubscription(accountViewModel)
// Napplets & nSites read directly from the local cache; their screens open the discovery
@@ -0,0 +1,108 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.HighlightsFilterAssemblerSubscription
@Composable
fun HighlightsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
HighlightsScreen(
highlightsFeedContentState = accountViewModel.feedStates.highlightsFeed,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Composable
fun HighlightsScreen(
highlightsFeedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(highlightsFeedContentState)
WatchAccountForHighlightsScreen(highlightsFeedContentState = highlightsFeedContentState, accountViewModel = accountViewModel)
HighlightsFilterAssemblerSubscription(accountViewModel)
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
HighlightsTopBar(accountViewModel, nav)
},
bottomBar = {
AppBottomBar(Route.Highlights, nav, accountViewModel) { route ->
if (route == Route.Highlights) {
highlightsFeedContentState.sendToTop()
} else {
nav.navBottomBar(route)
}
}
},
floatingButton = {
NewHighlightButton(nav)
},
accountViewModel = accountViewModel,
) {
RefresheableBox(highlightsFeedContentState, true) {
SaveableFeedContentState(highlightsFeedContentState, scrollStateKey = ScrollStateKeys.HIGHLIGHTS_SCREEN) { listState ->
RenderFeedContentState(
feedContentState = highlightsFeedContentState,
accountViewModel = accountViewModel,
listState = listState,
nav = nav,
routeForLastRead = "HighlightsFeed",
)
}
}
}
}
@Composable
fun WatchAccountForHighlightsScreen(
highlightsFeedContentState: FeedContentState,
accountViewModel: AccountViewModel,
) {
val listState by accountViewModel.account.liveHighlightsFollowLists.collectAsStateWithLifecycle()
val hiddenUsers =
accountViewModel.account.hiddenUsers.flow
.collectAsStateWithLifecycle()
LaunchedEffect(accountViewModel, listState, hiddenUsers) {
highlightsFeedContentState.checkKeysInvalidateDataAndSendToTop()
}
}
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun HighlightsTopBar(
accountViewModel: AccountViewModel,
nav: INav,
) {
UserDrawerSearchTopBar(accountViewModel, nav) {
val list by accountViewModel.account.settings.defaultHighlightsFollowList
.collectAsStateWithLifecycle()
HighlightsTopNavFilterBar(
followListsModel = accountViewModel.feedStates.feedListOptions,
listName = list,
accountViewModel = accountViewModel,
onChange = accountViewModel.account.settings::changeDefaultHighlightsFollowList,
)
}
}
@Composable
private fun HighlightsTopNavFilterBar(
followListsModel: TopNavFilterState,
listName: TopFilter,
accountViewModel: AccountViewModel,
onChange: (FeedDefinition) -> Unit,
) {
val allLists by followListsModel.highlightsRoutes.collectAsStateWithLifecycle()
FeedFilterSpinner(
placeholderCode = listName,
explainer = stringRes(R.string.select_list_to_filter),
options = allLists,
onSelect = onChange,
accountViewModel = accountViewModel,
)
}
@@ -0,0 +1,53 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size26Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
@Composable
fun NewHighlightButton(nav: INav) {
FloatingActionButton(
onClick = {
nav.nav(Route.NewHighlight())
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
painter = painterRes(R.drawable.ic_compose, 4),
contentDescription = stringRes(R.string.new_highlight_title),
modifier = Size26Modifier,
tint = MaterialTheme.colorScheme.onPrimary,
)
}
}
@@ -0,0 +1,212 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.pTags
import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag
import com.vitorpamplona.quartz.nip01Core.tags.references.references
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris
import com.vitorpamplona.quartz.nip10Notes.content.findURLs
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
/**
* Backs the "New Highlight" composer. A NIP-84 highlight is a quoted passage (the event
* `content`), its source, and an optional annotation. The annotation reuses the short-note
* composer's rich [message] field via [IMessageField], so it gets @-mention and custom-emoji
* autocomplete and inline previews; on publish it becomes the highlight's `comment` tag with
* the mentions, emoji, URLs, hashtags and quotes it references emitted as their own tags.
*
* The passage, the `textquoteselector` prefix/suffix, the surrounding `context`, and the
* nostr source (`a`/`e`/`p`) are carried through from the share or the "Highlight this note"
* action. When a nostr event is the source, [originalNote] is resolved so the screen can
* render it as a reply-style preview instead of showing a URL field.
*/
@Stable
class NewHighlightPostViewModel :
ViewModel(),
IMessageField {
private var accountViewModel: AccountViewModel? = null
private var account: Account? = null
/** The highlighted passage — becomes the event `content`. */
var quote by mutableStateOf("")
/** The source URL — becomes an `r` tag. Hidden when a nostr event is the source. */
var url by mutableStateOf("")
/** The user's annotation — the rich comment field; becomes a `comment` tag. */
override val message = TextFieldState()
/** The source note, when highlighting a nostr article/note, for the reply-style preview. */
var originalNote by mutableStateOf<Note?>(null)
private set
var userSuggestions: UserSuggestionState? = null
var emojiSuggestions: EmojiSuggestionState? = null
private var prefix: String? = null
private var suffix: String? = null
private var context: String? = null
private var sourceAddress: String? = null
private var sourceEventId: String? = null
private var author: String? = null
private var loaded = false
fun init(accountViewModel: AccountViewModel) {
if (this.accountViewModel == accountViewModel) return
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
userSuggestions = UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder())
emojiSuggestions = EmojiSuggestionState(accountViewModel.account.emoji)
}
/**
* Applies the incoming source once, and resolves [originalNote] for a nostr source. Guarded
* so a recomposition can't clobber edits the user already made.
*/
fun load(
quote: String?,
url: String?,
prefix: String?,
suffix: String?,
comment: String?,
context: String?,
sourceAddress: String?,
sourceEventId: String?,
author: String?,
) {
if (loaded) return
loaded = true
this.quote = quote.orEmpty()
this.url = url.orEmpty()
comment?.ifBlank { null }?.let { message.setTextAndPlaceCursorAtEnd(it) }
this.prefix = prefix
this.suffix = suffix
this.context = context
this.sourceAddress = sourceAddress
this.sourceEventId = sourceEventId
this.author = author
val accountViewModel = accountViewModel
if (accountViewModel != null) {
originalNote =
when {
!sourceAddress.isNullOrBlank() -> Address.parse(sourceAddress)?.let { accountViewModel.getOrCreateAddressableNote(it) }
!sourceEventId.isNullOrBlank() -> accountViewModel.getOrCreateNote(sourceEventId)
else -> null
}
}
}
override fun onMessageChanged() {
if (message.selection.collapsed) {
val lastWord = message.currentWord()
if (lastWord.startsWith("@")) {
userSuggestions?.processCurrentWord(lastWord)
} else {
userSuggestions?.reset()
}
emojiSuggestions?.processCurrentWord(lastWord)
}
}
fun autocompleteWithUser(item: User) {
userSuggestions?.let {
val lastWord = message.currentWord()
it.replaceCurrentWord(message, lastWord, item)
it.reset()
}
}
fun autocompleteWithEmoji(item: EmojiMedia) {
emojiSuggestions?.autocompleteInto(message, item)
}
fun autocompleteWithEmojiUrl(item: EmojiMedia) {
message.replaceCurrentWord(item.link + " ")
emojiSuggestions?.reset()
}
fun canPost(): Boolean = quote.isNotBlank()
suspend fun sendHighlight() {
val account = account ?: return
val dao = accountViewModel ?: return
if (!canPost()) return
// Resolve @mentions, nostr: refs, emoji, URLs and hashtags out of the annotation the same
// way the short-note composer does, so a highlight comment behaves like any other note.
val tagger = NewMessageTagger(message.text.toString().trim(), null, null, dao)
tagger.run()
val commentText = tagger.message.ifBlank { null }
val mentions = tagger.directMentionsUsers.map { it.toPTag() }
val emojiTags = account.emoji.findEmojiTags(tagger.message)
val urls = findURLs(tagger.message)
val tags = findHashtags(tagger.message)
val quotes = findNostrUris(tagger.message)
account.signAndComputeBroadcast(
HighlightEvent.build(
quote = quote.trim(),
url = url.trim().ifBlank { null },
prefix = prefix,
suffix = suffix,
comment = commentText,
context = context,
address = sourceAddress,
event = sourceEventId,
author = author,
) {
if (mentions.isNotEmpty()) pTags(mentions)
references(urls)
hashtags(tags)
quotes(quotes)
emojis(emojiTags)
},
)
}
}
@@ -0,0 +1,329 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.amethyst.ui.theme.replyModifier
/** A warm highlighter amber — the highlight metaphor reads as yellow regardless of theme. */
private val MarkerAccent = Color(0xFFF5C518)
/**
* The "New Highlight" composer. Reached either from the "Add highlight" action, a browser
* share, or the "Highlight" note-action, routed in as
* [com.vitorpamplona.amethyst.ui.navigation.routes.Route.NewHighlight].
*
* A NIP-84 highlight is a quoted passage, its source, and an optional annotation:
* - the passage is a pull-quote you craft (accent bar + quotation-mark watermark),
* - the source is either a nostr event — rendered as a reply-style preview — or a web URL,
* - the annotation is the same rich composer field the short-note screen uses, so it gets
* @-mention and custom-emoji autocomplete and inline previews.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewHighlightScreen(
quote: String? = null,
url: String? = null,
prefix: String? = null,
suffix: String? = null,
comment: String? = null,
context: String? = null,
sourceAddress: String? = null,
sourceEventId: String? = null,
author: String? = null,
accountViewModel: AccountViewModel,
nav: Nav,
) {
val postViewModel: NewHighlightPostViewModel = viewModel()
postViewModel.init(accountViewModel)
WatchAndLoadMyEmojiList(accountViewModel)
LaunchedEffect(Unit) {
postViewModel.load(quote, url, prefix, suffix, comment, context, sourceAddress, sourceEventId, author)
}
Scaffold(
topBar = {
PostingTopBar(
titleRes = R.string.new_highlight_title,
isActive = postViewModel::canPost,
onCancel = { nav.popBack() },
onPost = {
// Uses the accountViewModel scope so releasing the post ViewModel on
// popBack can't cancel the in-flight publish.
accountViewModel.launchSigner {
postViewModel.sendHighlight()
nav.popBack()
}
},
)
},
) { pad ->
Column(
modifier =
Modifier
.padding(pad)
.consumeWindowInsets(pad)
.imePadding()
.fillMaxSize(),
) {
Column(
modifier =
Modifier
.weight(1f)
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
HighlightEditorCard(
passage = postViewModel.quote,
onPassageChange = { postViewModel.quote = it },
)
val source = postViewModel.originalNote
if (source != null) {
NoteCompose(
baseNote = source,
modifier = MaterialTheme.colorScheme.replyModifier,
isQuotedNote = true,
unPackReply = ReplyRenderType.NONE,
makeItShort = true,
quotesLeft = 1,
accountViewModel = accountViewModel,
nav = nav,
)
} else {
IconField(
symbol = MaterialSymbols.Link,
value = postViewModel.url,
onValueChange = { postViewModel.url = it },
label = stringRes(R.string.new_highlight_source_label),
placeholder = "https://example.com/article",
singleLine = true,
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
symbol = MaterialSymbols.EditNote,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(8.dp))
Text(
text = stringRes(R.string.new_highlight_note_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
MessageField(
placeholder = R.string.new_highlight_note_placeholder,
viewModel = postViewModel,
requestFocus = false,
)
}
postViewModel.userSuggestions?.let {
ShowUserSuggestionList(
it,
postViewModel::autocompleteWithUser,
accountViewModel,
modifier = SuggestionListDefaultHeightPage,
)
}
postViewModel.emojiSuggestions?.let {
ShowEmojiSuggestionList(
it,
postViewModel::autocompleteWithEmoji,
postViewModel::autocompleteWithEmojiUrl,
modifier = SuggestionListDefaultHeightPage,
)
}
}
}
}
/**
* The hero: a rounded, tonal card carrying a quotation-mark watermark, a highlighter-yellow
* accent bar, and the editable passage set in a large, comfortable type.
*/
@Composable
private fun HighlightEditorCard(
passage: String,
onPassageChange: (String) -> Unit,
) {
Surface(
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
tonalElevation = 2.dp,
modifier = Modifier.fillMaxWidth(),
) {
Box(modifier = Modifier.fillMaxWidth()) {
Icon(
symbol = MaterialSymbols.FormatQuote,
contentDescription = null,
tint = MarkerAccent.copy(alpha = 0.20f),
modifier =
Modifier
.align(Alignment.TopStart)
.offset(x = 8.dp, y = (-6).dp)
.size(80.dp),
)
Row(
modifier =
Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min)
.padding(20.dp),
) {
Spacer(
Modifier
.width(4.dp)
.fillMaxHeight()
.clip(RoundedCornerShape(2.dp))
.background(MarkerAccent),
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
BasicTextField(
value = passage,
onValueChange = onPassageChange,
textStyle =
LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.onSurface,
fontSize = 20.sp,
lineHeight = 1.4.em,
fontWeight = FontWeight.Medium,
),
cursorBrush = SolidColor(MarkerAccent),
modifier = Modifier.fillMaxWidth().heightIn(min = 88.dp),
decorationBox = { inner ->
if (passage.isEmpty()) {
Text(
text = stringRes(R.string.new_highlight_passage_placeholder),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
fontSize = 20.sp,
lineHeight = 1.4.em,
)
}
inner()
},
)
Spacer(Modifier.height(10.dp))
Text(
text = "${passage.length}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.End,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun IconField(
symbol: MaterialSymbol,
value: String,
onValueChange: (String) -> Unit,
label: String,
placeholder: String,
singleLine: Boolean,
minLines: Int = 1,
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(),
label = { Text(label) },
placeholder = { Text(placeholder) },
leadingIcon = {
Icon(symbol = symbol, contentDescription = null, modifier = Modifier.size(20.dp))
},
singleLine = singleLine,
minLines = minLines,
shape = RoundedCornerShape(16.dp),
)
}
@@ -0,0 +1,85 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
/**
* The client-side data-access layer for the Highlights feed. Mirrors
* `GitRepositoriesFeedFilter`, but highlights (kind 9802) are *regular* events rather than
* addressable ones, so it scans `LocalCache.notes` instead of `LocalCache.addressables`.
* The `FilterByListParams` handles the selected top-nav follow-list, hidden users and
* future-dated / spam checks.
*/
class HighlightsFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code
override fun limit() = 200
fun followList(): TopFilter = account.settings.defaultHighlightsFollowList.value
fun TopFilter.isMuteList() = this is TopFilter.MuteList
fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress()
fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList()
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
override fun feed(): List<Note> {
val params = buildFilterParams(account)
val notes =
LocalCache.notes.filterIntoSet { _, it ->
val noteEvent = it.event
noteEvent is HighlightEvent && params.match(noteEvent, it.relays)
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
account.liveHighlightsFollowLists.value,
account.hiddenUsers.flow.value,
)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val params = buildFilterParams(account)
return collection.filterTo(HashSet()) {
val noteEvent = it.event
noteEvent is HighlightEvent && params.match(noteEvent, it.relays)
}
}
override fun sort(items: Set<Note>): List<Note> = items.sortedByDefaultFeedOrder()
}
@@ -0,0 +1,60 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies.filterHighlightsByAuthors
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies.filterHighlightsByFollows
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies.filterHighlightsByGeohashes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies.filterHighlightsByHashtag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies.filterHighlightsByMutedAuthors
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies.filterHighlightsGlobal
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
/**
* Routes the resolved top-nav filter set (author/hashtag/geohash/global/follows, per relay,
* via the outbox model) to the matching subassembly that pins kind 9802 to specific relays.
*
* Highlights are not community-scoped, so — unlike git repositories — the community filter
* sets fall through to [emptyList]; those options aren't offered in the top-nav catalog
* anyway (see TopNavFilterState._highlightsRoutes).
*/
fun makeHighlightsFilter(
feedSettings: IFeedTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> =
when (feedSettings) {
is AllFollowsTopNavPerRelayFilterSet -> filterHighlightsByFollows(feedSettings, since, defaultSince)
is AuthorsTopNavPerRelayFilterSet -> filterHighlightsByAuthors(feedSettings, since, defaultSince)
is GlobalTopNavPerRelayFilterSet -> filterHighlightsGlobal(feedSettings, since, defaultSince)
is HashtagTopNavPerRelayFilterSet -> filterHighlightsByHashtag(feedSettings, since, defaultSince)
is LocationTopNavPerRelayFilterSet -> filterHighlightsByGeohashes(feedSettings, since, defaultSince)
is MutedAuthorsTopNavPerRelayFilterSet -> filterHighlightsByMutedAuthors(feedSettings, since, defaultSince)
else -> emptyList()
}
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class HighlightsQueryState(
val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
@Stable
class HighlightsFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<HighlightsQueryState>() {
val group =
listOf(
HighlightsSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun HighlightsFilterAssemblerSubscription(accountViewModel: AccountViewModel) {
HighlightsFilterAssemblerSubscription(
accountViewModel.dataSources().highlights,
accountViewModel,
)
}
@Composable
fun HighlightsFilterAssemblerSubscription(
dataSource: HighlightsFilterAssembler,
accountViewModel: AccountViewModel,
) {
val state =
remember(accountViewModel.account) {
HighlightsQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope)
}
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
}
@@ -0,0 +1,100 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
class HighlightsSubAssembler(
client: INostrClient,
allKeys: () -> Set<HighlightsQueryState>,
) : PerUserAndFollowListEoseManager<HighlightsQueryState, TopFilter>(client, allKeys) {
override fun updateFilter(
key: HighlightsQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
// "Mine" needs no special-case: the shared TopFilter.Mine flow resolves to an author
// filter scoped to the user, so followsPerRelay() already carries authors=[me] against the
// user's own outbox.
val feedSettings = key.followsPerRelay()
return makeHighlightsFilter(feedSettings, since, key.feedStates.highlightsFeed.lastNoteCreatedAtIfFilled())
}
override fun user(key: HighlightsQueryState) = key.account.userProfile()
override fun list(key: HighlightsQueryState) = key.listName()
fun HighlightsQueryState.listNameFlow() = account.settings.defaultHighlightsFollowList
fun HighlightsQueryState.listName() = listNameFlow().value
fun HighlightsQueryState.followsPerRelayFlow() = account.liveHighlightsFollowListsPerRelay
fun HighlightsQueryState.followsPerRelay() = followsPerRelayFlow().value
val userJobMap = mutableMapOf<User, List<Job>>()
@OptIn(FlowPreview::class)
override fun newSub(key: HighlightsQueryState): Subscription {
val user = user(key)
userJobMap[user]?.forEach { it.cancel() }
userJobMap[user] =
listOf(
key.scope.launch(Dispatchers.IO) {
key.listNameFlow().collectLatest {
invalidateFilters()
}
},
key.scope.launch(Dispatchers.IO) {
key.followsPerRelayFlow().sample(500).collectLatest {
invalidateFilters()
}
},
key.account.scope.launch(Dispatchers.IO) {
key.feedStates.highlightsFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
invalidateFilters()
}
},
)
return super.newSub(key)
}
override fun endSub(
key: User,
subId: String,
) {
super.endSub(key, subId)
userJobMap[key]?.forEach { it.cancel() }
}
}
@@ -0,0 +1,92 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
fun filterHighlightsByAuthors(
relay: NormalizedRelayUrl,
authors: Set<HexKey>,
since: Long? = null,
): List<RelayBasedFilter> {
val authorList = authors.sorted()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
authors = authorList,
kinds = listOf(HighlightEvent.KIND),
limit = 200,
since = since,
),
),
)
}
fun filterHighlightsByAuthors(
authorSet: AuthorsTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (authorSet.set.isEmpty()) return emptyList()
return authorSet.set
.mapNotNull {
if (it.value.authors.isEmpty()) {
null
} else {
filterHighlightsByAuthors(
relay = it.key,
authors = it.value.authors,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}.flatten()
}
fun filterHighlightsByMutedAuthors(
authorSet: MutedAuthorsTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (authorSet.set.isEmpty()) return emptyList()
return authorSet.set
.mapNotNull {
if (it.value.authors.isEmpty()) {
null
} else {
filterHighlightsByAuthors(
relay = it.key,
authors = it.value.authors,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}.flatten()
}
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
fun filterHighlightsByFollows(
followsSet: AllFollowsTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (followsSet.set.isEmpty()) return emptyList()
return followsSet.set.flatMap {
val since = since?.get(it.key)?.time ?: defaultSince
val relay = it.key
listOfNotNull(
it.value.authors?.let {
filterHighlightsByAuthors(relay, it, since)
},
).flatten()
}
}
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
fun filterHighlightsByGeohashes(
relay: NormalizedRelayUrl,
geotags: Set<String>,
since: Long?,
): List<RelayBasedFilter> {
if (geotags.isEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(HighlightEvent.KIND),
tags = mapOf("g" to geotags.sorted()),
limit = 100,
since = since,
),
),
)
}
fun filterHighlightsByGeohashes(
geoSet: LocationTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long?,
): List<RelayBasedFilter> {
if (geoSet.set.isEmpty()) return emptyList()
return geoSet.set
.mapNotNull {
if (it.value.geotags.isEmpty()) {
null
} else {
filterHighlightsByGeohashes(
relay = it.key,
geotags = it.value.geotags,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}.flatten()
}
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
fun filterHighlightsByHashtag(
relay: NormalizedRelayUrl,
hashtags: Set<String>,
since: Long? = null,
): List<RelayBasedFilter> =
listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(HighlightEvent.KIND),
tags = mapOf("t" to hashtags.toList()),
limit = 200,
since = since,
),
),
)
fun filterHighlightsByHashtag(
hashtagSet: HashtagTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (hashtagSet.set.isEmpty()) return emptyList()
return hashtagSet.set
.mapNotNull { relayHashSet ->
if (relayHashSet.value.hashtags.isEmpty()) {
null
} else {
filterHighlightsByHashtag(
relay = relayHashSet.key,
hashtags = relayHashSet.value.hashtags,
since = since?.get(relayHashSet.key)?.time ?: defaultSince,
)
}
}.flatten()
}
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.utils.TimeUtils
fun filterHighlightsGlobal(
relays: GlobalTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (relays.set.isEmpty()) return emptyList()
return relays.set.map {
val since = since?.get(it.key)?.time ?: defaultSince ?: TimeUtils.oneWeekAgo()
RelayBasedFilter(
relay = it.key,
filter =
Filter(
kinds = listOf(HighlightEvent.KIND),
limit = 200,
since = since,
),
)
}
}
+9
View File
@@ -2764,6 +2764,14 @@
<string name="share_target_as_dm">Send as DM</string>
<string name="share_to_dm_title">Send to…</string>
<string name="share_to_dm_start_new">New message</string>
<string name="share_target_as_highlight">New Highlight</string>
<string name="new_highlight_title">New Highlight</string>
<string name="new_highlight_passage_label">Highlighted text</string>
<string name="new_highlight_passage_placeholder">What stood out to you?</string>
<string name="new_highlight_source_label">Source URL</string>
<string name="new_highlight_note_label">Your note (optional)</string>
<string name="new_highlight_note_placeholder">Add your thoughts…</string>
<string name="highlight_action">Highlight</string>
<string name="copy_url_to_clipboard">Copy URL to clipboard</string>
<string name="copy_the_note_id_to_the_clipboard">Copy Note ID to clipboard</string>
<string name="add_media_to_gallery">Add Media to Gallery</string>
@@ -3807,6 +3815,7 @@
<string name="git_repo_settings_topics">Topics (comma separated)</string>
<string name="git_repo_settings_save">Save</string>
<string name="git_repositories">Git Repositories</string>
<string name="highlights">Highlights</string>
<string name="nsite_title">nSite: %1$s</string>
<string name="napplet_card_title">nApplet: %1$s</string>
<string name="napplet_card_permissions">Permissions:</string>
@@ -22,13 +22,16 @@ package com.vitorpamplona.quartz.nip84Highlights
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.aTag.firstTaggedATag
import com.vitorpamplona.quartz.nip01Core.tags.aTag.firstTaggedAddress
@@ -190,5 +193,99 @@ class HighlightEvent(
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): HighlightEvent = signer.sign(createdAt, KIND, emptyArray(), msg)
/**
* Builds a fully-tagged NIP-84 highlight from the pieces a browser share (or the
* highlight composer) produces. The highlighted passage becomes the event `content`;
* the remaining inputs are emitted as their NIP-84 tags when present:
*
* - [address] → an `a` reference to a nostr addressable source (e.g. a NIP-23 article),
* - [event] → an `e` reference to a specific nostr event version highlighted,
* - [author] → a `p` attribution to the highlighted content's author,
* - [url] → an `r` source reference (normalized by [ReferenceTag]; clean it of
* trackers with [com.vitorpamplona.quartz.nip84Highlights.parse.UrlTrackerCleaner] first),
* - [prefix]/[suffix] → a `textquoteselector` anchor (the `exact` field stays a
* placeholder since the passage already lives in `content`),
* - [context] → the surrounding paragraph as a `context` tag,
* - [comment] → the user's own note as a `comment` tag (turns it into a quote highlight).
*
* This covers every NIP-84 source: a web page ([url]), a nostr article ([address]),
* a nostr note ([event]), each with optional author attribution and the user's note.
*/
suspend fun create(
quote: String,
url: String? = null,
prefix: String? = null,
suffix: String? = null,
comment: String? = null,
context: String? = null,
address: String? = null,
event: String? = null,
author: String? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): HighlightEvent = signer.sign(createdAt, KIND, assembleTags(url, prefix, suffix, comment, context, address, event, author), quote)
/**
* The unsigned [EventTemplate] counterpart of [create], for the app's
* sign-and-broadcast pipeline (`account.signAndComputeBroadcast(...)`). Same tag
* assembly; the caller supplies the signer.
*/
fun build(
quote: String,
url: String? = null,
prefix: String? = null,
suffix: String? = null,
comment: String? = null,
context: String? = null,
address: String? = null,
event: String? = null,
author: String? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<HighlightEvent>.() -> Unit = {},
): EventTemplate<HighlightEvent> =
eventTemplate(KIND, quote, createdAt) {
addAll(assembleTags(url, prefix, suffix, comment, context, address, event, author))
initializer()
}
private fun assembleTags(
url: String?,
prefix: String?,
suffix: String?,
comment: String?,
context: String?,
address: String? = null,
event: String? = null,
author: String? = null,
): Array<Array<String>> {
val tags = mutableListOf<Array<String>>()
if (!address.isNullOrBlank()) {
tags.add(ATag.assemble(address, null))
}
if (!event.isNullOrBlank()) {
tags.add(ETag.assemble(event, null, null))
}
if (!author.isNullOrBlank()) {
// Mark the role so [author] attributes to this p tag even when the highlight also
// carries `mention` p tags — the producer-side counterpart of that reader logic.
tags.add(arrayOf(PTag.TAG_NAME, author, "", AUTHOR_MARKER))
}
if (!url.isNullOrBlank()) {
tags.add(ReferenceTag.assemble(url))
}
if (!prefix.isNullOrEmpty() || !suffix.isNullOrEmpty()) {
tags.add(TextQuoteSelectorTag.assemble(null, prefix, suffix))
}
if (!context.isNullOrBlank()) {
tags.add(ContextTag.assemble(context))
}
if (!comment.isNullOrBlank()) {
tags.add(CommentTag.assemble(comment))
}
return tags.toTypedArray()
}
}
}
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip84Highlights.parse
/**
* The structured result of parsing a browser share into the pieces a NIP-84 kind:9802
* highlight needs. Produced by [SharedHighlightParser]; consumed by
* [com.vitorpamplona.quartz.nip84Highlights.HighlightEvent.Companion.create] and by the
* highlight composer UI (which pre-fills its fields and lets the user confirm/edit before
* signing).
*
* @property quote the highlighted passage → the event `content`
* @property url the cleaned source URL (trackers and text-fragment stripped) → an `r` tag
* @property prefix text just before the highlight, for a `textquoteselector` anchor
* @property suffix text just after the highlight, for a `textquoteselector` anchor
*/
class SharedHighlight(
val quote: String?,
val url: String?,
val prefix: String?,
val suffix: String?,
) {
/** True when nothing usable was found (neither a passage nor a source URL). */
fun isEmpty(): Boolean = quote.isNullOrBlank() && url.isNullOrBlank()
/** True when there is at least a highlighted passage or a source URL. */
fun isNotEmpty(): Boolean = !isEmpty()
fun hasSelector(): Boolean = !prefix.isNullOrEmpty() || !suffix.isNullOrEmpty()
}
@@ -0,0 +1,144 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip84Highlights.parse
/**
* Turns the free-form text a browser hands Amethyst on "Share selection" into the pieces of
* a NIP-84 highlight. The share intent's plain text can arrive in several shapes and this
* parser normalises all of them:
*
* - **Selection only** — `"Some highlighted sentence."` → quote, no URL.
* - **Selection + page URL** — `"Some highlighted sentence."\n\nhttps://example.com/post`
* (what many browsers and read-it-later apps emit) → quote + source URL.
* - **URL only** — `https://example.com/post` → source URL, no quote yet.
* - **Link to highlight** — `https://example.com/post#:~:text=prefix-,Some%20sentence,-after`
* (Chrome/Edge/Safari "Copy link to highlight") → the passage decoded from the text
* fragment plus its prefix/suffix anchors, with the fragment stripped off the stored URL.
*
* The URL is always cleaned of tracking parameters ([UrlTrackerCleaner]) and of its
* text-fragment directive ([TextFragmentParser]) before being returned. Surrounding quote
* marks the browser wraps around the selection are trimmed off the passage.
*
* The result is a best-effort pre-fill: the composer screen lets the user confirm and edit
* every field before the event is signed.
*/
object SharedHighlightParser {
private val URL_REGEX = Regex("""https?://\S+""", RegexOption.IGNORE_CASE)
// Trailing punctuation that is part of the surrounding sentence, not the URL token.
// Closing brackets are handled separately (balance-aware) so a Wikipedia URL such as
// `.../wiki/Mercury_(planet)` keeps its trailing `)`.
private const val URL_TRAILING_TRIM = ".,;:!?>\"'»”’"
private val OPEN_TO_CLOSE = mapOf('(' to ')', '[' to ']', '{' to '}')
// Quote marks a browser may wrap around a shared selection (straight, curly, guillemets).
private const val QUOTE_CHARS = "\"'“”‘’«»"
fun parse(shared: String): SharedHighlight {
val input = shared.trim()
if (input.isEmpty()) return SharedHighlight(null, null, null, null)
// The source URL is normally appended after the selection, so prefer the last URL in
// the string (a URL inside the highlighted text itself stays part of the quote).
val match = URL_REGEX.findAll(input).lastOrNull()
var url: String? = null
var prefix: String? = null
var suffix: String? = null
var fragmentQuote: String? = null
var remainder = input
if (match != null) {
val rawToken = match.value
val rawUrl = trimUrlEnd(rawToken)
val fragment = TextFragmentParser.parse(rawUrl)
prefix = fragment?.prefix
suffix = fragment?.suffix
fragmentQuote = fragment?.start
val stripped = TextFragmentParser.stripTextFragment(rawUrl)
url = UrlTrackerCleaner.clean(stripped).takeIf { it.isNotBlank() }
// Remove the whole matched token (incl. any trailing punctuation) from the passage.
remainder = dropDanglingOpenBracket(input.removeRange(match.range).trim())
}
val quote = cleanQuote(remainder) ?: fragmentQuote?.let { cleanQuote(it) }
return SharedHighlight(
quote = quote,
url = url,
prefix = prefix,
suffix = suffix,
)
}
/**
* Strips trailing sentence punctuation the URL token accidentally swallowed. A closing
* bracket is only stripped when it is unbalanced within the token — so a wrapping
* `(https://example.com)` loses its `)`, but `.../Mercury_(planet)` keeps it.
*/
private fun trimUrlEnd(token: String): String {
var end = token.length
while (end > 0) {
val c = token[end - 1]
when {
c in URL_TRAILING_TRIM -> end--
c == ')' || c == ']' || c == '}' -> {
val open = OPEN_TO_CLOSE.entries.first { it.value == c }.key
val opens = token.count { it == open }
val closes = token.take(end).count { it == c }
if (closes > opens) end-- else return token.substring(0, end)
}
else -> return token.substring(0, end)
}
}
return token.substring(0, end)
}
/**
* Drops a bracket left dangling at the end of the passage once the URL token carried its
* closing partner away. `See this quote (https://example.com/article)` loses the wrapping `)`
* in [trimUrlEnd], which would otherwise publish the passage as `See this quote (`.
*
* Only an *unbalanced* trailing bracket goes, so `He said (see below) https://…` keeps its
* matched pair, and a bracket anywhere but the very end is left alone — the URL removal can
* only ever orphan one at the tail.
*/
private fun dropDanglingOpenBracket(text: String): String {
var out = text.trimEnd()
while (out.isNotEmpty()) {
val open = out.last()
val close = OPEN_TO_CLOSE[open] ?: return out
if (out.count { it == open } <= out.count { it == close }) return out
out = out.dropLast(1).trimEnd()
}
return out
}
/** Trims surrounding whitespace and matching quote marks; returns null when nothing is left. */
private fun cleanQuote(text: String): String? {
val trimmed = text.trim { it.isWhitespace() || it in QUOTE_CHARS }
return trimmed.ifBlank { null }
}
}
@@ -0,0 +1,156 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip84Highlights.parse
/**
* The decoded pieces of a WICG Text Fragment directive
* (`#:~:text=[prefix-,]textStart[,textEnd][,-suffix]`), the anchor browsers append when a
* user shares a "link to highlight" of selected text.
*
* All fields are percent-decoded and never blank (empty pieces become null).
*
* @property prefix the text immediately before the match, used for disambiguation
* @property start the beginning of the matched (highlighted) text — the whole match when [end] is null
* @property end the end of the matched text when the browser split a long selection into start/end bounds
* @property suffix the text immediately after the match, used for disambiguation
*/
class TextFragment(
val prefix: String?,
val start: String?,
val end: String?,
val suffix: String?,
)
/**
* Parses (and strips) WICG Text Fragment directives from a URL.
*
* Text fragments live in the URL fragment after a `:~:` delimiter, e.g.
* `https://example.com/page#:~:text=prefix-,highlighted%20text,-suffix`. This is what
* Chrome/Edge/Safari emit when a user shares a link to selected text; the same encoding
* is used by the "Copy link to highlight" and text-selection share actions.
*/
object TextFragmentParser {
private const val DIRECTIVE_DELIMITER = ":~:"
private const val TEXT_PARAM = "text="
/**
* Extracts the first `text=` directive from [url]'s fragment, or null when there is
* no text fragment. Only the first `text=` directive is read — a URL may carry several
* (`&text=`), but a single highlight maps to one passage.
*/
fun parse(url: String): TextFragment? {
val hashIndex = url.indexOf('#')
if (hashIndex < 0) return null
val fragment = url.substring(hashIndex + 1)
val directiveIndex = fragment.indexOf(DIRECTIVE_DELIMITER)
if (directiveIndex < 0) return null
val directives = fragment.substring(directiveIndex + DIRECTIVE_DELIMITER.length)
val textParam = directives.split("&").firstOrNull { it.startsWith(TEXT_PARAM) } ?: return null
val value = textParam.substring(TEXT_PARAM.length)
if (value.isEmpty()) return null
// Commas that belong to the highlighted text itself are percent-encoded (%2C), so the
// raw commas here are always the directive's own start/end/prefix/suffix separators.
val tokens = value.split(",").toMutableList()
var prefix: String? = null
var suffix: String? = null
if (tokens.isNotEmpty() && tokens.first().endsWith("-")) {
prefix = tokens.removeAt(0).dropLast(1)
}
if (tokens.isNotEmpty() && tokens.last().startsWith("-")) {
suffix = tokens.removeAt(tokens.size - 1).drop(1)
}
val start = tokens.getOrNull(0)
val end = tokens.getOrNull(1)
return TextFragment(
prefix = decode(prefix),
start = decode(start),
end = decode(end),
suffix = decode(suffix),
)
}
/**
* Returns [url] with any `:~:` text-fragment directive removed, so it can be stored as a
* clean `r` source reference. A surrounding `#` that only introduced the directive is
* dropped too; a real element-id fragment before the `:~:` is kept.
*/
fun stripTextFragment(url: String): String {
val hashIndex = url.indexOf('#')
if (hashIndex < 0) return url
val fragment = url.substring(hashIndex + 1)
val directiveIndex = fragment.indexOf(DIRECTIVE_DELIMITER)
if (directiveIndex < 0) return url
val beforeDirective = fragment.substring(0, directiveIndex)
val base = url.substring(0, hashIndex)
return if (beforeDirective.isEmpty()) base else "$base#$beforeDirective"
}
private fun decode(value: String?): String? {
if (value.isNullOrEmpty()) return null
return percentDecode(value).takeIf { it.isNotEmpty() }
}
/**
* Percent-decodes a text-fragment component. Unlike form decoding it leaves `+`
* verbatim (a literal plus in the text is `+`, not a space — spaces arrive as `%20`),
* and decodes multi-byte UTF-8 sequences byte-by-byte.
*/
private fun percentDecode(input: String): String {
if (!input.contains('%')) return input
val bytes = ArrayList<Byte>(input.length)
var i = 0
while (i < input.length) {
val c = input[i]
if (c == '%' && i + 2 < input.length) {
val hi = hexValue(input[i + 1])
val lo = hexValue(input[i + 2])
if (hi >= 0 && lo >= 0) {
bytes.add(((hi shl 4) or lo).toByte())
i += 3
continue
}
}
// Non-escape character: re-encode as UTF-8 so it round-trips with decoded bytes.
c.toString().encodeToByteArray().forEach { bytes.add(it) }
i++
}
return bytes.toByteArray().decodeToString()
}
private fun hexValue(c: Char): Int =
when (c) {
in '0'..'9' -> c - '0'
in 'a'..'f' -> c - 'a' + 10
in 'A'..'F' -> c - 'A' + 10
else -> -1
}
}
@@ -0,0 +1,100 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip84Highlights.parse
/**
* Strips known tracking parameters from a URL's query string.
*
* NIP-84 asks clients to "do a best effort of cleaning the URL from trackers" before
* tagging the source of a highlight, so the same passage highlighted from two different
* campaign links collapses to one canonical `r` tag instead of leaking the sharer's
* `utm_*`/`fbclid`/etc. attribution into the published event.
*
* Only the query component is touched — the path and any fragment (including a
* `#:~:text=` directive) are preserved verbatim.
*/
object UrlTrackerCleaner {
/**
* Exact parameter names known to be pure tracking/attribution noise. Names are matched
* case-insensitively. Any parameter whose name starts with `utm_` is also dropped
* regardless of this set.
*/
private val TRACKER_PARAMS =
setOf(
"fbclid",
"gclid",
"gclsrc",
"gbraid",
"wbraid",
"dclid",
"msclkid",
"yclid",
"twclid",
"ttclid",
"igshid",
"igsh",
"mc_eid",
"mc_cid",
"mkt_tok",
"_hsenc",
"_hsmi",
"vero_id",
"vero_conv",
"oly_anon_id",
"oly_enc_id",
"wickedid",
"ncid",
"s_cid",
"cmpid",
"spm",
"scm",
"ref_src",
"ref_url",
"_ga",
)
private fun isTracker(name: String): Boolean {
val lower = name.lowercase()
return lower.startsWith("utm_") || lower in TRACKER_PARAMS
}
fun clean(url: String): String {
val queryStart = url.indexOf('?')
if (queryStart < 0) return url
// Keep any fragment (element id and/or `:~:text=` directive) untouched.
val fragmentStart = url.indexOf('#', queryStart)
val query = if (fragmentStart >= 0) url.substring(queryStart + 1, fragmentStart) else url.substring(queryStart + 1)
val fragment = if (fragmentStart >= 0) url.substring(fragmentStart) else ""
val base = url.substring(0, queryStart)
val kept =
query
.split("&")
.filter { it.isNotEmpty() && !isTracker(it.substringBefore("=")) }
return if (kept.isEmpty()) {
base + fragment
} else {
base + "?" + kept.joinToString("&") + fragment
}
}
}
@@ -0,0 +1,182 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip84Highlights
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip84Highlights.parse.SharedHighlightParser
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class HighlightEventBuilderTest {
private val signer = NostrSignerInternal(KeyPair())
@Test
fun buildsBarePassageWithNoTags() =
runTest {
val event = HighlightEvent.create(quote = "just a passage", signer = signer)
assertEquals(HighlightEvent.KIND, event.kind)
assertEquals("just a passage", event.quote())
assertTrue(event.tags.isEmpty())
}
@Test
fun buildsSourceReferenceTag() =
runTest {
val event =
HighlightEvent.create(
quote = "a passage",
url = "https://example.com/post",
signer = signer,
)
assertEquals("https://example.com/post", event.inUrl())
}
@Test
fun buildsSelectorFromPrefixSuffix() =
runTest {
val event =
HighlightEvent.create(
quote = "the passage",
url = "https://example.com/post",
prefix = "before ",
suffix = " after",
signer = signer,
)
val selector = event.textQuoteSelector()
assertNull(selector?.exact) // placeholder — the passage is in .content
assertEquals("before ", selector?.prefix)
assertEquals(" after", selector?.suffix)
}
@Test
fun omitsSelectorWhenNoPrefixOrSuffix() =
runTest {
val event =
HighlightEvent.create(
quote = "the passage",
url = "https://example.com/post",
signer = signer,
)
assertNull(event.textQuoteSelector())
}
@Test
fun buildsCommentAndContextTags() =
runTest {
val event =
HighlightEvent.create(
quote = "the passage",
url = "https://example.com/post",
comment = "my note about it",
context = "The surrounding paragraph with the passage in it.",
signer = signer,
)
assertEquals("my note about it", event.comment())
assertEquals("The surrounding paragraph with the passage in it.", event.context())
}
@Test
fun blankOptionalsAreSkipped() =
runTest {
val event =
HighlightEvent.create(
quote = "the passage",
url = " ",
comment = "",
context = " ",
signer = signer,
)
assertTrue(event.tags.isEmpty())
}
@Test
fun buildsNostrSourceTags() =
runTest {
val article = "30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:my-article"
val author = "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"
val version = "8d7ae10a57ef178a17563a6ecbf9a399bb1796a2e032ca72703b00913b4cfd42"
val event =
HighlightEvent.create(
quote = "a passage from an article",
address = article,
event = version,
author = author,
signer = signer,
)
assertEquals(article, event.inPostAddress()?.toValue())
assertEquals(version, event.inPostVersion()?.eventId)
assertEquals(author, event.author())
// The p tag carries the NIP-84 "author" role so attribution survives mention p tags.
assertTrue(event.tags.any { it[0] == "p" && it[1] == author && it.getOrNull(3) == "author" })
}
@Test
fun buildProducesUnsignedTemplateWithSameTags() {
val template =
HighlightEvent.build(
quote = "the passage",
url = "https://example.com/post",
prefix = "before ",
comment = "note",
)
assertEquals(HighlightEvent.KIND, template.kind)
assertEquals("the passage", template.content)
assertTrue(template.tags.any { it[0] == "r" && it[1] == "https://example.com/post" })
assertTrue(template.tags.any { it[0] == "textquoteselector" })
assertTrue(template.tags.any { it[0] == "comment" && it[1] == "note" })
}
@Test
fun roundTripsFromSharedHighlightParser() =
runTest {
val parsed =
SharedHighlightParser.parse(
"https://example.com/post?utm_source=x#:~:text=the%20-,highlighted%20passage,-follows",
)
val event =
HighlightEvent.create(
quote = parsed.quote!!,
url = parsed.url,
prefix = parsed.prefix,
suffix = parsed.suffix,
signer = signer,
)
assertEquals("highlighted passage", event.quote())
assertEquals("https://example.com/post", event.inUrl())
assertEquals("the ", event.textQuoteSelector()?.prefix)
assertEquals("follows", event.textQuoteSelector()?.suffix)
}
}
@@ -0,0 +1,210 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip84Highlights.parse
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class SharedHighlightParserTest {
@Test
fun emptyInputYieldsEmptyResult() {
val result = SharedHighlightParser.parse(" ")
assertTrue(result.isEmpty())
assertNull(result.quote)
assertNull(result.url)
}
@Test
fun selectionOnly() {
val result = SharedHighlightParser.parse("Nostr is a simple, open protocol.")
assertEquals("Nostr is a simple, open protocol.", result.quote)
assertNull(result.url)
assertTrue(result.isNotEmpty())
}
@Test
fun urlOnly() {
val result = SharedHighlightParser.parse("https://example.com/post")
assertNull(result.quote)
assertEquals("https://example.com/post", result.url)
}
@Test
fun selectionThenUrlOnSeparateLines() {
val result =
SharedHighlightParser.parse("Nostr is a simple, open protocol.\n\nhttps://example.com/post")
assertEquals("Nostr is a simple, open protocol.", result.quote)
assertEquals("https://example.com/post", result.url)
}
@Test
fun stripsWrappingQuotesFromSelection() {
val result = SharedHighlightParser.parse("\"Nostr is great\" https://example.com/post")
assertEquals("Nostr is great", result.quote)
assertEquals("https://example.com/post", result.url)
}
@Test
fun stripsCurlyQuotesAndGuillemets() {
assertEquals("inside", SharedHighlightParser.parse("“inside”").quote)
assertEquals("inside", SharedHighlightParser.parse("«inside»").quote)
}
@Test
fun cleansTrackersFromSharedUrl() {
val result =
SharedHighlightParser.parse("Some quote\n\nhttps://example.com/post?utm_source=twitter&id=9")
assertEquals("Some quote", result.quote)
assertEquals("https://example.com/post?id=9", result.url)
}
@Test
fun linkToHighlightWithoutSeparateSelectionUsesFragmentText() {
val result =
SharedHighlightParser.parse(
"https://example.com/post#:~:text=the%20-,highlighted%20passage,-and%20on",
)
assertEquals("highlighted passage", result.quote)
assertEquals("https://example.com/post", result.url)
assertEquals("the ", result.prefix)
assertEquals("and on", result.suffix)
assertTrue(result.hasSelector())
}
@Test
fun explicitSelectionWinsOverFragmentTextButKeepsAnchors() {
// The browser shared the exact selection AND a link-to-highlight; keep the readable
// selection as the passage but retain the prefix/suffix anchors from the fragment.
val result =
SharedHighlightParser.parse(
"The full readable passage.\n\nhttps://example.com/post#:~:text=before-,The%20full,-after",
)
assertEquals("The full readable passage.", result.quote)
assertEquals("https://example.com/post", result.url)
assertEquals("before", result.prefix)
assertEquals("after", result.suffix)
}
@Test
fun trailingSentencePunctuationNotSwallowedIntoUrl() {
val result = SharedHighlightParser.parse("See (https://example.com/post).")
assertEquals("https://example.com/post", result.url)
}
@Test
fun keepsBalancedTrailingParenInUrl() {
// A Wikipedia article whose slug ends in "(planet)" — the closing paren is part of
// the URL, not sentence punctuation.
val result =
SharedHighlightParser.parse("Mercury is small.\n\nhttps://en.wikipedia.org/wiki/Mercury_(planet)")
assertEquals("https://en.wikipedia.org/wiki/Mercury_(planet)", result.url)
}
@Test
fun stripsOnlyTheWrappingParenNotTheSlugParen() {
val result =
SharedHighlightParser.parse("(https://en.wikipedia.org/wiki/Mercury_(planet))")
assertEquals("https://en.wikipedia.org/wiki/Mercury_(planet)", result.url)
}
@Test
fun dropsTheOpenParenOrphanedByTheUrlTrim() {
// The wrapping ")" leaves with the URL in trimUrlEnd; the "(" it opened must not be
// left dangling as the tail of the published passage.
val result = SharedHighlightParser.parse("See this quote (https://example.com/article)")
assertEquals("https://example.com/article", result.url)
assertEquals("See this quote", result.quote)
}
@Test
fun keepsAMatchedBracketPairInThePassage() {
// Nothing was orphaned here, so the passage keeps its own parenthetical intact.
val result = SharedHighlightParser.parse("He said (see below) https://example.com/a")
assertEquals("https://example.com/a", result.url)
assertEquals("He said (see below)", result.quote)
}
@Test
fun keepsTheSlugParenCaseQuoteIntact() {
// Regression guard for the opposite direction: nothing was trimmed off this URL, so
// the passage must be untouched too.
val result =
SharedHighlightParser.parse("Mercury is small.\n\nhttps://en.wikipedia.org/wiki/Mercury_(planet)")
assertEquals("https://en.wikipedia.org/wiki/Mercury_(planet)", result.url)
assertEquals("Mercury is small.", result.quote)
}
@Test
fun urlInsideSelectionStaysWithQuoteWhenSourceAppended() {
// The last URL is treated as the source; an earlier URL inside the passage is kept.
val result =
SharedHighlightParser.parse("Visit https://inside.example first.\n\nhttps://source.example/a")
assertEquals("Visit https://inside.example first.", result.quote)
assertEquals("https://source.example/a", result.url)
}
@Test
fun noSelectorWhenPlainUrl() {
val result = SharedHighlightParser.parse("quote\n\nhttps://example.com")
assertFalse(result.hasSelector())
assertNull(result.prefix)
assertNull(result.suffix)
}
@Test
fun realWorldChromeCopyLinkWithPrefixSuffixAndEncodedHyphen() {
// Chrome "copy link to highlight": a short selection with prefix/suffix anchors, and an
// encoded hyphen (%2D "pró-Irã") inside the prefix that must not be read as a delimiter.
val result =
SharedHighlightParser.parse(
"\"baseados\"\n https://g1.globo.com/mundo/noticia/2026/07/28/ira-rompe-tregua-e-lanca-misseis-balisticos-contra-bases-dos-eua-no-oriente-medio.ghtml" +
"#:~:text=Saudita%20lan%C3%A7aram%20ataques%20em%20conjunto%20contra%20militantes%20pr%C3%B3%2DIr%C3%A3-,baseados,-no%20Iraque.",
)
assertEquals("baseados", result.quote)
assertEquals(
"https://g1.globo.com/mundo/noticia/2026/07/28/ira-rompe-tregua-e-lanca-misseis-balisticos-contra-bases-dos-eua-no-oriente-medio.ghtml",
result.url,
)
assertEquals("Saudita lançaram ataques em conjunto contra militantes pró-Irã", result.prefix)
assertEquals("no Iraque", result.suffix)
}
@Test
fun realWorldChromeCopyLinkStartOnlyWithEncodedCommas() {
// A long start-only fragment whose commas are encoded (%2C) so they don't split the
// directive, and whose apostrophe () inside the passage must survive quote-trimming.
val passage =
"cherish those corners. Geohots blog is one such corner that I rediscovered recently. " +
"Oh, what a joy to read something human from an actual human. His stuff makes me cry, laugh, and everything in"
val result =
SharedHighlightParser.parse(
"\"$passage\"\n https://dergigi.com/2026/07/28/typing/" +
"#:~:text=cherish%20those%20corners.%20Geohot%E2%80%99s%20blog%20is%20one%20such%20corner%20that%20I%20rediscovered%20recently.%20Oh%2C%20what%20a%20joy%20to%20read%20something%20human%20from%20an%20actual%20human.%20His%20stuff%20makes%20me%20cry%2C%20laugh%2C%20and%20everything%20in",
)
assertEquals(passage, result.quote)
assertEquals("https://dergigi.com/2026/07/28/typing/", result.url)
assertNull(result.prefix)
assertNull(result.suffix)
}
}
@@ -0,0 +1,118 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip84Highlights.parse
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class TextFragmentParserTest {
@Test
fun returnsNullWhenNoFragment() {
assertNull(TextFragmentParser.parse("https://example.com/post"))
}
@Test
fun returnsNullForPlainElementFragment() {
assertNull(TextFragmentParser.parse("https://example.com/post#section-2"))
}
@Test
fun parsesStartOnly() {
val fragment = TextFragmentParser.parse("https://example.com/post#:~:text=hello%20world")
assertEquals("hello world", fragment?.start)
assertNull(fragment?.prefix)
assertNull(fragment?.suffix)
assertNull(fragment?.end)
}
@Test
fun parsesPrefixStartSuffix() {
val fragment =
TextFragmentParser.parse(
"https://example.com/post#:~:text=the%20-,highlighted%20passage,-follows%20on",
)
assertEquals("the ", fragment?.prefix)
assertEquals("highlighted passage", fragment?.start)
assertEquals("follows on", fragment?.suffix)
}
@Test
fun parsesStartAndEndRange() {
val fragment =
TextFragmentParser.parse("https://example.com/post#:~:text=start%20of,end%20of")
assertEquals("start of", fragment?.start)
assertEquals("end of", fragment?.end)
}
@Test
fun decodesEncodedCommaWithinText() {
// A comma that belongs to the passage arrives percent-encoded (%2C) so it is not
// mistaken for the directive's own start/end separator.
val fragment = TextFragmentParser.parse("https://example.com/post#:~:text=one%2C%20two%2C%20three")
assertEquals("one, two, three", fragment?.start)
assertNull(fragment?.end)
}
@Test
fun leavesLiteralPlusVerbatim() {
// Form decoding would turn `+` into a space; a text fragment must not.
val fragment = TextFragmentParser.parse("https://example.com/post#:~:text=c%2B%2B%20rocks")
assertEquals("c++ rocks", fragment?.start)
}
@Test
fun readsFirstTextDirectiveWhenSeveral() {
val fragment =
TextFragmentParser.parse("https://example.com/post#:~:text=first&text=second")
assertEquals("first", fragment?.start)
}
@Test
fun parsesDirectiveAfterElementId() {
val fragment = TextFragmentParser.parse("https://example.com/post#heading:~:text=quoted")
assertEquals("quoted", fragment?.start)
}
@Test
fun stripsDirectiveAndBareHash() {
assertEquals(
"https://example.com/post",
TextFragmentParser.stripTextFragment("https://example.com/post#:~:text=hello"),
)
}
@Test
fun stripsDirectiveButKeepsElementId() {
assertEquals(
"https://example.com/post#heading",
TextFragmentParser.stripTextFragment("https://example.com/post#heading:~:text=hello"),
)
}
@Test
fun stripLeavesPlainFragmentUntouched() {
assertEquals(
"https://example.com/post#section",
TextFragmentParser.stripTextFragment("https://example.com/post#section"),
)
}
}
@@ -0,0 +1,87 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip84Highlights.parse
import kotlin.test.Test
import kotlin.test.assertEquals
class UrlTrackerCleanerTest {
@Test
fun keepsUrlsWithoutQuery() {
assertEquals("https://example.com/post", UrlTrackerCleaner.clean("https://example.com/post"))
}
@Test
fun keepsMeaningfulQueryParams() {
assertEquals(
"https://example.com/search?q=nostr&page=2",
UrlTrackerCleaner.clean("https://example.com/search?q=nostr&page=2"),
)
}
@Test
fun stripsUtmParams() {
assertEquals(
"https://example.com/post?id=42",
UrlTrackerCleaner.clean("https://example.com/post?utm_source=twitter&id=42&utm_medium=social"),
)
}
@Test
fun stripsKnownClickIds() {
assertEquals(
"https://example.com/post",
UrlTrackerCleaner.clean("https://example.com/post?fbclid=abc123&gclid=xyz"),
)
}
@Test
fun dropsQuestionMarkWhenOnlyTrackersRemain() {
assertEquals(
"https://example.com/post",
UrlTrackerCleaner.clean("https://example.com/post?utm_campaign=spring"),
)
}
@Test
fun matchesTrackerNamesCaseInsensitively() {
assertEquals(
"https://example.com/post",
UrlTrackerCleaner.clean("https://example.com/post?UTM_Source=x&FBCLID=y"),
)
}
@Test
fun preservesFragmentAfterCleaning() {
assertEquals(
"https://example.com/post?id=42#section",
UrlTrackerCleaner.clean("https://example.com/post?utm_source=x&id=42#section"),
)
}
@Test
fun preservesTextFragmentDirective() {
assertEquals(
"https://example.com/post#:~:text=hello",
UrlTrackerCleaner.clean("https://example.com/post?fbclid=abc#:~:text=hello"),
)
}
}