Chat shows realm it is belongs to

This commit is contained in:
2025-12-21 22:56:34 +08:00
parent fb62ce7735
commit 4d095aa333
10 changed files with 1638 additions and 399 deletions

View File

@@ -5,16 +5,19 @@ import 'package:island/database/draft.dart';
import 'package:island/models/account.dart';
import 'package:island/models/chat.dart';
import 'package:island/models/post.dart';
import 'package:island/models/realm.dart';
part 'drift_db.g.dart';
// Define the database
@DriftDatabase(tables: [ChatRooms, ChatMembers, ChatMessages, PostDrafts])
@DriftDatabase(
tables: [Realms, ChatRooms, ChatMembers, ChatMessages, PostDrafts],
)
class AppDatabase extends _$AppDatabase {
AppDatabase(super.e);
@override
int get schemaVersion => 9;
int get schemaVersion => 10;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -71,6 +74,11 @@ class AppDatabase extends _$AppDatabase {
'ALTER TABLE chat_members DROP COLUMN last_typed',
);
}
if (from < 10) {
// Add realms table and update chat_rooms foreign key
await m.createTable(realms);
// The realmId column in chat_rooms already exists, just need to ensure the foreign key constraint
}
},
);
@@ -92,11 +100,10 @@ class AppDatabase extends _$AppDatabase {
// Migrate existing data if any
try {
final oldDrafts =
await customSelect(
'SELECT id, post, lastModified FROM post_drafts_old',
readsFrom: {postDrafts},
).get();
final oldDrafts = await customSelect(
'SELECT id, post, lastModified FROM post_drafts_old',
readsFrom: {postDrafts},
).get();
for (final row in oldDrafts) {
final postJson = row.read<String>('post');
@@ -150,9 +157,9 @@ class AppDatabase extends _$AppDatabase {
}
Future<int> updateMessageStatus(String id, MessageStatus status) {
return (update(chatMessages)..where(
(m) => m.id.equals(id),
)).write(ChatMessagesCompanion(status: Value(status)));
return (update(chatMessages)..where((m) => m.id.equals(id))).write(
ChatMessagesCompanion(status: Value(status)),
);
}
Future<int> deleteMessage(String id) {
@@ -176,29 +183,28 @@ class AppDatabase extends _$AppDatabase {
if (query.isNotEmpty) {
final searchTerm = '%$query%';
selectStatement =
selectStatement..where(
(m) =>
m.content.like(searchTerm) |
m.meta.like(searchTerm) |
m.attachments.like(searchTerm) |
m.type.like(searchTerm),
);
selectStatement = selectStatement
..where(
(m) =>
m.content.like(searchTerm) |
m.meta.like(searchTerm) |
m.attachments.like(searchTerm) |
m.type.like(searchTerm),
);
}
if (withAttachments == true) {
selectStatement =
selectStatement..where((m) => m.attachments.equals('[]').not());
selectStatement = selectStatement
..where((m) => m.attachments.equals('[]').not());
}
final messages =
await (selectStatement
..orderBy([(m) => OrderingTerm.desc(m.createdAt)]))
.get();
final messageFutures =
messages
.map((msg) => companionToMessage(msg, fetchAccount: fetchAccount))
.toList();
final messageFutures = messages
.map((msg) => companionToMessage(msg, fetchAccount: fetchAccount))
.toList();
return await Future.wait(messageFutures);
}
@@ -234,9 +240,9 @@ class AppDatabase extends _$AppDatabase {
final data = jsonDecode(dbMessage.data);
SnChatMember? sender;
try {
final senderRow =
await (select(chatMembers)
..where((m) => m.id.equals(dbMessage.senderId))).getSingle();
final senderRow = await (select(
chatMembers,
)..where((m) => m.id.equals(dbMessage.senderId))).getSingle();
SnAccount senderAccount;
senderAccount = SnAccount.fromJson(senderRow.account);
@@ -358,6 +364,20 @@ class AppDatabase extends _$AppDatabase {
);
}
RealmsCompanion companionFromRealm(SnRealm realm) {
return RealmsCompanion(
id: Value(realm.id),
name: Value(realm.name),
description: Value(realm.description),
picture: Value(realm.picture?.toJson()),
background: Value(realm.background?.toJson()),
accountId: Value(realm.accountId),
createdAt: Value(realm.createdAt),
updatedAt: Value(realm.updatedAt),
deletedAt: Value(realm.deletedAt),
);
}
Future<void> saveChatRooms(
List<SnChatRoom> rooms, {
bool override = false,
@@ -373,17 +393,35 @@ class AppDatabase extends _$AppDatabase {
if (idsToRemove.isNotEmpty) {
final idsList = idsToRemove.toList();
// Remove messages
await (delete(chatMessages)
..where((t) => t.roomId.isIn(idsList))).go();
await (delete(
chatMessages,
)..where((t) => t.roomId.isIn(idsList))).go();
// Remove members
await (delete(chatMembers)
..where((t) => t.chatRoomId.isIn(idsList))).go();
await (delete(
chatMembers,
)..where((t) => t.chatRoomId.isIn(idsList))).go();
// Remove rooms
await (delete(chatRooms)..where((t) => t.id.isIn(idsList))).go();
}
}
// 2. Upsert remote rooms
// 2. Upsert realms first
final realmsToSave = rooms
.where((room) => room.realm != null)
.map((room) => room.realm!)
.toSet()
.toList();
await batch((batch) {
for (final realm in realmsToSave) {
batch.insert(
realms,
companionFromRealm(realm),
mode: InsertMode.insertOrReplace,
);
}
});
// 3. Upsert remote rooms
await batch((batch) {
for (final room in rooms) {
batch.insert(
@@ -445,8 +483,9 @@ class AppDatabase extends _$AppDatabase {
}
Future<PostDraft?> getPostDraftById(String id) async {
return await (select(postDrafts)
..where((tbl) => tbl.id.equals(id))).getSingleOrNull();
return await (select(
postDrafts,
)..where((tbl) => tbl.id.equals(id))).getSingleOrNull();
}
Future<void> saveMember(SnChatMember member) async {

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,21 @@ class ListMapConverter
String toSql(List<Map<String, dynamic>> value) => json.encode(value);
}
class Realms extends Table {
TextColumn get id => text()();
TextColumn get name => text().nullable()();
TextColumn get description => text().nullable()();
TextColumn get picture => text().map(const MapConverter()).nullable()();
TextColumn get background => text().map(const MapConverter()).nullable()();
TextColumn get accountId => text().nullable()();
DateTimeColumn get createdAt => dateTime()();
DateTimeColumn get updatedAt => dateTime()();
DateTimeColumn get deletedAt => dateTime().nullable()();
@override
Set<Column> get primaryKey => {id};
}
class ChatRooms extends Table {
TextColumn get id => text()();
TextColumn get name => text().nullable()();
@@ -47,7 +62,7 @@ class ChatRooms extends Table {
boolean().nullable().withDefault(const Constant(false))();
TextColumn get picture => text().map(const MapConverter()).nullable()();
TextColumn get background => text().map(const MapConverter()).nullable()();
TextColumn get realmId => text().nullable()();
TextColumn get realmId => text().references(Realms, #id).nullable()();
TextColumn get accountId => text().nullable()();
DateTimeColumn get createdAt => dateTime()();
DateTimeColumn get updatedAt => dateTime()();
@@ -91,10 +106,9 @@ class ChatMessages extends Table {
TextColumn get type => text().withDefault(const Constant('text'))();
TextColumn get meta =>
text().map(const MapConverter()).withDefault(const Constant('{}'))();
TextColumn get membersMentioned =>
text()
.map(const ListStringConverter())
.withDefault(const Constant('[]'))();
TextColumn get membersMentioned => text()
.map(const ListStringConverter())
.withDefault(const Constant('[]'))();
DateTimeColumn get editedAt => dateTime().nullable()();
TextColumn get attachments =>
text().map(const ListMapConverter()).withDefault(const Constant('[]'))();

View File

@@ -4,6 +4,7 @@ import 'package:island/database/drift_db.dart';
import 'package:island/models/account.dart';
import 'package:island/models/chat.dart';
import 'package:island/models/file.dart';
import 'package:island/models/realm.dart';
import 'package:island/pods/database.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart';
@@ -49,96 +50,10 @@ class ChatRoomJoinedNotifier extends _$ChatRoomJoinedNotifier {
if (localRoomsData.isNotEmpty) {
final localRooms = await Future.wait(
localRoomsData.map((row) async {
final membersRows =
await (db.select(db.chatMembers)
..where((m) => m.chatRoomId.equals(row.id))).get();
final members =
membersRows.map((mRow) {
final account = SnAccount.fromJson(mRow.account);
return SnChatMember(
id: mRow.id,
chatRoomId: mRow.chatRoomId,
accountId: mRow.accountId,
account: account,
nick: mRow.nick,
notify: mRow.notify,
joinedAt: mRow.joinedAt,
breakUntil: mRow.breakUntil,
timeoutUntil: mRow.timeoutUntil,
status: null,
createdAt: mRow.createdAt,
updatedAt: mRow.updatedAt,
deletedAt: mRow.deletedAt,
chatRoom: null,
);
}).toList();
return SnChatRoom(
id: row.id,
name: row.name,
description: row.description,
type: row.type,
isPublic: row.isPublic!,
isCommunity: row.isCommunity!,
picture:
row.picture != null
? SnCloudFile.fromJson(row.picture!)
: null,
background:
row.background != null
? SnCloudFile.fromJson(row.background!)
: null,
realmId: row.realmId,
accountId: row.accountId,
realm: null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
deletedAt: row.deletedAt,
members: members,
);
}),
);
// Background sync
Future(() async {
try {
final client = ref.read(apiClientProvider);
final resp = await client.get('/sphere/chat');
final remoteRooms =
resp.data
.map((e) => SnChatRoom.fromJson(e))
.cast<SnChatRoom>()
.toList();
await db.saveChatRooms(remoteRooms, override: true);
// Update state with fresh data
state = AsyncData(await _buildRoomsFromDb(db));
} catch (_) {}
}).ignore();
return localRooms;
}
} catch (_) {}
// Fallback to API
final client = ref.watch(apiClientProvider);
final resp = await client.get('/sphere/chat');
final rooms =
resp.data
.map((e) => SnChatRoom.fromJson(e))
.cast<SnChatRoom>()
.toList();
await db.saveChatRooms(rooms, override: true);
return rooms;
}
Future<List<SnChatRoom>> _buildRoomsFromDb(AppDatabase db) async {
final localRoomsData = await db.select(db.chatRooms).get();
return Future.wait(
localRoomsData.map((row) async {
final membersRows =
await (db.select(db.chatMembers)
..where((m) => m.chatRoomId.equals(row.id))).get();
final members =
membersRows.map((mRow) {
final membersRows = await (db.select(
db.chatMembers,
)..where((m) => m.chatRoomId.equals(row.id))).get();
final members = membersRows.map((mRow) {
final account = SnAccount.fromJson(mRow.account);
return SnChatMember(
id: mRow.id,
@@ -157,6 +72,121 @@ class ChatRoomJoinedNotifier extends _$ChatRoomJoinedNotifier {
chatRoom: null,
);
}).toList();
return SnChatRoom(
id: row.id,
name: row.name,
description: row.description,
type: row.type,
isPublic: row.isPublic!,
isCommunity: row.isCommunity!,
picture: row.picture != null
? SnCloudFile.fromJson(row.picture!)
: null,
background: row.background != null
? SnCloudFile.fromJson(row.background!)
: null,
realmId: row.realmId,
accountId: row.accountId,
realm: null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
deletedAt: row.deletedAt,
members: members,
);
}),
);
// Background sync
Future(() async {
try {
final client = ref.read(apiClientProvider);
final resp = await client.get('/sphere/chat');
final remoteRooms = resp.data
.map((e) => SnChatRoom.fromJson(e))
.cast<SnChatRoom>()
.toList();
await db.saveChatRooms(remoteRooms, override: true);
// Update state with fresh data
state = AsyncData(await _buildRoomsFromDb(db));
} catch (_) {}
}).ignore();
return localRooms;
}
} catch (_) {}
// Fallback to API
final client = ref.watch(apiClientProvider);
final resp = await client.get('/sphere/chat');
final rooms = resp.data
.map((e) => SnChatRoom.fromJson(e))
.cast<SnChatRoom>()
.toList();
await db.saveChatRooms(rooms, override: true);
return rooms;
}
Future<List<SnChatRoom>> _buildRoomsFromDb(AppDatabase db) async {
final localRoomsData = await db.select(db.chatRooms).get();
return Future.wait(
localRoomsData.map((row) async {
final membersRows = await (db.select(
db.chatMembers,
)..where((m) => m.chatRoomId.equals(row.id))).get();
final members = membersRows.map((mRow) {
final account = SnAccount.fromJson(mRow.account);
return SnChatMember(
id: mRow.id,
chatRoomId: mRow.chatRoomId,
accountId: mRow.accountId,
account: account,
nick: mRow.nick,
notify: mRow.notify,
joinedAt: mRow.joinedAt,
breakUntil: mRow.breakUntil,
timeoutUntil: mRow.timeoutUntil,
status: null,
createdAt: mRow.createdAt,
updatedAt: mRow.updatedAt,
deletedAt: mRow.deletedAt,
chatRoom: null,
);
}).toList();
// Load realm if it exists
SnRealm? realm;
if (row.realmId != null) {
try {
final realmRow = await (db.select(
db.realms,
)..where((r) => r.id.equals(row.realmId!))).getSingleOrNull();
if (realmRow != null) {
realm = SnRealm(
id: realmRow.id,
slug: '', // Not stored in DB
name: realmRow.name ?? '',
description: realmRow.description ?? '',
verifiedAs: null, // Not stored in DB
verifiedAt: null, // Not stored in DB
isCommunity: false, // Not stored in DB
isPublic: true, // Not stored in DB
picture: realmRow.picture != null
? SnCloudFile.fromJson(realmRow.picture!)
: null,
background: realmRow.background != null
? SnCloudFile.fromJson(realmRow.background!)
: null,
accountId: realmRow.accountId ?? '',
createdAt: realmRow.createdAt,
updatedAt: realmRow.updatedAt,
deletedAt: realmRow.deletedAt,
);
}
} catch (_) {
// Realm not found, keep as null
}
}
return SnChatRoom(
id: row.id,
name: row.name,
@@ -164,15 +194,15 @@ class ChatRoomJoinedNotifier extends _$ChatRoomJoinedNotifier {
type: row.type,
isPublic: row.isPublic!,
isCommunity: row.isCommunity!,
picture:
row.picture != null ? SnCloudFile.fromJson(row.picture!) : null,
background:
row.background != null
? SnCloudFile.fromJson(row.background!)
: null,
picture: row.picture != null
? SnCloudFile.fromJson(row.picture!)
: null,
background: row.background != null
? SnCloudFile.fromJson(row.background!)
: null,
realmId: row.realmId,
accountId: row.accountId,
realm: null,
realm: realm,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
deletedAt: row.deletedAt,
@@ -192,35 +222,34 @@ class ChatRoomNotifier extends _$ChatRoomNotifier {
try {
// Try to get from local database first
final localRoomData =
await (db.select(db.chatRooms)
..where((r) => r.id.equals(identifier))).getSingleOrNull();
final localRoomData = await (db.select(
db.chatRooms,
)..where((r) => r.id.equals(identifier))).getSingleOrNull();
if (localRoomData != null) {
// Fetch members for this room
final membersRows =
await (db.select(db.chatMembers)
..where((m) => m.chatRoomId.equals(localRoomData.id))).get();
final members =
membersRows.map((mRow) {
final account = SnAccount.fromJson(mRow.account);
return SnChatMember(
id: mRow.id,
chatRoomId: mRow.chatRoomId,
accountId: mRow.accountId,
account: account,
nick: mRow.nick,
notify: mRow.notify,
joinedAt: mRow.joinedAt,
breakUntil: mRow.breakUntil,
timeoutUntil: mRow.timeoutUntil,
status: null,
createdAt: mRow.createdAt,
updatedAt: mRow.updatedAt,
deletedAt: mRow.deletedAt,
chatRoom: null,
);
}).toList();
final membersRows = await (db.select(
db.chatMembers,
)..where((m) => m.chatRoomId.equals(localRoomData.id))).get();
final members = membersRows.map((mRow) {
final account = SnAccount.fromJson(mRow.account);
return SnChatMember(
id: mRow.id,
chatRoomId: mRow.chatRoomId,
accountId: mRow.accountId,
account: account,
nick: mRow.nick,
notify: mRow.notify,
joinedAt: mRow.joinedAt,
breakUntil: mRow.breakUntil,
timeoutUntil: mRow.timeoutUntil,
status: null,
createdAt: mRow.createdAt,
updatedAt: mRow.updatedAt,
deletedAt: mRow.deletedAt,
chatRoom: null,
);
}).toList();
final localRoom = SnChatRoom(
id: localRoomData.id,
@@ -229,14 +258,12 @@ class ChatRoomNotifier extends _$ChatRoomNotifier {
type: localRoomData.type,
isPublic: localRoomData.isPublic!,
isCommunity: localRoomData.isCommunity!,
picture:
localRoomData.picture != null
? SnCloudFile.fromJson(localRoomData.picture!)
: null,
background:
localRoomData.background != null
? SnCloudFile.fromJson(localRoomData.background!)
: null,
picture: localRoomData.picture != null
? SnCloudFile.fromJson(localRoomData.picture!)
: null,
background: localRoomData.background != null
? SnCloudFile.fromJson(localRoomData.background!)
: null,
realmId: localRoomData.realmId,
accountId: localRoomData.accountId,
realm: null,

View File

@@ -34,7 +34,7 @@ final class ChatRoomJoinedNotifierProvider
}
String _$chatRoomJoinedNotifierHash() =>
r'c8092225ba0d9c08b2b5bca6f800f1877303b4ff';
r'65961aac28b5188900c4b25308f6fd080a14d5ab';
abstract class _$ChatRoomJoinedNotifier
extends $AsyncNotifier<List<SnChatRoom>> {

View File

@@ -14,208 +14,15 @@ import 'package:island/services/event_bus.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/chat_room_widgets.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/navigation/fab_menu.dart';
import 'package:island/widgets/response.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:relative_time/relative_time.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
import 'package:island/pods/chat/chat_room.dart';
class ChatRoomListTile extends HookConsumerWidget {
final SnChatRoom room;
final bool isDirect;
final Widget? subtitle;
final Widget? trailing;
final VoidCallback? onTap;
const ChatRoomListTile({
super.key,
required this.room,
this.isDirect = false,
this.subtitle,
this.trailing,
this.onTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final summary = ref
.watch(chatSummaryProvider)
.whenData((summaries) => summaries[room.id]);
var validMembers = room.members ?? [];
if (validMembers.isNotEmpty) {
final userInfo = ref.watch(userInfoProvider);
if (userInfo.value != null) {
validMembers = validMembers
.where((e) => e.accountId != userInfo.value!.id)
.toList();
}
}
Widget buildSubtitle() {
if (subtitle != null) return subtitle!;
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
layoutBuilder: (currentChild, previousChildren) => Stack(
alignment: Alignment.centerLeft,
children: [
...previousChildren,
if (currentChild != null) currentChild,
],
),
child: summary.when(
data: (data) => Container(
key: const ValueKey('data'),
child: data == null
? isDirect && room.description == null
? Text(
validMembers
.map((e) => '@${e.account.name}')
.join(', '),
maxLines: 1,
)
: Text(
room.description ?? 'descriptionNone'.tr(),
maxLines: 1,
)
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (data.unreadCount > 0)
Text(
'unreadMessages'.plural(data.unreadCount),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
if (data.lastMessage == null)
Text(
room.description ?? 'descriptionNone'.tr(),
maxLines: 1,
)
else
Row(
spacing: 4,
children: [
Badge(
label: Text(
data.lastMessage!.sender.account.nick,
),
textColor: Theme.of(
context,
).colorScheme.onPrimary,
backgroundColor: Theme.of(
context,
).colorScheme.primary,
),
Expanded(
child: Text(
(data.lastMessage!.content?.isNotEmpty ?? false)
? data.lastMessage!.content!
: 'messageNone'.tr(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
),
Align(
alignment: Alignment.centerRight,
child: Text(
RelativeTime(
context,
).format(data.lastMessage!.createdAt),
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
],
),
),
loading: () => Container(
key: const ValueKey('loading'),
child: Builder(
builder: (context) {
final seed = DateTime.now().microsecondsSinceEpoch;
final len = 4 + (seed % 17); // 4..20 inclusive
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
var s = seed;
final buffer = StringBuffer();
for (var i = 0; i < len; i++) {
s = (s * 1103515245 + 12345) & 0x7fffffff;
buffer.write(chars[s % chars.length]);
}
return Skeletonizer(
enabled: true,
child: Text(buffer.toString()),
);
},
),
),
error: (_, _) => Container(
key: const ValueKey('error'),
child: isDirect && room.description == null
? Text(
validMembers.map((e) => '@${e.account.name}').join(', '),
maxLines: 1,
)
: Text(room.description ?? 'descriptionNone'.tr(), maxLines: 1),
),
),
);
}
String titleText;
if (isDirect && room.name == null) {
if (room.members?.isNotEmpty ?? false) {
titleText = validMembers.map((e) => e.account.nick).join(', ');
} else {
titleText = 'Direct Message';
}
} else {
titleText = room.name ?? '';
}
return ListTile(
leading: Badge(
isLabelVisible: summary.when(
data: (data) => (data?.unreadCount ?? 0) > 0,
loading: () => false,
error: (_, _) => false,
),
child: (isDirect && room.picture?.id == null)
? SplitAvatarWidget(
filesId: validMembers
.map((e) => e.account.profile.picture?.id)
.toList(),
)
: room.picture?.id == null
? CircleAvatar(child: Text(room.name![0].toUpperCase()))
: ProfilePictureWidget(fileId: room.picture?.id),
),
title: Text(titleText),
subtitle: buildSubtitle(),
trailing: trailing, // Add this line
onTap: () async {
// Clear unread count if there are unread messages
ref.read(chatSummaryProvider.future).then((summary) {
if ((summary[room.id]?.unreadCount ?? 0) > 0) {
ref.read(chatSummaryProvider.notifier).clearUnreadCount(room.id);
}
});
onTap?.call();
},
);
}
}
class ChatListBodyWidget extends HookConsumerWidget {
final bool isFloating;
final TabController tabController;
@@ -648,3 +455,75 @@ class _ChatInvitesSheet extends HookConsumerWidget {
);
}
}
class ChatRoomListTile extends HookConsumerWidget {
final SnChatRoom room;
final bool isDirect;
final Widget? subtitle;
final Widget? trailing;
final VoidCallback? onTap;
const ChatRoomListTile({
super.key,
required this.room,
this.isDirect = false,
this.subtitle,
this.trailing,
this.onTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final summary = ref
.watch(chatSummaryProvider)
.whenData((summaries) => summaries[room.id]);
var validMembers = room.members ?? [];
if (validMembers.isNotEmpty) {
final userInfo = ref.watch(userInfoProvider);
if (userInfo.value != null) {
validMembers = validMembers
.where((e) => e.accountId != userInfo.value!.id)
.toList();
}
}
String titleText;
if (isDirect && room.name == null) {
if (room.members?.isNotEmpty ?? false) {
titleText = validMembers.map((e) => e.account.nick).join(', ');
} else {
titleText = 'Direct Message';
}
} else {
titleText = room.name ?? '';
}
return ListTile(
leading: ChatRoomAvatar(
room: room,
isDirect: isDirect,
summary: summary,
validMembers: validMembers,
),
title: Text(titleText),
subtitle: ChatRoomSubtitle(
room: room,
isDirect: isDirect,
validMembers: validMembers,
summary: summary,
subtitle: subtitle,
),
trailing: trailing, // Add this line
onTap: () async {
// Clear unread count if there are unread messages
ref.read(chatSummaryProvider.future).then((summary) {
if ((summary[room.id]?.unreadCount ?? 0) > 0) {
ref.read(chatSummaryProvider.notifier).clearUnreadCount(room.id);
}
});
onTap?.call();
},
);
}
}

View File

@@ -0,0 +1,201 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/chat.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:relative_time/relative_time.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:easy_localization/easy_localization.dart';
class ChatRoomAvatar extends StatelessWidget {
final SnChatRoom room;
final bool isDirect;
final AsyncValue<SnChatSummary?> summary;
final List<SnChatMember> validMembers;
const ChatRoomAvatar({
super.key,
required this.room,
required this.isDirect,
required this.summary,
required this.validMembers,
});
@override
Widget build(BuildContext context) {
final avatarChild = (isDirect && room.picture?.id == null)
? SplitAvatarWidget(
filesId: validMembers
.map((e) => e.account.profile.picture?.id)
.toList(),
)
: room.picture?.id == null
? CircleAvatar(child: Text((room.name ?? 'DM')[0].toUpperCase()))
: ProfilePictureWidget(fileId: room.picture?.id);
final badgeChild = Badge(
isLabelVisible: summary.when(
data: (data) => (data?.unreadCount ?? 0) > 0,
loading: () => false,
error: (_, _) => false,
),
child: avatarChild,
);
// Show realm avatar as small overlay if chat belongs to a realm
if (room.realm != null) {
return Stack(
children: [
badgeChild,
Positioned(
bottom: 0,
right: 0,
child: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.25),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: ClipOval(
child: ProfilePictureWidget(file: room.realm!.picture),
),
),
),
],
);
}
return badgeChild;
}
}
class ChatRoomSubtitle extends StatelessWidget {
final SnChatRoom room;
final bool isDirect;
final List<SnChatMember> validMembers;
final AsyncValue<SnChatSummary?> summary;
final Widget? subtitle;
const ChatRoomSubtitle({
super.key,
required this.room,
required this.isDirect,
required this.validMembers,
required this.summary,
this.subtitle,
});
@override
Widget build(BuildContext context) {
if (subtitle != null) return subtitle!;
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
layoutBuilder: (currentChild, previousChildren) => Stack(
alignment: Alignment.centerLeft,
children: [...previousChildren, if (currentChild != null) currentChild],
),
child: summary.when(
data: (data) => Container(
key: const ValueKey('data'),
child: data == null
? isDirect && room.description == null
? Text(
validMembers
.map((e) => '@${e.account.name}')
.join(', '),
maxLines: 1,
)
: Text(
room.description ?? 'descriptionNone'.tr(),
maxLines: 1,
)
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (data.unreadCount > 0)
Text(
'unreadMessages'.plural(data.unreadCount),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
if (data.lastMessage == null)
Text(
room.description ?? 'descriptionNone'.tr(),
maxLines: 1,
)
else
Row(
spacing: 4,
children: [
Badge(
label: Text(data.lastMessage!.sender.account.nick),
textColor: Theme.of(context).colorScheme.onPrimary,
backgroundColor: Theme.of(
context,
).colorScheme.primary,
),
Expanded(
child: Text(
(data.lastMessage!.content?.isNotEmpty ?? false)
? data.lastMessage!.content!
: 'messageNone'.tr(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
),
Align(
alignment: Alignment.centerRight,
child: Text(
RelativeTime(
context,
).format(data.lastMessage!.createdAt),
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
],
),
),
loading: () => Container(
key: const ValueKey('loading'),
child: Builder(
builder: (context) {
final seed = DateTime.now().microsecondsSinceEpoch;
final len = 4 + (seed % 17); // 4..20 inclusive
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
var s = seed;
final buffer = StringBuffer();
for (var i = 0; i < len; i++) {
s = (s * 1103515245 + 12345) & 0x7fffffff;
buffer.write(chars[s % chars.length]);
}
return Skeletonizer(
enabled: true,
child: Text(buffer.toString()),
);
},
),
),
error: (_, _) => Container(
key: const ValueKey('error'),
child: isDirect && room.description == null
? Text(
validMembers.map((e) => '@${e.account.name}').join(', '),
maxLines: 1,
)
: Text(room.description ?? 'descriptionNone'.tr(), maxLines: 1),
),
),
);
}
}

View File

@@ -14,7 +14,7 @@ import 'package:island/pods/chat/chat_summary.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/route.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/chat_room_widgets.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:relative_time/relative_time.dart';
import 'package:styled_widget/styled_widget.dart';
@@ -698,22 +698,11 @@ class _ChatRoomSearchResult extends HookConsumerWidget {
borderRadius: const BorderRadius.all(Radius.circular(24)),
),
child: ListTile(
leading: Badge(
isLabelVisible: summary.maybeWhen(
data: (data) => (data?.unreadCount ?? 0) > 0,
orElse: () => false,
),
child: (isDirect && room.picture?.id == null)
? SplitAvatarWidget(
filesId: validMembers
.map((e) => e.account.profile.picture?.id)
.toList(),
)
: room.picture?.id == null
? CircleAvatar(child: Text((room.name ?? 'DM')[0].toUpperCase()))
: ProfilePictureWidget(
fileId: room.picture?.id,
), // Placeholder for now
leading: ChatRoomAvatar(
room: room,
isDirect: isDirect,
summary: summary,
validMembers: validMembers,
),
title: Text(titleText),
subtitle: buildSubtitle(),

View File

@@ -2635,6 +2635,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.2"
snow_fall_animation:
dependency: "direct main"
description:
name: snow_fall_animation
sha256: "41b97ec1e5c47aeb5886924346f95b1b12e917b85b03d394c925e98a67d8f90e"
url: "https://pub.dev"
source: hosted
version: "0.0.1+3"
source_gen:
dependency: transitive
description:

View File

@@ -172,6 +172,7 @@ dependencies:
hotkey_manager: ^0.2.3
shake: ^3.0.0
in_app_review: ^2.0.11
snow_fall_animation: ^0.0.1+3
dev_dependencies:
flutter_test: