feat: channel links, link info on long-tap
Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
parent
cdc944a571
commit
a3c5e43c5d
|
|
@ -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>(
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue