diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 51f0d145..5136bd9c 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -97,6 +97,7 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { |package com.nuvio.app.features.settings | |object CommunityConfig { + | const val CONTRIBUTIONS_URL = "${props.getProperty("CONTRIBUTIONS_URL", "")}" | const val DONATIONS_BASE_URL = "${props.getProperty("DONATIONS_BASE_URL", "")}" | const val DONATIONS_DONATE_URL = "${props.getProperty("DONATIONS_DONATE_URL", "")}" |} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt index ce25497f..3b3f8056 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt @@ -54,10 +54,7 @@ import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.NuvioSurfaceCard import com.nuvio.app.features.addons.httpRequestRaw -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch -import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json @@ -83,12 +80,16 @@ private data class CommunityUiState( ) @Serializable -private data class GitHubContributorDto( - val login: String? = null, - @SerialName("avatar_url") val avatarUrl: String? = null, - @SerialName("html_url") val htmlUrl: String? = null, - val contributions: Int? = null, - val type: String? = null, +private data class ContributionsResponseDto( + val contributors: List = emptyList(), +) + +@Serializable +private data class ContributionDto( + val name: String? = null, + val avatar: String? = null, + val profile: String? = null, + val total: Int? = null, ) @Serializable @@ -108,9 +109,6 @@ internal data class CommunityContributor( val avatarUrl: String?, val profileUrl: String?, val totalContributions: Int, - val mobileContributions: Int, - val tvContributions: Int, - val webContributions: Int, ) internal data class SupporterDonation( @@ -122,39 +120,31 @@ internal data class SupporterDonation( ) private object SupportersContributorsRepository { - private const val gitHubOwner = "nuviomedia" - private const val mobileRepository = "nuviomobile" - private const val tvRepository = "nuviotv" - private const val webRepository = "nuvioweb" - private const val gitHubApiBase = "https://api.github.com" - private val json = Json { ignoreUnknownKeys = true; isLenient = true } suspend fun getContributors(): Result> = runCatching { - coroutineScope { - val mobileDeferred = async { fetchRepoContributors(mobileRepository) } - val tvDeferred = async { fetchRepoContributors(tvRepository) } - val webDeferred = async { fetchRepoContributors(webRepository) } - - val mobileResult = mobileDeferred.await() - val tvResult = tvDeferred.await() - val webResult = webDeferred.await() - - if (mobileResult.isFailure && tvResult.isFailure && webResult.isFailure) { - throw ( - mobileResult.exceptionOrNull() - ?: tvResult.exceptionOrNull() - ?: webResult.exceptionOrNull() - ?: IllegalStateException(getString(Res.string.community_error_unable_load_contributors)) - ) - } - - mergeContributors( - mobileContributors = mobileResult.getOrDefault(emptyList()), - tvContributors = tvResult.getOrDefault(emptyList()), - webContributors = webResult.getOrDefault(emptyList()), - ) + val contributionsUrl = CommunityConfig.CONTRIBUTIONS_URL.trim() + check(contributionsUrl.isNotBlank()) { + getString(Res.string.community_error_unable_load_contributors) } + + val response = httpRequestRaw( + method = "GET", + url = contributionsUrl, + headers = emptyMap(), + body = "", + ) + if (response.status !in 200..299) { + error(getString(Res.string.community_error_contributors_request_failed)) + } + + json.decodeFromString(response.body) + .contributors + .mapNotNull(::normalizeContributor) + .sortedWith( + compareByDescending { it.totalContributions } + .thenBy { it.login.lowercase() }, + ) } suspend fun getSupporters(limit: Int = 200): Result> = runCatching { @@ -194,127 +184,19 @@ private object SupportersContributorsRepository { } } - private suspend fun fetchRepoContributors(repo: String): Result> = runCatching { - val contributors = mutableListOf() - var nextUrl: String? = "$gitHubApiBase/repos/$gitHubOwner/$repo/contributors?per_page=100" - - while (nextUrl != null) { - val response = httpRequestRaw( - method = "GET", - url = nextUrl, - headers = mapOf( - "Accept" to "application/vnd.github+json", - "User-Agent" to "NuvioMobile", - ), - body = "", - ) - if (response.status !in 200..299) { - error(getString(Res.string.community_error_contributors_request_failed)) - } - - contributors += json.decodeFromString>(response.body) - nextUrl = response.headers.entries - .firstOrNull { it.key.equals("link", ignoreCase = true) } - ?.value - ?.let(::parseNextLink) - } - - contributors - } - - private fun mergeContributors( - mobileContributors: List, - tvContributors: List, - webContributors: List, - ): List { - val contributorsByLogin = linkedMapOf() - - mobileContributors.forEach { dto -> - normalizeContributor(dto)?.let { contributor -> - val entry = contributorsByLogin.getOrPut(contributor.login.lowercase()) { - MutableCommunityContributor( - login = contributor.login, - avatarUrl = contributor.avatarUrl, - profileUrl = contributor.htmlUrl, - ) - } - entry.avatarUrl = entry.avatarUrl ?: contributor.avatarUrl - entry.profileUrl = entry.profileUrl ?: contributor.htmlUrl - entry.mobileContributions += contributor.contributions - } - } - - tvContributors.forEach { dto -> - normalizeContributor(dto)?.let { contributor -> - val entry = contributorsByLogin.getOrPut(contributor.login.lowercase()) { - MutableCommunityContributor( - login = contributor.login, - avatarUrl = contributor.avatarUrl, - profileUrl = contributor.htmlUrl, - ) - } - entry.avatarUrl = entry.avatarUrl ?: contributor.avatarUrl - entry.profileUrl = entry.profileUrl ?: contributor.htmlUrl - entry.tvContributions += contributor.contributions - } - } - - webContributors.forEach { dto -> - normalizeContributor(dto)?.let { contributor -> - val entry = contributorsByLogin.getOrPut(contributor.login.lowercase()) { - MutableCommunityContributor( - login = contributor.login, - avatarUrl = contributor.avatarUrl, - profileUrl = contributor.htmlUrl, - ) - } - entry.avatarUrl = entry.avatarUrl ?: contributor.avatarUrl - entry.profileUrl = entry.profileUrl ?: contributor.htmlUrl - entry.webContributions += contributor.contributions - } - } - - return contributorsByLogin.values - .map { contributor -> - CommunityContributor( - login = contributor.login, - avatarUrl = contributor.avatarUrl, - profileUrl = contributor.profileUrl, - totalContributions = contributor.mobileContributions + contributor.tvContributions + contributor.webContributions, - mobileContributions = contributor.mobileContributions, - tvContributions = contributor.tvContributions, - webContributions = contributor.webContributions, - ) - } - .sortedWith( - compareByDescending { it.totalContributions } - .thenBy { it.login.lowercase() }, - ) - } - - private fun normalizeContributor(dto: GitHubContributorDto): NormalizedContributor? { - val login = dto.login?.trim().orEmpty() - val contributions = dto.contributions ?: 0 - val type = dto.type?.trim() + private fun normalizeContributor(dto: ContributionDto): CommunityContributor? { + val login = dto.name?.trim().orEmpty() + val contributions = dto.total ?: 0 if (login.isBlank() || contributions <= 0) return null - if (type != null && !type.equals("User", ignoreCase = true)) return null - return NormalizedContributor( + return CommunityContributor( login = login, - avatarUrl = dto.avatarUrl?.trim()?.takeIf { it.isNotBlank() }, - htmlUrl = dto.htmlUrl?.trim()?.takeIf { it.isNotBlank() }, - contributions = contributions, + avatarUrl = dto.avatar?.trim()?.takeIf { it.isNotBlank() }, + profileUrl = dto.profile?.trim()?.takeIf { it.isNotBlank() }, + totalContributions = contributions, ) } - private fun parseNextLink(linkHeader: String): String? = - linkHeader.split(',') - .map(String::trim) - .firstOrNull { it.contains("rel=\"next\"") } - ?.substringAfter('<') - ?.substringBefore('>') - ?.takeIf { it.isNotBlank() } - private fun supporterSortTimestamp(rawDate: String): Long { val datePart = rawDate.substringBefore('T') val parts = datePart.split('-') @@ -324,22 +206,6 @@ private object SupportersContributorsRepository { val day = parts[2].toLongOrNull() ?: return Long.MIN_VALUE return year * 10_000L + month * 100L + day } - - private data class NormalizedContributor( - val login: String, - val avatarUrl: String?, - val htmlUrl: String?, - val contributions: Int, - ) - - private data class MutableCommunityContributor( - val login: String, - var avatarUrl: String?, - var profileUrl: String?, - var mobileContributions: Int = 0, - var tvContributions: Int = 0, - var webContributions: Int = 0, - ) } @Composable @@ -656,7 +522,7 @@ private fun ContributorsCard( ) { items( items = contributors, - key = { contributor -> contributor.login.lowercase() }, + key = { contributor -> "${contributor.login.lowercase()}-${contributor.profileUrl.orEmpty()}" }, ) { contributor -> ContributorRow( contributor = contributor, diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index a8260160..9f8d477b 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ -CURRENT_PROJECT_VERSION=38 -MARKETING_VERSION=0.1.7 +CURRENT_PROJECT_VERSION=40 +MARKETING_VERSION=0.1.9