🔄 Changes

Core Features
Unread Message Tracking: Added persistent unread counts for contacts and channels with visual badges
Message Deletion: Users can now long-press to delete individual messages in chats and channels
SMAZ Compression: Added per-contact compression settings (previously only channels)
UTF-8 Length Limiting: Text inputs now enforce protocol byte limits correctly
Channel Message Paths: New screen to visualize packet routing through repeater network with map view
Protocol Updates
Added maxContactMessageBytes() and maxChannelMessageBytes() helpers for message length validation
Changed channel PSK format from Base64 to Hexadecimal (breaking change)
Added app version field to connection handshake frame
UI Improvements
Unread badges on all contact and channel list items
Enhanced message bubbles with path visualization for channel messages
Character count displays in message input fields
Improved repeater CLI screen functionality
New Files
lib/storage/unread_store.dart - Unread tracking persistence
lib/storage/contact_settings_store.dart - Per-contact SMAZ settings
lib/widgets/unread_badge.dart - Unread count indicator
lib/helpers/utf8_length_limiter.dart - Byte-aware text input formatter
lib/screens/channel_message_path_screen.dart - Packet path visualization
This commit is contained in:
zach 2025-12-26 13:33:03 -07:00
parent e7a5b9e209
commit 02ca7801ea
25 changed files with 1656 additions and 259 deletions

View file

@ -0,0 +1,17 @@
import 'package:shared_preferences/shared_preferences.dart';
class ContactSettingsStore {
static const String _smazKeyPrefix = 'contact_smaz_';
Future<bool> loadSmazEnabled(String contactKeyHex) async {
final prefs = await SharedPreferences.getInstance();
final key = '$_smazKeyPrefix$contactKeyHex';
return prefs.getBool(key) ?? false;
}
Future<void> saveSmazEnabled(String contactKeyHex, bool enabled) async {
final prefs = await SharedPreferences.getInstance();
final key = '$_smazKeyPrefix$contactKeyHex';
await prefs.setBool(key, enabled);
}
}

View file

@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:typed_data';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/message.dart';
import '../helpers/smaz.dart';
class MessageStore {
static const String _keyPrefix = 'messages_';
@ -55,12 +56,15 @@ class MessageStore {
}
Message _messageFromJson(Map<String, dynamic> json) {
final rawText = json['text'] as String;
final isCli = json['isCli'] as bool? ?? false;
final decodedText = isCli ? rawText : (Smaz.tryDecodePrefixed(rawText) ?? rawText);
return Message(
senderKey: Uint8List.fromList(base64Decode(json['senderKey'] as String)),
text: json['text'] as String,
text: decodedText,
timestamp: DateTime.fromMillisecondsSinceEpoch(json['timestamp'] as int),
isOutgoing: json['isOutgoing'] as bool,
isCli: json['isCli'] as bool? ?? false,
isCli: isCli,
status: MessageStatus.values[json['status'] as int],
messageId: json['messageId'] as String?,
retryCount: json['retryCount'] as int? ?? 0,

View file

@ -0,0 +1,47 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
class UnreadStore {
static const String _contactLastReadKey = 'contact_last_read';
static const String _channelLastReadKey = 'channel_last_read';
Future<Map<String, int>> loadContactLastRead() async {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_contactLastReadKey);
if (jsonStr == null) return {};
try {
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
return json.map((key, value) => MapEntry(key, value as int));
} catch (_) {
return {};
}
}
Future<void> saveContactLastRead(Map<String, int> lastReadMs) async {
final prefs = await SharedPreferences.getInstance();
final jsonStr = jsonEncode(lastReadMs);
await prefs.setString(_contactLastReadKey, jsonStr);
}
Future<Map<int, int>> loadChannelLastRead() async {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_channelLastReadKey);
if (jsonStr == null) return {};
try {
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
return json.map((key, value) => MapEntry(int.parse(key), value as int));
} catch (_) {
return {};
}
}
Future<void> saveChannelLastRead(Map<int, int> lastReadMs) async {
final prefs = await SharedPreferences.getInstance();
final asString = lastReadMs.map((key, value) => MapEntry(key.toString(), value));
final jsonStr = jsonEncode(asString);
await prefs.setString(_channelLastReadKey, jsonStr);
}
}