fix(chat): separate active members by full callsign and category

This commit is contained in:
Marc Froehlich
2026-08-06 00:01:29 +02:00
parent 36d2bd512d
commit eb38268be5
3 changed files with 130 additions and 49 deletions
@@ -1175,27 +1175,45 @@ private ObservableList<String>
} }
/** /**
* Builds the active-member key. The raw/base callsign alone is not enough * Builds the unique identity key for one active ON4KST login.
* because the same station can be logged into multiple ON4KST categories at *
* the same time. Therefore the category number is part of the key. * <p>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.</p>
*
* <p>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.</p>
*/ */
private String buildActiveChatMemberKey(String callSign, ChatCategory category) { private String buildActiveChatMemberKey(String callSign, ChatCategory category) {
String normalizedCallsign = ChatMember.normalizeCallSignToBaseCallSign(callSign);
if (normalizedCallsign == null || normalizedCallsign.isBlank()) { if (callSign == null) {
return null; return null;
} }
int categoryNumber = category == null ? -1 : category.getCategoryNumber(); String fullCallSign = callSign.trim().toUpperCase(Locale.ROOT);
return normalizedCallsign.trim().toUpperCase(Locale.ROOT) + "|" + categoryNumber;
if (fullCallSign.isBlank()) {
return null;
}
int categoryNumber =
category == null ? -1 : category.getCategoryNumber();
return fullCallSign + "|" + categoryNumber;
} }
private String buildActiveChatMemberKey(ChatMember member) { private String buildActiveChatMemberKey(ChatMember member) {
if (member == null) { if (member == null) {
return null; return null;
} }
String callSign = member.getCallSignRaw() != null ? member.getCallSignRaw() : member.getCallSign(); return buildActiveChatMemberKey(
return buildActiveChatMemberKey(callSign, member.getChatCategory()); member.getCallSign(),
member.getChatCategory()
);
} }
/** /**
@@ -602,76 +602,129 @@ public class MessageBusManagementThread extends Thread {
} }
/** /**
* Resolves a message sender from the thread-safe active-member model. If a * Resolves a message sender from the thread-safe active-member model.
* CH/CR message arrives before the matching user-enter message, a marked *
* fallback sender is used. The returned sender is never null. * <p>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.</p>
*/ */
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(); ChatMember lookup = new ChatMember();
lookup.setCallSign(senderCallSign); lookup.setCallSign(senderCallSign);
lookup.setChatCategory(category); lookup.setChatCategory(category);
ChatMember senderObj = this.client.findActiveChatMember(lookup); ChatMember senderObj =
this.client.findActiveChatMember(lookup);
if (senderObj != null) { 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(
this.client.rememberLastInboundCategory(senderObj.getCallSignRaw(), senderObj.getChatCategory()); senderObj.getCallSignRaw(),
senderObj.getChatCategory()
);
// Metrics influence priority scoring; process them after message text is known.
this.client.getStationMetricsService().onInboundMessage( this.client.getStationMetricsService().onInboundMessage(
senderObj.getCallSignRaw(), senderObj.getCallSignRaw(),
System.currentTimeMillis(), System.currentTimeMillis(),
message == null ? null : message.getMessageText(), message == null ? null : message.getMessageText(),
this.client.getChatPreferences(), 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; return senderObj;
} }
ChatMember fallbackSender = new ChatMember(); ChatMember fallbackSender = new ChatMember();
String myCall = this.client.getChatPreferences().getStn_loginCallSign(); fallbackSender.setCallSign("[n/a]" + senderCallSign);
if (senderCallSign != null && senderCallSign.equalsIgnoreCase(myCall)) {
fallbackSender.setCallSign(myCall);
} else {
fallbackSender.setCallSign("[n/a]" + senderCallSign);
}
fallbackSender.setChatCategory(category); fallbackSender.setChatCategory(category);
fallbackSender.setAirPlaneReflectInfo(new AirPlaneReflectionInfo()); fallbackSender.setAirPlaneReflectInfo(
new AirPlaneReflectionInfo()
);
return fallbackSender; return fallbackSender;
} }
/** /**
* Resolves a message receiver from the active-member model. Unknown receivers * Resolves a message receiver from the thread-safe active-member model.
* are represented as explicit fallback objects instead of null. This keeps PM *
* echo display and historic messages stable even if the target station already * <p>The local login is handled before the active-user lookup. The receiver
* left the chat. * from the ON4KST packet therefore remains authoritative even if another
* station with the same base callsign is logged into the same category.</p>
*/ */
private ChatMember resolveInboundReceiver(String receiverCallSign, ChatCategory category) { private ChatMember resolveInboundReceiver(
if (receiverCallSign == null || receiverCallSign.equals("0")) { String receiverCallSign,
ChatCategory category
) {
if (receiverCallSign == null
|| receiverCallSign.equals("0")) {
return createAllReceiver(); 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(); ChatMember lookup = new ChatMember();
lookup.setCallSign(receiverCallSign); lookup.setCallSign(receiverCallSign);
lookup.setChatCategory(category); lookup.setChatCategory(category);
ChatMember receiverObj = this.client.findActiveChatMember(lookup); ChatMember receiverObj =
this.client.findActiveChatMember(lookup);
if (receiverObj != null) { if (receiverObj != null) {
return receiverObj; return receiverObj;
} }
ChatMember fallbackReceiver = new ChatMember(); ChatMember fallbackReceiver = new ChatMember();
String myCall = this.client.getChatPreferences().getStn_loginCallSign(); fallbackReceiver.setCallSign(receiverCallSign + "(left)");
if (receiverCallSign.equalsIgnoreCase(myCall)) {
fallbackReceiver.setCallSign(myCall);
} else {
fallbackReceiver.setCallSign(receiverCallSign + "(left)");
}
fallbackReceiver.setChatCategory(category); fallbackReceiver.setChatCategory(category);
fallbackReceiver.setAirPlaneReflectInfo(new AirPlaneReflectionInfo()); fallbackReceiver.setAirPlaneReflectInfo(
new AirPlaneReflectionInfo()
);
return fallbackReceiver; return fallbackReceiver;
} }
@@ -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 * <p>JavaFX may replace item instances during list refreshes, so object
* not enough. For selection stability we compare callsign/raw callsign and chat * identity alone is not sufficient. The complete callsign and the chat
* category number. * category identify one active ON4KST login.</p>
*
* <p>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.</p>
*/ */
private boolean isSameLogicalChatMember(ChatMember a, ChatMember b) { private boolean isSameLogicalChatMember(ChatMember a, ChatMember b) {
if (a == b) { if (a == b) {
return true; return true;
} }
if (a == null || b == null) { if (a == null || b == null) {
return false; return false;
} }
String aCall = a.getCallSignRaw() != null ? a.getCallSignRaw() : a.getCallSign(); String aCallSign = a.getCallSign();
String bCall = b.getCallSignRaw() != null ? b.getCallSignRaw() : b.getCallSign(); String bCallSign = b.getCallSign();
if (aCall == null || bCall == null || !aCall.equalsIgnoreCase(bCall)) { if (aCallSign == null
|| bCallSign == null
|| !aCallSign.equalsIgnoreCase(bCallSign)) {
return false; return false;
} }
@@ -2458,11 +2466,13 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
if (aCategory == bCategory) { if (aCategory == bCategory) {
return true; return true;
} }
if (aCategory == null || bCategory == null) { if (aCategory == null || bCategory == null) {
return false; return false;
} }
return aCategory.getCategoryNumber() == bCategory.getCategoryNumber(); return aCategory.getCategoryNumber()
== bCategory.getCategoryNumber();
} }
/** /**