Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/js/channels/MessageFlow.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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() &&
Expand Down
2 changes: 2 additions & 0 deletions src/js/dm.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions src/js/utils/network.js
Original file line number Diff line number Diff line change
@@ -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;
}
44 changes: 44 additions & 0 deletions tests/unit/channels.extended.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
18 changes: 18 additions & 0 deletions tests/unit/dm.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down
Loading