diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt b/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt index 3ad05edac..409b83667 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt @@ -349,7 +349,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI accountDisplayNameTextView.text = CustomEmojiHelper.emojifyString(account.name, account.emojis, accountDisplayNameTextView) val emojifiedNote = CustomEmojiHelper.emojifyText(account.note, account.emojis, accountNoteTextView) - LinkHelper.setClickableText(accountNoteTextView, emojifiedNote, null, this) + LinkHelper.setClickableText(accountNoteTextView, emojifiedNote, null, this, false) accountFieldAdapter.fields = account.fields ?: emptyList() accountFieldAdapter.emojis = account.emojis ?: emptyList() diff --git a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt index b76bbab5d..9f61e51d7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt @@ -17,10 +17,10 @@ package com.keylesspalace.tusky import android.content.Intent import android.os.Bundle -import androidx.annotation.VisibleForTesting -import com.google.android.material.bottomsheet.BottomSheetBehavior import android.view.View import android.widget.LinearLayout +import androidx.annotation.VisibleForTesting +import com.google.android.material.bottomsheet.BottomSheetBehavior import com.keylesspalace.tusky.entity.SearchResults import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.LinkHelper @@ -173,6 +173,7 @@ abstract class BottomSheetActivity : BaseActivity() { // https://pleroma.foo.bar/users/43456787654678 // https://pleroma.foo.bar/notice/43456787654678 // https://pleroma.foo.bar/objects/d4643c42-3ae0-4b73-b8b0-c725f5819207 +// https://mastodon.foo.bar/users/User/statuses/000000000000000000 fun looksLikeMastodonUrl(urlString: String): Boolean { val uri: URI try { @@ -192,5 +193,6 @@ fun looksLikeMastodonUrl(urlString: String): Boolean { path.matches("^/users/[^/]+$".toRegex()) || path.matches("^/@[^/]+/\\d+$".toRegex()) || path.matches("^/notice/\\d+$".toRegex()) || - path.matches("^/objects/[-a-f0-9]+$".toRegex()) + path.matches("^/objects/[-a-f0-9]+$".toRegex()) || + path.matches("^/users/[^/]+/statuses/[0-9]+$".toRegex()) } diff --git a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java index 33c21cbb0..c11e49631 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java @@ -187,6 +187,8 @@ public final class ComposeActivity private static final String SAVED_JSON_DESCRIPTIONS_EXTRA = "saved_json_descriptions"; private static final String TOOT_VISIBILITY_EXTRA = "toot_visibility"; private static final String IN_REPLY_TO_ID_EXTRA = "in_reply_to_id"; + private static final String QUOTE_ID_EXTRA = "quote_id"; + private static final String QUOTE_URL_EXTRA = "quote_url"; private static final String REPLY_VISIBILITY_EXTRA = "reply_visibility"; private static final String CONTENT_WARNING_EXTRA = "content_warning"; private static final String MENTIONED_USERNAMES_EXTRA = "mentioned_usernames"; @@ -201,6 +203,7 @@ public final class ComposeActivity private static final int MEDIA_DESCRIPTION_CHARACTER_LIMIT = 420; private static final String[] CAN_USE_UNLEAKABLE = {"itabashi.0j0.jp", "odakyu.app"}; + private static final String[] CAN_USE_QUOTE_ID = {"odakyu.app", "biwakodon.com", "dtp-mstdn.jp", "nitiasa.com"}; @Inject public MastodonApi mastodonApi; @@ -235,6 +238,8 @@ public final class ComposeActivity // this only exists when a status is trying to be sent, but uploads are still occurring private ProgressDialog finishingUploadDialog; private String inReplyToId; + private String quoteId; + private String quoteUrl; private List mediaQueued = new ArrayList<>(); private CountUpDownLatch waitForMediaLatch; private NewPoll poll; @@ -270,6 +275,7 @@ public final class ComposeActivity replyTextView = findViewById(R.id.composeReplyView); replyContentTextView = findViewById(R.id.composeReplyContentView); + TextView quoteTextView = findViewById(R.id.composeQuoteView); textEditor = findViewById(R.id.composeEditField); mediaPreviewBar = findViewById(R.id.compose_media_preview_bar); contentWarningBar = findViewById(R.id.composeContentWarningBar); @@ -441,6 +447,8 @@ public final class ComposeActivity ArrayList loadedDraftMediaDescriptions = null; ArrayList mediaAttachments = null; inReplyToId = null; + quoteId = null; + quoteUrl = null; if (intent != null) { if (startingVisibility == Status.Visibility.UNKNOWN) { @@ -453,6 +461,14 @@ public final class ComposeActivity inReplyToId = intent.getStringExtra(IN_REPLY_TO_ID_EXTRA); + quoteId = intent.getStringExtra(QUOTE_ID_EXTRA); + + if (intent.hasExtra(QUOTE_URL_EXTRA)) { + quoteTextView.setVisibility(View.VISIBLE); + quoteUrl = intent.getStringExtra(QUOTE_URL_EXTRA); + quoteTextView.setText(String.format(getString(R.string.quote_to), quoteUrl)); + } + mentionedUsernames = intent.getStringArrayExtra(MENTIONED_USERNAMES_EXTRA); String contentWarning = intent.getStringExtra(CONTENT_WARNING_EXTRA); @@ -1086,7 +1102,7 @@ public final class ComposeActivity } private void sendStatus(String content, Status.Visibility visibility, boolean sensitive, - String spoilerText) { + String spoilerText, @Nullable String quoteId, @Nullable String quoteUrl) { ArrayList mediaIds = new ArrayList<>(); ArrayList mediaUris = new ArrayList<>(); ArrayList mediaDescriptions = new ArrayList<>(); @@ -1096,12 +1112,22 @@ public final class ComposeActivity mediaDescriptions.add(item.description); } - Intent sendIntent = SendTootService.sendTootIntent(this, content, spoilerText, + Intent sendIntent; + AccountEntity activeAccount = accountManager.getActiveAccount(); + + if (activeAccount != null + && !Arrays.asList(CAN_USE_QUOTE_ID).contains(activeAccount.getDomain()) + && quoteUrl != null) { + content += "\n~~~~~~~~~~\n[" + quoteUrl + "]"; + quoteId = null; + } + + sendIntent = SendTootService.sendTootIntent(this, content, spoilerText, visibility, !mediaUris.isEmpty() && sensitive, mediaIds, mediaUris, mediaDescriptions, inReplyToId, poll, getIntent().getStringExtra(REPLYING_STATUS_CONTENT_EXTRA), getIntent().getStringExtra(REPLYING_STATUS_AUTHOR_USERNAME_EXTRA), getIntent().getStringExtra(SAVED_JSON_URLS_EXTRA), - accountManager.getActiveAccount(), savedTootUid); + quoteId, accountManager.getActiveAccount(), savedTootUid); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { startForegroundService(sendIntent); @@ -1169,7 +1195,7 @@ public final class ComposeActivity textEditor.setError(getString(R.string.error_empty)); enableButtons(); } else if (characterCount <= maximumTootCharacters) { - sendStatus(contentText, visibility, sensitive, spoilerText); + sendStatus(contentText, visibility, sensitive, spoilerText, quoteId, quoteUrl); } else { textEditor.setError(getString(R.string.error_compose_character_limit)); @@ -2074,6 +2100,10 @@ public final class ComposeActivity @Nullable private String inReplyToId; @Nullable + private String quoteId; + @Nullable + private String quoteUrl; + @Nullable private Status.Visibility replyVisibility; @Nullable private Status.Visibility visibility; @@ -2125,6 +2155,16 @@ public final class ComposeActivity return this; } + public IntentBuilder quoteId(String quoteId) { + this.quoteId = quoteId; + return this; + } + + public IntentBuilder quoteUrl(String quoteUrl) { + this.quoteUrl = quoteUrl; + return this; + } + public IntentBuilder replyVisibility(Status.Visibility replyVisibility) { this.replyVisibility = replyVisibility; return this; @@ -2182,6 +2222,12 @@ public final class ComposeActivity if (inReplyToId != null) { intent.putExtra(IN_REPLY_TO_ID_EXTRA, inReplyToId); } + if (quoteId != null) { + intent.putExtra(QUOTE_ID_EXTRA, quoteId); + } + if (quoteUrl != null) { + intent.putExtra(QUOTE_URL_EXTRA, quoteUrl); + } if (replyVisibility != null) { intent.putExtra(REPLY_VISIBILITY_EXTRA, replyVisibility.getNum()); } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldAdapter.kt index 8e94c7063..594079798 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldAdapter.kt @@ -15,11 +15,11 @@ package com.keylesspalace.tusky.adapter -import androidx.recyclerview.widget.RecyclerView import android.view.LayoutInflater -import android.view.ViewGroup import android.view.View +import android.view.ViewGroup import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Field @@ -47,7 +47,7 @@ class AccountFieldAdapter(private val linkListener: LinkListener) : RecyclerView viewHolder.nameTextView.text = emojifiedName val emojifiedValue = CustomEmojiHelper.emojifyText(field.value, emojis, viewHolder.valueTextView) - LinkHelper.setClickableText(viewHolder.valueTextView, emojifiedValue, null, linkListener) + LinkHelper.setClickableText(viewHolder.valueTextView, emojifiedValue, null, linkListener, false) if(field.verifiedAt != null) { viewHolder.valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java index 0602eb10b..4df7f9c64 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -33,10 +33,18 @@ import android.widget.ImageView; import android.widget.TextView; import android.widget.ToggleButton; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.core.content.ContextCompat; +import androidx.core.text.BidiFormatter; +import androidx.recyclerview.widget.RecyclerView; + import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.Notification; +import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.LinkListener; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.CustomEmojiHelper; @@ -48,17 +56,13 @@ import com.keylesspalace.tusky.viewdata.NotificationViewData; import com.keylesspalace.tusky.viewdata.StatusViewData; import com.mikepenz.iconics.utils.Utils; +import net.accelf.yuito.QuoteInlineHelper; + import java.text.SimpleDateFormat; import java.util.Date; import java.util.List; import java.util.Locale; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; -import androidx.core.text.BidiFormatter; -import androidx.recyclerview.widget.RecyclerView; - public class NotificationsAdapter extends RecyclerView.Adapter { public interface AdapterDataSource { @@ -352,6 +356,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { private final TextView contentWarningDescriptionTextView; private final ToggleButton contentWarningButton; private final ToggleButton contentCollapseButton; // TODO: This code SHOULD be based on StatusBaseViewHolder + private ConstraintLayout quoteContainer; private String accountId; private String notificationId; @@ -377,6 +382,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter { contentWarningButton = itemView.findViewById(R.id.notification_content_warning_button); contentCollapseButton = itemView.findViewById(R.id.button_toggle_notification_content); + quoteContainer = itemView.findViewById(R.id.status_quote_inline_container); + int darkerFilter = Color.rgb(123, 123, 123); statusAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY); notificationAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY); @@ -520,6 +527,15 @@ public class NotificationsAdapter extends RecyclerView.Adapter { notificationAvatar, notificationAvatarRadius, animateAvatar); } + private void setQuoteContainer(Status status, final LinkListener listener) { + if (status != null) { + quoteContainer.setVisibility(View.VISIBLE); + new QuoteInlineHelper(status, quoteContainer, listener).setupQuoteContainer(); + } else { + quoteContainer.setVisibility(View.GONE); + } + } + @Override public void onClick(View v) { switch (v.getId()) { @@ -570,11 +586,14 @@ public class NotificationsAdapter extends RecyclerView.Adapter { } Spanned emojifiedText = CustomEmojiHelper.emojifyText(content, emojis, statusContent); - LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getMentions(), listener); + LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getMentions(), listener, + notificationViewData.getStatusViewData().getQuote() != null); Spanned emojifiedContentWarning = CustomEmojiHelper.emojifyString(statusViewData.getSpoilerText(), statusViewData.getStatusEmojis(), contentWarningDescriptionTextView); contentWarningDescriptionTextView.setText(emojifiedContentWarning); + + setQuoteContainer(statusViewData.getQuote(), listener); } @Override diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 9f7b97415..9b2ee3be6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -1,6 +1,8 @@ package com.keylesspalace.tusky.adapter; import android.content.Context; +import android.content.res.Resources; +import android.graphics.PorterDuff; import android.graphics.drawable.Drawable; import android.text.Spanned; import android.text.TextUtils; @@ -16,6 +18,7 @@ import android.widget.ToggleButton; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.constraintlayout.widget.ConstraintLayout; import androidx.recyclerview.widget.DefaultItemAnimator; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -41,6 +44,8 @@ import com.keylesspalace.tusky.viewdata.PollViewDataKt; import com.keylesspalace.tusky.viewdata.StatusViewData; import com.mikepenz.iconics.utils.Utils; +import net.accelf.yuito.QuoteInlineHelper; + import java.text.NumberFormat; import java.text.SimpleDateFormat; import java.util.Date; @@ -62,6 +67,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private ImageButton replyButton; private SparkButton reblogButton; private SparkButton favouriteButton; + private ImageButton quoteButton; private ImageButton moreButton; protected MediaPreviewImageView[] mediaPreviews; private ImageView[] mediaOverlays; @@ -76,6 +82,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { public TextView content; public TextView contentWarningDescription; + private ConstraintLayout quoteContainer; + private RecyclerView pollOptions; private TextView pollDescription; private Button pollButton; @@ -105,6 +113,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { replyButton = itemView.findViewById(R.id.status_reply); reblogButton = itemView.findViewById(R.id.status_inset); favouriteButton = itemView.findViewById(R.id.status_favourite); + quoteButton = itemView.findViewById(R.id.status_quote); moreButton = itemView.findViewById(R.id.status_more); mediaPreviews = new MediaPreviewImageView[]{ @@ -131,6 +140,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { contentWarningButton = itemView.findViewById(R.id.status_content_warning_button); avatarInset = itemView.findViewById(R.id.status_avatar_inset); + quoteContainer = itemView.findViewById(R.id.status_quote_inline_container); + pollOptions = itemView.findViewById(R.id.status_poll_options); pollDescription = itemView.findViewById(R.id.status_poll_description); pollButton = itemView.findViewById(R.id.status_poll_button); @@ -174,11 +185,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { @Nullable String spoilerText, @Nullable Status.Mention[] mentions, @NonNull List emojis, - final StatusActionListener listener) { + final StatusActionListener listener, + boolean removeQuote) { if (TextUtils.isEmpty(spoilerText)) { contentWarningDescription.setVisibility(View.GONE); contentWarningButton.setVisibility(View.GONE); - this.setTextVisible(true, content, mentions, emojis, listener); + this.setTextVisible(true, content, mentions, emojis, listener, removeQuote); } else { CharSequence emojiSpoiler = CustomEmojiHelper.emojifyString(spoilerText, emojis, contentWarningDescription); contentWarningDescription.setText(emojiSpoiler); @@ -189,9 +201,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { if (getAdapterPosition() != RecyclerView.NO_POSITION) { listener.onExpandedChange(isChecked, getAdapterPosition()); } - this.setTextVisible(isChecked, content, mentions, emojis, listener); + this.setTextVisible(isChecked, content, mentions, emojis, listener, removeQuote); }); - this.setTextVisible(expanded, content, mentions, emojis, listener); + this.setTextVisible(expanded, content, mentions, emojis, listener, removeQuote); } } @@ -199,10 +211,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { Spanned content, Status.Mention[] mentions, List emojis, - final StatusActionListener listener) { + final StatusActionListener listener, + boolean removeQuote) { if (expanded) { Spanned emojifiedText = CustomEmojiHelper.emojifyText(content, emojis, this.content); - LinkHelper.setClickableText(this.content, emojifiedText, mentions, listener); + LinkHelper.setClickableText(this.content, emojifiedText, mentions, listener, removeQuote); } else { LinkHelper.setClickableMentions(this.content, mentions, listener); } @@ -334,10 +347,37 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } + private void setQuoteEnabled(boolean enabled, Status.Visibility visibility) { + quoteButton.setEnabled(enabled && visibility != Status.Visibility.PRIVATE && visibility != Status.Visibility.UNLEAKABLE); + + if (enabled && visibility != Status.Visibility.PRIVATE && visibility != Status.Visibility.UNLEAKABLE) { + int activeId; + activeId = ThemeUtils.getDrawableId(quoteButton.getContext(), + R.attr.status_quote_drawable, R.drawable.ic_quote_24dp); + quoteButton.setImageResource(activeId); + } else { + Resources res = quoteButton.getContext().getResources(); + Drawable disableIcon = res.getDrawable(R.drawable.ic_quote_disabled_24dp); + if (disableIcon != null) { + disableIcon.setColorFilter(res.getColor(R.color.status_reblog_button_disabled_dark), PorterDuff.Mode.DST_IN); + } + quoteButton.setImageDrawable(disableIcon); + } + } + protected void setFavourited(boolean favourited) { favouriteButton.setChecked(favourited); } + private void setQuoteContainer(Status status, final StatusActionListener listener) { + if (status != null) { + quoteContainer.setVisibility(View.VISIBLE); + new QuoteInlineHelper(status, quoteContainer, listener).setupQuoteContainer(); + } else { + quoteContainer.setVisibility(View.GONE); + } + } + private void loadImage(MediaPreviewImageView imageView, String previewUrl, String description, MetaData meta) { if (TextUtils.isEmpty(previewUrl)) { @@ -568,6 +608,16 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { public void onEventAnimationStart(ImageView button, boolean buttonState) { } }); + + if (quoteButton != null) { + quoteButton.setOnClickListener(view -> { + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) { + listener.onQuote(position); + } + }); + } + moreButton.setOnClickListener(v -> { int position = getAdapterPosition(); if (position != RecyclerView.NO_POSITION) { @@ -607,6 +657,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { setAvatar(status.getAvatar(), status.getRebloggedAvatar(), status.isBot(), showBotOverlay, animateAvatar); setReblogged(status.isReblogged()); setFavourited(status.isFavourited()); + setQuoteContainer(status.getQuote(), listener); List attachments = status.getAttachments(); boolean sensitive = status.isSensitive(); if (mediaPreviewEnabled) { @@ -631,8 +682,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { setupButtons(listener, status.getSenderId()); setRebloggingEnabled(status.getRebloggingEnabled(), status.getVisibility()); + setQuoteEnabled(status.getRebloggingEnabled(), status.getVisibility()); - setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(), status.getMentions(), status.getStatusEmojis(), listener); + setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(), status.getMentions(), status.getStatusEmojis(), listener, + status.getQuote() != null); setDescriptionForStatus(status); diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java index 5385dd4a2..0b9aae917 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -3,6 +3,7 @@ package com.keylesspalace.tusky.adapter; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; +import android.content.Intent; import android.graphics.drawable.Drawable; import android.text.SpannableStringBuilder; import android.text.Spanned; @@ -16,9 +17,14 @@ import android.widget.LinearLayout; import android.widget.TextView; import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + import com.bumptech.glide.Glide; import com.bumptech.glide.load.resource.bitmap.CenterCrop; import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.ViewThreadActivity; import com.keylesspalace.tusky.entity.Card; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.StatusActionListener; @@ -28,10 +34,8 @@ import com.keylesspalace.tusky.viewdata.StatusViewData; import java.text.DateFormat; import java.util.Date; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.RecyclerView; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import jp.wasabeef.glide.transformations.RoundedCornersTransformation; @@ -209,7 +213,22 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder { } - cardView.setOnClickListener(v -> LinkHelper.openLink(card.getUrl(), v.getContext())); + cardView.setOnClickListener(v -> { + String url = card.getUrl(); + String regex = ".*/users/[^/]+/statuses/([0-9]+)"; + String replace = "$1"; + Pattern p = Pattern.compile(regex); + Matcher m = p.matcher(url); + if (m.find()) { + String id = m.replaceAll(replace); + Intent intent = new Intent(v.getContext(), ViewThreadActivity.class); + intent.putExtra("id", id); + intent.putExtra("url", url); + v.getContext().startActivity(intent); + } else { + LinkHelper.openLink(url, v.getContext()); + } + }); } else { cardView.setVisibility(View.GONE); diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt index 754a22ead..ede14992c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt @@ -156,7 +156,8 @@ data class ConversationStatusEntity( application = null, pinned = false, poll = poll, - card = null) + card = null, + quote = null) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java index 6cb69382f..219ce7ad8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java @@ -107,7 +107,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder { setupButtons(listener, account.getId()); - setSpoilerAndContent(status.getExpanded(), status.getContent(), status.getSpoilerText(), status.getMentions(), status.getEmojis(), listener); + setSpoilerAndContent(status.getExpanded(), status.getContent(), status.getSpoilerText(), status.getMentions(), status.getEmojis(), listener, false); setConversationName(conversation.getAccounts()); diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt index 7799688ec..6c73b0a79 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt @@ -117,6 +117,10 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res viewModel.favourite(favourite, position) } + override fun onQuote(position: Int) { + // its impossible to quote private messages + } + override fun onMore(view: View, position: Int) { viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { more(it.toStatus(), view, position) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt index 608219722..f786c9328 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt @@ -83,7 +83,8 @@ class StatusViewHolder(itemView: View, viewState.isContentShow(status.id, status.sensitive), status.spoilerText) if (status.spoilerText.isBlank()) { - setTextVisible(true, status.content, status.mentions, status.emojis, adapterHandler) + setTextVisible(true, status.content, status.mentions, status.emojis, adapterHandler, + status.quote != null) itemView.statusContentWarningButton.hide() itemView.statusContentWarningDescription.hide() } else { @@ -96,10 +97,12 @@ class StatusViewHolder(itemView: View, status()?.let { status -> itemView.statusContentWarningDescription.invalidate() viewState.setContentShow(status.id, isViewChecked) - setTextVisible(isViewChecked, status.content, status.mentions, status.emojis, adapterHandler) + setTextVisible(isViewChecked, status.content, status.mentions, status.emojis, adapterHandler, + status.quote != null) } } - setTextVisible(viewState.isContentShow(status.id, true), status.content, status.mentions, status.emojis, adapterHandler) + setTextVisible(viewState.isContentShow(status.id, true), status.content, status.mentions, status.emojis, adapterHandler, + status.quote != null) } } } @@ -109,10 +112,11 @@ class StatusViewHolder(itemView: View, content: Spanned, mentions: Array?, emojis: List, - listener: LinkListener) { + listener: LinkListener, + removeQuote: Boolean) { if (expanded) { val emojifiedText = CustomEmojiHelper.emojifyText(content, emojis, itemView.statusContent) - LinkHelper.setClickableText(itemView.statusContent, emojifiedText, mentions, listener) + LinkHelper.setClickableText(itemView.statusContent, emojifiedText, mentions, listener, removeQuote) } else { LinkHelper.setClickableMentions(itemView.statusContent, mentions, listener) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt index 707288cac..e4ff89600 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt @@ -94,6 +94,12 @@ class SearchStatusesFragment : SearchFragment + quote(status) + } + } + override fun onMore(view: View, position: Int) { (adapter as? SearchStatusesAdapter)?.getItem(position)?.first?.let { more(it, view, position) @@ -200,6 +206,28 @@ class SearchStatusesFragment : SearchFragment() + 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 diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt b/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt index 1a3226fad..d8799621c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt @@ -26,7 +26,8 @@ data class NewStatus( val visibility: String, val sensitive: Boolean, @SerializedName("media_ids") val mediaIds: List?, - val poll: NewPoll? + val poll: NewPoll?, + @SerializedName("quote_id") val quoteId: String? ) @Parcelize diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt index 7746e7385..32eb08dbf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -43,7 +43,8 @@ data class Status( val application: Application?, var pinned: Boolean?, val poll: Poll?, - val card: Card? + val card: Card?, + val quote: Status? ) { val actionableId: String diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java index 59f1b2360..79f68faab 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -462,6 +462,11 @@ public class NotificationsFragment extends SFragment implements updateAdapter(); } + @Override + public void onQuote(int position) { + super.quote(notifications.get(position).asRight().getStatus()); + } + public void onVoteInPoll(int position, @NonNull List choices) { final Notification notification = notifications.get(position).asRight(); final Status status = notification.getStatus(); diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java index 1f2f99f89..d0fcabe12 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java @@ -163,6 +163,35 @@ public abstract class SFragment extends BaseFragment implements Injectable { getActivity().startActivity(intent); } + protected void quote(Status status) { + String id = status.getActionableId(); + Status actionableStatus = status.getActionableStatus(); + Status.Visibility visibility = actionableStatus.getVisibility(); + String url = actionableStatus.getUrl(); + Status.Mention[] mentions = actionableStatus.getMentions(); + Set mentionedUsernames = new LinkedHashSet<>(); + mentionedUsernames.add(actionableStatus.getAccount().getUsername()); + String loggedInUsername = null; + AccountEntity activeAccount = accountManager.getActiveAccount(); + if(activeAccount != null) { + loggedInUsername = activeAccount.getUsername(); + } + for (Status.Mention mention : mentions) { + mentionedUsernames.add(mention.getUsername()); + } + mentionedUsernames.remove(loggedInUsername); + if (status.getReblog() != null) { + url = status.getReblog().getUrl(); + } + Intent intent = new ComposeActivity.IntentBuilder() + .quoteId(id) + .quoteUrl(url) + .replyVisibility(visibility) + .mentionedUsernames(mentionedUsernames) + .build(getContext()); + startActivity(intent); + } + protected void more(@NonNull final Status status, View view, final int position) { final String id = status.getActionableId(); final String accountId = status.getActionableStatus().getAccount().getId(); diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java index 6dd0ea46e..9b18742f1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java @@ -631,6 +631,11 @@ public class TimelineFragment extends SFragment implements updateAdapter(); } + @Override + public void onQuote(int position) { + super.quote(statuses.get(position).asRight()); + } + public void onVoteInPoll(int position, @NonNull List choices) { final Status status = statuses.get(position).asRight(); diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java index 7239f793c..ff987fcf3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java @@ -257,6 +257,11 @@ public final class ViewThreadFragment extends SFragment implements ); } + @Override + public void onQuote(int position) { + super.quote(statuses.get(position)); + } + private void updateStatus(int position, Status status) { if (position >= 0 && position < statuses.size()) { diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java index ec7bcc21e..9e2ab0b3d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java @@ -17,15 +17,16 @@ package com.keylesspalace.tusky.interfaces; import android.view.View; -import java.util.List; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import java.util.List; + public interface StatusActionListener extends LinkListener { void onReply(int position); void onReblog(final boolean reblog, final int position); void onFavourite(final boolean favourite, final int position); + void onQuote(int position); void onMore(@NonNull View view, final int position); void onViewMedia(int position, int attachmentIndex, @Nullable View view); void onViewThread(int position); diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java index 4d3a6d8a9..d2bcc64b3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java @@ -15,6 +15,8 @@ package com.keylesspalace.tusky.network; +import androidx.annotation.Nullable; + import com.keylesspalace.tusky.entity.AccessToken; import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.entity.AppCredentials; @@ -37,7 +39,6 @@ import com.keylesspalace.tusky.entity.StatusContext; import java.util.List; import java.util.Set; -import androidx.annotation.Nullable; import io.reactivex.Completable; import io.reactivex.Single; import okhttp3.MultipartBody; diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt index 7ae1f6dff..321494e34 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt @@ -18,11 +18,11 @@ package com.keylesspalace.tusky.receiver import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.RemoteInput import androidx.core.content.ContextCompat -import android.util.Log import com.keylesspalace.tusky.ComposeActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.db.AccountManager @@ -96,7 +96,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { null, null, null, - null, account, 0) + null, null, account, 0) context.startService(sendIntent) diff --git a/app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt index d32cf6b20..ad6e55875 100644 --- a/app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt @@ -229,7 +229,8 @@ class TimelineRepositoryImpl( application = application, pinned = false, poll = poll, - card = null + card = null, + quote = null ) } val status = if (reblog != null) { @@ -255,7 +256,8 @@ class TimelineRepositoryImpl( application = null, pinned = false, poll = null, - card = null + card = null, + quote = null ) } else { Status( @@ -280,7 +282,8 @@ class TimelineRepositoryImpl( application = application, pinned = false, poll = poll, - card = null + card = null, + quote = null ) } return Either.Right(status) diff --git a/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt b/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt index ba4d0b982..8f500772c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt @@ -140,7 +140,8 @@ class SendTootService : Service(), Injectable { tootToSend.visibility, tootToSend.sensitive, tootToSend.mediaIds, - tootToSend.poll + tootToSend.poll, + tootToSend.quoteId ) val sendCall = mastodonApi.createStatus( @@ -289,6 +290,7 @@ class SendTootService : Service(), Injectable { replyingStatusContent: String?, replyingStatusAuthorUsername: String?, savedJsonUrls: String?, + quoteId: String?, account: AccountEntity, savedTootUid: Int ): Intent { @@ -308,6 +310,7 @@ class SendTootService : Service(), Injectable { replyingStatusContent, replyingStatusAuthorUsername, savedJsonUrls, + quoteId, account.id, savedTootUid, idempotencyKey, @@ -351,6 +354,7 @@ data class TootToSend(val text: String, val replyingStatusContent: String?, val replyingStatusAuthorUsername: String?, val savedJsonUrls: String?, + val quoteId: String?, val accountId: Long, val savedTootUid: Int, val idempotencyKey: String, diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java index 2505649db..e31aecfce 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java @@ -20,10 +20,6 @@ import android.content.Context; import android.content.Intent; import android.net.Uri; import android.preference.PreferenceManager; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.browser.customtabs.CustomTabsIntent; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.method.LinkMovementMethod; @@ -33,11 +29,14 @@ import android.util.Log; import android.view.View; import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.browser.customtabs.CustomTabsIntent; + import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.LinkListener; -import java.lang.CharSequence; import java.net.URI; import java.net.URISyntaxException; @@ -69,7 +68,8 @@ public class LinkHelper { * @param listener to notify about particular spans that are clicked */ public static void setClickableText(TextView view, Spanned content, - @Nullable Status.Mention[] mentions, final LinkListener listener) { + @Nullable Status.Mention[] mentions, final LinkListener listener, + boolean removeQuote) { SpannableStringBuilder builder = new SpannableStringBuilder(content); URLSpan[] urlSpans = content.getSpans(0, content.length(), URLSpan.class); for (URLSpan span : urlSpans) { @@ -126,6 +126,13 @@ public class LinkHelper { builder.subSequence(end, end + 1).toString().equals("\n")){ builder.insert(end, "\u200B"); } + + if (start >= 13 && end < builder.length() && removeQuote) { + if (builder.subSequence(start - 13, start).toString().equals("\n~~~~~~~~~~\n[") + && builder.subSequence(end, end + 1).toString().equals("]")) { + builder.delete(start - 13, end + 1); + } + } } view.setText(builder); diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java index 7997ce86a..15f7444b2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java @@ -64,6 +64,7 @@ public final class ViewDataUtils { .setPoll(visibleStatus.getPoll()) .setCard(visibleStatus.getCard()) .setIsBot(visibleStatus.getAccount().getBot()) + .setQuote(visibleStatus.getQuote()) .createStatusViewData(); } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java index 365e5c8a8..094652bf0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java @@ -16,10 +16,11 @@ package com.keylesspalace.tusky.viewdata; import android.os.Build; -import androidx.annotation.Nullable; import android.text.SpannableStringBuilder; import android.text.Spanned; +import androidx.annotation.Nullable; + import com.keylesspalace.tusky.entity.Attachment; import com.keylesspalace.tusky.entity.Card; import com.keylesspalace.tusky.entity.Emoji; @@ -92,6 +93,8 @@ public abstract class StatusViewData { private final PollViewData poll; private final boolean isBot; + private final Status quote; + public Concrete(String id, Spanned content, boolean reblogged, boolean favourited, @Nullable String spoilerText, Status.Visibility visibility, List attachments, @Nullable String rebloggedByUsername, @Nullable String rebloggedAvatar, boolean sensitive, boolean isExpanded, @@ -99,7 +102,7 @@ public abstract class StatusViewData { Date createdAt, int reblogsCount, int favouritesCount, @Nullable String inReplyToId, @Nullable Status.Mention[] mentions, String senderId, boolean rebloggingEnabled, Status.Application application, List statusEmojis, List accountEmojis, @Nullable Card card, - boolean isCollapsible, boolean isCollapsed, @Nullable PollViewData poll, boolean isBot) { + boolean isCollapsible, boolean isCollapsed, @Nullable PollViewData poll, boolean isBot, Status quote) { this.id = id; if (Build.VERSION.SDK_INT == 23) { @@ -138,6 +141,7 @@ public abstract class StatusViewData { this.isCollapsed = isCollapsed; this.poll = poll; this.isBot = isBot; + this.quote = quote; } public String getId() { @@ -277,6 +281,10 @@ public abstract class StatusViewData { return poll; } + public Status getQuote() { + return quote; + } + @Override public long getViewDataId() { // Chance of collision is super low and impact of mistake is low as well return id.hashCode(); @@ -313,8 +321,9 @@ public abstract class StatusViewData { Objects.equals(statusEmojis, concrete.statusEmojis) && Objects.equals(accountEmojis, concrete.accountEmojis) && Objects.equals(card, concrete.card) && - Objects.equals(poll, concrete.poll) - && isCollapsed == concrete.isCollapsed; + Objects.equals(poll, concrete.poll) && + isCollapsed == concrete.isCollapsed && + Objects.equals(quote, concrete.quote); } static Spanned replaceCrashingCharacters(Spanned content) { @@ -420,6 +429,7 @@ public abstract class StatusViewData { private boolean isCollapsed; /** Whether the status is shown partially or fully */ private PollViewData poll; private boolean isBot; + private Status quote; public Builder() { } @@ -455,6 +465,7 @@ public abstract class StatusViewData { isCollapsed = viewData.isCollapsed(); poll = viewData.poll; isBot = viewData.isBot(); + quote = viewData.getQuote(); } public Builder setId(String id) { @@ -621,6 +632,11 @@ public abstract class StatusViewData { return this; } + public Builder setQuote(Status quote){ + this.quote = quote; + return this; + } + public StatusViewData.Concrete createStatusViewData() { if (this.statusEmojis == null) statusEmojis = Collections.emptyList(); if (this.accountEmojis == null) accountEmojis = Collections.emptyList(); @@ -630,7 +646,7 @@ public abstract class StatusViewData { attachments, rebloggedByUsername, rebloggedAvatar, isSensitive, isExpanded, isShowingContent, userFullName, nickname, avatar, createdAt, reblogsCount, favouritesCount, inReplyToId, mentions, senderId, rebloggingEnabled, application, - statusEmojis, accountEmojis, card, isCollapsible, isCollapsed, poll, isBot); + statusEmojis, accountEmojis, card, isCollapsible, isCollapsed, poll, isBot, quote); } } } diff --git a/app/src/main/java/net/accelf/yuito/QuoteInlineHelper.java b/app/src/main/java/net/accelf/yuito/QuoteInlineHelper.java new file mode 100644 index 000000000..f9965df60 --- /dev/null +++ b/app/src/main/java/net/accelf/yuito/QuoteInlineHelper.java @@ -0,0 +1,130 @@ +package net.accelf.yuito; + +import android.content.Context; +import android.text.Spanned; +import android.text.TextUtils; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.ToggleButton; + +import com.bumptech.glide.Glide; +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.entity.Account; +import com.keylesspalace.tusky.entity.Emoji; +import com.keylesspalace.tusky.entity.Status; +import com.keylesspalace.tusky.interfaces.LinkListener; +import com.keylesspalace.tusky.util.CustomEmojiHelper; +import com.keylesspalace.tusky.util.LinkHelper; + +import java.util.List; + +public class QuoteInlineHelper { + private Status quoteStatus; + + private View quoteContainer; + private ImageView quoteAvatar; + private TextView quoteDisplayName; + private TextView quoteUsername; + private TextView quoteContentWarningDescription; + private ToggleButton quoteContentWarningButton; + private TextView quoteContent; + private TextView quoteMedia; + + private LinkListener listener; + + public QuoteInlineHelper(Status status, View container, LinkListener listener) { + quoteStatus = status; + quoteContainer = container; + quoteAvatar = container.findViewById(R.id.status_quote_inline_avatar); + quoteDisplayName = container.findViewById(R.id.status_quote_inline_display_name); + quoteUsername = container.findViewById(R.id.status_quote_inline_username); + quoteContentWarningDescription = container.findViewById(R.id.status_quote_inline_content_warning_description); + quoteContentWarningButton = container.findViewById(R.id.status_quote_inline_content_warning_button); + quoteContent = container.findViewById(R.id.status_quote_inline_content); + quoteMedia = container.findViewById(R.id.status_quote_inline_media); + this.listener = listener; + } + + private void setDisplayName(String name, List customEmojis) { + CharSequence emojifiedName = CustomEmojiHelper.emojifyString(name, customEmojis, quoteDisplayName); + quoteDisplayName.setText(emojifiedName); + } + + private void setUsername(String name) { + Context context = quoteUsername.getContext(); + String format = context.getString(R.string.status_username_format); + String usernameText = String.format(format, name); + quoteUsername.setText(usernameText); + } + + private void setContent(Spanned content, Status.Mention[] mentions, List emojis, + LinkListener listener) { + Spanned singleLineText = SpannedTextHelper.replaceSpanned(content); + Spanned emojifiedText = CustomEmojiHelper.emojifyText(singleLineText, emojis, quoteContent); + LinkHelper.setClickableText(quoteContent, emojifiedText, mentions, listener, false); + } + + private void setAvatar(String url) { + if (TextUtils.isEmpty(url)) { + quoteAvatar.setImageResource(R.drawable.avatar_default); + } else { + Glide.with(quoteAvatar.getContext()) + .load(url) + .placeholder(R.drawable.avatar_default) + .into(quoteAvatar); + } + } + + private void setSpoilerText(String spoilerText, List emojis) { + CharSequence emojiSpoiler = + CustomEmojiHelper.emojifyString(spoilerText, emojis, quoteContentWarningDescription); + quoteContentWarningDescription.setText(emojiSpoiler); + quoteContentWarningDescription.setVisibility(View.VISIBLE); + quoteContentWarningButton.setVisibility(View.VISIBLE); + quoteContentWarningButton.setChecked(false); + quoteContentWarningButton.setOnCheckedChangeListener((buttonView, isChecked) + -> quoteContent.setVisibility(isChecked ? View.VISIBLE : View.GONE)); + quoteContent.setVisibility(View.GONE); + + } + + private void hideSpoilerText() { + quoteContentWarningDescription.setVisibility(View.GONE); + quoteContentWarningButton.setVisibility(View.GONE); + quoteContent.setVisibility(View.VISIBLE); + } + + private void setOnClickListener(String accountId, String statusUrl) { + quoteAvatar.setOnClickListener(view -> listener.onViewAccount(accountId)); + quoteDisplayName.setOnClickListener(view -> listener.onViewAccount(accountId)); + quoteUsername.setOnClickListener(view -> listener.onViewAccount(accountId)); + quoteContent.setOnClickListener(view -> listener.onViewUrl(statusUrl)); + quoteMedia.setOnClickListener(view -> listener.onViewUrl(statusUrl)); + quoteContainer.setOnClickListener(view -> listener.onViewUrl(statusUrl)); + } + + public void setupQuoteContainer() { + Account account = quoteStatus.getAccount(); + setDisplayName(account.getDisplayName().equals("") ? account.getLocalUsername() : account.getDisplayName(), account.getEmojis()); + setUsername(account.getUsername()); + setContent(quoteStatus.getContent(), quoteStatus.getMentions(), + quoteStatus.getEmojis(), listener); + setAvatar(account.getAvatar()); + setOnClickListener(account.getId(), quoteStatus.getUrl()); + + if (quoteStatus.getSpoilerText().isEmpty()) { + hideSpoilerText(); + } else { + setSpoilerText(quoteStatus.getSpoilerText(), quoteStatus.getEmojis()); + } + + if (quoteStatus.getAttachments().size() == 0) { + quoteMedia.setVisibility(View.GONE); + } else { + quoteMedia.setVisibility(View.VISIBLE); + quoteMedia.setText(quoteContainer.getContext().getString(R.string.status_quote_media, + quoteStatus.getAttachments().size())); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/net/accelf/yuito/SpannedTextHelper.java b/app/src/main/java/net/accelf/yuito/SpannedTextHelper.java new file mode 100644 index 000000000..ffb7f7670 --- /dev/null +++ b/app/src/main/java/net/accelf/yuito/SpannedTextHelper.java @@ -0,0 +1,22 @@ +package net.accelf.yuito; + +import android.text.SpannableStringBuilder; +import android.text.Spanned; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +class SpannedTextHelper { + + static Spanned replaceSpanned(Spanned targetText) { + String targetString = targetText.toString(); + SpannableStringBuilder builder = new SpannableStringBuilder(targetText); + Pattern pattern = Pattern.compile("\n"); + Matcher matcher = pattern.matcher(targetString); + while (matcher.find()) { + builder.replace(matcher.start(), matcher.end(), " "); + } + return builder; + } + +} diff --git a/app/src/main/res/drawable/ic_quote_24dp.xml b/app/src/main/res/drawable/ic_quote_24dp.xml new file mode 100644 index 000000000..baf4e3567 --- /dev/null +++ b/app/src/main/res/drawable/ic_quote_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_quote_disabled_24dp.xml b/app/src/main/res/drawable/ic_quote_disabled_24dp.xml new file mode 100644 index 000000000..e91f83b2c --- /dev/null +++ b/app/src/main/res/drawable/ic_quote_disabled_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/quote_inline_frame.xml b/app/src/main/res/drawable/quote_inline_frame.xml new file mode 100644 index 000000000..84c3d3e60 --- /dev/null +++ b/app/src/main/res/drawable/quote_inline_frame.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_compose.xml b/app/src/main/res/layout/activity_compose.xml index b0b5efab3..4be024f68 100644 --- a/app/src/main/res/layout/activity_compose.xml +++ b/app/src/main/res/layout/activity_compose.xml @@ -90,6 +90,18 @@ tools:text="Post content which may be preeettyy long, so please, make sure there's enough room for everything, okay? Not kidding. I wish Eugen answered me more often, sigh." tools:visibility="visible" /> + + + + + + diff --git a/app/src/main/res/layout/item_status_detailed.xml b/app/src/main/res/layout/item_status_detailed.xml index 01db587ea..9a086c1d6 100644 --- a/app/src/main/res/layout/item_status_detailed.xml +++ b/app/src/main/res/layout/item_status_detailed.xml @@ -129,6 +129,18 @@ app:layout_constraintTop_toBottomOf="@+id/status_content_warning_button" tools:text="Status content. Can be pretty long. " /> + + + + diff --git a/app/src/main/res/layout/item_status_notification.xml b/app/src/main/res/layout/item_status_notification.xml index f9f0a4b6a..08697054d 100644 --- a/app/src/main/res/layout/item_status_notification.xml +++ b/app/src/main/res/layout/item_status_notification.xml @@ -137,6 +137,15 @@ android:textSize="?attr/status_text_medium" android:visibility="gone" /> + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 145a7e232..6bd35e600 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -46,6 +46,7 @@ 続きを隠す 続きを読む 閉じる + このトゥートには%d件のメディアが存在します。 何もありません。 現在トゥートはありません。更新するにはプルダウンしてください! %sさんがトゥートをブーストしました @@ -57,6 +58,7 @@ 返信 ブースト お気に入り + 引用 その他 新規投稿 Mastodonでログイン diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml index cab3579dc..372329bb1 100644 --- a/app/src/main/res/values-night/styles.xml +++ b/app/src/main/res/values-night/styles.xml @@ -37,6 +37,8 @@ @drawable/reblog_direct_dark @drawable/favourite_active_dark @drawable/favourite_inactive_dark + @drawable/ic_quote_24dp + @drawable/ic_quote_disabled_24dp @drawable/toggle_small @color/color_background_dark @drawable/media_preview_unloaded_dark diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index a9d794ca6..34aeb80cf 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -22,6 +22,8 @@ + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 537d3cfc1..afb9e3352 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -51,6 +51,7 @@ Show Less Expand Collapse + This toot has %d media(s). Nothing here. Nothing here. Pull down to refresh! @@ -124,6 +125,7 @@ Open boost author Show boosts Show favorites + Quote Hashtags Mentions @@ -321,6 +323,7 @@ Always expand toots marked with content warnings Media Replying to @%s + Quote : %s load more Public timelines diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 466c8e7c7..c19c81782 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -87,6 +87,8 @@ @drawable/reblog_direct_light @drawable/favourite_active_light @drawable/favourite_inactive_light + @drawable/ic_quote_24dp + @drawable/ic_quote_disabled_24dp @drawable/toggle_small_light @color/sensitive_media_warning_background_light diff --git a/app/src/test/java/com/keylesspalace/tusky/fragment/TimelineRepositoryTest.kt b/app/src/test/java/com/keylesspalace/tusky/fragment/TimelineRepositoryTest.kt index e95ee68e3..fbe05b947 100644 --- a/app/src/test/java/com/keylesspalace/tusky/fragment/TimelineRepositoryTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/fragment/TimelineRepositoryTest.kt @@ -316,7 +316,8 @@ class TimelineRepositoryTest { reblog = null, url = "http://example.com/statuses/$id", poll = null, - card = null + card = null, + quote = null ) }