[feedback] IJPL-177421 Permanent CSAT survey in IDEs

Show next CSAT date via internal action

(cherry picked from commit 5239a1bb889eb72c03880c0e4eaaf4bf2291efd1)


(cherry picked from commit 52deaf5498f65c4d613cbef89ef972cc453105cd)

IJ-MR-155667

GitOrigin-RevId: fc385859e5bc773bbe07c4518937b82815d8d2d9
This commit is contained in:
Yuriy Artamonov
2025-02-12 12:38:08 +01:00
committed by intellij-monorepo-bot
parent f0b1bb4c47
commit 0c96b4504c
7 changed files with 116 additions and 38 deletions

View File

@@ -72,5 +72,7 @@
<action class="com.intellij.platform.feedback.demo.ShowInIdeDemoFeedbackWithStatsAction" internal="true"/>
<action class="com.intellij.platform.feedback.csat.CsatFeedbackAction" internal="true"
text="Show CSAT Feedback Dialog"/>
<action class="com.intellij.platform.feedback.csat.CsatFeedbackNextDayAction" internal="true"
text="Show Next Date for CSAT Feedback Dialog"/>
</actions>
</idea-plugin>

View File

@@ -24,6 +24,7 @@ dialog.feedback.system.info.panel.disabled.plugins=Disabled bundled plugins:
dialog.feedback.system.info.panel.disabled.plugins.empty=None
dialog.feedback.system.info.panel.nonbundled.plugins=Non-bundled plugins:
dialog.feedback.system.info.panel.nonbundled.plugins.empty=None
dialog.feedback.system.info.panel.remote.dev.host=Remote development host:
dialog.feedback.ok.label=Send Feedback
dialog.feedback.cancel.label=Cancel

View File

@@ -1,8 +1,11 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.platform.feedback.csat
import com.intellij.notification.NotificationGroupManager
import com.intellij.notification.NotificationType
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import java.time.format.DateTimeFormatter
internal class CsatFeedbackAction : AnAction() {
override fun actionPerformed(e: AnActionEvent) {
@@ -11,4 +14,22 @@ internal class CsatFeedbackAction : AnAction() {
CsatFeedbackSurvey().showNotification(project, false)
}
}
}
internal class CsatFeedbackNextDayAction : AnAction() {
@Suppress("HardCodedStringLiteral")
override fun actionPerformed(e: AnActionEvent) {
val project = e.project
if (project != null) {
val nextDate = getNextCsatDay()
NotificationGroupManager.getInstance().getNotificationGroup("System Messages")
.createNotification(
"Next CSAT feedback day is " + nextDate.date.format(DateTimeFormatter.ISO_DATE) +
"User is${if (!nextDate.isNewUser) " not " else " "}new.",
NotificationType.INFORMATION
)
.notify(project)
}
}
}

View File

@@ -12,10 +12,12 @@ import com.intellij.platform.feedback.dialog.uiBlocks.FeedbackBlock
import com.intellij.platform.feedback.dialog.uiBlocks.SegmentedButtonBlock
import com.intellij.platform.feedback.dialog.uiBlocks.TextAreaBlock
import com.intellij.platform.feedback.dialog.uiBlocks.TopLabelBlock
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.encodeToJsonElement
@Serializable
internal data class CsatFeedbackSystemData(
val isNewUser: Boolean,
val systemInfo: CommonFeedbackSystemData

View File

@@ -6,7 +6,6 @@ import com.intellij.internal.statistic.eventLog.fus.MachineIdManager
import com.intellij.openapi.application.ApplicationInfo
import com.intellij.openapi.application.ConfigImportHelper
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.diagnostic.fileLogger
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.registry.Registry
import com.intellij.platform.feedback.FeedbackSurvey
@@ -18,6 +17,7 @@ import com.intellij.platform.feedback.impl.notification.RequestFeedbackNotificat
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.time.temporal.ChronoUnit
import kotlin.math.abs
@@ -25,6 +25,7 @@ internal const val USER_CONSIDERED_NEW_DAYS = 30
internal const val NEW_USER_SURVEY_PERIOD = 29
internal const val EXISTING_USER_SURVEY_PERIOD = 97
internal const val CSAT_SURVEY_LAST_FEEDBACK_DATE_KEY = "csat.survey.last.feedback.date"
internal const val CSAT_SURVEY_LAST_NOTIFICATION_DATE_KEY = "csat.survey.last.notification.date"
internal class CsatFeedbackSurvey : FeedbackSurvey() {
override val feedbackSurveyType: InIdeFeedbackSurveyType<InIdeFeedbackSurveyConfig> =
@@ -38,6 +39,7 @@ internal class CsatFeedbackSurveyConfig : InIdeFeedbackSurveyConfig {
override val surveyId: String = "csat_feedback"
override val lastDayOfFeedbackCollection: LocalDate = LocalDate(2050, Month.JANUARY, 1)
override val requireIdeEAP: Boolean = false
override val isIndefinite: Boolean = true
override fun checkIdeIsSuitable(): Boolean = Registry.`is`("csat.survey.enabled")
@@ -50,13 +52,25 @@ internal class CsatFeedbackSurveyConfig : InIdeFeedbackSurveyConfig {
}
override fun checkExtraConditionSatisfied(project: Project): Boolean {
if (ConfigImportHelper.isFirstSession()) return false
if (ConfigImportHelper.isFirstSession()) {
LOG.debug("It's a first user session, skip the survey")
return false
}
val today = getCsatToday()
LOG.debug("Today is ${today.format(DateTimeFormatter.ISO_LOCAL_DATE)}")
PropertiesComponent.getInstance().getValue(CSAT_SURVEY_LAST_NOTIFICATION_DATE_KEY)
?.let { tryParseDate(it) }
?.let {
if (it.isEqual(today)) {
LOG.debug("Already notified today, skip the survey")
return false
}
}
val lastFeedbackDate = PropertiesComponent.getInstance().getValue(CSAT_SURVEY_LAST_FEEDBACK_DATE_KEY)
?.let { java.time.LocalDate.parse(it, DateTimeFormatter.ISO_LOCAL_DATE) }
?.let { tryParseDate(it) }
if (lastFeedbackDate != null && lastFeedbackDate.plusDays(EXISTING_USER_SURVEY_PERIOD.toLong()).isAfter(today)) {
LOG.debug("User recently filled the survey, vacation period is in progress")
return false
@@ -70,10 +84,9 @@ internal class CsatFeedbackSurveyConfig : InIdeFeedbackSurveyConfig {
LOG.debug("User is a new user")
}
val surveyPeriod = if (isNewUser) NEW_USER_SURVEY_PERIOD else EXISTING_USER_SURVEY_PERIOD
val productHash = abs((ApplicationInfo.getInstance().versionName + MachineIdManager.getAnonymizedMachineId("CSAT Survey")).hashCode()) % surveyPeriod
val daysHash = abs(ChronoUnit.DAYS.between(java.time.LocalDate.of(1970, 1, 1), today).toInt()) % surveyPeriod
val surveyPeriod = getSurveyPeriod(isNewUser)
val productHash = getProductHash(surveyPeriod)
val daysHash = getDaysHash(today, surveyPeriod)
if (productHash != daysHash) {
LOG.debug("Periods do not match: $productHash / $daysHash, is not yet suitable date for the survey")
@@ -97,19 +110,54 @@ internal class CsatFeedbackSurveyConfig : InIdeFeedbackSurveyConfig {
}
override fun updateStateAfterNotificationShowed(project: Project) {
PropertiesComponent.getInstance().setValue(CSAT_SURVEY_LAST_NOTIFICATION_DATE_KEY,
getCsatToday().format(DateTimeFormatter.ISO_LOCAL_DATE))
}
}
private fun getDaysHash(today: java.time.LocalDate, surveyPeriod: Int): Int {
return abs(ChronoUnit.DAYS.between(java.time.LocalDate.of(1970, 1, 1), today).toInt()) % surveyPeriod
}
private fun getProductHash(surveyPeriod: Int): Int {
return abs((ApplicationInfo.getInstance().versionName + MachineIdManager.getAnonymizedMachineId("CSAT Survey")).hashCode()) % surveyPeriod
}
internal data class NextDate(
val isNewUser: Boolean,
val date: java.time.LocalDate
)
internal fun getNextCsatDay(): NextDate {
val today = getCsatToday()
val userCreatedDate = getCsatUserCreatedDate()
val isNewUser = userCreatedDate?.let { isNewUser(today, userCreatedDate) } ?: false
val surveyPeriod = getSurveyPeriod(isNewUser)
for (i in 0..364) {
val date = today.plusDays(i.toLong())
val productHash = getProductHash(surveyPeriod)
val daysHash = getDaysHash(date, surveyPeriod)
if (productHash == daysHash) {
return NextDate(isNewUser, date)
}
}
throw IllegalStateException("No suitable date found")
}
private fun getSurveyPeriod(isNewUser: Boolean): Int {
return if (isNewUser) NEW_USER_SURVEY_PERIOD else EXISTING_USER_SURVEY_PERIOD
}
internal fun getCsatToday(): java.time.LocalDate {
try {
Registry.stringValue("csat.survey.today")
.takeIf { it.isNotBlank() }
?.let { java.time.LocalDate.parse(it, DateTimeFormatter.ISO_LOCAL_DATE) }
?.let { return it }
}
catch (e: Exception) {
fileLogger().error(e)
}
Registry.stringValue("csat.survey.today")
.takeIf { it.isNotBlank() }
?.let { tryParseDate(it) }
?.let { return it }
return java.time.LocalDate.now()
}
@@ -156,4 +204,13 @@ internal fun flipACoin(productCode: String, newUser: Boolean): Boolean {
internal fun isNewUser(today: java.time.LocalDate, userCreatedDate: java.time.LocalDate): Boolean {
return today.isBefore(userCreatedDate.plusDays(USER_CONSIDERED_NEW_DAYS.toLong()))
}
internal fun tryParseDate(it: String): java.time.LocalDate? {
return try {
java.time.LocalDate.parse(it, DateTimeFormatter.ISO_LOCAL_DATE)
}
catch (_: DateTimeParseException) {
return null
}
}

View File

@@ -7,13 +7,10 @@ import com.intellij.openapi.project.Project
import com.intellij.openapi.startup.ProjectActivity
import com.intellij.openapi.util.registry.Registry
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.time.format.DateTimeFormatter.ISO_LOCAL_DATE
internal const val CSAT_NEW_USER_CREATED_AT_PROPERTY = "csat.user.created.at"
internal val CSAT_TS_FORMAT = DateTimeFormatter.ISO_LOCAL_DATE
internal class CsatNewUserTracker : ProjectActivity {
override suspend fun execute(project: Project) {
if (ConfigImportHelper.isNewUser()) {
@@ -21,7 +18,7 @@ internal class CsatNewUserTracker : ProjectActivity {
if (!propertiesComponent.isValueSet(CSAT_NEW_USER_CREATED_AT_PROPERTY)) {
propertiesComponent.setValue(CSAT_NEW_USER_CREATED_AT_PROPERTY,
LocalDate.now().format(CSAT_TS_FORMAT))
LocalDate.now().format(ISO_LOCAL_DATE))
}
}
}
@@ -30,19 +27,9 @@ internal class CsatNewUserTracker : ProjectActivity {
internal fun getCsatUserCreatedDate(): LocalDate? {
val mocked = Registry.stringValue("csat.survey.user.created.date")
if (!mocked.isBlank()) {
return try {
LocalDate.parse(mocked, CSAT_TS_FORMAT)
}
catch (_: DateTimeParseException) {
null
}
return tryParseDate(mocked)
}
val date = PropertiesComponent.getInstance().getValue(CSAT_NEW_USER_CREATED_AT_PROPERTY) ?: return null
return try {
LocalDate.parse(date, CSAT_TS_FORMAT)
}
catch (_: DateTimeParseException) {
null
}
return tryParseDate(date)
}

View File

@@ -4,6 +4,7 @@ package com.intellij.platform.feedback.dialog
import com.intellij.ide.nls.NlsMessages
import com.intellij.ide.plugins.IdeaPluginDescriptor
import com.intellij.ide.plugins.PluginManagerCore
import com.intellij.idea.AppMode
import com.intellij.internal.statistic.utils.getPluginInfoById
import com.intellij.internal.statistic.utils.platformPlugin
import com.intellij.openapi.application.ApplicationInfo
@@ -21,11 +22,12 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.encodeToJsonElement
import org.jetbrains.annotations.Nls
import java.text.SimpleDateFormat
import java.util.*
/** This number should be increased when [CommonFeedbackSystemData] fields changing */
const val COMMON_FEEDBACK_SYSTEM_INFO_VERSION = 2
const val COMMON_FEEDBACK_SYSTEM_INFO_VERSION: Int = 3
@Serializable
data class CommonFeedbackSystemData(
@@ -39,7 +41,8 @@ data class CommonFeedbackSystemData(
private val isInternalModeEnabled: Boolean,
private val registry: List<String>,
private val disabledBundledPlugins: List<String>,
private val nonBundledPlugins: List<String>
private val nonBundledPlugins: List<String>,
private val isRemoteDevelopmentHost: Boolean,
) : SystemDataJsonSerializable {
companion object {
fun getCurrentData(): CommonFeedbackSystemData {
@@ -54,7 +57,8 @@ data class CommonFeedbackSystemData(
getIsInternalMode(),
getRegistryKeys(),
getDisabledPlugins(),
getNonBundledPlugins()
getNonBundledPlugins(),
AppMode.isRemoteDevHost()
)
}
@@ -120,8 +124,10 @@ data class CommonFeedbackSystemData(
.toList()
}
fun getMemorySizeForDialog() = memorySize.toString() + "M"
fun getLicenseRestrictionsForDialog() = if (licenseRestrictions.isEmpty())
fun getMemorySizeForDialog(): String = memorySize.toString() + "M"
@Suppress("HardCodedStringLiteral")
fun getLicenseRestrictionsForDialog(): @Nls String = if (licenseRestrictions.isEmpty())
CommonFeedbackBundle.message("dialog.feedback.system.info.panel.license.no.info")
else
licenseRestrictions.joinToString("\n")
@@ -201,6 +207,8 @@ data class CommonFeedbackSystemData(
appendLine(getDisabledBundledPluginsForDialog())
appendLine(CommonFeedbackBundle.message("dialog.feedback.system.info.panel.nonbundled.plugins"))
appendLine(getNonBundledPluginsForDialog())
appendLine(CommonFeedbackBundle.message("dialog.feedback.system.info.panel.remote.dev.host"))
appendLine(isRemoteDevelopmentHost.toString())
}
}
}