channels: use canonical identity avatars

This commit is contained in:
DeFiDude 2026-08-02 18:55:57 -06:00
parent ae6fd89141
commit 431f5f0ed0
12 changed files with 367 additions and 72 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -3174,6 +3174,9 @@ pub struct ChannelHistoryEvent {
pub timestamp_ms: u64,
pub recorded_at_ms: u64,
pub source_hash: Option<String>,
/// Presentation-only LXMF destination derived by the command layer. It is
/// intentionally not duplicated in the local history table.
pub source_lxmf_hash: Option<String>,
pub nickname: Option<String>,
pub text: String,
pub ours: bool,
@ -3753,6 +3756,7 @@ fn channel_history_row(row: &rusqlite::Row<'_>) -> Result<ChannelHistoryEvent, r
timestamp_ms,
recorded_at_ms,
source_hash: row.get(7)?,
source_lxmf_hash: None,
nickname: row.get(8)?,
text: row.get(9)?,
ours: row.get::<_, i64>(10)? != 0,

View file

@ -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<String> {
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<String>,
/// Canonical `lxmf.delivery` destination derived from `identity_hash`.
pub lxmf_hash: Option<String>,
pub nickname: Option<String>,
pub is_self: bool,
}
@ -285,6 +303,8 @@ pub struct ChannelTranscriptItem {
pub kind: ChannelItemKind,
pub timestamp_ms: u64,
pub source_hash: Option<String>,
/// Canonical `lxmf.delivery` destination derived from `source_hash`.
pub source_lxmf_hash: Option<String>,
pub nickname: Option<String>,
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,

View file

@ -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]

View file

@ -11,7 +11,7 @@
injects pixel values via evaluateJavascript; desktop falls back to 0. -->
<title>Ratspeak - Dashboard</title>
<link rel="stylesheet" href="/static/fonts/fonts.css">
<link rel="stylesheet" href="/static/style.css?v=1.0.38">
<link rel="stylesheet" href="/static/style.css?v=1.0.39">
</head>
<body class="checking-setup">
@ -1882,7 +1882,7 @@
<script src="/static/js/vendor/jsQR.js"></script>
<script src="/static/js/contact_card.js?v=1.0.1"></script>
<script src="/static/js/lxmf.js"></script>
<script src="/static/js/channels.js?v=1.0.43"></script>
<script src="/static/js/channels.js?v=1.0.44"></script>
<script src="/static/js/channel_hub.js?v=1.0.36"></script>
<script src="/static/js/propagation.js?v=1.0.1"></script>
<script src="/static/js/settings.js?v=1.0.3"></script>

View file

@ -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 '<svg data-seed="' + seed + '" data-size="' + size + '"></svg>';
},
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');

View file

@ -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');

View file

@ -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 {

View file

@ -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 = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>';
row.appendChild(marker);
row.appendChild(avatar);
row.appendChild(copy);
row.appendChild(disclosure);
row.addEventListener('click', function() {