feat(canary/ComponentsV2): init @ LavaDesu/Aliucord@8ee06ba700

This commit is contained in:
LavaDesu 2025-07-15 17:53:33 +10:00
parent 78022652d7
commit 02ac3cb652
Signed by: cilly
GPG key ID: 6500251E087653C9
48 changed files with 2223 additions and 6 deletions

View file

@ -0,0 +1,54 @@
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
version = "7.15.0-8ee06ba"
description = "Beta backport of ComponentsV2"
aliucord {
// Changelog of your plugin
changelog.set("""
TODO {fixed}
======================
* File component
* SelectV2: searching
* SelectV2: showing selected items in chat list
Changelog {added marginTop}
======================
# 7.15.0
* Initial release >w<
""".trimIndent())
excludeFromUpdaterJson.set(false)
}
//apply(plugin = "com.gradleup.shadow")
apply(plugin = "com.github.johnrengelman.shadow") // remove when gradle 8
val shadowDir = File(buildDir, "intermediates/shadowed")
tasks.register<ShadowJar>("relocateJar") {
val task = tasks.findByName("compileDebugKotlin")!!
from(task.outputs)
// relocate("com.discord.api.botuikit", "moe.lava.awoocanary.componentsv2.botuikit") {
// exclude("com.discord.api.botuikit.ComponentType")
// }
relocate("com.aliucord.coreplugins.componentsv2", "moe.lava.corenary.componentsv2")
relocate("com.aliucord.coreplugins.ComponentsV2", "moe.lava.corenary.ComponentsV2")
archiveClassifier.set("shadowed")
destinationDirectory.set(File(buildDir, "intermediates"))
}
tasks.register<Sync>("copyShadowed") {
val reloc = tasks.findByName("relocateJar")!! as ShadowJar
dependsOn(reloc)
from(zipTree(reloc.archiveFile))
into(shadowDir)
}
project.afterEvaluate {
tasks.compileDex {
val copyShadowed = tasks.findByName("copyShadowed")!! as Sync
dependsOn(copyShadowed)
input.setFrom(shadowDir)
}
}

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="moe.lava.awoocanary.componentsv2" />

View file

@ -0,0 +1,219 @@
package com.aliucord.coreplugins
import android.content.Context
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.constraintlayout.widget.ConstraintLayout
import com.aliucord.Utils
import com.aliucord.annotations.AliucordPlugin
import com.aliucord.coreplugins.componentsv2.ComponentV2Type
import com.aliucord.coreplugins.componentsv2.models.*
import com.aliucord.coreplugins.componentsv2.patchMessageItems
import com.aliucord.coreplugins.componentsv2.views.*
import com.aliucord.entities.Plugin
import com.aliucord.patcher.*
import com.aliucord.utils.ReflectUtils
import com.discord.api.botuikit.*
import com.discord.api.botuikit.gson.ComponentRuntimeTypeAdapter
import com.discord.api.botuikit.gson.ComponentTypeTypeAdapter
import com.discord.api.message.attachment.MessageAttachment
import com.discord.models.botuikit.*
import com.discord.stores.StoreApplicationInteractions.InteractionSendState
import com.discord.utilities.view.extensions.ViewExtensions
import com.discord.widgets.botuikit.*
import com.discord.widgets.botuikit.ComponentChatListState.ComponentStoreState
import com.discord.widgets.botuikit.views.*
import com.discord.widgets.botuikit.views.select.SelectComponentView
import com.discord.widgets.chat.list.adapter.WidgetChatListAdapter
import com.discord.widgets.chat.list.adapter.WidgetChatListAdapterItemBotComponentRow
import com.discord.widgets.chat.list.entries.BotUiComponentEntry
import com.google.gson.stream.JsonReader
import com.lytefast.flexinput.R
import de.robv.android.xposed.XposedBridge
@AliucordPlugin(requiresRestart = true)
@Suppress("unused")
class ComponentsV2 : Plugin() {
companion object {
/** Creates a new [MessageAttachment] */
fun createAttachment(
filename: String,
filesize: Long,
proxyUrl: String,
url: String,
width: Int,
height: Int,
): MessageAttachment {
val inst = ReflectUtils.allocateInstance(clazz)
filenameField.set(inst, filename)
filesizeField.set(inst, filesize)
proxyUrlField.set(inst, proxyUrl)
urlField.set(inst, url)
widthField.set(inst, width)
heightField.set(inst, height)
return inst
}
private val clazz = MessageAttachment::class.java
private val filenameField = clazz.getDeclaredField("filename").apply { isAccessible = true }
private val filesizeField = clazz.getDeclaredField("size").apply { isAccessible = true }
private val proxyUrlField = clazz.getDeclaredField("proxyUrl").apply { isAccessible = true }
private val urlField = clazz.getDeclaredField("url").apply { isAccessible = true }
private val widthField = clazz.getDeclaredField("width").apply { isAccessible = true }
private val heightField = clazz.getDeclaredField("height").apply { isAccessible = true }
}
override fun start(context: Context) {
XposedBridge.makeClassInheritable(BotUiComponentEntry::class.java)
ComponentV2Type.make()
patchGson()
// https://github.com/LSPosed/LSPlant/issues/41
patchMessageItems(patcher)
patcher.instead<ComponentStateMapper>(
"toMessageLayoutComponent",
LayoutComponent::class.java,
Int::class.javaPrimitiveType!!,
List::class.java,
ComponentExperiments::class.java
) { (_, layout: LayoutComponent, index: Int, components: List<MessageComponent>) ->
when (layout) {
is ActionRowComponent ->
ActionRowMessageComponent(layout.type, index, components)
is SectionComponent ->
SectionMessageComponent.mergeToMessageComponent(layout, index, components)
is TextDisplayComponent ->
TextDisplayMessageComponent.mergeToMessageComponent(layout, index)
is ThumbnailComponent ->
ThumbnailMessageComponent.mergeToMessageComponent(layout, index)
is MediaGalleryComponent ->
MediaGalleryMessageComponent.mergeToMessageComponent(layout, index)
is FileComponent ->
ActionRowMessageComponent(layout.type, index, components)
is SeparatorComponent ->
SeparatorMessageComponent.mergeToMessageComponent(layout, index)
is ContainerComponent ->
ContainerMessageComponent.mergeToMessageComponent(layout, index, components)
else ->
throw IllegalArgumentException("Unknown layout component ${layout::class.java.name} (${layout.type.type}:${layout.type.name})")
}
}
patcher.instead<ComponentProvider>("configureView", ComponentActionListener::class.java, MessageComponent::class.java, ComponentView::class.java)
{ (_, listener: ComponentActionListener, component: MessageComponent, view: ComponentView<MessageComponent>?) ->
view?.configure(component, this, listener)
}
patcher.instead<ComponentInflater>("inflateComponent", ComponentType::class.java, ViewGroup::class.java)
{ (_, type: ComponentType, viewGroup: ViewGroup) ->
when (type) {
ComponentType.ACTION_ROW ->
ActionRowComponentView.Companion!!.inflateComponent(this.context, viewGroup)
ComponentType.BUTTON ->
ButtonComponentView.Companion!!.inflateComponent(this.context, viewGroup)
ComponentType.SELECT ->
SelectComponentView.Companion!!.inflateComponent(this.context, viewGroup)
ComponentV2Type.USER_SELECT,
ComponentV2Type.ROLE_SELECT,
ComponentV2Type.MENTIONABLE_SELECT,
ComponentV2Type.CHANNEL_SELECT ->
SelectV2ComponentView(this.context, type)
ComponentV2Type.SECTION ->
SectionComponentView(this.context)
ComponentV2Type.TEXT_DISPLAY ->
TextDisplayComponentView(this.context)
ComponentV2Type.THUMBNAIL ->
ThumbnailComponentView(this.context)
ComponentV2Type.MEDIA_GALLERY ->
MediaGalleryComponentView(this.context)
ComponentV2Type.FILE ->
null
ComponentV2Type.SEPARATOR ->
SeparatorComponentView(this.context)
ComponentV2Type.CONTAINER ->
ContainerComponentView(this.context)
else -> null
}
}
patcher.after<WidgetChatListAdapterItemBotComponentRow>(WidgetChatListAdapter::class.java)
{
val rootLayout = itemView.findViewById<LinearLayout>(Utils.getResId("chat_list_adapter_item_component_root", "id"))
rootLayout.layoutParams = (rootLayout.layoutParams as ConstraintLayout.LayoutParams).apply {
marginEnd = adapter.context.resources.getDimension(R.d.chat_cell_horizontal_spacing_padding).toInt()
}
ViewExtensions.setOnLongClickListenerConsumeClick(itemView) {
adapter.eventHandler.onMessageLongClicked(entry.message, "", false)
}
itemView.setOnClickListener {
adapter.eventHandler.onMessageClicked(entry.message, false)
}
}
patcher.instead<ComponentStateMapper>(
"createActionMessageComponent",
ActionComponent::class.java,
Int::class.javaPrimitiveType!!,
ComponentStoreState::class.java,
ComponentExperiments::class.java,
) { (
_,
actionComponent: ActionComponent,
index: Int,
componentStoreState: ComponentStoreState,
) ->
val interactionState: Map<Int, InteractionSendState>? = componentStoreState.interactionState;
val num = interactionState?.entries?.find { it.value is InteractionSendState.Loading }?.key
val state = interactionState?.get(index)
val comState: ActionInteractionComponentState = when {
state is InteractionSendState.Failed -> ActionInteractionComponentState.Failed(state.errorMessage)
num == null -> ActionInteractionComponentState.Enabled.INSTANCE
num == index -> ActionInteractionComponentState.Loading.INSTANCE
else -> ActionInteractionComponentState.Disabled.INSTANCE
}
when (actionComponent) {
is ButtonComponent ->
ButtonMessageComponentKt.mergeToMessageComponent(actionComponent, index, comState, componentStoreState)
is SelectComponent ->
SelectMessageComponentKt.mergeToMessageComponent(actionComponent, index, comState, componentStoreState)
is SelectV2Component ->
SelectV2MessageComponent.mergeToMessageComponent(actionComponent, index, comState, componentStoreState)
else -> null
}
}
}
override fun stop(context: Context) {
patcher.unpatchAll()
unpatchGson()
ComponentV2Type.unmake(logger)
}
private fun patchGson() {
val factory = ComponentRuntimeTypeAdapter.INSTANCE.a()
val typeToClass = factory.l
val classToType = factory.m
ComponentV2Type.newValues?.forEach {
typeToClass[it.type.toString()] = it.clazz
classToType[it.clazz] = it.type.toString()
}
patcher.instead<ComponentTypeTypeAdapter>("read", JsonReader::class.java)
{ (_, jsonReader: JsonReader) ->
val type: Int = b.c.a.a0.d.n1(jsonReader)
ComponentType.values().find { it.type == type } ?: ComponentType.UNKNOWN
}
}
private fun unpatchGson() {
val factory = ComponentRuntimeTypeAdapter.INSTANCE.a()
val typeToClass = factory.l
val classToType = factory.m
ComponentV2Type.newValues?.forEach {
typeToClass.remove(it.type.toString())
classToType.remove(it.clazz)
}
}
}

View file

@ -0,0 +1,41 @@
package com.aliucord.coreplugins.componentsv2
import com.discord.api.channel.Channel
import com.discord.api.role.GuildRole
import com.discord.models.botuikit.MessageComponent
import com.discord.models.member.GuildMember
import com.discord.models.message.Message
import com.discord.stores.StoreMessageState
import com.discord.widgets.chat.list.entries.BotUiComponentEntry
@Suppress("EqualsOrHashCode")
class BotUiComponentV2Entry(
message: Message, appId: Long, guildId: Long?, components: MutableList<out MessageComponent>,
private val v2Fields: V2Fields
) : BotUiComponentEntry(message, appId, guildId, components) {
data class V2Fields(
val state: StoreMessageState.State?,
val meId: Long,
val channel: Channel,
val guildMembers: Map<Long, GuildMember>,
val guildRoles: Map<Long, GuildRole>,
// val channelNames: Map<Long, String>,
)
companion object {
fun fromV1(entry: BotUiComponentEntry, fields: V2Fields) =
entry.run { BotUiComponentV2Entry(message, applicationId, guildId, messageComponents, fields) }
}
val state get() = v2Fields.state
val meId get() = v2Fields.meId
val channel get() = v2Fields.channel
val guildMembers get() = v2Fields.guildMembers
val guildRoles get() = v2Fields.guildRoles
override fun equals(other: Any?) =
super.equals(other) && if (other is BotUiComponentV2Entry) this.v2Fields == other.v2Fields else true
override fun toString() =
"AliuV2" + super.toString() + "& " + v2Fields.toString()
}

View file

@ -0,0 +1,63 @@
package com.aliucord.coreplugins.componentsv2
import com.aliucord.Logger
import com.discord.api.botuikit.*
// Values added by smali patch
object ComponentV2Type {
lateinit var USER_SELECT: ComponentType
lateinit var ROLE_SELECT: ComponentType
lateinit var MENTIONABLE_SELECT: ComponentType
lateinit var CHANNEL_SELECT: ComponentType
lateinit var SECTION: ComponentType
lateinit var TEXT_DISPLAY: ComponentType
lateinit var THUMBNAIL: ComponentType
lateinit var MEDIA_GALLERY: ComponentType
lateinit var FILE: ComponentType
lateinit var SEPARATOR: ComponentType
lateinit var CONTAINER: ComponentType
var newValues: Array<ComponentType>? = null
private var oldValues: Array<ComponentType>? = null
@Suppress("UNCHECKED_CAST", "UNUSED_CHANGED_VALUE")
fun make() {
if (oldValues != null)
return
oldValues = ComponentType.values()
val cls = ComponentType::class.java
val constructor = cls.declaredConstructors[0]
constructor.isAccessible = true
val field = cls.getDeclaredField("\$VALUES")
field.isAccessible = true
val values = ComponentType.values()
var nextIdx = values.size
USER_SELECT = constructor.newInstance("USER_SELECT", nextIdx++, 5, UserSelectComponent::class.java) as ComponentType
ROLE_SELECT = constructor.newInstance("ROLE_SELECT", nextIdx++, 6, RoleSelectComponent::class.java) as ComponentType
MENTIONABLE_SELECT = constructor.newInstance("MENTIONABLE_SELECT", nextIdx++, 7, MentionableSelectComponent::class.java) as ComponentType
CHANNEL_SELECT = constructor.newInstance("CHANNEL_SELECT", nextIdx++, 8, ChannelSelectComponent::class.java) as ComponentType
SECTION = constructor.newInstance("SECTION", nextIdx++, 9, SectionComponent::class.java) as ComponentType
TEXT_DISPLAY = constructor.newInstance("TEXT_DISPLAY", nextIdx++, 10, TextDisplayComponent::class.java) as ComponentType
THUMBNAIL = constructor.newInstance("THUMBNAIL", nextIdx++, 11, ThumbnailComponent::class.java) as ComponentType
MEDIA_GALLERY = constructor.newInstance("MEDIA_GALLERY", nextIdx++, 12, MediaGalleryComponent::class.java) as ComponentType
FILE = constructor.newInstance("FILE", nextIdx++, 13, FileComponent::class.java) as ComponentType
SEPARATOR = constructor.newInstance("SEPARATOR", nextIdx++, 14, SeparatorComponent::class.java) as ComponentType
CONTAINER = constructor.newInstance("CONTAINER", nextIdx++, 17, ContainerComponent::class.java) as ComponentType
newValues = arrayOf(USER_SELECT, ROLE_SELECT, MENTIONABLE_SELECT, CHANNEL_SELECT, SECTION, TEXT_DISPLAY, THUMBNAIL, MEDIA_GALLERY, FILE, SEPARATOR, CONTAINER)
field.set(null, values + newValues!!)
}
fun unmake(logger: Logger) {
if (oldValues == null)
return logger.error("No unpatched component types?", null)
val cls = ComponentType::class.java
val field = cls.getDeclaredField("\$VALUES")
field.isAccessible = true
field.set(null, oldValues)
oldValues = null
}
}

View file

@ -0,0 +1,43 @@
package com.aliucord.coreplugins.componentsv2
import com.aliucord.api.PatcherAPI
import com.aliucord.patcher.*
import com.discord.api.channel.Channel
import com.discord.api.role.GuildRole
import com.discord.models.member.GuildMember
import com.discord.models.message.Message
import com.discord.stores.StoreMessageReplies.MessageState
import com.discord.stores.StoreMessageState
import com.discord.stores.StoreThreadMessages
import com.discord.widgets.chat.list.entries.BotUiComponentEntry
import com.discord.widgets.chat.list.entries.ChatListEntry
import com.discord.widgets.chat.list.model.WidgetChatListModelMessages
fun patchMessageItems(patcher: PatcherAPI) {
@Suppress("UNUSED_DESTRUCTURED_PARAMETER_ENTRY", "LocalVariableName", "UnusedVariable")
patcher.patch(WidgetChatListModelMessages.Companion::class.java.declaredMethods.find { it.name == "getMessageItems" }!!)
{(
param,
channel: Channel,
guildMembers: Map<Long, GuildMember>,
guildRoles: Map<Long, GuildRole>,
_blockedRelationships: Map<Long, Int>?,
_referencedChannel: Channel?,
_threadStoreState: StoreThreadMessages.ThreadState?,
_message: Message,
state: StoreMessageState.State?,
_repliedMessages: Map<Long, MessageState>?,
_isBlockedExpanded: Boolean,
_isMinimal: Boolean,
) ->
@Suppress("UNCHECKED_CAST")
val result = (param.result as MutableList<ChatListEntry>)
val meId = param.args[15] as Long
result.forEachIndexed { index, entry ->
if (entry is BotUiComponentEntry && ((entry.message.flags shr 15) and 1 == 1L)) {
val fields = BotUiComponentV2Entry.V2Fields(state, meId, channel, guildMembers, guildRoles)
result[index] = BotUiComponentV2Entry.fromV1(entry, fields)
}
}
}
}

View file

@ -0,0 +1,41 @@
package com.aliucord.coreplugins.componentsv2.models
import com.discord.api.botuikit.ComponentType
import com.discord.api.botuikit.ContainerComponent
import com.discord.models.botuikit.MessageComponent
data class ContainerMessageComponent(
private val type: ComponentType,
private val index: Int,
override val id: Int,
val components: List<MessageComponent>,
val accentColor: Int?,
override val spoiler: Boolean,
) : SpoilableMessageComponent {
override fun getType() = type
override fun getIndex() = index
companion object {
fun mergeToMessageComponent(
component: ContainerComponent,
index: Int,
components: List<MessageComponent>,
): ContainerMessageComponent {
components.forEach {
if (it is MediaGalleryMessageComponent)
it.markedContained = true
}
return component.run {
ContainerMessageComponent(
type,
index,
id,
components,
accentColor,
spoiler,
)
}
}
}
}

View file

@ -0,0 +1,33 @@
package com.aliucord.coreplugins.componentsv2.models
import com.discord.api.botuikit.*
import com.discord.models.botuikit.MessageComponent
data class MediaGalleryMessageComponent(
private val type: ComponentType,
private val index: Int,
val id: Int,
val items: List<MediaGalleryItem>,
// Set by ContainerComponentView to tell MediaGalleryComponentView it is contained
var markedContained: Boolean = false,
) : MessageComponent {
override fun getType() = type
override fun getIndex() = index
companion object {
fun mergeToMessageComponent(
component: MediaGalleryComponent,
index: Int
): MediaGalleryMessageComponent {
return component.run {
MediaGalleryMessageComponent(
type,
index,
id,
items,
)
}
}
}
}

View file

@ -0,0 +1,37 @@
package com.aliucord.coreplugins.componentsv2.models
import com.discord.api.botuikit.ComponentType
import com.discord.api.botuikit.SectionComponent
import com.discord.models.botuikit.MessageComponent
data class SectionMessageComponent(
private val type: ComponentType,
private val index: Int,
val id: Int,
val components: List<MessageComponent>,
val accessory: MessageComponent?,
) : MessageComponent {
override fun getType() = type
override fun getIndex() = index
companion object {
fun mergeToMessageComponent(
component: SectionComponent,
index: Int,
components: List<MessageComponent>,
): SectionMessageComponent {
return component.run {
val realComponents = components.toMutableList()
val accessory = realComponents.removeAt(realComponents.lastIndex)
SectionMessageComponent(
type,
index,
id,
realComponents,
accessory,
)
}
}
}
}

View file

@ -0,0 +1,48 @@
package com.aliucord.coreplugins.componentsv2.models
import com.discord.api.botuikit.*
import com.discord.models.botuikit.ActionInteractionComponentState
import com.discord.models.botuikit.ActionMessageComponent
import com.discord.widgets.botuikit.ComponentChatListState
data class SelectV2MessageComponent(
private val type: ComponentType,
private val index: Int,
private val stateInteraction: ActionInteractionComponentState,
val id: Int,
val customId: String,
val placeholder: String,
val minValues: Int,
val maxValues: Int,
val defaultValues: List<SelectV2DefaultValue>,
val emojiAnimationsEnabled: Boolean,
) : ActionMessageComponent() {
override fun getType() = type
override fun getIndex() = index
override fun getStateInteraction() = stateInteraction
companion object {
fun mergeToMessageComponent(
selectComponent: SelectV2Component,
index: Int,
stateInteraction: ActionInteractionComponentState,
componentStoreState: ComponentChatListState.ComponentStoreState
): SelectV2MessageComponent {
return selectComponent.run {
SelectV2MessageComponent(
type,
index,
stateInteraction,
id,
customId,
placeholder,
minValues,
maxValues,
defaultValues ?: listOf(),
componentStoreState.animateEmojis
)
}
}
}
}

View file

@ -0,0 +1,32 @@
package com.aliucord.coreplugins.componentsv2.models
import com.discord.api.botuikit.ComponentType
import com.discord.api.botuikit.SeparatorComponent
import com.discord.models.botuikit.MessageComponent
data class SeparatorMessageComponent(
private val type: ComponentType,
private val index: Int,
val divider: Boolean,
val spacing: Int, // 1 = small padding, 2 = large padding
) : MessageComponent {
override fun getType() = type
override fun getIndex() = index
companion object {
fun mergeToMessageComponent(
component: SeparatorComponent,
index: Int
): SeparatorMessageComponent {
return component.run {
SeparatorMessageComponent(
type,
index,
divider,
spacing,
)
}
}
}
}

View file

@ -0,0 +1,8 @@
package com.aliucord.coreplugins.componentsv2.models
import com.discord.models.botuikit.MessageComponent
interface SpoilableMessageComponent : MessageComponent {
val id: Int
val spoiler: Boolean
}

View file

@ -0,0 +1,32 @@
package com.aliucord.coreplugins.componentsv2.models
import com.discord.api.botuikit.ComponentType
import com.discord.api.botuikit.TextDisplayComponent
import com.discord.models.botuikit.MessageComponent
data class TextDisplayMessageComponent(
private val type: ComponentType,
private val index: Int,
val id: Int,
val content: String,
) : MessageComponent {
override fun getType() = type
override fun getIndex() = index
companion object {
fun mergeToMessageComponent(
component: TextDisplayComponent,
index: Int
): TextDisplayMessageComponent {
return component.run {
TextDisplayMessageComponent(
type,
index,
id,
content
)
}
}
}
}

View file

@ -0,0 +1,34 @@
package com.aliucord.coreplugins.componentsv2.models
import com.discord.api.botuikit.*
data class ThumbnailMessageComponent(
private val type: ComponentType,
private val index: Int,
override val id: Int,
val media: UnfurledMediaItem,
val description: String?,
override val spoiler: Boolean,
) : SpoilableMessageComponent {
override fun getType() = type
override fun getIndex() = index
companion object {
fun mergeToMessageComponent(
component: ThumbnailComponent,
index: Int
): ThumbnailMessageComponent {
return component.run {
ThumbnailMessageComponent(
type,
index,
id,
media,
description,
spoiler,
)
}
}
}
}

View file

@ -0,0 +1,87 @@
@file:Suppress("MISSING_DEPENDENCY_CLASS", "MISSING_DEPENDENCY_SUPERCLASS")
package com.aliucord.coreplugins.componentsv2.selectsheet
import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.SimpleItemAnimator
import com.aliucord.Utils
import com.aliucord.coreplugins.componentsv2.BotUiComponentV2Entry
import com.aliucord.coreplugins.componentsv2.models.SelectV2MessageComponent
import com.discord.app.AppBottomSheet
import com.discord.utilities.view.extensions.ViewExtensions
import com.discord.utilities.view.recycler.MaxHeightRecyclerView
import com.discord.widgets.botuikit.views.select.`SelectComponentBottomSheet$binding$2`
import com.lytefast.flexinput.R
internal class SelectSheet : AppBottomSheet {
val entry: BotUiComponentV2Entry?
val component: SelectV2MessageComponent?
private lateinit var header: ConstraintLayout
private lateinit var placeholder: TextView
private lateinit var recycler: MaxHeightRecyclerView
private lateinit var select: TextView
private lateinit var subtitle: TextView
private lateinit var adapter: SelectSheetAdapter
constructor(entry: BotUiComponentV2Entry, component: SelectV2MessageComponent) {
this.entry = entry
this.component = component
}
constructor() {
this.entry = null
this.component = null
}
override fun getContentViewResId() = Utils.getResId("widget_select_component_bottom_sheet", "layout")
override fun onViewCreated(view: View, bundle: Bundle?) {
super.onViewCreated(view, bundle)
val viewModel = ViewModelProvider(this).get(SelectSheetViewModel::class.java)
`SelectComponentBottomSheet$binding$2`.INSTANCE.invoke(view).run {
header = a
placeholder = b
recycler = c
select = d
subtitle = e
}
adapter = SelectSheetAdapter(recycler, viewModel)
recycler.adapter = adapter
(recycler.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
select.setOnClickListener { viewModel.submit() }
viewModel.onUpdate = ::configureUI
viewModel.onRequestDismiss = ::dismiss
if (entry != null && component != null)
viewModel.configure(entry, component)
else
viewModel.state?.let { configureUI(it) }
}
private fun configureUI(state: SelectSheetViewModel.ViewState) {
placeholder.text = state.placeholder
subtitle.visibility = if (state.isMultiSelect) View.VISIBLE else View.GONE
if (state.isMultiSelect) {
subtitle.text =
b.a.k.b.k(
this,
R.h.message_select_component_select_requirement,
arrayOf(state.minSelections),
null,
4
)
}
select.visibility = if (state.isMultiSelect) View.VISIBLE else View.INVISIBLE
select.isClickable = state.isValidSelection
ViewExtensions.setEnabledAlpha(select, state.isValidSelection, 0.3f)
adapter.setData(state.items)
}
}

View file

@ -0,0 +1,13 @@
package com.aliucord.coreplugins.componentsv2.selectsheet
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.discord.utilities.mg_recycler.MGRecyclerAdapterSimple
import com.discord.utilities.mg_recycler.MGRecyclerViewHolder
internal class SelectSheetAdapter(recycler: RecyclerView, val viewModel: SelectSheetViewModel)
: MGRecyclerAdapterSimple<SelectSheetItem>(recycler) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MGRecyclerViewHolder<*, SelectSheetItem> {
return SelectSheetItemViewHolder(this)
}
}

View file

@ -0,0 +1,45 @@
package com.aliucord.coreplugins.componentsv2.selectsheet
import com.aliucord.wrappers.ChannelWrapper.Companion.id
import com.discord.api.channel.Channel
import com.discord.api.role.GuildRole
import com.discord.models.member.GuildMember
import com.discord.models.user.User
import com.discord.utilities.mg_recycler.MGRecyclerDataPayload
sealed class SelectSheetItem(
private val type: Int,
val id: Long,
) : MGRecyclerDataPayload {
override fun getKey() = id.toString()
override fun getType() = type
abstract val checked: Boolean
abstract val disabled: Boolean
abstract fun copy(checked: Boolean = this.checked, disabled: Boolean = this.disabled) : SelectSheetItem
internal data class UserSelectItem(
val user: User,
val member: GuildMember,
override val checked: Boolean,
override val disabled: Boolean = false,
) : SelectSheetItem(1, user.id) {
override fun copy(checked: Boolean, disabled: Boolean): SelectSheetItem = copy(checked = checked, disabled = disabled, user = user)
}
internal data class RoleSelectItem(
val role: GuildRole,
override val checked: Boolean,
override val disabled: Boolean = false,
) : SelectSheetItem(2, role.id) {
override fun copy(checked: Boolean, disabled: Boolean): SelectSheetItem = copy(checked = checked, disabled = disabled, role = role)
}
internal data class ChannelSelectItem(
val channel: Channel,
override val checked: Boolean,
override val disabled: Boolean = false,
) : SelectSheetItem(3, channel.id) {
override fun copy(checked: Boolean, disabled: Boolean): SelectSheetItem = copy(checked = checked, disabled = disabled, channel = channel)
}
}

View file

@ -0,0 +1,121 @@
package com.aliucord.coreplugins.componentsv2.selectsheet
import android.annotation.SuppressLint
import android.view.View
import android.view.View.GONE
import android.view.View.VISIBLE
import androidx.constraintlayout.widget.ConstraintLayout
import com.aliucord.Utils
import com.aliucord.utils.DimenUtils.dp
import com.aliucord.wrappers.ChannelWrapper.Companion.name
import com.aliucord.wrappers.ChannelWrapper.Companion.type
import com.aliucord.wrappers.GuildRoleWrapper.Companion.name
import com.discord.api.channel.Channel
import com.discord.models.member.GuildMember
import com.discord.utilities.color.ColorCompat
import com.discord.utilities.drawable.DrawableCompat
import com.discord.utilities.guilds.RoleUtils
import com.discord.utilities.icon.IconUtils
import com.discord.utilities.images.MGImages
import com.discord.utilities.mg_recycler.MGRecyclerViewHolder
import com.discord.utilities.user.UserUtils
import com.discord.utilities.view.extensions.ViewExtensions
import com.facebook.drawee.view.SimpleDraweeView
import com.google.android.material.checkbox.MaterialCheckBox
import com.google.android.material.textview.MaterialTextView
import com.lytefast.flexinput.R
@SuppressLint("SetTextI18n")
internal class SelectSheetItemViewHolder(adapter: SelectSheetAdapter)
: MGRecyclerViewHolder<SelectSheetAdapter, SelectSheetItem>(Utils.getResId("widget_select_component_bottom_sheet_item", "layout"), adapter) {
private val description = itemView.findViewById<MaterialTextView>(Utils.getResId("select_component_sheet_item_description", "id"))!!
private val divider = itemView.findViewById<View>(Utils.getResId("select_component_sheet_item_divider", "id"))!!
private val dividerWithIcon = itemView.findViewById<View>(Utils.getResId("select_component_sheet_item_divider_icon", "id"))!!
private val icon = itemView.findViewById<SimpleDraweeView>(Utils.getResId("select_component_sheet_item_icon", "id"))!!
private val checkbox = itemView.findViewById<MaterialCheckBox>(Utils.getResId("select_component_sheet_item_selected", "id"))!!
private val title = itemView.findViewById<MaterialTextView>(Utils.getResId("select_component_sheet_item_title", "id"))!!
init {
(itemView as ConstraintLayout).minHeight = 62.dp
divider.visibility = View.GONE
dividerWithIcon.visibility = View.VISIBLE
dividerWithIcon.layoutParams = (dividerWithIcon.layoutParams as ConstraintLayout.LayoutParams).apply {
marginStart = 56.dp
}
description.setPadding(0, 0, 0, 12.dp)
icon.visibility = View.VISIBLE
icon.layoutParams = (icon.layoutParams as ConstraintLayout.LayoutParams).apply {
width = 24.dp
height = 24.dp
}
MGImages.setRoundingParams(
icon,
Float.MAX_VALUE,
false,
null,
null,
null,
)
}
override fun onConfigure(viewType: Int, item: SelectSheetItem) {
super.onConfigure(viewType, item)
description.visibility = View.GONE
checkbox.visibility = if (adapter.viewModel.state?.isMultiSelect == true) VISIBLE else GONE
checkbox.isChecked = item.checked
title.setPadding(0, 12.dp, 0, 12.dp)
when (item) {
is SelectSheetItem.ChannelSelectItem -> configureChannel(item)
is SelectSheetItem.RoleSelectItem -> configureRole(item)
is SelectSheetItem.UserSelectItem -> configureUser(item)
}
itemView.setOnClickListener { adapter.viewModel.onItemSelect(item) }
ViewExtensions.setEnabledAlpha(itemView, !item.disabled, 0.3f);
itemView.isEnabled = !item.disabled
}
private fun configureChannel(item: SelectSheetItem.ChannelSelectItem) {
title.text = "#${item.channel.name}"
val res = when (item.channel.type) {
Channel.GUILD_ANNOUNCEMENT -> R.e.ic_channel_announcements
Channel.GUILD_VOICE -> R.e.ic_channel_voice
Channel.CATEGORY -> DrawableCompat.getThemedDrawableRes(adapter.context, R.b.ic_category)
else -> DrawableCompat.getThemedDrawableRes(adapter.context, R.b.ic_channel_text)
}
icon.setImageResource(res)
}
private fun configureRole(item: SelectSheetItem.RoleSelectItem) {
title.text = item.role.name
val opaqueColor: Int = RoleUtils.getOpaqueColor(item.role, ColorCompat.getColor(adapter.context, R.c.status_grey_500))
icon.setImageResource(R.e.ic_role_24dp)
icon.setColorFilter(opaqueColor)
}
private fun configureUser(item: SelectSheetItem.UserSelectItem) {
IconUtils.`setIcon$default`(
icon,
item.user,
R.d.avatar_size_standard,
null,
null,
item.member,
24,
null
)
title.text = GuildMember.Companion!!.getNickOrUsername(item.member, item.user)
val descText = item.user.username + if (item.user.discriminator != 0)
UserUtils.INSTANCE.getDiscriminatorWithPadding(item.user)
else
""
if (title.text != descText) {
title.setPadding(0, 12.dp, 0, 0)
description.visibility = View.VISIBLE
description.text = descText
}
}
}

View file

@ -0,0 +1,154 @@
@file:Suppress("MISSING_DEPENDENCY_CLASS", "MISSING_DEPENDENCY_SUPERCLASS")
package com.aliucord.coreplugins.componentsv2.selectsheet
import androidx.lifecycle.ViewModel
import com.aliucord.coreplugins.componentsv2.BotUiComponentV2Entry
import com.aliucord.coreplugins.componentsv2.ComponentV2Type
import com.aliucord.coreplugins.componentsv2.models.SelectV2MessageComponent
import com.aliucord.wrappers.ChannelWrapper.Companion.id
import com.discord.api.botuikit.ComponentType
import com.discord.restapi.RestAPIParams.ComponentInteractionData.SelectComponentInteractionData
import com.discord.stores.StoreStream
const val ENTRY_LIMIT = 15
internal class SelectSheetViewModel() : ViewModel() {
data class ViewState(
val placeholder: String,
val items: List<SelectSheetItem>,
val isMultiSelect: Boolean,
val minSelections: Int,
val maxSelections: Int,
val isValidSelection: Boolean,
)
private data class SubmissionData(
val applicationId: Long,
val guildId: Long?,
val channelId: Long,
val messageId: Long,
val messageFlags: Long,
val index: Int,
val customId: String,
val type: ComponentType,
)
var onUpdate: ((ViewState) -> Unit)? = null
var onRequestDismiss: (() -> Unit)? = null
var state: ViewState? = null
set(value) {
field = value
value?.let { onUpdate?.invoke(it) }
}
private var submissionData: SubmissionData? = null
fun configure(entry: BotUiComponentV2Entry, component: SelectV2MessageComponent) {
var entryCount = 0
val items = mutableListOf<SelectSheetItem>()
val users = StoreStream.getUsers().users
if (component.type in listOf(ComponentV2Type.USER_SELECT, ComponentV2Type.MENTIONABLE_SELECT)) {
for (member in entry.guildMembers.values) {
entryCount += 1
if (entryCount > ENTRY_LIMIT)
break
val user = users[member.userId]!!
val isDefault = component.defaultValues.any { it.id == member.userId }
items.add(SelectSheetItem.UserSelectItem(user, member, isDefault))
}
}
if (component.type in listOf(ComponentV2Type.ROLE_SELECT, ComponentV2Type.MENTIONABLE_SELECT)) {
for (role in entry.guildRoles.values) {
entryCount += 1
if (entryCount > ENTRY_LIMIT)
break
val isDefault = component.defaultValues.any { it.id == role.id }
items.add(SelectSheetItem.RoleSelectItem(role, isDefault))
}
}
// TODO: is the guildID check needed? as in, can server side allow this component?
if (component.type == ComponentV2Type.CHANNEL_SELECT && entry.guildId != null) {
val channels = StoreStream.getChannels().getChannelsForGuild(entry.guildId!!)!!
for (channel in channels.values) {
entryCount += 1
if (entryCount > ENTRY_LIMIT)
break
val isDefault = component.defaultValues.any { it.id == channel.id }
items.add(SelectSheetItem.ChannelSelectItem(channel, isDefault))
}
}
val min = component.minValues
val max = component.maxValues
state = ViewState(
component.placeholder,
items,
isMultiSelect = max > 1,
minSelections = min,
maxSelections = max,
isValidSelection = false,
)
submissionData = SubmissionData(
entry.applicationId,
entry.guildId,
entry.message.channelId,
entry.message.id,
entry.message.flags,
component.index,
component.customId,
component.type,
)
}
fun onItemSelect(item: SelectSheetItem) {
val state = state ?: return
var checkedCount = 0
var newItems = state.items.map {
val res = if (it == item)
item.copy(checked = !item.checked)
else
it
if (res.checked)
checkedCount += 1
res
}
val isMaxed = checkedCount == state.maxSelections
newItems = newItems.map {
it.copy(disabled = isMaxed && !it.checked)
}
this.state = state.copy(
items = newItems,
isValidSelection = checkedCount in state.minSelections..state.maxSelections
)
if (!state.isMultiSelect)
submit()
}
fun submit() {
// val companion = StoreStream.Companion
// companion.localActionComponentState.setSelectComponentSelection(this.componentContext.getMessageId(), this.componentIndex, u.toList(set))
val state = state ?: return
val submissionData = submissionData ?: return
val selected = state.items.filter { it.checked }.map { it.id.toString() }
submissionData.run {
StoreStream.getInteractions().sendComponentInteraction(
applicationId,
guildId,
channelId,
messageId,
index,
SelectComponentInteractionData(
type,
customId,
selected,
),
messageFlags
)
}
onRequestDismiss?.invoke()
}
}

View file

@ -0,0 +1,92 @@
@file:Suppress("MISSING_DEPENDENCY_CLASS", "MISSING_DEPENDENCY_SUPERCLASS")
package com.aliucord.coreplugins.componentsv2.views
import android.content.Context
import android.view.View
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID
import androidx.core.graphics.ColorUtils
import com.aliucord.Logger
import com.aliucord.coreplugins.componentsv2.BotUiComponentV2Entry
import com.aliucord.coreplugins.componentsv2.ComponentV2Type
import com.aliucord.coreplugins.componentsv2.models.ContainerMessageComponent
import com.aliucord.utils.DimenUtils.dp
import com.aliucord.utils.ViewUtils.addTo
import com.aliucord.widgets.LinearLayout
import com.discord.utilities.color.ColorCompat
import com.discord.widgets.botuikit.ComponentProvider
import com.discord.widgets.botuikit.views.ComponentActionListener
import com.discord.widgets.botuikit.views.ComponentView
import com.discord.widgets.chat.list.adapter.WidgetChatListAdapterItemBotComponentRow
import com.discord.widgets.chat.list.adapter.WidgetChatListAdapterItemBotComponentRowKt
import com.google.android.material.card.MaterialCardView
import com.lytefast.flexinput.R
class ContainerComponentView(ctx: Context) : ConstraintLayout(ctx), ComponentView<ContainerMessageComponent> {
override fun type() = ComponentV2Type.CONTAINER
companion object {
private val accentDividerId = View.generateViewId()
}
private lateinit var accentDivider: View
private lateinit var contentView: LinearLayout
private lateinit var spoilerView: SpoilerView
init {
MaterialCardView(ctx).addTo(this) {
radius = 8.dp.toFloat()
elevation = 0f
setCardBackgroundColor(ColorCompat.getThemedColor(ctx, R.b.colorBackgroundSecondary))
layoutParams = LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
topToTop = PARENT_ID
bottomToBottom = PARENT_ID
startToStart = PARENT_ID
}
ConstraintLayout(ctx).addTo(this) {
accentDivider = View(ctx).addTo(this) {
id = accentDividerId
layoutParams = LayoutParams(3.dp, 0).apply {
bottomToBottom = PARENT_ID
startToStart = PARENT_ID
topToTop = PARENT_ID
}
}
contentView = LinearLayout(ctx).addTo(this) {
setPadding(8.dp, 8.dp, 8.dp, 8.dp)
layoutParams = LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
startToEnd = accentDividerId
endToEnd = PARENT_ID
topToTop = PARENT_ID
constrainedWidth = true
}
}
spoilerView = SpoilerView(ctx, 1).addTo(this) {
layoutParams = SpoilerView.constraintLayoutParamsAround(PARENT_ID)
}
}
}
}
override fun configure(component: ContainerMessageComponent, provider: ComponentProvider, listener: ComponentActionListener) {
val item = listener as WidgetChatListAdapterItemBotComponentRow
val entry = item.entry
if (entry !is BotUiComponentV2Entry) {
Logger("ComponentsV2").warn("configured container with non-v2 entry")
return
}
val configuredViews = component.components.mapIndexed { index, child ->
provider.getConfiguredComponentView(listener, child, contentView, index)
}.filterNotNull()
WidgetChatListAdapterItemBotComponentRowKt.replaceViews(contentView, configuredViews)
val color = component.accentColor?.let { ColorUtils.setAlphaComponent(it, 255) }
?: ColorCompat.getThemedColor(context, R.b.colorBackgroundModifierAccent)
accentDivider.setBackgroundColor(color)
spoilerView.configure(entry, component)
}
}

View file

@ -0,0 +1,131 @@
@file:Suppress("MISSING_DEPENDENCY_CLASS", "MISSING_DEPENDENCY_SUPERCLASS")
package com.aliucord.coreplugins.componentsv2.views
import android.content.Context
import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID
import com.aliucord.Logger
import com.aliucord.coreplugins.ComponentsV2
import com.aliucord.coreplugins.componentsv2.BotUiComponentV2Entry
import com.aliucord.coreplugins.componentsv2.ComponentV2Type
import com.aliucord.coreplugins.componentsv2.models.MediaGalleryMessageComponent
import com.aliucord.utils.DimenUtils.dp
import com.aliucord.utils.ViewUtils.addTo
import com.aliucord.widgets.LinearLayout
import com.aliucord.wrappers.messages.AttachmentWrapper.Companion.height
import com.aliucord.wrappers.messages.AttachmentWrapper.Companion.width
import com.discord.api.message.attachment.MessageAttachment
import com.discord.utilities.color.ColorCompat
import com.discord.utilities.display.DisplayUtils
import com.discord.utilities.embed.EmbedResourceUtils
import com.discord.widgets.botuikit.ComponentProvider
import com.discord.widgets.botuikit.views.ComponentActionListener
import com.discord.widgets.botuikit.views.ComponentView
import com.discord.widgets.chat.list.InlineMediaView
import com.discord.widgets.chat.list.adapter.WidgetChatListAdapterItemBotComponentRow
import com.discord.widgets.media.WidgetMedia
import com.google.android.material.card.MaterialCardView
import com.lytefast.flexinput.R
class MediaGalleryComponentView(ctx: Context) : ConstraintLayout(ctx), ComponentView<MediaGalleryMessageComponent> {
override fun type() = ComponentV2Type.MEDIA_GALLERY
companion object {
private val mediaViewId = View.generateViewId()
private val maxEmbedHeight = EmbedResourceUtils.INSTANCE.maX_IMAGE_VIEW_HEIGHT_PX
}
private val layout = LinearLayout(ctx).addTo(this) {
layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply {
topToTop = PARENT_ID
startToStart = PARENT_ID
endToEnd = PARENT_ID
}
}
private var mediaViews: List<Pair<MessageAttachment, InlineMediaView>>? = null
// This isn't pretty, but Discord actually does this in their code (EmbedResourceUtils.computeMaximumImageWidthPx)
private fun calculateMaxWidth(contained: Boolean): Int {
var maxPossibleWidth = DisplayUtils.getScreenSize(context).width() -
resources.getDimensionPixelSize(R.d.uikit_guideline_chat) -
resources.getDimensionPixelSize(R.d.chat_cell_horizontal_spacing_total)
if (contained)
maxPossibleWidth -= 15.dp
return maxPossibleWidth.coerceAtMost(1440)
}
// Reference: WidgetChatListAdapterItemAttachment.configureUI
override fun configure(component: MediaGalleryMessageComponent, provider: ComponentProvider, listener: ComponentActionListener) {
val item = listener as WidgetChatListAdapterItemBotComponentRow
val entry = item.entry
if (entry !is BotUiComponentV2Entry) {
Logger("ComponentsV2").warn("configured media gallery with non-v2 entry")
return
}
val maxEmbedWidth = calculateMaxWidth(component.markedContained)
layout.removeAllViews()
val pendingViews = mutableListOf<Pair<MessageAttachment, InlineMediaView>>()
component.items.forEachIndexed { index, it ->
val media = it.media
// TODO: there's probably a utility to extract filename from url
val name = media.url.split("/").last().split("?").first()
val attachment = ComponentsV2.createAttachment(
name,
0,
media.proxyUrl,
media.url,
media.width,
media.height,
)
val (width, height) = EmbedResourceUtils.INSTANCE.calculateScaledSize(
attachment.width!!,
attachment.height!!,
maxEmbedWidth,
maxEmbedHeight,
resources,
0,
)
MaterialCardView(context).addTo(layout) {
radius = 8.dp.toFloat()
elevation = 0f
setCardBackgroundColor(ColorCompat.getThemedColor(context, R.b.colorBackgroundPrimary))
layoutParams = android.widget.LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
topMargin = 8.dp
}
ConstraintLayout(context).addTo(this) {
layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
val mediaView = InlineMediaView(context).addTo(this) {
radius = 8.dp.toFloat()
elevation = 0f
setCardBackgroundColor(ColorCompat.getThemedColor(context, R.b.colorBackgroundPrimary))
id = mediaViewId
layoutParams = LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
topToTop = PARENT_ID
startToStart = PARENT_ID
}
setOnClickListener {
WidgetMedia.Companion!!.launch(context, attachment);
}
updateUIWithAttachment(attachment, width, height, true)
}
val spoilerView = SpoilerView(context, 1).addTo(this) {
translationZ = 10f
layoutParams = SpoilerView.constraintLayoutParamsAround(mediaViewId)
}
pendingViews.add(attachment to mediaView)
spoilerView.configure(it.spoiler, entry.state, entry.message.id, Pair(component.id, "media:$index"))
}
}
}
mediaViews = pendingViews.toList()
}
}

View file

@ -0,0 +1,57 @@
@file:Suppress("MISSING_DEPENDENCY_CLASS", "MISSING_DEPENDENCY_SUPERCLASS")
package com.aliucord.coreplugins.componentsv2.views
import android.content.Context
import android.view.View
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID
import com.aliucord.coreplugins.componentsv2.ComponentV2Type
import com.aliucord.coreplugins.componentsv2.models.SectionMessageComponent
import com.aliucord.utils.DimenUtils.dp
import com.aliucord.utils.ViewUtils.addTo
import com.aliucord.widgets.LinearLayout
import com.discord.widgets.botuikit.ComponentProvider
import com.discord.widgets.botuikit.views.ComponentActionListener
import com.discord.widgets.botuikit.views.ComponentView
import com.discord.widgets.chat.list.adapter.WidgetChatListAdapterItemBotComponentRowKt
class SectionComponentView(ctx: Context) : ConstraintLayout(ctx), ComponentView<SectionMessageComponent> {
override fun type() = ComponentV2Type.SECTION
companion object {
private val accessoryViewId = View.generateViewId()
}
private val mainView = LinearLayout(ctx).addTo(this) {
layoutParams = LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
constrainedWidth = true
horizontalBias = 0f
topToTop = PARENT_ID
startToStart = PARENT_ID
endToStart = accessoryViewId
marginEnd = 16.dp
}
}
private var accessoryView = FrameLayout(ctx).addTo(this) {
id = accessoryViewId
layoutParams = LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
topToTop = PARENT_ID
endToEnd = PARENT_ID
}
}
override fun configure(component: SectionMessageComponent, provider: ComponentProvider, listener: ComponentActionListener) {
val configuredViews = component.components.mapIndexed { index, child ->
provider.getConfiguredComponentView(listener, child, mainView, index)
}.filterNotNull()
WidgetChatListAdapterItemBotComponentRowKt.replaceViews(mainView, configuredViews)
val accessoryComponent = provider.getConfiguredComponentView(listener, component.accessory, accessoryView, 0)
accessoryComponent?.let {
WidgetChatListAdapterItemBotComponentRowKt.replaceViews(accessoryView, listOf(accessoryComponent))
}
}
}

View file

@ -0,0 +1,85 @@
@file:Suppress("MISSING_DEPENDENCY_CLASS", "MISSING_DEPENDENCY_SUPERCLASS")
package com.aliucord.coreplugins.componentsv2.views
import android.annotation.SuppressLint
import android.content.Context
import android.widget.ImageView
import androidx.constraintlayout.widget.ConstraintLayout
import com.aliucord.Logger
import com.aliucord.coreplugins.componentsv2.BotUiComponentV2Entry
import com.aliucord.coreplugins.componentsv2.models.SelectV2MessageComponent
import com.aliucord.coreplugins.componentsv2.selectsheet.SelectSheet
import com.aliucord.utils.ViewUtils.addTo
import com.discord.api.botuikit.ComponentType
import com.discord.models.botuikit.SelectMessageComponent
import com.discord.views.typing.TypingDots
import com.discord.widgets.botuikit.ComponentProvider
import com.discord.widgets.botuikit.views.ComponentActionListener
import com.discord.widgets.botuikit.views.ComponentView
import com.discord.widgets.botuikit.views.select.SelectComponentView
import com.discord.widgets.chat.list.adapter.WidgetChatListAdapterItemBotComponentRow
import com.facebook.drawee.view.SimpleDraweeView
import com.google.android.flexbox.FlexboxLayout
import com.google.android.material.textview.MaterialTextView
@SuppressLint("ViewConstructor")
internal class SelectV2ComponentView(context: Context, private val type: ComponentType)
: ConstraintLayout(context), ComponentView<SelectV2MessageComponent> {
override fun type(): ComponentType = type
private val componentView: SelectComponentView
private val chevron: ImageView
private val loadingDots: TypingDots
private val selectionIcon: SimpleDraweeView
private val selectionText: MaterialTextView
private val selectionsRoot: FlexboxLayout
init {
val view = SelectComponentView.Companion!!.inflateComponent(context, this).addTo(this)
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
maxWidth = view.maxWidth
b.a.i.b5.a(view).run {
componentView = a
chevron = b
loadingDots = c
selectionIcon = d
selectionText = e
selectionsRoot = f
}
}
override fun configure(
component: SelectV2MessageComponent,
provider: ComponentProvider,
listener: ComponentActionListener,
) {
val item = listener as WidgetChatListAdapterItemBotComponentRow
val entry = item.entry
if (entry !is BotUiComponentV2Entry) {
Logger("ComponentsV2").warn("configured v2 select with non-v2 entry")
return
}
val proxyComponent = component.run {
SelectMessageComponent(
type,
index,
stateInteraction,
customId,
placeholder,
minValues,
maxValues,
listOf(),
listOf(),
emojiAnimationsEnabled,
)
}
componentView.configure(proxyComponent, provider, listener)
componentView.setOnClickListener {
val sh = SelectSheet(entry, component)
sh.show(item.adapter.fragmentManager, SelectSheet::class.java.name)
}
}
}

View file

@ -0,0 +1,42 @@
@file:Suppress("MISSING_DEPENDENCY_CLASS", "MISSING_DEPENDENCY_SUPERCLASS")
package com.aliucord.coreplugins.componentsv2.views
import android.content.Context
import androidx.constraintlayout.widget.ConstraintLayout
import com.aliucord.Logger
import com.aliucord.coreplugins.componentsv2.BotUiComponentV2Entry
import com.aliucord.coreplugins.componentsv2.ComponentV2Type
import com.aliucord.coreplugins.componentsv2.models.SeparatorMessageComponent
import com.aliucord.utils.DimenUtils.dp
import com.aliucord.utils.ViewUtils.addTo
import com.aliucord.views.Divider
import com.discord.utilities.color.ColorCompat
import com.discord.widgets.botuikit.ComponentProvider
import com.discord.widgets.botuikit.views.ComponentActionListener
import com.discord.widgets.botuikit.views.ComponentView
import com.discord.widgets.chat.list.adapter.WidgetChatListAdapterItemBotComponentRow
import com.lytefast.flexinput.R
class SeparatorComponentView(ctx: Context) : ConstraintLayout(ctx), ComponentView<SeparatorMessageComponent> {
override fun type() = ComponentV2Type.SEPARATOR
private val divider = Divider(ctx).addTo(this) {
setBackgroundColor(ColorCompat.getThemedColor(context, R.b.colorTextMuted));
}
override fun configure(component: SeparatorMessageComponent, provider: ComponentProvider, listener: ComponentActionListener) {
val item = listener as WidgetChatListAdapterItemBotComponentRow
val entry = item.entry
if (entry !is BotUiComponentV2Entry) {
Logger("ComponentsV2").warn("configured separator with non-v2 entry")
return
}
divider.visibility = if (component.divider) VISIBLE else INVISIBLE
divider.layoutParams = (divider.layoutParams as LayoutParams).apply {
val padding = 6.dp * component.spacing
setPadding(paddingLeft, padding, paddingRight, padding)
}
}
}

View file

@ -0,0 +1,121 @@
package com.aliucord.coreplugins.componentsv2.views
import android.annotation.SuppressLint
import android.content.Context
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.*
import androidx.cardview.widget.CardView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID
import com.aliucord.coreplugins.componentsv2.BotUiComponentV2Entry
import com.aliucord.coreplugins.componentsv2.models.SpoilableMessageComponent
import com.aliucord.utils.DimenUtils.dp
import com.aliucord.utils.ViewUtils.addTo
import com.discord.stores.StoreMessageState
import com.discord.stores.StoreStream
import com.discord.utilities.color.ColorCompat
import com.lytefast.flexinput.R
/**
* A view that can be spoilered.
*
* @param ctx Context
* @param type 1 for full (spoiler text and button), 2 for mini (eye icon)
*/
@SuppressLint("ViewConstructor")
internal class SpoilerView(ctx: Context, type: Int) : ConstraintLayout(ctx) {
companion object {
fun constraintLayoutParamsAround(viewId: Int) =
LayoutParams(0, 0).apply {
topToTop = viewId
bottomToBottom = viewId
startToStart = viewId
endToEnd = viewId
}
}
private val spoilerView = ConstraintLayout(ctx).addTo(this) {
visibility = GONE
setBackgroundColor(ColorCompat.getThemedColor(ctx, R.b.theme_chat_spoiler_bg))
layoutParams = LayoutParams(0, 0).apply {
bottomToBottom = PARENT_ID
endToEnd = PARENT_ID
startToStart = PARENT_ID
topToTop = PARENT_ID
}
isClickable = true
when (type) {
1 -> {
CardView(ctx).addTo(this) {
elevation = ctx.resources.getDimension(R.d.app_elevation)
setCardBackgroundColor(ColorCompat.getThemedColor(ctx, R.b.colorBackgroundFloating))
radius = 16.dp.toFloat()
layoutParams = LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
startToStart = PARENT_ID
endToEnd = PARENT_ID
topToTop = PARENT_ID
bottomToBottom = PARENT_ID
}
TextView(ctx, null, 0, R.i.UiKit_TextView_H2).addTo(this) {
setText(R.h.spoiler)
isAllCaps = true
setPadding(8.dp, 4.dp, 8.dp, 4.dp)
setTextColor(ColorCompat.getThemedColor(ctx, R.b.colorTextNormal))
layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
marginStart = 4.dp
marginEnd = 4.dp
}
}
}
}
2 -> {
ImageView(ctx).addTo(this) {
setImageResource(R.e.ic_spoiler)
layoutParams = LayoutParams(0, 0).apply {
startToStart = PARENT_ID
endToEnd = PARENT_ID
topToTop = PARENT_ID
bottomToBottom = PARENT_ID
dimensionRatio = "1:1"
matchConstraintPercentWidth = 0.5f
}
}
}
else -> throw IllegalArgumentException("Invalid spoiler view type")
}
}
fun configure(entry: BotUiComponentV2Entry, component: SpoilableMessageComponent, key: String? = null) {
configure(component.spoiler, entry.state, entry.message.id, Pair(component.id, key))
}
fun configure(
isSpoiler: Boolean,
state: StoreMessageState.State?,
messageId: Long,
key: Pair<Int, String?>,
) {
val (id, strKey) = key
val spoiled = if (strKey != null)
state?.visibleSpoilerEmbedMap?.get(id)?.contains(strKey) ?: false
else
state?.visibleSpoilerEmbedMap?.containsKey(id) ?: false
spoilerView.setOnClickListener {
spoilerView.setOnClickListener(null)
spoilerView.animate()
.withEndAction {
if (strKey != null)
StoreStream.getMessageState().revealSpoilerEmbedData(messageId, id, strKey)
else
StoreStream.getMessageState().revealSpoilerEmbed(messageId, id)
}
.alpha(0f)
}
spoilerView.visibility = if (isSpoiler && !spoiled) VISIBLE else GONE
spoilerView.alpha = 1f
}
}

View file

@ -0,0 +1,86 @@
@file:Suppress("MISSING_DEPENDENCY_CLASS", "MISSING_DEPENDENCY_SUPERCLASS")
package com.aliucord.coreplugins.componentsv2.views
import android.content.Context
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import androidx.appcompat.view.ContextThemeWrapper
import androidx.constraintlayout.widget.ConstraintLayout
import com.aliucord.Logger
import com.aliucord.coreplugins.componentsv2.BotUiComponentV2Entry
import com.aliucord.coreplugins.componentsv2.ComponentV2Type
import com.aliucord.coreplugins.componentsv2.models.TextDisplayMessageComponent
import com.aliucord.utils.DimenUtils.dp
import com.aliucord.utils.ViewUtils.addTo
import com.discord.stores.StoreStream
import com.discord.utilities.color.ColorCompat
import com.discord.utilities.message.MessageUtils
import com.discord.utilities.textprocessing.*
import com.discord.utilities.textprocessing.node.SpoilerNode
import com.discord.utilities.view.text.LinkifiedTextView
import com.discord.widgets.botuikit.ComponentProvider
import com.discord.widgets.botuikit.views.ComponentActionListener
import com.discord.widgets.botuikit.views.ComponentView
import com.discord.widgets.chat.list.adapter.*
import com.lytefast.flexinput.R
class TextDisplayComponentView(ctx: Context) : ConstraintLayout(ctx), ComponentView<TextDisplayMessageComponent> {
override fun type() = ComponentV2Type.TEXT_DISPLAY
private val textView = LinkifiedTextView(ContextThemeWrapper(ctx, R.i.UiKit_Chat_Text)).addTo(this) {
layoutParams = LayoutParams(0, WRAP_CONTENT).apply {
topMargin = 2.dp
bottomMargin = 2.dp
}
}
override fun configure(component: TextDisplayMessageComponent, provider: ComponentProvider, listener: ComponentActionListener) {
val item = listener as WidgetChatListAdapterItemBotComponentRow
val entry = item.entry
if (entry !is BotUiComponentV2Entry) {
Logger("ComponentsV2").warn("configured text display with non-v2 entry")
return
}
render(component.id, component.content, item.adapter, entry)
}
private fun render(id: Int, content: String, adapter: WidgetChatListAdapter, entry: BotUiComponentV2Entry) {
val data = adapter.data
@Suppress("UNCHECKED_CAST")
val spoilers = entry.state?.visibleSpoilerEmbedMap?.let {
WidgetChatListAdapterItemEmbed.Companion.`access$getEmbedFieldVisibleIndices`(
WidgetChatListAdapterItemEmbed.Companion,
it,
id,
"comp"
)
} as List<Int>?
val processor = MessagePreprocessor(entry.meId, spoilers, null, false, 50)
val nickOrUsernames = MessageUtils.getNickOrUsernames(entry.message, entry.channel, entry.guildMembers, entry.channel.q())
val parseChannelMessage = DiscordParser.parseChannelMessage(
context,
content,
MessageRenderContext(
context,
entry.meId,
false,
nickOrUsernames,
StoreStream.getChannels().channelNames, // TODO, does not change
entry.guildRoles,
R.b.colorTextLink,
`WidgetChatListAdapterItemMessage$getMessageRenderContext$1`.INSTANCE,
{ s: String -> adapter.eventHandler.onUrlLongClicked(s) },
ColorCompat.getThemedColor(context, R.b.theme_chat_spoiler_bg),
ColorCompat.getThemedColor(context, R.b.theme_chat_spoiler_bg_visible),
{ node: SpoilerNode<*> -> StoreStream.getMessageState().revealSpoilerEmbedData(entry.message.id, id, "comp:${node.id}") },
{ l: Long -> adapter.eventHandler.onUserMentionClicked(l, data.channelId, data.guildId) },
`WidgetChatListAdapterItemMessage$getMessageRenderContext$4`(context)
),
processor,
DiscordParser.ParserOptions.DEFAULT,
false
)
textView.setDraweeSpanStringBuilder(parseChannelMessage);
}
}

View file

@ -0,0 +1,95 @@
@file:Suppress("MISSING_DEPENDENCY_CLASS", "MISSING_DEPENDENCY_SUPERCLASS")
package com.aliucord.coreplugins.componentsv2.views
import android.content.Context
import android.view.View
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout
import androidx.constraintlayout.widget.ConstraintLayout
import com.aliucord.Logger
import com.aliucord.coreplugins.componentsv2.BotUiComponentV2Entry
import com.aliucord.coreplugins.componentsv2.ComponentV2Type
import com.aliucord.coreplugins.componentsv2.models.ThumbnailMessageComponent
import com.aliucord.utils.DimenUtils.dp
import com.aliucord.utils.ViewUtils.addTo
import com.discord.utilities.color.ColorCompat
import com.discord.utilities.embed.EmbedResourceUtils
import com.discord.utilities.images.MGImages
import com.discord.widgets.botuikit.ComponentProvider
import com.discord.widgets.botuikit.views.ComponentActionListener
import com.discord.widgets.botuikit.views.ComponentView
import com.discord.widgets.chat.list.adapter.WidgetChatListAdapterItemBotComponentRow
import com.facebook.drawee.view.SimpleDraweeView
import com.google.android.material.card.MaterialCardView
import com.lytefast.flexinput.R
class ThumbnailComponentView(ctx: Context) : ConstraintLayout(ctx), ComponentView<ThumbnailMessageComponent> {
override fun type() = ComponentV2Type.THUMBNAIL
private val embedThumbnailMaxSize = (ctx.resources.getDimension(R.d.embed_thumbnail_max_size) * 1.5).toInt()
companion object {
private val imageViewId = View.generateViewId()
}
private lateinit var imageView: SimpleDraweeView
private lateinit var spoilerView: SpoilerView
init {
MaterialCardView(ctx).addTo(this) {
radius = 8.dp.toFloat()
elevation = 0f
setCardBackgroundColor(ColorCompat.getThemedColor(ctx, R.b.colorBackgroundPrimary))
layoutParams = LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
ConstraintLayout(ctx).addTo(this) {
layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
imageView = SimpleDraweeView(ctx, null, 0, R.i.UiKit_ImageView).addTo(this) {
id = imageViewId
}
spoilerView = SpoilerView(ctx, 2).addTo(this) {
layoutParams = SpoilerView.constraintLayoutParamsAround(imageViewId)
}
}
}
}
// Reference: WidgetChatListAdapterItemEmbed.configureEmbedThumbnail
override fun configure(component: ThumbnailMessageComponent, provider: ComponentProvider, listener: ComponentActionListener) {
val item = listener as WidgetChatListAdapterItemBotComponentRow
val entry = item.entry
if (entry !is BotUiComponentV2Entry) {
Logger("ComponentsV2").warn("configured thumbnail with non-v2 entry")
return
}
val (width, height) = EmbedResourceUtils.INSTANCE.calculateScaledSize(
component.media.width,
component.media.height,
embedThumbnailMaxSize,
embedThumbnailMaxSize,
resources,
0
)
imageView.apply {
if (layoutParams.width != width || layoutParams.height != height)
layoutParams = layoutParams.apply {
this.width = width
this.height = height
}
MGImages.`setImage$default`(
this,
EmbedResourceUtils.INSTANCE.getPreviewUrls(component.media.proxyUrl, width, height, true), // z2: shouldAnimate
0,
0,
false,
null,
null,
null,
252,
null
)
}
spoilerView.configure(entry, component)
}
}

View file

@ -0,0 +1,41 @@
package com.aliucord.utils
import android.view.View
import android.view.ViewGroup
import androidx.constraintlayout.widget.ConstraintLayout
import com.discord.views.CheckedSetting
object ViewUtils {
/**
* Shorthand extension function to add a View into a ViewGroup, and then
* run a scoped function
*
* @param group ViewGroup to add this View into
* @param block A scoped function, with the View as its receiver
* @return The View
*/
fun <T : View> T.addTo(group: ViewGroup, block: (T.() -> Unit)? = null): T = apply { group.addView(this); block?.invoke(this) }
/**
* Shorthand extension function to add a View into a ViewGroup at specified
* index, and then run a scoped function
*
* @param group ViewGroup to add this View into
* @param index Index to insert this View at
* @param block A scoped function, with the View as its receiver
* @return The View
*/
fun <T : View> T.addTo(group: ViewGroup, index: Int, block: (T.() -> Unit)? = null): T = apply { group.addView(this, index); block?.invoke(this) }
/** Main layout of the setting */
val CheckedSetting.layout get() = l.b() as ConstraintLayout
/** Main text/label of the setting */
val CheckedSetting.label get() = l.a()
/** Checkbox button at the end of the setting */
val CheckedSetting.checkbox get() = l.c()
/** Subtext of the setting */
val CheckedSetting.subtext get() = l.f()
}

View file

@ -0,0 +1,14 @@
package com.discord.api.botuikit
data class ChannelSelectComponent(
private val type: ComponentType,
override val id: Int,
@b.i.d.p.b("custom_id") override val customId: String,
override val placeholder: String,
override val defaultValues: List<SelectV2DefaultValue>?,
override val minValues: Int,
override val maxValues: Int,
override val disabled: Boolean,
) : SelectV2Component() {
override fun getType() = type
}

View file

@ -0,0 +1,12 @@
package com.discord.api.botuikit
data class ContainerComponent(
private val type: ComponentType,
val id: Int,
val components: List<Component>,
@b.i.d.p.b("accent_color") val accentColor: Int?,
val spoiler: Boolean,
): LayoutComponent() {
override fun getType() = type
override fun a() = components
}

View file

@ -0,0 +1,7 @@
package com.discord.api.botuikit
import java.io.Serializable
abstract class ContentComponent : LayoutComponent(), Serializable {
final override fun a(): List<Component> = listOf()
}

View file

@ -0,0 +1,12 @@
package com.discord.api.botuikit
data class FileComponent(
private val type: ComponentType,
val id: Int,
val file: UnfurledMediaItem,
val spoiler: Boolean,
val name: String,
val size: Int,
) : ContentComponent() {
override fun getType() = type
}

View file

@ -0,0 +1,9 @@
package com.discord.api.botuikit
data class MediaGalleryComponent(
private val type: ComponentType,
val id: Int,
val items: List<MediaGalleryItem>,
) : ContentComponent() {
override fun getType() = type
}

View file

@ -0,0 +1,7 @@
package com.discord.api.botuikit
data class MediaGalleryItem(
val media: UnfurledMediaItem,
val description: String?,
val spoiler: Boolean,
)

View file

@ -0,0 +1,14 @@
package com.discord.api.botuikit
data class MentionableSelectComponent(
private val type: ComponentType,
override val id: Int,
@b.i.d.p.b("custom_id") override val customId: String,
override val placeholder: String,
override val defaultValues: List<SelectV2DefaultValue>?,
override val minValues: Int,
override val maxValues: Int,
override val disabled: Boolean,
) : SelectV2Component() {
override fun getType() = type
}

View file

@ -0,0 +1,14 @@
package com.discord.api.botuikit
data class RoleSelectComponent(
private val type: ComponentType,
override val id: Int,
@b.i.d.p.b("custom_id") override val customId: String,
override val placeholder: String,
override val defaultValues: List<SelectV2DefaultValue>?,
override val minValues: Int,
override val maxValues: Int,
override val disabled: Boolean,
) : SelectV2Component() {
override fun getType() = type
}

View file

@ -0,0 +1,15 @@
package com.discord.api.botuikit
data class SectionComponent(
private val type: ComponentType,
val id: Int,
val components: List<Component>,
val accessory: Component,
): LayoutComponent() {
override fun getType() = type
// This property will be accessed by ComponentStateMapper to be processed into MessageComponents,
// so we pass in the accessory component to be processed too.
// Back in SectionMessageComponent.mergeToMessageComponent, we will separate this back correctly.
override fun a() = components + accessory
}

View file

@ -0,0 +1,11 @@
package com.discord.api.botuikit
abstract class SelectV2Component() : ActionComponent() {
abstract val id: Int
abstract val customId: String
abstract val placeholder: String
abstract val defaultValues: List<SelectV2DefaultValue>?
abstract val minValues: Int
abstract val maxValues: Int
abstract val disabled: Boolean
}

View file

@ -0,0 +1,6 @@
package com.discord.api.botuikit
data class SelectV2DefaultValue(
val id: Long,
val type: SelectV2DefaultValueType,
)

View file

@ -0,0 +1,7 @@
package com.discord.api.botuikit
enum class SelectV2DefaultValueType {
@b.i.d.p.b("user") USER,
@b.i.d.p.b("role") ROLE,
@b.i.d.p.b("channel") CHANNEL,
}

View file

@ -0,0 +1,11 @@
package com.discord.api.botuikit
data class SeparatorComponent(
private val type: ComponentType,
val id: Int,
val divider: Boolean,
val spacing: Int, // 1 = small padding, 2 = large padding
): LayoutComponent() {
override fun getType() = type
override fun a(): List<Component> = listOf()
}

View file

@ -0,0 +1,9 @@
package com.discord.api.botuikit
data class TextDisplayComponent(
private val type: ComponentType,
val id: Int,
val content: String,
) : ContentComponent() {
override fun getType() = type
}

View file

@ -0,0 +1,11 @@
package com.discord.api.botuikit
data class ThumbnailComponent(
private val type: ComponentType,
val id: Int,
val media: UnfurledMediaItem,
val description: String?,
val spoiler: Boolean,
) : ContentComponent() {
override fun getType() = type
}

View file

@ -0,0 +1,10 @@
package com.discord.api.botuikit
data class UnfurledMediaItem(
val url: String,
@b.i.d.p.b("proxy_url") val proxyUrl: String,
val height: Int,
val width: Int,
@b.i.d.p.b("content_type") val contentType: String?,
@b.i.d.p.b("attachment_id") val attachmentId: Long?,
)

View file

@ -0,0 +1,14 @@
package com.discord.api.botuikit
data class UserSelectComponent(
private val type: ComponentType,
override val id: Int,
@b.i.d.p.b("custom_id") override val customId: String,
override val placeholder: String,
override val defaultValues: List<SelectV2DefaultValue>?,
override val minValues: Int,
override val maxValues: Int,
override val disabled: Boolean,
) : SelectV2Component() {
override fun getType() = type
}