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
* 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.
*
* <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) {
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()
);
}
/**
@@ -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.
*
* <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();
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.
*
* <p>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.</p>
*/
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;
}
@@ -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.
* <p>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.</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) {
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();
}
/**