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
|
## What a message and a room are.
##
## The types the rest of the port hangs off, transcribed from the shapes
## `frq.cells` holds and `frq.rooms` reads. Kept as one module because they
## are one idea: a room is a list of messages and the bookkeeping about how
## far it has been read.
##
## Fields that are `Option` in spirit are spelled as a value plus a `has`
## flag rather than `Option[T]`, for one reason: these cross the FFI as JSON,
## and a missing key and a null key are the same thing on the other side. An
## Option would have to be unwrapped at every boundary anyway.
import std/[options, strutils, tables]
type
Reaction* = object
## An emoji and who put it there. The nicks are a set in the Clojure; a
## seq here, kept ordered, because the order is what the pills are drawn
## in and a set would have thrown it away.
emoji*: string
nicks*: seq[string]
Message* = object
id*: string ## the server's msgid, "" until it echoes back
localId*: string ## what this client called it before then
editIds*: seq[string] ## every msgid this line has worn — see `answersTo`
frm*: string
text*: string
at*: int64 ## epoch MILLISECONDS, 0 where the line carried none
## Milliseconds because `clock.parseTimeTag` answers
## in them and every clock function takes them. The
## first draft of this said seconds and the day
## headings quietly stopped appearing.
system*: bool ## a join/part/notice rather than something said
mention*: bool
edited*: bool
replyTo*: string ## the id this answers, "" for a line answering none
reactions*: seq[Reaction]
imageUrl*: string ## the first picture link in the text, "" for none
account*: string
## The sender's DID, off the `account` tag. The only identity a client
## is given: a nick is whatever someone chose today, and the hostmask
## carries eight characters of a DID, too few to resolve.
avatar*: string ## their thumbnail, once a profile has been fetched
pending*: bool ## sent, not yet echoed
room*: string
## Which room this was said in. Empty on a stored message — a room
## already knows its own name — and filled in by `recentEverywhere`,
## where a line taken out of its conversation no longer says for itself.
Room* = object
## A buffer: a channel or a DM. Named Room rather than Channel because
## Nim's `system.Channel` is the thread-safe queue `conn.nim` uses, and a
## type that shadows it here would be a confusing thing to debug.
name*: string
messages*: seq[Message]
unread*: int
mention*: bool
joined*: bool
joining*: bool
users*: Table[string, string]
## nick → mode prefix ("" for none). A table and not a list because the
## people panel sorts ops first and a MODE has to find one person.
namesAcc*: Table[string, string]
## The 353 replies so far. Pending rather than live: NAMES arrives over
## as many lines as it takes and ends with 366, and replacing `users` on
## each would empty the panel and refill it a name at a time.
topic*: string
accessed*: int64 ## when this reader last opened it
lastActivity*: int64
lastReadId*: string
lastReadAt*: int64
peerDid*: string ## for a DM, who the other side is
func initMessage*(frm, text: string): Message =
Message(frm: frm, text: text)
func initRoom*(name: string): Room =
Room(name: name)
# ------------------------------------------------------------------- naming
func dm*(name: string): bool =
## Whether a buffer is a conversation with a person rather than a room.
## Every channel name starts with `#`; what does not is somebody's nick.
name.len > 0 and not name.startsWith("#")
func rowId*(m: Message): string =
## What this client calls a line: the server's name for it where there is
## one, and the name it was given here where there is not.
##
## freeq tags a message with a msgid and that is a line's identity
## everywhere it matters — a reply points at one, an edit rewrites one, a
## reaction lands on one. But not every line arrives with one: a replayed
## backlog can come with no tags at all, and a line this client has just
## sent has none until the server echoes it back. Those lines are not
## nameless to the reader — they are on the screen — so they get a local id
## made out of what they are. It is never sent.
if m.id.len > 0: m.id else: m.localId
func answersTo*(m: Message, id: string): bool =
## Whether `id` names this line — by any of the names it has had.
##
## `rowId` is what this client calls a line; this is what everybody else may
## call it. A message keeps the id it was born with through every revision,
## but the server gives each revision a msgid of its own, and anyone
## replying to a line already rewritten answers the wording in front of them
## — so the reply names the revision rather than the original. Both are this
## message, so both find it.
##
## The local name counts too: a line just sent has no msgid until the echo,
## and its own reply chip points at the local id until then.
if id.len == 0: return false
id == m.id or id == m.localId or id in m.editIds
func messageById*(ch: Room, id: string): Option[Message] =
## The message `id` names, if this buffer still holds it.
for m in ch.messages:
if m.answersTo(id): return some(m)
none(Message)
func indexById*(ch: Room, id: string): int =
## Where it is, or -1. Separate from `messageById` because scrolling wants
## the position and reading wants the value, and returning a copy to find an
## index would be the wrong way round.
for i, m in ch.messages:
if m.answersTo(id): return i
-1
|