1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
package social.grain
import androidx.compose.ui.graphics.Color
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import social.grain.data.model.Facet
import social.grain.ui.components.buildRichText
/**
* Facet indices are UTF-8 **byte** offsets, not character offsets.
*
* Slicing the string by character index looks correct in English and silently
* mislabels every link the moment a comment contains an emoji or an accent —
* the highlight drifts left and the tap target lands on the wrong word. These
* pin the byte-accurate path.
*/
@RunWith(AndroidJUnit4::class)
class RichTextFacetTest {
private val json = Json { ignoreUnknownKeys = true }
private fun facet(start: Int, end: Int, type: String, key: String, value: String): Facet =
Facet(
index = Facet.ByteSlice(start, end),
features = listOf(
json.parseToJsonElement("""{"${'$'}type":"$type","$key":"$value"}""").jsonObject,
),
)
private fun render(text: String, facets: List<Facet>?) =
buildRichText(text, facets, Color.Black, Color.Blue, null, null)
private fun mention(start: Int, end: Int, did: String = "did:plc:abc") =
facet(start, end, "app.bsky.richtext.facet#mention", "did", did)
@Test
fun facetSpanIsPlacedByByteOffsetNotCharacterOffset() {
// "📷" is four UTF-8 bytes but two Kotlin chars, so byte and char
// offsets diverge by two from here on.
val text = "📷 shot by @alice.test today"
val handle = "@alice.test"
val byteStart = text.substringBefore(handle).toByteArray(Charsets.UTF_8).size
val byteEnd = byteStart + handle.toByteArray(Charsets.UTF_8).size
val annotated = render(text, listOf(mention(byteStart, byteEnd)))
assertEquals("Rendered text must match the input exactly", text, annotated.text)
val links = annotated.getLinkAnnotations(0, annotated.length)
assertEquals(1, links.size)
assertEquals(
"The link must cover exactly the handle",
handle,
annotated.text.substring(links[0].start, links[0].end),
)
}
@Test
fun accentedTextKeepsLinkAlignment() {
val text = "café visit with @bob.test"
val handle = "@bob.test"
val byteStart = text.substringBefore(handle).toByteArray(Charsets.UTF_8).size
val byteEnd = byteStart + handle.toByteArray(Charsets.UTF_8).size
val annotated = render(text, listOf(mention(byteStart, byteEnd)))
val link = annotated.getLinkAnnotations(0, annotated.length).single()
assertEquals(handle, annotated.text.substring(link.start, link.end))
}
@Test
fun multipleFacetsAllLandOnTheirOwnSpans() {
val text = "@alice.test and @bob.test shot #35mm"
fun spanOf(needle: String): Pair<Int, Int> {
val start = text.substringBefore(needle).toByteArray(Charsets.UTF_8).size
return start to start + needle.toByteArray(Charsets.UTF_8).size
}
val (aStart, aEnd) = spanOf("@alice.test")
val (bStart, bEnd) = spanOf("@bob.test")
val (tStart, tEnd) = spanOf("#35mm")
val annotated = render(
text,
listOf(
mention(aStart, aEnd, "did:plc:alice"),
mention(bStart, bEnd, "did:plc:bob"),
facet(tStart, tEnd, "app.bsky.richtext.facet#tag", "tag", "35mm"),
),
)
val spans = annotated.getLinkAnnotations(0, annotated.length)
.map { annotated.text.substring(it.start, it.end) }
assertEquals(listOf("@alice.test", "@bob.test", "#35mm"), spans)
assertEquals(text, annotated.text)
}
@Test
fun anOutOfRangeFacetIsIgnoredRatherThanCrashing() {
// The appview can hand back a facet whose end runs past the text if the
// record was written with a different encoding assumption.
val text = "short"
val annotated = render(text, listOf(mention(0, 900)))
assertEquals(text, annotated.text)
}
@Test
fun textWithNoFacetsFallsBackToDetectingMentionsAndTags() {
val annotated = render("hi @alice.test see #35mm", null)
val spans = annotated.getLinkAnnotations(0, annotated.length)
.map { annotated.text.substring(it.start, it.end) }
assertEquals(listOf("@alice.test", "#35mm"), spans)
}
@Test
fun aTagStartingWithADigitIsStillLinkified() {
// "#35mm" and "#120film" are the tags this app exists for.
val annotated = render("shot on #35mm and #120film", null)
val spans = annotated.getLinkAnnotations(0, annotated.length)
.map { annotated.text.substring(it.start, it.end) }
assertEquals(listOf("#35mm", "#120film"), spans)
}
@Test
fun aPurelyNumericHashIsNotATag() {
// "ranked #1" is prose, not a tag.
val annotated = render("ranked #1 in #2024", null)
assertEquals(0, annotated.getLinkAnnotations(0, annotated.length).size)
}
@Test
fun aUrlIsNotCarvedUpByAHashInIt() {
val annotated = render("see https://grain.social/x#tag now", null)
val spans = annotated.getLinkAnnotations(0, annotated.length)
.map { annotated.text.substring(it.start, it.end) }
assertEquals(listOf("https://grain.social/x#tag"), spans)
}
}