Skip to content

Commit

Permalink
feat: Show "copy" button for links/hashtags/mentions in accessibility…
Browse files Browse the repository at this point in the history
… dialogs

Users report that copying items can be difficult using Talkback.

Make this easier in the dialogs that appear for links, mentions, and
hashtags by using a dedicated adapter that displays a "Copy" button
at the end of each item.

Fixes #1038
  • Loading branch information
nikclayton committed Oct 23, 2024
1 parent a012f94 commit 6beca4c
Show file tree
Hide file tree
Showing 4 changed files with 109 additions and 11 deletions.
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
package app.pachli.util

import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Build
import android.os.Bundle
import android.text.Spannable
import android.text.style.URLSpan
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityManager
import android.widget.ArrayAdapter
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat.getSystemService
import androidx.core.view.AccessibilityDelegateCompat
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat
Expand All @@ -19,6 +26,7 @@ import app.pachli.adapter.FilterableStatusViewHolder
import app.pachli.adapter.StatusBaseViewHolder
import app.pachli.core.activity.openLink
import app.pachli.core.network.model.Status.Companion.MAX_MEDIA_ATTACHMENTS
import app.pachli.databinding.SimpleListItem1CopyButtonBinding
import app.pachli.interfaces.StatusActionListener
import app.pachli.viewdata.IStatusViewData
import app.pachli.viewdata.NotificationViewData
Expand Down Expand Up @@ -211,9 +219,8 @@ class ListStatusAccessibilityDelegate<T : IStatusViewData>(
AlertDialog.Builder(host.context)
.setTitle(app.pachli.core.ui.R.string.title_links_dialog)
.setAdapter(
ArrayAdapter(
ArrayAdapterWithCopyButton(
host.context,
android.R.layout.simple_list_item_1,
textLinks,
),
) { _, which -> host.context.openLink(links[which].link) }
Expand All @@ -224,13 +231,15 @@ class ListStatusAccessibilityDelegate<T : IStatusViewData>(
private fun showMentionsDialog(host: View) {
val status = getStatus(host) as? IStatusViewData ?: return
val mentions = status.actionable.mentions
val stringMentions = mentions.map { it.username }

// Ensure mentions have the leading "@" to make them more useful when
// copied.
val stringMentions = mentions.map { "@${it.username}" }
AlertDialog.Builder(host.context)
.setTitle(R.string.title_mentions_dialog)
.setAdapter(
ArrayAdapter<CharSequence>(
ArrayAdapterWithCopyButton(
host.context,
android.R.layout.simple_list_item_1,
stringMentions,
),
) { _, which ->
Expand All @@ -242,13 +251,12 @@ class ListStatusAccessibilityDelegate<T : IStatusViewData>(

private fun showHashtagsDialog(host: View) {
val status = getStatus(host) as? IStatusViewData ?: return
val tags = getHashtags(status).map { it.subSequence(1, it.length) }.toList()
val tags = getHashtags(status)
AlertDialog.Builder(host.context)
.setTitle(app.pachli.core.ui.R.string.title_hashtags_dialog)
.setAdapter(
ArrayAdapter(
ArrayAdapterWithCopyButton(
host.context,
android.R.layout.simple_list_item_1,
tags,
),
) { _, which ->
Expand Down Expand Up @@ -281,12 +289,11 @@ class ListStatusAccessibilityDelegate<T : IStatusViewData>(
}
}

private fun getHashtags(status: IStatusViewData): Sequence<CharSequence> {
private fun getHashtags(status: IStatusViewData): List<CharSequence> {
val content = status.content
return content.getSpans(0, content.length, Object::class.java)
.asSequence()
.map { span ->
content.subSequence(content.getSpanStart(span), content.getSpanEnd(span))
content.subSequence(content.getSpanStart(span), content.getSpanEnd(span)).toString()
}
.filter(this::isHashtag)
}
Expand Down Expand Up @@ -406,3 +413,40 @@ class ListStatusAccessibilityDelegate<T : IStatusViewData>(

private data class LinkSpanInfo(val text: String, val link: String)
}

/**
* An [ArrayAdapter] that shows a "copy" button next to each item. When clicked
* the text of the item is copied to the clipboard and a toast is shown (if
* appropriate).
*/
private class ArrayAdapterWithCopyButton<T : CharSequence>(
context: Context,
items: List<T>,
) : ArrayAdapter<T>(context, R.layout.simple_list_item_1_copy_button, items) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val binding = if (convertView == null) {
SimpleListItem1CopyButtonBinding.inflate(LayoutInflater.from(context), parent, false)
} else {
SimpleListItem1CopyButtonBinding.bind(convertView)
}

getItem(position)?.let { text ->
binding.text1.text = text

binding.copy.setOnClickListener {
val clipboard = getSystemService(context, ClipboardManager::class.java) as ClipboardManager
val clip = ClipData.newPlainText("", text)
clipboard.setPrimaryClip(clip)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
Toast.makeText(
context,
context.getString(R.string.item_copied),
Toast.LENGTH_SHORT,
).show()
}
}
}

return binding.root
}
}
48 changes: 48 additions & 0 deletions app/src/main/res/layout/simple_list_item_1_copy_button.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright 2024 Pachli Association
~
~ This file is a part of Pachli.
~
~ 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.
~
~ Pachli 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 Pachli; if not,
~ see <http://www.gnu.org/licenses>.
-->

<!-- Like simple_list_item_1, but includes a button to copy the contents. -->

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
android:minHeight="?android:attr/listPreferredItemHeightSmall">

<TextView
android:id="@android:id/text1"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_gravity="center"
android:layout_weight="1"
android:gravity="center_vertical"
android:textAppearance="?android:attr/textAppearanceListItemSmall"
tools:text="#TestHashTag" />

<ImageButton
android:id="@+id/copy"
style="@style/AppImageButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:contentDescription="@string/action_copy_item"
app:srcCompat="@drawable/ic_content_copy_24" />

</LinearLayout>
3 changes: 3 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -850,4 +850,7 @@
<string name="search_operator_where_dialog_library_hint">Your own posts, boosts, favorites, bookmarks, and posts that @mention you</string>
<string name="search_operator_where_dialog_public">Public posts</string>
<string name="search_operator_where_dialog_public_hint">Public, searchable posts known by server</string>

<string name="action_copy_item">Copy item</string>
<string name="item_copied">Text copied</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

package app.pachli.core.network.model

import android.app.Application
import android.text.SpannableStringBuilder
import android.text.style.URLSpan
import app.pachli.core.common.extensions.getOrElse
Expand Down Expand Up @@ -162,7 +163,9 @@ data class Status(
data class Mention(
val id: String,
val url: String,
/** If this is remote then "[localUsername]@server", otherwise [localUsername]. */
@Json(name = "acct") val username: String,
/** The username, without the server part or leading "@". */
@Json(name = "username") val localUsername: String,
)

Expand Down

0 comments on commit 6beca4c

Please sign in to comment.