feat: notification badge at tray
|
@ -6,22 +6,27 @@
|
||||||
|
|
||||||
import { app, NativeImage, nativeImage } from "electron";
|
import { app, NativeImage, nativeImage } from "electron";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
import { IpcEvents } from "shared/IpcEvents";
|
||||||
import { BADGE_DIR } from "shared/paths";
|
import { BADGE_DIR } from "shared/paths";
|
||||||
|
|
||||||
|
import { mainWin } from "./mainWindow";
|
||||||
|
|
||||||
const imgCache = new Map<number, NativeImage>();
|
const imgCache = new Map<number, NativeImage>();
|
||||||
function loadBadge(index: number) {
|
export function loadBadge(index: number) {
|
||||||
const cached = imgCache.get(index);
|
const cached = imgCache.get(index);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
const img = nativeImage.createFromPath(join(BADGE_DIR, `${index}.ico`));
|
const img = nativeImage.createFromPath(join(BADGE_DIR, `${index}.png`));
|
||||||
imgCache.set(index, img);
|
imgCache.set(index, img);
|
||||||
|
|
||||||
return img;
|
return img;
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastIndex: null | number = -1;
|
let lastBadgeIndex: null | number = -1;
|
||||||
|
export var lastBadgeCount: number = -1;
|
||||||
|
|
||||||
export function setBadgeCount(count: number) {
|
export function setBadgeCount(count: number) {
|
||||||
|
lastBadgeCount = count;
|
||||||
switch (process.platform) {
|
switch (process.platform) {
|
||||||
case "linux":
|
case "linux":
|
||||||
if (count === -1) count = 0;
|
if (count === -1) count = 0;
|
||||||
|
@ -36,15 +41,17 @@ export function setBadgeCount(count: number) {
|
||||||
break;
|
break;
|
||||||
case "win32":
|
case "win32":
|
||||||
const [index, description] = getBadgeIndexAndDescription(count);
|
const [index, description] = getBadgeIndexAndDescription(count);
|
||||||
if (lastIndex === index) break;
|
if (lastBadgeIndex === index) break;
|
||||||
|
|
||||||
lastIndex = index;
|
lastBadgeIndex = index;
|
||||||
|
|
||||||
// circular import shenanigans
|
// circular import shenanigans
|
||||||
const { mainWin } = require("./mainWindow") as typeof import("./mainWindow");
|
const { mainWin } = require("./mainWindow") as typeof import("./mainWindow");
|
||||||
mainWin.setOverlayIcon(index === null ? null : loadBadge(index), description);
|
mainWin.setOverlayIcon(index === null ? null : loadBadge(index), description);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mainWin.webContents.send(IpcEvents.SET_CURRENT_VOICE_TRAY_ICON);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBadgeIndexAndDescription(count: number): [number | null, string] {
|
function getBadgeIndexAndDescription(count: number): [number | null, string] {
|
||||||
|
|
|
@ -28,9 +28,17 @@ import { IpcEvents } from "../shared/IpcEvents";
|
||||||
import { setBadgeCount } from "./appBadge";
|
import { setBadgeCount } from "./appBadge";
|
||||||
import { autoStart } from "./autoStart";
|
import { autoStart } from "./autoStart";
|
||||||
import { VENCORD_FILES_DIR, VENCORD_QUICKCSS_FILE, VENCORD_THEMES_DIR } from "./constants";
|
import { VENCORD_FILES_DIR, VENCORD_QUICKCSS_FILE, VENCORD_THEMES_DIR } from "./constants";
|
||||||
import { mainWin, setTrayIcon } from "./mainWindow";
|
import { mainWin } from "./mainWindow";
|
||||||
import { Settings } from "./settings";
|
import { Settings } from "./settings";
|
||||||
import { createTrayIcon, generateTrayIcons, getTrayIconFile, getTrayIconFileSync, pickTrayIcon } from "./tray";
|
import {
|
||||||
|
createTrayIcon,
|
||||||
|
generateTrayIcons,
|
||||||
|
getIconWithBadge,
|
||||||
|
getTrayIconFile,
|
||||||
|
getTrayIconFileSync,
|
||||||
|
pickTrayIcon,
|
||||||
|
setTrayIcon
|
||||||
|
} from "./tray";
|
||||||
import { handle, handleSync } from "./utils/ipcWrappers";
|
import { handle, handleSync } from "./utils/ipcWrappers";
|
||||||
import { PopoutWindows } from "./utils/popout";
|
import { PopoutWindows } from "./utils/popout";
|
||||||
import { isDeckGameMode, showGamePage } from "./utils/steamOS";
|
import { isDeckGameMode, showGamePage } from "./utils/steamOS";
|
||||||
|
@ -170,3 +178,4 @@ handle(IpcEvents.CREATE_TRAY_ICON_RESPONSE, (_, iconName, dataURL, isCustomIcon)
|
||||||
);
|
);
|
||||||
handle(IpcEvents.GENERATE_TRAY_ICONS, () => generateTrayIcons());
|
handle(IpcEvents.GENERATE_TRAY_ICONS, () => generateTrayIcons());
|
||||||
handle(IpcEvents.SELECT_TRAY_ICON, async (_, iconName) => pickTrayIcon(iconName));
|
handle(IpcEvents.SELECT_TRAY_ICON, async (_, iconName) => pickTrayIcon(iconName));
|
||||||
|
handle(IpcEvents.GET_ICON_WITH_BADGE, async (_, dataURL) => getIconWithBadge(dataURL));
|
||||||
|
|
|
@ -11,8 +11,6 @@ import {
|
||||||
dialog,
|
dialog,
|
||||||
Menu,
|
Menu,
|
||||||
MenuItemConstructorOptions,
|
MenuItemConstructorOptions,
|
||||||
NativeImage,
|
|
||||||
nativeImage,
|
|
||||||
nativeTheme,
|
nativeTheme,
|
||||||
screen,
|
screen,
|
||||||
session,
|
session,
|
||||||
|
@ -21,7 +19,7 @@ import {
|
||||||
import { rm } from "fs/promises";
|
import { rm } from "fs/promises";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { IpcEvents } from "shared/IpcEvents";
|
import { IpcEvents } from "shared/IpcEvents";
|
||||||
import { ICON_PATH, ICONS_DIR } from "shared/paths";
|
import { ICON_PATH } from "shared/paths";
|
||||||
import { isTruthy } from "shared/utils/guards";
|
import { isTruthy } from "shared/utils/guards";
|
||||||
import { once } from "shared/utils/once";
|
import { once } from "shared/utils/once";
|
||||||
import type { SettingsStore } from "shared/utils/SettingsStore";
|
import type { SettingsStore } from "shared/utils/SettingsStore";
|
||||||
|
@ -40,13 +38,13 @@ import {
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
import { Settings, State, VencordSettings } from "./settings";
|
import { Settings, State, VencordSettings } from "./settings";
|
||||||
import { createSplashWindow } from "./splash";
|
import { createSplashWindow } from "./splash";
|
||||||
import { generateTrayIcons, isCustomIcon, statusToSettingsKey } from "./tray";
|
import { setTrayIcon } from "./tray";
|
||||||
import { makeLinksOpenExternally } from "./utils/makeLinksOpenExternally";
|
import { makeLinksOpenExternally } from "./utils/makeLinksOpenExternally";
|
||||||
import { applyDeckKeyboardFix, askToApplySteamLayout, isDeckGameMode } from "./utils/steamOS";
|
import { applyDeckKeyboardFix, askToApplySteamLayout, isDeckGameMode } from "./utils/steamOS";
|
||||||
import { downloadVencordFiles, ensureVencordFiles } from "./utils/vencordLoader";
|
import { downloadVencordFiles, ensureVencordFiles } from "./utils/vencordLoader";
|
||||||
|
|
||||||
let isQuitting = false;
|
let isQuitting = false;
|
||||||
let tray: Tray;
|
export let tray: Tray;
|
||||||
|
|
||||||
applyDeckKeyboardFix();
|
applyDeckKeyboardFix();
|
||||||
|
|
||||||
|
@ -127,14 +125,7 @@ function initTray(win: BrowserWindow) {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
tray = new Tray(ICON_PATH);
|
tray = new Tray(ICON_PATH);
|
||||||
try {
|
setTrayIcon("icon");
|
||||||
if (Settings.store.trayMainOverride) tray.setImage(join(ICONS_DIR, "icon_custom.png"));
|
|
||||||
else tray.setImage(join(ICONS_DIR, "icon.png"));
|
|
||||||
} catch (error) {
|
|
||||||
console.log("Error while loading custom tray image. Recreating new ones.");
|
|
||||||
Settings.store.trayMainOverride = false;
|
|
||||||
generateTrayIcons();
|
|
||||||
}
|
|
||||||
tray.setToolTip("Vesktop");
|
tray.setToolTip("Vesktop");
|
||||||
tray.setContextMenu(trayMenu);
|
tray.setContextMenu(trayMenu);
|
||||||
tray.on("click", onTrayClick);
|
tray.on("click", onTrayClick);
|
||||||
|
@ -512,34 +503,3 @@ export async function createWindows() {
|
||||||
|
|
||||||
initArRPC();
|
initArRPC();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setTrayIcon(iconName: string) {
|
|
||||||
if (!tray || tray.isDestroyed()) return;
|
|
||||||
const Icons = new Set(["speaking", "muted", "deafened", "idle", "icon"]);
|
|
||||||
|
|
||||||
if (!Icons.has(iconName)) return;
|
|
||||||
try {
|
|
||||||
var trayImage: NativeImage;
|
|
||||||
if (isCustomIcon(iconName)) {
|
|
||||||
trayImage = nativeImage.createFromPath(join(ICONS_DIR, iconName + "_custom.png"));
|
|
||||||
if (trayImage.isEmpty()) {
|
|
||||||
const iconKey = statusToSettingsKey[iconName as keyof typeof statusToSettingsKey];
|
|
||||||
Settings.store[iconKey] = false;
|
|
||||||
generateTrayIcons();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else trayImage = nativeImage.createFromPath(join(ICONS_DIR, iconName + ".png"));
|
|
||||||
if (trayImage.isEmpty()) {
|
|
||||||
generateTrayIcons();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (process.platform === "darwin") {
|
|
||||||
trayImage = trayImage.resize({ width: 16, height: 16 });
|
|
||||||
}
|
|
||||||
tray.setImage(trayImage);
|
|
||||||
} catch (error) {
|
|
||||||
console.log("Error: ", error, "Regenerating tray icons.");
|
|
||||||
generateTrayIcons(); // TODO: pass here only one icon request
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
|
@ -5,13 +5,14 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { dialog, NativeImage, nativeImage } from "electron";
|
import { dialog, NativeImage, nativeImage } from "electron";
|
||||||
import { copyFileSync, mkdirSync, writeFileSync } from "fs";
|
import { mkdirSync, writeFileSync } from "fs";
|
||||||
import { readFile } from "fs/promises";
|
import { readFile } from "fs/promises";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { IpcEvents } from "shared/IpcEvents";
|
import { IpcEvents } from "shared/IpcEvents";
|
||||||
import { ICONS_DIR, STATIC_DIR } from "shared/paths";
|
import { ICONS_DIR, STATIC_DIR } from "shared/paths";
|
||||||
|
|
||||||
import { mainWin } from "./mainWindow";
|
import { lastBadgeCount, loadBadge } from "./appBadge";
|
||||||
|
import { mainWin, tray } from "./mainWindow";
|
||||||
import { Settings } from "./settings";
|
import { Settings } from "./settings";
|
||||||
|
|
||||||
export const statusToSettingsKey = {
|
export const statusToSettingsKey = {
|
||||||
|
@ -27,6 +28,62 @@ export const isCustomIcon = (status: string) => {
|
||||||
return Settings.store[settingKey];
|
return Settings.store[settingKey];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export async function setTrayIcon(iconName: string) {
|
||||||
|
if (!tray || tray.isDestroyed()) return;
|
||||||
|
const Icons = new Set(["speaking", "muted", "deafened", "idle", "icon"]);
|
||||||
|
if (!Icons.has(iconName)) return;
|
||||||
|
|
||||||
|
// if need to set main icon then check whether there is need of notif badge
|
||||||
|
if (iconName === "icon" && lastBadgeCount && lastBadgeCount > 0) {
|
||||||
|
var trayImage: NativeImage;
|
||||||
|
if (isCustomIcon("icon")) {
|
||||||
|
console.log("setting badge and CUSTOM icon");
|
||||||
|
trayImage = nativeImage.createFromPath(join(ICONS_DIR, "icon_custom.png"));
|
||||||
|
} else {
|
||||||
|
console.log("setting badge and stock icon");
|
||||||
|
trayImage = nativeImage.createFromPath(join(ICONS_DIR, "icon.png"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const badgeImg = loadBadge(lastBadgeCount);
|
||||||
|
// and send IPC call to renderer to add to image
|
||||||
|
mainWin.webContents.send(IpcEvents.ADD_BADGE_TO_ICON, trayImage.toDataURL(), badgeImg.toDataURL());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var trayImage: NativeImage;
|
||||||
|
if (isCustomIcon(iconName)) {
|
||||||
|
trayImage = nativeImage.createFromPath(join(ICONS_DIR, iconName + "_custom.png"));
|
||||||
|
if (trayImage.isEmpty()) {
|
||||||
|
const iconKey = statusToSettingsKey[iconName as keyof typeof statusToSettingsKey];
|
||||||
|
Settings.store[iconKey] = false;
|
||||||
|
generateTrayIcons();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else trayImage = nativeImage.createFromPath(join(ICONS_DIR, iconName + ".png"));
|
||||||
|
if (trayImage.isEmpty()) {
|
||||||
|
generateTrayIcons();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
trayImage = trayImage.resize({ width: 16, height: 16 });
|
||||||
|
}
|
||||||
|
tray.setImage(trayImage);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Error: ", error, "Regenerating tray icons.");
|
||||||
|
generateTrayIcons(); // TODO: pass here only one icon request
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setTrayIconWithBadge(iconDataURL: string) {
|
||||||
|
var trayImage = nativeImage.createFromDataURL(iconDataURL);
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
trayImage = trayImage.resize({ width: 16, height: 16 });
|
||||||
|
}
|
||||||
|
tray.setImage(trayImage);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getTrayIconFile(iconName: string) {
|
export async function getTrayIconFile(iconName: string) {
|
||||||
const Icons = new Set(["speaking", "muted", "deafened", "idle"]);
|
const Icons = new Set(["speaking", "muted", "deafened", "idle"]);
|
||||||
if (!Icons.has(iconName)) {
|
if (!Icons.has(iconName)) {
|
||||||
|
@ -62,7 +119,9 @@ export async function createTrayIcon(iconName: string, iconDataURL: string, isCu
|
||||||
// primarily called from renderer using CREATE_TRAY_ICON_RESPONSE IPC call
|
// primarily called from renderer using CREATE_TRAY_ICON_RESPONSE IPC call
|
||||||
iconDataURL = iconDataURL.replace(/^data:image\/png;base64,/, "");
|
iconDataURL = iconDataURL.replace(/^data:image\/png;base64,/, "");
|
||||||
if (isCustomIcon) {
|
if (isCustomIcon) {
|
||||||
writeFileSync(join(ICONS_DIR, iconName + "_custom.png"), iconDataURL, "base64");
|
const img = nativeImage.createFromDataURL(iconDataURL).resize({ width: 128, height: 128 });
|
||||||
|
// writeFileSync(join(ICONS_DIR, iconName + "_custom.png"), img.toDataURL(), "base64");
|
||||||
|
writeFileSync(join(ICONS_DIR, iconName + "_custom.png"), img.toPNG());
|
||||||
} else {
|
} else {
|
||||||
writeFileSync(join(ICONS_DIR, iconName + ".png"), iconDataURL, "base64");
|
writeFileSync(join(ICONS_DIR, iconName + ".png"), iconDataURL, "base64");
|
||||||
}
|
}
|
||||||
|
@ -76,7 +135,8 @@ export async function generateTrayIcons() {
|
||||||
for (const icon of Icons) {
|
for (const icon of Icons) {
|
||||||
mainWin.webContents.send(IpcEvents.CREATE_TRAY_ICON_REQUEST, icon);
|
mainWin.webContents.send(IpcEvents.CREATE_TRAY_ICON_REQUEST, icon);
|
||||||
}
|
}
|
||||||
copyFileSync(join(STATIC_DIR, "icon.png"), join(ICONS_DIR, "icon.png"));
|
const img = nativeImage.createFromPath(join(STATIC_DIR, "icon.png")).resize({ width: 128, height: 128 });
|
||||||
|
writeFileSync(join(ICONS_DIR, "icon.png"), img.toPNG());
|
||||||
mainWin.webContents.send(IpcEvents.SET_CURRENT_VOICE_TRAY_ICON);
|
mainWin.webContents.send(IpcEvents.SET_CURRENT_VOICE_TRAY_ICON);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -93,6 +153,11 @@ export async function pickTrayIcon(iconName: string) {
|
||||||
// add .svg !!
|
// add .svg !!
|
||||||
const image = nativeImage.createFromPath(dir);
|
const image = nativeImage.createFromPath(dir);
|
||||||
if (image.isEmpty()) return "invalid";
|
if (image.isEmpty()) return "invalid";
|
||||||
copyFileSync(dir, join(ICONS_DIR, iconName + "_custom.png"));
|
const img = nativeImage.createFromPath(dir).resize({ width: 128, height: 128 });
|
||||||
|
writeFileSync(join(ICONS_DIR, iconName + "_custom.png"), img.toPNG());
|
||||||
return dir;
|
return dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getIconWithBadge(dataURL: string) {
|
||||||
|
tray.setImage(nativeImage.createFromDataURL(dataURL));
|
||||||
|
}
|
||||||
|
|
|
@ -94,6 +94,12 @@ export const VesktopNative = {
|
||||||
generateTrayIcons: () => invoke<void>(IpcEvents.GENERATE_TRAY_ICONS),
|
generateTrayIcons: () => invoke<void>(IpcEvents.GENERATE_TRAY_ICONS),
|
||||||
setCurrentVoiceIcon: (listener: (...args: any[]) => void) => {
|
setCurrentVoiceIcon: (listener: (...args: any[]) => void) => {
|
||||||
ipcRenderer.on(IpcEvents.SET_CURRENT_VOICE_TRAY_ICON, listener);
|
ipcRenderer.on(IpcEvents.SET_CURRENT_VOICE_TRAY_ICON, listener);
|
||||||
}
|
},
|
||||||
|
addBadgeToIcon: (listener: (iconDataURL: string, badgeDataURL: string) => void) => {
|
||||||
|
ipcRenderer.on(IpcEvents.ADD_BADGE_TO_ICON, (_, iconDataURL: string, badgeDataURL: string) =>
|
||||||
|
listener(iconDataURL, badgeDataURL)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
returnIconWithBadge: (dataURL: string) => invoke<void>(IpcEvents.GET_ICON_WITH_BADGE, dataURL)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -58,6 +58,36 @@ VesktopNative.tray.createIconRequest(async (iconName: string) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
VesktopNative.tray.addBadgeToIcon(async (iconDataURL: string, badgeDataURL: string) => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = 128;
|
||||||
|
canvas.height = 128;
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.width = 128;
|
||||||
|
img.height = 128;
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (ctx) {
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
|
const iconImg = new Image();
|
||||||
|
iconImg.width = 64;
|
||||||
|
iconImg.height = 64;
|
||||||
|
|
||||||
|
iconImg.onload = () => {
|
||||||
|
ctx.drawImage(iconImg, 64, 0, 64, 64);
|
||||||
|
VesktopNative.tray.returnIconWithBadge(canvas.toDataURL());
|
||||||
|
};
|
||||||
|
|
||||||
|
iconImg.src = badgeDataURL;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = iconDataURL;
|
||||||
|
});
|
||||||
|
|
||||||
VesktopNative.tray.setCurrentVoiceIcon(() => {
|
VesktopNative.tray.setCurrentVoiceIcon(() => {
|
||||||
setCurrentTrayIcon();
|
setCurrentTrayIcon();
|
||||||
});
|
});
|
||||||
|
|
|
@ -59,5 +59,7 @@ export const enum IpcEvents {
|
||||||
GENERATE_TRAY_ICONS = "VCD_GENERATE_TRAY_ICONS",
|
GENERATE_TRAY_ICONS = "VCD_GENERATE_TRAY_ICONS",
|
||||||
SET_CURRENT_VOICE_TRAY_ICON = "VCD_SET_CURRENT_VOICE_ICON",
|
SET_CURRENT_VOICE_TRAY_ICON = "VCD_SET_CURRENT_VOICE_ICON",
|
||||||
GET_SYSTEM_ACCENT_COLOR = "VCD_GET_ACCENT_COLOR",
|
GET_SYSTEM_ACCENT_COLOR = "VCD_GET_ACCENT_COLOR",
|
||||||
SELECT_TRAY_ICON = "VCD_SELECT_TRAY_ICON"
|
SELECT_TRAY_ICON = "VCD_SELECT_TRAY_ICON",
|
||||||
|
ADD_BADGE_TO_ICON = "VCD_ADD_BADGE_TO_ICON",
|
||||||
|
GET_ICON_WITH_BADGE = "VCD_GET_ICON_WITH_BADGE"
|
||||||
}
|
}
|
||||||
|
|
Before Width: | Height: | Size: 15 KiB |
BIN
static/badges/1.png
Normal file
After Width: | Height: | Size: 1 KiB |
Before Width: | Height: | Size: 15 KiB |
BIN
static/badges/10.png
Normal file
After Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 15 KiB |
BIN
static/badges/11.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 15 KiB |
BIN
static/badges/2.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 15 KiB |
BIN
static/badges/3.png
Normal file
After Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 15 KiB |
BIN
static/badges/4.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 15 KiB |
BIN
static/badges/5.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 15 KiB |
BIN
static/badges/6.png
Normal file
After Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 15 KiB |
BIN
static/badges/7.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 15 KiB |
BIN
static/badges/8.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 15 KiB |
BIN
static/badges/9.png
Normal file
After Width: | Height: | Size: 1.6 KiB |