diff --git a/app/src/main/java/chat/revolt/callbacks/ActionChannel.kt b/app/src/main/java/chat/revolt/callbacks/ActionChannel.kt index 07b73627..498e7c88 100644 --- a/app/src/main/java/chat/revolt/callbacks/ActionChannel.kt +++ b/app/src/main/java/chat/revolt/callbacks/ActionChannel.kt @@ -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( diff --git a/app/src/main/java/chat/revolt/components/chat/Message.kt b/app/src/main/java/chat/revolt/components/chat/Message.kt index 07378f73..fb0da30d 100644 --- a/app/src/main/java/chat/revolt/components/chat/Message.kt +++ b/app/src/main/java/chat/revolt/components/chat/Message.kt @@ -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()) } diff --git a/app/src/main/java/chat/revolt/components/generic/Markdown.kt b/app/src/main/java/chat/revolt/components/generic/Markdown.kt index 65a62124..5848a29c 100644 --- a/app/src/main/java/chat/revolt/components/generic/Markdown.kt +++ b/app/src/main/java/chat/revolt/components/generic/Markdown.kt @@ -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, diff --git a/app/src/main/java/chat/revolt/internals/Platform.kt b/app/src/main/java/chat/revolt/internals/Platform.kt new file mode 100644 index 00000000..b0aa9fdf --- /dev/null +++ b/app/src/main/java/chat/revolt/internals/Platform.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/internals/markdown/LinkSpan.kt b/app/src/main/java/chat/revolt/internals/markdown/LinkSpan.kt index 242981b3..a76b810f 100644 --- a/app/src/main/java/chat/revolt/internals/markdown/LinkSpan.kt +++ b/app/src/main/java/chat/revolt/internals/markdown/LinkSpan.kt @@ -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 diff --git a/app/src/main/java/chat/revolt/internals/markdown/LongClickLinkMovementMethod.kt b/app/src/main/java/chat/revolt/internals/markdown/LongClickLinkMovementMethod.kt new file mode 100644 index 00000000..9f9cd146 --- /dev/null +++ b/app/src/main/java/chat/revolt/internals/markdown/LongClickLinkMovementMethod.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/internals/markdown/MarkdownNodes.kt b/app/src/main/java/chat/revolt/internals/markdown/MarkdownNodes.kt index 0cdb40e8..f0225b44 100644 --- a/app/src/main/java/chat/revolt/internals/markdown/MarkdownNodes.kt +++ b/app/src/main/java/chat/revolt/internals/markdown/MarkdownNodes.kt @@ -37,9 +37,18 @@ class UserMentionNode(private val userId: String) : Node() { class ChannelMentionNode(private val channelId: String) : Node() { 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 ) } } diff --git a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt index 7beb2247..0c19d117 100644 --- a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt @@ -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(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() diff --git a/app/src/main/java/chat/revolt/sheets/LinkInfoSheet.kt b/app/src/main/java/chat/revolt/sheets/LinkInfoSheet.kt new file mode 100644 index 00000000..127d8cfd --- /dev/null +++ b/app/src/main/java/chat/revolt/sheets/LinkInfoSheet.kt @@ -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() + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_lock_alert_24dp.xml b/app/src/main/res/drawable/ic_lock_alert_24dp.xml new file mode 100644 index 00000000..70ca176d --- /dev/null +++ b/app/src/main/res/drawable/ic_lock_alert_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6b8770f3..8875aa0f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -184,6 +184,9 @@ Copy Copied to clipboard + You can\'t view this channel + This channel may have been deleted or you may not have permission to view it. + Channel description There hasn\'t been a description set for this channel yet. Options