diff --git a/core/src/main/java/net/silthus/schat/message/Message.java b/core/src/main/java/net/silthus/schat/message/Message.java index 9721022cd..a6654825b 100644 --- a/core/src/main/java/net/silthus/schat/message/Message.java +++ b/core/src/main/java/net/silthus/schat/message/Message.java @@ -40,7 +40,7 @@ import static net.silthus.schat.pointer.Pointer.pointer; -public sealed interface Message extends Configurable permits MessageImpl { +public sealed interface Message extends Configurable, Comparable permits MessageImpl { Pointer ID = pointer(UUID.class, "id"); Pointer TIMESTAMP = pointer(Instant.class, "timestamp"); diff --git a/core/src/main/java/net/silthus/schat/message/MessageImpl.java b/core/src/main/java/net/silthus/schat/message/MessageImpl.java index 65fb3baed..58a586c22 100644 --- a/core/src/main/java/net/silthus/schat/message/MessageImpl.java +++ b/core/src/main/java/net/silthus/schat/message/MessageImpl.java @@ -95,6 +95,11 @@ private MessageImpl(Draft draft) { return new Draft(this); } + @Override + public int compareTo(@NotNull Message o) { + return timestamp().compareTo(o.timestamp()); + } + @NotNull private static Stream filterAndMapChannels(Stream stream) { return stream diff --git a/docs/configuration/_tab_format_config.md b/docs/configuration/_tab_format_config.md index ca71c1b88..58ffe0dc0 100644 --- a/docs/configuration/_tab_format_config.md +++ b/docs/configuration/_tab_format_config.md @@ -38,6 +38,36 @@ Controls the decoration of tab when it is **inactive**. Use `null` to apply no d inactive_decoration: null ``` +#### `highlight_unread` + +[:octicons-milestone-24: next][next] · `boolean` · :octicons-pin-24: `true` · :octicons-sync-24: + +Set to `false` to disable all unread message indicators on the tab. + +```yaml +highlight_unread: false +``` + +#### `unread_color` + +[:octicons-milestone-24: next][next] · [`color`][color] · :octicons-pin-24: `green` · :octicons-sync-24: + +Controls the color of the tab when it contains **unread messages**. + +```yaml +unread_color: "gray" +``` + +#### `unread_decoration` + +[:octicons-milestone-24: next][next] · [`decoration`][decoration] · :octicons-pin-24: `underlined` · :octicons-sync-24: + +Controls the decoration of tab when it contains **unread messages**. Use `null` to apply no decoration. + +```yaml +unread_decoration: null +``` + #### `message_format` [:octicons-milestone-24: next][next] · [`minimessage`][minimessage] · :octicons-sync-24: diff --git a/ui/src/main/java/net/silthus/schat/ui/views/tabbed/ChannelTab.java b/ui/src/main/java/net/silthus/schat/ui/views/tabbed/ChannelTab.java index 406f7fc27..d15664050 100644 --- a/ui/src/main/java/net/silthus/schat/ui/views/tabbed/ChannelTab.java +++ b/ui/src/main/java/net/silthus/schat/ui/views/tabbed/ChannelTab.java @@ -23,20 +23,25 @@ */ package net.silthus.schat.ui.views.tabbed; -import java.util.Collection; import java.util.Comparator; +import java.util.SortedMap; +import java.util.TreeMap; +import java.util.stream.Stream; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NonNull; import lombok.Setter; import lombok.experimental.Accessors; import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.format.TextDecoration; import net.silthus.schat.channel.Channel; import net.silthus.schat.message.Message; import net.silthus.schat.message.MessageSource; import net.silthus.schat.pointer.Settings; -import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import static java.util.stream.Collectors.toMap; import static net.kyori.adventure.text.Component.empty; import static net.kyori.adventure.text.Component.join; import static net.kyori.adventure.text.Component.translatable; @@ -46,7 +51,6 @@ import static net.kyori.adventure.text.format.NamedTextColor.GRAY; import static net.kyori.adventure.text.format.NamedTextColor.RED; import static net.silthus.schat.channel.ChannelSettings.FORCED; -import static net.silthus.schat.util.Iterators.lastN; @SuppressWarnings("CheckStyle") @Getter @@ -62,6 +66,8 @@ public class ChannelTab implements Tab { private final Channel channel; private final TabFormatConfig config; private final Settings settings; + private final SortedMap messages; + private int unreadCount = 0; protected ChannelTab(@NonNull TabbedChannelsView view, @NonNull Channel channel, @@ -76,21 +82,22 @@ protected ChannelTab(@NonNull TabbedChannelsView view, .withStatic(VIEWER, view.chatter()) .create() .copyFrom(channel.settings()); + this.messages = new TreeMap<>(Stream.concat(channel.messages().stream(), view.chatter().messages().stream()) + .filter(this::isMessageDisplayed) + .collect(toMap(message -> message, this::renderMessage))); + if (!isActive()) + unreadCount(messages.size()); } @Override public Component renderName() { Component name; - if (isActive()) { - name = name().colorIfAbsent(config.activeColor()); - if (config.activeDecoration() != null) - name = name.decorate(config.activeDecoration()); - } else { - name = name().color(config.inactiveColor()); - if (config.inactiveDecoration() != null) - name = name.decorate(config.inactiveDecoration()); - name = joinChannel(name); - } + if (isActive()) + name = style(name(), config.activeColor(), config.activeDecoration()); + else if (config.highlightUnread() && isUnread()) + name = joinChannel(style(name(), config.unreadColor(), config.unreadDecoration())); + else + name = joinChannel(style(name(), config.inactiveColor(), config.inactiveDecoration())); return closeChannel().append(name); } @@ -101,34 +108,31 @@ protected Component name() { @Override public Component render() { - return renderMessages(messages()); + if (isActive()) + resetUnreadCounter(); + return join(newlines(), messages.values()); } - protected Component renderMessages(@NonNull Collection messages) { - return join(newlines(), messages.stream() - .map(this::renderMessage) - .toList()); + @Override + public int length() { + return messages.size(); } - protected Component renderMessage(Message message) { - if (message.source().equals(MessageSource.nil()) && message.type() == Message.Type.SYSTEM) - return message.getOrDefault(Message.FORMATTED, message.text()); - else if (message.source().equals(view().chatter())) - return message.getOrDefault(Message.FORMATTED, config.selfMessageFormat().format(view, message)); - else - return message.getOrDefault(Message.FORMATTED, config.messageFormat().format(view, message)); + @Override + public void onReceivedMessage(Message message) { + if (!isMessageDisplayed(message)) + return; + this.messages.put(message, renderMessage(message)); + if (!isActive()) + unreadCount++; } - @Override - public int length() { - return messages().size(); + public boolean isUnread() { + return unreadCount() > 0; } - protected @NotNull Collection messages() { - return view().chatter().messages().stream() - .filter(this::isMessageDisplayed) - .sorted(MESSAGE_COMPARATOR) - .collect(lastN(100)); + protected void activate() { + resetUnreadCounter(); } @Override @@ -140,6 +144,15 @@ protected boolean isMessageDisplayed(Message message) { return message.type() == Message.Type.SYSTEM || message.channels().contains(channel); } + protected Component renderMessage(Message message) { + if (message.source().equals(MessageSource.nil()) && message.type() == Message.Type.SYSTEM) + return message.getOrDefault(Message.FORMATTED, message.text()); + else if (message.source().equals(view().chatter())) + return message.getOrDefault(Message.FORMATTED, config.selfMessageFormat().format(view, message)); + else + return message.getOrDefault(Message.FORMATTED, config.messageFormat().format(view, message)); + } + private Component joinChannel(Component component) { return component.hoverEvent(translatable("schat.hover.join-channel") .args(name()) @@ -160,4 +173,26 @@ private Component closeChannel() { else return empty(); } + + private Component style(@NonNull Component component, @Nullable TextColor color, @Nullable TextDecoration decoration) { + return decorate(color(component, color), decoration); + } + + private Component color(@NonNull Component component, @Nullable TextColor color) { + if (color != null) + return component.color(color); + else + return component; + } + + private Component decorate(@NonNull Component component, @Nullable TextDecoration decoration) { + if (decoration != null) + return component.decorate(decoration); + else + return component; + } + + private void resetUnreadCounter() { + unreadCount(0); + } } diff --git a/ui/src/main/java/net/silthus/schat/ui/views/tabbed/Tab.java b/ui/src/main/java/net/silthus/schat/ui/views/tabbed/Tab.java index 1d2e0b2fb..827f7b4ea 100644 --- a/ui/src/main/java/net/silthus/schat/ui/views/tabbed/Tab.java +++ b/ui/src/main/java/net/silthus/schat/ui/views/tabbed/Tab.java @@ -26,6 +26,7 @@ import net.kyori.adventure.text.Component; import net.silthus.schat.channel.Channel; import net.silthus.schat.chatter.Chatter; +import net.silthus.schat.message.Message; import net.silthus.schat.pointer.Configurable; import net.silthus.schat.pointer.Pointer; import net.silthus.schat.pointer.Setting; @@ -47,5 +48,8 @@ public interface Tab extends Configurable { int length(); + default void onReceivedMessage(Message message) { + } + boolean isActive(); } diff --git a/ui/src/main/java/net/silthus/schat/ui/views/tabbed/TabFormatConfig.java b/ui/src/main/java/net/silthus/schat/ui/views/tabbed/TabFormatConfig.java index bd9215d2b..0d7ff8442 100644 --- a/ui/src/main/java/net/silthus/schat/ui/views/tabbed/TabFormatConfig.java +++ b/ui/src/main/java/net/silthus/schat/ui/views/tabbed/TabFormatConfig.java @@ -56,6 +56,13 @@ public class TabFormatConfig { @Setting("inactive_decoration") private TextDecoration inactiveDecoration = null; + @Setting("highlight_unread") + private boolean highlightUnread = true; + @Setting("unread_color") + private TextColor unreadColor = NamedTextColor.WHITE; + @Setting("unread_decoration") + private TextDecoration unreadDecoration = null; + @Setting("message_format") private Format messageFormat = (view, msg) -> msg.get(Message.SOURCE) diff --git a/ui/src/main/java/net/silthus/schat/ui/views/tabbed/TabbedChannelsView.java b/ui/src/main/java/net/silthus/schat/ui/views/tabbed/TabbedChannelsView.java index e501aa692..d03ae857d 100644 --- a/ui/src/main/java/net/silthus/schat/ui/views/tabbed/TabbedChannelsView.java +++ b/ui/src/main/java/net/silthus/schat/ui/views/tabbed/TabbedChannelsView.java @@ -47,6 +47,7 @@ import net.silthus.schat.ui.view.View; import net.silthus.schat.ui.view.ViewConfig; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import static net.kyori.adventure.text.Component.join; import static net.kyori.adventure.text.Component.newline; @@ -98,6 +99,7 @@ protected void onLeftChannel(ChatterLeftChannelEvent event) { protected void onChangeChannel(ChatterChangedActiveChannelEvent event) { if (isNotApplicable(event.chatter())) return; + tab(event.newChannel()).ifPresent(ChannelTab::activate); update(); } @@ -105,6 +107,7 @@ protected void onChangeChannel(ChatterChangedActiveChannelEvent event) { protected void onMessage(ChatterReceivedMessageEvent event) { if (isNotApplicable(event.chatter())) return; + tabs().values().forEach(tab -> tab.onReceivedMessage(event.message())); update(); } @@ -163,7 +166,9 @@ private void removeTab(Channel channel) { tabs.remove(channel); } - private Optional tab(Channel channel) { + protected Optional tab(@Nullable Channel channel) { + if (channel == null) + return Optional.empty(); return Optional.ofNullable((ChannelTab) tabs.get(channel)); } diff --git a/ui/src/test/java/net/silthus/schat/ui/views/tabbed/ChannelTabTest.java b/ui/src/test/java/net/silthus/schat/ui/views/tabbed/ChannelTabTest.java index ef1e41a36..5f37e29c6 100644 --- a/ui/src/test/java/net/silthus/schat/ui/views/tabbed/ChannelTabTest.java +++ b/ui/src/test/java/net/silthus/schat/ui/views/tabbed/ChannelTabTest.java @@ -23,31 +23,37 @@ */ package net.silthus.schat.ui.views.tabbed; +import lombok.SneakyThrows; import net.silthus.schat.channel.Channel; import net.silthus.schat.channel.ChannelHelper; import net.silthus.schat.chatter.ChatterMock; import net.silthus.schat.eventbus.EventBusMock; import net.silthus.schat.ui.view.ViewConfig; -import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import static net.silthus.schat.message.MessageHelper.randomMessage; -import static net.silthus.schat.ui.view.ViewConfig.FORMAT_CONFIG; import static org.assertj.core.api.Assertions.assertThat; class ChannelTabTest { - private final @NotNull ChatterMock chatter = ChatterMock.randomChatter(); - private final Channel channel = ChannelHelper.randomChannel(); - private final ChannelTab tab = new ChannelTab(new TabbedChannelsView(chatter, new ViewConfig()), channel, channel.get(FORMAT_CONFIG)); private final EventBusMock eventBus = EventBusMock.eventBusMock(); + private ChatterMock chatter; + private Channel channel; + private ChannelTab tab; @BeforeEach void setUp() { - chatter.join(channel); + chatter = ChatterMock.randomChatter(); + channel = ChannelHelper.randomChannel(); + + TabbedChannelsView view = new TabbedChannelsView(chatter, new ViewConfig()); + eventBus.register(view); + + chatter.activeChannel(channel); + tab = view.tab(channel).get(); } @AfterEach @@ -55,9 +61,58 @@ void tearDown() { eventBus.close(); } + @SneakyThrows private void sendMessages(int amount) { for (int i = 0; i < amount; i++) { channel.sendMessage(randomMessage()); + Thread.sleep(1L); + } + } + + @Nested class unread_counter { + @Test + void given_no_messages_then_unread_counter_is_zero_() { + assertThat(tab.unreadCount()).isZero(); + } + + @Nested class given_tab_is_inactive { + @BeforeEach + void setUp() { + chatter.activeChannel(null); + } + + @Test + void unread_counter_increases() { + sendMessages(2); + assertThat(tab.unreadCount()).isEqualTo(2); + sendMessages(3); + assertThat(tab.unreadCount()).isEqualTo(5); + assertThat(tab.isUnread()).isTrue(); + } + + @Test + void when_channel_becomes_active_unread_counter_resets() { + sendMessages(3); + chatter.activeChannel(channel); + assertThat(tab.unreadCount()).isZero(); + chatter.activeChannel(null); + sendMessages(2); + assertThat(tab.unreadCount()).isEqualTo(2); + } + } + + @Nested class given_tab_is_active { + @BeforeEach + void setUp() { + chatter.activeChannel(channel); + } + + @Test + void then_unread_counter_does_not_increase() { + sendMessages(3); + assertThat(tab.unreadCount()).isZero(); + assertThat(tab.isUnread()).isFalse(); + } } } diff --git a/ui/src/test/java/net/silthus/schat/ui/views/tabbed/TabbedChannelsViewTests.java b/ui/src/test/java/net/silthus/schat/ui/views/tabbed/TabbedChannelsViewTests.java index b07846fe3..2da3ae7b4 100644 --- a/ui/src/test/java/net/silthus/schat/ui/views/tabbed/TabbedChannelsViewTests.java +++ b/ui/src/test/java/net/silthus/schat/ui/views/tabbed/TabbedChannelsViewTests.java @@ -40,7 +40,6 @@ import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -385,10 +384,15 @@ void then_message_two_is_not_displayed() { } @Test - @Disabled void then_channel_two_has_unread_indicator() { assertColorOnlyViewContains("two"); } + + @Test + void given_highlight_unread_is_false_then_unread_indicator_is_hidden() { + channelTwo.get(FORMAT_CONFIG).highlightUnread(false); + assertColorOnlyViewContains("two"); + } } } @@ -460,7 +464,7 @@ void renders_full_view() { No Source! Player: Hey Player2: Hello - | zzz'>">❌zzz | aaa'>">❌">aaa |"""); + | zzz'>">❌zzz | aaa'>">❌">aaa |"""); } }