Šepot — end-to-end encrypted messenger
Initial public release. Anonymous on-device identity (no phone number, e-mail, or password), Signal-protocol cryptography (X3DH + PQXDH, Double Ratchet, sealed sender) via libsignal, a metadata-minimizing relay, group chats, disappearing messages, encrypted local storage, optional Tor transport, and a UI localized into Czech, English, German, Russian, and Italian. Monorepo: Flutter client (app/), Rust relay + wire types (rust/), protobuf definitions (proto/), self-host deploy (deploy/). Licensed under AGPL-3.0 with an app-store distribution exception (§7).
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/features/chat_list/chat_list_tile.dart';
|
||||
import 'package:sepot/models/conversation.dart';
|
||||
import 'package:sepot/providers/contacts_provider.dart';
|
||||
import 'package:sepot/providers/conversations_provider.dart';
|
||||
import 'package:sepot/providers/groups_provider.dart';
|
||||
import 'package:sepot/providers/identity_provider.dart';
|
||||
|
||||
/// Archivované konverzace — stejné dlaždice, long-press nabídne odarchivovat.
|
||||
class ArchivedChatsScreen extends ConsumerWidget {
|
||||
const ArchivedChatsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final conversationsAsync = ref.watch(conversationsProvider);
|
||||
final contactsAsync = ref.watch(contactsProvider);
|
||||
final groupsAsync = ref.watch(groupsProvider);
|
||||
final myId = ref.watch(identityProvider).valueOrNull?.id ?? '';
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(context.l10n.archiveTitle)),
|
||||
body: conversationsAsync.when(
|
||||
data: (conversations) {
|
||||
final archived = conversations.where((c) => c.archived).toList();
|
||||
if (archived.isEmpty) {
|
||||
return Center(
|
||||
child: Text(context.l10n.archiveEmpty,
|
||||
style: const TextStyle(color: SepotColors.textSecondary)),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: archived.length,
|
||||
itemBuilder: (context, index) {
|
||||
final conv = archived[index];
|
||||
final group = groupsAsync.valueOrNull
|
||||
?.where((g) => g.id == conv.contactId)
|
||||
.firstOrNull;
|
||||
final contact = contactsAsync.valueOrNull
|
||||
?.where((c) => c.publicIdentity.id == conv.contactId)
|
||||
.firstOrNull;
|
||||
final name = group?.name ??
|
||||
contact?.displayName ??
|
||||
context.l10n.commonUnknownContact;
|
||||
return ChatListTile(
|
||||
conversation: conv,
|
||||
contactName: name,
|
||||
myId: myId,
|
||||
isGroup: group != null,
|
||||
onTap: () => context.push('/conversation/${conv.contactId}'),
|
||||
onLongPress: () => _actions(context, ref, conv),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(color: SepotColors.ember),
|
||||
),
|
||||
error: (e, _) =>
|
||||
Center(child: Text(describeError(context.l10n, e))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _actions(BuildContext context, WidgetRef ref, Conversation conv) {
|
||||
final notifier = ref.read(conversationsProvider.notifier);
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: SepotColors.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.unarchive_outlined),
|
||||
title: Text(context.l10n.archiveUnarchive),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
notifier.setArchived(conv.contactId, false);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/features/chat_list/chat_list_tile.dart';
|
||||
import 'package:sepot/models/conversation.dart';
|
||||
import 'package:sepot/providers/contacts_provider.dart';
|
||||
import 'package:sepot/providers/conversations_provider.dart';
|
||||
import 'package:sepot/providers/groups_provider.dart';
|
||||
import 'package:sepot/providers/identity_provider.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
import 'package:sepot/providers/push_provider.dart';
|
||||
import 'package:sepot/widgets/e2e_badge.dart';
|
||||
|
||||
/// Hlavní obrazovka — seznam konverzací
|
||||
class ChatListScreen extends ConsumerWidget {
|
||||
const ChatListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Nastartuje krypto + spojení s relayem (idempotentní)
|
||||
ref.watch(messagingStartProvider);
|
||||
// Zaregistruje push callbacky (po startu messagingu) — obnoví registraci,
|
||||
// pokud už distributor byl dřív vybraný. Push zapíná uživatel v nastavení.
|
||||
ref.watch(pushInitProvider);
|
||||
final conversationsAsync = ref.watch(conversationsProvider);
|
||||
final contactsAsync = ref.watch(contactsProvider);
|
||||
final groupsAsync = ref.watch(groupsProvider);
|
||||
final identityAsync = ref.watch(identityProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
context.l10n.commonAppName,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 22,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
onPressed: () {
|
||||
// TODO: Implementovat vyhledávání
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
onPressed: () => context.push('/settings'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: conversationsAsync.when(
|
||||
data: (conversations) {
|
||||
final active = conversations.where((c) => !c.archived).toList();
|
||||
final archived = conversations.where((c) => c.archived).toList();
|
||||
if (active.isEmpty && archived.isEmpty) {
|
||||
return _buildEmptyState(context);
|
||||
}
|
||||
final hasArchive = archived.isNotEmpty;
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: active.length + (hasArchive ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (hasArchive && index == 0) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.archive_outlined,
|
||||
color: SepotColors.textMuted),
|
||||
title: Text(context.l10n.chatListArchivedCount(archived.length),
|
||||
style: const TextStyle(color: SepotColors.textSecondary)),
|
||||
onTap: () => context.push('/archived'),
|
||||
);
|
||||
}
|
||||
final conv = active[index - (hasArchive ? 1 : 0)];
|
||||
final group = groupsAsync.valueOrNull
|
||||
?.where((g) => g.id == conv.contactId)
|
||||
.firstOrNull;
|
||||
final contact = contactsAsync.valueOrNull
|
||||
?.where((c) => c.publicIdentity.id == conv.contactId)
|
||||
.firstOrNull;
|
||||
final myId = identityAsync.valueOrNull?.id ?? '';
|
||||
final name = group?.name ??
|
||||
contact?.displayName ??
|
||||
context.l10n.commonUnknownContact;
|
||||
|
||||
return ChatListTile(
|
||||
conversation: conv,
|
||||
contactName: name,
|
||||
myId: myId,
|
||||
isGroup: group != null,
|
||||
onTap: () => context.push('/conversation/${conv.contactId}'),
|
||||
onLongPress: () => _showChatActions(context, ref, conv, name),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(color: SepotColors.ember),
|
||||
),
|
||||
error: (e, _) =>
|
||||
Center(child: Text(describeError(context.l10n, e))),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => context.push('/contacts'),
|
||||
child: const Icon(Icons.chat_bubble_outline_rounded),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(48),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: SepotColors.ember.withValues(alpha: 0.1),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.chat_bubble_outline_rounded,
|
||||
size: 40,
|
||||
color: SepotColors.ember,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
context.l10n.chatListEmptyTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.chatListEmptySubtitle,
|
||||
style: const TextStyle(color: SepotColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const E2EBadge(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Akce nad konverzací (long-press): ztlumit / archivovat.
|
||||
void _showChatActions(
|
||||
BuildContext context, WidgetRef ref, Conversation conv, String name) {
|
||||
final notifier = ref.read(conversationsProvider.notifier);
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: SepotColors.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(conv.isMuted
|
||||
? Icons.notifications_active_outlined
|
||||
: Icons.notifications_off_outlined),
|
||||
title: Text(conv.isMuted
|
||||
? context.l10n.chatListUnmute
|
||||
: context.l10n.chatListMute),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
notifier.setMuted(conv.contactId, !conv.isMuted);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.archive_outlined),
|
||||
title: Text(context.l10n.chatListArchive),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
notifier.setArchived(conv.contactId, true);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/models/conversation.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
import 'package:sepot/widgets/avatar.dart';
|
||||
|
||||
/// Dlaždice konverzace v seznamu chatů. Sdílená hlavním i archivovaným
|
||||
/// seznamem, aby se logika (fajfky, náhled, ztlumení) nerozešla.
|
||||
class ChatListTile extends StatelessWidget {
|
||||
final Conversation conversation;
|
||||
final String contactName;
|
||||
final String myId;
|
||||
final bool isGroup;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onLongPress;
|
||||
|
||||
const ChatListTile({
|
||||
super.key,
|
||||
required this.conversation,
|
||||
required this.contactName,
|
||||
required this.myId,
|
||||
this.isGroup = false,
|
||||
required this.onTap,
|
||||
this.onLongPress,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final lastMsg = conversation.lastMessage;
|
||||
final unread = conversation.unreadCount(myId);
|
||||
final muted = conversation.isMuted;
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
isGroup
|
||||
? Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: SepotColors.surfaceVariant,
|
||||
border: Border.all(color: SepotColors.border),
|
||||
),
|
||||
child: const Icon(Icons.group_rounded,
|
||||
color: SepotColors.ember, size: 24),
|
||||
)
|
||||
: SepotAvatar(name: contactName),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
contactName,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight:
|
||||
unread > 0 ? FontWeight.w700 : FontWeight.w500,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (muted)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 6),
|
||||
child: Icon(Icons.notifications_off,
|
||||
size: 15, color: SepotColors.textMuted),
|
||||
),
|
||||
if (lastMsg != null)
|
||||
Text(
|
||||
_formatTime(context, lastMsg.timestamp),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: unread > 0 && !muted
|
||||
? SepotColors.ember
|
||||
: SepotColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
if (lastMsg != null && lastMsg.isMine(myId))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: Icon(
|
||||
switch (lastMsg.status) {
|
||||
MessageStatus.sending => Icons.schedule,
|
||||
MessageStatus.sent => Icons.done,
|
||||
MessageStatus.delivered => Icons.done_all,
|
||||
MessageStatus.read => Icons.done_all,
|
||||
MessageStatus.failed => Icons.error_outline,
|
||||
},
|
||||
size: 16,
|
||||
color: lastMsg.status == MessageStatus.failed
|
||||
? SepotColors.error
|
||||
: lastMsg.status == MessageStatus.read
|
||||
? SepotColors.mist
|
||||
: SepotColors.textMuted,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_getLastMessageText(context.l10n, lastMsg),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: unread > 0
|
||||
? SepotColors.textPrimary
|
||||
: SepotColors.textSecondary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
if (unread > 0)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
// Ztlumené = šedý badge (počet ano, důraz ne)
|
||||
color: muted
|
||||
? SepotColors.textMuted
|
||||
: SepotColors.ember,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
unread.toString(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _getLastMessageText(AppLocalizations l10n, Message? msg) {
|
||||
if (msg == null) return l10n.chatListNewConversation;
|
||||
if (msg.isDeleted) return l10n.chatMessageDeleted;
|
||||
if (msg.attachment?.isAudio == true) return l10n.chatVoiceMessagePreview;
|
||||
switch (msg.type) {
|
||||
case MessageType.text:
|
||||
return msg.content ?? '';
|
||||
case MessageType.image:
|
||||
return msg.content?.isNotEmpty == true
|
||||
? l10n.chatListPhotoWithCaptionPreview(msg.content!)
|
||||
: l10n.chatPhotoPreview;
|
||||
case MessageType.file:
|
||||
return l10n.chatListFilePreview(msg.fileName ?? l10n.commonFile);
|
||||
case MessageType.readReceipt:
|
||||
return l10n.chatListReadReceiptPreview;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
static String _formatTime(BuildContext context, DateTime time) {
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(time);
|
||||
if (diff.inMinutes < 1) return context.l10n.chatListTimeNow;
|
||||
if (diff.inHours < 1) return context.l10n.chatListTimeMinutes(diff.inMinutes);
|
||||
if (diff.inDays < 1) return DateFormat.Hm(locale).format(time);
|
||||
if (diff.inDays < 7) return DateFormat.E(locale).format(time);
|
||||
return DateFormat.Md(locale).format(time);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user