/* Copyright 2019 Joel Pyska * * This file is a part of Tusky. * * This program is free software; you can redistribute it and/or modify it under the terms of the * GNU General Public License as published by the Free Software Foundation; either version 3 of the * License, or (at your option) any later version. * * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General * Public License for more details. * * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ package com.keylesspalace.tusky.components.search.fragments import android.Manifest import android.app.DownloadManager import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Environment import android.util.Log import android.view.View import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.PopupMenu import androidx.core.app.ActivityOptionsCompat import androidx.core.view.ViewCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LiveData import androidx.paging.PagedList import androidx.paging.PagedListAdapter import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import com.keylesspalace.tusky.* import com.keylesspalace.tusky.components.report.ReportActivity import com.keylesspalace.tusky.components.search.adapter.SearchStatusesAdapter import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.interfaces.AccountSelectionListener import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.NetworkState import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.StatusViewData import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from import com.uber.autodispose.autoDispose import io.reactivex.android.schedulers.AndroidSchedulers import kotlinx.android.synthetic.main.fragment_search.* import java.util.* open class SearchStatusesFragment : SearchFragment>(), StatusActionListener { override val networkStateRefresh: LiveData get() = viewModel.networkStateStatusRefresh override val networkState: LiveData get() = viewModel.networkStateStatus override val data: LiveData>> get() = viewModel.statuses override fun createAdapter(): PagedListAdapter, *> { val preferences = PreferenceManager.getDefaultSharedPreferences(searchRecyclerView.context) val useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false) val showBotOverlay = preferences.getBoolean("showBotOverlay", true) val animateAvatar = preferences.getBoolean("animateGifAvatars", false) searchRecyclerView.addItemDecoration(DividerItemDecoration(searchRecyclerView.context, DividerItemDecoration.VERTICAL)) searchRecyclerView.layoutManager = LinearLayoutManager(searchRecyclerView.context) return SearchStatusesAdapter(useAbsoluteTime, viewModel.mediaPreviewEnabled, showBotOverlay, animateAvatar, this) } override fun onContentHiddenChange(isShowing: Boolean, position: Int) { (adapter as? SearchStatusesAdapter)?.getItem(position)?.let { viewModel.contentHiddenChange(it, isShowing) } } override fun onReply(position: Int) { (adapter as? SearchStatusesAdapter)?.getItem(position)?.first?.let { status -> reply(status) } } override fun onFavourite(favourite: Boolean, position: Int) { (adapter as? SearchStatusesAdapter)?.getItem(position)?.let { status -> viewModel.favorite(status, favourite) } } override fun onQuote(position: Int) { (adapter as? SearchStatusesAdapter)?.getItem(position)?.first?.let { status -> quote(status) } } override fun onMore(view: View, position: Int) { (adapter as? SearchStatusesAdapter)?.getItem(position)?.first?.let { more(it, view, position) } } override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { (adapter as? SearchStatusesAdapter)?.getItem(position)?.first?.actionableStatus?.let { actionable -> when (actionable.attachments[attachmentIndex].type) { Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> { val attachments = AttachmentViewData.list(actionable) val intent = ViewMediaActivity.newIntent(context, attachments, attachmentIndex) if (view != null) { val url = actionable.attachments[attachmentIndex].url ViewCompat.setTransitionName(view, url) val options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(), view, url) startActivity(intent, options.toBundle()) } else { startActivity(intent) } } Attachment.Type.UNKNOWN -> { } } } } override fun onViewThread(position: Int) { (adapter as? SearchStatusesAdapter)?.getItem(position)?.first?.let { status -> val actionableStatus = status.actionableStatus bottomSheetActivity?.viewThread(actionableStatus.id, actionableStatus.url) } } override fun onOpenReblog(position: Int) { (adapter as? SearchStatusesAdapter)?.getItem(position)?.first?.let { status -> bottomSheetActivity?.viewAccount(status.account.id) } } override fun onExpandedChange(expanded: Boolean, position: Int) { (adapter as? SearchStatusesAdapter)?.getItem(position)?.let { viewModel.expandedChange(it, expanded) } } override fun onLoadMore(position: Int) { //Ignore } override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { (adapter as? SearchStatusesAdapter)?.getItem(position)?.let { viewModel.collapsedChange(it, isCollapsed) } } override fun onVoteInPoll(position: Int, choices: MutableList) { (adapter as? SearchStatusesAdapter)?.getItem(position)?.let { viewModel.voteInPoll(it, choices) } } open fun removeItem(position: Int) { (adapter as? SearchStatusesAdapter)?.getItem(position)?.let { viewModel.removeItem(it) } } override fun onReblog(reblog: Boolean, position: Int) { (adapter as? SearchStatusesAdapter)?.getItem(position)?.let { status -> viewModel.reblog(status, reblog) } } companion object { fun newInstance() = SearchStatusesFragment() } private fun reply(status: Status) { val inReplyToId = status.actionableId val actionableStatus = status.actionableStatus val replyVisibility = actionableStatus.visibility val contentWarning = actionableStatus.spoilerText val mentions = actionableStatus.mentions val mentionedUsernames = LinkedHashSet() mentionedUsernames.add(actionableStatus.account.username) val loggedInUsername = viewModel.activeAccount?.username for ((_, _, username) in mentions) { mentionedUsernames.add(username) } mentionedUsernames.remove(loggedInUsername) val intent = ComposeActivity.IntentBuilder() .inReplyToId(inReplyToId) .replyVisibility(replyVisibility) .contentWarning(contentWarning) .mentionedUsernames(mentionedUsernames) .replyingStatusAuthor(actionableStatus.account.localUsername) .replyingStatusContent(actionableStatus.content.toString()) .build(context) requireActivity().startActivity(intent) } private fun quote(status: Status) { val id = status.actionableId val actionableStatus = status.actionableStatus val visibility = actionableStatus.visibility val url = actionableStatus.url val mentions = actionableStatus.mentions val mentionedUsernames = LinkedHashSet() mentionedUsernames.add(actionableStatus.account.username) val loggedInUsername = viewModel.activeAccount?.username for ((_, _, username) in mentions) { mentionedUsernames.add(username) } mentionedUsernames.remove(loggedInUsername) val intent = ComposeActivity.IntentBuilder() .quoteId(id) .quoteUrl(url) .replyVisibility(visibility) .mentionedUsernames(mentionedUsernames) .build(context) startActivity(intent) } private fun more(status: Status, view: View, position: Int) { val id = status.actionableId val accountId = status.actionableStatus.account.id val accountUsername = status.actionableStatus.account.username val statusUrl = status.actionableStatus.url val accounts = viewModel.getAllAccountsOrderedByActive() var openAsTitle: String? = null val loggedInAccountId = viewModel.activeAccount?.accountId val popup = PopupMenu(view.context, view) // Give a different menu depending on whether this is the user's own toot or not. if (loggedInAccountId == null || loggedInAccountId != accountId) { popup.inflate(R.menu.status_more) val menu = popup.menu menu.findItem(R.id.status_download_media).isVisible = status.attachments.isNotEmpty() } else { popup.inflate(R.menu.status_more_for_user) val menu = popup.menu menu.findItem(R.id.status_open_as).isVisible = !statusUrl.isNullOrBlank() when (status.visibility) { Status.Visibility.PUBLIC, Status.Visibility.UNLISTED -> { val textId = getString(if (status.isPinned()) R.string.unpin_action else R.string.pin_action) menu.add(0, R.id.pin, 1, textId) } Status.Visibility.PRIVATE -> { var reblogged = status.reblogged if (status.reblog != null) reblogged = status.reblog.reblogged menu.findItem(R.id.status_reblog_private).isVisible = !reblogged menu.findItem(R.id.status_unreblog_private).isVisible = reblogged } Status.Visibility.UNKNOWN, Status.Visibility.DIRECT -> { } //Ignore } } val menu = popup.menu val openAsItem = menu.findItem(R.id.status_open_as) when (accounts.size) { 0, 1 -> openAsItem.isVisible = false 2 -> for (account in accounts) { if (account !== viewModel.activeAccount) { openAsTitle = String.format(getString(R.string.action_open_as), account.fullName) break } } else -> openAsTitle = String.format(getString(R.string.action_open_as), "…") } openAsItem.title = openAsTitle popup.setOnMenuItemClickListener { item -> when (item.itemId) { R.id.status_share_content -> { var statusToShare: Status? = status if (statusToShare!!.reblog != null) statusToShare = statusToShare.reblog val sendIntent = Intent() sendIntent.action = Intent.ACTION_SEND val stringToShare = statusToShare!!.account.username + " - " + statusToShare.content.toString() sendIntent.putExtra(Intent.EXTRA_TEXT, stringToShare) sendIntent.type = "text/plain" startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_status_content_to))) return@setOnMenuItemClickListener true } R.id.status_share_link -> { val sendIntent = Intent() sendIntent.action = Intent.ACTION_SEND sendIntent.putExtra(Intent.EXTRA_TEXT, statusUrl) sendIntent.type = "text/plain" startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_status_link_to))) return@setOnMenuItemClickListener true } R.id.status_copy_link -> { val clipboard = activity!!.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager clipboard.setPrimaryClip(ClipData.newPlainText(null, statusUrl)) return@setOnMenuItemClickListener true } R.id.status_open_as -> { showOpenAsDialog(statusUrl!!, item.title) return@setOnMenuItemClickListener true } R.id.status_download_media -> { requestDownloadAllMedia(status) return@setOnMenuItemClickListener true } R.id.status_mute -> { viewModel.muteAcount(accountId) return@setOnMenuItemClickListener true } R.id.status_block -> { viewModel.blockAccount(accountId) return@setOnMenuItemClickListener true } R.id.status_report -> { openReportPage(accountId, accountUsername, id) return@setOnMenuItemClickListener true } R.id.status_unreblog_private -> { onReblog(false, position) return@setOnMenuItemClickListener true } R.id.status_reblog_private -> { onReblog(true, position) return@setOnMenuItemClickListener true } R.id.status_delete -> { showConfirmDeleteDialog(id, position) return@setOnMenuItemClickListener true } R.id.status_delete_and_redraft -> { showConfirmEditDialog(id, position, status) return@setOnMenuItemClickListener true } R.id.pin -> { viewModel.pinAccount(status, !status.isPinned()) return@setOnMenuItemClickListener true } } false } popup.show() } private fun showOpenAsDialog(statusUrl: String, dialogTitle: CharSequence) { bottomSheetActivity?.showAccountChooserDialog(dialogTitle, false, object : AccountSelectionListener { override fun onAccountSelected(account: AccountEntity) { openAsAccount(statusUrl, account) } }) } private fun openAsAccount(statusUrl: String, account: AccountEntity) { viewModel.activeAccount = account val intent = Intent(context, MainActivity::class.java) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK intent.putExtra(MainActivity.STATUS_URL, statusUrl) startActivity(intent) (activity as BaseActivity).finishWithoutSlideOutAnimation() } private fun downloadAllMedia(status: Status) { Toast.makeText(context, R.string.downloading_media, Toast.LENGTH_SHORT).show() for ((_, url) in status.attachments) { val uri = Uri.parse(url) val filename = uri.lastPathSegment val downloadManager = activity!!.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager val request = DownloadManager.Request(uri) request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, filename) downloadManager.enqueue(request) } } private fun requestDownloadAllMedia(status: Status) { val permissions = arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE) (activity as BaseActivity).requestPermissions(permissions) { _, grantResults -> if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { downloadAllMedia(status) } else { Toast.makeText(context, R.string.error_media_download_permission, Toast.LENGTH_SHORT).show() } } } private fun openReportPage(accountId: String, accountUsername: String, statusId: String) { startActivity(ReportActivity.getIntent(requireContext(), accountId, accountUsername, statusId)) } private fun showConfirmDeleteDialog(id: String, position: Int) { context?.let { AlertDialog.Builder(it) .setMessage(R.string.dialog_delete_toot_warning) .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.deleteStatus(id) removeItem(position) } .setNegativeButton(android.R.string.cancel, null) .show() } } private fun showConfirmEditDialog(id: String, position: Int, status: Status) { activity?.let { AlertDialog.Builder(it) .setMessage(R.string.dialog_redraft_toot_warning) .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.deleteStatus(id) .observeOn(AndroidSchedulers.mainThread()) .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) .subscribe ({ deletedStatus -> removeItem(position) val redraftStatus = if(deletedStatus.isEmpty()) { status.toDeletedStatus() } else { deletedStatus } val intent = ComposeActivity.IntentBuilder() .tootText(redraftStatus.text) .inReplyToId(redraftStatus.inReplyToId) .visibility(redraftStatus.visibility) .contentWarning(redraftStatus.spoilerText) .mediaAttachments(redraftStatus.attachments) .sensitive(redraftStatus.sensitive) .poll(redraftStatus.poll?.toNewPoll(status.createdAt)) .build(context) startActivity(intent) }, { error -> Log.w("SearchStatusesFragment", "error deleting status", error) Toast.makeText(context, R.string.error_generic, Toast.LENGTH_SHORT).show() }) } .setNegativeButton(android.R.string.cancel, null) .show() } } }