finishing first attempt at message model pr. mainly group acks

This commit is contained in:
erinn 2020-10-20 15:57:34 -07:00
parent 7bb2198879
commit e09ad91ab5
5 changed files with 174 additions and 243 deletions

View File

@ -10,6 +10,7 @@ import (
"cwtch.im/ui/go/constants"
"cwtch.im/ui/go/the"
"cwtch.im/ui/go/ui"
"encoding/hex"
"git.openprivacy.ca/openprivacy/log"
"strconv"
"time"
@ -54,19 +55,15 @@ func PeerHandler(onion string, uiManager ui.Manager, subscribed chan bool) {
case event.NewMessageFromPeer: //event.TimestampReceived, event.RemotePeer, event.Data
ts, _ := time.Parse(time.RFC3339Nano, e.Data[event.TimestampReceived])
//uiManager.AddMessage(e.Data[event.RemotePeer], e.Data[event.RemotePeer], e.Data[event.Data], false, e.EventID, ts, true)
uiManager.AboutToAddMessage()
//time.Sleep(time.Millisecond)
peer.StoreMessage(e.Data[event.RemotePeer], e.Data[event.Data], ts)
uiManager.MessageJustAdded()
uiManager.StoreAndNotify(peer, e.Data[event.RemotePeer], e.Data[event.Data], ts, onion)
case event.PeerAcknowledgement:
uiManager.Acknowledge(e.Data[event.EventID])
uiManager.Acknowledge(e.Data[event.RemotePeer], e.Data[event.EventID])
case event.NewMessageFromGroup: //event.TimestampReceived, event.TimestampSent, event.Data, event.GroupID, event.RemotePeer
ts, _ := time.Parse(time.RFC3339Nano, e.Data[event.TimestampSent])
uiManager.AddMessage(e.Data[event.GroupID], e.Data[event.RemotePeer], e.Data[event.Data], e.Data[event.RemotePeer] == peer.GetOnion(), e.Data[event.Signature], ts, true)
uiManager.AddMessage(e.Data[event.GroupID], e.Data[event.RemotePeer], e.Data[event.Data], e.Data[event.RemotePeer] == peer.GetOnion(), hex.EncodeToString([]byte(e.Data[event.Signature])), ts, true)
case event.NewGroupInvite:
gid, err := peer.ProcessInvite(e.Data[event.GroupInvite], e.Data[event.RemotePeer])
group := peer.GetGroup(gid)

View File

@ -2,6 +2,7 @@ package ui
import (
"encoding/base64"
"strconv"
"sync"
"cwtch.im/cwtch/app"
@ -12,13 +13,11 @@ import (
"cwtch.im/ui/go/constants"
"github.com/therecipe/qt/qml"
"encoding/base32"
"strings"
"time"
"cwtch.im/ui/go/the"
"encoding/base32"
"git.openprivacy.ca/openprivacy/log"
"github.com/therecipe/qt/core"
"strings"
)
type GrandCentralDispatcher struct {
@ -66,8 +65,6 @@ type GrandCentralDispatcher struct {
_ func(handle, key, value string) `signal:"UpdateContactAttribute"`
// messages pane stuff
_ func(handle, from, displayName, message, image string, mID string, fromMe bool, ts int64, ackd bool, error bool) `signal:"AppendMessage"`
_ func(handle, from, displayName, message, image string, mID string, fromMe bool, ts int64, ackd bool, error bool) `signal:"PrependMessage"`
_ func() `signal:"ClearMessages"`
_ func() `signal:"ResetMessagePane"`
_ func(mID string) `signal:"Acknowledged"`
@ -120,6 +117,8 @@ type GrandCentralDispatcher struct {
_ func() `signal:"blockUnknownPeers,auto"`
_ func(onion string) `signal:"storeHistoryForPeer,auto"`
_ func(onion string) `signal:"deleteHistoryForPeer,auto"`
// chat
_ func(mID string) `slot:"acktest,auto"`
_ func(handle string) `signal:"requestServerSettings,auto"`
@ -175,6 +174,18 @@ func (this *GrandCentralDispatcher) DoIfProfile(profile string, fn func()) {
}
}
// Like DoIfProfile() but runs elseFn() if profile isn't the currently selected one in the UI
func (this *GrandCentralDispatcher) DoIfProfileElse(profile string, fn func(), elseFn func()) {
this.profileLock.Lock()
defer this.profileLock.Unlock()
if this.m_selectedProfile == profile {
fn()
} else {
elseFn()
}
}
func (this *GrandCentralDispatcher) selectedConversation() string {
this.conversationLock.Lock()
defer this.conversationLock.Unlock()
@ -204,6 +215,18 @@ func (this *GrandCentralDispatcher) DoIfConversation(conversation string, fn fun
}
}
// like DoIfConversation() but
func (this *GrandCentralDispatcher) DoIfConversationElse(conversation string, fn func(), elseFn func()) {
this.conversationLock.Lock()
defer this.conversationLock.Unlock()
if this.m_selectedConversation == conversation {
fn()
} else {
elseFn()
}
}
func (this *GrandCentralDispatcher) sendMessage(message string) {
if len(message) > 65530 {
this.InvokePopup("message is too long")
@ -224,22 +247,18 @@ func (this *GrandCentralDispatcher) sendMessage(message string) {
}
}
mID, err := the.Peer.SendMessageToGroupTracked(this.SelectedConversation(), message)
this.GetUiManager(this.selectedProfile()).AddMessage(this.SelectedConversation(), "me", message, true, mID, time.Now(), false)
this.TimelineInterface.AddMessage(this.TimelineInterface.num())
_, err := the.Peer.SendMessageToGroupTracked(this.SelectedConversation(), message)
this.TimelineInterface.RequestEIR()
if err != nil {
this.InvokePopup("failed to send message " + err.Error())
return
}
} else {
to := this.SelectedConversation()
prenum := this.TimelineInterface.num()
this.TimelineInterface.AddMessage(prenum)
/*mID := */the.Peer.SendMessageToPeer(to, message)
this.TimelineInterface.AddMessage(this.TimelineInterface.num())
the.Peer.SendMessageToPeer(this.SelectedConversation(), message)
this.TimelineInterface.RequestEIR()
//this.GetUiManager(this.selectedProfile()).AddMessage(to, "me", message, true, mID, time.Now(), false)
}
}
@ -266,7 +285,6 @@ func (this *GrandCentralDispatcher) loadMessagesPaneHelper(handle string) {
this.UpdateContactStatus(group.GroupID, int(state), loading)
this.requestGroupSettings(handle)
tl := group.GetTimeline()
nick := GetNick(handle)
updateLastReadTime(group.GroupID)
if nick == "" {
@ -275,34 +293,6 @@ func (this *GrandCentralDispatcher) loadMessagesPaneHelper(handle string) {
this.SetToolbarTitle(nick)
}
go func() {
// Janky hack to let the ui/qml respond to the status updates first before freezing under a deluge of new messages
time.Sleep(10 * time.Millisecond)
for i := len(tl) - 1; i >= 0; i-- {
if tl[i].PeerID == the.Peer.GetOnion() {
handle = "me"
} else {
handle = tl[i].PeerID
}
name := GetNick(tl[i].PeerID)
image := GetProfilePic(tl[i].PeerID)
this.PrependMessage(
handle,
tl[i].PeerID,
name,
tl[i].Message,
image,
string(tl[i].Signature),
tl[i].PeerID == the.Peer.GetOnion(),
tl[i].Timestamp.Unix(),
tl[i].Received.Equal(time.Unix(0, 0)) == false, // If the received timestamp is epoch, we have not yet received this message through an active server
false,
)
}
}()
return
} // ELSE LOAD CONTACT
@ -317,32 +307,6 @@ func (this *GrandCentralDispatcher) loadMessagesPaneHelper(handle string) {
}
updateLastReadTime(contact.Onion)
this.SetToolbarTitle(nick)
peer := the.Peer.GetContact(handle)
messages := peer.Timeline.GetMessages()
for i := range messages {
from := messages[i].PeerID
fromMe := messages[i].PeerID == the.Peer.GetOnion()
if fromMe {
from = "me"
}
displayname := GetNick(messages[i].PeerID)
image := GetProfilePic(messages[i].PeerID)
this.AppendMessage(
from,
messages[i].PeerID,
displayname,
messages[i].Message,
image,
string(messages[i].Signature),
fromMe,
messages[i].Timestamp.Unix(),
messages[i].Acknowledged,
messages[i].Error != "",
)
}
}
func (this *GrandCentralDispatcher) requestSettings() {
@ -760,3 +724,8 @@ func (this *GrandCentralDispatcher) deleteProfile(onion string) {
log.Infof("deleteProfile %v\n", onion)
the.CwtchApp.DeletePeer(onion)
}
func (this *GrandCentralDispatcher) acktest(mID string) {
idx, _ := strconv.Atoi(mID)
this.TimelineInterface.EditMessage(idx)
}

View File

@ -4,6 +4,7 @@ import (
"cwtch.im/cwtch/app"
"cwtch.im/cwtch/model"
"cwtch.im/cwtch/model/attr"
"cwtch.im/cwtch/peer"
"cwtch.im/cwtch/protocol/connections"
"cwtch.im/ui/go/constants"
"cwtch.im/ui/go/the"
@ -202,7 +203,7 @@ type manager struct {
// manager also performs call filtering based on UI state: users of manager can safely always call it on events and not have to worry about weather the relevant ui is active
// ie: you can always safely call AddMessage even if in the ui a different profile is selected. manager will check with gcd, and if the correct conditions are not met, it will not call on gcd to update the ui incorrectly
type Manager interface {
Acknowledge(mID string)
Acknowledge(handle, mID string)
AddContact(Handle string)
AddSendMessageError(peer string, signature string, err string)
AddMessage(handle string, from string, message string, fromMe bool, messageID string, timestamp time.Time, Acknowledged bool)
@ -218,6 +219,7 @@ type Manager interface {
AboutToAddMessage()
MessageJustAdded()
StoreAndNotify(peer.CwtchPeer, string, string, time.Time, string)
}
// NewManager returns a new Manager interface for a profile to the gcd
@ -226,9 +228,11 @@ func NewManager(profile string, gcd *GrandCentralDispatcher) Manager {
}
// Acknowledge acknowledges the given message id in the UI
func (this *manager) Acknowledge(mID string) {
func (this *manager) Acknowledge(handle, mID string) {
this.gcd.DoIfProfile(this.profile, func() {
this.gcd.Acknowledged(mID)
this.gcd.DoIfConversation(handle, func(){
this.gcd.Acktest(mID)
})
})
}
@ -291,28 +295,34 @@ func (this *manager) MessageJustAdded() {
this.gcd.TimelineInterface.RequestEIR()
}
func (this *manager) StoreAndNotify(pere peer.CwtchPeer, onion string, messageTxt string, sent time.Time, profileOnion string) {
this.gcd.DoIfProfileElse(this.profile, func() {
this.gcd.DoIfConversationElse(onion, func() {
updateLastReadTime(onion)
this.gcd.TimelineInterface.AddMessage(this.gcd.TimelineInterface.num())
pere.StoreMessage(onion, messageTxt, sent)
this.gcd.TimelineInterface.RequestEIR()
}, func() {
updateLastReadTime(onion)
pere.StoreMessage(onion, messageTxt, sent)
})
this.gcd.IncContactUnreadCount(onion)
}, func() {
the.CwtchApp.GetPeer(profileOnion).StoreMessage(onion, messageTxt, sent)
})
}
// AddMessage adds a message to the message pane for the supplied conversation if it is active
func (this *manager) AddMessage(handle string, from string, message string, fromMe bool, messageID string, timestamp time.Time, Acknowledged bool) {
log.Errorf("uiManager.AddMessage(...) uhhhh???? NO ZOOP ZEEP")
this.gcd.DoIfProfile(this.profile, func() {
//nick := GetNick(handle)
//image := GetProfilePic(handle)
// If we have this group loaded already
this.gcd.DoIfConversation(handle, func() {
updateLastReadTime(handle)
// If the message is not from the user then add it, otherwise, just acknowledge.
if !fromMe {
this.gcd.TimelineInterface.AddMessage(this.gcd.TimelineInterface.num())
//this.gcd.AppendMessage(handle, from, nick, message, image, messageID, fromMe, timestamp.Unix(), false, false)
if !fromMe || !Acknowledged {
this.gcd.TimelineInterface.AddMessage(this.gcd.TimelineInterface.num()-1)
this.gcd.TimelineInterface.RequestEIR()
} else {
if !Acknowledged {
this.gcd.TimelineInterface.AddMessage(this.gcd.TimelineInterface.num())
//this.gcd.AppendMessage(handle, from, nick, message, image, messageID, fromMe, timestamp.Unix(), false, false)
} else {
this.gcd.Acknowledged(messageID)
}
this.gcd.Acknowledged(messageID)
}
})
this.gcd.IncContactUnreadCount(handle)

View File

@ -3,6 +3,7 @@ package ui
import (
"cwtch.im/cwtch/model"
"cwtch.im/ui/go/the"
"encoding/hex"
"git.openprivacy.ca/openprivacy/log"
"github.com/therecipe/qt/core"
"reflect"
@ -12,16 +13,16 @@ import (
type MessageModel struct {
core.QAbstractTableModel
ackIdx int
handle string
//_ string `property:"handle,auto"`
_ func(string) `signal:"setHandle,auto"`
_ map[int]*core.QByteArray `property:"roles"`
_ func() `constructor:"init"`
_ func(int) `signal:"addMessage,auto"`
_ func(string) `signal:"createLocalFormEntry,auto"`
_ func() `signal:"requestEIR,auto"` // request this.EndInsertRecord() on gui thread
_ func(int) `signal:"editMessage,auto"`
_ func() `signal:"requestEIR,auto"`
_ func(string) string `slot:"getNick,auto"`
_ func(string) string `slot:"getImage,auto"`
@ -36,6 +37,8 @@ type MessageWrapper struct {
Acknowledged bool
RawMessage string
Error string
Day string
Signature string
_ bool `property:"ackd"`
}
@ -49,10 +52,12 @@ func (this *MessageModel) setHandle(handle string) {
func (this *MessageModel) init() {
//mdt := reflect.TypeOf([]model.Message{}).Elem()
mdt := reflect.TypeOf([]MessageWrapper{}).Elem()
roles := make(map[int]*core.QByteArray)
for i := 0; i < mdt.NumField(); i++ {
if mdt.Field(i).Name == "Acknowledged" {
this.ackIdx = int(core.Qt__UserRole) + 1 + i
}
roles[int(core.Qt__UserRole) + 1 + i] = core.NewQByteArray2(mdt.Field(i).Name, -1)
}
roles[int(core.Qt__DisplayRole)] = core.NewQByteArray2("display", -1)
@ -83,7 +88,7 @@ func (this *MessageModel) getImage(handle string) string {
func (this *MessageModel) num() int {
if this.Handle() == "" || the.Peer == nil {
log.Debugf("num: early returning 0")
log.Debugf("MessageModel.num: early returning 0")
return 0
}
@ -95,19 +100,17 @@ func (this *MessageModel) num() int {
} else {
contact := the.Peer.GetContact(this.Handle())
if contact != nil {
log.Debugf("num: returning %v", len(contact.Timeline.Messages))
return len(contact.Timeline.Messages)
}
}
log.Warnf("group/contact was nil, returning 0")
log.Warnf("MessageModel.num: group/contact was nil, returning 0")
return 0
}
func (this *MessageModel) getMessage(idx int) *MessageWrapper {
log.Infof("MessageModel.getMessage(%v)", idx)
var modelmsg model.Message
var ackd bool
if this.isGroup() {
group := the.Peer.GetGroup(this.Handle())
@ -115,6 +118,7 @@ func (this *MessageModel) getMessage(idx int) *MessageWrapper {
modelmsg = group.UnacknowledgedMessages[idx - len(group.Timeline.Messages)]
} else {
modelmsg = group.Timeline.Messages[idx]
ackd = true
}
} else {
contact := the.Peer.GetContact(this.Handle())
@ -122,9 +126,9 @@ func (this *MessageModel) getMessage(idx int) *MessageWrapper {
modelmsg = model.Message{Message:"oops test hi uhhhhh :/"}
} else if idx >= len(contact.Timeline.Messages) {
log.Errorf("this shouldnt happen")
//modelmsg = contact.UnacknowledgedMessages[idx-len(contact.Timeline.Messages)]
} else {
modelmsg = contact.Timeline.Messages[idx]
ackd = modelmsg.Acknowledged
}
}
@ -134,13 +138,13 @@ func (this *MessageModel) getMessage(idx int) *MessageWrapper {
RawMessage: modelmsg.Message,
PeerID: modelmsg.PeerID,
Error: modelmsg.Error,
Acknowledged: modelmsg.Acknowledged,
Acknowledged: ackd,
Day: modelmsg.Timestamp.Format("January 2, 2006"),
Signature: hex.EncodeToString(modelmsg.Signature),
}
}
func (this *MessageModel) data(index *core.QModelIndex, role int) *core.QVariant {
log.Infof("MessageModel.data(%v, %v)", index.Row(), role)
if !index.IsValid() {
return core.NewQVariant()
}
@ -173,7 +177,7 @@ func (this *MessageModel) headerData(section int, orientation core.Qt__Orientati
return this.HeaderDataDefault(section, orientation, role)
}
mdt := reflect.TypeOf([]model.Message{}).Elem()
mdt := reflect.TypeOf([]MessageWrapper{}).Elem()
return core.NewQVariant12(mdt.Field(section).Name)
}
@ -185,30 +189,29 @@ func (this *MessageModel) columnCount(parent *core.QModelIndex) int {
return reflect.TypeOf(MessageWrapper{}).NumField()
}
// perform this.BeginInsertRows() on the gui thread
// important:
// 1. idx MUST be set to this.num()'s value *before* calling addMessage()
// 2. insert the message yourself
// 3. this.RequestEIR() *must* be called afterward
func (this *MessageModel) addMessage(idx int) {
log.Debugf("MessageModel.addMessage() ZOOP ZOOP %v", this.handle)
this.BeginInsertRows(core.NewQModelIndex(), idx, idx)//this.num(), this.num())
//this.modelData = append(this.modelData, *fe)
//this.RequestEIR()
this.BeginInsertRows(core.NewQModelIndex(), idx, idx)
}
// perform this.EndInsertRows() on the gui thread
// perform this.EndInsertRows() on the gui thread after an AddMessage()
func (this *MessageModel) requestEIR() {
log.Debugf("MessageModel.requestEIR() ZEEP ZEEP %v", this.handle)
this.EndInsertRows()
}
func (this *MessageModel) createLocalFormEntry(name string) {
go this.createLocalFormEntry_thread(name)
}
func (this *MessageModel) createLocalFormEntry_thread(name string) {
log.Debugf("nyi #9779729343959699492726648294050382")
/*
fe := &model.Message{
Message: "hi!",
// notify the gui that the message acknowledgement at index idx has been modified
func (this *MessageModel) editMessage(idx int) {
if idx < 0 || idx >= this.num() {
log.Errorf("cant edit message %v. probably fine", idx)
return
}
this.addMessage(fe)
*/
}
log.Debugf("editMessage(%v, %v)", idx, this.ackIdx)
indexObject := this.Index(idx, 0, core.NewQModelIndex())
// replace third param with []int{} to update all attributes instead
this.DataChanged(indexObject, indexObject, []int{this.ackIdx})
}

View File

@ -16,11 +16,30 @@ W.Overlay {
//horizontalPadding: 15 * gcd.themeScale
ListModel { // MESSAGE OBJECTS ARE STORED HERE ...
id: messagesModel
Connections {
target: mm
onRowsInserted: {
if (messagesListView.atYEnd) thymer.running = true
//todo: this won't fire for non-active convos
windowItem.alert(0)
if (gcd.os == "android" && windowItem.activeFocusItem == null) {
androidCwtchActivity.notification = "New Content"
}
}
}
// onRowsInserted is firing after the model is updated but before the delegate is inflated
// causing positionViewAtEnd() to scroll to "just above the last message"
// so we use this timer to delay scrolling by a few milliseconds
Timer {
id: thymer
interval: 30
onTriggered: {
thymer.running = false
messagesListView.positionViewAtEnd()
}
}
contentItem: ListView {
id: messagesListView
@ -28,22 +47,26 @@ W.Overlay {
Layout.fillWidth: true
width: parent.width
model: mm//messagesModel//
model: mm
spacing: 6
clip: true
ScrollBar.vertical: Opaque.ScrollBar {}
maximumFlickVelocity: 1250
section.delegate: sectionHeading
section.property: "Day"
delegate: W.Message {
handle: PeerID
from: PeerID
displayName: mm.getNick(PeerID)
message: JSON.parse(RawMessage).d
message: JSON.parse(RawMessage).d+"//"+Signature
rawMessage: RawMessage
image: mm.getImage(PeerID)
messageID: "-1"//_mid
fromMe: PeerID == gcd.SelectedProfile
messageID: Signature
fromMe: PeerID == gcd.selectedProfile
timestamp: parseInt(Timestamp)
ackd: Acknowledged
error: Error
@ -53,119 +76,48 @@ W.Overlay {
width: messagesListView.width
}
Component {
id: sectionHeading
Rectangle {// outer rect because anchors._Margin isnt supported here
// with qt 5.15+ this can be changed to...
// required property string section
property string txt: section
color: Theme.backgroundMainColor
width: childrenRect.width
height: childrenRect.height + 12
anchors.horizontalCenter: parent.horizontalCenter
Rectangle {
opacity: 1
width: childrenRect.width + 66
height: childrenRect.height + 6
color: Theme.messageFromOtherBackgroundColor
radius: 15
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
Text {
// ... and this can be changed to
// text: parent.parent.section
text: parent.parent.txt
font.pixelSize: Theme.chatSize * gcd.themeScale
color: Theme.messageFromOtherTextColor
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
}
}
}
}
Connections {
target: gcd
onClearMessages: function() {
messagesModel.clear()
messagesListView.model = null
messagesListView.model = mm
}
onAppendMessage: function(handle, from, displayName, message, image, mid, fromMe, ts, ackd, error) {
return
var msg
try {
msg = JSON.parse(message)
} catch (e) {
msg = {"o": 1, "d": "(legacy message type) " + message}
}
if (msg.o != 1) return
var date = new Date(ts * 1000);
if (messagesModel.count != 0) {
var prevDate = new Date(messagesModel.get(messagesModel.count-1)["_ts"] * 1000);
if (prevDate.getFullYear() != date.getFullYear()
|| prevDate.getMonth() != date.getMonth()
|| prevDate.getUTCDate() != date.getUTCDate()) {
// new Day detected, Add Date message divider
messagesModel.append({
"_handle": "calendar",
"_from": "calendar",
"_displayName": "calendar",
"_message": Qt.formatDateTime(date, "MMMM dd, yyyy"),
"_rawMessage": "",
"_image": "",
"_mid": "",
"_fromMe": false,
"_ts": ts,
"_ackd": true,
"_error": "",
})
}
}
messagesModel.append({
"_handle": handle,
"_from": from,
"_displayName": displayName,
"_message": msg.d,
"_rawMessage":msg.d,
"_image": image,
"_mid": mid,
"_fromMe": fromMe,
"_ts": ts,
"_ackd": ackd,
"_error": error == true ? "this message failed to send" : "",
})
messagesListView.positionViewAtEnd()
// If the window is out of focus, alert the user (makes taskbar light up)
windowItem.alert(0)
if (gcd.os == "android" && windowItem.activeFocusItem == null) {
androidCwtchActivity.notification = "New Content"
}
}
onPrependMessage: function(handle, from, displayName, message, image, mid, fromMe, ts, ackd, error) {
return
var msg
try {
msg = JSON.parse(message)
} catch (e) {
msg = {"o": 1, "d": "(legacy message type) " + message}
}
if (msg.o != 1) return
var date = new Date(ts * 1000);
if (messagesModel.count != 0) {
var prevDate = new Date(messagesModel.get(0)["_ts"] * 1000);
if (prevDate.getFullYear() != date.getFullYear()
|| prevDate.getMonth() != date.getMonth()
|| prevDate.getUTCDate() != date.getUTCDate()) {
messagesModel.insert(0, {
"_handle": "calendar",
"_from": "calendar",
"_displayName": "calendar",
"_message": Qt.formatDateTime(prevDate, "MMMM dd, yyyy"),
"_rawMessage": "",
"_image": "",
"_mid": "",
"_fromMe": false,
"_ts": ts,
"_ackd": true,
"_error": "",
})
}
}
messagesModel.insert(0, {
"_handle": handle,
"_from": from,
"_displayName": displayName,
"_message": msg.d,
"_rawMessage":msg.d,
"_image": image,
"_mid": mid,
"_fromMe": fromMe,
"_ts": ts,
"_ackd": ackd,
"_error": error == true ? "this message failed to send" : "",
})
messagesListView.positionViewAtEnd()
messagesListView.positionViewAtEnd()
thymer.running = true
}
}
}