diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 1694849b08..f1ef8d84d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasourc import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource.FollowPackFeedFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.list.datasource.FollowPacksFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.datasource.RepositoryFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.datasource.LiveStreamsFilterAssembler @@ -96,6 +97,7 @@ class RelaySubscriptionsCoordinator( val channel = ChannelFilterAssembler(client) val chatroom = ChatroomFilterAssembler(client) val community = CommunityFilterAssembler(client) + val gitRepository = RepositoryFilterAssembler(client) val thread = ThreadFilterAssembler(client) val profile = UserProfileFilterAssembler(client) val hashtags = HashtagFilterAssembler(client) @@ -156,6 +158,7 @@ class RelaySubscriptionsCoordinator( channel, chatroom, community, + gitRepository, thread, profile, hashtags, 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 083c9f10ac..04055ec777 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 @@ -109,6 +109,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.FollowPack import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.list.FollowPacksScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryScreen 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 @@ -356,6 +357,7 @@ fun BuildNavigation( composableFromEndArgs { RelayManagementScreen(it.url, accountViewModel, nav) } composableFromEndArgs { RelayMembersScreen(it.url, accountViewModel, nav) } composableFromEndArgs { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + composableFromEndArgs { GitRepositoryScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEndArgs { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEndArgs { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt index c68fb7a63f..d38d7726b6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent @@ -144,6 +145,10 @@ fun routeForInner( Route.Community(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag()) } + is GitRepositoryEvent -> { + Route.GitRepository(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag()) + } + is GiftWrapEvent -> { noteEvent.innerEventId?.let { routeFor(LocalCache.getOrCreateNote(it), loggedIn) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 11ba830907..3d5ece6229 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -329,6 +329,18 @@ sealed class Route { ) } + @Serializable data class GitRepository( + val kind: Int, + val pubKeyHex: HexKey, + val dTag: String, + ) : Route() { + constructor(address: Address) : this( + kind = address.kind, + pubKeyHex = address.pubKeyHex, + dTag = address.dTag, + ) + } + @Serializable data class FollowPack( val kind: Int, val pubKeyHex: HexKey, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt index e9f43923e9..43b310e905 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -60,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.components.ClickableUrl import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags @@ -218,6 +220,7 @@ private fun RenderShortRepositoryHeader( .fillMaxWidth() .clip(ChipShape) .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.04f)) + .clickable { nav.nav(Route.GitRepository(baseNote.address)) } .padding(horizontal = Size10dp, vertical = Size8dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = HeaderSpacing, @@ -274,6 +277,8 @@ private fun RenderGitPatchEvent( contentColor = MaterialTheme.colorScheme.tertiary, ) + GitStatusPill(targetIdHex = note.idHex, defaultIfMissing = StatusKind.OPEN) + val commit = remember(noteEvent) { noteEvent.commit() } commit?.takeIf { it.isNotBlank() }?.let { hash -> Text( @@ -394,11 +399,18 @@ private fun RenderGitIssueEvent( nav: INav, ) { GitCardContainer { - TypeChip( - text = stringRes(id = R.string.kind_git_issue), - background = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f), - contentColor = MaterialTheme.colorScheme.primary, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = HeaderSpacing, + ) { + TypeChip( + text = stringRes(id = R.string.kind_git_issue), + background = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f), + contentColor = MaterialTheme.colorScheme.primary, + ) + + GitStatusPill(targetIdHex = note.idHex, defaultIfMissing = StatusKind.OPEN) + } val repository = remember(noteEvent) { noteEvent.repositoryAddress() } if (repository != null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusPill.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusPill.kt new file mode 100644 index 0000000000..ef714437f4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusPill.kt @@ -0,0 +1,196 @@ +/* + * 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.note.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent +import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent +import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent +import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent +import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent + +private val PillShape = RoundedCornerShape(8.dp) + +enum class StatusKind { + OPEN, + APPLIED, + CLOSED, + DRAFT, +} + +private fun GitStatusEvent.statusKind(): StatusKind = + when (this) { + is GitStatusAppliedEvent -> StatusKind.APPLIED + is GitStatusClosedEvent -> StatusKind.CLOSED + is GitStatusDraftEvent -> StatusKind.DRAFT + is GitStatusOpenEvent -> StatusKind.OPEN + else -> StatusKind.OPEN + } + +@Composable +fun rememberLatestStatus(targetIdHex: String): GitStatusEvent? { + var status by remember(targetIdHex) { + mutableStateOf(scanForLatestStatus(targetIdHex)) + } + + LaunchedEffect(targetIdHex) { + LocalCache.live.newEventBundles.collect { bundle -> + for (note in bundle) { + val event = note.event as? GitStatusEvent ?: continue + if (event.rootEventId() == targetIdHex) { + val current = status + if (current == null || event.createdAt > current.createdAt) { + status = event + } + } + } + } + } + + return status +} + +private fun scanForLatestStatus(targetIdHex: String): GitStatusEvent? { + var latest: GitStatusEvent? = null + LocalCache.notes.forEach { _, note -> + val event = note.event + if (event is GitStatusEvent && event.rootEventId() == targetIdHex) { + val current = latest + if (current == null || event.createdAt > current.createdAt) { + latest = event + } + } + } + return latest +} + +@Composable +fun GitStatusPill( + targetIdHex: String, + modifier: Modifier = Modifier, + defaultIfMissing: StatusKind? = null, +) { + val latest = rememberLatestStatus(targetIdHex) + val kind = + latest?.statusKind() ?: defaultIfMissing ?: return + + GitStatusPill(kind, modifier) +} + +@Composable +fun GitStatusPill( + kind: StatusKind, + modifier: Modifier = Modifier, +) { + val (label, symbol, container, content) = + when (kind) { + StatusKind.OPEN -> { + StatusStyle( + label = stringRes(id = R.string.git_status_open), + symbol = MaterialSymbols.RadioButtonChecked, + container = Color(0xFF1F883D), + content = Color.White, + ) + } + + StatusKind.APPLIED -> { + StatusStyle( + label = stringRes(id = R.string.git_status_merged), + symbol = MaterialSymbols.Check, + container = Color(0xFF8250DF), + content = Color.White, + ) + } + + StatusKind.CLOSED -> { + StatusStyle( + label = stringRes(id = R.string.git_status_closed), + symbol = MaterialSymbols.Cancel, + container = Color(0xFFCF222E), + content = Color.White, + ) + } + + StatusKind.DRAFT -> { + StatusStyle( + label = stringRes(id = R.string.git_status_draft), + symbol = MaterialSymbols.EditNote, + container = MaterialTheme.colorScheme.surfaceVariant, + content = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Row( + modifier = + modifier + .clip(PillShape) + .background(container) + .padding(horizontal = 8.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + symbol = symbol, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = content, + ) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = content, + ) + } +} + +private data class StatusStyle( + val label: String, + val symbol: MaterialSymbol, + val container: Color, + val content: Color, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryOverview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryOverview.kt new file mode 100644 index 0000000000..f2b2102347 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryOverview.kt @@ -0,0 +1,249 @@ +/* + * 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.gitRepo + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.components.ClickableUrl +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Font12SP +import com.vitorpamplona.amethyst.ui.theme.Size16dp +import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent + +private val SectionCardShape = RoundedCornerShape(15.dp) +private val SectionSpacing = Arrangement.spacedBy(12.dp) +private val LinkSpacing = Arrangement.spacedBy(8.dp) + +@Composable +fun GitRepositoryOverview( + event: GitRepositoryEvent, + accountViewModel: AccountViewModel, + nav: INav, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 16.dp), + verticalArrangement = SectionSpacing, + ) { + TitleHeader(event) + + val description = event.description() + if (!description.isNullOrBlank()) { + SectionCard(title = stringRes(id = R.string.git_repo_section_about)) { + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + + val webs = event.webs() + val clones = event.clones() + if (webs.isNotEmpty() || clones.isNotEmpty()) { + SectionCard(title = stringRes(id = R.string.git_repo_section_links)) { + Column(verticalArrangement = LinkSpacing) { + webs.forEach { LinkLine(symbol = MaterialSymbols.Public, url = it) } + clones.forEach { LinkLine(symbol = MaterialSymbols.AutoMirrored.OpenInNew, url = it) } + } + } + } + + val topics = event.hashtags() + if (topics.isNotEmpty()) { + SectionCard(title = stringRes(id = R.string.git_repo_section_topics)) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + topics.forEach { TopicChip(it) } + } + } + } + + val maintainers = listOfNotNull(event.pubKey).plus(event.maintainers()).distinct() + if (maintainers.isNotEmpty()) { + SectionCard(title = stringRes(id = R.string.git_repo_section_maintainers)) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + maintainers.forEach { hex -> + MaintainerRow(pubKeyHex = hex, accountViewModel = accountViewModel, nav = nav) + } + } + } + } + } +} + +@Composable +private fun TitleHeader(event: GitRepositoryEvent) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = + Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon( + symbol = MaterialSymbols.Code, + contentDescription = null, + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = event.name() ?: event.dTag(), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (event.isPersonalFork()) { + Spacer(Modifier.size(4.dp)) + TopicChip(stringRes(id = R.string.git_repo_personal_fork)) + } + } + } +} + +@Composable +private fun SectionCard( + title: String, + content: @Composable () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(SectionCardShape) + .background(MaterialTheme.colorScheme.surface) + .padding(16.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.grayText, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.size(8.dp)) + content() + } +} + +@Composable +private fun LinkLine( + symbol: com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol, + url: String, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + symbol = symbol, + contentDescription = null, + modifier = Modifier.size(Size16dp), + tint = MaterialTheme.colorScheme.grayText, + ) + ClickableUrl( + url = url, + urlText = url.removePrefix("https://").removePrefix("http://"), + ) + } +} + +@Composable +private fun TopicChip(label: String) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall.copy(fontSize = Font12SP), + color = MaterialTheme.colorScheme.grayText, + modifier = + Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)) + .padding(horizontal = 10.dp, vertical = 3.dp), + ) +} + +@Composable +private fun MaintainerRow( + pubKeyHex: HexKey, + accountViewModel: AccountViewModel, + nav: INav, +) { + val user = LocalCache.checkGetOrCreateUser(pubKeyHex) ?: return + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ClickableUserPicture( + baseUser = user, + size = 32.dp, + accountViewModel = accountViewModel, + onClick = { nav.nav(Route.Profile(it.pubkeyHex)) }, + ) + UsernameDisplay( + baseUser = user, + accountViewModel = accountViewModel, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt new file mode 100644 index 0000000000..eb480589c5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt @@ -0,0 +1,237 @@ +/* + * 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.gitRepo + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SecondaryTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar +import com.vitorpamplona.amethyst.ui.navigation.topbars.TitleIconModifier +import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.dal.RepositoryIssuesFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.dal.RepositoryPatchesFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.datasource.RepositoryFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.TabRowHeight +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +import kotlinx.coroutines.launch + +@Composable +fun GitRepositoryScreen( + address: Address, + accountViewModel: AccountViewModel, + nav: INav, +) { + LoadAddressableNote(address, accountViewModel) { note -> + note?.let { + PrepareGitRepositoryScreen( + note = it, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} + +@Composable +private fun PrepareGitRepositoryScreen( + note: AddressableNote, + accountViewModel: AccountViewModel, + nav: INav, +) { + val issuesViewModel: RepositoryIssuesFeedViewModel = + viewModel( + key = note.idHex + "GitRepoIssues", + factory = RepositoryIssuesFeedViewModel.Factory(note, accountViewModel.account), + ) + + val patchesViewModel: RepositoryPatchesFeedViewModel = + viewModel( + key = note.idHex + "GitRepoPatches", + factory = RepositoryPatchesFeedViewModel.Factory(note, accountViewModel.account), + ) + + GitRepositoryScreen( + note = note, + issuesViewModel = issuesViewModel, + patchesViewModel = patchesViewModel, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun GitRepositoryScreen( + note: AddressableNote, + issuesViewModel: RepositoryIssuesFeedViewModel, + patchesViewModel: RepositoryPatchesFeedViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchLifecycleAndUpdateModel(issuesViewModel) + WatchLifecycleAndUpdateModel(patchesViewModel) + RepositoryFilterAssemblerSubscription(note, accountViewModel.dataSources().gitRepository) + + val pagerState = rememberForeverPagerState(note.idHex + "GitRepoScreenPagerState") { 3 } + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + Column { + ShorterTopAppBar( + title = { + TopBarTitle(note) + }, + navigationIcon = { + Row(TitleIconModifier, verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = nav::popBack) { ArrowBackIcon() } + } + }, + ) + + SecondaryTabRow( + containerColor = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + modifier = TabRowHeight, + selectedTabIndex = pagerState.currentPage, + ) { + val coroutineScope = rememberCoroutineScope() + Tab( + selected = pagerState.currentPage == 0, + text = { Text(stringRes(R.string.git_repo_tab_overview)) }, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } }, + ) + Tab( + selected = pagerState.currentPage == 1, + text = { Text(stringRes(R.string.git_repo_tab_issues)) }, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } }, + ) + Tab( + selected = pagerState.currentPage == 2, + text = { Text(stringRes(R.string.git_repo_tab_patches)) }, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } }, + ) + } + } + }, + accountViewModel = accountViewModel, + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + ) { page -> + when (page) { + 0 -> { + val event = note.event as? GitRepositoryEvent + if (event != null) { + GitRepositoryOverview(event, accountViewModel, nav) + } else { + EmptyMessage(stringRes(R.string.loading_feed)) + } + } + + 1 -> { + RefresheableFeedView( + viewModel = issuesViewModel, + routeForLastRead = null, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + 2 -> { + RefresheableFeedView( + viewModel = patchesViewModel, + routeForLastRead = null, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + } +} + +@Composable +private fun TopBarTitle(note: AddressableNote) { + val event = note.event as? GitRepositoryEvent + val title = event?.name() ?: note.dTag() + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +@Composable +private fun EmptyMessage(text: String) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(24.dp), + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedFilter.kt new file mode 100644 index 0000000000..a9764da996 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedFilter.kt @@ -0,0 +1,55 @@ +/* + * 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.gitRepo.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent + +class RepositoryIssuesFeedFilter( + val repositoryNote: AddressableNote, + val account: Account, +) : AdditiveFeedFilter() { + private val repositoryAddressId = repositoryNote.address.toValue() + + override fun feedKey(): String = account.userProfile().pubkeyHex + "-issues-" + repositoryNote.idHex + + override fun feed(): List { + val result = + LocalCache.notes.mapNotNullIntoSet { _, note -> + if (matches(note)) note else null + } + return sort(result) + } + + override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { matches(it) } + + private fun matches(note: Note): Boolean { + val event = note.event as? GitIssueEvent ?: return false + return event.repositoryHex() == repositoryAddressId + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedViewModel.kt new file mode 100644 index 0000000000..24d8239407 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedViewModel.kt @@ -0,0 +1,40 @@ +/* + * 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.gitRepo.dal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel + +class RepositoryIssuesFeedViewModel( + val note: AddressableNote, + val account: Account, +) : AndroidFeedViewModel(RepositoryIssuesFeedFilter(note, account)) { + class Factory( + val note: AddressableNote, + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = RepositoryIssuesFeedViewModel(note, account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedFilter.kt new file mode 100644 index 0000000000..243ee020bb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedFilter.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent + +class RepositoryPatchesFeedFilter( + val repositoryNote: AddressableNote, + val account: Account, +) : AdditiveFeedFilter() { + private val repositoryAddressId = repositoryNote.address.toValue() + + override fun feedKey(): String = account.userProfile().pubkeyHex + "-patches-" + repositoryNote.idHex + + override fun feed(): List { + val result = + LocalCache.notes.mapNotNullIntoSet { _, note -> + if (matches(note)) note else null + } + return sort(result) + } + + override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { matches(it) } + + private fun matches(note: Note): Boolean { + val event = note.event ?: return false + return when (event) { + is GitPatchEvent -> event.repositoryAddress()?.toValue() == repositoryAddressId + is GitPullRequestEvent -> event.repositoryAddress()?.toValue() == repositoryAddressId + else -> false + } + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedViewModel.kt new file mode 100644 index 0000000000..629d7bb4d5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedViewModel.kt @@ -0,0 +1,40 @@ +/* + * 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.gitRepo.dal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel + +class RepositoryPatchesFeedViewModel( + val note: AddressableNote, + val account: Account, +) : AndroidFeedViewModel(RepositoryPatchesFeedFilter(note, account)) { + class Factory( + val note: AddressableNote, + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = RepositoryPatchesFeedViewModel(note, account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/FilterRepositoryContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/FilterRepositoryContent.kt new file mode 100644 index 0000000000..f5b05be87d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/FilterRepositoryContent.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.datasource + +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent +import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent +import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent +import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent +import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent + +val RepositoryContentKinds = + listOf( + GitIssueEvent.KIND, + GitPatchEvent.KIND, + GitPullRequestEvent.KIND, + GitStatusOpenEvent.KIND, + GitStatusAppliedEvent.KIND, + GitStatusClosedEvent.KIND, + GitStatusDraftEvent.KIND, + ) + +fun filterRepositoryContent( + relay: NormalizedRelayUrl, + repository: GitRepositoryEvent, + since: Long?, +): RelayBasedFilter = + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("a" to listOf(repository.addressTag())), + kinds = RepositoryContentKinds, + limit = 500, + since = since, + ), + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/RepositoryContentSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/RepositoryContentSubAssembler.kt new file mode 100644 index 0000000000..4f17b81b46 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/RepositoryContentSubAssembler.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.datasource + +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent + +class RepositoryContentSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : SingleSubEoseManager(client, allKeys) { + override fun updateFilter( + keys: List, + since: SincePerRelayMap?, + ): List { + if (keys.isEmpty()) return emptyList() + + return keys.flatMap { key -> + val repoEvent = key.repository.event as? GitRepositoryEvent ?: return@flatMap emptyList() + + val announcedRelays = repoEvent.relays().mapNotNull(RelayUrlNormalizer::normalizeOrNull) + val hintRelays = + if (announcedRelays.isEmpty()) { + LocalCache.relayHints.hintsForAddress(repoEvent.addressTag()) + } else { + emptyList() + } + val repoRelays = (announcedRelays + hintRelays + key.repository.relayUrls()).toSet() + + repoRelays.map { relay -> + filterRepositoryContent(relay, repoEvent, since?.get(relay)?.time) + } + } + } + + override fun distinct(key: RepositoryQueryState) = key.repository.idHex +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/RepositoryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/RepositoryFilterAssembler.kt new file mode 100644 index 0000000000..68e0c5f77a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/RepositoryFilterAssembler.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.datasource + +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient + +class RepositoryQueryState( + var repository: AddressableNote, +) + +class RepositoryFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + RepositoryContentSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/RepositoryFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/RepositoryFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..bf801b376f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/RepositoryFilterAssemblerSubscription.kt @@ -0,0 +1,39 @@ +/* + * 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.gitRepo.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.model.AddressableNote + +@Composable +fun RepositoryFilterAssemblerSubscription( + repository: AddressableNote, + filterAssembler: RepositoryFilterAssembler, +) { + val state = + remember(repository) { + RepositoryQueryState(repository) + } + + LifecycleAwareKeyDataSourceSubscription(state, filterAssembler) +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a17c82a1f9..107cb1c3a0 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2067,6 +2067,20 @@ Git Repository: %1$s Web: Clone: + Open + Merged + Closed + Draft + Overview + Issues + Patches & PRs + About + Links + Maintainers + Topics + Personal fork + No issues for this repository yet. + No patches or pull requests yet. Static Website: %1$s Root Site Source: