From 58faeac59c5939c6782bcc34392d0f76bd02f29e Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 25 Oct 2026 22:03:48 +0100 Subject: [PATCH 1/8] feat: load recommendation rows and send recommender feedback --- .../core/di/RecommenderRepositoryModule.kt | 8 +++ .../data/feed/HomeFeedRepositoryImpl.kt | 18 ++++- .../android/data/network/TypeTypeFeedApi.kt | 2 + .../data/network/TypeTypeRecommenderApi.kt | 13 ++++ .../network/dto/RecommenderFeedbackDto.kt | 20 ++++++ .../android/data/network/dto/VideoDto.kt | 12 ++++ .../RemoteRecommenderFeedbackRepository.kt | 65 +++++++++++++++++++ .../android/domain/feed/HomeFeedRepository.kt | 12 ++++ .../RecommenderFeedbackRepository.kt | 23 +++++++ 9 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 core/data/src/main/java/dev/typetype/android/data/network/dto/RecommenderFeedbackDto.kt create mode 100644 core/data/src/main/java/dev/typetype/android/data/recommendations/RemoteRecommenderFeedbackRepository.kt create mode 100644 core/model/src/main/java/dev/typetype/android/domain/recommendations/RecommenderFeedbackRepository.kt diff --git a/core/data/src/main/java/dev/typetype/android/core/di/RecommenderRepositoryModule.kt b/core/data/src/main/java/dev/typetype/android/core/di/RecommenderRepositoryModule.kt index 6da500f8..cbc1872d 100644 --- a/core/data/src/main/java/dev/typetype/android/core/di/RecommenderRepositoryModule.kt +++ b/core/data/src/main/java/dev/typetype/android/core/di/RecommenderRepositoryModule.kt @@ -4,7 +4,9 @@ import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import dev.typetype.android.data.recommendations.RemoteRecommenderFeedbackRepository import dev.typetype.android.data.recommendations.RemoteRecommenderSettingsRepository +import dev.typetype.android.domain.recommendations.RecommenderFeedbackRepository import dev.typetype.android.domain.recommendations.RecommenderSettingsRepository import javax.inject.Singleton @@ -16,4 +18,10 @@ abstract class RecommenderRepositoryModule { abstract fun bindRecommenderSettingsRepository( implementation: RemoteRecommenderSettingsRepository, ): RecommenderSettingsRepository + + @Binds + @Singleton + abstract fun bindRecommenderFeedbackRepository( + implementation: RemoteRecommenderFeedbackRepository, + ): RecommenderFeedbackRepository } diff --git a/core/data/src/main/java/dev/typetype/android/data/feed/HomeFeedRepositoryImpl.kt b/core/data/src/main/java/dev/typetype/android/data/feed/HomeFeedRepositoryImpl.kt index 702b37bd..ed38f3a9 100644 --- a/core/data/src/main/java/dev/typetype/android/data/feed/HomeFeedRepositoryImpl.kt +++ b/core/data/src/main/java/dev/typetype/android/data/feed/HomeFeedRepositoryImpl.kt @@ -1,5 +1,8 @@ package dev.typetype.android.data.feed +import java.util.Locale +import java.util.TimeZone +import dev.typetype.android.domain.feed.HomeRecommendationRow import dev.typetype.android.data.account.ActiveAccountScope import dev.typetype.android.data.account.AccountScope import dev.typetype.android.data.network.TypeTypeApiHolder @@ -54,6 +57,7 @@ class HomeFeedRepositoryImpl @Inject constructor( override suspend fun loadHomeRecommendations( cursor: String?, limit: Int, + service: Int, ): Result = feedResult { val scope = activeAccountScope.require() val server = if (scope.accountId.startsWith(GUEST_ACCOUNT_PREFIX)) { @@ -67,7 +71,14 @@ class HomeFeedRepositoryImpl @Inject constructor( } val api = apiHolder.require(scope) val response = withContext(Dispatchers.IO) { - api.homeRecommendations(intent = "auto", cursor = cursor, limit = limit) + api.homeRecommendations( + service = service, + intent = "auto", + cursor = cursor, + limit = limit, + locale = Locale.getDefault().toLanguageTag(), + timezone = TimeZone.getDefault().id, + ) } response.requireSuccessfulResponse() val body = response.body() ?: error("Empty recommendations body") @@ -75,6 +86,11 @@ class HomeFeedRepositoryImpl @Inject constructor( HomeRecommendationsPage( videos = body.items.map { it.toDomainVideo() }, nextCursor = body.nextCursor?.takeIf { body.hasMore && it.isNotBlank() }, + rows = body.rows.orEmpty().map { row -> + HomeRecommendationRow(row.id, row.title, row.reason, row.reasonRef, row.videoUrls) + }, + algorithm = body.algorithm, + preparing = body.preparing == true, ) } diff --git a/core/data/src/main/java/dev/typetype/android/data/network/TypeTypeFeedApi.kt b/core/data/src/main/java/dev/typetype/android/data/network/TypeTypeFeedApi.kt index d3a687df..e1cf1c38 100644 --- a/core/data/src/main/java/dev/typetype/android/data/network/TypeTypeFeedApi.kt +++ b/core/data/src/main/java/dev/typetype/android/data/network/TypeTypeFeedApi.kt @@ -27,6 +27,8 @@ interface TypeTypeFeedApi { @Query("limit") limit: Int = 30, @Query("intent") intent: String? = null, @Query("cursor") cursor: String? = null, + @Query("locale") locale: String? = null, + @Query("timezone") timezone: String? = null, ): Response @GET("recommendations/shorts") diff --git a/core/data/src/main/java/dev/typetype/android/data/network/TypeTypeRecommenderApi.kt b/core/data/src/main/java/dev/typetype/android/data/network/TypeTypeRecommenderApi.kt index d895387a..a61e2fb5 100644 --- a/core/data/src/main/java/dev/typetype/android/data/network/TypeTypeRecommenderApi.kt +++ b/core/data/src/main/java/dev/typetype/android/data/network/TypeTypeRecommenderApi.kt @@ -1,10 +1,15 @@ package dev.typetype.android.data.network +import dev.typetype.android.data.network.dto.RecommenderFeedbackDto +import dev.typetype.android.data.network.dto.RecommenderFeedbackRequestDto import kotlinx.serialization.json.JsonObject import retrofit2.Response import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.POST import retrofit2.http.PUT +import retrofit2.http.Path interface TypeTypeRecommenderApi { @GET("recommendations/settings") @@ -12,4 +17,12 @@ interface TypeTypeRecommenderApi { @PUT("recommendations/settings") suspend fun updateRecommenderSettings(@Body settings: JsonObject): Response + + @POST("recommendations/feedback") + suspend fun sendRecommenderFeedback( + @Body feedback: RecommenderFeedbackRequestDto, + ): Response + + @DELETE("recommendations/feedback/{id}") + suspend fun deleteRecommenderFeedback(@Path("id") id: String): Response } diff --git a/core/data/src/main/java/dev/typetype/android/data/network/dto/RecommenderFeedbackDto.kt b/core/data/src/main/java/dev/typetype/android/data/network/dto/RecommenderFeedbackDto.kt new file mode 100644 index 00000000..3c5c6cd3 --- /dev/null +++ b/core/data/src/main/java/dev/typetype/android/data/network/dto/RecommenderFeedbackDto.kt @@ -0,0 +1,20 @@ +package dev.typetype.android.data.network.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class RecommenderFeedbackRequestDto( + val kind: String, + val verdict: String, + val comment: String? = null, + val videoUrl: String? = null, + val reason: String? = null, + val title: String? = null, + val channelName: String? = null, + val thumbnailUrl: String? = null, +) + +@Serializable +data class RecommenderFeedbackDto( + val id: String = "", +) diff --git a/core/data/src/main/java/dev/typetype/android/data/network/dto/VideoDto.kt b/core/data/src/main/java/dev/typetype/android/data/network/dto/VideoDto.kt index 530fdf36..3b74ab89 100644 --- a/core/data/src/main/java/dev/typetype/android/data/network/dto/VideoDto.kt +++ b/core/data/src/main/java/dev/typetype/android/data/network/dto/VideoDto.kt @@ -31,6 +31,18 @@ data class HomeRecommendationsResponse( val items: List = emptyList(), val nextCursor: String? = null, val hasMore: Boolean = false, + val algorithm: String? = null, + val preparing: Boolean? = null, + val rows: List? = null, +) + +@Serializable +data class HomeRecommendationRowDto( + val id: String, + val title: String, + val reason: String = "", + val reasonRef: String? = null, + val videoUrls: List = emptyList(), ) @Serializable diff --git a/core/data/src/main/java/dev/typetype/android/data/recommendations/RemoteRecommenderFeedbackRepository.kt b/core/data/src/main/java/dev/typetype/android/data/recommendations/RemoteRecommenderFeedbackRepository.kt new file mode 100644 index 00000000..345f284c --- /dev/null +++ b/core/data/src/main/java/dev/typetype/android/data/recommendations/RemoteRecommenderFeedbackRepository.kt @@ -0,0 +1,65 @@ +package dev.typetype.android.data.recommendations + +import dev.typetype.android.core.error.suspendRunCatching +import dev.typetype.android.data.account.ActiveAccountScope +import dev.typetype.android.data.network.TypeTypeApiHolder +import dev.typetype.android.data.network.dto.RecommenderFeedbackRequestDto +import dev.typetype.android.data.network.requireSuccessfulResponse +import dev.typetype.android.domain.feed.Video +import dev.typetype.android.domain.recommendations.PageRefusalReason +import dev.typetype.android.domain.recommendations.RecommenderFeedbackRepository +import dev.typetype.android.domain.recommendations.VideoRefusalReason +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Singleton +class RemoteRecommenderFeedbackRepository @Inject constructor( + private val apiHolder: TypeTypeApiHolder, + private val activeAccountScope: ActiveAccountScope, +) : RecommenderFeedbackRepository { + override suspend fun notForMe(video: Video, reason: VideoRefusalReason?): Result = send( + RecommenderFeedbackRequestDto( + kind = "video", + verdict = "not_for_me", + videoUrl = video.url, + reason = reason?.wireName, + title = video.title.take(MAX_TEXT), + channelName = video.uploaderName.take(MAX_TEXT), + thumbnailUrl = video.thumbnailUrl.takeIf { it.length <= MAX_URL }, + ), + ) + + override suspend fun ratePage(good: Boolean, reason: PageRefusalReason?, comment: String?): Result = send( + RecommenderFeedbackRequestDto( + kind = "page", + verdict = if (good) "good" else "bad", + reason = reason?.wireName, + comment = comment?.trim()?.takeIf { it.isNotEmpty() }?.take(MAX_COMMENT), + ), + ) + + override suspend fun withdraw(feedbackId: String): Result = suspendRunCatching { + val scope = activeAccountScope.require() + val response = withContext(Dispatchers.IO) { + apiHolder.require(scope).deleteRecommenderFeedback(feedbackId) + } + response.requireSuccessfulResponse() + } + + private suspend fun send(request: RecommenderFeedbackRequestDto): Result = suspendRunCatching { + val scope = activeAccountScope.require() + val response = withContext(Dispatchers.IO) { + apiHolder.require(scope).sendRecommenderFeedback(request) + } + response.requireSuccessfulResponse() + response.body()?.id.orEmpty() + } + + private companion object { + const val MAX_TEXT = 300 + const val MAX_URL = 500 + const val MAX_COMMENT = 1000 + } +} diff --git a/core/model/src/main/java/dev/typetype/android/domain/feed/HomeFeedRepository.kt b/core/model/src/main/java/dev/typetype/android/domain/feed/HomeFeedRepository.kt index aca00814..d3d8bf1a 100644 --- a/core/model/src/main/java/dev/typetype/android/domain/feed/HomeFeedRepository.kt +++ b/core/model/src/main/java/dev/typetype/android/domain/feed/HomeFeedRepository.kt @@ -14,6 +14,17 @@ data class SubscriptionsPage( data class HomeRecommendationsPage( val videos: List