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
+436 -18
View File
@@ -73,17 +73,67 @@ class AppLocalizationsEn extends AppLocalizations {
String get groupsLeftBanner =>
'You\'re no longer in this group. Your history stays, but new messages won\'t arrive.';
@override
String groupsMembersWithCount(int count) {
return 'Members ($count)';
}
@override
String get groupsYou => 'You';
@override
String get groupsFounder => 'Founder';
@override
String get groupsAdmin => 'Admin';
@override
String get groupsMakeAdmin => 'Make admin';
@override
String get groupsDismissAdmin => 'Remove admin role';
@override
String get groupsRemoveMember => 'Remove from group';
@override
String groupsRemoveMemberConfirmTitle(String name) {
return 'Remove $name from the group?';
}
@override
String get groupsRemoveMemberConfirmBody =>
'They will stop getting new messages from this group. Their existing history stays.';
@override
String get groupsRemoveAction => 'Remove';
@override
String get groupsViewContact => 'View contact';
@override
String get groupsAdminOnlyHint =>
'Only admins can change the group name, members and admins.';
@override
String get groupsAdminOnlyCompatHint =>
'Only admins can rename the group or change admins. Anyone can still add people, because someone in the group has an older version of Šepot.';
@override
String groupsAdminActionFailed(String error) {
return 'The change failed: $error';
}
@override
String get groupsResyncNotice =>
'The group is catching up with its admin. Member changes may show up with a delay.';
@override
String get groupsRejectedChangeNotice =>
'Someone without permission tried to change the group. The change was not applied.';
@override
String get errorGroupNotAdmin => 'Only a group admin can do this.';
@override
String get errorGroupLastAdmin =>
'You\'re the only admin. Make someone else an admin first.';
@override
String get groupsNameHint => 'Group name';
@@ -190,6 +240,28 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get chatActionReply => 'Reply';
@override
String get chatActionForward => 'Forward';
@override
String get forwardTitle => 'Forward to…';
@override
String get forwardSearchHint => 'Search contact or group';
@override
String get forwardEmpty => 'Nothing here';
@override
String forwardDone(String name) {
return 'Forwarded to $name';
}
@override
String forwardFailed(String error) {
return 'Forwarding failed: $error';
}
@override
String get commonCopy => 'Copy';
@@ -246,6 +318,16 @@ class AppLocalizationsEn extends AppLocalizations {
return 'File is too large (max $maxMb MB)';
}
@override
String get attachmentKeepsMetadataTitle => 'Photo will include location';
@override
String get attachmentKeepsMetadataBody =>
'I can\'t remove metadata from this photo format (e.g. HEIC). It would be sent as the original, including where it was taken and details about the phone. Sending it via Gallery is safer, as metadata is removed there.';
@override
String get attachmentSendOriginal => 'Send original';
@override
String attachmentPickFailed(String error) {
return 'Picking attachment failed: $error';
@@ -290,6 +372,10 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get errorGroupNeedsMember => 'A group needs at least 1 member';
@override
String get errorAttachmentUnavailable =>
'The attachment is no longer on this phone';
@override
String errorGroupMaxMembers(int maxMembers) {
return 'A group can have at most $maxMembers members';
@@ -596,19 +682,100 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get chatListNewConversation => 'New conversation';
@override
String chatListPhotoWithCaptionPreview(String caption) {
return '📷 $caption';
}
@override
String chatListFilePreview(String fileName) {
return '📎 $fileName';
}
@override
String get chatListReadReceiptPreview => 'Read';
@override
String get chatListDraft => 'Draft';
@override
String get notesTitle => 'Note to self';
@override
String get notesSubtitle => 'Only on this phone, encrypted';
@override
String get callVoice => 'Call';
@override
String get callVideo => 'Video call';
@override
String get callStatusRinging => 'Ringing…';
@override
String get callIncomingVoice => 'Incoming call';
@override
String get callIncomingVideo => 'Incoming video call';
@override
String get callStatusConnecting => 'Connecting…';
@override
String get callEnded => 'Call ended';
@override
String get callEndedBusy => 'Busy';
@override
String get callEndedRejected => 'Call declined';
@override
String get callEndedUnanswered => 'No answer';
@override
String get callEndedFailed => 'Couldn\'t connect';
@override
String get callEncrypted => 'End-to-end encrypted';
@override
String get callAccept => 'Accept';
@override
String get callReject => 'Decline';
@override
String get callMute => 'Mute';
@override
String get callSpeaker => 'Speaker';
@override
String get callCamera => 'Turn off camera';
@override
String get callSwitchCamera => 'Switch camera';
@override
String get callHangUp => 'Hang up';
@override
String get callLogMissed => 'Missed call';
@override
String get callLogUnanswered => 'Unanswered call';
@override
String get callLogRejected => 'Declined call';
@override
String get callLogBusy => 'Busy';
@override
String get callLogFailed => 'Call failed';
@override
String callLogVoice(String duration) {
return 'Call · $duration';
}
@override
String callLogVideo(String duration) {
return 'Video call · $duration';
}
@override
String get chatListTimeNow => 'now';
@@ -941,14 +1108,14 @@ class AppLocalizationsEn extends AppLocalizations {
String get pushModeOffDesc => 'Messages arrive only after you open the app.';
@override
String get pushModeUp => 'UnifiedPush (recommended)';
String get pushModeUp => 'UnifiedPush (no Google)';
@override
String get pushModeUpDesc =>
'De-Googled — needs the ntfy app (F-Droid/Play).';
@override
String get pushModeFcm => 'Google (FCM)';
String get pushModeFcm => 'Google (FCM, default)';
@override
String get pushModeFcmDesc =>
@@ -993,7 +1160,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get pushNoDistributorFallback => 'no distributor';
@override
String get pushSubtitleFcm => 'Google (FCM) — fast, opt-in';
String get pushSubtitleFcm => 'Google (FCM)';
@override
String get restoreErrorNameRequired => 'Enter a display name.';
@@ -1297,4 +1464,255 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get photoPreviewDecodeFailed => 'Couldn\'t load the photo';
@override
String get chatActionInfo => 'Message info';
@override
String get messageInfoSent => 'Sent';
@override
String get messageInfoDelivered => 'Delivered';
@override
String get messageInfoRead => 'Read';
@override
String get messageInfoPending => 'not yet';
@override
String get messageInfoReadReceiptsOff =>
'You only see when people read your messages if you share read receipts yourself (Settings).';
@override
String chatStatusReadAt(String time) {
return 'Read $time';
}
@override
String chatStatusDeliveredAt(String time) {
return 'Delivered $time';
}
@override
String get groupsAddMembers => 'Add members';
@override
String get groupsAddMembersAction => 'Add';
@override
String groupsAddMembersFailed(String error) {
return 'Could not add members: $error';
}
@override
String get groupsNoContactsToAdd =>
'All your contacts are already in this group. Add someone new to your contacts via QR code first.';
@override
String get navChats => 'Chats';
@override
String get chatListNewChat => 'New chat';
@override
String get chatListSearchHint => 'Search chats and messages';
@override
String get chatFilterAll => 'All';
@override
String get chatFilterUnread => 'Unread';
@override
String get chatFilterGroups => 'Groups';
@override
String get chatFilterArchive => 'Archive';
@override
String get chatListNoResults => 'Nothing found.';
@override
String get chatListFilterEmpty => 'Nothing here yet.';
@override
String get chatListPin => 'Pin';
@override
String get chatListUnpin => 'Unpin';
@override
String get chatOpenProfile => 'View profile';
@override
String get groupsProfile => 'Group info';
@override
String get chatSearchHint => 'Search in chat';
@override
String get chatSearchNoMatches => 'None';
@override
String chatSearchCounter(int current, int total) {
return '$current of $total';
}
@override
String get chatSearchOlder => 'Older match';
@override
String get chatSearchNewer => 'Newer match';
@override
String chatUnreadDivider(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count new messages',
one: '1 new message',
);
return '$_temp0';
}
@override
String get chatJumpToLatest => 'Jump to latest';
@override
String get chatAttach => 'Attach';
@override
String get chatSend => 'Send';
@override
String get chatRecordVoice => 'Record voice message';
@override
String get profileSharedMedia => 'Shared media';
@override
String get profileMessage => 'Message';
@override
String get profileSearch => 'Search';
@override
String get profileUnmute => 'Unmute';
@override
String get profileDisappearing => 'Disappear';
@override
String get profileCommonGroups => 'Groups in common';
@override
String get profileSafetyNumber => 'Safety number';
@override
String get profileSafetyNumberHint => 'Compare in person or by QR code';
@override
String get profileVerifiedInPerson => 'Verified in person';
@override
String get profileVerifyNow => 'Verify contact';
@override
String get profileAdd => 'Add';
@override
String profileGroupSubtitle(String members, String date) {
return 'Group · $members · created $date';
}
@override
String profileMembersOfMax(int count, int max) {
return '$count of $max';
}
@override
String get chatTyping => 'typing…';
@override
String chatTypingNamed(String name) {
return '$name is typing…';
}
@override
String get chatTypingSeveral => 'Several people are typing…';
@override
String get settingsTypingTitle => 'Typing indicators';
@override
String get settingsTypingSubtitle =>
'Mutual — if you don\'t share it, you don\'t see it (off by default)';
@override
String get pollAttach => 'Poll';
@override
String get pollCreateTitle => 'New poll';
@override
String get pollQuestion => 'Question';
@override
String pollOptionHint(int number) {
return 'Option $number';
}
@override
String get pollAddOption => 'Add option';
@override
String get pollRemoveOption => 'Remove option';
@override
String get pollAllowMultiple => 'Allow multiple answers';
@override
String get pollSingle => 'Poll';
@override
String get pollMulti => 'Poll, multiple choice';
@override
String pollVoters(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count votes',
one: '1 vote',
zero: 'no votes yet',
);
return '$_temp0';
}
@override
String pollFallback(String question) {
return 'Poll: $question';
}
@override
String get pollFallbackUpdate => 'Update Šepot to vote.';
@override
String get profilePhotoEdit => 'Change profile photo';
@override
String get profilePhotoPick => 'Choose photo';
@override
String get profilePhotoRemove => 'Remove photo';
@override
String get profilePhotoHint =>
'Only people you chat with will see it — end-to-end encrypted like your messages. No location or photo metadata is sent.';
@override
String profilePhotoFailed(String error) {
return 'Could not set photo: $error';
}
}