From dc1bf43a6d5a27984fa6692972b888bc705760a7 Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su Date: Wed, 20 May 2026 13:17:04 -0700 Subject: [PATCH] feat: overhaul private message functionality, add ability to send private messages --- lib/l10n/app_en.arb | 12 ++ lib/l10n/generated/app_localizations.dart | 18 ++ lib/l10n/generated/app_localizations_ar.dart | 9 + lib/l10n/generated/app_localizations_be.dart | 9 + lib/l10n/generated/app_localizations_cs.dart | 9 + lib/l10n/generated/app_localizations_de.dart | 9 + lib/l10n/generated/app_localizations_en.dart | 9 + lib/l10n/generated/app_localizations_eo.dart | 9 + lib/l10n/generated/app_localizations_es.dart | 9 + lib/l10n/generated/app_localizations_fi.dart | 9 + lib/l10n/generated/app_localizations_fr.dart | 9 + lib/l10n/generated/app_localizations_hu.dart | 9 + lib/l10n/generated/app_localizations_it.dart | 9 + lib/l10n/generated/app_localizations_nb.dart | 9 + lib/l10n/generated/app_localizations_nl.dart | 9 + lib/l10n/generated/app_localizations_pl.dart | 9 + lib/l10n/generated/app_localizations_ps.dart | 9 + lib/l10n/generated/app_localizations_pt.dart | 9 + lib/l10n/generated/app_localizations_ru.dart | 9 + lib/l10n/generated/app_localizations_sk.dart | 9 + lib/l10n/generated/app_localizations_sv.dart | 9 + lib/l10n/generated/app_localizations_ta.dart | 9 + lib/l10n/generated/app_localizations_tr.dart | 9 + lib/l10n/generated/app_localizations_uk.dart | 9 + lib/l10n/generated/app_localizations_zh.dart | 9 + .../navigation_private_message.dart | 115 +++++++++++ lib/src/app/wiring/state_factories.dart | 25 +++ .../inbox/presentation/pages/inbox_page.dart | 19 ++ .../inbox/presentation/state/inbox_bloc.dart | 51 ++++- .../inbox/presentation/state/inbox_event.dart | 22 ++ .../inbox_private_message_thread_tile.dart | 83 ++++++++ .../widgets/inbox_private_messages_view.dart | 107 ++-------- lib/src/features/private_message/api.dart | 1 + .../private_message_repository.dart | 101 +++++++++ .../domain/models/private_message_thread.dart | 50 +++++ .../utils/private_message_thread_utils.dart | 86 ++++++++ .../pages/create_private_message_page.dart | 189 +++++++++++++++++ .../pages/private_message_thread_page.dart | 192 ++++++++++++++++++ .../state/create_private_message_cubit.dart | 129 ++++++++++++ .../state/create_private_message_state.dart | 74 +++++++ .../state/private_message_thread_cubit.dart | 138 +++++++++++++ .../state/private_message_thread_state.dart | 75 +++++++ .../create_private_message_bottom_bar.dart | 162 +++++++++++++++ ...create_private_message_editor_section.dart | 63 ++++++ ...create_private_message_recipient_tile.dart | 64 ++++++ .../thread/private_message_bubble.dart | 74 +++++++ .../widgets/thread/quick_reply_bar.dart | 78 +++++++ .../private_message/private_message.dart | 7 + .../widgets/user_action_bottom_sheet.dart | 17 +- .../user_header/user_header_actions.dart | 24 +++ .../presentation/widgets/user_list_entry.dart | 17 ++ .../lemmy/base_lemmy_api_client.dart | 42 +++- .../networking/lemmy/lemmy_v3_api_client.dart | 1 + .../networking/piefed/piefed_api_client.dart | 7 +- .../networking/thunder_api_client.dart | 14 ++ .../models/thunder_private_message.dart | 28 +++ 56 files changed, 2192 insertions(+), 100 deletions(-) create mode 100644 lib/src/app/shell/navigation/navigation_private_message.dart create mode 100644 lib/src/features/inbox/presentation/widgets/inbox_private_message_thread_tile.dart create mode 100644 lib/src/features/private_message/api.dart create mode 100644 lib/src/features/private_message/data/repositories/private_message_repository.dart create mode 100644 lib/src/features/private_message/domain/models/private_message_thread.dart create mode 100644 lib/src/features/private_message/domain/utils/private_message_thread_utils.dart create mode 100644 lib/src/features/private_message/presentation/pages/create_private_message_page.dart create mode 100644 lib/src/features/private_message/presentation/pages/private_message_thread_page.dart create mode 100644 lib/src/features/private_message/presentation/state/create_private_message_cubit.dart create mode 100644 lib/src/features/private_message/presentation/state/create_private_message_state.dart create mode 100644 lib/src/features/private_message/presentation/state/private_message_thread_cubit.dart create mode 100644 lib/src/features/private_message/presentation/state/private_message_thread_state.dart create mode 100644 lib/src/features/private_message/presentation/widgets/create_private_message/create_private_message_bottom_bar.dart create mode 100644 lib/src/features/private_message/presentation/widgets/create_private_message/create_private_message_editor_section.dart create mode 100644 lib/src/features/private_message/presentation/widgets/create_private_message/create_private_message_recipient_tile.dart create mode 100644 lib/src/features/private_message/presentation/widgets/thread/private_message_bubble.dart create mode 100644 lib/src/features/private_message/presentation/widgets/thread/quick_reply_bar.dart create mode 100644 lib/src/features/private_message/private_message.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 172ff28e1..020f86421 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -934,6 +934,10 @@ "@dimReadPosts": { "description": "Description of the effect on read posts." }, + "directMessage": "Direct message", + "@directMessage": { + "description": "Title for the private message compose page" + }, "disable": "Disable", "@disable": { "description": "Action for disabling something" @@ -2613,12 +2617,20 @@ "@selectLanguage": { "description": "The prompt to select a language in the post creation page" }, + "selectRecipient": "Select recipient", + "@selectRecipient": { + "description": "Prompt to choose a private message recipient" + }, "selectSearchType": "Select Search Type", "@selectSearchType": {}, "selectText": "Select Text", "@selectText": { "description": "Post and comment action for selecting text" }, + "send": "Send", + "@send": { + "description": "Action to send content" + }, "sendBackgroundTestLocalNotification": "Send background test local notification", "@sendBackgroundTestLocalNotification": { "description": "The option to send a background notification for testing" diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index a993d5064..b28046ee6 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -1512,6 +1512,12 @@ abstract class AppLocalizations { /// **'Dim Read Posts'** String get dimReadPosts; + /// Title for the private message compose page + /// + /// In en, this message translates to: + /// **'Direct message'** + String get directMessage; + /// Action for disabling something /// /// In en, this message translates to: @@ -4110,6 +4116,12 @@ abstract class AppLocalizations { /// **'Select Language'** String get selectLanguage; + /// Prompt to choose a private message recipient + /// + /// In en, this message translates to: + /// **'Select recipient'** + String get selectRecipient; + /// No description provided for @selectSearchType. /// /// In en, this message translates to: @@ -4122,6 +4134,12 @@ abstract class AppLocalizations { /// **'Select Text'** String get selectText; + /// Action to send content + /// + /// In en, this message translates to: + /// **'Send'** + String get send; + /// The option to send a background notification for testing /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 201f4cfa8..2e5d0b5cf 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -782,6 +782,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get dimReadPosts => 'Dim Read Posts'; + @override + String get directMessage => 'Direct message'; + @override String get disable => 'Disable'; @@ -2259,12 +2262,18 @@ class AppLocalizationsAr extends AppLocalizations { @override String get selectLanguage => 'Select Language'; + @override + String get selectRecipient => 'Select recipient'; + @override String get selectSearchType => 'Select Search Type'; @override String get selectText => 'Select Text'; + @override + String get send => 'Send'; + @override String get sendBackgroundTestLocalNotification => 'Send background test local notification'; diff --git a/lib/l10n/generated/app_localizations_be.dart b/lib/l10n/generated/app_localizations_be.dart index 373269cba..8a1273d7e 100644 --- a/lib/l10n/generated/app_localizations_be.dart +++ b/lib/l10n/generated/app_localizations_be.dart @@ -790,6 +790,9 @@ class AppLocalizationsBe extends AppLocalizations { @override String get dimReadPosts => 'Dim Read Posts'; + @override + String get directMessage => 'Direct message'; + @override String get disable => 'Disable'; @@ -2267,12 +2270,18 @@ class AppLocalizationsBe extends AppLocalizations { @override String get selectLanguage => 'Select Language'; + @override + String get selectRecipient => 'Select recipient'; + @override String get selectSearchType => 'Select Search Type'; @override String get selectText => 'Select Text'; + @override + String get send => 'Send'; + @override String get sendBackgroundTestLocalNotification => 'Send background test local notification'; diff --git a/lib/l10n/generated/app_localizations_cs.dart b/lib/l10n/generated/app_localizations_cs.dart index a723c7c9a..6affd3ba9 100644 --- a/lib/l10n/generated/app_localizations_cs.dart +++ b/lib/l10n/generated/app_localizations_cs.dart @@ -795,6 +795,9 @@ class AppLocalizationsCs extends AppLocalizations { @override String get dimReadPosts => 'Ztmavit Přečtené Příspěvky'; + @override + String get directMessage => 'Direct message'; + @override String get disable => 'Zrušit'; @@ -2265,12 +2268,18 @@ class AppLocalizationsCs extends AppLocalizations { @override String get selectLanguage => 'Vybrat Jazyk'; + @override + String get selectRecipient => 'Select recipient'; + @override String get selectSearchType => 'Vybrat Typ Vyhledávání'; @override String get selectText => 'Select Text'; + @override + String get send => 'Send'; + @override String get sendBackgroundTestLocalNotification => 'Send background test local notification'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index fc4ed71a9..d0fc6b095 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -800,6 +800,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get dimReadPosts => 'Gelesene Posts ausgrauen'; + @override + String get directMessage => 'Direct message'; + @override String get disable => 'Deaktivieren'; @@ -2303,12 +2306,18 @@ class AppLocalizationsDe extends AppLocalizations { @override String get selectLanguage => 'Sprache auswählen'; + @override + String get selectRecipient => 'Select recipient'; + @override String get selectSearchType => 'Suchart auswählen'; @override String get selectText => 'Text auswählen'; + @override + String get send => 'Send'; + @override String get sendBackgroundTestLocalNotification => 'Lokale Benachrichtigung für Hintergrundtests senden'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 64812be00..661ad1c36 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -790,6 +790,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get dimReadPosts => 'Dim Read Posts'; + @override + String get directMessage => 'Direct message'; + @override String get disable => 'Disable'; @@ -2267,12 +2270,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get selectLanguage => 'Select Language'; + @override + String get selectRecipient => 'Select recipient'; + @override String get selectSearchType => 'Select Search Type'; @override String get selectText => 'Select Text'; + @override + String get send => 'Send'; + @override String get sendBackgroundTestLocalNotification => 'Send background test local notification'; diff --git a/lib/l10n/generated/app_localizations_eo.dart b/lib/l10n/generated/app_localizations_eo.dart index 81c301191..ab7b2e6a6 100644 --- a/lib/l10n/generated/app_localizations_eo.dart +++ b/lib/l10n/generated/app_localizations_eo.dart @@ -786,6 +786,9 @@ class AppLocalizationsEo extends AppLocalizations { @override String get dimReadPosts => 'Legitaj afiŝoj estos grizitaj'; + @override + String get directMessage => 'Direct message'; + @override String get disable => 'Malebligi'; @@ -2249,12 +2252,18 @@ class AppLocalizationsEo extends AppLocalizations { @override String get selectLanguage => 'Elekti lingvon'; + @override + String get selectRecipient => 'Select recipient'; + @override String get selectSearchType => 'Elekti Serĉan Tipon'; @override String get selectText => 'Select Text'; + @override + String get send => 'Send'; + @override String get sendBackgroundTestLocalNotification => 'Send background test local notification'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 2896c173f..3d42b0008 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -806,6 +806,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get dimReadPosts => 'Publicaciones de lectura oscura'; + @override + String get directMessage => 'Direct message'; + @override String get disable => 'Desactivar'; @@ -2313,12 +2316,18 @@ class AppLocalizationsEs extends AppLocalizations { @override String get selectLanguage => 'Selecciona el idioma'; + @override + String get selectRecipient => 'Select recipient'; + @override String get selectSearchType => 'Seleccione el tipo de la búsqueda'; @override String get selectText => 'Seleccionar el texto'; + @override + String get send => 'Send'; + @override String get sendBackgroundTestLocalNotification => 'Enviar notificación local de prueba en segundo plano'; diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index 7d1d045c0..920e883c8 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -787,6 +787,9 @@ class AppLocalizationsFi extends AppLocalizations { @override String get dimReadPosts => 'Luetut postaukset näkyvät tummennettuina'; + @override + String get directMessage => 'Direct message'; + @override String get disable => 'Disable'; @@ -2253,12 +2256,18 @@ class AppLocalizationsFi extends AppLocalizations { @override String get selectLanguage => 'Valitse kieli'; + @override + String get selectRecipient => 'Select recipient'; + @override String get selectSearchType => 'Valitse Hakulaji'; @override String get selectText => 'Select Text'; + @override + String get send => 'Send'; + @override String get sendBackgroundTestLocalNotification => 'Send background test local notification'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 258e9ece7..719576fb0 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -810,6 +810,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get dimReadPosts => 'Griser les publications lues.'; + @override + String get directMessage => 'Direct message'; + @override String get disable => 'Désactiver'; @@ -2294,12 +2297,18 @@ class AppLocalizationsFr extends AppLocalizations { @override String get selectLanguage => 'Sélectionner la langue'; + @override + String get selectRecipient => 'Select recipient'; + @override String get selectSearchType => 'Sélectionner le type de recherche'; @override String get selectText => 'Select Text'; + @override + String get send => 'Send'; + @override String get sendBackgroundTestLocalNotification => 'Send background test local notification'; diff --git a/lib/l10n/generated/app_localizations_hu.dart b/lib/l10n/generated/app_localizations_hu.dart index d387ef3d1..82cf21953 100644 --- a/lib/l10n/generated/app_localizations_hu.dart +++ b/lib/l10n/generated/app_localizations_hu.dart @@ -790,6 +790,9 @@ class AppLocalizationsHu extends AppLocalizations { @override String get dimReadPosts => 'Dim Read Posts'; + @override + String get directMessage => 'Direct message'; + @override String get disable => 'Disable'; @@ -2267,12 +2270,18 @@ class AppLocalizationsHu extends AppLocalizations { @override String get selectLanguage => 'Select Language'; + @override + String get selectRecipient => 'Select recipient'; + @override String get selectSearchType => 'Select Search Type'; @override String get selectText => 'Select Text'; + @override + String get send => 'Send'; + @override String get sendBackgroundTestLocalNotification => 'Send background test local notification'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index e57d47371..31ff959b3 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -793,6 +793,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get dimReadPosts => 'Sbiadisci Post Letti'; + @override + String get directMessage => 'Direct message'; + @override String get disable => 'Disabilita'; @@ -2265,12 +2268,18 @@ class AppLocalizationsIt extends AppLocalizations { @override String get selectLanguage => 'Seleziona lingua'; + @override + String get selectRecipient => 'Select recipient'; + @override String get selectSearchType => 'Seleziona Tipo di Ricerca'; @override String get selectText => 'Seleziona Testo'; + @override + String get send => 'Send'; + @override String get sendBackgroundTestLocalNotification => 'Invia notifica locale di test in background'; diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index 0e32c3d76..65c53b3c7 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -785,6 +785,9 @@ class AppLocalizationsNb extends AppLocalizations { @override String get dimReadPosts => 'Dim Read Posts'; + @override + String get directMessage => 'Direct message'; + @override String get disable => 'Disable'; @@ -2250,12 +2253,18 @@ class AppLocalizationsNb extends AppLocalizations { @override String get selectLanguage => 'Select Language'; + @override + String get selectRecipient => 'Select recipient'; + @override String get selectSearchType => 'Select Search Type'; @override String get selectText => 'Select Text'; + @override + String get send => 'Send'; + @override String get sendBackgroundTestLocalNotification => 'Send background test local notification'; diff --git a/lib/l10n/generated/app_localizations_nl.dart b/lib/l10n/generated/app_localizations_nl.dart index 1c36bc456..92ee50c72 100644 --- a/lib/l10n/generated/app_localizations_nl.dart +++ b/lib/l10n/generated/app_localizations_nl.dart @@ -796,6 +796,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get dimReadPosts => 'Gelezen berichten dimmen'; + @override + String get directMessage => 'Direct message'; + @override String get disable => 'Uitschakelen'; @@ -2290,12 +2293,18 @@ class AppLocalizationsNl extends AppLocalizations { @override String get selectLanguage => 'Taal selecteren'; + @override + String get selectRecipient => 'Select recipient'; + @override String get selectSearchType => 'Selecteer zoektype'; @override String get selectText => 'Tekst selecteren'; + @override + String get send => 'Send'; + @override String get sendBackgroundTestLocalNotification => 'Lokale test­melding op achter­grond verzenden'; diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index 93bf87eba..5aaa6f54a 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -798,6 +798,9 @@ class AppLocalizationsPl extends AppLocalizations { @override String get dimReadPosts => 'Przyciemnij Przeczytane Wpisy'; + @override + String get directMessage => 'Direct message'; + @override String get disable => 'Wyłącz'; @@ -2268,12 +2271,18 @@ class AppLocalizationsPl extends AppLocalizations { @override String get selectLanguage => 'Wybierz język'; + @override + String get selectRecipient => 'Select recipient'; + @override String get selectSearchType => 'Wybierz Typ Wyszukiwania'; @override String get selectText => 'Select Text'; + @override + String get send => 'Send'; + @override String get sendBackgroundTestLocalNotification => 'Send background test local notification'; diff --git a/lib/l10n/generated/app_localizations_ps.dart b/lib/l10n/generated/app_localizations_ps.dart index b8fa071d5..5ae186ec6 100644 --- a/lib/l10n/generated/app_localizations_ps.dart +++ b/lib/l10n/generated/app_localizations_ps.dart @@ -791,6 +791,9 @@ class AppLocalizationsPs extends AppLocalizations { @override String get dimReadPosts => 'Dim Read Posts'; + @override + String get directMessage => 'Direct message'; + @override String get disable => 'Disable'; @@ -2268,12 +2271,18 @@ class AppLocalizationsPs extends AppLocalizations { @override String get selectLanguage => 'Select Language'; + @override + String get selectRecipient => 'Select recipient'; + @override String get selectSearchType => 'Select Search Type'; @override String get selectText => 'Select Text'; + @override + String get send => 'Send'; + @override String get sendBackgroundTestLocalNotification => 'Send background test local notification'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 51b84a769..7491dc552 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -799,6 +799,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get dimReadPosts => 'Dim Read Posts'; + @override + String get directMessage => 'Direct message'; + @override String get disable => 'Disable'; @@ -2276,12 +2279,18 @@ class AppLocalizationsPt extends AppLocalizations { @override String get selectLanguage => 'Select Language'; + @override + String get selectRecipient => 'Select recipient'; + @override String get selectSearchType => 'Select Search Type'; @override String get selectText => 'Select Text'; + @override + String get send => 'Send'; + @override String get sendBackgroundTestLocalNotification => 'Send background test local notification'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 0a1aa3738..7b0529aff 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -800,6 +800,9 @@ class AppLocalizationsRu extends AppLocalizations { String get dimReadPosts => 'Прочитанные сообщения будут выделены серым цветом'; + @override + String get directMessage => 'Direct message'; + @override String get disable => 'Отключить'; @@ -2267,12 +2270,18 @@ class AppLocalizationsRu extends AppLocalizations { @override String get selectLanguage => 'Выберите язык'; + @override + String get selectRecipient => 'Select recipient'; + @override String get selectSearchType => 'Выберите тип поиска'; @override String get selectText => 'Select Text'; + @override + String get send => 'Send'; + @override String get sendBackgroundTestLocalNotification => 'Send background test local notification'; diff --git a/lib/l10n/generated/app_localizations_sk.dart b/lib/l10n/generated/app_localizations_sk.dart index 036d394c6..efa1b1ec4 100644 --- a/lib/l10n/generated/app_localizations_sk.dart +++ b/lib/l10n/generated/app_localizations_sk.dart @@ -795,6 +795,9 @@ class AppLocalizationsSk extends AppLocalizations { @override String get dimReadPosts => 'Stmaviť prečítané príspevky'; + @override + String get directMessage => 'Direct message'; + @override String get disable => 'Zakázať'; @@ -2268,12 +2271,18 @@ class AppLocalizationsSk extends AppLocalizations { @override String get selectLanguage => 'Vybrať jazyk'; + @override + String get selectRecipient => 'Select recipient'; + @override String get selectSearchType => 'Vybrať typ vyhľadávania'; @override String get selectText => 'Select Text'; + @override + String get send => 'Send'; + @override String get sendBackgroundTestLocalNotification => 'Send background test local notification'; diff --git a/lib/l10n/generated/app_localizations_sv.dart b/lib/l10n/generated/app_localizations_sv.dart index 50b5d12c1..3b0d97cff 100644 --- a/lib/l10n/generated/app_localizations_sv.dart +++ b/lib/l10n/generated/app_localizations_sv.dart @@ -791,6 +791,9 @@ class AppLocalizationsSv extends AppLocalizations { @override String get dimReadPosts => 'Dim Read Posts'; + @override + String get directMessage => 'Direct message'; + @override String get disable => 'Disable'; @@ -2261,12 +2264,18 @@ class AppLocalizationsSv extends AppLocalizations { @override String get selectLanguage => 'Select Language'; + @override + String get selectRecipient => 'Select recipient'; + @override String get selectSearchType => 'Välj Söktyp'; @override String get selectText => 'Select Text'; + @override + String get send => 'Send'; + @override String get sendBackgroundTestLocalNotification => 'Send background test local notification'; diff --git a/lib/l10n/generated/app_localizations_ta.dart b/lib/l10n/generated/app_localizations_ta.dart index 4a378833e..d6bdd056b 100644 --- a/lib/l10n/generated/app_localizations_ta.dart +++ b/lib/l10n/generated/app_localizations_ta.dart @@ -802,6 +802,9 @@ class AppLocalizationsTa extends AppLocalizations { @override String get dimReadPosts => 'மங்கலான இடுகைகள்'; + @override + String get directMessage => 'Direct message'; + @override String get disable => 'முடக்கு'; @@ -2307,12 +2310,18 @@ class AppLocalizationsTa extends AppLocalizations { @override String get selectLanguage => 'மொழியைத் தேர்ந்தெடுக்கவும்'; + @override + String get selectRecipient => 'Select recipient'; + @override String get selectSearchType => 'தேடல் வகையைத் தேர்ந்தெடுக்கவும்'; @override String get selectText => 'உரையைத் தேர்ந்தெடுக்கவும்'; + @override + String get send => 'Send'; + @override String get sendBackgroundTestLocalNotification => 'பின்னணி சோதனை உள்ளக அறிவிப்பை அனுப்பவும்'; diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart index f8e708ec4..fbcb555de 100644 --- a/lib/l10n/generated/app_localizations_tr.dart +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -794,6 +794,9 @@ class AppLocalizationsTr extends AppLocalizations { @override String get dimReadPosts => 'Okunmuş Gönderileri Soluklaştır'; + @override + String get directMessage => 'Direct message'; + @override String get disable => 'Devre Dışı Bırak'; @@ -2276,12 +2279,18 @@ class AppLocalizationsTr extends AppLocalizations { @override String get selectLanguage => 'Dil Seç'; + @override + String get selectRecipient => 'Select recipient'; + @override String get selectSearchType => 'Arama Türünü Seç'; @override String get selectText => 'Metni Seç'; + @override + String get send => 'Send'; + @override String get sendBackgroundTestLocalNotification => 'Arka planda test yerel bildirimi gönder'; diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index f322b00cf..caad275f3 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -792,6 +792,9 @@ class AppLocalizationsUk extends AppLocalizations { @override String get dimReadPosts => 'Dim Read Posts'; + @override + String get directMessage => 'Direct message'; + @override String get disable => 'Disable'; @@ -2263,12 +2266,18 @@ class AppLocalizationsUk extends AppLocalizations { @override String get selectLanguage => 'Select Language'; + @override + String get selectRecipient => 'Select recipient'; + @override String get selectSearchType => 'Виберіть тип пошуку'; @override String get selectText => 'Select Text'; + @override + String get send => 'Send'; + @override String get sendBackgroundTestLocalNotification => 'Send background test local notification'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index c36d7b2e1..8819ad9a4 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -790,6 +790,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get dimReadPosts => 'Dim Read Posts'; + @override + String get directMessage => 'Direct message'; + @override String get disable => 'Disable'; @@ -2267,12 +2270,18 @@ class AppLocalizationsZh extends AppLocalizations { @override String get selectLanguage => 'Select Language'; + @override + String get selectRecipient => 'Select recipient'; + @override String get selectSearchType => 'Select Search Type'; @override String get selectText => 'Select Text'; + @override + String get send => 'Send'; + @override String get sendBackgroundTestLocalNotification => 'Send background test local notification'; diff --git a/lib/src/app/shell/navigation/navigation_private_message.dart b/lib/src/app/shell/navigation/navigation_private_message.dart new file mode 100644 index 000000000..0566ade8f --- /dev/null +++ b/lib/src/app/shell/navigation/navigation_private_message.dart @@ -0,0 +1,115 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/packages/ui/ui.dart'; +import 'package:thunder/src/app/shell/navigation/loading_page.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/src/app/shell/navigation/swipeable_page_route.dart'; +import 'package:thunder/src/app/wiring/state_factories.dart'; +import 'package:thunder/src/features/private_message/presentation/pages/create_private_message_page.dart'; +import 'package:thunder/src/features/private_message/presentation/pages/private_message_thread_page.dart'; +import 'package:thunder/src/features/private_message/presentation/state/create_private_message_cubit.dart'; +import 'package:thunder/src/features/private_message/presentation/state/private_message_thread_cubit.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/foundation/contracts/contracts.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; + +/// Opens the full direct-message editor and returns the sent message, if any. +Future navigateToCreatePrivateMessagePage( + BuildContext context, { + Account? account, + ThunderUser? recipient, + String? initialContent, + void Function(ThunderPrivateMessage message)? onMessageSent, +}) async { + try { + final routeScope = resolveAccountAwareRouteScope(context, account: account, includeThunderCubit: true); + final effectiveAccount = routeScope.account; + final createPrivateMessageCubit = createCreatePrivateMessageCubit(effectiveAccount); + final themeCubit = context.read(); + final gestureCubit = context.read(); + final reduceAnimations = themeCubit.state.reduceAnimations; + final enableFullScreenSwipeNavigationGesture = gestureCubit.state.enableFullScreenSwipeNavigationGesture; + + final route = SwipeablePageRoute( + transitionDuration: isLoadingPageShown + ? Duration.zero + : reduceAnimations + ? const Duration(milliseconds: 100) + : null, + reverseTransitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : const Duration(milliseconds: 500), + canSwipe: !kIsWeb && Platform.isIOS || enableFullScreenSwipeNavigationGesture, + canOnlySwipeFromEdge: true, + builder: (context) => MultiBlocProvider( + providers: routeScope.providers( + provideThunderCubit: true, + extraProviders: [ + BlocProvider.value(value: createPrivateMessageCubit), + ], + ), + child: CreatePrivateMessagePage( + account: effectiveAccount, + recipient: recipient, + initialContent: initialContent, + onMessageSent: onMessageSent, + ), + ), + ); + + final result = await pushOnTopOfLoadingPage(context, route); + if (result is ThunderPrivateMessage) return result; + } catch (e) { + showSnackbar(e.toString()); + } + + return null; +} + +/// Opens a direct-message thread with [participant]. +Future navigateToPrivateMessageThreadPage( + BuildContext context, { + Account? account, + required ThunderUser participant, + List initialMessages = const [], + int? conversationId, + ValueChanged>? onThreadUpdated, +}) async { + final routeScope = resolveAccountAwareRouteScope(context, account: account, includeThunderCubit: true); + final effectiveAccount = routeScope.account; + final threadCubit = createPrivateMessageThreadCubit( + effectiveAccount, + participant: participant, + initialMessages: initialMessages, + conversationId: conversationId, + ); + final themeCubit = context.read(); + final gestureCubit = context.read(); + final reduceAnimations = themeCubit.state.reduceAnimations; + final enableFullScreenSwipeNavigationGesture = gestureCubit.state.enableFullScreenSwipeNavigationGesture; + + await Navigator.of(context).push( + SwipeablePageRoute( + transitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : null, + canSwipe: !kIsWeb && Platform.isIOS || enableFullScreenSwipeNavigationGesture, + canOnlySwipeFromEdge: true, + backGestureDetectionWidth: 45, + builder: (context) => MultiBlocProvider( + providers: routeScope.providers( + provideThunderCubit: true, + extraProviders: [ + BlocProvider.value(value: threadCubit), + ], + ), + child: PrivateMessageThreadPage( + account: effectiveAccount, + participant: participant, + onThreadUpdated: onThreadUpdated, + ), + ), + ), + ); +} diff --git a/lib/src/app/wiring/state_factories.dart b/lib/src/app/wiring/state_factories.dart index 25e5496a9..b4dda3233 100644 --- a/lib/src/app/wiring/state_factories.dart +++ b/lib/src/app/wiring/state_factories.dart @@ -16,6 +16,7 @@ import 'package:thunder/src/features/instance/api.dart'; import 'package:thunder/src/features/moderator/api.dart'; import 'package:thunder/src/features/notification/api.dart'; import 'package:thunder/src/features/post/api.dart'; +import 'package:thunder/src/features/private_message/api.dart'; import 'package:thunder/src/features/search/api.dart'; import 'package:thunder/src/features/session/api.dart'; import 'package:thunder/src/features/user/api.dart'; @@ -165,6 +166,30 @@ CreateCommentCubit createCreateCommentCubit(Account account) { ); } +CreatePrivateMessageCubit createCreatePrivateMessageCubit(Account account) { + return CreatePrivateMessageCubit( + account: account, + privateMessageRepository: (account) => PrivateMessageRepositoryImpl(account: account), + searchRepository: (account) => SearchRepositoryImpl(account: account), + localizationService: const GlobalContextLocalizationService(), + ); +} + +PrivateMessageThreadCubit createPrivateMessageThreadCubit( + Account account, { + required ThunderUser participant, + List initialMessages = const [], + int? conversationId, +}) { + return PrivateMessageThreadCubit( + account: account, + participant: participant, + repository: PrivateMessageRepositoryImpl(account: account), + initialMessages: initialMessages, + conversationId: conversationId, + ); +} + AccountSettingsCubit createAccountSettingsCubit(Account account, {ThunderSiteResponse? initialSiteResponse}) { return AccountSettingsCubit( account: account, diff --git a/lib/src/features/inbox/presentation/pages/inbox_page.dart b/lib/src/features/inbox/presentation/pages/inbox_page.dart index bfd1079e3..8afb5602b 100644 --- a/lib/src/features/inbox/presentation/pages/inbox_page.dart +++ b/lib/src/features/inbox/presentation/pages/inbox_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_private_message.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/inbox/inbox.dart'; @@ -86,11 +87,28 @@ class _InboxPageState extends State with SingleTickerProviderStateMix ); } + Future _openPrivateMessageComposer() async { + final account = context.read().account; + final message = await navigateToCreatePrivateMessagePage(context, account: account); + if (!mounted || message == null) return; + + final inboxBloc = context.read(); + if (inboxBloc.account.id != account.id) return; + + inboxBloc.add(InboxPrivateMessageSentEvent(message)); + } + @override Widget build(BuildContext context) { final l10n = GlobalContext.l10n; return Scaffold( + floatingActionButton: inboxType == InboxType.messages + ? FloatingActionButton( + onPressed: _openPrivateMessageComposer, + child: Icon(Icons.mail_rounded, semanticLabel: l10n.directMessage), + ) + : null, body: BlocConsumer( listener: (context, state) { if (state.status == InboxStatus.initial || state.status == InboxStatus.loading || state.status == InboxStatus.empty) { @@ -171,6 +189,7 @@ class _InboxPageState extends State with SingleTickerProviderStateMix bottom: TabBar( controller: tabController, onTap: (index) { + setState(() {}); context.read().add(GetInboxEvent(inboxType: inboxType, reset: true, showAll: showAll)); }, tabs: [ diff --git a/lib/src/features/inbox/presentation/state/inbox_bloc.dart b/lib/src/features/inbox/presentation/state/inbox_bloc.dart index 21d5170b4..ca7427beb 100644 --- a/lib/src/features/inbox/presentation/state/inbox_bloc.dart +++ b/lib/src/features/inbox/presentation/state/inbox_bloc.dart @@ -9,6 +9,7 @@ import 'package:thunder/src/foundation/errors/errors.dart'; import 'package:thunder/src/features/comment/comment.dart'; import 'package:thunder/src/features/inbox/inbox.dart'; import 'package:thunder/src/features/notification/notification.dart'; +import 'package:thunder/src/features/private_message/domain/utils/private_message_thread_utils.dart'; part 'inbox_event.dart'; part 'inbox_state.dart'; @@ -54,9 +55,45 @@ class InboxBloc extends Bloc { void _init() { on(_getInboxEvent, transformer: restartable()); on(_inboxItemActionEvent); + on(_privateMessageSent); + on(_privateMessageThreadUpdated); on(_markAllAsRead); } + void _privateMessageSent(InboxPrivateMessageSentEvent event, Emitter emit) { + _upsertPrivateMessages([event.privateMessage], emit); + } + + void _privateMessageThreadUpdated(InboxPrivateMessageThreadUpdatedEvent event, Emitter emit) { + _upsertPrivateMessages(event.privateMessages, emit); + } + + void _upsertPrivateMessages(List privateMessages, Emitter emit) { + if (privateMessages.isEmpty) return; + + final updatedIds = privateMessages.map((privateMessage) => privateMessage.id).toSet(); + final previousThreadUnreadCount = _privateMessageUnreadCount(state.privateMessages.where((privateMessage) => updatedIds.contains(privateMessage.id))); + final nextThreadUnreadCount = _privateMessageUnreadCount(privateMessages); + final unreadDelta = nextThreadUnreadCount - previousThreadUnreadCount; + final updatedPrivateMessages = [ + ...privateMessages, + ...state.privateMessages.where((privateMessage) => !updatedIds.contains(privateMessage.id)), + ]..sort((a, b) => b.published.compareTo(a.published)); + + emit(state.copyWith( + status: InboxStatus.success, + privateMessages: cleanDeletedMessages(updatedPrivateMessages), + totalUnreadCount: (state.totalUnreadCount + unreadDelta).clamp(0, 1 << 31), + messagesUnreadCount: (state.messagesUnreadCount + unreadDelta).clamp(0, 1 << 31), + errorMessage: null, + errorReason: null, + )); + } + + int _privateMessageUnreadCount(Iterable privateMessages) { + return privateMessages.where((privateMessage) => !privateMessage.read && isIncomingPrivateMessage(privateMessage, account)).length; + } + Future _getInboxEvent(GetInboxEvent event, emit) async { if (account.anonymous) { return emit(state.copyWith( @@ -108,7 +145,7 @@ class InboxBloc extends Bloc { break; case InboxType.messages: privateMessagesResponse = await notificationRepository.messages( - unread: !event.showAll, + unread: false, limit: limit, page: 1, ); @@ -129,7 +166,7 @@ class InboxBloc extends Bloc { ); privateMessagesResponse = await notificationRepository.messages( - unread: !event.showAll, + unread: false, limit: limit, page: 1, ); @@ -194,7 +231,7 @@ class InboxBloc extends Bloc { case InboxType.messages: if (state.hasReachedInboxPrivateMessageEnd) return; privateMessagesResponse = await notificationRepository.messages( - unread: state.showUnreadOnly, + unread: false, limit: limit, page: state.inboxPrivateMessagePage, ); @@ -341,11 +378,7 @@ class InboxBloc extends Bloc { updatedMentions.removeAt(existingMentionIndex); } } else if (existingPrivateMessageView != null && existingPrivateMessageIndex != -1) { - if (!state.showUnreadOnly) { - updatedPrivateMessages[existingPrivateMessageIndex] = existingPrivateMessageView.copyWith(read: value); - } else if (value) { - updatedPrivateMessages.removeAt(existingPrivateMessageIndex); - } + updatedPrivateMessages[existingPrivateMessageIndex] = existingPrivateMessageView.copyWith(read: value); } if (existingCommentReplyView != null) { @@ -571,7 +604,7 @@ class InboxBloc extends Bloc { status: InboxStatus.success, replies: state.showUnreadOnly ? [] : updatedReplies, mentions: state.showUnreadOnly ? [] : updatedMentions, - privateMessages: state.showUnreadOnly ? [] : updatedPrivateMessages, + privateMessages: updatedPrivateMessages, totalUnreadCount: 0, repliesUnreadCount: 0, mentionsUnreadCount: 0, diff --git a/lib/src/features/inbox/presentation/state/inbox_event.dart b/lib/src/features/inbox/presentation/state/inbox_event.dart index b333d0d3a..c665683a9 100644 --- a/lib/src/features/inbox/presentation/state/inbox_event.dart +++ b/lib/src/features/inbox/presentation/state/inbox_event.dart @@ -97,4 +97,26 @@ class InboxItemActionEvent extends InboxEvent { List get props => [action, commentReplyId, personMentionId, privateMessageId, actionInput]; } +/// Adds or updates a sent private message in the current inbox state. +class InboxPrivateMessageSentEvent extends InboxEvent { + const InboxPrivateMessageSentEvent(this.privateMessage); + + /// The private message returned by the compose flow. + final ThunderPrivateMessage privateMessage; + + @override + List get props => [privateMessage]; +} + +/// Reconciles the inbox with the latest local state for an opened thread. +class InboxPrivateMessageThreadUpdatedEvent extends InboxEvent { + const InboxPrivateMessageThreadUpdatedEvent(this.privateMessages); + + /// Messages returned by the direct-message thread route. + final List privateMessages; + + @override + List get props => [privateMessages]; +} + class MarkAllAsReadEvent extends InboxEvent {} diff --git a/lib/src/features/inbox/presentation/widgets/inbox_private_message_thread_tile.dart b/lib/src/features/inbox/presentation/widgets/inbox_private_message_thread_tile.dart new file mode 100644 index 000000000..70d730484 --- /dev/null +++ b/lib/src/features/inbox/presentation/widgets/inbox_private_message_thread_tile.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/app/shell/navigation/navigation_private_message.dart'; +import 'package:thunder/src/features/inbox/presentation/state/inbox_bloc.dart'; +import 'package:thunder/src/features/private_message/domain/models/private_message_thread.dart'; +import 'package:thunder/src/foundation/contracts/contracts.dart'; +import 'package:thunder/src/foundation/utils/utils.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/src/shared/avatars/user_avatar.dart'; +import 'package:thunder/src/shared/name/full_name_widgets.dart'; + +/// List row for a grouped direct-message thread in the inbox. +class InboxPrivateMessageThreadTile extends StatelessWidget { + /// Creates a direct-message thread row. + const InboxPrivateMessageThreadTile({ + super.key, + required this.account, + required this.thread, + }); + + /// Account that owns the inbox. + final Account account; + + /// Grouped thread summary to display. + final PrivateMessageThread thread; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final participant = thread.participant; + + return ListTile( + key: ValueKey(participant.actorId), + leading: UserAvatar(user: participant, radius: 24.0), + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: UserFullNameWidget( + name: participant.name, + displayName: participant.displayName, + instance: fetchInstanceNameFromUrl(participant.actorId), + includeInstance: true, + ), + ), + Text( + formatTimeToString(dateTime: thread.latestMessage.published.toIso8601String()), + style: theme.textTheme.labelSmall, + ), + ], + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 2.0), + Text( + thread.latestMessage.content.replaceAll('\n', ' '), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: thread.unreadCount > 0 + ? theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500) + : theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface.withValues(alpha: 0.5)), + ), + ], + ), + onTap: () { + navigateToPrivateMessageThreadPage( + context, + account: account, + participant: participant, + initialMessages: thread.messages, + conversationId: thread.conversationId, + onThreadUpdated: (messages) { + if (!context.mounted) return; + context.read().add(InboxPrivateMessageThreadUpdatedEvent(messages)); + }, + ); + }, + ); + } +} diff --git a/lib/src/features/inbox/presentation/widgets/inbox_private_messages_view.dart b/lib/src/features/inbox/presentation/widgets/inbox_private_messages_view.dart index 0dd9af723..815f9a2fc 100644 --- a/lib/src/features/inbox/presentation/widgets/inbox_private_messages_view.dart +++ b/lib/src/features/inbox/presentation/widgets/inbox_private_messages_view.dart @@ -2,33 +2,26 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/foundation/primitives/primitives.dart'; -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/features/inbox/inbox.dart'; -import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; -import 'package:thunder/src/shared/name/full_name_widgets.dart'; -import 'package:thunder/src/foundation/utils/utils.dart'; -import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; -import 'package:thunder/packages/ui/ui.dart' show ThunderDivider; - -class InboxPrivateMessagesView extends StatefulWidget { - final List privateMessages; +import 'package:thunder/src/features/inbox/presentation/widgets/inbox_private_message_thread_tile.dart'; +import 'package:thunder/src/features/private_message/domain/utils/private_message_thread_utils.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +/// Displays direct messages grouped into participant threads. +class InboxPrivateMessagesView extends StatelessWidget { + /// Creates a direct-message inbox view. const InboxPrivateMessagesView({super.key, this.privateMessages = const []}); - @override - State createState() => _InboxPrivateMessagesViewState(); -} + /// Private messages loaded for the inbox. + final List privateMessages; -class _InboxPrivateMessagesViewState extends State { @override Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final theme = Theme.of(context); - final state = context.read().state; - final textStyle = theme.textTheme.bodyMedium; + final l10n = GlobalContext.l10n; + + final inboxBloc = context.read(); + final state = inboxBloc.state; + final threads = groupPrivateMessagesByParticipant(privateMessages, inboxBloc.account); return Builder(builder: (context) { return CustomScrollView( @@ -40,82 +33,22 @@ class _InboxPrivateMessagesViewState extends State { hasScrollBody: false, child: Center(child: CircularProgressIndicator()), ), - if (state.status != InboxStatus.loading && widget.privateMessages.isEmpty) + if (state.status != InboxStatus.loading && threads.isEmpty) SliverFillRemaining( hasScrollBody: false, child: Center(child: Text(l10n.noMessages)), ), SliverList.builder( - itemCount: widget.privateMessages.length, + itemCount: threads.length, itemBuilder: (context, index) { - return Card( - key: ValueKey(widget.privateMessages[index].id), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 12.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - UserFullNameWidget( - name: widget.privateMessages[index].creator?.name, - displayName: widget.privateMessages[index].creator?.displayName, - instance: fetchInstanceNameFromUrl(widget.privateMessages[index].creator?.actorId), - includeInstance: true), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 8.0), - child: Icon(Icons.arrow_forward_rounded, size: 14), - ), - UserFullNameWidget( - name: widget.privateMessages[index].recipient?.name, - displayName: widget.privateMessages[index].recipient?.displayName, - instance: fetchInstanceNameFromUrl(widget.privateMessages[index].recipient?.actorId), - includeInstance: true), - ], - ), - ), - Text( - formatTimeToString(dateTime: widget.privateMessages[index].published.toIso8601String()), - style: textStyle!.copyWith(fontSize: MediaQuery.textScalerOf(context).scale((textStyle.fontSize!) * (FontScale.base.textScaleFactor))), - ) - ], - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 10.0), - child: CommonMarkdownBody(body: widget.privateMessages[index].content), - ), - ThunderDivider(sliver: false, padding: false), - Align( - alignment: Alignment.centerRight, - child: IconButton( - visualDensity: VisualDensity.compact, - onPressed: () => context.read().add( - InboxItemActionEvent( - action: CommentAction.read, - privateMessageId: widget.privateMessages[index].id, - actionInput: ReadInboxActionInput(!widget.privateMessages[index].read), - ), - ), - icon: Icon( - Icons.check, - semanticLabel: l10n.markAsRead, - color: widget.privateMessages[index].read ? Colors.green : null, - ), - ), - ), - ], - ), - ), + final thread = threads[index]; + return InboxPrivateMessageThreadTile( + account: inboxBloc.account, + thread: thread, ); }, ), - if (state.hasReachedInboxMentionEnd && widget.privateMessages.isNotEmpty) const SliverToBoxAdapter(child: FeedReachedEnd()), - if (widget.privateMessages.isNotEmpty) + if (threads.isNotEmpty) SliverToBoxAdapter( child: SizedBox(height: kBottomNavigationBarHeight), ) diff --git a/lib/src/features/private_message/api.dart b/lib/src/features/private_message/api.dart new file mode 100644 index 000000000..4d6fcbea5 --- /dev/null +++ b/lib/src/features/private_message/api.dart @@ -0,0 +1 @@ +export 'private_message.dart'; diff --git a/lib/src/features/private_message/data/repositories/private_message_repository.dart b/lib/src/features/private_message/data/repositories/private_message_repository.dart new file mode 100644 index 000000000..3ad814038 --- /dev/null +++ b/lib/src/features/private_message/data/repositories/private_message_repository.dart @@ -0,0 +1,101 @@ +import 'package:flutter/foundation.dart'; + +import 'package:thunder/src/foundation/contracts/contracts.dart'; +import 'package:thunder/src/foundation/networking/networking.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; + +/// Repository contract for direct-message reads, writes, and read state. +abstract class PrivateMessageRepository { + /// Fetches private messages for the inbox. + Future> messages({ + bool unread, + int limit, + int page, + }); + + /// Fetches messages exchanged with a single person. + Future> conversation({ + required int personId, + int? conversationId, + int page, + int limit, + }); + + /// Sends a direct message to a recipient. + Future create({ + required int recipientId, + required String content, + }); + + /// Updates the read state for a private message. + Future markAsRead({ + required int messageId, + bool read, + }); +} + +/// API-backed implementation of [PrivateMessageRepository]. +class PrivateMessageRepositoryImpl implements PrivateMessageRepository { + /// Creates a repository for [account]. + PrivateMessageRepositoryImpl({ + required this.account, + ThunderApiClient? api, + LocalizationService localizationService = const GlobalContextLocalizationService(), + }) : _api = api ?? ApiClientFactory.create(account, debug: kDebugMode), + _localizationService = localizationService; + + /// Account used to authenticate private-message requests. + final Account account; + final ThunderApiClient _api; + final LocalizationService _localizationService; + + void _ensureLoggedIn() { + if (account.anonymous) { + throw Exception(_localizationService.l10n.userNotLoggedIn); + } + } + + @override + Future> messages({ + bool unread = false, + int limit = 50, + int page = 1, + }) async { + _ensureLoggedIn(); + return _api.getPrivateMessages(page: page, limit: limit, unread: unread); + } + + @override + Future> conversation({ + required int personId, + int? conversationId, + int page = 1, + int limit = 50, + }) async { + _ensureLoggedIn(); + return _api.getPrivateMessageConversation( + personId: personId, + conversationId: conversationId, + page: page, + limit: limit, + ); + } + + @override + Future create({ + required int recipientId, + required String content, + }) async { + _ensureLoggedIn(); + return _api.createPrivateMessage(recipientId: recipientId, content: content); + } + + @override + Future markAsRead({ + required int messageId, + bool read = true, + }) async { + _ensureLoggedIn(); + await _api.markPrivateMessageAsRead(messageId: messageId, read: read); + } +} diff --git a/lib/src/features/private_message/domain/models/private_message_thread.dart b/lib/src/features/private_message/domain/models/private_message_thread.dart new file mode 100644 index 000000000..b32a0e930 --- /dev/null +++ b/lib/src/features/private_message/domain/models/private_message_thread.dart @@ -0,0 +1,50 @@ +import 'package:equatable/equatable.dart'; + +import 'package:thunder/src/foundation/primitives/primitives.dart'; + +/// Summary of direct messages grouped by the other participant. +class PrivateMessageThread extends Equatable { + /// Creates a grouped direct-message thread. + const PrivateMessageThread({ + required this.participant, + required this.messages, + required this.latestMessage, + required this.unreadCount, + this.conversationId, + }); + + /// User on the other side of the thread. + final ThunderUser participant; + + /// Messages in the thread, sorted from oldest to newest. + final List messages; + + /// Most recent message used for inbox previews and sorting. + final ThunderPrivateMessage latestMessage; + + /// Count of unread incoming messages in this thread. + final int unreadCount; + + /// Server-provided conversation ID, when available. + final int? conversationId; + + /// Creates a copy with updated fields. + PrivateMessageThread copyWith({ + ThunderUser? participant, + List? messages, + ThunderPrivateMessage? latestMessage, + int? unreadCount, + int? conversationId, + }) { + return PrivateMessageThread( + participant: participant ?? this.participant, + messages: messages ?? this.messages, + latestMessage: latestMessage ?? this.latestMessage, + unreadCount: unreadCount ?? this.unreadCount, + conversationId: conversationId ?? this.conversationId, + ); + } + + @override + List get props => [participant, messages, latestMessage, unreadCount, conversationId]; +} diff --git a/lib/src/features/private_message/domain/utils/private_message_thread_utils.dart b/lib/src/features/private_message/domain/utils/private_message_thread_utils.dart new file mode 100644 index 000000000..3117592eb --- /dev/null +++ b/lib/src/features/private_message/domain/utils/private_message_thread_utils.dart @@ -0,0 +1,86 @@ +import 'package:collection/collection.dart'; + +import 'package:thunder/src/features/private_message/domain/models/private_message_thread.dart'; +import 'package:thunder/src/foundation/contracts/contracts.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; + +/// Returns the user on the other side of a private message. +ThunderUser? otherPrivateMessageParticipant(ThunderPrivateMessage message, Account account) { + final currentUserId = account.userId; + + if (currentUserId != null) { + if (message.creatorId == currentUserId) return message.recipient; + if (message.recipientId == currentUserId) return message.creator; + } + + if (message.creator?.actorId == account.actorId) return message.recipient; + return message.creator ?? message.recipient; +} + +/// Returns true when [message] was received by [account]. +bool isIncomingPrivateMessage(ThunderPrivateMessage message, Account account) { + final currentUserId = account.userId; + if (currentUserId != null) return message.recipientId == currentUserId; + return message.recipient?.actorId == account.actorId; +} + +/// Marks incoming messages as read locally for the account viewing a thread. +List markIncomingPrivateMessagesRead( + List messages, + Account account, +) { + return messages.map((message) { + if (message.read || !isIncomingPrivateMessage(message, account)) return message; + return message.copyWith(read: true); + }).toList(); +} + +/// Groups private messages into participant threads for the inbox. +List groupPrivateMessagesByParticipant( + List messages, + Account account, +) { + final grouped = >{}; + final participants = {}; + + for (final message in messages) { + final participant = otherPrivateMessageParticipant(message, account); + if (participant == null) continue; + + final key = participant.actorId.isNotEmpty ? participant.actorId : participant.id.toString(); + participants[key] = participant; + grouped.putIfAbsent(key, () => []).add(message); + } + + final threads = grouped.entries.map((entry) { + final threadMessages = [...entry.value]..sort((a, b) => a.published.compareTo(b.published)); + final latest = threadMessages.last; + final unreadCount = threadMessages.where((message) => !message.read && isIncomingPrivateMessage(message, account)).length; + final conversationId = threadMessages.firstWhereOrNull((message) => message.conversationId != null)?.conversationId; + + return PrivateMessageThread( + participant: participants[entry.key]!, + messages: threadMessages, + latestMessage: latest, + unreadCount: unreadCount, + conversationId: conversationId, + ); + }).toList(); + + threads.sort((a, b) => b.latestMessage.published.compareTo(a.latestMessage.published)); + return threads; +} + +/// Merges private messages by ID and sorts them from oldest to newest. +List mergePrivateMessages( + List current, + List incoming, +) { + final byId = {for (final message in current) message.id: message}; + + for (final message in incoming) { + byId[message.id] = message; + } + + return byId.values.toList()..sort((a, b) => a.published.compareTo(b.published)); +} diff --git a/lib/src/features/private_message/presentation/pages/create_private_message_page.dart b/lib/src/features/private_message/presentation/pages/create_private_message_page.dart new file mode 100644 index 000000000..43804af82 --- /dev/null +++ b/lib/src/features/private_message/presentation/pages/create_private_message_page.dart @@ -0,0 +1,189 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:keyboard_detection/keyboard_detection.dart'; + +import 'package:thunder/packages/ui/ui.dart'; +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/private_message/presentation/widgets/create_private_message/create_private_message_bottom_bar.dart'; +import 'package:thunder/src/features/private_message/presentation/widgets/create_private_message/create_private_message_editor_section.dart'; +import 'package:thunder/src/features/private_message/presentation/widgets/create_private_message/create_private_message_recipient_tile.dart'; +import 'package:thunder/src/features/private_message/presentation/state/create_private_message_cubit.dart'; +import 'package:thunder/src/features/user/user.dart'; +import 'package:thunder/src/foundation/contracts/contracts.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/shared/input_dialogs.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; + +/// Page for composing and sending a direct message. +class CreatePrivateMessagePage extends StatefulWidget { + /// Creates a direct-message composer for [account]. + const CreatePrivateMessagePage({ + super.key, + required this.account, + this.recipient, + this.initialContent, + this.onMessageSent, + }); + + /// Account used to send the message. + final Account account; + + /// Optional recipient used when the composer is opened from a user action. + final ThunderUser? recipient; + + /// Optional markdown content used when expanding a quick reply into the full composer. + final String? initialContent; + + /// Callback invoked after a message is successfully sent. + final void Function(ThunderPrivateMessage message)? onMessageSent; + + @override + State createState() => _CreatePrivateMessagePageState(); +} + +class _CreatePrivateMessagePageState extends State { + final TextEditingController _bodyController = TextEditingController(); + final FocusNode _bodyFocusNode = FocusNode(); + final KeyboardDetectionController _keyboardDetectionController = KeyboardDetectionController(); + + bool _showPreview = false; + bool _wasKeyboardVisible = false; + + @override + void initState() { + super.initState(); + _bodyController.text = widget.initialContent ?? ''; + _bodyController.addListener(() => context.read().updateContent(_bodyController.text)); + + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().initialize( + recipient: widget.recipient, + content: widget.initialContent, + ); + _bodyFocusNode.requestFocus(); + }); + } + + @override + void dispose() { + _bodyController.dispose(); + _bodyFocusNode.dispose(); + super.dispose(); + } + + void _selectRecipient(Account account) { + final l10n = GlobalContext.l10n; + + showUserInputDialog( + context, + title: l10n.selectRecipient, + account: account, + onUserSelected: (user) => context.read().setRecipient(user), + ); + } + + Future _submit() async { + final message = await context.read().submit(); + if (!mounted || message == null) return; + + widget.onMessageSent?.call(message); + Navigator.of(context).pop(message); + } + + void _togglePreview() { + if (!_showPreview) { + _wasKeyboardVisible = _keyboardDetectionController.stateAsBool(true) ?? false; + FocusManager.instance.primaryFocus?.unfocus(); + } + + setState(() => _showPreview = !_showPreview); + if (!_showPreview && _wasKeyboardVisible) { + _bodyFocusNode.requestFocus(); + } + } + + @override + Widget build(BuildContext context) { + final l10n = GlobalContext.l10n; + final theme = Theme.of(context); + + return BlocConsumer( + listener: (context, state) { + if (state.message?.isNotEmpty == true) { + showSnackbar(state.message!); + } + }, + builder: (context, state) { + final account = widget.account; + + return GestureDetector( + onTap: () => FocusManager.instance.primaryFocus?.unfocus(), + child: KeyboardDetection( + controller: _keyboardDetectionController, + child: Scaffold( + resizeToAvoidBottomInset: false, + appBar: AppBar( + title: Text(l10n.directMessage), + centerTitle: false, + ), + body: SafeArea( + bottom: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 16.0, top: 8.0), + child: UserSelector( + account: account, + enableAccountSwitching: false, + ), + ), + const SizedBox(height: 10), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: CreatePrivateMessageRecipientTile( + recipient: state.recipient, + onTap: () => _selectRecipient(account), + ), + ), + const SizedBox(height: 10), + CreatePrivateMessageEditorSection( + controller: _bodyController, + focusNode: _bodyFocusNode, + showPreview: _showPreview, + ), + ], + ), + ), + ), + const Divider(height: 1), + CreatePrivateMessageBottomBar( + account: account, + controller: _bodyController, + focusNode: _bodyFocusNode, + showPreview: _showPreview, + canSubmit: state.canSubmit, + status: state.status, + onTogglePreview: _togglePreview, + onSubmit: _submit, + ), + Container( + height: MediaQuery.of(context).padding.bottom, + color: theme.cardColor, + ), + ], + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/src/features/private_message/presentation/pages/private_message_thread_page.dart b/lib/src/features/private_message/presentation/pages/private_message_thread_page.dart new file mode 100644 index 000000000..32b759d93 --- /dev/null +++ b/lib/src/features/private_message/presentation/pages/private_message_thread_page.dart @@ -0,0 +1,192 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/packages/ui/ui.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_private_message.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/src/features/private_message/domain/utils/private_message_thread_utils.dart'; +import 'package:thunder/src/features/private_message/presentation/state/private_message_thread_cubit.dart'; +import 'package:thunder/src/features/private_message/presentation/widgets/thread/private_message_bubble.dart'; +import 'package:thunder/src/features/private_message/presentation/widgets/thread/quick_reply_bar.dart'; +import 'package:thunder/src/foundation/contracts/contracts.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/shared/avatars/user_avatar.dart'; +import 'package:thunder/src/shared/name/full_name_widgets.dart'; + +/// Page showing a direct-message conversation with one participant. +class PrivateMessageThreadPage extends StatefulWidget { + /// Creates a direct-message thread page. + const PrivateMessageThreadPage({ + super.key, + required this.account, + required this.participant, + this.onThreadUpdated, + }); + + /// Account viewing the thread. + final Account account; + + /// User on the other side of the thread. + final ThunderUser participant; + + /// Callback invoked with the latest local messages when the thread closes. + final ValueChanged>? onThreadUpdated; + + @override + State createState() => _PrivateMessageThreadPageState(); +} + +class _PrivateMessageThreadPageState extends State { + final TextEditingController _quickReplyController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + late final PrivateMessageThreadCubit _cubit; + int _previousMessageCount = 0; + PrivateMessageThreadStatus _previousStatus = PrivateMessageThreadStatus.initial; + + @override + void initState() { + super.initState(); + _cubit = context.read(); + _previousMessageCount = _cubit.state.messages.length; + _previousStatus = _cubit.state.status; + _quickReplyController.addListener(() => _cubit.updateQuickReply(_quickReplyController.text)); + if (_cubit.state.messages.isNotEmpty) { + _scrollToLatest(); + } + WidgetsBinding.instance.addPostFrameCallback((_) => _cubit.load()); + } + + @override + void dispose() { + widget.onThreadUpdated?.call(_cubit.state.messages); + _quickReplyController.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + /// Scrolls to the newest message at the bottom of the list. + void _scrollToLatest() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_scrollController.hasClients) return; + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + }); + } + + Future _openComposer() async { + final cubit = context.read(); + final result = await navigateToCreatePrivateMessagePage( + context, + account: widget.account, + recipient: widget.participant, + initialContent: cubit.state.quickReply, + ); + + if (!mounted || result == null) return; + if (!_belongsToCurrentThread(result)) return; + + cubit.appendMessage(result); + _quickReplyController.clear(); + } + + bool _belongsToCurrentThread(ThunderPrivateMessage message) { + final participant = otherPrivateMessageParticipant(message, widget.account); + if (participant == null) return false; + + if (participant.actorId.isNotEmpty && widget.participant.actorId.isNotEmpty) { + return participant.actorId == widget.participant.actorId; + } + + return participant.id == widget.participant.id; + } + + Future _sendQuickReply() async { + final message = await _cubit.sendQuickReply(); + if (message != null) { + _quickReplyController.clear(); + } + } + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listenWhen: (previous, current) => previous.messages.length != current.messages.length || previous.status != current.status || previous.message != current.message, + listener: (context, state) { + if (state.message?.isNotEmpty == true) { + showSnackbar(state.message!); + } + + final messagesGrew = state.messages.length > _previousMessageCount; + final finishedInitialLoad = + _previousStatus == PrivateMessageThreadStatus.loading && _previousMessageCount == 0 && state.status == PrivateMessageThreadStatus.success && state.messages.isNotEmpty; + + if (messagesGrew || finishedInitialLoad) { + _scrollToLatest(); + } + + _previousMessageCount = state.messages.length; + _previousStatus = state.status; + }, + builder: (context, state) { + return Scaffold( + appBar: AppBar( + titleSpacing: 0, + title: Row( + children: [ + UserAvatar(user: widget.participant, radius: 18), + const SizedBox(width: 10), + Expanded( + child: UserFullNameWidget( + name: widget.participant.name, + displayName: widget.participant.displayName, + instance: fetchInstanceNameFromUrl(widget.participant.actorId), + includeInstance: true, + ), + ), + ], + ), + ), + body: SafeArea( + child: Column( + children: [ + Expanded( + child: state.status == PrivateMessageThreadStatus.loading && state.messages.isEmpty + ? const Center(child: CircularProgressIndicator()) + : CustomScrollView( + controller: _scrollController, + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 16), + sliver: SliverList.builder( + itemCount: state.messages.length, + itemBuilder: (context, index) { + final message = state.messages[index]; + return PrivateMessageBubble( + account: widget.account, + message: message, + ); + }, + ), + ), + const SliverFillRemaining( + hasScrollBody: false, + child: SizedBox.shrink(), + ), + ], + ), + ), + QuickReplyBar( + controller: _quickReplyController, + canSend: state.canSendQuickReply, + sending: state.status == PrivateMessageThreadStatus.sending, + onOpenComposer: _openComposer, + onSend: _sendQuickReply, + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/src/features/private_message/presentation/state/create_private_message_cubit.dart b/lib/src/features/private_message/presentation/state/create_private_message_cubit.dart new file mode 100644 index 000000000..9f9304e1a --- /dev/null +++ b/lib/src/features/private_message/presentation/state/create_private_message_cubit.dart @@ -0,0 +1,129 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; + +import 'package:thunder/src/features/private_message/data/repositories/private_message_repository.dart'; +import 'package:thunder/src/features/search/search.dart'; +import 'package:thunder/src/foundation/contracts/contracts.dart'; +import 'package:thunder/src/foundation/errors/errors.dart'; +import 'package:thunder/src/foundation/networking/networking.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; + +part 'create_private_message_state.dart'; + +/// Coordinates recipient selection, body edits, and submission for direct messages. +class CreatePrivateMessageCubit extends Cubit { + /// Creates a cubit for composing direct messages from [account]. + CreatePrivateMessageCubit({ + required this.account, + required PrivateMessageRepository Function(Account account) privateMessageRepository, + required SearchRepository Function(Account account) searchRepository, + required LocalizationService localizationService, + }) : _privateMessageRepository = privateMessageRepository(account), + _searchRepository = searchRepository(account), + _localizationService = localizationService, + super(const CreatePrivateMessageState()); + + /// Account currently used to send direct messages. + final Account account; + final LocalizationService _localizationService; + + final PrivateMessageRepository _privateMessageRepository; + final SearchRepository _searchRepository; + + /// Initializes the composer with optional pre-filled recipient and content. + void initialize({ThunderUser? recipient, String? content}) { + emit(state.copyWith( + status: CreatePrivateMessageStatus.initial, + recipient: recipient, + content: content ?? '', + message: null, + errorReason: null, + privateMessage: null, + )); + } + + /// Sets the selected recipient. + void setRecipient(ThunderUser? recipient) { + emit(state.copyWith( + status: CreatePrivateMessageStatus.initial, + recipient: recipient, + suggestions: const [], + message: null, + errorReason: null, + )); + } + + /// Updates the message body. + void updateContent(String content) { + if (content == state.content) return; + emit(state.copyWith(content: content, message: null, errorReason: null)); + } + + /// Searches for users that can be selected as recipients. + Future searchRecipients(String query) async { + final trimmed = query.trim(); + if (trimmed.isEmpty) { + emit(state.copyWith(status: CreatePrivateMessageStatus.initial, suggestions: const [])); + return; + } + + try { + emit(state.copyWith(status: CreatePrivateMessageStatus.searching, message: null, errorReason: null)); + final results = await _searchRepository.search(query: trimmed, type: MetaSearchType.users, limit: 20); + emit(state.copyWith(status: CreatePrivateMessageStatus.initial, suggestions: results.users)); + } catch (e) { + final message = getExceptionErrorMessage(e); + emit(state.copyWith( + status: CreatePrivateMessageStatus.error, + message: message, + errorReason: AppErrorReason.unexpected(message: message, details: e.toString()), + )); + } + } + + /// Sends the direct message when the current state is valid. + Future submit() async { + final l10n = _localizationService.l10n; + final recipient = state.recipient; + final content = state.content.trim(); + + if (account.anonymous) { + emit(state.copyWith( + status: CreatePrivateMessageStatus.error, + message: l10n.userNotLoggedIn, + errorReason: AppErrorReason.notLoggedIn(message: l10n.userNotLoggedIn), + )); + return null; + } + + if (recipient == null || content.isEmpty) { + emit(state.copyWith( + status: CreatePrivateMessageStatus.error, + message: l10n.missingErrorMessage, + errorReason: AppErrorReason.validation(message: l10n.missingErrorMessage), + )); + return null; + } + + try { + emit(state.copyWith(status: CreatePrivateMessageStatus.submitting, message: null, errorReason: null)); + final privateMessage = await _privateMessageRepository.create(recipientId: recipient.id, content: content); + emit(state.copyWith( + status: CreatePrivateMessageStatus.success, + privateMessage: privateMessage, + content: '', + message: null, + errorReason: null, + )); + return privateMessage; + } catch (e) { + final message = getExceptionErrorMessage(e); + emit(state.copyWith( + status: CreatePrivateMessageStatus.error, + message: message, + errorReason: AppErrorReason.actionFailed(message: message, details: e.toString()), + )); + return null; + } + } +} diff --git a/lib/src/features/private_message/presentation/state/create_private_message_state.dart b/lib/src/features/private_message/presentation/state/create_private_message_state.dart new file mode 100644 index 000000000..8f41ccb9e --- /dev/null +++ b/lib/src/features/private_message/presentation/state/create_private_message_state.dart @@ -0,0 +1,74 @@ +part of 'create_private_message_cubit.dart'; + +const _createPrivateMessageStateUnset = Object(); + +/// Lifecycle status for the direct-message composer. +enum CreatePrivateMessageStatus { + initial, + searching, + submitting, + success, + error, +} + +/// State for composing and sending a direct message. +class CreatePrivateMessageState extends Equatable { + /// Creates direct-message compose state. + const CreatePrivateMessageState({ + this.status = CreatePrivateMessageStatus.initial, + this.recipient, + this.suggestions = const [], + this.content = '', + this.privateMessage, + this.message, + this.errorReason, + }); + + /// Current lifecycle status. + final CreatePrivateMessageStatus status; + + /// Selected recipient. + final ThunderUser? recipient; + + /// Recipient search suggestions. + final List suggestions; + + /// Markdown body content. + final String content; + + /// Message returned after a successful submit. + final ThunderPrivateMessage? privateMessage; + + /// User-visible status or error message. + final String? message; + + /// Structured error reason for recoverable failures. + final AppErrorReason? errorReason; + + /// Whether the current state can be submitted. + bool get canSubmit => recipient != null && content.trim().isNotEmpty && status != CreatePrivateMessageStatus.submitting; + + /// Creates a copy with updated fields. + CreatePrivateMessageState copyWith({ + CreatePrivateMessageStatus? status, + Object? recipient = _createPrivateMessageStateUnset, + List? suggestions, + String? content, + Object? privateMessage = _createPrivateMessageStateUnset, + Object? message = _createPrivateMessageStateUnset, + Object? errorReason = _createPrivateMessageStateUnset, + }) { + return CreatePrivateMessageState( + status: status ?? this.status, + recipient: identical(recipient, _createPrivateMessageStateUnset) ? this.recipient : recipient as ThunderUser?, + suggestions: suggestions ?? this.suggestions, + content: content ?? this.content, + privateMessage: identical(privateMessage, _createPrivateMessageStateUnset) ? this.privateMessage : privateMessage as ThunderPrivateMessage?, + message: identical(message, _createPrivateMessageStateUnset) ? this.message : message as String?, + errorReason: identical(errorReason, _createPrivateMessageStateUnset) ? this.errorReason : errorReason as AppErrorReason?, + ); + } + + @override + List get props => [status, recipient, suggestions, content, privateMessage, message, errorReason]; +} diff --git a/lib/src/features/private_message/presentation/state/private_message_thread_cubit.dart b/lib/src/features/private_message/presentation/state/private_message_thread_cubit.dart new file mode 100644 index 000000000..b74f0c95e --- /dev/null +++ b/lib/src/features/private_message/presentation/state/private_message_thread_cubit.dart @@ -0,0 +1,138 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; + +import 'package:thunder/src/features/private_message/data/repositories/private_message_repository.dart'; +import 'package:thunder/src/features/private_message/domain/utils/private_message_thread_utils.dart'; +import 'package:thunder/src/foundation/contracts/contracts.dart'; +import 'package:thunder/src/foundation/errors/errors.dart'; +import 'package:thunder/src/foundation/networking/networking.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; + +part 'private_message_thread_state.dart'; + +/// Coordinates loading and replying within a direct-message thread. +class PrivateMessageThreadCubit extends Cubit { + /// Creates a thread cubit for messages with [participant]. + PrivateMessageThreadCubit({ + required this.account, + required this.participant, + required PrivateMessageRepository repository, + List initialMessages = const [], + this.conversationId, + }) : _repository = repository, + super(PrivateMessageThreadState(messages: markIncomingPrivateMessagesRead(initialMessages, account))) { + if (initialMessages.isNotEmpty) { + unawaited(_markIncomingMessagesRead(initialMessages)); + } + } + + /// Account viewing the thread. + final Account account; + + /// User on the other side of the thread. + final ThunderUser participant; + final PrivateMessageRepository _repository; + + /// Optional server-provided conversation ID. + final int? conversationId; + + /// Loads the thread, optionally resetting pagination. + Future load({bool reset = true}) async { + try { + emit(state.copyWith(status: reset ? PrivateMessageThreadStatus.loading : PrivateMessageThreadStatus.refreshing, message: null, errorReason: null)); + + final response = await _repository.conversation( + personId: participant.id, + conversationId: conversationId, + page: reset ? 1 : state.page, + limit: 50, + ); + + final messages = markIncomingPrivateMessagesRead( + mergePrivateMessages(state.messages, response.isEmpty ? state.messages : response), + account, + ); + unawaited(_markIncomingMessagesRead(response)); + + emit(state.copyWith( + status: PrivateMessageThreadStatus.success, + messages: messages, + page: reset ? 2 : state.page + 1, + hasReachedEnd: response.length < 50, + message: null, + errorReason: null, + )); + } catch (e) { + final message = getExceptionErrorMessage(e); + emit(state.copyWith( + status: PrivateMessageThreadStatus.error, + message: message, + errorReason: AppErrorReason.unexpected(message: message, details: e.toString()), + )); + } + } + + /// Updates the inline quick-reply text. + void updateQuickReply(String value) { + if (value == state.quickReply) return; + emit(state.copyWith(quickReply: value, message: null, errorReason: null)); + } + + /// Clears the inline quick-reply text. + void clearQuickReply() { + emit(state.copyWith(quickReply: '')); + } + + /// Adds a newly sent message to the thread. + void appendMessage(ThunderPrivateMessage message) { + emit(state.copyWith( + status: PrivateMessageThreadStatus.success, + messages: mergePrivateMessages(state.messages, [message]), + quickReply: '', + message: null, + errorReason: null, + )); + } + + /// Sends the current quick reply. + Future sendQuickReply() async { + final content = state.quickReply.trim(); + if (content.isEmpty || state.status == PrivateMessageThreadStatus.sending) return null; + + try { + emit(state.copyWith(status: PrivateMessageThreadStatus.sending, message: null, errorReason: null)); + final privateMessage = await _repository.create(recipientId: participant.id, content: content); + + emit(state.copyWith( + status: PrivateMessageThreadStatus.success, + messages: mergePrivateMessages(state.messages, [privateMessage]), + quickReply: '', + message: null, + errorReason: null, + )); + + return privateMessage; + } catch (e) { + final message = getExceptionErrorMessage(e); + emit(state.copyWith( + status: PrivateMessageThreadStatus.error, + message: message, + errorReason: AppErrorReason.actionFailed(message: message, details: e.toString()), + )); + return null; + } + } + + Future _markIncomingMessagesRead(List messages) async { + for (final message in messages) { + if (message.read || !isIncomingPrivateMessage(message, account)) continue; + try { + await _repository.markAsRead(messageId: message.id); + } catch (_) { + // Keep the thread responsive; a later inbox refresh will reconcile failures. + } + } + } +} diff --git a/lib/src/features/private_message/presentation/state/private_message_thread_state.dart b/lib/src/features/private_message/presentation/state/private_message_thread_state.dart new file mode 100644 index 000000000..ea1dbc439 --- /dev/null +++ b/lib/src/features/private_message/presentation/state/private_message_thread_state.dart @@ -0,0 +1,75 @@ +part of 'private_message_thread_cubit.dart'; + +const _privateMessageThreadStateUnset = Object(); + +/// Lifecycle status for a direct-message thread. +enum PrivateMessageThreadStatus { + initial, + loading, + refreshing, + sending, + success, + error, +} + +/// State for a direct-message thread. +class PrivateMessageThreadState extends Equatable { + /// Creates direct-message thread state. + const PrivateMessageThreadState({ + this.status = PrivateMessageThreadStatus.initial, + this.messages = const [], + this.quickReply = '', + this.page = 1, + this.hasReachedEnd = false, + this.message, + this.errorReason, + }); + + /// Current lifecycle status. + final PrivateMessageThreadStatus status; + + /// Messages in this thread, sorted from oldest to newest. + final List messages; + + /// Inline quick-reply content. + final String quickReply; + + /// Next page to fetch. + final int page; + + /// Whether pagination has reached the end of the thread. + final bool hasReachedEnd; + + /// User-visible status or error message. + final String? message; + + /// Structured error reason for recoverable failures. + final AppErrorReason? errorReason; + + /// Whether the quick reply can be sent. + bool get canSendQuickReply => quickReply.trim().isNotEmpty && status != PrivateMessageThreadStatus.sending; + + /// Creates a copy with updated fields. + PrivateMessageThreadState copyWith({ + PrivateMessageThreadStatus? status, + List? messages, + String? quickReply, + int? page, + bool? hasReachedEnd, + Object? message = _privateMessageThreadStateUnset, + Object? errorReason = _privateMessageThreadStateUnset, + }) { + return PrivateMessageThreadState( + status: status ?? this.status, + messages: messages ?? this.messages, + quickReply: quickReply ?? this.quickReply, + page: page ?? this.page, + hasReachedEnd: hasReachedEnd ?? this.hasReachedEnd, + message: identical(message, _privateMessageThreadStateUnset) ? this.message : message as String?, + errorReason: identical(errorReason, _privateMessageThreadStateUnset) ? this.errorReason : errorReason as AppErrorReason?, + ); + } + + @override + List get props => [status, messages, quickReply, page, hasReachedEnd, message, errorReason]; +} diff --git a/lib/src/features/private_message/presentation/widgets/create_private_message/create_private_message_bottom_bar.dart b/lib/src/features/private_message/presentation/widgets/create_private_message/create_private_message_bottom_bar.dart new file mode 100644 index 000000000..765815e56 --- /dev/null +++ b/lib/src/features/private_message/presentation/widgets/create_private_message/create_private_message_bottom_bar.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; + +import 'package:markdown_editor/markdown_editor.dart'; + +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/src/features/private_message/presentation/state/create_private_message_cubit.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/shared/input_dialogs.dart'; +import 'package:thunder/src/shared/theme/color_utils.dart'; + +/// Bottom markdown toolbar and submit controls for the direct-message composer. +class CreatePrivateMessageBottomBar extends StatelessWidget { + /// Creates a bottom bar that mirrors the post/comment compose controls. + const CreatePrivateMessageBottomBar({ + super.key, + required this.account, + required this.controller, + required this.focusNode, + required this.showPreview, + required this.canSubmit, + required this.status, + required this.onTogglePreview, + required this.onSubmit, + }); + + /// Account used for username and community markdown lookups. + final Account account; + + /// Controls the message body input. + final TextEditingController controller; + + /// Focus node for the message body input. + final FocusNode focusNode; + + /// Whether the editor is currently showing markdown preview. + final bool showPreview; + + /// Whether the current compose state can be submitted. + final bool canSubmit; + + /// Current submission status. + final CreatePrivateMessageStatus status; + + /// Toggles between source and preview mode. + final VoidCallback onTogglePreview; + + /// Sends the direct message. + final VoidCallback onSubmit; + + @override + Widget build(BuildContext context) { + final l10n = GlobalContext.l10n; + final theme = Theme.of(context); + + return Container( + color: theme.cardColor, + margin: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: Row( + children: [ + Expanded( + child: IgnorePointer( + ignoring: showPreview, + child: MarkdownToolbar( + controller: controller, + focusNode: focusNode, + actions: const [ + MarkdownType.link, + MarkdownType.bold, + MarkdownType.italic, + MarkdownType.blockquote, + MarkdownType.strikethrough, + MarkdownType.title, + MarkdownType.list, + MarkdownType.separator, + MarkdownType.code, + MarkdownType.spoiler, + MarkdownType.username, + MarkdownType.community, + ], + customTapActions: { + MarkdownType.username: () => _insertUserMention(context), + MarkdownType.community: () => _insertCommunityMention(context), + }, + ), + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 2.0, top: 2.0, left: 4.0, right: 2.0), + child: IconButton( + onPressed: onTogglePreview, + icon: Icon( + showPreview ? Icons.visibility_off_rounded : Icons.visibility, + color: theme.colorScheme.onSecondary, + semanticLabel: l10n.postTogglePreview, + ), + style: ElevatedButton.styleFrom(backgroundColor: theme.colorScheme.secondaryContainer), + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 2.0, top: 2.0, left: 2.0, right: 8.0), + child: SizedBox( + width: 60, + child: IconButton( + onPressed: canSubmit ? onSubmit : null, + icon: status == CreatePrivateMessageStatus.submitting + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(), + ) + : Icon( + Icons.send_rounded, + color: theme.colorScheme.onSecondary, + semanticLabel: l10n.send, + ), + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.secondary, + disabledBackgroundColor: getBackgroundColor(context), + ), + ), + ), + ), + ], + ), + ); + } + + void _insertUserMention(BuildContext context) { + final l10n = GlobalContext.l10n; + + showUserInputDialog( + context, + title: l10n.username, + account: account, + onUserSelected: (user) { + controller.text = controller.text.replaceRange( + controller.selection.end, + controller.selection.end, + '[@${user.name}@${fetchInstanceNameFromUrl(user.actorId)}](${user.actorId})', + ); + }, + ); + } + + void _insertCommunityMention(BuildContext context) { + final l10n = GlobalContext.l10n; + + showCommunityInputDialog( + context, + title: l10n.community, + account: account, + onCommunitySelected: (community) { + controller.text = controller.text.replaceRange( + controller.selection.end, + controller.selection.end, + '!${community.name}@${fetchInstanceNameFromUrl(community.actorId)}', + ); + }, + ); + } +} diff --git a/lib/src/features/private_message/presentation/widgets/create_private_message/create_private_message_editor_section.dart b/lib/src/features/private_message/presentation/widgets/create_private_message/create_private_message_editor_section.dart new file mode 100644 index 000000000..fd17667d4 --- /dev/null +++ b/lib/src/features/private_message/presentation/widgets/create_private_message/create_private_message_editor_section.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; + +import 'package:markdown_editor/markdown_editor.dart'; + +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; +import 'package:thunder/src/shared/theme/color_utils.dart'; + +/// Displays the direct-message markdown editor or its rendered preview. +class CreatePrivateMessageEditorSection extends StatelessWidget { + /// Creates an editor section with the same layout as post/comment compose pages. + const CreatePrivateMessageEditorSection({ + super.key, + required this.controller, + required this.focusNode, + required this.showPreview, + }); + + /// Controls the message body input. + final TextEditingController controller; + + /// Focus node used to restore keyboard focus after preview toggles. + final FocusNode focusNode; + + /// Whether to show rendered markdown instead of the editor. + final bool showPreview; + + @override + Widget build(BuildContext context) { + final l10n = GlobalContext.l10n; + final theme = Theme.of(context); + + return AnimatedCrossFade( + firstChild: Padding( + padding: const EdgeInsets.all(8.0), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(8.0), + decoration: BoxDecoration( + color: getBackgroundColor(context), + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + ), + child: CommonMarkdownBody(body: controller.text, isComment: true), + ), + ), + secondChild: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: MarkdownTextInputField( + controller: controller, + focusNode: focusNode, + label: l10n.message(0), + minLines: 8, + maxLines: null, + textStyle: theme.textTheme.bodyLarge, + spellCheckConfiguration: const SpellCheckConfiguration.disabled(), + ), + ), + crossFadeState: showPreview ? CrossFadeState.showFirst : CrossFadeState.showSecond, + duration: const Duration(milliseconds: 120), + excludeBottomFocus: false, + ); + } +} diff --git a/lib/src/features/private_message/presentation/widgets/create_private_message/create_private_message_recipient_tile.dart b/lib/src/features/private_message/presentation/widgets/create_private_message/create_private_message_recipient_tile.dart new file mode 100644 index 000000000..405e60d30 --- /dev/null +++ b/lib/src/features/private_message/presentation/widgets/create_private_message/create_private_message_recipient_tile.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/src/features/user/user.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; + +/// A compact selector row for choosing the recipient of a direct message. +class CreatePrivateMessageRecipientTile extends StatelessWidget { + /// Creates a recipient selector matching the app's account selector style. + const CreatePrivateMessageRecipientTile({ + super.key, + required this.recipient, + required this.onTap, + }); + + /// The selected recipient, or null when the user still needs to choose one. + final ThunderUser? recipient; + + /// Opens recipient selection. + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final l10n = GlobalContext.l10n; + final recipient = this.recipient; + + return Transform.translate( + offset: const Offset(-8.0, 0), + child: InkWell( + borderRadius: const BorderRadius.all(Radius.circular(50.0)), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.only(left: 8.0, top: 4.0, bottom: 4.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + recipient == null ? _RecipientPlaceholder(label: l10n.selectRecipient) : UserIndicator(user: recipient), + const Icon(Icons.chevron_right_rounded), + ], + ), + ), + ), + ); + } +} + +class _RecipientPlaceholder extends StatelessWidget { + const _RecipientPlaceholder({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 40, + child: Row( + spacing: 12.0, + children: [ + const Icon(Icons.person_search_rounded), + Text(label), + ], + ), + ); + } +} diff --git a/lib/src/features/private_message/presentation/widgets/thread/private_message_bubble.dart b/lib/src/features/private_message/presentation/widgets/thread/private_message_bubble.dart new file mode 100644 index 000000000..c99f75087 --- /dev/null +++ b/lib/src/features/private_message/presentation/widgets/thread/private_message_bubble.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/src/foundation/contracts/contracts.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; +import 'package:thunder/src/shared/theme/color_utils.dart'; + +/// Chat bubble for one private message in a direct-message thread. +class PrivateMessageBubble extends StatefulWidget { + /// Creates a bubble aligned based on whether the message was sent by [account]. + const PrivateMessageBubble({ + super.key, + required this.account, + required this.message, + }); + + /// Account viewing the thread. + final Account account; + + /// Private message to render. + final ThunderPrivateMessage message; + + @override + State createState() => _PrivateMessageBubbleState(); +} + +class _PrivateMessageBubbleState extends State { + bool showFullDate = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final outgoing = widget.message.creatorId == widget.account.userId || widget.message.creator?.actorId == widget.account.actorId; + final color = outgoing ? theme.colorScheme.primaryContainer : getBackgroundColor(context); + final alignment = outgoing ? Alignment.centerRight : Alignment.centerLeft; + final textColor = outgoing ? theme.colorScheme.onPrimaryContainer : theme.colorScheme.onSurface; + + return Align( + alignment: alignment, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.78), + child: Column( + crossAxisAlignment: outgoing ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + Card( + color: color, + margin: const EdgeInsets.symmetric(vertical: 4), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 6), + child: DefaultTextStyle.merge( + style: TextStyle(color: textColor), + child: CommonMarkdownBody(body: widget.message.content, isComment: true), + ), + ), + ), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + setState(() => showFullDate = !showFullDate); + }, + child: Text( + showFullDate + ? '${MaterialLocalizations.of(context).formatMediumDate(widget.message.published)} · ${MaterialLocalizations.of(context).formatTimeOfDay(TimeOfDay.fromDateTime(widget.message.published))}' + : MaterialLocalizations.of(context).formatTimeOfDay(TimeOfDay.fromDateTime(widget.message.published)), + style: theme.textTheme.labelSmall?.copyWith(color: textColor.withValues(alpha: 0.7)), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/features/private_message/presentation/widgets/thread/quick_reply_bar.dart b/lib/src/features/private_message/presentation/widgets/thread/quick_reply_bar.dart new file mode 100644 index 000000000..6e0b3fc91 --- /dev/null +++ b/lib/src/features/private_message/presentation/widgets/thread/quick_reply_bar.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/src/foundation/config/global_context.dart'; + +/// Reply field for sending a short direct-messages. +class QuickReplyBar extends StatelessWidget { + /// Creates a compact reply bar with a shortcut to the full editor. + const QuickReplyBar({ + super.key, + required this.controller, + required this.canSend, + required this.sending, + required this.onOpenComposer, + required this.onSend, + }); + + /// Controls the quick reply text field. + final TextEditingController controller; + + /// Whether the reply can be sent. + final bool canSend; + + /// Whether a reply is currently being sent. + final bool sending; + + /// Opens the full direct-message editor. + final VoidCallback onOpenComposer; + + /// Sends the quick reply. + final VoidCallback onSend; + + @override + Widget build(BuildContext context) { + final l10n = GlobalContext.l10n; + final theme = Theme.of(context); + + return Container( + padding: EdgeInsets.fromLTRB(16, 4, 12, 8 + MediaQuery.paddingOf(context).bottom), + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(32.0), + ), + padding: const EdgeInsets.only(left: 0, right: 12, top: 4, bottom: 4), + child: Row( + children: [ + IconButton( + constraints: const BoxConstraints(minWidth: 20, minHeight: 20), + onPressed: onOpenComposer, + icon: Icon(Icons.add_circle_outline_rounded, semanticLabel: l10n.message(0)), + ), + Expanded( + child: TextField( + controller: controller, + minLines: 1, + maxLines: 4, + decoration: InputDecoration.collapsed(hintText: l10n.message(0)), + textInputAction: TextInputAction.newline, + ), + ), + ], + ), + ), + ), + const SizedBox(width: 4), + IconButton.filledTonal( + onPressed: canSend ? onSend : null, + icon: sending ? const SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2)) : Icon(Icons.send_rounded, semanticLabel: l10n.send, size: 22), + ), + ], + ), + ); + } +} diff --git a/lib/src/features/private_message/private_message.dart b/lib/src/features/private_message/private_message.dart new file mode 100644 index 000000000..e8204be28 --- /dev/null +++ b/lib/src/features/private_message/private_message.dart @@ -0,0 +1,7 @@ +export 'data/repositories/private_message_repository.dart'; +export 'domain/models/private_message_thread.dart'; +export 'domain/utils/private_message_thread_utils.dart'; +export 'presentation/pages/create_private_message_page.dart'; +export 'presentation/pages/private_message_thread_page.dart'; +export 'presentation/state/create_private_message_cubit.dart'; +export 'presentation/state/private_message_thread_cubit.dart'; diff --git a/lib/src/features/user/presentation/widgets/user_action_bottom_sheet.dart b/lib/src/features/user/presentation/widgets/user_action_bottom_sheet.dart index dd508f6f4..bfdbd6d3c 100644 --- a/lib/src/features/user/presentation/widgets/user_action_bottom_sheet.dart +++ b/lib/src/features/user/presentation/widgets/user_action_bottom_sheet.dart @@ -7,6 +7,7 @@ import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/features/user/user.dart'; import 'package:thunder/src/features/post/post.dart'; import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_private_message.dart'; import 'package:thunder/src/shared/avatars/user_avatar.dart'; import 'package:thunder/src/shared/chips/user_chip.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; @@ -25,6 +26,11 @@ enum UserBottomSheetAction { permissionType: PermissionType.user, requiresAuthentication: true, ), + messageUser( + icon: Icons.mail_rounded, + permissionType: PermissionType.user, + requiresAuthentication: true, + ), unblockUser( icon: Icons.block_rounded, permissionType: PermissionType.user, @@ -59,6 +65,7 @@ enum UserBottomSheetAction { String get name => switch (this) { UserBottomSheetAction.viewProfile => GlobalContext.l10n.visitUserProfile, UserBottomSheetAction.blockUser => GlobalContext.l10n.blockUser, + UserBottomSheetAction.messageUser => GlobalContext.l10n.message(0), UserBottomSheetAction.unblockUser => GlobalContext.l10n.unblockUser, UserBottomSheetAction.addUserLabel => GlobalContext.l10n.addUserLabel, UserBottomSheetAction.banUserFromCommunity => GlobalContext.l10n.banFromCommunity, @@ -147,6 +154,14 @@ class _UserActionBottomSheetState extends State { showSnackbar(l10n.successfullyBlockedUser(widget.user.displayNameOrName)); widget.onAction(UserAction.block, null); break; + case UserBottomSheetAction.messageUser: + Navigator.of(context).pop(); + Future.microtask(() { + if (widget.context.mounted) { + navigateToCreatePrivateMessagePage(widget.context, account: widget.account, recipient: widget.user); + } + }); + break; case UserBottomSheetAction.unblockUser: Navigator.of(context).pop(); await userRepository.block(widget.user.id, false); @@ -266,7 +281,7 @@ class _UserActionBottomSheetState extends State { userActions = userActions.where((action) => action.requiresAuthentication == false).toList(); } else { if (widget.account.username == widget.user.name && widget.account.instance == fetchInstanceNameFromUrl(widget.user.actorId)) { - userActions = userActions.where((action) => action != UserBottomSheetAction.blockUser && action != UserBottomSheetAction.unblockUser).toList(); + userActions = userActions.where((action) => action != UserBottomSheetAction.blockUser && action != UserBottomSheetAction.unblockUser && action != UserBottomSheetAction.messageUser).toList(); moderatorActions = moderatorActions.where((action) => action != UserBottomSheetAction.addUserAsCommunityModerator && action != UserBottomSheetAction.removeUserAsCommunityModerator).toList(); } diff --git a/lib/src/features/user/presentation/widgets/user_header/user_header_actions.dart b/lib/src/features/user/presentation/widgets/user_header/user_header_actions.dart index af8d978a5..7eb319b3c 100644 --- a/lib/src/features/user/presentation/widgets/user_header/user_header_actions.dart +++ b/lib/src/features/user/presentation/widgets/user_header/user_header_actions.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_private_message.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/features/post/post.dart'; @@ -117,6 +118,7 @@ class _ActionChipsList extends StatelessWidget { if (isOwnProfile) _SavedActionChip(), _SortActionChip(), if (!isOwnProfile) _LabelActionChip(user: user), + if (isLoggedIn && !isOwnProfile) _MessageActionChip(user: user), if (isLoggedIn && !isOwnProfile && user.isAdmin != true) _BlockActionChip(user: user), _ShareActionChip(user: user), ], @@ -179,6 +181,28 @@ class _SavedActionChipState extends State<_SavedActionChip> { } } +class _MessageActionChip extends StatelessWidget { + const _MessageActionChip({required this.user}); + + /// User to display actions for + final ThunderUser user; + + @override + Widget build(BuildContext context) { + final l10n = GlobalContext.l10n; + final account = context.read().state.account; + + return ThunderActionChip( + icon: Icons.mail_rounded, + label: l10n.message(0), + onPressed: () { + HapticFeedback.mediumImpact(); + navigateToCreatePrivateMessagePage(context, account: account, recipient: user); + }, + ); + } +} + /// Action chip for selecting feed type (posts/comments). class _FeedTypeActionChip extends StatelessWidget { /// The current feed type (posts/comments) diff --git a/lib/src/features/user/presentation/widgets/user_list_entry.dart b/lib/src/features/user/presentation/widgets/user_list_entry.dart index f7e9e138e..784879126 100644 --- a/lib/src/features/user/presentation/widgets/user_list_entry.dart +++ b/lib/src/features/user/presentation/widgets/user_list_entry.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/app/shell/navigation/navigation_private_message.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/settings/api.dart'; @@ -10,6 +13,7 @@ import 'package:thunder/src/shared/name/full_name_widgets.dart'; import 'package:thunder/src/features/user/user.dart'; import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; /// A widget that can display a single user entry for use within a list (e.g., search page, instance explorer) class UserListEntry extends StatelessWidget { @@ -23,6 +27,13 @@ class UserListEntry extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = GlobalContext.l10n; + + final isLoggedIn = context.select((bloc) => bloc.state.isLoggedIn); + final account = context.select((bloc) => bloc.state.account); + + final canMessage = isLoggedIn && account.userId != user.id; + return Tooltip( excludeFromSemantics: true, message: '${user.displayNameOrName}\n${generateUserFullName( @@ -48,6 +59,12 @@ class UserListEntry extends StatelessWidget { ), ], ), + trailing: canMessage + ? IconButton( + icon: Icon(Icons.mail_rounded, semanticLabel: l10n.message(0), size: 20.0), + onPressed: () => navigateToCreatePrivateMessagePage(context, account: account, recipient: user), + ) + : null, onTap: () async { int? userId = user.id; diff --git a/lib/src/foundation/networking/lemmy/base_lemmy_api_client.dart b/lib/src/foundation/networking/lemmy/base_lemmy_api_client.dart index d9c4a130f..2a45df90a 100644 --- a/lib/src/foundation/networking/lemmy/base_lemmy_api_client.dart +++ b/lib/src/foundation/networking/lemmy/base_lemmy_api_client.dart @@ -475,12 +475,50 @@ abstract class BaseLemmyApiClient extends BaseApiClient implements ThunderApiCli bool unread = false, int? creatorId, }) async { - throw UnimplementedError('Lemmy endpoints are implemented in version-specific clients.'); + final json = await request(HttpMethod.get, '$basePath/private_message/list', { + 'page': page, + 'limit': limit, + 'unread_only': unread, + 'creator_id': creatorId, + }); + return (json['private_messages'] as List).map((pm) => ThunderPrivateMessage.fromLemmyPrivateMessageView(pm)).toList(); } @override Future markPrivateMessageAsRead({required int messageId, required bool read}) async { - throw UnimplementedError('Lemmy endpoints are implemented in version-specific clients.'); + await request(HttpMethod.post, '$basePath/private_message/mark_as_read', { + 'private_message_id': messageId, + 'read': read, + }); + } + + @override + Future createPrivateMessage({required int recipientId, required String content}) async { + final json = await request(HttpMethod.post, '$basePath/private_message', { + 'recipient_id': recipientId, + 'content': content, + }); + return ThunderPrivateMessage.fromLemmyPrivateMessageView(json['private_message_view']); + } + + @override + Future> getPrivateMessageConversation({ + required int personId, + int? conversationId, + int? page, + int? limit, + }) async { + final messages = await getPrivateMessages(page: page, limit: limit); + final currentUserId = account.userId; + + return messages.where((message) { + final creatorMatches = message.creatorId == personId; + final recipientMatches = message.recipientId == personId; + final sentByCurrentUser = currentUserId != null && message.creatorId == currentUserId; + final receivedByCurrentUser = currentUserId != null && message.recipientId == currentUserId; + + return (creatorMatches && receivedByCurrentUser) || (recipientMatches && sentByCurrentUser); + }).toList(); } // ============================================================= diff --git a/lib/src/foundation/networking/lemmy/lemmy_v3_api_client.dart b/lib/src/foundation/networking/lemmy/lemmy_v3_api_client.dart index 11eb0e838..fdef3abd5 100644 --- a/lib/src/foundation/networking/lemmy/lemmy_v3_api_client.dart +++ b/lib/src/foundation/networking/lemmy/lemmy_v3_api_client.dart @@ -1279,6 +1279,7 @@ class LemmyV3ApiClient extends BaseLemmyApiClient { } // Private messages + @override Future createPrivateMessage({required int recipientId, required String content}) async { final json = await request(HttpMethod.post, '$basePath/private_message', { 'recipient_id': recipientId, diff --git a/lib/src/foundation/networking/piefed/piefed_api_client.dart b/lib/src/foundation/networking/piefed/piefed_api_client.dart index 28a9fda4a..fdf0056d0 100644 --- a/lib/src/foundation/networking/piefed/piefed_api_client.dart +++ b/lib/src/foundation/networking/piefed/piefed_api_client.dart @@ -1233,6 +1233,7 @@ class PiefedApiClient extends BaseApiClient implements ThunderApiClient { } /// Create a private message. + @override Future createPrivateMessage({required int recipientId, required String content}) async { final json = await request(HttpMethod.post, '$basePath/private_message', { 'recipient_id': recipientId, @@ -1269,8 +1270,9 @@ class PiefedApiClient extends BaseApiClient implements ThunderApiClient { } /// Get private message conversation. + @override Future> getPrivateMessageConversation({ - int? personId, + required int personId, int? conversationId, int? page, int? limit, @@ -1470,6 +1472,9 @@ class PiefedApiClient extends BaseApiClient implements ThunderApiClient { return ThunderPrivateMessage( id: privateMessage['id'], + creatorId: privateMessage['creator_id'], + recipientId: privateMessage['recipient_id'], + conversationId: privateMessageView['conversation_id'], content: privateMessage['content'], deleted: privateMessage['deleted'], read: privateMessage['read'], diff --git a/lib/src/foundation/networking/thunder_api_client.dart b/lib/src/foundation/networking/thunder_api_client.dart index 21f3f8626..20a76a32a 100644 --- a/lib/src/foundation/networking/thunder_api_client.dart +++ b/lib/src/foundation/networking/thunder_api_client.dart @@ -430,6 +430,20 @@ abstract class ThunderApiClient { /// Mark a private message as read. Future markPrivateMessageAsRead({required int messageId, required bool read}); + /// Create a private message. + Future createPrivateMessage({ + required int recipientId, + required String content, + }); + + /// Get private messages for a conversation with a user. + Future> getPrivateMessageConversation({ + required int personId, + int? conversationId, + int? page, + int? limit, + }); + // ============================================================= // Account Settings // ============================================================= diff --git a/lib/src/foundation/primitives/models/thunder_private_message.dart b/lib/src/foundation/primitives/models/thunder_private_message.dart index ab5278a8f..db7aa4cfc 100644 --- a/lib/src/foundation/primitives/models/thunder_private_message.dart +++ b/lib/src/foundation/primitives/models/thunder_private_message.dart @@ -1,9 +1,19 @@ import 'package:thunder/src/foundation/primitives/models/thunder_user.dart'; +/// Cross-platform representation of a Lemmy or PieFed private message. class ThunderPrivateMessage { /// The private message's ID. final int id; + /// The message creator's user ID. + final int? creatorId; + + /// The message recipient's user ID. + final int? recipientId; + + /// The conversation ID for platforms that expose one. + final int? conversationId; + /// The private message's content. final String content; @@ -22,8 +32,12 @@ class ThunderPrivateMessage { /// The private message's creator. final ThunderUser? creator; + /// Creates a private-message model. ThunderPrivateMessage({ required this.id, + this.creatorId, + this.recipientId, + this.conversationId, required this.content, required this.deleted, required this.read, @@ -32,8 +46,12 @@ class ThunderPrivateMessage { this.creator, }); + /// Creates a copy with updated fields. ThunderPrivateMessage copyWith({ int? id, + int? creatorId, + int? recipientId, + int? conversationId, String? content, bool? deleted, bool? read, @@ -43,6 +61,9 @@ class ThunderPrivateMessage { }) { return ThunderPrivateMessage( id: id ?? this.id, + creatorId: creatorId ?? this.creatorId, + recipientId: recipientId ?? this.recipientId, + conversationId: conversationId ?? this.conversationId, content: content ?? this.content, deleted: deleted ?? this.deleted, read: read ?? this.read, @@ -52,9 +73,12 @@ class ThunderPrivateMessage { ); } + /// Parses a Lemmy private-message payload without associated user data. factory ThunderPrivateMessage.fromLemmyPrivateMessage(Map privateMessage) { return ThunderPrivateMessage( id: privateMessage['id'], + creatorId: privateMessage['creator_id'], + recipientId: privateMessage['recipient_id'], content: privateMessage['content'], deleted: privateMessage['deleted'], read: privateMessage['read'], @@ -62,6 +86,7 @@ class ThunderPrivateMessage { ); } + /// Parses a Lemmy private-message view with creator and recipient data. factory ThunderPrivateMessage.fromLemmyPrivateMessageView(Map privateMessageView) { final privateMessage = privateMessageView['private_message']; final recipient = privateMessageView['recipient']; @@ -69,6 +94,9 @@ class ThunderPrivateMessage { return ThunderPrivateMessage( id: privateMessage['id'], + creatorId: privateMessage['creator_id'], + recipientId: privateMessage['recipient_id'], + conversationId: privateMessageView['conversation_id'], content: privateMessage['content'], deleted: privateMessage['deleted'], read: privateMessage['read'],