cchat-gtk/internal/ui/service/session/session.go

459 lines
12 KiB
Go

package session
import (
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/keyring"
"github.com/diamondburned/cchat-gtk/internal/log"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/actions"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/drag"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/roundimage"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/spinner"
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/button"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/commander"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/traverse"
"github.com/diamondburned/cchat/text"
"github.com/gotk3/gotk3/gdk"
"github.com/gotk3/gotk3/gtk"
"github.com/pkg/errors"
)
// Servicer extends server.RowController to add session.
type Servicer interface {
// Service asks the controller for its service.
Service() cchat.Service
// OnSessionDisconnect is called before a session is disconnected. This
// function is used for cleanups.
OnSessionDisconnect(*Row)
// SessionSelected is called when the row is clicked. The parent container
// should change the views to show this session's *Servers.
SessionSelected(*Row)
// MessengerSelected is called when a server that can display messages (aka
// implements Messenger) is called.
MessengerSelected(*Row, *server.ServerRow)
// RestoreSession is called with the session ID to ask the controller to
// restore it from keyring information.
RestoreSession(*Row, string) // ID string, async
// RemoveSession is called to ask the controller to remove the session from
// the list of sessions.
RemoveSession(*Row)
// MoveSession is called to ask the controller to move the session to
// somewhere else in the list of sessions.
MoveSession(id, movingID string)
}
// Row represents a session row entry in the session List.
type Row struct {
*gtk.ListBoxRow
avatar *roundimage.Avatar
iconBox *gtk.EventBox
icon *rich.Icon // nillable
parentcrumb traverse.Breadcrumber
Session cchat.Session // state; nilable
sessionID string
Servers *Servers // accessed by View for the right view
svcctrl Servicer
ActionsMenu *actions.Menu // session.*
// put commander in either a hover menu or a right click menu. maybe in the
// headerbar as well.
cmder *commander.Buffer
// Unread class enum for theming.
unreadClass primitives.ClassEnum
}
var rowCSS = primitives.PrepareClassCSS("session-row",
button.UnreadColorDefs+`
.session-row:last-child {
border-radius: 0 0 14px 14px;
}
.session-row:selected {
background-color: alpha(@theme_selected_bg_color, 0.5);
}
.session-row.unread {
background-color: alpha(@theme_fg_color, 0.25);
}
.session-row.unread:selected {
background-color: alpha(mix(
@theme_fg_color,
@theme_selected_bg_color,
0.65
), 0.85);
}
.session-row.mentioned {
background-color: alpha(@mentioned, 0.25);
}
.session-row.mentioned:selected {
background-color: alpha(mix(
@theme_fg_color,
@mentioned,
0.65
), 0.85);
}
.session-row.failed {
background-color: alpha(red, 0.45);
}
`)
var rowIconCSS = primitives.PrepareClassCSS("session-icon", `
.session-icon {
padding: 4px;
margin: 0;
}
`)
const IconSize = 48
const IconName = "face-plain-symbolic"
func newIcon(img rich.RoundIconContainer) *rich.Icon {
icon := rich.NewCustomIcon(img, IconSize)
icon.SetPlaceholderIcon(IconName, IconSize)
icon.ShowAll()
rowIconCSS(icon)
return icon
}
func New(parent traverse.Breadcrumber, ses cchat.Session, ctrl Servicer) *Row {
row := newRow(parent, text.Rich{}, ctrl)
row.SetSession(ses)
return row
}
func NewLoading(parent traverse.Breadcrumber, id, name string, ctrl Servicer) *Row {
row := newRow(parent, text.Rich{Content: name}, ctrl)
row.sessionID = id
row.SetLoading()
return row
}
func newRow(parent traverse.Breadcrumber, name text.Rich, ctrl Servicer) *Row {
row := &Row{
svcctrl: ctrl,
parentcrumb: parent,
}
row.avatar = roundimage.NewAvatar(IconSize)
row.avatar.SetText(name.Content)
row.avatar.Show()
row.iconBox, _ = gtk.EventBoxNew()
row.iconBox.Show()
row.ListBoxRow, _ = gtk.ListBoxRowNew()
row.ListBoxRow.Show()
rowCSS(row.ListBoxRow)
// TODO: commander button
row.Servers = NewServers(row, row)
row.Servers.Show()
// Bind session.* actions into row.
row.ActionsMenu = actions.NewMenu("session")
row.ActionsMenu.InsertActionGroup(row)
// Bind right clicks and show a popover menu on such event.
row.iconBox.Connect("button-press-event", func(_ gtk.IWidget, ev *gdk.Event) {
if gts.EventIsRightClick(ev) {
row.ActionsMenu.Popup(row)
}
})
// Bind drag-and-drop events.
drag.BindDraggable(row, "face-smile", ctrl.MoveSession)
// Bind the unread state.
row.Servers.Children.SetUnreadHandler(func(unread, mentioned bool) {
switch {
// Prioritize mentions over unreads.
case mentioned:
row.unreadClass.SetClass(row, "mentioned")
case unread:
row.unreadClass.SetClass(row, "unread")
default:
row.unreadClass.SetClass(row, "read")
}
})
// Reset to bring states set in that method to a newly constructed widget.
row.Reset()
return row
}
func NewAddButton() *gtk.ListBoxRow {
img, _ := gtk.ImageNew()
img.Show()
primitives.SetImageIcon(img, "list-add-symbolic", IconSize/2)
row, _ := gtk.ListBoxRowNew()
row.SetSizeRequest(IconSize, IconSize)
row.SetSelectable(false) // activatable though
row.Add(img)
row.Show()
rowCSS(row)
return row
}
// Reset extends the server row's Reset function and resets additional states.
// It resets all states back to nil, but the session ID stays.
func (r *Row) Reset() {
r.Servers.Reset() // wipe servers
r.ActionsMenu.Reset() // wipe menu items
r.ActionsMenu.AddAction("Remove", r.RemoveSession)
if r.icon == nil {
r.icon = newIcon(r.avatar)
r.iconBox.Add(r.icon)
}
// Set a lame placeholder icon.
r.icon.SetPlaceholderIcon("folder-remote-symbolic", IconSize)
r.Session = nil
r.cmder = nil
}
func (r *Row) ParentBreadcrumb() traverse.Breadcrumber {
return r.parentcrumb
}
func (r *Row) Breadcrumb() string {
if r.Session == nil {
return ""
}
return r.Session.Name().Content
}
// Activate executes whatever needs to be done. If the row has failed, then this
// method will reconnect. If the row is already loaded, then SessionSelected
// will be called.
func (r *Row) Activate() {
// If session is nil, then we've probably failed to load it. The row is
// deactivated while loading, so this wouldn't have happened.
if r.Session == nil {
r.ReconnectSession()
} else {
// Load all servers in this root node, then call the parent controller's
// method.
r.Servers.Children.LoadAll()
}
// Display the empty server list first, then try and reconnect.
r.svcctrl.SessionSelected(r)
}
// SetLoading sets the session button to have a spinner circle. DO NOT CONFUSE
// THIS WITH THE SERVERS LOADING.
func (r *Row) SetLoading() {
// Reset the state.
r.Session = nil
// Reset the icon.
primitives.RemoveChildren(r.iconBox)
r.icon = nil
// Remove everything from the row, including the icon.
primitives.RemoveChildren(r)
// Remove the failed class.
primitives.RemoveClass(r, "failed")
// Add a loading circle.
spin := spinner.New()
spin.SetSizeRequest(IconSize, IconSize)
spin.Start()
spin.Show()
rowIconCSS(spin)
r.Add(spin)
r.SetSensitive(false) // no activate
}
// SetFailed sets the initial connect status to failed. Do note that session can
// have 2 types of loading: loading the session and loading the server list.
// This one sets the former.
func (r *Row) SetFailed(err error) {
// Make sure that Session is still nil.
r.Session = nil
// Re-enable the row.
r.SetSensitive(true)
// Remove everything off the row.
primitives.RemoveChildren(r)
// Mark the row as failed.
primitives.AddClass(r, "failed")
if r.icon == nil {
r.icon = newIcon(r.avatar)
r.iconBox.Add(r.icon)
}
// Add the icon.
r.Add(r.iconBox)
// Set the button to a retry icon.
r.icon.SetPlaceholderIcon("view-refresh-symbolic", IconSize)
}
func (r *Row) RestoreSession(res cchat.SessionRestorer, k keyring.Session) {
go func() {
s, err := res.RestoreSession(k.Data)
if err != nil {
err = errors.Wrapf(err, "failed to restore session %s (%s)", k.ID, k.Name)
log.Error(err)
gts.ExecAsync(func() { r.SetFailed(err) })
} else {
gts.ExecAsync(func() { r.SetSession(s) })
}
}()
}
// SetSession binds the session and marks the row as ready. It extends SetDone.
func (r *Row) SetSession(ses cchat.Session) {
// Set the states.
r.Session = ses
r.sessionID = ses.ID()
r.SetTooltipMarkup(markup.Render(ses.Name()))
r.avatar.SetText(ses.Name().Content)
if r.icon == nil {
r.icon = newIcon(r.avatar)
r.iconBox.Add(r.icon)
}
r.icon.SetPlaceholderIcon(IconName, IconSize)
// If the session has an icon, then use it.
if iconer := ses.AsIconer(); iconer != nil {
r.icon.AsyncSetIconer(iconer, "failed to set session icon")
}
// Update to indicate that we're done.
primitives.RemoveChildren(r)
r.SetSensitive(true)
r.Add(r.iconBox)
// Bind extra menu items before loading. These items won't be clickable
// during loading.
r.ActionsMenu.Reset()
r.ActionsMenu.AddAction("Disconnect", r.DisconnectSession)
r.ActionsMenu.AddAction("Remove", r.RemoveSession)
// Set the commander, if any. The function will return nil if the assertion
// returns nil. As such, we assert with an ignored ok bool, allowing cmd to
// be nil.
if cmder := ses.AsCommander(); cmder != nil {
r.cmder = commander.NewBuffer(ses.Name().String(), cmder)
// Show the command button if the session actually supports the
// commander.
r.ActionsMenu.AddAction("Command Prompt", r.ShowCommander)
}
// Load all top-level servers now.
r.Servers.SetList(ses)
}
func (r *Row) MessengerSelected(sr *server.ServerRow) {
r.svcctrl.MessengerSelected(r, sr)
}
// RemoveSession removes itself from the session list.
func (r *Row) RemoveSession() {
// Remove the session off the list.
r.svcctrl.RemoveSession(r)
var session = r.Session
if session == nil {
return
}
// Asynchrously disconnect.
go func() {
if err := session.Disconnect(); err != nil {
log.Error(errors.Wrap(err, "non-fatal; failed to disconnect removed session"))
}
}()
}
// ReconnectSession tries to reconnect with the keyring data. This is a slow
// method but it's also a very cold path.
func (r *Row) ReconnectSession() {
// If we haven't ever connected, then don't run. In a legitimate case, this
// shouldn't happen.
if r.sessionID == "" {
return
}
// Set the row as loading.
r.SetLoading()
// Try to restore the session.
r.svcctrl.RestoreSession(r, r.sessionID)
}
// DisconnectSession disconnects the current session. It does nothing if the row
// does not have a session active.
func (r *Row) DisconnectSession() {
// No-op if no session.
if r.Session == nil {
return
}
// Call the disconnect function from the controller first.
r.svcctrl.OnSessionDisconnect(r)
// Copy the session to avoid data race and allow us to reset.
session := r.Session
// Show visually that we're disconnected first by wiping all servers.
r.Reset()
// Disable the button because we're busy disconnecting. We'll re-enable them
// once we're done reconnecting.
r.SetSensitive(false)
// Try and disconnect asynchronously.
gts.Async(func() (func(), error) {
// Disconnect and wrap the error if any. Wrap works with a nil error.
err := errors.Wrap(session.Disconnect(), "failed to disconnect.")
return func() {
// Re-enable access to the menu.
r.SetSensitive(true)
// Set the menu to allow disconnection.
r.ActionsMenu.AddAction("Connect", r.ReconnectSession)
r.ActionsMenu.AddAction("Remove", r.RemoveSession)
}, err
})
}
// ID returns the session ID.
func (r *Row) ID() string {
return r.sessionID
}
// ShowCommander shows the commander dialog, or it does nothing if session does
// not implement commander.
func (r *Row) ShowCommander() {
if r.cmder == nil {
return
}
r.cmder.ShowDialog()
}