diff --git a/src/js/channels/MessageFlow.js b/src/js/channels/MessageFlow.js index bf2f9eb..8cf0e20 100644 --- a/src/js/channels/MessageFlow.js +++ b/src/js/channels/MessageFlow.js @@ -16,6 +16,7 @@ import { mediaController } from '../media.js'; import { adminStatePoller } from '../adminStatePoller.js'; import { messageTime } from '../utils/messageTime.js'; import { storageFetch } from '../storageFetch.js'; +import { NO_NETWORK, isOffline } from '../utils/network.js'; export class MessageFlow { /** @@ -653,6 +654,7 @@ export class MessageFlow { delete message.undelivered; let published; try { + if (isOffline()) throw new Error(NO_NETWORK); await this.manager.rotationRetry?.settle(messageStreamId); published = await this.manager.publishWithRetry(messageStreamId, message, channel.password); } catch (error) { @@ -695,6 +697,7 @@ export class MessageFlow { if (!currentAddress) { throw new Error('Not authenticated'); } + if (isOffline()) return; let canPublish = false; const cacheValid = channel._publishPermCache?.address?.toLowerCase() === currentAddress.toLowerCase() && diff --git a/src/js/dm.js b/src/js/dm.js index b46fdd6..08e31a6 100644 --- a/src/js/dm.js +++ b/src/js/dm.js @@ -18,6 +18,7 @@ import { Logger } from './logger.js'; import { applyAccount, stripLocalFields, dropLocalState } from './publisherProof.js'; import { CONFIG } from './config.js'; import { CryptoError } from './utils/errors.js'; +import { NO_NETWORK, isOffline } from './utils/network.js'; import { streamrController, STREAM_CONFIG } from './streamr.js'; import { channelManager } from './channels.js'; import { secureStorage } from './secureStorage.js'; @@ -1347,6 +1348,7 @@ class DMManager { async _publishDm(channel, message) { const peerInboxStreamId = channel.messageStreamId; try { + if (isOffline()) throw new Error(NO_NETWORK); // E2E encrypt before publishing — NEVER send plaintext DMs const privateKey = authManager.wallet?.privateKey; const peerAddress = channel.peerAddress; diff --git a/src/js/utils/network.js b/src/js/utils/network.js new file mode 100644 index 0000000..7bb4e79 --- /dev/null +++ b/src/js/utils/network.js @@ -0,0 +1,6 @@ +export const NO_NETWORK = 'No network connection'; + +/** True only when the browser reports no network at all; `onLine === true` proves nothing. */ +export function isOffline() { + return typeof navigator !== 'undefined' && navigator.onLine === false; +} diff --git a/tests/unit/channels.extended.test.js b/tests/unit/channels.extended.test.js index 6675000..f91d992 100644 --- a/tests/unit/channels.extended.test.js +++ b/tests/unit/channels.extended.test.js @@ -611,6 +611,50 @@ describe('ChannelManager Extended', () => { }); // ==================== updateMemberPermissions ==================== + describe('sending with no network', () => { + const streamId = 'stream-offline-send'; + let channel; + let offline; + + beforeEach(() => { + channel = { + messageStreamId: streamId, + streamId, + type: 'public', + members: ['0xmyaddress'], + messages: [], + reactions: {}, + password: null, + ephemeralStreamId: `${streamId}-ephemeral`, + createdBy: '0xmyaddress' + }; + channelManager.channels.set(streamId, channel); + identityManager.resolveENS = vi.fn().mockResolvedValue(null); + offline = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false); + }); + + afterEach(() => { + offline.mockRestore(); + delete identityManager.resolveENS; + }); + + it('fails at once, keeps the message for Retry, and publishes it once the network is back', async () => { + const { NO_NETWORK } = await import('../../src/js/utils/network.js'); + const publish = vi.spyOn(channelManager, 'publishWithRetry').mockResolvedValue(undefined); + + await expect(channelManager.sendMessage(streamId, 'hello')).rejects.toThrow(NO_NETWORK); + expect(channel.messages[0]).toMatchObject({ text: 'hello', pending: false, failed: true, failError: NO_NETWORK }); + + await expect(channelManager.resendMessage(streamId, channel.messages[0].id)).rejects.toThrow(NO_NETWORK); + expect(publish).not.toHaveBeenCalled(); + + offline.mockReturnValue(true); + await channelManager.resendMessage(streamId, channel.messages[0].id); + expect(publish).toHaveBeenCalledTimes(1); + expect(channel.messages[0]).toMatchObject({ failed: false }); + }); + }); + describe('updateMemberPermissions', () => { const streamId = 'stream-gated-3'; diff --git a/tests/unit/dm.test.js b/tests/unit/dm.test.js index 7bad2c4..6be9b42 100644 --- a/tests/unit/dm.test.js +++ b/tests/unit/dm.test.js @@ -1278,6 +1278,24 @@ describe('DMManager', () => { ); }); + it('fails at once with no network, without sealing or publishing', async () => { + const { NO_NETWORK } = await import('../../src/js/utils/network.js'); + const peerAddress = '0xpeercccccccccccccccccccccccccccccccccccc'; + const streamId = `${peerAddress}/Pombo-DM-1`; + const channel = { messageStreamId: streamId, type: 'dm', peerAddress, messages: [] }; + channelManager.channels.set(streamId, channel); + const offline = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false); + try { + await expect(dmManager.sendMessage(streamId, 'Hello')).rejects.toThrow(NO_NETWORK); + } finally { + offline.mockRestore(); + } + + expect(channel.messages[0]).toMatchObject({ pending: false, failed: true, failError: NO_NETWORK }); + expect(dmCrypto.seal).not.toHaveBeenCalled(); + expect(streamrController.publishAs).not.toHaveBeenCalled(); + }); + it('marks a failed DM and resends it under the same id', async () => { const peerAddress = '0xpeeraaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; const streamId = `${peerAddress}/Pombo-DM-1`;