feat(app): redesigned interface, polls, drafts, forwarding and mentions

A new look across the app and a set of everyday messaging features.

Interface
- Home screen with Chats, Contacts and Settings tabs, a large title,
  search across chats and message text (diacritics-insensitive), filters
  and pinned chats.
- Conversation view with a translucent header and composer, grouped
  bubbles, author avatars and colours in groups, swipe to reply, an
  "N new messages" marker, a jump-to-latest button and in-chat search.
- Contact and group profiles: verification, shared photos, groups in
  common, disappearing messages, mute, block; members, admin badges,
  adding people, rename and leave for groups.
- Full-screen photo viewer that pages through the whole conversation.
- A stable avatar colour per person and a clearly visible read state.

Messaging
- Polls in chats and groups, single or multiple choice, with votes that
  can be changed or withdrawn; older clients see them as text.
- Drafts survive leaving a chat and show in the chat list.
- Forwarding of messages, photos, files and voice messages.
- Notes to self: a local-only, encrypted chat that never reaches the
  relay.
- @mentions in groups that notify even when the group is muted.
- Typing indicators sent as ephemeral messages, opt-in and mutual like
  read receipts.
- Delivery and read times per message and per group member.
- Profile photos, cropped, resized and stripped of metadata, delivered
  encrypted to contacts.
- Voice message playback at 1.5x and 2x.
- Call buttons in 1:1 chats and call log entries.

Fixes
- Muted groups no longer notify: muting was checked against the
  author's chat instead of the group.
- A blocked contact could unblock itself by changing its delivery
  address.
- Read receipts no longer regress to delivered when a late delivery
  receipt arrives.
- A message marked as failed corrects itself once it is delivered.
- Double ticks in groups now mean delivered to every member.
This commit is contained in:
Julius Joska
2026-10-03 22:44:53 +02:00
parent 933a74c900
commit 5246a97db2
56 changed files with 12028 additions and 1668 deletions
+437 -18
View File
@@ -73,17 +73,67 @@ class AppLocalizationsDe extends AppLocalizations {
String get groupsLeftBanner =>
'Du bist nicht mehr in der Gruppe. Der Verlauf bleibt, neue Nachrichten kommen nicht mehr an.';
@override
String groupsMembersWithCount(int count) {
return 'Mitglieder ($count)';
}
@override
String get groupsYou => 'Du';
@override
String get groupsFounder => 'Hat die Gruppe gegründet';
@override
String get groupsAdmin => 'Admin';
@override
String get groupsMakeAdmin => 'Zum Admin machen';
@override
String get groupsDismissAdmin => 'Adminrolle entziehen';
@override
String get groupsRemoveMember => 'Aus der Gruppe entfernen';
@override
String groupsRemoveMemberConfirmTitle(String name) {
return '$name aus der Gruppe entfernen?';
}
@override
String get groupsRemoveMemberConfirmBody =>
'Neue Nachrichten der Gruppe kommen dann nicht mehr an. Der bisherige Verlauf bleibt erhalten.';
@override
String get groupsRemoveAction => 'Entfernen';
@override
String get groupsViewContact => 'Kontakt anzeigen';
@override
String get groupsAdminOnlyHint =>
'Nur Admins können Namen, Mitglieder und Admins der Gruppe ändern.';
@override
String get groupsAdminOnlyCompatHint =>
'Nur Admins können die Gruppe umbenennen oder Admins ändern. Personen hinzufügen kann vorerst jeder, weil jemand in der Gruppe eine ältere Šepot-Version hat.';
@override
String groupsAdminActionFailed(String error) {
return 'Änderung fehlgeschlagen: $error';
}
@override
String get groupsResyncNotice =>
'Die Gruppe gleicht sich mit dem Admin ab. Änderungen an Mitgliedern können sich kurz verzögern.';
@override
String get groupsRejectedChangeNotice =>
'Jemand ohne Berechtigung hat versucht, die Gruppe zu ändern. Die Änderung wurde nicht übernommen.';
@override
String get errorGroupNotAdmin => 'Das kann nur ein Admin der Gruppe.';
@override
String get errorGroupLastAdmin =>
'Du bist der einzige Admin. Mach zuerst jemand anderen zum Admin.';
@override
String get groupsNameHint => 'Gruppenname';
@@ -191,6 +241,28 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get chatActionReply => 'Antworten';
@override
String get chatActionForward => 'Weiterleiten';
@override
String get forwardTitle => 'Weiterleiten an…';
@override
String get forwardSearchHint => 'Kontakt oder Gruppe suchen';
@override
String get forwardEmpty => 'Nichts gefunden';
@override
String forwardDone(String name) {
return 'Weitergeleitet an $name';
}
@override
String forwardFailed(String error) {
return 'Weiterleiten fehlgeschlagen: $error';
}
@override
String get commonCopy => 'Kopieren';
@@ -248,6 +320,16 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Datei ist zu groß (max. $maxMb MB)';
}
@override
String get attachmentKeepsMetadataTitle => 'Foto enthält den Standort';
@override
String get attachmentKeepsMetadataBody =>
'Aus diesem Fotoformat (z. B. HEIC) kann ich die Metadaten nicht entfernen. Es würde im Original gesendet, einschließlich Aufnahmeort und Angaben zum Telefon. Sicherer ist das Senden über die Galerie, dort werden die Metadaten entfernt.';
@override
String get attachmentSendOriginal => 'Original senden';
@override
String attachmentPickFailed(String error) {
return 'Auswahl des Anhangs fehlgeschlagen: $error';
@@ -294,6 +376,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get errorGroupNeedsMember =>
'Die Gruppe braucht mindestens 1 Mitglied';
@override
String get errorAttachmentUnavailable =>
'Der Anhang ist nicht mehr auf diesem Telefon';
@override
String errorGroupMaxMembers(int maxMembers) {
return 'Die Gruppe kann höchstens $maxMembers Mitglieder haben';
@@ -604,19 +690,100 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get chatListNewConversation => 'Neue Unterhaltung';
@override
String chatListPhotoWithCaptionPreview(String caption) {
return '📷 $caption';
}
@override
String chatListFilePreview(String fileName) {
return '📎 $fileName';
}
@override
String get chatListReadReceiptPreview => 'Gelesen';
@override
String get chatListDraft => 'Entwurf';
@override
String get notesTitle => 'Notizen für mich';
@override
String get notesSubtitle => 'Nur auf diesem Telefon, verschlüsselt';
@override
String get callVoice => 'Anrufen';
@override
String get callVideo => 'Videoanruf';
@override
String get callStatusRinging => 'Klingelt…';
@override
String get callIncomingVoice => 'Eingehender Anruf';
@override
String get callIncomingVideo => 'Eingehender Videoanruf';
@override
String get callStatusConnecting => 'Verbinde…';
@override
String get callEnded => 'Anruf beendet';
@override
String get callEndedBusy => 'Besetzt';
@override
String get callEndedRejected => 'Anruf abgelehnt';
@override
String get callEndedUnanswered => 'Keine Antwort';
@override
String get callEndedFailed => 'Verbindung fehlgeschlagen';
@override
String get callEncrypted => 'Ende-zu-Ende-verschlüsselt';
@override
String get callAccept => 'Annehmen';
@override
String get callReject => 'Ablehnen';
@override
String get callMute => 'Stummschalten';
@override
String get callSpeaker => 'Lautsprecher';
@override
String get callCamera => 'Kamera aus';
@override
String get callSwitchCamera => 'Kamera wechseln';
@override
String get callHangUp => 'Auflegen';
@override
String get callLogMissed => 'Verpasster Anruf';
@override
String get callLogUnanswered => 'Unbeantworteter Anruf';
@override
String get callLogRejected => 'Abgelehnter Anruf';
@override
String get callLogBusy => 'Besetzt';
@override
String get callLogFailed => 'Anruf fehlgeschlagen';
@override
String callLogVoice(String duration) {
return 'Anruf · $duration';
}
@override
String callLogVideo(String duration) {
return 'Videoanruf · $duration';
}
@override
String get chatListTimeNow => 'jetzt';
@@ -962,14 +1129,14 @@ class AppLocalizationsDe extends AppLocalizations {
'Nachrichten werden erst beim Öffnen der App abgerufen.';
@override
String get pushModeUp => 'UnifiedPush (empfohlen)';
String get pushModeUp => 'UnifiedPush (ohne Google)';
@override
String get pushModeUpDesc =>
'Google-frei — braucht die App ntfy (F-Droid/Play).';
@override
String get pushModeFcm => 'Google (FCM)';
String get pushModeFcm => 'Google (FCM, Standard)';
@override
String get pushModeFcmDesc =>
@@ -1014,7 +1181,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get pushNoDistributorFallback => 'kein Distributor';
@override
String get pushSubtitleFcm => 'Google (FCM) — schnell, opt-in';
String get pushSubtitleFcm => 'Google (FCM)';
@override
String get restoreErrorNameRequired => 'Gib einen Anzeigenamen ein.';
@@ -1324,4 +1491,256 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get photoPreviewDecodeFailed => 'Foto konnte nicht geladen werden';
@override
String get chatActionInfo => 'Nachrichteninfo';
@override
String get messageInfoSent => 'Gesendet';
@override
String get messageInfoDelivered => 'Zugestellt';
@override
String get messageInfoRead => 'Gelesen';
@override
String get messageInfoPending => 'noch nicht';
@override
String get messageInfoReadReceiptsOff =>
'Wann deine Nachrichten gelesen wurden, siehst du nur, wenn du selbst Lesebestätigungen teilst (Einstellungen).';
@override
String chatStatusReadAt(String time) {
return 'Gelesen $time';
}
@override
String chatStatusDeliveredAt(String time) {
return 'Zugestellt $time';
}
@override
String get groupsAddMembers => 'Mitglieder hinzufügen';
@override
String get groupsAddMembersAction => 'Hinzufügen';
@override
String groupsAddMembersFailed(String error) {
return 'Hinzufügen fehlgeschlagen: $error';
}
@override
String get groupsNoContactsToAdd =>
'Alle deine Kontakte sind schon in der Gruppe. Füge neue Personen zuerst per QR-Code zu deinen Kontakten hinzu.';
@override
String get navChats => 'Chats';
@override
String get chatListNewChat => 'Neuer Chat';
@override
String get chatListSearchHint => 'Chats und Nachrichten durchsuchen';
@override
String get chatFilterAll => 'Alle';
@override
String get chatFilterUnread => 'Ungelesen';
@override
String get chatFilterGroups => 'Gruppen';
@override
String get chatFilterArchive => 'Archiv';
@override
String get chatListNoResults => 'Nichts gefunden.';
@override
String get chatListFilterEmpty => 'Hier ist noch nichts.';
@override
String get chatListPin => 'Anheften';
@override
String get chatListUnpin => 'Lösen';
@override
String get chatOpenProfile => 'Profil anzeigen';
@override
String get groupsProfile => 'Gruppeninfo';
@override
String get chatSearchHint => 'Im Chat suchen';
@override
String get chatSearchNoMatches => 'Keine';
@override
String chatSearchCounter(int current, int total) {
return '$current von $total';
}
@override
String get chatSearchOlder => 'Ältere Treffer';
@override
String get chatSearchNewer => 'Neuerer Treffer';
@override
String chatUnreadDivider(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count neue Nachrichten',
one: '1 neue Nachricht',
);
return '$_temp0';
}
@override
String get chatJumpToLatest => 'Zu den neuesten';
@override
String get chatAttach => 'Anhängen';
@override
String get chatSend => 'Senden';
@override
String get chatRecordVoice => 'Sprachnachricht aufnehmen';
@override
String get profileSharedMedia => 'Geteilte Medien';
@override
String get profileMessage => 'Nachricht';
@override
String get profileSearch => 'Suchen';
@override
String get profileUnmute => 'Ton an';
@override
String get profileDisappearing => 'Verschwinden';
@override
String get profileCommonGroups => 'Gemeinsame Gruppen';
@override
String get profileSafetyNumber => 'Sicherheitsnummer';
@override
String get profileSafetyNumberHint =>
'Persönlich oder per QR-Code vergleichen';
@override
String get profileVerifiedInPerson => 'Persönlich verifiziert';
@override
String get profileVerifyNow => 'Kontakt verifizieren';
@override
String get profileAdd => 'Hinzufügen';
@override
String profileGroupSubtitle(String members, String date) {
return 'Gruppe · $members · erstellt am $date';
}
@override
String profileMembersOfMax(int count, int max) {
return '$count von $max';
}
@override
String get chatTyping => 'schreibt…';
@override
String chatTypingNamed(String name) {
return '$name schreibt…';
}
@override
String get chatTypingSeveral => 'Mehrere schreiben…';
@override
String get settingsTypingTitle => 'Tippindikator';
@override
String get settingsTypingSubtitle =>
'Gegenseitig — wer nicht teilt, sieht nichts (standardmäßig aus)';
@override
String get pollAttach => 'Umfrage';
@override
String get pollCreateTitle => 'Neue Umfrage';
@override
String get pollQuestion => 'Frage';
@override
String pollOptionHint(int number) {
return 'Option $number';
}
@override
String get pollAddOption => 'Option hinzufügen';
@override
String get pollRemoveOption => 'Option entfernen';
@override
String get pollAllowMultiple => 'Mehrere Antworten erlauben';
@override
String get pollSingle => 'Umfrage';
@override
String get pollMulti => 'Umfrage, Mehrfachauswahl';
@override
String pollVoters(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Stimmen',
one: '1 Stimme',
zero: 'noch keine Stimmen',
);
return '$_temp0';
}
@override
String pollFallback(String question) {
return 'Umfrage: $question';
}
@override
String get pollFallbackUpdate => 'Zum Abstimmen Šepot aktualisieren.';
@override
String get profilePhotoEdit => 'Profilbild ändern';
@override
String get profilePhotoPick => 'Foto auswählen';
@override
String get profilePhotoRemove => 'Foto entfernen';
@override
String get profilePhotoHint =>
'Nur deine Chatpartner sehen es — Ende-zu-Ende-verschlüsselt wie Nachrichten. Standort und Fotodaten werden nicht gesendet.';
@override
String profilePhotoFailed(String error) {
return 'Foto konnte nicht gesetzt werden: $error';
}
}