feat: creating, deleting comments | markdown, spoiler comments

This commit is contained in:
rebelonion 2024-02-14 06:41:24 -06:00
parent 129adc5825
commit aaf9bdd00c
15 changed files with 672 additions and 157 deletions

View file

@ -126,7 +126,6 @@ dependencies {
// UI // UI
implementation 'com.google.android.material:material:1.11.0' implementation 'com.google.android.material:material:1.11.0'
implementation 'nl.joery.animatedbottombar:library:1.1.0' implementation 'nl.joery.animatedbottombar:library:1.1.0'
implementation 'io.noties.markwon:core:4.6.2'
implementation 'com.flaviofaria:kenburnsview:1.0.7' implementation 'com.flaviofaria:kenburnsview:1.0.7'
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0' implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
implementation 'com.alexvasilkov:gesture-views:2.8.3' implementation 'com.alexvasilkov:gesture-views:2.8.3'
@ -134,6 +133,21 @@ dependencies {
implementation 'androidx.paging:paging-runtime-ktx:3.2.1' implementation 'androidx.paging:paging-runtime-ktx:3.2.1'
implementation 'com.github.eltos:simpledialogfragments:v3.7' implementation 'com.github.eltos:simpledialogfragments:v3.7'
// Markwon
ext.markwon_version = '4.6.2'
implementation "io.noties.markwon:core:$markwon_version"
implementation "io.noties.markwon:editor:$markwon_version"
implementation "io.noties.markwon:ext-strikethrough:$markwon_version"
implementation "io.noties.markwon:ext-tables:$markwon_version"
implementation "io.noties.markwon:ext-tasklist:$markwon_version"
implementation "io.noties.markwon:html:$markwon_version"
implementation "io.noties.markwon:image-glide:$markwon_version"
// Groupie
ext.groupie_version = '2.10.1'
implementation "com.github.lisawray.groupie:groupie:$groupie_version"
implementation "com.github.lisawray.groupie:groupie-viewbinding:$groupie_version"
// string matching // string matching
implementation 'me.xdrop:fuzzywuzzy:1.4.0' implementation 'me.xdrop:fuzzywuzzy:1.4.0'

View file

@ -109,7 +109,7 @@
android:name=".others.imagesearch.ImageSearchActivity" android:name=".others.imagesearch.ImageSearchActivity"
android:parentActivityName=".MainActivity" /> android:parentActivityName=".MainActivity" />
<activity <activity
android:name=".media.CommentsFragment" /> android:name=".media.comments.CommentsFragment" />
<activity <activity
android:name=".media.SearchActivity" android:name=".media.SearchActivity"
android:parentActivityName=".MainActivity" /> android:parentActivityName=".MainActivity" />

View file

@ -8,6 +8,7 @@ import androidx.multidex.MultiDex
import androidx.multidex.MultiDexApplication import androidx.multidex.MultiDexApplication
import ani.dantotsu.aniyomi.anime.custom.AppModule import ani.dantotsu.aniyomi.anime.custom.AppModule
import ani.dantotsu.aniyomi.anime.custom.PreferenceModule import ani.dantotsu.aniyomi.anime.custom.PreferenceModule
import ani.dantotsu.connections.comments.CommentsAPI
import ani.dantotsu.connections.crashlytics.CrashlyticsInterface import ani.dantotsu.connections.crashlytics.CrashlyticsInterface
import ani.dantotsu.others.DisabledReports import ani.dantotsu.others.DisabledReports
import ani.dantotsu.parsers.AnimeSources import ani.dantotsu.parsers.AnimeSources

View file

@ -42,6 +42,7 @@ class AnilistQueries {
PrefManager.setVal(PrefName.AnilistUserName, user.name) PrefManager.setVal(PrefName.AnilistUserName, user.name)
Anilist.userid = user.id Anilist.userid = user.id
PrefManager.setVal(PrefName.AnilistUserId, user.id.toString())
Anilist.username = user.name Anilist.username = user.name
Anilist.bg = user.bannerImage Anilist.bg = user.bannerImage
Anilist.avatar = user.avatar?.medium Anilist.avatar = user.avatar?.medium

View file

@ -7,6 +7,7 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import ani.dantotsu.BuildConfig import ani.dantotsu.BuildConfig
import ani.dantotsu.R import ani.dantotsu.R
import ani.dantotsu.connections.comments.CommentsAPI
import ani.dantotsu.connections.discord.Discord import ani.dantotsu.connections.discord.Discord
import ani.dantotsu.connections.mal.MAL import ani.dantotsu.connections.mal.MAL
import ani.dantotsu.media.Media import ani.dantotsu.media.Media
@ -36,6 +37,7 @@ suspend fun getUserId(context: Context, block: () -> Unit) {
if (MAL.token != null && !MAL.query.getUserData()) if (MAL.token != null && !MAL.query.getUserData())
snackString(context.getString(R.string.error_loading_mal_user_data)) snackString(context.getString(R.string.error_loading_mal_user_data))
} }
CommentsAPI.fetchAuthToken()
true true
} else { } else {
snackString(context.getString(R.string.error_loading_anilist_user_data)) snackString(context.getString(R.string.error_loading_anilist_user_data))

View file

@ -1,116 +0,0 @@
package ani.dantotsu.connections.comments
import android.security.keystore.KeyProperties
import android.util.Base64
import ani.dantotsu.BuildConfig
import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName
import com.lagradost.nicehttp.Requests
import eu.kanade.tachiyomi.network.NetworkHelper
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.FormBody
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.security.MessageDigest
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
class Comments {
val address: String = "http://10.0.2.2:8081"
val appSecret = BuildConfig.APP_SECRET
val requestClient = Injekt.get<NetworkHelper>().client
var authToken: String? = null
fun run() {
runBlocking {
val request = Requests(
requestClient,
headerBuilder()
)
.get(address)
println("comments: $request")
}
}
fun getCommentsForId(id: Int) {
val url = "$address/comments/$id"
runBlocking {
val request = Requests(
requestClient,
headerBuilder()
)
.get(url)
println("comments: $request")
}
}
fun fetchAuthToken() {
val url = "$address/authenticate"
//test user id = asdf
//test username = test
val user = User(generateUserId() ?: return, "rebel onion")
val body: FormBody = FormBody.Builder()
.add("user_id", user.id)
.add("username", user.username)
.build()
runBlocking {
val request = Requests(
requestClient,
headerBuilder()
)
val json = request.post(url, requestBody = body)
if (!json.text.startsWith("{")) return@runBlocking
val parsed = try {
Json.decodeFromString<Auth>(json.text)
} catch (e: Exception) {
return@runBlocking
}
authToken = parsed.authToken
println("comments: $json")
println("comments: $authToken")
}
}
private fun headerBuilder(): Map<String, String> {
return if (authToken != null) {
mapOf(
"appauth" to appSecret,
"Authorization" to authToken!!
)
} else {
mapOf(
"appauth" to appSecret,
)
}
}
private fun generateUserId(): String? {
val anilistId = PrefManager.getVal(PrefName.AnilistToken, null as String?) ?: return null
val userIdEncryptKey = BuildConfig.USER_ID_ENCRYPT_KEY
val keySpec = SecretKeySpec(userIdEncryptKey.toByteArray(), KeyProperties.KEY_ALGORITHM_AES)
val cipher = Cipher.getInstance("${KeyProperties.KEY_ALGORITHM_AES}/${KeyProperties.BLOCK_MODE_CBC}/${KeyProperties.ENCRYPTION_PADDING_PKCS7}")
cipher.init(Cipher.ENCRYPT_MODE, keySpec)
val encrypted = cipher.doFinal(anilistId.toByteArray())
val base = Base64.encodeToString(encrypted, Base64.NO_WRAP)
val bytes = MessageDigest.getInstance("SHA-256").digest(base.toByteArray())
return bytes.joinToString("") { "%02x".format(it) }
}
}
@Serializable
data class Auth(
@SerialName("authToken")
val authToken: String
)
@Serializable
data class User(
@SerialName("user_id")
val id: String,
@SerialName("username")
val username: String
)

View file

@ -0,0 +1,273 @@
package ani.dantotsu.connections.comments
import android.annotation.SuppressLint
import android.security.keystore.KeyProperties
import ani.dantotsu.BuildConfig
import ani.dantotsu.connections.anilist.Anilist
import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.snackString
import com.lagradost.nicehttp.Requests
import eu.kanade.tachiyomi.network.NetworkHelper
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json
import okhttp3.FormBody
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
object CommentsAPI {
val address: String = "http://10.0.2.2:8081"
val appSecret = BuildConfig.APP_SECRET
var authToken: String? = null
var userId: String? = null
var isBanned: Boolean = false
var isAdmin: Boolean = false
var isMod: Boolean = false
suspend fun getCommentsForId(id: Int, page: Int = 1): CommentResponse? {
val url = "$address/comments/$id/$page"
val request = requestBuilder()
val json = request.get(url)
if (!json.text.startsWith("{")) return null
val parsed = try {
Json.decodeFromString<CommentResponse>(json.text)
} catch (e: Exception) {
println("comments: $e")
return null
}
return parsed
}
suspend fun vote(commentId: Int, voteType: Int): Boolean {
val url = "$address/comments/vote/$commentId/$voteType"
val request = requestBuilder()
val json = request.post(url)
val res = json.code == 200
if (!res) {
errorReason(json.code)
}
return res
}
suspend fun comment(mediaId: Int, parentCommentId: Int?, content: String): Comment? {
val url = "$address/comments"
val body = FormBody.Builder()
.add("user_id", userId ?: return null)
.add("media_id", mediaId.toString())
.add("content", content)
parentCommentId?.let {
body.add("parent_comment_id", it.toString())
}
val request = requestBuilder()
val json = request.post(url, requestBody = body.build())
val res = json.code == 200
if (!res) {
errorReason(json.code)
return null
}
val parsed = try {
Json.decodeFromString<ReturnedComment>(json.text)
} catch (e: Exception) {
println("comment: $e")
snackString("Failed to parse comment")
return null
}
return Comment(
parsed.id,
parsed.userId,
parsed.mediaId,
parsed.parentCommentId,
parsed.content,
parsed.timestamp,
parsed.deleted,
0,
0,
null,
Anilist.username ?: "",
Anilist.avatar
)
}
suspend fun deleteComment(commentId: Int): Boolean {
val url = "$address/comments/$commentId"
val request = requestBuilder()
val json = request.delete(url)
val res = json.code == 200
if (!res) {
errorReason(json.code)
}
return res
}
suspend fun fetchAuthToken() {
val url = "$address/authenticate"
userId = generateUserId()
val user = User(userId ?: return, Anilist.username ?: "")
val body: FormBody = FormBody.Builder()
.add("user_id", user.id)
.add("username", user.username)
.add("profile_picture_url", Anilist.avatar ?: "")
.build()
val request = requestBuilder()
val json = request.post(url, requestBody = body)
if (!json.text.startsWith("{")) return
val parsed = try {
Json.decodeFromString<AuthResponse>(json.text)
} catch (e: Exception) {
println("comment: $e")
return
}
authToken = parsed.authToken
userId = parsed.user.id
isBanned = parsed.user.isBanned ?: false
isAdmin = parsed.user.isAdmin ?: false
isMod = parsed.user.isMod ?: false
}
private fun headerBuilder(): Map<String, String> {
return if (authToken != null) {
mapOf(
"appauth" to appSecret,
"Authorization" to authToken!!
)
} else {
mapOf(
"appauth" to appSecret,
)
}
}
private fun requestBuilder(): Requests {
return Requests(
Injekt.get<NetworkHelper>().client,
headerBuilder()
)
}
private fun errorReason(code: Int) {
val error = when (code) {
429 -> "Rate limited. :("
else -> "Failed to connect"
}
snackString("Error $code: $error")
}
@SuppressLint("GetInstance")
private fun generateUserId(): String? {
val anilistId = PrefManager.getVal(PrefName.AnilistUserId, null as String?) ?: return null
val userIdEncryptKey = BuildConfig.USER_ID_ENCRYPT_KEY
val keySpec = SecretKeySpec(userIdEncryptKey.toByteArray(), KeyProperties.KEY_ALGORITHM_AES)
val cipher = Cipher.getInstance("${KeyProperties.KEY_ALGORITHM_AES}/ECB/${KeyProperties.ENCRYPTION_PADDING_PKCS7}")
cipher.init(Cipher.ENCRYPT_MODE, keySpec)
val encrypted = cipher.doFinal(anilistId.toByteArray())
return encrypted.joinToString("") { "%02x".format(it) }
}
}
@Serializable
data class AuthResponse(
@SerialName("authToken")
val authToken: String,
@SerialName("user")
val user: User
)
@Serializable
data class User(
@SerialName("user_id")
val id: String,
@SerialName("username")
val username: String,
@SerialName("profile_picture_url")
val profilePictureUrl: String? = null,
@SerialName("is_banned")
@Serializable(with = NumericBooleanSerializer::class)
val isBanned: Boolean? = null,
@SerialName("is_mod")
@Serializable(with = NumericBooleanSerializer::class)
val isAdmin: Boolean? = null,
@SerialName("is_admin")
@Serializable(with = NumericBooleanSerializer::class)
val isMod: Boolean? = null
)
@Serializable
data class CommentResponse(
@SerialName("comments")
val comments: List<Comment>,
@SerialName("totalPages")
val totalPages: Int
)
@Serializable
data class Comment(
@SerialName("comment_id")
val commentId: Int,
@SerialName("user_id")
val userId: String,
@SerialName("media_id")
val mediaId: Int,
@SerialName("parent_comment_id")
val parentCommentId: Int?,
@SerialName("content")
val content: String,
@SerialName("timestamp")
val timestamp: String,
@SerialName("deleted")
@Serializable(with = NumericBooleanSerializer::class)
val deleted: Boolean?,
@SerialName("upvotes")
var upvotes: Int,
@SerialName("downvotes")
var downvotes: Int,
@SerialName("user_vote_type")
var userVoteType: Int?,
@SerialName("username")
val username: String,
@SerialName("profile_picture_url")
val profilePictureUrl: String?
)
@Serializable
data class ReturnedComment(
@SerialName("id")
var id: Int,
@SerialName("comment_id")
var commentId: Int?,
@SerialName("user_id")
val userId: String,
@SerialName("media_id")
val mediaId: Int,
@SerialName("parent_comment_id")
val parentCommentId: Int? = null,
@SerialName("content")
val content: String,
@SerialName("timestamp")
val timestamp: String,
@SerialName("deleted")
@Serializable(with = NumericBooleanSerializer::class)
val deleted: Boolean?,
)
object NumericBooleanSerializer : KSerializer<Boolean> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("NumericBoolean", PrimitiveKind.INT)
override fun serialize(encoder: Encoder, value: Boolean) {
encoder.encodeInt(if (value) 1 else 0)
}
override fun deserialize(decoder: Decoder): Boolean {
return decoder.decodeInt() != 0
}
}

View file

@ -1,34 +0,0 @@
package ani.dantotsu.media
import android.os.Bundle
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.updateLayoutParams
import ani.dantotsu.connections.anilist.Anilist
import ani.dantotsu.databinding.FragmentCommentsBinding
import ani.dantotsu.loadImage
import ani.dantotsu.navBarHeight
import ani.dantotsu.snackString
import ani.dantotsu.statusBarHeight
import ani.dantotsu.themes.ThemeManager
class CommentsFragment : AppCompatActivity(){
lateinit var binding: FragmentCommentsBinding
//Comments
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
ThemeManager(this).applyTheme()
binding = FragmentCommentsBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.commentsLayout.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = statusBarHeight
bottomMargin = navBarHeight
}
binding.commentUserAvatar.loadImage(Anilist.avatar)
binding.commentTitle.text = "Work in progress"
binding.commentSend.setOnClickListener {
//TODO
}
}
}

View file

@ -18,7 +18,7 @@ import ani.dantotsu.*
import ani.dantotsu.databinding.DialogLayoutBinding import ani.dantotsu.databinding.DialogLayoutBinding
import ani.dantotsu.databinding.ItemAnimeWatchBinding import ani.dantotsu.databinding.ItemAnimeWatchBinding
import ani.dantotsu.databinding.ItemChipBinding import ani.dantotsu.databinding.ItemChipBinding
import ani.dantotsu.media.CommentsFragment import ani.dantotsu.media.comments.CommentsFragment
import ani.dantotsu.media.Media import ani.dantotsu.media.Media
import ani.dantotsu.media.MediaDetailsActivity import ani.dantotsu.media.MediaDetailsActivity
import ani.dantotsu.media.SourceSearchDialogFragment import ani.dantotsu.media.SourceSearchDialogFragment
@ -59,12 +59,13 @@ class AnimeWatchAdapter(
override fun onBindViewHolder(holder: ViewHolder, position: Int) { override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val binding = holder.binding val binding = holder.binding
_binding = binding _binding = binding
//Comments //CommentsAPI
binding.animeComments.visibility = View.VISIBLE binding.animeComments.visibility = View.VISIBLE
binding.animeComments.setOnClickListener { binding.animeComments.setOnClickListener {
startActivity( startActivity(
fragment.requireContext(), fragment.requireContext(),
Intent(fragment.requireContext(), CommentsFragment::class.java), Intent(fragment.requireContext(), CommentsFragment::class.java)
.putExtra("mediaId", media.id),
null null
) )
} }

View file

@ -0,0 +1,140 @@
package ani.dantotsu.media.comments
import android.view.View
import ani.dantotsu.R
import ani.dantotsu.connections.comments.Comment
import ani.dantotsu.connections.comments.CommentsAPI
import ani.dantotsu.databinding.ItemCommentsBinding
import ani.dantotsu.loadImage
import ani.dantotsu.snackString
import com.xwray.groupie.GroupieAdapter
import com.xwray.groupie.Section
import com.xwray.groupie.viewbinding.BindableItem
import io.noties.markwon.Markwon
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.TimeZone
class CommentItem(private val comment: Comment,
private val markwon: Markwon,
private val section: Section
) : BindableItem<ItemCommentsBinding>() {
override fun bind(viewBinding: ItemCommentsBinding, position: Int) {
val isUserComment = CommentsAPI.userId == comment.userId
val node = markwon.parse(comment.content)
val spanned = markwon.render(node)
markwon.setParsedMarkdown(viewBinding.commentText, viewBinding.commentText.setSpoilerText(spanned, markwon))
viewBinding.commentDelete.visibility = if (isUserComment) View.VISIBLE else View.GONE
viewBinding.commentEdit.visibility = if (isUserComment) View.VISIBLE else View.GONE
viewBinding.commentReply.visibility = View.GONE //TODO: implement reply
viewBinding.commentTotalReplies.visibility = View.GONE //TODO: implement reply
viewBinding.commentReply.setOnClickListener {
}
viewBinding.commentDelete.setOnClickListener {
val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
scope.launch {
val success = CommentsAPI.deleteComment(comment.commentId)
if (success) {
snackString("Comment Deleted")
section.remove(this@CommentItem)
}
}
}
//fill the icon if the user has liked the comment
setVoteButtons(viewBinding)
viewBinding.commentUpVote.setOnClickListener {
val voteType = if (comment.userVoteType == 1) 0 else 1
val previousVoteType = comment.userVoteType
val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
scope.launch {
val success = CommentsAPI.vote(comment.commentId, voteType)
if (success) {
comment.userVoteType = voteType
if (previousVoteType == -1) {
comment.downvotes -= 1
}
comment.upvotes += if (voteType == 1) 1 else -1
notifyChanged()
}
}
}
viewBinding.commentDownVote.setOnClickListener {
val voteType = if (comment.userVoteType == -1) 0 else -1
val previousVoteType = comment.userVoteType
val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
scope.launch {
val success = CommentsAPI.vote(comment.commentId, voteType)
if (success) {
comment.userVoteType = voteType
if (previousVoteType == 1) {
comment.upvotes -= 1
}
comment.downvotes += if (voteType == -1) 1 else -1
notifyChanged()
}
}
}
viewBinding.commentTotalVotes.text = (comment.upvotes - comment.downvotes).toString()
viewBinding.commentUserAvatar
comment.profilePictureUrl?.let { viewBinding.commentUserAvatar.loadImage(it) }
viewBinding.commentUserName.text = comment.username
viewBinding.commentUserTime.text = formatTimestamp(comment.timestamp)
}
override fun getLayout(): Int {
return R.layout.item_comments
}
override fun initializeViewBinding(view: View): ItemCommentsBinding {
return ItemCommentsBinding.bind(view)
}
private fun setVoteButtons(viewBinding: ItemCommentsBinding) {
if (comment.userVoteType == 1) {
viewBinding.commentUpVote.setImageResource(R.drawable.ic_round_upvote_active_24)
viewBinding.commentDownVote.setImageResource(R.drawable.ic_round_upvote_inactive_24)
} else if (comment.userVoteType == -1) {
viewBinding.commentUpVote.setImageResource(R.drawable.ic_round_upvote_inactive_24)
viewBinding.commentDownVote.setImageResource(R.drawable.ic_round_upvote_active_24)
} else {
viewBinding.commentUpVote.setImageResource(R.drawable.ic_round_upvote_inactive_24)
viewBinding.commentDownVote.setImageResource(R.drawable.ic_round_upvote_inactive_24)
}
}
private fun formatTimestamp(timestamp: String): String {
return try {
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
dateFormat.timeZone = TimeZone.getTimeZone("UTC")
val parsedDate = dateFormat.parse(timestamp)
val currentDate = Date()
val diff = currentDate.time - (parsedDate?.time ?: 0)
val days = diff / (24 * 60 * 60 * 1000)
val hours = diff / (60 * 60 * 1000) % 24
val minutes = diff / (60 * 1000) % 60
return when {
days > 0 -> "$days days ago"
hours > 0 -> "$hours hours ago"
minutes > 0 -> "$minutes minutes ago"
else -> "just now"
}
} catch (e: Exception) {
"now"
}
}
}

View file

@ -0,0 +1,170 @@
package ani.dantotsu.media.comments
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import ani.dantotsu.connections.anilist.Anilist
import ani.dantotsu.connections.comments.Comment
import ani.dantotsu.connections.comments.CommentsAPI
import ani.dantotsu.databinding.FragmentCommentsBinding
import ani.dantotsu.databinding.ItemCommentsBinding
import ani.dantotsu.loadImage
import ani.dantotsu.navBarHeight
import ani.dantotsu.snackString
import ani.dantotsu.statusBarHeight
import ani.dantotsu.themes.ThemeManager
import com.bumptech.glide.Glide
import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.RequestManager
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.load.resource.gif.GifDrawable
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import com.xwray.groupie.GroupAdapter
import com.xwray.groupie.GroupieAdapter
import com.xwray.groupie.Section
import com.xwray.groupie.viewbinding.GroupieViewHolder
import io.noties.markwon.Markwon
import io.noties.markwon.SoftBreakAddsNewLinePlugin
import io.noties.markwon.editor.MarkwonEditor
import io.noties.markwon.editor.MarkwonEditorTextWatcher
import io.noties.markwon.ext.strikethrough.StrikethroughPlugin
import io.noties.markwon.ext.tables.TablePlugin
import io.noties.markwon.ext.tasklist.TaskListPlugin
import io.noties.markwon.html.HtmlPlugin
import io.noties.markwon.image.AsyncDrawable
import io.noties.markwon.image.glide.GlideImagesPlugin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class CommentsFragment : AppCompatActivity(){
lateinit var binding: FragmentCommentsBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
ThemeManager(this).applyTheme()
binding = FragmentCommentsBinding.inflate(layoutInflater)
setContentView(binding.root)
//get the media id from the intent
val mediaId = intent.getIntExtra("mediaId", -1)
if (mediaId == -1) {
snackString("Invalid Media ID")
finish()
}
val adapter = GroupieAdapter()
val section = Section()
val markwon = buildMarkwon()
binding.commentsLayout.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = statusBarHeight
bottomMargin = navBarHeight
}
binding.commentUserAvatar.loadImage(Anilist.avatar)
binding.commentTitle.text = "Work in progress"
val markwonEditor = MarkwonEditor.create(markwon)
binding.commentInput.addTextChangedListener(MarkwonEditorTextWatcher.withProcess(markwonEditor))
binding.commentSend.setOnClickListener {
if (CommentsAPI.isBanned) {
snackString("You are banned from commenting :(")
return@setOnClickListener
}
binding.commentInput.text.toString().let {
if (it.isNotEmpty()) {
binding.commentInput.text.clear()
lifecycleScope.launch {
val success = withContext(Dispatchers.IO) {
CommentsAPI.comment(mediaId, null, it)
}
if (success != null)
section.add(CommentItem(success, buildMarkwon(), section))
}
} else {
snackString("Comment cannot be empty")
}
}
}
binding.commentInput.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
}
override fun afterTextChanged(s: android.text.Editable?) {
if (binding.commentInput.text.length > 300) {
binding.commentInput.text.delete(300, binding.commentInput.text.length)
snackString("Comment cannot be longer than 300 characters")
}
}
})
binding.commentsList.adapter = adapter
binding.commentsList.layoutManager = LinearLayoutManager(this)
lifecycleScope.launch {
withContext(Dispatchers.IO) {
val comments = CommentsAPI.getCommentsForId(mediaId)
comments?.comments?.forEach {
withContext(Dispatchers.Main) {
section.add(CommentItem(it, buildMarkwon(), section))
}
}
}
adapter.add(section)
}
}
private fun buildMarkwon(): Markwon {
val markwon = Markwon.builder(this)
.usePlugin(SoftBreakAddsNewLinePlugin.create())
.usePlugin(StrikethroughPlugin.create())
.usePlugin(HtmlPlugin.create())
.usePlugin(TablePlugin.create(this))
.usePlugin(TaskListPlugin.create(this))
.usePlugin(GlideImagesPlugin.create(object : GlideImagesPlugin.GlideStore {
private val requestManager: RequestManager = Glide.with(this@CommentsFragment).apply {
addDefaultRequestListener(object : RequestListener<Any> {
override fun onResourceReady(
resource: Any,
model: Any,
target: Target<Any>,
dataSource: DataSource,
isFirstResource: Boolean
): Boolean {
if (resource is GifDrawable) {
resource.start()
}
return false
}
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Any>,
isFirstResource: Boolean
): Boolean {
return false
}
})
}
override fun load(drawable: AsyncDrawable): RequestBuilder<Drawable> {
return requestManager.load(drawable.destination)
}
override fun cancel(target: Target<*>) {
requestManager.clear(target)
}
}))
.build()
return markwon
}
}

View file

@ -0,0 +1,59 @@
package ani.dantotsu.media.comments
import android.content.Context
import android.text.SpannableString
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.util.AttributeSet
import android.view.View
import androidx.appcompat.widget.AppCompatTextView
import io.noties.markwon.Markwon
class SpoilerTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AppCompatTextView(context, attrs, defStyleAttr) {
private val replaceWith = ""
private var originalSpanned: SpannableStringBuilder? = null
fun setSpoilerText(text: Spanned, markwon: Markwon) : Spanned {
val pattern = Regex("\\|\\|(.*?)\\|\\|")
val matcher = pattern.toPattern().matcher(text)
val spannableBuilder = SpannableStringBuilder(text)
//remove the "||" from the text
val originalBuilder = SpannableStringBuilder(text)
originalSpanned = originalBuilder
val map = mutableMapOf<Int, Int>()
while (matcher.find()) {
val start = matcher.start()
val end = matcher.end()
map[start] = end
}
map.forEach { (start, end) ->
val replacement = replaceWith.repeat(end - start)
spannableBuilder.replace(start, end, replacement)
}
val spannableString = SpannableString(spannableBuilder)
map.forEach { (start, end) ->
val clickableSpan = object : ClickableSpan() {
override fun onClick(widget: View) {
markwon.setParsedMarkdown(this@SpoilerTextView, originalSpanned!!.delete(end - 2, end).delete(start, start + 2))
}
}
spannableString.setSpan(
clickableSpan,
start,
end,
SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
movementMethod = LinkMovementMethod.getInstance()
return spannableString
}
}

View file

@ -160,6 +160,7 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files
DiscordAvatar(Pref(Location.Protected, String::class, "")), DiscordAvatar(Pref(Location.Protected, String::class, "")),
AnilistToken(Pref(Location.Protected, String::class, "")), AnilistToken(Pref(Location.Protected, String::class, "")),
AnilistUserName(Pref(Location.Protected, String::class, "")), AnilistUserName(Pref(Location.Protected, String::class, "")),
AnilistUserId(Pref(Location.Protected, String::class, "")),
MALCodeChallenge(Pref(Location.Protected, String::class, "")), MALCodeChallenge(Pref(Location.Protected, String::class, "")),
MALToken(Pref(Location.Protected, MAL.ResponseToken::class, "")), MALToken(Pref(Location.Protected, MAL.ResponseToken::class, "")),
} }

View file

@ -60,11 +60,13 @@
android:textSize="12sp" android:textSize="12sp"
android:fontFamily="@font/poppins_semi_bold" android:fontFamily="@font/poppins_semi_bold"
android:hint="Add a comment..." android:hint="Add a comment..."
android:inputType="text" android:inputType="textMultiLine"
android:padding="8dp" android:padding="8dp"
android:background="@drawable/card_outline" android:background="@drawable/card_outline"
android:layout_marginEnd="8dp" android:layout_marginEnd="8dp"
android:layout_marginStart="8dp" android:layout_marginStart="8dp"
android:maxLength="300"
android:maxLines="15"
tools:ignore="HardcodedText" tools:ignore="HardcodedText"
android:autofillHints="The One Piece is real" /> android:autofillHints="The One Piece is real" />

View file

@ -57,7 +57,7 @@
</LinearLayout> </LinearLayout>
<TextView <ani.dantotsu.media.comments.SpoilerTextView
android:id="@+id/commentText" android:id="@+id/commentText"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -119,6 +119,7 @@
<LinearLayout <LinearLayout
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:orientation="vertical"> android:orientation="vertical">
<ImageView <ImageView
android:id="@+id/commentUpVote" android:id="@+id/commentUpVote"