Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68b1884b73 | |||
| fb786c4c3a | |||
| 07e22079ee |
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.0.6] - 2025-03-04
|
||||||
|
|
||||||
|
(No changes documented; release for version sync.)
|
||||||
|
|
||||||
## [2.0.4] - 2025-03-04
|
## [2.0.4] - 2025-03-04
|
||||||
|
|
||||||
(No changes documented; release for version sync.)
|
(No changes documented; release for version sync.)
|
||||||
@@ -56,7 +60,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Input validation and initData hash verification for Miniapp access.
|
- Input validation and initData hash verification for Miniapp access.
|
||||||
- Optional CORS and init_data_max_age; use env for secrets.
|
- Optional CORS and init_data_max_age; use env for secrets.
|
||||||
|
|
||||||
[Unreleased]: https://github.com/your-org/duty-teller/compare/v2.0.4...HEAD
|
[Unreleased]: https://github.com/your-org/duty-teller/compare/v2.0.6...HEAD
|
||||||
|
[2.0.6]: https://github.com/your-org/duty-teller/releases/tag/v2.0.6 <!-- placeholder: set to your repo URL when publishing -->
|
||||||
[2.0.4]: https://github.com/your-org/duty-teller/releases/tag/v2.0.4 <!-- placeholder: set to your repo URL when publishing -->
|
[2.0.4]: https://github.com/your-org/duty-teller/releases/tag/v2.0.4 <!-- placeholder: set to your repo URL when publishing -->
|
||||||
[2.0.3]: https://github.com/your-org/duty-teller/releases/tag/v2.0.3 <!-- placeholder: set to your repo URL when publishing -->
|
[2.0.3]: https://github.com/your-org/duty-teller/releases/tag/v2.0.3 <!-- placeholder: set to your repo URL when publishing -->
|
||||||
[2.0.2]: https://github.com/your-org/duty-teller/releases/tag/v2.0.2 <!-- placeholder: set to your repo URL when publishing -->
|
[2.0.2]: https://github.com/your-org/duty-teller/releases/tag/v2.0.2 <!-- placeholder: set to your repo URL when publishing -->
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "duty-teller"
|
name = "duty-teller"
|
||||||
version = "2.0.4"
|
version = "2.0.6"
|
||||||
description = "Telegram bot for team duty shift calendar and group reminder"
|
description = "Telegram bot for team duty shift calendar and group reminder"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ html::-webkit-scrollbar {
|
|||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
padding-bottom: env(safe-area-inset-bottom, 12px);
|
padding-bottom: var(--tg-viewport-content-safe-area-inset-bottom, env(safe-area-inset-bottom, 12px));
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,9 +261,25 @@ html::-webkit-scrollbar {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Android low-performance devices: minimize animations (Telegram User-Agent). */
|
||||||
|
[data-perf="low"] *,
|
||||||
|
[data-perf="low"] *::before,
|
||||||
|
[data-perf="low"] *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Safe area for Telegram Mini App (notch / status bar). */
|
/* Safe area for Telegram Mini App (notch / status bar). */
|
||||||
.pt-safe {
|
.pt-safe {
|
||||||
padding-top: env(safe-area-inset-top, 0);
|
padding-top: var(--tg-viewport-content-safe-area-inset-top, env(safe-area-inset-top, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content safe area: top/bottom only to avoid overlap with Telegram header/bottom bar (Bot API 8.0+).
|
||||||
|
Horizontal padding is left to layout classes (e.g. px-3) so indents are preserved when viewport vars are 0. */
|
||||||
|
.content-safe {
|
||||||
|
padding-top: var(--tg-viewport-content-safe-area-inset-top, env(safe-area-inset-top, 0));
|
||||||
|
padding-bottom: var(--tg-viewport-content-safe-area-inset-bottom, env(safe-area-inset-bottom, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sticky calendar header: shadow when scrolled (useStickyScroll). */
|
/* Sticky calendar header: shadow when scrolled (useStickyScroll). */
|
||||||
@@ -305,7 +321,7 @@ html::-webkit-scrollbar {
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
min-height: 100vh;
|
min-height: var(--tg-viewport-stable-height, 100vh);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default function Home() {
|
|||||||
const content = accessDenied ? (
|
const content = accessDenied ? (
|
||||||
<AccessDeniedScreen primaryAction="reload" />
|
<AccessDeniedScreen primaryAction="reload" />
|
||||||
) : currentView === "currentDuty" ? (
|
) : currentView === "currentDuty" ? (
|
||||||
<div className="mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6 pt-safe">
|
<div className="content-safe mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6">
|
||||||
<CurrentDutyView
|
<CurrentDutyView
|
||||||
onBack={handleBackFromCurrentDuty}
|
onBack={handleBackFromCurrentDuty}
|
||||||
openedFromPin={startParam === "duty"}
|
openedFromPin={startParam === "duty"}
|
||||||
@@ -62,9 +62,9 @@ export default function Home() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
className="min-h-[var(--tg-viewport-stable-height,100vh)]"
|
||||||
style={{
|
style={{
|
||||||
visibility: appContentReady ? "visible" : "hidden",
|
visibility: appContentReady ? "visible" : "hidden",
|
||||||
minHeight: "100vh",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
|
|||||||
}, [loading, accessDenied, setAppContentReady]);
|
}, [loading, accessDenied, setAppContentReady]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6 pt-safe">
|
<div className="content-safe mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6">
|
||||||
<div
|
<div
|
||||||
ref={calendarStickyRef}
|
ref={calendarStickyRef}
|
||||||
className="sticky top-0 z-10 min-h-[var(--calendar-block-min-height)] bg-background pb-2"
|
className="sticky top-0 z-10 min-h-[var(--calendar-block-min-height)] bg-background pb-2"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
formatHHMM,
|
formatHHMM,
|
||||||
} from "@/lib/date-utils";
|
} from "@/lib/date-utils";
|
||||||
import { getRemainingTime, findCurrentDuty } from "@/lib/current-duty";
|
import { getRemainingTime, findCurrentDuty } from "@/lib/current-duty";
|
||||||
|
import { triggerHapticLight } from "@/lib/telegram-haptic";
|
||||||
import { ContactLinks } from "@/components/contact/ContactLinks";
|
import { ContactLinks } from "@/components/contact/ContactLinks";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -145,10 +146,12 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
|
|||||||
}, [onBack]);
|
}, [onBack]);
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
|
triggerHapticLight();
|
||||||
onBack();
|
onBack();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
triggerHapticLight();
|
||||||
if (closeMiniApp.isAvailable()) {
|
if (closeMiniApp.isAvailable()) {
|
||||||
closeMiniApp();
|
closeMiniApp();
|
||||||
} else {
|
} else {
|
||||||
@@ -213,6 +216,7 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
|
|||||||
|
|
||||||
if (state === "error") {
|
if (state === "error") {
|
||||||
const handleRetry = () => {
|
const handleRetry = () => {
|
||||||
|
triggerHapticLight();
|
||||||
setState("loading");
|
setState("loading");
|
||||||
loadTodayDuties();
|
loadTodayDuties();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,15 +6,20 @@ import {
|
|||||||
mountMiniAppSync,
|
mountMiniAppSync,
|
||||||
mountThemeParamsSync,
|
mountThemeParamsSync,
|
||||||
bindThemeParamsCssVars,
|
bindThemeParamsCssVars,
|
||||||
|
mountViewport,
|
||||||
|
bindViewportCssVars,
|
||||||
|
unmountViewport,
|
||||||
} from "@telegram-apps/sdk-react";
|
} from "@telegram-apps/sdk-react";
|
||||||
import { fixSurfaceContrast } from "@/hooks/use-telegram-theme";
|
import { fixSurfaceContrast } from "@/hooks/use-telegram-theme";
|
||||||
|
import { applyAndroidPerformanceClass } from "@/lib/telegram-android-perf";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps the app with Telegram Mini App SDK initialization.
|
* Wraps the app with Telegram Mini App SDK initialization.
|
||||||
* Calls init(acceptCustomStyles), mounts theme params (binds --tg-theme-* CSS vars),
|
* Calls init(acceptCustomStyles), mounts theme params (binds --tg-theme-* CSS vars),
|
||||||
* and mounts the mini app. Does not call ready() here — the app calls
|
* mounts the mini app, then mounts viewport and binds viewport CSS vars
|
||||||
* callMiniAppReadyOnce() from lib/telegram-ready when the first visible screen
|
* (--tg-viewport-stable-height, --tg-viewport-content-safe-area-inset-*, etc.).
|
||||||
* has finished loading, so Telegram keeps its native loading animation until then.
|
* Does not call ready() here — the app calls callMiniAppReadyOnce() from
|
||||||
|
* lib/telegram-ready when the first visible screen has finished loading.
|
||||||
* Theme is set before first paint by the inline script in layout.tsx (URL hash);
|
* Theme is set before first paint by the inline script in layout.tsx (URL hash);
|
||||||
* useTelegramTheme() in the app handles ongoing theme changes.
|
* useTelegramTheme() in the app handles ongoing theme changes.
|
||||||
*/
|
*/
|
||||||
@@ -39,7 +44,26 @@ export function TelegramProvider({
|
|||||||
mountMiniAppSync();
|
mountMiniAppSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
return cleanup;
|
applyAndroidPerformanceClass();
|
||||||
|
|
||||||
|
let unbindViewportCssVars: (() => void) | undefined;
|
||||||
|
if (mountViewport.isAvailable()) {
|
||||||
|
mountViewport()
|
||||||
|
.then(() => {
|
||||||
|
if (bindViewportCssVars.isAvailable()) {
|
||||||
|
unbindViewportCssVars = bindViewportCssVars();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Viewport not supported (e.g. not in Mini App); ignore.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unbindViewportCssVars?.();
|
||||||
|
unmountViewport();
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { getLang, translate } from "@/i18n/messages";
|
import { getLang, translate } from "@/i18n/messages";
|
||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
import { triggerHapticLight } from "@/lib/telegram-haptic";
|
||||||
|
|
||||||
export interface AccessDeniedScreenProps {
|
export interface AccessDeniedScreenProps {
|
||||||
/** Optional detail from API 403 response, shown below the hint. */
|
/** Optional detail from API 403 response, shown below the hint. */
|
||||||
@@ -40,6 +41,7 @@ export function AccessDeniedScreen({
|
|||||||
const hasDetail = Boolean(serverDetail && String(serverDetail).trim());
|
const hasDetail = Boolean(serverDetail && String(serverDetail).trim());
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
|
triggerHapticLight();
|
||||||
if (primaryAction === "reload") {
|
if (primaryAction === "reload") {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { useTranslation } from "@/i18n/use-translation";
|
import { useTranslation } from "@/i18n/use-translation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { triggerHapticLight } from "@/lib/telegram-haptic";
|
||||||
|
|
||||||
export interface ErrorStateProps {
|
export interface ErrorStateProps {
|
||||||
/** Error message to display. If not provided, uses generic i18n message. */
|
/** Error message to display. If not provided, uses generic i18n message. */
|
||||||
@@ -65,7 +66,10 @@ export function ErrorState({ message, onRetry, className }: ErrorStateProps) {
|
|||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="mt-1 bg-primary text-primary-foreground hover:opacity-90 focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2"
|
className="mt-1 bg-primary text-primary-foreground hover:opacity-90 focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2"
|
||||||
onClick={onRetry}
|
onClick={() => {
|
||||||
|
triggerHapticLight();
|
||||||
|
onRetry();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t("error.retry")}
|
{t("error.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ vi.mock("@telegram-apps/sdk-react", () => ({
|
|||||||
isThemeParamsDark: vi.fn(),
|
isThemeParamsDark: vi.fn(),
|
||||||
setMiniAppBackgroundColor: { isAvailable: vi.fn(() => false) },
|
setMiniAppBackgroundColor: { isAvailable: vi.fn(() => false) },
|
||||||
setMiniAppHeaderColor: { isAvailable: vi.fn(() => false) },
|
setMiniAppHeaderColor: { isAvailable: vi.fn(() => false) },
|
||||||
|
setMiniAppBottomBarColor: { isAvailable: vi.fn(() => false) },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("getFallbackScheme", () => {
|
describe("getFallbackScheme", () => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
isThemeParamsDark,
|
isThemeParamsDark,
|
||||||
setMiniAppBackgroundColor,
|
setMiniAppBackgroundColor,
|
||||||
setMiniAppHeaderColor,
|
setMiniAppHeaderColor,
|
||||||
|
setMiniAppBottomBarColor,
|
||||||
} from "@telegram-apps/sdk-react";
|
} from "@telegram-apps/sdk-react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,6 +70,9 @@ export function applyTheme(scheme?: "dark" | "light"): void {
|
|||||||
if (setMiniAppHeaderColor.isAvailable()) {
|
if (setMiniAppHeaderColor.isAvailable()) {
|
||||||
setMiniAppHeaderColor("bg_color");
|
setMiniAppHeaderColor("bg_color");
|
||||||
}
|
}
|
||||||
|
if (setMiniAppBottomBarColor.isAvailable()) {
|
||||||
|
setMiniAppBottomBarColor("bottom_bar_bg_color");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
26
webapp-next/src/lib/telegram-android-perf.ts
Normal file
26
webapp-next/src/lib/telegram-android-perf.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Detects Android Telegram Mini App performance class from User-Agent and sets
|
||||||
|
* data-perf on document.documentElement so CSS can reduce animations on low-end devices.
|
||||||
|
* @see https://core.telegram.org/bots/webapps#additional-data-in-user-agent
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { retrieveAndroidDeviceData } from "@telegram-apps/sdk-react";
|
||||||
|
|
||||||
|
const DATA_ATTR = "data-perf";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs once: if running in Telegram on Android with LOW performance class,
|
||||||
|
* sets data-perf="low" on the document root for CSS to minimize animations.
|
||||||
|
*/
|
||||||
|
export function applyAndroidPerformanceClass(): void {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
try {
|
||||||
|
const data = retrieveAndroidDeviceData();
|
||||||
|
const perf = data?.performanceClass;
|
||||||
|
if (perf === "LOW") {
|
||||||
|
document.documentElement.setAttribute(DATA_ATTR, "low");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not in Telegram or not Android; ignore.
|
||||||
|
}
|
||||||
|
}
|
||||||
19
webapp-next/src/lib/telegram-haptic.ts
Normal file
19
webapp-next/src/lib/telegram-haptic.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Triggers Telegram Mini App haptic feedback when available.
|
||||||
|
* Use on primary actions and key interactions to mimic native Telegram behavior.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { hapticFeedbackImpactOccurred } from "@telegram-apps/sdk-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers light impact haptic feedback. No-op when not in Telegram or unsupported.
|
||||||
|
*/
|
||||||
|
export function triggerHapticLight(): void {
|
||||||
|
try {
|
||||||
|
if (hapticFeedbackImpactOccurred.isAvailable()) {
|
||||||
|
hapticFeedbackImpactOccurred("light");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// SDK not available; ignore.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* Single-call wrapper for Telegram Mini App ready().
|
* Single-call wrapper for Telegram Mini App ready() and expand().
|
||||||
* Called once when the first visible screen has finished loading so Telegram
|
* Called once when the first visible screen has finished loading so Telegram
|
||||||
* hides its native loading animation only after our content is ready.
|
* hides its native loading animation only after our content is ready.
|
||||||
|
* Also expands the Mini App to full height when supported.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { miniAppReady } from "@telegram-apps/sdk-react";
|
import { miniAppReady, expandViewport } from "@telegram-apps/sdk-react";
|
||||||
|
|
||||||
let readyCalled = false;
|
let readyCalled = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calls Telegram miniAppReady() at most once per session.
|
* Calls Telegram miniAppReady() at most once per session, then expandViewport()
|
||||||
|
* when available so the app opens to full height.
|
||||||
* Safe when SDK is unavailable (e.g. non-Telegram environment).
|
* Safe when SDK is unavailable (e.g. non-Telegram environment).
|
||||||
*/
|
*/
|
||||||
export function callMiniAppReadyOnce(): void {
|
export function callMiniAppReadyOnce(): void {
|
||||||
@@ -19,6 +21,9 @@ export function callMiniAppReadyOnce(): void {
|
|||||||
miniAppReady();
|
miniAppReady();
|
||||||
readyCalled = true;
|
readyCalled = true;
|
||||||
}
|
}
|
||||||
|
if (expandViewport.isAvailable()) {
|
||||||
|
expandViewport();
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// SDK not available or not in Mini App context; no-op.
|
// SDK not available or not in Mini App context; no-op.
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user