From aaf9bdd00ccd12f3e9245901e3fc3970ace0fc85 Mon Sep 17 00:00:00 2001
From: rebelonion <87634197+rebelonion@users.noreply.github.com>
Date: Wed, 14 Feb 2024 06:41:24 -0600
Subject: [PATCH] feat: creating, deleting comments | markdown, spoiler
comments
---
app/build.gradle | 16 +-
app/src/main/AndroidManifest.xml | 2 +-
app/src/main/java/ani/dantotsu/App.kt | 1 +
.../connections/anilist/AnilistQueries.kt | 1 +
.../connections/anilist/AnilistViewModel.kt | 2 +
.../dantotsu/connections/comments/Comments.kt | 116 --------
.../connections/comments/CommentsAPI.kt | 273 ++++++++++++++++++
.../ani/dantotsu/media/CommentsFragment.kt | 34 ---
.../dantotsu/media/anime/AnimeWatchAdapter.kt | 7 +-
.../dantotsu/media/comments/CommentItem.kt | 140 +++++++++
.../media/comments/CommentsFragment.kt | 170 +++++++++++
.../media/comments/SpoilerTextView.kt | 59 ++++
.../dantotsu/settings/saving/Preferences.kt | 1 +
app/src/main/res/layout/fragment_comments.xml | 4 +-
app/src/main/res/layout/item_comments.xml | 3 +-
15 files changed, 672 insertions(+), 157 deletions(-)
delete mode 100644 app/src/main/java/ani/dantotsu/connections/comments/Comments.kt
create mode 100644 app/src/main/java/ani/dantotsu/connections/comments/CommentsAPI.kt
delete mode 100644 app/src/main/java/ani/dantotsu/media/CommentsFragment.kt
create mode 100644 app/src/main/java/ani/dantotsu/media/comments/CommentItem.kt
create mode 100644 app/src/main/java/ani/dantotsu/media/comments/CommentsFragment.kt
create mode 100644 app/src/main/java/ani/dantotsu/media/comments/SpoilerTextView.kt
diff --git a/app/build.gradle b/app/build.gradle
index 39229933..c8a85389 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -126,7 +126,6 @@ dependencies {
// UI
implementation 'com.google.android.material:material:1.11.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.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
implementation 'com.alexvasilkov:gesture-views:2.8.3'
@@ -134,6 +133,21 @@ dependencies {
implementation 'androidx.paging:paging-runtime-ktx:3.2.1'
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
implementation 'me.xdrop:fuzzywuzzy:1.4.0'
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 183645ee..2c1be903 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -109,7 +109,7 @@
android:name=".others.imagesearch.ImageSearchActivity"
android:parentActivityName=".MainActivity" />
+ android:name=".media.comments.CommentsFragment" />
diff --git a/app/src/main/java/ani/dantotsu/App.kt b/app/src/main/java/ani/dantotsu/App.kt
index 5c11e74f..7861711b 100644
--- a/app/src/main/java/ani/dantotsu/App.kt
+++ b/app/src/main/java/ani/dantotsu/App.kt
@@ -8,6 +8,7 @@ import androidx.multidex.MultiDex
import androidx.multidex.MultiDexApplication
import ani.dantotsu.aniyomi.anime.custom.AppModule
import ani.dantotsu.aniyomi.anime.custom.PreferenceModule
+import ani.dantotsu.connections.comments.CommentsAPI
import ani.dantotsu.connections.crashlytics.CrashlyticsInterface
import ani.dantotsu.others.DisabledReports
import ani.dantotsu.parsers.AnimeSources
diff --git a/app/src/main/java/ani/dantotsu/connections/anilist/AnilistQueries.kt b/app/src/main/java/ani/dantotsu/connections/anilist/AnilistQueries.kt
index 92cfc52d..890e7132 100644
--- a/app/src/main/java/ani/dantotsu/connections/anilist/AnilistQueries.kt
+++ b/app/src/main/java/ani/dantotsu/connections/anilist/AnilistQueries.kt
@@ -42,6 +42,7 @@ class AnilistQueries {
PrefManager.setVal(PrefName.AnilistUserName, user.name)
Anilist.userid = user.id
+ PrefManager.setVal(PrefName.AnilistUserId, user.id.toString())
Anilist.username = user.name
Anilist.bg = user.bannerImage
Anilist.avatar = user.avatar?.medium
diff --git a/app/src/main/java/ani/dantotsu/connections/anilist/AnilistViewModel.kt b/app/src/main/java/ani/dantotsu/connections/anilist/AnilistViewModel.kt
index 95d84ec9..546049df 100644
--- a/app/src/main/java/ani/dantotsu/connections/anilist/AnilistViewModel.kt
+++ b/app/src/main/java/ani/dantotsu/connections/anilist/AnilistViewModel.kt
@@ -7,6 +7,7 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import ani.dantotsu.BuildConfig
import ani.dantotsu.R
+import ani.dantotsu.connections.comments.CommentsAPI
import ani.dantotsu.connections.discord.Discord
import ani.dantotsu.connections.mal.MAL
import ani.dantotsu.media.Media
@@ -36,6 +37,7 @@ suspend fun getUserId(context: Context, block: () -> Unit) {
if (MAL.token != null && !MAL.query.getUserData())
snackString(context.getString(R.string.error_loading_mal_user_data))
}
+ CommentsAPI.fetchAuthToken()
true
} else {
snackString(context.getString(R.string.error_loading_anilist_user_data))
diff --git a/app/src/main/java/ani/dantotsu/connections/comments/Comments.kt b/app/src/main/java/ani/dantotsu/connections/comments/Comments.kt
deleted file mode 100644
index 86e8b9ad..00000000
--- a/app/src/main/java/ani/dantotsu/connections/comments/Comments.kt
+++ /dev/null
@@ -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().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(json.text)
- } catch (e: Exception) {
- return@runBlocking
- }
- authToken = parsed.authToken
-
- println("comments: $json")
- println("comments: $authToken")
- }
- }
-
- private fun headerBuilder(): Map {
- 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
-)
\ No newline at end of file
diff --git a/app/src/main/java/ani/dantotsu/connections/comments/CommentsAPI.kt b/app/src/main/java/ani/dantotsu/connections/comments/CommentsAPI.kt
new file mode 100644
index 00000000..3d591d45
--- /dev/null
+++ b/app/src/main/java/ani/dantotsu/connections/comments/CommentsAPI.kt
@@ -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(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(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(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 {
+ return if (authToken != null) {
+ mapOf(
+ "appauth" to appSecret,
+ "Authorization" to authToken!!
+ )
+ } else {
+ mapOf(
+ "appauth" to appSecret,
+ )
+ }
+ }
+
+ private fun requestBuilder(): Requests {
+ return Requests(
+ Injekt.get().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,
+ @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 {
+ 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
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ani/dantotsu/media/CommentsFragment.kt b/app/src/main/java/ani/dantotsu/media/CommentsFragment.kt
deleted file mode 100644
index 644aac38..00000000
--- a/app/src/main/java/ani/dantotsu/media/CommentsFragment.kt
+++ /dev/null
@@ -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 {
- topMargin = statusBarHeight
- bottomMargin = navBarHeight
- }
- binding.commentUserAvatar.loadImage(Anilist.avatar)
- binding.commentTitle.text = "Work in progress"
- binding.commentSend.setOnClickListener {
- //TODO
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/ani/dantotsu/media/anime/AnimeWatchAdapter.kt b/app/src/main/java/ani/dantotsu/media/anime/AnimeWatchAdapter.kt
index ab94ab10..fb19ec39 100644
--- a/app/src/main/java/ani/dantotsu/media/anime/AnimeWatchAdapter.kt
+++ b/app/src/main/java/ani/dantotsu/media/anime/AnimeWatchAdapter.kt
@@ -18,7 +18,7 @@ import ani.dantotsu.*
import ani.dantotsu.databinding.DialogLayoutBinding
import ani.dantotsu.databinding.ItemAnimeWatchBinding
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.MediaDetailsActivity
import ani.dantotsu.media.SourceSearchDialogFragment
@@ -59,12 +59,13 @@ class AnimeWatchAdapter(
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val binding = holder.binding
_binding = binding
- //Comments
+ //CommentsAPI
binding.animeComments.visibility = View.VISIBLE
binding.animeComments.setOnClickListener {
startActivity(
fragment.requireContext(),
- Intent(fragment.requireContext(), CommentsFragment::class.java),
+ Intent(fragment.requireContext(), CommentsFragment::class.java)
+ .putExtra("mediaId", media.id),
null
)
}
diff --git a/app/src/main/java/ani/dantotsu/media/comments/CommentItem.kt b/app/src/main/java/ani/dantotsu/media/comments/CommentItem.kt
new file mode 100644
index 00000000..bd38d719
--- /dev/null
+++ b/app/src/main/java/ani/dantotsu/media/comments/CommentItem.kt
@@ -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() {
+
+ 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"
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ani/dantotsu/media/comments/CommentsFragment.kt b/app/src/main/java/ani/dantotsu/media/comments/CommentsFragment.kt
new file mode 100644
index 00000000..dcf9e283
--- /dev/null
+++ b/app/src/main/java/ani/dantotsu/media/comments/CommentsFragment.kt
@@ -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 {
+ 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 {
+ override fun onResourceReady(
+ resource: Any,
+ model: Any,
+ target: Target,
+ dataSource: DataSource,
+ isFirstResource: Boolean
+ ): Boolean {
+ if (resource is GifDrawable) {
+ resource.start()
+ }
+ return false
+ }
+
+ override fun onLoadFailed(
+ e: GlideException?,
+ model: Any?,
+ target: Target,
+ isFirstResource: Boolean
+ ): Boolean {
+ return false
+ }
+ })
+ }
+
+ override fun load(drawable: AsyncDrawable): RequestBuilder {
+ return requestManager.load(drawable.destination)
+ }
+ override fun cancel(target: Target<*>) {
+ requestManager.clear(target)
+ }
+ }))
+ .build()
+ return markwon
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ani/dantotsu/media/comments/SpoilerTextView.kt b/app/src/main/java/ani/dantotsu/media/comments/SpoilerTextView.kt
new file mode 100644
index 00000000..832330fc
--- /dev/null
+++ b/app/src/main/java/ani/dantotsu/media/comments/SpoilerTextView.kt
@@ -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()
+ 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
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ani/dantotsu/settings/saving/Preferences.kt b/app/src/main/java/ani/dantotsu/settings/saving/Preferences.kt
index 3810cf3f..b4da982c 100644
--- a/app/src/main/java/ani/dantotsu/settings/saving/Preferences.kt
+++ b/app/src/main/java/ani/dantotsu/settings/saving/Preferences.kt
@@ -160,6 +160,7 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files
DiscordAvatar(Pref(Location.Protected, String::class, "")),
AnilistToken(Pref(Location.Protected, String::class, "")),
AnilistUserName(Pref(Location.Protected, String::class, "")),
+ AnilistUserId(Pref(Location.Protected, String::class, "")),
MALCodeChallenge(Pref(Location.Protected, String::class, "")),
MALToken(Pref(Location.Protected, MAL.ResponseToken::class, "")),
}
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_comments.xml b/app/src/main/res/layout/fragment_comments.xml
index fbd07574..75b1dcc9 100644
--- a/app/src/main/res/layout/fragment_comments.xml
+++ b/app/src/main/res/layout/fragment_comments.xml
@@ -60,11 +60,13 @@
android:textSize="12sp"
android:fontFamily="@font/poppins_semi_bold"
android:hint="Add a comment..."
- android:inputType="text"
+ android:inputType="textMultiLine"
android:padding="8dp"
android:background="@drawable/card_outline"
android:layout_marginEnd="8dp"
android:layout_marginStart="8dp"
+ android:maxLength="300"
+ android:maxLines="15"
tools:ignore="HardcodedText"
android:autofillHints="The One Piece is real" />
diff --git a/app/src/main/res/layout/item_comments.xml b/app/src/main/res/layout/item_comments.xml
index fccadd26..6db2f6cb 100644
--- a/app/src/main/res/layout/item_comments.xml
+++ b/app/src/main/res/layout/item_comments.xml
@@ -57,7 +57,7 @@
-