diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 9d2102876..f153af026 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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 = emptySet(), val geotags: Set = emptySet(), val addresses: Set = emptySet(), - ) + ) { + val geotagScopes: Set = geotags.mapTo(mutableSetOf()) { GeohashId.toScope(it) } + val hashtagScopes: Set = hashtags.mapTo(mutableSetOf()) { 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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index 1b501bcd7..97b7c0c7b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 84b8dcfee..e4d6f7460 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt index 1727f2f45..3243d283e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt @@ -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 || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index a397b31b3..a4ee0fef9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { LoadRedirectScreen(it.id, accountViewModel, nav) } + composableFromBottomArgs { + 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 { + 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 { 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, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/INav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/INav.kt index 6f674bf99..868cc8425 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/INav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/INav.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt index 79ffe7b0e..d45908732 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt @@ -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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index 78bf46ac1..ea314a959 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -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() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() else -> { null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index dfce88743..7d7a276aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 7667edc9f..1d1b96086 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -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()) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt index d0a882705..074168dc7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt @@ -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(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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt index a95ef4fa0..846f87b6d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index f41556b50..e0a9b0405 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/TwoPaneNav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/TwoPaneNav.kt index 2bf99da57..e7dd5067a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/TwoPaneNav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/TwoPaneNav.kt @@ -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) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt index 1abbe6b6b..e5689840a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -389,7 +389,6 @@ open class NewProductViewModel : ) { viewModelScope.launch(Dispatchers.Default) { val myAccount = account ?: return@launch - val myMultiOrchestrator = multiOrchestrator ?: return@launch isUploadingImage = true diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashScreen.kt index 3a1d72fae..05bedb1b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashScreen.kt @@ -89,6 +89,9 @@ fun GeoHashScreen( popBack = nav::popBack, ) }, + floatingButton = { + NewGeoPostButton(tag, accountViewModel, nav) + }, accountViewModel = accountViewModel, ) { Column(Modifier.padding(it)) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoPostScreen.kt new file mode 100644 index 000000000..d132f35c8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoPostScreen.kt @@ -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) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoPostViewModel.kt new file mode 100644 index 000000000..106eb7d50 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoPostViewModel.kt @@ -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(null) + var replyingTo: Note? by mutableStateOf(null) + + val iMetaAttachments = IMetaAttachments() + var nip95attachments by mutableStateOf>>(emptyList()) + + var notifying by mutableStateOf?>(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(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()) + 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(null) + + var showRelaysDialog by mutableStateOf(false) + var relayList by mutableStateOf?>(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? { + 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() ?: 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?, + ): List { + 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 = (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) { + 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() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/NewGeoNoteButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/NewGeoNoteButton.kt new file mode 100644 index 000000000..45a8f79e8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/NewGeoNoteButton.kt @@ -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, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt index c16f8d953..cf5e46e1e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt @@ -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): List = collection.sortedWith(DefaultFeedOrder) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt index d9a25d8ff..ec91356dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt @@ -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, + ), + ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt new file mode 100644 index 000000000..0c2c893b4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt @@ -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) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostViewModel.kt new file mode 100644 index 000000000..d87c9bf6b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostViewModel.kt @@ -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(null) + var replyingTo: Note? by mutableStateOf(null) + + val iMetaAttachments = IMetaAttachments() + var nip95attachments by mutableStateOf>>(emptyList()) + + var notifying by mutableStateOf?>(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(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()) + override var forwardZapToEditting = mutableStateOf(TextFieldValue("")) + + // NSFW, Sensitive + var wantsToMarkAsSensitive by mutableStateOf(false) + + // GeoHash + var wantsToAddGeoHash by mutableStateOf(false) + var location: StateFlow? = null + + // ZapRaiser + var canAddZapRaiser by mutableStateOf(false) + var wantsZapraiser by mutableStateOf(false) + override val zapRaiserAmount = mutableStateOf(null) + + var showRelaysDialog by mutableStateOf(false) + var relayList by mutableStateOf?>(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? { + 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() ?: 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?, + ): List { + 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 = (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) { + 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 { + if (location == null) { + location = locationManager().geohashStateFlow + } + + return location!! + } + + override fun locationManager(): LocationState = Amethyst.instance.locationManager +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt index ce2df9a7d..fb306881a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt @@ -98,6 +98,9 @@ fun HashtagScreen( popBack = nav::popBack, ) }, + floatingButton = { + NewHashtagPostButton(tag, accountViewModel, nav) + }, accountViewModel = accountViewModel, ) { Column(Modifier.padding(it)) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/NewHashtagNoteButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/NewHashtagNoteButton.kt new file mode 100644 index 000000000..fa16b2a52 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/NewHashtagNoteButton.kt @@ -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, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt index 71380f4ff..265c8bf3f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt @@ -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): List = collection.sortedWith(DefaultFeedOrder) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt index 9f4b52865..d5983195d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt @@ -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, + ), + ), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 285264e9b..5d3deaef4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewNoteButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewNoteButton.kt index cac5bffa1..6a6a4f59c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewNoteButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewNoteButton.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/mixGeohashHashtagsCommunities/MixGeohashHashtagsCommunityEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/mixGeohashHashtagsCommunities/MixGeohashHashtagsCommunityEoseManager.kt index 36d8ed40d..eb7f79e67 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/mixGeohashHashtagsCommunities/MixGeohashHashtagsCommunityEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/mixGeohashHashtagsCommunities/MixGeohashHashtagsCommunityEoseManager.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Geohash/GeohashEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Geohash/GeohashEventsEoseManager.kt index 8d88bd184..2bbff0f7b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Geohash/GeohashEventsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Geohash/GeohashEventsEoseManager.kt @@ -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?, - ): List? = filterHomePostsByGeohashes(key.followLists()?.geotags, since) + ): List? = + listOfNotNull( + filterHomePostsByGeohashes(key.followLists()?.geotags, since), + filterHomePostsByScopes(key.followLists()?.geotagScopes, since), + ).flatten() override fun user(query: HomeQueryState) = query.account.userProfile() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Hashtags/HashtagEventsFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Hashtags/HashtagEventsFilterSubAssembler.kt index d59e083c0..be9be75ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Hashtags/HashtagEventsFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Hashtags/HashtagEventsFilterSubAssembler.kt @@ -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?, ): List? = - 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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip22Comments/FilterPostsByGeohashScopes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip22Comments/FilterPostsByGeohashScopes.kt new file mode 100644 index 000000000..6a9c7b582 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip22Comments/FilterPostsByGeohashScopes.kt @@ -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?, + since: Map?, +): List? { + 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, + ), + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index b03bccab5..8e9c54cb3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -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) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index fb8568a02..144044dec 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -733,6 +733,10 @@ Location-exclusive Post Only followers of the location will see it. Your general followers won\'t see it. + Hashtag-exclusive Post + Only followers of the hashtag will see it. Your general followers won\'t see it. + + Loading location No Location Permissions @@ -988,6 +992,7 @@ New Shorts: images or videos New Community Note New Product + New Geo-Exclusive Post Open all reactions to this post Close all reactions to this post diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip22Comments/GeolocatedComments.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip22Comments/GeolocatedComments.kt new file mode 100644 index 000000000..8346e3b7e --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip22Comments/GeolocatedComments.kt @@ -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)) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/EventExt.kt index ff7c12dd6..060dea65d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/geohash/EventExt.kt @@ -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) = - if (this is CommentEvent) { - this.isTaggedGeoHashes(hashtags) - } else { - tags.isTaggedGeoHashes(hashtags) - } +fun Event.isTaggedGeoHashes(hashtags: Set) = 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() } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/EventExt.kt index d55d4e9c1..b490c16fb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/EventExt.kt @@ -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) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/KindTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/KindTag.kt index 49744c7ff..a3b9ad43f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/KindTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/kinds/KindTag.kt @@ -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) = tag.has(1) && tag[0] == TAG_NAME + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME fun isTagged( tag: Array, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt index b5a326e4a..54104b2c6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt @@ -90,19 +90,29 @@ class CommentEvent( fun directKinds() = tags.filter(ReplyKindTag::match) - fun isGeohashTag(tag: Array) = 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) = 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) = 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) = 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 = tags.mapNotNull(ReplyEventTag::parseKey) + diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/TagArrayBuilderExt.kt index 4b73fc927..8a82bf113 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/TagArrayBuilderExt.kt @@ -78,7 +78,7 @@ fun TagArrayBuilder.replyKind(kind: String) = addUnique(ReplyKindT fun TagArrayBuilder.replyKind(kind: Int) = addUnique(ReplyKindTag.assemble(kind)) -fun TagArrayBuilder.replyKind(id: ExternalId) = addUnique(RootKindTag.assemble(id)) +fun TagArrayBuilder.replyKind(id: ExternalId) = addUnique(ReplyKindTag.assemble(id)) fun TagArrayBuilder.replyAuthor( pubKey: HexKey, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyIdentifierTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyIdentifierTag.kt index b4e4b9772..77c94fcba 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyIdentifierTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyIdentifierTag.kt @@ -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, + encodedScope: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == encodedScope + + fun isTagged( + tag: Array, + encodedScope: Set, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in encodedScope + + fun matchOrNull( + tag: Array, + encodedScope: Set, + ) = if (tag.has(1) && tag[0] == RootIdentifierTag.Companion.TAG_NAME && tag[1] in encodedScope) { + tag[1] + } else { + null + } + + fun isTagged( + tag: Array, + test: (String) -> Boolean, + ) = tag.has(1) && tag[0] == TAG_NAME && test(tag[1]) + + fun isTagged( + tag: Array, + value: String, + match: (String, String) -> Boolean, + ) = tag.has(1) && tag[0] == TAG_NAME && match(tag[1], value) + + fun isTagged( + tag: Array, + value: Set, + match: (String, Set) -> 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> = 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())) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyKindTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyKindTag.kt index f4280c115..7a19f9aa8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyKindTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyKindTag.kt @@ -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): List = kinds.map { assemble(it) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootIdentifierTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootIdentifierTag.kt index 3e72a57e3..86cd6e5c6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootIdentifierTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootIdentifierTag.kt @@ -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 { 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, + encodedScope: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == encodedScope + + fun isTagged( + tag: Array, + encodedScope: Set, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in encodedScope + + fun matchOrNull( + tag: Array, + encodedScope: Set, + ) = if (tag.has(1) && tag[0] == TAG_NAME && tag[1] in encodedScope) { + tag[1] + } else { + null + } + + fun isTagged( + tag: Array, + test: (String) -> Boolean, + ) = tag.has(1) && tag[0] == TAG_NAME && test(tag[1]) + + fun isTagged( + tag: Array, + value: String, + match: (String, String) -> Boolean, + ) = tag.has(1) && tag[0] == TAG_NAME && match(tag[1], value) + + fun isTagged( + tag: Array, + value: Set, + match: (String, Set) -> Boolean, + ) = tag.has(1) && tag[0] == TAG_NAME && match(tag[1], value) + @JvmStatic fun parse(tag: Tag): String? { ensure(tag.has(1)) { return null } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootKindTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootKindTag.kt index 723918587..a295a4da7 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootKindTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootKindTag.kt @@ -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 } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip70ProtectedEvts/tags/ProtectedTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip70ProtectedEvts/tags/ProtectedTag.kt index 1cf05e464..8088d6506 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip70ProtectedEvts/tags/ProtectedTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip70ProtectedEvts/tags/ProtectedTag.kt @@ -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): 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): Boolean? { + fun parse(tag: Tag): Boolean? { ensure(tag.has(0)) { return null } ensure(tag[0] == TAG_NAME) { return null } return true diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/BookId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/books/BookId.kt similarity index 63% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/BookId.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/books/BookId.kt index 8c87f2214..cad727838 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/BookId.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/books/BookId.kt @@ -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) + } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/GeohashId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/books/CommentEventExt.kt similarity index 73% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/GeohashId.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/books/CommentEventExt.kt index e964f54c6..2a8eadd91 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/GeohashId.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/books/CommentEventExt.kt @@ -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) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/location/CommentEventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/location/CommentEventExt.kt new file mode 100644 index 000000000..7ca7b6e92 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/location/CommentEventExt.kt @@ -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 } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/MovieId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/location/GeohashId.kt similarity index 57% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/MovieId.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/location/GeohashId.kt index e4ff97a19..fa666c723 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/MovieId.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/location/GeohashId.kt @@ -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) + } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PaperId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/movies/CommentEventExt.kt similarity index 73% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PaperId.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/movies/CommentEventExt.kt index 198121f79..e7af0eee8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PaperId.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/movies/CommentEventExt.kt @@ -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) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/movies/MovieId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/movies/MovieId.kt new file mode 100644 index 000000000..91843e388 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/movies/MovieId.kt @@ -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) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PodcastFeedId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/papers/CommentEventExt.kt similarity index 72% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PodcastFeedId.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/papers/CommentEventExt.kt index 5704b45d3..d00d1793d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PodcastFeedId.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/papers/CommentEventExt.kt @@ -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) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/papers/PaperId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/papers/PaperId.kt new file mode 100644 index 000000000..810353689 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/papers/PaperId.kt @@ -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) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/CommentEventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/CommentEventExt.kt new file mode 100644 index 000000000..46f3e1415 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/CommentEventExt.kt @@ -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) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PodcastEpisodeId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastEpisodeId.kt similarity index 63% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PodcastEpisodeId.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastEpisodeId.kt index 3cd3d39fa..7f019c121 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PodcastEpisodeId.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastEpisodeId.kt @@ -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) + } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastFeedId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastFeedId.kt new file mode 100644 index 000000000..39c6e03cf --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastFeedId.kt @@ -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) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PodcastPublisherId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastPublisherId.kt similarity index 63% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PodcastPublisherId.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastPublisherId.kt index 6d10455fb..907b290ac 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/PodcastPublisherId.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastPublisherId.kt @@ -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) + } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/HashtagId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/topics/CommentEventExt.kt similarity index 74% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/HashtagId.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/topics/CommentEventExt.kt index 6b94d6eaa..2c438c3ef 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/HashtagId.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/topics/CommentEventExt.kt @@ -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) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/topics/HashtagId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/topics/HashtagId.kt new file mode 100644 index 000000000..38897df8e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/topics/HashtagId.kt @@ -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 + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/urls/CommentEventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/urls/CommentEventExt.kt new file mode 100644 index 000000000..1137ff93c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/urls/CommentEventExt.kt @@ -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) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/UrlId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/urls/UrlId.kt similarity index 67% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/UrlId.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/urls/UrlId.kt index bb5f2591f..8d1393cad 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/UrlId.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip73ExternalIds/urls/UrlId.kt @@ -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 + } } }