Support display tag vote status (#1965)

* Support parsing GalleryTag vote status

* Append utf-8 emoji vote status

* Refresh tag list after voting

* Refine to use substringAfter

* Fix implicitNulls

* Refine to use withDraw vote

* Recompose first

* Optimize Snackbar to be cancellable for freq action

* Fix GalleryDetail recompose jump

* Refine VoteStatus display

* Support parse WeakStatus Active

* Append instead

* Add switch

* Refine naming

* Fix fillInfo

* Drop correctly

* Refine

* Refine rememberInVM impl with key

* Be type safe

* Refine

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
This commit is contained in:
renovate[bot]andDude so hot authored and GitHub committed 2024-11-23 07:44:23 +00:00
1 parent 03a343784d
commit 99d98b6227
18 files changed
+137 -83

No files matched your search

@@ -131,6 +131,7 @@ object Settings : DataStorePreferences(null) {
val avatar = stringOrNullPref("avatar", null)
val recentDownloadLabel = stringOrNullPref("recent_download_label", null)
var showVoteStatus by boolPref("show_vote_status", false)
var downloadScheme by stringOrNullPref("image_scheme", null)
var downloadAuthority by stringOrNullPref("image_authority", null)
var downloadPath by stringOrNullPref("image_path", null)
@@ -50,7 +50,10 @@ suspend inline fun <reified T> HttpStatement.executeAndParseAs() = executeSafely
inline fun <reified T> BufferedSource.parseAs(): T = json.decodeFromBufferedSource(this)
inline fun <reified T> String.parseAs(): T = json.decodeFromString(this)
val json = Json { ignoreUnknownKeys = true }
val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
fun HttpRequestBuilder.applyEhConfig(referer: String?, origin: String?) {
header("Referer", referer)
@@ -59,6 +59,8 @@ data class GalleryDetail(
fun fillInfo() {
val index = LANGUAGES.indexOf(language)
if (index != -1) simpleLanguage = S_LANGS[index]
simpleTags = tagGroups.fastFlatMap(GalleryTagGroup::tags)
simpleTags = tagGroups.fastFlatMap(GalleryTagGroup::tags).map { (text, power, _) ->
if (power == PowerStatus.WEAK) "_$text" else text
}
}
}
@@ -0,0 +1,23 @@
package com.hippo.ehviewer.client.data
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
enum class VoteStatus(val append: String) {
NONE(""),
UP("↑"),
DOWN("↓"),
}
enum class PowerStatus {
SOLID,
ACTIVE,
WEAK,
}
@Parcelize
data class GalleryTag(
val text: String,
val power: PowerStatus,
val vote: VoteStatus,
) : Parcelable
@@ -21,5 +21,5 @@ import kotlinx.parcelize.Parcelize
@Parcelize
data class GalleryTagGroup(
val nameSpace: TagNamespace,
val tags: List<String>,
val tags: List<GalleryTag>,
) : Parcelable
@@ -28,10 +28,13 @@ import com.hippo.ehviewer.client.data.GalleryCommentList
import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.LOCAL_FAVORITED
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
import com.hippo.ehviewer.client.data.GalleryTag
import com.hippo.ehviewer.client.data.GalleryTagGroup
import com.hippo.ehviewer.client.data.PowerStatus
import com.hippo.ehviewer.client.data.TagNamespace
import com.hippo.ehviewer.client.data.V1GalleryPreview
import com.hippo.ehviewer.client.data.V2GalleryPreview
import com.hippo.ehviewer.client.data.VoteStatus
import com.hippo.ehviewer.client.exception.EhException
import com.hippo.ehviewer.client.exception.OffensiveException
import com.hippo.ehviewer.client.exception.ParseException
@@ -280,12 +283,20 @@ object GalleryDetailParser {
}
checkNotNull(nameSpace) { "$nameSpace is not a valid tag namespace!" }
val tags = element.child(1).children().map { e ->
val text = e.text()
// Sometimes parody tag is followed with '|' and english translate, just remove them
val index = text.indexOf('|')
val tag = if (index >= 0) text.substring(0, index).trim() else text
// weak tag
if (e.className() == "gtw") "_$tag" else tag
GalleryTag(
// Sometimes parody tag is followed with '|' and english translate, just remove them
text = e.text().substringBefore('|').trim(),
power = when {
e.hasClass("gtw") -> PowerStatus.WEAK
e.hasClass("gtl") -> PowerStatus.ACTIVE
else -> PowerStatus.SOLID
},
vote = when {
e.child(0).hasClass("tup") -> VoteStatus.UP
e.child(0).hasClass("tdn") -> VoteStatus.DOWN
else -> VoteStatus.NONE
},
)
}
check(tags.isNotEmpty()) { "TagGroup is empty!" }
GalleryTagGroup(nameSpace, tags)
@@ -297,7 +308,7 @@ object GalleryDetailParser {
/**
* Parse tag groups with html parser
*/
private fun parseTagGroups(document: Document): List<GalleryTagGroup> = Either.catch {
fun parseTagGroups(document: Document): List<GalleryTagGroup> = Either.catch {
val taglist = document.getElementById("taglist")!!
taglist.child(0).child(0).children().mapNotNull(::parseSingleTagGroup)
}.getOrElse {
@@ -36,7 +36,7 @@ object GalleryTokenApiParser {
fun parse(body: String): String = Either.catch {
body.parseAs<Result>().tokenList[0].token
}.getOrElse {
throw EhException(body.parseAs<Error>().error)
throw EhException(body.parseAs<VoteTagResult>().error)
}
@Serializable
@@ -17,9 +17,15 @@
*/
package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.client.data.GalleryTagGroup
import com.hippo.ehviewer.client.parseAs
import org.jsoup.Jsoup
object VoteTagParser {
// {"error":"The tag \"neko\" is not allowed. Use character:neko or artist:neko"}
fun parse(body: String): String? = runCatching { body.parseAs<Error>().error }.getOrNull()
fun parse(body: String): List<GalleryTagGroup> = body.parseAs<VoteTagResult>().let { (error, tagpane) ->
error?.let { error(error) }
checkNotNull(tagpane)
GalleryDetailParser.parseTagGroups(Jsoup.parse("<div id=\"taglist\">$tagpane</div>"))
}
}
@@ -3,4 +3,7 @@ package com.hippo.ehviewer.client.parser
import kotlinx.serialization.Serializable
@Serializable
data class Error(val error: String)
data class VoteTagResult(
val error: String?,
val tagpane: String?,
)
@@ -3,6 +3,8 @@ package com.hippo.ehviewer.spider
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.data.GalleryTag
import com.hippo.ehviewer.client.data.PowerStatus
import com.hippo.ehviewer.client.data.SimpleTagsConverter
import com.hippo.ehviewer.client.data.TagNamespace
import com.hippo.ehviewer.client.data.TagNamespace.Artist
@@ -52,7 +54,7 @@ fun GalleryInfo.getComicInfo(): ComicInfo {
with(TagNamespace) {
when (this@getComicInfo) {
is GalleryDetail -> tagGroups.forEach { group ->
val list = group.tags.filterNot { it == TAG_ORIGINAL || it.startsWith('_') }
val list = group.tags.filterNot { (text, power, _) -> text == TAG_ORIGINAL || power == PowerStatus.WEAK }.map(GalleryTag::text)
when (val ns = group.nameSpace) {
Artist, Cosplayer -> artists.addAll(list)
Group -> groups.addAll(list)
@@ -548,13 +548,14 @@ class MainActivity : EhActivity() {
}
}
fun showTip(@StringRes id: Int, useToast: Boolean = false) {
val message = getString(id)
fun showTip(message: String, useToast: Boolean = false) {
if (useToast || !tipFlow.tryEmit(message)) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
fun showTip(@StringRes id: Int, useToast: Boolean = false) = showTip(getString(id), useToast)
override fun onProvideAssistContent(outContent: AssistContent?) {
super.onProvideAssistContent(outContent)
shareUrl?.let { outContent?.webUri = Uri.parse(shareUrl) }
@@ -20,14 +20,16 @@ import androidx.compose.ui.unit.dp
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhTagDatabase
import com.hippo.ehviewer.client.data.GalleryTagGroup
import com.hippo.ehviewer.client.data.PowerStatus
import com.hippo.ehviewer.client.data.TagNamespace
import com.hippo.ehviewer.client.data.VoteStatus
import com.hippo.ehviewer.ui.tools.includeFontPadding
@Composable
fun GalleryTags(
tagGroups: List<GalleryTagGroup>,
onTagClick: (String) -> Unit,
onTagLongClick: (String, String) -> Unit,
onTagLongClick: (String, String, VoteStatus) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
@@ -36,28 +38,25 @@ fun GalleryTags(
fun TagNamespace.translate() = ehTags?.getTranslation(tag = value) ?: value
fun String.translate(ns: TagNamespace) = ehTags?.getTranslation(prefix = ns.prefix, tag = this) ?: this
Column(modifier) {
tagGroups.forEach { tagGroup ->
tagGroups.forEach { (ns, tags) ->
Row {
val ns = tagGroup.nameSpace
BaseRoundText(
text = ns.translate(),
isGroup = true,
)
FlowRow {
tagGroup.tags.forEach {
val weak = it.startsWith('_')
val real = it.removePrefix("_")
val translated = real.translate(ns)
val tag = tagGroup.nameSpace.value + ":" + real
tags.forEach { (text, power, vote) ->
val translation = text.translate(ns)
val tag = ns.value + ":" + text
val hapticFeedback = LocalHapticFeedback.current
BaseRoundText(
text = translated,
weak = weak,
text = if (Settings.showVoteStatus) translation + vote.append else translation,
weak = power == PowerStatus.WEAK,
modifier = Modifier.combinedClickable(
onClick = { onTagClick(tag) },
onLongClick = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
onTagLongClick(translated, tag)
onTagLongClick(tag, translation, vote)
},
),
)
@@ -83,6 +83,7 @@ import com.hippo.ehviewer.client.data.GalleryTagGroup
import com.hippo.ehviewer.client.data.ListUrlBuilder
import com.hippo.ehviewer.client.data.TagNamespace
import com.hippo.ehviewer.client.data.V2GalleryPreview
import com.hippo.ehviewer.client.data.VoteStatus
import com.hippo.ehviewer.client.data.asGalleryDetail
import com.hippo.ehviewer.client.data.findBaseInfo
import com.hippo.ehviewer.client.exception.EhException
@@ -149,7 +150,6 @@ import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.launch
import moe.tarsin.coroutines.runSuspendCatching
import moe.tarsin.coroutines.runSwallowingWithUI
@@ -161,6 +161,7 @@ fun GalleryDetailContent(
contentPadding: PaddingValues,
getDetailError: String,
onRetry: () -> Unit,
voteTag: VoteTag,
modifier: Modifier,
) {
val keylineMargin = dimensionResource(R.dimen.keyline_margin)
@@ -245,7 +246,7 @@ fun GalleryDetailContent(
}
}
val previews = galleryDetail.collectPreviewItems()
val previews = galleryDetail?.collectPreviewItems()
when (windowSizeClass.windowWidthSizeClass) {
WindowWidthSizeClass.MEDIUM, WindowWidthSizeClass.COMPACT -> FastScrollLazyVerticalGrid(
columns = GridCells.Fixed(thumbColumns),
@@ -292,7 +293,7 @@ fun GalleryDetailContent(
if (getDetailError.isNotBlank()) {
GalleryDetailErrorTip(error = getDetailError, onClick = onRetry)
} else if (galleryDetail != null) {
BelowHeader(galleryDetail)
BelowHeader(galleryDetail, voteTag)
} else {
Box(
modifier = Modifier.fillMaxSize().padding(keylineMargin),
@@ -303,7 +304,7 @@ fun GalleryDetailContent(
}
}
}
if (galleryDetail != null) {
if (galleryDetail != null && previews != null) {
galleryPreview(galleryDetail, previews) { navToReader(galleryDetail.galleryInfo, it) }
}
}
@@ -360,7 +361,7 @@ fun GalleryDetailContent(
if (getDetailError.isNotBlank()) {
GalleryDetailErrorTip(error = getDetailError, onClick = onRetry)
} else if (galleryDetail != null) {
BelowHeader(galleryDetail)
BelowHeader(galleryDetail, voteTag)
} else {
Box(
modifier = Modifier.fillMaxSize().padding(keylineMargin),
@@ -371,7 +372,7 @@ fun GalleryDetailContent(
}
}
}
if (galleryDetail != null) {
if (galleryDetail != null && previews != null) {
galleryPreview(galleryDetail, previews) { navToReader(galleryDetail.galleryInfo, it) }
}
}
@@ -380,7 +381,7 @@ fun GalleryDetailContent(
context(Context, CoroutineScope, DestinationsNavigator, DialogState, SnackbarHostState)
@Composable
fun BelowHeader(galleryDetail: GalleryDetail) {
fun BelowHeader(galleryDetail: GalleryDetail, voteTag: VoteTag) {
@Composable
fun EhIconButton(
icon: ImageVector,
@@ -505,7 +506,7 @@ fun BelowHeader(galleryDetail: GalleryDetail) {
text = stringResource(id = R.string.similar_gallery),
onClick = {
val keyword = EhUtils.extractTitle(galleryDetail.title)
val artistTag = galleryDetail.tagGroups.getArtistTag()
val artistTag = galleryDetail.tagGroups.artistTag()
if (null != keyword) {
navigate(
ListUrlBuilder(
@@ -702,30 +703,26 @@ fun BelowHeader(galleryDetail: GalleryDetail) {
val filterAdded = stringResource(R.string.filter_added)
val upTag = stringResource(R.string.tag_vote_up)
val downTag = stringResource(R.string.tag_vote_down)
val withDraw = stringResource(R.string.tag_vote_withdraw)
GalleryTags(
tagGroups = tags,
onTagClick = {
navigate(ListUrlBuilder(mode = ListUrlBuilder.MODE_TAG, mKeyword = it).asDst())
},
onTagLongClick = { translated, tag ->
val index = tag.indexOf(':')
val temp = if (index >= 0) {
tag.substring(index + 1)
} else {
tag
}
onTagLongClick = { tag, translation, vote ->
val rawValue = tag.substringAfter(':')
launchIO {
awaitSelectAction {
onSelect(copy) {
addTextToClipboard(tag)
}
if (temp != translated) {
if (rawValue != translation) {
onSelect(copyTrans) {
addTextToClipboard(translated)
addTextToClipboard(translation)
}
}
onSelect(showDefine) {
openBrowser(EhUrl.getTagDefinitionUrl(temp))
openBrowser(EhUrl.getTagDefinitionUrl(rawValue))
}
onSelect(addFilter) {
awaitConfirmationOrCancel { Text(text = stringResource(R.string.filter_the_tag, tag)) }
@@ -733,8 +730,14 @@ fun BelowHeader(galleryDetail: GalleryDetail) {
showSnackbar(filterAdded)
}
if (galleryDetail.apiUid >= 0) {
onSelect(upTag) { galleryDetail.voteTag(tag, 1) }
onSelect(downTag) { galleryDetail.voteTag(tag, -1) }
when (vote) {
VoteStatus.NONE -> {
onSelect(upTag) { galleryDetail.voteTag(tag, 1) }
onSelect(downTag) { galleryDetail.voteTag(tag, -1) }
}
VoteStatus.UP -> onSelect(withDraw) { galleryDetail.voteTag(tag, -1) }
VoteStatus.DOWN -> onSelect(withDraw) { galleryDetail.voteTag(tag, 1) }
}
}
}()
}
@@ -764,23 +767,14 @@ private fun getRatingText(rating: Float): Int = when ((rating * 2).roundToInt())
else -> R.string.rating_none
}
private fun List<GalleryTagGroup>.getArtistTag(): String? {
for (tagGroup in this) {
val namespace = tagGroup.nameSpace
if (namespace == TagNamespace.Artist || namespace == TagNamespace.Cosplayer) {
return "$namespace:${tagGroup.tags[0].removePrefix("_")}"
}
}
return null
}
private fun List<GalleryTagGroup>.artistTag() = find { (ns, _) -> ns == TagNamespace.Artist || ns == TagNamespace.Cosplayer }?.let { (ns, tags) -> "$ns:${tags[0].text}" }
context(Context)
@Composable
private fun GalleryDetail?.collectPreviewItems() = rememberInVM(this) {
val detail = this@collectPreviewItems ?: return@rememberInVM emptyFlow()
val pageSize = detail.previewList.size
val pages = detail.pages
val previewPagesMap = detail.previewList.associateBy { it.position } as MutableMap
private fun GalleryDetail.collectPreviewItems() = rememberInVM(previewList) {
val pageSize = previewList.size
val pages = pages
val previewPagesMap = previewList.associateBy { it.position } as MutableMap
Pager(
PagingConfig(
pageSize = pageSize,
@@ -795,7 +789,7 @@ private fun GalleryDetail?.collectPreviewItems() = rememberInVM(this) {
val key = params.key ?: 0
val up = getOffset(params, key, pages)
val end = (up + getLimit(params, key) - 1).coerceAtMost(pages - 1)
detail.runSuspendCatching {
runSuspendCatching {
(up..end).filterNot { it in previewPagesMap }.map { it / pageSize }.toSet()
.parMap(concurrency = Settings.multiThreadDownload) { page ->
val url = EhUrl.getGalleryDetailUrl(gid, token, page, false)
@@ -1,6 +1,5 @@
package com.hippo.ehviewer.ui.screen
import android.content.Context
import android.os.Parcelable
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
import androidx.collection.SieveCache
@@ -18,7 +17,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
@@ -75,6 +73,8 @@ import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.parcelize.Parcelize
import moe.tarsin.coroutines.runSuspendCatching
typealias VoteTag = suspend GalleryDetail.(String, Int) -> Unit
val detailCache = SieveCache<Long, GalleryDetail>(25)
sealed interface GalleryDetailScreenArgs : Parcelable
@@ -139,6 +139,19 @@ fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, n
}
}
val voteTag: VoteTag = { tag, vote ->
runSuspendCatching {
EhEngine.voteTag(apiUid, apiKey, gid, token, tag, vote)
}.onSuccess { result ->
val new = copy(tagGroups = result).apply { fillInfo() }
detailCache[gid] = new
galleryInfo = new
showTip(R.string.tag_vote_successfully)
}.onFailure { e ->
showTip(e.displayString())
}
}
val signInFirst = stringResource(R.string.sign_in_first)
Scaffold(
topBar = {
@@ -308,6 +321,7 @@ fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, n
contentPadding = it,
getDetailError = getDetailError,
onRetry = { getDetailError = "" },
voteTag = voteTag,
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
)
} else if (getDetailError.isNotBlank()) {
@@ -322,18 +336,3 @@ fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, n
}
}
}
context(Context, SnackbarHostState)
suspend fun GalleryDetail.voteTag(tag: String, vote: Int) {
runSuspendCatching {
EhEngine.voteTag(apiUid, apiKey, gid, token, tag, vote)
}.onSuccess { result ->
if (result != null) {
showSnackbar(result)
} else {
showSnackbar(getString(R.string.tag_vote_successfully))
}
}.onFailure {
showSnackbar(getString(R.string.vote_failed))
}
}
@@ -234,6 +234,10 @@ fun EhScreen(navigator: DestinationsNavigator) {
title = stringResource(id = R.string.settings_eh_show_gallery_pages),
summary = stringResource(id = R.string.settings_eh_show_gallery_pages_summary),
)
SwitchPreference(
title = stringResource(id = R.string.settings_eh_show_vote_status),
value = Settings::showVoteStatus,
)
val showComments = Settings::showComments.observed
SwitchPreference(
title = stringResource(id = R.string.settings_eh_show_gallery_comments),
@@ -52,19 +52,21 @@ inline fun <reified T : Any> rememberInVM(
}
@Composable
inline fun <reified T : Any> rememberInVM(
key: Any?,
inline fun <T : Any, K : Any> rememberInVM(
key: K,
crossinline init: @DisallowComposableCalls ViewModel.() -> T,
) = with(viewModel<StateMapViewModel>()) {
val compositeKey = currentCompositeKeyHash
remember(key) {
val states = statesMap.getOrPut(compositeKey, ::ArrayDeque)
states.removeLastOrNull() as T? ?: init()
@Suppress("UNCHECKED_CAST")
(states.removeLastOrNull() as Pair<K, T>?)?.let { (k, v) -> v.takeIf { k == key } } ?: init()
}.also { value ->
val keyState by rememberUpdatedState(key)
val valueState by rememberUpdatedState(value)
DisposableEffect(compositeKey) {
onDispose {
statesMap[compositeKey]?.addFirst(valueState)
statesMap[compositeKey]?.addFirst(keyState to valueState)
}
}
}
@@ -300,6 +300,7 @@
<string name="settings_eh_show_jpn_title_summary">需同时在 EHentai 网站设置中启用 Japanese Title</string>
<string name="settings_eh_show_gallery_pages">显示画廊页数</string>
<string name="settings_eh_show_gallery_pages_summary">在画廊列表中显示页数</string>
<string name="settings_eh_show_vote_status">显示标签投票状态</string>
<string name="settings_eh_show_gallery_comments">显示画廊评论</string>
<string name="settings_eh_show_gallery_comments_summary">在画廊详情页中显示评论</string>
<string name="settings_eh_show_gallery_comment_threshold">评论分数阈值</string>
@@ -456,6 +457,7 @@
<string name="toplist">排行</string>
<string name="tag_vote_down">不,这不是</string>
<string name="tag_vote_up">是的,这很正确</string>
<string name="tag_vote_withdraw">撤回投票</string>
<string name="tag_vote_successfully">投票成功</string>
<string name="delete_search_history">从搜索记录中删除“%s”?</string>
<string name="action_add_tag">添加标签</string>
+2
View File
@@ -316,6 +316,7 @@
<string name="settings_eh_show_jpn_title_summary">Require enabling Japanese Title in Settings on EHentai website</string>
<string name="settings_eh_show_gallery_pages">Show gallery pages</string>
<string name="settings_eh_show_gallery_pages_summary">Display the number of pages in the gallery list</string>
<string name="settings_eh_show_vote_status">Show tag vote status</string>
<string name="settings_eh_show_gallery_comments">Show gallery comments</string>
<string name="settings_eh_show_gallery_comments_summary">Show comments on the gallery details page</string>
<string name="settings_eh_show_gallery_comment_threshold">Comment score threshold</string>
@@ -473,6 +474,7 @@
<string name="toplist">Toplist</string>
<string name="tag_vote_down">Vote down</string>
<string name="tag_vote_up">Vote up</string>
<string name="tag_vote_withdraw">Withdraw vote</string>
<string name="tag_vote_successfully">Vote successfully</string>
<string name="delete_search_history">Delete \"%s\" from search history?</string>
<string name="action_add_tag">Add tag</string>