Chats list: show balance & subscription for Telegram-linked accounts

- SponsorHelper now caches balance, expire_at, plan and telegram_linked
  from /mobile/me (keyed by Telegram id)
- New FoxAccountInfoCell: balance on the left, subscription days left on
  the right, shown as the first row above 'Archived chats'
- Only visible when the Telegram account is linked to GhostCloud/FoxCloud
- DialogsActivity refreshes on resume and updates the row on change
This commit is contained in:
instant992 2026-06-09 23:40:37 +04:00
parent 2fc29ea46d
commit b4c3d9fbd6
6 changed files with 233 additions and 8 deletions

View file

@ -56,6 +56,7 @@ import org.telegram.ui.Cells.DialogMeUrlCell;
import org.telegram.ui.Cells.DialogsEmptyCell; import org.telegram.ui.Cells.DialogsEmptyCell;
import org.telegram.ui.Cells.DialogsHintCell; import org.telegram.ui.Cells.DialogsHintCell;
import org.telegram.ui.Cells.DialogsRequestedEmptyCell; import org.telegram.ui.Cells.DialogsRequestedEmptyCell;
import org.telegram.ui.Cells.FoxAccountInfoCell;
import org.telegram.ui.Cells.GraySectionCell; import org.telegram.ui.Cells.GraySectionCell;
import org.telegram.ui.Cells.HeaderCell; import org.telegram.ui.Cells.HeaderCell;
import org.telegram.ui.Cells.ProfileSearchCell; import org.telegram.ui.Cells.ProfileSearchCell;
@ -82,6 +83,8 @@ import java.util.Collections;
import java.util.HashSet; import java.util.HashSet;
import java.util.Objects; import java.util.Objects;
import tw.nekomimi.nekogram.helpers.SponsorHelper;
public class DialogsAdapter extends RecyclerListView.SelectionAdapter implements DialogCell.DialogCellDelegate { public class DialogsAdapter extends RecyclerListView.SelectionAdapter implements DialogCell.DialogCellDelegate {
public final static int VIEW_TYPE_DIALOG = 0, public final static int VIEW_TYPE_DIALOG = 0,
VIEW_TYPE_FLICKER = 1, VIEW_TYPE_FLICKER = 1,
@ -104,7 +107,8 @@ public class DialogsAdapter extends RecyclerListView.SelectionAdapter implements
VIEW_TYPE_STORIES = 18, VIEW_TYPE_STORIES = 18,
VIEW_TYPE_ARCHIVE_FULLSCREEN = 19, VIEW_TYPE_ARCHIVE_FULLSCREEN = 19,
VIEW_TYPE_GRAY_SECTION = 20, VIEW_TYPE_GRAY_SECTION = 20,
VIEW_TYPE_FORWARD_TO_STORIES_CELL = 21; VIEW_TYPE_FORWARD_TO_STORIES_CELL = 21,
VIEW_TYPE_FOX_ACCOUNT_INFO = 22;
private Context mContext; private Context mContext;
private ArchiveHintCell archiveHintCell; private ArchiveHintCell archiveHintCell;
@ -172,6 +176,18 @@ public class DialogsAdapter extends RecyclerListView.SelectionAdapter implements
isReordering = reordering; isReordering = reordering;
} }
private boolean shouldShowFoxAccountInfo() {
return dialogsType == DialogsActivity.DIALOGS_TYPE_DEFAULT
&& folderId == 0
&& !isOnlySelect
&& !isTransitionSupport
&& !collapsedView
&& requestPeerType == null
&& (parentFragment == null || !parentFragment.isReplyTo)
&& getCurrentFilter() == null
&& SponsorHelper.isCurrentUserLinked();
}
public int fixPosition(int position) { public int fixPosition(int position) {
if (hasChatlistHint) { if (hasChatlistHint) {
position--; position--;
@ -328,6 +344,8 @@ public class DialogsAdapter extends RecyclerListView.SelectionAdapter implements
this.emptyType = viewTypeEmpty; this.emptyType = viewTypeEmpty;
if (viewTypeEmpty == VIEW_TYPE_LAST_EMPTY) { if (viewTypeEmpty == VIEW_TYPE_LAST_EMPTY) {
stableId = 1; stableId = 1;
} else if (viewTypeEmpty == VIEW_TYPE_FOX_ACCOUNT_INFO) {
stableId = 7;
} else { } else {
if (viewType == VIEW_TYPE_ARCHIVE_FULLSCREEN) { if (viewType == VIEW_TYPE_ARCHIVE_FULLSCREEN) {
stableId = 5; stableId = 5;
@ -574,7 +592,7 @@ public class DialogsAdapter extends RecyclerListView.SelectionAdapter implements
return viewType != VIEW_TYPE_FLICKER && viewType != VIEW_TYPE_EMPTY && viewType != VIEW_TYPE_DIVIDER && return viewType != VIEW_TYPE_FLICKER && viewType != VIEW_TYPE_EMPTY && viewType != VIEW_TYPE_DIVIDER &&
viewType != VIEW_TYPE_SHADOW && viewType != VIEW_TYPE_HEADER && viewType != VIEW_TYPE_SHADOW && viewType != VIEW_TYPE_HEADER &&
viewType != VIEW_TYPE_LAST_EMPTY && viewType != VIEW_TYPE_NEW_CHAT_HINT && viewType != VIEW_TYPE_CONTACTS_FLICKER && viewType != VIEW_TYPE_LAST_EMPTY && viewType != VIEW_TYPE_NEW_CHAT_HINT && viewType != VIEW_TYPE_CONTACTS_FLICKER &&
viewType != VIEW_TYPE_REQUIREMENTS && viewType != VIEW_TYPE_REQUIRED_EMPTY && viewType != VIEW_TYPE_STORIES && viewType != VIEW_TYPE_ARCHIVE_FULLSCREEN && viewType != VIEW_TYPE_GRAY_SECTION; viewType != VIEW_TYPE_REQUIREMENTS && viewType != VIEW_TYPE_REQUIRED_EMPTY && viewType != VIEW_TYPE_STORIES && viewType != VIEW_TYPE_ARCHIVE_FULLSCREEN && viewType != VIEW_TYPE_GRAY_SECTION && viewType != VIEW_TYPE_FOX_ACCOUNT_INFO;
} }
@Override @Override
@ -765,6 +783,9 @@ public class DialogsAdapter extends RecyclerListView.SelectionAdapter implements
case VIEW_TYPE_FOLDER_UPDATE_HINT: case VIEW_TYPE_FOLDER_UPDATE_HINT:
view = new DialogsHintCell(mContext); view = new DialogsHintCell(mContext);
break; break;
case VIEW_TYPE_FOX_ACCOUNT_INFO:
view = new FoxAccountInfoCell(mContext);
break;
case VIEW_TYPE_STORIES: { case VIEW_TYPE_STORIES: {
view = new View(mContext) { view = new View(mContext) {
@Override @Override
@ -1083,6 +1104,10 @@ public class DialogsAdapter extends RecyclerListView.SelectionAdapter implements
} }
break; break;
} }
case VIEW_TYPE_FOX_ACCOUNT_INFO: {
((FoxAccountInfoCell) holder.itemView).update();
break;
}
} }
if (i >= dialogsCount + 1) { if (i >= dialogsCount + 1) {
holder.itemView.setAlpha(1f); holder.itemView.setAlpha(1f);
@ -1475,6 +1500,10 @@ public class DialogsAdapter extends RecyclerListView.SelectionAdapter implements
return; return;
} }
if (shouldShowFoxAccountInfo()) {
itemInternals.add(new ItemInternal(VIEW_TYPE_FOX_ACCOUNT_INFO));
}
if (!hasHints && dialogsType == 0 && folderId == 0 && messagesController.isDialogsEndReached(folderId) && !forceUpdatingContacts) { if (!hasHints && dialogsType == 0 && folderId == 0 && messagesController.isDialogsEndReached(folderId) && !forceUpdatingContacts) {
if (messagesController.getAllFoldersDialogsCount() <= 10 && ContactsController.getInstance(currentAccount).doneLoadingContacts && !ContactsController.getInstance(currentAccount).contacts.isEmpty()) { if (messagesController.getAllFoldersDialogsCount() <= 10 && ContactsController.getInstance(currentAccount).doneLoadingContacts && !ContactsController.getInstance(currentAccount).contacts.isEmpty()) {
onlineContacts = new ArrayList<>(ContactsController.getInstance(currentAccount).contacts); onlineContacts = new ArrayList<>(ContactsController.getInstance(currentAccount).contacts);

View file

@ -0,0 +1,85 @@
package org.telegram.ui.Cells;
import android.content.Context;
import android.util.TypedValue;
import android.view.Gravity;
import android.widget.FrameLayout;
import android.widget.TextView;
import org.telegram.messenger.AndroidUtilities;
import org.telegram.messenger.LocaleController;
import org.telegram.messenger.R;
import org.telegram.messenger.UserConfig;
import org.telegram.ui.ActionBar.Theme;
import org.telegram.ui.Components.LayoutHelper;
import java.util.Locale;
import tw.nekomimi.nekogram.helpers.SponsorHelper;
/**
* A slim, non-clickable info row shown at the very top of the chats list for
* users who linked their Telegram account to GhostCloud/FoxCloud. Shows the
* account balance on the left and the subscription expiry on the right.
*/
public class FoxAccountInfoCell extends FrameLayout {
private final TextView balanceTextView;
private final TextView subTextView;
public FoxAccountInfoCell(Context context) {
super(context);
balanceTextView = new TextView(context);
balanceTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 13);
balanceTextView.setTextColor(Theme.getColor(Theme.key_windowBackgroundWhiteGrayText2));
balanceTextView.setGravity(Gravity.LEFT | Gravity.CENTER_VERTICAL);
balanceTextView.setSingleLine();
addView(balanceTextView, LayoutHelper.createFrame(LayoutHelper.WRAP_CONTENT, LayoutHelper.MATCH_PARENT,
Gravity.LEFT | Gravity.CENTER_VERTICAL, 16, 0, 8, 0));
subTextView = new TextView(context);
subTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 13);
subTextView.setTextColor(Theme.getColor(Theme.key_windowBackgroundWhiteGrayText2));
subTextView.setGravity(Gravity.RIGHT | Gravity.CENTER_VERTICAL);
subTextView.setSingleLine();
addView(subTextView, LayoutHelper.createFrame(LayoutHelper.WRAP_CONTENT, LayoutHelper.MATCH_PARENT,
Gravity.RIGHT | Gravity.CENTER_VERTICAL, 8, 0, 16, 0));
update();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(AndroidUtilities.dp(30), MeasureSpec.EXACTLY));
}
public void update() {
var user = UserConfig.getInstance(UserConfig.selectedAccount).getCurrentUser();
long tgId = user != null ? user.id : 0;
long kopecks = SponsorHelper.getBalanceKopecks(tgId);
double rubles = kopecks / 100.0;
String balanceStr;
if (rubles == Math.floor(rubles)) {
balanceStr = String.format(Locale.getDefault(), "%,d \u20bd", (long) rubles);
} else {
balanceStr = String.format(Locale.getDefault(), "%,.2f \u20bd", rubles);
}
balanceTextView.setText(LocaleController.formatString(R.string.FoxAccountBalance, balanceStr));
String expireAt = SponsorHelper.getExpireAt(tgId);
long expireMs = SponsorHelper.parseExpireMillis(expireAt);
if (expireMs <= 0) {
subTextView.setText(LocaleController.getString(R.string.FoxAccountSubNone));
} else {
long now = System.currentTimeMillis();
if (expireMs <= now) {
subTextView.setText(LocaleController.getString(R.string.FoxAccountSubExpired));
} else {
long daysLeft = (long) Math.ceil((expireMs - now) / (24.0 * 60 * 60 * 1000));
subTextView.setText(LocaleController.formatString(R.string.FoxAccountDaysLeft, (int) daysLeft));
}
}
}
}

View file

@ -2865,6 +2865,7 @@ public class DialogsActivity extends BaseFragment implements NotificationCenter.
if (!onlySelect) { if (!onlySelect) {
globalObserversGroup.add(NotificationCenter.closeSearchByActiveAction); globalObserversGroup.add(NotificationCenter.closeSearchByActiveAction);
globalObserversGroup.add(NotificationCenter.proxySettingsChanged); globalObserversGroup.add(NotificationCenter.proxySettingsChanged);
globalObserversGroup.add(NotificationCenter.sponsorStatusUpdated);
observersGroup.add(NotificationCenter.filterSettingsUpdated); observersGroup.add(NotificationCenter.filterSettingsUpdated);
observersGroup.add(NotificationCenter.dialogsUnreadCounterChanged); observersGroup.add(NotificationCenter.dialogsUnreadCounterChanged);
} }
@ -6992,6 +6993,9 @@ public class DialogsActivity extends BaseFragment implements NotificationCenter.
@Override @Override
public void onResume() { public void onResume() {
super.onResume(); super.onResume();
if (!onlySelect && folderId == 0) {
tw.nekomimi.nekogram.helpers.SponsorHelper.refresh(false);
}
if (dialogStoriesCell != null) { if (dialogStoriesCell != null) {
dialogStoriesCell.onResume(); dialogStoriesCell.onResume();
} }
@ -10380,6 +10384,16 @@ public class DialogsActivity extends BaseFragment implements NotificationCenter.
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@Override @Override
public void didReceivedNotification(int id, int account, Object... args) { public void didReceivedNotification(int id, int account, Object... args) {
if (id == NotificationCenter.sponsorStatusUpdated) {
if (viewPages != null) {
for (int a = 0; a < viewPages.length; a++) {
if (viewPages[a] != null && viewPages[a].dialogsAdapter != null) {
viewPages[a].dialogsAdapter.notifyDataSetChanged();
}
}
}
return;
}
if (id == NotificationCenter.dialogsNeedReload) { if (id == NotificationCenter.dialogsNeedReload) {
if (viewPages == null || dialogsListFrozen) { if (viewPages == null || dialogsListFrozen) {
return; return;

View file

@ -61,6 +61,73 @@ public final class SponsorHelper {
return user != null && isSponsor(user.id); return user != null && isSponsor(user.id);
} }
/**
* Whether the given Telegram id is linked to a FoxCloud/GhostCloud account.
* Only meaningful after at least one successful {@link #refresh(boolean)}.
*/
public static boolean isLinked(long telegramId) {
if (telegramId == 0) {
return false;
}
return prefs().getBoolean("linked_" + telegramId, false);
}
public static boolean isCurrentUserLinked() {
var user = UserConfig.getInstance(UserConfig.selectedAccount).getCurrentUser();
return user != null && isLinked(user.id);
}
/** Cached account balance in kopecks (1/100 of a ruble). */
public static long getBalanceKopecks(long telegramId) {
if (telegramId == 0) {
return 0;
}
return prefs().getLong("balance_" + telegramId, 0);
}
/**
* Cached subscription expiry, as the raw string returned by the backend
* ({@code "yyyy-MM-dd HH:mm:ss"}), or empty when there is no subscription.
*/
public static String getExpireAt(long telegramId) {
if (telegramId == 0) {
return "";
}
return prefs().getString("expire_" + telegramId, "");
}
/** Cached plan name, or empty. */
public static String getPlan(long telegramId) {
if (telegramId == 0) {
return "";
}
return prefs().getString("plan_" + telegramId, "");
}
/**
* Parse the backend expiry string ({@code "yyyy-MM-dd HH:mm:ss"}, treated as
* UTC) into epoch millis, or 0 if absent/unparseable.
*/
public static long parseExpireMillis(String expireAt) {
if (expireAt == null || expireAt.isEmpty()) {
return 0;
}
String s = expireAt.trim();
String[] patterns = {"yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd"};
for (String pattern : patterns) {
try {
java.text.SimpleDateFormat fmt = new java.text.SimpleDateFormat(pattern, java.util.Locale.US);
fmt.setTimeZone(java.util.TimeZone.getTimeZone("UTC"));
java.util.Date d = fmt.parse(s);
if (d != null) {
return d.getTime();
}
} catch (Exception ignore) {
}
}
return 0;
}
/** /**
* Kick off a background refresh of the sponsor status for the current * Kick off a background refresh of the sponsor status for the current
* account. Safe to call repeatedly: it self-throttles via {@link #TTL_MS} * account. Safe to call repeatedly: it self-throttles via {@link #TTL_MS}
@ -124,7 +191,12 @@ public final class SponsorHelper {
return; return;
} }
boolean sponsor = json.optBoolean("is_sponsor", false); boolean sponsor = json.optBoolean("is_sponsor", false);
store(telegramId, sponsor); boolean linked = json.optBoolean("telegram_linked", false);
// balance is sent in kopecks; the client divides by 100 for display.
long balance = json.optLong("balance", 0);
String expireAt = json.isNull("expire_at") ? "" : json.optString("expire_at", "");
String plan = json.isNull("plan") ? "" : json.optString("plan", "");
store(telegramId, sponsor, linked, balance, expireAt, plan);
} catch (Exception e) { } catch (Exception e) {
FileLog.e(e); FileLog.e(e);
} finally { } finally {
@ -134,13 +206,29 @@ public final class SponsorHelper {
} }
} }
private static void store(long telegramId, boolean sponsor) { private static void store(long telegramId, boolean sponsor, boolean linked, long balance, String expireAt, String plan) {
boolean previous = prefs().getBoolean("sponsor_" + telegramId, false); SharedPreferences p = prefs();
prefs().edit() boolean previousSponsor = p.getBoolean("sponsor_" + telegramId, false);
boolean previousLinked = p.getBoolean("linked_" + telegramId, false);
long previousBalance = p.getLong("balance_" + telegramId, 0);
String previousExpire = p.getString("expire_" + telegramId, "");
String previousPlan = p.getString("plan_" + telegramId, "");
p.edit()
.putBoolean("sponsor_" + telegramId, sponsor) .putBoolean("sponsor_" + telegramId, sponsor)
.putBoolean("linked_" + telegramId, linked)
.putLong("balance_" + telegramId, balance)
.putString("expire_" + telegramId, expireAt != null ? expireAt : "")
.putString("plan_" + telegramId, plan != null ? plan : "")
.putLong("checked_" + telegramId, System.currentTimeMillis()) .putLong("checked_" + telegramId, System.currentTimeMillis())
.apply(); .apply();
if (previous != sponsor) {
boolean changed = previousSponsor != sponsor
|| previousLinked != linked
|| previousBalance != balance
|| !previousExpire.equals(expireAt != null ? expireAt : "")
|| !previousPlan.equals(plan != null ? plan : "");
if (changed) {
AndroidUtilities.runOnUIThread(() -> AndroidUtilities.runOnUIThread(() ->
NotificationCenter.getGlobalInstance().postNotificationName(NotificationCenter.sponsorStatusUpdated)); NotificationCenter.getGlobalInstance().postNotificationName(NotificationCenter.sponsorStatusUpdated));
} }
@ -149,7 +237,6 @@ public final class SponsorHelper {
// //
// Top sponsors leaderboard // Top sponsors leaderboard
// //
public static class Sponsor { public static class Sponsor {
public int rank; public int rank;
public String name; public String name;

View file

@ -331,4 +331,9 @@
<string name="FoxTopSponsorsAbout">Те, кто поддерживает проект</string> <string name="FoxTopSponsorsAbout">Те, кто поддерживает проект</string>
<string name="FoxTopSponsorsEmpty">Пока нет спонсоров</string> <string name="FoxTopSponsorsEmpty">Пока нет спонсоров</string>
<string name="FoxTopSponsorsError">Не удалось загрузить список</string> <string name="FoxTopSponsorsError">Не удалось загрузить список</string>
<string name="FoxAccountBalance">Баланс: %1$s</string>
<string name="FoxAccountSubActive">Подписка: %1$s</string>
<string name="FoxAccountSubExpired">Подписка истекла</string>
<string name="FoxAccountSubNone">Нет подписки</string>
<string name="FoxAccountDaysLeft">осталось %1$d дн.</string>
</resources> </resources>

View file

@ -157,6 +157,11 @@
<string name="FoxTopSponsorsAbout">People who support the project</string> <string name="FoxTopSponsorsAbout">People who support the project</string>
<string name="FoxTopSponsorsEmpty">No sponsors yet</string> <string name="FoxTopSponsorsEmpty">No sponsors yet</string>
<string name="FoxTopSponsorsError">Failed to load sponsors</string> <string name="FoxTopSponsorsError">Failed to load sponsors</string>
<string name="FoxAccountBalance">Balance: %1$s</string>
<string name="FoxAccountSubActive">Subscription: %1$s</string>
<string name="FoxAccountSubExpired">Subscription expired</string>
<string name="FoxAccountSubNone">No subscription</string>
<string name="FoxAccountDaysLeft">%1$d d. left</string>
<string name="UpdateDownloading">Downloading update...</string> <string name="UpdateDownloading">Downloading update...</string>
<string name="UpdateInstallingNotification">A notification will be shown when the update completes.</string> <string name="UpdateInstallingNotification">A notification will be shown when the update completes.</string>
<string name="UpdateInstallingRelaunch">The app will relaunch when the update completes.</string> <string name="UpdateInstallingRelaunch">The app will relaunch when the update completes.</string>