feat: channel links, link info on long-tap

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2023-09-17 17:37:37 +02:00
parent cdc944a571
commit a3c5e43c5d
11 changed files with 319 additions and 9 deletions

View File

@ -4,6 +4,8 @@ import kotlinx.coroutines.channels.Channel
sealed class Action {
data class OpenUserSheet(val userId: String, val serverId: String?) : Action()
data class SwitchChannel(val channelId: String) : Action()
data class LinkInfo(val url: String) : Action()
}
val ActionChannel = Channel<Action>(

View File

@ -6,7 +6,6 @@ import android.net.Uri
import android.text.SpannableStringBuilder
import android.text.TextUtils
import android.text.format.DateUtils
import android.text.method.LinkMovementMethod
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@ -59,6 +58,7 @@ import chat.revolt.api.schemas.AutumnResource
import chat.revolt.api.schemas.User
import chat.revolt.components.generic.UserAvatar
import chat.revolt.components.generic.UserAvatarWidthPlaceholder
import chat.revolt.internals.markdown.LongClickLinkMovementMethod
import chat.revolt.api.schemas.Message as MessageSchema
@Composable
@ -294,7 +294,7 @@ fun Message(
textSize = 16f
typeface = ResourcesCompat.getFont(ctx, R.font.inter)
movementMethod = LinkMovementMethod.getInstance()
movementMethod = LongClickLinkMovementMethod.instance
setTextColor(contentColor.toArgb())
}

View File

@ -2,7 +2,6 @@ package chat.revolt.components.generic
import android.text.SpannableStringBuilder
import android.text.TextUtils
import android.text.method.LinkMovementMethod
import android.util.Log
import android.util.TypedValue
import android.view.ViewGroup
@ -25,6 +24,7 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.res.ResourcesCompat
import chat.revolt.R
import chat.revolt.api.RevoltAPI
import chat.revolt.internals.markdown.LongClickLinkMovementMethod
import chat.revolt.internals.markdown.MarkdownContext
import chat.revolt.internals.markdown.MarkdownParser
import chat.revolt.internals.markdown.MarkdownState
@ -97,7 +97,7 @@ fun UIMarkdown(
setMaxLines(maxLines)
setTextSize(TypedValue.COMPLEX_UNIT_SP, fontSize.value)
movementMethod = LinkMovementMethod.getInstance()
movementMethod = LongClickLinkMovementMethod.instance
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,

View File

@ -0,0 +1,9 @@
package chat.revolt.internals
import android.os.Build
object Platform {
fun needsShowClipboardNotification(): Boolean {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.S
}
}

View File

@ -3,7 +3,6 @@ package chat.revolt.internals.markdown
import android.content.Intent
import android.net.Uri
import android.text.TextPaint
import android.text.style.ClickableSpan
import android.view.View
import androidx.browser.customtabs.CustomTabsIntent
import chat.revolt.activities.InviteActivity
@ -15,7 +14,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
class LinkSpan(private val url: String, private val drawBackground: Boolean = false) :
ClickableSpan() {
LongClickableSpan() {
override fun onClick(widget: View) {
val uri = Uri.parse(url)
@ -49,6 +48,14 @@ class LinkSpan(private val url: String, private val drawBackground: Boolean = fa
ActionChannel.send(Action.OpenUserSheet(userId!!, serverId))
}
}
"channel" -> {
val channelId = uri.getQueryParameter("channel")
runBlocking(Dispatchers.IO) {
ActionChannel.send(Action.SwitchChannel(channelId!!))
}
}
}
return
@ -61,6 +68,12 @@ class LinkSpan(private val url: String, private val drawBackground: Boolean = fa
customTab.launchUrl(widget.context, Uri.parse(url))
}
override fun onLongClick(view: View?) {
runBlocking(Dispatchers.IO) {
ActionChannel.send(Action.LinkInfo(url))
}
}
override fun updateDrawState(ds: TextPaint) {
ds.color = ds.linkColor
ds.isUnderlineText = false

View File

@ -0,0 +1,84 @@
package chat.revolt.internals.markdown
import android.os.Handler
import android.os.Looper
import android.text.Selection
import android.text.Spannable
import android.text.method.LinkMovementMethod
import android.text.method.MovementMethod
import android.text.style.ClickableSpan
import android.view.MotionEvent
import android.view.View
import android.widget.TextView
abstract class LongClickableSpan : ClickableSpan() {
abstract fun onLongClick(view: View?)
}
// https://stackoverflow.com/a/63398843
class LongClickLinkMovementMethod : LinkMovementMethod() {
private var longClickHandler: Handler? = null
private var isLongPressed = false
override fun onTouchEvent(
widget: TextView, buffer: Spannable,
event: MotionEvent
): Boolean {
val action = event.action
if (action == MotionEvent.ACTION_CANCEL) {
longClickHandler?.removeCallbacksAndMessages(null)
}
if (action == MotionEvent.ACTION_UP ||
action == MotionEvent.ACTION_DOWN
) {
var x = event.x.toInt()
var y = event.y.toInt()
x -= widget.totalPaddingLeft
y -= widget.totalPaddingTop
x += widget.scrollX
y += widget.scrollY
val layout = widget.layout
val line = layout.getLineForVertical(y)
val off = layout.getOffsetForHorizontal(line, x.toFloat())
val link = buffer.getSpans(
off, off,
LongClickableSpan::class.java
)
if (link.isNotEmpty()) {
if (action == MotionEvent.ACTION_UP) {
longClickHandler?.removeCallbacksAndMessages(null)
if (!isLongPressed) {
link[0].onClick(widget)
}
isLongPressed = false
} else {
Selection.setSelection(
buffer,
buffer.getSpanStart(link[0]),
buffer.getSpanEnd(link[0])
)
longClickHandler?.postDelayed({
link[0].onLongClick(widget)
isLongPressed = true
}, LONG_CLICK_TIME)
}
return true
}
}
return super.onTouchEvent(widget, buffer, event)
}
companion object {
private const val LONG_CLICK_TIME = 500L
val instance: MovementMethod?
get() {
if (sInstance == null) {
sInstance = LongClickLinkMovementMethod()
// Handler deprecated https://stackoverflow.com/a/62477706/4116924
sInstance!!.longClickHandler = Handler(Looper.getMainLooper())
}
return sInstance
}
private var sInstance: LongClickLinkMovementMethod? = null
}
}

View File

@ -37,9 +37,18 @@ class UserMentionNode(private val userId: String) : Node<MarkdownContext>() {
class ChannelMentionNode(private val channelId: String) : Node<MarkdownContext>() {
override fun render(builder: SpannableStringBuilder, renderContext: MarkdownContext) {
builder.append(
renderContext.channelMap[channelId]?.let { "#$it" }
?: "<#${channelId}>"
val content = renderContext.channelMap[channelId]?.let { "#$it" }
?: "<#$channelId>"
builder.append(content)
builder.setSpan(
LinkSpan(
"revolt-android://link-action/channel?channel=$channelId",
drawBackground = true
),
builder.length - content.length,
builder.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}

View File

@ -28,6 +28,7 @@ import androidx.compose.material3.DrawerState
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@ -49,7 +50,9 @@ import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
@ -91,6 +94,7 @@ import chat.revolt.screens.chat.views.NoCurrentChannelScreen
import chat.revolt.screens.chat.views.channel.ChannelScreen
import chat.revolt.sheets.AddServerSheet
import chat.revolt.sheets.ChangelogSheet
import chat.revolt.sheets.LinkInfoSheet
import chat.revolt.sheets.ServerContextSheet
import chat.revolt.sheets.StatusSheet
import chat.revolt.sheets.UserContextSheet
@ -275,6 +279,11 @@ fun ChatRouterScreen(
var userContextSheetTarget by remember { mutableStateOf("") }
var userContextSheetServer by remember { mutableStateOf<String?>(null) }
var showChannelUnavailableAlert by remember { mutableStateOf(false) }
var showLinkInfoSheet by remember { mutableStateOf(false) }
var linkInfoSheetUrl by remember { mutableStateOf("") }
var useTabletAwareUI by remember { mutableStateOf(false) }
val drawerBackHandler = remember {
@ -359,6 +368,27 @@ fun ChatRouterScreen(
userContextSheetServer = action.serverId
showUserContextSheet = true
}
is Action.SwitchChannel -> {
val resolvedChannel = RevoltAPI.channelCache[action.channelId]
if (resolvedChannel == null) {
showChannelUnavailableAlert = true
return@let
}
viewModel.navigateToChannel(action.channelId, navController)
if (resolvedChannel.server != null) {
viewModel.navigateToServer(resolvedChannel.server, navController)
} else {
viewModel.navigateToServer("home", navController)
}
}
is Action.LinkInfo -> {
linkInfoSheetUrl = action.url
showLinkInfoSheet = true
}
}
}
}
@ -508,6 +538,60 @@ fun ChatRouterScreen(
}
}
if (showChannelUnavailableAlert) {
AlertDialog(
onDismissRequest = {
showChannelUnavailableAlert = false
},
icon = {
Icon(
painter = painterResource(R.drawable.ic_lock_alert_24dp),
contentDescription = null, // decorative
tint = MaterialTheme.colorScheme.primary
)
},
title = {
Text(
text = stringResource(id = R.string.channel_link_invalid),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
},
text = {
Text(
text = stringResource(id = R.string.channel_link_invalid_description),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
},
confirmButton = {
TextButton(onClick = {
showChannelUnavailableAlert = false
}) {
Text(text = stringResource(id = R.string.ok))
}
}
)
}
if (showLinkInfoSheet) {
val linkInfoSheetState = rememberModalBottomSheetState()
ModalBottomSheet(
sheetState = linkInfoSheetState,
onDismissRequest = {
showLinkInfoSheet = false
},
) {
LinkInfoSheet(
url = linkInfoSheetUrl,
onDismiss = {
showLinkInfoSheet = false
}
)
}
}
Column(
modifier = Modifier
.fillMaxWidth()

View File

@ -0,0 +1,97 @@
package chat.revolt.sheets
import android.net.Uri
import android.widget.Toast
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import chat.revolt.R
import chat.revolt.components.generic.SheetClickable
import chat.revolt.internals.Platform
import kotlinx.coroutines.launch
@Composable
fun LinkInfoSheet(url: String, onDismiss: () -> Unit) {
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
Column(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.verticalScroll(rememberScrollState()),
) {
Box(
modifier = Modifier
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(0.dp))
.clickable(onClick = {
if (url.startsWith("revolt-android://")) return@clickable
val customTab = CustomTabsIntent
.Builder()
.setShowTitle(true)
.build()
customTab.launchUrl(context, Uri.parse(url))
})
) {
Text(
text = url,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(16.dp)
)
}
Spacer(modifier = Modifier.height(8.dp))
SheetClickable(
icon = { modifier ->
Icon(
painter = painterResource(id = R.drawable.ic_content_copy_24dp),
contentDescription = null,
modifier = modifier
)
},
label = { style ->
Text(
text = stringResource(id = R.string.copy),
style = style
)
},
) {
coroutineScope.launch {
clipboardManager.setText(AnnotatedString(url))
if (Platform.needsShowClipboardNotification()) {
Toast.makeText(
context,
context.getString(R.string.copied),
Toast.LENGTH_SHORT
).show()
}
}
onDismiss()
}
}
}

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#ffffff"
android:pathData="M10 17C11.1 17 12 16.1 12 15C12 13.9 11.1 13 10 13C8.9 13 8 13.9 8 15S8.9 17 10 17M16 8C17.1 8 18 8.9 18 10V20C18 21.1 17.1 22 16 22H4C2.9 22 2 21.1 2 20V10C2 8.9 2.9 8 4 8H5V6C5 3.2 7.2 1 10 1S15 3.2 15 6V8H16M10 3C8.3 3 7 4.3 7 6V8H13V6C13 4.3 11.7 3 10 3M22 13H20V7H22V13M22 17H20V15H22V17Z" />
</vector>

View File

@ -184,6 +184,9 @@
<string name="copy">Copy</string>
<string name="copied">Copied to clipboard</string>
<string name="channel_link_invalid">You can\'t view this channel</string>
<string name="channel_link_invalid_description">This channel may have been deleted or you may not have permission to view it.</string>
<string name="channel_info_sheet_description">Channel description</string>
<string name="channel_info_sheet_description_empty">There hasn\'t been a description set for this channel yet.</string>
<string name="channel_info_sheet_options">Options</string>