Skip to content
Merged
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
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# supported values: 252, 253, 261
environmentName=261

pluginVersion=2026.13
pluginVersion=2026.14

# type of IDE (IDEA, CLion, etc.) used to build/test running
# for more details see `Different IDEs` section in `PlatformVersions.md`
Expand Down
4 changes: 0 additions & 4 deletions intellij-plugin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -185,10 +185,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,168 @@
package org.hyperskill.academy.socialMedia

import com.intellij.openapi.project.Project
import com.intellij.ui.scale.JBUIScale
import com.intellij.util.ui.JBUI
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.awt.Component
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.RenderingHints
import java.awt.image.BufferedImage
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import javax.imageio.ImageIO
import javax.swing.Icon
import kotlin.math.roundToInt

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 includes the "Learn more at ..." part (the same text shown in the dialog),
* and the tracked [SHARE_URL] is additionally passed via the `url` parameter.
* https://twitter.com/intent/tweet?text=...&url=...
*/
fun buildXShareUrl(solvedTask: Task): String {
val text = getDisplayMessage(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)}"
}

/**
* Loads the full-resolution banner and returns a DPI-aware icon that downscales it to the exact device size
* at paint time. This keeps the source pixels intact (no pre-baked downscale) so it stays crisp at any DPI.
*/
fun loadAchievementImage(): Icon? {
val source = runCatching {
SocialMediaUtils::class.java.getResourceAsStream(ACHIEVEMENT_IMAGE_PATH)?.use { ImageIO.read(it) }
}.getOrNull() ?: return null
return ScaledBannerIcon(source, JBUI.scale(BANNER_WIDTH))
}

private fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20")

// Logical width of the banner in the dialog (independent of monitor DPI; user-scaled via JBUI.scale)
private const val BANNER_WIDTH = 600
}

/**
* An [Icon] that holds the full-resolution banner and downscales it to the exact device-pixel size on paint,
* using a high-quality multi-step scale. The result is cached per device size, so repaints are cheap.
*/
private class ScaledBannerIcon(private val source: BufferedImage, private val logicalWidth: Int) : Icon {

private val logicalHeight: Int = (logicalWidth.toDouble() * source.height / source.width).roundToInt()

private var cached: BufferedImage? = null
private var cachedWidth = -1
private var cachedHeight = -1

override fun getIconWidth(): Int = logicalWidth
override fun getIconHeight(): Int = logicalHeight

override fun paintIcon(c: Component?, g: Graphics, x: Int, y: Int) {
val g2 = g.create() as Graphics2D
try {
// The monitor (system) scale lives in the graphics transform, so compute the real device size to scale to
val sysScale = JBUIScale.sysScale(g2)
val deviceWidth = maxOf(1, (logicalWidth * sysScale).roundToInt())
val deviceHeight = maxOf(1, (logicalHeight * sysScale).roundToInt())
val image = deviceImage(deviceWidth, deviceHeight)
// The pre-scaled image already matches the device size, so this blit is effectively 1:1
g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC)
g2.drawImage(image, x, y, logicalWidth, logicalHeight, c)
}
finally {
g2.dispose()
}
}

private fun deviceImage(width: Int, height: Int): BufferedImage {
cached?.let { if (cachedWidth == width && cachedHeight == height) return it }
return multiStepScale(source, width, height).also {
cached = it
cachedWidth = width
cachedHeight = height
}
}
}

/** High-quality downscale by progressive halving (much sharper than a single big bicubic step). */
private fun multiStepScale(source: BufferedImage, targetWidth: Int, targetHeight: Int): BufferedImage {
var width = source.width
var height = source.height
var current = source
while (width / 2 >= targetWidth && height / 2 >= targetHeight) {
width /= 2
height /= 2
current = scaleStep(current, width, height)
}
return scaleStep(current, targetWidth, targetHeight)
}

private fun scaleStep(source: BufferedImage, width: Int, height: Int): BufferedImage {
val result = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
val g = result.createGraphics()
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC)
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY)
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g.setRenderingHint(RenderingHints.KEY_COLOR_RENDERING, RenderingHints.VALUE_COLOR_RENDER_QUALITY)
g.drawImage(source, 0, 0, width, height, null)
g.dispose()
return result
}
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()
}
}
Loading
Loading