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

@ -6,6 +6,7 @@ buildscript {
google()
mavenCentral()
maven("https://maven.aliucord.com/snapshots")
gradlePluginPortal() // remove when gradle 8
maven("https://jitpack.io")
}
@ -13,6 +14,8 @@ buildscript {
classpath("com.android.tools.build:gradle:7.1.3")
classpath("com.aliucord:gradle:main-SNAPSHOT")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.21")
// classpath("com.gradleup.shadow:shadow-gradle-plugin:8.3.8")
classpath("com.github.johnrengelman.shadow:com.github.johnrengelman.shadow.gradle.plugin:7.1.2") // For Gradle 7 compat
}
}

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
}

172
canary/LICENSE Normal file
View file

@ -0,0 +1,172 @@
Open Software License ("OSL") v. 3.0
This Open Software License (the "License") applies to any original work of
authorship (the "Original Work") whose owner (the "Licensor") has placed the
following licensing notice adjacent to the copyright notice for the Original
Work:
Licensed under the Open Software License version 3.0
1) Grant of Copyright License. Licensor grants You a worldwide, royalty-free,
non-exclusive, sublicensable license, for the duration of the copyright, to do
the following:
a) to reproduce the Original Work in copies, either alone or as part of a
collective work;
b) to translate, adapt, alter, transform, modify, or arrange the Original
Work, thereby creating derivative works ("Derivative Works") based upon the
Original Work;
c) to distribute or communicate copies of the Original Work and Derivative
Works to the public, with the proviso that copies of Original Work or
Derivative Works that You distribute or communicate shall be licensed under
this Open Software License;
d) to perform the Original Work publicly; and
e) to display the Original Work publicly.
2) Grant of Patent License. Licensor grants You a worldwide, royalty-free,
non-exclusive, sublicensable license, under patent claims owned or controlled
by the Licensor that are embodied in the Original Work as furnished by the
Licensor, for the duration of the patents, to make, use, sell, offer for sale,
have made, and import the Original Work and Derivative Works.
3) Grant of Source Code License. The term "Source Code" means the preferred
form of the Original Work for making modifications to it and all available
documentation describing how to modify the Original Work. Licensor agrees to
provide a machine-readable copy of the Source Code of the Original Work along
with each copy of the Original Work that Licensor distributes. Licensor
reserves the right to satisfy this obligation by placing a machine-readable
copy of the Source Code in an information repository reasonably calculated to
permit inexpensive and convenient access by You for as long as Licensor
continues to distribute the Original Work.
4) Exclusions From License Grant. Neither the names of Licensor, nor the names
of any contributors to the Original Work, nor any of their trademarks or
service marks, may be used to endorse or promote products derived from this
Original Work without express prior permission of the Licensor. Except as
expressly stated herein, nothing in this License grants any license to
Licensor's trademarks, copyrights, patents, trade secrets or any other
intellectual property. No patent license is granted to make, use, sell, offer
for sale, have made, or import embodiments of any patent claims other than the
licensed claims defined in Section 2. No license is granted to the trademarks
of Licensor even if such marks are included in the Original Work. Nothing in
this License shall be interpreted to prohibit Licensor from licensing under
terms different from this License any Original Work that Licensor otherwise
would have a right to license.
5) External Deployment. The term "External Deployment" means the use,
distribution, or communication of the Original Work or Derivative Works in any
way such that the Original Work or Derivative Works may be used by anyone
other than You, whether those works are distributed or communicated to those
persons or made available as an application intended for use over a network.
As an express condition for the grants of license hereunder, You must treat
any External Deployment by You of the Original Work or a Derivative Work as a
distribution under section 1(c).
6) Attribution Rights. You must retain, in the Source Code of any Derivative
Works that You create, all copyright, patent, or trademark notices from the
Source Code of the Original Work, as well as any notices of licensing and any
descriptive text identified therein as an "Attribution Notice." You must cause
the Source Code for any Derivative Works that You create to carry a prominent
Attribution Notice reasonably calculated to inform recipients that You have
modified the Original Work.
7) Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that
the copyright in and to the Original Work and the patent rights granted herein
by Licensor are owned by the Licensor or are sublicensed to You under the
terms of this License with the permission of the contributor(s) of those
copyrights and patent rights. Except as expressly stated in the immediately
preceding sentence, the Original Work is provided under this License on an "AS
IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without
limitation, the warranties of non-infringement, merchantability or fitness for
a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK
IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this
License. No license to the Original Work is granted by this License except
under this disclaimer.
8) Limitation of Liability. Under no circumstances and under no legal theory,
whether in tort (including negligence), contract, or otherwise, shall the
Licensor be liable to anyone for any indirect, special, incidental, or
consequential damages of any character arising as a result of this License or
the use of the Original Work including, without limitation, damages for loss
of goodwill, work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses. This limitation of liability shall not
apply to the extent applicable law prohibits such limitation.
9) Acceptance and Termination. If, at any time, You expressly assented to this
License, that assent indicates your clear and irrevocable acceptance of this
License and all of its terms and conditions. If You distribute or communicate
copies of the Original Work or a Derivative Work, You must make a reasonable
effort under the circumstances to obtain the express assent of recipients to
the terms of this License. This License conditions your rights to undertake
the activities listed in Section 1, including your right to create Derivative
Works based upon the Original Work, and doing so without honoring these terms
and conditions is prohibited by copyright law and international treaty.
Nothing in this License is intended to affect copyright exceptions and
limitations (including "fair use" or "fair dealing"). This License shall
terminate immediately and You may no longer exercise any of the rights granted
to You by this License upon your failure to honor the conditions in Section
1(c).
10) Termination for Patent Action. This License shall terminate automatically
and You may no longer exercise any of the rights granted to You by this
License as of the date You commence an action, including a cross-claim or
counterclaim, against Licensor or any licensee alleging that the Original Work
infringes a patent. This termination provision shall not apply for an action
alleging patent infringement by combinations of the Original Work with other
software or hardware.
11) Jurisdiction, Venue and Governing Law. Any action or suit relating to this
License may be brought only in the courts of a jurisdiction wherein the
Licensor resides or in which Licensor conducts its primary business, and under
the laws of that jurisdiction excluding its conflict-of-law provisions. The
application of the United Nations Convention on Contracts for the
International Sale of Goods is expressly excluded. Any use of the Original
Work outside the scope of this License or after its termination shall be
subject to the requirements and penalties of copyright or patent law in the
appropriate jurisdiction. This section shall survive the termination of this
License.
12) Attorneys' Fees. In any action to enforce the terms of this License or
seeking damages relating thereto, the prevailing party shall be entitled to
recover its costs and expenses, including, without limitation, reasonable
attorneys' fees and costs incurred in connection with such action, including
any appeal of such action. This section shall survive the termination of this
License.
13) Miscellaneous. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent necessary
to make it enforceable.
14) Definition of "You" in This License. "You" throughout this License,
whether in upper or lower case, means an individual or a legal entity
exercising rights under, and complying with all of the terms of, this License.
For legal entities, "You" includes any entity that controls, is controlled by,
or is under common control with you. For purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the direction or
management of such entity, whether by contract or otherwise, or (ii) ownership
of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial
ownership of such entity.
15) Right to Use. You may use the Original Work in all ways not otherwise
restricted or conditioned by this License or by law, and Licensor promises not
to interfere with or be responsible for such uses by You.
16) Modification of This License. This License is Copyright © 2005 Lawrence
Rosen. Permission is granted to copy, distribute, or communicate this License
without modification. Nothing in this License permits You to modify this
License as applied to the Original Work or to Derivative Works. However, You
may modify the text of this License and copy, distribute or communicate your
modified version (the "Modified License") and apply it to other original works
of authorship subject to the following conditions: (i) You may not indicate in
any way that your Modified License is the "Open Software License" or "OSL" and
you may not use those names in the name of your Modified License; (ii) You
must replace the notice specified in the first paragraph above with the notice
"Licensed under <insert your license name here>" or with a notice of your own
that is not confusingly similar to the notice in this License; and (iii) You
may not claim that your original works are open source software unless your
Modified License has been approved by Open Source Initiative (OSI) and You
comply with its license review and certification process.

View file

@ -1,15 +1,17 @@
rootProject.name = "Awoocord"
// This file sets what projects are included. Every time you add a new project, you must add it
// to the includes below.
val canaryPlugins = arrayOf("ComponentsV2")
// Plugins are included like this
include(
"AlignThreads",
"Scout"
"Scout",
*canaryPlugins,
)
rootProject.children.forEach {
// Change kotlin to java if you'd rather use java
it.projectDir = file("plugins/${it.name}")
val isCanary = it.name in canaryPlugins
val dir = if (isCanary) "canary" else "plugins"
val name = it.name
if (isCanary) it.name += "-Beta"
it.projectDir = file("${dir}/${name}")
}