diff --git a/src/main/java/com/faforever/client/chat/ChannelTabController.java b/src/main/java/com/faforever/client/chat/ChannelTabController.java index 877e5f3353..1e364c43f4 100644 --- a/src/main/java/com/faforever/client/chat/ChannelTabController.java +++ b/src/main/java/com/faforever/client/chat/ChannelTabController.java @@ -26,7 +26,7 @@ import java.util.Arrays; import java.util.List; -import static com.faforever.client.fx.PlatformService.URL_REGEX_PATTERN; +import static com.faforever.client.fx.PlatformService.STRICT_URL_REGEX_PATTERN; @Slf4j @Component @@ -116,7 +116,7 @@ private void setChannelTopic(String content) { boolean notBlank = StringUtils.isNotBlank(content); if (notBlank) { Arrays.stream(content.split("\\s")).forEach(word -> { - if (URL_REGEX_PATTERN.matcher(word).matches()) { + if (STRICT_URL_REGEX_PATTERN.matcher(word).matches()) { Hyperlink link = new Hyperlink(word); link.setOnAction(event -> platformService.showDocument(word)); children.add(link); diff --git a/src/main/java/com/faforever/client/chat/ChatChannel.java b/src/main/java/com/faforever/client/chat/ChatChannel.java index fef37b9fd2..a7f47f3641 100644 --- a/src/main/java/com/faforever/client/chat/ChatChannel.java +++ b/src/main/java/com/faforever/client/chat/ChatChannel.java @@ -1,6 +1,7 @@ package com.faforever.client.chat; import com.faforever.client.chat.ChatMessage.Type; +import com.faforever.client.chat.emoticons.Reaction; import com.faforever.client.fx.JavaFxUtil; import com.google.common.annotations.VisibleForTesting; import javafx.beans.Observable; @@ -21,7 +22,9 @@ import java.util.Comparator; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; @EqualsAndHashCode(onlyExplicitlyIncluded = true) @@ -48,6 +51,7 @@ public class ChatChannel { private final ObjectProperty topic = new SimpleObjectProperty<>(new ChannelTopic(null, "")); private final ObservableMap messagesById = FXCollections.synchronizedObservableMap( FXCollections.observableHashMap()); + private final Map reactionsById = new ConcurrentHashMap<>(); private final ObservableList rawMessages = JavaFxUtil.attachListToMap( FXCollections.synchronizedObservableList(FXCollections.observableArrayList()), messagesById); private final ObservableList messages = FXCollections.synchronizedObservableList( @@ -145,6 +149,24 @@ public Optional getUser(String username) { return Optional.ofNullable(usernameToChatUser.get(username)); } + public Optional getMessage(String id) { + return Optional.ofNullable(messagesById.get(id)); + } + + public void removeMessage(String messageId) { + messagesById.remove(messageId); + Reaction removedReaction = reactionsById.remove(messageId); + if (removedReaction == null) { + return; + } + ChatMessage reactedToMessage = messagesById.get(removedReaction.targetMessageId()); + if (reactedToMessage == null) { + return; + } + + reactedToMessage.removeReaction(removedReaction); + } + public void removePendingMessage(String messageId) { messagesById.computeIfPresent(messageId, (ignored, chatMessage) -> chatMessage.getType() == Type.PENDING ? null : chatMessage); @@ -155,6 +177,16 @@ public void addMessage(ChatMessage message) { pruneMessages(); } + public void addReaction(Reaction reaction) { + ChatMessage targetMessage = messagesById.get(reaction.targetMessageId()); + if (targetMessage == null) { + return; + } + + targetMessage.addReaction(reaction); + reactionsById.put(reaction.messageId(), reaction); + } + public ObservableList getMessages() { return messages; } diff --git a/src/main/java/com/faforever/client/chat/ChatMessage.java b/src/main/java/com/faforever/client/chat/ChatMessage.java index cdb11433c0..fce979e24d 100644 --- a/src/main/java/com/faforever/client/chat/ChatMessage.java +++ b/src/main/java/com/faforever/client/chat/ChatMessage.java @@ -1,5 +1,11 @@ package com.faforever.client.chat; +import com.faforever.client.chat.emoticons.Emoticon; +import com.faforever.client.chat.emoticons.Reaction; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableMap; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -8,15 +14,58 @@ @RequiredArgsConstructor @EqualsAndHashCode(onlyExplicitlyIncluded = true) -@Getter public class ChatMessage { @EqualsAndHashCode.Include + @Getter private final String id; + @Getter private final Instant time; + @Getter private final ChatChannelUser sender; + @Getter private final String content; + @Getter private final Type type; + @Getter + private final ChatMessage targetMessage; + + private final BooleanProperty open = new SimpleBooleanProperty(); + private final ObservableMap> reactions = FXCollections.synchronizedObservableMap( + FXCollections.observableHashMap()); + private final ObservableMap> unmodifiableReactions = FXCollections.unmodifiableObservableMap( + reactions); + + public ObservableMap> getReactions() { + return unmodifiableReactions; + } + + public void addReaction(Reaction reaction) { + reactions.computeIfAbsent(reaction.emoticon(), + ignored -> FXCollections.synchronizedObservableMap(FXCollections.observableHashMap())) + .put(reaction.reactorName(), reaction.messageId()); + } + + public void removeReaction(Reaction reaction) { + ObservableMap reactors = reactions.getOrDefault(reaction.emoticon(), + FXCollections.emptyObservableMap()); + reactors.remove(reaction.reactorName()); + if (reactors.isEmpty()) { + reactions.remove(reaction.emoticon()); + } + } + + public boolean isOpen() { + return open.get(); + } + + public BooleanProperty openProperty() { + return open; + } + + public void setOpen(boolean open) { + this.open.set(open); + } public enum Type { MESSAGE, ACTION, PENDING diff --git a/src/main/java/com/faforever/client/chat/ChatMessageController.java b/src/main/java/com/faforever/client/chat/ChatMessageController.java index a483060c34..5aabb657ac 100644 --- a/src/main/java/com/faforever/client/chat/ChatMessageController.java +++ b/src/main/java/com/faforever/client/chat/ChatMessageController.java @@ -2,16 +2,22 @@ import com.faforever.client.avatar.AvatarService; import com.faforever.client.chat.ChatMessage.Type; +import com.faforever.client.chat.emoticons.Emoticon; import com.faforever.client.chat.emoticons.EmoticonService; +import com.faforever.client.chat.emoticons.EmoticonsWindowController; import com.faforever.client.domain.AvatarBean; import com.faforever.client.domain.PlayerBean; +import com.faforever.client.fx.FxApplicationThreadExecutor; import com.faforever.client.fx.ImageViewHelper; import com.faforever.client.fx.JavaFxUtil; import com.faforever.client.fx.NodeController; import com.faforever.client.fx.PlatformService; import com.faforever.client.i18n.I18n; import com.faforever.client.player.CountryFlagService; +import com.faforever.client.theme.UiService; +import com.faforever.client.util.PopupUtil; import com.faforever.client.util.TimeService; +import javafx.beans.binding.Bindings; import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleBooleanProperty; @@ -19,17 +25,28 @@ import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; import javafx.beans.value.ObservableValue; +import javafx.collections.FXCollections; +import javafx.collections.MapChangeListener; +import javafx.collections.ObservableMap; +import javafx.event.ActionEvent; +import javafx.event.EventHandler; +import javafx.geometry.Bounds; import javafx.geometry.Insets; import javafx.scene.Node; +import javafx.scene.control.Button; import javafx.scene.control.Hyperlink; import javafx.scene.control.Label; import javafx.scene.control.Tooltip; import javafx.scene.image.ImageView; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.FlowPane; import javafx.scene.layout.HBox; import javafx.scene.layout.Pane; import javafx.scene.layout.VBox; import javafx.scene.text.Text; import javafx.scene.text.TextFlow; +import javafx.stage.Popup; +import javafx.stage.PopupWindow.AnchorLocation; import javafx.util.Duration; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -39,7 +56,11 @@ import java.util.Arrays; import java.util.Collection; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; import java.util.regex.Pattern; @Slf4j @@ -54,8 +75,10 @@ public class ChatMessageController extends NodeController { private final PlatformService platformService; private final ChatService chatService; private final EmoticonService emoticonService; + private final UiService uiService; private final ImageViewHelper imageViewHelper; private final I18n i18n; + private final FxApplicationThreadExecutor fxApplicationThreadExecutor; public VBox root; public HBox detailsContainer; @@ -65,17 +88,30 @@ public class ChatMessageController extends NodeController { public Label authorLabel; public Label timeLabel; public TextFlow message; + public HBox messageActionsContainer; + public Button reactButton; + public Button replyButton; + public FlowPane reactionsContainer; + public HBox replyContainer; + public Label replyAuthorLabel; + public Label replyPreviewLabel; private final Tooltip avatarTooltip = new Tooltip(); private final ObjectProperty chatMessage = new SimpleObjectProperty<>(); private final BooleanProperty showDetails = new SimpleBooleanProperty(); private final StringProperty inlineTextColorStyleProperty = new SimpleStringProperty(); + private final ObjectProperty> onReplyButtonClicked = new SimpleObjectProperty<>(); + private final ObjectProperty> onReplyClicked = new SimpleObjectProperty<>(); + + private final MapChangeListener> reactionChangeListener = this::onReactionChange; + + private final Map reactionNodeMap = new HashMap<>(); private Pattern mentionPattern; @Override protected void onInitialize() { - JavaFxUtil.bindManagedToVisible(detailsContainer, message); + JavaFxUtil.bindManagedToVisible(detailsContainer, replyContainer, message); mentionPattern = chatService.getMentionPattern(); @@ -104,7 +140,7 @@ protected void onInitialize() { authorLabel.setOnMouseClicked(event -> { String username = usernameProperty.getValue(); if (username != null && event.getClickCount() == 2) { - chatService.onInitiatePrivateChat(username); + chatService.joinPrivateChat(username); } }); timeLabel.textProperty() @@ -125,21 +161,107 @@ protected void onInitialize() { avatarTooltip.setShowDelay(Duration.ZERO); avatarTooltip.setShowDuration(Duration.seconds(30)); Tooltip.install(avatarImageView, avatarTooltip); + + chatMessage.when(attached).subscribe((oldValue, newValue) -> { + if (oldValue != null) { + oldValue.getReactions().removeListener(reactionChangeListener); + oldValue.openProperty().unbind(); + oldValue.setOpen(false); + } + + reactionNodeMap.clear(); + fxApplicationThreadExecutor.execute(() -> reactionsContainer.getChildren().clear()); + + if (newValue != null) { + newValue.getReactions().forEach(this::addReaction); + newValue.getReactions().addListener(reactionChangeListener); + newValue.openProperty().bind(showing); + } + }); + + reactionsContainer.visibleProperty().bind(Bindings.isNotEmpty(reactionsContainer.getChildren()).when(showing)); + replyButton.onActionProperty().bind(chatMessage.flatMap(message -> onReplyButtonClicked.map( + onReplyClicked -> (EventHandler) event -> onReplyClicked.accept(message))) + .when(showing)); + + ObservableValue targetMessage = chatMessage.map(ChatMessage::getTargetMessage); + replyContainer.onMouseClickedProperty() + .bind(targetMessage.flatMap(message -> onReplyClicked.map( + onReplyClicked -> (EventHandler) event -> onReplyClicked.accept(message))) + .when(showing)); + replyContainer.visibleProperty().bind(targetMessage.map(Objects::nonNull).orElse(false).when(showing)); + replyPreviewLabel.textProperty().bind(targetMessage.map(ChatMessage::getContent).when(showing)); + replyAuthorLabel.textProperty() + .bind(targetMessage.map(ChatMessage::getSender) + .map(ChatChannelUser::getUsername) + .map(username -> "@" + username) + .when(showing)); + } + + @Override + protected void onDetached() { + ChatMessage chatMessage = getChatMessage(); + if (chatMessage != null) { + chatMessage.openProperty().unbind(); + chatMessage.setOpen(false); + } + } + + private void onReactionChange( + MapChangeListener.Change> change) { + Emoticon reaction = change.getKey(); + if (change.wasRemoved()) { + HBox reactionRoot = reactionNodeMap.remove(reaction); + fxApplicationThreadExecutor.execute(() -> reactionsContainer.getChildren().remove(reactionRoot)); + } + + if (change.wasAdded()) { + addReaction(reaction, change.getValueAdded()); + } + } + + private void addReaction(Emoticon reaction, ObservableMap reactors) { + ReactionController reactionController = uiService.loadFxml("theme/chat/emoticons/reaction.fxml"); + reactionController.setReaction(reaction); + reactionController.setReactors(reactors); + reactionController.onReactionClickedProperty().bind(chatMessage.map(target -> emoticon -> { + if (target == null) { + return; + } + + ObservableMap emoticonReactions = target.getReactions() + .getOrDefault(emoticon, + FXCollections.emptyObservableMap()); + String currentUsername = chatService.getCurrentUsername(); + if (!emoticonReactions.containsKey(currentUsername)) { + chatService.reactToMessageInBackground(target, emoticon); + } else { + chatService.redactMessageInBackground(target.getSender().getChannel(), emoticonReactions.get(currentUsername)); + } + })); + HBox reactionRoot = reactionController.getRoot(); + reactionNodeMap.put(reaction, reactionRoot); + fxApplicationThreadExecutor.execute(() -> reactionsContainer.getChildren().add(reactionRoot)); + } + + public void onMouseExited() { + messageActionsContainer.setVisible(false); + } + + public void onMouseEntered() { + messageActionsContainer.setVisible(true); } private List convertMessageToNodes(String message) { - return Arrays.stream(message.split("\\s+")) - .map(this::convertWordToNode) - .peek(this::styleMessageNode) - .toList(); + return Arrays.stream(message.split("\\s+")).map(this::convertWordToNode).peek(this::styleMessageNode).toList(); } private Node convertWordToNode(String word) { return switch (word) { - case String url when PlatformService.URL_REGEX_PATTERN.matcher(url).matches() -> createExternalHyperlink(url); + case String url when PlatformService.LENIENT_URL_REGEX_PATTERN.matcher(url).matches() -> + createExternalHyperlink(url); case String channel when channel.startsWith("#") -> createChannelLink(channel); - case String shortcode when emoticonService.getEmoticonShortcodeDetectorPattern().matcher(shortcode).matches() -> - createEmoticon(shortcode); + case String shortcode when emoticonService.isEmoticonShortcode(shortcode) -> createEmoticon(shortcode); default -> new Text(word + " "); }; } @@ -162,7 +284,13 @@ private Hyperlink createChannelLink(String channelName) { private Hyperlink createExternalHyperlink(String url) { Hyperlink hyperlink = new Hyperlink(url + " "); - hyperlink.setOnAction(event -> platformService.showDocument(url)); + hyperlink.setOnAction(event -> { + if (!url.matches("^https?://.*")) { + platformService.showDocument("https://" + url); + } else { + platformService.showDocument(url); + } + }); return hyperlink; } @@ -175,6 +303,39 @@ private void styleMessageNode(Node node) { } } + public void onReactButtonClicked() { + EmoticonsWindowController emoticonsWindowController = uiService.loadFxml( + "theme/chat/emoticons/emoticons_window.fxml"); + Popup emoticonsPopup = PopupUtil.createPopup(AnchorLocation.WINDOW_BOTTOM_RIGHT, + emoticonsWindowController.getRoot()); + emoticonsPopup.setConsumeAutoHidingEvents(false); + emoticonsWindowController.setOnEmoticonClicked(emoticon -> { + ChatMessage target = getChatMessage(); + emoticonsPopup.hide(); + + if (target == null) { + return; + } + + ObservableMap emoticonReactions = target.getReactions() + .getOrDefault(emoticon, + FXCollections.emptyObservableMap()); + String currentUsername = chatService.getCurrentUsername(); + if (emoticonReactions.containsKey(currentUsername)) { + return; + } + + chatService.reactToMessageInBackground(target, emoticon); + }); + + Bounds bounds = reactButton.localToScreen(reactButton.getBoundsInLocal()); + if (bounds == null) { + return; + } + + emoticonsPopup.show(reactButton.getScene().getWindow(), bounds.getMaxX() - 5, bounds.getMinY() - 5); + } + @Override public VBox getRoot() { return root; @@ -203,4 +364,28 @@ public BooleanProperty showDetailsProperty() { public void setShowDetails(boolean showDetails) { this.showDetails.set(showDetails); } + + public Consumer getOnReplyButtonClicked() { + return onReplyButtonClicked.get(); + } + + public ObjectProperty> onReplyButtonClickedProperty() { + return onReplyButtonClicked; + } + + public void setOnReplyButtonClicked(Consumer onReplyButtonClicked) { + this.onReplyButtonClicked.set(onReplyButtonClicked); + } + + public Consumer getOnReplyClicked() { + return onReplyClicked.get(); + } + + public ObjectProperty> onReplyClickedProperty() { + return onReplyClicked; + } + + public void setOnReplyClicked(Consumer onReplyClicked) { + this.onReplyClicked.set(onReplyClicked); + } } diff --git a/src/main/java/com/faforever/client/chat/ChatMessageItemCell.java b/src/main/java/com/faforever/client/chat/ChatMessageItemCell.java index c6d2440300..24566b1d5c 100644 --- a/src/main/java/com/faforever/client/chat/ChatMessageItemCell.java +++ b/src/main/java/com/faforever/client/chat/ChatMessageItemCell.java @@ -3,6 +3,8 @@ import com.faforever.client.fx.FxApplicationThreadExecutor; import com.faforever.client.theme.UiService; import javafx.beans.binding.Bindings; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; import javafx.beans.value.ObservableValue; import javafx.scene.control.ListCell; import javafx.scene.control.ListView; @@ -11,6 +13,7 @@ import org.springframework.stereotype.Component; import java.util.Objects; +import java.util.function.Consumer; @Component @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) @@ -19,6 +22,9 @@ public class ChatMessageItemCell extends ListCell { private final ChatMessageController chatMessageController; private final FxApplicationThreadExecutor fxApplicationThreadExecutor; + private final ObjectProperty> onReplyButtonClicked = new SimpleObjectProperty<>(); + private final ObjectProperty> onReplyClicked = new SimpleObjectProperty<>(); + public ChatMessageItemCell(UiService uiService, FxApplicationThreadExecutor fxApplicationThreadExecutor) { this.fxApplicationThreadExecutor = fxApplicationThreadExecutor; chatMessageController = uiService.loadFxml("theme/chat/chat_message.fxml"); @@ -31,6 +37,8 @@ public ChatMessageItemCell(UiService uiService, FxApplicationThreadExecutor fxAp () -> showDetails(previousMessageProperty.getValue(), getItem()), previousMessageProperty, itemProperty()).when(emptyProperty().not())); chatMessageController.getRoot().maxWidthProperty().bind(widthProperty().subtract(20)); + chatMessageController.onReplyButtonClickedProperty().bind(onReplyButtonClicked); + chatMessageController.onReplyClickedProperty().bind(onReplyClicked); } @Override @@ -59,4 +67,28 @@ private boolean showDetails(ChatMessage previousMessage, ChatMessage currentMess return !Objects.equals(previousMessage.getSender(), currentMessage.getSender()); } + + public Consumer getOnReplyButtonClicked() { + return onReplyButtonClicked.get(); + } + + public ObjectProperty> onReplyButtonClickedProperty() { + return onReplyButtonClicked; + } + + public void setOnReplyButtonClicked(Consumer onReplyButtonClicked) { + this.onReplyButtonClicked.set(onReplyButtonClicked); + } + + public Consumer getOnReplyClicked() { + return onReplyClicked.get(); + } + + public ObjectProperty> onReplyClickedProperty() { + return onReplyClicked; + } + + public void setOnReplyClicked(Consumer onReplyClicked) { + this.onReplyClicked.set(onReplyClicked); + } } diff --git a/src/main/java/com/faforever/client/chat/ChatMessageViewController.java b/src/main/java/com/faforever/client/chat/ChatMessageViewController.java index 4b3788412f..8f46f2a990 100644 --- a/src/main/java/com/faforever/client/chat/ChatMessageViewController.java +++ b/src/main/java/com/faforever/client/chat/ChatMessageViewController.java @@ -2,6 +2,7 @@ import com.faforever.client.chat.emoticons.EmoticonsWindowController; import com.faforever.client.fx.FxApplicationThreadExecutor; +import com.faforever.client.fx.JavaFxUtil; import com.faforever.client.fx.NodeController; import com.faforever.client.i18n.I18n; import com.faforever.client.notification.NotificationService; @@ -11,6 +12,7 @@ import com.faforever.client.util.PopupUtil; import javafx.beans.Observable; import javafx.beans.binding.Bindings; +import javafx.beans.binding.BooleanExpression; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.value.ObservableValue; @@ -28,6 +30,7 @@ import javafx.scene.control.TextField; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyEvent; +import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; import javafx.scene.web.WebView; import javafx.stage.Popup; @@ -43,6 +46,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; /** @@ -56,8 +60,6 @@ @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) public class ChatMessageViewController extends NodeController { - private static final String ACTION_PREFIX = "/me "; - private final ObjectFactory chatMessageItemCellFactory; private final NotificationService notificationService; private final ChatService chatService; @@ -69,18 +71,26 @@ public class ChatMessageViewController extends NodeController { public TextField messageTextField; public ListView messagesListView; public VBox root; - public Node emoticonsWindow; public Label typingLabel; + public Node emoticonsWindow; public EmoticonsWindowController emoticonsWindowController; + public Button cancelReplyButton; + public Label replyPreviewLabel; + public Label replyAuthorLabel; + public HBox replyContainer; private final List userMessageHistory = new ArrayList<>(); private final ObjectProperty chatChannel = new SimpleObjectProperty<>(); private final ObservableValue> users = chatChannel.map(ChatChannel::getUsers); private final ListChangeListener typingUserListChangeListener = this::updateTypingUsersLabel; + private final ObjectProperty targetMessage = new SimpleObjectProperty<>(); private final ObservableList rawMessages = FXCollections.synchronizedObservableList( FXCollections.observableArrayList(chatMessage -> new Observable[]{chatMessage.getSender().categoryProperty()})); private final FilteredList filteredMessages = new FilteredList<>(rawMessages); + private final BooleanExpression atEnd = BooleanExpression.booleanExpression( + Bindings.valueAt(filteredMessages, Bindings.size(filteredMessages).subtract(1)) + .flatMap(ChatMessage::openProperty)); private Popup emoticonsPopup; @@ -89,6 +99,8 @@ public class ChatMessageViewController extends NodeController { @Override protected void onInitialize() { + JavaFxUtil.bindManagedToVisible(replyContainer); + filteredMessages.predicateProperty().bind(chatPrefs.hideFoeMessagesProperty().map(hideFoes -> { if (!hideFoes) { return message -> true; @@ -97,13 +109,16 @@ protected void onInitialize() { } })); - filteredMessages.subscribe( - () -> fxApplicationThreadExecutor.execute(() -> messagesListView.scrollTo(filteredMessages.size()))); - messagesListView.setSelectionModel(null); messagesListView.setItems(filteredMessages); messagesListView.setOrientation(Orientation.VERTICAL); - messagesListView.setCellFactory(ignored -> chatMessageItemCellFactory.getObject()); + messagesListView.setCellFactory(ignored -> { + ChatMessageItemCell chatMessageItemCell = chatMessageItemCellFactory.getObject(); + chatMessageItemCell.setOnReplyButtonClicked(targetMessage::set); + chatMessageItemCell.setOnReplyClicked( + message -> fxApplicationThreadExecutor.execute(() -> messagesListView.scrollTo(message))); + return chatMessageItemCell; + }); messageTextField.setOnKeyPressed(this::handleKeyEvent); messageTextField.textProperty().subscribe(this::updateTypingState); @@ -125,14 +140,37 @@ protected void onInitialize() { ObservableList typingUsers = newValue.getTypingUsers(); setTypingLabel(typingUsers); typingUsers.addListener(typingUserListChangeListener); + scrollToEnd(); } })); - emoticonsWindowController.setTextInputControl(messageTextField); + emoticonsWindowController.setOnEmoticonClicked(emoticon -> { + messageTextField.appendText(" " + emoticon.shortcodes().getFirst() + " "); + messageTextField.requestFocus(); + messageTextField.selectEnd(); + }); emoticonsPopup = PopupUtil.createPopup(AnchorLocation.WINDOW_BOTTOM_RIGHT, emoticonsWindow); emoticonsPopup.setConsumeAutoHidingEvents(false); createAutoCompletionHelper().bindTo(messageTextField); + + replyContainer.visibleProperty().bind(targetMessage.isNotNull().when(showing)); + replyPreviewLabel.textProperty().bind(targetMessage.map(ChatMessage::getContent).when(showing)); + replyAuthorLabel.textProperty() + .bind(targetMessage.map(ChatMessage::getSender) + .map(ChatChannelUser::getUsername) + .map(username -> i18n.get("chat.replyingTo", username)) + .when(showing)); + + filteredMessages.subscribe(() -> { + if (atEnd.get()) { + scrollToEnd(); + } + }); + } + + private void scrollToEnd() { + fxApplicationThreadExecutor.execute(() -> messagesListView.scrollTo(filteredMessages.size())); } private AutoCompletionHelper createAutoCompletionHelper() { @@ -227,7 +265,7 @@ public void onSendMessage() { updateUserMessageHistory(text); sendMessage(); - hideEmoticonsWindow(); + emoticonsPopup.hide(); } private void updateUserMessageHistory(String text) { @@ -239,16 +277,24 @@ private void updateUserMessageHistory(String text) { } } - private void hideEmoticonsWindow() { - emoticonsPopup.hide(); - } - private void sendMessage() { messageTextField.setDisable(true); final String text = messageTextField.getText(); - chatService.sendMessageInBackground(chatChannel.get(), text).whenComplete((result, throwable) -> { + CompletableFuture sendFuture; + ChatMessage targetMessage = this.targetMessage.get(); + if (targetMessage == null) { + sendFuture = chatService.sendMessageInBackground(chatChannel.get(), text); + } else { + sendFuture = chatService.sendReplyInBackground(targetMessage, text).whenCompleteAsync((aVoid, throwable) -> { + if (throwable == null) { + removeReply(); + } + }, fxApplicationThreadExecutor); + } + + sendFuture.whenComplete((result, throwable) -> { if (throwable != null) { throwable = ConcurrentUtil.unwrapIfCompletionException(throwable); log.warn("Message could not be sent: {}", text, throwable); @@ -288,11 +334,13 @@ private void setTypingLabel(List typingUsers) { } public void openEmoticonsPopupWindow() { - Bounds screenBounds = emoticonsButton.localToScreen(emoticonsButton.getBoundsInLocal()); - double anchorX = screenBounds.getMaxX() - 5; - double anchorY = screenBounds.getMinY() - 5; + Bounds bounds = emoticonsButton.localToScreen(emoticonsButton.getBoundsInLocal()); messageTextField.requestFocus(); - emoticonsPopup.show(emoticonsButton.getScene().getWindow(), anchorX, anchorY); + emoticonsPopup.show(emoticonsButton.getScene().getWindow(), bounds.getMaxX() - 5, bounds.getMinY() - 5); + } + + public void removeReply() { + targetMessage.set(null); } } diff --git a/src/main/java/com/faforever/client/chat/ChatService.java b/src/main/java/com/faforever/client/chat/ChatService.java index 2dd448df48..7c17866c03 100644 --- a/src/main/java/com/faforever/client/chat/ChatService.java +++ b/src/main/java/com/faforever/client/chat/ChatService.java @@ -1,5 +1,6 @@ package com.faforever.client.chat; +import com.faforever.client.chat.emoticons.Emoticon; import com.faforever.client.net.ConnectionState; import javafx.beans.property.ReadOnlyObjectProperty; import javafx.collections.MapChangeListener; @@ -20,6 +21,12 @@ public interface ChatService { void disconnect(); + CompletableFuture redactMessageInBackground(ChatChannel chatChannel, String messageId); + + CompletableFuture reactToMessageInBackground(ChatMessage targetMessage, Emoticon reaction); + + CompletableFuture sendReplyInBackground(ChatMessage targetMessage, String message); + CompletableFuture sendMessageInBackground(ChatChannel chatChannel, String message); boolean userExistsInAnyChannel(String username); @@ -52,7 +59,7 @@ default void leaveChannel(String channelName) { void setChannelTopic(ChatChannel chatChannel, String text); - void onInitiatePrivateChat(String username); + void joinPrivateChat(String username); Set getChannels(); diff --git a/src/main/java/com/faforever/client/chat/ChatUserItemController.java b/src/main/java/com/faforever/client/chat/ChatUserItemController.java index 6bb2fd6525..905b79e840 100644 --- a/src/main/java/com/faforever/client/chat/ChatUserItemController.java +++ b/src/main/java/com/faforever/client/chat/ChatUserItemController.java @@ -186,7 +186,7 @@ public void onContextMenuRequested(ContextMenuEvent event) { public void onItemClicked(MouseEvent mouseEvent) { ChatChannelUser chatChannelUser = chatUser.get(); if (chatChannelUser != null && mouseEvent.getButton() == MouseButton.PRIMARY && mouseEvent.getClickCount() == 2) { - chatService.onInitiatePrivateChat(chatChannelUser.getUsername()); + chatService.joinPrivateChat(chatChannelUser.getUsername()); } } diff --git a/src/main/java/com/faforever/client/chat/KittehChatService.java b/src/main/java/com/faforever/client/chat/KittehChatService.java index 79128c10ab..3ccc07ff7c 100644 --- a/src/main/java/com/faforever/client/chat/KittehChatService.java +++ b/src/main/java/com/faforever/client/chat/KittehChatService.java @@ -2,13 +2,19 @@ import com.faforever.client.audio.AudioService; import com.faforever.client.chat.ChatMessage.Type; +import com.faforever.client.chat.emoticons.Emoticon; +import com.faforever.client.chat.emoticons.EmoticonService; +import com.faforever.client.chat.emoticons.Reaction; +import com.faforever.client.chat.kitteh.ChannelRedactMessageEvent; +import com.faforever.client.chat.kitteh.PrivateRedactMessageEvent; +import com.faforever.client.chat.kitteh.RedactListener; +import com.faforever.client.chat.kitteh.RedactMessageEvent; import com.faforever.client.chat.kitteh.WhoAwayListener; import com.faforever.client.chat.kitteh.WhoAwayListener.WhoAwayMessageEvent; import com.faforever.client.config.ClientProperties; import com.faforever.client.config.ClientProperties.Irc; import com.faforever.client.domain.PlayerBean; import com.faforever.client.fx.FxApplicationThreadExecutor; -import com.faforever.client.fx.JavaFxUtil; import com.faforever.client.main.event.NavigateEvent; import com.faforever.client.main.event.NavigationItem; import com.faforever.client.navigation.NavigationHandler; @@ -16,6 +22,7 @@ import com.faforever.client.notification.NotificationService; import com.faforever.client.notification.TransientNotification; import com.faforever.client.player.PlayerService; +import com.faforever.client.player.SocialStatus; import com.faforever.client.preferences.ChatPrefs; import com.faforever.client.preferences.NotificationPrefs; import com.faforever.client.remote.FafServerAccessor; @@ -47,6 +54,7 @@ import org.kitteh.irc.client.library.defaults.listener.DefaultTagmsgListener; import org.kitteh.irc.client.library.element.Actor; import org.kitteh.irc.client.library.element.Channel; +import org.kitteh.irc.client.library.element.MessageTag; import org.kitteh.irc.client.library.element.MessageTag.Label; import org.kitteh.irc.client.library.element.MessageTag.MsgId; import org.kitteh.irc.client.library.element.MessageTag.Time; @@ -66,6 +74,12 @@ import org.kitteh.irc.client.library.event.client.ClientNegotiationCompleteEvent; import org.kitteh.irc.client.library.event.connection.ClientConnectionEndedEvent; import org.kitteh.irc.client.library.event.connection.ClientConnectionFailedEvent; +import org.kitteh.irc.client.library.event.helper.ActorEvent; +import org.kitteh.irc.client.library.event.helper.ActorMessageEvent; +import org.kitteh.irc.client.library.event.helper.MessageEvent; +import org.kitteh.irc.client.library.event.helper.PrivateEvent; +import org.kitteh.irc.client.library.event.helper.ServerMessageEvent; +import org.kitteh.irc.client.library.event.helper.TagMessageEvent; import org.kitteh.irc.client.library.event.user.PrivateMessageEvent; import org.kitteh.irc.client.library.event.user.PrivateTagMessageEvent; import org.kitteh.irc.client.library.event.user.UserAwayMessageEvent; @@ -91,7 +105,6 @@ import java.util.Collection; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Optional; import java.util.Queue; import java.util.Random; @@ -130,6 +143,7 @@ public class KittehChatService implements ChatService, InitializingBean, Disposa private final NotificationPrefs notificationPrefs; private final AudioService audioService; private final NotificationService notificationService; + private final EmoticonService emoticonService; private final NavigationHandler navigationHandler; private final TaskScheduler taskScheduler; @Qualifier("userWebClient") @@ -166,7 +180,11 @@ public void afterPropertiesSet() { } }); - fafServerAccessor.addEventListener(SocialInfo.class, this::onSocialMessage); + fafServerAccessor.getEvents(SocialInfo.class) + .doOnNext(this::onSocialMessage) + .doOnError(throwable -> log.warn("Unable to process social info", throwable)) + .retry() + .subscribe(); playerService.addPlayerOnlineListener(this::onPlayerOnline); playerService.addPlayerOfflineListener(this::onPlayerOffline); @@ -240,25 +258,65 @@ void onPlayerOffline(PlayerBean player) { } @Handler - public void onTagMessage(PrivateTagMessageEvent event) { - event.getTag("+typing", Typing.class).ifPresent(typing -> { - if (event.getActor() instanceof User user) { - String username = user.getNick(); - ChatChannel chatChannel = channels.get(username); - if (chatChannel != null) { - chatChannel.getUser(username).ifPresent(chatUser -> updateUserTypingState(typing.getState(), chatUser)); - } - } - }); + public void onRedactMessage(RedactMessageEvent event) { + if (!(event instanceof ActorEvent actorEvent) || !(actorEvent.getActor() instanceof User user)) { + return; + } + + String senderNick = user.getNick(); + ChatChannel chatChannel = switch (event) { + case PrivateRedactMessageEvent privateRedactMessageEvent -> + channels.get(getPrivateMessageTarget(privateRedactMessageEvent, senderNick)); + case ChannelRedactMessageEvent channelRedactMessageEvent -> + channels.get(channelRedactMessageEvent.getChannel().getName()); + default -> null; + }; + + if (chatChannel == null) { + return; + } + + chatChannel.removeMessage(event.getRedactedMessageId()); } @Handler - public void onTagMessage(ChannelTagMessageEvent event) { - event.getTag("+typing", Typing.class).ifPresent(typing -> { - if (event.getActor() instanceof User user) { - updateUserTypingState(typing.getState(), getOrCreateChatUser(user, event.getChannel())); + public void onTagMessage(TagMessageEvent event) { + if (!(event instanceof ActorEvent actorEvent) || !(actorEvent.getActor() instanceof User user)) { + return; + } + + String senderNick = user.getNick(); + switch (event) { + case PrivateTagMessageEvent privateTagMessageEvent -> { + String target = getPrivateMessageTarget(privateTagMessageEvent, senderNick); + Optional.ofNullable(channels.get(target)) + .flatMap(channel -> channel.getUser(senderNick)) + .ifPresent(chatUser -> processTagMessage(privateTagMessageEvent, chatUser)); } - }); + case ChannelTagMessageEvent channelTagMessageEvent -> { + ChatChannelUser chatUser = getOrCreateChatUser(user, channelTagMessageEvent.getChannel()); + processTagMessage(channelTagMessageEvent, chatUser); + } + default -> {} + } + } + + private void processTagMessage(T event, ChatChannelUser chatUser) { + String messageId = event.getTag("msgid", MsgId.class) + .map(MsgId::getId) + .orElseThrow(() -> new IllegalArgumentException( + "Message does not have an id: %s".formatted(event.getSource()))); + + event.getTag("+typing", Typing.class).ifPresent(typing -> updateUserTypingState(typing.getState(), chatUser)); + + event.getTag("+draft/react") + .flatMap(MessageTag::getValue) + .map(emoticonService::getEmoticonByShortcode) + .flatMap(emoticon -> event.getTag("+draft/reply") + .flatMap(MessageTag::getValue) + .map(targetMessageId -> new Reaction(messageId, targetMessageId, emoticon, + chatUser.getUsername()))) + .ifPresent(reaction -> fxApplicationThreadExecutor.execute(() -> chatUser.getChannel().addReaction(reaction))); } @VisibleForTesting @@ -330,13 +388,18 @@ public void onConnect(ClientNegotiationCompleteEvent event) { joinChannel(NEWBIE_CHANNEL_NAME); } - client.commands().capabilityRequest().enable(ECHO_MESSAGE).execute(); + client.commands() + .capabilityRequest() + .enable(ECHO_MESSAGE) + .enable("draft/chathistory") + .enable("draft/event-playback").enable("draft/message-redaction") + .execute(); } @Handler - private void onJoinEvent(ChannelJoinEvent event) { + public void onJoinEvent(ChannelJoinEvent event) { User user = event.getActor(); - getOrCreateChatUser(user, event.getChannel()); + updateChatUser(user, event.getChannel()); } @Handler @@ -354,20 +417,21 @@ public void onWhoAway(WhoAwayMessageEvent event) { } @Handler - private void onPartEvent(ChannelPartEvent event) { + public void onPartEvent(ChannelPartEvent event) { User user = event.getActor(); onChatUserLeftChannel(event.getChannel().getName(), user.getNick()); } @Handler - private void onChatUserQuit(UserQuitEvent event) { + public void onChatUserQuit(UserQuitEvent event) { User user = event.getUser(); String username = user.getNick(); - channels.values().forEach(channel -> onChatUserLeftChannel(channel.getName(), username)); + + List.copyOf(channels.keySet()).forEach(channelName -> onChatUserLeftChannel(channelName, username)); } @Handler - private void onTopicChange(ChannelTopicEvent event) { + public void onTopicChange(ChannelTopicEvent event) { String author = event.getNewTopic() .getSetter() .map(Actor::getName) @@ -379,44 +443,82 @@ private void onTopicChange(ChannelTopicEvent event) { } @Handler - private void onChannelMessage(ChannelMessageEvent event) { - User user = event.getActor(); + public void onMessage(ActorMessageEvent event) { + if (!(event.getActor() instanceof User user) || user.getNick().equals("HistServ")) { + return; + } - String channelName = event.getChannel().getName(); + String senderNick = user.getNick(); + boolean hideFoeMessages = chatPrefs.isHideFoeMessages(); + ChatChannelUser sender = switch (event) { + case ChannelMessageEvent channelMessageEvent -> getOrCreateChatUser(user, channelMessageEvent.getChannel()); + case PrivateMessageEvent privateMessageEvent when playerService.getPlayerByNameIfOnline(senderNick) + .map(PlayerBean::getSocialStatus) + .map(SocialStatus.FOE::equals) + .map(isFoe -> !(hideFoeMessages && isFoe)) + .orElse(true) -> { + String target = getPrivateMessageTarget(privateMessageEvent, senderNick); + yield getOrCreateChatUser(senderNick, target); + } + default -> null; + }; + + if (sender == null) { + return; + } + + processChatMessage(event, sender); + } + + private String getPrivateMessageTarget(PrivateEvent privateEvent, String senderNick) { + return senderNick.equals(getCurrentUsername()) ? privateEvent.getTarget() : senderNick; + } + private void processChatMessage(MessageEvent event, ChatChannelUser sender) { String text = event.getMessage(); - ChatChannelUser sender = getOrCreateChatUser(user.getNick(), channelName); - sender.setTyping(false); ChatChannel chatChannel = sender.getChannel(); + sender.setTyping(false); - Instant messageTime = event.getTag("time", Time.class) - .map(Time::getTime) - .orElse(Instant.now()); + Instant messageTime = event.getTag("time", Time.class).map(Time::getTime).orElse(Instant.now()); String messageId = event.getTag("msgid", MsgId.class) .map(MsgId::getId) .orElseThrow( () -> new IllegalArgumentException("Message does not have an id: %s".formatted(event))); + + ChatMessage targetMessage = event.getTag("+draft/reply") + .flatMap(MessageTag::getValue) + .flatMap(chatChannel::getMessage) + .orElse(null); + event.getTag("label", Label.class).map(Label::getLabel).ifPresent(chatChannel::removePendingMessage); - ChatMessage message = new ChatMessage(messageId, messageTime, sender, text, Type.MESSAGE); + ChatMessage message = new ChatMessage(messageId, messageTime, sender, text, Type.MESSAGE, targetMessage); chatChannel.addMessage(message); - notifyIfMentioned(message); + + switch (event) { + case PrivateMessageEvent ignored -> notifyOnPrivateMessage(message); + case ChannelMessageEvent ignored -> notifyIfMentioned(message); + default -> {} + } } private void notifyIfMentioned(ChatMessage chatMessage) { - String text = chatMessage.getContent(); + if (chatMessage.getTime().isBefore(Instant.now().minusSeconds(60))) { + return; + } + ChatChannelUser sender = chatMessage.getSender(); if (sender.getCategory() == ChatUserCategory.FOE) { log.debug("Ignored ping from foe {}", sender); return; } + String text = chatMessage.getContent(); if (!hasMention(text)) { return; } - ChatChannel channel = sender.getChannel(); if (!channel.isOpen()) { audioService.playChatMentionSound(); @@ -435,6 +537,10 @@ private void notifyIfMentioned(ChatMessage chatMessage) { } private void notifyOnPrivateMessage(ChatMessage chatMessage) { + if (chatMessage.getTime().isBefore(Instant.now().minusSeconds(60))) { + return; + } + ChatChannelUser sender = chatMessage.getSender(); ChatChannel channel = sender.getChannel(); if (channel.isPrivateChannel() && !channel.isOpen()) { @@ -455,7 +561,7 @@ private void notifyOnPrivateMessage(ChatMessage chatMessage) { } @Handler - private void onChannelCTCP(ChannelCtcpEvent event) { + public void onChannelCTCP(ChannelCtcpEvent event) { User user = event.getActor(); String channelName = event.getChannel().getName(); @@ -474,13 +580,18 @@ private void onChannelCTCP(ChannelCtcpEvent event) { .map(MsgId::getId) .orElseThrow( () -> new IllegalArgumentException("Message does not have an id: %s".formatted(event))); + ChatMessage targetMessage = event.getTag("+draft/reply") + .flatMap(MessageTag::getValue) + .flatMap(chatChannel::getMessage) + .orElse(null); + event.getTag("label", Label.class).map(Label::getLabel).ifPresent(chatChannel::removePendingMessage); - chatChannel.addMessage(new ChatMessage(messageId, messageTime, sender, message, Type.ACTION)); + chatChannel.addMessage(new ChatMessage(messageId, messageTime, sender, message, Type.ACTION, targetMessage)); } @Handler - private void onChannelModeChanged(ChannelModeEvent event) { + public void onChannelModeChanged(ChannelModeEvent event) { event.getStatusList().getAll().forEach(channelModeStatus -> channelModeStatus.getParameter().ifPresent(username -> { Mode changedMode = channelModeStatus.getMode(); Action modeAction = channelModeStatus.getAction(); @@ -496,36 +607,6 @@ private void onChannelModeChanged(ChannelModeEvent event) { })); } - @Handler - private void onPrivateMessage(PrivateMessageEvent event) { - User user = event.getActor(); - - String senderNick = user.getNick(); - - ChatChannelUser sender = getOrCreateChatUser(user.getNick(), senderNick); - ChatChannel chatChannel = sender.getChannel(); - sender.setTyping(false); - if (sender.getCategory() == ChatUserCategory.FOE && chatPrefs.isHideFoeMessages()) { - log.debug("Suppressing chat message from foe '{}'", senderNick); - return; - } - - String text = event.getMessage(); - Instant messageTime = event.getTag("time", Time.class) - .map(Time::getTime) - .orElse(Instant.now()); - - String messageId = event.getTag("msgid", MsgId.class) - .map(MsgId::getId) - .orElseThrow( - () -> new IllegalArgumentException("Message does not have an id: %s".formatted(event))); - event.getTag("label", Label.class).map(Label::getLabel).ifPresent(chatChannel::removePendingMessage); - - ChatMessage message = new ChatMessage(messageId, messageTime, sender, text, Type.PENDING); - chatChannel.addMessage(message); - notifyOnPrivateMessage(message); - } - private void joinAutoChannels() { log.trace("Joining auto channels: {}", autoChannels); autoChannels.forEach(this::joinChannel); @@ -554,10 +635,6 @@ private void onChatUserLeftChannel(String channelName, String username) { } chatChannel.removeUser(username); - - if (client.getNick().equalsIgnoreCase(username)) { - removeChannel(channelName); - } } private void onMessage(String message) { @@ -569,7 +646,7 @@ private void onException(Throwable throwable) { } @Handler - private void onDisconnect(ClientConnectionEndedEvent event) { + public void onDisconnect(ClientConnectionEndedEvent event) { client.getEventManager().unregisterEventListener(this); channels.values().forEach(ChatChannel::clearUsers); List.copyOf(channels.keySet()).forEach(this::removeChannel); @@ -579,7 +656,7 @@ private void onDisconnect(ClientConnectionEndedEvent event) { } @Handler - private void onFailedConnect(ClientConnectionFailedEvent event) { + public void onFailedConnect(ClientConnectionFailedEvent event) { connectionState.set(ConnectionState.DISCONNECTED); client.shutdown(); event.getCause().ifPresent(throwable -> log.error("Chat disconnected with cause", throwable)); @@ -629,6 +706,7 @@ public void connect() { .forEach(eventListenerSuppliers::add); eventListenerSuppliers.add(() -> WhoAwayListener::new); eventListenerSuppliers.add(() -> DefaultTagmsgListener::new); + eventListenerSuppliers.add(() -> RedactListener::new); client = (WithManagement) Client.builder() .realName(username) @@ -646,10 +724,11 @@ public void connect() { .management() .eventListeners(eventListenerSuppliers) .then() - .build(); client.getMessageTagManager().registerTagCreator(MESSAGE_TAGS, "+typing", DefaultMessageTagTyping.FUNCTION); + client.getActorTracker().setQueryChannelInformation(false); + client.getEventManager().registerEventListener(this); userWebClientFactory.getObject() .get() @@ -661,7 +740,6 @@ public void connect() { client.getAuthManager() .addProtocol( new SaslPlain(client, "%s@FAF".formatted(username), "token:%s".formatted(token))); - client.getEventManager().registerEventListener(this); client.connect(); }); } @@ -674,10 +752,44 @@ public void disconnect() { } } + @Override + public CompletableFuture redactMessageInBackground(ChatChannel channel, String messageId) { + return CompletableFuture.runAsync(() -> client.sendRawLine("REDACT " + channel.getName() + " " + messageId)); + } + + @Override + public CompletableFuture reactToMessageInBackground(ChatMessage targetMessage, Emoticon reaction) { + return CompletableFuture.runAsync( + () -> new TagMessageCommand(client).target(targetMessage.getSender().getChannel().getName()) + .tags() + .add("+draft/reply", targetMessage.getId()) + .add("+draft/react", reaction.shortcodes().getFirst()) + .then() + .execute()); + } + + @Override + public CompletableFuture sendReplyInBackground(ChatMessage targetMessage, String message) { + ChatChannel chatChannel = targetMessage.getSender().getChannel(); + String channelName = chatChannel.getName(); + ChatChannelUser sender = getOrCreateChatUser(getCurrentUsername(), channelName); + String id = String.valueOf(new Random().nextInt()); + return CompletableFuture.runAsync(() -> { + new MessageCommand(client).target(channelName) + .message(message) + .tags() + .add("label", id) + .add("+draft/reply", targetMessage.getId()) + .then() + .execute(); + chatChannel.addMessage(new ChatMessage(id, Instant.now(), sender, message, Type.PENDING, targetMessage)); + }); + } + @Override public CompletableFuture sendMessageInBackground(ChatChannel chatChannel, String message) { ChatChannelUser sender = getOrCreateChatUser(getCurrentUsername(), chatChannel.getName()); - String id = String.valueOf(Objects.hash(new Random().nextInt(), message)); + String id = String.valueOf(new Random().nextInt()); return CompletableFuture.runAsync(() -> { new MessageCommand(client).target(chatChannel.getName()) .message(message) @@ -685,7 +797,7 @@ public CompletableFuture sendMessageInBackground(ChatChannel chatChannel, .add("label", id) .then() .execute(); - chatChannel.addMessage(new ChatMessage(id, Instant.now(), sender, message, Type.PENDING)); + chatChannel.addMessage(new ChatMessage(id, Instant.now(), sender, message, Type.PENDING, null)); }); } @@ -704,21 +816,21 @@ public ChatChannel getOrCreateChannel(String channelName) { @Override public void addChannelsListener(MapChangeListener listener) { - JavaFxUtil.addListener(channels, listener); + channels.addListener(listener); } @Override public void removeChannelsListener(MapChangeListener listener) { - JavaFxUtil.removeListener(channels, listener); + channels.removeListener(listener); } @Override public void leaveChannel(ChatChannel channel) { if (!channel.isPrivateChannel()) { client.removeChannel(channel.getName()); - } else { - removeChannel(channel.getName()); } + + removeChannel(channel.getName()); } private void removeChannel(String channelName) { @@ -737,6 +849,8 @@ public void joinChannel(String channelName) { bufferedChannels.add(channelName); } else { client.addChannel(channelName); + client.sendRawLine("CHATHISTORY LATEST " + channelName + " * " + (chatPrefs.getMaxMessages() * 2)); + client.sendRawLine("WHO " + channelName); } } @@ -795,8 +909,9 @@ private void incrementUnreadMessagesCount(Number oldValue, Number newValue) { } @Override - public void onInitiatePrivateChat(String username) { + public void joinPrivateChat(String username) { getOrCreateChannel(username); + client.sendRawLine("CHATHISTORY LATEST " + username + " * " + chatPrefs.getMaxMessages() + 50); } @Override diff --git a/src/main/java/com/faforever/client/chat/ReactionController.java b/src/main/java/com/faforever/client/chat/ReactionController.java new file mode 100644 index 0000000000..bde4236d52 --- /dev/null +++ b/src/main/java/com/faforever/client/chat/ReactionController.java @@ -0,0 +1,144 @@ +package com.faforever.client.chat; + +import com.faforever.client.chat.emoticons.Emoticon; +import com.faforever.client.chat.emoticons.EmoticonService; +import com.faforever.client.fx.FxApplicationThreadExecutor; +import com.faforever.client.fx.NodeController; +import com.faforever.client.i18n.I18n; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.MapChangeListener; +import javafx.collections.ObservableList; +import javafx.collections.ObservableMap; +import javafx.event.EventHandler; +import javafx.scene.control.Label; +import javafx.scene.control.Tooltip; +import javafx.scene.image.ImageView; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.HBox; +import javafx.scene.text.Font; +import javafx.util.Duration; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.SequencedCollection; +import java.util.Set; +import java.util.function.Consumer; + +@RequiredArgsConstructor +@Component +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +public class ReactionController extends NodeController { + + private static final String MY_REACTION_CLASS = "my-reaction"; + + private final EmoticonService emoticonService; + private final ChatService chatService; + private final I18n i18n; + private final FxApplicationThreadExecutor fxApplicationThreadExecutor; + + public Label label; + public HBox root; + public ImageView emoticonImageView; + + private final ObjectProperty reaction = new SimpleObjectProperty<>(); + private final ObjectProperty> reactors = new SimpleObjectProperty<>(); + private final ObjectProperty> onReactionClicked = new SimpleObjectProperty<>(); + + private final MapChangeListener reactorsListener = this::onReactorsChanged; + + private final Tooltip reactorsTooltip = new Tooltip(); + + @Override + public void onInitialize() { + root.onMouseClickedProperty() + .bind(onReactionClicked.flatMap(onReactionClicked -> reaction.map( + reaction -> (EventHandler) event -> onReactionClicked.accept(reaction))).when(showing)); + + emoticonImageView.imageProperty() + .bind(reaction.map(Emoticon::shortcodes) + .map(SequencedCollection::getFirst) + .map(emoticonService::getImageByShortcode) + .when(showing)); + + reactors.subscribe((oldValue, newValue) -> { + if (oldValue != null) { + oldValue.removeListener(reactorsListener); + } + + if (newValue != null) { + updateReactors(newValue); + newValue.addListener(reactorsListener); + } + }); + + reactorsTooltip.setFont(new Font(14)); + reactorsTooltip.setShowDuration(Duration.seconds(10)); + reactorsTooltip.setShowDelay(Duration.ZERO); + reactorsTooltip.setHideDelay(Duration.ZERO); + Tooltip.install(root, reactorsTooltip); + } + + private void onReactorsChanged(MapChangeListener.Change change) { + updateReactors(change.getMap()); + } + + private void updateReactors(Map map) { + boolean selected = map.containsKey(chatService.getCurrentUsername()); + Set reactors = Set.copyOf(map.keySet()); + fxApplicationThreadExecutor.execute(() -> { + ObservableList styleClass = root.getStyleClass(); + if (selected && !styleClass.contains(MY_REACTION_CLASS)) { + styleClass.add(MY_REACTION_CLASS); + } else if (!selected) { + styleClass.removeIf(MY_REACTION_CLASS::equals); + } + reactorsTooltip.setText(String.join(", ", reactors)); + label.setText(i18n.number(reactors.size())); + }); + } + + @Override + public HBox getRoot() { + return root; + } + + public Emoticon getReaction() { + return reaction.get(); + } + + public ObjectProperty reactionProperty() { + return reaction; + } + + public void setReaction(Emoticon reaction) { + this.reaction.set(reaction); + } + + public ObservableMap getReactors() { + return reactors.get(); + } + + public ObjectProperty> reactorsProperty() { + return reactors; + } + + public void setReactors(ObservableMap reactors) { + this.reactors.set(reactors); + } + + public Consumer getOnReactionClicked() { + return onReactionClicked.get(); + } + + public ObjectProperty> onReactionClickedProperty() { + return onReactionClicked; + } + + public void setOnReactionClicked(Consumer onReactionClicked) { + this.onReactionClicked.set(onReactionClicked); + } +} diff --git a/src/main/java/com/faforever/client/chat/emoticons/Emoticon.java b/src/main/java/com/faforever/client/chat/emoticons/Emoticon.java index a95757aea8..6071269c94 100644 --- a/src/main/java/com/faforever/client/chat/emoticons/Emoticon.java +++ b/src/main/java/com/faforever/client/chat/emoticons/Emoticon.java @@ -1,7 +1,5 @@ package com.faforever.client.chat.emoticons; -import javafx.scene.image.Image; - import java.util.List; -public record Emoticon(List shortcodes, String base64SvgContent, Image image) {} +public record Emoticon(List shortcodes, String base64SvgContent) {} diff --git a/src/main/java/com/faforever/client/chat/emoticons/EmoticonController.java b/src/main/java/com/faforever/client/chat/emoticons/EmoticonController.java index ac7a7650bb..058f8429f2 100644 --- a/src/main/java/com/faforever/client/chat/emoticons/EmoticonController.java +++ b/src/main/java/com/faforever/client/chat/emoticons/EmoticonController.java @@ -1,8 +1,15 @@ package com.faforever.client.chat.emoticons; import com.faforever.client.fx.NodeController; +import javafx.beans.property.IntegerProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleIntegerProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.event.EventHandler; +import javafx.geometry.Insets; import javafx.scene.control.Tooltip; import javafx.scene.image.ImageView; +import javafx.scene.input.MouseEvent; import javafx.scene.layout.AnchorPane; import javafx.scene.text.Font; import javafx.util.Duration; @@ -23,26 +30,91 @@ public class EmoticonController extends NodeController { private final EmoticonService emoticonService; - private final Font shortcodesFont = new Font(14d); - public AnchorPane root; public ImageView emoticonImageView; - public void setEmoticon(Emoticon emoticon, Consumer onAction) { - emoticonImageView.setImage(emoticonService.getImageByShortcode(emoticon.shortcodes().getFirst())); - root.setOnMouseClicked(event -> onAction.accept(emoticon.shortcodes().getFirst())); + private final Tooltip shortCodesTooltip = new Tooltip(); + private final ObjectProperty> onEmoticonClicked = new SimpleObjectProperty<>(); + private final ObjectProperty emoticon = new SimpleObjectProperty<>(); + private final IntegerProperty emoticonSize = new SimpleIntegerProperty(36); + private final ObjectProperty emoticonPadding = new SimpleObjectProperty<>(new Insets(5, 5, 5, 5)); + + @Override + protected void onInitialize() { + root.onMouseClickedProperty() + .bind(onEmoticonClicked.flatMap(onEmoticonClicked -> emoticon.map( + emoticon -> (EventHandler) event -> onEmoticonClicked.accept(emoticon))).when(showing)); + emoticonImageView.imageProperty() + .bind(emoticon.map(Emoticon::shortcodes) + .map(List::getFirst) + .map(emoticonService::getImageByShortcode) + .when(showing)); + emoticonImageView.fitHeightProperty().bind(emoticonSize); + emoticonImageView.fitWidthProperty().bind(emoticonSize); + root.paddingProperty().bind(emoticonPadding); + + shortCodesTooltip.textProperty() + .bind(emoticon.map(Emoticon::shortcodes) + .map(shortCodes -> String.join("\t", shortCodes)) + .when(showing)); + + shortCodesTooltip.setFont(new Font(14d)); + shortCodesTooltip.setShowDuration(Duration.seconds(10)); + shortCodesTooltip.setShowDelay(Duration.ZERO); + shortCodesTooltip.setHideDelay(Duration.ZERO); + Tooltip.install(root, shortCodesTooltip); + } + + public void setEmoticon(String shortcode) { + setEmoticon(emoticonService.getEmoticonByShortcode(shortcode)); + } + + public void setEmoticon(Emoticon emoticon) { + this.emoticon.set(emoticon); + } + + public Emoticon getEmoticon() { + return emoticon.get(); + } + + public ObjectProperty emoticonProperty() { + return emoticon; + } + + public void setOnEmoticonClicked(Consumer onEmoticonClicked) { + this.onEmoticonClicked.set(onEmoticonClicked); + } + + public Consumer getOnEmoticonClicked() { + return onEmoticonClicked.get(); + } + + public ObjectProperty> onEmoticonClickedProperty() { + return onEmoticonClicked; + } + + public int getEmoticonSize() { + return emoticonSize.get(); + } + + public IntegerProperty emoticonSizeProperty() { + return emoticonSize; + } + + public void setEmoticonSize(int emoticonSize) { + this.emoticonSize.set(emoticonSize); + } + + public Insets getEmoticonPadding() { + return emoticonPadding.get(); + } - displayShortcodesOnHover(emoticon.shortcodes()); + public ObjectProperty emoticonPaddingProperty() { + return emoticonPadding; } - private void displayShortcodesOnHover(List shortcodes) { - Tooltip tooltip = new Tooltip(); - tooltip.setText(String.join("\t",shortcodes)); - tooltip.setFont(shortcodesFont); - tooltip.setShowDuration(Duration.seconds(10)); - tooltip.setShowDelay(Duration.ZERO); - tooltip.setHideDelay(Duration.ZERO); - Tooltip.install(root, tooltip); + public void setEmoticonPadding(Insets emoticonPadding) { + this.emoticonPadding.set(emoticonPadding); } @Override diff --git a/src/main/java/com/faforever/client/chat/emoticons/EmoticonService.java b/src/main/java/com/faforever/client/chat/emoticons/EmoticonService.java index d1cf33e5f3..8207f7f76f 100644 --- a/src/main/java/com/faforever/client/chat/emoticons/EmoticonService.java +++ b/src/main/java/com/faforever/client/chat/emoticons/EmoticonService.java @@ -14,15 +14,12 @@ import java.io.IOException; import java.io.InputStream; -import java.util.Arrays; import java.util.Base64; import java.util.Base64.Decoder; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; -import java.util.regex.Pattern; -import java.util.stream.Collectors; @Slf4j @Service @@ -35,11 +32,10 @@ public class EmoticonService implements InitializingBean { private final ObjectMapper objectMapper; private List emoticonsGroups; - private Pattern emoticonShortcodeDetectorPattern; private final Decoder decoder = Base64.getDecoder(); - private final Map shortcodeToBase64SvgContent = new HashMap<>(); private final Map shortcodeToImage = new HashMap<>(); + private final Map shortcodeToEmoticon = new HashMap<>(); @Override public void afterPropertiesSet() { @@ -49,22 +45,16 @@ public void afterPropertiesSet() { @VisibleForTesting void loadAndVerifyEmoticons() { try (InputStream emoticonsInputStream = EMOTICONS_JSON_FILE_RESOURCE.getInputStream()) { - emoticonsGroups = Arrays.asList(objectMapper.readValue(emoticonsInputStream, EmoticonsGroup[].class)); + emoticonsGroups = List.of(objectMapper.readValue(emoticonsInputStream, EmoticonsGroup[].class)); emoticonsGroups.stream().flatMap(emoticonsGroup -> emoticonsGroup.emoticons().stream()).forEach(emoticon -> { String base64SvgContent = emoticon.base64SvgContent(); Image image = new Image(IOUtils.toInputStream(new String(decoder.decode(base64SvgContent)))); emoticon.shortcodes().forEach(shortcode -> { - if (shortcodeToBase64SvgContent.containsKey(shortcode)) { + if (shortcodeToImage.put(shortcode, image) != null || shortcodeToEmoticon.put(shortcode, emoticon) != null) { throw new ProgrammingError("Shortcode `" + shortcode + "` is already taken"); } - shortcodeToBase64SvgContent.put(shortcode, base64SvgContent); - shortcodeToImage.put(shortcode, image); }); }); - emoticonShortcodeDetectorPattern = Pattern.compile(shortcodeToBase64SvgContent.keySet() - .stream() - .map(Pattern::quote) - .collect(Collectors.joining("|"))); } catch (IOException e) { throw new AssetLoadException("Unable to load emoticons", e, ""); } @@ -74,15 +64,16 @@ public List getEmoticonsGroups() { return emoticonsGroups; } - public Pattern getEmoticonShortcodeDetectorPattern() { - return emoticonShortcodeDetectorPattern; - } - - public String getBase64SvgContentByShortcode(String shortcode) { - return shortcodeToBase64SvgContent.get(shortcode); + public boolean isEmoticonShortcode(String shortcode) { + return shortcodeToEmoticon.containsKey(shortcode); } public Image getImageByShortcode(String shortcode) { return shortcodeToImage.get(shortcode); } + + public Emoticon getEmoticonByShortcode(String shortcode) { + return shortcodeToEmoticon.get(shortcode); + } + } diff --git a/src/main/java/com/faforever/client/chat/emoticons/EmoticonsGroup.java b/src/main/java/com/faforever/client/chat/emoticons/EmoticonsGroup.java index 6f148d7d56..53e0ed14b2 100644 --- a/src/main/java/com/faforever/client/chat/emoticons/EmoticonsGroup.java +++ b/src/main/java/com/faforever/client/chat/emoticons/EmoticonsGroup.java @@ -2,4 +2,4 @@ import java.util.List; -public record EmoticonsGroup(String name, String attribution, List emoticons) {} +public record EmoticonsGroup(String name, String attributionUrl, List emoticons) {} diff --git a/src/main/java/com/faforever/client/chat/emoticons/EmoticonsGroupController.java b/src/main/java/com/faforever/client/chat/emoticons/EmoticonsGroupController.java index 1d7b3e1da0..77a0b81445 100644 --- a/src/main/java/com/faforever/client/chat/emoticons/EmoticonsGroupController.java +++ b/src/main/java/com/faforever/client/chat/emoticons/EmoticonsGroupController.java @@ -1,9 +1,15 @@ package com.faforever.client.chat.emoticons; +import com.faforever.client.fx.FxApplicationThreadExecutor; import com.faforever.client.fx.JavaFxUtil; import com.faforever.client.fx.NodeController; import com.faforever.client.fx.PlatformService; import com.faforever.client.theme.UiService; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.value.ObservableValue; +import javafx.event.ActionEvent; +import javafx.event.EventHandler; import javafx.scene.control.Hyperlink; import javafx.scene.control.Label; import javafx.scene.layout.AnchorPane; @@ -12,14 +18,12 @@ import javafx.scene.layout.VBox; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.context.annotation.Scope; import org.springframework.stereotype.Component; import java.util.List; import java.util.function.Consumer; -import java.util.stream.Collectors; @Slf4j @Component @@ -29,6 +33,7 @@ public class EmoticonsGroupController extends NodeController { private final UiService uiService; private final PlatformService platformService; + private final FxApplicationThreadExecutor fxApplicationThreadExecutor; public VBox root; public Label groupLabel; @@ -36,25 +41,59 @@ public class EmoticonsGroupController extends NodeController { public Hyperlink attributionHyperlink; public FlowPane emoticonsPane; + private final ObjectProperty emoticonsGroup = new SimpleObjectProperty<>(); + private final ObjectProperty> onEmoticonClicked = new SimpleObjectProperty>(); + @Override protected void onInitialize() { JavaFxUtil.bindManagedToVisible(attributionPane); + ObservableValue attribution = emoticonsGroup.map(EmoticonsGroup::attributionUrl); + attributionHyperlink.textProperty().bind(attribution.when(showing)); + attributionHyperlink.onActionProperty() + .bind(attribution.map( + url -> (EventHandler) event -> platformService.showDocument(url)) + .when(showing)); + attributionPane.visibleProperty().bind(attributionHyperlink.textProperty().isNotEmpty().when(showing)); + groupLabel.textProperty().bind(emoticonsGroup.map(EmoticonsGroup::name)); + emoticonsGroup.subscribe(this::populateEmoticons); + } + + public EmoticonsGroup getEmoticonsGroup() { + return emoticonsGroup.get(); + } + + public ObjectProperty emoticonsGroupProperty() { + return emoticonsGroup; + } + + public void setEmoticonsGroup(EmoticonsGroup emoticonsGroup) { + this.emoticonsGroup.set(emoticonsGroup); + } + + public Consumer getOnEmoticonClicked() { + return onEmoticonClicked.get(); + } + + public ObjectProperty> onEmoticonClickedProperty() { + return onEmoticonClicked; + } + + public void setOnEmoticonClicked(Consumer onEmoticonClicked) { + this.onEmoticonClicked.set(onEmoticonClicked); } - public void setGroup(EmoticonsGroup group, Consumer onEmoticonAction) { - groupLabel.setText(group.name()); - String attribution = group.attribution(); - if (!StringUtils.isBlank(attribution)) { - attributionHyperlink.setText(attribution); - attributionHyperlink.setOnAction(event -> platformService.showDocument(attribution)); - attributionPane.setVisible(true); + private void populateEmoticons(EmoticonsGroup group) { + if (group == null) { + fxApplicationThreadExecutor.execute(() -> emoticonsPane.getChildren().clear()); + } else { + List emoticonViewList = group.emoticons().stream().map(emoticon -> { + EmoticonController controller = uiService.loadFxml("theme/chat/emoticons/emoticon.fxml"); + controller.setEmoticon(emoticon); + controller.onEmoticonClickedProperty().bind(onEmoticonClicked); + return controller.getRoot(); + }).toList(); + fxApplicationThreadExecutor.execute(() -> emoticonsPane.getChildren().setAll(emoticonViewList)); } - List emoticonViewList = group.emoticons().stream().map(emoticon -> { - EmoticonController controller = uiService.loadFxml("theme/chat/emoticons/emoticon.fxml"); - controller.setEmoticon(emoticon, onEmoticonAction); - return controller.getRoot(); - }).collect(Collectors.toList()); - emoticonsPane.getChildren().addAll(emoticonViewList); } @Override diff --git a/src/main/java/com/faforever/client/chat/emoticons/EmoticonsWindowController.java b/src/main/java/com/faforever/client/chat/emoticons/EmoticonsWindowController.java index fafb1794e6..5669cb3c40 100644 --- a/src/main/java/com/faforever/client/chat/emoticons/EmoticonsWindowController.java +++ b/src/main/java/com/faforever/client/chat/emoticons/EmoticonsWindowController.java @@ -2,12 +2,11 @@ import com.faforever.client.fx.NodeController; import com.faforever.client.theme.UiService; -import com.google.common.annotations.VisibleForTesting; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; import javafx.scene.Node; -import javafx.scene.control.TextInputControl; import javafx.scene.layout.VBox; import lombok.RequiredArgsConstructor; -import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.context.annotation.Scope; @@ -28,27 +27,30 @@ public class EmoticonsWindowController extends NodeController { public VBox root; - @Setter - private TextInputControl textInputControl; + private final ObjectProperty> onEmoticonClicked = new SimpleObjectProperty<>(); @Override protected void onInitialize() { List nodes = new ArrayList<>(); emoticonService.getEmoticonsGroups().forEach(group -> { EmoticonsGroupController controller = uiService.loadFxml("theme/chat/emoticons/emoticons_group.fxml"); - controller.setGroup(group, onEmoticonClicked()); + controller.setEmoticonsGroup(group); + controller.onEmoticonClickedProperty().bind(onEmoticonClicked); nodes.add(controller.getRoot()); }); root.getChildren().addAll(nodes); } - @VisibleForTesting - protected Consumer onEmoticonClicked() { - return shortcode -> { - textInputControl.appendText(" " + shortcode + " "); - textInputControl.requestFocus(); - textInputControl.selectEnd(); - }; + public Consumer getOnEmoticonClicked() { + return onEmoticonClicked.get(); + } + + public ObjectProperty> onEmoticonClickedProperty() { + return onEmoticonClicked; + } + + public void setOnEmoticonClicked(Consumer onEmoticonClicked) { + this.onEmoticonClicked.set(onEmoticonClicked); } @Override diff --git a/src/main/java/com/faforever/client/chat/emoticons/Reaction.java b/src/main/java/com/faforever/client/chat/emoticons/Reaction.java new file mode 100644 index 0000000000..259378526d --- /dev/null +++ b/src/main/java/com/faforever/client/chat/emoticons/Reaction.java @@ -0,0 +1,3 @@ +package com.faforever.client.chat.emoticons; + +public record Reaction(String messageId, String targetMessageId, Emoticon emoticon, String reactorName) {} diff --git a/src/main/java/com/faforever/client/chat/kitteh/ChannelRedactMessageEvent.java b/src/main/java/com/faforever/client/chat/kitteh/ChannelRedactMessageEvent.java new file mode 100644 index 0000000000..740a6b2a8d --- /dev/null +++ b/src/main/java/com/faforever/client/chat/kitteh/ChannelRedactMessageEvent.java @@ -0,0 +1,46 @@ +package com.faforever.client.chat.kitteh; + +import org.kitteh.irc.client.library.Client; +import org.kitteh.irc.client.library.element.Channel; +import org.kitteh.irc.client.library.element.ServerMessage; +import org.kitteh.irc.client.library.element.User; +import org.kitteh.irc.client.library.event.abstractbase.ActorChannelEventBase; +import org.kitteh.irc.client.library.event.helper.ActorEvent; + +import java.util.Optional; + +/** + * Fires when a redact is sent to a channel. Note that the sender may be the client itself if the capability + * "echo-message" is enabled. + */ +public class ChannelRedactMessageEvent extends ActorChannelEventBase implements ActorEvent, RedactMessageEvent { + + private final String redactedMessageId; + private final String message; + + /** + * Creates the event. + * + * @param client client for which this is occurring + * @param sourceMessage source message + * @param sender who sent it + * @param channel channel receiving + * @param message message sent + */ + public ChannelRedactMessageEvent(Client client, ServerMessage sourceMessage, User sender, Channel channel, + String message, String redactedMessageId) { + super(client, sourceMessage, sender, channel); + this.redactedMessageId = redactedMessageId; + this.message = message; + } + + @Override + public String getRedactedMessageId() { + return redactedMessageId; + } + + @Override + public Optional getRedactMessage() { + return Optional.ofNullable(message); + } +} diff --git a/src/main/java/com/faforever/client/chat/kitteh/ChannelTargetedRedactMessageEvent.java b/src/main/java/com/faforever/client/chat/kitteh/ChannelTargetedRedactMessageEvent.java new file mode 100644 index 0000000000..48de9c5b86 --- /dev/null +++ b/src/main/java/com/faforever/client/chat/kitteh/ChannelTargetedRedactMessageEvent.java @@ -0,0 +1,55 @@ +package com.faforever.client.chat.kitteh; + +import org.kitteh.irc.client.library.Client; +import org.kitteh.irc.client.library.element.Channel; +import org.kitteh.irc.client.library.element.ServerMessage; +import org.kitteh.irc.client.library.element.User; +import org.kitteh.irc.client.library.element.mode.ChannelUserMode; +import org.kitteh.irc.client.library.event.abstractbase.ActorChannelEventBase; +import org.kitteh.irc.client.library.event.helper.ChannelTargetedEvent; + +import java.util.Optional; + +/** + * Fires when a redact is sent to a subset of users in a channel. Note that the sender may be the client itself if the + * capability "echo-message" is enabled. + */ +public class ChannelTargetedRedactMessageEvent extends ActorChannelEventBase implements ChannelTargetedEvent, RedactMessageEvent { + + private final String redactedMessageId; + private final ChannelUserMode prefix; + private final String message; + + /** + * Creates the event. + * + * @param client client for which this is occurring + * @param sourceMessage source message + * @param sender who sent it + * @param channel channel receiving + * @param prefix targeted prefix + * @param message message sent + */ + public ChannelTargetedRedactMessageEvent(Client client, ServerMessage sourceMessage, User sender, Channel channel, + ChannelUserMode prefix, String message, String redactedMessageId) { + super(client, sourceMessage, sender, channel); + this.prefix = prefix; + this.message = message; + this.redactedMessageId = redactedMessageId; + } + + @Override + public String getRedactedMessageId() { + return redactedMessageId; + } + + @Override + public Optional getRedactMessage() { + return Optional.ofNullable(message); + } + + @Override + public ChannelUserMode getPrefix() { + return prefix; + } +} diff --git a/src/main/java/com/faforever/client/chat/kitteh/PrivateRedactMessageEvent.java b/src/main/java/com/faforever/client/chat/kitteh/PrivateRedactMessageEvent.java new file mode 100644 index 0000000000..e72af33f95 --- /dev/null +++ b/src/main/java/com/faforever/client/chat/kitteh/PrivateRedactMessageEvent.java @@ -0,0 +1,44 @@ +package com.faforever.client.chat.kitteh; + +import org.kitteh.irc.client.library.Client; +import org.kitteh.irc.client.library.element.ServerMessage; +import org.kitteh.irc.client.library.element.User; +import org.kitteh.irc.client.library.event.abstractbase.PrivateEventBase; +import org.kitteh.irc.client.library.event.helper.PrivateEvent; + +import java.util.Optional; + +/** + * Fires when a redact is sent to the client. + */ +public class PrivateRedactMessageEvent extends PrivateEventBase implements PrivateEvent, RedactMessageEvent { + + private final String redactedMessageId; + private final String message; + + /** + * Creates the event. + * + * @param client client for which this is occurring + * @param sourceMessage source message + * @param sender who sent it + * @param target who received it + * @param message message sent + */ + public PrivateRedactMessageEvent(Client client, ServerMessage sourceMessage, User sender, String target, + String message, String redactedMessageId) { + super(client, sourceMessage, sender, target); + this.message = message; + this.redactedMessageId = redactedMessageId; + } + + @Override + public String getRedactedMessageId() { + return redactedMessageId; + } + + @Override + public Optional getRedactMessage() { + return Optional.ofNullable(message); + } +} diff --git a/src/main/java/com/faforever/client/chat/kitteh/RedactListener.java b/src/main/java/com/faforever/client/chat/kitteh/RedactListener.java new file mode 100644 index 0000000000..fc859db23f --- /dev/null +++ b/src/main/java/com/faforever/client/chat/kitteh/RedactListener.java @@ -0,0 +1,60 @@ +package com.faforever.client.chat.kitteh; + +import net.engio.mbassy.listener.Handler; +import org.kitteh.irc.client.library.Client; +import org.kitteh.irc.client.library.Client.WithManagement; +import org.kitteh.irc.client.library.defaults.listener.AbstractDefaultListenerBase; +import org.kitteh.irc.client.library.element.ServerMessage; +import org.kitteh.irc.client.library.element.User; +import org.kitteh.irc.client.library.event.client.ClientReceiveCommandEvent; +import org.kitteh.irc.client.library.feature.filter.CommandFilter; + +import java.util.List; + +/** + * Default JOIN listener, producing events using default classes. + */ +public class RedactListener extends AbstractDefaultListenerBase { + /** + * Constructs the listener. + * + * @param client client + */ + public RedactListener(Client.WithManagement client) { + super(client); + } + + @CommandFilter("REDACT") + @Handler(priority = Integer.MAX_VALUE - 1) + public void redact(ClientReceiveCommandEvent event) { + List parameters = event.getParameters(); + if (parameters.size() < 2) { + this.trackException(event, "REDACT message too short"); + return; + } + if (!(event.getActor() instanceof User user)) { + this.trackException(event, "Message from something other than a user"); + return; + } + MessageTargetInfo messageTargetInfo = this.getTypeByTarget(parameters.getFirst()); + ServerMessage source = event.getSource(); + WithManagement client = this.getClient(); + String redactedMessageId = parameters.get(1); + String message; + if (parameters.size() > 2) { + message = parameters.get(2); + } else { + message = null; + } + if (messageTargetInfo instanceof MessageTargetInfo.Private) { + this.fire(new PrivateRedactMessageEvent(client, source, user, parameters.getFirst(), message, redactedMessageId)); + } else if (messageTargetInfo instanceof MessageTargetInfo.ChannelInfo channelInfo) { + this.fire( + new ChannelRedactMessageEvent(client, source, user, channelInfo.getChannel(), message, redactedMessageId)); + } else if (messageTargetInfo instanceof MessageTargetInfo.TargetedChannel channelInfo) { + this.fire( + new ChannelTargetedRedactMessageEvent(client, source, user, channelInfo.getChannel(), channelInfo.getPrefix(), + message, redactedMessageId)); + } + } +} diff --git a/src/main/java/com/faforever/client/chat/kitteh/RedactMessageEvent.java b/src/main/java/com/faforever/client/chat/kitteh/RedactMessageEvent.java new file mode 100644 index 0000000000..91dd24fea1 --- /dev/null +++ b/src/main/java/com/faforever/client/chat/kitteh/RedactMessageEvent.java @@ -0,0 +1,13 @@ +package com.faforever.client.chat.kitteh; + +import org.kitteh.irc.client.library.event.helper.ClientEvent; + +import java.util.Optional; + +public interface RedactMessageEvent extends ClientEvent { + + String getRedactedMessageId(); + + Optional getRedactMessage(); + +} diff --git a/src/main/java/com/faforever/client/chat/kitteh/WhoAwayListener.java b/src/main/java/com/faforever/client/chat/kitteh/WhoAwayListener.java index 1211ce4151..04c4a70441 100644 --- a/src/main/java/com/faforever/client/chat/kitteh/WhoAwayListener.java +++ b/src/main/java/com/faforever/client/chat/kitteh/WhoAwayListener.java @@ -30,8 +30,8 @@ public void who(ClientReceiveNumericEvent event) { } final String channel = event.getParameters().get(1); - final String nick = event.getParameters().get(5); - final String status = event.getParameters().get(6); + final String nick = event.getParameters().get(5); + final String status = event.getParameters().get(6); boolean isAway = status.contains("G"); this.fire(new WhoAwayMessageEvent(this.getClient(), channel, nick, isAway)); diff --git a/src/main/java/com/faforever/client/fx/BrowserCallback.java b/src/main/java/com/faforever/client/fx/BrowserCallback.java index 3ff6e9fc52..8c52de624a 100644 --- a/src/main/java/com/faforever/client/fx/BrowserCallback.java +++ b/src/main/java/com/faforever/client/fx/BrowserCallback.java @@ -1,20 +1,8 @@ package com.faforever.client.fx; -import com.faforever.client.chat.ChatService; -import com.faforever.client.chat.UrlPreviewResolver; -import com.faforever.client.clan.ClanService; -import com.faforever.client.clan.ClanTooltipController; import com.faforever.client.config.ClientProperties; import com.faforever.client.main.event.ShowReplayEvent; import com.faforever.client.navigation.NavigationHandler; -import com.faforever.client.theme.UiService; -import com.faforever.client.ui.StageHolder; -import com.faforever.client.util.PopupUtil; -import com.google.common.annotations.VisibleForTesting; -import javafx.scene.control.ContentDisplay; -import javafx.scene.control.Tooltip; -import javafx.stage.Popup; -import javafx.stage.PopupWindow.AnchorLocation; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.config.ConfigurableBeanFactory; @@ -31,19 +19,9 @@ public class BrowserCallback implements InitializingBean { private final PlatformService platformService; - private final UrlPreviewResolver urlPreviewResolver; - private final ClanService clanService; - private final UiService uiService; private final ClientProperties clientProperties; - private final ChatService chatService; - private final FxApplicationThreadExecutor fxApplicationThreadExecutor; private final NavigationHandler navigationHandler; - @VisibleForTesting - Popup clanInfoPopup; - private Tooltip linkPreviewTooltip; - private double lastMouseX; - private double lastMouseY; private Pattern replayUrlPattern; @Override @@ -67,99 +45,4 @@ public void openUrl(String url) { String replayId = replayUrlMatcher.group(1); navigationHandler.navigateTo(new ShowReplayEvent(Integer.parseInt(replayId))); } - - /** - * Called from JavaScript when user clicked a channel link. - */ - @SuppressWarnings("unused") - public void openChannel(String channelName) { - chatService.joinChannel(channelName); - } - - /** - * Called from JavaScript when user clicks on user name in chat - */ - @SuppressWarnings("unused") - public void openPrivateMessageTab(String username) { - chatService.onInitiatePrivateChat(username); - } - - /** - * Called from JavaScript when user no longer hovers over an URL. - */ - @SuppressWarnings("unused") - public void hideUrlPreview() { - if (linkPreviewTooltip != null) { - linkPreviewTooltip.hide(); - linkPreviewTooltip = null; - } - } - - /** - * Called from JavaScript when user hovers over an URL. - */ - @SuppressWarnings("unused") - public void previewUrl(String urlString) { - urlPreviewResolver.resolvePreview(urlString).thenAccept(optionalPreview -> optionalPreview.ifPresent(preview -> { - linkPreviewTooltip = new Tooltip(preview.description()); - linkPreviewTooltip.setAutoHide(true); - linkPreviewTooltip.setAnchorLocation(AnchorLocation.CONTENT_BOTTOM_LEFT); - linkPreviewTooltip.setGraphic(preview.node()); - linkPreviewTooltip.setContentDisplay(ContentDisplay.TOP); - fxApplicationThreadExecutor.execute(() -> linkPreviewTooltip.show(StageHolder.getStage(), lastMouseX + 20, lastMouseY)); - })); - } - - /** - * Called from JavaScript when user hovers over a clan tag. - */ - @SuppressWarnings("unused") - public void showClanInfo(String clanTag) { - clanService.getClanByTag(clanTag).thenAcceptAsync(clan -> { - if (clan.isEmpty() || clanTag.isEmpty()) { - return; - } - ClanTooltipController clanTooltipController = uiService.loadFxml("theme/chat/clan_tooltip.fxml"); - clanTooltipController.setClan(clan.get()); - clanTooltipController.getRoot().getStyleClass().add("tooltip"); - - clanInfoPopup = PopupUtil.createPopup(AnchorLocation.CONTENT_TOP_LEFT, clanTooltipController.getRoot()); - clanInfoPopup.show(StageHolder.getStage(), lastMouseX, lastMouseY + 10); - }, fxApplicationThreadExecutor); - } - - /** - * Called from JavaScript when user no longer hovers over a clan tag. - */ - @SuppressWarnings("unused") - public void hideClanInfo() { - if (clanInfoPopup == null) { - return; - } - fxApplicationThreadExecutor.execute(() -> { - clanInfoPopup.hide(); - clanInfoPopup = null; - }); - } - - /** - * Called from JavaScript when user clicks on clan tag. - */ - @SuppressWarnings("unused") - public void showClanWebsite(String clanTag) { - clanService.getClanByTag(clanTag).thenAccept(clan -> { - if (clan.isEmpty()) { - return; - } - platformService.showDocument(clan.get().getWebsiteUrl()); - }); - } - - void setLastMouseX(double screenX) { - lastMouseX = screenX; - } - - void setLastMouseY(double screenY) { - lastMouseY = screenY; - } } diff --git a/src/main/java/com/faforever/client/fx/Controller.java b/src/main/java/com/faforever/client/fx/Controller.java index d2d088c2ed..5a4c319412 100644 --- a/src/main/java/com/faforever/client/fx/Controller.java +++ b/src/main/java/com/faforever/client/fx/Controller.java @@ -6,7 +6,7 @@ import java.util.ArrayList; import java.util.List; -public sealed abstract class Controller permits MenuItemController, NodeController, TabController { +public abstract class Controller { protected BooleanExpression attached; protected BooleanExpression showing; @@ -35,8 +35,8 @@ public final void initialize() { onDetached(); } }); - showing.subscribe(isSelected -> { - if (isSelected) { + showing.subscribe(isShowing -> { + if (isShowing) { onShow(); } else { shownSubscriptions.forEach(Subscription::unsubscribe); diff --git a/src/main/java/com/faforever/client/fx/MenuItemController.java b/src/main/java/com/faforever/client/fx/MenuItemController.java index 7cae598679..c9f2a37f79 100644 --- a/src/main/java/com/faforever/client/fx/MenuItemController.java +++ b/src/main/java/com/faforever/client/fx/MenuItemController.java @@ -4,7 +4,7 @@ import javafx.scene.control.MenuItem; -public abstract non-sealed class MenuItemController extends Controller { +public abstract class MenuItemController extends Controller { @Override protected BooleanExpression createAttachedExpression() { diff --git a/src/main/java/com/faforever/client/fx/NodeController.java b/src/main/java/com/faforever/client/fx/NodeController.java index 667d67edca..a63c06cedc 100644 --- a/src/main/java/com/faforever/client/fx/NodeController.java +++ b/src/main/java/com/faforever/client/fx/NodeController.java @@ -5,7 +5,7 @@ import javafx.scene.Node; -public abstract non-sealed class NodeController extends Controller { +public abstract class NodeController extends Controller { @Override protected BooleanExpression createAttachedExpression() { diff --git a/src/main/java/com/faforever/client/fx/PlatformService.java b/src/main/java/com/faforever/client/fx/PlatformService.java index 444fead1df..d0d302e33b 100644 --- a/src/main/java/com/faforever/client/fx/PlatformService.java +++ b/src/main/java/com/faforever/client/fx/PlatformService.java @@ -43,8 +43,11 @@ public class PlatformService { // Taken from https://stackoverflow.com/questions/163360/regular-expression-to-match-urls-in-java - public static final Pattern URL_REGEX_PATTERN = Pattern.compile( - "^(https?|ftp|file)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]"); + public static final Pattern STRICT_URL_REGEX_PATTERN = Pattern.compile( + "^(https?)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]"); + + public static final Pattern LENIENT_URL_REGEX_PATTERN = Pattern.compile( + "^(?:(https?)://)?(?:[-a-zA-Z0-9]+\\.?)+\\.[a-zA-Z]{2,}(?::[0-9]+)?(?:(?:/[^/]*)?)*(?:\\?.*)?$"); private final OperatingSystem operatingSystem; private final FxApplicationThreadExecutor fxApplicationThreadExecutor; diff --git a/src/main/java/com/faforever/client/fx/TabController.java b/src/main/java/com/faforever/client/fx/TabController.java index eb991c89b2..3942c46e80 100644 --- a/src/main/java/com/faforever/client/fx/TabController.java +++ b/src/main/java/com/faforever/client/fx/TabController.java @@ -4,7 +4,7 @@ import javafx.scene.control.Tab; -public abstract non-sealed class TabController extends Controller { +public abstract class TabController extends Controller { @Override protected BooleanExpression createAttachedExpression() { diff --git a/src/main/java/com/faforever/client/fx/WebViewConfigurer.java b/src/main/java/com/faforever/client/fx/WebViewConfigurer.java index 4236b6aa0a..bcb982f46d 100644 --- a/src/main/java/com/faforever/client/fx/WebViewConfigurer.java +++ b/src/main/java/com/faforever/client/fx/WebViewConfigurer.java @@ -3,9 +3,7 @@ import com.faforever.client.config.ClientProperties; import com.faforever.client.theme.ThemeService; import javafx.concurrent.Worker.State; -import javafx.event.EventHandler; import javafx.scene.input.KeyCode; -import javafx.scene.input.MouseEvent; import javafx.scene.paint.Color; import javafx.scene.web.WebEngine; import javafx.scene.web.WebView; @@ -50,11 +48,6 @@ public void configureWebView(WebView webView) { }); BrowserCallback browserCallback = browserCallbackFactory.getObject(); - EventHandler moveHandler = event -> { - browserCallback.setLastMouseX(event.getScreenX()); - browserCallback.setLastMouseY(event.getScreenY()); - }; - webView.addEventHandler(MouseEvent.MOUSE_MOVED, moveHandler); engine.setUserAgent(clientProperties.getUserAgent()); // removes faforever.com header and footer themeService.registerWebView(webView); diff --git a/src/main/java/com/faforever/client/fx/contextmenu/SendPrivateMessageClanLeaderMenuItem.java b/src/main/java/com/faforever/client/fx/contextmenu/SendPrivateMessageClanLeaderMenuItem.java index b2e8a622c5..e43635271b 100644 --- a/src/main/java/com/faforever/client/fx/contextmenu/SendPrivateMessageClanLeaderMenuItem.java +++ b/src/main/java/com/faforever/client/fx/contextmenu/SendPrivateMessageClanLeaderMenuItem.java @@ -26,8 +26,7 @@ protected void onClicked() { clanService.getClanByTag(object.getClan()) .thenAccept(possibleClan -> possibleClan.map(ClanBean::getLeader) - .map(PlayerBean::getUsername) - .ifPresent(chatService::onInitiatePrivateChat)); + .map(PlayerBean::getUsername).ifPresent(chatService::joinPrivateChat)); } @Override diff --git a/src/main/java/com/faforever/client/fx/contextmenu/SendPrivateMessageMenuItem.java b/src/main/java/com/faforever/client/fx/contextmenu/SendPrivateMessageMenuItem.java index f6d0f98e6c..428635773c 100644 --- a/src/main/java/com/faforever/client/fx/contextmenu/SendPrivateMessageMenuItem.java +++ b/src/main/java/com/faforever/client/fx/contextmenu/SendPrivateMessageMenuItem.java @@ -22,7 +22,7 @@ public class SendPrivateMessageMenuItem extends AbstractMenuItem { @Override protected void onClicked() { Assert.isTrue(!StringUtils.isBlank(object), "No username has been set"); - chatService.onInitiatePrivateChat(object); + chatService.joinPrivateChat(object); } @Override diff --git a/src/main/java/com/faforever/client/player/FriendOnlineNotifier.java b/src/main/java/com/faforever/client/player/FriendOnlineNotifier.java index e94dd93785..6c8aa6cc48 100644 --- a/src/main/java/com/faforever/client/player/FriendOnlineNotifier.java +++ b/src/main/java/com/faforever/client/player/FriendOnlineNotifier.java @@ -42,8 +42,7 @@ void onPlayerOnline(PlayerBean player) { new TransientNotification( i18n.get("friend.nowOnlineNotification.title", player.getUsername()), i18n.get("friend.nowOnlineNotification.action"), - IdenticonUtil.createIdenticon(player.getId()), - () -> chatService.onInitiatePrivateChat(player.getUsername()) + IdenticonUtil.createIdenticon(player.getId()), () -> chatService.joinPrivateChat(player.getUsername()) )); } } diff --git a/src/main/java/com/faforever/client/replay/WatchButtonController.java b/src/main/java/com/faforever/client/replay/WatchButtonController.java index 5ee9d100ba..adec99143f 100644 --- a/src/main/java/com/faforever/client/replay/WatchButtonController.java +++ b/src/main/java/com/faforever/client/replay/WatchButtonController.java @@ -118,7 +118,6 @@ public ObjectProperty gameProperty() { } private void showContextMenu() { - Bounds screenBounds = watchButton.localToScreen(watchButton.getBoundsInLocal()); GameBean gameBean = getGame(); if (gameBean == null) { return; @@ -130,6 +129,7 @@ private void showContextMenu() { .addItem(RunReplayImmediatelyMenuItem.class, gameBean) .addItem(CancelActionRunReplayImmediatelyMenuItem.class, gameBean) .build(); + Bounds screenBounds = watchButton.localToScreen(watchButton.getBoundsInLocal()); contextMenu.show(watchButton.getScene().getWindow(), screenBounds.getMinX(), screenBounds.getMaxY()); } diff --git a/src/main/java/com/faforever/client/teammatchmaking/InvitePlayerController.java b/src/main/java/com/faforever/client/teammatchmaking/InvitePlayerController.java index 8d09b53e3e..dff4914e4d 100644 --- a/src/main/java/com/faforever/client/teammatchmaking/InvitePlayerController.java +++ b/src/main/java/com/faforever/client/teammatchmaking/InvitePlayerController.java @@ -49,7 +49,7 @@ protected void onInitialize() { playerTextField.textProperty().addListener((observable, oldValue, newValue) -> playersListView.getSelectionModel().selectFirst() ); - playerList.setAll(getPlayerNames()); + playerList.setAll(playerService.getPlayerNames()); filteredPlayerList.predicateProperty().bind(playerTextField.textProperty().map(text -> playerName -> { if (playerService.getCurrentPlayer().getUsername().equals(playerName)) { diff --git a/src/main/java/com/faforever/client/theme/ThemeService.java b/src/main/java/com/faforever/client/theme/ThemeService.java index c7371a20da..eb96057507 100644 --- a/src/main/java/com/faforever/client/theme/ThemeService.java +++ b/src/main/java/com/faforever/client/theme/ThemeService.java @@ -93,11 +93,6 @@ public class ThemeService implements InitializingBean, DisposableBean { public static final String DEFAULT_ACHIEVEMENT_IMAGE = "theme/images/default_achievement.png"; public static final String MENTION_SOUND = "theme/sounds/userMentionSound.mp3"; public static final String CSS_CLASS_ICON = "icon"; - public static final String CHAT_CONTAINER = "theme/chat/chat_container.html"; - public static final String CHAT_SECTION_EXTENDED = "theme/chat/extended/chat_section.html"; - public static final String CHAT_SECTION_COMPACT = "theme/chat/compact/chat_section.html"; - public static final String CHAT_TEXT_EXTENDED = "theme/chat/extended/chat_text.html"; - public static final String CHAT_TEXT_COMPACT = "theme/chat/compact/chat_text.html"; public static final String CHAT_LIST_STATUS_HOSTING = "theme/images/player_status/host.png"; public static final String CHAT_LIST_STATUS_LOBBYING = "theme/images/player_status/lobby.png"; public static final String CHAT_LIST_STATUS_PLAYING = "theme/images/player_status/playing.png"; diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index 0760eca7ca..9b84394ab1 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -87,6 +87,7 @@ statusBar.reconnect = Reconnect statusBar.taskWithoutMessage.format = {0} statusBar.taskWithMessage.format = {0}\: {1} news.authoredFormat = {0} on {1,date} +chat.replyingTo=Replying to {0}: chat.noOpenChats = No chats are open chat.joinAChannel = Join a channel chat.channelNamePrompt = e.g. \#aeolus diff --git a/src/main/resources/images/emoticons/emoticons.json b/src/main/resources/images/emoticons/emoticons.json index 80743a10fd..cc82cdfd76 100644 --- a/src/main/resources/images/emoticons/emoticons.json +++ b/src/main/resources/images/emoticons/emoticons.json @@ -1,7 +1,6 @@ [ { "name": "FAF", - "attribution": "", "emoticons": [ { "shortcodes": [ @@ -31,7 +30,7 @@ }, { "name": "Common", - "attribution": "https://twemoji.twitter.com/", + "attributionUrl": "https://twemoji.twitter.com/", "emoticons": [ { "shortcodes": [ diff --git a/src/main/resources/theme/chat/chat_container.html b/src/main/resources/theme/chat/chat_container.html deleted file mode 100644 index 9553eeebe1..0000000000 --- a/src/main/resources/theme/chat/chat_container.html +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - -
- -
- -
-
- - diff --git a/src/main/resources/theme/chat/chat_message.fxml b/src/main/resources/theme/chat/chat_message.fxml index a2e56b93ea..52e2586323 100644 --- a/src/main/resources/theme/chat/chat_message.fxml +++ b/src/main/resources/theme/chat/chat_message.fxml @@ -1,26 +1,56 @@ + + + + fx:id="root" styleClass="message-container" onMouseEntered="#onMouseEntered" onMouseExited="#onMouseExited"> - + + + + + + + + + + + + + + diff --git a/src/main/resources/theme/chat/chat_message_view.fxml b/src/main/resources/theme/chat/chat_message_view.fxml index ed1ab54ed4..06656c974b 100644 --- a/src/main/resources/theme/chat/chat_message_view.fxml +++ b/src/main/resources/theme/chat/chat_message_view.fxml @@ -1,5 +1,6 @@ + @@ -13,6 +14,22 @@ fx:id="root">