/* * Twidere - Twitter client for Android * * Copyright (C) 2012-2014 Mariotaku Lee * * 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. * * This program 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 this program. If not, see . */ package org.mariotaku.twidere.fragment import android.accounts.AccountManager import android.app.Activity import android.app.Dialog import android.content.* import android.graphics.Color import android.graphics.Rect import android.nfc.NdefMessage import android.nfc.NdefRecord import android.nfc.NfcAdapter.CreateNdefMessageCallback import android.os.Bundle import android.support.annotation.UiThread import android.support.v4.app.LoaderManager.LoaderCallbacks import android.support.v4.app.hasRunningLoadersSafe import android.support.v4.content.ContextCompat import android.support.v4.content.FixedAsyncTaskLoader import android.support.v4.content.Loader import android.support.v4.view.MenuItemCompat import android.support.v4.view.ViewCompat import android.support.v7.app.AlertDialog import android.support.v7.widget.ActionMenuView import android.support.v7.widget.FixedLinearLayoutManager import android.support.v7.widget.LinearLayoutManager import android.support.v7.widget.RecyclerView import android.support.v7.widget.RecyclerView.ViewHolder import android.text.SpannableString import android.text.SpannableStringBuilder import android.text.Spanned import android.text.TextUtils import android.text.method.LinkMovementMethod import android.text.style.ForegroundColorSpan import android.view.* import android.view.View.OnClickListener import android.widget.Space import android.widget.TextView import android.widget.Toast import com.bumptech.glide.Glide import com.squareup.otto.Subscribe import kotlinx.android.synthetic.main.adapter_item_status_count_label.view.* import kotlinx.android.synthetic.main.fragment_status.* import kotlinx.android.synthetic.main.header_status.view.* import kotlinx.android.synthetic.main.layout_content_fragment_common.* import org.mariotaku.abstask.library.TaskStarter import org.mariotaku.kpreferences.get import org.mariotaku.ktextension.* import org.mariotaku.library.objectcursor.ObjectCursor import org.mariotaku.microblog.library.MicroBlog import org.mariotaku.microblog.library.MicroBlogException import org.mariotaku.microblog.library.twitter.model.Paging import org.mariotaku.microblog.library.twitter.model.TranslationResult import org.mariotaku.sqliteqb.library.Expression import org.mariotaku.twidere.Constants.* import org.mariotaku.twidere.R import org.mariotaku.twidere.activity.ColorPickerDialogActivity import org.mariotaku.twidere.adapter.BaseRecyclerViewAdapter import org.mariotaku.twidere.adapter.ListParcelableStatusesAdapter import org.mariotaku.twidere.adapter.LoadMoreSupportAdapter import org.mariotaku.twidere.adapter.decorator.ExtendedDividerItemDecoration import org.mariotaku.twidere.adapter.iface.IGapSupportedAdapter import org.mariotaku.twidere.adapter.iface.IItemCountsAdapter import org.mariotaku.twidere.adapter.iface.ILoadMoreSupportAdapter import org.mariotaku.twidere.adapter.iface.ILoadMoreSupportAdapter.IndicatorPosition import org.mariotaku.twidere.adapter.iface.IStatusesAdapter import org.mariotaku.twidere.annotation.AccountType import org.mariotaku.twidere.annotation.ProfileImageSize import org.mariotaku.twidere.annotation.Referral import org.mariotaku.twidere.constant.* import org.mariotaku.twidere.constant.KeyboardShortcutConstants.* import org.mariotaku.twidere.extension.applyTheme import org.mariotaku.twidere.extension.getErrorMessage import org.mariotaku.twidere.extension.loadProfileImage import org.mariotaku.twidere.extension.model.* import org.mariotaku.twidere.extension.model.api.key import org.mariotaku.twidere.extension.model.api.toParcelable import org.mariotaku.twidere.extension.view.calculateSpaceItemHeight import org.mariotaku.twidere.fragment.AbsStatusesFragment.Companion.handleActionClick import org.mariotaku.twidere.loader.ParcelableStatusLoader import org.mariotaku.twidere.loader.statuses.ConversationLoader import org.mariotaku.twidere.menu.FavoriteItemProvider import org.mariotaku.twidere.model.* import org.mariotaku.twidere.model.analyzer.Share import org.mariotaku.twidere.model.analyzer.StatusView import org.mariotaku.twidere.model.event.FavoriteTaskEvent import org.mariotaku.twidere.model.event.StatusListChangedEvent import org.mariotaku.twidere.model.pagination.Pagination import org.mariotaku.twidere.model.pagination.SinceMaxPagination import org.mariotaku.twidere.model.util.AccountUtils import org.mariotaku.twidere.model.util.ParcelableLocationUtils import org.mariotaku.twidere.model.util.ParcelableMediaUtils import org.mariotaku.twidere.provider.TwidereDataStore.CachedStatuses import org.mariotaku.twidere.provider.TwidereDataStore.Statuses import org.mariotaku.twidere.task.AbsAccountRequestTask import org.mariotaku.twidere.util.* import org.mariotaku.twidere.util.ContentScrollHandler.ContentListSupport import org.mariotaku.twidere.util.KeyboardShortcutsHandler.KeyboardShortcutCallback import org.mariotaku.twidere.util.RecyclerViewScrollHandler.RecyclerViewCallback import org.mariotaku.twidere.util.twitter.card.TwitterCardViewFactory import org.mariotaku.twidere.view.CardMediaContainer.OnMediaClickListener import org.mariotaku.twidere.view.ExtendedRecyclerView import org.mariotaku.twidere.view.ProfileImageView import org.mariotaku.twidere.view.holder.GapViewHolder import org.mariotaku.twidere.view.holder.LoadIndicatorViewHolder import org.mariotaku.twidere.view.holder.StatusViewHolder import org.mariotaku.twidere.view.holder.iface.IStatusViewHolder import org.mariotaku.twidere.view.holder.iface.IStatusViewHolder.StatusClickListener import java.lang.ref.WeakReference import java.util.* /** * Displays status details * Created by mariotaku on 14/12/5. */ class StatusFragment : BaseFragment(), LoaderCallbacks>, OnMediaClickListener, StatusClickListener, KeyboardShortcutCallback, ContentListSupport { private var mItemDecoration: ExtendedDividerItemDecoration? = null override lateinit var adapter: StatusAdapter private lateinit var layoutManager: LinearLayoutManager private lateinit var navigationHelper: RecyclerViewNavigationHelper private lateinit var scrollListener: RecyclerViewScrollHandler private var loadTranslationTask: LoadTranslationTask? = null // Data fields private var conversationLoaderInitialized: Boolean = false private var mActivityLoaderInitialized: Boolean = false private var hasMoreConversation = true // Listeners private val conversationsLoaderCallback = object : LoaderCallbacks> { override fun onCreateLoader(id: Int, args: Bundle): Loader> { val adapter = this@StatusFragment.adapter adapter.isRepliesLoading = true adapter.isConversationsLoading = true adapter.updateItemDecoration() val status: ParcelableStatus = args.getParcelable(EXTRA_STATUS) val loadingMore = args.getBoolean(EXTRA_LOADING_MORE, false) return ConversationLoader(activity, status, adapter.getData(), true, loadingMore).apply { pagination = args.toPagination() // Setting comparator to null lets statuses sort ascending comparator = null } } override fun onLoadFinished(loader: Loader>, data: List?) { val adapter = this@StatusFragment.adapter adapter.updateItemDecoration() val conversationLoader = loader as ConversationLoader var supportedPositions: Long = 0 if (data != null && !data.isEmpty()) { val sinceSortId = (conversationLoader.pagination as? SinceMaxPagination)?.sinceSortId ?: -1 if (sinceSortId < data[data.size - 1].sort_id) { supportedPositions = supportedPositions or ILoadMoreSupportAdapter.END } if (data[0].in_reply_to_status_id != null) { supportedPositions = supportedPositions or ILoadMoreSupportAdapter.START } } else { supportedPositions = supportedPositions or ILoadMoreSupportAdapter.END val status = status if (status != null && status.in_reply_to_status_id != null) { supportedPositions = supportedPositions or ILoadMoreSupportAdapter.START } } adapter.loadMoreSupportedPosition = supportedPositions setConversation(data) adapter.isConversationsLoading = false adapter.isRepliesLoading = false } override fun onLoaderReset(loader: Loader>) { } } private val statusActivityLoaderCallback = object : LoaderCallbacks { override fun onCreateLoader(id: Int, args: Bundle): Loader { val accountKey = args.getParcelable(EXTRA_ACCOUNT_KEY) val statusId = args.getString(EXTRA_STATUS_ID) return StatusActivitySummaryLoader(activity, accountKey, statusId) } override fun onLoadFinished(loader: Loader, data: StatusActivity?) { adapter.updateItemDecoration() adapter.statusActivity = data } override fun onLoaderReset(loader: Loader) { } } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { val activity = activity ?: return when (requestCode) { REQUEST_SET_COLOR -> { val status = adapter.status ?: return if (resultCode == Activity.RESULT_OK) { if (data == null) return val color = data.getIntExtra(EXTRA_COLOR, Color.TRANSPARENT) userColorNameManager.setUserColor(status.user_key, color) } else if (resultCode == ColorPickerDialogActivity.RESULT_CLEARED) { userColorNameManager.clearUserColor(status.user_key) } val args = arguments if (args.containsKey(EXTRA_STATUS)) { args.putParcelable(EXTRA_STATUS, status) } loaderManager.restartLoader(LOADER_ID_DETAIL_STATUS, args, this) } REQUEST_SELECT_ACCOUNT -> { val status = adapter.status ?: return if (resultCode == Activity.RESULT_OK) { if (data == null || !data.hasExtra(EXTRA_ID)) return val accountKey = data.getParcelableExtra(EXTRA_ACCOUNT_KEY) IntentUtils.openStatus(activity, accountKey, status.id) } } AbsStatusesFragment.REQUEST_FAVORITE_SELECT_ACCOUNT, AbsStatusesFragment.REQUEST_RETWEET_SELECT_ACCOUNT -> { AbsStatusesFragment.handleActionActivityResult(this, requestCode, resultCode, data) } } } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { return inflater.inflate(R.layout.fragment_status, container, false) } override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) setHasOptionsMenu(true) Utils.setNdefPushMessageCallback(activity, CreateNdefMessageCallback { val status = status ?: return@CreateNdefMessageCallback null NdefMessage(arrayOf(NdefRecord.createUri(LinkCreator.getStatusWebLink(status)))) }) adapter = StatusAdapter(this) layoutManager = StatusListLinearLayoutManager(context, recyclerView) mItemDecoration = StatusDividerItemDecoration(context, adapter, layoutManager.orientation) recyclerView.addItemDecoration(mItemDecoration) layoutManager.recycleChildrenOnDetach = true recyclerView.layoutManager = layoutManager recyclerView.clipToPadding = false adapter.statusClickListener = this recyclerView.adapter = adapter registerForContextMenu(recyclerView) scrollListener = RecyclerViewScrollHandler(this, RecyclerViewCallback(recyclerView)) scrollListener.touchSlop = ViewConfiguration.get(context).scaledTouchSlop navigationHelper = RecyclerViewNavigationHelper(recyclerView, layoutManager, adapter, null) setState(STATE_LOADING) loaderManager.initLoader(LOADER_ID_DETAIL_STATUS, arguments, this) } override fun onMediaClick(holder: IStatusViewHolder, view: View, current: ParcelableMedia, statusPosition: Int) { val status = adapter.getStatus(statusPosition) IntentUtils.openMedia(activity, status, current, preferences[newDocumentApiKey], preferences[displaySensitiveContentsKey]) } override fun onQuotedMediaClick(holder: IStatusViewHolder, view: View, current: ParcelableMedia, statusPosition: Int) { val status = adapter.getStatus(statusPosition) val quotedMedia = status.quoted_media ?: return IntentUtils.openMedia(activity, status.account_key, status.is_possibly_sensitive, status, current, quotedMedia, preferences[newDocumentApiKey], preferences[displaySensitiveContentsKey]) } override fun onGapClick(holder: GapViewHolder, position: Int) { } override fun onItemActionClick(holder: ViewHolder, id: Int, position: Int) { val status = adapter.getStatus(position) handleActionClick(this@StatusFragment, id, status, holder as StatusViewHolder) } override fun onItemActionLongClick(holder: RecyclerView.ViewHolder, id: Int, position: Int): Boolean { val status = adapter.getStatus(position) return AbsStatusesFragment.handleActionLongClick(this, status, adapter.getItemId(position), id) } override fun onStatusClick(holder: IStatusViewHolder, position: Int) { val status = adapter.getStatus(position) IntentUtils.openStatus(activity, status) } override fun onQuotedStatusClick(holder: IStatusViewHolder, position: Int) { val status = adapter.getStatus(position) val quotedId = status.quoted_id ?: return IntentUtils.openStatus(activity, status.account_key, quotedId) } override fun onStatusLongClick(holder: IStatusViewHolder, position: Int): Boolean { return false } override fun onItemMenuClick(holder: ViewHolder, menuView: View, position: Int) { if (activity == null) return val view = layoutManager.findViewByPosition(position) ?: return recyclerView.showContextMenuForChild(view) } override fun onUserProfileClick(holder: IStatusViewHolder, position: Int) { val status = adapter.getStatus(position) IntentUtils.openUserProfile(activity, status.account_key, status.user_key, status.user_screen_name, status.extras?.user_statusnet_profile_url, preferences[newDocumentApiKey], Referral.TIMELINE_STATUS, null) } override fun onMediaClick(view: View, current: ParcelableMedia, accountKey: UserKey?, id: Long) { val status = adapter.status ?: return if ((view.parent as View).id == R.id.quotedMediaPreview && status.quoted_media != null) { IntentUtils.openMediaDirectly(activity, accountKey, status.quoted_media!!, current, newDocument = preferences[newDocumentApiKey], status = status) } else if (status.media != null) { IntentUtils.openMediaDirectly(activity, accountKey, status.media!!, current, newDocument = preferences[newDocumentApiKey], status = status) } } override fun handleKeyboardShortcutSingle(handler: KeyboardShortcutsHandler, keyCode: Int, event: KeyEvent, metaState: Int): Boolean { if (!KeyboardShortcutsHandler.isValidForHotkey(keyCode, event)) return false val focusedChild = RecyclerViewUtils.findRecyclerViewChild(recyclerView, layoutManager.focusedChild) val position: Int if (focusedChild != null && focusedChild.parent === recyclerView) { position = recyclerView.getChildLayoutPosition(focusedChild) } else { return false } if (position == -1) return false val status = adapter.getStatus(position) val action = handler.getKeyAction(CONTEXT_TAG_STATUS, keyCode, event, metaState) ?: return false return AbsStatusesFragment.handleKeyboardShortcutAction(this, action, status, position) } override fun isKeyboardShortcutHandled(handler: KeyboardShortcutsHandler, keyCode: Int, event: KeyEvent, metaState: Int): Boolean { val action = handler.getKeyAction(CONTEXT_TAG_STATUS, keyCode, event, metaState) ?: return false when (action) { ACTION_STATUS_REPLY, ACTION_STATUS_RETWEET, ACTION_STATUS_FAVORITE -> return true } return navigationHelper.isKeyboardShortcutHandled(handler, keyCode, event, metaState) } override fun handleKeyboardShortcutRepeat(handler: KeyboardShortcutsHandler, keyCode: Int, repeatCount: Int, event: KeyEvent, metaState: Int): Boolean { return navigationHelper.handleKeyboardShortcutRepeat(handler, keyCode, repeatCount, event, metaState) } override fun onCreateLoader(id: Int, args: Bundle): Loader> { val fragmentArgs = arguments val accountKey = fragmentArgs.getParcelable(EXTRA_ACCOUNT_KEY) val statusId = fragmentArgs.getString(EXTRA_STATUS_ID) return ParcelableStatusLoader(activity, false, fragmentArgs, accountKey, statusId) } override fun onLoadFinished(loader: Loader>, data: SingleResponse) { val activity = activity ?: return val status = data.data if (status != null) { val readPosition = saveReadPosition() val dataExtra = data.extras val details: AccountDetails? = dataExtra.getParcelable(EXTRA_ACCOUNT) if (adapter.setStatus(status, details)) { val args = arguments if (args.containsKey(EXTRA_STATUS)) { args.putParcelable(EXTRA_STATUS, status) } adapter.loadMoreSupportedPosition = ILoadMoreSupportAdapter.BOTH adapter.setData(null) loadConversation(status, null, null) loadActivity(status) val position = adapter.getFirstPositionOfItem(StatusAdapter.ITEM_IDX_STATUS) if (position != RecyclerView.NO_POSITION) { layoutManager.scrollToPositionWithOffset(position, 0) } Analyzer.log(StatusView(details?.type, status.media_type).apply { this.type = StatusView.getStatusType(status) this.source = status.source?.let(HtmlEscapeHelper::toPlainText) }) } else if (readPosition != null) { restoreReadPosition(readPosition) } setState(STATE_LOADED) } else { adapter.loadMoreSupportedPosition = ILoadMoreSupportAdapter.NONE setState(STATE_ERROR) val errorInfo = StatusCodeMessageUtils.getErrorInfo(context, data.exception!!) errorText.spannable = errorInfo.message errorIcon.setImageResource(errorInfo.icon) } activity.supportInvalidateOptionsMenu() } override fun onLoaderReset(loader: Loader>) { } override val refreshing: Boolean get() = loaderManager.hasRunningLoadersSafe() override fun onLoadMoreContents(@IndicatorPosition position: Long) { if (!hasMoreConversation) return if (position and ILoadMoreSupportAdapter.START != 0L) { val start = adapter.getIndexStart(StatusAdapter.ITEM_IDX_CONVERSATION) val status = adapter.getStatus(start) if (status.in_reply_to_status_id == null) return loadConversation(status, null, status.id) } else if (position and ILoadMoreSupportAdapter.END != 0L) { val start = adapter.getIndexStart(StatusAdapter.ITEM_IDX_CONVERSATION) val status = adapter.getStatus(start + adapter.getStatusCount(true) - 1) loadConversation(status, status.id, null) } adapter.loadMoreIndicatorPosition = position } override fun setControlVisible(visible: Boolean) { // No-op } override val reachingEnd: Boolean get() { val lm = layoutManager var itemPos = lm.findLastCompletelyVisibleItemPosition() if (itemPos == RecyclerView.NO_POSITION) { // No completely visible item, find visible item instead itemPos = lm.findLastVisibleItemPosition() } return itemPos >= lm.itemCount - 1 } override val reachingStart: Boolean get() { val lm = layoutManager var itemPos = lm.findFirstCompletelyVisibleItemPosition() if (itemPos == RecyclerView.NO_POSITION) { // No completely visible item, find visible item instead itemPos = lm.findFirstVisibleItemPosition() } return itemPos <= 1 } private val status: ParcelableStatus? get() = adapter.status private fun loadConversation(status: ParcelableStatus?, sinceId: String?, maxId: String?) { if (status == null || activity == null) return val args = Bundle() args.putParcelable(EXTRA_ACCOUNT_KEY, status.account_key) args.putString(EXTRA_STATUS_ID, if (status.is_retweet) status.retweet_id else status.id) args.putString(EXTRA_SINCE_ID, sinceId) args.putString(EXTRA_MAX_ID, maxId) args.putParcelable(EXTRA_STATUS, status) if (conversationLoaderInitialized) { loaderManager.restartLoader(LOADER_ID_STATUS_CONVERSATIONS, args, conversationsLoaderCallback) return } loaderManager.initLoader(LOADER_ID_STATUS_CONVERSATIONS, args, conversationsLoaderCallback) conversationLoaderInitialized = true } private fun loadActivity(status: ParcelableStatus?) { if (status == null || host == null || isDetached) return val args = Bundle() args.putParcelable(EXTRA_ACCOUNT_KEY, status.account_key) args.putString(EXTRA_STATUS_ID, if (status.is_retweet) status.retweet_id else status.id) if (mActivityLoaderInitialized) { loaderManager.restartLoader(LOADER_ID_STATUS_ACTIVITY, args, statusActivityLoaderCallback) return } loaderManager.initLoader(LOADER_ID_STATUS_ACTIVITY, args, statusActivityLoaderCallback) mActivityLoaderInitialized = true } private fun loadTranslation(status: ParcelableStatus?) { if (status == null) return if (loadTranslationTask?.isFinished ?: false) return loadTranslationTask = run { val task = LoadTranslationTask(this, status) TaskStarter.execute(task) return@run task } } private fun setConversation(data: List?) { val readPosition = saveReadPosition() val changed = adapter.setData(data) hasMoreConversation = data != null && changed restoreReadPosition(readPosition) } private fun scrollToCurrent() { if (adapter.status != null) { val position = adapter.getFirstPositionOfItem(StatusAdapter.ITEM_IDX_STATUS) recyclerView.smoothScrollToPosition(position) } } private fun displayTranslation(translation: TranslationResult) { adapter.translationResult = translation } private fun saveReadPosition(): ReadPosition? { val lm = layoutManager val adapter = this.adapter val position = lm.findFirstVisibleItemPosition() if (position == RecyclerView.NO_POSITION) return null val itemType = adapter.getItemType(position) var itemId = adapter.getItemId(position) val positionView: View? if (itemType == StatusAdapter.ITEM_IDX_CONVERSATION_LOAD_MORE) { // Should be next item positionView = lm.findViewByPosition(position + 1) itemId = adapter.getItemId(position + 1) } else { positionView = lm.findViewByPosition(position) } return ReadPosition(itemId, positionView?.top ?: 0) } private fun restoreReadPosition(position: ReadPosition?) { val adapter = this.adapter if (position == null) return val adapterPosition = adapter.findPositionByItemId(position.statusId) if (adapterPosition < 0) return layoutManager.scrollToPositionWithOffset(adapterPosition, position.offsetTop) } private fun setState(state: Int) { statusContent.visibility = if (state == STATE_LOADED) View.VISIBLE else View.GONE progressContainer.visibility = if (state == STATE_LOADING) View.VISIBLE else View.GONE errorContainer.visibility = if (state == STATE_ERROR) View.VISIBLE else View.GONE } override fun onStart() { super.onStart() bus.register(this) recyclerView.addOnScrollListener(scrollListener) recyclerView.setOnTouchListener(scrollListener.touchListener) } override fun onStop() { recyclerView.setOnTouchListener(null) recyclerView.removeOnScrollListener(scrollListener) bus.unregister(this) super.onStop() } override fun onCreateContextMenu(menu: ContextMenu, v: View, menuInfo: ContextMenu.ContextMenuInfo?) { if (!userVisibleHint) return val contextMenuInfo = menuInfo as? ExtendedRecyclerView.ContextMenuInfo ?: return val status = adapter.getStatus(contextMenuInfo.position) val inflater = MenuInflater(context) inflater.inflate(R.menu.action_status, menu) MenuUtils.setupForStatus(context, menu, preferences, twitterWrapper, userColorNameManager, status) } override fun onContextItemSelected(item: MenuItem): Boolean { if (!userVisibleHint) return false val contextMenuInfo = item.menuInfo as? ExtendedRecyclerView.ContextMenuInfo ?: return false val status = adapter.getStatus(contextMenuInfo.position) if (item.itemId == R.id.share) { val shareIntent = Utils.createStatusShareIntent(activity, status) val chooser = Intent.createChooser(shareIntent, getString(R.string.share_status)) startActivity(chooser) val am = AccountManager.get(context) val accountType = AccountUtils.findByAccountKey(am, status.account_key)?.getAccountType(am) Analyzer.log(Share.status(accountType, status)) return true } return MenuUtils.handleStatusClick(activity, this, fragmentManager, preferences, userColorNameManager, twitterWrapper, status, item) } @Subscribe fun notifyStatusListChanged(event: StatusListChangedEvent) { adapter.notifyDataSetChanged() } @Subscribe fun notifyFavoriteTask(event: FavoriteTaskEvent) { if (!event.isSucceeded) return val status = adapter.findStatusById(event.accountKey, event.statusId) if (status != null) { when (event.action) { FavoriteTaskEvent.Action.CREATE -> { status.is_favorite = true } FavoriteTaskEvent.Action.DESTROY -> { status.is_favorite = false } } } } private fun onUserClick(user: ParcelableUser) { IntentUtils.openUserProfile(context, user, true, Referral.TIMELINE_STATUS, null) } class LoadSensitiveImageConfirmDialogFragment : BaseDialogFragment(), DialogInterface.OnClickListener { override fun onClick(dialog: DialogInterface, which: Int) { when (which) { DialogInterface.BUTTON_POSITIVE -> { val f = parentFragment if (f is StatusFragment) { val adapter = f.adapter adapter.isDetailMediaExpanded = true } } } } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val context = activity val builder = AlertDialog.Builder(context) builder.setTitle(android.R.string.dialog_alert_title) builder.setMessage(R.string.sensitive_content_warning) builder.setPositiveButton(android.R.string.ok, this) builder.setNegativeButton(android.R.string.cancel, null) val dialog = builder.create() dialog.setOnShowListener { it as AlertDialog it.applyTheme() } return dialog } } internal class LoadTranslationTask(fragment: StatusFragment, val status: ParcelableStatus) : AbsAccountRequestTask(fragment.context, status.account_key) { val weakFragment = WeakReference(fragment) override fun onExecute(account: AccountDetails, params: Any?): TranslationResult { val twitter = account.newMicroBlogInstance(context, MicroBlog::class.java) val prefDest = preferences.getString(KEY_TRANSLATION_DESTINATION, null) val dest: String if (TextUtils.isEmpty(prefDest)) { dest = twitter.accountSettings.language val editor = preferences.edit() editor.putString(KEY_TRANSLATION_DESTINATION, dest) editor.apply() } else { dest = prefDest } val statusId = if (status.is_retweet) status.retweet_id else status.id return twitter.showTranslation(statusId, dest) } override fun onSucceed(callback: Any?, result: TranslationResult) { val fragment = weakFragment.get() ?: return fragment.displayTranslation(result) } override fun onException(callback: Any?, exception: MicroBlogException) { val context = this.context ?: return Toast.makeText(context, exception.getErrorMessage(context), Toast.LENGTH_SHORT).show() } } private class DetailStatusViewHolder( private val adapter: StatusAdapter, itemView: View ) : ViewHolder(itemView), OnClickListener, ActionMenuView.OnMenuItemClickListener { private val linkClickHandler: StatusLinkClickHandler private val linkify: TwidereLinkify private val profileTypeView = itemView.profileType private val nameView = itemView.name private val summaryView = itemView.summary private val textView = itemView.text private val locationView = itemView.locationView private val retweetedByView = itemView.retweetedBy init { this.linkClickHandler = DetailStatusLinkClickHandler(adapter.context, adapter.multiSelectManager, adapter, adapter.preferences) this.linkify = TwidereLinkify(linkClickHandler) initViews() } @UiThread fun displayStatus(account: AccountDetails?, status: ParcelableStatus?, statusActivity: StatusActivity?, translation: TranslationResult?) { if (account == null || status == null) return val fragment = adapter.fragment val context = adapter.context val formatter = adapter.bidiFormatter val twitter = adapter.twitterWrapper val nameFirst = adapter.nameFirst val colorNameManager = adapter.userColorNameManager linkClickHandler.status = status if (status.retweet_id != null) { val retweetedBy = colorNameManager.getDisplayName(status.retweeted_by_user_key!!, status.retweeted_by_user_name!!, status.retweeted_by_user_acct!!, nameFirst) retweetedByView.spannable = context.getString(R.string.name_retweeted, retweetedBy) retweetedByView.visibility = View.VISIBLE } else { retweetedByView.spannable = null retweetedByView.visibility = View.GONE } itemView.profileContainer.drawEnd(status.account_color) val layoutPosition = layoutPosition val skipLinksInText = status.extras?.support_entities ?: false if (status.is_quote) { itemView.quotedView.visibility = View.VISIBLE val quoteContentAvailable = status.quoted_text_plain != null && status.quoted_text_unescaped != null if (quoteContentAvailable) { itemView.quotedName.visibility = View.VISIBLE itemView.quotedText.visibility = View.VISIBLE itemView.quotedName.name = colorNameManager.getUserNickname(status.quoted_user_key!!, status.quoted_user_name) itemView.quotedName.screenName = "@${status.quoted_user_acct}" itemView.quotedName.updateText(formatter) val quotedDisplayEnd = status.extras?.quoted_display_text_range?.getOrNull(1) ?: -1 val quotedText = SpannableStringBuilder.valueOf(status.quoted_text_unescaped) status.quoted_spans?.applyTo(quotedText) linkify.applyAllLinks(quotedText, status.account_key, layoutPosition.toLong(), status.is_possibly_sensitive, skipLinksInText) if (quotedDisplayEnd != -1 && quotedDisplayEnd <= quotedText.length) { itemView.quotedText.spannable = quotedText.subSequence(0, quotedDisplayEnd) } else { itemView.quotedText.spannable = quotedText } itemView.quotedText.hideIfEmpty() val quotedUserColor = colorNameManager.getUserColor(status.quoted_user_key!!) if (quotedUserColor != 0) { itemView.quotedView.drawStart(quotedUserColor) } else { itemView.quotedView.drawStart(ThemeUtils.getColorFromAttribute(context, R.attr.quoteIndicatorBackgroundColor)) } val quotedMedia = status.quoted_media if (quotedMedia?.isEmpty() ?: true) { itemView.quotedMediaLabel.visibility = View.GONE itemView.quotedMediaPreview.visibility = View.GONE } else if (adapter.isDetailMediaExpanded) { itemView.quotedMediaLabel.visibility = View.GONE itemView.quotedMediaPreview.visibility = View.VISIBLE itemView.quotedMediaPreview.displayMedia(adapter.requestManager, media = quotedMedia, accountKey = status.account_key, mediaClickListener = adapter.fragment) } else { itemView.quotedMediaLabel.visibility = View.VISIBLE itemView.quotedMediaPreview.visibility = View.GONE } } else { itemView.quotedName.visibility = View.GONE itemView.quotedText.visibility = View.VISIBLE itemView.quotedMediaLabel.visibility = View.GONE itemView.quotedMediaPreview.visibility = View.GONE // Not available val string = SpannableString.valueOf(context.getString(R.string.label_status_not_available)) string.setSpan(ForegroundColorSpan(ThemeUtils.getColorFromAttribute(context, android.R.attr.textColorTertiary, textView.currentTextColor)), 0, string.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) itemView.quotedText.spannable = string itemView.quotedView.drawStart(ThemeUtils.getColorFromAttribute(context, R.attr.quoteIndicatorBackgroundColor)) } } else { itemView.quotedView.visibility = View.GONE } itemView.profileContainer.drawStart(colorNameManager.getUserColor(status.user_key)) val timestamp: Long if (status.is_retweet) { timestamp = status.retweet_timestamp } else { timestamp = status.timestamp } nameView.name = colorNameManager.getUserNickname(status.user_key, status.user_name) nameView.screenName = "@${status.user_acct}" nameView.updateText(formatter) adapter.requestManager.loadProfileImage(context, status, adapter.profileImageStyle, itemView.profileImage.cornerRadius, itemView.profileImage.cornerRadiusRatio, size = ProfileImageSize.ORIGINAL).into(itemView.profileImage) val typeIconRes = Utils.getUserTypeIconRes(status.user_is_verified, status.user_is_protected) val typeDescriptionRes = Utils.getUserTypeDescriptionRes(status.user_is_verified, status.user_is_protected) if (typeIconRes != 0 && typeDescriptionRes != 0) { profileTypeView.setImageResource(typeIconRes) profileTypeView.contentDescription = context.getString(typeDescriptionRes) profileTypeView.visibility = View.VISIBLE } else { profileTypeView.setImageDrawable(null) profileTypeView.contentDescription = null profileTypeView.visibility = View.GONE } val timeString = Utils.formatToLongTimeString(context, timestamp)?.takeIf(String::isNotEmpty) val source = status.source?.takeIf(String::isNotEmpty) itemView.timeSource.spannable = when { timeString != null && source != null -> { HtmlSpanBuilder.fromHtml(context.getString(R.string.status_format_time_source, timeString, source)) } source != null -> HtmlSpanBuilder.fromHtml(source) timeString != null -> timeString else -> null } itemView.timeSource.movementMethod = LinkMovementMethod.getInstance() val displayEnd = status.extras?.display_text_range?.getOrNull(1) ?: -1 val text = SpannableStringBuilder.valueOf(status.text_unescaped).apply { status.spans?.applyTo(this) linkify.applyAllLinks(this, status.account_key, layoutPosition.toLong(), status.is_possibly_sensitive, skipLinksInText) } summaryView.spannable = status.extras?.summary_text summaryView.hideIfEmpty() if (displayEnd != -1 && displayEnd <= text.length) { textView.spannable = text.subSequence(0, displayEnd) } else { textView.spannable = text } textView.hideIfEmpty() val location: ParcelableLocation? = status.location val placeFullName: String? = status.place_full_name if (!TextUtils.isEmpty(placeFullName)) { locationView.visibility = View.VISIBLE locationView.spannable = placeFullName locationView.isClickable = ParcelableLocationUtils.isValidLocation(location) } else if (ParcelableLocationUtils.isValidLocation(location)) { locationView.visibility = View.VISIBLE locationView.setText(R.string.action_view_map) locationView.isClickable = true } else { locationView.visibility = View.GONE locationView.spannable = null } val interactUsersAdapter = itemView.countsUsers.adapter as CountsUsersAdapter if (statusActivity != null) { updateStatusActivity(statusActivity) } else { interactUsersAdapter.setUsers(null) interactUsersAdapter.setCounts(status) } if (interactUsersAdapter.itemCount > 0) { itemView.countsUsers.visibility = View.VISIBLE itemView.countsUsersHeightHolder.visibility = View.INVISIBLE } else { itemView.countsUsers.visibility = View.GONE itemView.countsUsersHeightHolder.visibility = View.GONE } val media = status.media if (media?.isEmpty() ?: true) { itemView.mediaPreviewContainer.visibility = View.GONE itemView.mediaPreview.visibility = View.GONE itemView.mediaPreviewLoad.visibility = View.GONE itemView.mediaPreview.displayMedia() } else if (adapter.isDetailMediaExpanded) { itemView.mediaPreviewContainer.visibility = View.VISIBLE itemView.mediaPreview.visibility = View.VISIBLE itemView.mediaPreviewLoad.visibility = View.GONE itemView.mediaPreview.displayMedia(adapter.requestManager, media = media, accountKey = status.account_key, mediaClickListener = adapter.fragment) } else { itemView.mediaPreviewContainer.visibility = View.VISIBLE itemView.mediaPreview.visibility = View.GONE itemView.mediaPreviewLoad.visibility = View.VISIBLE itemView.mediaPreview.displayMedia() } if (TwitterCardUtils.isCardSupported(status)) { val size = TwitterCardUtils.getCardSize(status.card!!) if (size != null) { itemView.twitterCard.setCardSize(size.x, size.y) } else { itemView.twitterCard.setCardSize(0, 0) } val vc = TwitterCardViewFactory.from(status) itemView.twitterCard.viewController = vc if (vc != null) { itemView.twitterCard.visibility = View.VISIBLE } else { itemView.twitterCard.visibility = View.GONE } } else { itemView.twitterCard.viewController = null itemView.twitterCard.visibility = View.GONE } MenuUtils.setupForStatus(context, itemView.menuBar.menu, fragment.preferences, twitter, colorNameManager, status, adapter.statusAccount!!) val lang = status.lang if (CheckUtils.isValidLocale(lang) && account.isOfficial(context)) { val locale = Locale(lang) itemView.translateContainer.visibility = View.VISIBLE if (translation != null) { itemView.translateLabel.text = context.getString(R.string.label_translation) itemView.translateResult.visibility = View.VISIBLE itemView.translateResult.text = translation.text } else { itemView.translateLabel.text = context.getString(R.string.label_translate_from_language, locale.displayLanguage) itemView.translateResult.visibility = View.GONE } } else { itemView.translateLabel.setText(R.string.unknown_language) itemView.translateContainer.visibility = View.GONE } textView.setTextIsSelectable(true) itemView.translateResult.setTextIsSelectable(true) textView.movementMethod = LinkMovementMethod.getInstance() itemView.quotedText.movementMethod = null } override fun onClick(v: View) { val status = adapter.getStatus(layoutPosition) val fragment = adapter.fragment val preferences = fragment.preferences when (v) { itemView.mediaPreviewLoad -> { if (adapter.sensitiveContentEnabled || !status.is_possibly_sensitive) { adapter.isDetailMediaExpanded = true } else { val f = LoadSensitiveImageConfirmDialogFragment() f.show(fragment.childFragmentManager, "load_sensitive_image_confirm") } } itemView.profileContainer -> { val activity = fragment.activity IntentUtils.openUserProfile(activity, status.account_key, status.user_key, status.user_screen_name, status.extras?.user_statusnet_profile_url, preferences[newDocumentApiKey], Referral.STATUS, null) } retweetedByView -> { if (status.retweet_id != null) { IntentUtils.openUserProfile(adapter.context, status.account_key, status.retweeted_by_user_key, status.retweeted_by_user_screen_name, null, preferences[newDocumentApiKey], Referral.STATUS, null) } } locationView -> { val location = status.location if (!ParcelableLocationUtils.isValidLocation(location)) return IntentUtils.openMap(adapter.context, location.latitude, location.longitude) } itemView.quotedView -> { val quotedId = status.quoted_id ?: return IntentUtils.openStatus(adapter.context, status.account_key, quotedId) } itemView.translateLabel -> { fragment.loadTranslation(adapter.status) } } } override fun onMenuItemClick(item: MenuItem): Boolean { val layoutPosition = layoutPosition if (layoutPosition < 0) return false val fragment = adapter.fragment val status = adapter.getStatus(layoutPosition) val preferences = fragment.preferences val twitter = fragment.twitterWrapper val manager = fragment.userColorNameManager val activity = fragment.activity return MenuUtils.handleStatusClick(activity, fragment, fragment.childFragmentManager, preferences, manager, twitter, status, item) } internal fun updateStatusActivity(activity: StatusActivity) { val adapter = itemView.countsUsers.adapter as CountsUsersAdapter adapter.setUsers(activity.retweeters) adapter.setCounts(activity) } private fun initViews() { itemView.menuBar.setOnMenuItemClickListener(this) val fragment = adapter.fragment val activity = fragment.activity val inflater = activity.menuInflater val menu = itemView.menuBar.menu inflater.inflate(R.menu.menu_detail_status, menu) val favoriteItem = menu.findItem(R.id.favorite) val provider = MenuItemCompat.getActionProvider(favoriteItem) if (provider is FavoriteItemProvider) { val defaultColor = ThemeUtils.getActionIconColor(activity) provider.setDefaultColor(defaultColor) val favoriteHighlight = ContextCompat.getColor(activity, R.color.highlight_favorite) val likeHighlight = ContextCompat.getColor(activity, R.color.highlight_like) val useStar = adapter.useStarsForLikes provider.setActivatedColor(if (useStar) favoriteHighlight else likeHighlight) provider.setIcon(if (useStar) R.drawable.ic_action_star else R.drawable.ic_action_heart) provider.setUseStar(useStar) provider.init(itemView.menuBar, favoriteItem) } ThemeUtils.wrapMenuIcon(itemView.menuBar, excludeGroups = MENU_GROUP_STATUS_SHARE) itemView.mediaPreviewLoad.setOnClickListener(this) itemView.profileContainer.setOnClickListener(this) retweetedByView.setOnClickListener(this) locationView.setOnClickListener(this) itemView.quotedView.setOnClickListener(this) itemView.translateLabel.setOnClickListener(this) val textSize = adapter.textSize nameView.setPrimaryTextSize(textSize * 1.25f) nameView.setSecondaryTextSize(textSize * 0.85f) summaryView.textSize = textSize * 1.25f textView.textSize = textSize * 1.25f itemView.quotedName.setPrimaryTextSize(textSize * 1.25f) itemView.quotedName.setSecondaryTextSize(textSize * 0.85f) itemView.quotedText.textSize = textSize * 1.25f locationView.textSize = textSize * 0.85f itemView.timeSource.textSize = textSize * 0.85f itemView.translateLabel.textSize = textSize * 0.85f itemView.translateResult.textSize = textSize * 1.05f itemView.countsUsersHeightHolder.count.textSize = textSize * 1.25f itemView.countsUsersHeightHolder.label.textSize = textSize * 0.85f nameView.nameFirst = adapter.nameFirst itemView.quotedName.nameFirst = adapter.nameFirst itemView.mediaPreview.style = adapter.mediaPreviewStyle itemView.quotedMediaPreview.style = adapter.mediaPreviewStyle itemView.text.customSelectionActionModeCallback = StatusActionModeCallback(itemView.text, activity) itemView.profileImage.style = adapter.profileImageStyle val layoutManager = LinearLayoutManager(adapter.context) layoutManager.orientation = LinearLayoutManager.HORIZONTAL itemView.countsUsers.layoutManager = layoutManager val countsUsersAdapter = CountsUsersAdapter(fragment, adapter) itemView.countsUsers.adapter = countsUsersAdapter val resources = activity.resources itemView.countsUsers.addItemDecoration(SpacingItemDecoration(resources.getDimensionPixelOffset(R.dimen.element_spacing_normal))) // Apply font families nameView.applyFontFamily(adapter.lightFont) summaryView.applyFontFamily(adapter.lightFont) textView.applyFontFamily(adapter.lightFont) itemView.quotedName.applyFontFamily(adapter.lightFont) itemView.quotedText.applyFontFamily(adapter.lightFont) itemView.locationView.applyFontFamily(adapter.lightFont) itemView.translateLabel.applyFontFamily(adapter.lightFont) itemView.translateResult.applyFontFamily(adapter.lightFont) } private class CountsUsersAdapter( private val fragment: StatusFragment, private val statusAdapter: StatusAdapter ) : BaseRecyclerViewAdapter(statusAdapter.context, Glide.with(fragment)) { private val inflater = LayoutInflater.from(statusAdapter.context) private var counts: List? = null private var users: List? = null override fun onBindViewHolder(holder: ViewHolder, position: Int) { when (holder.itemViewType) { ITEM_VIEW_TYPE_USER -> { (holder as ProfileImageViewHolder).displayUser(getUser(position)!!) } ITEM_VIEW_TYPE_COUNT -> { (holder as CountViewHolder).displayCount(getCount(position)!!) } } } private fun getCount(position: Int): LabeledCount? { if (counts == null) return null if (position < countItemsCount) { return counts!![position] } return null } override fun getItemCount(): Int { return countItemsCount + usersCount } override fun getItemViewType(position: Int): Int { val countItemsCount = countItemsCount if (position < countItemsCount) { return ITEM_VIEW_TYPE_COUNT } return ITEM_VIEW_TYPE_USER } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { when (viewType) { ITEM_VIEW_TYPE_USER -> return ProfileImageViewHolder(this, inflater.inflate(R.layout.adapter_item_status_interact_user, parent, false)) ITEM_VIEW_TYPE_COUNT -> return CountViewHolder(this, inflater.inflate(R.layout.adapter_item_status_count_label, parent, false)) } throw UnsupportedOperationException("Unsupported viewType " + viewType) } fun setUsers(users: List?) { this.users = users notifyDataSetChanged() } fun setCounts(activity: StatusActivity?) { if (activity != null) { val counts = ArrayList() val replyCount = activity.replyCount if (replyCount > 0) { counts.add(LabeledCount(KEY_REPLY_COUNT, replyCount)) } val retweetCount = activity.retweetCount if (retweetCount > 0) { counts.add(LabeledCount(KEY_RETWEET_COUNT, retweetCount)) } val favoriteCount = activity.favoriteCount if (favoriteCount > 0) { counts.add(LabeledCount(KEY_FAVORITE_COUNT, favoriteCount)) } this.counts = counts } else { counts = null } notifyDataSetChanged() } fun setCounts(status: ParcelableStatus?) { if (status != null) { val counts = ArrayList() if (status.reply_count > 0) { counts.add(LabeledCount(KEY_REPLY_COUNT, status.reply_count)) } if (status.retweet_count > 0) { counts.add(LabeledCount(KEY_RETWEET_COUNT, status.retweet_count)) } if (status.favorite_count > 0) { counts.add(LabeledCount(KEY_FAVORITE_COUNT, status.favorite_count)) } this.counts = counts } else { counts = null } notifyDataSetChanged() } val countItemsCount: Int get() { if (counts == null) return 0 return counts!!.size } private val usersCount: Int get() { if (users == null) return 0 return users!!.size } private fun notifyItemClick(position: Int) { when (getItemViewType(position)) { ITEM_VIEW_TYPE_COUNT -> { val count = getCount(position) val status = statusAdapter.status if (count == null || status == null) return when (count.type) { KEY_RETWEET_COUNT -> { if (status.is_retweet) { IntentUtils.openStatusRetweeters(context, status.account_key, status.retweet_id) } else { IntentUtils.openStatusRetweeters(context, status.account_key, status.id) } } KEY_FAVORITE_COUNT -> { if (status.is_retweet) { IntentUtils.openStatusFavoriters(context, status.account_key, status.retweet_id) } else { IntentUtils.openStatusFavoriters(context, status.account_key, status.id) } } } } ITEM_VIEW_TYPE_USER -> { fragment.onUserClick(getUser(position)!!) } } } private fun getUser(position: Int): ParcelableUser? { val countItemsCount = countItemsCount if (users == null || position < countItemsCount) return null return users!![position - countItemsCount] } internal class ProfileImageViewHolder(private val adapter: CountsUsersAdapter, itemView: View) : ViewHolder(itemView), OnClickListener { private val profileImageView = itemView.findViewById(R.id.profileImage) as ProfileImageView init { itemView.setOnClickListener(this) } fun displayUser(item: ParcelableUser) { val context = adapter.context val requestManager = adapter.requestManager requestManager.loadProfileImage(context, item, adapter.profileImageStyle, profileImageView.cornerRadius, profileImageView.cornerRadiusRatio, adapter.profileImageSize).into(profileImageView) } override fun onClick(v: View) { adapter.notifyItemClick(layoutPosition) } } internal class CountViewHolder( private val adapter: CountsUsersAdapter, itemView: View ) : ViewHolder(itemView), OnClickListener { init { itemView.setOnClickListener(this) val textSize = adapter.textSize itemView.count.textSize = textSize * 1.25f itemView.label.textSize = textSize * 0.85f } override fun onClick(v: View) { adapter.notifyItemClick(layoutPosition) } fun displayCount(count: LabeledCount) { val label: String when (count.type) { KEY_REPLY_COUNT -> { label = adapter.context.getString(R.string.replies) } KEY_RETWEET_COUNT -> { label = adapter.context.getString(R.string.count_label_retweets) } KEY_FAVORITE_COUNT -> { label = adapter.context.getString(R.string.title_favorites) } else -> { throw UnsupportedOperationException("Unsupported type " + count.type) } } itemView.count.text = Utils.getLocalizedNumber(Locale.getDefault(), count.count) itemView.label.text = label } } internal class LabeledCount(var type: Int, var count: Long) companion object { private val ITEM_VIEW_TYPE_USER = 1 private val ITEM_VIEW_TYPE_COUNT = 2 private val KEY_REPLY_COUNT = 1 private val KEY_RETWEET_COUNT = 2 private val KEY_FAVORITE_COUNT = 3 } } private class DetailStatusLinkClickHandler( context: Context, manager: MultiSelectManager, private val adapter: StatusAdapter, preferences: SharedPreferences ) : StatusLinkClickHandler(context, manager, preferences) { override fun onLinkClick(link: String, orig: String?, accountKey: UserKey?, extraId: Long, type: Int, sensitive: Boolean, start: Int, end: Int): Boolean { val position = extraId.toInt() val current = getCurrentMedia(link, position) if (current != null && !current.open_browser) { expandOrOpenMedia(current) return true } return super.onLinkClick(link, orig, accountKey, extraId, type, sensitive, start, end) } private fun expandOrOpenMedia(current: ParcelableMedia) { if (adapter.isDetailMediaExpanded) { IntentUtils.openMedia(adapter.context, adapter.status!!, current, preferences[newDocumentApiKey], preferences[displaySensitiveContentsKey]) return } adapter.isDetailMediaExpanded = true } override fun isMedia(link: String, extraId: Long): Boolean { val current = getCurrentMedia(link, extraId.toInt()) return current != null && !current.open_browser } private fun getCurrentMedia(link: String, extraId: Int): ParcelableMedia? { val status = adapter.getStatus(extraId) val media = ParcelableMediaUtils.getAllMedia(status) return StatusLinkClickHandler.findByLink(media, link) } } private class SpacingItemDecoration(private val spacing: Int) : RecyclerView.ItemDecoration() { override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State?) { if (ViewCompat.getLayoutDirection(parent) == ViewCompat.LAYOUT_DIRECTION_RTL) { outRect.set(spacing, 0, 0, 0) } else { outRect.set(0, 0, spacing, 0) } } } } private class SpaceViewHolder(itemView: View) : ViewHolder(itemView) class StatusAdapter( val fragment: StatusFragment ) : LoadMoreSupportAdapter(fragment.context, Glide.with(fragment)), IStatusesAdapter>, IItemCountsAdapter { override val twidereLinkify: TwidereLinkify override var statusClickListener: StatusClickListener? = null override val itemCounts = ItemCounts(ITEM_TYPES_SUM) override val nameFirst = preferences[nameFirstKey] override val mediaPreviewStyle = preferences[mediaPreviewStyleKey] override val linkHighlightingStyle = preferences[linkHighlightOptionKey] override val lightFont = preferences[lightFontKey] override val mediaPreviewEnabled = preferences[mediaPreviewKey] override val sensitiveContentEnabled = preferences[displaySensitiveContentsKey] override val useStarsForLikes = preferences[iWantMyStarsBackKey] private val inflater: LayoutInflater private val cardBackgroundColor: Int private val showCardActions = !preferences[hideCardActionsKey] private var recyclerView: RecyclerView? = null private var detachedStatusViewHolder: DetailStatusViewHolder? = null private var mDetailMediaExpanded: Boolean = false var status: ParcelableStatus? = null internal set var translationResult: TranslationResult? = null internal set(translation) { if (translation == null || status?.originalId != translation.id) { field = null } else { field = translation } notifyDataSetChanged() } var statusActivity: StatusActivity? = null internal set(value) { val status = status ?: return if (value != null && !value.isStatus(status)) { return } field = value val statusIndex = getIndexStart(ITEM_IDX_STATUS) notifyItemChanged(statusIndex, value) } var statusAccount: AccountDetails? = null internal set private var data: List? = null private var replyError: CharSequence? = null private var conversationError: CharSequence? = null private var replyStart: Int = 0 private var showingActionCardPosition: Int = 0 init { setHasStableIds(true) val context = fragment.activity // There's always a space at the end of the list itemCounts[ITEM_IDX_SPACE] = 1 itemCounts[ITEM_IDX_STATUS] = 1 itemCounts[ITEM_IDX_CONVERSATION_LOAD_MORE] = 1 itemCounts[ITEM_IDX_REPLY_LOAD_MORE] = 1 inflater = LayoutInflater.from(context) cardBackgroundColor = ThemeUtils.getCardBackgroundColor(context, preferences[themeBackgroundOptionKey], preferences[themeBackgroundAlphaKey]) val listener = StatusAdapterLinkClickHandler>(context, preferences) listener.setAdapter(this) twidereLinkify = TwidereLinkify(listener) } override fun getStatus(position: Int, raw: Boolean): ParcelableStatus { when (getItemCountIndex(position, raw)) { ITEM_IDX_CONVERSATION -> { var idx = position - getIndexStart(ITEM_IDX_CONVERSATION) if (data!![idx].is_filtered) idx++ return data!![idx] } ITEM_IDX_REPLY -> { var idx = position - getIndexStart(ITEM_IDX_CONVERSATION) - getTypeCount(ITEM_IDX_CONVERSATION) - getTypeCount(ITEM_IDX_STATUS) + replyStart if (data!![idx].is_filtered) idx++ return data!![idx] } ITEM_IDX_STATUS -> { return status!! } } throw IndexOutOfBoundsException("index: $position") } fun getIndexStart(index: Int): Int { if (index == 0) return 0 return itemCounts.getItemStartPosition(index) } override fun getStatusId(position: Int, raw: Boolean): String { return getStatus(position, raw).id } override fun getStatusTimestamp(position: Int, raw: Boolean): Long { return getStatus(position, raw).timestamp } override fun getStatusPositionKey(position: Int, raw: Boolean): Long { val status = getStatus(position, raw) return if (status.position_key > 0) status.timestamp else getStatusTimestamp(position, raw) } override fun getAccountKey(position: Int, raw: Boolean) = getStatus(position, raw).account_key override fun findStatusById(accountKey: UserKey, statusId: String): ParcelableStatus? { if (status != null && accountKey == status!!.account_key && TextUtils.equals(statusId, status!!.id)) { return status } return data?.firstOrNull { accountKey == it.account_key && TextUtils.equals(it.id, statusId) } } override fun getStatusCount(raw: Boolean): Int { return getTypeCount(ITEM_IDX_CONVERSATION) + getTypeCount(ITEM_IDX_STATUS) + getTypeCount(ITEM_IDX_REPLY) } override fun isCardActionsShown(position: Int): Boolean { if (position == RecyclerView.NO_POSITION) return showCardActions return showCardActions || showingActionCardPosition == position } override fun showCardActions(position: Int) { if (showingActionCardPosition != RecyclerView.NO_POSITION) { notifyItemChanged(showingActionCardPosition) } showingActionCardPosition = position if (position != RecyclerView.NO_POSITION) { notifyItemChanged(position) } } override fun setData(data: List?): Boolean { val status = this.status ?: return false val changed = this.data != data this.data = data if (data == null || data.isEmpty()) { setTypeCount(ITEM_IDX_CONVERSATION, 0) setTypeCount(ITEM_IDX_REPLY, 0) replyStart = -1 } else { var sortId = status.sort_id if (status.is_retweet) { sortId = data.find { it.id == status.retweet_id }?.sort_id ?: status.retweet_timestamp } var conversationCount = 0 var replyCount = 0 var replyStart = -1 data.forEachIndexed { i, item -> if (item.sort_id < sortId) { if (!item.is_filtered) { conversationCount++ } } else if (status.id == item.id) { this.status = item } else if (item.sort_id > sortId) { if (replyStart < 0) { replyStart = i } if (!item.is_filtered) { replyCount++ } } } setTypeCount(ITEM_IDX_CONVERSATION, conversationCount) setTypeCount(ITEM_IDX_REPLY, replyCount) this.replyStart = replyStart } notifyDataSetChanged() updateItemDecoration() return changed } override val showAccountsColor: Boolean get() = false var isDetailMediaExpanded: Boolean get() { if (mDetailMediaExpanded) return true if (mediaPreviewEnabled) { val status = this.status return status != null && (sensitiveContentEnabled || !status.is_possibly_sensitive) } return false } set(expanded) { mDetailMediaExpanded = expanded notifyDataSetChanged() updateItemDecoration() } override fun isGapItem(position: Int): Boolean { return false } override val gapClickListener: IGapSupportedAdapter.GapClickListener? get() = statusClickListener override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder? { when (viewType) { VIEW_TYPE_DETAIL_STATUS -> { if (detachedStatusViewHolder != null) { return detachedStatusViewHolder } val view = inflater.inflate(R.layout.header_status, parent, false) view.setBackgroundColor(cardBackgroundColor) return DetailStatusViewHolder(this, view) } VIEW_TYPE_LIST_STATUS -> { return ListParcelableStatusesAdapter.createStatusViewHolder(this, inflater, parent) } VIEW_TYPE_CONVERSATION_LOAD_INDICATOR, VIEW_TYPE_REPLIES_LOAD_INDICATOR -> { val view = inflater.inflate(R.layout.list_item_load_indicator, parent, false) return LoadIndicatorViewHolder(view) } VIEW_TYPE_SPACE -> { return SpaceViewHolder(Space(context)) } VIEW_TYPE_REPLY_ERROR -> { val view = inflater.inflate(R.layout.adapter_item_status_error, parent, false) return StatusErrorItemViewHolder(view) } } return null } override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: List) { var handled = false when (holder.itemViewType) { VIEW_TYPE_DETAIL_STATUS -> { holder as DetailStatusViewHolder payloads.forEach { it -> when (it) { is StatusActivity -> { holder.updateStatusActivity(it) } is ParcelableStatus -> { holder.displayStatus(statusAccount, status, statusActivity, translationResult) } } handled = true } } } if (handled) return super.onBindViewHolder(holder, position, payloads) } override fun onBindViewHolder(holder: ViewHolder, position: Int) { when (holder.itemViewType) { VIEW_TYPE_DETAIL_STATUS -> { val status = getStatus(position) val detailHolder = holder as DetailStatusViewHolder detailHolder.displayStatus(statusAccount, status, statusActivity, translationResult) } VIEW_TYPE_LIST_STATUS -> { val status = getStatus(position) val statusHolder = holder as IStatusViewHolder // Display 'in reply to' for first item // useful to indicate whether first tweet has reply or not // We only display that indicator for first conversation item val itemType = getItemType(position) val displayInReplyTo = itemType == ITEM_IDX_CONVERSATION && position - getItemTypeStart(position) == 0 statusHolder.display(status = status, displayInReplyTo = displayInReplyTo) } VIEW_TYPE_REPLY_ERROR -> { val errorHolder = holder as StatusErrorItemViewHolder errorHolder.showError(replyError!!) } VIEW_TYPE_CONVERSATION_ERROR -> { val errorHolder = holder as StatusErrorItemViewHolder errorHolder.showError(conversationError!!) } VIEW_TYPE_CONVERSATION_LOAD_INDICATOR -> { val indicatorHolder = holder as LoadIndicatorViewHolder indicatorHolder.setLoadProgressVisible(isConversationsLoading) } VIEW_TYPE_REPLIES_LOAD_INDICATOR -> { val indicatorHolder = holder as LoadIndicatorViewHolder indicatorHolder.setLoadProgressVisible(isRepliesLoading) } } } override fun onViewDetachedFromWindow(holder: ViewHolder?) { if (holder is DetailStatusViewHolder) { detachedStatusViewHolder = holder as DetailStatusViewHolder? } super.onViewDetachedFromWindow(holder) } override fun onViewAttachedToWindow(holder: ViewHolder?) { if (holder === detachedStatusViewHolder) { detachedStatusViewHolder = null } super.onViewAttachedToWindow(holder) } override fun getItemViewType(position: Int): Int { return getItemViewTypeByItemType(getItemType(position)) } override fun addGapLoadingId(id: ObjectId) { } override fun removeGapLoadingId(id: ObjectId) { } private fun getItemViewTypeByItemType(type: Int): Int { when (type) { ITEM_IDX_CONVERSATION, ITEM_IDX_REPLY -> return VIEW_TYPE_LIST_STATUS ITEM_IDX_CONVERSATION_LOAD_MORE -> return VIEW_TYPE_CONVERSATION_LOAD_INDICATOR ITEM_IDX_REPLY_LOAD_MORE -> return VIEW_TYPE_REPLIES_LOAD_INDICATOR ITEM_IDX_STATUS -> return VIEW_TYPE_DETAIL_STATUS ITEM_IDX_SPACE -> return VIEW_TYPE_SPACE ITEM_IDX_REPLY_ERROR -> return VIEW_TYPE_REPLY_ERROR ITEM_IDX_CONVERSATION_ERROR -> return VIEW_TYPE_CONVERSATION_ERROR } throw IllegalStateException() } private fun getItemCountIndex(position: Int, raw: Boolean): Int { return itemCounts.getItemCountIndex(position) } fun getItemType(position: Int): Int { var typeStart = 0 for (type in 0..ITEM_TYPES_SUM - 1) { val typeCount = getTypeCount(type) val typeEnd = typeStart + typeCount if (position in typeStart until typeEnd) return type typeStart = typeEnd } throw IllegalStateException("Unknown position " + position) } fun getItemTypeStart(position: Int): Int { var typeStart = 0 for (type in 0..ITEM_TYPES_SUM - 1) { val typeCount = getTypeCount(type) val typeEnd = typeStart + typeCount if (position in typeStart until typeEnd) return typeStart typeStart = typeEnd } throw IllegalStateException() } override fun getItemId(position: Int): Long { val countIndex = getItemCountIndex(position) when (countIndex) { ITEM_IDX_CONVERSATION, ITEM_IDX_STATUS, ITEM_IDX_REPLY -> { val status = getStatus(position) val hashCode = ParcelableStatus.calculateHashCode(status.account_key, status.id) return (countIndex.toLong() shl 32) or hashCode.toLong() } } val countPos = (position - getItemStartPosition(countIndex)).toLong() return (countIndex.toLong() shl 32) or countPos } override fun getItemCount(): Int { if (status == null) return 0 return itemCounts.itemCount } override fun onAttachedToRecyclerView(recyclerView: RecyclerView?) { super.onAttachedToRecyclerView(recyclerView) this.recyclerView = recyclerView } override fun onDetachedFromRecyclerView(recyclerView: RecyclerView?) { super.onDetachedFromRecyclerView(recyclerView) this.recyclerView = null } private fun setTypeCount(idx: Int, size: Int) { itemCounts[idx] = size notifyDataSetChanged() } fun getTypeCount(idx: Int): Int { return itemCounts[idx] } fun setReplyError(error: CharSequence?) { replyError = error setTypeCount(ITEM_IDX_REPLY_ERROR, if (error != null) 1 else 0) updateItemDecoration() } fun setConversationError(error: CharSequence?) { conversationError = error setTypeCount(ITEM_IDX_CONVERSATION_ERROR, if (error != null) 1 else 0) updateItemDecoration() } fun setStatus(status: ParcelableStatus, account: AccountDetails?): Boolean { val oldStatus = this.status val oldAccount = this.statusAccount val changed = oldStatus != status && oldAccount != account this.status = status this.statusAccount = account if (changed) { notifyDataSetChanged() updateItemDecoration() } else { val statusIndex = getIndexStart(ITEM_IDX_STATUS) notifyItemChanged(statusIndex, status) } return changed } fun updateItemDecoration() { if (recyclerView == null) return } fun getFirstPositionOfItem(itemIdx: Int): Int { var position = 0 for (i in 0..ITEM_TYPES_SUM - 1) { if (itemIdx == i) return position position += getTypeCount(i) } return RecyclerView.NO_POSITION } fun getData(): List? { return data } var isConversationsLoading: Boolean get() = ILoadMoreSupportAdapter.START in loadMoreIndicatorPosition set(loading) { if (loading) { loadMoreIndicatorPosition = loadMoreIndicatorPosition or ILoadMoreSupportAdapter.START } else { loadMoreIndicatorPosition = loadMoreIndicatorPosition and ILoadMoreSupportAdapter.START.inv() } updateItemDecoration() } var isRepliesLoading: Boolean get() = ILoadMoreSupportAdapter.END in loadMoreIndicatorPosition set(loading) { if (loading) { loadMoreIndicatorPosition = loadMoreIndicatorPosition or ILoadMoreSupportAdapter.END } else { loadMoreIndicatorPosition = loadMoreIndicatorPosition and ILoadMoreSupportAdapter.END.inv() } updateItemDecoration() } class StatusErrorItemViewHolder(itemView: View) : ViewHolder(itemView) { private val textView = itemView.findViewById(android.R.id.text1) as TextView init { textView.movementMethod = LinkMovementMethod.getInstance() textView.linksClickable = true } fun showError(text: CharSequence) { textView.text = text } } companion object { const val VIEW_TYPE_LIST_STATUS = 0 const val VIEW_TYPE_DETAIL_STATUS = 1 const val VIEW_TYPE_CONVERSATION_LOAD_INDICATOR = 2 const val VIEW_TYPE_REPLIES_LOAD_INDICATOR = 3 const val VIEW_TYPE_REPLY_ERROR = 4 const val VIEW_TYPE_CONVERSATION_ERROR = 5 const val VIEW_TYPE_SPACE = 6 const val VIEW_TYPE_EMPTY = 7 const val ITEM_IDX_CONVERSATION_LOAD_MORE = 0 const val ITEM_IDX_CONVERSATION_ERROR = 1 const val ITEM_IDX_CONVERSATION = 2 const val ITEM_IDX_STATUS = 3 const val ITEM_IDX_REPLY = 4 const val ITEM_IDX_REPLY_ERROR = 5 const val ITEM_IDX_REPLY_LOAD_MORE = 6 const val ITEM_IDX_SPACE = 7 const val ITEM_TYPES_SUM = 8 } } private class StatusListLinearLayoutManager(context: Context, private val recyclerView: RecyclerView) : FixedLinearLayoutManager(context) { private var spaceHeight: Int = 0 init { orientation = LinearLayoutManager.VERTICAL } override fun getDecoratedMeasuredHeight(child: View): Int { if (getItemViewType(child) == StatusAdapter.VIEW_TYPE_SPACE) { val height = calculateSpaceItemHeight(child, StatusAdapter.VIEW_TYPE_SPACE, StatusAdapter.VIEW_TYPE_DETAIL_STATUS) if (height >= 0) { return height } } return super.getDecoratedMeasuredHeight(child) } override fun setOrientation(orientation: Int) { if (orientation != LinearLayoutManager.VERTICAL) throw IllegalArgumentException("Only VERTICAL orientation supported") super.setOrientation(orientation) } override fun computeVerticalScrollExtent(state: RecyclerView.State?): Int { val firstPosition = findFirstVisibleItemPosition() val lastPosition = Math.min(validScrollItemCount - 1, findLastVisibleItemPosition()) if (firstPosition < 0 || lastPosition < 0) return 0 val childCount = lastPosition - firstPosition + 1 if (childCount > 0) { if (isSmoothScrollbarEnabled) { var extent = childCount * 100 var view = findViewByPosition(firstPosition) ?: return 0 val top = view.top var height = view.height if (height > 0) { extent += top * 100 / height } view = findViewByPosition(lastPosition) ?: return 0 val bottom = view.bottom height = view.height if (height > 0) { extent -= (bottom - getHeight()) * 100 / height } return extent } else { return 1 } } return 0 } override fun computeVerticalScrollOffset(state: RecyclerView.State?): Int { val firstPosition = findFirstVisibleItemPosition() val lastPosition = Math.min(validScrollItemCount - 1, findLastVisibleItemPosition()) if (firstPosition < 0 || lastPosition < 0) return 0 val childCount = lastPosition - firstPosition + 1 val skippedCount = skippedScrollItemCount if (firstPosition >= skippedCount && childCount > 0) { if (isSmoothScrollbarEnabled) { val view = findViewByPosition(firstPosition) ?: return 0 val top = view.top val height = view.height if (height > 0) { return Math.max((firstPosition - skippedCount) * 100 - top * 100 / height, 0) } } else { val index: Int val count = validScrollItemCount if (firstPosition == 0) { index = 0 } else if (firstPosition + childCount == count) { index = count } else { index = firstPosition + childCount / 2 } return (firstPosition + childCount * (index / count.toFloat())).toInt() } } return 0 } override fun computeVerticalScrollRange(state: RecyclerView.State?): Int { val result: Int if (isSmoothScrollbarEnabled) { result = Math.max(validScrollItemCount * 100, 0) } else { result = validScrollItemCount } return result } private val skippedScrollItemCount: Int get() { val adapter = recyclerView.adapter as StatusAdapter var skipped = 0 if (!adapter.isConversationsLoading) { skipped += adapter.getTypeCount(StatusAdapter.ITEM_IDX_CONVERSATION_LOAD_MORE) } return skipped } private val validScrollItemCount: Int get() { val adapter = recyclerView.adapter as StatusAdapter var count = 0 if (adapter.isConversationsLoading) { count += adapter.getTypeCount(StatusAdapter.ITEM_IDX_CONVERSATION_LOAD_MORE) } count += adapter.getTypeCount(StatusAdapter.ITEM_IDX_CONVERSATION_ERROR) count += adapter.getTypeCount(StatusAdapter.ITEM_IDX_CONVERSATION) count += adapter.getTypeCount(StatusAdapter.ITEM_IDX_STATUS) count += adapter.getTypeCount(StatusAdapter.ITEM_IDX_REPLY) count += adapter.getTypeCount(StatusAdapter.ITEM_IDX_REPLY_ERROR) if (adapter.isRepliesLoading) { count += adapter.getTypeCount(StatusAdapter.ITEM_IDX_REPLY_LOAD_MORE) } val spaceHeight = calculateSpaceHeight() if (spaceHeight > 0) { count += adapter.getTypeCount(StatusAdapter.ITEM_IDX_SPACE) } return count } private fun calculateSpaceHeight(): Int { val space = findViewByPosition(itemCount - 1) ?: return spaceHeight spaceHeight = getDecoratedMeasuredHeight(space) return spaceHeight } } class StatusActivitySummaryLoader( context: Context, private val accountKey: UserKey, private val statusId: String ) : FixedAsyncTaskLoader(context) { override fun loadInBackground(): StatusActivity? { val context = context val details = AccountUtils.getAccountDetails(AccountManager.get(context), accountKey, true) ?: return null if (AccountType.TWITTER != details.type) { return null } val twitter = MicroBlogAPIFactory.getInstance(context, accountKey) ?: return null val paging = Paging() paging.setCount(10) val activitySummary = StatusActivity(statusId, emptyList()) try { activitySummary.retweeters = twitter.getRetweets(statusId, paging) .filterNot { DataStoreUtils.isFilteringUser(context, it.user.key) } .distinctBy { it.user.id } .map { it.user.toParcelable(details) } val countValues = ContentValues() val status = twitter.showStatus(statusId) activitySummary.favoriteCount = status.favoriteCount activitySummary.retweetCount = status.retweetCount activitySummary.replyCount = status.replyCount countValues.put(Statuses.REPLY_COUNT, activitySummary.replyCount) countValues.put(Statuses.FAVORITE_COUNT, activitySummary.favoriteCount) countValues.put(Statuses.RETWEET_COUNT, activitySummary.retweetCount) val cr = context.contentResolver val statusWhere = Expression.and( Expression.equalsArgs(Statuses.ACCOUNT_KEY), Expression.or( Expression.equalsArgs(Statuses.ID), Expression.equalsArgs(Statuses.RETWEET_ID))) val statusWhereArgs = arrayOf(accountKey.toString(), statusId, statusId) cr.update(Statuses.CONTENT_URI, countValues, statusWhere.sql, statusWhereArgs) cr.updateStatusInfo(DataStoreUtils.STATUSES_ACTIVITIES_URIS, Statuses.COLUMNS, accountKey, statusId, ParcelableStatus::class.java) { item -> item.favorite_count = activitySummary.favoriteCount item.reply_count = activitySummary.replyCount item.retweet_count = activitySummary.retweetCount } val pStatus = status.toParcelable(details) cr.insert(CachedStatuses.CONTENT_URI, ObjectCursor .valuesCreatorFrom(ParcelableStatus::class.java).create(pStatus)) return activitySummary } catch (e: MicroBlogException) { return null } } override fun onStartLoading() { forceLoad() } } data class StatusActivity( var statusId: String, var retweeters: List, var favoriteCount: Long = 0, var replyCount: Long = -1, var retweetCount: Long = 0 ) { fun isStatus(status: ParcelableStatus): Boolean { return statusId == status.retweet_id ?: status.id } } data class ReadPosition(var statusId: Long, var offsetTop: Int) private class StatusDividerItemDecoration( context: Context, private val statusAdapter: StatusAdapter, orientation: Int ) : ExtendedDividerItemDecoration(context, orientation) { override fun isDividerEnabled(childPos: Int): Boolean { if (childPos >= statusAdapter.itemCount || childPos < 0) return false val itemType = statusAdapter.getItemType(childPos) when (itemType) { StatusAdapter.ITEM_IDX_REPLY_LOAD_MORE, StatusAdapter.ITEM_IDX_REPLY_ERROR, StatusAdapter.ITEM_IDX_SPACE -> return false } return true } } companion object { // Constants private val LOADER_ID_DETAIL_STATUS = 1 private val LOADER_ID_STATUS_CONVERSATIONS = 2 private val LOADER_ID_STATUS_ACTIVITY = 3 private val STATE_LOADED = 1 private val STATE_LOADING = 2 private val STATE_ERROR = 3 fun Bundle.toPagination(): Pagination { val maxId = getString(EXTRA_MAX_ID) val sinceId = getString(EXTRA_SINCE_ID) val maxSortId = getLong(EXTRA_MAX_SORT_ID) val sinceSortId = getLong(EXTRA_SINCE_SORT_ID) return SinceMaxPagination().apply { this.maxId = maxId this.sinceId = sinceId this.maxSortId = maxSortId this.sinceSortId = sinceSortId } } } }