Skip to content

Commit 209e979

Browse files
Miseiclaude
authored andcommitted
Add donation prompt dialog feature
Implement a donation prompt dialog that appears on first launch and every 30 days to encourage users to support the project. Users can permanently dismiss the dialog with a "Don't show this again" option. Backend (Rust): - Add DonationPromptConfig structure to track last shown timestamp and dismissal status - Implement should_show_donation_prompt command to check display conditions - Implement dismiss_donation_prompt command to update state and persist config - Add donation_prompt field to AppState and AppConfig with backward compatibility Frontend (React): - Create DonationDialog component with Material-UI matching existing design patterns - Add three support buttons: GitHub Star, GitHub Sponsors, Twitch Subscribe - Integrate dialog into App.tsx with smart display logic (main window only, 2s delay) The dialog only shows on the main window, not on popup windows like List Manager. Configuration is persisted in config.json with backward compatibility using #[serde(default)]. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 508b9e0 commit 209e979

File tree

8 files changed

+402
-6
lines changed

8 files changed

+402
-6
lines changed

DONATION_DIALOG_IMPLEMENTATION.md

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Donation Dialog Implementation
2+
3+
## Summary
4+
Successfully implemented a donation prompt dialog that appears on first launch and every 30 days thereafter, with an option to permanently dismiss it.
5+
6+
## Changes Made
7+
8+
### Rust Backend
9+
10+
#### 1. `app/src-tauri/src/types.rs`
11+
- Added `DonationPromptConfig` struct with:
12+
- `last_shown: Option<String>` - RFC3339 timestamp of last display
13+
- `permanently_dismissed: bool` - Flag for "Don't show again"
14+
- Added `donation_prompt: Option<DonationPromptConfig>` field to `AppConfig` (with `#[serde(default)]` for backward compatibility)
15+
16+
#### 2. `app/src-tauri/src/state.rs`
17+
- Added `donation_prompt: Arc<Mutex<DonationPromptConfig>>` to `AppState`
18+
- Initialized in `new()` with `DonationPromptConfig::default()`
19+
- Updated `save_config()` to include donation_prompt in AppConfig
20+
- Updated `load_config()` to restore donation_prompt from config.json
21+
22+
#### 3. `app/src-tauri/src/commands.rs`
23+
- Added `use chrono::{DateTime, Duration, Utc};` for date/time handling
24+
- Implemented `should_show_donation_prompt()`:
25+
- Returns `false` if `permanently_dismissed == true`
26+
- Returns `true` if `last_shown == None` (first launch)
27+
- Returns `true` if 30+ days have passed since `last_shown`
28+
- Returns `false` otherwise
29+
- Implemented `dismiss_donation_prompt(permanently: bool)`:
30+
- Updates `last_shown` to current timestamp
31+
- Sets `permanently_dismissed = true` if requested
32+
- Calls `save_config()` to persist changes
33+
34+
#### 4. `app/src-tauri/src/lib.rs`
35+
- Registered new commands in `generate_handler!`:
36+
- `should_show_donation_prompt`
37+
- `dismiss_donation_prompt`
38+
39+
### React Frontend
40+
41+
#### 5. `app/src/components/DonationDialog.tsx` (New File)
42+
- Material-UI Dialog component matching existing design patterns
43+
- Title: "Support vmix-utility" with FavoriteOutlined icon
44+
- Content:
45+
- Thank you message
46+
- Explanation that project is free and open source
47+
- Three support buttons (matching Developer.tsx style):
48+
- "Star on GitHub" (secondary color)
49+
- "GitHub Sponsors" (#13C3FF blue)
50+
- "Subscribe on Twitch" (#9146FF purple)
51+
- "Don't show this again" checkbox
52+
- "Close" button in DialogActions
53+
- Uses same TwitchIcon SVG as Developer.tsx
54+
55+
#### 6. `app/src/App.tsx`
56+
- Added imports: `DonationDialog`, `invoke`
57+
- Added state: `donationDialogOpen`
58+
- Added useEffect to check dialog display conditions:
59+
- Only runs on main window (`currentPath === '/'`)
60+
- Waits 2 seconds after startup
61+
- Calls `invoke('should_show_donation_prompt')`
62+
- Opens dialog if backend returns true
63+
- Added `handleDonationDialogClose()`:
64+
- Closes dialog
65+
- Calls `invoke('dismiss_donation_prompt', { permanently })`
66+
- Rendered `<DonationDialog>` inside `<VMixStatusProvider>`
67+
68+
## Design Considerations
69+
70+
### Backward Compatibility
71+
- Used `#[serde(default)]` and `Option<DonationPromptConfig>` so existing config.json files work without migration
72+
- Default values ensure safe behavior for missing fields
73+
74+
### Display Timing
75+
- 2-second delay after startup to avoid conflicts with:
76+
- Theme loading
77+
- Update checker (3-second delay)
78+
- Only shows on main window (not on popup windows like List Manager)
79+
80+
### State Management
81+
- Backend centralizes all display logic for consistency
82+
- Frontend only calls backend commands, no date logic duplication
83+
- Persistent storage via existing config.json mechanism
84+
85+
### User Experience
86+
- Non-intrusive: only shows on first launch and monthly
87+
- Easy to dismiss permanently
88+
- Uses familiar UI patterns from existing Developer page
89+
- Accessible via same support buttons throughout the app
90+
91+
## Testing Checklist
92+
93+
1. ✅ First launch: Dialog appears 2 seconds after startup
94+
2. ⏳ Second launch (within 30 days): Dialog does NOT appear
95+
3. ⏳ Launch after 30+ days: Dialog appears again
96+
4. ⏳ "Don't show again" + close: Dialog never appears again (even after 30 days)
97+
5. ⏳ Manual config.json test: Verify timestamps are saved correctly
98+
6. ⏳ Popup windows (List Manager): Dialog does NOT appear
99+
7. ⏳ All support buttons work (open correct URLs)
100+
101+
## Build Status
102+
✅ Compilation successful (both Rust and TypeScript)
103+
✅ Debug build completed without errors
104+
✅ Type checking passed
105+
106+
## Files Modified
107+
- `app/src-tauri/src/types.rs`
108+
- `app/src-tauri/src/state.rs`
109+
- `app/src-tauri/src/commands.rs`
110+
- `app/src-tauri/src/lib.rs`
111+
- `app/src/App.tsx`
112+
113+
## Files Created
114+
- `app/src/components/DonationDialog.tsx`
115+
116+
## Dependencies Used
117+
- `chrono` - Already in Cargo.toml (for date/time calculations)
118+
- No new frontend dependencies required

app/src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/src-tauri/src/commands.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use crate::app_log;
1010
use crate::network_scanner::{get_network_interfaces, scan_network_for_vmix, NetworkInterface, VmixScanResult};
1111
use std::collections::HashMap;
1212
use tauri::{AppHandle, Manager, State, WebviewUrl, WebviewWindowBuilder, Emitter};
13+
use chrono::{DateTime, Duration, Utc};
1314

1415

1516
// Shared builder function to build VideoList inputs from vmix-rs model
@@ -1313,4 +1314,76 @@ pub async fn scan_network_for_vmix_command(
13131314
Err(format!("Failed to scan network for vMix: {}", e))
13141315
}
13151316
}
1317+
}
1318+
1319+
#[tauri::command]
1320+
pub async fn should_show_donation_prompt(
1321+
state: State<'_, AppState>
1322+
) -> Result<bool, String> {
1323+
let prompt_config = state.donation_prompt.lock().unwrap();
1324+
1325+
// If permanently dismissed, don't show
1326+
if prompt_config.permanently_dismissed {
1327+
app_log!(debug, "Donation prompt permanently dismissed");
1328+
return Ok(false);
1329+
}
1330+
1331+
// If never shown before, show it
1332+
if prompt_config.last_shown.is_none() {
1333+
app_log!(info, "Donation prompt: first time, should show");
1334+
return Ok(true);
1335+
}
1336+
1337+
// Check if 30 days have passed since last shown
1338+
if let Some(ref last_shown_str) = prompt_config.last_shown {
1339+
match DateTime::parse_from_rfc3339(last_shown_str) {
1340+
Ok(last_shown) => {
1341+
let now = Utc::now();
1342+
let duration_since_last = now.signed_duration_since(last_shown.with_timezone(&Utc));
1343+
1344+
// Show if 30 days (or more) have passed
1345+
if duration_since_last >= Duration::days(30) {
1346+
app_log!(info, "Donation prompt: 30 days passed, should show");
1347+
Ok(true)
1348+
} else {
1349+
app_log!(debug, "Donation prompt: shown {} days ago, not showing yet", duration_since_last.num_days());
1350+
Ok(false)
1351+
}
1352+
}
1353+
Err(e) => {
1354+
app_log!(error, "Failed to parse last_shown timestamp: {}", e);
1355+
// If timestamp is invalid, treat as first time
1356+
Ok(true)
1357+
}
1358+
}
1359+
} else {
1360+
Ok(true)
1361+
}
1362+
}
1363+
1364+
#[tauri::command]
1365+
pub async fn dismiss_donation_prompt(
1366+
state: State<'_, AppState>,
1367+
app_handle: AppHandle,
1368+
permanently: bool
1369+
) -> Result<(), String> {
1370+
app_log!(info, "Dismissing donation prompt (permanently: {})", permanently);
1371+
1372+
{
1373+
let mut prompt_config = state.donation_prompt.lock().unwrap();
1374+
1375+
// Update last_shown to current time
1376+
prompt_config.last_shown = Some(Utc::now().to_rfc3339());
1377+
1378+
// Set permanently_dismissed flag if requested
1379+
if permanently {
1380+
prompt_config.permanently_dismissed = true;
1381+
app_log!(info, "Donation prompt permanently dismissed");
1382+
}
1383+
}
1384+
1385+
// Save config to persist the changes
1386+
state.save_config(&app_handle).await?;
1387+
1388+
Ok(())
13161389
}

app/src-tauri/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,9 @@ pub fn run() {
221221
check_for_updates,
222222
install_update,
223223
get_network_interfaces_command,
224-
scan_network_for_vmix_command
224+
scan_network_for_vmix_command,
225+
should_show_donation_prompt,
226+
dismiss_donation_prompt
225227
])
226228
.run(tauri::generate_context!())
227229
.expect("error while running tauri application");

app/src-tauri/src/state.rs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use crate::types::{
22
AppConfig, AppSettings, AutoRefreshConfig, ConnectionConfig, ConnectionType,
3-
VmixConnection, VmixInput, VmixVideoListInput,
3+
DonationPromptConfig, VmixConnection, VmixInput, VmixVideoListInput,
44
};
55
use crate::http_client::VmixClientWrapper;
66
use crate::tcp_manager::TcpVmixManager;
@@ -32,6 +32,7 @@ pub struct AppState {
3232
pub connection_labels: Arc<Mutex<HashMap<String, String>>>,
3333
pub app_settings: Arc<Mutex<AppSettings>>,
3434
pub video_list_windows: Arc<Mutex<HashMap<String, VideoListWindow>>>,
35+
pub donation_prompt: Arc<Mutex<DonationPromptConfig>>,
3536
}
3637

3738
impl AppState {
@@ -46,6 +47,7 @@ impl AppState {
4647
connection_labels: Arc::new(Mutex::new(HashMap::new())),
4748
app_settings: Arc::new(Mutex::new(AppSettings::default())),
4849
video_list_windows: Arc::new(Mutex::new(HashMap::new())),
50+
donation_prompt: Arc::new(Mutex::new(DonationPromptConfig::default())),
4951
}
5052
}
5153

@@ -138,6 +140,7 @@ impl AppState {
138140
connections: all_connections,
139141
app_settings: Some(self.app_settings.lock().unwrap().clone()),
140142
logging_config: Some(crate::logging::LOGGING_CONFIG.lock().unwrap().clone()),
143+
donation_prompt: Some(self.donation_prompt.lock().unwrap().clone()),
141144
}
142145
};
143146

@@ -223,9 +226,16 @@ impl AppState {
223226
*config = logging_config;
224227
println!("Loaded logging config");
225228
}
226-
229+
230+
// Load donation prompt config
231+
if let Some(donation_prompt) = config.donation_prompt {
232+
let mut prompt = self.donation_prompt.lock().unwrap();
233+
*prompt = donation_prompt;
234+
println!("Loaded donation prompt config");
235+
}
236+
227237
println!("Config loading completed");
228-
238+
229239
Ok(())
230240
}
231241

app/src-tauri/src/types.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,8 @@ pub struct AppConfig {
133133
pub connections: Vec<ConnectionConfig>,
134134
pub app_settings: Option<AppSettings>,
135135
pub logging_config: Option<LoggingConfig>,
136+
#[serde(default)]
137+
pub donation_prompt: Option<DonationPromptConfig>,
136138
}
137139

138140
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -234,6 +236,21 @@ impl Default for LoggingConfig {
234236
}
235237
}
236238

239+
#[derive(Debug, Clone, Serialize, Deserialize)]
240+
pub struct DonationPromptConfig {
241+
pub last_shown: Option<String>, // RFC3339 timestamp
242+
pub permanently_dismissed: bool, // "今後表示しない" flag
243+
}
244+
245+
impl Default for DonationPromptConfig {
246+
fn default() -> Self {
247+
Self {
248+
last_shown: None,
249+
permanently_dismissed: false
250+
}
251+
}
252+
}
253+
237254
#[derive(Clone, Debug, Serialize, Deserialize)]
238255
pub struct VmixConnection {
239256
pub host: String,

app/src/App.tsx

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@ import { Box, Typography } from '@mui/material';
55
import Layout from './components/Layout';
66
import ListManager from './pages/ListManager';
77
import SingleVideoList from './pages/SingleVideoList';
8+
import DonationDialog from './components/DonationDialog';
89
import { VMixStatusProvider } from './hooks/useVMixStatus';
910
import { ThemeProvider as CustomThemeProvider, useTheme } from './hooks/useTheme';
1011
import { UISettingsProvider } from './hooks/useUISettings.tsx';
12+
import { invoke } from '@tauri-apps/api/core';
1113
import "./App.css";
1214

1315
function AppContent() {
1416
const { resolvedTheme, isLoading } = useTheme();
1517
const [currentPath, setCurrentPath] = useState(window.location.pathname);
18+
const [donationDialogOpen, setDonationDialogOpen] = useState(false);
1619

1720
const theme = useMemo(() => createTheme({
1821
palette: {
@@ -34,12 +37,44 @@ function AppContent() {
3437

3538
// Listen for popstate events (back/forward buttons)
3639
window.addEventListener('popstate', handleLocationChange);
37-
40+
3841
return () => {
3942
window.removeEventListener('popstate', handleLocationChange);
4043
};
4144
}, []);
4245

46+
// Check if donation prompt should be shown (only on main window)
47+
useEffect(() => {
48+
// Only show on main window (not on popup windows like /list-manager)
49+
if (currentPath !== '/') {
50+
return;
51+
}
52+
53+
// Wait 2 seconds after startup before checking
54+
const timer = setTimeout(async () => {
55+
try {
56+
const shouldShow = await invoke<boolean>('should_show_donation_prompt');
57+
if (shouldShow) {
58+
setDonationDialogOpen(true);
59+
}
60+
} catch (error) {
61+
console.error('Failed to check donation prompt:', error);
62+
}
63+
}, 2000);
64+
65+
return () => clearTimeout(timer);
66+
}, [currentPath]);
67+
68+
const handleDonationDialogClose = async (permanently: boolean) => {
69+
setDonationDialogOpen(false);
70+
71+
try {
72+
await invoke('dismiss_donation_prompt', { permanently });
73+
} catch (error) {
74+
console.error('Failed to dismiss donation prompt:', error);
75+
}
76+
};
77+
4378
if (isLoading) {
4479
return (
4580
<div style={{
@@ -89,6 +124,10 @@ function AppContent() {
89124
<UISettingsProvider>
90125
<VMixStatusProvider>
91126
{renderContent()}
127+
<DonationDialog
128+
open={donationDialogOpen}
129+
onClose={handleDonationDialogClose}
130+
/>
92131
</VMixStatusProvider>
93132
</UISettingsProvider>
94133
</ThemeProvider>

0 commit comments

Comments
 (0)