diff --git a/.github/workflows/release-android.yml b/.github/workflows/release-android.yml index 95949ab..2c262f4 100644 --- a/.github/workflows/release-android.yml +++ b/.github/workflows/release-android.yml @@ -214,7 +214,7 @@ jobs: tag_name: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} prerelease: ${{ github.event_name == 'push' || inputs.prerelease }} body: | - - Public Channels beta: connect, discover, share, and join live RRC hubs with local history, unread counts, mentions, and presence + - Public Channels beta: connect, discover, share, and join live RRC hubs with local history, unread counts, mentions, presence, and identity avatars - Bidirectional RRC messages and actions with authenticated reconnect/rejoin and safer malformed-peer handling - Opt-in hub hosting with graphical room, access, moderation, and discovery controls - Mobile Channels polish for keyboard resizing, safe areas, Back navigation, sheets, and partial member lists diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 3f50d33..6dbf946 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -295,7 +295,7 @@ jobs: tag_name: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} prerelease: ${{ github.event_name == 'push' || inputs.prerelease }} body: | - - Public Channels beta: connect, discover, share, and join live RRC hubs with local history, unread counts, mentions, and presence + - Public Channels beta: connect, discover, share, and join live RRC hubs with local history, unread counts, mentions, presence, and identity avatars - Bidirectional RRC messages and actions with authenticated reconnect/rejoin and safer malformed-peer handling - Opt-in hub hosting with graphical room, access, moderation, and discovery controls - Mobile Channels polish for keyboard resizing, safe areas, Back navigation, sheets, and partial member lists diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index 41029c4..a690e04 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -263,7 +263,7 @@ jobs: tag_name: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} prerelease: ${{ github.event_name == 'push' || inputs.prerelease }} body: | - - Public Channels beta: connect, discover, share, and join live RRC hubs with local history, unread counts, mentions, and presence + - Public Channels beta: connect, discover, share, and join live RRC hubs with local history, unread counts, mentions, presence, and identity avatars - Bidirectional RRC messages and actions with authenticated reconnect/rejoin and safer malformed-peer handling - Opt-in hub hosting with graphical room, access, moderation, and discovery controls - Mobile Channels polish for keyboard resizing, safe areas, Back navigation, sheets, and partial member lists diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index 3d7de42..6aa7e1b 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -192,7 +192,7 @@ jobs: tag_name: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} prerelease: ${{ github.event_name == 'push' || inputs.prerelease }} body: | - - Public Channels beta: connect, discover, share, and join live RRC hubs with local history, unread counts, mentions, and presence + - Public Channels beta: connect, discover, share, and join live RRC hubs with local history, unread counts, mentions, presence, and identity avatars - Bidirectional RRC messages and actions with authenticated reconnect/rejoin and safer malformed-peer handling - Opt-in hub hosting with graphical room, access, moderation, and discovery controls - Mobile Channels polish for keyboard resizing, safe areas, Back navigation, sheets, and partial member lists diff --git a/crates/ratspeak-db/src/db.rs b/crates/ratspeak-db/src/db.rs index 7421fc7..7ef30b6 100644 --- a/crates/ratspeak-db/src/db.rs +++ b/crates/ratspeak-db/src/db.rs @@ -3174,6 +3174,9 @@ pub struct ChannelHistoryEvent { pub timestamp_ms: u64, pub recorded_at_ms: u64, pub source_hash: Option, + /// Presentation-only LXMF destination derived by the command layer. It is + /// intentionally not duplicated in the local history table. + pub source_lxmf_hash: Option, pub nickname: Option, pub text: String, pub ours: bool, @@ -3753,6 +3756,7 @@ fn channel_history_row(row: &rusqlite::Row<'_>) -> Result(10)? != 0, diff --git a/crates/ratspeak-runtime/src/channels.rs b/crates/ratspeak-runtime/src/channels.rs index 5d4ff39..c3b4890 100644 --- a/crates/ratspeak-runtime/src/channels.rs +++ b/crates/ratspeak-runtime/src/channels.rs @@ -53,6 +53,7 @@ const DIRECTORY_MAX_TOPIC_BYTES: usize = 512; const DEFAULT_NICK_MAX_BYTES: usize = 32; const DEFAULT_ROOM_MAX_BYTES: usize = 64; const DEFAULT_MESSAGE_MAX_BYTES: usize = 350; +const LXMF_DELIVERY_ASPECT: &str = "lxmf.delivery"; const TRANSCRIPT_LIMIT: usize = 300; const NOTICE_LIMIT: usize = 100; const SEEN_MESSAGE_LIMIT: usize = 2_048; @@ -95,6 +96,21 @@ fn next_channels_generation() -> u64 { NEXT_CHANNELS_GENERATION.fetch_add(1, Ordering::Relaxed) } +fn lxmf_destination_hash(identity_hash: [u8; 16]) -> String { + hex::encode(Destination::hash_from_name_and_identity( + LXMF_DELIVERY_ASPECT, + Some(&identity_hash), + )) +} + +/// Derive the canonical LXMF delivery destination used by Ratspeak avatars +/// from a Reticulum identity hash supplied by an authenticated RRC Link. +pub fn lxmf_destination_hash_from_identity_hex(identity_hash: &str) -> Option { + let bytes = hex::decode(identity_hash).ok()?; + let identity_hash: [u8; 16] = bytes.try_into().ok()?; + Some(lxmf_destination_hash(identity_hash)) +} + /// Fenced Activity recorder shared with the hub service: both sides of /// Channels record through the same origin-fence logic rather than two copies. #[derive(Clone)] @@ -275,6 +291,8 @@ pub struct ChannelMemberSnapshot { /// Stable Reticulum identity hash from a hub roster or the reported source /// of observed room content. Some hubs omit both, leaving only a nickname. pub identity_hash: Option, + /// Canonical `lxmf.delivery` destination derived from `identity_hash`. + pub lxmf_hash: Option, pub nickname: Option, pub is_self: bool, } @@ -285,6 +303,8 @@ pub struct ChannelTranscriptItem { pub kind: ChannelItemKind, pub timestamp_ms: u64, pub source_hash: Option, + /// Canonical `lxmf.delivery` destination derived from `source_hash`. + pub source_lxmf_hash: Option, pub nickname: Option, pub text: String, pub ours: bool, @@ -5023,6 +5043,7 @@ fn apply_joined(active: &mut ActiveSession, envelope: &Envelope) { let identity_count = identities.len(); let includes_self = identities.contains(&active.source); let single_identity_hash = (identity_count == 1).then(|| hex::encode(identities[0])); + let single_lxmf_hash = (identity_count == 1).then(|| lxmf_destination_hash(identities[0])); let mut single_member_inserted = false; let mut nickname_member_inserted = false; let confirming_self = room.phase == ChannelRoomPhase::Joining @@ -5121,6 +5142,13 @@ fn apply_joined(active: &mut ActiveSession, envelope: &Envelope) { } else { None }; + item.source_lxmf_hash = if confirming_self { + Some(lxmf_destination_hash(active.source)) + } else if identity_count == 1 { + single_lxmf_hash + } else { + None + }; append_room_item( &mut active.history_events, active.destination_hash, @@ -5377,6 +5405,7 @@ fn apply_rrcd_room_status_notice(active: &mut ActiveSession, envelope: &Envelope kind: ChannelItemKind::Join, timestamp_ms: envelope.timestamp_ms, source_hash: Some(hex::encode(active.source)), + source_lxmf_hash: Some(lxmf_destination_hash(active.source)), nickname: Some(active.nickname.clone()), text: "You joined".into(), ours: true, @@ -5449,6 +5478,7 @@ fn apply_parted(active: &mut ActiveSession, envelope: &Envelope) { false, ); item.source_hash = (identities.len() == 1).then(|| hex::encode(identities[0])); + item.source_lxmf_hash = (identities.len() == 1).then(|| lxmf_destination_hash(identities[0])); append_room_item( &mut active.history_events, active.destination_hash, @@ -5822,6 +5852,7 @@ fn transcript_item( kind, timestamp_ms: envelope.timestamp_ms, source_hash: Some(hex::encode(envelope.source)), + source_lxmf_hash: Some(lxmf_destination_hash(envelope.source)), nickname, text, ours, @@ -5877,6 +5908,7 @@ fn upsert_member( is_self: bool, ) -> bool { let identity_hash = identity.map(hex::encode); + let lxmf_hash = identity.map(lxmf_destination_hash); let existing_index = identity_hash .as_deref() .and_then(|hash| { @@ -5895,6 +5927,9 @@ fn upsert_member( if existing.identity_hash.is_none() { existing.identity_hash = identity_hash; } + if existing.lxmf_hash.is_none() { + existing.lxmf_hash = lxmf_hash; + } if nickname.is_some() { existing.nickname = nickname; } @@ -5903,6 +5938,7 @@ fn upsert_member( } else { members.push(ChannelMemberSnapshot { identity_hash, + lxmf_hash, nickname, is_self, }); @@ -7980,6 +8016,7 @@ mod tests { kind: ChannelItemKind::Message, timestamp_ms: index as u64, source_hash: None, + source_lxmf_hash: None, nickname: None, text: "signal".into(), ours: false, @@ -8003,6 +8040,7 @@ mod tests { fn observed_member_upsert_promotes_nickname_only_rows_without_duplicates() { let mut members = vec![ChannelMemberSnapshot { identity_hash: None, + lxmf_hash: None, nickname: Some("Field Rat".into()), is_self: false, }]; @@ -8020,6 +8058,16 @@ mod tests { members[0].identity_hash.as_deref(), Some(identity_hash.as_str()) ); + let expected_lxmf_hash = lxmf_destination_hash(identity); + assert_eq!( + members[0].lxmf_hash.as_deref(), + Some(expected_lxmf_hash.as_str()) + ); + assert_eq!( + lxmf_destination_hash_from_identity_hex(&identity_hash).as_deref(), + Some(expected_lxmf_hash.as_str()) + ); + assert!(lxmf_destination_hash_from_identity_hex("not-an-identity").is_none()); assert!(!upsert_member( &mut members, diff --git a/crates/ratspeak-tauri/src/commands/channels.rs b/crates/ratspeak-tauri/src/commands/channels.rs index d0d019d..4c0f8fc 100644 --- a/crates/ratspeak-tauri/src/commands/channels.rs +++ b/crates/ratspeak-tauri/src/commands/channels.rs @@ -295,7 +295,7 @@ pub async fn api_channel_history( let after = args.after; let prune_expired = before.is_none() && after.is_none(); let pool = state.db.clone(); - crate::db::spawn_db(pool, move |pool| { + let mut page = crate::db::spawn_db(pool, move |pool| { if prune_expired { crate::db::prune_expired_channel_history(&pool)?; } @@ -320,7 +320,14 @@ pub async fn api_channel_history( }) .await .map_err(|_| AppError::internal("channel history database task panicked"))? - .map_err(AppError::database_unavailable) + .map_err(AppError::database_unavailable)?; + for item in &mut page.items { + item.source_lxmf_hash = item + .source_hash + .as_deref() + .and_then(ratspeak_runtime::channels::lxmf_destination_hash_from_identity_hex); + } + Ok(page) } #[tauri::command] diff --git a/dashboard/index.html b/dashboard/index.html index 284eff3..126b83c 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -11,7 +11,7 @@ injects pixel values via evaluateJavascript; desktop falls back to 0. --> Ratspeak - Dashboard - + @@ -1882,7 +1882,7 @@ - + diff --git a/dashboard/scripts/test_channels_avatars.js b/dashboard/scripts/test_channels_avatars.js new file mode 100644 index 0000000..2d6a009 --- /dev/null +++ b/dashboard/scripts/test_channels_avatars.js @@ -0,0 +1,168 @@ +#!/usr/bin/env node +'use strict'; + +var assert = require('assert'); +var fs = require('fs'); +var path = require('path'); +var vm = require('vm'); + +var dashboardRoot = path.join(__dirname, '..'); +var channelsSource = fs.readFileSync( + path.join(dashboardRoot, 'static', 'js', 'channels.js'), + 'utf8' +); +var channelsCss = fs.readFileSync( + path.join(dashboardRoot, 'static', 'css', '09-channels.css'), + 'utf8' +); +var indexSource = fs.readFileSync(path.join(dashboardRoot, 'index.html'), 'utf8'); + +function functionSource(name) { + var start = channelsSource.indexOf('function ' + name + '('); + assert.notStrictEqual(start, -1, name + ' must exist'); + var brace = channelsSource.indexOf('{', start); + var depth = 0; + for (var index = brace; index < channelsSource.length; index++) { + if (channelsSource[index] === '{') depth += 1; + if (channelsSource[index] === '}') { + depth -= 1; + if (depth === 0) return channelsSource.slice(start, index + 1); + } + } + throw new Error('unterminated function ' + name); +} + +var active = { + hash: 'aa'.repeat(16), + lxmf_hash: '11'.repeat(16) +}; +var live = { + identity_hash: 'aa'.repeat(16), + hash: '11'.repeat(16) +}; +var knownRemote = 'bb'.repeat(16); +var remoteLxmf = '22'.repeat(16); +var context = { + Number: Number, + String: String, + activeIdentity: function() { return active; }, + lxmfIdentity: live, + _channelsPeerForIdentity: function(identityHash) { + return identityHash === knownRemote ? { hash: remoteLxmf } : null; + }, + _channelsPeerLxmfAddress: function(peer) { return peer ? peer.hash : ''; }, + identityAvatar: function(seed, size) { + return ''; + }, + document: { + createElement: function() { + return { className: '', textContent: '' }; + } + } +}; +vm.createContext(context); +vm.runInContext( + functionSource('_channelsNormalizeHistoryItem') + '\n' + + functionSource('_channelsIdentityAvatarSeed') + '\n' + + functionSource('_channelsPopulateIdentityAvatar'), + context +); + +var normalizedHistory = context._channelsNormalizeHistoryItem({ + event_id: 'event-1', + source_hash: knownRemote, + source_lxmf_hash: remoteLxmf +}); +assert.strictEqual(normalizedHistory.source_hash, knownRemote); +assert.strictEqual(normalizedHistory.source_lxmf_hash, remoteLxmf, + 'persisted history must retain its canonical LXMF avatar seed'); + +assert.strictEqual( + context._channelsIdentityAvatarSeed(active.hash, '', true), + active.lxmf_hash, + 'the local channel identity must reuse the active LXMF avatar seed' +); +assert.strictEqual( + context._channelsIdentityAvatarSeed(knownRemote, remoteLxmf, false), + remoteLxmf, + 'a canonical remote LXMF destination must be used directly' +); +assert.strictEqual( + context._channelsIdentityAvatarSeed(knownRemote, '', false), + remoteLxmf, + 'a legacy snapshot may still reuse a discovered peer LXMF destination' +); +assert.strictEqual( + context._channelsIdentityAvatarSeed('cc'.repeat(16), '', false), + '', + 'an unidentified LXMF destination must use the neutral avatar' +); +assert.strictEqual( + context._channelsIdentityAvatarSeed('dd'.repeat(16), '', true), + '', + 'a mismatched local identity must not borrow the active identity avatar' +); +assert.strictEqual( + context._channelsIdentityAvatarSeed('', '', false), + '', + 'a nickname-only member must use the neutral avatar instead of a mutable seed' +); + +var avatar = { + innerHTML: '', + attributes: {}, + children: [], + setAttribute: function(name, value) { this.attributes[name] = value; }, + appendChild: function(child) { this.children.push(child); } +}; +context._channelsPopulateIdentityAvatar(avatar, remoteLxmf, 40, 'Ada'); +assert.strictEqual(avatar.attributes['aria-hidden'], 'true'); +assert(avatar.innerHTML.includes('data-seed="' + remoteLxmf + '"')); +assert(avatar.innerHTML.includes('data-size="40"')); + +context.identityAvatar = undefined; +var fallback = { + innerHTML: '', + attributes: {}, + children: [], + setAttribute: function(name, value) { this.attributes[name] = value; }, + appendChild: function(child) { this.children.push(child); } +}; +context._channelsPopulateIdentityAvatar(fallback, '', 40); +assert.strictEqual(fallback.children.length, 1); +assert.strictEqual(fallback.children[0].className, 'channel-avatar-fallback'); +assert.strictEqual(fallback.children[0].textContent, ''); + +var transcriptStart = channelsSource.indexOf('function _channelsBuildTranscriptItem'); +var transcriptEnd = channelsSource.indexOf('\nfunction _channelsMemberName', transcriptStart); +var transcriptSource = channelsSource.slice(transcriptStart, transcriptEnd); +assert(transcriptSource.includes("avatar.className = 'channel-event-avatar'")); +assert(transcriptSource.includes('_channelsIdentityAvatarSeed(item.source_hash, item.source_lxmf_hash, !!item.ours')); +assert(!transcriptSource.includes('channel-identity-marker')); +assert(!transcriptSource.includes('event.dataset.tone')); + +var memberStart = channelsSource.indexOf('function _channelsRenderMembers'); +var memberEnd = channelsSource.indexOf('\nfunction _channelsUpdateMobileMode', memberStart); +var memberSource = channelsSource.slice(memberStart, memberEnd); +assert(memberSource.includes("avatar.className = 'channel-member-avatar'")); +assert(memberSource.includes('_channelsIdentityAvatarSeed(member.identity_hash, member.lxmf_hash, !!member.is_self')); +assert(!memberSource.includes('channel-identity-marker')); +assert(!memberSource.includes('row.dataset.tone')); + +var detailStart = channelsSource.indexOf('function _channelsRenderMemberDetail'); +var detailEnd = channelsSource.indexOf('\nfunction _channelsShowMemberList', detailStart); +var detailSource = channelsSource.slice(detailStart, detailEnd); +assert(detailSource.includes("details.lxmfAddress || ''")); +assert(!detailSource.includes('details.lxmfAddress || details.identityHash')); + +assert(channelsCss.includes('grid-template-areas:')); +assert(channelsCss.includes('"avatar author meta"')); +assert(channelsCss.includes('.channel-event-avatar')); +assert(channelsCss.includes('.channel-member-avatar')); +assert(!channelsCss.includes('.channel-identity-marker')); +assert(!channelsCss.includes('.channel-event[data-tone=')); +assert(!channelsCss.includes('.channel-member-row[data-tone=')); +assert(indexSource.includes('/static/style.css?v=1.0.39')); +assert(indexSource.includes('/static/js/channels.js?v=1.0.44')); + +process.stdout.write('Channels avatar tests passed.\n'); diff --git a/dashboard/scripts/test_channels_timestamp_render.js b/dashboard/scripts/test_channels_timestamp_render.js index 8354de7..e777b9a 100644 --- a/dashboard/scripts/test_channels_timestamp_render.js +++ b/dashboard/scripts/test_channels_timestamp_render.js @@ -49,6 +49,11 @@ var context = { _channelsBuildHubNotice: function() { throw new Error('not a hub notice'); }, _channelsBuildPresenceEvent: function() { throw new Error('not presence'); }, _channelsBuildQuoteButton: function() { return null; }, + _channelsIdentityAvatarSeed: function(sourceHash, lxmfHash) { return lxmfHash || sourceHash; }, + _channelsPopulateIdentityAvatar: function(avatar, seed, size) { + avatar.avatarSeed = seed; + avatar.avatarSize = size; + }, _channelsIdentityTone: function() { return '0'; }, _channelsShortHash: function() { return 'peer'; } }; @@ -64,14 +69,19 @@ var rendered = context._channelsBuildTranscriptItem({ kind: 'message', timestamp_ms: '18446744073709551615', source_hash: '11'.repeat(16), + source_lxmf_hash: '22'.repeat(16), nickname: 'Remote rat', text: 'still renders', ours: false }, false); -var meta = rendered.children[1]; +var avatar = rendered.children[0]; +var meta = rendered.children[2]; var time = meta.children[0]; assert.doesNotThrow(function() { new Date(time.dateTime).toISOString(); }); assert.notStrictEqual(time.dateTime, 'Invalid Date'); -assert.strictEqual(rendered.children[2].textContent, 'still renders'); +assert.strictEqual(avatar.className, 'channel-event-avatar'); +assert.strictEqual(avatar.avatarSeed, '22'.repeat(16)); +assert.strictEqual(avatar.avatarSize, 32); +assert.strictEqual(rendered.children[3].textContent, 'still renders'); process.stdout.write('Channels timestamp rendering tests passed.\n'); diff --git a/dashboard/static/css/09-channels.css b/dashboard/static/css/09-channels.css index 4872d71..690648d 100644 --- a/dashboard/static/css/09-channels.css +++ b/dashboard/static/css/09-channels.css @@ -1186,7 +1186,10 @@ html[data-channel-hosting="off"] .channel-owned-hub { .channel-event { display: grid; - grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + "avatar author meta" + "avatar body body"; + grid-template-columns: 32px minmax(0, 1fr) auto; column-gap: var(--space-5); row-gap: var(--space-1); margin: 0 0 var(--space-6); @@ -1208,48 +1211,45 @@ html[data-channel-hosting="off"] .channel-owned-hub { box-shadow: inset 2px 0 0 var(--accent); } -.channel-event[data-tone="0"], -.channel-member-row[data-tone="0"] { - --channel-identity-color: var(--status-info); -} - -.channel-event[data-tone="1"], -.channel-member-row[data-tone="1"] { - --channel-identity-color: var(--status-purple); -} - -.channel-event[data-tone="2"], -.channel-member-row[data-tone="2"] { - --channel-identity-color: var(--status-online); -} - -.channel-event[data-tone="3"], -.channel-member-row[data-tone="3"] { - --channel-identity-color: var(--ble-accent); -} - -.channel-event[data-tone="4"], -.channel-member-row[data-tone="4"] { - --channel-identity-color: var(--status-error); -} - -.channel-event[data-tone="5"], -.channel-member-row[data-tone="5"], -.channel-event[data-tone="self"], -.channel-member-row[data-tone="self"] { - --channel-identity-color: var(--accent); -} - -.channel-identity-marker { - display: inline-block; - width: 7px; - height: 7px; - flex: 0 0 7px; +.channel-event-avatar { + grid-area: avatar; + display: flex; + width: 32px; + height: 32px; + align-self: start; + flex: 0 0 32px; + overflow: hidden; border-radius: var(--radius-full); - background: var(--channel-identity-color, var(--text-muted)); + line-height: 0; +} + +.channel-event-avatar svg, +.channel-event-avatar img, +.channel-event-avatar canvas, +.channel-member-avatar svg, +.channel-member-avatar img, +.channel-member-avatar canvas, +.channel-member-detail-avatar svg, +.channel-member-detail-avatar img, +.channel-member-detail-avatar canvas { + display: block; + width: 100%; + height: 100%; +} + +.channel-avatar-fallback { + display: inline-flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + border-radius: var(--radius-full); + background: var(--surface-control); + box-shadow: inset 0 0 0 1px var(--border-surface-soft); } .channel-event-author { + grid-area: author; display: flex; align-items: center; gap: var(--space-3); @@ -1283,6 +1283,7 @@ html[data-channel-hosting="off"] .channel-owned-hub { } .channel-event-meta { + grid-area: meta; display: inline-flex; align-items: center; justify-content: flex-end; @@ -1342,8 +1343,7 @@ html[data-channel-hosting="off"] .channel-owned-hub { } .channel-event-text { - grid-column: 1 / 3; - padding-left: calc(7px + var(--space-3)); + grid-area: body; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--type-leading-copy); @@ -1761,8 +1761,8 @@ html[data-channel-hosting="off"] .channel-owned-hub { align-items: center; width: 100%; gap: var(--space-4); - min-height: 44px; - padding: var(--space-4); + min-height: 56px; + padding: var(--space-3) var(--space-4); border: 0; border-radius: var(--radius-md); background: transparent; @@ -1790,6 +1790,16 @@ html[data-channel-hosting="off"] .channel-owned-hub { min-width: 0; } +.channel-member-avatar { + display: flex; + width: 40px; + height: 40px; + flex: 0 0 40px; + overflow: hidden; + border-radius: var(--radius-full); + line-height: 0; +} + .channel-member-name { overflow: hidden; color: var(--text-primary); @@ -1823,7 +1833,13 @@ html[data-channel-hosting="off"] .channel-owned-hub { } .channel-member-detail-avatar { + display: flex; + width: 52px; + height: 52px; flex: 0 0 52px; + overflow: hidden; + border-radius: var(--radius-full); + line-height: 0; } .channel-member-detail-hero-copy { diff --git a/dashboard/static/js/channels.js b/dashboard/static/js/channels.js index f378093..b5a9467 100644 --- a/dashboard/static/js/channels.js +++ b/dashboard/static/js/channels.js @@ -1260,6 +1260,7 @@ function _channelsNormalizeHistoryItem(item) { timestamp_ms: Number(item.timestamp_ms) || 0, recorded_at_ms: Number(item.recorded_at_ms) || 0, source_hash: item.source_hash || null, + source_lxmf_hash: item.source_lxmf_hash || null, nickname: item.nickname || null, text: String(item.text || ''), ours: !!item.ours, @@ -2842,16 +2843,19 @@ function _channelsBuildTranscriptItem(item, hubNotice) { event.className = 'channel-event ' + kind + (item.ours ? ' ours' : '') + (mentioned ? ' mentioned' : ''); var authorText = item.nickname || (item.ours ? (channelsSnapshot.nickname || 'You') : _channelsShortHash(item.source_hash)) || 'Hub'; - event.dataset.tone = item.ours ? 'self' : _channelsIdentityTone(item.source_hash || authorText); + + var avatar = document.createElement('span'); + avatar.className = 'channel-event-avatar'; + _channelsPopulateIdentityAvatar( + avatar, + _channelsIdentityAvatarSeed(item.source_hash, item.source_lxmf_hash, !!item.ours), + 32 + ); var author = document.createElement('span'); author.className = 'channel-event-author'; - var marker = document.createElement('i'); - marker.className = 'channel-identity-marker'; - marker.setAttribute('aria-hidden', 'true'); var authorLabel = document.createElement('span'); authorLabel.textContent = item.ours ? authorText + ' (you)' : authorText; - author.appendChild(marker); author.appendChild(authorLabel); if (mentioned) { var mentionMarker = document.createElement('span'); @@ -2872,6 +2876,7 @@ function _channelsBuildTranscriptItem(item, hubNotice) { body.className = 'channel-event-text'; body.textContent = kind === 'action' ? authorText + ' ' + (item.text || '') : (item.text || ''); + event.appendChild(avatar); event.appendChild(author); event.appendChild(meta); event.appendChild(body); @@ -2911,6 +2916,42 @@ function _channelsPeerLxmfAddress(peer) { return services.indexOf('lxmf.delivery') !== -1 ? peer.hash : ''; } +function _channelsIdentityAvatarSeed(identityHash, lxmfHash, isSelf) { + var target = String(identityHash || '').trim().toLowerCase(); + var canonical = String(lxmfHash || '').trim().toLowerCase(); + if (canonical) return canonical; + if (isSelf) { + var active = typeof activeIdentity === 'function' ? activeIdentity() : null; + var activeIdentityHash = String(active && (active.hash || active.identity_hash) || '') + .trim().toLowerCase(); + if (active && (!target || !activeIdentityHash || activeIdentityHash === target)) { + var activeLxmf = String(active.lxmf_hash || '').trim().toLowerCase(); + if (activeLxmf) return activeLxmf; + } + var live = typeof lxmfIdentity !== 'undefined' ? lxmfIdentity : null; + var liveIdentityHash = String(live && live.identity_hash || '').trim().toLowerCase(); + if (live && (!target || !liveIdentityHash || liveIdentityHash === target)) { + var liveLxmf = String(live.lxmf_hash || live.hash || '').trim().toLowerCase(); + if (liveLxmf) return liveLxmf; + } + } else if (target) { + var peerLxmf = _channelsPeerLxmfAddress(_channelsPeerForIdentity(target)); + if (peerLxmf) return String(peerLxmf).trim().toLowerCase(); + } + return ''; +} + +function _channelsPopulateIdentityAvatar(element, seed, size) { + element.setAttribute('aria-hidden', 'true'); + if (typeof identityAvatar === 'function') { + element.innerHTML = identityAvatar(seed, size); + return; + } + var fallback = document.createElement('span'); + fallback.className = 'channel-avatar-fallback'; + element.appendChild(fallback); +} + function _channelsMemberDetails(member) { var identityHash = String(member.identity_hash || '').toLowerCase(); var peer = member.is_self ? null : _channelsPeerForIdentity(identityHash); @@ -2919,8 +2960,8 @@ function _channelsMemberDetails(member) { var liveSelf = member.is_self && typeof lxmfIdentity !== 'undefined' ? lxmfIdentity : null; var liveSelfMatches = liveSelf && (!identityHash || String(liveSelf.identity_hash || '').toLowerCase() === identityHash); var lxmfAddress = member.is_self - ? String((activeMatches ? active.lxmf_hash : '') || (liveSelfMatches ? liveSelf.hash : '') || '') - : _channelsPeerLxmfAddress(peer); + ? String(member.lxmf_hash || (activeMatches ? active.lxmf_hash : '') || (liveSelfMatches ? liveSelf.hash : '') || '') + : String(member.lxmf_hash || _channelsPeerLxmfAddress(peer) || ''); var knownName = member.is_self ? String((activeMatches ? (active.display_name || active.nickname) : '') || (liveSelfMatches ? liveSelf.display_name : '') || '') : String(peer && peer.display_name || ''); @@ -2989,13 +3030,11 @@ function _channelsRenderMemberDetail(room, member, list, info) { hero.className = 'channel-member-detail-hero'; var avatar = document.createElement('div'); avatar.className = 'channel-member-detail-avatar'; - if (typeof identityAvatar === 'function') { - avatar.innerHTML = identityAvatar(details.lxmfAddress || details.identityHash || channelName, 52); - } else { - var fallback = document.createElement('span'); - fallback.className = 'channel-identity-marker'; - avatar.appendChild(fallback); - } + _channelsPopulateIdentityAvatar( + avatar, + details.lxmfAddress || '', + 52 + ); var heroCopy = document.createElement('div'); heroCopy.className = 'channel-member-detail-hero-copy'; var name = document.createElement('strong'); @@ -3147,11 +3186,14 @@ function _channelsRenderMembers(room) { row.type = 'button'; row.className = 'channel-member-row'; row.dataset.memberKey = memberKey; - row.dataset.tone = member.is_self ? 'self' : _channelsIdentityTone(member.identity_hash || member.nickname); row.setAttribute('aria-label', 'View details for ' + nameText); - var marker = document.createElement('span'); - marker.className = 'channel-identity-marker'; - marker.setAttribute('aria-hidden', 'true'); + var avatar = document.createElement('span'); + avatar.className = 'channel-member-avatar'; + _channelsPopulateIdentityAvatar( + avatar, + _channelsIdentityAvatarSeed(member.identity_hash, member.lxmf_hash, !!member.is_self), + 40 + ); var copy = document.createElement('span'); copy.className = 'channel-member-copy'; var name = document.createElement('span'); @@ -3168,7 +3210,7 @@ function _channelsRenderMembers(room) { disclosure.className = 'channel-member-disclosure'; disclosure.setAttribute('aria-hidden', 'true'); disclosure.innerHTML = ''; - row.appendChild(marker); + row.appendChild(avatar); row.appendChild(copy); row.appendChild(disclosure); row.addEventListener('click', function() {