Path: blob/main/internal/sidebar/channels/channel_item.go
366 views
package channels12import (3"context"4"fmt"56"github.com/diamondburned/arikawa/v3/discord"7"github.com/diamondburned/arikawa/v3/gateway"8"github.com/diamondburned/chatkit/components/author"9"github.com/diamondburned/gotk4/pkg/core/glib"10"github.com/diamondburned/gotk4/pkg/gtk/v4"11"github.com/diamondburned/gotk4/pkg/pango"12"github.com/diamondburned/gotkit/app"13"github.com/diamondburned/gotkit/app/locale"14"github.com/diamondburned/gotkit/gtkutil/cssutil"15"github.com/diamondburned/gotkit/gtkutil/imgutil"16"github.com/diamondburned/ningen/v3"17"github.com/diamondburned/ningen/v3/states/read"18"libdb.so/dissent/internal/components/hoverpopover"19"libdb.so/dissent/internal/gtkcord"20"libdb.so/dissent/internal/signaling"21)2223var revealStateKey = app.NewStateKey[bool]("collapsed-channels-state")2425type channelItemState struct {26state *gtkcord.State27reveal *app.TypedState[bool]28}2930func newChannelItemFactory(ctx context.Context, model *gtk.TreeListModel) *gtk.ListItemFactory {31factory := gtk.NewSignalListItemFactory()32state := channelItemState{33state: gtkcord.FromContext(ctx),34reveal: revealStateKey.Acquire(ctx),35}3637unbindFns := make(map[uintptr]func())3839factory.ConnectBind(func(obj *glib.Object) {40item := obj.Cast().(*gtk.ListItem)41row := model.Row(item.Position())42unbind := bindChannelItem(state, item, row)43unbindFns[item.Native()] = unbind44})4546factory.ConnectUnbind(func(obj *glib.Object) {47item := obj.Cast().(*gtk.ListItem)48unbind := unbindFns[item.Native()]49unbind()50delete(unbindFns, item.Native())51item.SetChild(nil)52})5354return &factory.ListItemFactory55}5657func channelIDFromListItem(item *gtk.ListItem) discord.ChannelID {58return channelIDFromItem(item.Item())59}6061func channelIDFromItem(item *glib.Object) discord.ChannelID {62str := item.Cast().(*gtk.StringObject)6364id, err := discord.ParseSnowflake(str.String())65if err != nil {66panic(fmt.Sprintf("channelIDFromListItem: failed to parse ID: %v", err))67}6869return discord.ChannelID(id)70}7172var _ = cssutil.WriteCSS(`73.channels-viewtree row:hover,74.channels-viewtree row:selected {75background: none;76}77.channels-viewtree row:hover .channel-item-outer {78background: alpha(@theme_fg_color, 0.075);79}80.channels-viewtree row:selected .channel-item-outer {81background: alpha(@theme_fg_color, 0.125);82}83.channels-viewtree row:selected:hover .channel-item-outer {84background: alpha(@theme_fg_color, 0.175);85}86.channel-item {87padding: 0.35em 0;88}89.channel-item > :first-child {90min-width: 2.5em;91margin: 0;92}93.channel-item expander + * {94/* Weird workaround because GTK is adding extra padding here for some95* reason. */96margin-left: -0.35em;97}98.channel-item-muted {99opacity: 0.35;100}101.channel-unread-indicator {102font-size: 0.75em;103font-weight: 700;104}105.channel-item-unread .channel-unread-indicator,106.channel-item-mentioned .channel-unread-indicator {107font-size: 0.7em;108font-weight: 900;109font-family: monospace;110111min-width: 1em;112min-height: 1em;113line-height: 1em;114115padding: 0;116margin: 0 1em;117118outline: 1.5px solid @theme_fg_color;119border-radius: 99px;120}121.channel-item-mentioned .channel-unread-indicator {122font-size: 0.8em;123outline-color: @mentioned;124background: @mentioned;125color: @theme_bg_color;126}127`)128129type channelItem struct {130state *gtkcord.State131item *gtk.ListItem132row *gtk.TreeListRow133reveal *app.TypedState[bool]134135child struct {136*gtk.Box137content gtk.Widgetter138indicator *gtk.Label139}140141chID discord.ChannelID142}143144func bindChannelItem(state channelItemState, item *gtk.ListItem, row *gtk.TreeListRow) func() {145i := &channelItem{146state: state.state,147item: item,148row: row,149reveal: state.reveal,150chID: channelIDFromListItem(item),151}152153i.child.indicator = gtk.NewLabel("")154i.child.indicator.AddCSSClass("channel-unread-indicator")155i.child.indicator.SetHExpand(true)156i.child.indicator.SetHAlign(gtk.AlignEnd)157i.child.indicator.SetVAlign(gtk.AlignCenter)158159i.child.Box = gtk.NewBox(gtk.OrientationHorizontal, 0)160i.child.Box.Append(i.child.indicator)161162hoverpopover.NewMarkupHoverPopover(i.child.Box, func(w *hoverpopover.MarkupHoverPopoverWidget) bool {163summary := i.state.SummaryState.LastSummary(i.chID)164if summary == nil {165return false166}167168window := app.GTKWindowFromContext(i.state.Context())169if window.Width() > 600 {170w.SetPosition(gtk.PosRight)171} else {172w.SetPosition(gtk.PosBottom)173}174175w.Label.SetEllipsize(pango.EllipsizeEnd)176w.Label.SetSingleLineMode(true)177w.Label.SetMaxWidthChars(50)178w.Label.SetMarkup(fmt.Sprintf(179"<b>%s</b>%s",180locale.Get("Chatting about: "),181summary.Topic,182))183184return true185})186187i.item.SetChild(i.child.Box)188189var unbind signaling.DisconnectStack190unbind.Push(191i.state.AddHandler(func(ev *read.UpdateEvent) {192if ev.ChannelID == i.chID {193i.Invalidate()194}195}),196i.state.AddHandler(func(ev *gateway.ChannelUpdateEvent) {197if ev.ID == i.chID {198i.Invalidate()199}200}),201)202203ch, _ := i.state.Offline().Channel(i.chID)204if ch != nil {205switch ch.Type {206case discord.GuildPublicThread, discord.GuildPrivateThread, discord.GuildAnnouncementThread:207unbind.Push(i.state.AddHandler(func(ev *gateway.ThreadUpdateEvent) {208if ev.ID == i.chID {209i.Invalidate()210}211}))212}213214guildID := ch.GuildID215switch ch.Type {216case discord.GuildVoice, discord.GuildStageVoice:217unbind.Push(i.state.AddHandler(func(ev *gateway.VoiceStateUpdateEvent) {218// The channel ID becomes null when the user leaves the channel,219// so we'll just update when any guild state changes.220if ev.GuildID == guildID {221i.Invalidate()222}223}))224}225}226227i.Invalidate()228return unbind.Disconnect229}230231var readCSSClasses = map[ningen.UnreadIndication]string{232ningen.ChannelUnread: "channel-item-unread",233ningen.ChannelMentioned: "channel-item-mentioned",234}235236const channelMutedClass = "channel-item-muted"237238// Invalidate updates the channel item's contents.239func (i *channelItem) Invalidate() {240if i.child.content != nil {241i.child.Box.Remove(i.child.content)242}243244i.item.SetSelectable(true)245246ch, _ := i.state.Offline().Channel(i.chID)247if ch == nil {248i.child.content = newUnknownChannelItem(i.chID.String())249i.item.SetSelectable(false)250} else {251switch ch.Type {252case253discord.GuildText, discord.GuildAnnouncement,254discord.GuildPublicThread, discord.GuildPrivateThread, discord.GuildAnnouncementThread:255256i.child.content = newChannelItemText(ch)257258case discord.GuildCategory, discord.GuildForum:259switch ch.Type {260case discord.GuildCategory:261i.child.content = newChannelItemCategory(ch, i.row, i.reveal)262i.item.SetSelectable(false)263case discord.GuildForum:264i.child.content = newChannelItemForum(ch, i.row)265}266267case discord.GuildVoice, discord.GuildStageVoice:268i.child.content = newChannelItemVoice(i.state, ch)269270default:271panic("unreachable")272}273}274275i.child.Box.SetCSSClasses(nil)276i.child.Box.Prepend(i.child.content)277278// Steal CSS classes from the child.279for _, class := range gtk.BaseWidget(i.child.content).CSSClasses() {280i.child.Box.AddCSSClass(class + "-outer")281}282283unreadOpts := ningen.UnreadOpts{284// We can do this within the channel list itself because it's easy to285// expand categories and see the unread channels within them.286IncludeMutedCategories: true,287}288289unread := i.state.ChannelIsUnread(i.chID, unreadOpts)290if unread != ningen.ChannelRead {291i.child.Box.AddCSSClass(readCSSClasses[unread])292}293294i.updateIndicator(unread)295296if i.state.ChannelIsMuted(i.chID, unreadOpts) {297i.child.Box.AddCSSClass(channelMutedClass)298} else {299i.child.Box.RemoveCSSClass(channelMutedClass)300}301}302303func (i *channelItem) updateIndicator(unread ningen.UnreadIndication) {304if unread == ningen.ChannelMentioned {305i.child.indicator.SetText("!")306} else {307i.child.indicator.SetText("")308}309}310311var _ = cssutil.WriteCSS(`312.channel-item-unknown {313opacity: 0.35;314font-style: italic;315}316`)317318func newUnknownChannelItem(name string) gtk.Widgetter {319icon := NewChannelIcon(nil)320321label := gtk.NewLabel(name)322label.SetEllipsize(pango.EllipsizeEnd)323label.SetXAlign(0)324325box := gtk.NewBox(gtk.OrientationHorizontal, 0)326box.AddCSSClass("channel-item")327box.AddCSSClass("channel-item-unknown")328box.Append(icon)329box.Append(label)330331return box332}333334var _ = cssutil.WriteCSS(`335.channel-item-thread {336padding: 0.25em 0;337opacity: 0.5;338}339.channel-item-unread .channel-item-thread,340.channel-item-mention .channel-item-thread {341opacity: 1;342}343`)344345func newChannelItemText(ch *discord.Channel) gtk.Widgetter {346icon := NewChannelIcon(ch)347348label := gtk.NewLabel(ch.Name)349label.SetEllipsize(pango.EllipsizeEnd)350label.SetXAlign(0)351bindLabelTooltip(label, false)352353box := gtk.NewBox(gtk.OrientationHorizontal, 0)354box.AddCSSClass("channel-item")355box.Append(icon)356box.Append(label)357358switch ch.Type {359case discord.GuildText:360box.AddCSSClass("channel-item-text")361case discord.GuildAnnouncement:362box.AddCSSClass("channel-item-announcement")363case discord.GuildPublicThread, discord.GuildPrivateThread, discord.GuildAnnouncementThread:364box.AddCSSClass("channel-item-thread")365}366367return box368}369370var _ = cssutil.WriteCSS(`371.channel-item-forum {372padding: 0.35em 0;373}374.channel-item-forum label {375padding: 0;376}377`)378379func newChannelItemForum(ch *discord.Channel, row *gtk.TreeListRow) gtk.Widgetter {380label := gtk.NewLabel(ch.Name)381label.SetEllipsize(pango.EllipsizeEnd)382label.SetXAlign(0)383bindLabelTooltip(label, false)384385expander := gtk.NewTreeExpander()386expander.AddCSSClass("channel-item")387expander.AddCSSClass("channel-item-forum")388expander.SetHExpand(true)389expander.SetListRow(row)390expander.SetChild(label)391392// GTK 4.10 or later only.393expander.SetObjectProperty("indent-for-depth", false)394395return expander396}397398var _ = cssutil.WriteCSS(`399.channels-viewtree row:not(:first-child) .channel-item-category-outer {400margin-top: 0.75em;401}402.channels-viewtree row:hover .channel-item-category-outer {403background: none;404}405.channel-item-category {406padding: 0.4em 0;407}408.channel-item-category label {409margin-bottom: -0.2em;410padding: 0;411font-size: 0.85em;412font-weight: 700;413text-transform: uppercase;414}415`)416417func newChannelItemCategory(ch *discord.Channel, row *gtk.TreeListRow, reveal *app.TypedState[bool]) gtk.Widgetter {418label := gtk.NewLabel(ch.Name)419label.SetEllipsize(pango.EllipsizeEnd)420label.SetXAlign(0)421bindLabelTooltip(label, false)422423expander := gtk.NewTreeExpander()424expander.AddCSSClass("channel-item")425expander.AddCSSClass("channel-item-category")426expander.SetHExpand(true)427expander.SetListRow(row)428expander.SetChild(label)429430ref := glib.NewWeakRef[*gtk.TreeListRow](row)431chID := ch.ID432433// Add this notifier after a small delay so GTK can initialize the row.434// Otherwise, it will falsely emit the signal.435glib.TimeoutSecondsAdd(1, func() {436row := ref.Get()437if row == nil {438return439}440441row.NotifyProperty("expanded", func() {442row := ref.Get()443if row == nil {444return445}446447// Only retain collapsed states. Expanded states are assumed to be448// the default.449if !row.Expanded() {450reveal.Set(chID.String(), true)451} else {452reveal.Delete(chID.String())453}454})455})456457reveal.Get(ch.ID.String(), func(collapsed bool) {458if collapsed {459// GTK will actually explode if we set the expanded property without460// waiting for it to load for some reason?461glib.IdleAdd(func() { row.SetExpanded(false) })462}463})464465return expander466}467468var _ = cssutil.WriteCSS(`469.channel-item-voice .mauthor-chip {470margin: 0.15em 0;471margin-left: 2.5em;472margin-right: 1em;473}474.channel-item-voice .mauthor-chip:nth-child(2) {475margin-top: 0;476}477.channel-item-voice .mauthor-chip:last-child {478margin-bottom: 0.3em;479}480.channel-item-voice-counter {481margin-left: 0.5em;482margin-right: 0.5em;483font-size: 0.8em;484opacity: 0.75;485}486`)487488func newChannelItemVoice(state *gtkcord.State, ch *discord.Channel) gtk.Widgetter {489icon := NewChannelIcon(ch)490491label := gtk.NewLabel(ch.Name)492label.SetEllipsize(pango.EllipsizeEnd)493label.SetXAlign(0)494label.SetTooltipText(ch.Name)495496top := gtk.NewBox(gtk.OrientationHorizontal, 0)497top.AddCSSClass("channel-item")498top.Append(icon)499top.Append(label)500501var voiceParticipants int502voiceStates, _ := state.VoiceStates(ch.GuildID)503for _, voiceState := range voiceStates {504if voiceState.ChannelID == ch.ID {505voiceParticipants++506}507}508509if voiceParticipants > 0 {510counter := gtk.NewLabel(fmt.Sprintf("%d", voiceParticipants))511counter.AddCSSClass("channel-item-voice-counter")512counter.SetVExpand(true)513counter.SetXAlign(0)514counter.SetYAlign(1)515top.Append(counter)516}517518return top519520// TODO: fix read indicator alignment. This probably should be in a separate521// ListModel instead.522523// box := gtk.NewBox(gtk.OrientationVertical, 0)524// box.AddCSSClass("channel-item-voice")525// box.Append(top)526527// voiceStates, _ := state.VoiceStates(ch.GuildID)528// for _, voiceState := range voiceStates {529// if voiceState.ChannelID == ch.ID {530// box.Append(newVoiceParticipant(state, voiceState))531// }532// }533534// return box535}536537func newVoiceParticipant(state *gtkcord.State, voiceState discord.VoiceState) gtk.Widgetter {538chip := author.NewChip(context.Background(), imgutil.HTTPProvider)539chip.Unpad()540541member := voiceState.Member542if member == nil {543member, _ = state.Member(voiceState.GuildID, voiceState.UserID)544}545546if member != nil {547chip.SetName(member.User.DisplayOrUsername())548chip.SetAvatar(gtkcord.InjectAvatarSize(member.AvatarURL(voiceState.GuildID)))549if color, ok := state.MemberColor(voiceState.GuildID, voiceState.UserID); ok {550chip.SetColor(color.String())551}552} else {553chip.SetName(voiceState.UserID.String())554}555556return chip557}558559func bindLabelTooltip(label *gtk.Label, markup bool) {560ref := glib.NewWeakRef(label)561label.NotifyProperty("label", func() {562label := ref.Get()563inner := label.Label()564if markup {565label.SetTooltipMarkup(inner)566} else {567label.SetTooltipText(inner)568}569})570}571572573