mirror of
https://github.com/ratspeak/Ratspeak
synced 2026-08-12 18:07:35 -04:00
channels: use canonical identity avatars
This commit is contained in:
parent
ae6fd89141
commit
431f5f0ed0
12 changed files with 367 additions and 72 deletions
2
.github/workflows/release-android.yml
vendored
2
.github/workflows/release-android.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/release-desktop.yml
vendored
2
.github/workflows/release-desktop.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/release-macos.yml
vendored
2
.github/workflows/release-macos.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/release-windows.yml
vendored
2
.github/workflows/release-windows.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
168
dashboard/scripts/test_channels_avatars.js
Normal file
168
dashboard/scripts/test_channels_avatars.js
Normal 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');
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue