feat(activity): foco de peer, badges contados vs indicador

PeerActivityFocus suprime notificações no canal visível; screencast só prepara IP e signal; chat sem eco na sala.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Gabriel Felipe Souza SantosandCursor committed 2026-10-27 18:10:05 -03:00
1 parent f6b2cf3ecd
commit 1414f59a20
16 files changed
+497 -56

No files matched your search

+1
View File
@@ -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) | | 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) | | 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) | | 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. Novas decisões: adicionar linha nesta tabela e detalhar no arquivo técnico correspondente.
@@ -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;
}
}
@@ -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<String, String> 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);
}
}
@@ -1,54 +1,161 @@
package pitiupi.application.activity; package pitiupi.application.activity;
import pitiupi.api.plugin.PeerActivityFocusTokens;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; 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 final class PeerActivityInbox {
public static final String BROADCAST_PEER_KEY = "__broadcast__"; public static final String BROADCAST_PEER_KEY = "__broadcast__";
private final Map<String, Map<String, Integer>> countByPeerAndAction = new ConcurrentHashMap<>(); private final Map<String, Map<String, Integer>> countedByPeerAndAction = new ConcurrentHashMap<>();
private final Map<String, Map<String, Integer>> indicatorByPeerAndAction = new ConcurrentHashMap<>();
private PeerActivityFocus focus;
private Runnable onChanged = () -> { private Runnable onChanged = () -> {
}; };
public void setFocus(PeerActivityFocus focus) {
this.focus = focus;
if (focus != null) {
focus.bindInbox(this);
}
}
public void setOnChanged(Runnable onChanged) { public void setOnChanged(Runnable onChanged) {
this.onChanged = onChanged != null ? onChanged : () -> { this.onChanged = onChanged != null ? onChanged : () -> {
}; };
} }
/** @deprecated use {@link #recordCounted} */
@Deprecated
public void record(String peerInstanceId, String pluginId, String peerActionId) { public void record(String peerInstanceId, String pluginId, String peerActionId) {
String peerKey = peerKey(peerInstanceId); recordCounted(peerInstanceId, pluginId, peerActionId, null);
String actionKey = actionKey(pluginId, peerActionId);
countByPeerAndAction.computeIfAbsent(peerKey, k -> new ConcurrentHashMap<>())
.merge(actionKey, 1, Integer::sum);
onChanged.run();
} }
public void recordBroadcast(String pluginId, String peerActionId) { 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) { public int totalForPeer(String peerInstanceId) {
return sum(countByPeerAndAction.getOrDefault(peerKey(peerInstanceId), Map.of())); return totalCountedForPeer(peerInstanceId);
}
public boolean hasIndicatorForPeer(String peerInstanceId) {
Map<String, Integer> map = indicatorByPeerAndAction.get(peerKey(peerInstanceId));
return map != null && !map.isEmpty();
} }
public int countForAction(String peerInstanceId, String pluginId, String peerActionId) { public int countForAction(String peerInstanceId, String pluginId, String peerActionId) {
return countByPeerAndAction return countedByPeerAndAction
.getOrDefault(peerKey(peerInstanceId), Map.of()) .getOrDefault(peerKey(peerInstanceId), Map.of())
.getOrDefault(actionKey(pluginId, peerActionId), 0); .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) { public void clearAction(String peerInstanceId, String pluginId, String peerActionId) {
Map<String, Integer> actions = countByPeerAndAction.get(peerKey(peerInstanceId)); String peerKey = peerKey(peerInstanceId);
if (actions != null) { String actionKey = actionKey(pluginId, peerActionId);
actions.remove(actionKey(pluginId, peerActionId)); removeAction(peerKey, actionKey);
if (actions.isEmpty()) { notifyChanged();
countByPeerAndAction.remove(peerKey(peerInstanceId)); }
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<String, Integer> counted = countedByPeerAndAction.get(peerKey);
if (counted != null) {
counted.keySet().removeIf(k -> k.startsWith(pluginIdPrefix));
if (counted.isEmpty()) {
countedByPeerAndAction.remove(peerKey);
} }
} }
Map<String, Integer> 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<String, Integer> counted = countedByPeerAndAction.get(peerKey);
if (counted != null) {
counted.remove(actionKey);
if (counted.isEmpty()) {
countedByPeerAndAction.remove(peerKey);
}
}
Map<String, Integer> 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(); onChanged.run();
} }
@@ -60,7 +167,26 @@ public final class PeerActivityInbox {
return total; 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) { private static String peerKey(String peerInstanceId) {
if (BROADCAST_PEER_KEY.equals(peerInstanceId)) {
return BROADCAST_PEER_KEY;
}
return peerInstanceId != null ? peerInstanceId : BROADCAST_PEER_KEY; return peerInstanceId != null ? peerInstanceId : BROADCAST_PEER_KEY;
} }
@@ -48,10 +48,12 @@ public final class PresenceService {
public List<UserPeerView> listPeerViews() { public List<UserPeerView> listPeerViews() {
List<UserPeerView> views = new ArrayList<>(); List<UserPeerView> views = new ArrayList<>();
int broadcastActivity = peerActivityInbox != null int broadcastCounted = peerActivityInbox != null
? peerActivityInbox.totalForPeer(PeerActivityInbox.BROADCAST_PEER_KEY) ? peerActivityInbox.totalCountedForPeer(PeerActivityInbox.BROADCAST_PEER_KEY)
: 0; : 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(); String selfId = heartbeatManager.getInstanceId();
for (User user : heartbeatManager.getPeers()) { for (User user : heartbeatManager.getPeers()) {
boolean isSelf = selfId.equals(user.getInstanceId()); boolean isSelf = selfId.equals(user.getInstanceId());
@@ -59,14 +61,17 @@ public final class PresenceService {
if (inviteInboxService != null && user.getInstanceId() != null) { if (inviteInboxService != null && user.getInstanceId() != null) {
notifications += inviteInboxService.getNotificationCount(user.getInstanceId()); notifications += inviteInboxService.getNotificationCount(user.getInstanceId());
} }
boolean activityIndicator = false;
if (peerActivityInbox != null && user.getInstanceId() != null) { 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( views.add(new UserPeerView(
user.getUserName(), user.getUserName(),
user.getInstanceId(), user.getInstanceId(),
user.getAddress(), user.getAddress(),
notifications, notifications,
activityIndicator,
isSelf, isSelf,
false false
)); ));
@@ -12,6 +12,7 @@ public final class UserPeerView {
private final String instanceId; private final String instanceId;
private final InetAddress inetAddress; private final InetAddress inetAddress;
private final int notificationCount; private final int notificationCount;
private final boolean activityIndicator;
private final boolean isSelf; private final boolean isSelf;
private final boolean isBroadcastTarget; private final boolean isBroadcastTarget;
@@ -22,11 +23,24 @@ public final class UserPeerView {
int notificationCount, int notificationCount,
boolean isSelf, boolean isSelf,
boolean isBroadcastTarget 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.displayName = displayName;
this.instanceId = instanceId; this.instanceId = instanceId;
this.inetAddress = inetAddress; this.inetAddress = inetAddress;
this.notificationCount = notificationCount; this.notificationCount = notificationCount;
this.activityIndicator = activityIndicator;
this.isSelf = isSelf; this.isSelf = isSelf;
this.isBroadcastTarget = isBroadcastTarget; this.isBroadcastTarget = isBroadcastTarget;
} }
@@ -47,6 +61,10 @@ public final class UserPeerView {
return notificationCount; return notificationCount;
} }
public boolean hasActivityIndicator() {
return activityIndicator;
}
public boolean isSelf() { public boolean isSelf() {
return isSelf; return isSelf;
} }
@@ -56,6 +74,6 @@ public final class UserPeerView {
} }
public static UserPeerView broadcastTarget() { public static UserPeerView broadcastTarget() {
return new UserPeerView("Todos", null, null, 0, false, true); return new UserPeerView("Todos", null, null, 0, false, false, true);
} }
} }
@@ -1,5 +1,6 @@
package pitiupi.control; package pitiupi.control;
import pitiupi.application.activity.PeerActivityFocus;
import pitiupi.application.activity.PeerActivityInbox; import pitiupi.application.activity.PeerActivityInbox;
import pitiupi.application.invites.InviteInboxService; import pitiupi.application.invites.InviteInboxService;
import pitiupi.application.peeractions.PeerActionBroker; import pitiupi.application.peeractions.PeerActionBroker;
@@ -26,6 +27,7 @@ public class ConnectionManager {
private final PeerActionBroker peerActionBroker; private final PeerActionBroker peerActionBroker;
private final InviteInboxService inviteInboxService; private final InviteInboxService inviteInboxService;
private final PeerActivityInbox peerActivityInbox; private final PeerActivityInbox peerActivityInbox;
private final PeerActivityFocus peerActivityFocus;
private SocketTCP socketTCP; private SocketTCP socketTCP;
private SocketUDP socketUDP; private SocketUDP socketUDP;
@@ -49,6 +51,8 @@ public class ConnectionManager {
peerActionBroker = new PeerActionBroker(); peerActionBroker = new PeerActionBroker();
inviteInboxService = new InviteInboxService(heartbeatManager); inviteInboxService = new InviteInboxService(heartbeatManager);
peerActivityInbox = new PeerActivityInbox(); peerActivityInbox = new PeerActivityInbox();
peerActivityFocus = new PeerActivityFocus();
peerActivityInbox.setFocus(peerActivityFocus);
presenceService = new PresenceService(heartbeatManager, inviteInboxService, peerActivityInbox); presenceService = new PresenceService(heartbeatManager, inviteInboxService, peerActivityInbox);
inviteInboxService.setOnNotificationsChanged(heartbeatManager::notifyPeerListeners); inviteInboxService.setOnNotificationsChanged(heartbeatManager::notifyPeerListeners);
peerActivityInbox.setOnChanged(heartbeatManager::notifyPeerListeners); peerActivityInbox.setOnChanged(heartbeatManager::notifyPeerListeners);
@@ -73,6 +77,10 @@ public class ConnectionManager {
return peerActivityInbox; return peerActivityInbox;
} }
public PeerActivityFocus getPeerActivityFocus() {
return peerActivityFocus;
}
public void setInviteAcceptedUiListener(Consumer<String> listener) { public void setInviteAcceptedUiListener(Consumer<String> listener) {
this.inviteAcceptedUiListener = listener != null ? listener : pluginId -> { this.inviteAcceptedUiListener = listener != null ? listener : pluginId -> {
}; };
@@ -64,6 +64,12 @@ public final class MainShellFrame extends JFrame implements ShellHost {
if (activeId != null) { if (activeId != null) {
connectionManager.getPeerActionBroker().setActivePlugin(activeId); 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.setOnSettingsTabSelected(settingsPanel::onTabSelected);
tabWorkspace.setOnPluginTabClosed(pluginManager::notifyShellTabClosed); tabWorkspace.setOnPluginTabClosed(pluginManager::notifyShellTabClosed);
@@ -70,7 +70,8 @@ public final class NetworkUsersPanel extends JPanel implements PeerListener {
if (value instanceof DisplayPeer peer) { if (value instanceof DisplayPeer peer) {
label.setText(ShellUi.formatPeerListLabel( label.setText(ShellUi.formatPeerListLabel(
peer.view.getDisplayName(), peer.view.getDisplayName(),
peer.view.getNotificationCount() peer.view.getNotificationCount(),
peer.view.hasActivityIndicator()
)); ));
} }
return label; return label;
@@ -101,6 +102,10 @@ public final class NetworkUsersPanel extends JPanel implements PeerListener {
connectionManager.getPeerActivityInbox().setOnChanged(this::refreshFromPresence); 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) { private void showPeerActions(DisplayPeer peer, PeerActionTarget target, int x, int y) {
JPopupMenu popup = new JPopupMenu(); JPopupMenu popup = new JPopupMenu();
PeerActionBroker broker = connectionManager.getPeerActionBroker(); PeerActionBroker broker = connectionManager.getPeerActionBroker();
@@ -119,8 +124,11 @@ public final class NetworkUsersPanel extends JPanel implements PeerListener {
int pending = activityPeerKey != null int pending = activityPeerKey != null
? activity.countForAction(activityPeerKey, group.pluginId(), action.getId()) ? activity.countForAction(activityPeerKey, group.pluginId(), action.getId())
: 0; : 0;
boolean indicator = activityPeerKey != null
&& activity.hasIndicatorForAction(activityPeerKey, group.pluginId(), action.getId());
boolean indicatorOnly = indicator && pending == 0 && isIndicatorAction(action.getId());
JMenuItem item = new JMenuItem( JMenuItem item = new JMenuItem(
ShellUi.formatActionLabelWithBadge(action.getLabel(), pending) ShellUi.formatActionLabelWithBadge(action.getLabel(), pending, indicatorOnly)
); );
item.addActionListener(e -> { item.addActionListener(e -> {
if (activityPeerKey != null) { if (activityPeerKey != null) {
@@ -130,13 +130,35 @@ public final class ShellUi {
scroll.setViewportBorder(BorderFactory.createEmptyBorder()); scroll.setViewportBorder(BorderFactory.createEmptyBorder());
} }
public static String formatPeerListLabel(String displayName, int notificationCount) { public static String formatPeerListLabel(String displayName, int notificationCount, boolean activityIndicator) {
if (notificationCount <= 0) { if (notificationCount <= 0 && !activityIndicator) {
return displayName; return displayName;
} }
return "<html>" + notificationBadgePrefix() StringBuilder html = new StringBuilder("<html>");
+ escapeHtml(displayName) if (activityIndicator || notificationCount > 0) {
+ " <span style='color:" + NOTIFICATION_COUNT_COLOR + "'>(" + notificationCount + ")</span></html>"; html.append(notificationBadgePrefix());
}
html.append(escapeHtml(displayName));
if (notificationCount > 0) {
html.append(" <span style='color:").append(NOTIFICATION_COUNT_COLOR).append("'>(")
.append(notificationCount).append(")</span>");
}
html.append("</html>");
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 "<html>" + notificationBadgePrefix() + escapeHtml(label) + "</html>";
}
return formatActionLabelWithBadge(label, pendingCount);
} }
public static String formatActionLabelWithBadge(String label, int pendingCount) { public static String formatActionLabelWithBadge(String label, int pendingCount) {
@@ -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"));
}
}
@@ -1,19 +1,30 @@
package pitiupi.application.activity; package pitiupi.application.activity;
import org.junit.jupiter.api.Test; 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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PeerActivityInboxTest { class PeerActivityInboxTest {
@Test @Test
void recordsAndClearsPerAction() { void recordsAndClearsCountedPerAction() {
PeerActivityInbox inbox = new PeerActivityInbox(); PeerActivityInbox inbox = new PeerActivityInbox();
inbox.record("peer-1", "plugin.chat", "chat.peer"); inbox.recordCounted("peer-1", "plugin.chat", "chat.peer", null);
inbox.record("peer-1", "plugin.chat", "chat.peer"); inbox.recordCounted("peer-1", "plugin.chat", "chat.peer", null);
assertEquals(2, inbox.countForAction("peer-1", "plugin.chat", "chat.peer")); 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"); 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"));
} }
} }
@@ -4,6 +4,7 @@ import pitiupi.application.messaging.MessageDispatcher;
import pitiupi.application.messaging.PluginTransportCatalog; import pitiupi.application.messaging.PluginTransportCatalog;
import pitiupi.application.messaging.SendTarget; import pitiupi.application.messaging.SendTarget;
import pitiupi.application.messaging.TransportMode; import pitiupi.application.messaging.TransportMode;
import pitiupi.api.plugin.PeerActivityFocusTokens;
import pitiupi.application.presence.UserPeerView; import pitiupi.application.presence.UserPeerView;
import pitiupi.chat.control.PrivateChatController; import pitiupi.chat.control.PrivateChatController;
import pitiupi.chat.control.PrivateChatUiSink; import pitiupi.chat.control.PrivateChatUiSink;
@@ -68,6 +69,7 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink {
private PrivateChatController privateChatController; private PrivateChatController privateChatController;
private PeerDraftPanel draftPanel; private PeerDraftPanel draftPanel;
private Runnable pendingTranscriptFlush; private Runnable pendingTranscriptFlush;
private String pluginId;
public ChatPanel(ConnectionManager connectionManager) { public ChatPanel(ConnectionManager connectionManager) {
this.connectionManager = connectionManager; this.connectionManager = connectionManager;
@@ -100,6 +102,10 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink {
selectRoomSession(); selectRoomSession();
} }
public void setPluginId(String pluginId) {
this.pluginId = pluginId;
}
private void initSessionTabs() { private void initSessionTabs() {
sessionTabs.addOrSelectTab( sessionTabs.addOrSelectTab(
PluginSessionKeys.ROOM, PluginSessionKeys.ROOM,
@@ -146,6 +152,7 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink {
if (PluginSessionKeys.ROOM.equals(key)) { if (PluginSessionKeys.ROOM.equals(key)) {
routingPanel.bindRoomSession(); routingPanel.bindRoomSession();
switchActiveConversation(ROOM_KEY); switchActiveConversation(ROOM_KEY);
reportActivityFocusForRoom();
return; return;
} }
if (key.startsWith("peer:") && !PluginSessionKeys.PEER_DRAFT.equals(key)) { 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); String name = peerDisplayNames.getOrDefault(ip, ip);
routingPanel.bindPeerSession(ip, name); routingPanel.bindPeerSession(ip, name);
switchActiveConversation(resolveConversationKey()); 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() { private void openDraftPeerTab() {
String previous = sessionTabs.selectedKey(); String previous = sessionTabs.selectedKey();
if (previous != null && !PluginSessionKeys.PEER_DRAFT.equals(previous)) { if (previous != null && !PluginSessionKeys.PEER_DRAFT.equals(previous)) {
@@ -260,6 +298,7 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink {
} }
public void focusGeneralRoom() { public void focusGeneralRoom() {
reportActivityFocusForRoom();
sessionTabs.selectTab(PluginSessionKeys.ROOM); 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."); appendSystemLine(activeConversationKey, "Informe um IP válido para enviar a um peer.");
return; return;
} }
String ip = LocalNetworkInfo.bestLocalIpv4(); String ip = PeerIpv4.normalize(LocalNetworkInfo.bestLocalIpv4());
ChatMessage message = new ChatMessage( ChatMessage message = new ChatMessage(
connectionManager.getUserName(), connectionManager.getUserName(),
text, text,
@@ -2,6 +2,7 @@ package pitiupi.chat.plugin;
import pitiupi.api.plugin.PeerActionsRegistrar; import pitiupi.api.plugin.PeerActionsRegistrar;
import pitiupi.api.plugin.PluginWorkspace; import pitiupi.api.plugin.PluginWorkspace;
import pitiupi.api.plugin.PeerActivityFocusTokens;
import pitiupi.application.activity.PeerActivityInbox; import pitiupi.application.activity.PeerActivityInbox;
import pitiupi.application.peeractions.PeerAction; import pitiupi.application.peeractions.PeerAction;
import pitiupi.application.peeractions.PeerActionTarget; import pitiupi.application.peeractions.PeerActionTarget;
@@ -41,6 +42,7 @@ public class ChatPlugin extends Plugin implements PrivateChatHost {
this.workspace = workspace; this.workspace = workspace;
this.privateChatController = new PrivateChatController(connectionManager, this); this.privateChatController = new PrivateChatController(connectionManager, this);
this.chatPanel = new ChatPanel(connectionManager); this.chatPanel = new ChatPanel(connectionManager);
chatPanel.setPluginId(getIdentifier());
chatPanel.setPrivateChatController(privateChatController); chatPanel.setPrivateChatController(privateChatController);
privateChatController.setUiSink(chatPanel); privateChatController.setUiSink(chatPanel);
bindShellWorkspaceContent(chatPanel); bindShellWorkspaceContent(chatPanel);
@@ -119,6 +121,10 @@ public class ChatPlugin extends Plugin implements PrivateChatHost {
} }
private void recordChatActivity(ChatMessage msg) { 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())) { if (LocalUserIdentity.isLocalSender(connectionManager, msg.getName(), msg.getSenderIp())) {
return; return;
} }
@@ -126,12 +132,17 @@ public class ChatPlugin extends Plugin implements PrivateChatHost {
String tag = msg.getTransportLabel(); String tag = msg.getTransportLabel();
boolean multicast = tag != null && tag.toLowerCase().contains("multicast"); boolean multicast = tag != null && tag.toLowerCase().contains("multicast");
if (multicast) { if (multicast) {
inbox.recordBroadcast(getIdentifier(), "chat.focus"); inbox.recordBroadcastCounted(getIdentifier(), "chat.focus", PeerActivityFocusTokens.broadcast());
return; return;
} }
String instanceId = peerInstanceIdForActivity(msg.getSenderIp()); String instanceId = peerInstanceIdForActivity(msg.getSenderIp());
if (instanceId != null) { 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); privateChatController.receiveMessage(connection, legacy);
String instanceId = peerInstanceIdForActivity(PeerIpv4.fromInetAddress(connection.getAddress())); String instanceId = peerInstanceIdForActivity(PeerIpv4.fromInetAddress(connection.getAddress()));
if (instanceId != null) { if (instanceId != null) {
connectionManager.getPeerActivityInbox().record(instanceId, getIdentifier(), "chat.private.tcp"); connectionManager.getPeerActivityInbox().recordCounted(
instanceId,
getIdentifier(),
"chat.private.tcp",
PeerActivityFocusTokens.peerInstance(instanceId)
);
} }
return true; return true;
} }
@@ -4,6 +4,7 @@ import pitiupi.application.messaging.MessageDispatcher;
import pitiupi.application.messaging.PluginTransportCatalog; import pitiupi.application.messaging.PluginTransportCatalog;
import pitiupi.application.messaging.SendTarget; import pitiupi.application.messaging.SendTarget;
import pitiupi.application.messaging.TransportMode; import pitiupi.application.messaging.TransportMode;
import pitiupi.api.plugin.PeerActivityFocusTokens;
import pitiupi.application.presence.UserPeerView; import pitiupi.application.presence.UserPeerView;
import pitiupi.control.ConnectionManager; import pitiupi.control.ConnectionManager;
import pitiupi.presentation.shell.PeerDraftPanel; import pitiupi.presentation.shell.PeerDraftPanel;
@@ -77,6 +78,7 @@ public class ScreenCastPanel extends JPanel {
private final JPanel bodyCards = new JPanel(bodyLayout); private final JPanel bodyCards = new JPanel(bodyLayout);
private final Map<String, JPanel> bodyPanels = new HashMap<>(); private final Map<String, JPanel> bodyPanels = new HashMap<>();
private PeerDraftPanel draftPanel; private PeerDraftPanel draftPanel;
private String pluginId;
public ScreenCastPanel(ConnectionManager connectionManager) { public ScreenCastPanel(ConnectionManager connectionManager) {
this.connectionManager = connectionManager; this.connectionManager = connectionManager;
@@ -147,14 +149,31 @@ public class ScreenCastPanel extends JPanel {
sessionTabs.selectTab(PluginSessionKeys.ROOM); sessionTabs.selectTab(PluginSessionKeys.ROOM);
} }
public void setPluginId(String pluginId) {
this.pluginId = pluginId;
}
private void onSessionTabClosed(String key) { private void onSessionTabClosed(String key) {
if (PluginSessionKeys.PEER_DRAFT.equals(key)) { if (PluginSessionKeys.PEER_DRAFT.equals(key)) {
if (draftPanel != null) { if (draftPanel != null) {
draftPanel.clearIpField(); draftPanel.clearIpField();
} }
SwingUtilities.invokeLater(this::restoreSessionAfterDraftClosed); 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); sharingByTabKey.remove(key);
if (wasSharing && key != null && key.equals(shareContextTabKey)) {
cancelShareTimer();
setShareToggleSelectedSilently(false);
applySharingForActiveTab();
}
} }
private void restoreSessionAfterDraftClosed() { private void restoreSessionAfterDraftClosed() {
@@ -190,6 +209,48 @@ public class ScreenCastPanel extends JPanel {
if (!isSharingForTab(key)) { if (!isSharingForTab(key)) {
refreshOutboundBodyForTab(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() { private void persistShareStateForContextTab() {
@@ -354,29 +415,18 @@ public class ScreenCastPanel extends JPanel {
return PeerIpv4.normalize(ip); return PeerIpv4.normalize(ip);
} }
public void focusMulticastSharing() { public void focusMulticastSession() {
ensureTodosTab(); ensureTodosTab();
sharingByTabKey.put(PluginSessionKeys.ROOM, true); sharingByTabKey.put(PluginSessionKeys.ROOM, false);
sessionTabs.selectTab(PluginSessionKeys.ROOM); sessionTabs.selectTab(PluginSessionKeys.ROOM);
routingPanel.bindRoomSession(); routingPanel.bindRoomSession();
routingPanel.selectTransport(TransportMode.MULTICAST_UDP); routingPanel.selectTransport(TransportMode.MULTICAST_UDP);
setShareToggleSelectedSilently(true); setShareToggleSelectedSilently(false);
applySharingForActiveTab(); applySharingForActiveTab();
} }
public void focusPeerSharing(UserPeerView peer) { public void focusPeerSharing(UserPeerView peer) {
if (peer == null || peer.isBroadcastTarget() || peer.isSelf()) { focusPeerAddress(peer);
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();
});
} }
public void focusPeerAddress(UserPeerView peer) { public void focusPeerAddress(UserPeerView peer) {
@@ -4,6 +4,7 @@ import java.util.List;
import pitiupi.api.plugin.PeerActionsRegistrar; import pitiupi.api.plugin.PeerActionsRegistrar;
import pitiupi.api.plugin.PluginWorkspace; import pitiupi.api.plugin.PluginWorkspace;
import pitiupi.api.plugin.PeerActivityFocusTokens;
import pitiupi.application.activity.PeerActivityInbox; import pitiupi.application.activity.PeerActivityInbox;
import pitiupi.application.peeractions.PeerAction; import pitiupi.application.peeractions.PeerAction;
import pitiupi.application.presence.UserPeerView; import pitiupi.application.presence.UserPeerView;
@@ -55,6 +56,7 @@ public class ScreenCastPlugin extends Plugin {
this.connectionManager = connectionManager; this.connectionManager = connectionManager;
this.workspace = workspace; this.workspace = workspace;
this.screenCastPanel = new ScreenCastPanel(connectionManager); this.screenCastPanel = new ScreenCastPanel(connectionManager);
screenCastPanel.setPluginId(getIdentifier());
bindShellWorkspaceContent(screenCastPanel); bindShellWorkspaceContent(screenCastPanel);
installPeerActions(workspace.peerActions()); installPeerActions(workspace.peerActions());
} }
@@ -80,17 +82,17 @@ public class ScreenCastPlugin extends Plugin {
PeerActionTarget.ALL_PEERS, PeerActionTarget.ALL_PEERS,
target -> { target -> {
workspace.openOrFocusTab(getIdentifier(), getDisplayName()); workspace.openOrFocusTab(getIdentifier(), getDisplayName());
screenCastPanel.focusMulticastSharing(); screenCastPanel.focusMulticastSession();
} }
)); ));
registrar.register(PeerAction.of( registrar.register(PeerAction.of(
"screencast.peer", "screencast.peer",
"Compartilhar com este peer", "Preparar compartilhamento com este peer",
PeerActionTarget.SINGLE_PEER, PeerActionTarget.SINGLE_PEER,
target -> { target -> {
workspace.openOrFocusTab(getIdentifier(), getDisplayName()); workspace.openOrFocusTab(getIdentifier(), getDisplayName());
if (target instanceof PeerTarget.Single single) { 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() != null
&& msg.getName().equals(view.getDisplayName()) && msg.getName().equals(view.getDisplayName())
&& view.getInstanceId() != null) { && view.getInstanceId() != null) {
inbox.record(view.getInstanceId(), getIdentifier(), "screencast.peer"); inbox.signal(
view.getInstanceId(),
getIdentifier(),
"screencast.peer",
PeerActivityFocusTokens.peerInstance(view.getInstanceId())
);
return; return;
} }
} }
} }
inbox.recordBroadcast(getIdentifier(), "screencast.multicast"); inbox.signalBroadcast(getIdentifier(), "screencast.multicast", PeerActivityFocusTokens.broadcast());
} }
} }