Š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:
Julius Joska
2026-07-11 17:06:39 +02:00
commit 8081d98c67
309 changed files with 44102 additions and 0 deletions
@@ -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);
}
}