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
|
## Message text as alternating text and link runs.
##
## From the bottom of `common/frq/screens/chat.cljc`. Pulled into a module of
## its own because it is the one part of that screen that is pure string
## work, and it is the part most worth testing on its own.
##
## Runs because a link has to be styled and clickable on its own. They are
## laid out as one inline row — a paragraph — rather than stacked: stacking
## gave every link a line of its own, and a plain wrapping row measures each
## label against the row's width rather than the column's, which is what
## drags long URLs off the left edge.
import std/strutils
from std/unicode import runeLen, runeSubStr
type
RunKind* = enum rkText, rkLink
Run* = object
kind*: RunKind
value*: string
func trimTrailingPunctuation*(url: string): string =
## A URL at the end of a sentence would otherwise keep the sentence's
## punctuation. A closing bracket only counts as trailing when the URL does
## not open one itself, which is what keeps a wikipedia-style path intact.
result = url
while result.len > 0:
let c = result[^1]
if c in {'.', ',', ';', ':', '!', '?'}:
result.setLen(result.len - 1)
elif c == ')' and '(' notin result:
result.setLen(result.len - 1)
else:
break
func urlAt(text: string, start: int): (int, int) =
## Where the next `http://` or `https://` run begins and ends, or (-1, -1).
## Hand-rolled rather than a regex for the reason the Clojure's time-tag
## parser is: this runs once per message of a hundred-message backlog.
var i = start
while i < text.len:
if text[i] == 'h' and
(text.continuesWith("http://", i) or text.continuesWith("https://", i)):
var j = i
while j < text.len and text[j] notin {' ', '\t', '\n', '\r', '<', '>', '"'}:
j += 1
return (i, j)
i += 1
(-1, -1)
func trimEnds(runs: seq[Run]): seq[Run] =
## The message's leading and trailing whitespace, off the runs that carry
## it.
##
## Only the two ends: every space between the runs is a space somebody typed
## between two words, and the paragraph they now share is where it shows.
result = runs
if result.len > 0 and result[0].kind == rkText:
let v = result[0].value.strip(leading = true, trailing = false)
if v.len > 0: result[0].value = v
else: result.delete(0)
if result.len > 0 and result[^1].kind == rkText:
let v = result[^1].value.strip(leading = false, trailing = true)
if v.len > 0: result[^1].value = v
else: result.setLen(result.len - 1)
func textRuns*(text: string): seq[Run] =
var pos = 0
while pos < text.len:
let (at, stop) = urlAt(text, pos)
if at < 0:
if pos < text.len:
result.add Run(kind: rkText, value: text[pos .. ^1])
break
if at > pos:
result.add Run(kind: rkText, value: text[pos ..< at])
let url = trimTrailingPunctuation(text[at ..< stop])
result.add Run(kind: rkLink, value: url)
# Past the trimmed URL, not the raw one: the punctuation that was trimmed
# is text and belongs in the next run.
pos = at + url.len
result = trimEnds(result)
func firstImageUrl*(text: string): string =
## The first picture link in a message, or "".
##
## PNG only, which is what the preview can draw. The link is left in the
## text either way — a preview is an addition to the message, not a
## replacement for what was said.
for r in textRuns(text):
if r.kind == rkLink:
let low = r.value.toLowerAscii
if low.endsWith(".png") or low.contains("/media/"):
return r.value
""
func summarise*(text: string, n: int): string =
## One line of `text`, cut to `n` characters with an ellipsis.
##
## Two callers with the same need: a reply chip quoting what it answers, and
## a room's last line in the conversation list. A pasted shell script or a
## long link is a card's worth of text otherwise, and the cards stop reading
## as a list of rooms.
##
## Runes and not bytes. A byte slice lands inside a multi-byte character and
## makes mojibake where an ellipsis was wanted, which both copies of this got
## wrong before a test with a hundred emoji in it found them.
var line = newStringOfCap(text.len)
var inSpace = false
for c in text:
if c in {' ', '\t', '\n', '\r'}:
if not inSpace: line.add ' '
inSpace = true
else:
line.add c
inSpace = false
line = line.strip()
if line.runeLen > n: line.runeSubStr(0, n - 1) & "…" else: line
|