diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index c78969caa..96055ee66 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -1313,6 +1313,20 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_info_link_label" = "Link"; "lng_info_location_label" = "Location"; "lng_info_about_label" = "About"; +"lng_info_work_open" = "Open"; +"lng_info_work_closed" = "Closed"; +"lng_info_hours_label" = "Business hours"; +"lng_info_hours_closed" = "closed"; +"lng_info_hours_opens_in_minutes#one" = "opens in {count} minute"; +"lng_info_hours_opens_in_minutes#other" = "opens in {count} minutes"; +"lng_info_hours_opens_in_hours#one" = "opens in {count} hour"; +"lng_info_hours_opens_in_hours#other" = "opens in {count} hours"; +"lng_info_hours_opens_in_days#one" = "opens in {count} day"; +"lng_info_hours_opens_in_days#other" = "opens in {count} days"; +"lng_info_hours_open_full" = "open 24 hours"; +"lng_info_hours_next_day" = "{time} (next day)"; +"lng_info_hours_local_time" = "local time"; +"lng_info_hours_my_time" = "my time"; "lng_info_user_title" = "User Info"; "lng_info_bot_title" = "Bot Info"; "lng_info_group_title" = "Group Info"; @@ -2190,7 +2204,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_hours_sunday" = "Sunday"; "lng_hours_closed" = "Closed"; "lng_hours_open_full" = "Open 24 hours"; -"lng_hours_next_day" = "Next day, {time}"; +"lng_hours_next_day" = "{time} (Next day)"; "lng_hours_time_zone_title" = "Choose Time Zone"; "lng_hours_add_button" = "Add a Set of Hours"; "lng_hours_opening" = "Opening Time"; diff --git a/Telegram/SourceFiles/data/business/data_business_common.cpp b/Telegram/SourceFiles/data/business/data_business_common.cpp index 34a46f3cc..14b1b0903 100644 --- a/Telegram/SourceFiles/data/business/data_business_common.cpp +++ b/Telegram/SourceFiles/data/business/data_business_common.cpp @@ -106,6 +106,11 @@ WorkingIntervals ExtractDayIntervals( return result; } +bool IsFullOpen(const WorkingIntervals &extractedDay) { + return extractedDay + && (extractedDay.list.front() == WorkingInterval{ 0, kDay }); +} + WorkingIntervals RemoveDayIntervals( const WorkingIntervals &intervals, int dayIndex) { diff --git a/Telegram/SourceFiles/data/business/data_business_common.h b/Telegram/SourceFiles/data/business/data_business_common.h index a47dce3a2..86422bc98 100644 --- a/Telegram/SourceFiles/data/business/data_business_common.h +++ b/Telegram/SourceFiles/data/business/data_business_common.h @@ -138,6 +138,7 @@ struct WorkingHours { [[nodiscard]] WorkingIntervals ExtractDayIntervals( const WorkingIntervals &intervals, int dayIndex); +[[nodiscard]] bool IsFullOpen(const WorkingIntervals &extractedDay); [[nodiscard]] WorkingIntervals RemoveDayIntervals( const WorkingIntervals &intervals, int dayIndex); @@ -148,7 +149,7 @@ struct WorkingHours { struct BusinessLocation { QString address; - LocationPoint point; + std::optional point; explicit operator bool() const { return !address.isEmpty(); diff --git a/Telegram/SourceFiles/data/business/data_business_info.cpp b/Telegram/SourceFiles/data/business/data_business_info.cpp index 43ef345e6..d874e52b9 100644 --- a/Telegram/SourceFiles/data/business/data_business_info.cpp +++ b/Telegram/SourceFiles/data/business/data_business_info.cpp @@ -8,6 +8,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "data/business/data_business_info.h" #include "apiwrap.h" +#include "base/unixtime.h" #include "data/business/data_business_common.h" #include "data/data_session.h" #include "data/data_user.h" @@ -270,4 +271,24 @@ rpl::producer BusinessInfo::timezonesValue() const { return _timezones.value(); } +QString FindClosestTimezoneId(const std::vector &list) { + const auto local = QDateTime::currentDateTime(); + const auto utc = QDateTime(local.date(), local.time(), Qt::UTC); + const auto shift = base::unixtime::now() - (TimeId)::time(nullptr); + const auto delta = int(utc.toSecsSinceEpoch()) + - int(local.toSecsSinceEpoch()) + - shift; + const auto proj = [&](const Timezone &value) { + auto distance = value.utcOffset - delta; + while (distance > 12 * 3600) { + distance -= 24 * 3600; + } + while (distance < -12 * 3600) { + distance += 24 * 3600; + } + return std::abs(distance); + }; + return ranges::min_element(list, ranges::less(), proj)->id; +} + } // namespace Data diff --git a/Telegram/SourceFiles/data/business/data_business_info.h b/Telegram/SourceFiles/data/business/data_business_info.h index 3b747eb0f..adea50a17 100644 --- a/Telegram/SourceFiles/data/business/data_business_info.h +++ b/Telegram/SourceFiles/data/business/data_business_info.h @@ -55,4 +55,7 @@ private: }; +[[nodiscard]] QString FindClosestTimezoneId( + const std::vector &list); + } // namespace Data diff --git a/Telegram/SourceFiles/data/data_user.cpp b/Telegram/SourceFiles/data/data_user.cpp index daa9bb593..d670d9308 100644 --- a/Telegram/SourceFiles/data/data_user.cpp +++ b/Telegram/SourceFiles/data/data_user.cpp @@ -280,6 +280,7 @@ const Data::BusinessDetails &UserData::businessDetails() const { } void UserData::setBusinessDetails(Data::BusinessDetails details) { + details.hours = details.hours.normalized(); if ((!details && !_businessDetails) || (details && _businessDetails && details == *_businessDetails)) { return; diff --git a/Telegram/SourceFiles/info/info.style b/Telegram/SourceFiles/info/info.style index d53d12f94..19162c400 100644 --- a/Telegram/SourceFiles/info/info.style +++ b/Telegram/SourceFiles/info/info.style @@ -1008,3 +1008,21 @@ similarChannelsLockAbout: FlatLabel(defaultFlatLabel) { minWidth: 128px; } similarChannelsLockAboutPadding: margins(12px, 12px, 12px, 12px); + +infoHoursState: FlatLabel(infoLabeled) { + minWidth: 0px; +} +infoHoursValue: FlatLabel(infoHoursState) { + textFg: windowSubTextFg; + align: align(topright); +} +infoHoursDayLabel: infoHoursState; +infoHoursOuter: RoundButton(defaultActiveButton) { + textBg: transparent; + textBgOver: transparent; + ripple: RippleAnimation(defaultRippleAnimation) { + color: windowBgOver; + } +} +infoHoursOuterMargin: margins(8px, 4px, 8px, 4px); +infoHoursDaySkip: 6px; diff --git a/Telegram/SourceFiles/info/profile/info_profile_actions.cpp b/Telegram/SourceFiles/info/profile/info_profile_actions.cpp index eba90de15..f0d690b51 100644 --- a/Telegram/SourceFiles/info/profile/info_profile_actions.cpp +++ b/Telegram/SourceFiles/info/profile/info_profile_actions.cpp @@ -9,6 +9,10 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "api/api_chat_participants.h" #include "base/options.h" +#include "base/timer_rpl.h" +#include "base/unixtime.h" +#include "data/business/data_business_common.h" +#include "data/business/data_business_info.h" #include "data/data_peer_values.h" #include "data/data_session.h" #include "data/data_folder.h" @@ -71,6 +75,8 @@ namespace Info { namespace Profile { namespace { +constexpr auto kDay = Data::WorkingInterval::kDay; + base::options::toggle ShowPeerIdBelowAbout({ .id = kOptionShowPeerIdBelowAbout, .name = "Show Peer IDs in Profile", @@ -159,6 +165,435 @@ base::options::toggle ShowPeerIdBelowAbout({ }); } +[[nodiscard]] bool AreNonTrivialHours(const Data::WorkingHours &hours) { + if (!hours) { + return false; + } + const auto &intervals = hours.intervals.list; + for (auto i = 0; i != 7; ++i) { + const auto day = Data::WorkingInterval{ i * kDay, (i + 1) * kDay }; + for (const auto &interval : intervals) { + const auto intersection = interval.intersected(day); + if (intersection && intersection != day) { + return true; + } + } + } + return false; +} + +[[nodiscard]] TimeId OpensIn( + const Data::WorkingIntervals &intervals, + TimeId now) { + using namespace Data; + + while (now < 0) { + now += WorkingInterval::kWeek; + } + while (now > WorkingInterval::kWeek) { + now -= WorkingInterval::kWeek; + } + auto closest = WorkingInterval::kWeek; + for (const auto &interval : intervals.list) { + if (interval.start <= now && interval.end > now) { + return TimeId(0); + } else if (interval.start > now && interval.start - now < closest) { + closest = interval.start - now; + } else if (interval.start < now) { + const auto next = interval.start + WorkingInterval::kWeek - now; + if (next < closest) { + closest = next; + } + } + } + return closest; +} + +[[nodiscard]] rpl::producer OpensInText( + rpl::producer in, + rpl::producer fallback) { + return rpl::combine( + std::move(in), + std::move(fallback) + ) | rpl::map([](TimeId in, QString fallback) { + return !in + ? std::move(fallback) + : (in >= 86400) + ? tr::lng_info_hours_opens_in_days(tr::now, lt_count, in / 86400) + : (in >= 3600) + ? tr::lng_info_hours_opens_in_hours(tr::now, lt_count, in / 3600) + : tr::lng_info_hours_opens_in_minutes( + tr::now, + lt_count, + std::max(in / 60, 1)); + }); +} + +[[nodiscard]] QString FormatDayTime(TimeId time) { + const auto wrap = [](TimeId value) { + const auto hours = value / 3600; + const auto minutes = (value % 3600) / 60; + return QString::number(hours).rightJustified(2, u'0') + + ':' + + QString::number(minutes).rightJustified(2, u'0'); + }; + return (time > kDay) + ? tr::lng_info_hours_next_day(tr::now, lt_time, wrap(time - kDay)) + : wrap(time == kDay ? 0 : time); +} + +[[nodiscard]] QString JoinIntervals(const Data::WorkingIntervals &data) { + auto result = QStringList(); + result.reserve(data.list.size()); + for (const auto &interval : data.list) { + const auto start = FormatDayTime(interval.start); + const auto end = FormatDayTime(interval.end); + result.push_back(start + u" - "_q + end); + } + return result.join('\n'); +} + +[[nodiscard]] QString FormatDayHours( + const Data::WorkingHours &hours, + const Data::WorkingIntervals &mine, + bool my, + int day) { + using namespace Data; + + const auto local = ExtractDayIntervals(hours.intervals, day); + if (IsFullOpen(local)) { + return tr::lng_info_hours_open_full(tr::now); + } + const auto use = my ? ExtractDayIntervals(mine, day) : local; + if (!use) { + return tr::lng_info_hours_closed(tr::now); + } + return JoinIntervals(use); +} + +[[nodiscard]] Data::WorkingIntervals ShiftedIntervals( + Data::WorkingIntervals intervals, + int delta) { + auto &list = intervals.list; + if (!delta || list.empty()) { + return { std::move(list) }; + } + for (auto &interval : list) { + interval.start += delta; + interval.end += delta; + } + while (list.front().start < 0) { + constexpr auto kWeek = Data::WorkingInterval::kWeek; + const auto first = list.front(); + if (first.end > 0) { + list.push_back({ first.start + kWeek, kWeek }); + list.front().start = 0; + } else { + list.push_back(first.shifted(kWeek)); + list.erase(list.begin()); + } + } + return intervals.normalized(); +} + +[[nodiscard]] object_ptr> CreateWorkingHours( + not_null parent, + not_null user) { + using namespace Data; + + auto result = object_ptr>( + parent, + object_ptr( + parent, + rpl::single(QString()), + st::infoHoursOuter), + st::infoProfileLabeledPadding - st::infoHoursOuterMargin); + const auto button = result->entity(); + const auto inner = Ui::CreateChild(button); + button->widthValue() | rpl::start_with_next([=](int width) { + const auto margin = st::infoHoursOuterMargin; + inner->resizeToWidth(width - margin.left() - margin.right()); + inner->move(margin.left(), margin.top()); + }, inner->lifetime()); + inner->heightValue() | rpl::start_with_next([=](int height) { + const auto margin = st::infoHoursOuterMargin; + height += margin.top() + margin.bottom(); + button->resize(button->width(), height); + }, inner->lifetime()); + + const auto info = &user->owner().businessInfo(); + + struct State { + rpl::variable hours; + rpl::variable time; + rpl::variable day; + rpl::variable timezoneDelta; + + rpl::variable mine; + rpl::variable mineByDays; + rpl::variable opensIn; + rpl::variable opened; + rpl::variable expanded; + rpl::variable nonTrivial; + rpl::variable myTimezone; + + rpl::event_stream<> recounts; + }; + const auto state = inner->lifetime().make_state(); + + auto recounts = state->recounts.events_starting_with_copy(rpl::empty); + const auto recount = [=] { + state->recounts.fire({}); + }; + + state->hours = user->session().changes().peerFlagsValue( + user, + PeerUpdate::Flag::BusinessDetails + ) | rpl::map([=] { + return user->businessDetails().hours; + }); + state->nonTrivial = state->hours.value() | rpl::map(AreNonTrivialHours); + + const auto seconds = QTime::currentTime().msecsSinceStartOfDay() / 1000; + const auto inMinute = seconds % 60; + const auto firstTick = inMinute ? (61 - inMinute) : 1; + state->time = rpl::single(rpl::empty) | rpl::then( + base::timer_once(firstTick * crl::time(1000)) + ) | rpl::then( + base::timer_each(60 * crl::time(1000)) + ) | rpl::map([] { + const auto local = QDateTime::currentDateTime(); + const auto day = local.date().dayOfWeek() - 1; + const auto seconds = local.time().msecsSinceStartOfDay() / 1000; + return day * kDay + seconds; + }); + + state->day = state->time.value() | rpl::map([](TimeId time) { + return time / kDay; + }); + state->timezoneDelta = rpl::combine( + state->hours.value(), + info->timezonesValue() + ) | rpl::filter([]( + const WorkingHours &hours, + const Timezones &timezones) { + return ranges::contains( + timezones.list, + hours.timezoneId, + &Timezone::id); + }) | rpl::map([](WorkingHours &&hours, const Timezones &timezones) { + const auto &list = timezones.list; + const auto closest = FindClosestTimezoneId(list); + const auto i = ranges::find(list, closest, &Timezone::id); + const auto j = ranges::find(list, hours.timezoneId, &Timezone::id); + Assert(i != end(list)); + Assert(j != end(list)); + return i->utcOffset - j->utcOffset; + }); + + state->mine = rpl::combine( + state->hours.value(), + state->timezoneDelta.value() + ) | rpl::map([](WorkingHours &&hours, int delta) { + return ShiftedIntervals(hours.intervals, delta); + }); + + state->opensIn = rpl::combine( + state->mine.value(), + state->time.value() + ) | rpl::map([](const WorkingIntervals &mine, TimeId time) { + return OpensIn(mine, time); + }); + state->opened = state->opensIn.value() | rpl::map(rpl::mappers::_1 == 0); + + state->mineByDays = rpl::combine( + state->hours.value(), + state->timezoneDelta.value() + ) | rpl::map([](WorkingHours &&hours, int delta) { + auto full = std::array(); + auto withoutFullDays = hours.intervals; + for (auto i = 0; i != 7; ++i) { + if (IsFullOpen(ExtractDayIntervals(hours.intervals, i))) { + full[i] = true; + withoutFullDays = ReplaceDayIntervals( + withoutFullDays, + i, + Data::WorkingIntervals()); + } + } + auto result = ShiftedIntervals(withoutFullDays, delta); + for (auto i = 0; i != 7; ++i) { + if (full[i]) { + result = ReplaceDayIntervals( + result, + i, + Data::WorkingIntervals{ { { 0, kDay } } }); + } + } + return result; + }); + + const auto dayHoursText = [=](int day) { + return rpl::combine( + state->hours.value(), + state->mineByDays.value(), + state->myTimezone.value() + ) | rpl::map([=]( + const WorkingHours &hours, + const WorkingIntervals &mine, + bool my) { + return FormatDayHours(hours, mine, my, day); + }); + }; + const auto dayHoursTextValue = [=](rpl::producer day) { + return std::move(day) + | rpl::map(dayHoursText) + | rpl::flatten_latest(); + }; + + const auto openedWrap = inner->add(object_ptr(inner)); + const auto opened = Ui::CreateChild( + openedWrap, + rpl::conditional( + state->opened.value(), + tr::lng_info_work_open(), + tr::lng_info_work_closed() + ) | rpl::after_next(recount), + st::infoHoursState); + opened->setAttribute(Qt::WA_TransparentForMouseEvents); + const auto timing = Ui::CreateChild( + openedWrap, + OpensInText( + state->opensIn.value(), + dayHoursTextValue(state->day.value()) + ) | rpl::after_next(recount), + st::infoHoursValue); + timing->setAttribute(Qt::WA_TransparentForMouseEvents); + state->opened.value() | rpl::start_with_next([=](bool value) { + opened->setTextColorOverride(value + ? st::boxTextFgGood->c + : st::boxTextFgError->c); + }, opened->lifetime()); + + rpl::combine( + openedWrap->widthValue(), + opened->heightValue(), + timing->sizeValue() + ) | rpl::start_with_next([=](int width, int h1, QSize size) { + opened->moveToLeft(0, 0, width); + timing->moveToRight(0, 0, width); + + const auto margins = opened->getMargins(); + const auto added = margins.top() + margins.bottom(); + openedWrap->resize(width, std::max(h1, size.height()) - added); + }, openedWrap->lifetime()); + + const auto labelWrap = inner->add(object_ptr(inner)); + const auto label = Ui::CreateChild( + labelWrap, + tr::lng_info_hours_label(), + st::infoLabel); + label->setAttribute(Qt::WA_TransparentForMouseEvents); + const auto link = Ui::CreateChild( + labelWrap, + QString()); + rpl::combine( + state->nonTrivial.value(), + state->hours.value(), + state->mine.value(), + state->myTimezone.value() + ) | rpl::map([=]( + bool complex, + const WorkingHours &hours, + const WorkingIntervals &mine, + bool my) { + return (!complex || hours.intervals == mine) + ? rpl::single(QString()) + : my + ? tr::lng_info_hours_my_time() + : tr::lng_info_hours_local_time(); + }) | rpl::flatten_latest( + ) | rpl::start_with_next([=](const QString &text) { + link->setText(text); + }, link->lifetime()); + link->setClickedCallback([=] { + state->myTimezone = !state->myTimezone.current(); + }); + + rpl::combine( + labelWrap->widthValue(), + label->heightValue(), + link->sizeValue() + ) | rpl::start_with_next([=](int width, int h1, QSize size) { + label->moveToLeft(0, 0, width); + link->moveToRight(0, 0, width); + + const auto margins = label->getMargins(); + const auto added = margins.top() + margins.bottom(); + labelWrap->resize(width, std::max(h1, size.height()) - added); + }, labelWrap->lifetime()); + + const auto other = inner->add( + object_ptr>( + inner, + object_ptr(inner))); + other->toggleOn(state->expanded.value(), anim::type::normal); + other->finishAnimating(); + const auto days = other->entity(); + + for (auto i = 1; i != 7; ++i) { + const auto dayWrap = days->add( + object_ptr(other), + QMargins(0, st::infoHoursDaySkip, 0, 0)); + auto label = state->day.value() | rpl::map([=](int day) { + switch ((day + i) % 7) { + case 0: return tr::lng_hours_monday(); + case 1: return tr::lng_hours_tuesday(); + case 2: return tr::lng_hours_wednesday(); + case 3: return tr::lng_hours_thursday(); + case 4: return tr::lng_hours_friday(); + case 5: return tr::lng_hours_saturday(); + case 6: return tr::lng_hours_sunday(); + } + Unexpected("Index in working hours."); + }) | rpl::flatten_latest(); + const auto dayLabel = Ui::CreateChild( + dayWrap, + std::move(label), + st::infoHoursDayLabel); + dayLabel->setAttribute(Qt::WA_TransparentForMouseEvents); + const auto dayHours = Ui::CreateChild( + dayWrap, + dayHoursTextValue(state->day.value() + | rpl::map((rpl::mappers::_1 + i) % 7)), + st::infoHoursValue); + dayHours->setAttribute(Qt::WA_TransparentForMouseEvents); + rpl::combine( + dayWrap->widthValue(), + dayLabel->heightValue(), + dayHours->sizeValue() + ) | rpl::start_with_next([=](int width, int h1, QSize size) { + dayLabel->moveToLeft(0, 0, width); + dayHours->moveToRight(0, 0, width); + + const auto margins = dayLabel->getMargins(); + const auto added = margins.top() + margins.bottom(); + dayWrap->resize(width, std::max(h1, size.height()) - added); + }, dayWrap->lifetime()); + } + + button->setClickedCallback([=] { + state->expanded = !state->expanded.current(); + }); + + result->toggleOn(state->hours.value( + ) | rpl::map([](const WorkingHours &data) { + return bool(data); + })); + + return result; +} + template auto AddActionButton( not_null parent, @@ -563,6 +998,28 @@ object_ptr DetailsFiller::setupInfo() { } return false; }); + } else { + tracker.track(result->add(CreateWorkingHours(result, user))); + + auto locationText = user->session().changes().peerFlagsValue( + user, + Data::PeerUpdate::Flag::BusinessDetails + ) | rpl::map([=] { + const auto &details = user->businessDetails(); + if (!details.location) { + return TextWithEntities(); + } else if (!details.location.point) { + return TextWithEntities{ details.location.address }; + } + return Ui::Text::Link( + TextUtilities::SingleLine(details.location.address), + LocationClickHandler::Url(*details.location.point)); + }); + addInfoOneLine( + tr::lng_info_location_label(), + std::move(locationText), + QString() + ).text->setLinksTrusted(); } AddMainButton( diff --git a/Telegram/SourceFiles/settings/business/settings_working_hours.cpp b/Telegram/SourceFiles/settings/business/settings_working_hours.cpp index dd6c54b66..bae1b6b3d 100644 --- a/Telegram/SourceFiles/settings/business/settings_working_hours.cpp +++ b/Telegram/SourceFiles/settings/business/settings_working_hours.cpp @@ -70,27 +70,6 @@ private: return prefix + ' ' + data.name; } -[[nodiscard]] QString FindClosestTimezoneId( - const std::vector &list) { - const auto local = QDateTime::currentDateTime(); - const auto utc = QDateTime(local.date(), local.time(), Qt::UTC); - const auto shift = base::unixtime::now() - (TimeId)::time(nullptr); - const auto delta = int(utc.toSecsSinceEpoch()) - - int(local.toSecsSinceEpoch()) - - shift; - const auto proj = [&](const Data::Timezone &value) { - auto distance = value.utcOffset - delta; - while (distance > 12 * 3600) { - distance -= 24 * 3600; - } - while (distance < -12 * 3600) { - distance += 24 * 3600; - } - return std::abs(distance); - }; - return ranges::min_element(list, ranges::less(), proj)->id; -} - [[nodiscard]] QString FormatDayTime( TimeId time, bool showEndAsNextDay = false) { @@ -372,7 +351,7 @@ void ChooseTimezoneBox( }); if (!ranges::contains(list, id, &Data::Timezone::id)) { - id = FindClosestTimezoneId(list); + id = Data::FindClosestTimezoneId(list); } const auto i = ranges::find(list, id, &Data::Timezone::id); const auto value = int(i - begin(list)); @@ -472,7 +451,7 @@ void AddWeekButton( } if (!intervals) { return tr::lng_hours_closed(); - } else if (intervals.list.front() == WorkingInterval{ 0, kDay }) { + } else if (IsFullOpen(intervals)) { return tr::lng_hours_open_full(); } return rpl::single(JoinIntervals(intervals)); @@ -613,7 +592,7 @@ void WorkingHours::setupContent( const auto now = _hours.current().timezoneId; if (!ranges::contains(value.list, now, &Data::Timezone::id)) { auto copy = _hours.current(); - copy.timezoneId = FindClosestTimezoneId(value.list); + copy.timezoneId = Data::FindClosestTimezoneId(value.list); _hours = std::move(copy); } }, inner->lifetime());