erinn
/
ui
forked from cwtch.im/ui
1
0
Fork 0

initial commit

This commit is contained in:
erinn 2018-10-27 19:49:14 -07:00
parent 8983f41438
commit 65c50a50b0
2927 changed files with 1804 additions and 121 deletions

41
chatchannellistener.go Normal file
View File

@ -0,0 +1,41 @@
package main
import (
"git.openprivacy.ca/openprivacy/libricochet-go/application"
"git.openprivacy.ca/openprivacy/libricochet-go/channels"
"time"
)
type ChatChannelListener struct {
rai *application.ApplicationInstance
ra *application.RicochetApplication
}
func (this *ChatChannelListener) Init(rai *application.ApplicationInstance, ra *application.RicochetApplication) {
this.rai = rai
this.ra = ra
}
// We always want bidirectional chat channels
func (this *ChatChannelListener) OpenInbound() {
outboutChatChannel := this.rai.Connection.Channel("im.ricochet.chat", channels.Outbound)
if outboutChatChannel == nil {
this.rai.Connection.Do(func() error {
this.rai.Connection.RequestOpenChannel("im.ricochet.chat",
&channels.ChatChannel{
Handler: this,
})
return nil
})
}
}
func (this *ChatChannelListener) ChatMessage(messageID uint32, when time.Time, message string) bool {
DeliverMessageToUI(this.rai.RemoteHostname, message, uint(messageID), false, when)
return true
}
func (this *ChatChannelListener) ChatMessageAck(messageID uint32, accepted bool) {
gcd.Acknowledged(acknowledgementIDs[messageID])
}

80
gcd.go
View File

@ -6,22 +6,27 @@ import (
"github.com/therecipe/qt/core"
"log"
"strings"
"time"
"fmt"
)
var TIME_FORMAT = "Mon 3:04pm"
type GrandCentralDispatcher struct {
core.QObject
currentOpenConversation string
// messages pane stuff
_ func(from, message string) `signal:"AppendMessage"`
_ func() `signal:"ClearMessages"`
_ func() `signal:"ResetMessagePane"`
_ func(from, message string, mID uint, ts 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, image, badge string, trusted bool) `signal:"AddContact"`
_ func(name, onion, server, image, badge string, trusted bool) `signal:"AddContact"`
_ func(onion string) `signal:"MarkTrusted"`
// profile-area stuff
@ -32,15 +37,15 @@ type GrandCentralDispatcher struct {
_ func(str string) `signal:"InvokePopup"`
// exfiltrated signals (written in go, below)
_ func(message string) `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(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) {
func (this *GrandCentralDispatcher) sendMessage(message string, mID uint) {
if len(message) > 65530 {
gcd.InvokePopup("message is too long")
return
@ -50,6 +55,16 @@ func (this *GrandCentralDispatcher) sendMessage(message string) {
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
@ -57,13 +72,13 @@ func (this *GrandCentralDispatcher) sendMessage(message string) {
gcd.MarkTrusted(gcd.currentOpenConversation)
}
select { // fancy 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}:
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, message, true)
DeliverMessageToUI(gcd.currentOpenConversation, message, mID, true, time.Now())
}
func (this *GrandCentralDispatcher) loadMessagesPane(onion string) {
@ -71,6 +86,16 @@ func (this *GrandCentralDispatcher) loadMessagesPane(onion string) {
gcd.currentOpenConversation = onion
gcd.SetUnread(onion, 0)
if len(onion) == 32 { // eg 48e7dcfc353e6d77da2c31d63654fd19
log.Printf("LOADING GROUP %s", onion)
tl := peer.GetGroup(onion).GetTimeline()
log.Printf("messages: %d", len(tl))
for i := range tl {
gcd.AppendMessage(tl[i].PeerID, tl[i].Message, 0, tl[i].Timestamp.Format(TIME_FORMAT))
}
return
}
_, exists := contactMgr[onion]
if exists { // (if not, they haven't been accepted as a contact yet)
contactMgr[onion].Unread = 0
@ -81,7 +106,7 @@ func (this *GrandCentralDispatcher) loadMessagesPane(onion string) {
if messages[i].FromMe {
from = "me"
}
gcd.AppendMessage(from, messages[i].Message)
gcd.AppendMessage(from, messages[i].Message, messages[i].MessageID, messages[i].Timestamp.Format(TIME_FORMAT))
}
}
}
@ -89,7 +114,7 @@ func (this *GrandCentralDispatcher) loadMessagesPane(onion string) {
func (this *GrandCentralDispatcher) broadcast(signal string) {
switch signal {
default:
log.Fatalf("unhandled broadcast signal: %v", signal)
log.Printf("unhandled broadcast signal: %v", signal)
case "ResetMessagePane":
gcd.ResetMessagePane()
}
@ -99,10 +124,27 @@ func (this *GrandCentralDispatcher) importString(str string) {
log.Printf("importing: %s\n", str)
onion := str
name := onion
str = strings.TrimSpace(str)
if strings.Contains(str, " ") {// usually people prepend spaces and we don't want it going into the name (use ~ for that)
//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]
str = parts[len(parts)-1]
}
if strings.Contains(str, "~") {

120
main.go
View File

@ -17,7 +17,12 @@ import (
"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"
)
var gcd *GrandCentralDispatcher
@ -27,6 +32,9 @@ 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
@ -40,20 +48,22 @@ func (this *Contact) AddMessage(m Message) {
type Message struct {
With, Message string
FromMe bool
MessageID uint
Timestamp time.Time
}
func DeliverMessageToUI(from, message string, fromMe bool) {
func DeliverMessageToUI(from, message string, mID uint, fromMe bool, ts time.Time) {
_, found := contactMgr[from]
if !found {
contactMgr[from] = &Contact{[]Message{}, 0, 0}
}
contactMgr[from].AddMessage(Message{from, message, fromMe})
contactMgr[from].AddMessage(Message{from, message, fromMe, mID, ts})
if gcd.currentOpenConversation == from {
if fromMe {
from = "me"
}
gcd.AppendMessage(from, message)
gcd.AppendMessage(from, message, mID, ts.Format(TIME_FORMAT))
} else {
contactMgr[from].Unread++
gcd.SetUnread(from, contactMgr[from].Unread)
@ -66,6 +76,7 @@ func init() {
func main() {
contactMgr = make(ContactManager)
acknowledgementIDs = make(map[uint32]uint)
core.QCoreApplication_SetAttribute(core.Qt__AA_EnableHighDpiScaling, true)
widgets.NewQApplication(len(os.Args), os.Args)
@ -89,22 +100,24 @@ func main() {
view.Show()
go torStatusPoller()
go presencePoller()
go groupPoller()
go ricochetListener()
go alice()
widgets.QApplication_Exec()
}
func alice() {
i := 0
func groupPoller() {
for {
time.Sleep(time.Second * 3)
//words, _ := diceware.Generate(3)
//DeliverMessageToUI("f76b5vtleqx2puhwgkords34gs6crgbjqud6sebfzwtlrq4ngbqgcsyd", strings.Join(words, " "), i % 3 == 0)
i++
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 {
@ -117,7 +130,7 @@ func presencePoller() { // TODO: make this subscribe-able in ricochet
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)
gcd.AddContact(c.Name, contacts[i], "", randomProfileImage(contacts[i]), "0", c.Trusted)
}
c, found := peer.GetPeers()[contacts[i]]
@ -162,36 +175,18 @@ func torStatusPoller() {
}
gcd.TorStatus(2, status["SUMMARY"])
//qCwtchApp.SetTorStatusProgress(progress)
//qCwtchApp.SetTorStatusSummary(status["SUMMARY"])
//if status["TAG"] == "done" {
}
}
func cwtchListener(groupID string, channel chan model.Message) {
for {
m := <-channel
log.Printf("GROUPMSG %s", m.Message)
DeliverMessageToUI(groupID, m.Message, 0, m.PeerID == peer.GetProfile().Onion, m.Timestamp)
}
}
func ricochetListener() {
processData := func(onion string, data []byte) []byte {
/* _, exists := peer.GetProfile().GetCustomAttribute(onion + "_name")
if !exists {
for peer.GetContact(onion) == nil {
time.Sleep(time.Millisecond * 30)
}
name := peer.GetContact(onion).Name
fmt.Printf("adding new untrusted contact %v <%v>\n", name, onion)
peer.GetProfile().SetCustomAttribute(onion+"_name", name)
peer.GetProfile().SetCustomAttribute(name+"_onion", onion)
peer.Save()
gcd.AddContact(name, onion, randomProfileImage(onion), "0", false)
} */
DeliverMessageToUI(onion, string(data), false)
return nil
}
peer.SetPeerDataHandler(processData)
fmt.Fprintf(os.Stderr, "waiting for messages...\n")
err := peer.Listen()
if err != nil {
fmt.Printf("error listening for connections: %v\n", err)
@ -219,7 +214,13 @@ func andHisBlackAndWhiteCat(incomingMessages chan Message) {
for {
m := <-incomingMessages
connection := peer.PeerWithOnion(m.With)
connection.SendPacket([]byte(m.Message))
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
}
})
}
}
@ -257,11 +258,36 @@ func initialize(view *quick.QQuickView) {
gcd.UpdateMyProfile(peer.GetProfile().Name, peer.GetProfile().Onion, randomProfileImage(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)
return func() channels.Handler {
chat := new(channels.ChatChannel)
chat.Handler = ccl
return chat
}
})
peer.SetApplicationInstanceFactory(aif)
contacts := 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)
gcd.AddContact(attr, contacts[i], "", randomProfileImage(contacts[i]), "0", peer.GetContact(contacts[i]).Trusted)
}
groups := peer.GetGroups()
for i := range groups {
group := 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)
}
}
@ -275,3 +301,13 @@ func randomProfileImage(onion string) string {
}
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"
}

568
qml/fonts/Twemoji.js Normal file

File diff suppressed because one or more lines are too long

BIN
qml/fonts/Twemoji.jsc Normal file

Binary file not shown.

BIN
qml/fonts/fontawesome.ttf Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 923 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 557 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 682 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 603 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 722 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 747 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 697 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 849 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 989 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 723 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 847 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 835 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 652 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 940 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 978 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 919 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 884 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 666 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 871 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 805 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1000 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 669 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 961 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 913 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 643 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 688 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 809 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 833 B

Some files were not shown because too many files have changed in this diff Show More