Compare commits

..

12 Commits

15 changed files with 326 additions and 84 deletions

View File

@@ -449,4 +449,13 @@ class AppDatabase extends _$AppDatabase {
chatMembers,
).insert(companionFromMember(member), mode: InsertMode.insertOrReplace);
}
Future<int> saveMessageWithSender(LocalChatMessage message) async {
// First save the sender if it exists
if (message.sender != null) {
await saveMember(message.sender!);
}
// Then save the message
return await saveMessage(messageToCompanion(message));
}
}

View File

@@ -6,7 +6,7 @@ part of 'call.dart';
// RiverpodGenerator
// **************************************************************************
String _$callNotifierHash() => r'2caee30f42315e539cb4df17c0d464ceed41ffa0';
String _$callNotifierHash() => r'ef4e3e9c9d411cf9dce1ceb456a3b866b2c87db3';
/// See also [CallNotifier].
@ProviderFor(CallNotifier)

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:island/models/chat.dart';
import 'package:island/pods/network.dart';
@@ -6,6 +8,58 @@ import 'package:island/pods/chat/chat_subscribe.dart';
part 'chat_summary.g.dart';
@riverpod
class ChatUnreadCountNotifier extends _$ChatUnreadCountNotifier {
StreamSubscription<WebSocketPacket>? _subscription;
@override
Future<int> build() async {
// Subscribe to websocket events when this provider is built
_subscribeToWebSocket();
// Dispose the subscription when this provider is disposed
ref.onDispose(() {
_subscription?.cancel();
});
try {
final client = ref.read(apiClientProvider);
final response = await client.get('/sphere/chat/unread');
return (response.data as num).toInt();
} catch (_) {
return 0;
}
}
void _subscribeToWebSocket() {
final webSocketService = ref.read(websocketProvider);
_subscription = webSocketService.dataStream.listen((packet) {
if (packet.type == 'messages.new' && packet.data != null) {
final message = SnChatMessage.fromJson(packet.data!);
final currentSubscribed = ref.read(currentSubscribedChatIdProvider);
// Only increment if the message is not from the currently subscribed chat
if (message.chatRoomId != currentSubscribed) {
_incrementCounter();
}
}
});
}
Future<void> _incrementCounter() async {
final current = await future;
state = AsyncData(current + 1);
}
Future<void> decrement(int count) async {
final current = await future;
state = AsyncData(math.max(current - count, 0));
}
void clear() async {
state = AsyncData(0);
}
}
@riverpod
class ChatSummary extends _$ChatSummary {
@override
@@ -41,6 +95,14 @@ class ChatSummary extends _$ChatSummary {
state.whenData((summaries) {
final summary = summaries[chatId];
if (summary != null) {
// Decrement global unread count
final unreadToDecrement = summary.unreadCount;
if (unreadToDecrement > 0) {
ref
.read(chatUnreadCountNotifierProvider.notifier)
.decrement(unreadToDecrement);
}
state = AsyncData({
...summaries,
chatId: SnChatSummary(

View File

@@ -6,6 +6,24 @@ part of 'chat_summary.dart';
// RiverpodGenerator
// **************************************************************************
String _$chatUnreadCountNotifierHash() =>
r'b8d93589dc37f772d4c3a07d9afd81c37026e57d';
/// See also [ChatUnreadCountNotifier].
@ProviderFor(ChatUnreadCountNotifier)
final chatUnreadCountNotifierProvider =
AutoDisposeAsyncNotifierProvider<ChatUnreadCountNotifier, int>.internal(
ChatUnreadCountNotifier.new,
name: r'chatUnreadCountNotifierProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$chatUnreadCountNotifierHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$ChatUnreadCountNotifier = AutoDisposeAsyncNotifier<int>;
String _$chatSummaryHash() => r'33815a3bd81d20902b7063e8194fe336930df9b4';
/// See also [ChatSummary].

View File

@@ -45,6 +45,7 @@ class MessagesNotifier extends _$MessagesNotifier {
bool _isSyncing = false;
bool _isJumping = false;
bool _isUpdatingState = false;
bool _allRemoteMessagesFetched = false;
DateTime? _lastPauseTime;
late final Future<SnAccount?> Function(String) _fetchAccount;
@@ -278,6 +279,7 @@ class MessagesNotifier extends _$MessagesNotifier {
}
if (offset >= _totalCount!) {
_allRemoteMessagesFetched = true;
return [];
}
@@ -299,10 +301,7 @@ class MessagesNotifier extends _$MessagesNotifier {
}).toList();
for (final message in messages) {
await _database.saveMessage(_database.messageToCompanion(message));
if (message.sender != null) {
await _database.saveMember(message.sender!); // Save/update member data
}
await _database.saveMessageWithSender(message);
if (message.nonce != null) {
_pendingMessages.removeWhere(
(_, pendingMsg) => pendingMsg.nonce == message.nonce,
@@ -310,6 +309,11 @@ class MessagesNotifier extends _$MessagesNotifier {
}
}
// Check if we've fetched all remote messages
if (offset + messages.length >= _totalCount!) {
_allRemoteMessagesFetched = true;
}
return messages;
}
@@ -319,6 +323,7 @@ class MessagesNotifier extends _$MessagesNotifier {
return;
}
_isSyncing = true;
_allRemoteMessagesFetched = false;
talker.log('Starting message sync');
Future.microtask(() => ref.read(isSyncingProvider.notifier).state = true);
@@ -346,19 +351,48 @@ class MessagesNotifier extends _$MessagesNotifier {
return;
}
// Sync with pagination support using timestamp-based cursor
int? totalMessages;
int syncedCount = 0;
int lastSyncTimestamp =
lastMessage.toRemoteMessage().updatedAt.millisecondsSinceEpoch;
do {
final resp = await _apiClient.post(
'/sphere/chat/${_room.id}/sync',
data: {
'last_sync_timestamp':
lastMessage.toRemoteMessage().updatedAt.millisecondsSinceEpoch,
},
data: {'last_sync_timestamp': lastSyncTimestamp},
);
// Read total count from header on first request
if (totalMessages == null) {
totalMessages = int.parse(
resp.headers['x-total']?.firstOrNull ?? '0',
);
talker.log('Total messages to sync: $totalMessages');
}
final response = MessageSyncResponse.fromJson(resp.data);
talker.log('Sync response: ${response.messages.length} changes');
final messagesCount = response.messages.length;
talker.log(
'Sync page: synced=$syncedCount/$totalMessages, count=$messagesCount',
);
for (final message in response.messages) {
await receiveMessage(message);
}
syncedCount += messagesCount;
// Update cursor to the last message's createdAt for next page
if (response.messages.isNotEmpty) {
lastSyncTimestamp =
response.messages.last.createdAt.millisecondsSinceEpoch;
}
// Continue if there are more messages to fetch
} while (syncedCount < totalMessages);
talker.log('Sync complete: synced $syncedCount messages');
} catch (err, stackTrace) {
talker.log(
'Error syncing messages',
@@ -397,14 +431,35 @@ class MessagesNotifier extends _$MessagesNotifier {
withAttachments: _withAttachments,
);
if (localMessages.isNotEmpty) {
// If we have local messages AND we've fetched all remote messages, return local
if (localMessages.isNotEmpty && _allRemoteMessagesFetched) {
return localMessages;
}
// If we haven't fetched all remote messages, check remote even if we have local
// OR if we have no local messages at all
if (_searchQuery == null || _searchQuery!.isEmpty) {
return await _fetchAndCacheMessages(offset: offset, take: take);
final remoteMessages = await _fetchAndCacheMessages(
offset: offset,
take: take,
);
// If we got remote messages, re-fetch from cache to get merged result
if (remoteMessages.isNotEmpty) {
return await _getCachedMessages(
offset: offset,
take: take,
searchQuery: _searchQuery,
withLinks: _withLinks,
withAttachments: _withAttachments,
);
}
// No remote messages, return local (if any)
return localMessages;
} else {
return []; // If searching, and no local messages, don't fetch from network
// For search queries, return local only
return localMessages;
}
} catch (e) {
final localMessages = await _getCachedMessages(
@@ -424,6 +479,7 @@ class MessagesNotifier extends _$MessagesNotifier {
Future<void> loadInitial() async {
talker.log('Loading initial messages');
_allRemoteMessagesFetched = false;
if (_searchQuery == null || _searchQuery!.isEmpty) {
syncMessages();
}
@@ -445,6 +501,7 @@ class MessagesNotifier extends _$MessagesNotifier {
if (!_hasMore || state is AsyncLoading) return;
talker.log('Loading more messages');
Future.microtask(() => ref.read(isSyncingProvider.notifier).state = true);
try {
final currentMessages = state.value ?? [];
final offset = currentMessages.length;
@@ -466,6 +523,10 @@ class MessagesNotifier extends _$MessagesNotifier {
stackTrace: stackTrace,
);
showErrorAlert(err);
} finally {
Future.microtask(
() => ref.read(isSyncingProvider.notifier).state = false,
);
}
}
@@ -501,8 +562,7 @@ class MessagesNotifier extends _$MessagesNotifier {
_pendingMessages[localMessage.id] = localMessage;
_fileUploadProgress[localMessage.id] = {};
await _database.saveMessage(_database.messageToCompanion(localMessage));
await _database.saveMember(mockMessage.sender);
await _database.saveMessageWithSender(localMessage);
final currentMessages = state.value ?? [];
state = AsyncValue.data([localMessage, ...currentMessages]);
@@ -553,7 +613,7 @@ class MessagesNotifier extends _$MessagesNotifier {
_pendingMessages.remove(localMessage.id);
await _database.deleteMessage(localMessage.id);
await _database.saveMessage(_database.messageToCompanion(updatedMessage));
await _database.saveMessageWithSender(updatedMessage);
final currentMessages = state.value ?? [];
if (editingTo != null) {
@@ -635,7 +695,7 @@ class MessagesNotifier extends _$MessagesNotifier {
_pendingMessages.remove(pendingMessageId);
await _database.deleteMessage(pendingMessageId);
await _database.saveMessage(_database.messageToCompanion(updatedMessage));
await _database.saveMessageWithSender(updatedMessage);
final newMessages =
(state.value ?? []).map((m) {
@@ -692,7 +752,7 @@ class MessagesNotifier extends _$MessagesNotifier {
);
}
await _database.saveMessage(_database.messageToCompanion(localMessage));
await _database.saveMessageWithSender(localMessage);
final currentMessages = state.value ?? [];
final existingIndex = currentMessages.indexWhere(
@@ -789,7 +849,7 @@ class MessagesNotifier extends _$MessagesNotifier {
messageToUpdate.status,
);
await _database.saveMessage(_database.messageToCompanion(deletedMessage));
await _database.saveMessageWithSender(deletedMessage);
if (messageIndex != -1) {
final newList = [...currentMessages];
@@ -913,6 +973,7 @@ class MessagesNotifier extends _$MessagesNotifier {
_searchQuery = null;
_withLinks = null;
_withAttachments = null;
_allRemoteMessagesFetched = false;
loadInitial();
}
@@ -938,7 +999,7 @@ class MessagesNotifier extends _$MessagesNotifier {
MessageStatus.sent,
);
await _database.saveMessage(_database.messageToCompanion(message));
await _database.saveMessageWithSender(message);
return message;
} catch (e) {
if (e is DioException) return null;

View File

@@ -6,7 +6,7 @@ part of 'messages_notifier.dart';
// RiverpodGenerator
// **************************************************************************
String _$messagesNotifierHash() => r'27e5d686d9204ba39adbd1838cf4a6eaea0ac85f';
String _$messagesNotifierHash() => r'27ce32c54e317a04e1d554ed4a70a24e4503fdd1';
/// Copied from Dart SDK
class _SystemHash {

View File

@@ -6,11 +6,12 @@ import 'package:island/widgets/content/sheet.dart';
class CaptchaScreen extends ConsumerWidget {
static Future<String?> show(BuildContext context) {
return showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
isDismissible: false,
return Navigator.push<String>(
context,
MaterialPageRoute(
builder: (context) => const CaptchaScreen(),
fullscreenDialog: true,
),
);
}

View File

@@ -1,5 +1,3 @@
// ignore_for_file: invalid_runtime_check_with_js_interop_types
import 'dart:ui_web' as ui;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/config.dart';
@@ -10,11 +8,12 @@ import 'package:flutter/material.dart';
class CaptchaScreen extends ConsumerStatefulWidget {
static Future<String?> show(BuildContext context) {
return showModalBottomSheet<String>(
context: context,
isDismissible: false,
isScrollControlled: true,
return Navigator.push<String>(
context,
MaterialPageRoute(
builder: (context) => const CaptchaScreen(),
fullscreenDialog: true,
),
);
}
@@ -29,7 +28,9 @@ class _CaptchaScreenState extends ConsumerState<CaptchaScreen> {
void _setupWebListener(String serverUrl) async {
web.window.onMessage.listen((event) {
// ignore: invalid_runtime_check_with_js_interop_types
if (event.data != null && event.data is String) {
// ignore: invalid_runtime_check_with_js_interop_types
final message = event.data as String;
if (message.startsWith("captcha_tk=")) {
String token = message.replaceFirst("captcha_tk=", "");

View File

@@ -36,7 +36,7 @@ class FileListScreen extends HookConsumerWidget {
isNoBackground: false,
appBar: AppBar(
title: Text('files').tr(),
leading: const PageBackButton(),
leading: const PageBackButton(backTo: '/account'),
actions: [
IconButton(
icon: const Icon(Symbols.bar_chart),

View File

@@ -10,6 +10,7 @@ import 'package:island/pods/network.dart';
import 'package:island/pods/websocket.dart';
import 'package:island/route.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/markdown.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
@@ -68,6 +69,16 @@ class NotificationUnreadCountNotifier
void clear() async {
state = AsyncData(0);
}
Future<void> refresh() async {
try {
final client = ref.read(apiClientProvider);
final response = await client.get('/ring/notifications/count');
state = AsyncData((response.data as num).toInt());
} catch (_) {
// Keep the current state if refresh fails
}
}
}
@riverpod
@@ -115,8 +126,36 @@ class NotificationListNotifier extends _$NotificationListNotifier
class NotificationSheet extends HookConsumerWidget {
const NotificationSheet({super.key});
IconData _getNotificationIcon(String topic) {
switch (topic) {
case 'post.replies':
return Symbols.reply;
case 'wallet.transactions':
return Symbols.account_balance_wallet;
case 'relationships.friends.request':
return Symbols.person_add;
case 'invites.chat':
return Symbols.chat;
case 'invites.realm':
return Symbols.domain;
case 'auth.login':
return Symbols.login;
case 'posts.new':
return Symbols.post_add;
case 'wallet.orders.paid':
return Symbols.shopping_bag;
case 'posts.reactions.new':
return Symbols.add_reaction;
default:
return Symbols.notifications;
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
// Refresh unread count when sheet opens to sync across devices
ref.read(notificationUnreadCountNotifierProvider.notifier).refresh();
Future<void> markAllRead() async {
showLoadingModal(context);
final apiClient = ref.watch(apiClientProvider);
@@ -149,12 +188,30 @@ class NotificationSheet extends HookConsumerWidget {
}
final notification = data.items[index];
final pfp = notification.meta['pfp'] as String?;
final images = notification.meta['images'] as List?;
final imageIds = images?.cast<String>() ?? [];
return ListTile(
isThreeLine: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
leading:
pfp != null
? ProfilePictureWidget(fileId: pfp, radius: 20)
: CircleAvatar(
backgroundColor:
Theme.of(context).colorScheme.primaryContainer,
child: Icon(
_getNotificationIcon(notification.topic),
color:
Theme.of(
context,
).colorScheme.onPrimaryContainer,
),
),
title: Text(notification.title),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -187,6 +244,29 @@ class NotificationSheet extends HookConsumerWidget {
).colorScheme.onSurface.withOpacity(0.8),
),
),
if (imageIds.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Wrap(
spacing: 8,
runSpacing: 8,
children:
imageIds.map((imageId) {
return SizedBox(
width: 80,
height: 80,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CloudImageWidget(
fileId: imageId,
aspectRatio: 1,
fit: BoxFit.cover,
),
),
);
}).toList(),
),
),
],
),
trailing:

View File

@@ -14,6 +14,7 @@ import 'package:island/widgets/navigation/fab_menu.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/chat/chat_summary.dart';
final currentRouteProvider = StateProvider<String?>((ref) => null);
@@ -50,6 +51,8 @@ class TabsScreen extends HookConsumerWidget {
notificationUnreadCountNotifierProvider,
);
final chatUnreadCount = ref.watch(chatUnreadCountNotifierProvider);
final wideScreen = isWideScreen(context);
final destinations = [
@@ -59,7 +62,11 @@ class TabsScreen extends HookConsumerWidget {
),
NavigationDestination(
label: 'chat'.tr(),
icon: const Icon(Symbols.forum_rounded),
icon: Badge.count(
count: chatUnreadCount.value ?? 0,
isLabelVisible: (chatUnreadCount.value ?? 0) > 0,
child: const Icon(Symbols.forum_rounded),
),
),
NavigationDestination(
label: 'realms'.tr(),

View File

@@ -25,12 +25,24 @@ const Map<String, Color> kUsernamePlainColors = {
'white': Colors.white,
};
const kVerificationMarkColors = [
const List<IconData> kVerificationMarkIcons = [
Symbols.build_circle,
Symbols.verified,
Symbols.verified,
Symbols.account_balance,
Symbols.palette,
Symbols.code,
Symbols.masks,
];
const List<Color> kVerificationMarkColors = [
Colors.teal,
Colors.blue,
Colors.amber,
Colors.blueGrey,
Colors.lightBlue,
Colors.indigo,
Colors.red,
Colors.orange,
Colors.blue,
Colors.blueAccent,
];
class AccountName extends StatelessWidget {
@@ -291,13 +303,14 @@ class VerificationMark extends StatelessWidget {
@override
Widget build(BuildContext context) {
final icon = Icon(
mark.type == 4
? Symbols.play_circle
: mark.type == 0
? Symbols.build_circle
(kVerificationMarkIcons.length > mark.type && mark.type >= 0)
? kVerificationMarkIcons[mark.type]
: Symbols.verified,
size: 16,
color: kVerificationMarkColors[mark.type],
color:
(kVerificationMarkColors.length > mark.type && mark.type >= 0)
? kVerificationMarkColors[mark.type]
: Colors.blue,
fill: 1,
);
@@ -394,13 +407,14 @@ class VerificationStatusCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Icon(
mark.type == 4
? Symbols.play_circle
: mark.type == 0
? Symbols.build_circle
(kVerificationMarkIcons.length > mark.type && mark.type >= 0)
? kVerificationMarkIcons[mark.type]
: Symbols.verified,
size: 32,
color: kVerificationMarkColors[mark.type],
color:
(kVerificationMarkColors.length > mark.type && mark.type >= 0)
? kVerificationMarkColors[mark.type]
: Colors.blue,
fill: 1,
).alignment(Alignment.centerLeft),
const Gap(8),

View File

@@ -76,8 +76,6 @@ class MessageSenderInfo extends StatelessWidget {
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
AccountName(
account: sender.account,
@@ -86,19 +84,6 @@ class MessageSenderInfo extends StatelessWidget {
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 4),
Badge(
label:
Text(
sender.role >= 100
? 'permissionOwner'
: sender.role >= 50
? 'permissionModerator'
: 'permissionMember',
).tr(),
),
],
),
Text(
timestamp,
style: TextStyle(

View File

@@ -55,16 +55,20 @@ class _EmbedLinkWidgetState extends State<EmbedLinkWidget> {
stream.removeListener(listener);
final aspectRatio = info.image.width / info.image.height;
if (mounted) {
setState(() {
_isSquare = aspectRatio >= 0.9 && aspectRatio <= 1.1;
});
}
} catch (e) {
// If error, assume not square
if (mounted) {
setState(() {
_isSquare = false;
});
}
}
}
Future<void> _launchUrl() async {
final uri = Uri.parse(widget.link.url);

View File

@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 3.3.0+147
version: 3.3.0+148
environment:
sdk: ^3.7.2