init
Some checks failed
Docker. / Ubuntu (push) Has been cancelled
User-agent updater. / User-agent (push) Failing after 15s
Lock Threads / lock (push) Failing after 10s
Waiting for answer. / waiting-for-answer (push) Failing after 22s
Needs user action. / needs-user-action (push) Failing after 8s
Can't reproduce. / cant-reproduce (push) Failing after 8s
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
allhaileris
2026-02-16 15:50:16 +03:00
commit afb81b8278
13816 changed files with 3689732 additions and 0 deletions

View File

@@ -0,0 +1,155 @@
/*
This file is part of Telegram Desktop,
the official desktop application for the Telegram messaging service.
For license and copyright information please follow this link:
https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
*/
using "ui/basic.style";
using "boxes/boxes.style";
using "statistics/statistics.style";
channelEarnLearnArrowMargins: margins(-2px, 5px, 0px, 0px);
channelEarnOverviewTitleSkip: 11px;
channelEarnOverviewMajorLabel: FlatLabel(defaultFlatLabel) {
maxHeight: 30px;
style: TextStyle(defaultTextStyle) {
font: font(15px semibold);
}
}
channelEarnOverviewMinorLabel: FlatLabel(channelEarnOverviewMajorLabel) {
style: TextStyle(defaultTextStyle) {
font: font(12px semibold);
}
}
channelEarnOverviewMinorLabelSkip: 3px;
channelEarnOverviewSubMinorLabel: FlatLabel(channelEarnOverviewMinorLabel) {
textFg: windowSubTextFg;
style: TextStyle(defaultTextStyle) {
font: font(13px);
}
}
channelEarnOverviewSubMinorLabelPos: point(4px, 2px);
channelEarnSemiboldLabel: FlatLabel(channelEarnOverviewMajorLabel) {
style: semiboldTextStyle;
}
channelEarnHeaderLabel: FlatLabel(channelEarnOverviewMajorLabel) {
style: TextStyle(statisticsHeaderTitleTextStyle) {
}
textFg: windowActiveTextFg;
}
channelEarnHistorySubLabel: FlatLabel(channelEarnOverviewSubMinorLabel) {
style: TextStyle(defaultTextStyle) {
font: font(12px);
}
}
channelEarnHistoryRecipientLabel: FlatLabel(channelEarnOverviewSubMinorLabel) {
maxHeight: 0px;
minWidth: 100px;
style: TextStyle(defaultTextStyle) {
font: font(12px);
}
}
channelEarnHistoryMajorLabel: FlatLabel(channelEarnOverviewMajorLabel) {
style: TextStyle(defaultTextStyle) {
font: font(14px semibold);
}
}
channelEarnHistoryMinorLabel: FlatLabel(channelEarnOverviewMinorLabel) {
style: TextStyle(defaultTextStyle) {
font: font(12px semibold);
}
}
channelEarnHistoryDescriptionLabel: FlatLabel(channelEarnHistoryMajorLabel) {
// boxWidth - boxRowPadding = 320 - 24 * 2
minWidth: 272px;
maxHeight: 0px;
align: align(center);
}
channelEarnHistoryMinorLabelSkip: 2px;
channelEarnHistoryOuter: margins(0px, 6px, 0px, 6px);
channelEarnHistoryTwoSkip: 5px;
channelEarnHistoryThreeSkip: 3px;
channelEarnHistoryRecipientButton: RoundButton {
textFg: transparent;
textFgOver: transparent;
numbersTextFg: transparent;
numbersTextFgOver: transparent;
textBg: windowBgOver;
textBgOver: windowBgOver;
numbersSkip: 7px;
width: 190px;
height: 34px;
padding: margins(0px, 0px, 0px, 0px);
textTop: 8px;
radius: 8px;
iconPosition: point(0px, 0px);
style: semiboldTextStyle;
ripple: RippleAnimation(defaultRippleAnimation) {
color: windowBgRipple;
}
}
channelEarnHistoryRecipientButtonLabel: FlatLabel(channelEarnHistoryRecipientLabel) {
align: align(center);
}
channelEarnBalanceMajorLabel: FlatLabel(channelEarnOverviewMajorLabel) {
style: TextStyle(defaultTextStyle) {
font: font(22px semibold);
}
}
channelEarnBalanceMinorLabel: FlatLabel(channelEarnOverviewMinorLabel) {
style: TextStyle(defaultTextStyle) {
font: font(16px semibold);
}
}
channelEarnBalanceMinorLabelSkip: 6px;
channelEarnFadeDuration: 60;
channelEarnLearnDescription: FlatLabel(defaultFlatLabel) {
maxHeight: 0px;
minWidth: 264px;
align: align(top);
}
channelEarnCurrencyCommonMargins: margins(0px, 3px, 1px, 0px);
channelEarnCurrencyLearnMargins: margins(0px, 2px, 0px, 0px);
sponsoredAboutTitleIcon: icon {{ "sponsored/large_about", activeButtonFg }};
sponsoredAboutPrivacyIcon: icon {{ "sponsored/privacy_about", boxTextFg }};
sponsoredAboutRemoveIcon: icon {{ "sponsored/remove_about", boxTextFg }};
sponsoredAboutSplitIcon: icon {{ "sponsored/revenue_split", boxTextFg }};
channelEarnLearnTitleIcon: icon {{ "sponsored/large_earn", activeButtonFg }};
channelEarnLearnChannelIcon: icon {{ "sponsored/channel", boxTextFg }};
channelEarnLearnWithdrawalsIcon: icon {{ "sponsored/withdrawals", boxTextFg }};
sponsoredReportLabel: FlatLabel(defaultFlatLabel) {
style: boxTextStyle;
minWidth: 150px;
}
botEarnInputField: InputField(defaultInputField) {
textMargins: margins(23px, 28px, 0px, 4px);
placeholderMargins: margins(-23px, 0px, 0px, 0px);
width: 100px;
heightMax: 55px;
}
botEarnLockedButtonLabel: FlatLabel(channelEarnOverviewMajorLabel) {
style: TextStyle(defaultTextStyle) {
font: font(10px semibold);
}
}
botEarnButtonLock: IconEmoji {
icon: icon{{ "chat/mini_lock", premiumButtonFg }};
padding: margins(-2px, 4px, 0px, 0px);
}

View File

@@ -0,0 +1,95 @@
/*
This file is part of Telegram Desktop,
the official desktop application for the Telegram messaging service.
For license and copyright information please follow this link:
https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
*/
#include "info/channel_statistics/earn/earn_format.h"
#include <QtCore/QLocale>
namespace Info::ChannelEarn {
namespace {
constexpr auto kMinorPartLength = 9;
constexpr auto kMaxChoppedZero = kMinorPartLength - 2;
constexpr auto kZero = QChar('0');
const auto DecimalPoint = QString() + QLocale().decimalPoint();
using EarnInt = Data::EarnInt;
} // namespace
QString MajorPart(EarnInt value) {
const auto string = QString::number(value);
const auto diff = int(string.size()) - kMinorPartLength;
return (diff <= 0) ? QString(kZero) : string.mid(0, diff);
}
QString MajorPart(CreditsAmount value) {
return QString::number(int64(value.value()));
}
QString MinorPart(EarnInt value) {
if (!value) {
return DecimalPoint + kZero + kZero;
}
const auto string = QString::number(value);
const auto diff = int(string.size()) - kMinorPartLength;
const auto result = (diff < 0)
? DecimalPoint + u"%1"_q.arg(0, std::abs(diff), 10, kZero) + string
: DecimalPoint + string.mid(diff);
const auto begin = (result.constData());
const auto end = (begin + result.size());
auto ch = end - 1;
auto zeroCount = 0;
while (ch != begin) {
if (((*ch) == kZero) && (zeroCount < kMaxChoppedZero)) {
zeroCount++;
} else {
break;
}
ch--;
}
return result.chopped(zeroCount);
}
QString MinorPart(CreditsAmount value) {
static const int DecimalPointLength = DecimalPoint.length();
const auto fractional = std::abs(int(value.value() * 100)) % 100;
auto result = QString(DecimalPointLength + 2, Qt::Uninitialized);
for (int i = 0; i < DecimalPointLength; ++i) {
result[i] = DecimalPoint[i];
}
result[DecimalPointLength] = QChar('0' + fractional / 10);
result[DecimalPointLength + 1] = QChar('0' + fractional % 10);
return result;
}
QString ToUsd(
Data::EarnInt value,
float64 rate,
int afterFloat) {
return ToUsd(CreditsAmount(value), rate, afterFloat);
}
QString ToUsd(
CreditsAmount value,
float64 rate,
int afterFloat) {
constexpr auto kApproximately = QChar(0x2248);
return QString(kApproximately)
+ QChar('$')
+ QLocale().toString(
value.value() * rate,
'f',
afterFloat ? afterFloat : 2);
}
} // namespace Info::ChannelEarn

View File

@@ -0,0 +1,27 @@
/*
This file is part of Telegram Desktop,
the official desktop application for the Telegram messaging service.
For license and copyright information please follow this link:
https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
*/
#pragma once
#include "data/data_channel_earn.h"
namespace Info::ChannelEarn {
[[nodiscard]] QString MajorPart(Data::EarnInt value);
[[nodiscard]] QString MajorPart(CreditsAmount value);
[[nodiscard]] QString MinorPart(Data::EarnInt value);
[[nodiscard]] QString MinorPart(CreditsAmount value);
[[nodiscard]] QString ToUsd(
Data::EarnInt value,
float64 rate,
int afterFloat);
[[nodiscard]] QString ToUsd(
CreditsAmount value,
float64 rate,
int afterFloat);
} // namespace Info::ChannelEarn

View File

@@ -0,0 +1,186 @@
/*
This file is part of Telegram Desktop,
the official desktop application for the Telegram messaging service.
For license and copyright information please follow this link:
https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
*/
#include "info/channel_statistics/earn/earn_icons.h"
#include "ui/effects/credits_graphics.h"
#include "ui/effects/premium_graphics.h"
#include "ui/text/custom_emoji_instance.h"
#include "ui/rect.h"
#include "styles/style_credits.h"
#include "styles/style_menu_icons.h"
#include "styles/style_widgets.h"
#include "styles/style_info.h" // infoIconReport.
#include <QFile>
#include <QtSvg/QSvgRenderer>
namespace Ui::Earn {
namespace {
[[nodiscard]] QByteArray CurrencySvg(const QColor &c) {
const auto color = u"rgb(%1,%2,%3)"_q
.arg(c.red())
.arg(c.green())
.arg(c.blue())
.toUtf8();
return R"(
<svg width="72px" height="72px" viewBox="0 0 72 72">
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g transform="translate(9.000000, 14.000000)
" stroke-width="7.2" stroke=")" + color + R"(">
<path d="M2.96014341,0 L50.9898193,0 C51.9732032,-7.06402744e-15
52.7703933,0.797190129 52.7703933,1.78057399 C52.7703933,2.08038611
52.6946886,2.3753442 52.5502994,2.63809702 L29.699977,44.2200383
C28.7527832,45.9436969 26.5876295,46.5731461 24.8639708,45.6259523
C24.2556953,45.2916896 23.7583564,44.7869606 23.4331014,44.1738213
L1.38718565,2.61498853 C0.926351231,1.74626794 1.25700829,0.668450654
2.12572888,0.20761623 C2.38272962,0.0712838007 2.6692209,4.97530809e-16
2.96014341,0 Z"></path>
<line x1="27" y1="44.4532875" x2="27" y2="0"></line>
</g>
</g>
</svg>)";
}
} // namespace
QImage IconCurrencyColored(int size, const QColor &c) {
const auto s = Size(size);
auto svg = QSvgRenderer(CurrencySvg(c));
auto image = QImage(
s * style::DevicePixelRatio(),
QImage::Format_ARGB32_Premultiplied);
image.setDevicePixelRatio(style::DevicePixelRatio());
image.fill(Qt::transparent);
{
auto p = QPainter(&image);
svg.render(&p, Rect(s));
}
return image;
}
QImage IconCurrencyColored(
const style::font &font,
const QColor &c) {
return IconCurrencyColored(font->ascent, c);
}
QByteArray CurrencySvgColored(const QColor &c) {
return CurrencySvg(c);
}
QImage MenuIconCurrency(const QSize &size) {
auto image = QImage(
size * style::DevicePixelRatio(),
QImage::Format_ARGB32_Premultiplied);
image.setDevicePixelRatio(style::DevicePixelRatio());
image.fill(Qt::transparent);
auto p = QPainter(&image);
st::infoIconReport.paintInCenter(
p,
Rect(size),
st::infoIconFg->c);
p.setCompositionMode(QPainter::CompositionMode_Clear);
const auto w = st::lineWidth * 6;
p.fillRect(
QRect(
rect::center(Rect(size)).x() - w / 2,
rect::center(Rect(size)).y() - w,
w,
w * 2),
Qt::white);
p.setCompositionMode(QPainter::CompositionMode_SourceOver);
const auto s = Size(st::inviteLinkSubscribeBoxTerms.style.font->ascent);
auto svg = QSvgRenderer(CurrencySvg(st::infoIconFg->c));
svg.render(
&p,
QRectF(
(size.width() - s.width()) / 2.,
(size.height() - s.height()) / 2.,
s.width(),
s.height()));
return image;
}
QImage MenuIconCredits() {
constexpr auto kStrokeWidth = 5;
const auto sizeShift = st::lineWidth * 1.5;
auto colorized = [&] {
auto f = QFile(Ui::Premium::Svg());
if (!f.open(QIODevice::ReadOnly)) {
return QString();
}
return QString::fromUtf8(f.readAll()).replace(
u"#fff"_q,
u"#ffffff00"_q);
}();
colorized.replace(
u"stroke=\"none\""_q,
u"stroke=\"%1\""_q.arg(st::menuIconColor->c.name()));
colorized.replace(
u"stroke-width=\"1\""_q,
u"stroke-width=\"%1\""_q.arg(kStrokeWidth));
auto svg = QSvgRenderer(colorized.toUtf8());
svg.setViewBox(svg.viewBox()
+ Margins(style::ConvertScale(kStrokeWidth)));
auto image = QImage(
st::menuIconLinks.size() * style::DevicePixelRatio(),
QImage::Format_ARGB32_Premultiplied);
image.setDevicePixelRatio(style::DevicePixelRatio());
image.fill(Qt::transparent);
{
auto p = QPainter(&image);
svg.render(&p, Rect(st::menuIconLinks.size()) - Margins(sizeShift));
}
return image;
}
std::unique_ptr<Ui::Text::CustomEmoji> MakeCurrencyIconEmoji(
const style::font &font,
const QColor &c) {
return std::make_unique<Ui::CustomEmoji::Internal>(
u"currency_icon:%1:%2"_q.arg(font->height).arg(c.name()),
IconCurrencyColored(font, c));
}
Ui::Text::PaletteDependentEmoji IconCreditsEmoji(
IconDescriptor descriptor) {
return { .factory = [=] {
return Ui::GenerateStars(
(descriptor.size
? descriptor.size
: st::defaultTableLabel.style.font->height),
1);
}, .margin = descriptor.margin.value_or(
QMargins{ 0, st::giftBoxByStarsSkip, 0, 0 }) };
}
Ui::Text::PaletteDependentEmoji IconCurrencyEmoji(
IconDescriptor descriptor) {
return { .factory = [=] {
return IconCurrencyColored(
descriptor.size ? descriptor.size : st::earnTonIconSize,
st::currencyFg->c);
}, .margin = descriptor.margin.value_or(st::earnTonIconMargin) };
}
Ui::Text::PaletteDependentEmoji IconCreditsEmojiSmall() {
return IconCreditsEmoji({
.size = st::giftBoxByStarsStyle.font->height,
.margin = QMargins{ 0, st::giftBoxByStarsStarTop, 0, 0 },
});
}
Ui::Text::PaletteDependentEmoji IconCurrencyEmojiSmall() {
return IconCreditsEmoji({});
}
} // namespace Ui::Earn

View File

@@ -0,0 +1,43 @@
/*
This file is part of Telegram Desktop,
the official desktop application for the Telegram messaging service.
For license and copyright information please follow this link:
https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
*/
#pragma once
#include "ui/text/custom_emoji_helper.h"
namespace Ui::Text {
class CustomEmoji;
} // namespace Ui::Text
namespace Ui::Earn {
[[nodiscard]] QImage IconCurrencyColored(int size, const QColor &c);
[[nodiscard]] QImage IconCurrencyColored(
const style::font &font,
const QColor &c);
[[nodiscard]] QByteArray CurrencySvgColored(const QColor &c);
[[nodiscard]] QImage MenuIconCurrency(const QSize &size);
[[nodiscard]] QImage MenuIconCredits();
std::unique_ptr<Ui::Text::CustomEmoji> MakeCurrencyIconEmoji(
const style::font &font,
const QColor &c);
struct IconDescriptor {
int size = 0;
std::optional<QMargins> margin;
};
[[nodiscard]] Ui::Text::PaletteDependentEmoji IconCreditsEmoji(
IconDescriptor descriptor = {});
[[nodiscard]] Ui::Text::PaletteDependentEmoji IconCurrencyEmoji(
IconDescriptor descriptor = {});
[[nodiscard]] Ui::Text::PaletteDependentEmoji IconCreditsEmojiSmall();
[[nodiscard]] Ui::Text::PaletteDependentEmoji IconCurrencyEmojiSmall();
} // namespace Ui::Earn

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,73 @@
/*
This file is part of Telegram Desktop,
the official desktop application for the Telegram messaging service.
For license and copyright information please follow this link:
https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
*/
#pragma once
#include "info/channel_statistics/earn/info_channel_earn_widget.h"
#include "ui/widgets/scroll_area.h"
#include "ui/wrap/vertical_layout.h"
namespace Ui {
class Show;
class FlatLabel;
} // namespace Ui
namespace Info {
class Controller;
} // namespace Info
namespace Info::ChannelEarn {
class Memento;
class InnerWidget final : public Ui::VerticalLayout {
public:
struct ShowRequest final {
};
InnerWidget(
QWidget *parent,
not_null<Controller*> controller,
not_null<PeerData*> peer);
[[nodiscard]] not_null<PeerData*> peer() const;
[[nodiscard]] rpl::producer<Ui::ScrollToRequest> scrollToRequests() const;
[[nodiscard]] rpl::producer<ShowRequest> showRequests() const;
void showFinished();
void setInnerFocus();
void saveState(not_null<Memento*> memento);
void restoreState(not_null<Memento*> memento);
private:
void load();
void fill();
not_null<Controller*> _controller;
not_null<PeerData*> _peer;
std::shared_ptr<Ui::Show> _show;
Memento::SavedState _state;
rpl::event_stream<Ui::ScrollToRequest> _scrollToRequests;
rpl::event_stream<ShowRequest> _showRequests;
rpl::event_stream<> _showFinished;
rpl::event_stream<> _focusRequested;
rpl::event_stream<bool> _loaded;
rpl::event_stream<> _stateUpdated;
};
void AddEmojiToMajor(
not_null<Ui::FlatLabel*> label,
rpl::producer<CreditsAmount> value,
std::optional<bool> isIn,
std::optional<QMargins> margins);
} // namespace Info::ChannelEarn

View File

@@ -0,0 +1,122 @@
/*
This file is part of Telegram Desktop,
the official desktop application for the Telegram messaging service.
For license and copyright information please follow this link:
https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
*/
#include "info/channel_statistics/earn/info_channel_earn_widget.h"
#include "info/channel_statistics/earn/info_channel_earn_list.h"
#include "info/info_controller.h"
#include "info/info_memento.h"
#include "lang/lang_keys.h"
#include "ui/ui_utility.h"
namespace Info::ChannelEarn {
Memento::Memento(not_null<Controller*> controller)
: ContentMemento(controller->statisticsTag()) {
}
Memento::Memento(not_null<PeerData*> peer)
: ContentMemento(Info::Statistics::Tag{ peer, {}, {} }) {
}
Memento::~Memento() = default;
Section Memento::section() const {
return Section(Section::Type::ChannelEarn);
}
void Memento::setState(SavedState state) {
_state = std::move(state);
}
Memento::SavedState Memento::state() {
return base::take(_state);
}
object_ptr<ContentWidget> Memento::createWidget(
QWidget *parent,
not_null<Controller*> controller,
const QRect &geometry) {
auto result = object_ptr<Widget>(parent, controller);
result->setInternalState(geometry, this);
return result;
}
Widget::Widget(
QWidget *parent,
not_null<Controller*> controller)
: ContentWidget(parent, controller)
, _inner(setInnerWidget(
object_ptr<InnerWidget>(
this,
controller,
controller->statisticsTag().peer))) {
_inner->showRequests(
) | rpl::on_next([=](InnerWidget::ShowRequest request) {
}, _inner->lifetime());
_inner->scrollToRequests(
) | rpl::on_next([=](const Ui::ScrollToRequest &request) {
scrollTo(request);
}, _inner->lifetime());
}
not_null<PeerData*> Widget::peer() const {
return _inner->peer();
}
bool Widget::showInternal(not_null<ContentMemento*> memento) {
return (memento->statisticsTag().peer == peer());
}
rpl::producer<QString> Widget::title() {
return tr::lng_channel_earn_title();
}
void Widget::setInternalState(
const QRect &geometry,
not_null<Memento*> memento) {
setGeometry(geometry);
Ui::SendPendingMoveResizeEvents(this);
restoreState(memento);
}
rpl::producer<bool> Widget::desiredShadowVisibility() const {
return rpl::single<bool>(true);
}
void Widget::showFinished() {
_inner->showFinished();
}
void Widget::setInnerFocus() {
_inner->setInnerFocus();
}
std::shared_ptr<ContentMemento> Widget::doCreateMemento() {
auto result = std::make_shared<Memento>(controller());
saveState(result.get());
return result;
}
void Widget::saveState(not_null<Memento*> memento) {
memento->setScrollTop(scrollTopSave());
_inner->saveState(memento);
}
void Widget::restoreState(not_null<Memento*> memento) {
_inner->restoreState(memento);
scrollTopRestore(memento->scrollTop());
}
std::shared_ptr<Info::Memento> Make(not_null<PeerData*> peer) {
return std::make_shared<Info::Memento>(
std::vector<std::shared_ptr<ContentMemento>>(
1,
std::make_shared<Memento>(peer)));
}
} // namespace Info::ChannelEarn

View File

@@ -0,0 +1,76 @@
/*
This file is part of Telegram Desktop,
the official desktop application for the Telegram messaging service.
For license and copyright information please follow this link:
https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
*/
#pragma once
#include "data/data_channel_earn.h"
#include "data/data_credits.h"
#include "data/data_credits_earn.h"
#include "info/info_content_widget.h"
namespace Info::ChannelEarn {
class InnerWidget;
class Memento final : public ContentMemento {
public:
Memento(not_null<Controller*> controller);
Memento(not_null<PeerData*> peer);
~Memento();
object_ptr<ContentWidget> createWidget(
QWidget *parent,
not_null<Controller*> controller,
const QRect &geometry) override;
Section section() const override;
struct SavedState final {
Data::EarnStatistics currencyEarn;
Data::CreditsEarnStatistics creditsEarn;
Data::CreditsStatusSlice creditsStatusSlice;
PeerId premiumBotId = PeerId(0);
bool canViewCurrencyMegagroupEarn = true;
};
void setState(SavedState states);
[[nodiscard]] SavedState state();
private:
SavedState _state;
};
class Widget final : public ContentWidget {
public:
Widget(QWidget *parent, not_null<Controller*> controller);
bool showInternal(not_null<ContentMemento*> memento) override;
rpl::producer<QString> title() override;
rpl::producer<bool> desiredShadowVisibility() const override;
void showFinished() override;
void setInnerFocus() override;
[[nodiscard]] not_null<PeerData*> peer() const;
void setInternalState(
const QRect &geometry,
not_null<Memento*> memento);
private:
void saveState(not_null<Memento*> memento);
void restoreState(not_null<Memento*> memento);
std::shared_ptr<ContentMemento> doCreateMemento() override;
const not_null<InnerWidget*> _inner;
};
[[nodiscard]] std::shared_ptr<Info::Memento> Make(not_null<PeerData*> peer);
} // namespace Info::ChannelEarn