mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
feat(rate-dialog): calm, recurring, win-timed store rating prompt (#8704)
* feat(rate-dialog): native store review + prompt after a productive win Baseline of PR #8680 (squashed) so review improvements land as a separate, cherry-pickable commit on top. Co-Authored-By: Claude <noreply@anthropic.com> * feat(rate-dialog): calm banner, feedback cooldown, delayed win prompt Review + UX follow-ups on top of PR #8680: - Fix (correctness): re-check full eligibility at prompt fire-time, not just opt-out, so a crash/data-damage recorded after arming still suppresses it. - Fix: iOS advances the rating cadence only once the native request resolves; a reject leaves eligibility intact instead of burning a lifetime prompt. - Fix: Android review-flow failure now logs and abandons instead of opening the Play Store unprompted (the trigger is an automatic win, not a user tap). - UX: web/electron/F-Droid now show a calm, non-modal banner that opens the full rate/feedback dialog on request, rather than a modal shoved in mid-flow. - UX: 'give feedback' no longer permanently opts out — it starts a long cooldown (~90 app-start days) so an engaged user can still be asked once more later. - UX: the win prompt fires a few seconds after the completion, not on the tap. - UX: dedicated 'Send feedback' entry in the Help menu (GitHub Discussions). - UX: show the maintainer email as selectable text (mailto: dead-end fallback). - Refactor: move selectTodayProgress into work-context.selectors (colocation); idempotency guard on the win subscription. Co-Authored-By: Claude <noreply@anthropic.com> * test(rate-dialog): import WIN_PROMPT_DELAY_MS in spec instead of mirroring it Export the delay constant from the service and reference it in the spec so the test can't silently drift from the real value. Found by a review pass. Co-Authored-By: Claude <noreply@anthropic.com> * feat(rate-dialog): recurring cadence, version-age gate, GitHub star CTA Growth-focused follow-ups (review found the lifetime cap starves review recency/velocity, which is what stores rank on): - Recurring cadence: after the fixed onboarding tiers (32/96 app-start days) the prompt no longer stops forever — it recurs every ~180 app-start days (~6+ months of real use), well inside Apple's ~3/365 allowance and Play's own quota. Still calm: win-timed, opt-out/crash/feedback-gated, OS-throttled. - Version-age gate: hold the prompt for 7 days after the app version changes so a fresh (possibly regressed) release isn't asked to be rated immediately. Tracked via two device-local LS keys, checked at arm time and fire time. - Play tier-burn is now only a deferral, not a lifetime loss (recurring cadence). - Web/Electron CTA: 'Star us on GitHub' (the desktop-distribution equivalent of store ranking) instead of the near-zero-conversion how-to-rate doc. Tests cover recurrence at + beyond the last tier, the version-age gate, and the updated cadence expectations. 60/60 green; tsc clean. Co-Authored-By: Claude <noreply@anthropic.com> * fix(rate-dialog): register iOS plugin, drop version-age gate, cut per-tick churn Second multi-review pass (7 reviewers): - CRITICAL (Codex, verified): the iOS StoreReview Capacitor plugin was never registered — CustomViewController registers WebDavHttpPlugin but not StoreReviewPlugin, so requestReview() rejected and the native App Store review card never showed. Register it via registerPluginInstance. - Remove the version-age gate (4 reviewers): getAppVersionStr() changes every release and the app ships ~weekly, so the 7-day window kept re-arming on every update (web SW reload / Electron auto-update) and near-permanently suppressed the prompt on desktop — the platform where the GitHub-star intent matters most. It also duplicated the 30-day crash gate, which already covers crashing regressions. Drops 2 LS keys, a constant, and ~40 LOC. - Perf: add distinctUntilChanged on the armed win stream so the 1s time-tracking tick (new {done,total} with identical numbers) no longer re-runs scan/filter every second for the whole session. 59/59 specs green; tsc clean. Kotlin/Swift can't be compiled here — the iOS registration needs an on-device/simulator smoke test. Co-Authored-By: Claude <noreply@anthropic.com> * chore(i18n): translate new rate-prompt strings into all locales Add BANNER_ACTION, BTN_STAR_GITHUB (F.D_RATE) and SEND_FEEDBACK (MH.HM) to all 27 non-en locale files — previously they existed only in en.json and fell back to English for everyone else. SEND_FEEDBACK reuses each locale's existing feedback wording for consistency; GitHub is kept untranslated. Deliberately edits locale files beyond the usual en-only workflow, per request. Best-effort translations — a native check on the less-common languages is welcome. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
393f686e4b
commit
b692eb72ce
54 changed files with 1046 additions and 223 deletions
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = "<group>"; };
|
||||
B63E17A02C5F3D8A00A1B2C3 /* WebDavHttpPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebDavHttpPlugin.swift; sourceTree = "<group>"; };
|
||||
B63E17A22C5F3D8A00A1B2C3 /* WebDavHttpPlugin.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = WebDavHttpPlugin.m; sourceTree = "<group>"; };
|
||||
B63E17B02C5F3D8A00A1B2C3 /* StoreReviewPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreReviewPlugin.swift; sourceTree = "<group>"; };
|
||||
B63E17B22C5F3D8A00A1B2C3 /* StoreReviewPlugin.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = StoreReviewPlugin.m; sourceTree = "<group>"; };
|
||||
B63E17A42C5F3D8A00A1B2C3 /* CustomViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomViewController.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
/* 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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,5 +4,6 @@ import Capacitor
|
|||
class CustomViewController: CAPBridgeViewController {
|
||||
override open func capacitorDidLoad() {
|
||||
bridge?.registerPluginInstance(WebDavHttpPlugin())
|
||||
bridge?.registerPluginInstance(StoreReviewPlugin())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
5
ios/App/App/StoreReviewPlugin.m
Normal file
5
ios/App/App/StoreReviewPlugin.m
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#import <Capacitor/Capacitor.h>
|
||||
|
||||
CAP_PLUGIN(StoreReviewPlugin, "StoreReview",
|
||||
CAP_PLUGIN_METHOD(requestReview, CAPPluginReturnPromise);
|
||||
)
|
||||
42
ios/App/App/StoreReviewPlugin.swift
Normal file
42
ios/App/App/StoreReviewPlugin.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
? [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<MatDialog>;
|
||||
let pluginService: jasmine.SpyObj<PluginService>;
|
||||
let ratePromptService: jasmine.SpyObj<RatePromptService>;
|
||||
|
||||
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<MatDialog>;
|
||||
pluginService = TestBed.inject(PluginService) as jasmine.SpyObj<PluginService>;
|
||||
ratePromptService = TestBed.inject(
|
||||
RatePromptService,
|
||||
) as jasmine.SpyObj<RatePromptService>;
|
||||
});
|
||||
|
||||
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) => {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
// Initialize plugin system
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -76,6 +76,9 @@
|
|||
<small>{{ T.F.D_RATE.FEEDBACK_PRIVATE_DESC | translate }}</small>
|
||||
</span>
|
||||
</a>
|
||||
<!-- Fallback: if no mail client is registered the mailto: link above does
|
||||
nothing, so show the address as selectable text (never a dead end). -->
|
||||
<small class="feedback-email">{{ maintainerEmail }}</small>
|
||||
<a
|
||||
class="action-item"
|
||||
[href]="contributingUrl"
|
||||
|
|
|
|||
|
|
@ -17,6 +17,15 @@ mat-dialog-content {
|
|||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
// Selectable email fallback shown under the email option (mailto: dead-end guard).
|
||||
.feedback-email {
|
||||
align-self: center;
|
||||
margin-top: -2px;
|
||||
opacity: 0.65;
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatDialogRef } from '@angular/material/dialog';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
import { DialogPleaseRateComponent } from './dialog-please-rate.component';
|
||||
import { RateDialogResult } from './rate-dialog-state';
|
||||
|
||||
describe('DialogPleaseRateComponent', () => {
|
||||
let component: DialogPleaseRateComponent;
|
||||
let fixture: ComponentFixture<DialogPleaseRateComponent>;
|
||||
let mockDialogRef: jasmine.SpyObj<
|
||||
MatDialogRef<DialogPleaseRateComponent, RateDialogResult>
|
||||
>;
|
||||
|
||||
// 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<string, () => 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<DialogPleaseRateComponent, RateDialogResult>>(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');
|
||||
|
|
|
|||
|
|
@ -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> = {}): 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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
236
src/app/features/dialog-please-rate/rate-prompt.service.spec.ts
Normal file
236
src/app/features/dialog-please-rate/rate-prompt.service.spec.ts
Normal file
|
|
@ -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<MatDialog>;
|
||||
let bannerService: jasmine.SpyObj<BannerService>;
|
||||
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'),
|
||||
);
|
||||
}));
|
||||
});
|
||||
});
|
||||
198
src/app/features/dialog-please-rate/rate-prompt.service.ts
Normal file
198
src/app/features/dialog-please-rate/rate-prompt.service.ts
Normal file
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void>;
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { registerPlugin } from '@capacitor/core';
|
||||
import type { StoreReviewPlugin } from './definitions';
|
||||
|
||||
const StoreReview = registerPlugin<StoreReviewPlugin>('StoreReview', {
|
||||
web: () => import('./web').then((m) => new m.StoreReviewWeb()),
|
||||
});
|
||||
|
||||
export * from './definitions';
|
||||
export { StoreReview };
|
||||
9
src/app/features/dialog-please-rate/store-review/web.ts
Normal file
9
src/app/features/dialog-please-rate/store-review/web.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { WebPlugin } from '@capacitor/core';
|
||||
import type { StoreReviewPlugin } from './definitions';
|
||||
|
||||
export class StoreReviewWeb extends WebPlugin implements StoreReviewPlugin {
|
||||
async requestReview(): Promise<void> {
|
||||
// No native review UI on web/electron. The caller only invokes this on iOS
|
||||
// native; this stub keeps the plugin contract satisfied everywhere else.
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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": "كيفية: تكوين المزامنة"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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": "نحوه انجام: همگام سازی را پیکربندی کنید"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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": "ハウツー同期の設定"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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": "방법: 동기화 구성"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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": "Как настроить синхронизацию"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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": "Як налаштувати синхронізацію"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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ộ"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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": "如何:設定同步"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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": "如何:配置同步"
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue