Files
buzz/mobile/lib/features/activity/feed_item.dart
32ead93101 fix(mobile): tapping threaded message in Inbox navigates to top level of channel (#2103)
## The bug

When you tap an item in the Activity inbox that refers to a message
inside a thread (for example, someone replied to you or mentioned you in
a thread reply), the app opened the channel at its top level. It did not
open the thread, and it did not show you the message the notification
was about. You had to hunt for the reply manually.

## The fix

Activity items now keep track of two things: the root message of the
thread and the specific message that triggered the notification. Tapping
the item now:

1. Opens the thread detail view for that thread (instead of the
channel's top level).
2. Scrolls to the specific message that triggered the notification.
3. Briefly highlights that message so it is easy to spot.

This works for both direct replies and replies nested deeper in a
thread, and it fetches the thread from the relay if it is not already
loaded (for example, right after app launch).

## Testing

- Flutter analyzer
- 64 focused mobile tests covering direct and nested thread markers plus
Activity navigation
- Full pre-push suite (mobile, desktop, Rust, and Tauri tests)

## Manual verification

Open Activity, tap a mention for a reply inside a thread, and confirm
Buzz opens that thread, scrolls to the reply, and highlights it.

---------

Signed-off-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub15w828kxsxu2684ynste0uah2jwkgatd99flt7ds4523hzm8ju6cshdr8hh <a38ea3d8d03715a3d49382f2fe76ea93ac8eada52a7ebf3615a2a3716cf2e6b1@buzz.block.builderlab.xyz>
Co-authored-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz>
Co-authored-by: npub15w828kxsxu2684ynste0uah2jwkgatd99flt7ds4523hzm8ju6cshdr8hh <a38ea3d8d03715a3d49382f2fe76ea93ac8eada52a7ebf3615a2a3716cf2e6b1@buzz.block.builderlab.xyz>
2026-07-27 10:50:55 -07:00

138 lines
3.8 KiB
Dart

import 'package:flutter/foundation.dart';
@immutable
class FeedItem {
final String id;
final int kind;
final String pubkey;
final String content;
final int createdAt;
final String? channelId;
final String channelName;
final List<List<String>> tags;
final String
category; // "mention", "needs_action", "activity", "agent_activity"
const FeedItem({
required this.id,
required this.kind,
required this.pubkey,
required this.content,
required this.createdAt,
required this.channelId,
required this.channelName,
required this.tags,
required this.category,
});
factory FeedItem.fromJson(Map<String, dynamic> json) => FeedItem(
id: json['id'] as String,
kind: json['kind'] as int,
pubkey: json['pubkey'] as String,
content: json['content'] as String,
createdAt: json['created_at'] as int,
channelId: json['channel_id'] as String?,
channelName: (json['channel_name'] as String?) ?? '',
tags:
(json['tags'] as List<dynamic>?)
?.map((t) => (t as List<dynamic>).map((e) => e as String).toList())
.toList() ??
const [],
category: json['category'] as String,
);
/// The message whose thread directly contains this item, when this event is
/// a reply. For nested replies this is the direct parent, not the outer root.
String? get threadHeadId {
for (final tag in tags) {
if (tag.length >= 4 && tag[0] == 'e' && tag[3] == 'reply') {
return tag[1];
}
}
return null;
}
/// Human-readable headline based on event kind and category.
String get headline {
switch (kind) {
case 45001:
return 'Forum post';
case 45003:
return 'Forum reply';
case 46010:
return 'Approval requested';
case 43001:
return 'Job requested';
case 43002:
return 'Job accepted';
case 43003:
return 'Progress update';
case 43004:
return 'Job result';
case 43005:
return 'Job cancelled';
case 43006:
return 'Job failed';
default:
if (category == 'mention') return 'Mention';
if (category == 'agent_activity') return 'Agent update';
return 'Channel update';
}
}
/// Trimmed content, with a fallback for empty events.
String get displayContent {
final trimmed = content.trim();
if (trimmed.isNotEmpty) return trimmed;
if (kind == 46010) return 'A workflow is waiting for approval.';
return 'No additional details.';
}
}
/// Parsed response from GET /api/feed.
@immutable
class HomeFeedResponse {
final List<FeedItem> mentions;
final List<FeedItem> needsAction;
final List<FeedItem> activity;
final List<FeedItem> agentActivity;
HomeFeedResponse({
required this.mentions,
required this.needsAction,
required this.activity,
required this.agentActivity,
});
factory HomeFeedResponse.fromJson(Map<String, dynamic> json) {
final feed = json['feed'] as Map<String, dynamic>;
return HomeFeedResponse(
mentions: _parseItems(feed['mentions']),
needsAction: _parseItems(feed['needs_action']),
activity: _parseItems(feed['activity']),
agentActivity: _parseItems(feed['agent_activity']),
);
}
/// All items merged into a single list, sorted newest-first.
late final List<FeedItem> all = () {
final items = [...mentions, ...needsAction, ...activity, ...agentActivity];
items.sort((a, b) => b.createdAt.compareTo(a.createdAt));
return List<FeedItem>.unmodifiable(items);
}();
bool get isEmpty =>
mentions.isEmpty &&
needsAction.isEmpty &&
activity.isEmpty &&
agentActivity.isEmpty;
}
List<FeedItem> _parseItems(dynamic json) {
if (json == null) return const [];
return (json as List<dynamic>)
.cast<Map<String, dynamic>>()
.map(FeedItem.fromJson)
.toList();
}