diff --git a/CHANGELOG.md b/CHANGELOG.md index 942485b2..f72999d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - Minor: Add a setting under Moderation -> Logs to customize the timestamp used for chat logs. (#6338) - Minor: Add a setting under Moderation -> Logs to use server timestamp from the message instead of the local clock time for logging. (#6346) - Minor: Add feature to search for only zero-width emotes when prepended by `:~`. (#6362) +- Minor: The follow and sub dates now show the duration in a tooltip. (#6384) - Minor: Usercards now show a live indicator if the user is currently streaming. (#6383) - Bugfix: Commands are no longer tab-completable in the middle of messages. (#6273) - Bugfix: Automatic streamer mode detection now works from Flatpak. (#6250) diff --git a/src/util/FormatTime.cpp b/src/util/FormatTime.cpp index 2c5d98e5..9bb3b141 100644 --- a/src/util/FormatTime.cpp +++ b/src/util/FormatTime.cpp @@ -1,5 +1,10 @@ #include "util/FormatTime.hpp" +#include "common/QLogging.hpp" + +#include +#include + #include #include @@ -7,7 +12,9 @@ namespace chatterino { namespace { -void appendDuration(int count, QChar &&suffix, QString &out) +using namespace Qt::Literals; + +void appendShortDuration(int count, QChar suffix, QString &out) { if (!out.isEmpty()) { @@ -17,6 +24,101 @@ void appendDuration(int count, QChar &&suffix, QString &out) out.append(suffix); } +std::pair yearsBetween( + boost::gregorian::date first, boost::gregorian::date second) +{ + uint16_t years = 0; + boost::gregorian::date lastOk = first; + boost::gregorian::year_iterator yit(first); + ++yit; // skip one year + for (; *yit <= second; ++yit) + { + years++; + lastOk = *yit; + } + return {years, lastOk}; +} + +std::pair monthsBetween( + boost::gregorian::date first, boost::gregorian::date second) +{ + uint16_t months = 0; + boost::gregorian::date lastOk = first; + boost::gregorian::month_iterator mit(first); + ++mit; // skip one month + for (; *mit <= second; ++mit) + { + months++; + lastOk = *mit; + } + return {months, lastOk}; +} + +struct BalancedDuration { + uint16_t years = 0; + uint16_t months = 0; + uint16_t days = 0; + uint16_t hours = 0; + uint16_t minutes = 0; + uint16_t seconds = 0; + + using Component = std::pair; + constexpr static std::array COMPONENTS{ + std::pair{&BalancedDuration::years, u"year"}, + {&BalancedDuration::months, u"month"}, + {&BalancedDuration::days, u"day"}, + {&BalancedDuration::hours, u"hour"}, + {&BalancedDuration::minutes, u"minute"}, + {&BalancedDuration::seconds, u"second"}, + }; + + uint8_t components() const + { + uint8_t n = 0; + for (const auto &[ptr, _name] : COMPONENTS) + { + if (this->*ptr != 0) + { + n++; + } + } + return n; + } +}; + +BalancedDuration durationBetween(const QDateTime &a, const QDateTime &b) +{ + auto fromDT = boost::posix_time::from_time_t(a.toSecsSinceEpoch()); + auto toDT = boost::posix_time::from_time_t(b.toSecsSinceEpoch()); + if (fromDT > toDT) + { + std::swap(fromDT, toDT); + } + + auto fromD = fromDT.date(); + auto toD = toDT.date(); + + if (toDT.time_of_day() < fromDT.time_of_day()) + { + toD -= boost::gregorian::date_duration(1); + } + + auto [years, yd] = yearsBetween(fromD, toD); + auto [months, md] = monthsBetween(yd, toD); + + auto rem = toDT - boost::posix_time::ptime(md, fromDT.time_of_day()); + auto hoursAndDays = rem.hours(); + + return { + .years = years, + .months = months, + .days = static_cast(hoursAndDays / 24), + .hours = static_cast(hoursAndDays % 24), + .minutes = static_cast(rem.minutes() % 60), + .seconds = static_cast(rem.seconds() % 60), + }; +} + } // namespace QString formatTime(int totalSeconds) @@ -31,19 +133,19 @@ QString formatTime(int totalSeconds) int days = timeoutHours / 24; if (days > 0) { - appendDuration(days, 'd', res); + appendShortDuration(days, 'd', res); } if (hours > 0) { - appendDuration(hours, 'h', res); + appendShortDuration(hours, 'h', res); } if (minutes > 0) { - appendDuration(minutes, 'm', res); + appendShortDuration(minutes, 'm', res); } if (seconds > 0) { - appendDuration(seconds, 's', res); + appendShortDuration(seconds, 's', res); } return res; } @@ -71,4 +173,65 @@ QString formatTime(std::chrono::seconds totalSeconds) std::numeric_limits::max())))); } +QString formatLongFriendlyDuration(const QDateTime &from, const QDateTime &to) +{ + if (!from.isValid() || !to.isValid()) + { + qCWarning(chatterinoHelper) + << "Invalid arguments to formatLongFriendlyDuration - from:" << from + << "to:" << to; + return u"n/a"_s; + } + + auto bd = durationBetween(from, to); + auto remaining = std::min(bd.components(), 4); + const auto total = remaining; + if (remaining == 0) + { + return u"0 seconds"_s; + } + + QString out; + for (const auto &[ptr, name] : BalancedDuration::COMPONENTS) + { + uint16_t n = bd.*ptr; + if (n == 0) + { + continue; + } + + if (!out.isEmpty()) + { + if (remaining == 1) + { + if (total > 2) + { + out += ','; + } + out += u" and "; + } + else + { + out += u", "; + } + } + + out += QString::number(n); + out += u' '; + out += name; + if (n != 1) + { + out += u's'; + } + + --remaining; + if (remaining == 0) + { + break; + } + } + + return out; +} + } // namespace chatterino diff --git a/src/util/FormatTime.hpp b/src/util/FormatTime.hpp index cb26cc9c..cf54e514 100644 --- a/src/util/FormatTime.hpp +++ b/src/util/FormatTime.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -11,4 +12,10 @@ QString formatTime(int totalSeconds); QString formatTime(const QString &totalSecondsString); QString formatTime(std::chrono::seconds totalSeconds); +/// Formats a duration that's expected to be long (sevreal months or years) like +/// "4 years, 5 days and 8 hours". +/// +/// This includes the components year, month, day, and hour. +QString formatLongFriendlyDuration(const QDateTime &from, const QDateTime &to); + } // namespace chatterino diff --git a/src/widgets/dialogs/UserInfoPopup.cpp b/src/widgets/dialogs/UserInfoPopup.cpp index 5daee0c4..89b9653e 100644 --- a/src/widgets/dialogs/UserInfoPopup.cpp +++ b/src/widgets/dialogs/UserInfoPopup.cpp @@ -23,6 +23,7 @@ #include "singletons/Theme.hpp" #include "singletons/WindowManager.hpp" #include "util/Clipboard.hpp" +#include "util/FormatTime.hpp" #include "util/Helpers.hpp" #include "util/LayoutCreator.hpp" #include "util/PostToThread.hpp" @@ -955,6 +956,12 @@ void UserInfoPopup::updateUserData() user.displayName, this->underlyingChannel_->getName())); this->ui_.createdDateLabel->setText( TEXT_CREATED.arg(user.createdAt.section("T", 0, 0))); + this->ui_.createdDateLabel->setToolTip( + formatLongFriendlyDuration( + QDateTime::fromString(user.createdAt, Qt::ISODateWithMs), + QDateTime::currentDateTimeUtc()) + + u" ago"_s); + this->ui_.createdDateLabel->setMouseTracking(true); this->ui_.userIDLabel->setText(TEXT_USER_ID % user.id); this->ui_.userIDLabel->setProperty("copy-text", user.id); @@ -1054,6 +1061,11 @@ void UserInfoPopup::updateUserData() followedAt.toString("yyyy-MM-dd"); this->ui_.followageLabel->setText("❤ Following since " + followingSince); + this->ui_.followageLabel->setToolTip( + formatLongFriendlyDuration( + followedAt, QDateTime::currentDateTimeUtc()) + + u" ago"_s); + this->ui_.followageLabel->setMouseTracking(true); } if (subageInfo.isSubHidden) diff --git a/tests/src/FormatTime.cpp b/tests/src/FormatTime.cpp index 6fe82ab9..2a1ff0e8 100644 --- a/tests/src/FormatTime.cpp +++ b/tests/src/FormatTime.cpp @@ -205,3 +205,288 @@ TEST(FormatTime, chrono) << actual << " did not match expected value " << expected; } } + +TEST(FormatTime, formatLongFriendlyDuration) +{ + struct Case { + QString from; + QString to; + QString dur; + }; + + // You can verify this with Temporal in your browser: + // function humanDuration(from, to) { + // return Temporal.Instant.from(to) + // .since(Temporal.Instant.from(from)) + // .round({ + // smallestUnit: "seconds", + // largestUnit: "years", + // relativeTo: Temporal.Instant.from(from).toZonedDateTimeISO("UTC"), + // }); + // } + // > humanDuration("2018-07-04T06:34:24Z", "2025-08-10T13:03:50Z") + // > P7Y1M6DT6H29M26S + std::array cases{ + // basic + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2025-08-10T13:03:50Z", + .dur = "7 years, 1 month, 6 days, and 6 hours", + }, + // eq + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2018-07-04T06:34:24Z", + .dur = "0 seconds", + }, + // one + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2018-07-04T06:34:25Z", + .dur = "1 second", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2018-07-04T06:35:24Z", + .dur = "1 minute", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2018-07-04T06:35:25Z", + .dur = "1 minute and 1 second", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2018-07-04T07:34:24Z", + .dur = "1 hour", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2018-07-05T06:34:24Z", + .dur = "1 day", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2018-07-05T07:34:24Z", + .dur = "1 day and 1 hour", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2018-08-04T06:34:24Z", + .dur = "1 month", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2018-08-05T06:34:24Z", + .dur = "1 month and 1 day", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2018-08-05T07:34:24Z", + .dur = "1 month, 1 day, and 1 hour", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2018-08-04T07:34:24Z", + .dur = "1 month and 1 hour", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2019-07-04T06:34:24Z", + .dur = "1 year", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2019-07-04T07:34:24Z", + .dur = "1 year and 1 hour", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2019-07-05T06:34:24Z", + .dur = "1 year and 1 day", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2019-08-04T06:34:24Z", + .dur = "1 year and 1 month", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2019-07-05T07:34:24Z", + .dur = "1 year, 1 day, and 1 hour", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2019-08-05T06:34:24Z", + .dur = "1 year, 1 month, and 1 day", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2019-08-04T07:34:24Z", + .dur = "1 year, 1 month, and 1 hour", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2019-08-05T07:34:24Z", + .dur = "1 year, 1 month, 1 day, and 1 hour", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2019-07-05T07:35:24Z", + .dur = "1 year, 1 day, 1 hour, and 1 minute", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2019-07-04T07:35:25Z", + .dur = "1 year, 1 hour, 1 minute, and 1 second", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2018-07-04T07:35:25Z", + .dur = "1 hour, 1 minute, and 1 second", + }, + // two + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2018-07-04T06:34:26Z", + .dur = "2 seconds", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2018-07-04T06:36:24Z", + .dur = "2 minutes", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2018-07-04T06:36:26Z", + .dur = "2 minutes and 2 seconds", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2018-07-04T08:34:24Z", + .dur = "2 hours", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2018-07-06T06:34:24Z", + .dur = "2 days", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2018-07-06T08:34:24Z", + .dur = "2 days and 2 hours", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2018-09-04T06:34:24Z", + .dur = "2 months", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2018-09-06T06:34:24Z", + .dur = "2 months and 2 days", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2018-09-06T08:34:24Z", + .dur = "2 months, 2 days, and 2 hours", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2020-07-04T06:34:24Z", + .dur = "2 years", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2020-09-04T06:34:24Z", + .dur = "2 years and 2 months", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2020-09-06T06:34:24Z", + .dur = "2 years, 2 months, and 2 days", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2020-09-06T08:34:24Z", + .dur = "2 years, 2 months, 2 days, and 2 hours", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2020-07-06T08:36:24Z", + .dur = "2 years, 2 days, 2 hours, and 2 minutes", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2020-07-04T08:36:26Z", + .dur = "2 years, 2 hours, 2 minutes, and 2 seconds", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2018-07-04T08:36:26Z", + .dur = "2 hours, 2 minutes, and 2 seconds", + }, + // more units + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2020-09-06T08:36:26Z", + .dur = "2 years, 2 months, 2 days, and 2 hours", + }, + Case{ + .from = "2018-07-04T06:34:24Z", + .to = "2020-07-06T08:36:26Z", + .dur = "2 years, 2 days, 2 hours, and 2 minutes", + }, + // swapped + Case{ + .from = "2020-09-06T08:34:24Z", + .to = "2018-07-04T06:34:24Z", + .dur = "2 years, 2 months, 2 days, and 2 hours", + }, + // lower time + Case{ + .from = "2018-07-04T13:34:24Z", + .to = "2025-08-04T10:03:50Z", + .dur = "7 years, 30 days, 20 hours, and 29 minutes", + }, + Case{ + .from = "2018-07-04T23:50:50Z", + .to = "2018-07-05T00:03:00Z", + .dur = "12 minutes and 10 seconds", + }, + Case{ + .from = "2018-07-04T23:50:50Z", + .to = "2018-07-05T00:00:00Z", + .dur = "9 minutes and 10 seconds", + }, + Case{ + .from = "2018-07-04T23:50:50Z", + .to = "2018-07-06T00:03:00Z", + .dur = "1 day, 12 minutes, and 10 seconds", + }, + Case{ + .from = "2018-07-04T23:50:50Z", + .to = "2018-07-07T00:03:00Z", + .dur = "2 days, 12 minutes, and 10 seconds", + }, + Case{ + .from = "2018-07-04T23:50:50Z", + .to = "2018-08-04T00:03:00Z", + .dur = "30 days, 12 minutes, and 10 seconds", + }, + Case{ + .from = "2018-07-04T23:50:50Z", + .to = "2018-09-04T00:03:00Z", + .dur = "1 month, 30 days, 12 minutes, and 10 seconds", + }, + }; + + for (const auto &c : cases) + { + auto d = formatLongFriendlyDuration( + QDateTime::fromString(c.from, Qt::ISODate), + QDateTime::fromString(c.to, Qt::ISODate)); + ASSERT_EQ(d, c.dur) + << "from=" << c.from << " to=" << c.to << " expected=" << c.dur; + } +} diff --git a/vcpkg.json b/vcpkg.json index 13e69d7e..4ae4829b 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -7,6 +7,7 @@ "boost-asio", "boost-beast", "boost-circular-buffer", + "boost-date-time", "boost-foreach", "boost-interprocess", "boost-json",