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

@ -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.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
)
}

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