Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions intellij-plugin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -167,10 +167,6 @@ tasks {
}

withType<PrepareSandboxTask> {
from("socialMedia") {
into("${projectName.get()}/socialMedia")
include("**/*.gif")
}
doLast {
val kotlinJarRe = """kotlin-(stdlib|reflect|runtime).*\.jar""".toRegex()
val libraryDir = destinationDir.resolve("${projectName.get()}/lib")
Expand Down
2 changes: 2 additions & 0 deletions intellij-plugin/hs-core/resources/META-INF/Hyperskill.xml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@

<extensions defaultExtensionNs="HyperskillEducational">
<optionsProvider instance="org.hyperskill.academy.learning.stepik.hyperskill.settings.HyperskillOptions"/>
<optionsProvider instance="org.hyperskill.academy.socialMedia.SocialMediaOptionsProvider"/>
<checkListener implementation="org.hyperskill.academy.learning.stepik.hyperskill.checker.HyperskillCheckListener"/>
<checkListener implementation="org.hyperskill.academy.socialMedia.SuggestToPostOnProjectCompletionListener"/>
<remoteTaskChecker implementation="org.hyperskill.academy.learning.stepik.hyperskill.checker.HyperskillRemoteTaskChecker"/>
<submissionsProvider implementation="org.hyperskill.academy.learning.stepik.hyperskill.HyperskillSubmissionsProvider"/>
</extensions>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -619,3 +619,16 @@ yaml.editor.notification.parameter.is.empty={0} is empty
# {0} for course YAML version, {1} for supported YAML version
yaml.version.compatibility.title=Course Created with Newer Plugin Version
yaml.version.compatibility.message=This course was created with a newer version of the plugin (YAML version {0}). The current plugin supports YAML version {1}. The course will be loaded in compatibility mode, and the YAML version will be automatically downgraded. Some features from the newer plugin version may not be available.

# Suggest to share achievement on project completion
social.media.suggest.to.post.dialog.title=Congratulations!
social.media.share.on.x.button.text=Share on X
social.media.share.on.linkedin.button.text=Share on LinkedIn
social.media.close.button.text=Close
social.media.do.not.ask.dialog.checkbox=Don't ask again
# {0} for the project name
social.media.hyperskill.achievement.message=#HyperskillAchievement unlocked! I''ve just programmed the "{0}" application on Hyperskill!
# {0} for the link (a bare domain in the dialog, a full tracked URL in the LinkedIn share text)
social.media.learn.more.at=Learn more at {0}
social.media.settings.prompt.to.share=Suggest sharing achievements after completing a project
social.media.settings.display.name=Social Media
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package org.hyperskill.academy.socialMedia

import com.intellij.ui.components.JBCheckBox
import com.intellij.ui.dsl.builder.panel
import org.hyperskill.academy.learning.messages.EduCoreBundle
import org.hyperskill.academy.learning.settings.OptionsProvider
import javax.swing.JComponent

/**
* Settings checkbox that lets the user re-enable the "share your achievement" suggestion
* after it was turned off via the "Don't ask again" checkbox.
*/
class SocialMediaOptionsProvider : OptionsProvider {

private val askToPostCheckBox = JBCheckBox(EduCoreBundle.message("social.media.settings.prompt.to.share"))

override fun createComponent(): JComponent = panel {
row {
cell(askToPostCheckBox)
}
}

override fun isModified(): Boolean = askToPostCheckBox.isSelected != SocialMediaSettings.getInstance().askToPost

override fun apply() {
SocialMediaSettings.getInstance().askToPost = askToPostCheckBox.isSelected
}

override fun reset() {
askToPostCheckBox.isSelected = SocialMediaSettings.getInstance().askToPost
}

override fun getDisplayName(): String = EduCoreBundle.message("social.media.settings.display.name")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package org.hyperskill.academy.socialMedia

import com.intellij.openapi.components.BaseState
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.SimplePersistentStateComponent
import com.intellij.openapi.components.State
import com.intellij.openapi.components.Storage
import com.intellij.openapi.components.service
import org.hyperskill.academy.learning.EduTestAware
import org.hyperskill.academy.learning.isUnitTestMode

/**
* Stores the user preference whether to show the "share your achievement" dialog
* on Hyperskill project completion. Disabled permanently once the user checks "Don't ask again".
*/
@Service(Service.Level.APP)
@State(name = "HyperskillSocialMediaSettings", storages = [Storage("hyperskill.xml")])
class SocialMediaSettings : SimplePersistentStateComponent<SocialMediaSettings.SocialMediaState>(SocialMediaState()), EduTestAware {

// Don't use property delegation like `var askToPost by state::askToPost`.
// It doesn't work because `state` may change but delegation keeps the initial state object
var askToPost: Boolean
get() = state.askToPost
set(value) {
state.askToPost = value
}

override fun cleanUpState() {
askToPost = !isUnitTestMode
}

companion object {
fun getInstance(): SocialMediaSettings = service()
}

class SocialMediaState : BaseState() {
var askToPost: Boolean by property(!isUnitTestMode)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package org.hyperskill.academy.socialMedia

import com.intellij.openapi.project.Project
import com.intellij.openapi.util.IconLoader
import org.hyperskill.academy.learning.course
import org.hyperskill.academy.learning.courseFormat.CheckStatus
import org.hyperskill.academy.learning.courseFormat.hyperskill.HyperskillCourse
import org.hyperskill.academy.learning.courseFormat.tasks.Task
import org.hyperskill.academy.learning.messages.EduCoreBundle
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import javax.swing.Icon

object SocialMediaUtils {

/**
* Hyperskill courses page the achievement points to. UTM tags are part of the agreed link and must be kept as is.
* Used both as the `url` parameter of the X share intent and embedded into the LinkedIn share text.
*/
const val SHARE_URL: String =
"https://hyperskill.org/courses?source=ide_share&utm_source=jetbrains&utm_medium=social&utm_campaign=ide_plugin"

// Human-friendly link shown in the dialog body (without tracking parameters)
private const val COURSES_DISPLAY_LINK: String = "hyperskill.org/courses"

private const val ACHIEVEMENT_IMAGE_PATH = "/socialMedia/hyperskill/project_complete.png"

/**
* Defines the policy when the user is suggested to share the achievement.
* The dialog is shown only once the whole Hyperskill project (the project lesson) is solved
* and only right after the task transitions to the solved state (not on re-solving an already solved task).
*/
fun shouldSuggestToPost(project: Project, solvedTask: Task, statusBeforeCheck: CheckStatus): Boolean {
val course = project.course as? HyperskillCourse ?: return false
if (!course.isStudy) return false
if (statusBeforeCheck == CheckStatus.Solved) return false

val projectLesson = course.getProjectLesson() ?: return false
if (solvedTask.lesson != projectLesson) return false

var allProjectTasksSolved = true
projectLesson.visitTasks {
allProjectTasksSolved = allProjectTasksSolved && it.status == CheckStatus.Solved
}
return allProjectTasksSolved
}

/** The achievement sentence without any link. Shown as the base of the dialog message and used as the X post text. */
private fun achievementCore(solvedTask: Task): String {
val course = solvedTask.course
val projectName = (course as? HyperskillCourse)?.getProjectLesson()?.presentableName ?: course.presentableName
return EduCoreBundle.message("social.media.hyperskill.achievement.message", projectName)
}

/** Text shown (and selectable) in the dialog body: the achievement plus the human-friendly courses link. */
fun getDisplayMessage(solvedTask: Task): String =
"${achievementCore(solvedTask)} ${EduCoreBundle.message("social.media.learn.more.at", COURSES_DISPLAY_LINK)}"

/**
* X share intent. The post text intentionally has no link because X renders the [SHARE_URL] from the `url` parameter.
* https://twitter.com/intent/tweet?text=...&url=...
*/
fun buildXShareUrl(solvedTask: Task): String {
val text = achievementCore(solvedTask)
return "https://twitter.com/intent/tweet?text=${encode(text)}&url=${encode(SHARE_URL)}"
}

/**
* LinkedIn share intent. LinkedIn has no separate `url` parameter, so the tracked [SHARE_URL] is embedded into the text.
* https://www.linkedin.com/feed/?shareActive=true&text=...
*/
fun buildLinkedInShareUrl(solvedTask: Task): String {
val text = "${achievementCore(solvedTask)} ${EduCoreBundle.message("social.media.learn.more.at", SHARE_URL)}"
return "https://www.linkedin.com/feed/?shareActive=true&text=${encode(text)}"
}

fun loadAchievementImage(): Icon? = IconLoader.findIcon(ACHIEVEMENT_IMAGE_PATH, SocialMediaUtils::class.java.classLoader)

private fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package org.hyperskill.academy.socialMedia

import com.intellij.openapi.project.Project
import org.hyperskill.academy.learning.checker.CheckListener
import org.hyperskill.academy.learning.courseFormat.CheckResult
import org.hyperskill.academy.learning.courseFormat.CheckStatus
import org.hyperskill.academy.learning.courseFormat.tasks.Task
import org.hyperskill.academy.socialMedia.suggestToPostDialog.createSuggestToPostDialogUI

/**
* Suggests sharing the achievement once a Hyperskill project is fully solved.
* See [SocialMediaUtils.shouldSuggestToPost] for the exact policy.
*/
class SuggestToPostOnProjectCompletionListener : CheckListener {

private var statusBeforeCheck: CheckStatus? = null

override fun beforeCheck(project: Project, task: Task) {
statusBeforeCheck = task.status
}

override fun afterCheck(project: Project, task: Task, result: CheckResult) {
val statusBefore = statusBeforeCheck ?: return
statusBeforeCheck = null

if (!SocialMediaSettings.getInstance().askToPost) return
if (!SocialMediaUtils.shouldSuggestToPost(project, task, statusBefore)) return

createSuggestToPostDialogUI(
project,
SocialMediaUtils.getDisplayMessage(task),
SocialMediaUtils.buildXShareUrl(task),
SocialMediaUtils.buildLinkedInShareUrl(task)
).showAndGet()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package org.hyperskill.academy.socialMedia.suggestToPostDialog

import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.DialogWrapper
import com.intellij.openapi.ui.DoNotAskOption
import org.hyperskill.academy.learning.EduBrowser
import org.hyperskill.academy.learning.messages.EduCoreBundle
import org.hyperskill.academy.socialMedia.SocialMediaSettings
import org.hyperskill.academy.socialMedia.SocialMediaUtils
import java.awt.event.ActionEvent
import javax.swing.AbstractAction
import javax.swing.Action
import javax.swing.JComponent

/**
* Modal dialog suggesting to share the Hyperskill project achievement.
* Provides "Share on X" and "Share on LinkedIn" buttons that open the corresponding web share intents,
* a "Close" button, and a "Don't ask again" checkbox that disables the suggestion via [SocialMediaSettings].
*/
class SuggestToPostDialog(
project: Project,
message: String,
private val xShareUrl: String,
private val linkedInShareUrl: String
) : SuggestToPostDialogUI, DialogWrapper(project) {

private val panel = SuggestToPostDialogPanel(message, SocialMediaUtils.loadAchievementImage())

init {
title = EduCoreBundle.message("social.media.suggest.to.post.dialog.title")
setCancelButtonText(EduCoreBundle.message("social.media.close.button.text"))
isResizable = false
installDoNotAskOption()
init()
}

// `setDoNotAskOption` is the standard way to add a "do not ask again" checkbox to a custom `DialogWrapper`.
// It is deprecated in the platform but kept intentionally as it works across all supported versions.
@Suppress("DEPRECATION")
private fun installDoNotAskOption() {
setDoNotAskOption(DoNotAskToSuggestOption())
}

override fun createCenterPanel(): JComponent = panel

// Share buttons open a browser but keep the dialog open so the user can share to both networks
override fun createActions(): Array<Action> = arrayOf(
ShareAction(EduCoreBundle.message("social.media.share.on.x.button.text"), xShareUrl),
ShareAction(EduCoreBundle.message("social.media.share.on.linkedin.button.text"), linkedInShareUrl),
cancelAction
)

private class ShareAction(name: String, private val url: String) : AbstractAction(name) {
override fun actionPerformed(e: ActionEvent?) {
EduBrowser.getInstance().browse(url)
}
}

private class DoNotAskToSuggestOption : DoNotAskOption.Adapter() {
override fun rememberChoice(isSelected: Boolean, exitCode: Int) {
// `isSelected` means "keep showing the dialog next time"; an unchecked box means the user opted out
if (!isSelected) {
SocialMediaSettings.getInstance().askToPost = false
}
}

// Persist the opt-out even if the dialog is closed via the "Close" (cancel) button
override fun shouldSaveOptionsOnCancel(): Boolean = true

override fun getDoNotShowMessage(): String = EduCoreBundle.message("social.media.do.not.ask.dialog.checkbox")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package org.hyperskill.academy.socialMedia.suggestToPostDialog

import com.intellij.openapi.ui.VerticalFlowLayout
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBTextArea
import com.intellij.util.ui.JBUI
import java.awt.Dimension
import javax.swing.Box
import javax.swing.Icon
import javax.swing.JPanel

/**
* Center panel of the [SuggestToPostDialog]: a selectable (so it can be copied) achievement message
* followed by an optional banner image.
*/
class SuggestToPostDialogPanel(message: String, image: Icon?) : JPanel(VerticalFlowLayout(0, 0)) {

init {
border = JBUI.Borders.empty()

val width = image?.iconWidth ?: JBUI.scale(DEFAULT_WIDTH)
add(MessageTextArea(message, width))

if (image != null) {
// Don't use a border for the label because it changes the size of its content
add(Box.createVerticalStrut(JBUI.scale(10)))
add(JBLabel(image))
}
}

/**
* Read-only, word-wrapped text area whose preferred width is pinned to [targetWidth]
* so the dialog wraps the message to the banner width instead of stretching to a single long line.
*/
private class MessageTextArea(text: String, private val targetWidth: Int) : JBTextArea(text) {
init {
lineWrap = true
wrapStyleWord = true
isEditable = false
isOpaque = false
border = JBUI.Borders.empty()
}

override fun getPreferredSize(): Dimension {
// Constrain the width first so the height is computed for the wrapped layout
setSize(targetWidth, Short.MAX_VALUE.toInt())
return Dimension(targetWidth, super.getPreferredSize().height)
}
}

companion object {
private const val DEFAULT_WIDTH = 600
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package org.hyperskill.academy.socialMedia.suggestToPostDialog

import com.intellij.openapi.project.Project
import org.hyperskill.academy.learning.isUnitTestMode
import org.jetbrains.annotations.TestOnly

interface SuggestToPostDialogUI {
/**
* Shows the dialog. Returns `true` only if it was closed via an OK action.
* Sharing happens through in-dialog actions, so callers don't rely on the return value.
*/
fun showAndGet(): Boolean
}

fun createSuggestToPostDialogUI(
project: Project,
message: String,
xShareUrl: String,
linkedInShareUrl: String
): SuggestToPostDialogUI {
return if (isUnitTestMode) {
MOCK ?: error("You should set mock UI via `withMockSuggestToPostDialogUI`")
}
else {
SuggestToPostDialog(project, message, xShareUrl, linkedInShareUrl)
}
}

private var MOCK: SuggestToPostDialogUI? = null

@TestOnly
fun withMockSuggestToPostDialogUI(mockUI: SuggestToPostDialogUI, action: () -> Unit) {
try {
MOCK = mockUI
action()
}
finally {
MOCK = null
}
}
Loading
Loading