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 @@ -