- Adds support for creating and replying to NIP-22 geo scope posts
- Adds support for creating and replying to NIP-22 hashtag scope posts - Refactors NIP-73 and NIP-22 on quartz to better support scoped replies - Creates New post screens for both hashtags and geolocated posts.
This commit is contained in:
@@ -165,6 +165,8 @@ import com.vitorpamplona.quartz.nip68Picture.pictureIMeta
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoMeta
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent
|
||||
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
|
||||
@@ -230,7 +232,10 @@ class Account(
|
||||
val hashtags: Set<String> = emptySet(),
|
||||
val geotags: Set<String> = emptySet(),
|
||||
val addresses: Set<String> = emptySet(),
|
||||
)
|
||||
) {
|
||||
val geotagScopes: Set<String> = geotags.mapTo(mutableSetOf<String>()) { GeohashId.toScope(it) }
|
||||
val hashtagScopes: Set<String> = hashtags.mapTo(mutableSetOf<String>()) { HashtagId.toScope(it) }
|
||||
}
|
||||
|
||||
class FeedsBaseFlows(
|
||||
val listName: String,
|
||||
@@ -623,7 +628,6 @@ class Account(
|
||||
listName,
|
||||
location = Amethyst.instance.locationManager.geohashStateFlow,
|
||||
)
|
||||
|
||||
else -> {
|
||||
val note = LocalCache.checkGetOrCreateAddressableNote(listName)
|
||||
if (note != null) {
|
||||
|
||||
@@ -50,6 +50,7 @@ import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
|
||||
@@ -854,6 +855,10 @@ open class Note(
|
||||
return true
|
||||
}
|
||||
|
||||
if (thisEvent is CommentEvent) {
|
||||
thisEvent.isScoped { it.containsAny(accountChoices.hiddenWordsCase) }
|
||||
}
|
||||
|
||||
if (thisEvent.anyHashTag { it.containsAny(accountChoices.hiddenWordsCase) }) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
|
||||
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser
|
||||
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.GeohashId
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId
|
||||
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
|
||||
import com.vitorpamplona.quartz.nip92IMeta.imetas
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.alt
|
||||
|
||||
@@ -58,7 +58,9 @@ class FilterByListParams(
|
||||
} else if (noteEvent is CommentEvent) {
|
||||
// ignore follows and checks only the root scope
|
||||
noteEvent.isTaggedHashes(followLists.hashtags) ||
|
||||
noteEvent.isTaggedScopes(followLists.hashtagScopes) ||
|
||||
noteEvent.isTaggedGeoHashes(followLists.geotags) ||
|
||||
noteEvent.isTaggedScopes(followLists.geotagScopes) ||
|
||||
noteEvent.isTaggedAddressableNotes(followLists.addresses)
|
||||
} else {
|
||||
noteEvent.pubKey in followLists.authors ||
|
||||
|
||||
@@ -70,6 +70,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.N
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoPostScreen
|
||||
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.home.HomeScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationScreen
|
||||
@@ -140,6 +142,32 @@ fun AppNavigation(
|
||||
|
||||
composableArgs<Route.EventRedirect> { LoadRedirectScreen(it.id, accountViewModel, nav) }
|
||||
|
||||
composableFromBottomArgs<Route.GeoPost> {
|
||||
GeoPostScreen(
|
||||
geohash = it.geohash,
|
||||
message = it.message,
|
||||
attachment = it.attachment?.ifBlank { null }?.toUri(),
|
||||
reply = it.replyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) },
|
||||
quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) },
|
||||
draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) },
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
|
||||
composableFromBottomArgs<Route.HashtagPost> {
|
||||
HashtagPostScreen(
|
||||
hashtag = it.hashtag,
|
||||
message = it.message,
|
||||
attachment = it.attachment?.ifBlank { null }?.toUri(),
|
||||
reply = it.replyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) },
|
||||
quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) },
|
||||
draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) },
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
|
||||
composableFromBottomArgs<Route.NewProduct> {
|
||||
NewProductScreen(
|
||||
message = it.message,
|
||||
@@ -160,7 +188,6 @@ fun AppNavigation(
|
||||
fork = it.fork?.let { hex -> accountViewModel.getNoteIfExists(hex) },
|
||||
version = it.version?.let { hex -> accountViewModel.getNoteIfExists(hex) },
|
||||
draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) },
|
||||
enableGeolocation = it.enableGeolocation,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
@@ -56,7 +56,7 @@ interface INav {
|
||||
|
||||
fun nav(route: Route)
|
||||
|
||||
fun nav(computeRoute: suspend () -> Route)
|
||||
fun nav(computeRoute: suspend () -> Route?)
|
||||
|
||||
fun newStack(route: Route)
|
||||
|
||||
@@ -95,10 +95,10 @@ class Nav(
|
||||
}
|
||||
}
|
||||
|
||||
override fun nav(computeRoute: suspend () -> Route) {
|
||||
override fun nav(computeRoute: suspend () -> Route?) {
|
||||
scope.launch {
|
||||
val route = computeRoute()
|
||||
if (getRouteWithArguments(controller) != route) {
|
||||
if (route != null && getRouteWithArguments(controller) != route) {
|
||||
controller.navigate(route)
|
||||
}
|
||||
}
|
||||
@@ -148,7 +148,7 @@ object EmptyNav : INav {
|
||||
|
||||
override fun nav(route: Route) {}
|
||||
|
||||
override fun nav(computeRoute: suspend () -> Route) {}
|
||||
override fun nav(computeRoute: suspend () -> Route?) {}
|
||||
|
||||
override fun newStack(route: Route) {}
|
||||
|
||||
@@ -181,7 +181,7 @@ class ObservableNavigate(
|
||||
nav.nav(route)
|
||||
}
|
||||
|
||||
override fun nav(computeRoute: suspend () -> Route) {
|
||||
override fun nav(computeRoute: suspend () -> Route?) {
|
||||
onNavigate()
|
||||
nav.nav(computeRoute)
|
||||
}
|
||||
|
||||
@@ -29,14 +29,19 @@ import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.location.isGeohashedScoped
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.topics.isHashtagScoped
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
|
||||
fun routeFor(
|
||||
@@ -142,8 +147,16 @@ fun routeToMessage(
|
||||
replyId: HexKey? = null,
|
||||
draftId: HexKey? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
): Route = routeToMessage(room, draftMessage, replyId, draftId, accountViewModel.userProfile())
|
||||
|
||||
fun routeToMessage(
|
||||
room: ChatroomKey,
|
||||
draftMessage: String?,
|
||||
replyId: HexKey? = null,
|
||||
draftId: HexKey? = null,
|
||||
fromUser: User,
|
||||
): Route {
|
||||
accountViewModel.account.userProfile().createChatroom(room)
|
||||
fromUser.createChatroom(room)
|
||||
|
||||
return Route.Room(room.hashCode(), draftMessage, replyId, draftId)
|
||||
}
|
||||
@@ -168,3 +181,69 @@ fun routeFor(roomId: RoomId): Route = Route.EphemeralChat(roomId.id, roomId.rela
|
||||
fun routeFor(user: User): Route.Profile = Route.Profile(user.pubkeyHex)
|
||||
|
||||
fun authorRouteFor(note: Note): Route.Profile? = note.author?.pubkeyHex?.let { Route.Profile(it) }
|
||||
|
||||
fun routeReplyTo(
|
||||
note: Note,
|
||||
asUser: User,
|
||||
): Route? {
|
||||
val noteEvent = note.event
|
||||
return when (noteEvent) {
|
||||
is TextNoteEvent -> Route.NewPost(baseReplyTo = note.idHex)
|
||||
is PrivateDmEvent ->
|
||||
routeToMessage(
|
||||
room = noteEvent.chatroomKey(asUser.pubkeyHex),
|
||||
draftMessage = null,
|
||||
replyId = noteEvent.id,
|
||||
draftId = null,
|
||||
fromUser = asUser,
|
||||
)
|
||||
is ChatroomKeyable ->
|
||||
routeToMessage(
|
||||
room = noteEvent.chatroomKey(asUser.pubkeyHex),
|
||||
draftMessage = null,
|
||||
replyId = noteEvent.id,
|
||||
draftId = null,
|
||||
fromUser = asUser,
|
||||
)
|
||||
is CommentEvent -> {
|
||||
if (noteEvent.isGeohashedScoped()) {
|
||||
Route.GeoPost(replyTo = note.idHex)
|
||||
} else if (noteEvent.isHashtagScoped()) {
|
||||
Route.HashtagPost(replyTo = note.idHex)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun routeQuote(
|
||||
note: Note,
|
||||
asUser: User,
|
||||
): Route? {
|
||||
val noteEvent = note.event
|
||||
return when (noteEvent) {
|
||||
is TextNoteEvent -> Route.NewPost(baseReplyTo = note.idHex)
|
||||
is PrivateDmEvent ->
|
||||
routeToMessage(
|
||||
room = noteEvent.chatroomKey(asUser.pubkeyHex),
|
||||
draftMessage = null,
|
||||
replyId = noteEvent.id,
|
||||
draftId = null,
|
||||
fromUser = asUser,
|
||||
)
|
||||
is ChatroomKeyable ->
|
||||
routeToMessage(
|
||||
room = noteEvent.chatroomKey(asUser.pubkeyHex),
|
||||
draftMessage = null,
|
||||
replyId = noteEvent.id,
|
||||
draftId = null,
|
||||
fromUser = asUser,
|
||||
)
|
||||
is CommentEvent -> Route.GeoPost(replyTo = note.idHex)
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +139,26 @@ sealed class Route {
|
||||
val draft: String? = null,
|
||||
) : Route()
|
||||
|
||||
@Serializable
|
||||
data class GeoPost(
|
||||
val geohash: String? = null,
|
||||
val message: String? = null,
|
||||
val attachment: String? = null,
|
||||
val replyTo: String? = null,
|
||||
val quote: String? = null,
|
||||
val draft: String? = null,
|
||||
) : Route()
|
||||
|
||||
@Serializable
|
||||
data class HashtagPost(
|
||||
val hashtag: String? = null,
|
||||
val message: String? = null,
|
||||
val attachment: String? = null,
|
||||
val replyTo: String? = null,
|
||||
val quote: String? = null,
|
||||
val draft: String? = null,
|
||||
) : Route()
|
||||
|
||||
@Serializable
|
||||
data class NewPost(
|
||||
val message: String? = null,
|
||||
@@ -148,7 +168,6 @@ sealed class Route {
|
||||
val fork: String? = null,
|
||||
val version: String? = null,
|
||||
val draft: String? = null,
|
||||
val enableGeolocation: Boolean = false,
|
||||
) : Route()
|
||||
}
|
||||
|
||||
@@ -191,6 +210,8 @@ fun getRouteWithArguments(navController: NavHostController): Route? {
|
||||
dest.hasRoute<Route.Room>() -> entry.toRoute<Route.Room>()
|
||||
dest.hasRoute<Route.NewPost>() -> entry.toRoute<Route.NewPost>()
|
||||
dest.hasRoute<Route.NewProduct>() -> entry.toRoute<Route.NewProduct>()
|
||||
dest.hasRoute<Route.GeoPost>() -> entry.toRoute<Route.GeoPost>()
|
||||
dest.hasRoute<Route.HashtagPost>() -> entry.toRoute<Route.HashtagPost>()
|
||||
|
||||
else -> {
|
||||
null
|
||||
|
||||
@@ -157,7 +157,7 @@ import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent
|
||||
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableKind
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
@@ -974,7 +974,7 @@ fun SecondUserInfoRow(
|
||||
ObserveDisplayNip05Status(noteAuthor, remember(noteEvent) { Modifier.weight(1f) }, accountViewModel, nav)
|
||||
}
|
||||
|
||||
val geo = remember(noteEvent) { noteEvent.getGeoHash() }
|
||||
val geo = remember(noteEvent) { noteEvent.geoHashOrScope() }
|
||||
if (geo != null) {
|
||||
Spacer(StdHorzSpacer)
|
||||
DisplayLocation(geo, nav)
|
||||
|
||||
@@ -114,7 +114,7 @@ import com.vitorpamplona.amethyst.ui.components.GenericLoadable
|
||||
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routeToMessage
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routeReplyTo
|
||||
import com.vitorpamplona.amethyst.ui.note.types.EditState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -151,7 +151,6 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.reactionBox
|
||||
import com.vitorpamplona.amethyst.ui.theme.ripple24dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.selectedReactionBoxModifier
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
|
||||
@@ -581,35 +580,7 @@ private fun ReplyReactionWithDialog(
|
||||
nav: INav,
|
||||
) {
|
||||
ReplyReaction(baseNote, grayTint, accountViewModel) {
|
||||
val noteEvent = baseNote.event
|
||||
if (noteEvent is PrivateDmEvent) {
|
||||
nav.nav {
|
||||
routeToMessage(
|
||||
room = noteEvent.chatroomKey(accountViewModel.userProfile().pubkeyHex),
|
||||
draftMessage = null,
|
||||
replyId = noteEvent.id,
|
||||
draftId = null,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
} else if (noteEvent is ChatroomKeyable) {
|
||||
nav.nav {
|
||||
routeToMessage(
|
||||
room = noteEvent.chatroomKey(accountViewModel.userProfile().pubkeyHex),
|
||||
draftMessage = null,
|
||||
replyId = noteEvent.id,
|
||||
draftId = null,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
nav.nav {
|
||||
Route.NewPost(
|
||||
baseReplyTo = baseNote.idHex,
|
||||
quote = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
nav.nav { routeReplyTo(baseNote, accountViewModel.userProfile()) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-1
@@ -41,6 +41,8 @@ import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.firstIsTaggedHashes
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId
|
||||
|
||||
@Composable
|
||||
fun DisplayFollowingHashtagsInPost(
|
||||
@@ -52,7 +54,14 @@ fun DisplayFollowingHashtagsInPost(
|
||||
var firstTag by remember(baseNote) { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = userFollowState) {
|
||||
val newFirstTag = baseNote.event?.firstIsTaggedHashes(userFollowState.hashtags)
|
||||
val noteEvent = baseNote.event
|
||||
|
||||
val newFirstTag =
|
||||
if (noteEvent is CommentEvent) {
|
||||
noteEvent.firstTaggedScopeIn(userFollowState.hashtagScopes)?.let { HashtagId.parse(it) } ?: noteEvent.firstIsTaggedHashes(userFollowState.hashtags)
|
||||
} else {
|
||||
noteEvent?.firstIsTaggedHashes(userFollowState.hashtags)
|
||||
}
|
||||
|
||||
if (firstTag != newFirstTag) {
|
||||
firstTag = newFirstTag
|
||||
|
||||
@@ -154,13 +154,11 @@ fun NewPostScreen(
|
||||
fork: Note? = null,
|
||||
version: Note? = null,
|
||||
draft: Note? = null,
|
||||
enableGeolocation: Boolean = false,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: Nav,
|
||||
) {
|
||||
val postViewModel: NewPostViewModel = viewModel()
|
||||
postViewModel.init(accountViewModel)
|
||||
postViewModel.wantsToAddGeoHash = enableGeolocation
|
||||
|
||||
val context = LocalContext.current
|
||||
val activity = context.getActivity()
|
||||
|
||||
+2
-2
@@ -77,7 +77,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip13Pow.strongPoWOrNull
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
|
||||
@@ -230,7 +230,7 @@ fun NormalChatNote(
|
||||
|
||||
ZapReaction(note, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav)
|
||||
|
||||
val geo = remember(note) { note.event?.getGeoHash() }
|
||||
val geo = remember(note) { note.event?.geoHashOrScope() }
|
||||
if (geo != null) {
|
||||
Spacer(StdHorzSpacer)
|
||||
DisplayLocation(geo, nav)
|
||||
|
||||
+7
-5
@@ -45,13 +45,15 @@ class TwoPaneNav(
|
||||
}
|
||||
}
|
||||
|
||||
override fun nav(routeMaker: suspend () -> Route) {
|
||||
override fun nav(routeMaker: suspend () -> Route?) {
|
||||
scope.launch(Dispatchers.Default) {
|
||||
val route = routeMaker()
|
||||
if (route is Route.Room || route is Route.Channel) {
|
||||
innerNav.value = route
|
||||
} else {
|
||||
nav.nav(route)
|
||||
if (route != null) {
|
||||
if (route is Route.Room || route is Route.Channel) {
|
||||
innerNav.value = route
|
||||
} else {
|
||||
nav.nav(route)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -389,7 +389,6 @@ open class NewProductViewModel :
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
val myAccount = account ?: return@launch
|
||||
|
||||
val myMultiOrchestrator = multiOrchestrator ?: return@launch
|
||||
|
||||
isUploadingImage = true
|
||||
|
||||
+3
@@ -89,6 +89,9 @@ fun GeoHashScreen(
|
||||
popBack = nav::popBack,
|
||||
)
|
||||
},
|
||||
floatingButton = {
|
||||
NewGeoPostButton(tag, accountViewModel, nav)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
Column(Modifier.padding(it)) {
|
||||
|
||||
+546
@@ -0,0 +1,546 @@
|
||||
/**
|
||||
* 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.geohash
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.LocationOn
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.actions.RelaySelectionDialogEasy
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewUrl
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewUrlFillWidth
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.AddZapraiserButton
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.ZapRaiserRequest
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapToButton
|
||||
import com.vitorpamplona.amethyst.ui.painterRes
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.Notifying
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.PostButton
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.FillWidthQuoteBorderModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Height100Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.SquaredQuoteBorderModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun GeoPostScreen(
|
||||
geohash: String? = null,
|
||||
message: String? = null,
|
||||
attachment: Uri? = null,
|
||||
reply: Note? = null,
|
||||
quote: Note? = null,
|
||||
draft: Note? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: Nav,
|
||||
) {
|
||||
val postViewModel: GeoPostViewModel = viewModel()
|
||||
postViewModel.init(accountViewModel)
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
postViewModel.reloadRelaySet()
|
||||
geohash?.let {
|
||||
postViewModel.newPostFor(GeohashId(it))
|
||||
}
|
||||
reply?.let {
|
||||
postViewModel.reply(it)
|
||||
}
|
||||
draft?.let {
|
||||
postViewModel.editFromDraft(it)
|
||||
}
|
||||
quote?.let {
|
||||
postViewModel.quote(it)
|
||||
}
|
||||
message?.ifBlank { null }?.let {
|
||||
postViewModel.updateMessage(TextFieldValue(it))
|
||||
}
|
||||
attachment?.let {
|
||||
withContext(Dispatchers.IO) {
|
||||
val mediaType = context.contentResolver.getType(it)
|
||||
postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NewGeoPostScreenInner(postViewModel, accountViewModel, nav)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NewGeoPostScreenInner(
|
||||
postViewModel: GeoPostViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: Nav,
|
||||
) {
|
||||
WatchAndLoadMyEmojiList(accountViewModel)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = CenterVertically,
|
||||
) {
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
|
||||
Box {
|
||||
IconButton(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
onClick = { postViewModel.showRelaysDialog = true },
|
||||
) {
|
||||
Icon(
|
||||
painter = painterRes(R.drawable.relays),
|
||||
contentDescription = stringRes(id = R.string.relay_list_selector),
|
||||
modifier = Modifier.height(25.dp),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
PostButton(
|
||||
onPost = {
|
||||
// uses the accountViewModel scope to avoid cancelling this
|
||||
// function when the postViewModel is released
|
||||
accountViewModel.viewModelScope.launch(Dispatchers.IO) {
|
||||
postViewModel.sendPostSync()
|
||||
nav.popBack()
|
||||
}
|
||||
},
|
||||
isActive = postViewModel.canPost(),
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
Row {
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
CloseButton(
|
||||
onPress = {
|
||||
// uses the accountViewModel scope to avoid cancelling this
|
||||
// function when the postViewModel is released
|
||||
accountViewModel.viewModelScope.launch(Dispatchers.IO) {
|
||||
postViewModel.sendDraftSync()
|
||||
nav.popBack()
|
||||
postViewModel.cancel()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
colors =
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { pad ->
|
||||
if (postViewModel.showRelaysDialog) {
|
||||
RelaySelectionDialogEasy(
|
||||
preSelectedList = postViewModel.relayList ?: persistentListOf(),
|
||||
onClose = { postViewModel.showRelaysDialog = false },
|
||||
onPost = { postViewModel.relayList = it.map { it.url }.toImmutableList() },
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(pad)
|
||||
.consumeWindowInsets(pad)
|
||||
.imePadding(),
|
||||
) {
|
||||
NewGeoPostBody(
|
||||
postViewModel,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewGeoPostBody(
|
||||
postViewModel: GeoPostViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: Nav,
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = Size10dp,
|
||||
end = Size10dp,
|
||||
).weight(1f),
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().verticalScroll(scrollState)) {
|
||||
postViewModel.externalIdentity?.let {
|
||||
Row {
|
||||
DisplayExternalId(it, accountViewModel, nav)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
}
|
||||
}
|
||||
|
||||
postViewModel.replyingTo?.let {
|
||||
Row {
|
||||
NoteCompose(
|
||||
baseNote = it,
|
||||
modifier = MaterialTheme.colorScheme.replyModifier,
|
||||
isQuotedNote = true,
|
||||
unPackReply = false,
|
||||
makeItShort = true,
|
||||
quotesLeft = 1,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
Notifying(postViewModel.notifying?.toImmutableList(), accountViewModel) {
|
||||
postViewModel.removeFromReplyList(it)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = Size10dp),
|
||||
) {
|
||||
BaseUserPicture(
|
||||
accountViewModel.userProfile(),
|
||||
Size35dp,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
MessageField(
|
||||
R.string.what_s_on_your_mind,
|
||||
postViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
DisplayPreviews(postViewModel, accountViewModel, nav)
|
||||
|
||||
if (postViewModel.wantsToMarkAsSensitive) {
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
|
||||
) {
|
||||
ContentSensitivityExplainer()
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.wantsForwardZapTo) {
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = Modifier.padding(top = Size5dp, bottom = Size5dp, start = Size10dp),
|
||||
) {
|
||||
ForwardZapTo(postViewModel, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
postViewModel.multiOrchestrator?.let {
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
ImageVideoDescription(
|
||||
it,
|
||||
accountViewModel.account.settings.defaultFileServer,
|
||||
onAdd = { alt, server, sensitiveContent, mediaQuality ->
|
||||
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context)
|
||||
if (server.type != ServerType.NIP95) {
|
||||
accountViewModel.account.settings.changeDefaultFileServer(server)
|
||||
}
|
||||
},
|
||||
onDelete = postViewModel::deleteMediaToUpload,
|
||||
onCancel = { postViewModel.multiOrchestrator = null },
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.wantsInvoice) {
|
||||
postViewModel.lnAddress()?.let { lud16 ->
|
||||
InvoiceRequest(
|
||||
lud16,
|
||||
accountViewModel.account.userProfile().pubkeyHex,
|
||||
accountViewModel,
|
||||
stringRes(id = R.string.lightning_invoice),
|
||||
stringRes(id = R.string.lightning_create_and_add_invoice),
|
||||
onSuccess = {
|
||||
postViewModel.insertAtCursor(it)
|
||||
postViewModel.wantsInvoice = false
|
||||
},
|
||||
onError = { title, message -> accountViewModel.toastManager.toast(title, message) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.wantsSecretEmoji) {
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
SecretEmojiRequest {
|
||||
postViewModel.insertAtCursor(it)
|
||||
postViewModel.wantsSecretEmoji = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.wantsZapraiser && postViewModel.hasLnAddress()) {
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
|
||||
) {
|
||||
ZapRaiserRequest(
|
||||
stringRes(id = R.string.zapraiser),
|
||||
postViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
postViewModel.userSuggestions?.let {
|
||||
ShowUserSuggestionList(
|
||||
it,
|
||||
postViewModel::autocompleteWithUser,
|
||||
accountViewModel,
|
||||
modifier = Modifier.heightIn(0.dp, 300.dp),
|
||||
)
|
||||
}
|
||||
|
||||
postViewModel.emojiSuggestions?.let {
|
||||
ShowEmojiSuggestionList(
|
||||
it,
|
||||
postViewModel::autocompleteWithEmoji,
|
||||
postViewModel::autocompleteWithEmojiUrl,
|
||||
accountViewModel,
|
||||
modifier = Modifier.heightIn(0.dp, 300.dp),
|
||||
)
|
||||
}
|
||||
|
||||
BottomRowActions(postViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayExternalId(
|
||||
externalId: GeohashId,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
Row(modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
LoadCityName(externalId.geohash) { cityName ->
|
||||
Icon(
|
||||
imageVector = Icons.Default.LocationOn,
|
||||
contentDescription = stringRes(id = R.string.geohash_exclusive),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
||||
Spacer(StdHorzSpacer)
|
||||
|
||||
Text(
|
||||
text =
|
||||
buildAnnotatedString {
|
||||
withLink(
|
||||
LinkAnnotation.Clickable("cityname") { nav.nav(Route.Geohash(externalId.geohash)) },
|
||||
) {
|
||||
append(cityName)
|
||||
}
|
||||
},
|
||||
style =
|
||||
LocalTextStyle.current.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomRowActions(postViewModel: GeoPostViewModel) {
|
||||
val scrollState = rememberScrollState()
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.horizontalScroll(scrollState)
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
verticalAlignment = CenterVertically,
|
||||
) {
|
||||
SelectFromGallery(
|
||||
isUploading = postViewModel.isUploadingImage,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier,
|
||||
) {
|
||||
postViewModel.selectImage(it)
|
||||
}
|
||||
|
||||
TakePictureButton(
|
||||
onPictureTaken = {
|
||||
postViewModel.selectImage(it)
|
||||
},
|
||||
)
|
||||
|
||||
ForwardZapToButton(postViewModel.wantsForwardZapTo) {
|
||||
postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo
|
||||
}
|
||||
|
||||
if (postViewModel.canAddZapRaiser) {
|
||||
AddZapraiserButton(postViewModel.wantsZapraiser) {
|
||||
postViewModel.wantsZapraiser = !postViewModel.wantsZapraiser
|
||||
}
|
||||
}
|
||||
|
||||
MarkAsSensitiveButton(postViewModel.wantsToMarkAsSensitive) {
|
||||
postViewModel.toggleMarkAsSensitive()
|
||||
}
|
||||
|
||||
AddSecretEmojiButton(postViewModel.wantsSecretEmoji) {
|
||||
postViewModel.wantsSecretEmoji = !postViewModel.wantsSecretEmoji
|
||||
}
|
||||
|
||||
if (postViewModel.canAddInvoice && postViewModel.hasLnAddress()) {
|
||||
AddLnInvoiceButton(postViewModel.wantsInvoice) {
|
||||
postViewModel.wantsInvoice = !postViewModel.wantsInvoice
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayPreviews(
|
||||
postViewModel: GeoPostViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val urlPreviews by postViewModel.urlPreviews.results.collectAsStateWithLifecycle(emptyList())
|
||||
|
||||
if (urlPreviews.isNotEmpty()) {
|
||||
Row(HalfHorzPadding) {
|
||||
if (urlPreviews.size > 1) {
|
||||
LazyRow(Height100Modifier, horizontalArrangement = spacedBy(Size5dp)) {
|
||||
items(urlPreviews) {
|
||||
Box(SquaredQuoteBorderModifier) {
|
||||
PreviewUrl(it, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Box(FillWidthQuoteBorderModifier) {
|
||||
PreviewUrlFillWidth(urlPreviews[0], accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+663
@@ -0,0 +1,663 @@
|
||||
/**
|
||||
* 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.geohash
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.compose.currentWord
|
||||
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
|
||||
import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState.EmojiMedia
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||
import com.vitorpamplona.amethyst.ui.actions.UserSuggestionAnchor
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewState
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.IZapRaiser
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.IZapField
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.SplitBuilder
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.toZapSplitSetup
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
|
||||
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||
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.nip18Reposts.quotes.taggedQuoteIds
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.notify
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
|
||||
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser
|
||||
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.location.geohashedScope
|
||||
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
|
||||
import com.vitorpamplona.quartz.nip92IMeta.imetas
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.alt
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.blurhash
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.dims
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.hash
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.magnet
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.size
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Stable
|
||||
open class GeoPostViewModel :
|
||||
ViewModel(),
|
||||
IMessageField,
|
||||
IZapField,
|
||||
IZapRaiser {
|
||||
val draftTag = DraftTagState()
|
||||
|
||||
init {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
draftTag.versions.collectLatest {
|
||||
sendDraftSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var accountViewModel: AccountViewModel? = null
|
||||
var account: Account? = null
|
||||
|
||||
var externalIdentity by mutableStateOf<GeohashId?>(null)
|
||||
var replyingTo: Note? by mutableStateOf<Note?>(null)
|
||||
|
||||
val iMetaAttachments = IMetaAttachments()
|
||||
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
|
||||
|
||||
var notifying by mutableStateOf<List<User>?>(null)
|
||||
|
||||
override var message by mutableStateOf(TextFieldValue(""))
|
||||
|
||||
val urlPreviews = PreviewState()
|
||||
|
||||
var isUploadingImage by mutableStateOf(false)
|
||||
|
||||
var userSuggestions: UserSuggestionState? = null
|
||||
var userSuggestionsMainMessage: UserSuggestionAnchor? = null
|
||||
|
||||
var emojiSuggestions: EmojiSuggestionState? = null
|
||||
|
||||
// Images and Videos
|
||||
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
|
||||
|
||||
// Invoices
|
||||
var canAddInvoice by mutableStateOf(false)
|
||||
var wantsInvoice by mutableStateOf(false)
|
||||
|
||||
var wantsSecretEmoji by mutableStateOf(false)
|
||||
|
||||
// Forward Zap to
|
||||
var wantsForwardZapTo by mutableStateOf(false)
|
||||
override var forwardZapTo = mutableStateOf<SplitBuilder<User>>(SplitBuilder())
|
||||
override var forwardZapToEditting = mutableStateOf(TextFieldValue(""))
|
||||
|
||||
// NSFW, Sensitive
|
||||
var wantsToMarkAsSensitive by mutableStateOf(false)
|
||||
|
||||
// ZapRaiser
|
||||
var canAddZapRaiser by mutableStateOf(false)
|
||||
var wantsZapraiser by mutableStateOf(false)
|
||||
override val zapRaiserAmount = mutableStateOf<Long?>(null)
|
||||
|
||||
var showRelaysDialog by mutableStateOf(false)
|
||||
var relayList by mutableStateOf<ImmutableList<String>?>(null)
|
||||
|
||||
fun lnAddress(): String? = account?.userProfile()?.info?.lnAddress()
|
||||
|
||||
fun hasLnAddress(): Boolean = account?.userProfile()?.info?.lnAddress() != null
|
||||
|
||||
fun user(): User? = account?.userProfile()
|
||||
|
||||
open fun init(accountVM: AccountViewModel) {
|
||||
this.accountViewModel = accountVM
|
||||
this.account = accountVM.account
|
||||
this.canAddInvoice = hasLnAddress()
|
||||
this.canAddZapRaiser = hasLnAddress()
|
||||
|
||||
this.userSuggestions?.reset()
|
||||
this.userSuggestions = UserSuggestionState(accountVM)
|
||||
|
||||
this.emojiSuggestions?.reset()
|
||||
this.emojiSuggestions = EmojiSuggestionState(accountVM)
|
||||
}
|
||||
|
||||
fun newPostFor(externalIdentity: GeohashId) {
|
||||
this.externalIdentity = externalIdentity
|
||||
}
|
||||
|
||||
fun editFromDraft(draft: Note) {
|
||||
val accountViewModel = accountViewModel ?: return
|
||||
|
||||
val noteEvent = draft.event
|
||||
val noteAuthor = draft.author
|
||||
|
||||
if (noteEvent is DraftEvent && noteAuthor != null) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
accountViewModel.createTempDraftNote(noteEvent) { innerNote ->
|
||||
if (innerNote != null) {
|
||||
val oldTag = (draft.event as? AddressableEvent)?.dTag()
|
||||
if (oldTag != null) {
|
||||
draftTag.set(oldTag)
|
||||
}
|
||||
loadFromDraft(innerNote)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open fun reply(post: Note) {
|
||||
val accountViewModel = accountViewModel ?: return
|
||||
val noteEvent = post.event as? CommentEvent ?: return
|
||||
val geohash = noteEvent.geohashedScope() ?: return
|
||||
|
||||
this.replyingTo = post
|
||||
this.externalIdentity = GeohashId(geohash)
|
||||
}
|
||||
|
||||
open fun quote(quote: Note) {
|
||||
val accountViewModel = accountViewModel ?: return
|
||||
|
||||
message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}")
|
||||
|
||||
quote.author?.let { quotedUser ->
|
||||
if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
|
||||
if (forwardZapTo.value.items.none { it.key.pubkeyHex == quotedUser.pubkeyHex }) {
|
||||
forwardZapTo.value.addItem(quotedUser)
|
||||
}
|
||||
if (forwardZapTo.value.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) {
|
||||
forwardZapTo.value.addItem(accountViewModel.userProfile())
|
||||
}
|
||||
|
||||
val pos = forwardZapTo.value.items.indexOfFirst { it.key.pubkeyHex == quotedUser.pubkeyHex }
|
||||
forwardZapTo.value.updatePercentage(pos, 0.9f)
|
||||
}
|
||||
}
|
||||
|
||||
if (!forwardZapTo.value.items.isEmpty()) {
|
||||
wantsForwardZapTo = true
|
||||
}
|
||||
|
||||
urlPreviews.update(message)
|
||||
}
|
||||
|
||||
private fun loadFromDraft(draft: Note) {
|
||||
val draftEvent = draft.event ?: return
|
||||
if (draftEvent !is CommentEvent) return
|
||||
|
||||
loadFromDraft(draftEvent)
|
||||
}
|
||||
|
||||
private fun loadFromDraft(draftEvent: CommentEvent) {
|
||||
val geohash = draftEvent.geohashedScope() ?: return
|
||||
this.externalIdentity = GeohashId(geohash)
|
||||
|
||||
canAddInvoice = accountViewModel?.userProfile()?.info?.lnAddress() != null
|
||||
canAddZapRaiser = accountViewModel?.userProfile()?.info?.lnAddress() != null
|
||||
multiOrchestrator = null
|
||||
|
||||
val localfowardZapTo = draftEvent.tags.filter { it.size > 1 && it[0] == "zap" }
|
||||
forwardZapTo.value = SplitBuilder()
|
||||
localfowardZapTo.forEach {
|
||||
val user = LocalCache.getOrCreateUser(it[1])
|
||||
val value = it.last().toFloatOrNull() ?: 0f
|
||||
forwardZapTo.value.addItem(user, value)
|
||||
}
|
||||
forwardZapToEditting.value = TextFieldValue("")
|
||||
wantsForwardZapTo = localfowardZapTo.isNotEmpty()
|
||||
|
||||
wantsToMarkAsSensitive = draftEvent.isSensitive()
|
||||
|
||||
val zapraiser = draftEvent.zapraiserAmount()
|
||||
wantsZapraiser = zapraiser != null
|
||||
zapRaiserAmount.value = null
|
||||
if (zapraiser != null) {
|
||||
zapRaiserAmount.value = zapraiser
|
||||
}
|
||||
|
||||
draftEvent.replyingTo()?.let {
|
||||
replyingTo = LocalCache.getOrCreateNote(it)
|
||||
}
|
||||
|
||||
notifying = draftEvent.rootAuthorKeys().mapNotNull { LocalCache.checkGetOrCreateUser(it) } +
|
||||
draftEvent.replyAuthorKeys().mapNotNull { LocalCache.checkGetOrCreateUser(it) }
|
||||
|
||||
if (forwardZapTo.value.items.isNotEmpty()) {
|
||||
wantsForwardZapTo = true
|
||||
}
|
||||
|
||||
message = TextFieldValue(draftEvent.content)
|
||||
|
||||
iMetaAttachments.addAll(draftEvent.imetas())
|
||||
|
||||
urlPreviews.update(message)
|
||||
}
|
||||
|
||||
suspend fun sendPostSync() {
|
||||
val template = createTemplate() ?: return
|
||||
val relayList = relayList
|
||||
|
||||
if (nip95attachments.isNotEmpty() && relayList != null) {
|
||||
val usedImages = template.tags.taggedQuoteIds().toSet()
|
||||
nip95attachments.forEach {
|
||||
if (usedImages.contains(it.second.id) == true) {
|
||||
account?.sendNip95Privately(it.first, it.second, relayList)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
accountViewModel?.account?.signAndSendPrivatelyOrBroadcast(
|
||||
template,
|
||||
relayList = { relayList },
|
||||
)
|
||||
|
||||
accountViewModel?.deleteDraft(draftTag.current)
|
||||
|
||||
cancel()
|
||||
}
|
||||
|
||||
suspend fun sendDraftSync() {
|
||||
val accountViewModel = accountViewModel ?: return
|
||||
|
||||
if (message.text.isBlank()) {
|
||||
accountViewModel.account.deleteDraft(draftTag.current)
|
||||
} else {
|
||||
val template = createTemplate() ?: return
|
||||
accountViewModel.account.createAndSendDraft(draftTag.current, template)
|
||||
|
||||
nip95attachments.forEach {
|
||||
account?.sendToPrivateOutboxAndLocal(it.first)
|
||||
account?.sendToPrivateOutboxAndLocal(it.second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createTemplate(): EventTemplate<out Event>? {
|
||||
val accountViewModel = accountViewModel ?: return null
|
||||
|
||||
val tagger =
|
||||
NewMessageTagger(
|
||||
message = message.text,
|
||||
dao = accountViewModel,
|
||||
)
|
||||
tagger.run()
|
||||
|
||||
val emojis = findEmoji(tagger.message, account?.emoji?.myEmojis?.value)
|
||||
val urls = findURLs(tagger.message)
|
||||
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
|
||||
|
||||
val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null
|
||||
val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null
|
||||
val contentWarningReason = if (wantsToMarkAsSensitive) "" else null
|
||||
|
||||
val replyingTo = replyingTo
|
||||
|
||||
val template =
|
||||
if (replyingTo != null) {
|
||||
val eventHint = replyingTo.toEventHint<Event>() ?: return null
|
||||
|
||||
CommentEvent.replyBuilder(
|
||||
msg = tagger.message,
|
||||
replyingTo = eventHint,
|
||||
) {
|
||||
tagger.pTags?.let { notify(it.map { it.toPTag() }) }
|
||||
|
||||
hashtags(findHashtags(tagger.message))
|
||||
references(findURLs(tagger.message))
|
||||
quotes(findNostrUris(tagger.message))
|
||||
|
||||
localZapRaiserAmount?.let { zapraiser(it) }
|
||||
zapReceiver?.let { zapSplits(it) }
|
||||
contentWarningReason?.let { contentWarning(it) }
|
||||
|
||||
emojis(emojis)
|
||||
imetas(usedAttachments)
|
||||
}
|
||||
} else {
|
||||
val externalIdentity = externalIdentity ?: return null
|
||||
CommentEvent.replyExternalIdentity(
|
||||
msg = tagger.message,
|
||||
extId = externalIdentity,
|
||||
) {
|
||||
tagger.pTags?.let { notify(it.map { it.toPTag() }) }
|
||||
|
||||
hashtags(findHashtags(tagger.message))
|
||||
references(findURLs(tagger.message))
|
||||
quotes(findNostrUris(tagger.message))
|
||||
|
||||
localZapRaiserAmount?.let { zapraiser(it) }
|
||||
zapReceiver?.let { zapSplits(it) }
|
||||
contentWarningReason?.let { contentWarning(it) }
|
||||
|
||||
emojis(emojis)
|
||||
imetas(usedAttachments)
|
||||
}
|
||||
}
|
||||
|
||||
return template
|
||||
}
|
||||
|
||||
fun findEmoji(
|
||||
message: String,
|
||||
myEmojiSet: List<EmojiMedia>?,
|
||||
): List<EmojiUrlTag> {
|
||||
if (myEmojiSet == null) return emptyList()
|
||||
return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji ->
|
||||
myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link.url) }
|
||||
}
|
||||
}
|
||||
|
||||
fun upload(
|
||||
alt: String?,
|
||||
contentWarningReason: String?,
|
||||
mediaQuality: Int,
|
||||
server: ServerName,
|
||||
onError: (title: String, message: String) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
val myAccount = account ?: return@launch
|
||||
val myMultiOrchestrator = multiOrchestrator ?: return@launch
|
||||
|
||||
isUploadingImage = true
|
||||
|
||||
val results =
|
||||
myMultiOrchestrator.upload(
|
||||
viewModelScope,
|
||||
alt,
|
||||
contentWarningReason,
|
||||
MediaCompressor.intToCompressorQuality(mediaQuality),
|
||||
server,
|
||||
myAccount,
|
||||
context,
|
||||
)
|
||||
|
||||
if (results.allGood) {
|
||||
results.successful.forEach {
|
||||
if (it.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
|
||||
account?.createNip95(it.result.bytes, headerInfo = it.result.fileHeader, alt, contentWarningReason) { nip95 ->
|
||||
nip95attachments = nip95attachments + nip95
|
||||
val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) }
|
||||
|
||||
note?.let {
|
||||
message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
|
||||
urlPreviews.update(message)
|
||||
}
|
||||
}
|
||||
} else if (it.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
|
||||
val iMeta =
|
||||
IMetaTagBuilder(it.result.url)
|
||||
.apply {
|
||||
hash(it.result.fileHeader.hash)
|
||||
size(it.result.fileHeader.size)
|
||||
it.result.fileHeader.mimeType
|
||||
?.let { mimeType(it) }
|
||||
it.result.fileHeader.dim
|
||||
?.let { dims(it) }
|
||||
it.result.fileHeader.blurHash
|
||||
?.let { blurhash(it.blurhash) }
|
||||
it.result.magnet?.let { magnet(it) }
|
||||
it.result.uploadedHash?.let { originalHash(it) }
|
||||
|
||||
alt?.let { alt(it) }
|
||||
contentWarningReason?.let { sensitiveContent(contentWarningReason) }
|
||||
}.build()
|
||||
|
||||
iMetaAttachments.replace(iMeta.url, iMeta)
|
||||
|
||||
message = message.insertUrlAtCursor(it.result.url)
|
||||
urlPreviews.update(message)
|
||||
}
|
||||
}
|
||||
|
||||
multiOrchestrator = null
|
||||
} else {
|
||||
val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct()
|
||||
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
|
||||
}
|
||||
|
||||
isUploadingImage = false
|
||||
}
|
||||
}
|
||||
|
||||
open fun cancel() {
|
||||
message = TextFieldValue("")
|
||||
|
||||
replyingTo = null
|
||||
externalIdentity = null
|
||||
|
||||
multiOrchestrator = null
|
||||
isUploadingImage = false
|
||||
|
||||
notifying = null
|
||||
|
||||
wantsInvoice = false
|
||||
wantsZapraiser = false
|
||||
zapRaiserAmount.value = null
|
||||
|
||||
wantsForwardZapTo = false
|
||||
wantsToMarkAsSensitive = false
|
||||
wantsSecretEmoji = false
|
||||
|
||||
forwardZapTo.value = SplitBuilder()
|
||||
forwardZapToEditting.value = TextFieldValue("")
|
||||
|
||||
urlPreviews.reset()
|
||||
|
||||
userSuggestions?.reset()
|
||||
userSuggestionsMainMessage = null
|
||||
|
||||
iMetaAttachments.reset()
|
||||
|
||||
emojiSuggestions?.reset()
|
||||
|
||||
showRelaysDialog = false
|
||||
|
||||
reloadRelaySet()
|
||||
|
||||
draftTag.rotate()
|
||||
}
|
||||
|
||||
fun reloadRelaySet() {
|
||||
val account = accountViewModel?.account ?: return
|
||||
|
||||
val nip65 = account.normalizedNIP65WriteRelayList.value
|
||||
val private = account.normalizedPrivateOutBoxRelaySet.value
|
||||
val local = account.settings.localRelayServers
|
||||
|
||||
relayList =
|
||||
if (nip65.isEmpty()) {
|
||||
account.activeWriteRelays().map { it.url }.toImmutableList()
|
||||
} else {
|
||||
val combined: Set<String> = (nip65 + private + local)
|
||||
combined.toImmutableList()
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
|
||||
this.multiOrchestrator?.remove(selected)
|
||||
}
|
||||
|
||||
open fun removeFromReplyList(userToRemove: User) {
|
||||
notifying = notifying?.filter { it != userToRemove }
|
||||
}
|
||||
|
||||
override fun updateMessage(it: TextFieldValue) {
|
||||
message = it
|
||||
urlPreviews.update(message)
|
||||
|
||||
if (message.selection.collapsed) {
|
||||
val lastWord = message.currentWord()
|
||||
|
||||
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
|
||||
userSuggestions?.processCurrentWord(lastWord)
|
||||
|
||||
emojiSuggestions?.processCurrentWord(lastWord)
|
||||
}
|
||||
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
override fun updateZapForwardTo(newZapForwardTo: TextFieldValue) {
|
||||
forwardZapToEditting.value = newZapForwardTo
|
||||
if (newZapForwardTo.selection.collapsed) {
|
||||
val lastWord = newZapForwardTo.text
|
||||
userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS
|
||||
userSuggestions?.processCurrentWord(lastWord)
|
||||
}
|
||||
}
|
||||
|
||||
open fun autocompleteWithUser(item: User) {
|
||||
userSuggestions?.let { userSuggestions ->
|
||||
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
|
||||
val lastWord = message.currentWord()
|
||||
message = userSuggestions.replaceCurrentWord(message, lastWord, item)
|
||||
urlPreviews.update(message)
|
||||
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
|
||||
forwardZapTo.value.addItem(item)
|
||||
forwardZapToEditting.value = TextFieldValue("")
|
||||
}
|
||||
|
||||
userSuggestionsMainMessage = null
|
||||
userSuggestions.reset()
|
||||
}
|
||||
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
open fun autocompleteWithEmoji(item: EmojiMedia) {
|
||||
val wordToInsert = ":${item.code}:"
|
||||
|
||||
message = message.replaceCurrentWord(wordToInsert)
|
||||
urlPreviews.update(message)
|
||||
|
||||
emojiSuggestions?.reset()
|
||||
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
open fun autocompleteWithEmojiUrl(item: EmojiMedia) {
|
||||
val wordToInsert = item.link.url + " "
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
iMetaAttachments.downloadAndPrepare(
|
||||
item.link.url,
|
||||
{ Amethyst.instance.okHttpClients.getHttpClient(accountViewModel?.account?.shouldUseTorForImageDownload(item.link.url) ?: false) },
|
||||
)
|
||||
}
|
||||
|
||||
message = message.replaceCurrentWord(wordToInsert)
|
||||
urlPreviews.update(message)
|
||||
|
||||
emojiSuggestions?.reset()
|
||||
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
fun canPost(): Boolean =
|
||||
message.text.isNotBlank() &&
|
||||
!isUploadingImage &&
|
||||
!wantsInvoice &&
|
||||
(!wantsZapraiser || zapRaiserAmount.value != null) &&
|
||||
multiOrchestrator == null
|
||||
|
||||
fun insertAtCursor(newElement: String) {
|
||||
message = message.insertUrlAtCursor(newElement)
|
||||
}
|
||||
|
||||
fun selectImage(uris: ImmutableList<SelectedMedia>) {
|
||||
multiOrchestrator = MultiOrchestrator(uris)
|
||||
}
|
||||
|
||||
override fun updateZapPercentage(
|
||||
index: Int,
|
||||
sliderValue: Float,
|
||||
) {
|
||||
forwardZapTo.value.updatePercentage(index, sliderValue)
|
||||
}
|
||||
|
||||
override fun updateZapFromText() {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
val tagger =
|
||||
NewMessageTagger(message.text, emptyList(), emptyList(), null, accountViewModel!!)
|
||||
tagger.run()
|
||||
tagger.pTags?.forEach { taggedUser ->
|
||||
if (!forwardZapTo.value.items.any { it.key == taggedUser }) {
|
||||
forwardZapTo.value.addItem(taggedUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateZapRaiserAmount(newAmount: Long?) {
|
||||
zapRaiserAmount.value = newAmount
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
fun toggleMarkAsSensitive() {
|
||||
wantsToMarkAsSensitive = !wantsToMarkAsSensitive
|
||||
draftTag.newVersion()
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 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.geohash
|
||||
|
||||
import androidx.compose.foundation.layout.size
|
||||
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 androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import com.vitorpamplona.amethyst.ui.painterRes
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||
|
||||
@Composable
|
||||
fun NewGeoPostButton(
|
||||
tag: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
nav.nav(Route.GeoPost(tag))
|
||||
},
|
||||
modifier = Size55Modifier,
|
||||
shape = CircleShape,
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterRes(R.drawable.ic_compose),
|
||||
contentDescription = stringRes(id = R.string.new_community_note),
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
+24
-10
@@ -27,12 +27,15 @@ import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
|
||||
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHash
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId
|
||||
|
||||
class GeoHashFeedFilter(
|
||||
val tag: String,
|
||||
@@ -58,18 +61,29 @@ class GeoHashFeedFilter(
|
||||
it: Note,
|
||||
geoTag: String,
|
||||
): Boolean =
|
||||
(
|
||||
it.event is TextNoteEvent ||
|
||||
it.event is LongTextNoteEvent ||
|
||||
it.event is WikiNoteEvent ||
|
||||
it.event is ChannelMessageEvent ||
|
||||
it.event is PrivateDmEvent ||
|
||||
it.event is PollNoteEvent ||
|
||||
it.event is AudioHeaderEvent
|
||||
) &&
|
||||
it.event?.isTaggedGeoHash(geoTag) == true &&
|
||||
(acceptableViaHashtag(it.event, geoTag) || acceptableViaScope(it.event, geoTag)) &&
|
||||
!it.isHiddenFor(account.flowHiddenUsers.value) &&
|
||||
account.isAcceptable(it)
|
||||
|
||||
fun acceptableViaHashtag(
|
||||
event: Event?,
|
||||
geohash: String,
|
||||
): Boolean =
|
||||
(
|
||||
event is TextNoteEvent ||
|
||||
event is LongTextNoteEvent ||
|
||||
event is WikiNoteEvent ||
|
||||
event is ChannelMessageEvent ||
|
||||
event is PrivateDmEvent ||
|
||||
event is PollNoteEvent ||
|
||||
event is AudioHeaderEvent
|
||||
) &&
|
||||
event.isTaggedGeoHash(geohash) == true
|
||||
|
||||
fun acceptableViaScope(
|
||||
event: Event?,
|
||||
geohash: String,
|
||||
): Boolean = event is CommentEvent && event.isTaggedScope(geohash, GeohashId::match)
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> = collection.sortedWith(DefaultFeedOrder)
|
||||
}
|
||||
|
||||
+14
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
||||
|
||||
@@ -62,4 +63,17 @@ fun filterPostsByGeohash(
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
tags = mapOf("I" to listOf(GeohashId.toScope(geohash))),
|
||||
kinds =
|
||||
listOf(
|
||||
CommentEvent.KIND,
|
||||
),
|
||||
limit = 200,
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
+558
@@ -0,0 +1,558 @@
|
||||
/**
|
||||
* 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.hashtag
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Tag
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.actions.RelaySelectionDialogEasy
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewUrl
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewUrlFillWidth
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.AddZapraiserButton
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.ZapRaiserRequest
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapToButton
|
||||
import com.vitorpamplona.amethyst.ui.painterRes
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.Notifying
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.PostButton
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.FillWidthQuoteBorderModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Height100Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.SquaredQuoteBorderModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun HashtagPostScreen(
|
||||
hashtag: String? = null,
|
||||
message: String? = null,
|
||||
attachment: Uri? = null,
|
||||
reply: Note? = null,
|
||||
quote: Note? = null,
|
||||
draft: Note? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: Nav,
|
||||
) {
|
||||
val postViewModel: HashtagPostViewModel = viewModel()
|
||||
postViewModel.init(accountViewModel)
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
postViewModel.reloadRelaySet()
|
||||
hashtag?.let {
|
||||
postViewModel.newPostFor(HashtagId(it))
|
||||
}
|
||||
reply?.let {
|
||||
postViewModel.reply(it)
|
||||
}
|
||||
draft?.let {
|
||||
postViewModel.editFromDraft(it)
|
||||
}
|
||||
quote?.let {
|
||||
postViewModel.quote(it)
|
||||
}
|
||||
message?.ifBlank { null }?.let {
|
||||
postViewModel.updateMessage(TextFieldValue(it))
|
||||
}
|
||||
attachment?.let {
|
||||
withContext(Dispatchers.IO) {
|
||||
val mediaType = context.contentResolver.getType(it)
|
||||
postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HashtagPostScreenInner(postViewModel, accountViewModel, nav)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HashtagPostScreenInner(
|
||||
postViewModel: HashtagPostViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: Nav,
|
||||
) {
|
||||
WatchAndLoadMyEmojiList(accountViewModel)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = CenterVertically,
|
||||
) {
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
|
||||
Box {
|
||||
IconButton(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
onClick = { postViewModel.showRelaysDialog = true },
|
||||
) {
|
||||
Icon(
|
||||
painter = painterRes(R.drawable.relays),
|
||||
contentDescription = stringRes(id = R.string.relay_list_selector),
|
||||
modifier = Modifier.height(25.dp),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
PostButton(
|
||||
onPost = {
|
||||
// uses the accountViewModel scope to avoid cancelling this
|
||||
// function when the postViewModel is released
|
||||
accountViewModel.viewModelScope.launch(Dispatchers.IO) {
|
||||
postViewModel.sendPostSync()
|
||||
nav.popBack()
|
||||
}
|
||||
},
|
||||
isActive = postViewModel.canPost(),
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
Row {
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
CloseButton(
|
||||
onPress = {
|
||||
// uses the accountViewModel scope to avoid cancelling this
|
||||
// function when the postViewModel is released
|
||||
accountViewModel.viewModelScope.launch(Dispatchers.IO) {
|
||||
postViewModel.sendDraftSync()
|
||||
nav.popBack()
|
||||
postViewModel.cancel()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
colors =
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { pad ->
|
||||
if (postViewModel.showRelaysDialog) {
|
||||
RelaySelectionDialogEasy(
|
||||
preSelectedList = postViewModel.relayList ?: persistentListOf(),
|
||||
onClose = { postViewModel.showRelaysDialog = false },
|
||||
onPost = { postViewModel.relayList = it.map { it.url }.toImmutableList() },
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(pad)
|
||||
.consumeWindowInsets(pad)
|
||||
.imePadding(),
|
||||
) {
|
||||
HashtagPostBody(
|
||||
postViewModel,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HashtagPostBody(
|
||||
postViewModel: HashtagPostViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: Nav,
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = Size10dp,
|
||||
end = Size10dp,
|
||||
).weight(1f),
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().verticalScroll(scrollState)) {
|
||||
postViewModel.externalIdentity?.let {
|
||||
Row {
|
||||
DisplayExternalId(it, accountViewModel, nav)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
}
|
||||
}
|
||||
|
||||
postViewModel.replyingTo?.let {
|
||||
Row {
|
||||
NoteCompose(
|
||||
baseNote = it,
|
||||
modifier = MaterialTheme.colorScheme.replyModifier,
|
||||
isQuotedNote = true,
|
||||
unPackReply = false,
|
||||
makeItShort = true,
|
||||
quotesLeft = 1,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
Notifying(postViewModel.notifying?.toImmutableList(), accountViewModel) {
|
||||
postViewModel.removeFromReplyList(it)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = Size10dp),
|
||||
) {
|
||||
BaseUserPicture(
|
||||
accountViewModel.userProfile(),
|
||||
Size35dp,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
MessageField(
|
||||
R.string.what_s_on_your_mind,
|
||||
postViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
DisplayPreviews(postViewModel, accountViewModel, nav)
|
||||
|
||||
if (postViewModel.wantsToMarkAsSensitive) {
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
|
||||
) {
|
||||
ContentSensitivityExplainer()
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.wantsToAddGeoHash) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
|
||||
) {
|
||||
LocationAsHash(postViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.wantsForwardZapTo) {
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = Modifier.padding(top = Size5dp, bottom = Size5dp, start = Size10dp),
|
||||
) {
|
||||
ForwardZapTo(postViewModel, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
postViewModel.multiOrchestrator?.let {
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
ImageVideoDescription(
|
||||
it,
|
||||
accountViewModel.account.settings.defaultFileServer,
|
||||
onAdd = { alt, server, sensitiveContent, mediaQuality ->
|
||||
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context)
|
||||
if (server.type != ServerType.NIP95) {
|
||||
accountViewModel.account.settings.changeDefaultFileServer(server)
|
||||
}
|
||||
},
|
||||
onDelete = postViewModel::deleteMediaToUpload,
|
||||
onCancel = { postViewModel.multiOrchestrator = null },
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.wantsInvoice) {
|
||||
postViewModel.lnAddress()?.let { lud16 ->
|
||||
InvoiceRequest(
|
||||
lud16,
|
||||
accountViewModel.account.userProfile().pubkeyHex,
|
||||
accountViewModel,
|
||||
stringRes(id = R.string.lightning_invoice),
|
||||
stringRes(id = R.string.lightning_create_and_add_invoice),
|
||||
onSuccess = {
|
||||
postViewModel.insertAtCursor(it)
|
||||
postViewModel.wantsInvoice = false
|
||||
},
|
||||
onError = { title, message -> accountViewModel.toastManager.toast(title, message) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.wantsSecretEmoji) {
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
SecretEmojiRequest {
|
||||
postViewModel.insertAtCursor(it)
|
||||
postViewModel.wantsSecretEmoji = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.wantsZapraiser && postViewModel.hasLnAddress()) {
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
|
||||
) {
|
||||
ZapRaiserRequest(
|
||||
stringRes(id = R.string.zapraiser),
|
||||
postViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
postViewModel.userSuggestions?.let {
|
||||
ShowUserSuggestionList(
|
||||
it,
|
||||
postViewModel::autocompleteWithUser,
|
||||
accountViewModel,
|
||||
modifier = Modifier.heightIn(0.dp, 300.dp),
|
||||
)
|
||||
}
|
||||
|
||||
postViewModel.emojiSuggestions?.let {
|
||||
ShowEmojiSuggestionList(
|
||||
it,
|
||||
postViewModel::autocompleteWithEmoji,
|
||||
postViewModel::autocompleteWithEmojiUrl,
|
||||
accountViewModel,
|
||||
modifier = Modifier.heightIn(0.dp, 300.dp),
|
||||
)
|
||||
}
|
||||
|
||||
BottomRowActions(postViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayExternalId(
|
||||
externalId: HashtagId,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
Row(modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Tag,
|
||||
contentDescription = stringRes(id = R.string.hashtag_exclusive),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
||||
Spacer(StdHorzSpacer)
|
||||
|
||||
Text(
|
||||
text =
|
||||
buildAnnotatedString {
|
||||
withLink(
|
||||
LinkAnnotation.Clickable("hashtag") { nav.nav(Route.Hashtag(externalId.topic)) },
|
||||
) {
|
||||
append(externalId.topic)
|
||||
}
|
||||
},
|
||||
style =
|
||||
LocalTextStyle.current.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomRowActions(postViewModel: HashtagPostViewModel) {
|
||||
val scrollState = rememberScrollState()
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.horizontalScroll(scrollState)
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
verticalAlignment = CenterVertically,
|
||||
) {
|
||||
SelectFromGallery(
|
||||
isUploading = postViewModel.isUploadingImage,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier,
|
||||
) {
|
||||
postViewModel.selectImage(it)
|
||||
}
|
||||
|
||||
TakePictureButton(
|
||||
onPictureTaken = {
|
||||
postViewModel.selectImage(it)
|
||||
},
|
||||
)
|
||||
|
||||
ForwardZapToButton(postViewModel.wantsForwardZapTo) {
|
||||
postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo
|
||||
}
|
||||
|
||||
if (postViewModel.canAddZapRaiser) {
|
||||
AddZapraiserButton(postViewModel.wantsZapraiser) {
|
||||
postViewModel.wantsZapraiser = !postViewModel.wantsZapraiser
|
||||
}
|
||||
}
|
||||
|
||||
MarkAsSensitiveButton(postViewModel.wantsToMarkAsSensitive) {
|
||||
postViewModel.toggleMarkAsSensitive()
|
||||
}
|
||||
|
||||
AddGeoHashButton(postViewModel.wantsToAddGeoHash) {
|
||||
postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash
|
||||
}
|
||||
|
||||
AddSecretEmojiButton(postViewModel.wantsSecretEmoji) {
|
||||
postViewModel.wantsSecretEmoji = !postViewModel.wantsSecretEmoji
|
||||
}
|
||||
|
||||
if (postViewModel.canAddInvoice && postViewModel.hasLnAddress()) {
|
||||
AddLnInvoiceButton(postViewModel.wantsInvoice) {
|
||||
postViewModel.wantsInvoice = !postViewModel.wantsInvoice
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayPreviews(
|
||||
postViewModel: HashtagPostViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val urlPreviews by postViewModel.urlPreviews.results.collectAsStateWithLifecycle(emptyList())
|
||||
|
||||
if (urlPreviews.isNotEmpty()) {
|
||||
Row(HalfHorzPadding) {
|
||||
if (urlPreviews.size > 1) {
|
||||
LazyRow(Height100Modifier, horizontalArrangement = spacedBy(Size5dp)) {
|
||||
items(urlPreviews) {
|
||||
Box(SquaredQuoteBorderModifier) {
|
||||
PreviewUrl(it, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Box(FillWidthQuoteBorderModifier) {
|
||||
PreviewUrlFillWidth(urlPreviews[0], accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+690
@@ -0,0 +1,690 @@
|
||||
/**
|
||||
* 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.hashtag
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.compose.currentWord
|
||||
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
|
||||
import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState.EmojiMedia
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||
import com.vitorpamplona.amethyst.ui.actions.UserSuggestionAnchor
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewState
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.IZapRaiser
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.IZapField
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.SplitBuilder
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.toZapSplitSetup
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
|
||||
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.hasGeohashes
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||
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.nip18Reposts.quotes.taggedQuoteIds
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.notify
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
|
||||
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser
|
||||
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.topics.hashtagScope
|
||||
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
|
||||
import com.vitorpamplona.quartz.nip92IMeta.imetas
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.alt
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.blurhash
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.dims
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.hash
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.magnet
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.size
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Stable
|
||||
open class HashtagPostViewModel :
|
||||
ViewModel(),
|
||||
ILocationGrabber,
|
||||
IMessageField,
|
||||
IZapField,
|
||||
IZapRaiser {
|
||||
val draftTag = DraftTagState()
|
||||
|
||||
init {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
draftTag.versions.collectLatest {
|
||||
sendDraftSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var accountViewModel: AccountViewModel? = null
|
||||
var account: Account? = null
|
||||
|
||||
var externalIdentity by mutableStateOf<HashtagId?>(null)
|
||||
var replyingTo: Note? by mutableStateOf<Note?>(null)
|
||||
|
||||
val iMetaAttachments = IMetaAttachments()
|
||||
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
|
||||
|
||||
var notifying by mutableStateOf<List<User>?>(null)
|
||||
|
||||
override var message by mutableStateOf(TextFieldValue(""))
|
||||
|
||||
val urlPreviews = PreviewState()
|
||||
|
||||
var isUploadingImage by mutableStateOf(false)
|
||||
|
||||
var userSuggestions: UserSuggestionState? = null
|
||||
var userSuggestionsMainMessage: UserSuggestionAnchor? = null
|
||||
|
||||
var emojiSuggestions: EmojiSuggestionState? = null
|
||||
|
||||
// Images and Videos
|
||||
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
|
||||
|
||||
// Invoices
|
||||
var canAddInvoice by mutableStateOf(false)
|
||||
var wantsInvoice by mutableStateOf(false)
|
||||
|
||||
var wantsSecretEmoji by mutableStateOf(false)
|
||||
|
||||
// Forward Zap to
|
||||
var wantsForwardZapTo by mutableStateOf(false)
|
||||
override var forwardZapTo = mutableStateOf<SplitBuilder<User>>(SplitBuilder())
|
||||
override var forwardZapToEditting = mutableStateOf(TextFieldValue(""))
|
||||
|
||||
// NSFW, Sensitive
|
||||
var wantsToMarkAsSensitive by mutableStateOf(false)
|
||||
|
||||
// GeoHash
|
||||
var wantsToAddGeoHash by mutableStateOf(false)
|
||||
var location: StateFlow<LocationState.LocationResult>? = null
|
||||
|
||||
// ZapRaiser
|
||||
var canAddZapRaiser by mutableStateOf(false)
|
||||
var wantsZapraiser by mutableStateOf(false)
|
||||
override val zapRaiserAmount = mutableStateOf<Long?>(null)
|
||||
|
||||
var showRelaysDialog by mutableStateOf(false)
|
||||
var relayList by mutableStateOf<ImmutableList<String>?>(null)
|
||||
|
||||
fun lnAddress(): String? = account?.userProfile()?.info?.lnAddress()
|
||||
|
||||
fun hasLnAddress(): Boolean = account?.userProfile()?.info?.lnAddress() != null
|
||||
|
||||
fun user(): User? = account?.userProfile()
|
||||
|
||||
open fun init(accountVM: AccountViewModel) {
|
||||
this.accountViewModel = accountVM
|
||||
this.account = accountVM.account
|
||||
this.canAddInvoice = hasLnAddress()
|
||||
this.canAddZapRaiser = hasLnAddress()
|
||||
|
||||
this.userSuggestions?.reset()
|
||||
this.userSuggestions = UserSuggestionState(accountVM)
|
||||
|
||||
this.emojiSuggestions?.reset()
|
||||
this.emojiSuggestions = EmojiSuggestionState(accountVM)
|
||||
}
|
||||
|
||||
fun newPostFor(externalIdentity: HashtagId) {
|
||||
this.externalIdentity = externalIdentity
|
||||
}
|
||||
|
||||
fun editFromDraft(draft: Note) {
|
||||
val accountViewModel = accountViewModel ?: return
|
||||
|
||||
val noteEvent = draft.event
|
||||
val noteAuthor = draft.author
|
||||
|
||||
if (noteEvent is DraftEvent && noteAuthor != null) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
accountViewModel.createTempDraftNote(noteEvent) { innerNote ->
|
||||
if (innerNote != null) {
|
||||
val oldTag = (draft.event as? AddressableEvent)?.dTag()
|
||||
if (oldTag != null) {
|
||||
draftTag.set(oldTag)
|
||||
}
|
||||
loadFromDraft(innerNote)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open fun reply(post: Note) {
|
||||
val accountViewModel = accountViewModel ?: return
|
||||
val noteEvent = post.event as? CommentEvent ?: return
|
||||
val hashtag = noteEvent.hashtagScope() ?: return
|
||||
|
||||
this.replyingTo = post
|
||||
this.externalIdentity = HashtagId(hashtag)
|
||||
}
|
||||
|
||||
open fun quote(quote: Note) {
|
||||
val accountViewModel = accountViewModel ?: return
|
||||
|
||||
message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}")
|
||||
|
||||
quote.author?.let { quotedUser ->
|
||||
if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
|
||||
if (forwardZapTo.value.items.none { it.key.pubkeyHex == quotedUser.pubkeyHex }) {
|
||||
forwardZapTo.value.addItem(quotedUser)
|
||||
}
|
||||
if (forwardZapTo.value.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) {
|
||||
forwardZapTo.value.addItem(accountViewModel.userProfile())
|
||||
}
|
||||
|
||||
val pos = forwardZapTo.value.items.indexOfFirst { it.key.pubkeyHex == quotedUser.pubkeyHex }
|
||||
forwardZapTo.value.updatePercentage(pos, 0.9f)
|
||||
}
|
||||
}
|
||||
|
||||
if (!forwardZapTo.value.items.isEmpty()) {
|
||||
wantsForwardZapTo = true
|
||||
}
|
||||
|
||||
urlPreviews.update(message)
|
||||
}
|
||||
|
||||
private fun loadFromDraft(draft: Note) {
|
||||
val draftEvent = draft.event ?: return
|
||||
if (draftEvent !is CommentEvent) return
|
||||
|
||||
loadFromDraft(draftEvent)
|
||||
}
|
||||
|
||||
private fun loadFromDraft(draftEvent: CommentEvent) {
|
||||
val hashtag = draftEvent.hashtagScope() ?: return
|
||||
this.externalIdentity = HashtagId(hashtag)
|
||||
|
||||
canAddInvoice = accountViewModel?.userProfile()?.info?.lnAddress() != null
|
||||
canAddZapRaiser = accountViewModel?.userProfile()?.info?.lnAddress() != null
|
||||
multiOrchestrator = null
|
||||
|
||||
val localfowardZapTo = draftEvent.tags.filter { it.size > 1 && it[0] == "zap" }
|
||||
forwardZapTo.value = SplitBuilder()
|
||||
localfowardZapTo.forEach {
|
||||
val user = LocalCache.getOrCreateUser(it[1])
|
||||
val value = it.last().toFloatOrNull() ?: 0f
|
||||
forwardZapTo.value.addItem(user, value)
|
||||
}
|
||||
forwardZapToEditting.value = TextFieldValue("")
|
||||
wantsForwardZapTo = localfowardZapTo.isNotEmpty()
|
||||
|
||||
wantsToMarkAsSensitive = draftEvent.isSensitive()
|
||||
|
||||
val zapraiser = draftEvent.zapraiserAmount()
|
||||
wantsZapraiser = zapraiser != null
|
||||
zapRaiserAmount.value = null
|
||||
if (zapraiser != null) {
|
||||
zapRaiserAmount.value = zapraiser
|
||||
}
|
||||
|
||||
draftEvent.replyingTo()?.let {
|
||||
replyingTo = LocalCache.getOrCreateNote(it)
|
||||
}
|
||||
|
||||
wantsToAddGeoHash = draftEvent.hasGeohashes()
|
||||
|
||||
notifying = draftEvent.rootAuthorKeys().mapNotNull { LocalCache.checkGetOrCreateUser(it) } +
|
||||
draftEvent.replyAuthorKeys().mapNotNull { LocalCache.checkGetOrCreateUser(it) }
|
||||
|
||||
if (forwardZapTo.value.items.isNotEmpty()) {
|
||||
wantsForwardZapTo = true
|
||||
}
|
||||
|
||||
message = TextFieldValue(draftEvent.content)
|
||||
|
||||
iMetaAttachments.addAll(draftEvent.imetas())
|
||||
|
||||
urlPreviews.update(message)
|
||||
}
|
||||
|
||||
suspend fun sendPostSync() {
|
||||
val template = createTemplate() ?: return
|
||||
val relayList = relayList
|
||||
|
||||
if (nip95attachments.isNotEmpty() && relayList != null) {
|
||||
val usedImages = template.tags.taggedQuoteIds().toSet()
|
||||
nip95attachments.forEach {
|
||||
if (usedImages.contains(it.second.id) == true) {
|
||||
account?.sendNip95Privately(it.first, it.second, relayList)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
accountViewModel?.account?.signAndSendPrivatelyOrBroadcast(
|
||||
template,
|
||||
relayList = { relayList },
|
||||
)
|
||||
|
||||
accountViewModel?.deleteDraft(draftTag.current)
|
||||
|
||||
cancel()
|
||||
}
|
||||
|
||||
suspend fun sendDraftSync() {
|
||||
val accountViewModel = accountViewModel ?: return
|
||||
|
||||
if (message.text.isBlank()) {
|
||||
accountViewModel.account.deleteDraft(draftTag.current)
|
||||
} else {
|
||||
val template = createTemplate() ?: return
|
||||
accountViewModel.account.createAndSendDraft(draftTag.current, template)
|
||||
|
||||
nip95attachments.forEach {
|
||||
account?.sendToPrivateOutboxAndLocal(it.first)
|
||||
account?.sendToPrivateOutboxAndLocal(it.second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createTemplate(): EventTemplate<out Event>? {
|
||||
val accountViewModel = accountViewModel ?: return null
|
||||
|
||||
val tagger =
|
||||
NewMessageTagger(
|
||||
message = message.text,
|
||||
dao = accountViewModel,
|
||||
)
|
||||
tagger.run()
|
||||
|
||||
val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString()
|
||||
|
||||
val emojis = findEmoji(tagger.message, account?.emoji?.myEmojis?.value)
|
||||
val urls = findURLs(tagger.message)
|
||||
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
|
||||
|
||||
val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null
|
||||
val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null
|
||||
val contentWarningReason = if (wantsToMarkAsSensitive) "" else null
|
||||
|
||||
val replyingTo = replyingTo
|
||||
|
||||
val template =
|
||||
if (replyingTo != null) {
|
||||
val eventHint = replyingTo.toEventHint<Event>() ?: return null
|
||||
|
||||
CommentEvent.replyBuilder(
|
||||
msg = tagger.message,
|
||||
replyingTo = eventHint,
|
||||
) {
|
||||
tagger.pTags?.let { notify(it.map { it.toPTag() }) }
|
||||
|
||||
hashtags(findHashtags(tagger.message))
|
||||
references(findURLs(tagger.message))
|
||||
quotes(findNostrUris(tagger.message))
|
||||
|
||||
localZapRaiserAmount?.let { zapraiser(it) }
|
||||
zapReceiver?.let { zapSplits(it) }
|
||||
contentWarningReason?.let { contentWarning(it) }
|
||||
|
||||
emojis(emojis)
|
||||
imetas(usedAttachments)
|
||||
geoHash?.let { geohash(it) }
|
||||
}
|
||||
} else {
|
||||
val externalIdentity = externalIdentity ?: return null
|
||||
CommentEvent.replyExternalIdentity(
|
||||
msg = tagger.message,
|
||||
extId = externalIdentity,
|
||||
) {
|
||||
tagger.pTags?.let { notify(it.map { it.toPTag() }) }
|
||||
|
||||
hashtags(findHashtags(tagger.message))
|
||||
references(findURLs(tagger.message))
|
||||
quotes(findNostrUris(tagger.message))
|
||||
|
||||
localZapRaiserAmount?.let { zapraiser(it) }
|
||||
zapReceiver?.let { zapSplits(it) }
|
||||
contentWarningReason?.let { contentWarning(it) }
|
||||
|
||||
emojis(emojis)
|
||||
imetas(usedAttachments)
|
||||
geoHash?.let { geohash(it) }
|
||||
}
|
||||
}
|
||||
|
||||
return template
|
||||
}
|
||||
|
||||
fun findEmoji(
|
||||
message: String,
|
||||
myEmojiSet: List<EmojiMedia>?,
|
||||
): List<EmojiUrlTag> {
|
||||
if (myEmojiSet == null) return emptyList()
|
||||
return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji ->
|
||||
myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link.url) }
|
||||
}
|
||||
}
|
||||
|
||||
fun upload(
|
||||
alt: String?,
|
||||
contentWarningReason: String?,
|
||||
mediaQuality: Int,
|
||||
server: ServerName,
|
||||
onError: (title: String, message: String) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
val myAccount = account ?: return@launch
|
||||
val myMultiOrchestrator = multiOrchestrator ?: return@launch
|
||||
|
||||
isUploadingImage = true
|
||||
|
||||
val results =
|
||||
myMultiOrchestrator.upload(
|
||||
viewModelScope,
|
||||
alt,
|
||||
contentWarningReason,
|
||||
MediaCompressor.intToCompressorQuality(mediaQuality),
|
||||
server,
|
||||
myAccount,
|
||||
context,
|
||||
)
|
||||
|
||||
if (results.allGood) {
|
||||
results.successful.forEach {
|
||||
if (it.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
|
||||
account?.createNip95(it.result.bytes, headerInfo = it.result.fileHeader, alt, contentWarningReason) { nip95 ->
|
||||
nip95attachments = nip95attachments + nip95
|
||||
val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) }
|
||||
|
||||
note?.let {
|
||||
message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
|
||||
urlPreviews.update(message)
|
||||
}
|
||||
}
|
||||
} else if (it.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
|
||||
val iMeta =
|
||||
IMetaTagBuilder(it.result.url)
|
||||
.apply {
|
||||
hash(it.result.fileHeader.hash)
|
||||
size(it.result.fileHeader.size)
|
||||
it.result.fileHeader.mimeType
|
||||
?.let { mimeType(it) }
|
||||
it.result.fileHeader.dim
|
||||
?.let { dims(it) }
|
||||
it.result.fileHeader.blurHash
|
||||
?.let { blurhash(it.blurhash) }
|
||||
it.result.magnet?.let { magnet(it) }
|
||||
it.result.uploadedHash?.let { originalHash(it) }
|
||||
|
||||
alt?.let { alt(it) }
|
||||
contentWarningReason?.let { sensitiveContent(contentWarningReason) }
|
||||
}.build()
|
||||
|
||||
iMetaAttachments.replace(iMeta.url, iMeta)
|
||||
|
||||
message = message.insertUrlAtCursor(it.result.url)
|
||||
urlPreviews.update(message)
|
||||
}
|
||||
}
|
||||
|
||||
multiOrchestrator = null
|
||||
} else {
|
||||
val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct()
|
||||
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
|
||||
}
|
||||
|
||||
isUploadingImage = false
|
||||
}
|
||||
}
|
||||
|
||||
open fun cancel() {
|
||||
message = TextFieldValue("")
|
||||
|
||||
replyingTo = null
|
||||
externalIdentity = null
|
||||
|
||||
multiOrchestrator = null
|
||||
isUploadingImage = false
|
||||
|
||||
notifying = null
|
||||
|
||||
wantsInvoice = false
|
||||
wantsZapraiser = false
|
||||
zapRaiserAmount.value = null
|
||||
|
||||
wantsForwardZapTo = false
|
||||
wantsToMarkAsSensitive = false
|
||||
wantsToAddGeoHash = false
|
||||
wantsSecretEmoji = false
|
||||
|
||||
forwardZapTo.value = SplitBuilder()
|
||||
forwardZapToEditting.value = TextFieldValue("")
|
||||
|
||||
urlPreviews.reset()
|
||||
|
||||
userSuggestions?.reset()
|
||||
userSuggestionsMainMessage = null
|
||||
|
||||
iMetaAttachments.reset()
|
||||
|
||||
emojiSuggestions?.reset()
|
||||
|
||||
showRelaysDialog = false
|
||||
|
||||
reloadRelaySet()
|
||||
|
||||
draftTag.rotate()
|
||||
}
|
||||
|
||||
fun reloadRelaySet() {
|
||||
val account = accountViewModel?.account ?: return
|
||||
|
||||
val nip65 = account.normalizedNIP65WriteRelayList.value
|
||||
val private = account.normalizedPrivateOutBoxRelaySet.value
|
||||
val local = account.settings.localRelayServers
|
||||
|
||||
relayList =
|
||||
if (nip65.isEmpty()) {
|
||||
account.activeWriteRelays().map { it.url }.toImmutableList()
|
||||
} else {
|
||||
val combined: Set<String> = (nip65 + private + local)
|
||||
combined.toImmutableList()
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
|
||||
this.multiOrchestrator?.remove(selected)
|
||||
}
|
||||
|
||||
open fun removeFromReplyList(userToRemove: User) {
|
||||
notifying = notifying?.filter { it != userToRemove }
|
||||
}
|
||||
|
||||
override fun updateMessage(it: TextFieldValue) {
|
||||
message = it
|
||||
urlPreviews.update(message)
|
||||
|
||||
if (message.selection.collapsed) {
|
||||
val lastWord = message.currentWord()
|
||||
|
||||
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
|
||||
userSuggestions?.processCurrentWord(lastWord)
|
||||
|
||||
emojiSuggestions?.processCurrentWord(lastWord)
|
||||
}
|
||||
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
override fun updateZapForwardTo(newZapForwardTo: TextFieldValue) {
|
||||
forwardZapToEditting.value = newZapForwardTo
|
||||
if (newZapForwardTo.selection.collapsed) {
|
||||
val lastWord = newZapForwardTo.text
|
||||
userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS
|
||||
userSuggestions?.processCurrentWord(lastWord)
|
||||
}
|
||||
}
|
||||
|
||||
open fun autocompleteWithUser(item: User) {
|
||||
userSuggestions?.let { userSuggestions ->
|
||||
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
|
||||
val lastWord = message.currentWord()
|
||||
message = userSuggestions.replaceCurrentWord(message, lastWord, item)
|
||||
urlPreviews.update(message)
|
||||
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
|
||||
forwardZapTo.value.addItem(item)
|
||||
forwardZapToEditting.value = TextFieldValue("")
|
||||
}
|
||||
|
||||
userSuggestionsMainMessage = null
|
||||
userSuggestions.reset()
|
||||
}
|
||||
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
open fun autocompleteWithEmoji(item: EmojiMedia) {
|
||||
val wordToInsert = ":${item.code}:"
|
||||
|
||||
message = message.replaceCurrentWord(wordToInsert)
|
||||
urlPreviews.update(message)
|
||||
|
||||
emojiSuggestions?.reset()
|
||||
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
open fun autocompleteWithEmojiUrl(item: EmojiMedia) {
|
||||
val wordToInsert = item.link.url + " "
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
iMetaAttachments.downloadAndPrepare(
|
||||
item.link.url,
|
||||
{ Amethyst.instance.okHttpClients.getHttpClient(accountViewModel?.account?.shouldUseTorForImageDownload(item.link.url) ?: false) },
|
||||
)
|
||||
}
|
||||
|
||||
message = message.replaceCurrentWord(wordToInsert)
|
||||
urlPreviews.update(message)
|
||||
|
||||
emojiSuggestions?.reset()
|
||||
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
fun canPost(): Boolean =
|
||||
message.text.isNotBlank() &&
|
||||
!isUploadingImage &&
|
||||
!wantsInvoice &&
|
||||
(!wantsZapraiser || zapRaiserAmount.value != null) &&
|
||||
multiOrchestrator == null
|
||||
|
||||
fun insertAtCursor(newElement: String) {
|
||||
message = message.insertUrlAtCursor(newElement)
|
||||
}
|
||||
|
||||
fun selectImage(uris: ImmutableList<SelectedMedia>) {
|
||||
multiOrchestrator = MultiOrchestrator(uris)
|
||||
}
|
||||
|
||||
override fun updateZapPercentage(
|
||||
index: Int,
|
||||
sliderValue: Float,
|
||||
) {
|
||||
forwardZapTo.value.updatePercentage(index, sliderValue)
|
||||
}
|
||||
|
||||
override fun updateZapFromText() {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
val tagger =
|
||||
NewMessageTagger(message.text, emptyList(), emptyList(), null, accountViewModel!!)
|
||||
tagger.run()
|
||||
tagger.pTags?.forEach { taggedUser ->
|
||||
if (!forwardZapTo.value.items.any { it.key == taggedUser }) {
|
||||
forwardZapTo.value.addItem(taggedUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateZapRaiserAmount(newAmount: Long?) {
|
||||
zapRaiserAmount.value = newAmount
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
fun toggleMarkAsSensitive() {
|
||||
wantsToMarkAsSensitive = !wantsToMarkAsSensitive
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
override fun locationFlow(): StateFlow<LocationState.LocationResult> {
|
||||
if (location == null) {
|
||||
location = locationManager().geohashStateFlow
|
||||
}
|
||||
|
||||
return location!!
|
||||
}
|
||||
|
||||
override fun locationManager(): LocationState = Amethyst.instance.locationManager
|
||||
}
|
||||
+3
@@ -98,6 +98,9 @@ fun HashtagScreen(
|
||||
popBack = nav::popBack,
|
||||
)
|
||||
},
|
||||
floatingButton = {
|
||||
NewHashtagPostButton(tag, accountViewModel, nav)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
Column(Modifier.padding(it)) {
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 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.hashtag
|
||||
|
||||
import androidx.compose.foundation.layout.size
|
||||
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 androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import com.vitorpamplona.amethyst.ui.painterRes
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||
|
||||
@Composable
|
||||
fun NewHashtagPostButton(
|
||||
tag: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
nav.nav(Route.HashtagPost(tag))
|
||||
},
|
||||
modifier = Size55Modifier,
|
||||
shape = CircleShape,
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterRes(R.drawable.ic_compose),
|
||||
contentDescription = stringRes(id = R.string.new_community_note),
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
+26
-12
@@ -27,14 +27,17 @@ import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
|
||||
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId
|
||||
|
||||
class HashtagFeedFilter(
|
||||
val tag: String,
|
||||
@@ -60,20 +63,31 @@ class HashtagFeedFilter(
|
||||
it: Note,
|
||||
hashTag: String,
|
||||
): Boolean =
|
||||
(
|
||||
it.event is TextNoteEvent ||
|
||||
it.event is RepostEvent ||
|
||||
it.event is GenericRepostEvent ||
|
||||
it.event is LongTextNoteEvent ||
|
||||
it.event is WikiNoteEvent ||
|
||||
it.event is ChannelMessageEvent ||
|
||||
it.event is PrivateDmEvent ||
|
||||
it.event is PollNoteEvent ||
|
||||
it.event is AudioHeaderEvent
|
||||
) &&
|
||||
it.event?.isTaggedHash(hashTag) == true &&
|
||||
(acceptableViaHashtag(it.event, hashTag) || acceptableViaScope(it.event, hashTag)) &&
|
||||
!it.isHiddenFor(account.flowHiddenUsers.value) &&
|
||||
account.isAcceptable(it)
|
||||
|
||||
fun acceptableViaHashtag(
|
||||
event: Event?,
|
||||
hashTag: String,
|
||||
): Boolean =
|
||||
(
|
||||
event is TextNoteEvent ||
|
||||
event is RepostEvent ||
|
||||
event is GenericRepostEvent ||
|
||||
event is LongTextNoteEvent ||
|
||||
event is WikiNoteEvent ||
|
||||
event is ChannelMessageEvent ||
|
||||
event is PrivateDmEvent ||
|
||||
event is PollNoteEvent ||
|
||||
event is AudioHeaderEvent
|
||||
) &&
|
||||
event.isTaggedHash(hashTag) == true
|
||||
|
||||
fun acceptableViaScope(
|
||||
event: Event?,
|
||||
hashTag: String,
|
||||
): Boolean = event is CommentEvent && event.isTaggedScope(hashTag, HashtagId::match)
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> = collection.sortedWith(DefaultFeedOrder)
|
||||
}
|
||||
|
||||
+11
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
||||
|
||||
@@ -81,5 +82,15 @@ fun filterPostsByHashtags(
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
tags = mapOf("I" to listOf(HashtagId.toScope(hashtag))),
|
||||
kinds = listOf(CommentEvent.KIND),
|
||||
limit = 200,
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+24
-5
@@ -53,10 +53,12 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.AROUND_ME
|
||||
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
|
||||
import com.vitorpamplona.amethyst.service.OnlineChecker
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||
import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedState
|
||||
@@ -75,6 +77,7 @@ import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.NewGeoPostButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.live.RenderEphemeralBubble
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -199,11 +202,7 @@ private fun HomePages(
|
||||
}
|
||||
},
|
||||
floatingButton = {
|
||||
val list =
|
||||
accountViewModel.account.settings.defaultHomeFollowList
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
NewNoteButton(nav, list.value == AROUND_ME)
|
||||
HomeScreenFloatingButton(accountViewModel, nav)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
@@ -224,6 +223,26 @@ private fun HomePages(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeScreenFloatingButton(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val list = accountViewModel.account.settings.defaultHomeFollowList.collectAsStateWithLifecycle()
|
||||
|
||||
if (list.value == AROUND_ME) {
|
||||
val location by Amethyst.instance.locationManager.geohashStateFlow.collectAsStateWithLifecycle()
|
||||
|
||||
when (val myLocation = location) {
|
||||
is LocationState.LocationResult.Success -> NewGeoPostButton(myLocation.geoHash.toString(), accountViewModel, nav)
|
||||
is LocationState.LocationResult.LackPermission -> { }
|
||||
is LocationState.LocationResult.Loading -> { }
|
||||
}
|
||||
} else {
|
||||
NewNoteButton(nav)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeFeeds(
|
||||
feedState: FeedContentState,
|
||||
|
||||
+2
-5
@@ -37,13 +37,10 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||
|
||||
@Composable
|
||||
fun NewNoteButton(
|
||||
nav: INav,
|
||||
enableGeolocation: Boolean = false,
|
||||
) {
|
||||
fun NewNoteButton(nav: INav) {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
nav.nav(Route.NewPost(enableGeolocation = enableGeolocation))
|
||||
nav.nav(Route.NewPost())
|
||||
},
|
||||
modifier = Size55Modifier,
|
||||
shape = CircleShape,
|
||||
|
||||
+3
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseMa
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeQueryState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Geohash.filterHomePostsByGeohashes
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Hashtags.filterHomePostsByHashtags
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.filterHomePostsByScopes
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsFromCommunities
|
||||
import com.vitorpamplona.ammolite.relays.NostrClient
|
||||
import com.vitorpamplona.ammolite.relays.TypedFilter
|
||||
@@ -46,6 +47,8 @@ class MixGeohashHashtagsCommunityEoseManager(
|
||||
listOfNotNull(
|
||||
filterHomePostsByHashtags(key.followLists()?.hashtags, since),
|
||||
filterHomePostsByGeohashes(key.followLists()?.geotags, since),
|
||||
filterHomePostsByScopes(key.followLists()?.hashtagScopes, since),
|
||||
filterHomePostsByScopes(key.followLists()?.geotagScopes, since),
|
||||
filterHomePostsFromCommunities(key.followLists()?.addresses, since),
|
||||
).flatten()
|
||||
|
||||
|
||||
+7
-1
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Geoha
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeQueryState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.filterHomePostsByScopes
|
||||
import com.vitorpamplona.ammolite.relays.NostrClient
|
||||
import com.vitorpamplona.ammolite.relays.TypedFilter
|
||||
import com.vitorpamplona.ammolite.relays.datasources.Subscription
|
||||
@@ -31,6 +32,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.collections.flatten
|
||||
|
||||
class GeohashEventsEoseManager(
|
||||
client: NostrClient,
|
||||
@@ -39,7 +41,11 @@ class GeohashEventsEoseManager(
|
||||
override fun updateFilter(
|
||||
key: HomeQueryState,
|
||||
since: Map<String, EOSETime>?,
|
||||
): List<TypedFilter>? = filterHomePostsByGeohashes(key.followLists()?.geotags, since)
|
||||
): List<TypedFilter>? =
|
||||
listOfNotNull(
|
||||
filterHomePostsByGeohashes(key.followLists()?.geotags, since),
|
||||
filterHomePostsByScopes(key.followLists()?.geotagScopes, since),
|
||||
).flatten()
|
||||
|
||||
override fun user(query: HomeQueryState) = query.account.userProfile()
|
||||
|
||||
|
||||
+8
-4
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Hasht
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeQueryState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.filterHomePostsByScopes
|
||||
import com.vitorpamplona.ammolite.relays.NostrClient
|
||||
import com.vitorpamplona.ammolite.relays.TypedFilter
|
||||
import com.vitorpamplona.ammolite.relays.datasources.Subscription
|
||||
@@ -40,10 +41,13 @@ class HashtagEventsFilterSubAssembler(
|
||||
key: HomeQueryState,
|
||||
since: Map<String, EOSETime>?,
|
||||
): List<TypedFilter>? =
|
||||
filterHomePostsByHashtags(
|
||||
key.followLists()?.hashtags,
|
||||
since,
|
||||
)
|
||||
listOfNotNull(
|
||||
filterHomePostsByHashtags(
|
||||
key.followLists()?.hashtags,
|
||||
since,
|
||||
),
|
||||
filterHomePostsByScopes(key.followLists()?.hashtagScopes, since),
|
||||
).flatten()
|
||||
|
||||
override fun user(query: HomeQueryState) = query.account.userProfile()
|
||||
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 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.home.datasource.nip22Comments
|
||||
|
||||
import com.vitorpamplona.ammolite.relays.FeedType
|
||||
import com.vitorpamplona.ammolite.relays.TypedFilter
|
||||
import com.vitorpamplona.ammolite.relays.filters.EOSETime
|
||||
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
|
||||
fun filterHomePostsByScopes(
|
||||
scopesToLoad: Set<String>?,
|
||||
since: Map<String, EOSETime>?,
|
||||
): List<TypedFilter>? {
|
||||
if (scopesToLoad == null || scopesToLoad.isEmpty()) return null
|
||||
|
||||
return listOf(
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(CommentEvent.KIND),
|
||||
tags = mapOf("I" to scopesToLoad.toList()),
|
||||
limit = 100,
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
+2
-2
@@ -181,7 +181,7 @@ import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableKind
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip13Pow.strongPoWOrNull
|
||||
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
||||
@@ -477,7 +477,7 @@ private fun FullBleedNoteCompose(
|
||||
nav,
|
||||
)
|
||||
|
||||
val geo = remember { noteEvent.getGeoHash() }
|
||||
val geo = remember { noteEvent.geoHashOrScope() }
|
||||
if (geo != null) {
|
||||
DisplayLocation(geo, nav)
|
||||
}
|
||||
|
||||
@@ -733,6 +733,10 @@
|
||||
<string name="geohash_exclusive">Location-exclusive Post</string>
|
||||
<string name="geohash_exclusive_explainer">Only followers of the location will see it. Your general followers won\'t see it.</string>
|
||||
|
||||
<string name="hashtag_exclusive">Hashtag-exclusive Post</string>
|
||||
<string name="hashtag_exclusive_explainer">Only followers of the hashtag will see it. Your general followers won\'t see it.</string>
|
||||
|
||||
|
||||
<string name="loading_location">Loading location</string>
|
||||
<string name="lack_location_permissions">No Location Permissions</string>
|
||||
|
||||
@@ -988,6 +992,7 @@
|
||||
<string name="new_short">New Shorts: images or videos</string>
|
||||
<string name="new_community_note">New Community Note</string>
|
||||
<string name="new_product">New Product</string>
|
||||
<string name="new_exclusive_geo_note">New Geo-Exclusive Post</string>
|
||||
|
||||
<string name="open_all_reactions_to_this_post">Open all reactions to this post</string>
|
||||
<string name="close_all_reactions_to_this_post">Close all reactions to this post</string>
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 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.nip22Comments
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip01Core.verify
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import junit.framework.TestCase.assertFalse
|
||||
import junit.framework.TestCase.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class GeolocatedComments {
|
||||
private val privateKey = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray()
|
||||
private val signer = NostrSignerSync(KeyPair(privateKey))
|
||||
|
||||
val event =
|
||||
CommentEvent(
|
||||
"fecb2ecf61a1433d417a784d10bd1e8ec19a916170a53ca8fb3a15fc666a6592",
|
||||
"f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
|
||||
1747753115,
|
||||
arrayOf(
|
||||
arrayOf("alt", "Reply to geo:drt3n"),
|
||||
arrayOf("I", "geo:drt3n"),
|
||||
arrayOf("I", "geo:drt3"),
|
||||
arrayOf("I", "geo:drt"),
|
||||
arrayOf("I", "geo:dr"),
|
||||
arrayOf("I", "geo:d"),
|
||||
arrayOf("K", "geo"),
|
||||
arrayOf("i", "geo:drt3n"),
|
||||
arrayOf("i", "geo:drt3"),
|
||||
arrayOf("i", "geo:drt"),
|
||||
arrayOf("i", "geo:dr"),
|
||||
arrayOf("i", "geo:d"),
|
||||
arrayOf("k", "geo"),
|
||||
),
|
||||
"testing",
|
||||
"12070e663272f1227c639fb834eb2122fc7bb995f4c49e55ebb1dfe2135ef7347d44810bacd2e64fd26b8826fd47d2800ce6c3d3b579bb3afe39088ffd4faa60",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun verifyEvent() {
|
||||
assertTrue(event.verify())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testScopes() {
|
||||
assertEquals("drt3n", event.geohashedScope())
|
||||
assertTrue(event.isTaggedScope("drt3n", GeohashId::match))
|
||||
assertTrue(event.isTaggedScope(GeohashId.toScope("drt3n")))
|
||||
assertFalse(event.isTaggedScope("drt3n"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCreation() {
|
||||
val event =
|
||||
signer.sign(
|
||||
CommentEvent.replyExternalIdentity(
|
||||
"Message",
|
||||
GeohashId("drt3n"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("drt3n", event!!.geohashedScope())
|
||||
assertTrue(event.isTaggedScope(GeohashId.toScope("drt3n")))
|
||||
assertFalse(event.isTaggedScope("drt3n"))
|
||||
|
||||
assertTrue(event.hasScopeKind(GeohashId.KIND))
|
||||
assertTrue(event.hasRootScopeKind(GeohashId.KIND))
|
||||
assertTrue(event.hasReplyScopeKind(GeohashId.KIND))
|
||||
}
|
||||
}
|
||||
@@ -22,38 +22,21 @@ package com.vitorpamplona.quartz.nip01Core.tags.geohash
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.location.geohashedScope
|
||||
|
||||
fun Event.hasGeohashes() =
|
||||
if (this is CommentEvent) {
|
||||
this.hasGeohashes()
|
||||
} else {
|
||||
tags.hasGeohashes()
|
||||
}
|
||||
fun Event.hasGeohashes() = tags.hasGeohashes()
|
||||
|
||||
fun Event.isTaggedGeoHashes(hashtags: Set<String>) =
|
||||
if (this is CommentEvent) {
|
||||
this.isTaggedGeoHashes(hashtags)
|
||||
} else {
|
||||
tags.isTaggedGeoHashes(hashtags)
|
||||
}
|
||||
fun Event.isTaggedGeoHashes(hashtags: Set<String>) = tags.isTaggedGeoHashes(hashtags)
|
||||
|
||||
fun Event.isTaggedGeoHash(hashtag: String) =
|
||||
if (this is CommentEvent) {
|
||||
this.isTaggedGeoHash(hashtag)
|
||||
} else {
|
||||
tags.isTaggedGeoHash(hashtag)
|
||||
}
|
||||
fun Event.isTaggedGeoHash(hashtag: String) = tags.isTaggedGeoHash(hashtag)
|
||||
|
||||
fun Event.geohashes() =
|
||||
if (this is CommentEvent) {
|
||||
geohashes()
|
||||
} else {
|
||||
tags.geohashes()
|
||||
}
|
||||
fun Event.geohashes() = tags.geohashes()
|
||||
|
||||
fun Event.getGeoHash(): String? =
|
||||
fun Event.getGeoHash(): String? = tags.getGeoHash()
|
||||
|
||||
fun Event.geoHashOrScope() =
|
||||
if (this is CommentEvent) {
|
||||
getGeoHash()
|
||||
geohashedScope()
|
||||
} else {
|
||||
tags.getGeoHash()
|
||||
}
|
||||
|
||||
@@ -21,9 +21,6 @@
|
||||
package com.vitorpamplona.quartz.nip01Core.tags.hashtags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
fun Event.forEachHashTag(onEach: (eventId: HexKey) -> Unit) = tags.forEachHashTag(onEach)
|
||||
|
||||
fun Event.anyHashTag(onEach: (str: String) -> Boolean) = tags.anyHashTag(onEach)
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.tags.kinds
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
@@ -27,7 +28,7 @@ class KindTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "k"
|
||||
|
||||
fun match(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME
|
||||
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME
|
||||
|
||||
fun isTagged(
|
||||
tag: Array<String>,
|
||||
|
||||
@@ -90,19 +90,29 @@ class CommentEvent(
|
||||
|
||||
fun directKinds() = tags.filter(ReplyKindTag::match)
|
||||
|
||||
fun isGeohashTag(tag: Array<String>) = tag.size > 1 && (tag[0] == "i" || tag[0] == "I") && tag[1].startsWith("geo:")
|
||||
/** root and reply scope search */
|
||||
fun isTaggedScope(scopeId: String) = tags.any { RootIdentifierTag.isTagged(it, scopeId) || ReplyIdentifierTag.isTagged(it, scopeId) }
|
||||
|
||||
private fun getGeoHashList() = tags.filter { isGeohashTag(it) }
|
||||
fun isTaggedScopes(scopeIds: Set<String>) = tags.any { RootIdentifierTag.isTagged(it, scopeIds) || ReplyIdentifierTag.isTagged(it, scopeIds) }
|
||||
|
||||
fun hasGeohashes() = tags.any { isGeohashTag(it) }
|
||||
fun isTaggedScope(
|
||||
value: String,
|
||||
match: (String, String) -> Boolean,
|
||||
) = tags.any { RootIdentifierTag.isTagged(it, value, match) || ReplyIdentifierTag.isTagged(it, value, match) }
|
||||
|
||||
fun geohashes() = getGeoHashList().map { it[1].drop(4).lowercase() }
|
||||
fun firstTaggedScopeIn(scopeIds: Set<String>) = tags.firstNotNullOfOrNull { RootIdentifierTag.matchOrNull(it, scopeIds) ?: ReplyIdentifierTag.matchOrNull(it, scopeIds) }
|
||||
|
||||
fun getGeoHash(): String? = geohashes().maxByOrNull { it.length }
|
||||
fun isScoped(scopeTest: (String) -> Boolean) = tags.any { RootIdentifierTag.isTagged(it, scopeTest) || ReplyIdentifierTag.isTagged(it, scopeTest) }
|
||||
|
||||
fun isTaggedGeoHash(hashtag: String) = tags.any { isGeohashTag(it) && it[1].endsWith(hashtag, true) }
|
||||
fun hasRootScopeKind(kind: String) = tags.any { RootKindTag.isKind(it, kind) }
|
||||
|
||||
fun isTaggedGeoHashes(hashtags: Set<String>) = geohashes().any { it in hashtags }
|
||||
fun hasReplyScopeKind(kind: String) = tags.any { ReplyKindTag.isKind(it, kind) }
|
||||
|
||||
fun hasScopeKind(kind: String) = tags.any { RootKindTag.isKind(it, kind) || ReplyKindTag.isKind(it, kind) }
|
||||
|
||||
fun scopeValues(parser: (String) -> String?) = tags.mapNotNull { RootIdentifierTag.parse(it)?.let { parser(it) } }
|
||||
|
||||
fun firstScopeValue(parser: (String) -> String?) = tags.firstNotNullOfOrNull { RootIdentifierTag.parse(it)?.let { parser(it) } }
|
||||
|
||||
override fun markedReplyTos(): List<HexKey> =
|
||||
tags.mapNotNull(ReplyEventTag::parseKey) +
|
||||
|
||||
@@ -78,7 +78,7 @@ fun TagArrayBuilder<CommentEvent>.replyKind(kind: String) = addUnique(ReplyKindT
|
||||
|
||||
fun TagArrayBuilder<CommentEvent>.replyKind(kind: Int) = addUnique(ReplyKindTag.assemble(kind))
|
||||
|
||||
fun TagArrayBuilder<CommentEvent>.replyKind(id: ExternalId) = addUnique(RootKindTag.assemble(id))
|
||||
fun TagArrayBuilder<CommentEvent>.replyKind(id: ExternalId) = addUnique(ReplyKindTag.assemble(id))
|
||||
|
||||
fun TagArrayBuilder<CommentEvent>.replyAuthor(
|
||||
pubKey: HexKey,
|
||||
|
||||
+39
-2
@@ -25,7 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.GeohashId
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId
|
||||
import com.vitorpamplona.quartz.utils.arrayOfNotNull
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
@@ -37,6 +37,43 @@ class ReplyIdentifierTag {
|
||||
@JvmStatic
|
||||
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
@JvmStatic
|
||||
fun isTagged(
|
||||
tag: Array<String>,
|
||||
encodedScope: String,
|
||||
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == encodedScope
|
||||
|
||||
fun isTagged(
|
||||
tag: Array<String>,
|
||||
encodedScope: Set<String>,
|
||||
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in encodedScope
|
||||
|
||||
fun matchOrNull(
|
||||
tag: Array<String>,
|
||||
encodedScope: Set<String>,
|
||||
) = if (tag.has(1) && tag[0] == RootIdentifierTag.Companion.TAG_NAME && tag[1] in encodedScope) {
|
||||
tag[1]
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
fun isTagged(
|
||||
tag: Array<String>,
|
||||
test: (String) -> Boolean,
|
||||
) = tag.has(1) && tag[0] == TAG_NAME && test(tag[1])
|
||||
|
||||
fun isTagged(
|
||||
tag: Array<String>,
|
||||
value: String,
|
||||
match: (String, String) -> Boolean,
|
||||
) = tag.has(1) && tag[0] == TAG_NAME && match(tag[1], value)
|
||||
|
||||
fun isTagged(
|
||||
tag: Array<String>,
|
||||
value: Set<String>,
|
||||
match: (String, Set<String>) -> Boolean,
|
||||
) = tag.has(1) && tag[0] == TAG_NAME && match(tag[1], value)
|
||||
|
||||
@JvmStatic
|
||||
fun parse(tag: Tag): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
@@ -54,7 +91,7 @@ class ReplyIdentifierTag {
|
||||
@JvmStatic
|
||||
fun assemble(id: ExternalId): List<Array<String>> =
|
||||
when (id) {
|
||||
is GeohashId -> GeoHashTag.geoMipMap(id.geohash).map { assemble(it, id.hint) }
|
||||
is GeohashId -> GeoHashTag.geoMipMap(id.geohash).map { assemble(GeohashId.toScope(it), id.hint) }
|
||||
else -> listOf(assemble(id.toScope(), id.hint()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,12 @@ class ReplyKindTag {
|
||||
@JvmStatic
|
||||
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
@JvmStatic
|
||||
fun isKind(
|
||||
tag: Tag,
|
||||
kind: String,
|
||||
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == kind
|
||||
|
||||
@JvmStatic
|
||||
fun isTagged(
|
||||
tag: Tag,
|
||||
@@ -59,7 +65,7 @@ class ReplyKindTag {
|
||||
fun assemble(kind: Int) = arrayOf(TAG_NAME, kind.toString())
|
||||
|
||||
@JvmStatic
|
||||
fun assemble(id: ExternalId) = RootKindTag.assemble(id.toKind())
|
||||
fun assemble(id: ExternalId) = assemble(id.toKind())
|
||||
|
||||
@JvmStatic
|
||||
fun assemble(kinds: List<String>): List<Tag> = kinds.map { assemble(it) }
|
||||
|
||||
+38
-2
@@ -25,18 +25,54 @@ import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.GeohashId
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId
|
||||
import com.vitorpamplona.quartz.utils.arrayOfNotNull
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
@Immutable
|
||||
class RootIdentifierTag {
|
||||
class RootIdentifierTag<T : ExternalId> {
|
||||
companion object {
|
||||
const val TAG_NAME = "I"
|
||||
|
||||
@JvmStatic
|
||||
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun isTagged(
|
||||
tag: Array<String>,
|
||||
encodedScope: String,
|
||||
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == encodedScope
|
||||
|
||||
fun isTagged(
|
||||
tag: Array<String>,
|
||||
encodedScope: Set<String>,
|
||||
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in encodedScope
|
||||
|
||||
fun matchOrNull(
|
||||
tag: Array<String>,
|
||||
encodedScope: Set<String>,
|
||||
) = if (tag.has(1) && tag[0] == TAG_NAME && tag[1] in encodedScope) {
|
||||
tag[1]
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
fun isTagged(
|
||||
tag: Array<String>,
|
||||
test: (String) -> Boolean,
|
||||
) = tag.has(1) && tag[0] == TAG_NAME && test(tag[1])
|
||||
|
||||
fun isTagged(
|
||||
tag: Array<String>,
|
||||
value: String,
|
||||
match: (String, String) -> Boolean,
|
||||
) = tag.has(1) && tag[0] == TAG_NAME && match(tag[1], value)
|
||||
|
||||
fun isTagged(
|
||||
tag: Array<String>,
|
||||
value: Set<String>,
|
||||
match: (String, Set<String>) -> Boolean,
|
||||
) = tag.has(1) && tag[0] == TAG_NAME && match(tag[1], value)
|
||||
|
||||
@JvmStatic
|
||||
fun parse(tag: Tag): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
|
||||
@@ -32,6 +32,12 @@ class RootKindTag {
|
||||
@JvmStatic
|
||||
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
@JvmStatic
|
||||
fun isKind(
|
||||
tag: Tag,
|
||||
kind: String,
|
||||
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == kind
|
||||
|
||||
@JvmStatic
|
||||
fun parse(tag: Tag): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
|
||||
+3
-2
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip70ProtectedEvts.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
@@ -28,14 +29,14 @@ class ProtectedTag {
|
||||
const val TAG_NAME = "-"
|
||||
|
||||
@JvmStatic
|
||||
fun match(tag: Array<String>): Boolean {
|
||||
fun match(tag: Tag): Boolean {
|
||||
ensure(tag.has(0)) { return false }
|
||||
ensure(tag[0] == TAG_NAME) { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun parse(tag: Array<String>): Boolean? {
|
||||
fun parse(tag: Tag): Boolean? {
|
||||
ensure(tag.has(0)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
return true
|
||||
|
||||
+23
-4
@@ -18,7 +18,10 @@
|
||||
* 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.nip73ExternalIds
|
||||
package com.vitorpamplona.quartz.nip73ExternalIds.books
|
||||
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
class BookId(
|
||||
val isbn: String,
|
||||
@@ -31,9 +34,25 @@ class BookId(
|
||||
override fun hint() = hint
|
||||
|
||||
companion object {
|
||||
// "isbn:9780765382030"
|
||||
fun toScope(isbn: String) = "isbn:" + isbn.lowercase().replace("-", "")
|
||||
const val KIND = "isbn"
|
||||
const val PREFIX_COLON = "isbn:"
|
||||
|
||||
fun toKind(isbn: String) = "isbn"
|
||||
// "isbn:9780765382030"
|
||||
fun toScope(isbn: String) = PREFIX_COLON + isbn.lowercase().replace("-", "")
|
||||
|
||||
fun toKind(isbn: String) = KIND
|
||||
|
||||
fun match(
|
||||
encoded: String,
|
||||
value: String,
|
||||
): Boolean {
|
||||
ensure(encoded.startsWith(PREFIX_COLON)) { return false }
|
||||
return encoded.indexOf(value, PREFIX_COLON.length) > 0
|
||||
}
|
||||
|
||||
fun parse(encoded: String): String? {
|
||||
ensure(encoded.startsWith(PREFIX_COLON)) { return null }
|
||||
return encoded.substring(PREFIX_COLON.length)
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-14
@@ -18,21 +18,12 @@
|
||||
* 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.nip73ExternalIds
|
||||
package com.vitorpamplona.quartz.nip73ExternalIds.books
|
||||
|
||||
class GeohashId(
|
||||
val geohash: String,
|
||||
val hint: String? = null,
|
||||
) : ExternalId {
|
||||
override fun toScope() = toScope(geohash)
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
|
||||
override fun toKind() = toKind(geohash)
|
||||
fun CommentEvent.isBookScoped() = hasScopeKind(BookId.KIND)
|
||||
|
||||
override fun hint() = hint
|
||||
fun CommentEvent.bookScope() = firstScopeValue(BookId::parse)
|
||||
|
||||
companion object {
|
||||
fun toScope(geohash: String) = "geo:" + geohash.lowercase()
|
||||
|
||||
fun toKind(geohash: String) = "geo"
|
||||
}
|
||||
}
|
||||
fun CommentEvent.bookScopes() = scopeValues(BookId::parse)
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 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.nip73ExternalIds.location
|
||||
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
|
||||
fun CommentEvent.isGeohashedScoped() = hasScopeKind(GeohashId.KIND)
|
||||
|
||||
fun CommentEvent.geohashedScope() = scopeValues(GeohashId::parse).maxByOrNull { it.length }
|
||||
+25
-9
@@ -18,24 +18,40 @@
|
||||
* 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.nip73ExternalIds
|
||||
package com.vitorpamplona.quartz.nip73ExternalIds.location
|
||||
|
||||
import kotlin.math.min
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
class MovieId(
|
||||
val isan: String,
|
||||
class GeohashId(
|
||||
val geohash: String,
|
||||
val hint: String? = null,
|
||||
) : ExternalId {
|
||||
override fun toScope() = toScope(isan)
|
||||
override fun toScope() = toScope(geohash)
|
||||
|
||||
override fun toKind() = toKind(isan)
|
||||
override fun toKind() = toKind(geohash)
|
||||
|
||||
override fun hint() = hint
|
||||
|
||||
companion object {
|
||||
// "isan:0000-0000-401A-0000-7"
|
||||
fun toScope(isan: String) = "isan:" + isan.lowercase().substring(0, min(21, isan.length))
|
||||
const val KIND = "geo"
|
||||
const val PREFIX_COLON = "geo:"
|
||||
|
||||
fun toKind(isan: String) = "isan"
|
||||
fun toScope(geohash: String) = PREFIX_COLON + geohash.lowercase()
|
||||
|
||||
fun toKind(geohash: String) = KIND
|
||||
|
||||
fun match(
|
||||
encoded: String,
|
||||
value: String,
|
||||
): Boolean {
|
||||
ensure(encoded.startsWith(PREFIX_COLON)) { return false }
|
||||
return encoded.indexOf(value, PREFIX_COLON.length) > 0
|
||||
}
|
||||
|
||||
fun parse(encoded: String): String? {
|
||||
ensure(encoded.startsWith(PREFIX_COLON)) { return null }
|
||||
return encoded.substring(PREFIX_COLON.length)
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-15
@@ -18,22 +18,12 @@
|
||||
* 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.nip73ExternalIds
|
||||
package com.vitorpamplona.quartz.nip73ExternalIds.movies
|
||||
|
||||
class PaperId(
|
||||
val doi: String,
|
||||
val hint: String? = null,
|
||||
) : ExternalId {
|
||||
override fun toScope() = toScope(doi)
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
|
||||
override fun toKind() = toKind(doi)
|
||||
fun CommentEvent.isMovieScoped() = hasScopeKind(MovieId.KIND)
|
||||
|
||||
override fun hint() = hint
|
||||
fun CommentEvent.movieScope() = firstScopeValue(MovieId::parse)
|
||||
|
||||
companion object {
|
||||
// "doi:10.1000/182"
|
||||
fun toScope(doi: String) = "doi:" + doi.lowercase()
|
||||
|
||||
fun toKind(doi: String) = "doi"
|
||||
}
|
||||
}
|
||||
fun CommentEvent.movieScopes() = scopeValues(MovieId::parse)
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 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.nip73ExternalIds.movies
|
||||
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
class MovieId(
|
||||
val isan: String,
|
||||
val hint: String? = null,
|
||||
) : ExternalId {
|
||||
override fun toScope() = toScope(isan)
|
||||
|
||||
override fun toKind() = toKind(isan)
|
||||
|
||||
override fun hint() = hint
|
||||
|
||||
companion object {
|
||||
const val KIND = "isan"
|
||||
const val PREFIX_COLON = "isan:"
|
||||
|
||||
// "isan:0000-0000-401A-0000-7"
|
||||
fun toScope(isan: String) =
|
||||
PREFIX_COLON +
|
||||
isan.lowercase().substring(
|
||||
0,
|
||||
min(21, isan.length),
|
||||
)
|
||||
|
||||
fun toKind(isan: String) = KIND
|
||||
|
||||
fun match(
|
||||
encoded: String,
|
||||
value: String,
|
||||
): Boolean {
|
||||
ensure(encoded.startsWith(PREFIX_COLON)) { return false }
|
||||
return encoded.indexOf(value, PREFIX_COLON.length) > 0
|
||||
}
|
||||
|
||||
fun parse(encoded: String): String? {
|
||||
ensure(encoded.startsWith(PREFIX_COLON)) { return null }
|
||||
return encoded.substring(PREFIX_COLON.length)
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-15
@@ -18,22 +18,12 @@
|
||||
* 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.nip73ExternalIds
|
||||
package com.vitorpamplona.quartz.nip73ExternalIds.papers
|
||||
|
||||
class PodcastFeedId(
|
||||
val guid: String,
|
||||
val hint: String? = null,
|
||||
) : ExternalId {
|
||||
override fun toScope() = toScope(guid)
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
|
||||
override fun toKind() = toKind(guid)
|
||||
fun CommentEvent.isPaperScoped() = hasScopeKind(PaperId.KIND)
|
||||
|
||||
override fun hint() = hint
|
||||
fun CommentEvent.paperScope() = firstScopeValue(PaperId::parse)
|
||||
|
||||
companion object {
|
||||
// "isbn:9780765382030"
|
||||
fun toScope(guid: String) = "podcast:guid:" + guid
|
||||
|
||||
fun toKind(guid: String) = "podcast:guid"
|
||||
}
|
||||
}
|
||||
fun CommentEvent.paperScopes() = scopeValues(PaperId::parse)
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 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.nip73ExternalIds.papers
|
||||
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
class PaperId(
|
||||
val doi: String,
|
||||
val hint: String? = null,
|
||||
) : ExternalId {
|
||||
override fun toScope() = toScope(doi)
|
||||
|
||||
override fun toKind() = toKind(doi)
|
||||
|
||||
override fun hint() = hint
|
||||
|
||||
companion object {
|
||||
const val KIND = "doi"
|
||||
const val PREFIX_COLON = "doi:"
|
||||
|
||||
// "doi:10.1000/182"
|
||||
fun toScope(doi: String) = PREFIX_COLON + doi.lowercase()
|
||||
|
||||
fun toKind(doi: String) = KIND
|
||||
|
||||
fun match(
|
||||
encoded: String,
|
||||
value: String,
|
||||
): Boolean {
|
||||
ensure(encoded.startsWith(PREFIX_COLON)) { return false }
|
||||
return encoded.indexOf(value, PREFIX_COLON.length) > 0
|
||||
}
|
||||
|
||||
fun parse(encoded: String): String? {
|
||||
ensure(encoded.startsWith(PREFIX_COLON)) { return null }
|
||||
return encoded.substring(PREFIX_COLON.length)
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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.nip73ExternalIds.podcasts
|
||||
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
|
||||
fun CommentEvent.isPodcastEpisodeScoped() = hasScopeKind(PodcastEpisodeId.KIND)
|
||||
|
||||
fun CommentEvent.podcastEpisodeScope() = firstScopeValue(PodcastEpisodeId::parse)
|
||||
|
||||
fun CommentEvent.podcastEpisodeScopes() = scopeValues(PodcastEpisodeId::parse)
|
||||
|
||||
fun CommentEvent.isPodcastFeedScoped() = hasScopeKind(PodcastFeedId.KIND)
|
||||
|
||||
fun CommentEvent.podcastFeedScope() = firstScopeValue(PodcastFeedId::parse)
|
||||
|
||||
fun CommentEvent.podcastFeedScopes() = scopeValues(PodcastFeedId::parse)
|
||||
|
||||
fun CommentEvent.isPodcastPublisherScoped() = hasScopeKind(PodcastPublisherId.KIND)
|
||||
|
||||
fun CommentEvent.podcastPublisherScope() = firstScopeValue(PodcastPublisherId::parse)
|
||||
|
||||
fun CommentEvent.podcastPublisherScopes() = scopeValues(PodcastPublisherId::parse)
|
||||
+22
-4
@@ -18,7 +18,10 @@
|
||||
* 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.nip73ExternalIds
|
||||
package com.vitorpamplona.quartz.nip73ExternalIds.podcasts
|
||||
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
class PodcastEpisodeId(
|
||||
val guid: String,
|
||||
@@ -31,9 +34,24 @@ class PodcastEpisodeId(
|
||||
override fun hint() = hint
|
||||
|
||||
companion object {
|
||||
// "isbn:9780765382030"
|
||||
fun toScope(guid: String) = "podcast:item:guid:" + guid
|
||||
const val KIND = "podcast:item:guid:guid"
|
||||
const val PREFIX_COLON = "podcast:item:guid:"
|
||||
|
||||
fun toKind(guid: String) = "podcast:item:guid"
|
||||
fun toScope(guid: String) = PREFIX_COLON + guid
|
||||
|
||||
fun toKind(guid: String) = KIND
|
||||
|
||||
fun match(
|
||||
encoded: String,
|
||||
value: String,
|
||||
): Boolean {
|
||||
ensure(encoded.startsWith(PREFIX_COLON)) { return false }
|
||||
return encoded.indexOf(value, PREFIX_COLON.length) > 0
|
||||
}
|
||||
|
||||
fun parse(encoded: String): String? {
|
||||
ensure(encoded.startsWith(PREFIX_COLON)) { return null }
|
||||
return encoded.substring(PREFIX_COLON.length)
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 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.nip73ExternalIds.podcasts
|
||||
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
class PodcastFeedId(
|
||||
val guid: String,
|
||||
val hint: String? = null,
|
||||
) : ExternalId {
|
||||
override fun toScope() = toScope(guid)
|
||||
|
||||
override fun toKind() = toKind(guid)
|
||||
|
||||
override fun hint() = hint
|
||||
|
||||
companion object {
|
||||
const val KIND = "podcast:guid"
|
||||
const val PREFIX_COLON = "podcast:guid:"
|
||||
|
||||
// "isbn:9780765382030"
|
||||
fun toScope(guid: String) = PREFIX_COLON + guid
|
||||
|
||||
fun toKind(guid: String) = KIND
|
||||
|
||||
fun match(
|
||||
encoded: String,
|
||||
value: String,
|
||||
): Boolean {
|
||||
ensure(encoded.startsWith(PREFIX_COLON)) { return false }
|
||||
return encoded.indexOf(value, PREFIX_COLON.length) > 0
|
||||
}
|
||||
|
||||
fun parse(encoded: String): String? {
|
||||
ensure(encoded.startsWith(PREFIX_COLON)) { return null }
|
||||
return encoded.substring(PREFIX_COLON.length)
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
-4
@@ -18,7 +18,10 @@
|
||||
* 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.nip73ExternalIds
|
||||
package com.vitorpamplona.quartz.nip73ExternalIds.podcasts
|
||||
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
class PodcastPublisherId(
|
||||
val guid: String,
|
||||
@@ -31,9 +34,25 @@ class PodcastPublisherId(
|
||||
override fun hint() = hint
|
||||
|
||||
companion object {
|
||||
// "isbn:9780765382030"
|
||||
fun toScope(guid: String) = "podcast:publisher:guid:" + guid
|
||||
const val KIND = "podcast:publisher:guid"
|
||||
const val PREFIX_COLON = "podcast:publisher:guid:"
|
||||
|
||||
fun toKind(guid: String) = "podcast:publisher:guid"
|
||||
// "isbn:9780765382030"
|
||||
fun toScope(guid: String) = PREFIX_COLON + guid
|
||||
|
||||
fun toKind(guid: String) = KIND
|
||||
|
||||
fun match(
|
||||
encoded: String,
|
||||
value: String,
|
||||
): Boolean {
|
||||
ensure(encoded.startsWith(PREFIX_COLON)) { return false }
|
||||
return encoded.indexOf(value, PREFIX_COLON.length) > 0
|
||||
}
|
||||
|
||||
fun parse(encoded: String): String? {
|
||||
ensure(encoded.startsWith(PREFIX_COLON)) { return null }
|
||||
return encoded.substring(PREFIX_COLON.length)
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-14
@@ -18,21 +18,12 @@
|
||||
* 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.nip73ExternalIds
|
||||
package com.vitorpamplona.quartz.nip73ExternalIds.topics
|
||||
|
||||
class HashtagId(
|
||||
val topic: String,
|
||||
val hint: String? = null,
|
||||
) : ExternalId {
|
||||
override fun toScope() = toScope(topic)
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
|
||||
override fun toKind() = toKind(topic)
|
||||
fun CommentEvent.isHashtagScoped() = hasScopeKind(HashtagId.KIND)
|
||||
|
||||
override fun hint() = hint
|
||||
fun CommentEvent.hashtagScope() = firstScopeValue(HashtagId::parse)
|
||||
|
||||
companion object {
|
||||
fun toScope(topic: String) = "#" + topic.lowercase()
|
||||
|
||||
fun toKind(topic: String) = "#"
|
||||
}
|
||||
}
|
||||
fun CommentEvent.hashtagScopes() = scopeValues(HashtagId::parse)
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 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.nip73ExternalIds.topics
|
||||
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
class HashtagId(
|
||||
val topic: String,
|
||||
val hint: String? = null,
|
||||
) : ExternalId {
|
||||
override fun toScope() = toScope(topic)
|
||||
|
||||
override fun toKind() = toKind(topic)
|
||||
|
||||
override fun hint() = hint
|
||||
|
||||
companion object {
|
||||
const val KIND = "#"
|
||||
const val KIND_CHR = '#'
|
||||
|
||||
fun toScope(topic: String) = KIND + topic.lowercase()
|
||||
|
||||
fun toKind(topic: String) = KIND
|
||||
|
||||
fun match(
|
||||
encoded: String,
|
||||
value: String,
|
||||
): Boolean {
|
||||
ensure(encoded.length > 1) { return false }
|
||||
ensure(encoded[0] == KIND_CHR) { return false }
|
||||
return encoded.indexOf(value, 1) > 0
|
||||
}
|
||||
|
||||
fun parse(encoded: String): String? {
|
||||
return if (encoded.length > 1 && encoded[0] == KIND_CHR) {
|
||||
encoded.substring(1)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 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.nip73ExternalIds.urls
|
||||
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
|
||||
fun CommentEvent.isUrlScoped() = hasScopeKind(UrlId.KIND)
|
||||
|
||||
fun CommentEvent.urlScope() = firstScopeValue(UrlId::parse)
|
||||
|
||||
fun CommentEvent.urlScopes() = scopeValues(UrlId::parse)
|
||||
+21
-2
@@ -18,8 +18,10 @@
|
||||
* 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.nip73ExternalIds
|
||||
package com.vitorpamplona.quartz.nip73ExternalIds.urls
|
||||
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
import com.vitorpamplona.quartz.utils.toStringNoFragment
|
||||
import org.czeal.rfc3986.URIReference
|
||||
|
||||
@@ -34,8 +36,25 @@ class UrlId(
|
||||
override fun hint() = hint
|
||||
|
||||
companion object {
|
||||
const val KIND = "web"
|
||||
const val PREFIX1 = "https://"
|
||||
const val PREFIX2 = "http://"
|
||||
|
||||
fun toScope(url: String) = URIReference.parse(url).normalize().toStringNoFragment()
|
||||
|
||||
fun toKind(url: String) = "web"
|
||||
fun toKind(url: String) = KIND
|
||||
|
||||
fun match(
|
||||
encoded: String,
|
||||
value: String,
|
||||
): Boolean {
|
||||
ensure(encoded.startsWith(PREFIX1) || encoded.startsWith(PREFIX2)) { return false }
|
||||
return encoded == value
|
||||
}
|
||||
|
||||
fun parse(encoded: String): String? {
|
||||
ensure(encoded.startsWith(PREFIX1) || encoded.startsWith(PREFIX2)) { return null }
|
||||
return encoded
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user