LibIMAP+Mail: Show unseen message count for mailboxes

This PR implements the standard behavior of displaying the mailbox name
and parenthesized unseen message count in bold when the unseen message
count is greater than zero.
This commit is contained in:
Alec Murphy
2024-08-24 15:03:19 -04:00
committed by Nico Weber
parent 4ac1ad48a2
commit d73bad14ea
12 changed files with 123 additions and 26 deletions

View File

@@ -74,3 +74,25 @@ void AccountHolder::rebuild_tree()
{
m_mailbox_tree_model->invalidate();
}
void MailboxNode::update_display_name_with_unseen_count()
{
m_display_name_with_unseen_count = ByteString::formatted("{} ({})", m_display_name, m_unseen_count);
}
void MailboxNode::decrement_unseen_count()
{
if (m_unseen_count)
set_unseen_count(m_unseen_count - 1);
}
void MailboxNode::increment_unseen_count()
{
set_unseen_count(m_unseen_count + 1);
}
void MailboxNode::set_unseen_count(unsigned unseen_count)
{
m_unseen_count = unseen_count;
update_display_name_with_unseen_count();
}

View File

@@ -58,6 +58,7 @@ public:
AccountNode const& associated_account() const { return m_associated_account; }
ByteString const& select_name() const { return m_mailbox.name; }
ByteString const& display_name() const { return m_display_name; }
ByteString const& display_name_with_unseen_count() const { return m_display_name_with_unseen_count; }
IMAP::ListItem const& mailbox() const { return m_mailbox; }
bool has_parent() const { return m_parent; }
@@ -68,6 +69,11 @@ public:
Vector<NonnullRefPtr<MailboxNode>> const& children() const { return m_children; }
void add_child(NonnullRefPtr<MailboxNode> child) { m_children.append(child); }
unsigned unseen_count() const { return m_unseen_count; }
void decrement_unseen_count();
void increment_unseen_count();
void set_unseen_count(unsigned unseen_count);
private:
MailboxNode(AccountNode const& associated_account, IMAP::ListItem const& mailbox, ByteString display_name)
: m_associated_account(associated_account)
@@ -76,9 +82,13 @@ private:
{
}
void update_display_name_with_unseen_count();
AccountNode const& m_associated_account;
IMAP::ListItem m_mailbox;
ByteString m_display_name;
ByteString m_display_name_with_unseen_count;
unsigned m_unseen_count;
Vector<NonnullRefPtr<MailboxNode>> m_children;
RefPtr<MailboxNode> m_parent;

View File

@@ -13,9 +13,14 @@ InboxModel::InboxModel(Vector<InboxEntry> entries)
{
}
void InboxModel::set_seen(int row, bool seen)
MailStatus InboxModel::mail_status(int row)
{
m_entries[row].seen = seen;
return m_entries[row].status;
}
void InboxModel::set_mail_status(int row, MailStatus status)
{
m_entries[row].status = status;
did_update(DontInvalidateIndices);
}
@@ -53,10 +58,8 @@ GUI::Variant InboxModel::data(GUI::ModelIndex const& index, GUI::ModelRole role)
if (index.column() == Column::Date)
return Gfx::TextAlignment::CenterRight;
}
if (role == GUI::ModelRole::Font) {
if (!value.seen)
return Gfx::FontDatabase::default_font().bold_variant();
}
if (role == GUI::ModelRole::Font && value.status == MailStatus::Unseen)
return Gfx::FontDatabase::default_font().bold_variant();
if (role == static_cast<GUI::ModelRole>(InboxModelCustomRole::Sequence))
return value.sequence_number;
return {};

View File

@@ -10,12 +10,17 @@
#include <LibGUI/Model.h>
#include <LibIMAP/Objects.h>
enum class MailStatus {
Unseen,
Seen,
};
struct InboxEntry {
u32 sequence_number;
ByteString date;
ByteString from;
ByteString subject;
bool seen;
MailStatus status;
};
enum class InboxModelCustomRole {
@@ -39,7 +44,8 @@ public:
virtual ~InboxModel() override = default;
void set_seen(int row, bool);
MailStatus mail_status(int row);
void set_mail_status(int row, MailStatus status);
virtual int row_count(const GUI::ModelIndex& = GUI::ModelIndex()) const override;
virtual int column_count(const GUI::ModelIndex& = GUI::ModelIndex()) const override { return Column::__Count; }

View File

@@ -7,6 +7,7 @@
*/
#include "MailWidget.h"
#include "InboxModel.h"
#include <AK/Base64.h>
#include <AK/GenericLexer.h>
#include <Applications/Mail/MailWindowGML.h>
@@ -100,6 +101,31 @@ MailWidget::MailWidget()
};
}
MailboxNode* MailWidget::get_mailbox_by_name(ByteString const& username, ByteString const& mailbox_name)
{
for (auto& account : m_account_holder->accounts()) {
if (account->name() == username) {
for (auto& mailbox : account->mailboxes()) {
if (mailbox->select_name() == mailbox_name)
return mailbox;
}
}
}
VERIFY_NOT_REACHED();
}
ErrorOr<void> MailWidget::refresh_unseen_count_for_mailbox(MailboxNode* mailbox)
{
auto response = TRY(m_imap_client->status(mailbox->select_name(), { IMAP::StatusItemType::Unseen, IMAP::StatusItemType::Messages })->await());
if (response.status() != IMAP::ResponseStatus::OK) {
dbgln("Failed to get mailbox status. The server says: '{}'", response.response_text());
return {};
}
if (response.data().status_items().size() > 0)
mailbox->set_unseen_count(response.data().status_items()[0].get(IMAP::StatusItemType::Unseen));
return {};
}
ErrorOr<bool> MailWidget::connect_and_login()
{
auto server = Config::read_string("Mail"sv, "Connection"sv, "Server"sv, {});
@@ -150,7 +176,7 @@ ErrorOr<bool> MailWidget::connect_and_login()
}
m_statusbar->set_text("Logged in. Loading mailboxes..."_string);
response = TRY(m_imap_client->list(""sv, "*"sv)->await());
response = TRY(m_imap_client->list(""sv, "*"sv, true)->await());
if (response.status() != IMAP::ResponseStatus::OK) {
dbgln("Failed to retrieve mailboxes. The server says: '{}'", response.response_text());
@@ -160,14 +186,21 @@ ErrorOr<bool> MailWidget::connect_and_login()
auto& list_items = response.data().list_items();
m_statusbar->set_text(MUST(String::formatted("Loaded {} mailboxes", list_items.size())));
m_account_holder = AccountHolder::create();
m_account_holder->add_account_with_name_and_mailboxes(username, move(list_items));
m_statusbar->set_text(MUST(String::formatted("Loaded {} mailboxes", list_items.size())));
m_mailbox_list->set_model(m_account_holder->mailbox_tree_model());
m_mailbox_list->expand_tree();
auto& status_items = response.data().status_items();
for (auto& status_item : status_items) {
auto mailbox = get_mailbox_by_name(username, status_item.mailbox());
mailbox->set_unseen_count(status_item.get(IMAP::StatusItemType::Unseen));
}
return true;
}
@@ -263,6 +296,8 @@ void MailWidget::selected_mailbox(GUI::ModelIndex const& index)
auto& mailbox_node = verify_cast<MailboxNode>(base_node);
auto& mailbox = mailbox_node.mailbox();
m_selected_mailbox_node = mailbox_node;
// FIXME: It would be better if we didn't allow the user to click on this mailbox node at all.
if (mailbox.flags & (unsigned)IMAP::MailboxFlag::NoSelect)
return;
@@ -350,12 +385,14 @@ void MailWidget::selected_mailbox(GUI::ModelIndex const& index)
}
ByteString from = sender_builder.to_byte_string();
InboxEntry inbox_entry { sequence_number, date, from, subject, seen };
InboxEntry inbox_entry { sequence_number, date, from, subject, seen ? MailStatus::Seen : MailStatus::Unseen };
m_statusbar->set_text(String::formatted("[{}]: Loading entry {}", mailbox.name, ++i).release_value_but_fixme_should_propagate_errors());
active_inbox_entries.append(inbox_entry);
}
(void)refresh_unseen_count_for_mailbox(m_selected_mailbox_node);
m_statusbar->set_text(String::formatted("[{}]: Loaded {} entries", mailbox.name, i).release_value_but_fixme_should_propagate_errors());
m_mailbox_model = InboxModel::create(move(active_inbox_entries));
m_mailbox_sorting_model = MUST(GUI::SortingProxyModel::create(*m_mailbox_model));
@@ -467,7 +504,11 @@ void MailWidget::selected_email_to_load(GUI::ModelIndex const& index)
auto& fetch_response_data = fetch_data.last().get<IMAP::FetchResponseData>();
auto seen = !fetch_response_data.flags().find_if([](StringView value) { return value.equals_ignoring_ascii_case("\\Seen"sv); }).is_end();
m_mailbox_model->set_seen(index.row(), seen);
if (m_mailbox_model->mail_status(index.row()) != (seen ? MailStatus::Seen : MailStatus::Unseen)) {
seen ? m_selected_mailbox_node->decrement_unseen_count() : m_selected_mailbox_node->increment_unseen_count();
m_mailbox_list->repaint();
}
m_mailbox_model->set_mail_status(index.row(), seen ? MailStatus::Seen : MailStatus::Unseen);
if (!fetch_response_data.contains_response_type(IMAP::FetchResponseType::Body)) {
GUI::MessageBox::show_error(window(), "The server sent no body."sv);

View File

@@ -20,12 +20,14 @@ public:
virtual ~MailWidget() override = default;
ErrorOr<bool> connect_and_login();
ErrorOr<void> refresh_unseen_count_for_mailbox(MailboxNode* mailbox);
void on_window_close();
private:
MailWidget();
MailboxNode* get_mailbox_by_name(ByteString const& username, ByteString const& mailbox_name);
void selected_mailbox(GUI::ModelIndex const&);
void selected_email_to_load(GUI::ModelIndex const&);
@@ -46,6 +48,7 @@ private:
RefPtr<GUI::SortingProxyModel> m_mailbox_sorting_model;
RefPtr<GUI::TableView> m_individual_mailbox_view;
RefPtr<WebView::OutOfProcessWebView> m_web_view;
RefPtr<MailboxNode> m_selected_mailbox_node;
RefPtr<GUI::Statusbar> m_statusbar;
RefPtr<GUI::Menu> m_link_context_menu;

View File

@@ -7,6 +7,7 @@
#include "MailboxTreeModel.h"
#include "AccountHolder.h"
#include <LibGfx/Font/FontDatabase.h>
MailboxTreeModel::MailboxTreeModel(AccountHolder const& account_holder)
: m_account_holder(account_holder)
@@ -95,7 +96,13 @@ GUI::Variant MailboxTreeModel::data(GUI::ModelIndex const& index, GUI::ModelRole
}
auto& mailbox_node = verify_cast<MailboxNode>(base_node);
return mailbox_node.display_name();
return mailbox_node.unseen_count() ? mailbox_node.display_name_with_unseen_count() : mailbox_node.display_name();
}
if (role == GUI::ModelRole::Font && is<MailboxNode>(base_node)) {
auto& mailbox_node = verify_cast<MailboxNode>(base_node);
if (mailbox_node.unseen_count())
return Gfx::FontDatabase::default_font().bold_variant();
}
if (role == GUI::ModelRole::Icon) {

View File

@@ -204,11 +204,12 @@ NonnullRefPtr<Promise<SolidResponse>> Client::login(StringView username, StringV
return cast_promise<SolidResponse>(send_command(move(command)));
}
NonnullRefPtr<Promise<SolidResponse>> Client::list(StringView reference_name, StringView mailbox)
NonnullRefPtr<Promise<SolidResponse>> Client::list(StringView reference_name, StringView mailbox, bool unseen)
{
auto command = Command { CommandType::List, m_current_command,
{ ByteString::formatted("\"{}\"", reference_name),
ByteString::formatted("\"{}\"", mailbox) } };
ByteString::formatted("\"{}\"", mailbox),
unseen ? "RETURN (STATUS (UNSEEN))" : "" } };
return cast_promise<SolidResponse>(send_command(move(command)));
}
@@ -276,8 +277,10 @@ ErrorOr<void> Client::send_next_command()
buffer.append(command_type.data(), command_type.size());
for (auto& arg : command.args) {
buffer.append(" ", 1);
buffer.append(arg.bytes().data(), arg.length());
if (arg.length()) {
buffer.append(" ", 1);
buffer.append(arg.bytes().data(), arg.length());
}
}
TRY(send_raw(buffer));

View File

@@ -34,7 +34,7 @@ public:
NonnullRefPtr<Promise<Response>> send_simple_command(CommandType);
ErrorOr<void> send_raw(StringView data);
NonnullRefPtr<Promise<SolidResponse>> login(StringView username, StringView password);
NonnullRefPtr<Promise<SolidResponse>> list(StringView reference_name, StringView mailbox_name);
NonnullRefPtr<Promise<SolidResponse>> list(StringView reference_name, StringView mailbox_name, bool unseen = false);
NonnullRefPtr<Promise<SolidResponse>> lsub(StringView reference_name, StringView mailbox_name);
NonnullRefPtr<Promise<SolidResponse>> select(StringView string);
NonnullRefPtr<Promise<SolidResponse>> examine(StringView string);

View File

@@ -685,15 +685,16 @@ public:
return m_bye_message;
}
void set_status(StatusItem&& status_item)
void add_status_item(StatusItem&& item)
{
add_response_type(ResponseType::Status);
m_status_item = move(status_item);
m_status_items.append(move(item));
}
StatusItem& status_item()
Vector<StatusItem>& status_items()
{
return m_status_item;
VERIFY(contains_response_type(ResponseType::Status));
return m_status_items;
}
private:
@@ -702,6 +703,7 @@ private:
Vector<ByteString> m_capabilities;
Vector<ListItem> m_list_items;
Vector<ListItem> m_lsub_items;
Vector<StatusItem> m_status_items;
Vector<unsigned> m_expunged;
unsigned m_recent {};
@@ -715,7 +717,6 @@ private:
Vector<Tuple<unsigned, FetchResponseData>> m_fetch_responses;
Vector<unsigned> m_search_results;
Optional<ByteString> m_bye_message;
StatusItem m_status_item;
};
enum class StoreMethod {

View File

@@ -255,7 +255,7 @@ ErrorOr<void> Parser::parse_untagged()
if (!at_end() && m_buffer[m_position] != ')')
TRY(consume(" "sv));
}
m_response.data().set_status(move(status_item));
m_response.data().add_status_item(move(status_item));
consume_if(" "sv); // Not in the spec but the Outlook server sends a space for some reason.
TRY(consume("\r\n"sv));
} else {

View File

@@ -86,7 +86,8 @@ ErrorOr<int> serenity_main(Main::Arguments arguments)
outln("[SEARCH] Number of results: {}", search_results.size());
response = TRY(client->status("INBOX"sv, { IMAP::StatusItemType::Recent, IMAP::StatusItemType::Messages })->await());
outln("[STATUS] Recent items: {}", response.data().status_item().get(IMAP::StatusItemType::Recent));
if (response.data().status_items().size() > 0)
outln("[STATUS] Recent items: {}", response.data().status_items()[0].get(IMAP::StatusItemType::Recent));
for (auto item : search_results) {
// clang-format off