diff --git a/README.md b/README.md index f47311d..dc37eb0 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ You can now also attach breakpoints in code for debugging purposes, by clicking | Variable | Default | Description | |-------------------|------------------------------------|-----------------------------------------------------------------| +| ADMIN_PASSWORD | | The password to get admin access (empty = disabled). | | DOAG_EVENT_ID | 0 | The ID of the DOAG event to read the conference agenda. | | FILTER_REPLIES | true | Hide social media messages which are replies. | | FILTER_SENSITIVE | true | Hide social media messages which contain sensitive information. | diff --git a/src/main/java/swiss/fihlon/apus/configuration/Admin.java b/src/main/java/swiss/fihlon/apus/configuration/Admin.java new file mode 100644 index 0000000..77fbd68 --- /dev/null +++ b/src/main/java/swiss/fihlon/apus/configuration/Admin.java @@ -0,0 +1,22 @@ +/* + * Apus - A social wall for conferences with additional features. + * Copyright (C) Marcus Fihlon and the individual contributors to Apus. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package swiss.fihlon.apus.configuration; + +import org.jetbrains.annotations.NotNull; + +public record Admin(@NotNull String password) { } diff --git a/src/main/java/swiss/fihlon/apus/configuration/Configuration.java b/src/main/java/swiss/fihlon/apus/configuration/Configuration.java index 1413499..7c8a54c 100644 --- a/src/main/java/swiss/fihlon/apus/configuration/Configuration.java +++ b/src/main/java/swiss/fihlon/apus/configuration/Configuration.java @@ -28,6 +28,7 @@ public class Configuration { private String version; + private Admin admin; private DOAG doag; private Mastodon mastodon; private Filter filter; @@ -39,6 +40,14 @@ public void setVersion(@NotNull final String version) { this.version = version; } + public Admin getAdmin() { + return admin; + } + + public void setAdmin(@NotNull final Admin admin) { + this.admin = admin; + } + public DOAG getDoag() { return doag; } diff --git a/src/main/java/swiss/fihlon/apus/service/SocialService.java b/src/main/java/swiss/fihlon/apus/service/SocialService.java index 1b0f444..3a5e511 100644 --- a/src/main/java/swiss/fihlon/apus/service/SocialService.java +++ b/src/main/java/swiss/fihlon/apus/service/SocialService.java @@ -20,6 +20,8 @@ import jakarta.annotation.PreDestroy; import org.jetbrains.annotations.NotNull; import org.jsoup.Jsoup; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.scheduling.TaskScheduler; import org.springframework.stereotype.Service; import swiss.fihlon.apus.configuration.Configuration; @@ -27,9 +29,12 @@ import swiss.fihlon.apus.social.mastodon.MastodonAPI; import java.time.Duration; +import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Locale; +import java.util.Set; import java.util.concurrent.ScheduledFuture; @Service @@ -37,6 +42,7 @@ public final class SocialService { private static final Duration UPDATE_FREQUENCY = Duration.ofMinutes(1); private static final Locale DEFAULT_LOCALE = Locale.getDefault(); + private static final Logger LOGGER = LoggerFactory.getLogger(SocialService.class); private final ScheduledFuture updateScheduler; private final MastodonAPI mastodonAPI; @@ -44,6 +50,7 @@ public final class SocialService { private final boolean filterReplies; private final boolean filterSensitive; private final List filterWords; + private final Set manuallyHiddenId = new HashSet<>(); private List messages = List.of(); public SocialService(@NotNull final TaskScheduler taskScheduler, @@ -66,12 +73,13 @@ public void stopUpdateScheduler() { private void updateMessages() { final var newMessages = mastodonAPI.getMessages(hashtag).stream() + .filter(message -> !manuallyHiddenId.contains(message.id())) .filter(message -> !filterSensitive || !message.isSensitive()) .filter(message -> !filterReplies || !message.isReply()) .filter(this::checkWordFilter) .toList(); synchronized (this) { - messages = newMessages; + messages = new ArrayList<>(newMessages); } } @@ -95,4 +103,10 @@ public List getMessages(final int limit) { } } + public void hideMessage(@NotNull final Message message) { + LOGGER.warn("Hiding message (id={}, profile={}, author={})", + message.id(), message.profile(), message.author()); + messages.remove(message); + manuallyHiddenId.add(message.id()); + } } diff --git a/src/main/java/swiss/fihlon/apus/ui/view/MessageView.java b/src/main/java/swiss/fihlon/apus/ui/view/MessageView.java index 9d50ea7..05f5645 100644 --- a/src/main/java/swiss/fihlon/apus/ui/view/MessageView.java +++ b/src/main/java/swiss/fihlon/apus/ui/view/MessageView.java @@ -22,14 +22,23 @@ import com.vaadin.flow.component.Text; import com.vaadin.flow.component.UI; import com.vaadin.flow.component.avatar.Avatar; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.confirmdialog.ConfirmDialog; +import com.vaadin.flow.component.contextmenu.ContextMenu; import com.vaadin.flow.component.dependency.CssImport; +import com.vaadin.flow.component.dialog.Dialog; import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.html.Footer; import com.vaadin.flow.component.html.Header; import com.vaadin.flow.component.html.Image; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.textfield.PasswordField; +import com.vaadin.flow.data.value.ValueChangeMode; import org.jetbrains.annotations.NotNull; import org.jsoup.Jsoup; import org.ocpsoft.prettytime.PrettyTime; +import swiss.fihlon.apus.configuration.Configuration; +import swiss.fihlon.apus.service.SocialService; import swiss.fihlon.apus.social.Message; @CssImport(value = "./themes/apus/views/message-view.css") @@ -37,8 +46,15 @@ public final class MessageView extends Div { private static final int MAX_LENGTH = 500; private static final String TRUNC_INDICATOR = " […]"; + private final transient SocialService socialService; + private final transient Configuration configuration; - public MessageView(@NotNull final Message message) { + public MessageView(@NotNull final Message message, + @NotNull final SocialService socialService, + @NotNull final Configuration configuration) { + this.socialService = socialService; + this.configuration = configuration; + setId("message-" + message.id()); addClassName("message-view"); add(createHeaderComponent(message)); add(createTextComponent(message)); @@ -47,7 +63,7 @@ public MessageView(@NotNull final Message message) { } @NotNull Component createHeaderComponent(@NotNull final Message message) { - final var avatar = new Avatar(message.author(), message.avatar()); + final var avatar = createAvatarComponent(message); final var author = new Div(new Text(message.author())); author.addClassName("author"); final var profile = new Div(new Text(message.profile())); @@ -57,6 +73,73 @@ public MessageView(@NotNull final Message message) { return new Header(avatar, authorContainer); } + private Component createAvatarComponent(@NotNull final Message message) { + final var avatar = new Avatar(message.author(), message.avatar()); + if (!configuration.getAdmin().password().isBlank()) { + final var menu = new ContextMenu(); + menu.addItem("Hide", event -> confirmHideMessage(message)); + menu.setTarget(avatar); + } + return avatar; + } + + private void confirmHideMessage(@NotNull final Message message) { + final var dialog = new ConfirmDialog(); + dialog.setHeader("Confirm hide message"); + dialog.setText(String.format("Do you really want to hide the message from %s posted at %s?", message.author(), message.date())); + dialog.setCloseOnEsc(true); + + dialog.setCancelable(true); + dialog.addCancelListener(event -> dialog.close()); + + dialog.setConfirmText("Hide"); + dialog.addConfirmListener(event -> { + dialog.close(); + authorizeHideMessage(message); + }); + + dialog.open(); + } + + private void authorizeHideMessage(@NotNull final Message message) { + final Dialog dialog = new Dialog(); + dialog.setHeaderTitle("Authorize hide message"); + dialog.setCloseOnEsc(true); + dialog.setCloseOnOutsideClick(true); + + final PasswordField passwordField = new PasswordField(); + passwordField.setPlaceholder("password"); + passwordField.setRequired(true); + passwordField.setValueChangeMode(ValueChangeMode.EAGER); + + final Button hideButton = new Button("Hide", event -> { + dialog.close(); + hideMessage(message, passwordField.getValue()); + }); + hideButton.setEnabled(false); + hideButton.setDisableOnClick(true); + + final Button cancelButton = new Button("Cancel", event -> dialog.close()); + cancelButton.setDisableOnClick(true); + dialog.getFooter().add(hideButton, cancelButton); + + passwordField.addKeyDownListener(event -> hideButton.setEnabled(!passwordField.isEmpty())); + dialog.add(passwordField); + + dialog.open(); + passwordField.focus(); + } + + private void hideMessage(@NotNull final Message message, @NotNull final String password) { + if (password.equals(configuration.getAdmin().password())) { + socialService.hideMessage(message); + removeFromParent(); + Notification.show("The message was hidden as requested."); + } else { + Notification.show("You are not authorized to hide messages!"); + } + } + @NotNull private Component createTextComponent(@NotNull final Message message) { final String messageText = Jsoup.parse(message.html()).text(); diff --git a/src/main/java/swiss/fihlon/apus/ui/view/SocialView.java b/src/main/java/swiss/fihlon/apus/ui/view/SocialView.java index 003e4ea..03ede1a 100644 --- a/src/main/java/swiss/fihlon/apus/ui/view/SocialView.java +++ b/src/main/java/swiss/fihlon/apus/ui/view/SocialView.java @@ -35,12 +35,14 @@ public final class SocialView extends Div { private static final Duration UPDATE_FREQUENCY = Duration.ofMinutes(1); private final transient SocialService socialService; + private final transient Configuration configuration; private final Div messageContainer = new Div(); public SocialView(@NotNull final SocialService socialService, @NotNull final TaskScheduler taskScheduler, @NotNull final Configuration configuration) { this.socialService = socialService; + this.configuration = configuration; setId("social-view"); add(new H2(getTranslation("social.heading", configuration.getMastodon().hashtag()))); @@ -58,7 +60,7 @@ private void updateScheduler() { private void updateMessages() { messageContainer.removeAll(); for (final Message message : socialService.getMessages(30)) { - messageContainer.add(new MessageView(message)); + messageContainer.add(new MessageView(message, socialService, configuration)); } } } diff --git a/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 0815139..8e83595 100644 --- a/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -29,5 +29,10 @@ "name": "apus.filter.words", "type": "java.lang.String", "description": "Hide social media messages which contain these words." + }, + { + "name": "apus.admin.password", + "type": "java.lang.String", + "description": "The password to get admin access (empty = disabled)." } ] } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 3235483..2661ffe 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -9,6 +9,7 @@ vaadin.launch-browser=false vaadin.frontend.hotdeploy=true apus.version=@project.version@ +apus.admin.password=${ADMIN_PASSWORD:} apus.doag.eventId=${DOAG_EVENT_ID:0} apus.mastodon.instance=${MASTODON_INSTANCE:mastodon.social} apus.mastodon.hashtag=${MASTODON_HASHTAG:java}