diff --git a/app/components/channel-nav.hbs b/app/components/channel-nav.hbs index 4f1f4848..4048fb66 100644 --- a/app/components/channel-nav.hbs +++ b/app/components/channel-nav.hbs @@ -18,7 +18,7 @@ {{/if}} {{else}} - # {{channel.shortName}} + # {{channel.sidebarName}} {{/if}} kosmos-dev@server). Multi-hash + // channels preserve all '#' characters (e.g. ##kosmos-dev -> + // ##kosmos-dev@server, URL-encoded as %23%23kosmos-dev@server). + return this.id.replace(/^#(?=[^#])/, ''); } - get shortName () { + get sidebarName () { switch (this.protocol) { case 'IRC': - return this.name.replace(/#/g,''); + // Strip a single leading '#' so the sidebar (which already renders a + // leading '#') shows all hashes of the channel name, e.g. + // #kosmos-dev -> kosmos-dev (renders as "# kosmos-dev"), + // ##kosmos-dev -> #kosmos-dev (renders as "# #kosmos-dev"). + return this.name.replace(/^#/, ''); case 'XMPP': return this.name.match(/^(.+)@/)[1]; default: diff --git a/app/models/channel.js b/app/models/channel.js index a2a7fa36..6ee20c29 100644 --- a/app/models/channel.js +++ b/app/models/channel.js @@ -24,8 +24,13 @@ export default class Channel extends BaseChannel { } get publicLogsBaseUrl () { - // TODO needs to get hostname from channel meta info for federated protocols - return `${config.publicLogs.defaultBaseUrl}/${this.account.server.hostname.toLowerCase()}/channels/${this.shortName}`; + // Mirror the slug's leading-# rule: strip a single leading '#' only for + // single-# channels (keeps existing log URLs working); preserve all '#' for + // multi-# channels and percent-encode them so they don't become URL fragments. + const channelName = this.name + .replace(/^#(?=[^#])/, '') + .replace(/#/g, '%23'); + return `${config.publicLogs.defaultBaseUrl}/${this.account.server.hostname.toLowerCase()}/channels/${channelName}`; } } diff --git a/app/routes/base_channel.js b/app/routes/base_channel.js index 21c0e74a..65fcb8f1 100644 --- a/app/routes/base_channel.js +++ b/app/routes/base_channel.js @@ -13,15 +13,15 @@ export default class BaseChannelRoute extends Route { } model (params) { - let channel = this.coms.channels.find(ch => ch.slug === params.slug); + const slug = decodeURIComponent(params.slug); + let channel = this.coms.channels.find(ch => ch.slug === slug); if (channel) return channel; - const channelId = decodeURIComponent(params.slug); - const domain = channelId.match(/@([^/]+)/)[1]; + const domain = slug.match(/@([^/]+)/)[1]; const randomChannelForDomain = this.coms.channels.find(ch => ch.domain === domain); if (randomChannelForDomain) { - channel = this.createChannelOrUserChannel(randomChannelForDomain.account, channelId); + channel = this.createChannelOrUserChannel(randomChannelForDomain.account, slug); return channel; } else { const firstChannel = this.coms.channels.firstObject; diff --git a/app/routes/channel.js b/app/routes/channel.js index d5c123ed..5509f2d5 100644 --- a/app/routes/channel.js +++ b/app/routes/channel.js @@ -5,9 +5,14 @@ export default class ChannelRoute extends BaseChannel { createChannelOrUserChannel (account, channelId) { let channel; switch(account.protocol) { - case 'IRC': - channel = this.coms.createChannel(account, '#'+channelId.match(/^(.+)@/)[1]); + case 'IRC': { + let name = channelId.match(/^(.+)@/)[1]; + // Old/persisted slugs for single-hash channels have no leading '#'; + // multi-hash slugs already carry their '#' characters. + if (!name.startsWith('#')) name = `#${name}`; + channel = this.coms.createChannel(account, name); break; + } case 'XMPP': channel = this.coms.createChannel(account, channelId); break; diff --git a/app/services/sockethub-irc.js b/app/services/sockethub-irc.js index c2458755..f95f019d 100644 --- a/app/services/sockethub-irc.js +++ b/app/services/sockethub-irc.js @@ -91,7 +91,7 @@ export default class SockethubIrcService extends Service { } handlePresenceUpdate (message) { - const match = message.target.id.match(/(.+)\//); + const match = message.target.id.match(/@(.+)$/); if (!match) { console.warn('Could not parse hostname from presence message', message); return; } let channel = this.coms.channels.find(ch => ch.sockethubChannelId === message.target.id); diff --git a/tests/unit/models/base-channel-test.js b/tests/unit/models/base-channel-test.js index 284030c0..d944e9ea 100644 --- a/tests/unit/models/base-channel-test.js +++ b/tests/unit/models/base-channel-test.js @@ -18,6 +18,53 @@ module('Unit | Model | base-channel', function (hooks) { assert.strictEqual(model.slug, 'kosmos-dev@irc.libera.chat'); }); + test('#slug strips only one leading hash for multi-hash channels', function (assert) { + const model = new BaseChannel({ + account: ircAccount, + name: '##kosmos-dev' + }); + + assert.strictEqual(model.slug, '##kosmos-dev@irc.libera.chat'); + }); + + test('#id and #sockethubChannelId preserve all hashes for IRC channels', function (assert) { + let model = new BaseChannel({ + account: ircAccount, + name: '#kosmos-dev' + }); + assert.strictEqual(model.id, '#kosmos-dev@irc.libera.chat', 'id preserves single hash'); + assert.strictEqual(model.sockethubChannelId, '#kosmos-dev@irc.libera.chat', 'sockethubChannelId matches id'); + + model = new BaseChannel({ + account: ircAccount, + name: '##kosmos-dev' + }); + assert.strictEqual(model.id, '##kosmos-dev@irc.libera.chat', 'id preserves all hashes'); + assert.strictEqual(model.sockethubChannelId, '##kosmos-dev@irc.libera.chat', 'sockethubChannelId matches id'); + }); + + test('#sidebarName (IRC) strips a single leading hash', function (assert) { + let model = new BaseChannel({ + account: ircAccount, + name: '#kosmos-dev' + }); + assert.strictEqual(model.sidebarName, 'kosmos-dev', 'strips the single leading hash'); + + model = new BaseChannel({ + account: ircAccount, + name: '##kosmos-dev' + }); + assert.strictEqual(model.sidebarName, '#kosmos-dev', 'preserves remaining hashes'); + }); + + test('#sidebarName (XMPP) returns the local part', function (assert) { + const model = new BaseChannel({ + account: xmppAccount, + name: 'kosmos-dev@kosmos.chat' + }); + assert.strictEqual(model.sidebarName, 'kosmos-dev'); + }); + // // unreadMessagesClass // diff --git a/tests/unit/models/channel-test.js b/tests/unit/models/channel-test.js index e9a34740..968f8bf3 100644 --- a/tests/unit/models/channel-test.js +++ b/tests/unit/models/channel-test.js @@ -26,17 +26,21 @@ module('Unit | Model | channel', function (hooks) { assert.strictEqual(channel.formattedTopic.toString(), 'never gonna <marquee>give you up</marquee>'); }); - test('#shortName', function (assert) { - let channel = new Channel({ + test('#publicLogsBaseUrl keeps single-hash channels unencoded', function (assert) { + const channel = new Channel({ account: ircAccount, name: '#kosmos-dev' }); - assert.strictEqual(channel.shortName, 'kosmos-dev', 'returns name without hash for IRC'); + assert.strictEqual(channel.publicLogsBaseUrl, 'https://storage.5apps.com/kosmos/public/chat-messages/irc.libera.chat/channels/kosmos-dev'); + }); - channel = new Channel({ - account: xmppAccount, - name: 'kosmos-dev@kosmos.chat' + test('#publicLogsBaseUrl percent-encodes all hashes for multi-hash channels', function (assert) { + const channel = new Channel({ + account: ircAccount, + name: '##kosmos-dev' }); - assert.strictEqual(channel.shortName, 'kosmos-dev', 'returns name without MUC domain for XMPP'); + const baseUrl = channel.publicLogsBaseUrl; + assert.ok(!baseUrl.includes('#'), 'logs URL has no fragment'); + assert.strictEqual(baseUrl, 'https://storage.5apps.com/kosmos/public/chat-messages/irc.libera.chat/channels/%23%23kosmos-dev'); }); }); diff --git a/tests/unit/routes/channel-test.js b/tests/unit/routes/channel-test.js new file mode 100644 index 00000000..4cc52fb6 --- /dev/null +++ b/tests/unit/routes/channel-test.js @@ -0,0 +1,53 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import Service from '@ember/service'; +import ChannelRoute from 'hyperchannel/routes/channel'; +import Channel from 'hyperchannel/models/channel'; +import { ircAccount } from '../../fixtures/accounts'; + +// Stubs the coms service so createChannelOrUserChannel's channelName argument +// can be captured without joining a real channel. Returns a real Channel +// instance so id/sockethubChannelId getters resolve. +class ComsStub extends Service { + createChannelCalls = []; + createChannel (account, channelName) { + this.createChannelCalls.push({ account, channelName }); + return new Channel({ account, name: channelName }); + } +} + +module('Unit | Route | channel', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.owner.register('service:coms', ComsStub); + }); + + test('createChannelOrUserChannel reconstructs a single-hash IRC channel name', function (assert) { + const route = new ChannelRoute(); + route.coms = this.owner.lookup('service:coms'); + + // Slug for #kosmos-dev (single leading hash stripped) -> kosmos-dev@server + const channel = route.createChannelOrUserChannel(ircAccount, 'kosmos-dev@irc.libera.chat'); + + assert.strictEqual(route.coms.createChannelCalls.length, 1); + assert.strictEqual(route.coms.createChannelCalls[0].channelName, '#kosmos-dev'); + assert.strictEqual(channel.name, '#kosmos-dev'); + assert.strictEqual(channel.id, '#kosmos-dev@irc.libera.chat'); + assert.strictEqual(channel.sockethubChannelId, '#kosmos-dev@irc.libera.chat'); + }); + + test('createChannelOrUserChannel reconstructs a multi-hash IRC channel name', function (assert) { + const route = new ChannelRoute(); + route.coms = this.owner.lookup('service:coms'); + + // Slug for ##kosmos-dev (all hashes preserved) -> ##kosmos-dev@server + const channel = route.createChannelOrUserChannel(ircAccount, '##kosmos-dev@irc.libera.chat'); + + assert.strictEqual(route.coms.createChannelCalls.length, 1); + assert.strictEqual(route.coms.createChannelCalls[0].channelName, '##kosmos-dev'); + assert.strictEqual(channel.name, '##kosmos-dev'); + assert.strictEqual(channel.id, '##kosmos-dev@irc.libera.chat'); + assert.strictEqual(channel.sockethubChannelId, '##kosmos-dev@irc.libera.chat'); + }); +}); diff --git a/tests/unit/services/sockethub-irc-test.js b/tests/unit/services/sockethub-irc-test.js index e6ade7db..8a1d0762 100644 --- a/tests/unit/services/sockethub-irc-test.js +++ b/tests/unit/services/sockethub-irc-test.js @@ -15,6 +15,27 @@ module('Unit | Service | sockethub irc', function (hooks) { assert.ok(channel.connected); }); + test('#handlePresenceUpdate extracts hostname from the name@host ID format and adds the user', function (assert) { + const channel = new Channel({ + account: ircAccount, + name: '##kosmos-dev' + }); + const service = this.owner.lookup('service:sockethub-irc'); + service.coms = { + channels: [channel], + accounts: [ircAccount] + }; + + service.handlePresenceUpdate({ + target: { id: '##kosmos-dev@irc.libera.chat', type: 'room' }, + actor: { id: 'newuser@irc.libera.chat', name: 'newuser' } + }); + + assert.ok(channel.connected, 'marks the channel connected'); + assert.ok(channel.userList.includes('newuser'), 'adds the incoming user'); + assert.ok(channel.userList.includes(ircAccount.nickname), 'adds the own nickname'); + }); + // FIXME this test randomly fails with error "Assertion occured after test had finished." // skip('#join sends the join activity to Sockethub for a room channel', function(assert) { // const done = assert.async();