diff --git a/android/app/build.gradle b/android/app/build.gradle index e0ed8898b8..9d686835cf 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -105,6 +105,9 @@ dependencies { implementation "com.anggrayudi:storage:1.5.4" playImplementation "com.google.android.gms:play-services-auth:21.3.0" + // Play In-App Review (native rating card). play flavor ONLY — this is a + // proprietary Google library and must never enter the F-Droid build. + playImplementation "com.google.android.play:review:2.0.2" implementation project(':capacitor-android') diff --git a/android/app/src/fdroid/java/com/superproductivity/superproductivity/review/InAppReview.kt b/android/app/src/fdroid/java/com/superproductivity/superproductivity/review/InAppReview.kt new file mode 100644 index 0000000000..8e9f1082d3 --- /dev/null +++ b/android/app/src/fdroid/java/com/superproductivity/superproductivity/review/InAppReview.kt @@ -0,0 +1,19 @@ +package com.superproductivity.superproductivity.review + +import android.app.Activity + +/** + * fdroid flavor: intentional no-op. + * + * F-Droid has no store ratings and the build must stay free of the proprietary + * Play Core library, so there is nothing to launch here. The web layer detects + * F-Droid via the SUPFDroid bridge (IS_F_DROID_APP) and never calls + * requestReview() on the fdroid flavor; this stub exists only so the shared + * bridge in :main compiles for both flavors. + */ +object InAppReview { + @Suppress("UNUSED_PARAMETER") + fun request(activity: Activity) { + // no-op — see class doc + } +} diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt b/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt index e805195ec8..7c69552db9 100644 --- a/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt +++ b/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt @@ -15,6 +15,7 @@ import com.superproductivity.superproductivity.App import com.superproductivity.superproductivity.BuildConfig import com.superproductivity.superproductivity.FullscreenActivity.Companion.WINDOW_INTERFACE_PROPERTY import com.superproductivity.superproductivity.app.LaunchDecider +import com.superproductivity.superproductivity.review.InAppReview import com.superproductivity.superproductivity.service.BackgroundSyncCredentialStore import com.superproductivity.superproductivity.service.FocusModeForegroundService import com.superproductivity.superproductivity.service.ForegroundServiceFailure @@ -82,6 +83,18 @@ class JavaScriptInterface( return "${versionName}_L$launchMode" } + // Launch the Play In-App Review flow (play flavor). Delegates to a + // flavor-specific InAppReview: the real Play Core implementation in src/play, + // and a no-op stub in src/fdroid so the proprietary library stays out of the + // F-Droid build. Play controls whether/when the card actually shows. + @Suppress("unused") + @JavascriptInterface + fun requestReview() { + activity.runOnUiThread { + InAppReview.request(activity) + } + } + @Suppress("unused") @JavascriptInterface fun showToast(toast: String) { diff --git a/android/app/src/play/java/com/superproductivity/superproductivity/review/InAppReview.kt b/android/app/src/play/java/com/superproductivity/superproductivity/review/InAppReview.kt new file mode 100644 index 0000000000..2a682c65b3 --- /dev/null +++ b/android/app/src/play/java/com/superproductivity/superproductivity/review/InAppReview.kt @@ -0,0 +1,39 @@ +package com.superproductivity.superproductivity.review + +import android.app.Activity +import android.util.Log +import com.google.android.play.core.review.ReviewManagerFactory + +/** + * play flavor: launches the native Google Play In-App Review card. + * + * Per Play policy the flow is opaque — we get no signal about whether the card + * was shown or what the user did, and Play enforces its own display quota, so + * we simply request-then-launch and ignore the outcome. + * + * On failure (offline, unsupported device, quota) we log and abandon. We do NOT + * fall back to opening the Play Store listing: the review request is triggered + * automatically at a "productive win", not by a user tapping "Rate", so yanking + * the user out to the Play Store would be a surprising, unrequested context + * switch. This also matches Google's guidance that a failed in-app review flow + * must not alter the user's normal flow. + */ +object InAppReview { + private const val TAG = "InAppReview" + + fun request(activity: Activity) { + try { + val manager = ReviewManagerFactory.create(activity) + manager.requestReviewFlow().addOnCompleteListener { task -> + if (task.isSuccessful) { + manager.launchReviewFlow(activity, task.result) + } else { + // Log and abandon — do not redirect the user (see class doc). + Log.w(TAG, "requestReviewFlow failed; skipping", task.exception) + } + } + } catch (e: Exception) { + Log.e(TAG, "In-app review unavailable; skipping", e) + } + } +} diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index fd26472d71..76b0b43d4b 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -17,6 +17,8 @@ A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; }; B63E17A12C5F3D8A00A1B2C3 /* WebDavHttpPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B63E17A02C5F3D8A00A1B2C3 /* WebDavHttpPlugin.swift */; }; B63E17A32C5F3D8A00A1B2C3 /* WebDavHttpPlugin.m in Sources */ = {isa = PBXBuildFile; fileRef = B63E17A22C5F3D8A00A1B2C3 /* WebDavHttpPlugin.m */; }; + B63E17B12C5F3D8A00A1B2C3 /* StoreReviewPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B63E17B02C5F3D8A00A1B2C3 /* StoreReviewPlugin.swift */; }; + B63E17B32C5F3D8A00A1B2C3 /* StoreReviewPlugin.m in Sources */ = {isa = PBXBuildFile; fileRef = B63E17B22C5F3D8A00A1B2C3 /* StoreReviewPlugin.m */; }; B63E17A52C5F3D8A00A1B2C3 /* CustomViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B63E17A42C5F3D8A00A1B2C3 /* CustomViewController.swift */; }; /* End PBXBuildFile section */ @@ -34,6 +36,8 @@ AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = ""; }; B63E17A02C5F3D8A00A1B2C3 /* WebDavHttpPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebDavHttpPlugin.swift; sourceTree = ""; }; B63E17A22C5F3D8A00A1B2C3 /* WebDavHttpPlugin.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = WebDavHttpPlugin.m; sourceTree = ""; }; + B63E17B02C5F3D8A00A1B2C3 /* StoreReviewPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreReviewPlugin.swift; sourceTree = ""; }; + B63E17B22C5F3D8A00A1B2C3 /* StoreReviewPlugin.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = StoreReviewPlugin.m; sourceTree = ""; }; B63E17A42C5F3D8A00A1B2C3 /* CustomViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomViewController.swift; sourceTree = ""; }; FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -84,6 +88,8 @@ B63E17A42C5F3D8A00A1B2C3 /* CustomViewController.swift */, B63E17A02C5F3D8A00A1B2C3 /* WebDavHttpPlugin.swift */, B63E17A22C5F3D8A00A1B2C3 /* WebDavHttpPlugin.m */, + B63E17B02C5F3D8A00A1B2C3 /* StoreReviewPlugin.swift */, + B63E17B22C5F3D8A00A1B2C3 /* StoreReviewPlugin.m */, 504EC30B1FED79650016851F /* Main.storyboard */, 504EC30E1FED79650016851F /* Assets.xcassets */, 504EC3101FED79650016851F /* LaunchScreen.storyboard */, @@ -222,6 +228,8 @@ B63E17A52C5F3D8A00A1B2C3 /* CustomViewController.swift in Sources */, B63E17A12C5F3D8A00A1B2C3 /* WebDavHttpPlugin.swift in Sources */, B63E17A32C5F3D8A00A1B2C3 /* WebDavHttpPlugin.m in Sources */, + B63E17B12C5F3D8A00A1B2C3 /* StoreReviewPlugin.swift in Sources */, + B63E17B32C5F3D8A00A1B2C3 /* StoreReviewPlugin.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/App/App/CustomViewController.swift b/ios/App/App/CustomViewController.swift index 5721fd3782..d9e75fcb10 100644 --- a/ios/App/App/CustomViewController.swift +++ b/ios/App/App/CustomViewController.swift @@ -4,5 +4,6 @@ import Capacitor class CustomViewController: CAPBridgeViewController { override open func capacitorDidLoad() { bridge?.registerPluginInstance(WebDavHttpPlugin()) + bridge?.registerPluginInstance(StoreReviewPlugin()) } } diff --git a/ios/App/App/StoreReviewPlugin.m b/ios/App/App/StoreReviewPlugin.m new file mode 100644 index 0000000000..54910e34c8 --- /dev/null +++ b/ios/App/App/StoreReviewPlugin.m @@ -0,0 +1,5 @@ +#import + +CAP_PLUGIN(StoreReviewPlugin, "StoreReview", + CAP_PLUGIN_METHOD(requestReview, CAPPluginReturnPromise); +) diff --git a/ios/App/App/StoreReviewPlugin.swift b/ios/App/App/StoreReviewPlugin.swift new file mode 100644 index 0000000000..24d9bebc4d --- /dev/null +++ b/ios/App/App/StoreReviewPlugin.swift @@ -0,0 +1,42 @@ +import Foundation +import Capacitor +import StoreKit +import UIKit + +/// Bridges the native App Store review prompt to the web layer. +/// +/// The web rating flow calls `StoreReview.requestReview()` on iOS instead of +/// opening a custom dialog. Per App Store policy the system fully controls the +/// prompt: it may not appear at all, is rate-limited (max ~3 / 365 days), and +/// returns no result — so `requestReview` simply asks and resolves. +@objc(StoreReviewPlugin) +public class StoreReviewPlugin: CAPPlugin, CAPBridgedPlugin { + public let identifier = "StoreReviewPlugin" + public let jsName = "StoreReview" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "requestReview", returnType: CAPPluginReturnPromise) + ] + + @objc func requestReview(_ call: CAPPluginCall) { + DispatchQueue.main.async { + guard let scene = self.resolveWindowScene() else { + call.reject("No active window scene for review prompt") + return + } + // Deprecated in iOS 18 in favor of AppStore.requestReview(in:), but + // still functional and the widely-compatible call for our iOS 16 + // deployment target. Deprecation is a warning, not a build error. + SKStoreReviewController.requestReview(in: scene) + call.resolve() + } + } + + private func resolveWindowScene() -> UIWindowScene? { + if let scene = self.bridge?.viewController?.view.window?.windowScene { + return scene + } + let scenes = UIApplication.shared.connectedScenes + return (scenes.first(where: { $0.activationState == .foregroundActive }) + ?? scenes.first) as? UIWindowScene + } +} diff --git a/src/app/core-ui/magic-side-nav/magic-nav-config.service.ts b/src/app/core-ui/magic-side-nav/magic-nav-config.service.ts index 764b1e7feb..b604372e5b 100644 --- a/src/app/core-ui/magic-side-nav/magic-nav-config.service.ts +++ b/src/app/core-ui/magic-side-nav/magic-nav-config.service.ts @@ -285,6 +285,13 @@ export class MagicNavConfigService { icon: 'bug_report', action: () => this._openBugReport(), }, + { + type: 'href', + id: 'help-feedback', + label: T.MH.HM.SEND_FEEDBACK, + icon: 'feedback', + href: 'https://github.com/super-productivity/super-productivity/discussions', + }, // Not allowed to display donation stuff on iOS per App Store guidelines ...(!IS_IOS_NATIVE ? [ diff --git a/src/app/core/banner/banner.model.ts b/src/app/core/banner/banner.model.ts index 0e323a42d1..f897590d1a 100644 --- a/src/app/core/banner/banner.model.ts +++ b/src/app/core/banner/banner.model.ts @@ -15,6 +15,7 @@ export enum BannerId { DeadlinesToday = 'DeadlinesToday', SyncSafetyReminder = 'SyncSafetyReminder', SuperSyncEncryptionMigration = 'SuperSyncEncryptionMigration', + RatePrompt = 'RatePrompt', SyncConflictContentResolved = 'SyncConflictContentResolved', } @@ -33,6 +34,7 @@ export const BANNER_SORT_PRIO_MAP = { [BannerId.InstallWebApp]: 0, [BannerId.SyncSafetyReminder]: 0, [BannerId.SuperSyncEncryptionMigration]: 0, + [BannerId.RatePrompt]: 0, [BannerId.SyncConflictContentResolved]: 1, } as const; diff --git a/src/app/core/startup/startup.service.spec.ts b/src/app/core/startup/startup.service.spec.ts index 7dd6f37643..2cf692626b 100644 --- a/src/app/core/startup/startup.service.spec.ts +++ b/src/app/core/startup/startup.service.spec.ts @@ -5,7 +5,6 @@ import { TranslateService } from '@ngx-translate/core'; import { LocalBackupService } from '../../imex/local-backup/local-backup.service'; import { GlobalConfigService } from '../../features/config/global-config.service'; import { SnackService } from '../snack/snack.service'; -import { MatDialog } from '@angular/material/dialog'; import { PluginService } from '../../plugins/plugin.service'; import { SyncWrapperService } from '../../imex/sync/sync-wrapper.service'; import { BannerService } from '../banner/banner.service'; @@ -22,12 +21,12 @@ import { LS } from '../persistence/storage-keys.const'; import { provideMockStore } from '@ngrx/store/testing'; import { selectSyncConfig } from '../../features/config/store/global-config.reducer'; import { selectEnabledIssueProviders } from '../../features/issue/store/issue-provider.selectors'; -import { getDbDateStr } from '../../util/get-db-date-str'; +import { RatePromptService } from '../../features/dialog-please-rate/rate-prompt.service'; describe('StartupService', () => { let service: StartupService; - let matDialog: jasmine.SpyObj; let pluginService: jasmine.SpyObj; + let ratePromptService: jasmine.SpyObj; beforeEach(() => { // Mock localStorage @@ -73,8 +72,7 @@ describe('StartupService', () => { }); const snackServiceSpy = jasmine.createSpyObj('SnackService', ['open']); - const matDialogSpy = jasmine.createSpyObj('MatDialog', ['open']); - matDialogSpy.open.and.returnValue({ afterClosed: () => of(undefined) }); + const ratePromptServiceSpy = jasmine.createSpyObj('RatePromptService', ['init']); const pluginServiceSpy = jasmine.createSpyObj('PluginService', ['initializePlugins']); pluginServiceSpy.initializePlugins.and.returnValue(Promise.resolve()); @@ -128,7 +126,7 @@ describe('StartupService', () => { { provide: LocalBackupService, useValue: localBackupServiceSpy }, { provide: GlobalConfigService, useValue: globalConfigServiceSpy }, { provide: SnackService, useValue: snackServiceSpy }, - { provide: MatDialog, useValue: matDialogSpy }, + { provide: RatePromptService, useValue: ratePromptServiceSpy }, { provide: PluginService, useValue: pluginServiceSpy }, { provide: SyncWrapperService, useValue: syncWrapperServiceSpy }, { provide: BannerService, useValue: bannerServiceSpy }, @@ -152,8 +150,10 @@ describe('StartupService', () => { }); service = TestBed.inject(StartupService); - matDialog = TestBed.inject(MatDialog) as jasmine.SpyObj; pluginService = TestBed.inject(PluginService) as jasmine.SpyObj; + ratePromptService = TestBed.inject( + RatePromptService, + ) as jasmine.SpyObj; }); describe('init', () => { @@ -178,6 +178,9 @@ describe('StartupService', () => { flush(); + // Deferred init hands the rating prompt off to RatePromptService. + expect(ratePromptService.init).toHaveBeenCalled(); + // Restore (window as any).BroadcastChannel = originalBroadcastChannel; })); @@ -219,109 +222,6 @@ describe('StartupService', () => { }); }); - describe('_handleAppStartRating (private, tested via init)', () => { - it('should increment app start count on new day', () => { - (localStorage.getItem as jasmine.Spy).and.callFake((key: string) => { - if (key === LS.APP_START_COUNT) return '5'; - if (key === LS.APP_START_COUNT_LAST_START_DAY) return '2026-01-04'; - return null; - }); - - (service as any)._handleAppStartRating(); - - expect(localStorage.setItem).toHaveBeenCalledWith(LS.APP_START_COUNT, '6'); - }); - - it('should not double-increment count on the dialog trigger day', () => { - (localStorage.getItem as jasmine.Spy).and.callFake((key: string) => { - if (key === LS.APP_START_COUNT) return '31'; - if (key === LS.APP_START_COUNT_LAST_START_DAY) return '2026-01-04'; - return null; - }); - - (service as any)._handleAppStartRating(); - - const incrementCalls = (localStorage.setItem as jasmine.Spy).calls - .allArgs() - .filter(([k]) => k === LS.APP_START_COUNT); - expect(incrementCalls.length).toBe(1); - expect(incrementCalls[0][1]).toBe('32'); - }); - - it('should show rating dialog at the day-32 tier on a fresh state', () => { - (localStorage.getItem as jasmine.Spy).and.callFake((key: string) => { - if (key === LS.APP_START_COUNT) return '31'; - if (key === LS.APP_START_COUNT_LAST_START_DAY) return '2026-01-04'; - return null; - }); - - (service as any)._handleAppStartRating(); - - expect(matDialog.open).toHaveBeenCalled(); - }); - - it('should show rating dialog at the day-96 tier when previously shown at day 32', () => { - (localStorage.getItem as jasmine.Spy).and.callFake((key: string) => { - if (key === LS.APP_START_COUNT) return '95'; - if (key === LS.APP_START_COUNT_LAST_START_DAY) return '2026-01-04'; - if (key === LS.RATE_DIALOG_STATE) - return JSON.stringify({ lastShownAppStartDay: 32, permanentOptOut: false }); - return null; - }); - - (service as any)._handleAppStartRating(); - - expect(matDialog.open).toHaveBeenCalled(); - }); - - it('should not show rating dialog if already shown at the current tier', () => { - (localStorage.getItem as jasmine.Spy).and.callFake((key: string) => { - if (key === LS.APP_START_COUNT) return '49'; - if (key === LS.APP_START_COUNT_LAST_START_DAY) return '2026-01-04'; - if (key === LS.RATE_DIALOG_STATE) - return JSON.stringify({ lastShownAppStartDay: 32, permanentOptOut: false }); - return null; - }); - - (service as any)._handleAppStartRating(); - - expect(matDialog.open).not.toHaveBeenCalled(); - }); - - it('should not show rating dialog when permanentOptOut is true', () => { - (localStorage.getItem as jasmine.Spy).and.callFake((key: string) => { - if (key === LS.APP_START_COUNT) return '95'; - if (key === LS.APP_START_COUNT_LAST_START_DAY) return '2026-01-04'; - if (key === LS.RATE_DIALOG_STATE) - return JSON.stringify({ lastShownAppStartDay: 32, permanentOptOut: true }); - return null; - }); - - (service as any)._handleAppStartRating(); - - expect(matDialog.open).not.toHaveBeenCalled(); - }); - - it('should not increment count if same day', () => { - const todayStr = getDbDateStr(); - (localStorage.getItem as jasmine.Spy).and.callFake((key: string) => { - if (key === LS.APP_START_COUNT) return '10'; - if (key === LS.APP_START_COUNT_LAST_START_DAY) return todayStr; - return null; - }); - - (service as any)._handleAppStartRating(); - - // Should not have set a new count since it's the same day - // (the method only sets when lastStartDay !== todayStr) - const setItemCalls = (localStorage.setItem as jasmine.Spy).calls.all(); - const countSetCalls = setItemCalls.filter( - (call) => call.args[0] === LS.APP_START_COUNT, - ); - expect(countSetCalls.length).toBe(0); - }); - }); - describe('_isTourLikelyToBeShown (private)', () => { it('should return false if IS_SKIP_TOUR is set', () => { (localStorage.getItem as jasmine.Spy).and.callFake((key: string) => { diff --git a/src/app/core/startup/startup.service.ts b/src/app/core/startup/startup.service.ts index 69d540cb56..18e2ca0b88 100644 --- a/src/app/core/startup/startup.service.ts +++ b/src/app/core/startup/startup.service.ts @@ -4,7 +4,6 @@ import { TranslateService } from '@ngx-translate/core'; import { LocalBackupService } from '../../imex/local-backup/local-backup.service'; import { GlobalConfigService } from '../../features/config/global-config.service'; import { SnackService } from '../snack/snack.service'; -import { MatDialog } from '@angular/material/dialog'; import { PluginService } from '../../plugins/plugin.service'; import { SyncWrapperService } from '../../imex/sync/sync-wrapper.service'; import { BannerService } from '../banner/banner.service'; @@ -19,15 +18,7 @@ import { LegacyPfDbService } from '../persistence/legacy-pf-db.service'; import { BannerId } from '../banner/banner.model'; import { isOnline$ } from '../../util/is-online'; import { LS } from '../persistence/storage-keys.const'; -import { getDbDateStr } from '../../util/get-db-date-str'; -import { DialogPleaseRateComponent } from '../../features/dialog-please-rate/dialog-please-rate.component'; -import { - applyRateDialogResult, - loadRateDialogState, - saveRateDialogState, - shouldShowRateDialog, -} from '../../features/dialog-please-rate/rate-dialog-state'; -import { getMsSinceLastCriticalError } from '../../util/critical-error-signal'; +import { RatePromptService } from '../../features/dialog-please-rate/rate-prompt.service'; import { map, switchMap, take } from 'rxjs/operators'; import { combineLatest } from 'rxjs'; import { Store } from '@ngrx/store'; @@ -67,7 +58,7 @@ export class StartupService { private _localBackupService = inject(LocalBackupService); private _globalConfigService = inject(GlobalConfigService); private _snackService = inject(SnackService); - private _matDialog = inject(MatDialog); + private _ratePromptService = inject(RatePromptService); private _pluginService = inject(PluginService); private _syncWrapperService = inject(SyncWrapperService); private _bannerService = inject(BannerService); @@ -173,7 +164,7 @@ export class StartupService { } } - this._handleAppStartRating(); + this._ratePromptService.init(); await this._initPlugins(); }, DEFERRED_INIT_DELAY_MS); @@ -435,33 +426,6 @@ export class StartupService { } } - private _handleAppStartRating(): void { - const lastStartDay = localStorage.getItem(LS.APP_START_COUNT_LAST_START_DAY); - const todayStr = getDbDateStr(); - let appStarts = +(localStorage.getItem(LS.APP_START_COUNT) || 0); - if (lastStartDay !== todayStr) { - appStarts += 1; - localStorage.setItem(LS.APP_START_COUNT, appStarts.toString()); - localStorage.setItem(LS.APP_START_COUNT_LAST_START_DAY, todayStr); - } - - const state = loadRateDialogState(); - if (!shouldShowRateDialog(state, appStarts, getMsSinceLastCriticalError())) { - return; - } - this._matDialog - .open(DialogPleaseRateComponent) - .afterClosed() - .subscribe((result) => { - const next = applyRateDialogResult( - loadRateDialogState(), - result ?? null, - appStarts, - ); - saveRateDialogState(next); - }); - } - private async _initPlugins(): Promise { // Initialize plugin system try { diff --git a/src/app/features/android/android-interface.ts b/src/app/features/android/android-interface.ts index d8af4fdd34..8693684ec0 100644 --- a/src/app/features/android/android-interface.ts +++ b/src/app/features/android/android-interface.ts @@ -14,6 +14,10 @@ export interface AndroidShareData { export interface AndroidInterface { getVersion?(): string; + // Launches the native Play In-App Review card (play flavor). No-op on fdroid. + // The outcome is intentionally opaque (Play policy) — nothing is returned. + requestReview?(): void; + showToast(s: string): void; // save diff --git a/src/app/features/dialog-please-rate/dialog-please-rate.component.html b/src/app/features/dialog-please-rate/dialog-please-rate.component.html index db997337ef..e259d804ad 100644 --- a/src/app/features/dialog-please-rate/dialog-please-rate.component.html +++ b/src/app/features/dialog-please-rate/dialog-please-rate.component.html @@ -76,6 +76,9 @@ {{ T.F.D_RATE.FEEDBACK_PRIVATE_DESC | translate }} + + { + let component: DialogPleaseRateComponent; + let fixture: ComponentFixture; + let mockDialogRef: jasmine.SpyObj< + MatDialogRef + >; + + // The view/nav members are `protected`; access them through typed casts so the + // spec exercises real behaviour without widening the component's API. + const view = (): string => (component as unknown as { view: () => string }).view(); + const nav = (m: 'showMain' | 'showFeedback'): void => + (component as unknown as Record void>)[m](); + const close = (r: RateDialogResult): void => + (component as unknown as { close: (r: RateDialogResult) => void }).close(r); + + beforeEach(async () => { + mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']); + + await TestBed.configureTestingModule({ + imports: [ + DialogPleaseRateComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [{ provide: MatDialogRef, useValue: mockDialogRef }], + }).compileComponents(); + + fixture = TestBed.createComponent(DialogPleaseRateComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('opens on the main view (store CTA shown to everyone — no sentiment gate)', () => { + expect(view()).toBe('main'); + }); + + it('navigates to the feedback view and back', () => { + nav('showFeedback'); + expect(view()).toBe('feedback'); + nav('showMain'); + expect(view()).toBe('main'); + }); + + it('navigating to feedback never closes the dialog or opts the user out', () => { + nav('showFeedback'); + nav('showMain'); + expect(mockDialogRef.close).not.toHaveBeenCalled(); + }); + + it('forwards each explicit result to the dialog ref', () => { + (['rate', 'feedback', 'later', 'never'] as RateDialogResult[]).forEach((r) => { + close(r); + expect(mockDialogRef.close).toHaveBeenCalledWith(r); + }); + }); +}); diff --git a/src/app/features/dialog-please-rate/dialog-please-rate.component.ts b/src/app/features/dialog-please-rate/dialog-please-rate.component.ts index d8dcbb0c72..e5ffedca42 100644 --- a/src/app/features/dialog-please-rate/dialog-please-rate.component.ts +++ b/src/app/features/dialog-please-rate/dialog-please-rate.component.ts @@ -12,6 +12,7 @@ import { MatIcon } from '@angular/material/icon'; import { CONTRIBUTING_URL, DISCUSSIONS_URL, + MAINTAINER_EMAIL, RateDialogResult, buildFeedbackMailto, getPrimaryCta, @@ -37,11 +38,18 @@ export class DialogPleaseRateComponent { inject>(MatDialogRef); protected readonly T = T; + // No sentiment gate: the store CTA is shown to everyone (store-policy safe), + // with a separate, decoupled path to feedback. On play-flavor Android the + // native review card is used instead and this dialog isn't shown at all. protected readonly view = signal<'main' | 'feedback'>('main'); protected readonly cta = getPrimaryCta(); protected readonly mailtoUrl = buildFeedbackMailto(); protected readonly discussionsUrl = DISCUSSIONS_URL; protected readonly contributingUrl = CONTRIBUTING_URL; + // Shown as selectable text under the email option so the channel isn't a dead + // end when no mail client is registered (common on Linux/web) and the mailto: + // link silently does nothing. + protected readonly maintainerEmail = MAINTAINER_EMAIL; protected showFeedback(): void { this.view.set('feedback'); diff --git a/src/app/features/dialog-please-rate/rate-dialog-state.spec.ts b/src/app/features/dialog-please-rate/rate-dialog-state.spec.ts index 94dbbed38b..f409a35607 100644 --- a/src/app/features/dialog-please-rate/rate-dialog-state.spec.ts +++ b/src/app/features/dialog-please-rate/rate-dialog-state.spec.ts @@ -1,7 +1,10 @@ import { ERROR_SUPPRESSION_MS, + FEEDBACK_SUPPRESSION_STARTS, + RECURRING_INTERVAL_STARTS, RateDialogState, applyRateDialogResult, + isProgressWin, loadRateDialogState, saveRateDialogState, shouldShowRateDialog, @@ -11,9 +14,17 @@ import { LS } from '../../core/persistence/storage-keys.const'; // No recent error — the common case for the existing tier/opt-out tests. const NO_ERR = Number.POSITIVE_INFINITY; +// Factory so specs don't have to spell out every field of RateDialogState. +const state = (partial: Partial = {}): RateDialogState => ({ + lastShownAppStartDay: 0, + permanentOptOut: false, + feedbackGivenAppStartDay: 0, + ...partial, +}); + describe('rate-dialog-state', () => { describe('shouldShowRateDialog', () => { - const fresh: RateDialogState = { lastShownAppStartDay: 0, permanentOptOut: false }; + const fresh = state(); it('does not show before day 32 on a fresh state', () => { expect(shouldShowRateDialog(fresh, 1, NO_ERR)).toBe(false); @@ -25,39 +36,37 @@ describe('rate-dialog-state', () => { }); it('shows again at day 96 after first tier dismissal', () => { - const afterFirst: RateDialogState = { - lastShownAppStartDay: 32, - permanentOptOut: false, - }; + const afterFirst = state({ lastShownAppStartDay: 32 }); expect(shouldShowRateDialog(afterFirst, 33, NO_ERR)).toBe(false); expect(shouldShowRateDialog(afterFirst, 95, NO_ERR)).toBe(false); expect(shouldShowRateDialog(afterFirst, 96, NO_ERR)).toBe(true); }); - it('does not show again after the second tier (96)', () => { - const afterSecond: RateDialogState = { - lastShownAppStartDay: 96, - permanentOptOut: false, - }; + it('recurs on a slow cadence after the last fixed tier (not a lifetime cap)', () => { + const afterSecond = state({ lastShownAppStartDay: 96 }); + const recurDay = 96 + RECURRING_INTERVAL_STARTS; expect(shouldShowRateDialog(afterSecond, 97, NO_ERR)).toBe(false); - expect(shouldShowRateDialog(afterSecond, 1000, NO_ERR)).toBe(false); + expect(shouldShowRateDialog(afterSecond, recurDay - 1, NO_ERR)).toBe(false); + expect(shouldShowRateDialog(afterSecond, recurDay, NO_ERR)).toBe(true); + }); + + it('keeps recurring at the same interval on each subsequent prompt', () => { + const afterRecur = state({ lastShownAppStartDay: 96 + RECURRING_INTERVAL_STARTS }); + const nextRecurDay = 96 + RECURRING_INTERVAL_STARTS + RECURRING_INTERVAL_STARTS; + expect(shouldShowRateDialog(afterRecur, nextRecurDay - 1, NO_ERR)).toBe(false); + expect(shouldShowRateDialog(afterRecur, nextRecurDay, NO_ERR)).toBe(true); }); it('never shows when permanentOptOut is true', () => { - const optedOut: RateDialogState = { - lastShownAppStartDay: 0, - permanentOptOut: true, - }; + const optedOut = state({ permanentOptOut: true }); expect(shouldShowRateDialog(optedOut, 32, NO_ERR)).toBe(false); expect(shouldShowRateDialog(optedOut, 96, NO_ERR)).toBe(false); }); it('does not show on the same start day it was last shown', () => { - const sameDay: RateDialogState = { - lastShownAppStartDay: 32, - permanentOptOut: false, - }; - expect(shouldShowRateDialog(sameDay, 32, NO_ERR)).toBe(false); + expect(shouldShowRateDialog(state({ lastShownAppStartDay: 32 }), 32, NO_ERR)).toBe( + false, + ); }); describe('recent-error suppression', () => { @@ -72,10 +81,7 @@ describe('rate-dialog-state', () => { }); it('only delays — a later tier still fires after the window passes', () => { - const afterFirst: RateDialogState = { - lastShownAppStartDay: 32, - permanentOptOut: false, - }; + const afterFirst = state({ lastShownAppStartDay: 32 }); // Within window at day 96: held back. expect(shouldShowRateDialog(afterFirst, 96, 0)).toBe(false); // Window elapsed by a later start day: shows (tier check stays `>=`). @@ -83,55 +89,101 @@ describe('rate-dialog-state', () => { }); it('permanent opt-out wins even with a recent error in the window', () => { - const optedOut: RateDialogState = { - lastShownAppStartDay: 0, - permanentOptOut: true, - }; // Pass a recent error (0 ms ago) so this actually exercises the // opt-out-vs-cooldown ordering rather than the no-error path. - expect(shouldShowRateDialog(optedOut, 32, 0)).toBe(false); + expect(shouldShowRateDialog(state({ permanentOptOut: true }), 32, 0)).toBe(false); }); it('uses a window of at least 30 days', () => { expect(ERROR_SUPPRESSION_MS).toBeGreaterThanOrEqual(30 * 24 * 60 * 60 * 1000); }); }); + + describe('post-feedback suppression', () => { + it('holds the prompt for the whole cooldown after feedback, then resumes', () => { + // Gave feedback at day 32 (tier 32 consumed). The next tier is 96, but + // the feedback cooldown must hold it until feedbackGiven + window. + const afterFeedback = state({ + lastShownAppStartDay: 32, + feedbackGivenAppStartDay: 32, + }); + const resumeDay = 32 + FEEDBACK_SUPPRESSION_STARTS; + expect(shouldShowRateDialog(afterFeedback, 96, NO_ERR)).toBe(false); + expect(shouldShowRateDialog(afterFeedback, resumeDay - 1, NO_ERR)).toBe(false); + // Window elapsed AND a tier (96) is due → the one remaining ask fires. + expect(shouldShowRateDialog(afterFeedback, resumeDay, NO_ERR)).toBe(true); + }); + + it('is a delay, not a permanent opt-out', () => { + const afterFeedback = state({ + lastShownAppStartDay: 32, + feedbackGivenAppStartDay: 32, + }); + expect(afterFeedback.permanentOptOut).toBe(false); + expect(shouldShowRateDialog(afterFeedback, 1000, NO_ERR)).toBe(true); + }); + }); }); describe('applyRateDialogResult', () => { - const seen32: RateDialogState = { lastShownAppStartDay: 32, permanentOptOut: false }; + const seen32 = state({ lastShownAppStartDay: 32 }); it('sets permanentOptOut on rate', () => { - const next = applyRateDialogResult(seen32, 'rate', 33); - expect(next).toEqual({ lastShownAppStartDay: 33, permanentOptOut: true }); - }); - - it('sets permanentOptOut on feedback', () => { - const next = applyRateDialogResult(seen32, 'feedback', 33); - expect(next).toEqual({ lastShownAppStartDay: 33, permanentOptOut: true }); + expect(applyRateDialogResult(seen32, 'rate', 33)).toEqual( + state({ lastShownAppStartDay: 33, permanentOptOut: true }), + ); }); it('sets permanentOptOut on never', () => { - const next = applyRateDialogResult(seen32, 'never', 33); - expect(next).toEqual({ lastShownAppStartDay: 33, permanentOptOut: true }); + expect(applyRateDialogResult(seen32, 'never', 33)).toEqual( + state({ lastShownAppStartDay: 33, permanentOptOut: true }), + ); + }); + + it('does NOT opt out on feedback — it records the feedback day for a cooldown', () => { + expect(applyRateDialogResult(seen32, 'feedback', 33)).toEqual( + state({ + lastShownAppStartDay: 33, + permanentOptOut: false, + feedbackGivenAppStartDay: 33, + }), + ); }); it('only updates lastShownAppStartDay on later (no permanent opt-out yet)', () => { - const next = applyRateDialogResult(seen32, 'later', 33); - expect(next).toEqual({ lastShownAppStartDay: 33, permanentOptOut: false }); + expect(applyRateDialogResult(seen32, 'later', 33)).toEqual( + state({ lastShownAppStartDay: 33 }), + ); }); it('only updates lastShownAppStartDay on null (ESC / backdrop)', () => { - const next = applyRateDialogResult(seen32, null, 33); - expect(next).toEqual({ lastShownAppStartDay: 33, permanentOptOut: false }); + expect(applyRateDialogResult(seen32, null, 33)).toEqual( + state({ lastShownAppStartDay: 33 }), + ); }); - it('two later clicks across tiers result in no further prompts (implicit permanent stop)', () => { - const fresh: RateDialogState = { lastShownAppStartDay: 0, permanentOptOut: false }; - const afterFirstLater = applyRateDialogResult(fresh, 'later', 32); + it('two later clicks walk both fixed tiers, then the prompt recurs slowly', () => { + const afterFirstLater = applyRateDialogResult(state(), 'later', 32); expect(shouldShowRateDialog(afterFirstLater, 96, NO_ERR)).toBe(true); const afterSecondLater = applyRateDialogResult(afterFirstLater, 'later', 96); - expect(shouldShowRateDialog(afterSecondLater, 1000, NO_ERR)).toBe(false); + // No longer a permanent stop — recurs after the interval. + expect(shouldShowRateDialog(afterSecondLater, 97, NO_ERR)).toBe(false); + expect( + shouldShowRateDialog(afterSecondLater, 96 + RECURRING_INTERVAL_STARTS, NO_ERR), + ).toBe(true); + }); + + it('feedback then the final tiered prompt, then a rate opts out for good', () => { + const afterFeedback = applyRateDialogResult( + state({ lastShownAppStartDay: 32 }), + 'feedback', + 32, + ); + // remaining tier (96) fires only once the cooldown has elapsed + const day = 32 + FEEDBACK_SUPPRESSION_STARTS; + expect(shouldShowRateDialog(afterFeedback, day, NO_ERR)).toBe(true); + const afterRate = applyRateDialogResult(afterFeedback, 'rate', day); + expect(shouldShowRateDialog(afterRate, day + 1000, NO_ERR)).toBe(false); }); }); @@ -147,38 +199,67 @@ describe('rate-dialog-state', () => { }); it('returns default state when nothing is stored', () => { - expect(loadRateDialogState()).toEqual({ - lastShownAppStartDay: 0, - permanentOptOut: false, - }); + expect(loadRateDialogState()).toEqual(state()); }); it('round-trips a state object', () => { - saveRateDialogState({ lastShownAppStartDay: 96, permanentOptOut: true }); - expect(localStorage.setItem).toHaveBeenCalledWith( - LS.RATE_DIALOG_STATE, - JSON.stringify({ lastShownAppStartDay: 96, permanentOptOut: true }), - ); - expect(loadRateDialogState()).toEqual({ + const s = state({ lastShownAppStartDay: 96, permanentOptOut: true, + feedbackGivenAppStartDay: 40, }); + saveRateDialogState(s); + expect(localStorage.setItem).toHaveBeenCalledWith( + LS.RATE_DIALOG_STATE, + JSON.stringify(s), + ); + expect(loadRateDialogState()).toEqual(s); }); it('falls back to defaults on malformed JSON', () => { store[LS.RATE_DIALOG_STATE] = '{not-json'; - expect(loadRateDialogState()).toEqual({ - lastShownAppStartDay: 0, - permanentOptOut: false, - }); + expect(loadRateDialogState()).toEqual(state()); }); it('coerces missing or wrong-type fields to defaults', () => { store[LS.RATE_DIALOG_STATE] = JSON.stringify({ lastShownAppStartDay: 'oops' }); - expect(loadRateDialogState()).toEqual({ - lastShownAppStartDay: 0, + expect(loadRateDialogState()).toEqual(state()); + }); + + it('defaults feedbackGivenAppStartDay when absent in older stored state', () => { + store[LS.RATE_DIALOG_STATE] = JSON.stringify({ + lastShownAppStartDay: 32, permanentOptOut: false, }); + expect(loadRateDialogState()).toEqual(state({ lastShownAppStartDay: 32 })); + }); + }); + + describe('isProgressWin', () => { + it('fires on the absolute threshold regardless of list size', () => { + expect(isProgressWin(8, 100)).toBe(true); // 8 done even if only 8% of a big list + expect(isProgressWin(8, 8)).toBe(true); + }); + + it('fires at >=50% done once the min-done floor is met', () => { + expect(isProgressWin(3, 6)).toBe(true); // 50%, 3 done + expect(isProgressWin(5, 6)).toBe(true); + }); + + it('does not fire on the trivial "half of a tiny list" case', () => { + expect(isProgressWin(1, 2)).toBe(false); // 50% but only 1 done + expect(isProgressWin(2, 4)).toBe(false); // 50% but below the floor of 3 + }); + + it('does not fire below 50% when under the absolute threshold', () => { + expect(isProgressWin(3, 10)).toBe(false); // 30% + expect(isProgressWin(7, 20)).toBe(false); // 35%, still < 8 done + }); + + it('handles the empty/zero case', () => { + expect(isProgressWin(0, 0)).toBe(false); + expect(isProgressWin(0, 5)).toBe(false); + expect(isProgressWin(3, 0)).toBe(false); // no divide-by-zero win }); }); }); diff --git a/src/app/features/dialog-please-rate/rate-dialog-state.ts b/src/app/features/dialog-please-rate/rate-dialog-state.ts index b7b7666950..8b1d6d0317 100644 --- a/src/app/features/dialog-please-rate/rate-dialog-state.ts +++ b/src/app/features/dialog-please-rate/rate-dialog-state.ts @@ -1,14 +1,26 @@ import { LS } from '../../core/persistence/storage-keys.const'; -import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view'; +import { IS_ANDROID_WEB_VIEW, IS_F_DROID_APP } from '../../util/is-android-web-view'; import { IS_IOS } from '../../util/is-ios'; import { IS_ELECTRON } from '../../app.constants'; import { getAppVersionStr } from '../../util/get-app-version-str'; // Device-local only. localStorage keys are implicitly excluded from sync exports. -// Length of TRIGGER_TIERS is the prompt cap: at most one prompt per tier, ever. -// Adding a third entry breaks the "don't be annoying" two-prompts-max guarantee. +// +// The first two prompts are front-loaded at these fixed onboarding tiers (in +// app-start days), when a habit has formed. After the last tier we DON'T stop — +// we re-prompt on a slow recurring cadence (RECURRING_INTERVAL_STARTS) so a +// long-tenured happy user is asked again occasionally. Review recency/velocity +// is what the stores actually rank on, so a hard lifetime cap silently starves +// the signal as the install base ages. Still calm: only ~2 asks per year of +// real use, always after a "win", never after a crash, honouring opt-out and +// the OS review quota (which self-throttles on top of this). const TRIGGER_TIERS = [32, 96] as const; +// Slow recurring re-prompt interval (app-start days) once the fixed tiers are +// past. ~180 active days ≈ 6+ months of actual use, well inside Apple's ~3/365 +// allowance and Play's own quota. +export const RECURRING_INTERVAL_STARTS = 180; + // After a real (unhandled) error we hold the rating prompt for this long, so we // never ask for a review right after the user hit a crash. Because the tier // check below stays `>=`, the prompt simply re-fires on the first app start @@ -18,12 +30,24 @@ const TRIGGER_TIERS = [32, 96] as const; // third-party noise rather than a genuine app failure. export const ERROR_SUPPRESSION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days -const MAINTAINER_EMAIL = 'contact@super-productivity.com'; +// After the user chooses "give feedback" we hold off asking to rate for a good +// while — giving feedback means "I have something to say", not "never ask me" — +// but we can't detect when that feedback is actually resolved, so we approximate +// with a long cooldown. Measured in app-start days (same unit as TRIGGER_TIERS); +// the counter increments at most once per calendar day, so this is also a +// wall-clock floor of ~this many days. Afterwards the normal cadence resumes. +export const FEEDBACK_SUPPRESSION_STARTS = 90; + +export const MAINTAINER_EMAIL = 'contact@super-productivity.com'; const PLAY_STORE_URL = 'https://play.google.com/store/apps/details?id=com.superproductivity.superproductivity'; const APP_STORE_URL = 'https://apps.apple.com/app/id1482572463'; const HOW_TO_RATE_URL = 'https://github.com/super-productivity/super-productivity/blob/master/docs/how-to-rate.md'; +// Web/Electron have no app store to rank in; a GitHub star is the equivalent +// social-proof/discovery signal for the desktop distribution, so that's the CTA +// there instead of the near-zero-conversion "how to rate" doc. +const GITHUB_REPO_URL = 'https://github.com/super-productivity/super-productivity'; export const DISCUSSIONS_URL = 'https://github.com/super-productivity/super-productivity/discussions/new'; export const CONTRIBUTING_URL = @@ -32,6 +56,10 @@ export const CONTRIBUTING_URL = export interface RateDialogState { lastShownAppStartDay: number; permanentOptOut: boolean; + // App-start day the user last chose "give feedback" (0 = never). Drives the + // post-feedback cooldown; separate from lastShownAppStartDay so it survives + // the later tiered prompt bookkeeping. + feedbackGivenAppStartDay: number; } export type RateDialogResult = 'rate' | 'feedback' | 'later' | 'never'; @@ -39,6 +67,7 @@ export type RateDialogResult = 'rate' | 'feedback' | 'later' | 'never'; const DEFAULT_STATE: RateDialogState = { lastShownAppStartDay: 0, permanentOptOut: false, + feedbackGivenAppStartDay: 0, }; export const loadRateDialogState = (): RateDialogState => { @@ -50,6 +79,10 @@ export const loadRateDialogState = (): RateDialogState => { lastShownAppStartDay: typeof parsed.lastShownAppStartDay === 'number' ? parsed.lastShownAppStartDay : 0, permanentOptOut: parsed.permanentOptOut === true, + feedbackGivenAppStartDay: + typeof parsed.feedbackGivenAppStartDay === 'number' + ? parsed.feedbackGivenAppStartDay + : 0, }; } catch { return { ...DEFAULT_STATE }; @@ -69,9 +102,25 @@ export const shouldShowRateDialog = ( // Recent crash or data damage → delay (not cancel). Caller passes Infinity // when none; see getMsSinceLastCriticalError in util/critical-error-signal. if (msSinceLastCriticalError < ERROR_SUPPRESSION_MS) return false; + // Gave feedback recently → hold off (see FEEDBACK_SUPPRESSION_STARTS). Also a + // delay, not a cancel: once the window elapses the tier check resumes. + if ( + state.feedbackGivenAppStartDay > 0 && + currentAppStarts < state.feedbackGivenAppStartDay + FEEDBACK_SUPPRESSION_STARTS + ) { + return false; + } if (currentAppStarts <= state.lastShownAppStartDay) return false; - const nextTier = TRIGGER_TIERS.find((t) => t > state.lastShownAppStartDay); - return nextTier !== undefined && currentAppStarts >= nextTier; + return currentAppStarts >= nextEligibleAppStart(state.lastShownAppStartDay); +}; + +// Next app-start day the prompt may fire: the next fixed onboarding tier, or — +// once those are past — a slow recurring re-prompt. Never returns undefined, so +// the prompt recurs for the app's lifetime (still gated by opt-out/crash/ +// feedback/version + the OS quota); it is NOT a two-prompts-forever cap. +const nextEligibleAppStart = (lastShownAppStartDay: number): number => { + const nextTier = TRIGGER_TIERS.find((t) => t > lastShownAppStartDay); + return nextTier ?? lastShownAppStartDay + RECURRING_INTERVAL_STARTS; }; export const applyRateDialogResult = ( @@ -79,14 +128,42 @@ export const applyRateDialogResult = ( result: RateDialogResult | null, currentAppStarts: number, ): RateDialogState => { - // null = ESC / backdrop close. Treat as silent dismiss for cadence purposes — - // do not pester again until the next tier — but never trigger permanent opt-out. - if (result === 'rate' || result === 'feedback' || result === 'never') { - return { lastShownAppStartDay: currentAppStarts, permanentOptOut: true }; + // Rated or explicitly dismissed forever → never ask again. + if (result === 'rate' || result === 'never') { + return { ...state, lastShownAppStartDay: currentAppStarts, permanentOptOut: true }; } + // Gave feedback → NOT a permanent opt-out. Advance the tier (this prompt is + // spent) and start the long feedback cooldown so we don't ask again until it + // elapses — a feedbacker is engaged and may still rate later. + if (result === 'feedback') { + return { + ...state, + lastShownAppStartDay: currentAppStarts, + feedbackGivenAppStartDay: currentAppStarts, + }; + } + // 'later' or null (ESC / backdrop / silent dismiss): advance the tier only — + // do not pester again until the next tier — but never permanently opt out. return { ...state, lastShownAppStartDay: currentAppStarts }; }; +// "Productive win" thresholds for timing the rating prompt after a positive +// moment rather than on cold launch. A win is: cleared at least half of today's +// tasks (with a floor so finishing 1 of 2 doesn't count) OR got a solid number +// done regardless of list size (so heavy planners who never cross 50% still +// hit a win). +export const RATE_PROGRESS_MIN_DONE = 3; +export const RATE_PROGRESS_ABSOLUTE_DONE = 8; + +export const isProgressWin = (doneToday: number, totalToday: number): boolean => { + if (doneToday >= RATE_PROGRESS_ABSOLUTE_DONE) { + return true; + } + return ( + doneToday >= RATE_PROGRESS_MIN_DONE && totalToday > 0 && doneToday / totalToday >= 0.5 + ); +}; + export interface PrimaryCta { labelKey: string; url: string; @@ -94,12 +171,21 @@ export interface PrimaryCta { export const getPrimaryCta = (): PrimaryCta => { if (IS_ANDROID_WEB_VIEW) { + // F-Droid has no store ratings, so point those users at the neutral + // "how to rate / support" doc instead of the Play listing. The play flavor + // normally uses the native review card and never reaches this dialog; the + // Play URL here is only a fallback if the native flow is unavailable. + if (IS_F_DROID_APP) { + return { labelKey: 'F.D_RATE.A_HOW', url: HOW_TO_RATE_URL }; + } return { labelKey: 'F.D_RATE.BTN_RATE_PLAY_STORE', url: PLAY_STORE_URL }; } if (IS_IOS) { return { labelKey: 'F.D_RATE.BTN_RATE_APP_STORE', url: APP_STORE_URL }; } - return { labelKey: 'F.D_RATE.A_HOW', url: HOW_TO_RATE_URL }; + // Web / Electron: no app store to rank in — a GitHub star is the equivalent + // discovery signal for the desktop distribution. + return { labelKey: 'F.D_RATE.BTN_STAR_GITHUB', url: GITHUB_REPO_URL }; }; const getPlatformLabel = (): string => { diff --git a/src/app/features/dialog-please-rate/rate-prompt.service.spec.ts b/src/app/features/dialog-please-rate/rate-prompt.service.spec.ts new file mode 100644 index 0000000000..5efb782c10 --- /dev/null +++ b/src/app/features/dialog-please-rate/rate-prompt.service.spec.ts @@ -0,0 +1,236 @@ +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { of } from 'rxjs'; +import { provideMockStore, MockStore } from '@ngrx/store/testing'; + +import { RatePromptService, WIN_PROMPT_DELAY_MS } from './rate-prompt.service'; +import { selectTodayProgress } from '../work-context/store/work-context.selectors'; +import { LS } from '../../core/persistence/storage-keys.const'; +import { getDbDateStr } from '../../util/get-db-date-str'; +import { DataInitStateService } from '../../core/data-init/data-init-state.service'; +import { BannerService } from '../../core/banner/banner.service'; +import { Banner } from '../../core/banner/banner.model'; + +// The prompt fires a beat after the win, not on the same tick — tick past the +// delay to flush the delayed emission. +const WIN_DELAY = WIN_PROMPT_DELAY_MS; + +// Note: IS_ANDROID_WEB_VIEW / IS_IOS_NATIVE are false in jsdom, so _promptNow +// always takes the web-banner branch here. The native Play/iOS card paths (and +// their cadence save) are not exercisable in unit tests — they need e2e/native. +describe('RatePromptService', () => { + let service: RatePromptService; + let matDialog: jasmine.SpyObj; + let bannerService: jasmine.SpyObj; + let store: MockStore; + + beforeEach(() => { + const storageMock: { [key: string]: string } = {}; + spyOn(localStorage, 'getItem').and.callFake((k: string) => storageMock[k] ?? null); + spyOn(localStorage, 'setItem').and.callFake((k: string, v: string) => { + storageMock[k] = v; + }); + + matDialog = jasmine.createSpyObj('MatDialog', ['open']); + matDialog.open.and.returnValue({ afterClosed: () => of(undefined) } as never); + bannerService = jasmine.createSpyObj('BannerService', ['open', 'dismiss']); + + TestBed.configureTestingModule({ + providers: [ + RatePromptService, + { provide: MatDialog, useValue: matDialog }, + { provide: BannerService, useValue: bannerService }, + { + provide: DataInitStateService, + useValue: { isAllDataLoadedInitially$: of(true) }, + }, + provideMockStore({ + selectors: [{ selector: selectTodayProgress, value: { done: 0, total: 10 } }], + }), + ], + }); + + service = TestBed.inject(RatePromptService); + store = TestBed.inject(MockStore); + }); + + const setEligible = (): void => { + (localStorage.getItem as jasmine.Spy).and.callFake((key: string) => { + if (key === LS.APP_START_COUNT) return '31'; // → 32, the first tier + if (key === LS.APP_START_COUNT_LAST_START_DAY) return '2020-01-01'; + return null; + }); + }; + + const setProgress = (done: number, total = 10): void => { + store.overrideSelector(selectTodayProgress, { done, total }); + store.refreshState(); + }; + + const lastBanner = (): Banner => bannerService.open.calls.mostRecent().args[0]; + + describe('app-start cadence counter', () => { + it('increments the count on a new day', () => { + (localStorage.getItem as jasmine.Spy).and.callFake((key: string) => { + if (key === LS.APP_START_COUNT) return '5'; + if (key === LS.APP_START_COUNT_LAST_START_DAY) return '2020-01-01'; + return null; + }); + + service.init(); + + expect(localStorage.setItem).toHaveBeenCalledWith(LS.APP_START_COUNT, '6'); + }); + + it('does not increment the count on the same day', () => { + const todayStr = getDbDateStr(); + (localStorage.getItem as jasmine.Spy).and.callFake((key: string) => { + if (key === LS.APP_START_COUNT) return '10'; + if (key === LS.APP_START_COUNT_LAST_START_DAY) return todayStr; + return null; + }); + + service.init(); + + const countSets = (localStorage.setItem as jasmine.Spy).calls + .allArgs() + .filter(([k]) => k === LS.APP_START_COUNT); + expect(countSets.length).toBe(0); + }); + }); + + describe('arming + win timing', () => { + it('arms but does NOT prompt on startup — it waits for a productive win', fakeAsync(() => { + setEligible(); + service.init(); + tick(WIN_DELAY); + expect(bannerService.open).not.toHaveBeenCalled(); + })); + + it('shows the banner a beat AFTER a productive win, not on the same tick', fakeAsync(() => { + setEligible(); + service.init(); // baseline done = 0 + setProgress(8); // done = 8 → absolute-win threshold + expect(bannerService.open).not.toHaveBeenCalled(); // delayed, not immediate + tick(WIN_DELAY); + expect(bannerService.open).toHaveBeenCalledTimes(1); + })); + + it('does NOT prompt when the win is already true at arm time (baseline guard)', fakeAsync(() => { + setProgress(8); // 8 done before we ever arm — a disguised cold-launch win + setEligible(); + service.init(); + tick(WIN_DELAY); + expect(bannerService.open).not.toHaveBeenCalled(); + + // ...but a genuine further completion this session still fires. + setProgress(9); + tick(WIN_DELAY); + expect(bannerService.open).toHaveBeenCalledTimes(1); + })); + + it('prompts at most once per session even on further wins', fakeAsync(() => { + setEligible(); + service.init(); + setProgress(8); + setProgress(9); + setProgress(10); + tick(WIN_DELAY); + expect(bannerService.open).toHaveBeenCalledTimes(1); + })); + + it('advances the prompt cadence when the banner is shown', fakeAsync(() => { + setEligible(); + service.init(); + setProgress(8); + tick(WIN_DELAY); + expect(localStorage.setItem).toHaveBeenCalledWith( + LS.RATE_DIALOG_STATE, + jasmine.stringMatching('"lastShownAppStartDay":32'), + ); + })); + + it('does not prompt for progress below the win threshold', fakeAsync(() => { + setEligible(); + service.init(); + setProgress(2); // below the floor of 3 + tick(WIN_DELAY); + expect(bannerService.open).not.toHaveBeenCalled(); + })); + + it('does not prompt when the user has permanently opted out', fakeAsync(() => { + (localStorage.getItem as jasmine.Spy).and.callFake((key: string) => { + if (key === LS.APP_START_COUNT) return '31'; + if (key === LS.APP_START_COUNT_LAST_START_DAY) return '2020-01-01'; + if (key === LS.RATE_DIALOG_STATE) + return JSON.stringify({ lastShownAppStartDay: 0, permanentOptOut: true }); + return null; + }); + + service.init(); + setProgress(10); // a clear win — but opted out + tick(WIN_DELAY); + expect(bannerService.open).not.toHaveBeenCalled(); + })); + + it('does not prompt when not yet at an eligible tier', fakeAsync(() => { + (localStorage.getItem as jasmine.Spy).and.callFake((key: string) => { + if (key === LS.APP_START_COUNT) return '5'; + if (key === LS.APP_START_COUNT_LAST_START_DAY) return '2020-01-01'; + return null; + }); + + service.init(); + setProgress(10); + tick(WIN_DELAY); + expect(bannerService.open).not.toHaveBeenCalled(); + })); + + it('does NOT prompt if a critical error is recorded after arming (crash suppression re-checked at fire time)', fakeAsync(() => { + setEligible(); + service.init(); // eligible + armed while no error existed (baseline done = 0) + + // A crash / data-damage happens this session, AFTER arming. + (localStorage.getItem as jasmine.Spy).and.callFake((key: string) => { + if (key === LS.APP_START_COUNT) return '31'; + if (key === LS.APP_START_COUNT_LAST_START_DAY) return '2020-01-01'; + if (key === LS.LAST_CRITICAL_ERROR_TIME) return Date.now().toString(); + return null; + }); + + setProgress(8); // a genuine win — but the fresh crash must hold the prompt + tick(WIN_DELAY); + expect(bannerService.open).not.toHaveBeenCalled(); + })); + }); + + describe('banner → dialog hand-off', () => { + const showBannerViaWin = (): void => { + setEligible(); + service.init(); + setProgress(8); + tick(WIN_DELAY); + }; + + it('the banner action opens the full rate/feedback dialog', fakeAsync(() => { + showBannerViaWin(); + matDialog.open.calls.reset(); + + lastBanner().action!.fn(); + + expect(matDialog.open).toHaveBeenCalledTimes(1); + })); + + it('choosing "rate" in that dialog permanently opts the user out', fakeAsync(() => { + matDialog.open.and.returnValue({ afterClosed: () => of('rate') } as never); + showBannerViaWin(); + + lastBanner().action!.fn(); + + expect(localStorage.setItem).toHaveBeenCalledWith( + LS.RATE_DIALOG_STATE, + jasmine.stringMatching('"permanentOptOut":true'), + ); + })); + }); +}); diff --git a/src/app/features/dialog-please-rate/rate-prompt.service.ts b/src/app/features/dialog-please-rate/rate-prompt.service.ts new file mode 100644 index 0000000000..2dbda3532d --- /dev/null +++ b/src/app/features/dialog-please-rate/rate-prompt.service.ts @@ -0,0 +1,198 @@ +import { DestroyRef, inject, Injectable } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatDialog } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { + delay, + distinctUntilChanged, + filter, + scan, + switchMap, + take, +} from 'rxjs/operators'; +import { DataInitStateService } from '../../core/data-init/data-init-state.service'; +import { LS } from '../../core/persistence/storage-keys.const'; +import { getDbDateStr } from '../../util/get-db-date-str'; +import { getMsSinceLastCriticalError } from '../../util/critical-error-signal'; +import { IS_ANDROID_WEB_VIEW, IS_F_DROID_APP } from '../../util/is-android-web-view'; +import { IS_IOS_NATIVE } from '../../util/is-native-platform'; +import { androidInterface } from '../android/android-interface'; +import { Log } from '../../core/log'; +import { BannerService } from '../../core/banner/banner.service'; +import { BannerId } from '../../core/banner/banner.model'; +import { T } from '../../t.const'; +import { selectTodayProgress } from '../work-context/store/work-context.selectors'; +import { DialogPleaseRateComponent } from './dialog-please-rate.component'; +import { + applyRateDialogResult, + isProgressWin, + loadRateDialogState, + saveRateDialogState, + shouldShowRateDialog, +} from './rate-dialog-state'; +import { StoreReview } from './store-review'; + +// Don't fire the instant a task is checked off — let the completion land, then +// prompt a beat later so it reads as "nice session" rather than a reflex popup. +// Exported for the spec (which has to tick past it). +export const WIN_PROMPT_DELAY_MS = 3000; + +/** + * Owns the "please rate" prompt: when to ask (cadence) and when within a session + * to actually show it. We never prompt on cold launch — both stores recommend + * asking after a positive moment — so an eligible session is only *armed*, and + * the prompt fires a few seconds after the first productive "win" (see + * isProgressWin). The prompt is the native review card on Play/iOS, and a calm, + * non-modal banner elsewhere (the banner opens the full rate/feedback dialog on + * request, so we never shove a modal in the user's face mid-flow). + */ +@Injectable({ providedIn: 'root' }) +export class RatePromptService { + private readonly _matDialog = inject(MatDialog); + private readonly _store = inject(Store); + private readonly _dataInitStateService = inject(DataInitStateService); + private readonly _destroyRef = inject(DestroyRef); + private readonly _bannerService = inject(BannerService); + + private _appStarts = 0; + private _isArmed = false; + + /** Call once during deferred startup. */ + init(): void { + const lastStartDay = localStorage.getItem(LS.APP_START_COUNT_LAST_START_DAY); + const todayStr = getDbDateStr(); + let appStarts = +(localStorage.getItem(LS.APP_START_COUNT) || 0); + if (lastStartDay !== todayStr) { + appStarts += 1; + localStorage.setItem(LS.APP_START_COUNT, appStarts.toString()); + localStorage.setItem(LS.APP_START_COUNT_LAST_START_DAY, todayStr); + } + this._appStarts = appStarts; + + const state = loadRateDialogState(); + if (!shouldShowRateDialog(state, appStarts, getMsSinceLastCriticalError())) { + return; + } + this._armForWin(); + } + + private _armForWin(): void { + // Idempotent: a second init()/arm must not spin up a second long-lived + // store subscription (the _isArmed check in _promptNow only guards the + // prompt, not the subscription). + if (this._isArmed) { + return; + } + this._isArmed = true; + + this._dataInitStateService.isAllDataLoadedInitially$ + .pipe( + // Sample the baseline only once data has hydrated — otherwise the empty + // pre-hydration state is captured as the baseline and the first loaded + // emission looks like an in-session win (a disguised cold-launch prompt). + filter((isLoaded) => isLoaded), + take(1), + switchMap(() => this._store.select(selectTodayProgress)), + // selectTodayProgress recomputes on unrelated task changes (e.g. the 1s + // time-tracking tick emits a new {done,total} object with identical + // numbers); collapse those so the scan/filter only run on a real change. + distinctUntilChanged((a, b) => a.done === b.done && a.total === b.total), + // First (settled) emission is the session baseline; only fire on a later + // increase, i.e. a real completion this session. NOTE: the baseline is a + // fixed count, so a session left open past midnight keeps yesterday's + // baseline — this errs toward under-prompting (new day starts near 0 + // done), never toward nagging, so it's acceptable. + scan( + (acc, cur, index) => ({ + ...cur, + baseline: index === 0 ? cur.done : acc.baseline, + }), + { done: 0, total: 0, baseline: 0 }, + ), + filter( + ({ done, total, baseline }) => done > baseline && isProgressWin(done, total), + ), + take(1), + // Small beat after the win so the prompt doesn't fire on the same tick as + // the completion tap. + delay(WIN_PROMPT_DELAY_MS), + takeUntilDestroyed(this._destroyRef), + ) + // _promptNow re-checks _isArmed, so a stray second init() can't double-prompt. + .subscribe(() => this._promptNow()); + } + + private _promptNow(): void { + if (!this._isArmed) { + return; + } + this._isArmed = false; + + const state = loadRateDialogState(); + // Re-check eligibility, not just opt-out: a crash or data-damage recorded + // *after* arming (GlobalErrorHandler / state-validation this session) must + // still suppress the prompt, so we never ask for a review right after the + // user hit a failure. Inputs are re-read fresh here. + if (!shouldShowRateDialog(state, this._appStarts, getMsSinceLastCriticalError())) { + return; + } + + // Play-flavor Android: native Play In-App Review card. Play decides + // whether/when it shows and returns no result, so we just advance the + // cadence. Play may quota-suppress the card silently — with the recurring + // cadence that's only a deferral to the next window, not a lost lifetime + // prompt, and Play throttles duplicate requests itself. + if ( + IS_ANDROID_WEB_VIEW && + !IS_F_DROID_APP && + typeof androidInterface.requestReview === 'function' + ) { + androidInterface.requestReview(); + saveRateDialogState(applyRateDialogResult(state, 'later', this._appStarts)); + return; + } + + // iOS: native App Store review prompt (SKStoreReviewController). Advance the + // cadence only once the request actually resolves — if the plugin rejects + // (e.g. no active window scene), leave eligibility intact so a later session + // can retry rather than silently burning this cadence slot. + if (IS_IOS_NATIVE) { + void StoreReview.requestReview() + .then(() => + saveRateDialogState( + applyRateDialogResult(loadRateDialogState(), 'later', this._appStarts), + ), + ) + .catch((e) => + Log.err({ id: 'rate-store-review-ios', error: (e as Error)?.message }), + ); + return; + } + + // Web / Electron / F-Droid: a calm, non-modal banner instead of a blocking + // dialog (honours "flow, not friction"). Merely showing it counts as this + // tier's prompt, so record 'later' now; the user opens the full dialog only + // if they choose to, and that dialog's result upgrades the state on close. + saveRateDialogState(applyRateDialogResult(state, 'later', this._appStarts)); + this._bannerService.open({ + id: BannerId.RatePrompt, + ico: 'star', + msg: T.F.D_RATE.TITLE, + action: { + label: T.F.D_RATE.BANNER_ACTION, + fn: () => this._openRateDialog(), + }, + }); + } + + private _openRateDialog(): void { + this._matDialog + .open(DialogPleaseRateComponent) + .afterClosed() + .subscribe((result) => { + saveRateDialogState( + applyRateDialogResult(loadRateDialogState(), result ?? null, this._appStarts), + ); + }); + } +} diff --git a/src/app/features/dialog-please-rate/store-review/definitions.ts b/src/app/features/dialog-please-rate/store-review/definitions.ts new file mode 100644 index 0000000000..a4ff4fb288 --- /dev/null +++ b/src/app/features/dialog-please-rate/store-review/definitions.ts @@ -0,0 +1,9 @@ +export interface StoreReviewPlugin { + /** + * Ask the OS to show its native App Store review prompt (iOS, + * SKStoreReviewController). The system decides whether it actually appears, + * rate-limits it, and returns no result — so this resolves once the request + * has been made, regardless of outcome. No-op on non-iOS platforms. + */ + requestReview(): Promise; +} diff --git a/src/app/features/dialog-please-rate/store-review/index.ts b/src/app/features/dialog-please-rate/store-review/index.ts new file mode 100644 index 0000000000..de266fb31f --- /dev/null +++ b/src/app/features/dialog-please-rate/store-review/index.ts @@ -0,0 +1,9 @@ +import { registerPlugin } from '@capacitor/core'; +import type { StoreReviewPlugin } from './definitions'; + +const StoreReview = registerPlugin('StoreReview', { + web: () => import('./web').then((m) => new m.StoreReviewWeb()), +}); + +export * from './definitions'; +export { StoreReview }; diff --git a/src/app/features/dialog-please-rate/store-review/web.ts b/src/app/features/dialog-please-rate/store-review/web.ts new file mode 100644 index 0000000000..01558ff589 --- /dev/null +++ b/src/app/features/dialog-please-rate/store-review/web.ts @@ -0,0 +1,9 @@ +import { WebPlugin } from '@capacitor/core'; +import type { StoreReviewPlugin } from './definitions'; + +export class StoreReviewWeb extends WebPlugin implements StoreReviewPlugin { + async requestReview(): Promise { + // No native review UI on web/electron. The caller only invokes this on iOS + // native; this stub keeps the plugin contract satisfied everywhere else. + } +} diff --git a/src/app/features/work-context/store/work-context.selectors.ts b/src/app/features/work-context/store/work-context.selectors.ts index 3cf1287359..bc7fbd77dd 100644 --- a/src/app/features/work-context/store/work-context.selectors.ts +++ b/src/app/features/work-context/store/work-context.selectors.ts @@ -352,6 +352,23 @@ export const selectUndoneTodayTaskIds = createSelector( }, ); +/** + * Done vs total count of today's top-level tasks, from a single composed + * selector so `done`/`total` are always read from the SAME settled state. + * Deriving them from two separate store.select subscriptions (combineLatest) + * glitches: a task-add emits the new total with a stale undone count, + * transiently inflating `done`. Since `undone ⊆ all`, `done` never goes + * negative. Used by the rating-prompt "productive win" signal. + */ +export const selectTodayProgress = createSelector( + selectTodayTaskIds, + selectUndoneTodayTaskIds, + (allIds, undoneIds): { done: number; total: number } => ({ + done: allIds.length - undoneIds.length, + total: allIds.length, + }), +); + export const selectTimelineTasks = createSelector( selectTodayTaskIds, selectMapOfAllTasksInActiveProjects, diff --git a/src/app/t.const.ts b/src/app/t.const.ts index 27537fa553..0ac6d0ebb0 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -266,11 +266,13 @@ const T = { }, D_RATE: { A_HOW: 'F.D_RATE.A_HOW', + BANNER_ACTION: 'F.D_RATE.BANNER_ACTION', BTN_DONT_BOTHER: 'F.D_RATE.BTN_DONT_BOTHER', BTN_FEEDBACK: 'F.D_RATE.BTN_FEEDBACK', BTN_LATER: 'F.D_RATE.BTN_LATER', BTN_RATE_APP_STORE: 'F.D_RATE.BTN_RATE_APP_STORE', BTN_RATE_PLAY_STORE: 'F.D_RATE.BTN_RATE_PLAY_STORE', + BTN_STAR_GITHUB: 'F.D_RATE.BTN_STAR_GITHUB', FEEDBACK_BACK: 'F.D_RATE.FEEDBACK_BACK', FEEDBACK_CONTRIBUTE: 'F.D_RATE.FEEDBACK_CONTRIBUTE', FEEDBACK_CONTRIBUTE_DESC: 'F.D_RATE.FEEDBACK_CONTRIBUTE_DESC', @@ -2773,6 +2775,7 @@ const T = { KEYBOARD: 'MH.HM.KEYBOARD', REDDIT_COMMUNITY: 'MH.HM.REDDIT_COMMUNITY', REPORT_A_PROBLEM: 'MH.HM.REPORT_A_PROBLEM', + SEND_FEEDBACK: 'MH.HM.SEND_FEEDBACK', START_WELCOME: 'MH.HM.START_WELCOME', SYNC: 'MH.HM.SYNC', }, diff --git a/src/assets/i18n/ar.json b/src/assets/i18n/ar.json index 519309123a..127bdd438d 100644 --- a/src/assets/i18n/ar.json +++ b/src/assets/i18n/ar.json @@ -208,11 +208,13 @@ }, "D_RATE": { "A_HOW": "كيفية التقييم", + "BANNER_ACTION": "بالتأكيد!", "BTN_DONT_BOTHER": "لا تسأل مرة أخرى", "BTN_FEEDBACK": "إرسال ملاحظات", "BTN_LATER": "ربما لاحقًا", "BTN_RATE_APP_STORE": "قيّم على App Store", "BTN_RATE_PLAY_STORE": "قيّم على Play Store", + "BTN_STAR_GITHUB": "قيّمنا بنجمة على GitHub", "FEEDBACK_BACK": "رجوع", "FEEDBACK_CONTRIBUTE": "ساهم في المشروع", "FEEDBACK_CONTRIBUTE_DESC": "البرمجة، التصميم، الترجمة وغير ذلك", @@ -2495,6 +2497,7 @@ "KEYBOARD": "كيفية: لوحة المفاتيح المتقدمة", "REDDIT_COMMUNITY": "مجتمع ريديت", "REPORT_A_PROBLEM": "الإبلاغ عن مشكلة", + "SEND_FEEDBACK": "إرسال ملاحظات", "START_WELCOME": "ابدأ جولة الترحيب", "SYNC": "كيفية: تكوين المزامنة" }, diff --git a/src/assets/i18n/cs.json b/src/assets/i18n/cs.json index dc4f7aa9f7..163a113472 100644 --- a/src/assets/i18n/cs.json +++ b/src/assets/i18n/cs.json @@ -200,11 +200,13 @@ }, "D_RATE": { "A_HOW": "Jak hodnotit", + "BANNER_ACTION": "Jasně!", "BTN_DONT_BOTHER": "Příště se neptat", "BTN_FEEDBACK": "Poslat zpětnou vazbu", "BTN_LATER": "Možná později", "BTN_RATE_APP_STORE": "Ohodnotit v App Store", "BTN_RATE_PLAY_STORE": "Ohodnotit v Play Store", + "BTN_STAR_GITHUB": "Dejte nám hvězdičku na GitHubu", "FEEDBACK_BACK": "Zpět", "FEEDBACK_CONTRIBUTE": "Přispět do projektu", "FEEDBACK_CONTRIBUTE_DESC": "Kód, design, překlady a další", @@ -2360,6 +2362,7 @@ "KEYBOARD": "Návod: Pokročilá klávesnice", "REDDIT_COMMUNITY": "Komunita Reddit", "REPORT_A_PROBLEM": "Nahlásit problém", + "SEND_FEEDBACK": "Poslat zpětnou vazbu", "START_WELCOME": "Spustit uvítací prohlídku", "SYNC": "Návod: Konfigurace synchronizace" }, diff --git a/src/assets/i18n/de.json b/src/assets/i18n/de.json index da291f0023..5aaa82b148 100644 --- a/src/assets/i18n/de.json +++ b/src/assets/i18n/de.json @@ -254,11 +254,13 @@ }, "D_RATE": { "A_HOW": "Wie bewerten", + "BANNER_ACTION": "Gerne!", "BTN_DONT_BOTHER": "Nicht mehr fragen", "BTN_FEEDBACK": "Feedback geben", "BTN_LATER": "Vielleicht später", "BTN_RATE_APP_STORE": "Im App Store bewerten", "BTN_RATE_PLAY_STORE": "Im Play Store bewerten", + "BTN_STAR_GITHUB": "Gib uns einen Stern auf GitHub", "FEEDBACK_BACK": "Zurück", "FEEDBACK_CONTRIBUTE": "Zum Projekt beitragen", "FEEDBACK_CONTRIBUTE_DESC": "Code, Design, Übersetzungen und mehr", @@ -2662,6 +2664,7 @@ "KEYBOARD": "Howto: Erweiterte Tastatursteuerung", "REDDIT_COMMUNITY": "Reddit-Gemeinschaft", "REPORT_A_PROBLEM": "Ein Problem melden", + "SEND_FEEDBACK": "Feedback senden", "START_WELCOME": "Willkommenstour starten", "SYNC": "Howto: Synchronisierung konfigurieren" }, diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index ec92269b09..a5bcde64b1 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -262,11 +262,13 @@ }, "D_RATE": { "A_HOW": "How to rate", + "BANNER_ACTION": "Sure!", "BTN_DONT_BOTHER": "Don't ask again", "BTN_FEEDBACK": "Give feedback", "BTN_LATER": "Maybe later", "BTN_RATE_APP_STORE": "Rate on App Store", "BTN_RATE_PLAY_STORE": "Rate on Play Store", + "BTN_STAR_GITHUB": "Star us on GitHub", "FEEDBACK_BACK": "Back", "FEEDBACK_CONTRIBUTE": "Contribute to the project", "FEEDBACK_CONTRIBUTE_DESC": "Code, design, translations, and more", @@ -2708,6 +2710,7 @@ "KEYBOARD": "How to: Keyboard Navigation", "REDDIT_COMMUNITY": "Reddit community", "REPORT_A_PROBLEM": "Report a problem", + "SEND_FEEDBACK": "Send feedback", "START_WELCOME": "Start welcome tour", "SYNC": "How to: Configure sync" }, diff --git a/src/assets/i18n/es.json b/src/assets/i18n/es.json index f761717bb0..8fbfa790a6 100644 --- a/src/assets/i18n/es.json +++ b/src/assets/i18n/es.json @@ -218,11 +218,13 @@ }, "D_RATE": { "A_HOW": "Cómo calificar", + "BANNER_ACTION": "¡Claro!", "BTN_DONT_BOTHER": "No volver a preguntar", "BTN_FEEDBACK": "Enviar comentarios", "BTN_LATER": "Quizás más tarde", "BTN_RATE_APP_STORE": "Calificar en App Store", "BTN_RATE_PLAY_STORE": "Calificar en Play Store", + "BTN_STAR_GITHUB": "Danos una estrella en GitHub", "FEEDBACK_BACK": "Volver", "FEEDBACK_CONTRIBUTE": "Contribuir al proyecto", "FEEDBACK_CONTRIBUTE_DESC": "Código, diseño, traducciones y más", @@ -2506,6 +2508,7 @@ "KEYBOARD": "Tutorial: Teclado Avanzado", "REDDIT_COMMUNITY": "Comunidad de Reddit", "REPORT_A_PROBLEM": "Reportar un problema", + "SEND_FEEDBACK": "Enviar comentarios", "START_WELCOME": "Iniciar Tour de Bienvenida", "SYNC": "Tutorial: Configurar Sync" }, diff --git a/src/assets/i18n/fa.json b/src/assets/i18n/fa.json index 9b097efeef..bd2af7418f 100644 --- a/src/assets/i18n/fa.json +++ b/src/assets/i18n/fa.json @@ -177,11 +177,13 @@ }, "D_RATE": { "A_HOW": "چگونه امتیاز دهیم", + "BANNER_ACTION": "حتماً!", "BTN_DONT_BOTHER": "دیگر نپرس", "BTN_FEEDBACK": "ارسال بازخورد", "BTN_LATER": "شاید بعداً", "BTN_RATE_APP_STORE": "در App Store امتیاز دهید", "BTN_RATE_PLAY_STORE": "در Play Store امتیاز دهید", + "BTN_STAR_GITHUB": "به ما در GitHub ستاره بدهید", "FEEDBACK_BACK": "بازگشت", "FEEDBACK_CONTRIBUTE": "مشارکت در پروژه", "FEEDBACK_CONTRIBUTE_DESC": "کد، طراحی، ترجمه و موارد دیگر", @@ -2337,6 +2339,7 @@ "KEYBOARD": "نحوه انجام: صفحه کلید پیشرفته", "REDDIT_COMMUNITY": "انجمن Reddit", "REPORT_A_PROBLEM": "گزارش یک مشکل", + "SEND_FEEDBACK": "ارسال بازخورد", "START_WELCOME": "آغاز تور خوش‌آمدگویی", "SYNC": "نحوه انجام: همگام سازی را پیکربندی کنید" }, diff --git a/src/assets/i18n/fi.json b/src/assets/i18n/fi.json index d77d8f6f01..891cc3edaa 100644 --- a/src/assets/i18n/fi.json +++ b/src/assets/i18n/fi.json @@ -182,11 +182,13 @@ }, "D_RATE": { "A_HOW": "Miten arvostella", + "BANNER_ACTION": "Toki!", "BTN_DONT_BOTHER": "Älä kysy uudelleen", "BTN_FEEDBACK": "Anna palautetta", "BTN_LATER": "Ehkä myöhemmin", "BTN_RATE_APP_STORE": "Arvostele App Storessa", "BTN_RATE_PLAY_STORE": "Arvostele Play Storessa", + "BTN_STAR_GITHUB": "Tähditä meidät GitHubissa", "FEEDBACK_BACK": "Takaisin", "FEEDBACK_CONTRIBUTE": "Osallistu projektiin", "FEEDBACK_CONTRIBUTE_DESC": "Koodi, suunnittelu, käännökset ja muuta", @@ -2347,6 +2349,7 @@ "KEYBOARD": "Ohje: Kehittynyt näppäimistö", "REDDIT_COMMUNITY": "Reddit-yhteisö", "REPORT_A_PROBLEM": "Ilmoita ongelmasta", + "SEND_FEEDBACK": "Lähetä palautetta", "START_WELCOME": "Aloita tervetuloa-kierros", "SYNC": "Ohje: Määritä synkronointi" }, diff --git a/src/assets/i18n/fr.json b/src/assets/i18n/fr.json index 062bf7180c..8f0b9a6b28 100644 --- a/src/assets/i18n/fr.json +++ b/src/assets/i18n/fr.json @@ -213,11 +213,13 @@ }, "D_RATE": { "A_HOW": "Comment évaluer", + "BANNER_ACTION": "Avec plaisir !", "BTN_DONT_BOTHER": "Ne plus me demander", "BTN_FEEDBACK": "Envoyer un retour", "BTN_LATER": "Peut-être plus tard", "BTN_RATE_APP_STORE": "Noter sur l'App Store", "BTN_RATE_PLAY_STORE": "Noter sur le Play Store", + "BTN_STAR_GITHUB": "Mettez-nous une étoile sur GitHub", "FEEDBACK_BACK": "Retour", "FEEDBACK_CONTRIBUTE": "Contribuer au projet", "FEEDBACK_CONTRIBUTE_DESC": "Code, design, traductions et plus", @@ -2506,6 +2508,7 @@ "KEYBOARD": "Tutoriel : Raccourcis clavier avancés", "REDDIT_COMMUNITY": "Communauté Reddit", "REPORT_A_PROBLEM": "Signaler un problème", + "SEND_FEEDBACK": "Envoyer un retour", "START_WELCOME": "Démarrer le tour de bienvenue", "SYNC": "Tutoriel : Configurer la synchronisation" }, diff --git a/src/assets/i18n/hr.json b/src/assets/i18n/hr.json index 4c9165f0a3..03aae6bd3f 100644 --- a/src/assets/i18n/hr.json +++ b/src/assets/i18n/hr.json @@ -225,11 +225,13 @@ }, "D_RATE": { "A_HOW": "Kako ocijeniti", + "BANNER_ACTION": "Naravno!", "BTN_DONT_BOTHER": "Ne pitaj ponovno", "BTN_FEEDBACK": "Pošalji povratnu informaciju", "BTN_LATER": "Možda kasnije", "BTN_RATE_APP_STORE": "Ocijeni na App Storeu", "BTN_RATE_PLAY_STORE": "Ocijeni na Play Storeu", + "BTN_STAR_GITHUB": "Označite nas zvjezdicom na GitHubu", "FEEDBACK_BACK": "Natrag", "FEEDBACK_CONTRIBUTE": "Doprinesi projektu", "FEEDBACK_CONTRIBUTE_DESC": "Kod, dizajn, prijevodi i više", @@ -2484,6 +2486,7 @@ "KEYBOARD": "Kako: Napredna tipkovnica", "REDDIT_COMMUNITY": "Reddit zajednica", "REPORT_A_PROBLEM": "Prijavi problem", + "SEND_FEEDBACK": "Pošalji povratnu informaciju", "START_WELCOME": "Pokreni uvodni vodič", "SYNC": "Kako: Konfigurirati sinkronizaciju" }, diff --git a/src/assets/i18n/id.json b/src/assets/i18n/id.json index e58185be80..f13ea62624 100644 --- a/src/assets/i18n/id.json +++ b/src/assets/i18n/id.json @@ -213,11 +213,13 @@ }, "D_RATE": { "A_HOW": "Cara menilai", + "BANNER_ACTION": "Tentu!", "BTN_DONT_BOTHER": "Jangan tanya lagi", "BTN_FEEDBACK": "Beri masukan", "BTN_LATER": "Mungkin nanti", "BTN_RATE_APP_STORE": "Nilai di App Store", "BTN_RATE_PLAY_STORE": "Nilai di Play Store", + "BTN_STAR_GITHUB": "Beri kami bintang di GitHub", "FEEDBACK_BACK": "Kembali", "FEEDBACK_CONTRIBUTE": "Berkontribusi ke proyek", "FEEDBACK_CONTRIBUTE_DESC": "Kode, desain, terjemahan, dan lainnya", @@ -2505,6 +2507,7 @@ "KEYBOARD": "Howto: Papan Ketik Tingkat Lanjut", "REDDIT_COMMUNITY": "Komunitas Reddit", "REPORT_A_PROBLEM": "Laporkan masalah", + "SEND_FEEDBACK": "Kirim masukan", "START_WELCOME": "Mulai Tur Selamat Datang", "SYNC": "Howto: Mengonfigurasi Sinkronisasi" }, diff --git a/src/assets/i18n/it.json b/src/assets/i18n/it.json index c6d8abffd0..5ac54d6d26 100644 --- a/src/assets/i18n/it.json +++ b/src/assets/i18n/it.json @@ -213,11 +213,13 @@ }, "D_RATE": { "A_HOW": "Come valutare", + "BANNER_ACTION": "Certo!", "BTN_DONT_BOTHER": "Non chiedere più", "BTN_FEEDBACK": "Lascia un feedback", "BTN_LATER": "Forse più tardi", "BTN_RATE_APP_STORE": "Valuta su App Store", "BTN_RATE_PLAY_STORE": "Valuta su Play Store", + "BTN_STAR_GITHUB": "Lasciaci una stella su GitHub", "FEEDBACK_BACK": "Indietro", "FEEDBACK_CONTRIBUTE": "Contribuisci al progetto", "FEEDBACK_CONTRIBUTE_DESC": "Codice, design, traduzioni e altro", @@ -2506,6 +2508,7 @@ "KEYBOARD": "Come fare: Tastiera Avanzata", "REDDIT_COMMUNITY": "Comunità di Reddit", "REPORT_A_PROBLEM": "Segnala un problema", + "SEND_FEEDBACK": "Invia feedback", "START_WELCOME": "Avvia Tour di Benvenuto", "SYNC": "Howto: Configura Sincronizzazione" }, diff --git a/src/assets/i18n/ja.json b/src/assets/i18n/ja.json index c8eec8c234..506169a4ca 100644 --- a/src/assets/i18n/ja.json +++ b/src/assets/i18n/ja.json @@ -208,11 +208,13 @@ }, "D_RATE": { "A_HOW": "評価の方法", + "BANNER_ACTION": "はい!", "BTN_DONT_BOTHER": "今後表示しない", "BTN_FEEDBACK": "フィードバックを送る", "BTN_LATER": "あとで", "BTN_RATE_APP_STORE": "App Store で評価", "BTN_RATE_PLAY_STORE": "Play Store で評価", + "BTN_STAR_GITHUB": "GitHub でスターを付ける", "FEEDBACK_BACK": "戻る", "FEEDBACK_CONTRIBUTE": "プロジェクトに貢献する", "FEEDBACK_CONTRIBUTE_DESC": "コード、デザイン、翻訳など", @@ -2495,6 +2497,7 @@ "KEYBOARD": "ハウツー高度なキーボード", "REDDIT_COMMUNITY": "Reddit コミュニティ", "REPORT_A_PROBLEM": "問題を報告する", + "SEND_FEEDBACK": "フィードバックを送る", "START_WELCOME": "ウェルカムツアー開始", "SYNC": "ハウツー同期の設定" }, diff --git a/src/assets/i18n/ko.json b/src/assets/i18n/ko.json index 2b0490a1fa..4fd1a7c6b7 100644 --- a/src/assets/i18n/ko.json +++ b/src/assets/i18n/ko.json @@ -197,11 +197,13 @@ }, "D_RATE": { "A_HOW": "평가하는 방법", + "BANNER_ACTION": "좋아요!", "BTN_DONT_BOTHER": "다시 묻지 않기", "BTN_FEEDBACK": "피드백 보내기", "BTN_LATER": "나중에", "BTN_RATE_APP_STORE": "App Store에서 평가", "BTN_RATE_PLAY_STORE": "Play Store에서 평가", + "BTN_STAR_GITHUB": "GitHub에서 스타 주기", "FEEDBACK_BACK": "뒤로", "FEEDBACK_CONTRIBUTE": "프로젝트에 기여하기", "FEEDBACK_CONTRIBUTE_DESC": "코드, 디자인, 번역 등", @@ -2483,6 +2485,7 @@ "KEYBOARD": "Howto: 고급 키보드", "REDDIT_COMMUNITY": "Reddit 커뮤니티", "REPORT_A_PROBLEM": "문제 신고하기", + "SEND_FEEDBACK": "피드백 보내기", "START_WELCOME": "웰컴 투어 시작", "SYNC": "방법: 동기화 구성" }, diff --git a/src/assets/i18n/nb.json b/src/assets/i18n/nb.json index 5bb23316bc..f9d7328e0d 100644 --- a/src/assets/i18n/nb.json +++ b/src/assets/i18n/nb.json @@ -182,11 +182,13 @@ }, "D_RATE": { "A_HOW": "Hvordan vurdere", + "BANNER_ACTION": "Gjerne!", "BTN_DONT_BOTHER": "Ikke spør igjen", "BTN_FEEDBACK": "Gi tilbakemelding", "BTN_LATER": "Kanskje senere", "BTN_RATE_APP_STORE": "Vurder i App Store", "BTN_RATE_PLAY_STORE": "Vurder i Play Store", + "BTN_STAR_GITHUB": "Gi oss en stjerne på GitHub", "FEEDBACK_BACK": "Tilbake", "FEEDBACK_CONTRIBUTE": "Bidra til prosjektet", "FEEDBACK_CONTRIBUTE_DESC": "Kode, design, oversettelser og mer", @@ -2347,6 +2349,7 @@ "KEYBOARD": "Slik gjør du det: Avansert tastatur", "REDDIT_COMMUNITY": "Reddit-fellesskapet", "REPORT_A_PROBLEM": "Rapporter et problem", + "SEND_FEEDBACK": "Send tilbakemelding", "START_WELCOME": "Start velkomstturen", "SYNC": "Slik gjør du det: Konfigurere synkronisering" }, diff --git a/src/assets/i18n/nl.json b/src/assets/i18n/nl.json index 6301ce6ab9..a0221ef4a1 100644 --- a/src/assets/i18n/nl.json +++ b/src/assets/i18n/nl.json @@ -213,11 +213,13 @@ }, "D_RATE": { "A_HOW": "Hoe te beoordelen", + "BANNER_ACTION": "Zeker!", "BTN_DONT_BOTHER": "Niet meer vragen", "BTN_FEEDBACK": "Feedback geven", "BTN_LATER": "Misschien later", "BTN_RATE_APP_STORE": "Beoordeel in App Store", "BTN_RATE_PLAY_STORE": "Beoordeel in Play Store", + "BTN_STAR_GITHUB": "Geef ons een ster op GitHub", "FEEDBACK_BACK": "Terug", "FEEDBACK_CONTRIBUTE": "Bijdragen aan het project", "FEEDBACK_CONTRIBUTE_DESC": "Code, design, vertalingen en meer", @@ -2506,6 +2508,7 @@ "KEYBOARD": "Howto: Geavanceerd toetsenbord", "REDDIT_COMMUNITY": "Reddit-gemeenschap", "REPORT_A_PROBLEM": "Een probleem melden", + "SEND_FEEDBACK": "Feedback versturen", "START_WELCOME": "Begin welkomstrondleiding", "SYNC": "Howto: Synchronisatie configureren" }, diff --git a/src/assets/i18n/pl.json b/src/assets/i18n/pl.json index 438ed05162..447a95828d 100644 --- a/src/assets/i18n/pl.json +++ b/src/assets/i18n/pl.json @@ -217,11 +217,13 @@ }, "D_RATE": { "A_HOW": "Jak oceniać", + "BANNER_ACTION": "Jasne!", "BTN_DONT_BOTHER": "Nie pytaj ponownie", "BTN_FEEDBACK": "Wyślij opinię", "BTN_LATER": "Może później", "BTN_RATE_APP_STORE": "Oceń w App Store", "BTN_RATE_PLAY_STORE": "Oceń w Play Store", + "BTN_STAR_GITHUB": "Daj nam gwiazdkę na GitHubie", "FEEDBACK_BACK": "Wstecz", "FEEDBACK_CONTRIBUTE": "Przyczynij się do projektu", "FEEDBACK_CONTRIBUTE_DESC": "Kod, design, tłumaczenia i więcej", @@ -2489,6 +2491,7 @@ "KEYBOARD": "Poradnik: Zaawansowana klawiatura", "REDDIT_COMMUNITY": "Społeczność Reddit", "REPORT_A_PROBLEM": "Zgłoś problem", + "SEND_FEEDBACK": "Wyślij opinię", "START_WELCOME": "Rozpocznij samouczek powitalny", "SYNC": "Poradnik: Konfiguracja synchronizacji" }, diff --git a/src/assets/i18n/pt-br.json b/src/assets/i18n/pt-br.json index b916b385f2..f17aac8747 100644 --- a/src/assets/i18n/pt-br.json +++ b/src/assets/i18n/pt-br.json @@ -217,11 +217,13 @@ }, "D_RATE": { "A_HOW": "Como avaliar", + "BANNER_ACTION": "Claro!", "BTN_DONT_BOTHER": "Não perguntar de novo", "BTN_FEEDBACK": "Enviar feedback", "BTN_LATER": "Talvez mais tarde", "BTN_RATE_APP_STORE": "Avaliar na App Store", "BTN_RATE_PLAY_STORE": "Avaliar na Play Store", + "BTN_STAR_GITHUB": "Dê uma estrela no GitHub", "FEEDBACK_BACK": "Voltar", "FEEDBACK_CONTRIBUTE": "Contribuir com o projeto", "FEEDBACK_CONTRIBUTE_DESC": "Código, design, traduções e mais", @@ -2485,6 +2487,7 @@ "KEYBOARD": "Como fazer: Teclado avançado", "REDDIT_COMMUNITY": "Comunidade no Reddit", "REPORT_A_PROBLEM": "Relatar um problema", + "SEND_FEEDBACK": "Enviar feedback", "START_WELCOME": "Iniciar tour de boas-vindas", "SYNC": "Como fazer: Configurar sincronização" }, diff --git a/src/assets/i18n/pt.json b/src/assets/i18n/pt.json index 5bd81742eb..1cec32ba54 100644 --- a/src/assets/i18n/pt.json +++ b/src/assets/i18n/pt.json @@ -213,11 +213,13 @@ }, "D_RATE": { "A_HOW": "Como avaliar", + "BANNER_ACTION": "Claro!", "BTN_DONT_BOTHER": "Não perguntar novamente", "BTN_FEEDBACK": "Enviar comentários", "BTN_LATER": "Talvez mais tarde", "BTN_RATE_APP_STORE": "Avaliar na App Store", "BTN_RATE_PLAY_STORE": "Avaliar na Play Store", + "BTN_STAR_GITHUB": "Dê-nos uma estrela no GitHub", "FEEDBACK_BACK": "Voltar", "FEEDBACK_CONTRIBUTE": "Contribuir para o projeto", "FEEDBACK_CONTRIBUTE_DESC": "Código, design, traduções e mais", @@ -2506,6 +2508,7 @@ "KEYBOARD": "Como fazer: Teclado avançado", "REDDIT_COMMUNITY": "Comunidade no Reddit", "REPORT_A_PROBLEM": "Reportar um problema", + "SEND_FEEDBACK": "Enviar comentários", "START_WELCOME": "Iniciar a visita de boas-vindas", "SYNC": "Como fazer: Configurar a sincronização" }, diff --git a/src/assets/i18n/ro-md.json b/src/assets/i18n/ro-md.json index 39955baf10..e32b0adea7 100644 --- a/src/assets/i18n/ro-md.json +++ b/src/assets/i18n/ro-md.json @@ -255,11 +255,13 @@ }, "D_RATE": { "A_HOW": "Cum acorzi o notă", + "BANNER_ACTION": "Sigur!", "BTN_DONT_BOTHER": "Nu mă mai întreba", "BTN_FEEDBACK": "Trimite feedback", "BTN_LATER": "Poate mai tîrziu", "BTN_RATE_APP_STORE": "Acordă o notă pe App Store", "BTN_RATE_PLAY_STORE": "Acordă o notă pe Play Store", + "BTN_STAR_GITHUB": "Oferă-ne o stea pe GitHub", "FEEDBACK_BACK": "Înapoi", "FEEDBACK_CONTRIBUTE": "Contribuie la proiect", "FEEDBACK_CONTRIBUTE_DESC": "Cod, design, traduceri și altele", @@ -2683,6 +2685,7 @@ "KEYBOARD": "Cum să: Tastatură avansată", "REDDIT_COMMUNITY": "Comunitatea Reddit", "REPORT_A_PROBLEM": "Raportează o problemă", + "SEND_FEEDBACK": "Trimite feedback", "START_WELCOME": "Începe tur de bun venit", "SYNC": "Cum să: Configurare sincronizare" }, diff --git a/src/assets/i18n/ro.json b/src/assets/i18n/ro.json index b40bb298f5..77f2ce545c 100644 --- a/src/assets/i18n/ro.json +++ b/src/assets/i18n/ro.json @@ -255,11 +255,13 @@ }, "D_RATE": { "A_HOW": "Cum acorzi o notă", + "BANNER_ACTION": "Sigur!", "BTN_DONT_BOTHER": "Nu mă mai întreba", "BTN_FEEDBACK": "Trimite feedback", "BTN_LATER": "Poate mai târziu", "BTN_RATE_APP_STORE": "Acordă o notă pe App Store", "BTN_RATE_PLAY_STORE": "Acordă o notă pe Play Store", + "BTN_STAR_GITHUB": "Oferă-ne o stea pe GitHub", "FEEDBACK_BACK": "Înapoi", "FEEDBACK_CONTRIBUTE": "Contribuie la proiect", "FEEDBACK_CONTRIBUTE_DESC": "Cod, design, traduceri și altele", @@ -2683,6 +2685,7 @@ "KEYBOARD": "Cum să: Tastatură avansată", "REDDIT_COMMUNITY": "Comunitatea Reddit", "REPORT_A_PROBLEM": "Raportează o problemă", + "SEND_FEEDBACK": "Trimite feedback", "START_WELCOME": "Începe tur de bun venit", "SYNC": "Cum să: Configurare sincronizare" }, diff --git a/src/assets/i18n/ru.json b/src/assets/i18n/ru.json index 21602436a8..4f21fae7d5 100644 --- a/src/assets/i18n/ru.json +++ b/src/assets/i18n/ru.json @@ -213,11 +213,13 @@ }, "D_RATE": { "A_HOW": "Как оценить", + "BANNER_ACTION": "Конечно!", "BTN_DONT_BOTHER": "Больше не спрашивать", "BTN_FEEDBACK": "Отправить отзыв", "BTN_LATER": "Может, позже", "BTN_RATE_APP_STORE": "Оценить в App Store", "BTN_RATE_PLAY_STORE": "Оценить в Play Store", + "BTN_STAR_GITHUB": "Поставьте нам звезду на GitHub", "FEEDBACK_BACK": "Назад", "FEEDBACK_CONTRIBUTE": "Внести вклад в проект", "FEEDBACK_CONTRIBUTE_DESC": "Код, дизайн, переводы и другое", @@ -2512,6 +2514,7 @@ "KEYBOARD": "Инструкция: Продвинутая клавиатура", "REDDIT_COMMUNITY": "Сообщество Reddit", "REPORT_A_PROBLEM": "Сообщить о проблеме", + "SEND_FEEDBACK": "Отправить отзыв", "START_WELCOME": "Начать приветственный тур", "SYNC": "Как настроить синхронизацию" }, diff --git a/src/assets/i18n/sk.json b/src/assets/i18n/sk.json index 3c65db1896..8c03e0453e 100644 --- a/src/assets/i18n/sk.json +++ b/src/assets/i18n/sk.json @@ -182,11 +182,13 @@ }, "D_RATE": { "A_HOW": "Ako hodnotiť", + "BANNER_ACTION": "Jasné!", "BTN_DONT_BOTHER": "Už sa nepýtať", "BTN_FEEDBACK": "Poslať spätnú väzbu", "BTN_LATER": "Možno neskôr", "BTN_RATE_APP_STORE": "Ohodnotiť v App Store", "BTN_RATE_PLAY_STORE": "Ohodnotiť v Play Store", + "BTN_STAR_GITHUB": "Dajte nám hviezdičku na GitHube", "FEEDBACK_BACK": "Späť", "FEEDBACK_CONTRIBUTE": "Prispieť do projektu", "FEEDBACK_CONTRIBUTE_DESC": "Kód, dizajn, preklady a viac", @@ -2347,6 +2349,7 @@ "KEYBOARD": "Ako na to: Pokročilá klávesnica", "REDDIT_COMMUNITY": "Komunita Reddit", "REPORT_A_PROBLEM": "Nahlásiť problém", + "SEND_FEEDBACK": "Poslať spätnú väzbu", "START_WELCOME": "Začiatok uvítacej prehliadky", "SYNC": "Ako na to: Konfigurácia synchronizácie" }, diff --git a/src/assets/i18n/sv.json b/src/assets/i18n/sv.json index 169c71b01c..7057e1dcf5 100644 --- a/src/assets/i18n/sv.json +++ b/src/assets/i18n/sv.json @@ -255,11 +255,13 @@ }, "D_RATE": { "A_HOW": "Så betygsätter du", + "BANNER_ACTION": "Gärna!", "BTN_DONT_BOTHER": "Fråga inte igen", "BTN_FEEDBACK": "Lämna feedback", "BTN_LATER": "Kanske senare", "BTN_RATE_APP_STORE": "Betygsätt i App Store", "BTN_RATE_PLAY_STORE": "Betygsätt i Play Store", + "BTN_STAR_GITHUB": "Ge oss en stjärna på GitHub", "FEEDBACK_BACK": "Tillbaka", "FEEDBACK_CONTRIBUTE": "Bidra till projektet", "FEEDBACK_CONTRIBUTE_DESC": "Kod, design, översättningar och mer", @@ -2688,6 +2690,7 @@ "KEYBOARD": "Hur man gör: Avancerat tangentbord", "REDDIT_COMMUNITY": "Reddit-community", "REPORT_A_PROBLEM": "Rapportera ett problem", + "SEND_FEEDBACK": "Skicka feedback", "START_WELCOME": "Starta välkomstrundtur", "SYNC": "Hur man gör: Konfigurera synkronisering" }, diff --git a/src/assets/i18n/tr.json b/src/assets/i18n/tr.json index 09335916c8..6491efa235 100644 --- a/src/assets/i18n/tr.json +++ b/src/assets/i18n/tr.json @@ -255,11 +255,13 @@ }, "D_RATE": { "A_HOW": "Nasıl değerlendirilir", + "BANNER_ACTION": "Tabii ki!", "BTN_DONT_BOTHER": "Bir daha sorma", "BTN_FEEDBACK": "Geri bildirim gönder", "BTN_LATER": "Belki daha sonra", "BTN_RATE_APP_STORE": "App Store'da değerlendir", "BTN_RATE_PLAY_STORE": "Play Store'da değerlendir", + "BTN_STAR_GITHUB": "GitHub'da bize yıldız verin", "FEEDBACK_BACK": "Geri", "FEEDBACK_CONTRIBUTE": "Projeye katkıda bulun", "FEEDBACK_CONTRIBUTE_DESC": "Kod, tasarım, çeviri ve daha fazlası", @@ -2674,6 +2676,7 @@ "KEYBOARD": "Nasıl Yapılır: Gelişmiş Klavye", "REDDIT_COMMUNITY": "Reddit Topluluğu", "REPORT_A_PROBLEM": "Sorun bildir", + "SEND_FEEDBACK": "Geri bildirim gönder", "START_WELCOME": "Hoşgeldin turunu başlat", "SYNC": "Nasıl Yapılır: Eşitleme ayarlamak" }, diff --git a/src/assets/i18n/uk.json b/src/assets/i18n/uk.json index 38fd3ef99b..435431a101 100644 --- a/src/assets/i18n/uk.json +++ b/src/assets/i18n/uk.json @@ -255,11 +255,13 @@ }, "D_RATE": { "A_HOW": "Як оцінити", + "BANNER_ACTION": "Звісно!", "BTN_DONT_BOTHER": "Більше не запитувати", "BTN_FEEDBACK": "Надіслати відгук", "BTN_LATER": "Можливо, пізніше", "BTN_RATE_APP_STORE": "Оцінити в App Store", "BTN_RATE_PLAY_STORE": "Оцінити в Play Store", + "BTN_STAR_GITHUB": "Поставте нам зірку на GitHub", "FEEDBACK_BACK": "Назад", "FEEDBACK_CONTRIBUTE": "Зробити внесок у проєкт", "FEEDBACK_CONTRIBUTE_DESC": "Код, дизайн, переклади тощо", @@ -2686,6 +2688,7 @@ "KEYBOARD": "Як використовувати розширені клавіатурні команди", "REDDIT_COMMUNITY": "Спільнота Reddit", "REPORT_A_PROBLEM": "Повідомити про проблему", + "SEND_FEEDBACK": "Надіслати відгук", "START_WELCOME": "Почати ознайомчий тур", "SYNC": "Як налаштувати синхронізацію" }, diff --git a/src/assets/i18n/vi.json b/src/assets/i18n/vi.json index b10803d5f4..bb379847a8 100644 --- a/src/assets/i18n/vi.json +++ b/src/assets/i18n/vi.json @@ -230,11 +230,13 @@ }, "D_RATE": { "A_HOW": "Cách và nơi đánh giá", + "BANNER_ACTION": "Chắc chắn!", "BTN_DONT_BOTHER": "Đừng làm phiền tôi nữa", "BTN_FEEDBACK": "Gửi phản hồi", "BTN_LATER": "Để sau", "BTN_RATE_APP_STORE": "Đánh giá trên App Store", "BTN_RATE_PLAY_STORE": "Đánh giá trên Play Store", + "BTN_STAR_GITHUB": "Gắn sao cho chúng tôi trên GitHub", "FEEDBACK_BACK": "Quay lại", "FEEDBACK_CONTRIBUTE": "Đóng góp cho dự án", "FEEDBACK_CONTRIBUTE_DESC": "Mã nguồn, thiết kế, bản dịch và hơn thế nữa", @@ -2525,6 +2527,7 @@ "KEYBOARD": "Cách: Bàn phím nâng cao", "REDDIT_COMMUNITY": "Cộng đồng Reddit", "REPORT_A_PROBLEM": "Báo cáo vấn đề", + "SEND_FEEDBACK": "Gửi phản hồi", "START_WELCOME": "Bắt đầu hướng dẫn chào mừng", "SYNC": "Cách: Cấu hình đồng bộ" }, diff --git a/src/assets/i18n/zh-tw.json b/src/assets/i18n/zh-tw.json index 4ebc1b1fea..567193097c 100644 --- a/src/assets/i18n/zh-tw.json +++ b/src/assets/i18n/zh-tw.json @@ -218,11 +218,13 @@ }, "D_RATE": { "A_HOW": "如何評分", + "BANNER_ACTION": "好啊!", "BTN_DONT_BOTHER": "不要再詢問", "BTN_FEEDBACK": "提供意見", "BTN_LATER": "稍後再說", "BTN_RATE_APP_STORE": "在 App Store 評分", "BTN_RATE_PLAY_STORE": "在 Play Store 評分", + "BTN_STAR_GITHUB": "在 GitHub 上為我們點星", "FEEDBACK_BACK": "返回", "FEEDBACK_CONTRIBUTE": "為此專案貢獻", "FEEDBACK_CONTRIBUTE_DESC": "程式碼、設計、翻譯等", @@ -2508,6 +2510,7 @@ "KEYBOARD": "如何:進階鍵盤", "REDDIT_COMMUNITY": "Reddit 社群", "REPORT_A_PROBLEM": "回報問題", + "SEND_FEEDBACK": "提供意見", "START_WELCOME": "開始歡迎導覽", "SYNC": "如何:設定同步" }, diff --git a/src/assets/i18n/zh.json b/src/assets/i18n/zh.json index 8aade13836..846089eca6 100644 --- a/src/assets/i18n/zh.json +++ b/src/assets/i18n/zh.json @@ -231,11 +231,13 @@ }, "D_RATE": { "A_HOW": "如何评分", + "BANNER_ACTION": "好的!", "BTN_DONT_BOTHER": "不再询问", "BTN_FEEDBACK": "提供反馈", "BTN_LATER": "稍后再说", "BTN_RATE_APP_STORE": "在 App Store 评分", "BTN_RATE_PLAY_STORE": "在 Play Store 评分", + "BTN_STAR_GITHUB": "在 GitHub 上给我们点星", "FEEDBACK_BACK": "返回", "FEEDBACK_CONTRIBUTE": "为项目做贡献", "FEEDBACK_CONTRIBUTE_DESC": "代码、设计、翻译等", @@ -2519,6 +2521,7 @@ "KEYBOARD": "如何:高级键盘", "REDDIT_COMMUNITY": "Reddit 社区", "REPORT_A_PROBLEM": "报告问题", + "SEND_FEEDBACK": "发送反馈", "START_WELCOME": "开始欢迎导览", "SYNC": "如何:配置同步" },