From 8a9ba6d1540b2f19a2c86063dc9f5f671e9f50ef Mon Sep 17 00:00:00 2001 From: erinn Date: Wed, 21 Nov 2018 16:01:17 -0800 Subject: [PATCH] initial commit --- gcd.go | 237 ----------- go/characters/cwtchlistener.go | 39 ++ go/characters/grouppoller.go | 21 + go/characters/postmanpat.go | 39 ++ go/characters/presencepoller.go | 49 +++ go/characters/torstatuspoller.go | 35 ++ go/constants/style.go | 3 + .../cwtchthings/chatchannellistener.go | 33 +- go/cwutil/utils.go | 29 ++ go/gobjects/contact.go | 11 + go/gobjects/letter.go | 8 + go/gobjects/message.go | 14 + go/gothings/gcd.go | 303 ++++++++++++++ go/gothings/uistate.go | 116 ++++++ go/the/globals.go | 11 + main.go | 394 +++++------------- qml/main.qml | 60 +-- qml/panes/AddGroupPane.qml | 53 +++ qml/panes/GroupSettingsPane.qml | 68 +++ qml/panes/SettingsPane.qml | 50 +++ qml/widgets/ContactList.qml | 36 +- qml/widgets/{Contact.qml => ContactRow.qml} | 39 +- qml/widgets/Message.qml | 10 +- qml/widgets/MessageList.qml | 39 +- qml/widgets/MyProfile.qml | 19 +- qml/widgets/MyProfile.qmlc | Bin 21412 -> 22324 bytes qml/widgets/ScalingLabel.qml | 2 +- qml/widgets/SimpleButton.qml | 4 +- qml/widgets/SimpleButton.qmlc | Bin 8384 -> 8384 bytes qml/widgets/StackToolbar.qml | 2 +- 30 files changed, 1074 insertions(+), 650 deletions(-) delete mode 100644 gcd.go create mode 100644 go/characters/cwtchlistener.go create mode 100644 go/characters/grouppoller.go create mode 100644 go/characters/postmanpat.go create mode 100644 go/characters/presencepoller.go create mode 100644 go/characters/torstatuspoller.go create mode 100644 go/constants/style.go rename chatchannellistener.go => go/cwtchthings/chatchannellistener.go (55%) create mode 100644 go/cwutil/utils.go create mode 100644 go/gobjects/contact.go create mode 100644 go/gobjects/letter.go create mode 100644 go/gobjects/message.go create mode 100644 go/gothings/gcd.go create mode 100644 go/gothings/uistate.go create mode 100644 go/the/globals.go create mode 100644 qml/panes/AddGroupPane.qml create mode 100644 qml/panes/GroupSettingsPane.qml create mode 100644 qml/panes/SettingsPane.qml rename qml/widgets/{Contact.qml => ContactRow.qml} (80%) diff --git a/gcd.go b/gcd.go deleted file mode 100644 index d17b8a3..0000000 --- a/gcd.go +++ /dev/null @@ -1,237 +0,0 @@ -package main - -import ( - "cwtch.im/cwtch/model" - "encoding/base32" - "github.com/therecipe/qt/core" - "log" - "strings" - "time" - "fmt" -) - -var TIME_FORMAT = "Mon 3:04pm" - -type GrandCentralDispatcher struct { - core.QObject - - currentOpenConversation string `property:"currentOpenConversation"` - themeScale float32 `property:"themeScale"` - - // messages pane stuff - _ func(from, message, displayname string, mID uint, ts, source string) `signal:"AppendMessage"` - _ func() `signal:"ClearMessages"` - _ func() `signal:"ResetMessagePane"` - _ func(uint) `signal:"Acknowledged"` - - // contact list stuff - _ func(onion string, num int) `signal:"SetUnread"` - _ func(onion string, status int) `signal:"SetConnectionStatus"` - _ func(name, onion, server, image, badge string, trusted bool) `signal:"AddContact"` - _ func(onion string) `signal:"MarkTrusted"` - - // profile-area stuff - _ func(name, onion, image string) `signal:"UpdateMyProfile"` - _ func(status int, str string) `signal:"TorStatus"` - - // other stuff i can't ontologize atm - _ func(str string) `signal:"InvokePopup"` - - // exfiltrated signals (written in go, below) - _ func(message string, mid uint) `signal:"sendMessage,auto"` - _ func(onion string) `signal:"loadMessagesPane,auto"` - _ func(signal string) `signal:"broadcast,auto"` // convenience relay signal - _ func(str string) `signal:"importString,auto"` - _ func(str string) `signal:"popup,auto"` - _ func(nick string) `signal:"updateNick,auto"` -} - -func (this *GrandCentralDispatcher) sendMessage(message string, mID uint) { - if len(message) > 65530 { - gcd.InvokePopup("message is too long") - return - } - - if gcd.currentOpenConversation == "" { - return - } - - if len(gcd.currentOpenConversation) == 32 { // SEND TO GROUP - if !peer.GetGroup(gcd.currentOpenConversation).Accepted { - peer.GetGroup(gcd.currentOpenConversation).Accepted = true - peer.Save() - } - - peer.SendMessageToGroup(gcd.currentOpenConversation, message) - return - } - - // TODO: require explicit invite accept/reject instead of implicitly trusting on send - if !peer.GetContact(gcd.currentOpenConversation).Trusted { - peer.GetContact(gcd.currentOpenConversation).Trusted = true - peer.Save() - gcd.MarkTrusted(gcd.currentOpenConversation) - } - - select { // 1 weird trick to do a non-blocking send. this means the user can only send a limited number of messages - // before the channel buffer fills. TODO: stop the user from sending if the buffer is full - case outgoingMessages <- Message{gcd.currentOpenConversation, message, true, mID, time.Now()}: - default: - } - - DeliverMessageToUI(gcd.currentOpenConversation, gcd.currentOpenConversation, "", message, mID, true, time.Now()) -} - -func (this *GrandCentralDispatcher) loadMessagesPane(onion string) { - gcd.ClearMessages() - gcd.currentOpenConversation = onion - gcd.SetUnread(onion, 0) - - if len(onion) == 32 { // LOAD GROUP - log.Printf("LOADING GROUP %s", onion) - tl := peer.GetGroup(onion).GetTimeline() - log.Printf("messages: %d", len(tl)) - for i := range tl { - var handle string - if tl[i].PeerID == peer.GetProfile().Onion { - handle = "me" - } else { - handle = tl[i].PeerID - } - var name string - var exists bool - name, exists = peer.GetProfile().GetCustomAttribute(tl[i].PeerID + "_name") - if !exists || name == "" { - name = tl[i].PeerID[:16] + "..." - } - gcd.AppendMessage(handle, tl[i].Message, name, 0, tl[i].Timestamp.Format(TIME_FORMAT), randomProfileImage(tl[i].PeerID)) - } - return - } // ELSE LOAD CONTACT - - _, exists := contactMgr[onion] - if exists { // (if not, they haven't been accepted as a contact yet) - contactMgr[onion].Unread = 0 - - messages := contactMgr[onion].Messages - for i := range messages { - from := messages[i].With - if messages[i].FromMe { - from = "me" - } - gcd.AppendMessage(from, messages[i].Message, "", messages[i].MessageID, messages[i].Timestamp.Format(TIME_FORMAT), randomProfileImage(onion)) - } - } -} - -func (this *GrandCentralDispatcher) broadcast(signal string) { - switch signal { - default: - log.Printf("unhandled broadcast signal: %v", signal) - case "ResetMessagePane": - gcd.ResetMessagePane() - } -} - -func (this *GrandCentralDispatcher) importString(str string) { - if len(str) < 5 { - log.Printf("ignoring short string") - return - } - - log.Printf("importing: %s\n", str) - onion := str - name := onion - str = strings.TrimSpace(str) - - //eg: torv3JFDWkXExBsZLkjvfkkuAxHsiLGZBk0bvoeJID9ItYnU=EsEBCiBhOWJhZDU1OTQ0NWI3YmM2N2YxYTM5YjkzMTNmNTczNRIgpHeNaG+6jy750eDhwLO39UX4f2xs0irK/M3P6mDSYQIaOTJjM2ttb29ibnlnaGoyenc2cHd2N2Q1N3l6bGQ3NTNhdW8zdWdhdWV6enB2ZmFrM2FoYzRiZHlkCiJAdVSSVgsksceIfHe41OJu9ZFHO8Kwv3G6F5OK3Hw4qZ6hn6SiZjtmJlJezoBH0voZlCahOU7jCOg+dsENndZxAA== - if str[0:5] == "torv3" { // GROUP INVITE - groupID, err := peer.ImportGroup(str) - if err != nil { - gcd.InvokePopup("not a valid group invite") - return - } - - group := peer.GetGroup(groupID) - peer.JoinServer(group.GroupServer) - peer.Save() - fmt.Printf("imported groupid=%s server=%s", groupID, group.GroupServer) - - return - } - - if strings.Contains(str, " ") { // usually people prepend spaces and we don't want it going into the name (use ~ for that) - parts := strings.Split(strings.TrimSpace(str), " ") - str = parts[len(parts)-1] - } - - if strings.Contains(str, "~") { - parts := strings.Split(str, "~") - onion = parts[len(parts)-1] - name = strings.Join(parts[:len(parts)-1], " ") - } - - if len(onion) != 56 { - gcd.InvokePopup("invalid format") - return - } - - name = strings.TrimSpace(name) - if name == "" { - gcd.InvokePopup("empty name") - return - } - - if len(name) > 32 { - name = name[:32] //TODO: better strategy for long names? - } - - decodedPub, err := base32.StdEncoding.DecodeString(strings.ToUpper(onion[:56])) - if err != nil { - log.Printf("%v", err) - gcd.InvokePopup("bad format. missing characters?") - return - } - - _, exists := peer.GetProfile().GetCustomAttribute(name + "_onion") - if exists { - gcd.InvokePopup("can't re-use names :(") - return - } - - _, exists = peer.GetProfile().GetCustomAttribute(onion + "_name") - if exists { - gcd.InvokePopup("already have this contact") - return //TODO: bring them to the duplicate - } - - pp := model.PublicProfile{ - name, - decodedPub[:32], - false, - false, - onion} - - if peer == nil { - log.Printf("[!!!] peer is nil?!?!?") - } - - log.Printf("adding %v <%v>", name, onion) - peer.GetProfile().Contacts[onion] = &pp - peer.GetProfile().SetCustomAttribute(onion+"_name", name) - peer.GetProfile().SetCustomAttribute(name+"_onion", onion) - peer.GetProfile().TrustPeer(onion) - peer.Save() - go peer.PeerWithOnion(onion) - //contactMgr[onion] = &Contact{[]Message{}, 0, 0} - //gcd.AddContact(name, onion, randomProfileImage(onion), "0") -} - -func (this *GrandCentralDispatcher) popup(str string) { - gcd.InvokePopup(str) -} - -func (this *GrandCentralDispatcher) updateNick(nick string) { - peer.GetProfile().Name = nick - peer.Save() -} diff --git a/go/characters/cwtchlistener.go b/go/characters/cwtchlistener.go new file mode 100644 index 0000000..1bb21fe --- /dev/null +++ b/go/characters/cwtchlistener.go @@ -0,0 +1,39 @@ +package characters + +import ( + "cwtch.im/cwtch/model" + "bounce/go/the" + "bounce/go/gobjects" +) + +func CwtchListener(callback func(message *gobjects.Message), groupID string, channel chan model.Message) { + for { + m := <-channel + + name := m.PeerID + if name == the.Peer.GetProfile().Onion { + name = "me" + } else { + var exists bool // lol this is a golang antifeature + name, exists = the.Peer.GetContact(m.PeerID).GetAttribute("name") + if !exists { + name = "" + } + } + + if name == "" { + name = m.PeerID[:16] + "..." + } + + callback(&gobjects.Message{ + groupID, + m.PeerID, + name, + m.Message, + "", + m.PeerID == the.Peer.GetProfile().Onion, + 0, + m.Timestamp, + }) + } +} \ No newline at end of file diff --git a/go/characters/grouppoller.go b/go/characters/grouppoller.go new file mode 100644 index 0000000..4e1c7c6 --- /dev/null +++ b/go/characters/grouppoller.go @@ -0,0 +1,21 @@ +package characters + +import ( + "time" + "bounce/go/the" + "bounce/go/gobjects" +) + +func GroupPoller(getContact func(string) *gobjects.Contact, updateContact func(string)) { + for { + time.Sleep(time.Second * 4) + + servers := the.Peer.GetServers() + groups := the.Peer.GetGroups() + for i := range groups { + group := the.Peer.GetGroup(groups[i]) + getContact(group.GroupID).Status = int(servers[group.GroupServer]) + updateContact(group.GroupID) + } + } +} diff --git a/go/characters/postmanpat.go b/go/characters/postmanpat.go new file mode 100644 index 0000000..643bb16 --- /dev/null +++ b/go/characters/postmanpat.go @@ -0,0 +1,39 @@ +package characters + +import ( + "bounce/go/the" + "git.openprivacy.ca/openprivacy/libricochet-go/channels" + "log" + "bounce/go/gobjects" +) + +func PostmanPat(messages chan gobjects.Letter) { + postOffice := make(map[string]chan gobjects.Letter) + + for { + m := <-messages + + _, found := postOffice[m.To] + if !found { + postOffice[m.To] = make(chan gobjects.Letter, 100) + go andHisBlackAndWhiteCat(postOffice[m.To]) + } + + postOffice[m.To] <- m + } +} + +func andHisBlackAndWhiteCat(incomingMessages chan gobjects.Letter) { + for { + m := <-incomingMessages + connection := the.Peer.PeerWithOnion(m.To) + connection.WaitTilAuthenticated() + connection.DoOnChannel("im.ricochet.chat", channels.Outbound, func(channel *channels.Channel) { + chatchannel, ok := channel.Handler.(*channels.ChatChannel) + if ok { + log.Printf("Sending packet") + the.AcknowledgementIDs[chatchannel.SendMessage(m.Message)] = m.MID + } + }) + } +} diff --git a/go/characters/presencepoller.go b/go/characters/presencepoller.go new file mode 100644 index 0000000..90cd70b --- /dev/null +++ b/go/characters/presencepoller.go @@ -0,0 +1,49 @@ +package characters + +import ( + "time" + "bounce/go/the" + "bounce/go/cwutil" + "bounce/go/gobjects" +) + +func PresencePoller(getContact func(string) *gobjects.Contact, addContact func(contact *gobjects.Contact), updateContact func(string)) { // TODO: make this subscribe-able in ricochet + time.Sleep(time.Second * 4) + for { + contacts := the.Peer.GetContacts() + for i := range contacts { + ct := getContact(contacts[i]) + if ct == nil { // new contact has attempted to connect with us, treat it as an invite + toc := the.Peer.GetContact(contacts[i]) + c, _ := the.Peer.GetProfile().GetContact(contacts[i]) + addContact(&gobjects.Contact{ + toc.Onion, + toc.Name, + cwutil.RandomProfileImage(toc.Onion), + "", + 0, + 0, + c.Trusted, + }) + c.SetAttribute("name", c.Name) + the.CwtchApp.SaveProfile(the.Peer) + } + + cxnState, found := the.Peer.GetPeers()[contacts[i]] + if !found { + c2 := getContact(contacts[i]) + if c2 != nil && c2.Status != -2 { + c2.Status = -2 + updateContact(contacts[i]) + } + } else { + c2 := getContact(contacts[i]) + if c2 != nil && c2.Status != int(cxnState) { + c2.Status = int(cxnState) + updateContact(contacts[i]) + } + } + } + time.Sleep(time.Second * 4) + } +} \ No newline at end of file diff --git a/go/characters/torstatuspoller.go b/go/characters/torstatuspoller.go new file mode 100644 index 0000000..a48e709 --- /dev/null +++ b/go/characters/torstatuspoller.go @@ -0,0 +1,35 @@ +package characters + +import ( + "git.openprivacy.ca/openprivacy/asaur" + "strconv" + "time" +) + +func TorStatusPoller(setTorStatus func(int, string)) { + for { + time.Sleep(time.Second) + //todo: this should use a config manager + //todo: also, try dialing the proxy to differentiate tor not running vs control port not configured + rawStatus, err := asaur.GetInfo("localhost:9051", "tcp4", "", "status/bootstrap-phase") + if err != nil { + setTorStatus(0, "can't find tor. is it running? is the controlport configured?") + continue + } + + status := asaur.ParseBootstrapPhase(rawStatus) + progress, _ := strconv.Atoi(status["PROGRESS"]) + + if status["TAG"] == "done" { + setTorStatus(3, "tor appears to be running just fine!") + continue + } + + if progress == 0 { + setTorStatus(1, "tor is trying to start up") + continue + } + + setTorStatus(2, status["SUMMARY"]) + } +} diff --git a/go/constants/style.go b/go/constants/style.go new file mode 100644 index 0000000..75b5bb4 --- /dev/null +++ b/go/constants/style.go @@ -0,0 +1,3 @@ +package constants + +var TIME_FORMAT = "Mon 3:04pm" diff --git a/chatchannellistener.go b/go/cwtchthings/chatchannellistener.go similarity index 55% rename from chatchannellistener.go rename to go/cwtchthings/chatchannellistener.go index dcaa873..5e403af 100644 --- a/chatchannellistener.go +++ b/go/cwtchthings/chatchannellistener.go @@ -1,20 +1,26 @@ -package main +package cwtchthings import ( "git.openprivacy.ca/openprivacy/libricochet-go/application" - "git.openprivacy.ca/openprivacy/libricochet-go/channels" + "git.openprivacy.ca/openprivacy/libricochet-go/channels" "time" - ) + "bounce/go/the" + "bounce/go/cwutil" + "bounce/go/gobjects" +) type ChatChannelListener struct { rai *application.ApplicationInstance ra *application.RicochetApplication + addMessage func(*gobjects.Message) + acknowledged func(uint) } - -func (this *ChatChannelListener) Init(rai *application.ApplicationInstance, ra *application.RicochetApplication) { +func (this *ChatChannelListener) Init(rai *application.ApplicationInstance, ra *application.RicochetApplication, addMessage func(*gobjects.Message), acknowledged func(uint)) { this.rai = rai this.ra = ra + this.addMessage = addMessage + this.acknowledged = acknowledged } // We always want bidirectional chat channels @@ -32,14 +38,23 @@ func (this *ChatChannelListener) OpenInbound() { } func (this *ChatChannelListener) ChatMessage(messageID uint32, when time.Time, message string) bool { - DeliverMessageToUI(this.rai.RemoteHostname, this.rai.RemoteHostname, "", message, uint(messageID), false, when) - go func() { + this.addMessage(&gobjects.Message{ + this.rai.RemoteHostname, + this.rai.RemoteHostname, + "", + message, + cwutil.RandomProfileImage(this.rai.RemoteHostname), + false, + int(messageID), + when, + }) + go func() { // TODO: this is probably no longer necessary. check later time.Sleep(time.Second) - peer.Save() + the.CwtchApp.SaveProfile(the.Peer) }() return true } func (this *ChatChannelListener) ChatMessageAck(messageID uint32, accepted bool) { - gcd.Acknowledged(acknowledgementIDs[messageID]) + this.acknowledged(the.AcknowledgementIDs[messageID]) } \ No newline at end of file diff --git a/go/cwutil/utils.go b/go/cwutil/utils.go new file mode 100644 index 0000000..9bb695c --- /dev/null +++ b/go/cwutil/utils.go @@ -0,0 +1,29 @@ +package cwutil + +import ( + "encoding/base32" + "encoding/hex" + "fmt" + "strings" +) + +// temporary until we do real picture selection +func RandomProfileImage(onion string) string { + choices := []string{"001-centaur", "002-kraken", "003-dinosaur", "004-tree-1", "005-hand", "006-echidna", "007-robot", "008-mushroom", "009-harpy", "010-phoenix", "011-dragon-1", "012-devil", "013-troll", "014-alien", "015-minotaur", "016-madre-monte", "017-satyr", "018-karakasakozou", "019-pirate", "020-werewolf", "021-scarecrow", "022-valkyrie", "023-curupira", "024-loch-ness-monster", "025-tree", "026-cerberus", "027-gryphon", "028-mermaid", "029-vampire", "030-goblin", "031-yeti", "032-leprechaun", "033-medusa", "034-chimera", "035-elf", "036-hydra", "037-cyclops", "038-pegasus", "039-narwhal", "040-woodcutter", "041-zombie", "042-dragon", "043-frankenstein", "044-witch", "045-fairy", "046-genie", "047-pinocchio", "048-ghost", "049-wizard", "050-unicorn"} + barr, err := base32.StdEncoding.DecodeString(strings.ToUpper(onion)) + if err != nil || len(barr) != 35 { + fmt.Printf("error: %v %v %v\n", onion, err, barr) + return "qrc:/qml/images/extra/openprivacy.png" + } + return "qrc:/qml/images/profiles/" + choices[int(barr[33])%len(choices)] + ".png" +} + +func RandomGroupImage(handle string) string { + choices := []string{"001-borobudur", "002-opera-house", "003-burj-al-arab", "004-chrysler", "005-acropolis", "006-empire-state-building", "007-temple", "008-indonesia-1", "009-new-zealand", "010-notre-dame", "011-space-needle", "012-seoul", "013-mosque", "014-milan", "015-statue", "016-pyramid", "017-cologne", "018-brandenburg-gate", "019-berlin-cathedral", "020-hungarian-parliament", "021-buckingham", "022-thailand", "023-independence", "024-angkor-wat", "025-vaticano", "026-christ-the-redeemer", "027-colosseum", "028-golden-gate-bridge", "029-sphinx", "030-statue-of-liberty", "031-cradle-of-humankind", "032-istanbul", "033-london-eye", "034-sagrada-familia", "035-tower-bridge", "036-burj-khalifa", "037-washington", "038-big-ben", "039-stonehenge", "040-white-house", "041-ahu-tongariki", "042-capitol", "043-eiffel-tower", "044-church-of-the-savior-on-spilled-blood", "045-arc-de-triomphe", "046-windmill", "047-louvre", "048-torii-gate", "049-petronas", "050-matsumoto-castle", "051-fuji", "052-temple-of-heaven", "053-pagoda", "054-chichen-itza", "055-forbidden-city", "056-merlion", "057-great-wall-of-china", "058-taj-mahal", "059-pisa", "060-indonesia"} + barr, err := hex.DecodeString(handle) + if err != nil || len(barr) == 0 { + fmt.Printf("error: %v %v %v\n", handle, err, barr) + return "qrc:/qml/images/extra/openprivacy.png" + } + return "qrc:/qml/images/servers/" + choices[int(barr[0])%len(choices)] + ".png" +} diff --git a/go/gobjects/contact.go b/go/gobjects/contact.go new file mode 100644 index 0000000..cdd235a --- /dev/null +++ b/go/gobjects/contact.go @@ -0,0 +1,11 @@ +package gobjects + +type Contact struct { + Handle string + DisplayName string + Image string + Server string + Badge int + Status int + Trusted bool +} \ No newline at end of file diff --git a/go/gobjects/letter.go b/go/gobjects/letter.go new file mode 100644 index 0000000..5b47fe7 --- /dev/null +++ b/go/gobjects/letter.go @@ -0,0 +1,8 @@ +package gobjects + +// a Letter is a very simple message object passed to us from the UI +type Letter struct { + To, Message string + MID uint +} + diff --git a/go/gobjects/message.go b/go/gobjects/message.go new file mode 100644 index 0000000..7d5ebb5 --- /dev/null +++ b/go/gobjects/message.go @@ -0,0 +1,14 @@ +package gobjects + +import "time" + +type Message struct { + Handle string + From string + DisplayName string + Message string + Image string + FromMe bool + MessageID int + Timestamp time.Time +} \ No newline at end of file diff --git a/go/gothings/gcd.go b/go/gothings/gcd.go new file mode 100644 index 0000000..31dbc37 --- /dev/null +++ b/go/gothings/gcd.go @@ -0,0 +1,303 @@ +package gothings + +import ( + "bounce/go/constants" + "bounce/go/cwutil" + + "encoding/base32" + "fmt" + "github.com/therecipe/qt/core" + "log" + "strings" + "time" + "bounce/go/the" + "cwtch.im/cwtch/model" + "bounce/go/characters" + "bounce/go/gobjects" +) + +type GrandCentralDispatcher struct { + core.QObject + + OutgoingMessages chan gobjects.Letter + UIState InterfaceState + + _ string `property:"currentOpenConversation"` + _ float32 `property:"themeScale"` + + // contact list stuff + _ func(handle, displayName, image, server string, badge, status int, trusted bool) `signal:"AddContact"` + _ func(handle, displayName, image, server string, badge, status int, trusted bool) `signal:"UpdateContact"` + + // messages pane stuff + _ func(handle, from, displayName, message, image string, mID uint, fromMe bool, ts string) `signal:"AppendMessage"` + _ func() `signal:"ClearMessages"` + _ func() `signal:"ResetMessagePane"` + _ func(mID uint) `signal:"Acknowledged"` + + // profile-area stuff + _ func(name, onion, image string) `signal:"UpdateMyProfile"` + _ func(status int, str string) `signal:"TorStatus"` + + // other stuff i can't ontologize atm + _ func(str string) `signal:"InvokePopup"` + _ func(name, server, invitation string) `signal:"SupplyGroupSettings"` + + // signals emitted from the ui (written in go, below) + _ func(message string, mid uint) `signal:"sendMessage,auto"` + _ func(onion string) `signal:"loadMessagesPane,auto"` + _ func(signal string) `signal:"broadcast,auto"` // convenience relay signal + _ func(str string) `signal:"importString,auto"` + _ func(str string) `signal:"popup,auto"` + _ func(nick string) `signal:"updateNick,auto"` + _ func(server, groupName string) `signal:"createGroup,auto"` + _ func() `signal:"requestGroupSettings,auto"` +} + +func (this *GrandCentralDispatcher) sendMessage(message string, mID uint) { + if len(message) > 65530 { + this.InvokePopup("message is too long") + return + } + + if this.CurrentOpenConversation() == "" { + this.InvokePopup("ui error") + return + } + + if len(this.CurrentOpenConversation()) == 32 { // SEND TO GROUP + if !the.Peer.GetGroup(this.CurrentOpenConversation()).Accepted { + the.Peer.GetGroup(this.CurrentOpenConversation()).Accepted = true + the.CwtchApp.SaveProfile(the.Peer) + c := this.UIState.GetContact(this.CurrentOpenConversation()) + c.Trusted = true + this.UIState.UpdateContact(c.Handle) + } + + the.Peer.SendMessageToGroup(this.CurrentOpenConversation(), message) + return + } + + // TODO: require explicit invite accept/reject instead of implicitly trusting on send + if !this.UIState.GetContact(this.CurrentOpenConversation()).Trusted { + this.UIState.GetContact(this.CurrentOpenConversation()).Trusted = true + this.UIState.UpdateContact(this.CurrentOpenConversation()) + } + + select { // 1 weird trick to do a non-blocking send. this means the user can only send a limited number of messages + // before the channel buffer fills. TODO: stop the user from sending if the buffer is full + case this.OutgoingMessages <- gobjects.Letter{this.CurrentOpenConversation(), message, mID}: + default: + } + + this.UIState.AddMessage(&gobjects.Message{ + this.CurrentOpenConversation(), + "me", + "", + message, + "", + true, + int(mID), + time.Now(), + }) +} + +func (this *GrandCentralDispatcher) loadMessagesPane(handle string) { + this.ClearMessages() + this.SetCurrentOpenConversation(handle) + c := this.UIState.GetContact(handle) + c.Badge = 0 + this.UIState.UpdateContact(handle) + + if len(handle) == 32 { // LOAD GROUP + log.Printf("LOADING GROUP %s", handle) + tl := the.Peer.GetGroup(handle).GetTimeline() + log.Printf("messages: %d", len(tl)) + for i := range tl { + if tl[i].PeerID == the.Peer.GetProfile().Onion { + handle = "me" + } else { + handle = tl[i].PeerID + } + var name string + var exists bool + name, exists = the.Peer.GetProfile().GetCustomAttribute(tl[i].PeerID + "_name") + if !exists || name == "" { + name = tl[i].PeerID[:16] + "..." + } + this.AppendMessage( + handle, + tl[i].PeerID, + name, + tl[i].Message, + cwutil.RandomProfileImage(tl[i].PeerID), + 0, + tl[i].PeerID == the.Peer.GetProfile().Onion, + tl[i].Timestamp.Format(constants.TIME_FORMAT), + ) + } + return + } // ELSE LOAD CONTACT + + messages := this.UIState.GetMessages(handle) + for i := range messages { + from := messages[i].From + if messages[i].FromMe { + from = "me" + } + this.AppendMessage( + messages[i].Handle, + from, + messages[i].DisplayName, + messages[i].Message, + cwutil.RandomProfileImage(handle), + uint(messages[i].MessageID), + messages[i].FromMe, + messages[i].Timestamp.Format(constants.TIME_FORMAT), + ) + } +} + +func (this *GrandCentralDispatcher) requestGroupSettings() { + log.Printf("requestGroupSettings()") + group := the.Peer.GetGroup(this.CurrentOpenConversation()) + nick, _ := group.GetAttribute("nick") + invite, _ := the.Peer.ExportGroup(this.CurrentOpenConversation()) + this.SupplyGroupSettings(nick, group.GroupServer, invite) +} + +func (this *GrandCentralDispatcher) broadcast(signal string) { + switch signal { + default: + log.Printf("unhandled broadcast signal: %v", signal) + case "ResetMessagePane": + this.ResetMessagePane() + } +} + +func (this *GrandCentralDispatcher) importString(str string) { + if len(str) < 5 { + log.Printf("ignoring short string") + return + } + + log.Printf("importing: %s\n", str) + onion := str + name := onion + str = strings.TrimSpace(str) + + //eg: torv3JFDWkXExBsZLkjvfkkuAxHsiLGZBk0bvoeJID9ItYnU=EsEBCiBhOWJhZDU1OTQ0NWI3YmM2N2YxYTM5YjkzMTNmNTczNRIgpHeNaG+6jy750eDhwLO39UX4f2xs0irK/M3P6mDSYQIaOTJjM2ttb29ibnlnaGoyenc2cHd2N2Q1N3l6bGQ3NTNhdW8zdWdhdWV6enB2ZmFrM2FoYzRiZHlkCiJAdVSSVgsksceIfHe41OJu9ZFHO8Kwv3G6F5OK3Hw4qZ6hn6SiZjtmJlJezoBH0voZlCahOU7jCOg+dsENndZxAA== + if str[0:5] == "torv3" { // GROUP INVITE + groupID, err := the.Peer.ImportGroup(str) + if err != nil { + this.InvokePopup("not a valid group invite") + return + } + + group := the.Peer.GetGroup(groupID) + the.Peer.JoinServer(group.GroupServer) + the.CwtchApp.SaveProfile(the.Peer) + this.UIState.AddContact(&gobjects.Contact{ + groupID[:12], + groupID, + cwutil.RandomGroupImage(groupID), + group.GroupServer, + 0, + 0, + true, + }) + fmt.Printf("imported groupid=%s server=%s", groupID, group.GroupServer) + + return + } + + if strings.Contains(str, " ") { // usually people prepend spaces and we don't want it going into the name (use ~ for that) + parts := strings.Split(strings.TrimSpace(str), " ") + str = parts[len(parts)-1] + } + + if strings.Contains(str, "~") { + parts := strings.Split(str, "~") + onion = parts[len(parts)-1] + name = strings.Join(parts[:len(parts)-1], " ") + } + + if len(onion) != 56 { + this.InvokePopup("invalid format") + return + } + + name = strings.TrimSpace(name) + if name == "" { + this.InvokePopup("empty name") + return + } + + if len(name) > 32 { + name = name[:32] //TODO: better strategy for long names? + } + + _, err := base32.StdEncoding.DecodeString(strings.ToUpper(onion[:56])) + if err != nil { + log.Printf("%v", err) + this.InvokePopup("bad format. missing characters?") + return + } + + _, exists := the.Peer.GetProfile().GetCustomAttribute(name + "_onion") + if exists { + this.InvokePopup("can't re-use names :(") + return + } + + _, exists = the.Peer.GetProfile().GetCustomAttribute(onion + "_name") + if exists { + this.InvokePopup("already have this contact") + return //TODO: bring them to the duplicate + } + + this.UIState.AddContact(&gobjects.Contact{ + onion, + name, + cwutil.RandomProfileImage(onion), + "", + 0, + 0, + true, + }) +} + +func (this *GrandCentralDispatcher) popup(str string) { + this.InvokePopup(str) +} + +func (this *GrandCentralDispatcher) updateNick(nick string) { + the.Peer.GetProfile().Name = nick + the.CwtchApp.SaveProfile(the.Peer) +} + +func (this *GrandCentralDispatcher) createGroup(server, groupName string) { + groupID, _, err := the.Peer.StartGroup(server) + if err != nil { + this.popup("group creation failed :(") + return + } + + this.UIState.AddContact(&gobjects.Contact{ + groupID, + groupName, + cwutil.RandomGroupImage(groupID), + server, + 0, + 0, + true, + }) + + group := the.Peer.GetGroup(groupID) + group.SetAttribute("nick", groupName) + the.CwtchApp.SaveProfile(the.Peer) + + the.Peer.JoinServer(server) + group.NewMessage = make(chan model.Message) + go characters.CwtchListener(this.UIState.AddMessage, group.GroupID, group.NewMessage) +} \ No newline at end of file diff --git a/go/gothings/uistate.go b/go/gothings/uistate.go new file mode 100644 index 0000000..c8d407a --- /dev/null +++ b/go/gothings/uistate.go @@ -0,0 +1,116 @@ +package gothings + +import ( + "cwtch.im/cwtch/model" + "encoding/base32" + "strings" + "log" + "bounce/go/constants" + "bounce/go/the" + "bounce/go/gobjects" +) + +type InterfaceState struct { + parentGcd *GrandCentralDispatcher + contacts map[string]*gobjects.Contact + messages map[string][]*gobjects.Message +} + +func NewUIState(gcd *GrandCentralDispatcher) (uis InterfaceState) { + uis = InterfaceState{gcd, make(map[string]*gobjects.Contact), make(map[string][]*gobjects.Message)} + return +} + +func (this *InterfaceState) AddContact(c *gobjects.Contact) { + if len(c.Handle) == 32 { // ADD GROUP + //TODO: we should handle group creation here too probably? the code for groups vs individuals is weird right now ^ea + + if _, found := this.contacts[c.Handle]; !found { + this.contacts[c.Handle] = c + this.parentGcd.AddContact(c.Handle, c.DisplayName, c.Image, c.Server, c.Badge, c.Status, c.Trusted) + } + + return + } else if len(c.Handle) != 56 { + log.Printf("sorry, unable to handle AddContact(%v)", c.Handle) + return + } + + if the.Peer.GetContact(c.Handle) == nil { + decodedPub, _ := base32.StdEncoding.DecodeString(strings.ToUpper(c.Handle)) + + pp := &model.PublicProfile{Name: c.DisplayName, Ed25519PublicKey: decodedPub[:32], Trusted: c.Trusted, Blocked:false, Onion:c.Handle, Attributes:make(map[string]string)} + pp.SetAttribute("name", c.DisplayName) + the.Peer.GetProfile().Contacts[c.Handle] = pp + if c.Trusted { + the.Peer.GetProfile().TrustPeer(c.Handle) + } + the.CwtchApp.SaveProfile(the.Peer) + go the.Peer.PeerWithOnion(c.Handle) + } + + if _, found := this.contacts[c.Handle]; !found { + this.contacts[c.Handle] = c + this.parentGcd.AddContact(c.Handle, c.DisplayName, c.Image, c.Server, c.Badge, c.Status, c.Trusted) + } + + the.CwtchApp.SaveProfile(the.Peer) +} + +func (this *InterfaceState) GetContact(handle string) *gobjects.Contact { + return this.contacts[handle] +} + +func (this *InterfaceState) AddMessage(m *gobjects.Message) { + _, found := this.contacts[m.Handle] + if !found { + this.AddContact(&gobjects.Contact{ + m.DisplayName, + m.Image, + m.Handle, + "", + 1, + 0, + false, + }) + } + + c := the.Peer.GetContact(m.Handle) + if c == nil { + + } + + _, found = this.messages[m.Handle] + if !found { + this.messages[m.Handle] = make([]*gobjects.Message, 0) + } + + this.messages[m.Handle] = append(this.messages[m.Handle], m) + + if this.parentGcd.CurrentOpenConversation() == m.Handle { + if m.FromMe { + m.From = "me" + } + + this.parentGcd.AppendMessage(m.Handle, m.From, m.DisplayName, m.Message, m.Image, uint(m.MessageID), m.FromMe, m.Timestamp.Format(constants.TIME_FORMAT)) + } else { + c := this.GetContact(m.Handle) + c.Badge++ + this.UpdateContact(c.Handle) + } +} + +func (this *InterfaceState) GetMessages(handle string) []*gobjects.Message { + _, found := this.messages[handle] + if !found { + this.messages[handle] = make([]*gobjects.Message, 0) + } + return this.messages[handle] +} + +func (this *InterfaceState) UpdateContact(handle string) { + c, found := this.contacts[handle] + if found { + this.parentGcd.UpdateContact(c.Handle, c.DisplayName, c.Image, c.Server, c.Badge, c.Status, c.Trusted) + } +} \ No newline at end of file diff --git a/go/the/globals.go b/go/the/globals.go new file mode 100644 index 0000000..6fc5e21 --- /dev/null +++ b/go/the/globals.go @@ -0,0 +1,11 @@ +package the + +import ( + libPeer "cwtch.im/cwtch/peer" + "cwtch.im/cwtch/app" + ) + +var CwtchApp app.Application +var Peer libPeer.CwtchPeer +var CwtchDir string +var AcknowledgementIDs map[uint32]uint diff --git a/main.go b/main.go index 4f2d503..319166c 100644 --- a/main.go +++ b/main.go @@ -1,84 +1,73 @@ package main import ( - libpeer "cwtch.im/cwtch/peer" - "cwtch.im/cwtch/peer/connections" - "encoding/base32" - "fmt" - "github.com/sethvargo/go-diceware/diceware" - "github.com/therecipe/qt/core" + "bounce/go/characters" + "bounce/go/cwutil" + "bounce/go/the" + libapp "cwtch.im/cwtch/app" + "cwtch.im/cwtch/model" + "fmt" + "git.openprivacy.ca/openprivacy/libricochet-go/application" + "git.openprivacy.ca/openprivacy/libricochet-go/channels" + "github.com/therecipe/qt/core" "github.com/therecipe/qt/quick" "github.com/therecipe/qt/quickcontrols2" "github.com/therecipe/qt/widgets" + "log" "os" "os/user" "path" - "strings" - "time" - "strconv" - "git.openprivacy.ca/openprivacy/asaur" - "git.openprivacy.ca/openprivacy/libricochet-go/application" - "git.openprivacy.ca/openprivacy/libricochet-go/channels" - "log" - "cwtch.im/cwtch/model" - "encoding/hex" - "cwtch.im/cwtch/app" + "bounce/go/cwtchthings" + "bounce/go/gothings" + "bounce/go/gobjects" ) -var gcd *GrandCentralDispatcher - -type ContactManager map[string]*Contact - -var contactMgr ContactManager -var peer libpeer.CwtchPeer -var outgoingMessages chan Message - -// need this to translate from the message IDs the UI uses to the ones the acknowledgement system uses on the wire -var acknowledgementIDs map[uint32]uint - -type Contact struct { - Messages []Message - Unread int - Status connections.ConnectionState -} - -func (this *Contact) AddMessage(m Message) { - this.Messages = append(this.Messages, m) -} - -type Message struct { - With, Message string - FromMe bool - MessageID uint - Timestamp time.Time -} - -func DeliverMessageToUI(handle, from, displayname, message string, mID uint, fromMe bool, ts time.Time) { - _, found := contactMgr[handle] - if !found { - contactMgr[handle] = &Contact{[]Message{}, 0, 0} - } - - contactMgr[handle].AddMessage(Message{from, message, fromMe, mID, ts}) - if gcd.currentOpenConversation == handle { - if fromMe { - from = "me" - } - gcd.AppendMessage(from, message, displayname, mID, ts.Format(TIME_FORMAT), randomProfileImage(from)) - } else { - contactMgr[handle].Unread++ - gcd.SetUnread(handle, contactMgr[handle].Unread) - } -} - func init() { - GrandCentralDispatcher_QmlRegisterType2("CustomQmlTypes", 1, 0, "GrandCentralDispatcher") + // make go-defined types available in qml + gothings.GrandCentralDispatcher_QmlRegisterType2("CustomQmlTypes", 1, 0, "GrandCentralDispatcher") } func main() { - contactMgr = make(ContactManager) - acknowledgementIDs = make(map[uint32]uint) + // our globals + gcd := gothings.NewGrandCentralDispatcher(nil) + gcd.UIState = gothings.NewUIState(gcd) + the.AcknowledgementIDs = make(map[uint32]uint) + gcd.OutgoingMessages = make(chan gobjects.Letter, 1000) + //TODO: put theme stuff somewhere better + gcd.SetThemeScale(1.0) + + // this is to load local qml files quickly when developing + var qmlSource *core.QUrl + if len(os.Args) == 2 && os.Args[1] == "-local" { + qmlSource = core.QUrl_FromLocalFile("./qml/main.qml") + } else { + qmlSource = core.NewQUrl3("qrc:/qml/main.qml", 0) + } + + // window construction boilerplate + view := initializeQtView() + + // variables we want to access from inside qml + view.RootContext().SetContextProperty("gcd", gcd) + + // this actually loads the qml + view.SetSource(qmlSource) + + // these are long-lived pollers/listeners for incoming messages and status changes + loadCwtchData(gcd) + go characters.PostmanPat(gcd.OutgoingMessages) + go characters.TorStatusPoller(gcd.TorStatus) + go characters.PresencePoller(gcd.UIState.GetContact, gcd.UIState.AddContact, gcd.UIState.UpdateContact) + go characters.GroupPoller(gcd.UIState.GetContact, gcd.UIState.UpdateContact) + + // here we go! + view.Show() + widgets.QApplication_Exec() +} + +// window construction boilerplate +func initializeQtView() *quick.QQuickView { core.QCoreApplication_SetAttribute(core.Qt__AA_EnableHighDpiScaling, true) widgets.NewQApplication(len(os.Args), os.Args) quickcontrols2.QQuickStyle_SetStyle("Universe") @@ -87,176 +76,23 @@ func main() { view.SetMinimumHeight(280) view.SetMinimumWidth(300) view.SetTitle("cwtch") - gcd = NewGrandCentralDispatcher(nil) - view.RootContext().SetContextProperty("gcd", gcd) - if len(os.Args) == 2 && os.Args[1] == "local" { - view.SetSource(core.QUrl_FromLocalFile("./qml/main.qml")) - } else { - view.SetSource(core.NewQUrl3("qrc:/qml/main.qml", 0)) - } - - outgoingMessages = make(chan Message, 1000) - go postmanPat() - - initialize(view) - view.Show() - go torStatusPoller() - go presencePoller() - go groupPoller() - go ricochetListener() - widgets.QApplication_Exec() + return view } -func groupPoller() { - for { - time.Sleep(time.Second * 4) - - servers := peer.GetServers() - groups := peer.GetGroups() - for i := range groups { - group := peer.GetGroup(groups[i]) - //log.Printf("setting group %s to status %d", groups[i], int(servers[group.GroupServer])) - gcd.SetConnectionStatus(groups[i], int(servers[group.GroupServer])) - } - } -} - -func presencePoller() { // TODO: make this subscribe-able in ricochet - time.Sleep(time.Second * 4) - for { - contacts := peer.GetContacts() - for i := range contacts { - _, found := contactMgr[contacts[i]] - if !found { // new contact has attempted to connect with us, treat it as an invite - contactMgr[contacts[i]] = &Contact{[]Message{}, 0, -1} - c, _ := peer.GetProfile().GetContact(contacts[i]) - peer.GetProfile().SetCustomAttribute(contacts[i]+"_name", c.Name) - peer.GetProfile().SetCustomAttribute(c.Name+"_onion", contacts[i]) - peer.Save() - gcd.AddContact(c.Name, contacts[i], "", randomProfileImage(contacts[i]), "0", c.Trusted) - } - - c, found := peer.GetPeers()[contacts[i]] - if !found && contactMgr[contacts[i]].Status != -2 { - //log.Printf("setting %v to -2", contacts[i]) - contactMgr[contacts[i]].Status = -2 - gcd.SetConnectionStatus(contacts[i], -2) - } else if contactMgr[contacts[i]].Status != c { - //log.Printf("was: %v", contactMgr[contacts[i]].Status) - contactMgr[contacts[i]].Status = c - //log.Printf("setting %v to status %v", contacts[i], c) - gcd.SetConnectionStatus(contacts[i], int(c)) - //log.Printf("now: %v", contactMgr[contacts[i]].Status) - } - } - time.Sleep(time.Second * 4) - } -} - -func torStatusPoller() { - for { - time.Sleep(time.Second) - //todo: this should use a config manager - //todo: also, try dialing the proxy to differentiate tor not running vs control port not configured - rawStatus, err := asaur.GetInfo("localhost:9051", "tcp4", "", "status/bootstrap-phase") - if err != nil { - gcd.TorStatus(0, "can't find tor. is it running? is the controlport configured?") - continue - } - - status := asaur.ParseBootstrapPhase(rawStatus) - progress, _ := strconv.Atoi(status["PROGRESS"]) - - if status["TAG"] == "done" { - gcd.TorStatus(3, "tor appears to be running just fine!") - continue - } - - if progress == 0 { - gcd.TorStatus(1, "tor is trying to start up") - continue - } - - gcd.TorStatus(2, status["SUMMARY"]) - } -} - -func cwtchListener(groupID string, channel chan model.Message) { - for { - m := <-channel - log.Printf("GROUPMSG %s %s", m.Message, m.PeerID) - name := m.PeerID - if name == peer.GetProfile().Onion { - name = "me" - } else { - var exists bool // lol this is a golang antifeature - name, exists = peer.GetProfile().GetCustomAttribute(m.PeerID + "_name") - if !exists { - name = "" - } - } - if name == "" { - name = m.PeerID[:16] + "..." - } - DeliverMessageToUI(groupID, m.PeerID, name, m.Message, 0, m.PeerID == peer.GetProfile().Onion, m.Timestamp) - peer.Save() - } -} - -func ricochetListener() { - err := peer.Listen() - if err != nil { - fmt.Printf("error listening for connections: %v\n", err) - gcd.InvokePopup("error handling network connection") - } -} - -func postmanPat() { - postOffice := make(map[string]chan Message) - - for { - m := <-outgoingMessages - - _, found := postOffice[m.With] - if !found { - postOffice[m.With] = make(chan Message, 100) - go andHisBlackAndWhiteCat(postOffice[m.With]) - } - - postOffice[m.With] <- m - } -} - -func andHisBlackAndWhiteCat(incomingMessages chan Message) { - for { - m := <-incomingMessages - connection := peer.PeerWithOnion(m.With) - connection.DoOnChannel("im.ricochet.chat", channels.Outbound, func(channel *channels.Channel) { - chatchannel, ok := channel.Handler.(*channels.ChatChannel) - if ok { - log.Printf("Sending packet") - acknowledgementIDs[chatchannel.SendMessage(m.Message)] = m.MessageID - } - }) - } -} - -func initialize(view *quick.QQuickView) { +// this is mostly going to get factored out when we add profile support +// for now, it loads a single peer and fills the ui with its data +func loadCwtchData(gcd *gothings.GrandCentralDispatcher) { var err error - //TODO: this section is ported over and has a lot of printf errors, need to show them in the ui - var dirname, filename string if os.Getenv("CWTCH_FOLDER") != "" { - dirname = os.Getenv("CWTCH_FOLDER") - filename = path.Join(dirname, "keep-this-file-private") + the.CwtchDir = os.Getenv("CWTCH_FOLDER") } else { usr, err := user.Current() if err != nil { fmt.Printf("\nerror: could not load current user: %v\n", err) os.Exit(1) } - dirname = path.Join(usr.HomeDir, ".cwtch") - filename = path.Join(dirname, "keep-this-file-private") + the.CwtchDir = path.Join(usr.HomeDir, ".cwtch") } /*_, err := app2.NewApp(dirname, "/data/data/org.qtproject.example.go/lib/libtor.so") @@ -265,83 +101,79 @@ func initialize(view *quick.QQuickView) { } time.Sleep(time.Second * 10) */ - os.MkdirAll(dirname, 0700) + os.MkdirAll(the.CwtchDir, 0700) - _, err = app.NewApp("/data/data/org.qtproject.example.go/files/", "/data/data/org.qtproject.example.go/lib/libtor.so") - log.Printf("starting Swtch app: err: %v\n", err) - - peer, err = libpeer.LoadCwtchPeer(filename, "be gay do crime") + the.CwtchApp, err = libapp.NewApp(the.CwtchDir, "tor") if err != nil { - fmt.Println("couldn't load your config file, attempting to create a new one now") - - names, err := diceware.Generate(1) - peer, err = libpeer.NewCwtchPeer(names[0], "be gay do crime", filename) - if err != nil { - fmt.Println("couldn't create one either :( exiting") - os.Exit(1) - } - - peer.Save() + //TODO no more fatalfs + log.Fatalf("couldn't create cwtch app: %v", err) } - gcd.UpdateMyProfile(peer.GetProfile().Name, peer.GetProfile().Onion, randomProfileImage(peer.GetProfile().Onion)) + err = the.CwtchApp.LoadProfiles("be gay do crime") + if err != nil { + //TODO no more fatalfs + log.Fatalf("couldn't load profiles: %v", err) + } + + if len(the.CwtchApp.ListPeers()) == 0 { + log.Printf("couldn't load your config file. attempting to create one now") + the.Peer, err = the.CwtchApp.CreatePeer("alice", "be gay do crime") + if err != nil { + log.Fatalf("couldn't create one. is your cwtch folder writable?") + } + the.CwtchApp.SaveProfile(the.Peer) + } else { + the.Peer = the.CwtchApp.PrimaryIdentity() + } + + gcd.UpdateMyProfile(the.Peer.GetProfile().Name, the.Peer.GetProfile().Onion, cwutil.RandomProfileImage(the.Peer.GetProfile().Onion)) aif := application.ApplicationInstanceFactory{} aif.Init() app := new(application.RicochetApplication) aif.AddHandler("im.ricochet.chat", func(rai *application.ApplicationInstance) func() channels.Handler { - ccl := new(ChatChannelListener) - ccl.Init(rai, app) + ccl := new(cwtchthings.ChatChannelListener) + ccl.Init(rai, app, gcd.UIState.AddMessage, gcd.Acknowledged) return func() channels.Handler { chat := new(channels.ChatChannel) chat.Handler = ccl return chat } }) - peer.SetApplicationInstanceFactory(aif) + the.Peer.SetApplicationInstanceFactory(aif) + the.CwtchApp.LaunchPeers() - contacts := peer.GetContacts() + + contacts := the.Peer.GetContacts() for i := range contacts { - attr, _ := peer.GetProfile().GetCustomAttribute(contacts[i] + "_name") - contactMgr[contacts[i]] = &Contact{[]Message{}, 0, 0} - gcd.AddContact(attr, contacts[i], "", randomProfileImage(contacts[i]), "0", peer.GetContact(contacts[i]).Trusted) + contact, _ := the.Peer.GetProfile().GetContact(contacts[i]) + displayName, _ := contact.GetAttribute("name") + gcd.UIState.AddContact(&gobjects.Contact{ + contacts[i], + displayName, + cwutil.RandomProfileImage(contacts[i]), + "", + 0, + 0, + contact.Trusted, + }) } - groups := peer.GetGroups() + groups := the.Peer.GetGroups() for i := range groups { - group := peer.GetGroup(groups[i]) + log.Printf("adding saved group %v", groups[i]) + group := the.Peer.GetGroup(groups[i]) group.NewMessage = make(chan model.Message) - go cwtchListener(groups[i], group.NewMessage) - peer.JoinServer(group.GroupServer) - //TODO: base the profileImage off the groupid, not the server. probably by decoding it and then re-encoding it b32 - gcd.AddContact(group.GroupID[:12], group.GroupID, group.GroupServer, randomGroupImage(groups[i]), "0", group.Accepted) - log.Printf("GROUP %s@%s", group.GroupID, group.GroupServer) + go characters.CwtchListener(gcd.UIState.AddMessage, groups[i], group.NewMessage) + the.Peer.JoinServer(group.GroupServer) + gcd.UIState.AddContact(&gobjects.Contact{ + group.GroupID, + group.GroupID[:12], + cwutil.RandomGroupImage(group.GroupID), + group.GroupServer, + 0, + 0, + group.Accepted, + }) } - -} - -// temporary until we do real picture selection -func randomProfileImage(onion string) string { - //TODO: this is a hack, fix ever passing this in - if onion == "me" { - onion = peer.GetProfile().Onion - } - - choices := []string{"001-centaur", "002-kraken", "003-dinosaur", "004-tree-1", "005-hand", "006-echidna", "007-robot", "008-mushroom", "009-harpy", "010-phoenix", "011-dragon-1", "012-devil", "013-troll", "014-alien", "015-minotaur", "016-madre-monte", "017-satyr", "018-karakasakozou", "019-pirate", "020-werewolf", "021-scarecrow", "022-valkyrie", "023-curupira", "024-loch-ness-monster", "025-tree", "026-cerberus", "027-gryphon", "028-mermaid", "029-vampire", "030-goblin", "031-yeti", "032-leprechaun", "033-medusa", "034-chimera", "035-elf", "036-hydra", "037-cyclops", "038-pegasus", "039-narwhal", "040-woodcutter", "041-zombie", "042-dragon", "043-frankenstein", "044-witch", "045-fairy", "046-genie", "047-pinocchio", "048-ghost", "049-wizard", "050-unicorn"} - barr, err := base32.StdEncoding.DecodeString(strings.ToUpper(onion)) - if err != nil || len(barr) != 35 { - fmt.Printf("error: %v %v %v\n", onion, err, barr) - return "qrc:/qml/images/extra/openprivacy.png" - } - return "qrc:/qml/images/profiles/" + choices[int(barr[33])%len(choices)] + ".png" -} - -func randomGroupImage(onion string) string { - choices := []string{"001-borobudur","002-opera-house","003-burj-al-arab","004-chrysler","005-acropolis","006-empire-state-building","007-temple","008-indonesia-1","009-new-zealand","010-notre-dame","011-space-needle","012-seoul","013-mosque","014-milan","015-statue","016-pyramid","017-cologne","018-brandenburg-gate","019-berlin-cathedral","020-hungarian-parliament","021-buckingham","022-thailand","023-independence","024-angkor-wat","025-vaticano","026-christ-the-redeemer","027-colosseum","028-golden-gate-bridge","029-sphinx","030-statue-of-liberty","031-cradle-of-humankind","032-istanbul","033-london-eye","034-sagrada-familia","035-tower-bridge","036-burj-khalifa","037-washington","038-big-ben","039-stonehenge","040-white-house","041-ahu-tongariki","042-capitol","043-eiffel-tower","044-church-of-the-savior-on-spilled-blood","045-arc-de-triomphe","046-windmill","047-louvre","048-torii-gate","049-petronas","050-matsumoto-castle","051-fuji","052-temple-of-heaven","053-pagoda","054-chichen-itza","055-forbidden-city","056-merlion","057-great-wall-of-china","058-taj-mahal","059-pisa","060-indonesia"} - barr, err := hex.DecodeString(onion) - if err != nil || len(barr) == 0 { - fmt.Printf("error: %v %v %v\n", onion, err, barr) - return "qrc:/qml/images/extra/openprivacy.png" - } - return "qrc:/qml/images/servers/" + choices[int(barr[0])%len(choices)] + ".png" } \ No newline at end of file diff --git a/qml/main.qml b/qml/main.qml index b1feb5f..3c3b6f7 100644 --- a/qml/main.qml +++ b/qml/main.qml @@ -6,6 +6,7 @@ import QtQuick.Layouts 1.3 import QtQuick.Window 2.11 import "fonts/Twemoji.js" as T +import "panes" import "widgets" Item { @@ -133,6 +134,7 @@ Item { readonly property int settingsPane: 2 readonly property int userProfilePane: 3 readonly property int groupProfilePane: 4 + readonly property int addGroupPane: 5 Item {} // empty @@ -141,47 +143,7 @@ Item { anchors.fill: parent } - ColumnLayout { // settingsPane - anchors.fill: parent - - - StackToolbar { - text: "Cwtch Settings" - aux.visible: false - } - - ScalingLabel { - Layout.maximumWidth: parent.width - text: "welcome to the global app settings page!" - } - - Slider { - id: zoomSlider - from: 0.5 - to: 2.4 - } - - ScalingLabel { - text: "Large text" - size: 20 - } - - ScalingLabel{ - text: "Default size text, scale factor: " + zoomSlider.value - } - - ScalingLabel { - text: "Small text" - size: 8 - } - - - Component.onCompleted: { - zoomSlider.value = Screen.pixelDensity / 3.2 // artistic license. set by erinn. fight me before changing - if (zoomSlider.value < zoomSlider.from) zoomSlider.value = zoomSlider.from - if (zoomSlider.value > zoomSlider.to) zoomSlider.value = zoomSlider.to - } - } + SettingsPane{} ColumnLayout { // userProfilePane anchors.fill: parent @@ -197,21 +159,9 @@ Item { Label { text: "per-user things like contact name and picture will be edited here" } } - Label { // groupProfilePane - font.pixelSize: 12 - text: "invite new people or change the group name here" + GroupSettingsPane{} - - StackToolbar { text: "Group settings" } - } - - Label { // addGroupPane - font.pixelSize: 12 - text: "add a new group" - - - StackToolbar { text: "Create group" } - } + AddGroupPane { anchors.fill: parent } } } } diff --git a/qml/panes/AddGroupPane.qml b/qml/panes/AddGroupPane.qml new file mode 100644 index 0000000..b9049af --- /dev/null +++ b/qml/panes/AddGroupPane.qml @@ -0,0 +1,53 @@ +import QtGraphicalEffects 1.0 +import QtQuick 2.7 +import QtQuick.Controls 2.4 +import QtQuick.Controls.Material 2.0 +import QtQuick.Layouts 1.3 +import QtQuick.Window 2.11 + +import "../widgets" + +ColumnLayout { // settingsPane + anchors.fill: parent + + + StackToolbar { + text: "Create a new group" + aux.visible: false + } + + ScalingLabel { + text: "Server:" + } + + TextEdit { + id: txtServer + width: 100 + text: "6xnl4rlc5gq5crnyki6wp6qy2dy2brsjtfixwhgujfycsu7jly7pmxad" + } + + ScalingLabel{ + text: "Group name:" + } + + TextEdit { + id: txtGroupName + width: 100 + text: "my awesome group" + } + + SimpleButton { + text: "create" + + onClicked: { + gcd.createGroup(txtServer.text, txtGroupName.text) + } + } + + + Component.onCompleted: { + zoomSlider.value = Screen.pixelDensity / 3.2 // artistic license. set by erinn. fight me before changing + if (zoomSlider.value < zoomSlider.from) zoomSlider.value = zoomSlider.from + if (zoomSlider.value > zoomSlider.to) zoomSlider.value = zoomSlider.to + } +} \ No newline at end of file diff --git a/qml/panes/GroupSettingsPane.qml b/qml/panes/GroupSettingsPane.qml new file mode 100644 index 0000000..2ffbb14 --- /dev/null +++ b/qml/panes/GroupSettingsPane.qml @@ -0,0 +1,68 @@ +import QtGraphicalEffects 1.0 +import QtQuick 2.7 +import QtQuick.Controls 2.4 +import QtQuick.Controls.Material 2.0 +import QtQuick.Layouts 1.3 +import QtQuick.Window 2.11 + +import "../widgets" + +ColumnLayout { // groupSettingsPane + anchors.fill: parent + + + StackToolbar { + id: toolbar + aux.visible: false + } + + ScalingLabel { + text: "Server:" + } + + TextEdit { + id: txtServer + width: 100 + } + + ScalingLabel{ + text: "Group name:" + } + + TextEdit { + id: txtGroupName + width: 100 + } + + ScalingLabel { + text: "Invitation:" + } + + TextEdit { + id: txtInvitation + width: 200 + } + + SimpleButton { + icon: "regular/clipboard" + text: "copy" + + onClicked: { + gcd.popup("copied to clipboard!") + txtInvitation.selectAll() + txtInvitation.copy() + } + } + + + Connections { + target: gcd + + onSupplyGroupSettings: function(name, server, invite) { + toolbar.text = name + txtGroupName.text = name + txtServer.text = server + txtInvitation.text = invite + } + } +} \ No newline at end of file diff --git a/qml/panes/SettingsPane.qml b/qml/panes/SettingsPane.qml new file mode 100644 index 0000000..ac74200 --- /dev/null +++ b/qml/panes/SettingsPane.qml @@ -0,0 +1,50 @@ +import QtGraphicalEffects 1.0 +import QtQuick 2.7 +import QtQuick.Controls 2.4 +import QtQuick.Controls.Material 2.0 +import QtQuick.Layouts 1.3 +import QtQuick.Window 2.11 + +import "../widgets" + +ColumnLayout { // settingsPane + anchors.fill: parent + + + StackToolbar { + text: "Cwtch Settings" + aux.visible: false + } + + ScalingLabel { + Layout.maximumWidth: parent.width + text: "welcome to the global app settings page!" + } + + Slider { + id: zoomSlider + from: 0.5 + to: 2.4 + } + + ScalingLabel { + text: "Large text" + size: 20 + } + + ScalingLabel{ + text: "Default size text, scale factor: " + zoomSlider.value + } + + ScalingLabel { + text: "Small text" + size: 8 + } + + + Component.onCompleted: { + zoomSlider.value = Screen.pixelDensity / 3.2 // artistic license. set by erinn. fight me before changing + if (zoomSlider.value < zoomSlider.from) zoomSlider.value = zoomSlider.from + if (zoomSlider.value > zoomSlider.to) zoomSlider.value = zoomSlider.to + } +} \ No newline at end of file diff --git a/qml/widgets/ContactList.qml b/qml/widgets/ContactList.qml index 4f8e3e2..00de5f0 100644 --- a/qml/widgets/ContactList.qml +++ b/qml/widgets/ContactList.qml @@ -8,11 +8,11 @@ ColumnLayout { id: root - MyProfile{ + MyProfile{ // CURRENT PROFILE INFO AND CONTROL BAR id: myprof } - Flickable { // CONTACT LIST + Flickable { // THE ACTUAL CONTACT LIST id: sv //Layout.alignment: Qt.AlignLeft | Qt.AlignTop clip: true @@ -36,18 +36,18 @@ ColumnLayout { width: root.width spacing: 0 - Connections { // ADD/REMOVE CONTACT ENTRIES target: gcd - onAddContact: function(name, onion, server, image, badge, trusted) { + onAddContact: function(handle, displayName, image, server, badge, status, trusted) { contactsModel.append({ - "n": name, - "o": onion, - "s": server, - "i": image, - "b": badge, - "t": trusted + "_handle": handle, + "_displayName": displayName, + "_image": image, + "_server": server, + "_badge": badge, + "_status": status, + "_trusted": trusted, }) } } @@ -56,16 +56,16 @@ ColumnLayout { id: contactsModel } - Repeater { model: contactsModel // ... AND DISPLAYED HERE - delegate: Contact{ - nick: n - onion: o - server: s - image: i - badge: b - isTrusted: t + delegate: ContactRow { + handle: _handle + displayName: _displayName + image: _image + server: _server + badge: _badge + status: _status + trusted: _trusted } } } diff --git a/qml/widgets/Contact.qml b/qml/widgets/ContactRow.qml similarity index 80% rename from qml/widgets/Contact.qml rename to qml/widgets/ContactRow.qml index 77eb363..acd33c0 100644 --- a/qml/widgets/Contact.qml +++ b/qml/widgets/ContactRow.qml @@ -8,15 +8,15 @@ import CustomQmlTypes 1.0 RowLayout { // LOTS OF NESTING TO DEAL WITH QT WEIRDNESS, SORRY anchors.left: parent.left anchors.right: parent.right - visible: nick != "" + visible: true - property alias nick: cn.text + property alias displayName: cn.text property alias image: imgProfile.source - property string onion - property string badge + property string handle + property int badge property bool isActive property bool isHover - property bool isTrusted + property bool trusted property alias status: imgProfile.status property string server @@ -48,7 +48,7 @@ RowLayout { // LOTS OF NESTING TO DEAL WITH QT WEIRDNESS, SORRY anchors.left: imgProfile.right anchors.right: rectUnread.left font.pixelSize: 16 - font.italic: !isTrusted + font.italic: !trusted textFormat: Text.PlainText //fontSizeMode: Text.HorizontalFit elide: Text.ElideRight @@ -61,7 +61,7 @@ RowLayout { // LOTS OF NESTING TO DEAL WITH QT WEIRDNESS, SORRY width: lblUnread.width + 10 radius: 8 color: "#4B3557" - visible: badge != "0" + visible: badge != 0 anchors.rightMargin: 9 @@ -85,7 +85,7 @@ RowLayout { // LOTS OF NESTING TO DEAL WITH QT WEIRDNESS, SORRY gcd.broadcast("ResetMessagePane") isActive = true theStack.pane = theStack.messagePane - gcd.loadMessagesPane(onion) + gcd.loadMessagesPane(handle) } onEntered: { @@ -100,25 +100,18 @@ RowLayout { // LOTS OF NESTING TO DEAL WITH QT WEIRDNESS, SORRY Connections { // UPDATE UNREAD MESSAGES COUNTER target: gcd - onSetUnread: function(foronion, n) { - if (onion == foronion) { - badge = ""+n - } - } - onResetMessagePane: function() { isActive = false } - onSetConnectionStatus: function(foronion, x) { - if (onion == foronion) { - status = x - } - } - - onMarkTrusted: function(foronion) { - if (onion == foronion) { - isTrusted = true + onUpdateContact: function(_handle, _displayName, _image, _server, _badge, _status, _trusted) { + if (handle == _handle) { + displayName = _displayName + image = _image + server = _server + badge = _badge + status = _status + trusted = _trusted } } } diff --git a/qml/widgets/Message.qml b/qml/widgets/Message.qml index 6ef597d..1e336a9 100644 --- a/qml/widgets/Message.qml +++ b/qml/widgets/Message.qml @@ -15,10 +15,12 @@ RowLayout { property alias message: lbl.text property string from - property string displayname + property string handle + property string displayName property int messageID + property bool fromMe property alias timestamp: ts.text - property alias source: imgProfile.source + property alias image: imgProfile.source property alias status: imgProfile.status @@ -107,14 +109,14 @@ RowLayout { color: "#FFFFFF" font.pixelSize: 10 anchors.right: parent.right - text: displayname + text: displayName visible: from != "me" } Image { // ACKNOWLEDGEMENT ICON id: ack anchors.right: parent.right - source: displayname == "" ? "qrc:/qml/images/fontawesome/regular/hourglass.svg" : "qrc:/qml/images/fontawesome/regular/check-circle.svg" + source: displayName == "" ? "qrc:/qml/images/fontawesome/regular/hourglass.svg" : "qrc:/qml/images/fontawesome/regular/check-circle.svg" height: 10 sourceSize.height: 10 visible: from == "me" diff --git a/qml/widgets/MessageList.qml b/qml/widgets/MessageList.qml index 1d9f770..494a289 100644 --- a/qml/widgets/MessageList.qml +++ b/qml/widgets/MessageList.qml @@ -14,7 +14,10 @@ ColumnLayout { StackToolbar { text: "open privacy exec" - aux.onClicked: theStack.pane = theStack.userProfilePane + aux.onClicked: { + theStack.pane = gcd.currentOpenConversation.length == 32 ? theStack.groupProfilePane : theStack.userProfilePane + gcd.requestGroupSettings() + } } Flickable { // THE MESSAGE LIST ITSELF @@ -38,14 +41,16 @@ ColumnLayout { messagesModel.clear() } - onAppendMessage: function(from, message, displayname, mid, ts, source) { + onAppendMessage: function(handle, from, displayName, message, image, mid, fromMe, ts) { messagesModel.append({ - "f": from, - "m": parse(message, 12), - "d": displayname, - "i": mid, - "t": ts, - "src": source + "_handle": handle, + "_from": from, + "_displayName": displayName, + "_message": parse(message, 12), + "_image": image, + "_mid": mid, + "_fromMe": fromMe, + "_ts": ts, }) if (sv.contentY + sv.height >= sv.contentHeight - colMessages.height && sv.contentHeight > sv.height) { @@ -73,12 +78,14 @@ ColumnLayout { Repeater { // ... AND DISPLAYED HERE model: messagesModel delegate: Message { - from: f - message: m - displayname: d - messageID: i - timestamp: t - source: src + handle: _handle + from: _from + displayName: _displayName + message: _message + image: _image + messageID: _mid + fromMe: _fromMe + timestamp: _ts } } } @@ -88,8 +95,8 @@ ColumnLayout { Rectangle { // MESSAGE ENTRY TEXTFIELD id: rectMessage Layout.fillWidth: true - Layout.minimumHeight: 40 * zoomSlider.value - Layout.maximumHeight: 40 * zoomSlider.value + Layout.minimumHeight: 40 * gcd.themeScale + Layout.maximumHeight: 40 * gcd.themeScale color: "#EDEDED" border.color: "#AAAAAA" radius: 10 diff --git a/qml/widgets/MyProfile.qml b/qml/widgets/MyProfile.qml index 3481110..0535f9c 100644 --- a/qml/widgets/MyProfile.qml +++ b/qml/widgets/MyProfile.qml @@ -149,7 +149,7 @@ ColumnLayout { Row { // TOOLS FOR EDITING PROFILE anchors.horizontalCenter: parent.horizontalCenter - spacing: zoomSlider.value * 2 + spacing: gcd.themeScale * 2 TextEdit { // USED TO POWER THE COPY/PASTE BUTTON @@ -164,8 +164,8 @@ ColumnLayout { onClicked: { gcd.popup("copied to clipboard!") txtHidden.text = nick.replace(" ", "~") + "~" + onion - txtHidden.selectAll(); - txtHidden.copy(); + txtHidden.selectAll() + txtHidden.copy() } } @@ -184,6 +184,19 @@ ColumnLayout { } } + Row { + anchors.horizontalCenter: parent.horizontalCenter + spacing: gcd.themeScale * 2 + + + SimpleButton { // CREATE GROUP BUTTON + icon: "regular/clipboard" + text: "new group" + + onClicked: theStack.pane = theStack.addGroupPane + } + } + RowLayout { anchors.left: parent.left anchors.right: parent.right diff --git a/qml/widgets/MyProfile.qmlc b/qml/widgets/MyProfile.qmlc index 4bb0b48e187a94f0df6039359a0b1f86ed70b803..5715500604b9f41387d2a9a3bf2881848126fd62 100644 GIT binary patch delta 3330 zcmZA3ZBSI#8Nl)9z+D#D1zC0x1m7Z5QNaqXiWx{qvxdZAdlkZAT1INOoE@#+Q^%Cl#$9)><25`ahRtWjxI9 zo_o%7UhX~j-m|p7r@G%)`8!_nZwzi|+%Q2zl*=I(Km0?*W+mcZbs)bybf>aDduYk7 z-JO4}U*OFCea?gD2H$;Q(w+ISGu2hjqhI{RNw42ws7ho$`7xN(#t38c!|}4ozajmG zpkeyY%@-*nA0#))v`I}8*`6dJ8RX>AWRXtfSt76CI@UWx`caWW2UIZ9VWg(g5re2s z`$gsAY+5LrHAxP<;3 z;^P?*)wxWHtUQs|P|U&}Hj69aeae%h_UX5i&!~OjdTW2u^q)|@xUg_eidvFX<7|4( z6n%D4QT(YXI+}hKE^r2t)$4kFdV^}!AE$q$x93{nL#{M6em_lIsZDQ~EUoEHlchJk z75>1zR;dFzFLRSRsP|-UQ!U|ZnNCIapK+_m^b?-Rx-GLf`KTPz$5pO=ASpBbD809( zYSuds?DT@Dn(5Ux*X{a{U^s9>y%7Qv{9$@($rjC znO&TGS~^E3R;Ts;>>4X7mqr?X==B`*RwiG#+hWp%(Xqdq!keAW+rqO&1GeeC-a_lL zR41mHd6{b7EcpVH@pml75H{i;_&)xrZ+NH8{}pE}`C*f}3QHX78J8>l?O#*mjT7^_ z^R`J#3kO%4i4%QC*ZPXo4!y%yXniTmN2WgF^LW0bm6`fq;9Zg{dcZeB{eb22YIjbS zb#?SESu)Snc;jpF#{bq&<&pQ!1R3OqmNyNu1(2Tk%A`$rR zBZq$)Zv@&38K#K(Z&8n;86r`%7gINrHt@}648%-cLXM(2^o8|pkwy$5I#(pX7T8@T z4@-oF_0MA<)N#8*Xk>3KT0lnx7SaL13d)P9zn>ibN-hNcDm^{_{tWgwX@+?kL*cFY zHL8p&OQe%BRgAmIDO^P&#fZhie=fL^u6F6J=~LA1@WAwo$!bryYt}lYLSawIwiLBT z?=3&Vv25O(9FNbNqE_h@_tm6*5FdH9zU=opk212^XlDC&>kgHAaB`kL>33)JNT--h z#>~0abW_Lln(~6B$E8}1%VGOc_Ha~^A}nwsMToNl+2sc~Rtvuk@$^0N6V#lD_ur^D z%&JM|M)gPYCpbUmLZGkApWx`_yjMTxU#I`L(4BFLYcYF{878Pb3%$fUiQn=sOEbMp znyfDudrLoM^iSiza}(|QyR$z_xu1AHeND>I12er#&rowF-ow0b!u^bW6sL)^vDuN4 zgHuY!l>SECFezOhsPJY)DMjNWZq{8DUdI461A69yEybTO(kJmdx%c|&8DW6+^~6`yb-oh5!& z_g1Vc8{6v~?}6ECO|va_+mw5H_0wz3qAeL_Z04|AzNiNl)r8FFXVQ<%Avs0Z3+(hN z$FF%G&CX^jcA5im^Z%1c=8L~c-0Zie{E|7aTqAEVzekjAP-3gMBhA$!t>{J{Vkmit z|GR<@5kLp}a2_#O-(iPA0F7uyJGv1;6hnx?{V;=}7LDjZ6hpWTcR-{PwFu%6x)BM4 z*gJU;egv@#-H0Ft>k(q8MFS3@0}-4@489sBM-aQvjXvCl`%$({_z^@STG4@F_(G4- z0F|giE85YG2%@l-FaVm-h7R-~ieXHAoWaqER`ei>VYq9FBY-+IpbZ^}AQa_bEu|4k z5I`Ne5ryvw>QRdZbf6FJWwb>bx)DJP6PI%_XhskEFofGETEW<8L>mGtdH%OoGT4*c zAp)pF5RGU<2YN6JtB#97EqV}z^%Rq$2qg$$EgH~@J`BTpnwxdp zoL@EG?R{Q0$;&cTHp>gLOTlis-E)+END6y&ca Q(PcR`W*`Z#S^2p7Kc_E%i~s-t delta 2918 zcmZA34@{hA9l-J5bN_(5D?7?TdxfsI9&4Ee3LCZ1(Tyocoj7j7GELY97G`a4EiSC9 zlX}Rmx;a`pA7UDVu9}i2oePU6#i z-sgRu=l49%z3=-x4;}xxioc}Rd~JXA&aTeh&VYz0k5it#v;W+qN+jO;{F>XN$82}~ z=ITFA+Mj+oGL$~?&tEv*f!fzMyz;YOubMov_rYJD&!X2$=-(!CJNYC`dWI2}=SP>y zCjSrV$21zI|M}G-734kUCy-3cN9eFgRLc{X)OpRiw6hY|-Ihix0a&5vmeg(_U?URzVvz;{g1 zZxt2AXH3yK@N8mJR)bxASNFIh>N)+U`-1vO;$@Fl6%G>M$sRatvYdePI>C$<&kYhr8O3$cOy2Z9)JKOJ3*oil=8-Lah6|CR#L+*Iq z7fj{}l{wYJ9*_Ijk0_dt%StgHSPsRwcydfTt;h9vL5b?oot}I>U*L0Gk#J^uzLv-p zn4j<_OvPVxZQ(}sxb7?rIcAqVTHY*;Ch%>Tz`yEKg(0=adX*`e&WyS;MPuew?zUd# zUR#CZqAX-$)?Qp56EHC%U!;zeC;QEi_G|xphik7bMc;TdD_A-MR}aY7h#g=U>@-un9=58=6DzE19?y9 z1U9OttQo5)o0tXO{Ku;(pMo{x`{XxftkOe)?T%$ZIeH=BSpQQ3OfntJjDG{WPyP-z zkGwCsaLt)$=^7?37U{JTm^ zzl|}_@dU-0B;;v2>m4OLUjMWmg9i zjb)E0_07bK<@;Rfpf0FB$@OgY5Z4E`tW#gsA8u;0|5k$ftt|(&wn~NyYt+BS8Dq+K zS(Pqf1!U3;?K8Uk&Z4Yo?xtl{Zw?kjBeY+pe0k{^%uFJ7>uN4v1v9D(GG>h&rL1^2 ztfNetrN22bOhrFTJL@fzIC?m3!dCmYmfDxz(Y;nsngDY=nnI&hlOtw$ICg$5cFF)w;~Cvy#~&*J$Uw zv&zNWMM}_&E({{Mo&NzWa#O+=Z9o@#F^CbQa0TAGMT${}X0#)MJ`5s`BqjB=C0BK)7C0qq#X2vV4XUV<+lnc3sxt%CrZXfnqyT9U-~!?jh75*OhFl;H2FfHd