Vai al contenuto
Monoverse Monoverse IRC community websites by SimosNap
GitHub PULL REQUEST
kiwiirc/kiwiirc
CoryChaplin 16/07/2026 20:58
OPEN
#1964 feat: reconcile own-message echoes to fix duplicates after reconnect
feat/own-message-echo-reconciliation master
Own messages were rendered optimistically with a local numeric id while the server copy (live echo or CHATHISTORY replay) carries a msgid, so the msgid-only dedup never matched and reconnects duplicated our own recent messages.

All outgoing privmsg/action/notice now flow through a single path, PendingMessages.sendAndTrack: the optimistic copy is tracked as pending and sent with a unique label when the server supports labeled-response (requested alongside echo-message). The returning echo is reconciled onto the existing message - exact match by label, content+time heuristic as fallback - grafting the server msgid so history replay dedups naturally via messageIds.

A labeled ACK (eg. kiwibnc when no echo will follow) resolves the pending state without ever flagging failure. Messages with no acknowledgement after 30s or when the connection drops are flagged "not sent" with a manual resend action in all three message layouts.

Servers without these caps keep the exact previous behaviour.
3 commit 11 file +1013 -43
src/components/MessageListMessageCompact.vue +33 -0
@@ -26,6 +26,8 @@
props.ml.message_info_open && props.ml.message_info_open !== props.message
? 'kiwi-messagelist-message--blur'
: '',
+ props.message.pending ? 'kiwi-messagelist-message--pending' : '',
+ props.message.send_failed ? 'kiwi-messagelist-message--send-failed' : '',
(props.message.user && props.m().userMode(props.message.user))
? `kiwi-messagelist-message--user-mode-${props.m().userMode(props.message.user)}`
: '',
@@ -86,6 +88,16 @@
/>
<div v-else class="kiwi-messagelist-body" v-html="props.ml.formatMessage(props.message)" />
+ <div
+ v-if="props.message.send_failed"
+ class="kiwi-messagelist-sendstatus"
+ >
+ {{ props.ml.$t('message_not_sent') }}
+ <a class="u-link" @click.stop="props.m().resendMessage(props.message)">
+ {{ props.ml.$t('message_resend') }}
+ </a>
+ </div>
+
<component
:is="injections.components.MessageInfo"
v-if="props.ml.message_info_open === props.message"
@@ -149,6 +161,10 @@ const methods = {
let props = this.props;
return props.ml.buffer.userModePrefix(user);
},
+ resendMessage(message) {
+ let props = this.props;
+ props.ml.buffer.getNetwork().pendingMessages.resend(message);
+ },
};
export default {
@@ -233,6 +249,23 @@ export default {
padding-left: 130px;
}
+.kiwi-messagelist-message--compact.kiwi-messagelist-message--pending .kiwi-messagelist-body,
+.kiwi-messagelist-message--compact.kiwi-messagelist-message--send-failed .kiwi-messagelist-body {
+ opacity: 0.55;
+}
+
+.kiwi-messagelist-message--compact .kiwi-messagelist-sendstatus {
+ display: block;
+ margin-left: 120px;
+ font-size: 0.85em;
+ opacity: 0.8;
+}
+
+.kiwi-messagelist-message--compact .kiwi-messagelist-sendstatus .u-link {
+ cursor: pointer;
+ font-weight: 600;
+}
+
//Channel traffic messages
.kiwi-messagelist-message--compact.kiwi-messagelist-message-traffic {
margin: 0;
src/components/MessageListMessageInline.vue +31 -0
@@ -23,6 +23,8 @@
props.ml.message_info_open && props.ml.message_info_open !== props.message
? 'kiwi-messagelist-message--blur'
: '',
+ props.message.pending ? 'kiwi-messagelist-message--pending' : '',
+ props.message.send_failed ? 'kiwi-messagelist-message--send-failed' : '',
(props.message.user && props.m().userMode(props.message.user))
? `kiwi-messagelist-message--user-mode-${props.m().userMode(props.message.user)}`
: '',
@@ -84,6 +86,16 @@
/>
</div>
+ <div
+ v-if="props.message.send_failed"
+ class="kiwi-messagelist-sendstatus"
+ >
+ {{ props.ml.$t('message_not_sent') }}
+ <a class="u-link" @click.stop="props.m().resendMessage(props.message)">
+ {{ props.ml.$t('message_resend') }}
+ </a>
+ </div>
+
<component
:is="injections.components.MessageInfo"
v-if="props.ml.message_info_open === props.message"
@@ -127,6 +139,10 @@ const methods = {
let props = this.props;
return props.ml.buffer.userModePrefix(user);
},
+ resendMessage(message) {
+ let props = this.props;
+ props.ml.buffer.getNetwork().pendingMessages.resend(message);
+ },
};
export default {
@@ -210,6 +226,21 @@ export default {
word-break: break-all;
}
+.kiwi-messagelist-message--text.kiwi-messagelist-message--pending .kiwi-messagelist-body,
+.kiwi-messagelist-message--text.kiwi-messagelist-message--send-failed .kiwi-messagelist-body {
+ opacity: 0.55;
+}
+
+.kiwi-messagelist-message--text .kiwi-messagelist-sendstatus {
+ font-size: 0.85em;
+ opacity: 0.8;
+}
+
+.kiwi-messagelist-message--text .kiwi-messagelist-sendstatus .u-link {
+ cursor: pointer;
+ font-weight: 600;
+}
+
.kiwi-messagelist-message--text .kiwi-messagelist-message-privmsg:hover,
.kiwi-messagelist-message--text .kiwi-messagelist-message-action:hover,
.kiwi-messagelist-message--text .kiwi-messagelist-message-notice:hover {
src/components/MessageListMessageModern.vue +35 -0
@@ -26,6 +26,8 @@
props.ml.message_info_open && props.ml.message_info_open !== props.message
? 'kiwi-messagelist-message--blur'
: '',
+ props.message.pending ? 'kiwi-messagelist-message--pending' : '',
+ props.message.send_failed ? 'kiwi-messagelist-message--send-failed' : '',
(props.message.user && props.m().userMode(props.message.user))
? `kiwi-messagelist-message--user-mode-${props.m().userMode(props.message.user)}`
: '',
@@ -110,6 +112,16 @@
v-html="props.ml.formatMessage(props.message)"
/>
+ <div
+ v-if="props.message.send_failed"
+ class="kiwi-messagelist-sendstatus"
+ >
+ {{ props.ml.$t('message_not_sent') }}
+ <a class="u-link" @click.stop="props.m().resendMessage(props.message)">
+ {{ props.ml.$t('message_resend') }}
+ </a>
+ </div>
+
<component
:is="injections.components.MessageInfo"
v-if="props.ml.message_info_open === props.message"
@@ -236,6 +248,10 @@ const methods = {
let props = this.props;
return props.ml.buffer.userModePrefix(user);
},
+ resendMessage(message) {
+ let props = this.props;
+ props.ml.buffer.getNetwork().pendingMessages.resend(message);
+ },
};
export default {
@@ -371,6 +387,25 @@ export default {
margin-right: 10px;
}
+.kiwi-messagelist-message--pending .kiwi-messagelist-body {
+ opacity: 0.55;
+}
+
+.kiwi-messagelist-message--send-failed .kiwi-messagelist-body {
+ opacity: 0.55;
+}
+
+.kiwi-messagelist-sendstatus {
+ font-size: 0.85em;
+ margin-bottom: 10px;
+ opacity: 0.8;
+}
+
+.kiwi-messagelist-sendstatus .u-link {
+ cursor: pointer;
+ font-weight: 600;
+}
+
.kiwi-messagelist-message-traffic .kiwi-messagelist-body {
margin-bottom: 0;
}
src/libs/InputHandler.js +7 -25
@@ -169,26 +169,13 @@ function handleMessage(type, event, command, line, context) {
let buffer = localBuffer.length && this.state.getOrAddBufferByName(network.id, localBuffer);
if (buffer) {
- let textFormatType = 'privmsg';
- if (type === 'action') {
- textFormatType = 'action';
- } else if (type === 'notice') {
- textFormatType = 'notice';
- }
-
- let messageBody = TextFormatting.formatText(textFormatType, {
- nick: network.nick,
- text: message,
- });
-
- let newMessage = {
- time: Date.now(),
- nick: network.nick,
- message: messageBody,
+ // Renders the message optimistically, sends it (with a label when the server
+ // supports labeled-response) and tracks it for echo reconciliation + manual resend
+ network.pendingMessages.sendAndTrack(buffer, message, {
type: type,
- };
-
- this.state.addMessage(buffer, newMessage);
+ targetName: bufferName,
+ });
+ return;
}
let fnNames = {
@@ -226,12 +213,7 @@ inputCommands.dice = function inputCommandDice(event, command, line, context) {
sides: TextFormatting.formatNumber(sides),
number: TextFormatting.formatNumber(rndNumber),
});
- network.ircClient.action(buffer.name, msg);
- this.state.addMessage(buffer, {
- nick: network.nick,
- message: msg,
- type: 'action',
- });
+ network.pendingMessages.sendAndTrack(buffer, msg, { type: 'action' });
};
inputCommands.ctcp = function inputCommandCtcp(event, command, line, context) {
src/libs/IrcClient.js +32 -0
@@ -4,6 +4,7 @@ import _ from 'lodash';
import strftime from 'strftime';
import Irc from 'irc-framework';
import * as TextFormatting from '@/helpers/TextFormatting';
+import PendingMessages from './PendingMessages';
import typingMiddleware from './TypingMiddleware';
import chathistoryMiddleware from './ChathistoryMiddleware';
import * as ServerConnection from './ServerConnection';
@@ -19,10 +20,20 @@ export function create(state, network) {
message_max_length: 350,
});
ircClient.requestCap('znc.in/self-message');
+ // echo-message + labeled-response let us reconcile the server's echo of our own
+ // messages onto the optimistically rendered copy (see PendingMessages)
+ ircClient.requestCap(['echo-message', 'labeled-response']);
ircClient.use(chathistoryMiddleware());
ircClient.use(clientMiddleware(state, network));
ircClient.use(typingMiddleware());
+ // Tracks optimistically rendered outgoing messages for echo reconciliation and manual
+ // resend. Non-enumerable so it stays out of Vue reactivity and state persistence.
+ Object.defineProperty(network, 'pendingMessages', {
+ writable: true,
+ value: new PendingMessages(network),
+ });
+
// Overload the connect() function to make sure we are connecting with the
// most recent connection details from the network state
let originalIrcClientConnect = ircClient.connect;
@@ -170,6 +181,10 @@ function clientMiddleware(state, network) {
isRegistered = false;
network.state = 'disconnected';
+ // Any message still waiting for its server echo will never get one on this
+ // connection - flag them as failed so the user can manually resend them.
+ network.pendingMessages.failAll();
+
if (err) {
network.state_error = (typeof err === 'string') ? err : 'err_unknown';
}
@@ -305,6 +320,15 @@ function clientMiddleware(state, network) {
}
}
+ // labeled-response ACK: the server completes a labeled command that produces no
+ // other response (eg. kiwibnc when the upstream cannot carry our label, or for
+ // *bnc-handled messages). Resolve the matching pending message and keep the ACK
+ // out of the server tab.
+ if (command === 'unknown command' && event.command === 'ACK') {
+ network.pendingMessages.handleAck(event.tags && event.tags.label);
+ return;
+ }
+
// Show unhandled data from the server in the servers tab
if (command === 'unknown command') {
let buffer = network.serverBuffer();
@@ -428,6 +452,14 @@ function clientMiddleware(state, network) {
bufferName = event.message.substr(1, event.message.indexOf(']') - 1);
}
+ // Our own messages coming back to us - either echoed live (echo-message) or
+ // replayed from history after a reconnect - may correspond to a message we
+ // already rendered when sending it. Reconcile those onto the existing message
+ // instead of adding a duplicate.
+ if (network.pendingMessages.reconcile(event, bufferName)) {
+ return;
+ }
+
// Notices from somewhere when we don't have an existing buffer for them should go into
// the server tab. ie. notices from servers
if (event.type === 'notice') {
0 Pong
0

Pong (0)

Ancora nessun Pong.