Files
pi-im/main.go
2025-08-04 10:05:43 +01:00

472 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"encoding/json"
"fmt"
"image/color"
"io"
"log"
_ "net/url"
"os"
"strings"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
_ "fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
_ "fyne.io/x/fyne/theme"
catppuccin "github.com/mbaklor/fyne-catppuccin"
"mellium.im/xmpp/jid"
"mellium.im/xmpp/muc"
"mellium.im/xmpp/stanza"
oasisSdk "pain.agency/oasis-sdk"
)
// by sunglocto
// license AGPL
type Message struct {
Author string
Content string
ID string
ReplyID string
Raw oasisSdk.XMPPChatMessage
}
type MucTab struct {
Jid jid.JID
Nick string
Messages []Message
Scroller *widget.List
isMuc bool
}
var chatTabs = make(map[string]*MucTab)
var tabs *container.AppTabs
var selectedId widget.ListItemID
var replying bool = false
var notifications bool = true
type myTheme struct{}
func (m myTheme) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color {
return catppuccin.New().Color(name, variant)
}
func (m myTheme) Icon(name fyne.ThemeIconName) fyne.Resource {
return theme.DefaultTheme().Icon(name)
}
func (m myTheme) Font(style fyne.TextStyle) fyne.Resource {
return theme.DefaultTheme().Font(style)
}
func (m myTheme) Size(name fyne.ThemeSizeName) float32 {
if name == theme.SizeNameHeadingText {
return 18
}
return theme.DefaultTheme().Size(name)
}
var scrollDownOnNewMessage bool = true
var w fyne.Window
var a fyne.App
func addChatTab(isMuc bool, chatJid jid.JID, nick string) {
mucJidStr := chatJid.String()
if _, ok := chatTabs[mucJidStr]; ok {
// Tab already exists
return
}
tabData := &MucTab{
Jid: chatJid,
Nick: nick,
Messages: []Message{},
isMuc: isMuc,
}
var scroller *widget.List
scroller = widget.NewList(
func() int {
return len(tabData.Messages)
},
func() fyne.CanvasObject {
author := widget.NewLabel("author")
author.TextStyle.Bold = true
content := widget.NewRichTextWithText("content")
content.Wrapping = fyne.TextWrapWord
return container.NewVBox(author, content)
},
func(i widget.ListItemID, co fyne.CanvasObject) {
vbox := co.(*fyne.Container)
author := vbox.Objects[0].(*widget.Label)
content := vbox.Objects[1].(*widget.RichText)
content.ParseMarkdown(tabData.Messages[i].Content)
if tabData.Messages[i].ReplyID != "PICLIENT:UNAVAILABLE" {
author.SetText(fmt.Sprintf("%s ↳ %s", tabData.Messages[i].Author, jid.MustParse(tabData.Messages[i].ReplyID).Resourcepart()))
} else {
author.SetText(tabData.Messages[i].Author)
}
scroller.SetItemHeight(i, vbox.MinSize().Height)
},
)
scroller.OnSelected = func(id widget.ListItemID) {
selectedId = id
}
tabData.Scroller = scroller
chatTabs[mucJidStr] = tabData
tabItem := container.NewTabItem(chatJid.Localpart(), scroller)
tabs.Append(tabItem)
}
func main() {
login := oasisSdk.LoginInfo{}
DMs := []string{}
bytes, err := os.ReadFile("./pi.json")
if err != nil {
a = app.New()
w = a.NewWindow("Error")
w.Resize(fyne.NewSize(500, 500))
dialog.ShowInformation("Error", fmt.Sprintf("Please make sure there is a file named pi.json in the same directory you are running this executable...\n%s", err.Error()), w)
w.ShowAndRun()
return
}
err = json.Unmarshal(bytes, &login)
if err != nil {
a = app.New()
w = a.NewWindow("Error")
w.Resize(fyne.NewSize(500, 500))
dialog.ShowError(err, w)
w.ShowAndRun()
return
}
client, err := oasisSdk.CreateClient(
&login,
func(client *oasisSdk.XmppClient, msg *oasisSdk.XMPPChatMessage) {
fmt.Println(msg)
userJidStr := msg.From.Bare().String()
tab, ok := chatTabs[userJidStr]
fmt.Println(msg.From.String())
if ok {
str := *msg.CleanedBody
if notifications {
a.SendNotification(fyne.NewNotification(fmt.Sprintf("%s says", userJidStr), str))
}
/*
if strings.Contains(str, "https://") {
fmt.Println("Attempting to do URL thingy")
s := strings.Split(str, " ")
for i, v := range s {
_, err := url.Parse(v)
if err == nil {
s[i] = fmt.Sprintf("[%s](%s)", v, v)
}
}
str = strings.Join(s, " ")
}*/
var replyID string
if msg.Reply == nil {
replyID = "PICLIENT:UNAVAILABLE"
} else {
replyID = msg.Reply.ID
}
myMessage := Message{
Author: msg.From.Resourcepart(),
Content: str,
ID: msg.ID,
ReplyID: replyID,
Raw: *msg,
}
tab.Messages = append(tab.Messages, myMessage)
fyne.Do(func() {
tab.Scroller.Refresh()
if scrollDownOnNewMessage {
tab.Scroller.ScrollToBottom()
}
})
}
},
func(client *oasisSdk.XmppClient, _ *muc.Channel, msg *oasisSdk.XMPPChatMessage) {
mucJidStr := msg.From.Bare().String()
if tab, ok := chatTabs[mucJidStr]; ok {
str := *msg.CleanedBody
if notifications {
if strings.Contains(str, login.DisplayName) || (msg.Reply != nil && strings.Contains(msg.Reply.To, login.User)) {
a.SendNotification(fyne.NewNotification(fmt.Sprintf("Mentioned in %s", mucJidStr), str))
}
}
/*
if strings.Contains(str, "https://") {
s := strings.Split(str, " ")
for i, v := range s {
_, err := url.Parse(v)
if err == nil {
s[i] = fmt.Sprintf("[%s](%s)", v, v)
}
}
str = strings.Join(s, " ")
}*/
fmt.Println(msg.ID)
var replyID string
if msg.Reply == nil {
replyID = "PICLIENT:UNAVAILABLE"
} else {
replyID = msg.Reply.To
}
myMessage := Message{
Author: msg.From.Resourcepart(),
Content: str,
ID: msg.ID,
ReplyID: replyID,
Raw: *msg,
}
tab.Messages = append(tab.Messages, myMessage)
fyne.Do(func() {
tab.Scroller.Refresh()
if scrollDownOnNewMessage {
tab.Scroller.ScrollToBottom()
}
})
}
},
func(_ *oasisSdk.XmppClient, from jid.JID, state oasisSdk.ChatState) {
//fromStr := from.String()
switch state {
case oasisSdk.ChatStateActive:
case oasisSdk.ChatStateComposing:
case oasisSdk.ChatStatePaused:
case oasisSdk.ChatStateInactive:
case oasisSdk.ChatStateGone:
default:
}
},
func(_ *oasisSdk.XmppClient, from jid.JID, id string) {
fmt.Printf("Delivered %s to %s", id, from.String())
},
func(_ *oasisSdk.XmppClient, from jid.JID, id string) {
fmt.Printf("%s has seen %s", from.String(), id)
},
)
if err != nil {
log.Fatalln("Could not create client - " + err.Error())
}
go func() {
err = client.Connect()
if err != nil {
log.Fatalln("Could not connect - " + err.Error())
}
}()
a = app.New()
a.Settings().SetTheme(myTheme{})
w = a.NewWindow("pi")
w.Resize(fyne.NewSize(500, 500))
entry := widget.NewMultiLineEntry()
entry.SetPlaceHolder("Say something, you know you want to.")
sendbtn := widget.NewButton("Send", func() {
text := entry.Text
if tabs.Selected() == nil || tabs.Selected().Content == nil {
return
}
selectedScroller, ok := tabs.Selected().Content.(*widget.List)
if !ok {
return
}
var activeMucJid string
var isMuc bool
for jid, tabData := range chatTabs {
if tabData.Scroller == selectedScroller {
activeMucJid = jid
isMuc = tabData.isMuc
break
}
}
if activeMucJid == "" {
return
}
go func() {
//TODO: Fix message hack until jjj adds message sending
if replying {
m := chatTabs[activeMucJid].Messages[selectedId].Raw
client.ReplyToEvent(&m, text)
return
}
var typ stanza.MessageType
if isMuc {
typ = stanza.GroupChatMessage
} else {
typ = stanza.ChatMessage
}
msg := oasisSdk.XMPPChatMessage{
Message: stanza.Message{
To: jid.MustParse(activeMucJid),
Type: typ,
},
ChatMessageBody: oasisSdk.ChatMessageBody{
Body: &text,
},
}
err := client.Session.Encode(client.Ctx, msg)
if err != nil {
dialog.ShowError(err, w)
}
}()
if !isMuc {
chatTabs[activeMucJid].Messages = append(chatTabs[activeMucJid].Messages, Message{
Author: "You",
Content: text,
})
fyne.Do(func() {
if scrollDownOnNewMessage {
chatTabs[activeMucJid].Scroller.ScrollToBottom()
}
})
}
entry.SetText("")
})
mit := fyne.NewMenuItem("About pi", func() {
dialog.ShowInformation("About pi", "the XMPP client from hell\n\npi is an experimental XMPP client\nwritten by Sunglocto in Go.", w)
})
mia := fyne.NewMenuItem("Configure message view", func() {
ch := widget.NewCheck("", func(b bool) {})
ch2 := widget.NewCheck("", func(b bool) {})
ch.Checked = scrollDownOnNewMessage
ch2.Checked = notifications
scrollView := widget.NewFormItem("Scroll to bottom on new message", ch)
notiView := widget.NewFormItem("Send notifications when mentioned", ch2)
items := []*widget.FormItem{
scrollView,
notiView,
}
dialog.ShowForm("Configure message view", "Apply", "Cancel", items, func(b bool) {
if b {
scrollDownOnNewMessage = ch.Checked
notifications = ch2.Checked
}
}, w)
})
mis := fyne.NewMenuItem("Clear chat window", func() {
dialog.ShowConfirm("Clear chat window", "Are you sure you want to clear the chat window?", func(b bool) {
if b {
fmt.Println("clearing chat")
}
}, w)
})
mib := fyne.NewMenuItem("Join a room", func() {
nickEntry := widget.NewEntry()
nickEntry.SetText(login.DisplayName)
roomEntry := widget.NewEntry()
items := []*widget.FormItem{
widget.NewFormItem("Nick", nickEntry),
widget.NewFormItem("MUC address", roomEntry),
}
dialog.ShowForm("Join a MUC", "Join", "Cancel", items, func(b bool) {
if b {
roomJid, err := jid.Parse(roomEntry.Text)
if err != nil {
dialog.ShowError(err, w)
return
}
nick := nickEntry.Text
go func() {
// We probably don't need to handle the error here, if it fails the user will know
_, err := client.MucClient.Join(client.Ctx, roomJid, client.Session, nil)
if err != nil {
panic(err)
}
}()
addChatTab(true, roomJid, nick)
}
}, w)
})
mic := fyne.NewMenuItem("Upload a file", func() {
dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) {
if err != nil {
dialog.ShowError(err, w)
}
bytes, err := io.ReadAll(reader)
link, err := client.UploadFileFromBytes(reader.URI().String(), bytes)
if err != nil {
dialog.ShowError(err, w)
return
}
a.Clipboard().SetContent(link)
dialog.ShowInformation("File successfully uploaded", link, w)
}, w)
})
menu_help := fyne.NewMenu("π", mit)
menu_changeroom := fyne.NewMenu("β", mib, mic)
menu_configureview := fyne.NewMenu("γ", mia, mis)
bit := fyne.NewMenuItem("Mark message as read", func() {
selectedScroller, ok := tabs.Selected().Content.(*widget.List)
if !ok {
return
}
var activeMucJid string
for jid, tabData := range chatTabs {
if tabData.Scroller == selectedScroller {
activeMucJid = jid
break
}
}
m := chatTabs[activeMucJid].Messages[selectedId].Raw
client.MarkAsRead(&m)
})
bia := fyne.NewMenuItem("Toggle replying to message", func() {
replying = !replying
})
menu_messageoptions := fyne.NewMenu("Σ", bit, bia)
ma := fyne.NewMainMenu(menu_help, menu_changeroom, menu_configureview, menu_messageoptions)
w.SetMainMenu(ma)
tabs = container.NewAppTabs(
container.NewTabItem("τίποτα", widget.NewRichTextFromMarkdown("# No chat selected.")),
)
for _, mucJidStr := range login.MucsToJoin {
mucJid, err := jid.Parse(mucJidStr)
if err == nil {
addChatTab(true, mucJid, login.DisplayName)
}
}
for _, userJidStr := range DMs {
fmt.Println(userJidStr)
DMjid, err := jid.Parse(userJidStr)
if err == nil {
addChatTab(false, DMjid, login.DisplayName)
}
}
w.SetContent(container.NewVSplit(tabs, container.NewHSplit(entry, sendbtn)))
w.ShowAndRun()
}