From 1414f59a20f9571013c57da0ebc1e862afb65fa6 Mon Sep 17 00:00:00 2001 From: Gabriel Felipe Souza Santos Date: Tue, 27 Oct 2026 18:10:05 -0300 Subject: [PATCH] feat(activity): foco de peer, badges contados vs indicador MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PeerActivityFocus suprime notificações no canal visível; screencast só prepara IP e signal; chat sem eco na sala. Co-authored-by: Cursor --- docs/decision-register.md | 1 + .../api/plugin/PeerActivityFocusTokens.java | 33 ++++ .../activity/PeerActivityFocus.java | 45 +++++ .../activity/PeerActivityInbox.java | 156 ++++++++++++++++-- .../application/presence/PresenceService.java | 13 +- .../application/presence/UserPeerView.java | 20 ++- .../pitiupi/control/ConnectionManager.java | 8 + .../presentation/shell/MainShellFrame.java | 6 + .../presentation/shell/NetworkUsersPanel.java | 12 +- .../presentation/shell/ui/ShellUi.java | 32 +++- .../activity/PeerActivityFocusTest.java | 46 ++++++ .../activity/PeerActivityInboxTest.java | 21 ++- .../chat/src/pitiupi/chat/GUI/ChatPanel.java | 41 ++++- .../src/pitiupi/chat/plugin/ChatPlugin.java | 22 ++- .../screenCast/GUI/ScreenCastPanel.java | 80 +++++++-- .../screenCast/plugin/ScreenCastPlugin.java | 17 +- 16 files changed, 497 insertions(+), 56 deletions(-) create mode 100644 pitiupi/src/pitiupi/api/plugin/PeerActivityFocusTokens.java create mode 100644 pitiupi/src/pitiupi/application/activity/PeerActivityFocus.java create mode 100644 pitiupi/test/pitiupi/application/activity/PeerActivityFocusTest.java diff --git a/docs/decision-register.md b/docs/decision-register.md index 9e46771..708eec9 100644 --- a/docs/decision-register.md +++ b/docs/decision-register.md @@ -20,5 +20,6 @@ | D20 | Peer actions por plugin no popup | Lista lateral agrupa ações de todos os plugins (submenus), não só a aba ativa | [09_sistema_de_plugins.md](documentation/09_sistema_de_plugins.md) | | D21 | `onShellTabClosed` | Fechar aba do plugin na shell notifica o plugin (ex.: parar screencast) | [09_sistema_de_plugins.md](documentation/09_sistema_de_plugins.md) | | D22 | `PeerIpv4` para IP de peer na UI | No Windows, `InetAddress.getHostAddress()` pode devolver IPv6-mapeado e sufixo de zona (`%n`); chat e roteamento usam IPv4 pontilhado | [appendix_portas_e_multicast.md](documentation/appendix_portas_e_multicast.md) | +| D23 | Foco de atividade e badges | `PeerActivityFocus` + `PeerActivityFocusTokens`; chat usa contagem (`recordCounted`), screencast usa indicador (`signal`); suprime badge no canal em foco | [09_sistema_de_plugins.md](documentation/09_sistema_de_plugins.md) | Novas decisões: adicionar linha nesta tabela e detalhar no arquivo técnico correspondente. diff --git a/pitiupi/src/pitiupi/api/plugin/PeerActivityFocusTokens.java b/pitiupi/src/pitiupi/api/plugin/PeerActivityFocusTokens.java new file mode 100644 index 0000000..b71ab12 --- /dev/null +++ b/pitiupi/src/pitiupi/api/plugin/PeerActivityFocusTokens.java @@ -0,0 +1,33 @@ +package pitiupi.api.plugin; + +import pitiupi.presentation.shell.PeerIpv4; + +/** + * Tokens de foco para suprimir badges enquanto o usuário vê o canal correspondente. + */ +public final class PeerActivityFocusTokens { + + public static final String BROADCAST = "broadcast"; + + private PeerActivityFocusTokens() { + } + + public static String broadcast() { + return BROADCAST; + } + + public static String peerInstance(String instanceId) { + if (instanceId == null || instanceId.isBlank()) { + return ""; + } + return "peer:" + instanceId.trim(); + } + + public static String peerIp(String ip) { + String normalized = PeerIpv4.normalize(ip != null ? ip : ""); + if (normalized.isEmpty()) { + return ""; + } + return "peer-ip:" + normalized; + } +} diff --git a/pitiupi/src/pitiupi/application/activity/PeerActivityFocus.java b/pitiupi/src/pitiupi/application/activity/PeerActivityFocus.java new file mode 100644 index 0000000..2b17bb9 --- /dev/null +++ b/pitiupi/src/pitiupi/application/activity/PeerActivityFocus.java @@ -0,0 +1,45 @@ +package pitiupi.application.activity; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Canal de atividade em foco por plugin (sub-aba / sessão visível). + */ +public final class PeerActivityFocus { + + private final Map focusedChannelByPlugin = new ConcurrentHashMap<>(); + private PeerActivityInbox inbox; + + public void bindInbox(PeerActivityInbox inbox) { + this.inbox = inbox; + } + + public void setFocusedChannel(String pluginId, String focusToken) { + if (pluginId == null || pluginId.isBlank()) { + return; + } + if (focusToken == null || focusToken.isBlank()) { + focusedChannelByPlugin.remove(pluginId); + return; + } + focusedChannelByPlugin.put(pluginId, focusToken); + if (inbox != null) { + inbox.clearForPluginFocus(pluginId, focusToken); + } + } + + public void clear(String pluginId) { + if (pluginId != null) { + focusedChannelByPlugin.remove(pluginId); + } + } + + public boolean isFocused(String pluginId, String focusToken) { + if (pluginId == null || focusToken == null || focusToken.isBlank()) { + return false; + } + String current = focusedChannelByPlugin.get(pluginId); + return focusToken.equals(current); + } +} diff --git a/pitiupi/src/pitiupi/application/activity/PeerActivityInbox.java b/pitiupi/src/pitiupi/application/activity/PeerActivityInbox.java index 0633cfa..22a06eb 100644 --- a/pitiupi/src/pitiupi/application/activity/PeerActivityInbox.java +++ b/pitiupi/src/pitiupi/application/activity/PeerActivityInbox.java @@ -1,54 +1,161 @@ package pitiupi.application.activity; +import pitiupi.api.plugin.PeerActivityFocusTokens; + import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** - * Pendências de atividade por peer (mensagem, tela, etc.) para badges na lista lateral. + * Pendências de atividade por peer: contagem (chat) ou indicador (screencast). */ public final class PeerActivityInbox { public static final String BROADCAST_PEER_KEY = "__broadcast__"; - private final Map> countByPeerAndAction = new ConcurrentHashMap<>(); + private final Map> countedByPeerAndAction = new ConcurrentHashMap<>(); + private final Map> indicatorByPeerAndAction = new ConcurrentHashMap<>(); + private PeerActivityFocus focus; private Runnable onChanged = () -> { }; + public void setFocus(PeerActivityFocus focus) { + this.focus = focus; + if (focus != null) { + focus.bindInbox(this); + } + } + public void setOnChanged(Runnable onChanged) { this.onChanged = onChanged != null ? onChanged : () -> { }; } + /** @deprecated use {@link #recordCounted} */ + @Deprecated public void record(String peerInstanceId, String pluginId, String peerActionId) { - String peerKey = peerKey(peerInstanceId); - String actionKey = actionKey(pluginId, peerActionId); - countByPeerAndAction.computeIfAbsent(peerKey, k -> new ConcurrentHashMap<>()) - .merge(actionKey, 1, Integer::sum); - onChanged.run(); + recordCounted(peerInstanceId, pluginId, peerActionId, null); } public void recordBroadcast(String pluginId, String peerActionId) { - record(BROADCAST_PEER_KEY, pluginId, peerActionId); + recordCounted(BROADCAST_PEER_KEY, pluginId, peerActionId, PeerActivityFocusTokens.broadcast()); } + public void recordCounted(String peerInstanceId, String pluginId, String peerActionId, String focusToken) { + if (shouldSkip(pluginId, focusToken)) { + return; + } + String peerKey = peerKey(peerInstanceId); + String actionKey = actionKey(pluginId, peerActionId); + countedByPeerAndAction.computeIfAbsent(peerKey, k -> new ConcurrentHashMap<>()) + .merge(actionKey, 1, Integer::sum); + notifyChanged(); + } + + public void recordBroadcastCounted(String pluginId, String peerActionId, String focusToken) { + recordCounted(BROADCAST_PEER_KEY, pluginId, peerActionId, focusToken); + } + + public void signal(String peerInstanceId, String pluginId, String peerActionId, String focusToken) { + if (shouldSkip(pluginId, focusToken)) { + return; + } + String peerKey = peerKey(peerInstanceId); + String actionKey = actionKey(pluginId, peerActionId); + indicatorByPeerAndAction.computeIfAbsent(peerKey, k -> new ConcurrentHashMap<>()) + .put(actionKey, 1); + notifyChanged(); + } + + public void signalBroadcast(String pluginId, String peerActionId, String focusToken) { + signal(BROADCAST_PEER_KEY, pluginId, peerActionId, focusToken); + } + + public int totalCountedForPeer(String peerInstanceId) { + return sum(countedByPeerAndAction.getOrDefault(peerKey(peerInstanceId), Map.of())); + } + + /** @deprecated use {@link #totalCountedForPeer} */ + @Deprecated public int totalForPeer(String peerInstanceId) { - return sum(countByPeerAndAction.getOrDefault(peerKey(peerInstanceId), Map.of())); + return totalCountedForPeer(peerInstanceId); + } + + public boolean hasIndicatorForPeer(String peerInstanceId) { + Map map = indicatorByPeerAndAction.get(peerKey(peerInstanceId)); + return map != null && !map.isEmpty(); } public int countForAction(String peerInstanceId, String pluginId, String peerActionId) { - return countByPeerAndAction + return countedByPeerAndAction .getOrDefault(peerKey(peerInstanceId), Map.of()) .getOrDefault(actionKey(pluginId, peerActionId), 0); } + public boolean hasIndicatorForAction(String peerInstanceId, String pluginId, String peerActionId) { + return indicatorByPeerAndAction + .getOrDefault(peerKey(peerInstanceId), Map.of()) + .containsKey(actionKey(pluginId, peerActionId)); + } + public void clearAction(String peerInstanceId, String pluginId, String peerActionId) { - Map actions = countByPeerAndAction.get(peerKey(peerInstanceId)); - if (actions != null) { - actions.remove(actionKey(pluginId, peerActionId)); - if (actions.isEmpty()) { - countByPeerAndAction.remove(peerKey(peerInstanceId)); + String peerKey = peerKey(peerInstanceId); + String actionKey = actionKey(pluginId, peerActionId); + removeAction(peerKey, actionKey); + notifyChanged(); + } + + void clearForPluginFocus(String pluginId, String focusToken) { + String peerKey = peerKeyFromFocusToken(focusToken); + if (peerKey == null || pluginId == null) { + return; + } + String prefix = pluginId + ":"; + clearPluginActionsOnPeer(peerKey, prefix); + notifyChanged(); + } + + private void clearPluginActionsOnPeer(String peerKey, String pluginIdPrefix) { + Map counted = countedByPeerAndAction.get(peerKey); + if (counted != null) { + counted.keySet().removeIf(k -> k.startsWith(pluginIdPrefix)); + if (counted.isEmpty()) { + countedByPeerAndAction.remove(peerKey); } } + Map indicators = indicatorByPeerAndAction.get(peerKey); + if (indicators != null) { + indicators.keySet().removeIf(k -> k.startsWith(pluginIdPrefix)); + if (indicators.isEmpty()) { + indicatorByPeerAndAction.remove(peerKey); + } + } + } + + private void removeAction(String peerKey, String actionKey) { + Map counted = countedByPeerAndAction.get(peerKey); + if (counted != null) { + counted.remove(actionKey); + if (counted.isEmpty()) { + countedByPeerAndAction.remove(peerKey); + } + } + Map indicators = indicatorByPeerAndAction.get(peerKey); + if (indicators != null) { + indicators.remove(actionKey); + if (indicators.isEmpty()) { + indicatorByPeerAndAction.remove(peerKey); + } + } + } + + private boolean shouldSkip(String pluginId, String focusToken) { + return focus != null + && focusToken != null + && !focusToken.isBlank() + && focus.isFocused(pluginId, focusToken); + } + + private void notifyChanged() { onChanged.run(); } @@ -60,7 +167,26 @@ public final class PeerActivityInbox { return total; } + static String peerKeyFromFocusToken(String focusToken) { + if (focusToken == null || focusToken.isBlank()) { + return null; + } + if (PeerActivityFocusTokens.BROADCAST.equals(focusToken)) { + return BROADCAST_PEER_KEY; + } + if (focusToken.startsWith("peer:")) { + return focusToken.substring("peer:".length()); + } + if (focusToken.startsWith("peer-ip:")) { + return BROADCAST_PEER_KEY; + } + return null; + } + private static String peerKey(String peerInstanceId) { + if (BROADCAST_PEER_KEY.equals(peerInstanceId)) { + return BROADCAST_PEER_KEY; + } return peerInstanceId != null ? peerInstanceId : BROADCAST_PEER_KEY; } diff --git a/pitiupi/src/pitiupi/application/presence/PresenceService.java b/pitiupi/src/pitiupi/application/presence/PresenceService.java index 0e8ca0e..e7070e5 100644 --- a/pitiupi/src/pitiupi/application/presence/PresenceService.java +++ b/pitiupi/src/pitiupi/application/presence/PresenceService.java @@ -48,10 +48,12 @@ public final class PresenceService { public List listPeerViews() { List views = new ArrayList<>(); - int broadcastActivity = peerActivityInbox != null - ? peerActivityInbox.totalForPeer(PeerActivityInbox.BROADCAST_PEER_KEY) + int broadcastCounted = peerActivityInbox != null + ? peerActivityInbox.totalCountedForPeer(PeerActivityInbox.BROADCAST_PEER_KEY) : 0; - views.add(new UserPeerView("Todos", null, null, broadcastActivity, false, true)); + boolean broadcastIndicator = peerActivityInbox != null + && peerActivityInbox.hasIndicatorForPeer(PeerActivityInbox.BROADCAST_PEER_KEY); + views.add(new UserPeerView("Todos", null, null, broadcastCounted, broadcastIndicator, false, true)); String selfId = heartbeatManager.getInstanceId(); for (User user : heartbeatManager.getPeers()) { boolean isSelf = selfId.equals(user.getInstanceId()); @@ -59,14 +61,17 @@ public final class PresenceService { if (inviteInboxService != null && user.getInstanceId() != null) { notifications += inviteInboxService.getNotificationCount(user.getInstanceId()); } + boolean activityIndicator = false; if (peerActivityInbox != null && user.getInstanceId() != null) { - notifications += peerActivityInbox.totalForPeer(user.getInstanceId()); + notifications += peerActivityInbox.totalCountedForPeer(user.getInstanceId()); + activityIndicator = peerActivityInbox.hasIndicatorForPeer(user.getInstanceId()); } views.add(new UserPeerView( user.getUserName(), user.getInstanceId(), user.getAddress(), notifications, + activityIndicator, isSelf, false )); diff --git a/pitiupi/src/pitiupi/application/presence/UserPeerView.java b/pitiupi/src/pitiupi/application/presence/UserPeerView.java index 610654f..b3d4745 100644 --- a/pitiupi/src/pitiupi/application/presence/UserPeerView.java +++ b/pitiupi/src/pitiupi/application/presence/UserPeerView.java @@ -12,6 +12,7 @@ public final class UserPeerView { private final String instanceId; private final InetAddress inetAddress; private final int notificationCount; + private final boolean activityIndicator; private final boolean isSelf; private final boolean isBroadcastTarget; @@ -22,11 +23,24 @@ public final class UserPeerView { int notificationCount, boolean isSelf, boolean isBroadcastTarget + ) { + this(displayName, instanceId, inetAddress, notificationCount, false, isSelf, isBroadcastTarget); + } + + public UserPeerView( + String displayName, + String instanceId, + InetAddress inetAddress, + int notificationCount, + boolean activityIndicator, + boolean isSelf, + boolean isBroadcastTarget ) { this.displayName = displayName; this.instanceId = instanceId; this.inetAddress = inetAddress; this.notificationCount = notificationCount; + this.activityIndicator = activityIndicator; this.isSelf = isSelf; this.isBroadcastTarget = isBroadcastTarget; } @@ -47,6 +61,10 @@ public final class UserPeerView { return notificationCount; } + public boolean hasActivityIndicator() { + return activityIndicator; + } + public boolean isSelf() { return isSelf; } @@ -56,6 +74,6 @@ public final class UserPeerView { } public static UserPeerView broadcastTarget() { - return new UserPeerView("Todos", null, null, 0, false, true); + return new UserPeerView("Todos", null, null, 0, false, false, true); } } diff --git a/pitiupi/src/pitiupi/control/ConnectionManager.java b/pitiupi/src/pitiupi/control/ConnectionManager.java index b7c7e0f..7a7e8a2 100644 --- a/pitiupi/src/pitiupi/control/ConnectionManager.java +++ b/pitiupi/src/pitiupi/control/ConnectionManager.java @@ -1,5 +1,6 @@ package pitiupi.control; +import pitiupi.application.activity.PeerActivityFocus; import pitiupi.application.activity.PeerActivityInbox; import pitiupi.application.invites.InviteInboxService; import pitiupi.application.peeractions.PeerActionBroker; @@ -26,6 +27,7 @@ public class ConnectionManager { private final PeerActionBroker peerActionBroker; private final InviteInboxService inviteInboxService; private final PeerActivityInbox peerActivityInbox; + private final PeerActivityFocus peerActivityFocus; private SocketTCP socketTCP; private SocketUDP socketUDP; @@ -49,6 +51,8 @@ public class ConnectionManager { peerActionBroker = new PeerActionBroker(); inviteInboxService = new InviteInboxService(heartbeatManager); peerActivityInbox = new PeerActivityInbox(); + peerActivityFocus = new PeerActivityFocus(); + peerActivityInbox.setFocus(peerActivityFocus); presenceService = new PresenceService(heartbeatManager, inviteInboxService, peerActivityInbox); inviteInboxService.setOnNotificationsChanged(heartbeatManager::notifyPeerListeners); peerActivityInbox.setOnChanged(heartbeatManager::notifyPeerListeners); @@ -73,6 +77,10 @@ public class ConnectionManager { return peerActivityInbox; } + public PeerActivityFocus getPeerActivityFocus() { + return peerActivityFocus; + } + public void setInviteAcceptedUiListener(Consumer listener) { this.inviteAcceptedUiListener = listener != null ? listener : pluginId -> { }; diff --git a/pitiupi/src/pitiupi/presentation/shell/MainShellFrame.java b/pitiupi/src/pitiupi/presentation/shell/MainShellFrame.java index 0b222d6..be94d8c 100644 --- a/pitiupi/src/pitiupi/presentation/shell/MainShellFrame.java +++ b/pitiupi/src/pitiupi/presentation/shell/MainShellFrame.java @@ -64,6 +64,12 @@ public final class MainShellFrame extends JFrame implements ShellHost { if (activeId != null) { connectionManager.getPeerActionBroker().setActivePlugin(activeId); } + for (Plugin plugin : pluginManager.getLoadedPlugins()) { + String pluginId = plugin.getIdentifier(); + if (activeId == null || !activeId.equals(pluginId)) { + connectionManager.getPeerActivityFocus().clear(pluginId); + } + } }); tabWorkspace.setOnSettingsTabSelected(settingsPanel::onTabSelected); tabWorkspace.setOnPluginTabClosed(pluginManager::notifyShellTabClosed); diff --git a/pitiupi/src/pitiupi/presentation/shell/NetworkUsersPanel.java b/pitiupi/src/pitiupi/presentation/shell/NetworkUsersPanel.java index 1a8cf30..72c9b07 100644 --- a/pitiupi/src/pitiupi/presentation/shell/NetworkUsersPanel.java +++ b/pitiupi/src/pitiupi/presentation/shell/NetworkUsersPanel.java @@ -70,7 +70,8 @@ public final class NetworkUsersPanel extends JPanel implements PeerListener { if (value instanceof DisplayPeer peer) { label.setText(ShellUi.formatPeerListLabel( peer.view.getDisplayName(), - peer.view.getNotificationCount() + peer.view.getNotificationCount(), + peer.view.hasActivityIndicator() )); } return label; @@ -101,6 +102,10 @@ public final class NetworkUsersPanel extends JPanel implements PeerListener { connectionManager.getPeerActivityInbox().setOnChanged(this::refreshFromPresence); } + private static boolean isIndicatorAction(String actionId) { + return actionId != null && actionId.startsWith("screencast."); + } + private void showPeerActions(DisplayPeer peer, PeerActionTarget target, int x, int y) { JPopupMenu popup = new JPopupMenu(); PeerActionBroker broker = connectionManager.getPeerActionBroker(); @@ -119,8 +124,11 @@ public final class NetworkUsersPanel extends JPanel implements PeerListener { int pending = activityPeerKey != null ? activity.countForAction(activityPeerKey, group.pluginId(), action.getId()) : 0; + boolean indicator = activityPeerKey != null + && activity.hasIndicatorForAction(activityPeerKey, group.pluginId(), action.getId()); + boolean indicatorOnly = indicator && pending == 0 && isIndicatorAction(action.getId()); JMenuItem item = new JMenuItem( - ShellUi.formatActionLabelWithBadge(action.getLabel(), pending) + ShellUi.formatActionLabelWithBadge(action.getLabel(), pending, indicatorOnly) ); item.addActionListener(e -> { if (activityPeerKey != null) { diff --git a/pitiupi/src/pitiupi/presentation/shell/ui/ShellUi.java b/pitiupi/src/pitiupi/presentation/shell/ui/ShellUi.java index d4cb514..17ff53b 100644 --- a/pitiupi/src/pitiupi/presentation/shell/ui/ShellUi.java +++ b/pitiupi/src/pitiupi/presentation/shell/ui/ShellUi.java @@ -130,13 +130,35 @@ public final class ShellUi { scroll.setViewportBorder(BorderFactory.createEmptyBorder()); } - public static String formatPeerListLabel(String displayName, int notificationCount) { - if (notificationCount <= 0) { + public static String formatPeerListLabel(String displayName, int notificationCount, boolean activityIndicator) { + if (notificationCount <= 0 && !activityIndicator) { return displayName; } - return "" + notificationBadgePrefix() - + escapeHtml(displayName) - + " (" + notificationCount + ")"; + StringBuilder html = new StringBuilder(""); + if (activityIndicator || notificationCount > 0) { + html.append(notificationBadgePrefix()); + } + html.append(escapeHtml(displayName)); + if (notificationCount > 0) { + html.append(" (") + .append(notificationCount).append(")"); + } + html.append(""); + return html.toString(); + } + + public static String formatPeerListLabel(String displayName, int notificationCount) { + return formatPeerListLabel(displayName, notificationCount, false); + } + + public static String formatActionLabelWithBadge(String label, int pendingCount, boolean indicatorOnly) { + if (pendingCount <= 0 && !indicatorOnly) { + return label; + } + if (indicatorOnly) { + return "" + notificationBadgePrefix() + escapeHtml(label) + ""; + } + return formatActionLabelWithBadge(label, pendingCount); } public static String formatActionLabelWithBadge(String label, int pendingCount) { diff --git a/pitiupi/test/pitiupi/application/activity/PeerActivityFocusTest.java b/pitiupi/test/pitiupi/application/activity/PeerActivityFocusTest.java new file mode 100644 index 0000000..fbb909b --- /dev/null +++ b/pitiupi/test/pitiupi/application/activity/PeerActivityFocusTest.java @@ -0,0 +1,46 @@ +package pitiupi.application.activity; + +import org.junit.jupiter.api.Test; +import pitiupi.api.plugin.PeerActivityFocusTokens; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PeerActivityFocusTest { + + @Test + void focusClearsPendingForPluginChannel() { + PeerActivityInbox inbox = new PeerActivityInbox(); + PeerActivityFocus focus = new PeerActivityFocus(); + inbox.setFocus(focus); + + inbox.recordCounted("peer-1", "plugin.chat", "chat.peer", PeerActivityFocusTokens.peerInstance("peer-1")); + assertEquals(1, inbox.totalCountedForPeer("peer-1")); + + focus.setFocusedChannel("plugin.chat", PeerActivityFocusTokens.peerInstance("peer-1")); + assertEquals(0, inbox.totalCountedForPeer("peer-1")); + assertTrue(focus.isFocused("plugin.chat", PeerActivityFocusTokens.peerInstance("peer-1"))); + } + + @Test + void recordSkippedWhileFocused() { + PeerActivityInbox inbox = new PeerActivityInbox(); + PeerActivityFocus focus = new PeerActivityFocus(); + inbox.setFocus(focus); + focus.setFocusedChannel("plugin.chat", PeerActivityFocusTokens.broadcast()); + + inbox.recordBroadcastCounted("plugin.chat", "chat.focus", PeerActivityFocusTokens.broadcast()); + assertEquals(0, inbox.totalCountedForPeer(PeerActivityInbox.BROADCAST_PEER_KEY)); + } + + @Test + void signalCapsAtOne() { + PeerActivityInbox inbox = new PeerActivityInbox(); + inbox.signalBroadcast("plugin.cast", "screencast.multicast", PeerActivityFocusTokens.broadcast()); + inbox.signalBroadcast("plugin.cast", "screencast.multicast", PeerActivityFocusTokens.broadcast()); + assertTrue(inbox.hasIndicatorForPeer(PeerActivityInbox.BROADCAST_PEER_KEY)); + assertTrue(inbox.hasIndicatorForAction( + PeerActivityInbox.BROADCAST_PEER_KEY, "plugin.cast", "screencast.multicast")); + } +} diff --git a/pitiupi/test/pitiupi/application/activity/PeerActivityInboxTest.java b/pitiupi/test/pitiupi/application/activity/PeerActivityInboxTest.java index b25353e..60d8cb1 100644 --- a/pitiupi/test/pitiupi/application/activity/PeerActivityInboxTest.java +++ b/pitiupi/test/pitiupi/application/activity/PeerActivityInboxTest.java @@ -1,19 +1,30 @@ package pitiupi.application.activity; import org.junit.jupiter.api.Test; +import pitiupi.api.plugin.PeerActivityFocusTokens; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; class PeerActivityInboxTest { @Test - void recordsAndClearsPerAction() { + void recordsAndClearsCountedPerAction() { PeerActivityInbox inbox = new PeerActivityInbox(); - inbox.record("peer-1", "plugin.chat", "chat.peer"); - inbox.record("peer-1", "plugin.chat", "chat.peer"); + inbox.recordCounted("peer-1", "plugin.chat", "chat.peer", null); + inbox.recordCounted("peer-1", "plugin.chat", "chat.peer", null); assertEquals(2, inbox.countForAction("peer-1", "plugin.chat", "chat.peer")); - assertEquals(2, inbox.totalForPeer("peer-1")); + assertEquals(2, inbox.totalCountedForPeer("peer-1")); inbox.clearAction("peer-1", "plugin.chat", "chat.peer"); - assertEquals(0, inbox.totalForPeer("peer-1")); + assertEquals(0, inbox.totalCountedForPeer("peer-1")); + } + + @Test + void signalIsIndicatorNotCounted() { + PeerActivityInbox inbox = new PeerActivityInbox(); + inbox.signal("peer-1", "plugin.cast", "screencast.peer", null); + inbox.signal("peer-1", "plugin.cast", "screencast.peer", null); + assertEquals(0, inbox.totalCountedForPeer("peer-1")); + assertTrue(inbox.hasIndicatorForPeer("peer-1")); } } diff --git a/plugins/chat/src/pitiupi/chat/GUI/ChatPanel.java b/plugins/chat/src/pitiupi/chat/GUI/ChatPanel.java index 22b5632..7ee0c79 100644 --- a/plugins/chat/src/pitiupi/chat/GUI/ChatPanel.java +++ b/plugins/chat/src/pitiupi/chat/GUI/ChatPanel.java @@ -4,6 +4,7 @@ import pitiupi.application.messaging.MessageDispatcher; import pitiupi.application.messaging.PluginTransportCatalog; import pitiupi.application.messaging.SendTarget; import pitiupi.application.messaging.TransportMode; +import pitiupi.api.plugin.PeerActivityFocusTokens; import pitiupi.application.presence.UserPeerView; import pitiupi.chat.control.PrivateChatController; import pitiupi.chat.control.PrivateChatUiSink; @@ -68,6 +69,7 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink { private PrivateChatController privateChatController; private PeerDraftPanel draftPanel; private Runnable pendingTranscriptFlush; + private String pluginId; public ChatPanel(ConnectionManager connectionManager) { this.connectionManager = connectionManager; @@ -100,6 +102,10 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink { selectRoomSession(); } + public void setPluginId(String pluginId) { + this.pluginId = pluginId; + } + private void initSessionTabs() { sessionTabs.addOrSelectTab( PluginSessionKeys.ROOM, @@ -146,6 +152,7 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink { if (PluginSessionKeys.ROOM.equals(key)) { routingPanel.bindRoomSession(); switchActiveConversation(ROOM_KEY); + reportActivityFocusForRoom(); return; } if (key.startsWith("peer:") && !PluginSessionKeys.PEER_DRAFT.equals(key)) { @@ -153,9 +160,40 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink { String name = peerDisplayNames.getOrDefault(ip, ip); routingPanel.bindPeerSession(ip, name); switchActiveConversation(resolveConversationKey()); + reportActivityFocusForPeerIp(ip); } } + private void reportActivityFocusForRoom() { + if (pluginId == null) { + return; + } + connectionManager.getPeerActivityFocus() + .setFocusedChannel(pluginId, PeerActivityFocusTokens.broadcast()); + } + + private void reportActivityFocusForPeerIp(String ip) { + if (pluginId == null) { + return; + } + connectionManager.getPeerActivityFocus() + .setFocusedChannel(pluginId, resolvePeerFocusToken(ip)); + } + + private String resolvePeerFocusToken(String ip) { + String canonical = canonicalPeerIp(ip); + for (UserPeerView view : connectionManager.getPresenceService().listPeerViews()) { + if (view.isBroadcastTarget() || view.isSelf()) { + continue; + } + if (view.getInstanceId() != null + && view.getInetAddress().map(PeerIpv4::fromInetAddress).orElse("").equals(canonical)) { + return PeerActivityFocusTokens.peerInstance(view.getInstanceId()); + } + } + return PeerActivityFocusTokens.peerIp(canonical); + } + private void openDraftPeerTab() { String previous = sessionTabs.selectedKey(); if (previous != null && !PluginSessionKeys.PEER_DRAFT.equals(previous)) { @@ -260,6 +298,7 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink { } public void focusGeneralRoom() { + reportActivityFocusForRoom(); sessionTabs.selectTab(PluginSessionKeys.ROOM); } @@ -420,7 +459,7 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink { appendSystemLine(activeConversationKey, "Informe um IP válido para enviar a um peer."); return; } - String ip = LocalNetworkInfo.bestLocalIpv4(); + String ip = PeerIpv4.normalize(LocalNetworkInfo.bestLocalIpv4()); ChatMessage message = new ChatMessage( connectionManager.getUserName(), text, diff --git a/plugins/chat/src/pitiupi/chat/plugin/ChatPlugin.java b/plugins/chat/src/pitiupi/chat/plugin/ChatPlugin.java index ad69ee2..c523a74 100644 --- a/plugins/chat/src/pitiupi/chat/plugin/ChatPlugin.java +++ b/plugins/chat/src/pitiupi/chat/plugin/ChatPlugin.java @@ -2,6 +2,7 @@ package pitiupi.chat.plugin; import pitiupi.api.plugin.PeerActionsRegistrar; import pitiupi.api.plugin.PluginWorkspace; +import pitiupi.api.plugin.PeerActivityFocusTokens; import pitiupi.application.activity.PeerActivityInbox; import pitiupi.application.peeractions.PeerAction; import pitiupi.application.peeractions.PeerActionTarget; @@ -41,6 +42,7 @@ public class ChatPlugin extends Plugin implements PrivateChatHost { this.workspace = workspace; this.privateChatController = new PrivateChatController(connectionManager, this); this.chatPanel = new ChatPanel(connectionManager); + chatPanel.setPluginId(getIdentifier()); chatPanel.setPrivateChatController(privateChatController); privateChatController.setUiSink(chatPanel); bindShellWorkspaceContent(chatPanel); @@ -119,6 +121,10 @@ public class ChatPlugin extends Plugin implements PrivateChatHost { } private void recordChatActivity(ChatMessage msg) { + String localName = connectionManager.getUserName(); + if (localName != null && msg.getName() != null && localName.equals(msg.getName().trim())) { + return; + } if (LocalUserIdentity.isLocalSender(connectionManager, msg.getName(), msg.getSenderIp())) { return; } @@ -126,12 +132,17 @@ public class ChatPlugin extends Plugin implements PrivateChatHost { String tag = msg.getTransportLabel(); boolean multicast = tag != null && tag.toLowerCase().contains("multicast"); if (multicast) { - inbox.recordBroadcast(getIdentifier(), "chat.focus"); + inbox.recordBroadcastCounted(getIdentifier(), "chat.focus", PeerActivityFocusTokens.broadcast()); return; } String instanceId = peerInstanceIdForActivity(msg.getSenderIp()); if (instanceId != null) { - inbox.record(instanceId, getIdentifier(), "chat.peer"); + inbox.recordCounted( + instanceId, + getIdentifier(), + "chat.peer", + PeerActivityFocusTokens.peerInstance(instanceId) + ); } } @@ -169,7 +180,12 @@ public class ChatPlugin extends Plugin implements PrivateChatHost { privateChatController.receiveMessage(connection, legacy); String instanceId = peerInstanceIdForActivity(PeerIpv4.fromInetAddress(connection.getAddress())); if (instanceId != null) { - connectionManager.getPeerActivityInbox().record(instanceId, getIdentifier(), "chat.private.tcp"); + connectionManager.getPeerActivityInbox().recordCounted( + instanceId, + getIdentifier(), + "chat.private.tcp", + PeerActivityFocusTokens.peerInstance(instanceId) + ); } return true; } diff --git a/plugins/screencast/src/pitiupi/screenCast/GUI/ScreenCastPanel.java b/plugins/screencast/src/pitiupi/screenCast/GUI/ScreenCastPanel.java index 55c9366..26bd7a8 100644 --- a/plugins/screencast/src/pitiupi/screenCast/GUI/ScreenCastPanel.java +++ b/plugins/screencast/src/pitiupi/screenCast/GUI/ScreenCastPanel.java @@ -4,6 +4,7 @@ import pitiupi.application.messaging.MessageDispatcher; import pitiupi.application.messaging.PluginTransportCatalog; import pitiupi.application.messaging.SendTarget; import pitiupi.application.messaging.TransportMode; +import pitiupi.api.plugin.PeerActivityFocusTokens; import pitiupi.application.presence.UserPeerView; import pitiupi.control.ConnectionManager; import pitiupi.presentation.shell.PeerDraftPanel; @@ -77,6 +78,7 @@ public class ScreenCastPanel extends JPanel { private final JPanel bodyCards = new JPanel(bodyLayout); private final Map bodyPanels = new HashMap<>(); private PeerDraftPanel draftPanel; + private String pluginId; public ScreenCastPanel(ConnectionManager connectionManager) { this.connectionManager = connectionManager; @@ -147,14 +149,31 @@ public class ScreenCastPanel extends JPanel { sessionTabs.selectTab(PluginSessionKeys.ROOM); } + public void setPluginId(String pluginId) { + this.pluginId = pluginId; + } + private void onSessionTabClosed(String key) { if (PluginSessionKeys.PEER_DRAFT.equals(key)) { if (draftPanel != null) { draftPanel.clearIpField(); } SwingUtilities.invokeLater(this::restoreSessionAfterDraftClosed); + return; } + if (key != null && key.startsWith("view:")) { + peerIpsByViewKey.remove(key); + receiveScopes.remove(key); + bodyPanels.remove(key); + return; + } + boolean wasSharing = isSharingForTab(key); sharingByTabKey.remove(key); + if (wasSharing && key != null && key.equals(shareContextTabKey)) { + cancelShareTimer(); + setShareToggleSelectedSilently(false); + applySharingForActiveTab(); + } } private void restoreSessionAfterDraftClosed() { @@ -190,6 +209,48 @@ public class ScreenCastPanel extends JPanel { if (!isSharingForTab(key)) { refreshOutboundBodyForTab(key); } + reportActivityFocusForSession(key); + } + + private void reportActivityFocusForSession(String key) { + if (pluginId == null || key == null || PluginSessionKeys.PEER_DRAFT.equals(key)) { + return; + } + var focus = connectionManager.getPeerActivityFocus(); + if (PluginSessionKeys.ROOM.equals(key)) { + focus.setFocusedChannel(pluginId, PeerActivityFocusTokens.broadcast()); + return; + } + if (key.startsWith("peer:")) { + String ip = key.substring("peer:".length()); + focus.setFocusedChannel(pluginId, resolvePeerFocusToken(ip)); + return; + } + if (key.startsWith("view:")) { + ScreenCastReceiveScope scope = receiveScopes.getOrDefault(key, ScreenCastReceiveScope.BROADCAST); + if (scope == ScreenCastReceiveScope.BROADCAST) { + focus.setFocusedChannel(pluginId, PeerActivityFocusTokens.broadcast()); + } else { + String ip = peerIpsByViewKey.get(key); + if (ip != null && !ip.isBlank()) { + focus.setFocusedChannel(pluginId, resolvePeerFocusToken(ip)); + } + } + } + } + + private String resolvePeerFocusToken(String ip) { + String canonical = canonicalPeerIp(ip); + for (UserPeerView view : connectionManager.getPresenceService().listPeerViews()) { + if (view.isBroadcastTarget() || view.isSelf()) { + continue; + } + if (view.getInstanceId() != null + && view.getInetAddress().map(PeerIpv4::fromInetAddress).orElse("").equals(canonical)) { + return PeerActivityFocusTokens.peerInstance(view.getInstanceId()); + } + } + return PeerActivityFocusTokens.peerIp(canonical); } private void persistShareStateForContextTab() { @@ -354,29 +415,18 @@ public class ScreenCastPanel extends JPanel { return PeerIpv4.normalize(ip); } - public void focusMulticastSharing() { + public void focusMulticastSession() { ensureTodosTab(); - sharingByTabKey.put(PluginSessionKeys.ROOM, true); + sharingByTabKey.put(PluginSessionKeys.ROOM, false); sessionTabs.selectTab(PluginSessionKeys.ROOM); routingPanel.bindRoomSession(); routingPanel.selectTransport(TransportMode.MULTICAST_UDP); - setShareToggleSelectedSilently(true); + setShareToggleSelectedSilently(false); applySharingForActiveTab(); } public void focusPeerSharing(UserPeerView peer) { - if (peer == null || peer.isBroadcastTarget() || peer.isSelf()) { - return; - } - peer.getInetAddress().ifPresent(addr -> { - String ip = PeerIpv4.fromInetAddress(addr); - openPeerTab(ip, peer.getDisplayName()); - String key = PluginSessionKeys.peer(canonicalPeerIp(ip)); - sharingByTabKey.put(key, true); - routingPanel.selectTransport(TransportMode.UNICAST_UDP); - setShareToggleSelectedSilently(true); - applySharingForActiveTab(); - }); + focusPeerAddress(peer); } public void focusPeerAddress(UserPeerView peer) { diff --git a/plugins/screencast/src/pitiupi/screenCast/plugin/ScreenCastPlugin.java b/plugins/screencast/src/pitiupi/screenCast/plugin/ScreenCastPlugin.java index b459343..a1e91d5 100644 --- a/plugins/screencast/src/pitiupi/screenCast/plugin/ScreenCastPlugin.java +++ b/plugins/screencast/src/pitiupi/screenCast/plugin/ScreenCastPlugin.java @@ -4,6 +4,7 @@ import java.util.List; import pitiupi.api.plugin.PeerActionsRegistrar; import pitiupi.api.plugin.PluginWorkspace; +import pitiupi.api.plugin.PeerActivityFocusTokens; import pitiupi.application.activity.PeerActivityInbox; import pitiupi.application.peeractions.PeerAction; import pitiupi.application.presence.UserPeerView; @@ -55,6 +56,7 @@ public class ScreenCastPlugin extends Plugin { this.connectionManager = connectionManager; this.workspace = workspace; this.screenCastPanel = new ScreenCastPanel(connectionManager); + screenCastPanel.setPluginId(getIdentifier()); bindShellWorkspaceContent(screenCastPanel); installPeerActions(workspace.peerActions()); } @@ -80,17 +82,17 @@ public class ScreenCastPlugin extends Plugin { PeerActionTarget.ALL_PEERS, target -> { workspace.openOrFocusTab(getIdentifier(), getDisplayName()); - screenCastPanel.focusMulticastSharing(); + screenCastPanel.focusMulticastSession(); } )); registrar.register(PeerAction.of( "screencast.peer", - "Compartilhar com este peer", + "Preparar compartilhamento com este peer", PeerActionTarget.SINGLE_PEER, target -> { workspace.openOrFocusTab(getIdentifier(), getDisplayName()); if (target instanceof PeerTarget.Single single) { - screenCastPanel.focusPeerSharing(single.peer()); + screenCastPanel.focusPeerAddress(single.peer()); } } )); @@ -146,11 +148,16 @@ public class ScreenCastPlugin extends Plugin { && msg.getName() != null && msg.getName().equals(view.getDisplayName()) && view.getInstanceId() != null) { - inbox.record(view.getInstanceId(), getIdentifier(), "screencast.peer"); + inbox.signal( + view.getInstanceId(), + getIdentifier(), + "screencast.peer", + PeerActivityFocusTokens.peerInstance(view.getInstanceId()) + ); return; } } } - inbox.recordBroadcast(getIdentifier(), "screencast.multicast"); + inbox.signalBroadcast(getIdentifier(), "screencast.multicast", PeerActivityFocusTokens.broadcast()); } }