mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
+3
@@ -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 =
|
||||
|
||||
+12
-3
@@ -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
|
||||
}
|
||||
|
||||
+10
@@ -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
|
||||
|
||||
+19
@@ -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)
|
||||
|
||||
+5
@@ -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)
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
+108
@@ -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()
|
||||
}
|
||||
}
|
||||
+70
@@ -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,
|
||||
)
|
||||
}
|
||||
+53
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+212
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+329
@@ -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),
|
||||
)
|
||||
}
|
||||
+85
@@ -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()
|
||||
}
|
||||
+60
@@ -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()
|
||||
}
|
||||
+50
@@ -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() }
|
||||
}
|
||||
+48
@@ -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)
|
||||
}
|
||||
+100
@@ -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() }
|
||||
}
|
||||
}
|
||||
+92
@@ -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()
|
||||
}
|
||||
+44
@@ -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()
|
||||
}
|
||||
}
|
||||
+70
@@ -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()
|
||||
}
|
||||
+67
@@ -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()
|
||||
}
|
||||
+49
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+97
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+48
@@ -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()
|
||||
}
|
||||
+144
@@ -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 }
|
||||
}
|
||||
}
|
||||
+156
@@ -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
|
||||
}
|
||||
}
|
||||
+100
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+182
@@ -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)
|
||||
}
|
||||
}
|
||||
+210
@@ -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. Geohot’s 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)
|
||||
}
|
||||
}
|
||||
+118
@@ -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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
+87
@@ -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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user