Skip to content

Commit

Permalink
feat(ui): add unread message highlighting to tabs
Browse files Browse the repository at this point in the history
  • Loading branch information
Silthus committed Mar 2, 2022
1 parent 066874f commit 37a8e53
Show file tree
Hide file tree
Showing 9 changed files with 189 additions and 44 deletions.
2 changes: 1 addition & 1 deletion core/src/main/java/net/silthus/schat/message/Message.java
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@

import static net.silthus.schat.pointer.Pointer.pointer;

public sealed interface Message extends Configurable<Message> permits MessageImpl {
public sealed interface Message extends Configurable<Message>, Comparable<Message> permits MessageImpl {

Pointer<UUID> ID = pointer(UUID.class, "id");
Pointer<Instant> TIMESTAMP = pointer(Instant.class, "timestamp");
Expand Down
5 changes: 5 additions & 0 deletions core/src/main/java/net/silthus/schat/message/MessageImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<Channel> filterAndMapChannels(Stream<MessageTarget> stream) {
return stream
Expand Down
30 changes: 30 additions & 0 deletions docs/configuration/_tab_format_config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
101 changes: 68 additions & 33 deletions ui/src/main/java/net/silthus/schat/ui/views/tabbed/ChannelTab.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -62,6 +66,8 @@ public class ChannelTab implements Tab {
private final Channel channel;
private final TabFormatConfig config;
private final Settings settings;
private final SortedMap<Message, Component> messages;
private int unreadCount = 0;

protected ChannelTab(@NonNull TabbedChannelsView view,
@NonNull Channel channel,
Expand All @@ -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);
}
Expand All @@ -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<Message> 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<Message> messages() {
return view().chatter().messages().stream()
.filter(this::isMessageDisplayed)
.sorted(MESSAGE_COMPARATOR)
.collect(lastN(100));
protected void activate() {
resetUnreadCounter();
}

@Override
Expand All @@ -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())
Expand All @@ -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);
}
}
4 changes: 4 additions & 0 deletions ui/src/main/java/net/silthus/schat/ui/views/tabbed/Tab.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -47,5 +48,8 @@ public interface Tab extends Configurable<Tab> {

int length();

default void onReceivedMessage(Message message) {
}

boolean isActive();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -98,13 +99,15 @@ protected void onLeftChannel(ChatterLeftChannelEvent event) {
protected void onChangeChannel(ChatterChangedActiveChannelEvent event) {
if (isNotApplicable(event.chatter()))
return;
tab(event.newChannel()).ifPresent(ChannelTab::activate);
update();
}

@Subscribe
protected void onMessage(ChatterReceivedMessageEvent event) {
if (isNotApplicable(event.chatter()))
return;
tabs().values().forEach(tab -> tab.onReceivedMessage(event.message()));
update();
}

Expand Down Expand Up @@ -163,7 +166,9 @@ private void removeTab(Channel channel) {
tabs.remove(channel);
}

private Optional<ChannelTab> tab(Channel channel) {
protected Optional<ChannelTab> tab(@Nullable Channel channel) {
if (channel == null)
return Optional.empty();
return Optional.ofNullable((ChannelTab) tabs.get(channel));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,41 +23,96 @@
*/
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
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();
}
}
}

Expand Down
Loading

0 comments on commit 37a8e53

Please sign in to comment.