Sponsor: GhostCloud labeling, curved gloss streak, support link & top sponsors

- Bulletin now reads 'Sponsor GhostCloud' + granted for 444+ rubles (en/ru)
- Heart highlight reworked: thin curved streak sweeping diagonally instead
  of an orbiting blob; palette tuned closer to the reference
- SponsorHelper.loadTopSponsors() hits mobile donors/leaderboard via X-Tg-Id
- New FoxSponsorsActivity lists the top sponsors
- Neko settings: 'Support the project' (t.me/vpnghostbot) + 'Top sponsors'
This commit is contained in:
instant992 2026-06-09 09:54:31 +04:00
parent 9b81a463a6
commit 47f9aef160
6 changed files with 263 additions and 39 deletions

View file

@ -1,5 +1,6 @@
package tw.nekomimi.nekogram.helpers;
import android.graphics.BlurMaskFilter;
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.LinearGradient;
@ -16,9 +17,9 @@ import androidx.annotation.NonNull;
import org.telegram.messenger.AndroidUtilities;
/**
* A glossy 3D-looking heart badge with a soft multicolor gradient (purple/blue
* to orange) and a moving glossy highlight ("blik") sweeping across it, like an
* iridescent emoji sticker.
* A glossy 3D-looking heart badge with a soft multicolor gradient (purple
* blue orange) and a thin curved glossy streak ("blik") that slowly sweeps
* diagonally across it, like light reflecting off a shiny sticker.
*
* It self-invalidates each frame, so when attached to a view via
* {@code setRightDrawable(...)} / {@code setRightDrawable2(...)} the host keeps
@ -29,14 +30,16 @@ public class ShimmerHeartDrawable extends Drawable {
private final Paint basePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint tintPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint highlightPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint shadePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint glossDotPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint streakPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Path heart = new Path();
private final Path streak = new Path();
private int lastWidth = -1;
private int lastHeight = -1;
private static final long CYCLE_MS = 3200L;
private static final long CYCLE_MS = 3600L;
private final int size;
public ShimmerHeartDrawable() {
@ -47,8 +50,10 @@ public class ShimmerHeartDrawable extends Drawable {
this.size = sizePx;
basePaint.setStyle(Paint.Style.FILL);
tintPaint.setStyle(Paint.Style.FILL);
highlightPaint.setStyle(Paint.Style.FILL);
shadePaint.setStyle(Paint.Style.FILL);
glossDotPaint.setStyle(Paint.Style.FILL);
streakPaint.setStyle(Paint.Style.STROKE);
streakPaint.setStrokeCap(Paint.Cap.ROUND);
}
private void buildHeart(Rect b) {
@ -72,25 +77,35 @@ public class ShimmerHeartDrawable extends Drawable {
float l = b.left;
float t = b.top;
// Base diagonal gradient: deep purple (top-left) -> blue -> warm orange (bottom-right).
// Base diagonal gradient matching the reference: violet (top-left)
// blue cyan warm orange (bottom-right).
basePaint.setShader(new LinearGradient(
l, t, l + w, t + h,
new int[]{0xFF7A3CE0, 0xFF5B6BFF, 0xFF4FA8FF, 0xFFFF9A4D, 0xFFFF7A3C},
new float[]{0f, 0.32f, 0.55f, 0.82f, 1f},
l + w * 0.15f, t, l + w * 0.9f, t + h,
new int[]{0xFF8E2DE2, 0xFF5B5BFF, 0xFF2F9BFF, 0xFFFFB24D, 0xFFFF8A3D},
new float[]{0f, 0.30f, 0.55f, 0.85f, 1f},
Shader.TileMode.CLAMP));
// A soft purple glow blob on the upper-left lobe for depth.
// Soft violet glow on the upper-left lobe for depth.
tintPaint.setShader(new RadialGradient(
l + w * 0.30f, t + h * 0.30f, w * 0.55f,
new int[]{0xCC8A4DFF, 0x00000000},
l + w * 0.32f, t + h * 0.28f, w * 0.6f,
new int[]{0x999B30FF, 0x00000000},
null, Shader.TileMode.CLAMP));
// Soft inner shade at the bottom tip for a rounded 3D feel.
// Inner shade at the bottom tip for a rounded 3D feel.
shadePaint.setShader(new RadialGradient(
l + w * 0.5f, t + h * 0.95f, w * 0.6f,
new int[]{0x66351A6B, 0x00000000},
l + w * 0.55f, t + h * 0.92f, w * 0.55f,
new int[]{0x55401E7A, 0x00000000},
null, Shader.TileMode.CLAMP));
// Fixed small specular dot, top-left (glossy sticker look).
glossDotPaint.setShader(new RadialGradient(
l + w * 0.33f, t + h * 0.27f, w * 0.16f,
new int[]{0xE6FFFFFF, 0x00FFFFFF},
null, Shader.TileMode.CLAMP));
streakPaint.setStrokeWidth(Math.max(1f, w * 0.10f));
streakPaint.setMaskFilter(new BlurMaskFilter(Math.max(1f, w * 0.06f), BlurMaskFilter.Blur.NORMAL));
lastWidth = b.width();
lastHeight = b.height();
}
@ -115,34 +130,46 @@ public class ShimmerHeartDrawable extends Drawable {
float w = b.width();
float h = b.height();
float phase = (System.currentTimeMillis() % CYCLE_MS) / (float) CYCLE_MS;
double ang = phase * 2 * Math.PI;
float l = b.left;
float t = b.top;
int save = canvas.save();
canvas.clipPath(heart);
// Base colors + purple glow + bottom shade.
// Base colors + violet glow + bottom shade.
canvas.drawPath(heart, basePaint);
canvas.drawPath(heart, tintPaint);
canvas.drawPath(heart, shadePaint);
// Moving glossy highlight: a soft bright blob orbiting inside the heart.
float hx = b.left + w * (0.5f + 0.28f * (float) Math.cos(ang));
float hy = b.top + h * (0.42f + 0.24f * (float) Math.sin(ang));
float hr = w * 0.45f;
highlightPaint.setShader(new RadialGradient(
hx, hy, hr,
new int[]{0xCCFFFFFF, 0x33FFFFFF, 0x00FFFFFF},
new float[]{0f, 0.4f, 1f},
Shader.TileMode.CLAMP));
canvas.drawPath(heart, highlightPaint);
// Moving curved glossy streak sweeping diagonally across the heart.
float phase = (System.currentTimeMillis() % CYCLE_MS) / (float) CYCLE_MS;
// travel from top-left (off-screen) to bottom-right (off-screen)
float p = -0.4f + phase * 1.8f;
float dx = w * p; // horizontal offset of the streak
streak.reset();
// A gently curved (bowed) vertical-ish line, slanted diagonally.
float x0 = l + dx + w * 0.10f;
float y0 = t - h * 0.10f;
float xc = l + dx + w * 0.45f;
float yc = t + h * 0.50f;
float x1 = l + dx + w * 0.30f;
float y1 = t + h * 1.10f;
streak.moveTo(x0, y0);
streak.quadTo(xc, yc, x1, y1);
// A small fixed top-left specular dot for a glossy sticker look.
highlightPaint.setShader(new RadialGradient(
b.left + w * 0.34f, b.top + h * 0.28f, w * 0.18f,
new int[]{0xE6FFFFFF, 0x00FFFFFF},
null, Shader.TileMode.CLAMP));
canvas.drawPath(heart, highlightPaint);
// Fade the streak in/out at the edges of the sweep.
float edge = Math.min(1f, Math.min(phase, 1f - phase) * 3f);
int alpha = (int) (200 * Math.max(0f, edge));
int whiteCore = (alpha << 24) | 0x00FFFFFF;
streakPaint.setShader(new LinearGradient(
x0, y0, x1, y1,
new int[]{0x00FFFFFF, whiteCore, 0x00FFFFFF},
new float[]{0f, 0.5f, 1f},
Shader.TileMode.CLAMP));
canvas.drawPath(streak, streakPaint);
// Fixed specular dot.
canvas.drawPath(heart, glossDotPaint);
canvas.restoreToCount(save);

View file

@ -2,6 +2,7 @@ package tw.nekomimi.nekogram.helpers;
import android.content.SharedPreferences;
import org.json.JSONArray;
import org.json.JSONObject;
import org.telegram.messenger.AndroidUtilities;
import org.telegram.messenger.ApplicationLoader;
@ -13,6 +14,8 @@ import org.telegram.messenger.Utilities;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
/**
* Resolves the "sponsor" status of the current Telegram user against the
@ -142,4 +145,93 @@ public final class SponsorHelper {
NotificationCenter.getGlobalInstance().postNotificationName(NotificationCenter.sponsorStatusUpdated));
}
}
//
// Top sponsors leaderboard
//
public static class Sponsor {
public int rank;
public String name;
public double totalAmount;
public boolean isMe;
}
public interface SponsorsCallback {
/** Called on the UI thread. {@code sponsors} is null on error. */
void onResult(List<Sponsor> sponsors);
}
/**
* Load the top-sponsors leaderboard from the backend. Authenticated by the
* current account's Telegram ID. The callback runs on the UI thread.
*/
public static void loadTopSponsors(SponsorsCallback callback) {
var user = UserConfig.getInstance(UserConfig.selectedAccount).getCurrentUser();
final long telegramId = user != null ? user.id : 0;
Utilities.globalQueue.postRunnable(() -> {
List<Sponsor> result = fetchTopSponsors(telegramId);
AndroidUtilities.runOnUIThread(() -> callback.onResult(result));
});
}
// @WorkerThread
private static List<Sponsor> fetchTopSponsors(long telegramId) {
HttpURLConnection connection = null;
try {
URL url = new URL(BASE_URL + "/api/auth/mobile/donors/leaderboard");
connection = (HttpURLConnection) url.openConnection();
connection.setInstanceFollowRedirects(true);
connection.setConnectTimeout(15000);
connection.setReadTimeout(15000);
if (telegramId != 0) {
connection.setRequestProperty("X-Tg-Id", String.valueOf(telegramId));
}
connection.setRequestProperty("Accept", "application/json");
int code = connection.getResponseCode();
if (code != HttpURLConnection.HTTP_OK) {
FileLog.d(TAG + ": donors/leaderboard returned HTTP " + code);
return null;
}
StringBuilder sb = new StringBuilder();
try (InputStream in = connection.getInputStream()) {
byte[] buffer = new byte[4096];
int read;
while ((read = in.read(buffer)) >= 0) {
sb.append(new String(buffer, 0, read, "UTF-8"));
}
}
JSONObject json = new JSONObject(sb.toString());
if (!json.optBoolean("success", false)) {
return null;
}
JSONArray board = json.optJSONArray("leaderboard");
List<Sponsor> list = new ArrayList<>();
if (board != null) {
for (int i = 0; i < board.length(); i++) {
JSONObject o = board.optJSONObject(i);
if (o == null) {
continue;
}
Sponsor s = new Sponsor();
s.rank = o.optInt("rank", i + 1);
s.name = o.optString("name", "");
s.totalAmount = o.optDouble("total_amount", 0);
s.isMe = o.optBoolean("is_me", false);
list.add(s);
}
}
return list;
} catch (Exception e) {
FileLog.e(e);
return null;
} finally {
if (connection != null) {
connection.disconnect();
}
}
}
}

View file

@ -0,0 +1,83 @@
package tw.nekomimi.nekogram.settings;
import android.view.View;
import org.telegram.messenger.LocaleController;
import org.telegram.messenger.R;
import org.telegram.ui.Components.UItem;
import org.telegram.ui.Components.UniversalAdapter;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import tw.nekomimi.nekogram.helpers.SponsorHelper;
public class FoxSponsorsActivity extends BaseNekoSettingsActivity {
private final int headerRow = rowId++;
private final int firstSponsorRow = 1000;
private List<SponsorHelper.Sponsor> sponsors = null;
private boolean loading = true;
private boolean failed = false;
@Override
public boolean onFragmentCreate() {
super.onFragmentCreate();
load();
return true;
}
private void load() {
loading = true;
failed = false;
SponsorHelper.loadTopSponsors(result -> {
loading = false;
if (result == null) {
failed = true;
sponsors = null;
} else {
failed = false;
sponsors = result;
}
if (listView != null) {
listView.adapter.update(true);
}
});
}
@Override
protected void fillItems(ArrayList<UItem> items, UniversalAdapter adapter) {
items.add(UItem.asShadow(LocaleController.getString(R.string.FoxTopSponsorsAbout)));
if (loading) {
items.add(TextSettingsCellFactory.of(headerRow, LocaleController.getString(R.string.Loading)));
return;
}
if (failed) {
items.add(TextSettingsCellFactory.of(headerRow, LocaleController.getString(R.string.FoxTopSponsorsError)));
return;
}
if (sponsors == null || sponsors.isEmpty()) {
items.add(TextSettingsCellFactory.of(headerRow, LocaleController.getString(R.string.FoxTopSponsorsEmpty)));
return;
}
int i = 0;
for (SponsorHelper.Sponsor s : sponsors) {
String title = s.rank + ". " + s.name + (s.isMe ? " (" + LocaleController.getString(R.string.FromYou) + ")" : "");
String amount = String.format(Locale.getDefault(), "%,.0f \u20bd", s.totalAmount);
items.add(TextSettingsCellFactory.of(firstSponsorRow + i, title, amount));
i++;
}
items.add(UItem.asShadow(null));
}
@Override
protected void onItemClick(UItem item, View view, int position, float x, float y) {
}
@Override
protected String getActionBarTitle() {
return LocaleController.getString(R.string.FoxTopSponsors);
}
}

View file

@ -68,6 +68,8 @@ public class NekoSettingsActivity extends BaseNekoSettingsActivity implements Fa
private final int sourceCodeRow = rowId++;
private final int translationRow = rowId++;
private final int donateRow = rowId++;
private final int supportProjectRow = rowId++;
private final int topSponsorsRow = rowId++;
private final int sponsorRow = 100;
@ -178,6 +180,10 @@ public class NekoSettingsActivity extends BaseNekoSettingsActivity implements Fa
items.add(UItem.asButtonSubtext(donateRow, R.drawable.msg_input_like, LocaleController.getString(R.string.Donate), LocaleController.getString(R.string.DonateAbout)).slug("donate"));
items.add(UItem.asShadow(null));
items.add(UItem.asButtonSubtext(supportProjectRow, R.drawable.msg_input_like, LocaleController.getString(R.string.FoxSupportProject), LocaleController.getString(R.string.FoxSupportProjectAbout)).slug("supportProject"));
items.add(UItem.asButtonSubtext(topSponsorsRow, R.drawable.msg_premium_liststar, LocaleController.getString(R.string.FoxTopSponsors), LocaleController.getString(R.string.FoxTopSponsorsAbout)).slug("topSponsors"));
items.add(UItem.asShadow(null));
newsList.clear();
newsList.addAll(ConfigHelper.getNewsForSettings());
if (!newsList.isEmpty()) {
@ -214,6 +220,10 @@ public class NekoSettingsActivity extends BaseNekoSettingsActivity implements Fa
getMessagesController().openByUserName(LocaleController.getString(R.string.OfficialChannelUsername), this, 1);
} else if (id == donateRow) {
presentFragment(new NekoDonateActivity());
} else if (id == supportProjectRow) {
Browser.openUrl(getParentActivity(), "https://t.me/vpnghostbot");
} else if (id == topSponsorsRow) {
presentFragment(new FoxSponsorsActivity());
} else if (id == translationRow) {
Browser.openUrl(getParentActivity(), "https://neko.crowdin.com/nekogram");
} else if (id == websiteRow) {

View file

@ -323,6 +323,12 @@
<string name="StrokeOnViews">Эффекты бликов</string>
<!-- Accessibility -->
<string name="AccessibilitySettings">Настройки специальных возможностей</string>
<string name="FoxSponsorBadge">Спонсор</string>
<string name="FoxSponsorBadgeInfo">Эта отметка выдана за поддержку проекта. Спасибо!</string>
<string name="FoxSponsorBadge">Спонсор GhostCloud</string>
<string name="FoxSponsorBadgeInfo">Эта отметка выдана за поддержание проекта на 444+ рублей.</string>
<string name="FoxSupportProject">Поддержать проект</string>
<string name="FoxSupportProjectAbout">Оформите GhostCloud через нашего бота</string>
<string name="FoxTopSponsors">Топ спонсоров</string>
<string name="FoxTopSponsorsAbout">Те, кто поддерживает проект</string>
<string name="FoxTopSponsorsEmpty">Пока нет спонсоров</string>
<string name="FoxTopSponsorsError">Не удалось загрузить список</string>
</resources>

View file

@ -149,8 +149,14 @@
<string name="ShareNekogram">Share FoxiGram...</string>
<string name="NekogramVersion">FoxiGram %1$s\nBased on Telegram %2$s\nDesigned by %3$s</string>
<string name="UpdateInstalling">Installing update...</string>
<string name="FoxSponsorBadge">Sponsor</string>
<string name="FoxSponsorBadgeInfo">You received this badge for supporting the project. Thank you!</string>
<string name="FoxSponsorBadge">Sponsor GhostCloud</string>
<string name="FoxSponsorBadgeInfo">This badge is granted for supporting the project with 444+ rubles.</string>
<string name="FoxSupportProject">Support the project</string>
<string name="FoxSupportProjectAbout">Subscribe to GhostCloud via our bot</string>
<string name="FoxTopSponsors">Top sponsors</string>
<string name="FoxTopSponsorsAbout">People who support the project</string>
<string name="FoxTopSponsorsEmpty">No sponsors yet</string>
<string name="FoxTopSponsorsError">Failed to load sponsors</string>
<string name="UpdateDownloading">Downloading update...</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>