package window
import (
"context"
"fmt"
"log/slog"
"strings"
"github.com/diamondburned/adaptive"
"github.com/diamondburned/arikawa/v3/discord"
"github.com/diamondburned/arikawa/v3/gateway"
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
"github.com/diamondburned/gotk4/pkg/gdk/v4"
"github.com/diamondburned/gotk4/pkg/gio/v2"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
"github.com/diamondburned/gotk4/pkg/pango"
"github.com/diamondburned/gotkit/app"
"github.com/diamondburned/gotkit/gtkutil"
"github.com/diamondburned/gotkit/gtkutil/cssutil"
"github.com/diamondburned/ningen/v3/states/read"
"libdb.so/ctxt"
"libdb.so/dissent/internal/gtkcord"
"libdb.so/dissent/internal/messages"
"libdb.so/dissent/internal/sidebar"
"libdb.so/dissent/internal/sidebar/channels"
"libdb.so/dissent/internal/window/backbutton"
"libdb.so/dissent/internal/window/quickswitcher"
)
var lastGuildKey = app.NewSingleStateKey[discord.GuildID]("last-guild-state")
var lastChannelKey = app.NewStateKey[discord.ChannelID]("guild-last-open")
type ChatPage struct {
*adw.OverlaySplitView
Sidebar *sidebar.Sidebar
RightHeader *adw.HeaderBar
rightTitle *adw.Bin
tabView *adw.TabView
lastGuildState *app.TypedSingleState[discord.GuildID]
lastChannelState *app.TypedState[discord.ChannelID]
lastGuild discord.GuildID
lastButtons []gtk.Widgetter
tabs map[uintptr]*chatTab
ctx context.Context
}
type chatPageView struct {
body gtk.Widgetter
headerButtons []gtk.Widgetter
}
var chatPageCSS = cssutil.Applier("window-chatpage", `
.window-chatpage-rightbox > .top-bar > windowhandle > .collapse-spacing {
padding: 0;
}
.right-header {
border-radius: 0;
box-shadow: none;
}
.right-header-label {
font-weight: bold;
}
.right-header-channel-icon {
margin-right: 4px;
}
`)
func NewChatPage(ctx context.Context, w *Window) *ChatPage {
p := ChatPage{
ctx: ctx,
tabs: make(map[uintptr]*chatTab),
lastGuildState: lastGuildKey.Acquire(ctx),
lastChannelState: lastChannelKey.Acquire(ctx),
}
p.tabView = adw.NewTabView()
p.tabView.AddCSSClass("window-chatpage-tabview")
p.tabView.SetDefaultIcon(gio.NewThemedIcon("channel-symbolic"))
p.tabView.NotifyProperty("selected-page", func() {
p.onActiveTabChange(p.tabView.SelectedPage())
})
p.tabView.ConnectClosePage(func(page *adw.TabPage) bool {
_, ok := p.tabs[page.Native()]
if ok {
delete(p.tabs, page.Native())
p.tabView.ClosePageFinish(page, true)
}
return gdk.EVENT_STOP
})
p.Sidebar = sidebar.NewSidebar(ctx)
p.Sidebar.SetHAlign(gtk.AlignStart)
p.rightTitle = adw.NewBin()
p.rightTitle.AddCSSClass("right-header-bin")
p.rightTitle.SetHExpand(true)
back := backbutton.New()
newTabButton := gtk.NewButtonFromIconName("list-add-symbolic")
newTabButton.SetTooltipText("Open a New Tab")
newTabButton.ConnectClicked(func() { p.newTab() })
p.RightHeader = adw.NewHeaderBar()
p.RightHeader.AddCSSClass("titlebar")
p.RightHeader.AddCSSClass("right-header")
p.RightHeader.SetShowStartTitleButtons(false)
p.RightHeader.SetShowEndTitleButtons(true)
p.RightHeader.SetShowBackButton(false)
p.RightHeader.SetShowTitle(false)
p.RightHeader.PackStart(back)
p.RightHeader.PackStart(p.rightTitle)
p.RightHeader.PackEnd(newTabButton)
tabBar := adw.NewTabBar()
tabBar.AddCSSClass("window-chatpage-tabbar")
tabBar.SetView(p.tabView)
tabBar.SetAutohide(true)
rightBox := adw.NewToolbarView()
rightBox.AddCSSClass("window-chatpage-rightbox")
rightBox.SetTopBarStyle(adw.ToolbarFlat)
rightBox.SetHExpand(true)
rightBox.AddTopBar(p.RightHeader)
rightBox.AddTopBar(tabBar)
rightBox.SetContent(p.tabView)
p.OverlaySplitView = adw.NewOverlaySplitView()
p.OverlaySplitView.SetSidebar(p.Sidebar)
p.OverlaySplitView.SetSidebarPosition(gtk.PackStart)
p.OverlaySplitView.SetContent(rightBox)
p.OverlaySplitView.SetEnableHideGesture(true)
p.OverlaySplitView.SetEnableShowGesture(true)
p.OverlaySplitView.SetMinSidebarWidth(200)
p.OverlaySplitView.SetMaxSidebarWidth(300)
p.OverlaySplitView.SetSidebarWidthFraction(0.5)
back.ConnectSplitView(p.OverlaySplitView)
breakpoint := adw.NewBreakpoint(adw.BreakpointConditionParse("max-width: 500sp"))
breakpoint.AddSetter(p.OverlaySplitView, "collapsed", true)
w.AddBreakpoint(breakpoint)
state := gtkcord.FromContext(ctx)
w.ConnectDestroy(state.AddHandler(
func(*gateway.MessageCreateEvent) { p.updateWindowTitle() },
func(*gateway.MessageUpdateEvent) { p.updateWindowTitle() },
func(*gateway.MessageDeleteEvent) { p.updateWindowTitle() },
func(*read.UpdateEvent) { p.updateWindowTitle() },
))
chatPageCSS(p)
return &p
}
func (p *ChatPage) OpenQuickSwitcher() { quickswitcher.ShowDialog(p.ctx) }
func (p *ChatPage) ResetView() { p.SwitchToPlaceholder() }
func (p *ChatPage) SwitchToPlaceholder() {
tab := p.currentTab()
tab.switchToPlaceholder()
p.onActiveTabChange(p.tabView.Page(tab))
}
func (p *ChatPage) SwitchToMessages() {
tab := p.currentTab()
tab.switchToPlaceholder()
p.lastGuildState.Get(func(id discord.GuildID) {
if id.IsValid() {
p.OpenGuild(id)
} else {
p.OpenDMs()
}
})
}
func (p *ChatPage) OpenDMs() {
p.lastGuild = 0
p.lastGuildState.Set(0)
p.Sidebar.OpenDMs()
p.restoreLastChannel(0)
}
func (p *ChatPage) OpenGuild(guildID discord.GuildID) {
p.lastGuild = guildID
p.lastGuildState.Set(guildID)
p.Sidebar.SetSelectedGuild(guildID)
p.restoreLastChannel(guildID)
}
func (p *ChatPage) restoreLastChannel(guildID discord.GuildID) {
k := guildID.String()
p.lastChannelState.Exists(k, func(exists bool) {
if exists {
p.lastChannelState.Get(k, func(chID discord.ChannelID) {
slog.Debug(
"restoring last channel from state",
"guild_id", guildID,
"restored_channel_id", chID)
p.OpenChannel(chID)
})
} else {
p.SwitchToPlaceholder()
}
})
}
func (p *ChatPage) OpenChannel(chID discord.ChannelID) {
var tab *chatTab
var reselect bool
for _, t := range p.tabs {
if t.alreadyOpens(chID) {
tab = t
reselect = true
break
}
}
if tab == nil {
tab = p.currentTab()
}
tab.switchToChannel(chID)
page := p.tabView.Page(tab)
updateTabInfo(p.ctx, page, chID)
if reselect {
p.tabView.SetSelectedPage(page)
}
p.onActiveTabChange(page)
state := gtkcord.FromContext(p.ctx).Offline()
ch, _ := state.Channel(chID)
if ch != nil {
p.lastChannelState.Set(ch.GuildID.String(), chID)
}
}
func updateTabInfo(ctx context.Context, page *adw.TabPage, chID discord.ChannelID) {
if chID.IsValid() {
page.SetIcon(gio.NewThemedIcon("channel-symbolic"))
title := gtkcord.WindowTitleFromID(ctx, chID)
title = strings.TrimPrefix(title, "#")
page.SetTitle(title)
} else {
page.SetIcon(nil)
page.SetTitle("New Tab")
}
}
func (p *ChatPage) currentTab() *chatTab {
var tab *chatTab
page := p.tabView.SelectedPage()
if page != nil {
tab = p.tabs[page.Native()]
} else {
tab = p.newTab()
}
return tab
}
func (p *ChatPage) newTab() *chatTab {
tab := newChatTab(p.ctx)
page := p.tabView.Append(tab)
updateTabInfo(p.ctx, page, 0)
p.tabs[page.Native()] = tab
p.tabView.SetSelectedPage(page)
return tab
}
func (p *ChatPage) onActiveTabChange(page *adw.TabPage) {
for _, button := range p.lastButtons {
p.RightHeader.Remove(button)
}
p.lastButtons = nil
p.updateWindowTitle()
var tab *chatTab
var chID discord.ChannelID
if page != nil {
tab = p.tabs[page.Native()]
if tab == nil {
return
}
chID = tab.channelID()
if tab.messageView != nil {
p.lastButtons = tab.messageView.HeaderButtons()
for i := len(p.lastButtons) - 1; i >= 0; i-- {
button := p.lastButtons[i]
p.RightHeader.PackEnd(button)
}
}
}
if chID.IsValid() {
p.Sidebar.SelectChannel(chID)
} else {
if p.lastGuild.IsValid() {
p.Sidebar.SetSelectedGuild(p.lastGuild)
}
}
if !chID.IsValid() {
p.rightTitle.SetChild(nil)
return
}
state := gtkcord.FromContext(p.ctx)
ch, _ := state.Cabinet.Channel(chID)
chName := gtkcord.ChannelNameWithoutHash(ch)
label := gtk.NewLabel(chName)
label.AddCSSClass("right-header-label")
label.SetEllipsize(pango.EllipsizeEnd)
label.SetXAlign(0)
chIcon := channels.NewChannelIcon(ch)
chIcon.AddCSSClass("right-header-channel-icon")
box := gtk.NewBox(gtk.OrientationHorizontal, 0)
box.AddCSSClass("right-header-channel-box")
box.Append(chIcon)
box.Append(label)
p.rightTitle.SetChild(box)
}
func (p *ChatPage) updateWindowTitle() {
var title string
if page := p.tabView.SelectedPage(); page != nil {
title = page.Title()
}
state := gtkcord.FromContext(p.ctx)
mentions := state.ReadState.TotalMentionCount()
if mentions > 0 {
title = fmt.Sprintf("(%d) %s", mentions, title)
}
win, _ := ctxt.From[*Window](p.ctx)
win.SetTitle(title)
}
type chatTab struct {
*gtk.Stack
placeholder gtk.Widgetter
messageView *messages.View
ctx context.Context
}
func newChatTab(ctx context.Context) *chatTab {
var t chatTab
t.ctx = ctx
t.placeholder = newEmptyMessagePlaceholder()
t.Stack = gtk.NewStack()
t.Stack.AddCSSClass("window-message-page")
t.Stack.SetTransitionType(gtk.StackTransitionTypeCrossfade)
t.Stack.AddChild(t.placeholder)
t.Stack.SetVisibleChild(t.placeholder)
return &t
}
func (t *chatTab) alreadyOpens(id discord.ChannelID) bool {
return t.channelID() == id
}
func (t *chatTab) channelID() discord.ChannelID {
if t.messageView == nil {
return 0
}
return t.messageView.ChannelID()
}
func (t *chatTab) switchToPlaceholder() bool {
return t.switchToChannel(0)
}
func (t *chatTab) switchToChannel(id discord.ChannelID) bool {
if t.alreadyOpens(id) {
return false
}
old := t.messageView
if id.IsValid() {
t.messageView = messages.NewView(t.ctx, id)
t.messageView.FetchBacklog()
t.Stack.AddChild(t.messageView)
t.Stack.SetVisibleChild(t.messageView)
viewWidget := gtk.BaseWidget(t.messageView)
viewWidget.GrabFocus()
} else {
t.messageView = nil
t.Stack.SetVisibleChild(t.placeholder)
}
if old != nil {
gtkutil.NotifyProperty(t.Stack, "transition-running", func() bool {
if !t.Stack.TransitionRunning() {
t.Stack.Remove(old)
return true
}
return false
})
}
return true
}
func newEmptyMessagePlaceholder() gtk.Widgetter {
status := adaptive.NewStatusPage()
status.SetIconName("chat-bubbles-empty-symbolic")
status.Icon.SetOpacity(0.45)
status.Icon.SetIconSize(gtk.IconSizeLarge)
return status
}