fix(mobile): preserve and display sidebar section icons (#2403)

Mobile's ChannelSection model dropped desktop's optional `icon` field, so any
mobile section mutation republished the whole-blob LWW channel-sections event
with every icon stripped — wiping sidebar section emojis on all devices. Now
mobile round-trips the icon through storage/sync, preserves it across all
mutations and the publish no-op check, and renders it in section headers
(native glyph or registered custom emoji; unknown shortcodes fall back to
literal text).

Co-authored-by: morty <1d284070cd2ca08ceeb15c6bafa9ef1a43b717d6837f30727f6e9819b8439f40@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
Wes
2026-07-22 12:13:50 -06:00
committed by GitHub
parent f8fd055b9f
commit a5c5bcc2e7
5 changed files with 107 additions and 12 deletions

View File

@@ -120,7 +120,12 @@ class ChannelSectionsManager {
sections: [
for (final s in _store.sections)
if (s.id == sectionId)
ChannelSection(id: s.id, name: newName.trim(), order: s.order)
ChannelSection(
id: s.id,
name: newName.trim(),
icon: s.icon,
order: s.order,
)
else
s,
],
@@ -286,7 +291,12 @@ class ChannelSectionsManager {
for (var i = 0; i < _store.sections.length; i++) {
final a = last.sections[i];
final b = _store.sections[i];
if (a.id != b.id || a.name != b.name || a.order != b.order) return false;
if (a.id != b.id ||
a.name != b.name ||
a.icon != b.icon ||
a.order != b.order) {
return false;
}
}
for (final key in _store.assignments.keys) {
if (last.assignments[key] != _store.assignments[key]) return false;
@@ -352,9 +362,9 @@ class ChannelSectionsManager {
sections: [
for (final s in _store.sections)
if (s.id == idA)
ChannelSection(id: s.id, name: s.name, order: orderB)
ChannelSection(id: s.id, name: s.name, icon: s.icon, order: orderB)
else if (s.id == idB)
ChannelSection(id: s.id, name: s.name, order: orderA)
ChannelSection(id: s.id, name: s.name, icon: s.icon, order: orderA)
else
s,
],

View File

@@ -7,19 +7,27 @@ String channelSectionsKey(String pubkey) => 'buzz.channel-sections.v1:$pubkey';
class ChannelSection {
final String id;
final String name;
final String? icon;
final int order;
const ChannelSection({
required this.id,
required this.name,
this.icon,
required this.order,
});
Map<String, dynamic> toJson() => {'id': id, 'name': name, 'order': order};
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
if (icon != null) 'icon': icon,
'order': order,
};
factory ChannelSection.fromJson(Map<String, dynamic> json) => ChannelSection(
id: json['id'] as String,
name: json['name'] as String,
icon: json['icon'] is String ? json['icon'] as String : null,
order: json['order'] as int,
);
}

View File

@@ -12,6 +12,9 @@ import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../custom_emoji/custom_emoji.dart';
import '../custom_emoji/custom_emoji_provider.dart';
import '../custom_emoji/custom_emoji_render.dart';
import '../profile/profile_avatar.dart';
import '../profile/profile_provider.dart';
import '../settings/settings_page.dart';

View File

@@ -80,7 +80,7 @@ class _CustomChannelSection extends StatelessWidget {
}
}
class _CustomSectionHeader extends StatelessWidget {
class _CustomSectionHeader extends ConsumerWidget {
final ChannelSection section;
final bool expanded;
final bool isFirst;
@@ -104,8 +104,12 @@ class _CustomSectionHeader extends StatelessWidget {
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final sectionColor = context.colors.primary;
final icon = section.icon;
final customEmoji = icon == null
? null
: _resolveCustomEmoji(icon, ref.watch(customEmojiListProvider));
return GestureDetector(
onTap: onToggle,
@@ -123,11 +127,28 @@ class _CustomSectionHeader extends StatelessWidget {
width: _kChannelLeadingWidth,
child: Align(
alignment: Alignment.centerLeft,
child: Icon(
LucideIcons.folder,
size: _kChannelIconSize,
color: sectionColor,
),
child: icon == null || icon.isEmpty
? Icon(
LucideIcons.folder,
size: _kChannelIconSize,
color: sectionColor,
)
: customEmoji != null
? CustomEmojiImage(
shortcode: customEmoji.shortcode,
url: customEmoji.url,
size: _kChannelIconSize,
)
: Text(
icon,
maxLines: 1,
overflow: TextOverflow.visible,
style: TextStyle(
fontSize: _kChannelIconSize,
height: 1,
color: sectionColor,
),
),
),
),
const SizedBox(width: _kChannelLabelGap),
@@ -192,6 +213,16 @@ class _CustomSectionHeader extends StatelessWidget {
}
}
CustomEmoji? _resolveCustomEmoji(String icon, List<CustomEmoji> palette) {
if (!icon.startsWith(':') || !icon.endsWith(':')) return null;
final shortcode = normalizeShortcode(icon);
if (shortcode == null) return null;
for (final emoji in palette) {
if (emoji.shortcode == shortcode) return emoji;
}
return null;
}
class _SectionNameDialog extends HookWidget {
final String title;
final String confirmLabel;

View File

@@ -0,0 +1,43 @@
import 'package:buzz/features/channels/channel_sections/channel_sections_storage.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('ChannelSection icon', () {
test('round-trips through fromJson and toJson', () {
const original = ChannelSection(
id: 'section-1',
name: 'Friends',
icon: ':party_parrot:',
order: 0,
);
final roundTrip = ChannelSection.fromJson(original.toJson());
expect(roundTrip.icon, ':party_parrot:');
expect(roundTrip.toJson(), original.toJson());
});
test('missing icon remains absent', () {
final section = ChannelSection.fromJson({
'id': 'section-1',
'name': 'Friends',
'order': 0,
});
expect(section.icon, isNull);
expect(section.toJson(), isNot(contains('icon')));
});
test('empty icon round-trips', () {
final section = ChannelSection.fromJson({
'id': 'section-1',
'name': 'Friends',
'icon': '',
'order': 0,
});
expect(section.icon, isEmpty);
expect(section.toJson()['icon'], '');
});
});
}