From eb38268be5c0d1abe591fa047741018843a18bc1 Mon Sep 17 00:00:00 2001 From: Marc Froehlich Date: Thu, 6 Aug 2026 00:01:29 +0200 Subject: [PATCH] fix(chat): separate active members by full callsign and category --- .../controller/ChatController.java | 36 ++++-- .../MessageBusManagementThread.java | 117 +++++++++++++----- .../view/Kst4ContestApplication.java | 26 ++-- 3 files changed, 130 insertions(+), 49 deletions(-) diff --git a/src/main/java/kst4contest/controller/ChatController.java b/src/main/java/kst4contest/controller/ChatController.java index f54ed67..601f8ae 100644 --- a/src/main/java/kst4contest/controller/ChatController.java +++ b/src/main/java/kst4contest/controller/ChatController.java @@ -1175,27 +1175,45 @@ private ObservableList } /** - * Builds the active-member key. The raw/base callsign alone is not enough - * because the same station can be logged into multiple ON4KST categories at - * the same time. Therefore the category number is part of the key. + * Builds the unique identity key for one active ON4KST login. + * + *

The complete callsign must be preserved here. Callsigns such as + * {@code 9A0BB-2}, {@code 9A0BB-70} and {@code 9A0BB-144} represent different + * active chat sessions and must therefore remain separate objects, even when + * they are logged into the same chat category.

+ * + *

The normalized base callsign ({@code callSignRaw}) is deliberately not + * used for this key. It remains the common station key for worked, band and + * NOT-QRV information.

*/ private String buildActiveChatMemberKey(String callSign, ChatCategory category) { - String normalizedCallsign = ChatMember.normalizeCallSignToBaseCallSign(callSign); - if (normalizedCallsign == null || normalizedCallsign.isBlank()) { + + if (callSign == null) { return null; } - int categoryNumber = category == null ? -1 : category.getCategoryNumber(); - return normalizedCallsign.trim().toUpperCase(Locale.ROOT) + "|" + categoryNumber; + String fullCallSign = callSign.trim().toUpperCase(Locale.ROOT); + + if (fullCallSign.isBlank()) { + return null; + } + + int categoryNumber = + category == null ? -1 : category.getCategoryNumber(); + + return fullCallSign + "|" + categoryNumber; } private String buildActiveChatMemberKey(ChatMember member) { + if (member == null) { return null; } - String callSign = member.getCallSignRaw() != null ? member.getCallSignRaw() : member.getCallSign(); - return buildActiveChatMemberKey(callSign, member.getChatCategory()); + return buildActiveChatMemberKey( + member.getCallSign(), + member.getChatCategory() + ); } /** diff --git a/src/main/java/kst4contest/controller/MessageBusManagementThread.java b/src/main/java/kst4contest/controller/MessageBusManagementThread.java index 4bf1010..57a186a 100644 --- a/src/main/java/kst4contest/controller/MessageBusManagementThread.java +++ b/src/main/java/kst4contest/controller/MessageBusManagementThread.java @@ -602,76 +602,129 @@ public class MessageBusManagementThread extends Thread { } /** - * Resolves a message sender from the thread-safe active-member model. If a - * CH/CR message arrives before the matching user-enter message, a marked - * fallback sender is used. The returned sender is never null. + * Resolves a message sender from the thread-safe active-member model. + * + *

The local login is handled before the active-user lookup because the own + * ChatMember is intentionally not stored in the visible user list. This also + * prevents another active login with the same base callsign from replacing the + * identity of our own message echo.

*/ - private ChatMember resolveInboundSender(String senderCallSign, ChatCategory category, ChatMessage message) { + private ChatMember resolveInboundSender( + String senderCallSign, + ChatCategory category, + ChatMessage message + ) { + + String myCall = + this.client.getChatPreferences().getStn_loginCallSign(); + + if (senderCallSign != null + && myCall != null + && senderCallSign.equalsIgnoreCase(myCall)) { + + ChatMember ownSender = new ChatMember(); + ownSender.setCallSign(senderCallSign); + ownSender.setChatCategory(category); + ownSender.setAirPlaneReflectInfo( + new AirPlaneReflectionInfo() + ); + + return ownSender; + } + ChatMember lookup = new ChatMember(); lookup.setCallSign(senderCallSign); lookup.setChatCategory(category); - ChatMember senderObj = this.client.findActiveChatMember(lookup); + ChatMember senderObj = + this.client.findActiveChatMember(lookup); + if (senderObj != null) { - senderObj.setActivityTimeLastInEpoch(new Utils4KST().time_generateCurrentEpochTime()); + senderObj.setActivityTimeLastInEpoch( + new Utils4KST().time_generateCurrentEpochTime() + ); - // Remember the last active category so later outgoing replies can be routed correctly. - this.client.rememberLastInboundCategory(senderObj.getCallSignRaw(), senderObj.getChatCategory()); + this.client.rememberLastInboundCategory( + senderObj.getCallSignRaw(), + senderObj.getChatCategory() + ); - // Metrics influence priority scoring; process them after message text is known. this.client.getStationMetricsService().onInboundMessage( senderObj.getCallSignRaw(), System.currentTimeMillis(), message == null ? null : message.getMessageText(), this.client.getChatPreferences(), - this.client.getChatPreferences().getStn_loginCallSign() + myCall + ); + + this.client.getScoreService().requestRecompute( + "rx-chat-message" ); - this.client.getScoreService().requestRecompute("rx-chat-message"); return senderObj; } ChatMember fallbackSender = new ChatMember(); - String myCall = this.client.getChatPreferences().getStn_loginCallSign(); - if (senderCallSign != null && senderCallSign.equalsIgnoreCase(myCall)) { - fallbackSender.setCallSign(myCall); - } else { - fallbackSender.setCallSign("[n/a]" + senderCallSign); - } + fallbackSender.setCallSign("[n/a]" + senderCallSign); fallbackSender.setChatCategory(category); - fallbackSender.setAirPlaneReflectInfo(new AirPlaneReflectionInfo()); + fallbackSender.setAirPlaneReflectInfo( + new AirPlaneReflectionInfo() + ); + return fallbackSender; } /** - * Resolves a message receiver from the active-member model. Unknown receivers - * are represented as explicit fallback objects instead of null. This keeps PM - * echo display and historic messages stable even if the target station already - * left the chat. + * Resolves a message receiver from the thread-safe active-member model. + * + *

The local login is handled before the active-user lookup. The receiver + * from the ON4KST packet therefore remains authoritative even if another + * station with the same base callsign is logged into the same category.

*/ - private ChatMember resolveInboundReceiver(String receiverCallSign, ChatCategory category) { - if (receiverCallSign == null || receiverCallSign.equals("0")) { + private ChatMember resolveInboundReceiver( + String receiverCallSign, + ChatCategory category + ) { + + if (receiverCallSign == null + || receiverCallSign.equals("0")) { return createAllReceiver(); } + String myCall = + this.client.getChatPreferences().getStn_loginCallSign(); + + if (myCall != null + && receiverCallSign.equalsIgnoreCase(myCall)) { + + ChatMember ownReceiver = new ChatMember(); + ownReceiver.setCallSign(receiverCallSign); + ownReceiver.setChatCategory(category); + ownReceiver.setAirPlaneReflectInfo( + new AirPlaneReflectionInfo() + ); + + return ownReceiver; + } + ChatMember lookup = new ChatMember(); lookup.setCallSign(receiverCallSign); lookup.setChatCategory(category); - ChatMember receiverObj = this.client.findActiveChatMember(lookup); + ChatMember receiverObj = + this.client.findActiveChatMember(lookup); + if (receiverObj != null) { return receiverObj; } ChatMember fallbackReceiver = new ChatMember(); - String myCall = this.client.getChatPreferences().getStn_loginCallSign(); - if (receiverCallSign.equalsIgnoreCase(myCall)) { - fallbackReceiver.setCallSign(myCall); - } else { - fallbackReceiver.setCallSign(receiverCallSign + "(left)"); - } + fallbackReceiver.setCallSign(receiverCallSign + "(left)"); fallbackReceiver.setChatCategory(category); - fallbackReceiver.setAirPlaneReflectInfo(new AirPlaneReflectionInfo()); + fallbackReceiver.setAirPlaneReflectInfo( + new AirPlaneReflectionInfo() + ); + return fallbackReceiver; } diff --git a/src/main/java/kst4contest/view/Kst4ContestApplication.java b/src/main/java/kst4contest/view/Kst4ContestApplication.java index e6506ce..b64dcd2 100644 --- a/src/main/java/kst4contest/view/Kst4ContestApplication.java +++ b/src/main/java/kst4contest/view/Kst4ContestApplication.java @@ -2431,24 +2431,32 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } /** - * Compares two ChatMember objects by their logical identity. + * Compares two ChatMember objects by their active chat identity. * - * JavaFX may replace item instances during refreshes, so object identity alone is - * not enough. For selection stability we compare callsign/raw callsign and chat - * category number. + *

JavaFX may replace item instances during list refreshes, so object + * identity alone is not sufficient. The complete callsign and the chat + * category identify one active ON4KST login.

+ * + *

The raw/base callsign must not be used here. Otherwise callsigns such as + * {@code 9A0BB-2} and {@code 9A0BB-70} would still be treated as the same + * selection inside one chat category.

*/ private boolean isSameLogicalChatMember(ChatMember a, ChatMember b) { + if (a == b) { return true; } + if (a == null || b == null) { return false; } - String aCall = a.getCallSignRaw() != null ? a.getCallSignRaw() : a.getCallSign(); - String bCall = b.getCallSignRaw() != null ? b.getCallSignRaw() : b.getCallSign(); + String aCallSign = a.getCallSign(); + String bCallSign = b.getCallSign(); - if (aCall == null || bCall == null || !aCall.equalsIgnoreCase(bCall)) { + if (aCallSign == null + || bCallSign == null + || !aCallSign.equalsIgnoreCase(bCallSign)) { return false; } @@ -2458,11 +2466,13 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL if (aCategory == bCategory) { return true; } + if (aCategory == null || bCategory == null) { return false; } - return aCategory.getCategoryNumber() == bCategory.getCategoryNumber(); + return aCategory.getCategoryNumber() + == bCategory.getCategoryNumber(); } /**