PixelDroid-App-Android/app/src/main/java/org/pixeldroid/app/posts/StatusViewHolder.kt

757 lines
32 KiB
Kotlin

package org.pixeldroid.app.posts
import android.Manifest
import android.app.Activity
import android.app.AlertDialog
import android.content.Intent
import android.graphics.Typeface
import android.graphics.drawable.AnimatedVectorDrawable
import android.graphics.drawable.Drawable
import android.net.Uri
import android.text.method.LinkMovementMethod
import android.util.Log
import android.view.LayoutInflater
import android.view.Menu
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.recyclerview.widget.RecyclerView
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
import androidx.viewbinding.ViewBinding
import com.bumptech.glide.Glide
import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.request.target.CustomViewTarget
import com.bumptech.glide.request.transition.Transition
import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.google.android.material.snackbar.Snackbar
import com.karumi.dexter.Dexter
import com.karumi.dexter.listener.PermissionDeniedResponse
import com.karumi.dexter.listener.PermissionGrantedResponse
import com.karumi.dexter.listener.single.BasePermissionListener
import kotlinx.coroutines.launch
import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.AlbumImageViewBinding
import org.pixeldroid.app.databinding.OpenedAlbumBinding
import org.pixeldroid.app.databinding.PostFragmentBinding
import org.pixeldroid.app.posts.MediaViewerActivity.Companion.openActivity
import org.pixeldroid.app.utils.BlurHashDecoder
import org.pixeldroid.app.utils.api.PixelfedAPI
import org.pixeldroid.app.utils.api.objects.Attachment
import org.pixeldroid.app.utils.api.objects.Status
import org.pixeldroid.app.utils.api.objects.Status.Companion.POST_COMMENT_TAG
import org.pixeldroid.app.utils.api.objects.Status.Companion.POST_TAG
import org.pixeldroid.app.utils.api.objects.Status.Companion.VIEW_COMMENTS_TAG
import org.pixeldroid.app.utils.db.AppDatabase
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
import org.pixeldroid.app.utils.setProfileImageFromURL
import retrofit2.HttpException
import java.io.File
import java.io.IOException
import kotlin.math.roundToInt
/**
* View Holder for a [Status] RecyclerView list item.
*/
class StatusViewHolder(val binding: PostFragmentBinding) : RecyclerView.ViewHolder(binding.root) {
private var status: Status? = null
fun bind(status: Status?, pixelfedAPI: PixelfedAPIHolder, db: AppDatabase, lifecycleScope: LifecycleCoroutineScope, displayDimensionsInPx: Pair<Int, Int>, isActivity: Boolean = false) {
this.itemView.visibility = View.VISIBLE
this.status = status
val maxImageRatio: Float = status?.media_attachments?.maxOfOrNull {
if (it.meta?.original?.width == null || it.meta.original.height == null) {
1f
} else {
it.meta.original.width.toFloat() / it.meta.original.height.toFloat()
}
} ?: 1f
val (displayWidth, displayHeight) = displayDimensionsInPx
if (displayWidth / maxImageRatio > displayHeight * 3/4f) {
binding.postPager.layoutParams.width = ((displayHeight * 3 / 4f) * maxImageRatio).roundToInt()
binding.postPager.layoutParams.height = (displayHeight * 3 / 4f).toInt()
} else {
binding.postPager.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT
binding.postPager.layoutParams.height = (displayWidth / maxImageRatio).toInt()
}
//Setup the post layout
val picRequest = Glide.with(itemView).asDrawable().fitCenter()
val user = db.userDao().getActiveUser()!!
setupPost(picRequest, user.instance_uri, isActivity)
activateButtons(pixelfedAPI, db, lifecycleScope, isActivity)
}
private fun setupPost(
request: RequestBuilder<Drawable>,
domain: String,
isActivity: Boolean,
) {
//Setup username as a button that opens the profile
binding.username.apply {
text = status?.account?.getDisplayName() ?: ""
setTypeface(null, Typeface.BOLD)
setOnClickListener { status?.account?.openProfile(binding.root.context) }
}
binding.usernameDesc.apply {
text = status?.account?.getDisplayName() ?: ""
setTypeface(null, Typeface.BOLD)
}
binding.nlikes.apply {
text = status?.getNLikes(binding.root.context)
setTypeface(null, Typeface.BOLD)
}
binding.nshares.apply {
text = status?.getNShares(binding.root.context)
setTypeface(null, Typeface.BOLD)
}
//Convert the date to a readable string
setTextViewFromISO8601(
status?.created_at!!,
binding.postDate,
isActivity,
binding.root.context
)
binding.postDomain.text = status?.getStatusDomain(domain, binding.postDomain.context)
//Setup images
setProfileImageFromURL(
binding.root,
status?.getProfilePicUrl(),
binding.profilePic
)
binding.profilePic.setOnClickListener { status?.account?.openProfile(binding.root.context) }
//Setup post pic only if there are media attachments
if(!status?.media_attachments.isNullOrEmpty()) {
setupPostPics(binding, request)
} else {
binding.postPager.visibility = View.GONE
binding.postIndicator.visibility = View.GONE
}
}
private fun setupPostPics(
binding: PostFragmentBinding,
request: RequestBuilder<Drawable>,
) {
// Standard layout
binding.postPager.visibility = View.VISIBLE
//Attach the given tabs to the view pager
binding.postPager.adapter = AlbumViewPagerAdapter(status?.media_attachments ?: emptyList(), status?.sensitive, false)
if((status?.media_attachments?.size ?: 0) > 1) {
binding.postIndicator.setViewPager(binding.postPager)
binding.postIndicator.visibility = View.VISIBLE
} else {
binding.postIndicator.visibility = View.GONE
}
if (status?.sensitive == true) {
setupSensitiveLayout()
} else {
// GONE is the default, but have to set it again because of how RecyclerViews work
binding.sensitiveWarning.visibility = View.GONE
}
}
private fun setupSensitiveLayout() {
// Set dark layout and warning message
binding.sensitiveWarning.visibility = View.VISIBLE
//binding.postPicture.colorFilter = ColorMatrixColorFilter(censorMatrix)
fun uncensorPicture(binding: PostFragmentBinding) {
binding.sensitiveWarning.visibility = View.GONE
(binding.postPager.adapter as AlbumViewPagerAdapter).uncensor()
}
binding.sensitiveWarning.setOnClickListener {
uncensorPicture(binding)
}
}
private fun setDescription(
apiHolder: PixelfedAPIHolder,
lifecycleScope: LifecycleCoroutineScope,
) {
binding.description.apply {
if (status?.content.isNullOrBlank()) {
visibility = View.GONE
} else {
text = parseHTMLText(
status?.content.orEmpty(),
status?.mentions,
apiHolder,
binding.root.context,
lifecycleScope
)
movementMethod = LinkMovementMethod.getInstance()
}
}
}
//region buttons
private fun activateButtons(
apiHolder: PixelfedAPIHolder,
db: AppDatabase,
lifecycleScope: LifecycleCoroutineScope,
isActivity: Boolean,
){
//Set the special HTML text
setDescription(apiHolder, lifecycleScope)
//Activate onclickListeners
activateLiker(
apiHolder, status?.favourited ?: false, lifecycleScope
)
activateReblogger(
apiHolder, status?.reblogged ?: false, lifecycleScope
)
if(isActivity){
binding.commenter.visibility = View.INVISIBLE
}
else {
binding.commenter.setOnClickListener {
lifecycleScope.launchWhenCreated {
//Open status in activity
val intent = Intent(it.context, PostActivity::class.java)
intent.putExtra(POST_TAG, status)
intent.putExtra(POST_COMMENT_TAG, true)
it.context.startActivity(intent)
}
}
}
showComments(lifecycleScope, isActivity)
activateMoreButton(apiHolder, db, lifecycleScope)
}
private fun activateReblogger(
apiHolder: PixelfedAPIHolder,
isReblogged: Boolean,
lifecycleScope: LifecycleCoroutineScope,
) {
binding.reblogger.apply {
//Set initial button state
isChecked = isReblogged
//Activate the button
setEventListener { _, buttonState ->
lifecycleScope.launchWhenCreated {
val api: PixelfedAPI = apiHolder.api ?: apiHolder.setToCurrentUser()
if (buttonState) {
// Button is active
undoReblogPost(api)
} else {
// Button is inactive
reblogPost(api)
}
}
//show animation or not?
true
}
}
}
private suspend fun reblogPost(api: PixelfedAPI) {
//Call the api function
status?.id?.let {
try {
val resp = api.reblogStatus(it)
//Update shown share count
binding.nshares.text = resp.getNShares(binding.root.context)
binding.reblogger.isChecked = resp.reblogged!!
} catch (exception: HttpException) {
Log.e("RESPONSE_CODE", exception.code().toString())
binding.reblogger.isChecked = false
} catch (exception: IOException) {
Log.e("REBLOG ERROR", exception.toString())
binding.reblogger.isChecked = false
}
}
}
private suspend fun undoReblogPost(api: PixelfedAPI) {
//Call the api function
status?.id?.let {
try {
val resp = api.undoReblogStatus(it)
//Update shown share count
binding.nshares.text = resp.getNShares(binding.root.context)
binding.reblogger.isChecked = resp.reblogged!!
} catch (exception: HttpException) {
Log.e("RESPONSE_CODE", exception.code().toString())
binding.reblogger.isChecked = true
} catch (exception: IOException) {
Log.e("REBLOG ERROR", exception.toString())
binding.reblogger.isChecked = true
}
}
}
private suspend fun bookmarkPost(api: PixelfedAPI, db: AppDatabase, menu: Menu, bookmarked: Boolean) : Boolean? {
//Call the api function
status?.id?.let { id ->
try {
if(bookmarked) {
api.bookmarkStatus(id)
} else {
api.undoBookmarkStatus(id)
}
val user = db.userDao().getActiveUser()!!
db.homePostDao().bookmarkStatus(id, user.user_id, user.instance_uri, bookmarked)
db.publicPostDao().bookmarkStatus(id, user.user_id, user.instance_uri, bookmarked)
menu.setGroupVisible(R.id.post_more_menu_group_bookmark, !bookmarked)
menu.setGroupVisible(R.id.post_more_menu_group_unbookmark, bookmarked)
return bookmarked
} catch (exception: HttpException) {
Toast.makeText(
binding.root.context,
binding.root.context.getString(R.string.bookmark_post_failed_error, exception.code()),
Toast.LENGTH_SHORT
).show()
} catch (exception: IOException) {
Toast.makeText(
binding.root.context,
binding.root.context.getString(R.string.bookmark_post_failed_io_except),
Toast.LENGTH_SHORT
).show()
}
}
return null
}
private fun activateMoreButton(apiHolder: PixelfedAPIHolder, db: AppDatabase, lifecycleScope: LifecycleCoroutineScope){
var bookmarked: Boolean? = null
binding.statusMore.setOnClickListener {
PopupMenu(it.context, it).apply {
setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.post_more_menu_report -> {
val intent = Intent(it.context, ReportActivity::class.java)
intent.putExtra(Status.POST_TAG, status)
ContextCompat.startActivity(it.context, intent, null)
true
}
R.id.post_more_menu_share_link -> {
val share = Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, status?.uri)
type = "text/plain"
putExtra(Intent.EXTRA_TITLE, status?.content)
}, null)
ContextCompat.startActivity(it.context, share, null)
true
}
R.id.post_more_menu_bookmark -> {
lifecycleScope.launch {
bookmarked = bookmarkPost(apiHolder.api ?: apiHolder.setToCurrentUser(), db, menu, true)
}
true
}
R.id.post_more_menu_unbookmark -> {
lifecycleScope.launch {
bookmarked = bookmarkPost(apiHolder.api ?: apiHolder.setToCurrentUser(), db, menu, false)
}
true
}
R.id.post_more_menu_save_to_gallery -> {
Dexter.withContext(binding.root.context)
.withPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
.withListener(object : BasePermissionListener() {
override fun onPermissionDenied(p0: PermissionDeniedResponse?) {
Toast.makeText(
binding.root.context,
binding.root.context.getString(R.string.write_permission_download_pic),
Toast.LENGTH_SHORT
).show()
}
override fun onPermissionGranted(p0: PermissionGrantedResponse?) {
status?.downloadImage(
binding.root.context,
status?.media_attachments?.getOrNull(binding.postPager.currentItem)?.url
?: "",
binding.root
)
}
}).check()
true
}
R.id.post_more_menu_share_picture -> {
Dexter.withContext(binding.root.context)
.withPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
.withListener(object : BasePermissionListener() {
override fun onPermissionDenied(p0: PermissionDeniedResponse?) {
Toast.makeText(
binding.root.context,
binding.root.context.getString(R.string.write_permission_share_pic),
Toast.LENGTH_SHORT
).show()
}
override fun onPermissionGranted(p0: PermissionGrantedResponse?) {
status?.downloadImage(
binding.root.context,
status?.media_attachments?.getOrNull(binding.postPager.currentItem)?.url
?: "",
binding.root,
share = true,
)
}
}).check()
true
}
R.id.post_more_menu_delete -> {
val builder = AlertDialog.Builder(binding.root.context)
builder.apply {
setMessage(R.string.delete_dialog)
setPositiveButton(android.R.string.ok) { _, _ ->
lifecycleScope.launch {
val user = db.userDao().getActiveUser()!!
status?.id?.let { id ->
db.homePostDao().delete(id, user.user_id, user.instance_uri)
db.publicPostDao().delete(id, user.user_id, user.instance_uri)
try {
val api = apiHolder.api ?: apiHolder.setToCurrentUser()
api.deleteStatus(id)
binding.root.visibility = View.GONE
} catch (exception: HttpException) {
Toast.makeText(
binding.root.context,
binding.root.context.getString(R.string.delete_post_failed_error, exception.code()),
Toast.LENGTH_SHORT
).show()
} catch (exception: IOException) {
Toast.makeText(
binding.root.context,
binding.root.context.getString(R.string.delete_post_failed_io_except),
Toast.LENGTH_SHORT
).show()
}
}
}
}
setNegativeButton(android.R.string.cancel) { _, _ -> }
show()
}
true
}
else -> false
}
}
inflate(R.menu.post_more_menu)
if(bookmarked == true || status?.bookmarked == true) {
menu.setGroupVisible(R.id.post_more_menu_group_bookmark, false)
} else if(bookmarked == false || status?.bookmarked != true) {
menu.setGroupVisible(R.id.post_more_menu_group_unbookmark, false)
}
if(status?.media_attachments.isNullOrEmpty()) {
//make sure to disable image-related things if there aren't any
menu.setGroupVisible(R.id.post_more_group_picture, false)
}
if(status?.account?.id == db.userDao().getActiveUser()!!.user_id){
// Enable deleting post if it's the user's
menu.setGroupVisible(R.id.post_more_menu_group_delete, true)
// And disable reporting your own post (just delete it if you don't like it :P)
menu.setGroupVisible(R.id.post_more_menu_group_report, false)
}
show()
}
}
}
private fun activateLiker(
apiHolder: PixelfedAPIHolder,
isLiked: Boolean,
lifecycleScope: LifecycleCoroutineScope,
) {
binding.liker.apply {
//Set initial state
isChecked = isLiked
//Activate the liker
setEventListener { _, buttonState ->
lifecycleScope.launchWhenCreated {
val api: PixelfedAPI = apiHolder.api ?: apiHolder.setToCurrentUser()
if (buttonState) {
// Button is active, unlike
unLikePostCall(api)
} else {
// Button is inactive, like
likePostCall(api)
}
}
//show animation or not?
true
}
}
//Activate tap interactions (double and single)
binding.postPagerHost.doubleTapCallback = {
lifecycleScope.launchWhenCreated {
//Check that the post isn't hidden
if (binding.sensitiveWarning.visibility == View.GONE) {
val api: PixelfedAPI = apiHolder.api ?: apiHolder.setToCurrentUser()
if (binding.liker.isChecked) {
// Button is active, unlike
binding.liker.isChecked = false
unLikePostCall(api)
} else {
// Button is inactive, like
binding.liker.playAnimation()
binding.liker.isChecked = true
binding.likeAnimation.animateView()
likePostCall(api)
}
}
}
}
status?.media_attachments?.let { binding.postPagerHost.images = ArrayList(it) }
}
private fun ImageView.animateView() {
visibility = View.VISIBLE
when (val drawable = drawable) {
is AnimatedVectorDrawableCompat -> {
drawable.start()
}
is AnimatedVectorDrawable -> {
drawable.start()
}
}
}
private suspend fun likePostCall(api: PixelfedAPI) {
//Call the api function
status?.id?.let {
try {
val resp = api.likePost(it)
//Update shown like count and internal like toggle
binding.nlikes.text = resp.getNLikes(binding.root.context)
binding.liker.isChecked = resp.favourited ?: false
} catch (exception: IOException) {
Log.e("LIKE ERROR", exception.toString())
binding.liker.isChecked = false
} catch (exception: HttpException) {
Log.e("RESPONSE_CODE", exception.code().toString())
binding.liker.isChecked = false
}
}
}
private suspend fun unLikePostCall(api: PixelfedAPI) {
//Call the api function
status?.id?.let {
try {
val resp = api.unlikePost(it)
//Update shown like count and internal like toggle
binding.nlikes.text = resp.getNLikes(binding.root.context)
binding.liker.isChecked = resp.favourited ?: false
} catch (exception: IOException) {
Log.e("UNLIKE ERROR", exception.toString())
binding.liker.isChecked = true
} catch (exception: HttpException) {
Log.e("RESPONSE_CODE", exception.code().toString())
binding.liker.isChecked = true
}
}
}
//endregion
private fun showComments(
lifecycleScope: LifecycleCoroutineScope,
isActivity: Boolean,
) {
//Show number of comments on the post
if (status?.replies_count == 0) {
binding.viewComments.text = binding.root.context.getString(R.string.NoCommentsToShow)
} else {
binding.viewComments.apply {
text = resources.getQuantityString(R.plurals.number_comments,
status?.replies_count ?: 0,
status?.replies_count ?: 0
)
if(!isActivity) {
setOnClickListener {
lifecycleScope.launchWhenCreated {
//Open status in activity
val intent = Intent(context, PostActivity::class.java)
intent.putExtra(POST_TAG, status)
intent.putExtra(VIEW_COMMENTS_TAG, true)
context.startActivity(intent)
}
}
}
}
}
}
companion object {
fun create(parent: ViewGroup): StatusViewHolder {
val itemBinding = PostFragmentBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)
return StatusViewHolder(itemBinding)
}
}
}
class AlbumViewPagerAdapter(
private val media_attachments: List<Attachment>, private var sensitive: Boolean?,
private val opened: Boolean,
) :
RecyclerView.Adapter<AlbumViewPagerAdapter.ViewHolder>() {
private var isActionBarHidden: Boolean = false
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return if(!opened) ViewHolderClosed(AlbumImageViewBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)) else ViewHolderOpen(OpenedAlbumBinding.inflate(
LayoutInflater.from(parent.context), parent, false
))
}
override fun getItemCount() = media_attachments.size
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
media_attachments[position].apply {
val video = type == Attachment.AttachmentType.video
val blurhashBitMap = blurhash?.let {
BlurHashDecoder.blurHashBitmap(
holder.binding.root.resources,
it,
meta?.original?.width,
meta?.original?.height
)
}
if (sensitive == false) {
val imageUrl = if(video) preview_url else url
if(opened){
Glide.with(holder.binding.root)
.download(GlideUrl(imageUrl))
.into(object : CustomViewTarget<SubsamplingScaleImageView, File>((holder.image as SubsamplingScaleImageView)) {
override fun onResourceReady(resource: File, t: Transition<in File>?) =
view.setImage(ImageSource.uri(Uri.fromFile(resource)))
override fun onLoadFailed(errorDrawable: Drawable?) {}
override fun onResourceCleared(placeholder: Drawable?) {}
})
(holder.image as SubsamplingScaleImageView).apply {
setMinimumDpi(80)
setDoubleTapZoomDpi(240)
resetScaleAndCenter()
}
holder.image.setOnClickListener {
val windowInsetsController = WindowCompat.getInsetsController((it.context as Activity).window, it)
// Configure the behavior of the hidden system bars
if (isActionBarHidden) {
windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
// Hide both the status bar and the navigation bar
(it.context as AppCompatActivity).supportActionBar?.show()
windowInsetsController.show(WindowInsetsCompat.Type.systemBars())
isActionBarHidden = false
} else {
// Configure the behavior of the hidden system bars
windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
// Hide both the status bar and the navigation bar
(it.context as AppCompatActivity).supportActionBar?.hide()
windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
isActionBarHidden = true
}
}
}
else Glide.with(holder.binding.root)
.asDrawable().fitCenter()
.placeholder(blurhashBitMap)
.load(imageUrl).into(holder.image as ImageView)
} else if(!opened){
Glide.with(holder.binding.root)
.asDrawable().fitCenter()
.load(blurhashBitMap).into(holder.image as ImageView)
}
holder.videoPlayButton.visibility = if(video) View.VISIBLE else View.GONE
if(video && (opened || media_attachments.size == 1)){
holder.videoPlayButton.setOnClickListener {
openActivity(holder.binding.root.context, url, description)
}
holder.image.setOnClickListener {
openActivity(holder.binding.root.context, url, description)
}
}
val description = description
.orEmpty()
.ifEmpty { holder.binding.root.context.getString(R.string.no_description) }
holder.image.setOnLongClickListener {
Snackbar.make(it, description, Snackbar.LENGTH_SHORT).show()
true
}
holder.image.contentDescription = description
}
}
fun uncensor(){
sensitive = false
notifyDataSetChanged()
}
abstract class ViewHolder(open val binding: ViewBinding) : RecyclerView.ViewHolder(binding.root){
abstract val image: View
abstract val videoPlayButton: ImageView
}
class ViewHolderOpen(override val binding: OpenedAlbumBinding) : ViewHolder(binding) {
override val image: SubsamplingScaleImageView = binding.imageImageView
override val videoPlayButton: ImageView = binding.videoPlayButton
}
class ViewHolderClosed(override val binding: AlbumImageViewBinding) : ViewHolder(binding) {
override val image: ImageView = binding.imageImageView
override val videoPlayButton: ImageView = binding.videoPlayButton
}
}