Path: blob/main/internal/sidebar/channels/channel_item.go
453 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:not(.channel-item-category) {87padding: 0.35em 0;88opacity: 0.50;89font-weight: 600;90}91.channel-item > :first-child {92min-width: 2.5em;93margin: 0;94}95.channel-item expander + * {96/* Weird workaround because GTK is adding extra padding here for some97* reason. */98margin-left: -0.35em;99}100.channel-item-muted .channel-item {101opacity: 0.25;102}103.channel-unread-indicator {104font-size: 0.75em;105font-weight: 700;106}107.channel-item-unread .channel-unread-indicator,108.channel-item-mentioned .channel-unread-indicator {109font-size: 0.7em;110font-weight: 900;111font-family: monospace;112113min-width: 1em;114min-height: 1em;115line-height: 1em;116117padding: 0;118margin: 0 1em;119120background: alpha(@theme_fg_color, .75);121border-radius: 99px;122}123.channel-item-mentioned .channel-unread-indicator {124font-size: 0.8em;125outline-color: @mentioned;126background: @mentioned;127color: @theme_bg_color;128}129.channel-item-unread .channel-item {130opacity: 1;131}132`)133134type channelItem struct {135state *gtkcord.State136item *gtk.ListItem137row *gtk.TreeListRow138reveal *app.TypedState[bool]139140child struct {141*gtk.Box142content gtk.Widgetter143indicator *gtk.Label144}145146chID discord.ChannelID147}148149func bindChannelItem(state channelItemState, item *gtk.ListItem, row *gtk.TreeListRow) func() {150i := &channelItem{151state: state.state,152item: item,153row: row,154reveal: state.reveal,155chID: channelIDFromListItem(item),156}157158i.child.indicator = gtk.NewLabel("")159i.child.indicator.AddCSSClass("channel-unread-indicator")160i.child.indicator.SetHExpand(true)161i.child.indicator.SetHAlign(gtk.AlignEnd)162i.child.indicator.SetVAlign(gtk.AlignCenter)163164i.child.Box = gtk.NewBox(gtk.OrientationHorizontal, 0)165i.child.Box.Append(i.child.indicator)166167hoverpopover.NewMarkupHoverPopover(i.child.Box, func(w *hoverpopover.MarkupHoverPopoverWidget) bool {168summary := i.state.SummaryState.LastSummary(i.chID)169if summary == nil {170return false171}172173window := app.GTKWindowFromContext(i.state.Context())174if window.Width() > 600 {175w.SetPosition(gtk.PosRight)176} else {177w.SetPosition(gtk.PosBottom)178}179180w.Label.SetEllipsize(pango.EllipsizeEnd)181w.Label.SetSingleLineMode(true)182w.Label.SetMaxWidthChars(50)183w.Label.SetMarkup(fmt.Sprintf(184"<b>%s</b>%s",185locale.Get("Chatting about: "),186summary.Topic,187))188189return true190})191192i.item.SetChild(i.child.Box)193194var unbind signaling.DisconnectStack195unbind.Push(196i.state.AddHandler(func(ev *read.UpdateEvent) {197if ev.ChannelID == i.chID {198i.Invalidate()199}200}),201i.state.AddHandler(func(ev *gateway.ChannelUpdateEvent) {202if ev.ID == i.chID {203i.Invalidate()204}205}),206)207208ch, _ := i.state.Offline().Channel(i.chID)209if ch != nil {210switch ch.Type {211case discord.GuildPublicThread, discord.GuildPrivateThread, discord.GuildAnnouncementThread:212unbind.Push(i.state.AddHandler(func(ev *gateway.ThreadUpdateEvent) {213if ev.ID == i.chID {214i.Invalidate()215}216}))217}218219guildID := ch.GuildID220switch ch.Type {221case discord.GuildVoice, discord.GuildStageVoice:222unbind.Push(i.state.AddHandler(func(ev *gateway.VoiceStateUpdateEvent) {223// The channel ID becomes null when the user leaves the channel,224// so we'll just update when any guild state changes.225if ev.GuildID == guildID {226i.Invalidate()227}228}))229}230}231232i.Invalidate()233return unbind.Disconnect234}235236var readCSSClasses = map[ningen.UnreadIndication]string{237ningen.ChannelUnread: "channel-item-unread",238ningen.ChannelMentioned: "channel-item-mentioned",239}240241const channelMutedClass = "channel-item-muted"242243// Invalidate updates the channel item's contents.244func (i *channelItem) Invalidate() {245if i.child.content != nil {246i.child.Box.Remove(i.child.content)247}248249i.item.SetSelectable(true)250251ch, _ := i.state.Offline().Channel(i.chID)252if ch == nil {253i.child.content = newUnknownChannelItem(i.chID.String())254i.item.SetSelectable(false)255} else {256switch ch.Type {257case258discord.GuildText, discord.GuildAnnouncement,259discord.GuildPublicThread, discord.GuildPrivateThread, discord.GuildAnnouncementThread:260261i.child.content = newChannelItemText(ch)262263case discord.GuildCategory, discord.GuildForum:264switch ch.Type {265case discord.GuildCategory:266i.child.content = newChannelItemCategory(ch, i.row, i.reveal)267i.item.SetSelectable(false)268case discord.GuildForum:269i.child.content = newChannelItemForum(ch, i.row)270}271272case discord.GuildVoice, discord.GuildStageVoice:273i.child.content = newChannelItemVoice(i.state, ch)274275default:276panic("unreachable")277}278}279280i.child.Box.SetCSSClasses(nil)281i.child.Box.Prepend(i.child.content)282283// Steal CSS classes from the child.284for _, class := range gtk.BaseWidget(i.child.content).CSSClasses() {285i.child.Box.AddCSSClass(class + "-outer")286}287288unreadOpts := ningen.UnreadOpts{289// We can do this within the channel list itself because it's easy to290// expand categories and see the unread channels within them.291IncludeMutedCategories: true,292}293294unread := i.state.ChannelIsUnread(i.chID, unreadOpts)295if unread != ningen.ChannelRead {296i.child.Box.AddCSSClass(readCSSClasses[unread])297}298299i.updateIndicator(unread)300301if i.state.ChannelIsMuted(i.chID, unreadOpts) {302i.child.Box.AddCSSClass(channelMutedClass)303} else {304i.child.Box.RemoveCSSClass(channelMutedClass)305}306}307308func (i *channelItem) updateIndicator(unread ningen.UnreadIndication) {309if unread == ningen.ChannelMentioned {310i.child.indicator.SetText("!")311} else {312i.child.indicator.SetText("")313}314}315316var _ = cssutil.WriteCSS(`317.channel-item-unknown {318opacity: 0.35;319font-style: italic;320}321`)322323func newUnknownChannelItem(name string) gtk.Widgetter {324icon := NewChannelIcon(nil)325326label := gtk.NewLabel(name)327label.SetEllipsize(pango.EllipsizeEnd)328label.SetXAlign(0)329330box := gtk.NewBox(gtk.OrientationHorizontal, 0)331box.AddCSSClass("channel-item")332box.AddCSSClass("channel-item-unknown")333box.Append(icon)334box.Append(label)335336return box337}338339var _ = cssutil.WriteCSS(`340.channel-item-thread {341padding: 0.25em 0;342opacity: 0.5;343}344.channel-item-unread .channel-item-thread,345.channel-item-mention .channel-item-thread {346opacity: 1;347}348`)349350func newChannelItemText(ch *discord.Channel) gtk.Widgetter {351icon := NewChannelIcon(ch)352353label := gtk.NewLabel(ch.Name)354label.SetEllipsize(pango.EllipsizeEnd)355label.SetXAlign(0)356bindLabelTooltip(label, false)357358box := gtk.NewBox(gtk.OrientationHorizontal, 0)359box.AddCSSClass("channel-item")360box.Append(icon)361box.Append(label)362363switch ch.Type {364case discord.GuildText:365box.AddCSSClass("channel-item-text")366case discord.GuildAnnouncement:367box.AddCSSClass("channel-item-announcement")368case discord.GuildPublicThread, discord.GuildPrivateThread, discord.GuildAnnouncementThread:369box.AddCSSClass("channel-item-thread")370}371372return box373}374375var _ = cssutil.WriteCSS(`376.channel-item-forum {377padding: 0.35em 0;378}379.channel-item-forum label {380padding: 0;381}382`)383384func newChannelItemForum(ch *discord.Channel, row *gtk.TreeListRow) gtk.Widgetter {385label := gtk.NewLabel(ch.Name)386label.SetEllipsize(pango.EllipsizeEnd)387label.SetXAlign(0)388bindLabelTooltip(label, false)389390expander := gtk.NewTreeExpander()391expander.AddCSSClass("channel-item")392expander.AddCSSClass("channel-item-forum")393expander.SetHExpand(true)394expander.SetListRow(row)395expander.SetChild(label)396397// GTK 4.10 or later only.398expander.SetObjectProperty("indent-for-depth", false)399400return expander401}402403var _ = cssutil.WriteCSS(`404.channels-viewtree row:not(:first-child) .channel-item-category-outer {405margin-top: 0.75em;406}407.channels-viewtree row:hover .channel-item-category-outer {408background: none;409}410.channel-item-category {411padding: 0.4em 0;412}413.channel-item-category label {414margin-bottom: -0.2em;415padding: 0;416font-size: 0.85em;417font-weight: 700;418text-transform: uppercase;419}420`)421422func newChannelItemCategory(ch *discord.Channel, row *gtk.TreeListRow, reveal *app.TypedState[bool]) gtk.Widgetter {423label := gtk.NewLabel(ch.Name)424label.SetEllipsize(pango.EllipsizeEnd)425label.SetXAlign(0)426bindLabelTooltip(label, false)427428expander := gtk.NewTreeExpander()429expander.AddCSSClass("channel-item")430expander.AddCSSClass("channel-item-category")431expander.SetHExpand(true)432expander.SetListRow(row)433expander.SetChild(label)434435ref := glib.NewWeakRef[*gtk.TreeListRow](row)436chID := ch.ID437438// Add this notifier after a small delay so GTK can initialize the row.439// Otherwise, it will falsely emit the signal.440glib.TimeoutSecondsAdd(1, func() {441row := ref.Get()442if row == nil {443return444}445446row.NotifyProperty("expanded", func() {447row := ref.Get()448if row == nil {449return450}451452// Only retain collapsed states. Expanded states are assumed to be453// the default.454if !row.Expanded() {455reveal.Set(chID.String(), true)456} else {457reveal.Delete(chID.String())458}459})460})461462reveal.Get(ch.ID.String(), func(collapsed bool) {463if collapsed {464// GTK will actually explode if we set the expanded property without465// waiting for it to load for some reason?466glib.IdleAdd(func() { row.SetExpanded(false) })467}468})469470return expander471}472473var _ = cssutil.WriteCSS(`474.channel-item-voice .mauthor-chip {475margin: 0.15em 0;476margin-left: 2.5em;477margin-right: 1em;478}479.channel-item-voice .mauthor-chip:nth-child(2) {480margin-top: 0;481}482.channel-item-voice .mauthor-chip:last-child {483margin-bottom: 0.3em;484}485.channel-item-voice-counter {486margin-left: 0.5em;487margin-right: 0.5em;488font-size: 0.8em;489opacity: 0.75;490}491`)492493func newChannelItemVoice(state *gtkcord.State, ch *discord.Channel) gtk.Widgetter {494icon := NewChannelIcon(ch)495496label := gtk.NewLabel(ch.Name)497label.SetEllipsize(pango.EllipsizeEnd)498label.SetXAlign(0)499label.SetTooltipText(ch.Name)500501top := gtk.NewBox(gtk.OrientationHorizontal, 0)502top.AddCSSClass("channel-item")503top.Append(icon)504top.Append(label)505506var voiceParticipants int507voiceStates, _ := state.VoiceStates(ch.GuildID)508for _, voiceState := range voiceStates {509if voiceState.ChannelID == ch.ID {510voiceParticipants++511}512}513514if voiceParticipants > 0 {515counter := gtk.NewLabel(fmt.Sprintf("%d", voiceParticipants))516counter.AddCSSClass("channel-item-voice-counter")517counter.SetVExpand(true)518counter.SetXAlign(0)519counter.SetYAlign(1)520top.Append(counter)521}522523return top524525// TODO: fix read indicator alignment. This probably should be in a separate526// ListModel instead.527528// box := gtk.NewBox(gtk.OrientationVertical, 0)529// box.AddCSSClass("channel-item-voice")530// box.Append(top)531532// voiceStates, _ := state.VoiceStates(ch.GuildID)533// for _, voiceState := range voiceStates {534// if voiceState.ChannelID == ch.ID {535// box.Append(newVoiceParticipant(state, voiceState))536// }537// }538539// return box540}541542func newVoiceParticipant(state *gtkcord.State, voiceState discord.VoiceState) gtk.Widgetter {543chip := author.NewChip(context.Background(), imgutil.HTTPProvider)544chip.Unpad()545546member := voiceState.Member547if member == nil {548member, _ = state.Member(voiceState.GuildID, voiceState.UserID)549}550551if member != nil {552chip.SetName(member.User.DisplayOrUsername())553chip.SetAvatar(gtkcord.InjectAvatarSize(member.AvatarURL(voiceState.GuildID)))554if color, ok := state.MemberColor(voiceState.GuildID, voiceState.UserID); ok {555chip.SetColor(color.String())556}557} else {558chip.SetName(voiceState.UserID.String())559}560561return chip562}563564func bindLabelTooltip(label *gtk.Label, markup bool) {565ref := glib.NewWeakRef(label)566label.NotifyProperty("label", func() {567label := ref.Get()568inner := label.Label()569if markup {570label.SetTooltipMarkup(inner)571} else {572label.SetTooltipText(inner)573}574})575}576577578