Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Unverified Commit cd293b9f authored by Rafael Tonholo's avatar Rafael Tonholo
Browse files

feat(notifications): add in-app notification host state

parent 83c389a6
Loading
Loading
Loading
Loading
+14 −0
Original line number Original line Diff line number Diff line
package net.thunderbird.feature.notification.api.ui.host

import kotlinx.collections.immutable.toPersistentSet

enum class DisplayInAppNotificationFlag {
    BannerGlobalNotifications,
    BannerInlineNotifications,
    SnackbarNotifications,
    ;

    companion object {
        val AllNotifications = DisplayInAppNotificationFlag.entries.toPersistentSet()
    }
}
+141 −0
Original line number Original line Diff line number Diff line
package net.thunderbird.feature.notification.api.ui.host

import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import net.thunderbird.feature.notification.api.content.InAppNotification
import net.thunderbird.feature.notification.api.ui.host.visual.BannerGlobalVisual
import net.thunderbird.feature.notification.api.ui.host.visual.BannerInlineVisual
import net.thunderbird.feature.notification.api.ui.host.visual.InAppNotificationHostState
import net.thunderbird.feature.notification.api.ui.host.visual.InAppNotificationVisual
import net.thunderbird.feature.notification.api.ui.host.visual.SnackbarVisual

@Stable
class InAppNotificationHostStateHolder(private val enabled: ImmutableSet<DisplayInAppNotificationFlag>) {
    private val internalState =
        MutableStateFlow<InAppNotificationHostStateImpl>(value = InAppNotificationHostStateImpl())
    internal val currentInAppNotificationHostState: StateFlow<InAppNotificationHostState> = internalState.asStateFlow()

    fun showInAppNotification(
        notification: InAppNotification,
    ) {
        val newData = notification.toInAppNotificationData()
        // TODO(#9572): If global is already present, show the one with the highest priority
        //              show the previous one back once the higher priority has fixed and the
        //              other wasn't
        internalState.update {
            newData.bannerGlobalVisual.showIfNeeded(
                ifFlagEnabled = DisplayInAppNotificationFlag.BannerGlobalNotifications,
                select = { bannerGlobalVisual },
                transformIfDifferent = { copy(bannerGlobalVisual = it) },
            )
        }
        internalState.update {
            newData.bannerInlineVisuals.showIfNeeded()
        }
        internalState.update {
            newData.snackbarVisual.showIfNeeded(
                ifFlagEnabled = DisplayInAppNotificationFlag.SnackbarNotifications,
                select = { snackbarVisual },
                transformIfDifferent = { copy(snackbarVisual = it) },
            )
        }
    }

    private fun ImmutableSet<BannerInlineVisual>.showIfNeeded(): InAppNotificationHostStateImpl {
        if (!isEnabled(flag = DisplayInAppNotificationFlag.BannerInlineNotifications)) {
            return internalState.value
        }
        val new = this
        val current = internalState.value

        return if (!current.bannerInlineVisuals.containsAll(new)) {
            current.copy(
                bannerInlineVisuals = (current.bannerInlineVisuals + new).toPersistentSet(),
            )
        } else {
            current
        }
    }

    private inline fun <TVisual : InAppNotificationVisual> TVisual?.showIfNeeded(
        ifFlagEnabled: DisplayInAppNotificationFlag,
        select: InAppNotificationHostStateImpl.() -> TVisual?,
        transformIfDifferent: InAppNotificationHostStateImpl.(TVisual) -> InAppNotificationHostStateImpl,
    ): InAppNotificationHostStateImpl {
        if (!isEnabled(ifFlagEnabled)) {
            return internalState.value
        }
        val new = this
        val current = internalState.value
        return if (new != null && new != current.select()) {
            current.transformIfDifferent(new)
        } else {
            current
        }
    }

    /**
     * Dismisses the given in-app notification visual.
     *
     * This function is responsible for removing the specified notification visual
     * from the display.
     *
     * @param visual The [InAppNotificationVisual] to dismiss.
     */
    fun dismiss(visual: InAppNotificationVisual) {
        internalState.update { current ->
            current.copy(
                bannerGlobalVisual = visual.nullIfDifferent(otherwise = current.bannerGlobalVisual),
                bannerInlineVisuals = (visual as? BannerInlineVisual)?.let { bannerInlineVisual ->
                    (current.bannerInlineVisuals - bannerInlineVisual).toPersistentSet()
                } ?: current.bannerInlineVisuals,
                snackbarVisual = visual.nullIfDifferent(otherwise = current.snackbarVisual),
            )
        }
    }

    private inline fun <reified T : InAppNotificationVisual> InAppNotificationVisual?.nullIfDifferent(
        otherwise: T?,
    ): T? {
        val current = (this as? T?) ?: otherwise
        return if (this == current) null else otherwise
    }

    private fun isEnabled(flag: DisplayInAppNotificationFlag): Boolean {
        return enabled.any { it == flag } || enabled == DisplayInAppNotificationFlag.AllNotifications
    }

    private data class InAppNotificationHostStateImpl(
        override val bannerGlobalVisual: BannerGlobalVisual? = null,
        override val bannerInlineVisuals: ImmutableSet<BannerInlineVisual> = persistentSetOf(),
        override val snackbarVisual: SnackbarVisual? = null,
        private val onDismissVisual: (InAppNotificationVisual) -> Unit = {},
    ) : InAppNotificationHostState

    private fun InAppNotification.toInAppNotificationData(): InAppNotificationHostState =
        InAppNotificationHostStateImpl(
            bannerGlobalVisual = BannerGlobalVisual.from(notification = this),
            bannerInlineVisuals = BannerInlineVisual.from(notification = this).toPersistentSet(),
            snackbarVisual = SnackbarVisual.from(notification = this),
        )

    override fun toString(): String {
        return "InAppNotificationHostState(" +
            "enabled=$enabled, currentInAppNotificationData=${currentInAppNotificationHostState.value})"
    }
}

@Composable
fun rememberInAppNotificationHostState(
    enabled: ImmutableSet<DisplayInAppNotificationFlag> = DisplayInAppNotificationFlag.AllNotifications,
): InAppNotificationHostStateHolder {
    return remember { InAppNotificationHostStateHolder(enabled) }
}
+35 −0
Original line number Original line Diff line number Diff line
package net.thunderbird.feature.notification.api.ui.host.visual

import androidx.compose.runtime.Stable
import kotlinx.collections.immutable.ImmutableSet

/**
 * Defines the visual representation of in-app notifications.
 *
 * This interface holds the visual data for different types of in-app notifications
 * that can be displayed to the user. It allows for a structured way to manage
 * and present notification information.
 */
@Stable
internal interface InAppNotificationHostState {
    /**
     * The visual representation of a global banner notification.
     *
     * This property holds a [BannerGlobalVisual] object if a global banner is
     * currently active, or `null` if no global banner is being shown.
     */
    val bannerGlobalVisual: BannerGlobalVisual?

    /**
     * A set of inline banner visuals that are currently active.
     */
    val bannerInlineVisuals: ImmutableSet<BannerInlineVisual>

    /**
     * The visual representation of a snackbar notification.
     *
     * This property holds a [SnackbarVisual] object if a snackbar notification
     * is currently active, or `null` if no snackbar is being displayed.
     */
    val snackbarVisual: SnackbarVisual?
}
+221 −0
Original line number Original line Diff line number Diff line
package net.thunderbird.feature.notification.api.ui.host.visual

import androidx.compose.runtime.Stable
import androidx.compose.ui.util.fastFilter
import kotlin.time.Duration
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import net.thunderbird.feature.notification.api.NotificationSeverity
import net.thunderbird.feature.notification.api.content.InAppNotification
import net.thunderbird.feature.notification.api.ui.action.NotificationAction
import net.thunderbird.feature.notification.api.ui.host.visual.BannerInlineVisual.Companion.MAX_SUPPORTING_TEXT_LENGTH
import net.thunderbird.feature.notification.api.ui.host.visual.BannerInlineVisual.Companion.MAX_TITLE_LENGTH
import net.thunderbird.feature.notification.api.ui.style.InAppNotificationStyle

sealed interface InAppNotificationVisual

/**
 * Represents the visual appearance of a [InAppNotificationStyle.BannerGlobalNotification].
 *
 * @property message The text content to be displayed in the banner global. This can be any CharSequence,
 *   allowing for formatted text.
 * @property severity The [NotificationSeverity] of the notification, indicating its importance or type.
 *   Used to determine which BannerGlobal visual style to use.
 * @property action An optional [NotificationAction] that the user can perform in response to the notification.
 *   If null, no action is available.
 * @property priority An integer representing the priority of the notification. Higher values typically indicate
 *   higher priority. Used to determine which notification to display, in case of multiples
 *   [InAppNotificationStyle.BannerGlobalNotification].
 * @see InAppNotificationVisual
 * @see InAppNotificationStyle.BannerGlobalNotification
 */
@Stable
data class BannerGlobalVisual(
    val message: CharSequence,
    val severity: NotificationSeverity,
    val action: NotificationAction?,
    val priority: Int,
) : InAppNotificationVisual {
    internal companion object {
        /**
         * Creates a [BannerGlobalVisual] from an [InAppNotification].
         *
         * This function attempts to convert an [InAppNotification] into a [BannerGlobalVisual].
         * It expects the notification to have a style of [InAppNotificationStyle.BannerGlobalNotification].
         *
         * It performs the following checks:
         * - The `contentText` of the notification must not be null.
         * - The notification must have zero or one action.
         *
         * If the notification has a matching style and passes all checks, a [BannerGlobalVisual] is created.
         * Otherwise, this function returns `null`.
         *
         * @param notification The [InAppNotification] to convert.
         * @return A [BannerGlobalVisual] if the conversion is successful, `null` otherwise.
         * @throws IllegalStateException fails the check validations.
         */
        fun from(notification: InAppNotification): BannerGlobalVisual? =
            notification.toVisuals<InAppNotificationStyle.BannerGlobalNotification, BannerGlobalVisual> { style ->
                BannerGlobalVisual(
                    message = checkNotNull(notification.contentText) {
                        "A notification with a BannerGlobalNotification style must have a contentText not null"
                    },
                    severity = notification.severity,
                    action = notification
                        .actions
                        .let { actions ->
                            check(actions.size in 0..1) {
                                "A notification with a BannerGlobalNotification style must have at zero or one action"
                            }
                            actions.toPersistentList()
                        }
                        .firstOrNull(),
                    priority = style.priority,
                )
            }.singleOrNull()
    }
}

/**
 * Represents the visual appearance of a [InAppNotificationStyle.BannerInlineNotification].
 *
 * @property title The title of the notification.
 * @property supportingText The main content/message of the notification.
 * @property severity The [NotificationSeverity] of the notification, indicating its importance or type.
 *   Used to determine which BannerGlobal visual style to use.
 * @property actions An immutable list of [NotificationAction] objects representing actions
 *  the user can take in response to the notification.
 * @see InAppNotificationVisual
 * @see InAppNotificationStyle.BannerInlineNotification
 */
@Stable
data class BannerInlineVisual(
    val title: CharSequence,
    val supportingText: CharSequence,
    val severity: NotificationSeverity,
    val actions: ImmutableList<NotificationAction>,
) : InAppNotificationVisual {
    internal companion object {
        internal const val MAX_TITLE_LENGTH = 100
        internal const val MAX_SUPPORTING_TEXT_LENGTH = 200

        /**
         * Creates a [BannerInlineVisual] from an [InAppNotification].
         *
         * This function attempts to convert an [InAppNotification] into a [BannerInlineVisual].
         * It expects the notification to have a style of [InAppNotificationStyle.BannerInlineNotification].
         *
         * It performs the following checks:
         * - The `title` of the notification must have at least 1 and at most [MAX_TITLE_LENGTH] chars.
         * - The `contentText` of the notification must not be null and have at least 1 and at most
         *   [MAX_SUPPORTING_TEXT_LENGTH] chars.
         * - The notification must have 1 or 2 actions.
         *
         * If the notification has a matching style and passes all checks, a [BannerInlineVisual] is created.
         * Otherwise, this function returns an empty list.
         *
         * @param notification The [InAppNotification] to convert.
         * @return A list containing a [BannerInlineVisual] if the conversion is successful, an empty list otherwise.
         * @throws IllegalStateException if any of the validation checks fail.
         */
        fun from(notification: InAppNotification): List<BannerInlineVisual> =
            notification.toVisuals<InAppNotificationStyle.BannerInlineNotification, BannerInlineVisual> { style ->
                BannerInlineVisual(
                    title = checkTitle(notification.title),
                    supportingText = checkContentText(notification.contentText),
                    severity = notification.severity,
                    actions = notification
                        .actions
                        .let { actions ->
                            check(actions.size in 1..2) {
                                "A notification with a BannerInlineNotification style must have at one or two actions"
                            }
                            actions.toPersistentList()
                        },
                )
            }

        private fun checkTitle(title: String): String {
            check(title.length in 1..MAX_TITLE_LENGTH) {
                "A notification with a BannerInlineNotification style must have a title length of 1 to " +
                    "$MAX_TITLE_LENGTH characters."
            }
            return title
        }

        private fun checkContentText(contentText: String?): String {
            checkNotNull(contentText) {
                "A notification with a BannerInlineNotification style must have a contentText not null"
            }
            check(contentText.length in 1..MAX_SUPPORTING_TEXT_LENGTH) {
                "A notification with a BannerInlineNotification style must have a contentText length of 1 to " +
                    "$MAX_SUPPORTING_TEXT_LENGTH characters."
            }
            return contentText
        }
    }
}

/**
 * Represents the visual appearance of a [InAppNotificationStyle.SnackbarNotification].
 *
 * @property message The text message to be displayed in the snackbar.
 * @property action An optional [NotificationAction] that the user can perform. This is typically a
 *   single action like "Undo" or "Dismiss". If null, no action button is shown.
 * @property duration The [Duration] for which the snackbar will be visible.
 * @see InAppNotificationVisual
 * @see InAppNotificationStyle.SnackbarNotification
 */
@Stable
data class SnackbarVisual(
    val message: String,
    val action: NotificationAction?,
    val duration: Duration,
) : InAppNotificationVisual {
    internal companion object {
        /**
         * Creates a [SnackbarVisual] from an [InAppNotification].
         *
         * This function attempts to convert an [InAppNotification] into a [SnackbarVisual].
         * It expects the notification to have a style of [InAppNotificationStyle.SnackbarNotification].
         *
         * It performs the following checks:
         * - The `contentText` of the notification must not be null.
         * - The notification must have exactly one action.
         *
         * If the notification has a matching style and passes all checks, a [SnackbarVisual] is created.
         * Otherwise, this function returns `null`.
         *
         * @param notification The [InAppNotification] to convert.
         * @return A [SnackbarVisual] if the conversion is successful, `null` otherwise.
         * @throws IllegalStateException if `contentText` is null or if the number of actions is not 1
         * when the style is [InAppNotificationStyle.SnackbarNotification].
         */
        fun from(notification: InAppNotification): SnackbarVisual? =
            notification.toVisuals<InAppNotificationStyle.SnackbarNotification, SnackbarVisual> { style ->
                SnackbarVisual(
                    message = checkNotNull(notification.contentText) {
                        "A notification with a SnackbarNotification style must have a contentText not null"
                    },
                    action = checkNotNull(notification.actions.singleOrNull()) {
                        "A notification with a SnackbarNotification style must have exactly one action"
                    },
                    duration = style.duration,
                )
            }.singleOrNull()
    }
}

private inline fun <
    reified TStyle : InAppNotificationStyle,
    reified TVisual : InAppNotificationVisual,
    > InAppNotification.toVisuals(
    transform: (TStyle) -> TVisual,
): List<TVisual> {
    return inAppNotificationStyles
        .fastFilter { style -> style is TStyle }
        .map { style ->
            check(style is TStyle)
            transform(style)
        }
}
+3 −1
Original line number Original line Diff line number Diff line
@@ -27,7 +27,9 @@ sealed interface InAppNotificationStyle {
    /**
    /**
     * @see InAppNotificationStyleBuilder.bannerGlobal
     * @see InAppNotificationStyleBuilder.bannerGlobal
     */
     */
    data object BannerGlobalNotification : InAppNotificationStyle
    data class BannerGlobalNotification(
        val priority: Int,
    ) : InAppNotificationStyle


    /**
    /**
     * @see [InAppNotificationStyleBuilder.snackbar]
     * @see [InAppNotificationStyleBuilder.snackbar]
Loading