Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,17 @@ private fun asciiHost(host: String): String {
* secondary. Above the button sits "Don't ask again for <host>", unchecked; [trusted] keeps the host
* only when Open Website is tapped with it checked.
*
* A masked link, whose text is a [label] rather than its address, says so in the subtitle:
* "<label>" will open <host>. A bare link keeps the plain subtitle.
*
* Only for links someone else wrote, such as a chat message. A link the app opens on purpose
* (terms, a token's socials) goes straight to the browser.
*/
fun openWithExternalLinkCheck(
context: Context,
url: String,
trusted: TrustedWebsites,
label: String? = null,
open: () -> Unit,
) {
val trustedHosts = trusted.websites.value.mapTo(mutableSetOf()) { it.host }
Expand All @@ -129,7 +133,11 @@ fun openWithExternalLinkCheck(
externalLinkWarning(
destination = destination,
title = context.getString(R.string.prompt_title_externalLink),
message = context.getString(R.string.prompt_description_externalLink, destination.host),
message = if (label == null) {
context.getString(R.string.prompt_description_externalLink, destination.host)
} else {
context.getString(R.string.prompt_description_externalLinkMasked, label, destination.host)
},
openWebsite = context.getString(R.string.action_openWebsite),
dontAskAgain = context.getString(R.string.action_dontAskAgainForHost, destination.host),
onTrust = trusted::trust,
Expand Down Expand Up @@ -188,4 +196,9 @@ class ExternalLinkUriHandler(
override fun openUri(uri: String) {
openWithExternalLinkCheck(context, uri, trusted) { delegate.openUri(uri) }
}

/** A masked link: the warning names [label] as well as the host it really opens. */
fun openMasked(label: String, uri: String) {
openWithExternalLinkCheck(context, uri, trusted, label) { delegate.openUri(uri) }
}
}
2 changes: 2 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,8 @@
<string name="prompt_title_externalLink">You\'re Leaving Flipcash</string>
<!-- %1$s is the link's host. -->
<string name="prompt_description_externalLink">This will open %1$s. Never share your Access Key with a website</string>
<!-- %1$s is the link's text, %2$s the host it opens. -->
<string name="prompt_description_externalLinkMasked">“%1$s” will open %2$s. Never share your Access Key with a website.</string>
<string name="action_openWebsite">Open Website</string>
<string name="action_dontAskAgainForHost">Don\'t ask again for %1$s</string>
<string name="action_unlinkAccount">Unlink Account</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import com.flipcash.libs.textformat.FormatStyle
import com.flipcash.libs.textformat.RangeKind
import com.flipcash.libs.textformat.StyledSpan
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.LinkInteractionListener
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.SpanStyle
Expand Down Expand Up @@ -96,6 +97,7 @@ internal fun buildMentionedText(
mentionStyle: SpanStyle,
onMentionClick: ((String) -> Unit)?,
formatStyles: FormatStyles = FormatStyles(),
onMaskedLinkClick: ((label: String, url: String) -> Unit)? = null,
): MentionedText {
// Out of order or overlapping only if the caller handed over ranges from other text.
val ordered = mentions.sortedBy { it.start }.fold(emptyList<DetectedMention>()) { kept, mention ->
Expand Down Expand Up @@ -166,8 +168,17 @@ internal fun buildMentionedText(
?: display.substring(link.start, link.end).let { shown ->
detectUrls(shown).firstOrNull()?.url ?: shown
}
// A masked link's label is not its address, so the open warning is told the label.
val listener = onMaskedLinkClick.takeIf { link.target != null }?.let { masked ->
val label = display.substring(link.start, link.end)
LinkInteractionListener { masked(label, url) }
}
addLink(
LinkAnnotation.Url(url = url, styles = TextLinkStyles(style = linkStyle)),
LinkAnnotation.Url(
url = url,
styles = TextLinkStyles(style = linkStyle),
linkInteractionListener = listener,
),
start = laidOut(link.start),
end = laidOut(link.end),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
Expand All @@ -60,6 +61,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.paging.compose.LazyPagingItems
import com.flipcash.app.core.links.ExternalLinkUriHandler
import com.flipcash.app.core.ui.TokenIconWithName
import com.flipcash.app.theme.FlipcashThemeWrapper
import com.flipcash.services.models.chat.MessageContent
Expand Down Expand Up @@ -549,11 +551,16 @@ private fun TextBubble(
// not rebuild the body; only whether mentions can be tapped at all is part of its key.
val currentOnMentionClick by rememberUpdatedState(onMentionClick)
val mentionsTappable = onMentionClick != null
// Only the transcript's handler knows how to warn; anywhere else a masked link opens as
// an ordinary one.
val uriHandler = LocalUriHandler.current as? ExternalLinkUriHandler
val currentUriHandler by rememberUpdatedState(uriHandler)
val maskedLinksWarn = uriHandler != null
// A tombstone carries no link and nothing worth selecting; it is a notice, not a message.
val mentioned = if (isTombstone) {
null
} else {
remember(bodyString, mentions, linkStyle, mentionStyle, mentionsTappable, formatStyles) {
remember(bodyString, mentions, linkStyle, mentionStyle, mentionsTappable, formatStyles, maskedLinksWarn) {
buildMentionedText(
text = bodyString,
mentions = mentions,
Expand All @@ -565,6 +572,11 @@ private fun TextBubble(
null
},
formatStyles = formatStyles,
onMaskedLinkClick = if (maskedLinksWarn) {
{ label, url -> currentUriHandler?.openMasked(label, url) }
} else {
null
},
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,4 +153,23 @@ class FormattedBubbleTextTest {
val literal = "[free money] ($cash)"
assertEquals(listOf(cash), cardLinks(literal).map { it.url })
}

@Test
fun `only a masked link reports its label when tapped`() {
val taps = mutableListOf<Pair<String, String>>()
val text = "[the docs](https://example.com/docs) and example.org/a"
val built = buildMentionedText(
text = text,
mentions = emptyList(),
linkStyle = linkStyle,
mentionStyle = mentionStyle,
onMentionClick = null,
onMaskedLinkClick = { label, url -> taps += label to url },
)

val links = built.text.getLinkAnnotations(0, built.text.length).map { it.item as LinkAnnotation.Url }
links[0].linkInteractionListener!!.onClick(links[0])
assertEquals(listOf("the docs" to "https://example.com/docs"), taps)
assertEquals(null, links[1].linkInteractionListener)
}
}
Loading