Use correct channel icons in channel chat screen

At the top of the channel chat screen is an icon, indicating the
channel type.

Previously, the public icon was used correctly, but the
hashtag icon was used for all other types.

Now, consistent with the channels screen, we use the lock icon for
private channels, and the composite icons for community public &
community hashtag types.

The fix for private channels was trivial, as we can identify hashtag
channels by their name. Finding out whether a channel belongs to a
community is much more involved. All the hard-working code was copied
from channels_screen.dart. (I tried refactoring to reduce duplication,
but my results were complex and not worth it.)

Closes #432
This commit is contained in:
Seth Golub
2026-05-11 21:13:50 -07:00
parent e92a66ff28
commit 2763d83fe4
4 changed files with 164 additions and 59 deletions
+34
View File
@@ -4,6 +4,9 @@ import 'dart:typed_data';
import 'package:crypto/crypto.dart' as crypto; import 'package:crypto/crypto.dart' as crypto;
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import 'community.dart';
enum ChannelType { public, private, hashtag, communityPublic, communityHashtag }
class Channel { class Channel {
final int index; final int index;
@@ -111,5 +114,36 @@ class Channel {
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
} }
static bool isCommunityChannel(ChannelType channelType) {
switch (channelType) {
case ChannelType.communityPublic:
case ChannelType.communityHashtag:
return true;
case ChannelType.public:
case ChannelType.private:
case ChannelType.hashtag:
return false;
}
}
static ChannelType getChannelType(
Channel channel,
CommunityPskIndex communityIndex,
) {
Community? community = communityIndex.getCommunityForChannel(channel);
if (community != null) {
if (Community.isCommunityPublicChannel(channel, community)) {
return ChannelType.communityPublic;
}
return ChannelType.communityHashtag;
}
if (channel.isPublicChannel) {
return ChannelType.public;
} else if (channel.name.startsWith('#')) {
return ChannelType.hashtag;
}
return ChannelType.private;
}
static const String publicChannelPsk = '8b3387e9c5cdea6ac9e5edbaa115cd72'; static const String publicChannelPsk = '8b3387e9c5cdea6ac9e5edbaa115cd72';
} }
+33
View File
@@ -4,6 +4,8 @@ import 'dart:typed_data';
import 'package:crypto/crypto.dart' as crypto; import 'package:crypto/crypto.dart' as crypto;
import 'channel.dart';
/// Represents a community with a shared secret for deriving channel PSKs. /// Represents a community with a shared secret for deriving channel PSKs.
/// ///
/// A Community is a namespace with a shared secret K (32 random bytes), /// A Community is a namespace with a shared secret K (32 random bytes),
@@ -162,6 +164,12 @@ class Community {
return hashtag.replaceFirst(RegExp(r'^#'), '').toLowerCase().trim(); return hashtag.replaceFirst(RegExp(r'^#'), '').toLowerCase().trim();
} }
/// Returns true if this is the community's public channel
static bool isCommunityPublicChannel(Channel channel, Community community) {
final publicPsk = community.deriveCommunityPublicPsk();
return channel.pskHex == Channel.formatPskHex(publicPsk);
}
/// Add a hashtag channel to this community's list /// Add a hashtag channel to this community's list
Community addHashtagChannel(String hashtag) { Community addHashtagChannel(String hashtag) {
final normalized = _normalizeCommunityHashtag(hashtag); final normalized = _normalizeCommunityHashtag(hashtag);
@@ -237,3 +245,28 @@ class Community {
@override @override
int get hashCode => id.hashCode; int get hashCode => id.hashCode;
} }
class CommunityPskIndex {
// Cache of PSK hex -> Community for quick lookup
final Map<String, Community> _pskToCommunity = {};
void initialize(List<Community> communities) {
_pskToCommunity.clear();
for (final community in communities) {
// Map the community public channel PSK
final publicPsk = community.deriveCommunityPublicPsk();
_pskToCommunity[Channel.formatPskHex(publicPsk)] = community;
// Map all known hashtag channel PSKs
for (final hashtag in community.hashtagChannels) {
final hashtagPsk = community.deriveCommunityHashtagPsk(hashtag);
_pskToCommunity[Channel.formatPskHex(hashtagPsk)] = community;
}
}
}
/// Returns the community this channel belongs to, or null if not a community channel
Community? getCommunityForChannel(Channel channel) {
return _pskToCommunity[channel.pskHex];
}
}
+70 -4
View File
@@ -8,6 +8,8 @@ import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../models/community.dart';
import '../storage/community_store.dart';
import '../utils/platform_info.dart'; import '../utils/platform_info.dart';
import '../helpers/chat_scroll_controller.dart'; import '../helpers/chat_scroll_controller.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
@@ -56,8 +58,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final ChatScrollController _scrollController = ChatScrollController(); final ChatScrollController _scrollController = ChatScrollController();
final FocusNode _textFieldFocusNode = FocusNode(); final FocusNode _textFieldFocusNode = FocusNode();
ChannelMessage? _replyingToMessage; ChannelMessage? _replyingToMessage;
final CommunityStore _communityStore = CommunityStore();
final CommunityPskIndex _communityIndex = CommunityPskIndex();
final Map<String, GlobalKey> _messageKeys = {}; final Map<String, GlobalKey> _messageKeys = {};
bool _isLoadingOlder = false; bool _isLoadingOlder = false;
bool _communitiesLoaded = false;
MeshCoreConnector? _connector; MeshCoreConnector? _connector;
DateTime? _lastChannelSendAt; DateTime? _lastChannelSendAt;
@@ -81,6 +86,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final idx = widget.channel.index; final idx = widget.channel.index;
final unread = widget.initialUnreadCount; final unread = widget.initialUnreadCount;
final messages = connector.getChannelMessages(widget.channel); final messages = connector.getChannelMessages(widget.channel);
_loadCommunities();
ChannelMessage? anchor; ChannelMessage? anchor;
if (unread > 0) { if (unread > 0) {
anchor = _findOldestUnreadChannelAnchor(messages, unread); anchor = _findOldestUnreadChannelAnchor(messages, unread);
@@ -107,6 +113,19 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
}); });
} }
// TODO: Reload communities when returning from another screen
Future<void> _loadCommunities() async {
final connector = context.read<MeshCoreConnector>();
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
final communities = await _communityStore.loadCommunities();
if (mounted) {
setState(() {
_communityIndex.initialize(communities);
_communitiesLoaded = true;
});
}
}
ChannelMessage? _findOldestUnreadChannelAnchor( ChannelMessage? _findOldestUnreadChannelAnchor(
List<ChannelMessage> messages, List<ChannelMessage> messages,
int unreadCount, int unreadCount,
@@ -193,16 +212,63 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
); );
} }
Widget _channelIcon(Channel channel) {
// Determine icon based on channel type
final ChannelType channelType = Channel.getChannelType(
channel,
_communityIndex,
);
final bool isCommunityChannel = Channel.isCommunityChannel(channelType);
IconData icon;
switch (channelType) {
case ChannelType.communityPublic:
icon = Icons.groups;
case ChannelType.communityHashtag:
icon = Icons.tag;
case ChannelType.public:
icon = Icons.public;
case ChannelType.hashtag:
icon = Icons.tag;
case ChannelType.private:
icon = Icons.lock;
}
return Stack(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: _communitiesLoaded
? Icon(icon, size: 20)
: SizedBox.square(dimension: 20),
),
if (isCommunityChannel)
Positioned(
right: 0,
bottom: 0,
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: Colors.purple,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).cardColor,
width: 2,
),
),
child: const Icon(Icons.people, size: 8, color: Colors.white),
),
),
],
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Row( title: Row(
children: [ children: [
Icon( _channelIcon(widget.channel),
widget.channel.isPublicChannel ? Icons.public : Icons.tag,
size: 20,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Column( child: Column(
+27 -55
View File
@@ -44,11 +44,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
with DisconnectNavigationMixin { with DisconnectNavigationMixin {
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final CommunityStore _communityStore = CommunityStore(); final CommunityStore _communityStore = CommunityStore();
Timer? _searchDebounce; final CommunityPskIndex _communityIndex = CommunityPskIndex();
List<Community> _communities = []; List<Community> _communities = [];
Timer? _searchDebounce;
// Cache of PSK hex -> Community for quick lookup
final Map<String, Community> _pskToCommunity = {};
ChannelMessageStore get _channelMessageStore => ChannelMessageStore(); ChannelMessageStore get _channelMessageStore => ChannelMessageStore();
@@ -71,37 +69,11 @@ class _ChannelsScreenState extends State<ChannelsScreen>
if (mounted) { if (mounted) {
setState(() { setState(() {
_communities = communities; _communities = communities;
_buildPskCommunityMap(); _communityIndex.initialize(communities);
}); });
} }
} }
void _buildPskCommunityMap() {
_pskToCommunity.clear();
for (final community in _communities) {
// Map the community public channel PSK
final publicPsk = community.deriveCommunityPublicPsk();
_pskToCommunity[Channel.formatPskHex(publicPsk)] = community;
// Map all known hashtag channel PSKs
for (final hashtag in community.hashtagChannels) {
final hashtagPsk = community.deriveCommunityHashtagPsk(hashtag);
_pskToCommunity[Channel.formatPskHex(hashtagPsk)] = community;
}
}
}
/// Returns the community this channel belongs to, or null if not a community channel
Community? _getCommunityForChannel(Channel channel) {
return _pskToCommunity[channel.pskHex];
}
/// Returns true if this is the community's public channel
bool _isCommunityPublicChannel(Channel channel, Community community) {
final publicPsk = community.deriveCommunityPublicPsk();
return channel.pskHex == Channel.formatPskHex(publicPsk);
}
@override @override
void dispose() { void dispose() {
_searchDebounce?.cancel(); _searchDebounce?.cancel();
@@ -375,37 +347,37 @@ class _ChannelsScreenState extends State<ChannelsScreen>
int? dragIndex, int? dragIndex,
}) { }) {
final unreadCount = connector.getUnreadCountForChannel(channel); final unreadCount = connector.getUnreadCountForChannel(channel);
final community = _getCommunityForChannel(channel);
final isCommunityChannel = community != null;
final isCommunityPublic =
isCommunityChannel && _isCommunityPublicChannel(channel, community);
// Determine icon and colors based on channel type // Determine icon and colors based on channel type
IconData icon; IconData icon;
Color iconColor; Color iconColor;
Color bgColor; Color bgColor;
final ChannelType channelType = Channel.getChannelType(
if (isCommunityChannel) { channel,
// Community channel styling _communityIndex,
iconColor = Colors.purple; );
bgColor = Colors.purple.withValues(alpha: 0.2); final bool isCommunityChannel = Channel.isCommunityChannel(channelType);
if (isCommunityPublic) { switch (channelType) {
case ChannelType.communityPublic:
icon = Icons.groups; icon = Icons.groups;
} else { iconColor = Colors.purple;
bgColor = Colors.purple.withValues(alpha: 0.2);
case ChannelType.communityHashtag:
icon = Icons.tag; icon = Icons.tag;
} iconColor = Colors.purple;
} else if (channel.isPublicChannel) { bgColor = Colors.purple.withValues(alpha: 0.2);
icon = Icons.public; case ChannelType.public:
iconColor = Colors.green; icon = Icons.public;
bgColor = Colors.green.withValues(alpha: 0.2); iconColor = Colors.green;
} else if (channel.name.startsWith('#')) { bgColor = Colors.green.withValues(alpha: 0.2);
icon = Icons.tag; case ChannelType.hashtag:
iconColor = Colors.blue; icon = Icons.tag;
bgColor = Colors.blue.withValues(alpha: 0.2); iconColor = Colors.blue;
} else { bgColor = Colors.blue.withValues(alpha: 0.2);
icon = Icons.lock; case ChannelType.private:
iconColor = Colors.blue; icon = Icons.lock;
bgColor = Colors.blue.withValues(alpha: 0.2); iconColor = Colors.blue;
bgColor = Colors.blue.withValues(alpha: 0.2);
} }
return Card( return Card(