mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
🤖 ## Summary When you read a channel or thread on the phone, the web and desktop app (buzz-app) should show it as read too, and the reverse. buzz-app no longer saves a "read" mark for each ordinary message. It saves one "caught up to here" mark per channel and per thread, and keeps per-message marks only for mentions, DMs, broadcasts and replies. Mobile already reads those marks. This PR makes mobile write them, so reading on the phone clears unread on other devices and saved read state stays small. What changes on the phone: - Reaching the bottom of a channel, and staying there for 300 ms, marks it caught up. Reaching the end of a thread does the same for that thread. - A message you see fully on screen, away from the bottom, is marked read on its own. - Mentions, DMs, broadcasts and thread replies you see keep their own read marks, as in buzz-app. - Automatic reading never marks the whole channel read. Only the explicit **Mark read** action does that. DMs and forums are the exception: opening one marks it all read, as mobile does today. - Reaching the bottom of a channel you marked unread ends that manual unread. Unseen mentions and replies stay unread. - Activity rows clear when you have read every message in the row, not just the newest. ### Related issue None found. buzz-app makes the same DM and manual-unread changes in https://github.com/block/buzz-app/pull/616. ### Testing - Unit tests cover the new write rules, Activity rows and the size limit on saved read state. - Widget tests cover reading on the channel page and the thread page, opening a DM, and reaching the bottom of a channel you marked unread. - No visual changes. ## Details **Read marks.** "Caught up" is saved as `activity:<channel>` for a channel and `thread-activity:<root>` for a thread. A caught-up mark reads only ordinary top-level messages (channel) or that thread's replies (thread). A per-message mark is `msg:<id>`. **Caught-up time.** The mark is set to the time of the bottom row, and never later than the current time. The time of a newer reply is not used. So a top-level message that arrives late, with an earlier timestamp, stays unread. A clock that runs fast, on this phone or the sender's, cannot mark messages read before they arrive. **Nested threads.** A thread opened on a nested reply shows only one branch. Reaching its end writes per-message marks only, so unread messages in other branches stay unread. **Reading delay.** The 300 ms reading delay now restarts only when messages arrive or leave. Before, any page rebuild restarted it, for example a typing indicator. **Activity rows.** A row that groups several events is done only when each event is read. Each event is checked against the channel mark, its own `msg:` mark, and its thread marks if it is a reply. Opening a row goes to the oldest unread event. **Publishing.** Mobile no longer republishes per-message marks it got from other devices. buzz-app removes those once a caught-up mark covers them. Broad marks (channel, thread, caught-up) are still republished, so they stay available after the fetch window. The published blob is capped at buzz-app's 40 KiB limit. Channel marks are kept first, then thread marks, then caught-up marks, then the newest per-message marks. Before, a blob over the encryption limit (about 64 KiB) turned off sync for the rest of the session. **DMs and manual unread.** Opening a DM reads it through the newest loaded message, replies included, as mobile does today. Reaching the bottom of a channel ends a manual unread. Manual unread on mobile is local to this session, so ending it does not change what other devices see. **Compatibility.** Older desktop builds read only channel and per-message marks. They may show extra unread for channels read on the phone. **Follow-ups, not in this PR** - Unread catch-up fetches start after the channel mark and are capped at 1,000 events. In a busy channel, an old unread mention can drop out of the badge. The fetch should also start from the caught-up mark, or load more pages. - Each mark triggers its own save and rebuild. They could be batched. - Broadcast replies are counted slightly differently from buzz-app. - buzz-app can also move its caught-up mark past the current time. --------- Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
901 lines
35 KiB
Dart
901 lines
35 KiB
Dart
import '../../shared/widgets/modal_presentation.dart';
|
|
import '../../shared/widgets/ios_scroll_to_top.dart';
|
|
import 'message_footer_layout.dart';
|
|
import 'dart:async';
|
|
import 'dart:math' show cos, max, min, pi;
|
|
import 'dart:ui' show ImageFilter, lerpDouble;
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/rendering.dart' show ScrollDirection;
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:buzz/shared/theme/buzz_icons.dart';
|
|
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
|
|
|
import '../../shared/emoji/emoji_burst.dart';
|
|
import '../../shared/huddle/huddle.dart';
|
|
import '../../shared/identity_names/identity_names.dart';
|
|
import '../../shared/identity_names/identity_names_provider.dart';
|
|
import '../../shared/mentions/agent_identity_provider.dart';
|
|
import '../../shared/relay/relay.dart';
|
|
import '../../shared/theme/theme.dart';
|
|
import '../../shared/widgets/avatar_image.dart';
|
|
import '../../shared/widgets/buzz_loading_indicator.dart';
|
|
import '../../shared/widgets/bouncing_dots_indicator.dart';
|
|
import '../../shared/widgets/concentric_sheet_surface.dart';
|
|
import '../../shared/widgets/frosted_app_bar.dart';
|
|
import '../../shared/widgets/frosted_scaffold.dart';
|
|
import '../../shared/widgets/flapping_bee.dart';
|
|
import '../../shared/widgets/keyboard_dismiss_on_drag.dart';
|
|
import '../../shared/widgets/load_error_view.dart';
|
|
import '../../shared/widgets/message_author_meta.dart';
|
|
import '../../shared/widgets/skeleton.dart';
|
|
import '../profile/presence_cache_provider.dart';
|
|
import '../../shared/profile/user_cache_provider.dart';
|
|
import '../../shared/profile/user_profile.dart';
|
|
import '../forum/forum_posts_view.dart';
|
|
import '../forum/forum_provider.dart';
|
|
import 'android_ime_lift.dart';
|
|
import 'channel.dart';
|
|
import 'channel_actions_sheet.dart';
|
|
import 'channel_identity_names_provider.dart';
|
|
import 'channel_member_profile_actions.dart';
|
|
import 'channel_link_navigation.dart';
|
|
import 'channel_management_provider.dart';
|
|
import 'channel_sections/channel_sections_provider.dart';
|
|
import 'channel_messages_provider.dart';
|
|
import 'channel_typing_provider.dart';
|
|
import 'channel_typing_indicator.dart';
|
|
import 'channels_provider.dart';
|
|
import 'unread_badge/observed_unread_event.dart';
|
|
import 'compose_bar.dart';
|
|
import 'composer_dock_size_reporter.dart';
|
|
import 'date_formatters.dart';
|
|
import 'day_divider.dart';
|
|
import 'dm_channel_labels.dart';
|
|
import 'ephemeral_channel_display.dart';
|
|
import 'emoji_picker.dart';
|
|
import 'ime_metrics_settle_observer.dart';
|
|
import 'jump_to_latest_button.dart';
|
|
import 'jump_to_latest_switcher.dart';
|
|
import 'local_message_send_animation_provider.dart';
|
|
import 'local_message_send_transition.dart';
|
|
import 'mobile_huddle_controller.dart';
|
|
import 'message_actions.dart';
|
|
import 'message_action_backdrop_state.dart';
|
|
import 'message_long_press_region.dart';
|
|
import 'message_content.dart';
|
|
import 'message_skeleton_body.dart';
|
|
import '../../shared/read_state/deferred_read_state_update.dart';
|
|
import '../../shared/read_state/read_state_provider.dart';
|
|
import '../../shared/read_state/read_state_time.dart';
|
|
import 'reaction_row.dart';
|
|
import 'recent_emoji_provider.dart';
|
|
import 'send_message_provider.dart';
|
|
import '../profile/user_profile_sheet.dart';
|
|
import 'small_avatar.dart';
|
|
import 'sticky_date_header.dart';
|
|
import 'thread_detail_page.dart';
|
|
import 'thread_replies_provider.dart';
|
|
import 'reading_marks.dart';
|
|
import 'timeline_message.dart';
|
|
|
|
part 'channel_detail_page/message_list.dart';
|
|
part 'channel_detail_page/oldest_navigation.dart';
|
|
part 'channel_detail_page/system_rows.dart';
|
|
part 'channel_detail_page/activity_members_sheet.dart';
|
|
part 'channel_detail_page/huddle_sheet.dart';
|
|
part 'channel_detail_page/huddle_call_avatar.dart';
|
|
part 'channel_detail_page/huddle_participant_cluster.dart';
|
|
part 'channel_detail_page/huddle_call_participants.dart';
|
|
part 'channel_detail_page/huddle_participant_overlay.dart';
|
|
part 'channel_detail_page/huddle_call_controls.dart';
|
|
part 'channel_detail_page/huddle_drawer.dart';
|
|
part 'channel_detail_page/huddle_reactions.dart';
|
|
part 'channel_detail_page/message_bubble.dart';
|
|
part 'channel_detail_page/banners.dart';
|
|
part 'channel_detail_page/app_bar.dart';
|
|
|
|
/// Fetch deep-link targets that may be outside the loaded channel window.
|
|
Future<void> _loadDeepLinkEvents(
|
|
WidgetRef ref,
|
|
String channelId,
|
|
Set<String> eventIds,
|
|
) async {
|
|
try {
|
|
await ref
|
|
.read(channelMessagesProvider(channelId).notifier)
|
|
.loadEventsById(eventIds);
|
|
} catch (error) {
|
|
debugPrint('deep-link: failed to load target messages: $error');
|
|
}
|
|
}
|
|
|
|
/// Fetch channel members and preload their profiles into the user cache.
|
|
/// One-to-one DMs additionally refresh participant profiles for identity gates.
|
|
/// Returns whether identity resolution completed successfully.
|
|
Future<bool> _preloadMembers(
|
|
WidgetRef ref,
|
|
String channelId,
|
|
List<String> participantPubkeys, {
|
|
required bool refreshDmParticipants,
|
|
}) async {
|
|
// Capture references before async gap to avoid using disposed ref.
|
|
final notifier = ref.read(userCacheProvider.notifier);
|
|
try {
|
|
final members = await ref.read(channelMembersProvider(channelId).future);
|
|
await notifier.preload(members.map((member) => member.pubkey).toList());
|
|
if (refreshDmParticipants) {
|
|
return notifier.refresh(participantPubkeys);
|
|
}
|
|
return true;
|
|
} catch (_) {
|
|
// Identity remains unresolved, so agent-only actions stay hidden.
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<void Function()> _subscribeToDmIdentityUpdates(
|
|
WidgetRef ref,
|
|
List<String> participantPubkeys, {
|
|
required ValueChanged<bool> onReadyChanged,
|
|
required ValueChanged<Set<String>> onAgentPubkeysChanged,
|
|
required VoidCallback onFailure,
|
|
}) async {
|
|
final session = ref.read(relaySessionProvider.notifier);
|
|
var subscriptionStatus = RelaySubscriptionStatus.retrying;
|
|
var directLookupComplete = false;
|
|
final agentPubkeys = <String>{};
|
|
|
|
void publishAgentPubkeys() {
|
|
onAgentPubkeysChanged(Set.unmodifiable(agentPubkeys));
|
|
}
|
|
|
|
void handleEvent(NostrEvent event) {
|
|
if (event.kind == 0) {
|
|
try {
|
|
ref.read(userCacheProvider.notifier).cacheProfileEvent(event);
|
|
} catch (error) {
|
|
debugPrint('[DmIdentity] invalid live profile: $error');
|
|
onFailure();
|
|
}
|
|
} else if (event.kind == 10100) {
|
|
agentPubkeys.add(event.pubkey.toLowerCase());
|
|
publishAgentPubkeys();
|
|
ref.invalidate(agentDirectoryProvider);
|
|
ref.invalidate(agentOwnersProvider);
|
|
}
|
|
}
|
|
|
|
final unsubscribe = await session.subscribeWithStatus(
|
|
NostrFilter(
|
|
kinds: const [0, 10100],
|
|
authors: participantPubkeys,
|
|
limit: 100,
|
|
).copyWithSince(DateTime.now().millisecondsSinceEpoch ~/ 1000 - 5),
|
|
handleEvent,
|
|
onClosed: (_) => onFailure(),
|
|
onStatusChanged: (status) {
|
|
subscriptionStatus = status;
|
|
if (status == RelaySubscriptionStatus.retrying) {
|
|
onReadyChanged(false);
|
|
} else if (directLookupComplete) {
|
|
onReadyChanged(true);
|
|
}
|
|
},
|
|
);
|
|
|
|
try {
|
|
final profiles = await session.fetchHistory(
|
|
NostrFilter(
|
|
kinds: const [10100],
|
|
authors: participantPubkeys,
|
|
limit: participantPubkeys.length,
|
|
),
|
|
);
|
|
for (final profile in profiles) {
|
|
if (profile.kind == 10100) {
|
|
agentPubkeys.add(profile.pubkey.toLowerCase());
|
|
}
|
|
}
|
|
publishAgentPubkeys();
|
|
directLookupComplete = true;
|
|
onReadyChanged(subscriptionStatus == RelaySubscriptionStatus.ready);
|
|
return unsubscribe;
|
|
} catch (_) {
|
|
unsubscribe();
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// The time that opening [channel] reads it through, or null when opening it
|
|
/// reads nothing. Forums read through their last activity. DMs read through
|
|
/// the newest loaded message, including replies, or the last activity before
|
|
/// messages load.
|
|
int? _openReadTimestamp({
|
|
required Channel channel,
|
|
required AsyncValue<List<NostrEvent>> messagesState,
|
|
}) {
|
|
if (channel.isForum) return dateTimeToUnixSeconds(channel.lastMessageAt);
|
|
if (!channel.isDm) return null;
|
|
var latest = 0;
|
|
for (final event in messagesState.value ?? const <NostrEvent>[]) {
|
|
if (event.createdAt > latest) latest = event.createdAt;
|
|
}
|
|
return latest > 0 ? latest : dateTimeToUnixSeconds(channel.lastMessageAt);
|
|
}
|
|
|
|
bool _isOneToOneAgentDm(Channel channel, Set<String> agentPubkeys) {
|
|
final participants = channel.participantPubkeys
|
|
.map((pubkey) => pubkey.trim().toLowerCase())
|
|
.where((pubkey) => pubkey.isNotEmpty)
|
|
.toSet();
|
|
return channel.isDm &&
|
|
participants.length == 2 &&
|
|
participants.any(agentPubkeys.contains);
|
|
}
|
|
|
|
/// Controls how a hydrated initial thread is added to the navigation stack.
|
|
enum InitialThreadRouteBehavior {
|
|
/// Keep the channel route beneath the thread.
|
|
push,
|
|
|
|
/// Replace the temporary channel route so Back returns to its origin.
|
|
replaceCurrentRoute,
|
|
}
|
|
|
|
class ChannelDetailPage extends HookConsumerWidget {
|
|
final Channel channel;
|
|
final String? initialMessageId;
|
|
final String? initialThreadRootId;
|
|
|
|
/// How the automatically opened initial thread affects the route stack.
|
|
final InitialThreadRouteBehavior initialThreadRouteBehavior;
|
|
|
|
const ChannelDetailPage({
|
|
super.key,
|
|
required this.channel,
|
|
this.initialMessageId,
|
|
this.initialThreadRootId,
|
|
this.initialThreadRouteBehavior = InitialThreadRouteBehavior.push,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final stickyDateHeaderState = useValueNotifier(
|
|
StickyDateHeaderState.hidden,
|
|
[channel.id],
|
|
);
|
|
final composerDockHeight = useState(0.0);
|
|
final composerFocusNode = useFocusNode();
|
|
final restoreComposerFocus = useRef<VoidCallback?>(null);
|
|
final sendMessage = ref.read(sendMessageProvider);
|
|
final detailsAsync = ref.watch(channelDetailsProvider(channel.id));
|
|
final channelsAsync = ref.watch(channelsProvider);
|
|
final messagesState = ref.watch(channelMessagesProvider(channel.id));
|
|
final huddleLifecycle =
|
|
ref.watch(huddleLifecycleProvider(channel.id)).value ?? const [];
|
|
final sessionStatus = ref.watch(relaySessionProvider).status;
|
|
final readState = ref.watch(readStateProvider);
|
|
final channelsNotifier = ref.read(channelsProvider.notifier);
|
|
final initialOrdinaryUnreadMessageIdsRef = useRef<Set<String>>(const {});
|
|
final initialOldestOrdinaryUnreadMessageIdRef = useRef<String?>(null);
|
|
final initialForcedUnreadMessageIdsRef = useRef<Set<String>>(const {});
|
|
final didCaptureInitialReadAt = useRef(false);
|
|
if (readState.isReady && !didCaptureInitialReadAt.value) {
|
|
final ordinaryUnreadEvents = [
|
|
for (final event
|
|
in channelsNotifier
|
|
.observedUnreadEventsByChannel[channel.id]
|
|
?.values ??
|
|
const <ObservedUnreadEvent>[])
|
|
if (event.rootId == null &&
|
|
event.createdAt >
|
|
(observedUnreadEventReadAt(
|
|
event,
|
|
channel.id,
|
|
readState.effectiveTimestamp,
|
|
) ??
|
|
0))
|
|
event,
|
|
]..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
|
initialOrdinaryUnreadMessageIdsRef.value = {
|
|
for (final event in ordinaryUnreadEvents) event.id,
|
|
};
|
|
initialOldestOrdinaryUnreadMessageIdRef.value =
|
|
ordinaryUnreadEvents.firstOrNull?.id;
|
|
initialForcedUnreadMessageIdsRef.value = {
|
|
for (final entry in readState.forcedUnreadContexts.entries)
|
|
if (entry.value == channel.id && entry.key.startsWith('msg:'))
|
|
entry.key.substring('msg:'.length),
|
|
};
|
|
didCaptureInitialReadAt.value = true;
|
|
}
|
|
final initialOrdinaryUnreadMessageIds =
|
|
initialOrdinaryUnreadMessageIdsRef.value;
|
|
final initialOldestOrdinaryUnreadMessageId =
|
|
initialOldestOrdinaryUnreadMessageIdRef.value;
|
|
final initialForcedUnreadMessageIds =
|
|
initialForcedUnreadMessageIdsRef.value;
|
|
// Ownership comes from the signing identity, not reloadable profile metadata.
|
|
final currentPubkey = ref.watch(currentPubkeyProvider);
|
|
// Only show channel-level typing (exclude thread-scoped entries and self).
|
|
final typingEntries = ref
|
|
.watch(channelTypingProvider(channel.id))
|
|
.where((e) => e.threadHeadId == null)
|
|
.where(
|
|
(e) =>
|
|
currentPubkey == null ||
|
|
e.pubkey.toLowerCase() != currentPubkey.toLowerCase(),
|
|
)
|
|
.toList();
|
|
final baseChannel =
|
|
channelsAsync
|
|
.whenData(
|
|
(channels) => channels.firstWhere(
|
|
(candidate) => candidate.id == channel.id,
|
|
orElse: () => channel,
|
|
),
|
|
)
|
|
.value ??
|
|
channel;
|
|
final resolvedChannel =
|
|
detailsAsync.whenData(baseChannel.mergeDetails).value ?? baseChannel;
|
|
final participantCount = resolvedChannel.participantPubkeys
|
|
.map((pubkey) => pubkey.trim().toLowerCase())
|
|
.where((pubkey) => pubkey.isNotEmpty)
|
|
.toSet()
|
|
.length;
|
|
final isOneToOneDm = resolvedChannel.isDm && participantCount == 2;
|
|
final memberProfilesPreload = useMemoized(
|
|
() => _preloadMembers(
|
|
ref,
|
|
resolvedChannel.id,
|
|
resolvedChannel.participantPubkeys,
|
|
refreshDmParticipants: isOneToOneDm,
|
|
),
|
|
[
|
|
resolvedChannel.id,
|
|
sessionStatus,
|
|
isOneToOneDm,
|
|
Object.hashAll(resolvedChannel.participantPubkeys),
|
|
],
|
|
);
|
|
final memberProfilesPreloadState = useFuture(memberProfilesPreload);
|
|
final showsComposer =
|
|
!resolvedChannel.isForum &&
|
|
resolvedChannel.isMember &&
|
|
!resolvedChannel.isArchived;
|
|
final profileOwnedAgentPubkeys = <String>[];
|
|
for (final participantPubkey in resolvedChannel.participantPubkeys) {
|
|
final normalized = participantPubkey.trim().toLowerCase();
|
|
final isProfileOwnedAgent = ref.watch(
|
|
userCacheProvider.select(
|
|
(cache) => cache[normalized]?.ownerPubkey != null,
|
|
),
|
|
);
|
|
if (isProfileOwnedAgent) profileOwnedAgentPubkeys.add(normalized);
|
|
}
|
|
final agentDirectoryState = ref.watch(agentDirectoryProvider);
|
|
final agentOwnersState = ref.watch(agentOwnersProvider);
|
|
final channelMembershipUpdateState = isOneToOneDm
|
|
? ref.watch(channelMembershipUpdateProvider(resolvedChannel.id))
|
|
: const ChannelMembershipUpdateState(isReady: true);
|
|
final channelBotPubkeysState = ref.watch(
|
|
channelBotPubkeysProvider(resolvedChannel.id),
|
|
);
|
|
final identitySubscriptionPubkeys = isOneToOneDm
|
|
? (resolvedChannel.participantPubkeys
|
|
.map((pubkey) => pubkey.trim().toLowerCase())
|
|
.where((pubkey) => pubkey.isNotEmpty)
|
|
.toSet()
|
|
.toList()
|
|
..sort())
|
|
: const <String>[];
|
|
final identitySubscriptionKey = Object.hashAll(identitySubscriptionPubkeys);
|
|
final identitySubscriptionReady = useValueNotifier(false, [
|
|
sessionStatus,
|
|
resolvedChannel.id,
|
|
identitySubscriptionKey,
|
|
]);
|
|
final directlyResolvedAgentPubkeys = useValueNotifier(<String>{}, [
|
|
sessionStatus,
|
|
resolvedChannel.id,
|
|
identitySubscriptionKey,
|
|
]);
|
|
final isIdentitySubscriptionReady = useValueListenable(
|
|
identitySubscriptionReady,
|
|
);
|
|
final directAgentPubkeys = useValueListenable(directlyResolvedAgentPubkeys);
|
|
final agentPubkeys = agentPubkeysWithChannelBots(
|
|
knownAgentPubkeys: agentPubkeysWithProfileOwners(
|
|
knownAgentPubkeys: {
|
|
...ref.watch(knownAgentPubkeysProvider),
|
|
...directAgentPubkeys,
|
|
},
|
|
profileOwnedAgentPubkeys: profileOwnedAgentPubkeys,
|
|
),
|
|
channelBotPubkeys:
|
|
channelBotPubkeysState.asData?.value ?? const <String>{},
|
|
);
|
|
useEffect(() {
|
|
if (sessionStatus != SessionStatus.connected ||
|
|
identitySubscriptionPubkeys.isEmpty) {
|
|
return null;
|
|
}
|
|
var disposed = false;
|
|
var subscriptionFailed = false;
|
|
void markFailed() {
|
|
subscriptionFailed = true;
|
|
if (!disposed) identitySubscriptionReady.value = false;
|
|
}
|
|
|
|
void Function()? unsubscribe;
|
|
Future.microtask(() async {
|
|
try {
|
|
final cleanup = await _subscribeToDmIdentityUpdates(
|
|
ref,
|
|
identitySubscriptionPubkeys,
|
|
onReadyChanged: (isReady) {
|
|
if (!disposed && !subscriptionFailed) {
|
|
identitySubscriptionReady.value = isReady;
|
|
}
|
|
},
|
|
onAgentPubkeysChanged: (pubkeys) {
|
|
if (!disposed) directlyResolvedAgentPubkeys.value = pubkeys;
|
|
},
|
|
onFailure: markFailed,
|
|
);
|
|
if (disposed) {
|
|
cleanup();
|
|
} else {
|
|
unsubscribe = cleanup;
|
|
}
|
|
} catch (error) {
|
|
if (!disposed) {
|
|
debugPrint('[DmIdentity] live subscription failed: $error');
|
|
markFailed();
|
|
}
|
|
}
|
|
});
|
|
return () {
|
|
disposed = true;
|
|
unsubscribe?.call();
|
|
};
|
|
}, [sessionStatus, resolvedChannel.id, identitySubscriptionKey]);
|
|
final isAgentIdentityUnresolved =
|
|
isOneToOneDm &&
|
|
(sessionStatus != SessionStatus.connected ||
|
|
!isIdentitySubscriptionReady ||
|
|
agentDirectoryState.isLoading ||
|
|
agentDirectoryState.hasError ||
|
|
agentOwnersState.isLoading ||
|
|
agentOwnersState.hasError ||
|
|
!channelMembershipUpdateState.isReady ||
|
|
channelMembershipUpdateState.error != null ||
|
|
channelBotPubkeysState.isLoading ||
|
|
channelBotPubkeysState.hasError ||
|
|
memberProfilesPreloadState.connectionState !=
|
|
ConnectionState.done ||
|
|
memberProfilesPreloadState.data != true);
|
|
final showsHuddleAction =
|
|
showsComposer &&
|
|
!isAgentIdentityUnresolved &&
|
|
!_isOneToOneAgentDm(resolvedChannel, agentPubkeys);
|
|
final messagesNotifier = ref.read(
|
|
channelMessagesProvider(channel.id).notifier,
|
|
);
|
|
final isConnectionInProgress =
|
|
sessionStatus == SessionStatus.connecting ||
|
|
sessionStatus == SessionStatus.reconnecting;
|
|
final showConnectionSkeleton = useState(false);
|
|
final shouldDebounceConnectionSkeleton =
|
|
isConnectionInProgress &&
|
|
(resolvedChannel.isForum || messagesNotifier.hasLoadedMessages);
|
|
useEffect(() {
|
|
if (!shouldDebounceConnectionSkeleton) {
|
|
showConnectionSkeleton.value = false;
|
|
return null;
|
|
}
|
|
final timer = Timer(const Duration(seconds: 2), () {
|
|
showConnectionSkeleton.value = true;
|
|
});
|
|
return timer.cancel;
|
|
}, [shouldDebounceConnectionSkeleton]);
|
|
final showInitialConnectionSkeleton =
|
|
!resolvedChannel.isForum &&
|
|
isConnectionInProgress &&
|
|
!messagesNotifier.hasLoadedMessages;
|
|
final appBarTitleContentHeight = _twoLineAppBarTitleContentHeight(context);
|
|
|
|
// Opening a forum or a DM reads the whole channel: a forum has no
|
|
// timeline to read row by row, and a DM is all for the reader. Other
|
|
// timelines write their read marks while the reader looks at rows; see
|
|
// `readingMarks`.
|
|
final openReadTimestamp = _openReadTimestamp(
|
|
channel: resolvedChannel,
|
|
messagesState: messagesState,
|
|
);
|
|
|
|
useEffect(() {
|
|
final session = ref.read(relaySessionProvider.notifier);
|
|
return session.registerVisibleChannel(channel.id);
|
|
}, [channel.id]);
|
|
|
|
useEffect(
|
|
() {
|
|
if (channel.isForum) return null;
|
|
final eventIds = {
|
|
?initialMessageId,
|
|
?initialThreadRootId,
|
|
?initialOldestOrdinaryUnreadMessageId,
|
|
...initialForcedUnreadMessageIds,
|
|
};
|
|
if (eventIds.isEmpty) return null;
|
|
final notifier = ref.read(channelMessagesProvider(channel.id).notifier);
|
|
unawaited(_loadDeepLinkEvents(ref, channel.id, eventIds));
|
|
return () => notifier.releaseDeepLinkEvents(eventIds);
|
|
},
|
|
[
|
|
channel.id,
|
|
initialMessageId,
|
|
initialThreadRootId,
|
|
initialOldestOrdinaryUnreadMessageId,
|
|
initialForcedUnreadMessageIds,
|
|
],
|
|
);
|
|
|
|
// Opening reads only while this page is in front and the app is in use.
|
|
// A covered or backgrounded DM must not read a reply that loads meanwhile;
|
|
// it reads through it when it is shown again.
|
|
final openReadActive =
|
|
isAppInUse(useAppLifecycleState()) &&
|
|
(ModalRoute.of(context)?.isCurrent ?? true);
|
|
useEffect(() {
|
|
if (!readState.isReady || !openReadActive || openReadTimestamp == null) {
|
|
return null;
|
|
}
|
|
return deferReadStateUpdate(context, () {
|
|
ref
|
|
.read(readStateProvider.notifier)
|
|
.markContextRead(channel.id, openReadTimestamp);
|
|
ref
|
|
.read(channelsProvider.notifier)
|
|
.clearObservedUnreadCoveredByRead(channel.id, openReadTimestamp);
|
|
});
|
|
}, [channel.id, readState.isReady, openReadActive, openReadTimestamp]);
|
|
|
|
final dmHeader = resolvedChannel.isDm
|
|
? _watchDmHeader(ref, resolvedChannel, currentPubkey)
|
|
: null;
|
|
final headerMembers = ref.watch(channelMembersProvider(resolvedChannel.id));
|
|
final headerMemberCount =
|
|
headerMembers.value?.length ?? resolvedChannel.memberCount;
|
|
final headerMemberLabel =
|
|
'$headerMemberCount ${headerMemberCount == 1 ? 'member' : 'members'}';
|
|
Future<void> openChannelDetails() async {
|
|
final shouldClose = await showChannelDetailsPage(
|
|
context: context,
|
|
channel: resolvedChannel,
|
|
currentPubkey: currentPubkey,
|
|
onMemberTap: (context, pubkey) => showUserProfileSheet(
|
|
context,
|
|
pubkey,
|
|
names: channelIdentityNamesProvider(resolvedChannel.id),
|
|
contextualActions: (_) => ChannelMemberProfileActions(
|
|
channel: resolvedChannel,
|
|
pubkey: pubkey,
|
|
),
|
|
),
|
|
sectionId: ref
|
|
.read(channelSectionsProvider)
|
|
.store
|
|
.assignments[resolvedChannel.id],
|
|
);
|
|
if (shouldClose == true && context.mounted) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
}
|
|
|
|
final retention = ephemeralChannelDisplay(resolvedChannel);
|
|
final nativeHeaderSubtitle = isOneToOneDm
|
|
? dmHeader?.presenceLabel
|
|
: headerMemberLabel;
|
|
|
|
final timelineMessages = resolvedChannel.isForum
|
|
? <TimelineMessage>[]
|
|
: formatTimeline(
|
|
messagesState.value ?? const [],
|
|
currentPubkey: currentPubkey,
|
|
);
|
|
final timelineEntries = buildMainTimelineEntries(
|
|
timelineMessages,
|
|
relaySummaries: ref
|
|
.read(channelMessagesProvider(channel.id).notifier)
|
|
.threadSummaries,
|
|
);
|
|
|
|
return FrostedScaffold(
|
|
foreground:
|
|
!resolvedChannel.isForum &&
|
|
timelineEntries.isNotEmpty &&
|
|
!showInitialConnectionSkeleton &&
|
|
!showConnectionSkeleton.value &&
|
|
!messagesState.isLoading
|
|
? Stack(
|
|
children: [
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
top:
|
|
frostedAppBarHeight(
|
|
context,
|
|
titleContentHeight: appBarTitleContentHeight,
|
|
) +
|
|
Grid.twelve,
|
|
child: StickyDateHeader(
|
|
key: const ValueKey('channel-sticky-date-header'),
|
|
state: stickyDateHeaderState,
|
|
),
|
|
),
|
|
],
|
|
)
|
|
: null,
|
|
resizeToAvoidBottomInset:
|
|
!usesFixedAndroidImeViewport || resolvedChannel.isForum,
|
|
appBar: FrostedAppBar(
|
|
alwaysFrosted: true,
|
|
nativeViewSuppressed: messageActionBackdropActive,
|
|
nativeTitle:
|
|
dmHeader?.label ??
|
|
resolveDmChannelDisplayLabel(
|
|
resolvedChannel,
|
|
currentPubkey: currentPubkey,
|
|
),
|
|
nativeSubtitle: retention == null
|
|
? nativeHeaderSubtitle
|
|
: [
|
|
'Temporary',
|
|
if (retention.detailLabel != null) retention.detailLabel!,
|
|
if (nativeHeaderSubtitle != null &&
|
|
nativeHeaderSubtitle.isNotEmpty)
|
|
nativeHeaderSubtitle,
|
|
].join(' · '),
|
|
nativeEphemeralLabel: retention?.tooltipLabel,
|
|
nativeTitlePresenceColor: switch (isOneToOneDm
|
|
? dmHeader?.presence
|
|
: null) {
|
|
'online' => context.appColors.success,
|
|
'away' => context.appColors.warning,
|
|
'offline' => context.colors.outline,
|
|
_ => null,
|
|
},
|
|
onNativeTitlePressed: openChannelDetails,
|
|
nativeActions: [
|
|
if (resolvedChannel.isDm ? showsHuddleAction : showsComposer)
|
|
_huddleNavigationAction(context, ref, resolvedChannel, [
|
|
...messagesState.value ?? const [],
|
|
...huddleLifecycle,
|
|
]),
|
|
],
|
|
iconColor: context.colors.primary,
|
|
titleContentHeight: appBarTitleContentHeight,
|
|
titleStyle: channelTitleTextStyle,
|
|
centerTitle: true,
|
|
title: _ConversationAppBarTitle(
|
|
channel: resolvedChannel,
|
|
label: dmHeader?.label ?? resolvedChannel.name,
|
|
subtitle: isOneToOneDm ? dmHeader!.presenceLabel : headerMemberLabel,
|
|
presence: isOneToOneDm ? dmHeader?.presence : null,
|
|
onTap: openChannelDetails,
|
|
),
|
|
actions: [
|
|
if (resolvedChannel.isDm ? showsHuddleAction : showsComposer)
|
|
_HuddleButton(
|
|
channel: resolvedChannel,
|
|
events: [...messagesState.value ?? const [], ...huddleLifecycle],
|
|
),
|
|
],
|
|
),
|
|
body: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
Column(
|
|
children: [
|
|
Expanded(
|
|
child: resolvedChannel.isForum
|
|
? Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
ForumPostsView(
|
|
channel: resolvedChannel,
|
|
currentPubkey: currentPubkey,
|
|
),
|
|
if (showConnectionSkeleton.value &&
|
|
ref.watch(forumPostsProvider(channel.id)).value ==
|
|
null)
|
|
Positioned(
|
|
top:
|
|
frostedAppBarHeight(
|
|
context,
|
|
titleContentHeight:
|
|
appBarTitleContentHeight,
|
|
) +
|
|
Grid.xs,
|
|
left: Grid.gutter,
|
|
right: Grid.gutter,
|
|
bottom: 0,
|
|
child: _ForumConnectionSkeleton(
|
|
status: sessionStatus,
|
|
),
|
|
),
|
|
],
|
|
)
|
|
: SkeletonReveal(
|
|
key: ValueKey('message-loading:${channel.id}'),
|
|
loading:
|
|
showInitialConnectionSkeleton ||
|
|
showConnectionSkeleton.value ||
|
|
messagesState.isLoading,
|
|
shimmerEnabled:
|
|
sessionStatus != SessionStatus.disconnected,
|
|
skeleton: _MessageTimelineSkeleton(
|
|
messages: timelineEntries
|
|
.map((entry) => entry.message)
|
|
.toList(),
|
|
appBarTitleContentHeight: appBarTitleContentHeight,
|
|
),
|
|
loadingSemanticsKey: const Key(
|
|
'channel-detail-connection-skeleton',
|
|
),
|
|
loadingLabel: switch (sessionStatus) {
|
|
SessionStatus.connecting => 'Connecting',
|
|
SessionStatus.reconnecting => 'Reconnecting',
|
|
_ => 'Loading',
|
|
},
|
|
skeletonPadding: EdgeInsets.only(
|
|
bottom: showsComposer ? composerDockHeight.value : 0,
|
|
),
|
|
content: messagesState.when(
|
|
skipLoadingOnReload:
|
|
messagesState.hasValue && !messagesState.hasError,
|
|
skipLoadingOnRefresh: !messagesState.hasError,
|
|
loading: SizedBox.shrink,
|
|
error: (e, _) => Padding(
|
|
padding: EdgeInsets.only(
|
|
top: frostedAppBarHeight(
|
|
context,
|
|
titleContentHeight: appBarTitleContentHeight,
|
|
),
|
|
),
|
|
child: LoadErrorView(
|
|
message: 'Failed to load messages',
|
|
onRetry: () => ref.invalidate(
|
|
channelMessagesProvider(channel.id),
|
|
),
|
|
),
|
|
),
|
|
data: (events) {
|
|
return _MessageList(
|
|
stickyDateHeaderState: stickyDateHeaderState,
|
|
entries: timelineEntries,
|
|
allMessages: timelineMessages,
|
|
initialMessageId: initialMessageId,
|
|
initialThreadRootId: initialThreadRootId,
|
|
initialThreadRouteBehavior:
|
|
initialThreadRouteBehavior,
|
|
initialOrdinaryUnreadMessageIds:
|
|
initialOrdinaryUnreadMessageIds,
|
|
initialOldestOrdinaryUnreadMessageId:
|
|
initialOldestOrdinaryUnreadMessageId,
|
|
initialForcedUnreadMessageIds:
|
|
initialForcedUnreadMessageIds,
|
|
hasInitialUnread:
|
|
readState.isReady &&
|
|
(readState.isForcedUnread(channel.id) ||
|
|
initialForcedUnreadMessageIds
|
|
.isNotEmpty ||
|
|
initialOldestOrdinaryUnreadMessageId !=
|
|
null),
|
|
channelId: channel.id,
|
|
isDm: resolvedChannel.isDm,
|
|
currentPubkey: currentPubkey,
|
|
isMember: resolvedChannel.isMember,
|
|
isArchived: resolvedChannel.isArchived,
|
|
appBarTitleContentHeight:
|
|
appBarTitleContentHeight,
|
|
composerBottomInset: showsComposer
|
|
? composerDockHeight.value
|
|
: 0,
|
|
composerFocusNode: showsComposer
|
|
? composerFocusNode
|
|
: null,
|
|
restoreComposerFocus: showsComposer
|
|
? () => restoreComposerFocus.value?.call()
|
|
: null,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
if (!resolvedChannel.isForum &&
|
|
(!resolvedChannel.isMember ||
|
|
resolvedChannel.isArchived)) ...[
|
|
AnimatedSize(
|
|
duration: MediaQuery.disableAnimationsOf(context)
|
|
? Duration.zero
|
|
: const Duration(milliseconds: 180),
|
|
curve: Curves.easeOutCubic,
|
|
alignment: Alignment.bottomCenter,
|
|
child: typingEntries.isEmpty
|
|
? const SizedBox.shrink()
|
|
: ChannelTypingIndicator(
|
|
channelId: resolvedChannel.id,
|
|
entries: typingEntries,
|
|
),
|
|
),
|
|
if (!resolvedChannel.isDm)
|
|
_ReadOnlyNotice(channel: resolvedChannel),
|
|
],
|
|
],
|
|
),
|
|
if (showsComposer)
|
|
AndroidImeLift(
|
|
child: Align(
|
|
alignment: Alignment.bottomCenter,
|
|
child: ComposerDockSizeReporter(
|
|
key: const ValueKey('channel-composer-dock'),
|
|
onHeightChanged: (height) {
|
|
if ((composerDockHeight.value - height).abs() < 0.5) return;
|
|
composerDockHeight.value = height;
|
|
},
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
AnimatedSize(
|
|
duration: MediaQuery.disableAnimationsOf(context)
|
|
? Duration.zero
|
|
: const Duration(milliseconds: 180),
|
|
curve: Curves.easeOutCubic,
|
|
alignment: Alignment.bottomCenter,
|
|
child: typingEntries.isEmpty
|
|
? const SizedBox.shrink()
|
|
: ChannelTypingIndicator(
|
|
channelId: resolvedChannel.id,
|
|
entries: typingEntries,
|
|
),
|
|
),
|
|
ComposeBar(
|
|
channelId: channel.id,
|
|
focusNode: composerFocusNode,
|
|
onFocusRestorerChanged: (restoreFocus) =>
|
|
restoreComposerFocus.value = restoreFocus,
|
|
channelName: resolvedChannel.isDm
|
|
? ''
|
|
: resolvedChannel.name,
|
|
onSend:
|
|
(
|
|
content,
|
|
mentionPubkeys, {
|
|
mediaTags = const <List<String>>[],
|
|
}) => sendMessage.call(
|
|
channelId: channel.id,
|
|
content: content,
|
|
mentionPubkeys: mentionPubkeys,
|
|
channel: resolvedChannel,
|
|
mediaTags: mediaTags,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|