mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'], '');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user