package main import ( "os" "sync" "context" "fmt" "github.com/diamondburned/gotk4/pkg/gdk/v4" "github.com/diamondburned/gotk4/pkg/gdkpixbuf/v2" "github.com/diamondburned/gotk4/pkg/gio/v2" "github.com/diamondburned/gotk4/pkg/glib/v2" "github.com/diamondburned/gotk4/pkg/gtk/v4" "github.com/gen2brain/beeep" "github.com/go-analyze/charts" "path/filepath" "github.com/BurntSushi/toml" "gosrc.io/xmpp" "gosrc.io/xmpp/stanza" "mellium.im/xmpp/jid" "time" _ "embed" "encoding/xml" "github.com/kr/pretty" "runtime" ) var loadedConfig lambdaConfig var empty_dialog *gtk.Image var connectionStatus *gtk.Label var connectionIcon *gtk.Image var mStatus *gtk.Label var mIcon *gtk.Image var pingStatus *gtk.Label // var msgs *gtk.ListBox var content *gtk.Widgetter // var tabs map[string]*chatTab = make(map[string]*chatTab) var tabs sync.Map var current string var scroller *gtk.ScrolledWindow var memberList *gtk.ScrolledWindow var menu *gtk.Box //go:embed style.css var styleCSS string var client xmpp.Sender var clientroot *xmpp.Client var uiQueue = make(chan func(), 100) var window *gtk.ApplicationWindow // stores members of mucs var mucmembers sync.Map // stores devices of users var userdevices sync.Map var pingTimes = [][]float64{} var clientAssets map[string]gdk.Paintabler = make(map[string]gdk.Paintabler) func init() { beeep.AppName = "Lambda" go func() { for fn := range uiQueue { glib.IdleAdd(func() bool { fn() return false }) time.Sleep(10 * time.Millisecond) // Small delay between updates } }() } func main() { pingTimes = append(pingTimes, []float64{}) p, err := ensureConfig() if err != nil { panic(err) } b, err := os.ReadFile(filepath.Join(p, "lambda.toml")) if err != nil { dropToSignInPage(err) return // panic(err) } _, err = toml.Decode(string(b), &loadedConfig) if err != nil { panic(err) } if loadedConfig.Resource == "" { fmt.Println("Config resource is empty! Generating a random one") loadedConfig.Resource = randomClientResource() } config := xmpp.Config{ TransportConfiguration: xmpp.TransportConfiguration{ Address: loadedConfig.Server, }, Jid: loadedConfig.Username + "/" + loadedConfig.Resource, Credential: xmpp.Password(loadedConfig.Password), Insecure: loadedConfig.Insecure, // StreamLogger: os.Stdout, StreamManagementEnable: true, } router := xmpp.NewRouter() router.NewRoute().IQNamespaces(stanza.NSDiscoInfo).HandlerFunc(func(s xmpp.Sender, p stanza.Packet) { iq, ok := p.(*stanza.IQ) if !ok || iq.Type != stanza.IQTypeGet { return } iqResp, err := stanza.NewIQ(stanza.Attrs{Type: "result", From: iq.To, To: iq.From, Id: iq.Id}) if err != nil { panic(err) } identity := stanza.Identity{ Name: "Lambda", Category: "client", // TODO: Allow spoofing on user request Type: "pc", } payload := stanza.DiscoInfo{ XMLName: xml.Name{ Space: stanza.NSDiscoInfo, Local: "query", }, Identity: []stanza.Identity{identity}, Features: []stanza.Feature{ {Var: stanza.NSDiscoInfo}, {Var: stanza.NSDiscoItems}, {Var: "jabber:iq:version"}, {Var: "urn:xmpp:delegation:1"}, {Var: "http://jabber.org/protocol/muc"}, {Var: "λ"}, }, } iqResp.Payload = &payload s.Send(iqResp) }) router.NewRoute().IQNamespaces("jabber:iq:version").HandlerFunc(func(s xmpp.Sender, p stanza.Packet) { iq, ok := p.(*stanza.IQ) if !ok || iq.Type != stanza.IQTypeGet { return } iqResp, err := stanza.NewIQ(stanza.Attrs{Type: "result", From: iq.To, To: iq.From, Id: iq.Id}) if err != nil { panic(err) } v := &stanza.Version{} v = v.SetInfo("Lambda", lambda_version, runtime.GOOS) // TODO: Allow spoofing on user request iqResp.Payload = v s.Send(iqResp) }) router.HandleFunc("message", func(s xmpp.Sender, p stanza.Packet) { m, ok := p.(stanza.Message) if !ok { return } pretty.Println(m) e := stanza.PubSubEvent{} ok = m.Get(&e) if ok { fmt.Println(e) } /* if m.Body == "" { return } */ originator := JidMustParse(m.From).Bare() mStatus.SetText(originator) glib.IdleAdd(func() { //uiQueue <- func() { b := gtk.NewBox(gtk.OrientationVertical, 0) tab, ok := tabs.Load(originator) typed_tab := tab.(*chatTab) if ok { typed_tab.msgs.Append(b) scrollToBottomAfterUpdate(scroller) } else { fmt.Println("Got message when the tab does not exist!") } ba, ok := generateMessageWidget(p).(*gtk.Box) if ok { b.Append(ba) } //} }) }) router.HandleFunc("presence", func(s xmpp.Sender, p stanza.Packet) { presence, ok := p.(stanza.Presence) if !ok { return } if presence.Error.Reason != "" { beeep.Notify(fmt.Sprintf("%s : %s", presence.From, presence.Error.Reason), presence.Error.Text, cancelBytes) return } var mu MucUser var ocu OccupantID ok = presence.Get(&mu) if ok { // This is a presence stanza from a user in a MUC presence.Get(&ocu) id := ocu.ID if id == "" { id = JidMustParse(presence.From).Resource } from, _ := stanza.NewJid(presence.From) muc := from.Bare() _, ok = mucmembers.Load(muc) if !ok { mucmembers.Store(muc, mucUnit{}) } unit, ok := mucmembers.Load(muc) if !ok { return } typed_unit := unit.(mucUnit) if presence.Type != "unavailable" { _, ok := typed_unit.Members.Load(id) if !ok { glib.IdleAdd(func() { b := gtk.NewBox(gtk.OrientationVertical, 0) ba, ok := generatePresenceWidget(p).(*gtk.Box) if ok { b = ba } tab, ok := tabs.Load(muc) typed_tab := tab.(*chatTab) if ok { typed_tab.msgs.Append(b) scrollToBottomAfterUpdate(scroller) } else { fmt.Println("Got message when the tab does not exist!") } }) } typed_unit.Members.Store(id, presence) } else { typed_unit.Members.Delete(id) glib.IdleAdd(func() { b := gtk.NewBox(gtk.OrientationVertical, 0) ba, ok := generatePresenceWidget(p).(*gtk.Box) if ok { b = ba } tab, ok := tabs.Load(muc) typed_tab := tab.(*chatTab) if ok { typed_tab.msgs.Append(b) scrollToBottomAfterUpdate(scroller) } else { fmt.Println("Got message when the tab does not exist!") } }) } mucmembers.Store(muc, typed_unit) } else { // This is a presence stanza from a regular user // The code is basically the exact same as above, we just don't check for mucuser user := jid.MustParse(presence.From).Bare().String() _, ok := userdevices.Load(user) _, mok := mucmembers.Load(user) if !ok && !mok { // FIXME: The initial muc presence gets picked up from this check ok := createTab(user, false) if ok { userdevices.Store(user, userUnit{}) b := gtk.NewLabel(user) gesture1 := gtk.NewGestureClick() gesture1.SetButton(1) gesture1.Connect("pressed", func() { switchToTab(user, &window.Window) }) b.AddController(gesture1) menu.Append(b) } } unit, ok := userdevices.Load(user) if !ok { return } resource := jid.MustParse(presence.From).Resourcepart() typed_unit := unit.(userUnit) if presence.Type != "unavailable" { typed_unit.Devices.Store(resource, presence) } else { typed_unit.Devices.Delete(resource) } userdevices.Store(user, typed_unit) } time.Sleep(1 * time.Second) }) c, err := xmpp.NewClient(&config, router, func(err error) { connectionStatus.SetText(fmt.Sprintf("Disconnected: %s", err.Error())) connectionIcon.SetFromPaintable(clientAssets["disconnect"]) }) if err != nil { showErrorDialog(err) panic(err) } client = c clientroot = c cm := xmpp.NewStreamManager(c, func(c xmpp.Sender) { fmt.Println("XMPP client connected") go func() { for { time.Sleep(5 * time.Second) pingStatus.AddCSSClass("pending") before := time.Now() iq := new(stanza.IQ) iq.From = clientroot.Session.BindJid iq.To = iq.From iq.Type = "get" ctx, _ := context.WithTimeout(context.Background(), 30*time.Second) mychan, err := client.SendIQ(ctx, iq) if err != nil { continue } _ = <-mychan pingStatus.RemoveCSSClass("pending") delay := time.Since(before) / time.Millisecond pingStatus.SetText(fmt.Sprintf("%d ms", delay)) pingTimes[0] = append(pingTimes[0], float64(delay)) } }() connectionStatus.SetText(fmt.Sprintf("Connected as %s", JidMustParse(clientroot.Session.BindJid).Bare())) connectionStatus.SetTooltipText(fmt.Sprintf("Binded JID: %s\nUsing TLS: %t", clientroot.Session.BindJid, clientroot.Session.TlsEnabled)) connectionIcon.SetFromPaintable(clientAssets["connect"]) // Join rooms in bookmarks if loadedConfig.JoinBookmarks { books, err := stanza.NewItemsRequest("", "urn:xmpp:bookmarks:1", 0) if err == nil { mychan, err := c.SendIQ(context.TODO(), books) result := <-mychan if err == nil { res, ok := result.Payload.(*stanza.PubSubGeneric) if ok { for _, item := range res.Items.List { go func() { jid := item.Id node := item.Any autojoin := false nick := loadedConfig.Nick for _, attr := range node.Attrs { if attr.Name.Local == "autojoin" { autojoin = attr.Value == "true" } } for _, node := range node.Nodes { if node.XMLName.Local == "nick" { nick = node.Content } } _, ok := tabs.Load(jid) if !ok && autojoin { err := joinMuc(client, clientroot.Session.BindJid, jid, nick) if err != nil { panic(err) } createTab(jid, true) b := gtk.NewLabel(jid) gesture1 := gtk.NewGestureClick() gesture1.SetButton(1) gesture1.Connect("pressed", func() { switchToTab(jid, &window.Window) }) b.AddController(gesture1) menu.Append(b) } }() } } } } } }) go func() { time.Sleep(3 * time.Second) connectionStatus.SetText("Connecting...") connectionIcon.SetFromPaintable(clientAssets["hourglass"]) err = cm.Run() if err != nil { fmt.Println(err.Error()) connectionStatus.SetText(fmt.Sprintf("Disconnected: %s", err.Error())) connectionIcon.SetFromPaintable(clientAssets["disconnect"]) } }() app := gtk.NewApplication("net.sunglocto.lambda", gio.ApplicationFlagsNone) app.ConnectActivate(func() { activate(app) }) if code := app.Run(os.Args); code > 0 { os.Exit(code) } } func activate(app *gtk.Application) { // Load the CSS and apply it globally. gtk.StyleContextAddProviderForDisplay( gdk.DisplayGetDefault(), loadCSS(styleCSS), gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, ) window = gtk.NewApplicationWindow(app) the_menu := gio.NewMenu() fileMenu := gio.NewMenu() fileMenu.Append("Join MUC", "app.join") fileMenu.Append("Start DM", "app.dm") fileMenu.Append("Destroy MUC", "app.destroymuc") helpMenu := gio.NewMenu() helpMenu.Append("About", "app.about") aboutAction := gio.NewSimpleAction("about", nil) aboutAction.ConnectActivate(func(p *glib.Variant) { a := gtk.AboutDialog{} a.SetVisible(true) }) destroymucAction := gio.NewSimpleAction("destroymuc", nil) destroymucAction.ConnectActivate(func(p *glib.Variant) { cur, ok := tabs.Load(current) if ok { cur := cur.(*chatTab) if cur.isMuc { win := gtk.NewWindow() win.SetTitle("Destroy MUC") win.SetDefaultSize(400, 1) win.SetResizable(false) box := gtk.NewBox(gtk.OrientationVertical, 0) box.Append(gtk.NewLabel("Are you sure? This MUC will be gone forever! (a very long time)")) box.Append(gtk.NewLabel("If you wish to continue, type 'I understand'")) cancel := gtk.NewButtonWithLabel("Cancel") cancel.ConnectClicked(func() { win.SetVisible(false) }) en := gtk.NewEntry() en.SetPlaceholderText("...") submit := gtk.NewButtonWithLabel("Destroy") submit.ConnectClicked(func() { fmt.Println(en.Text()) if en.Text() == "I understand" { cur, ok := tabs.Load(current) if ok { cur := cur.(*chatTab) if cur.isMuc { client.SendRaw(fmt.Sprintf(` User requested `, clientroot.Session.BindJid, current, JidMustParse(clientroot.Session.BindJid).Bare())) } } win.SetVisible(false) } }) box.Append(en) box.Append(submit) box.Append(cancel) mu, ok := mucmembers.Load(current) if ok { typed_mu := mu.(mucUnit) typed_mu.Members.Range(func(k, v any) bool { user, ok := v.(stanza.Presence) if ok { mu := MucUser{} ok := user.Get(&mu) if ok { if mu.MucUserItem.JID != "" { if JidMustParse(mu.MucUserItem.JID).Bare() == JidMustParse(clientroot.Session.BindJid).Bare() { if mu.MucUserItem.Affiliation != "owner" { box.Append(gtk.NewLabel("You are not an owner of this MUC and thus will most likely not be able to delete it")) } // return false } } } else { panic("not ok") } } else { panic("not ok") } return true }) } else { panic("not ok") } win.SetChild(box) win.SetVisible(true) } } }) joinAction := gio.NewSimpleAction("join", nil) joinAction.ConnectActivate(func(p *glib.Variant) { box := gtk.NewBox(gtk.OrientationVertical, 0) jid_box := gtk.NewBox(gtk.OrientationHorizontal, 0) nick_box := gtk.NewBox(gtk.OrientationHorizontal, 0) jid_entry := gtk.NewEntry() nick_entry := gtk.NewEntry() jid_entry.SetHAlign(gtk.AlignEnd) jid_entry.SetHExpand(true) nick_entry.SetHAlign(gtk.AlignEnd) nick_entry.SetHExpand(true) nick_entry.SetText(loadedConfig.Nick) jid_box.Append(gtk.NewLabel("MUC JID:")) jid_box.Append(jid_entry) nick_box.Append(gtk.NewLabel("Nick:")) nick_box.Append(nick_entry) box.Append(jid_box) box.Append(nick_box) btn := gtk.NewButtonWithLabel("Submit") btn.SetVAlign(gtk.AlignBaseline) box.Append(btn) win := gtk.NewWindow() win.SetTitle("Join MUC") win.SetDefaultSize(400, 1) win.SetResizable(false) win.SetChild(box) btn.ConnectClicked(func() { t := jid_entry.Text() _, ok := tabs.Load(t) if !ok { err := joinMuc(client, clientroot.Session.BindJid, t, nick_entry.Text()) if err != nil { panic(err) } createTab(t, true) b := gtk.NewLabel(t) gesture1 := gtk.NewGestureClick() gesture1.SetButton(1) gesture1.Connect("pressed", func() { switchToTab(t, &window.Window) }) b.AddController(gesture1) menu.Append(b) } win.SetVisible(false) }) win.SetTransientFor(win) win.Present() }) app.AddAction(joinAction) app.AddAction(aboutAction) app.AddAction(destroymucAction) the_menu.AppendSubmenu("File", fileMenu) the_menu.AppendSubmenu("Help", helpMenu) the_menuBar := gtk.NewPopoverMenuBarFromModel(the_menu) app.SetMenubar(gio.NewMenu()) window.SetTitle("Lambda") window.Window.AddCSSClass("ssd") window.Window.SetDefaultSize(500, 500) menu = gtk.NewBox(gtk.OrientationVertical, 0) empty_dialog = gtk.NewImageFromPaintable(clientAssets["disabled_logo"]) empty_dialog.SetPixelSize(100) empty_dialog.SetVExpand(true) scroller = gtk.NewScrolledWindow() memberList = gtk.NewScrolledWindow() scroller.SetHExpand(true) memberList.SetHExpand(true) box := gtk.NewBox(gtk.OrientationVertical, 0) box.Append(the_menuBar) statBar := gtk.NewBox(gtk.OrientationHorizontal, 0) cBox := gtk.NewBox(gtk.OrientationHorizontal, 0) connectionIcon = gtk.NewImageFromPaintable((clientAssets["disconnect"])) connectionIcon.AddCSSClass("icon") connectionStatus = gtk.NewLabel("Disconnected") cBox.Append(connectionIcon) cBox.Append(connectionStatus) statBar.Append(cBox) mBox := gtk.NewBox(gtk.OrientationHorizontal, 0) gesture1 := gtk.NewGestureClick() gesture1.SetButton(1) gesture1.Connect("pressed", func() { current = mStatus.Text() switchToTab(current, &window.Window) }) mIcon = gtk.NewImageFromPaintable((clientAssets["comment"])) mIcon.AddCSSClass("icon") mStatus = gtk.NewLabel("-") mStatus.AddController(gesture1) cBox.Append(mIcon) cBox.Append(mStatus) statBar.Append(mBox) pBox := gtk.NewBox(gtk.OrientationHorizontal, 0) pBox.SetTooltipText("Ping between you and your XMPP server\nRight-click to see graph") gesture := gtk.NewGestureClick() gesture.SetButton(3) gesture.Connect("pressed", func() { opt := charts.NewLineChartOptionWithData(pingTimes) opt.Title = charts.TitleOption{ Text: "Server latency", } /* opt.XAxis.Labels = []string{ // The 7 labels here match to the 7 values above "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", }*/ opt.Legend = charts.LegendOption{ SeriesNames: []string{ "Ping (ms)", }, } opt.StrokeSmoothingTension = 0.9 p := charts.NewPainter(charts.PainterOptions{ Width: 600, Height: 400, }) err := p.LineChart(opt) if err != nil { panic(err) } buf, err := p.Bytes() if err != nil { panic(err) } loader := gdkpixbuf.NewPixbufLoader() loader.Write(buf) loader.Close() i := gtk.NewPictureForPaintable(gdk.NewTextureForPixbuf(loader.Pixbuf())) win := gtk.NewWindow() win.SetDefaultSize(600, 400) win.SetTitle("Server latency") box := gtk.NewBox(gtk.OrientationVertical, 0) box.Append(i) win.SetChild(box) win.SetVisible(true) }) pBox.AddController(gesture) i := (gtk.NewImageFromPaintable(clientAssets["chart_bar"])) i.AddCSSClass("icon") pBox.Append(i) pingStatus = gtk.NewLabel("...") pBox.Append(pingStatus) statBar.Append(pBox) scrollerStatBar := gtk.NewScrolledWindow() scrollerStatBar.SetChild(statBar) box.Append(scrollerStatBar) // scroller.SetChild(empty_dialog) scroller.SetChild(empty_dialog) menu_scroll := gtk.NewScrolledWindow() menu_scroll.SetHExpand(true) menu_scroll.SetChild(menu) // box.Append(menu_scroll) chatbox := gtk.NewBox(gtk.OrientationHorizontal, 0) // chatbox.Append(menu_scroll) chat_pane := gtk.NewPaned(gtk.OrientationHorizontal) chat_pane.SetStartChild(scroller) chat_pane.SetEndChild(memberList) chat_pane.SetPosition(225) main_pane := gtk.NewPaned(gtk.OrientationHorizontal) main_pane.SetStartChild(menu_scroll) main_pane.SetEndChild(chat_pane) main_pane.SetPosition(135) chatbox.Append(main_pane) box.Append(chatbox) entry_box := gtk.NewBox(gtk.OrientationHorizontal, 0) en := gtk.NewEntry() en.SetPlaceholderText("Say something, what else are you gonna do here?") b := gtk.NewButtonWithLabel("Send") sendtxt := func() { t := en.Text() if t == "" { dialog := >k.AlertDialog{} dialog.SetDetail("detail") dialog.SetMessage("message") dialog.SetButtons([]string{"yes, no"}) dialog.Choose(context.TODO(), &window.Window, nil) } message_type := stanza.MessageTypeChat tab, ok := tabs.Load(current) if !ok { return } typed_tab := tab.(*chatTab) if typed_tab.isMuc { message_type = stanza.MessageTypeGroupchat } err := sendMessage(client, current, message_type, t, "", "") if err != nil { panic(err) // TODO: Show error message via GTK } en.SetText("") scrollToBottomAfterUpdate(scroller) } en.Connect("activate", sendtxt) b.ConnectClicked(sendtxt) en.SetHExpand(true) entry_box.Append(en) entry_box.Append(b) box.Append(entry_box) window.SetChild(box) window.SetVisible(true) } func loadCSS(content string) *gtk.CSSProvider { prov := gtk.NewCSSProvider() prov.LoadFromString(content) return prov }