Compare commits

...

No commits in common. "main" and "builds" have entirely different histories.
main ... builds

121 changed files with 1 additions and 6897 deletions

View file

@ -1,59 +0,0 @@
name: Build
# https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#concurrency
concurrency:
group: "build"
cancel-in-progress: true
on:
push:
branches:
- main
paths-ignore:
- '*.md'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@master
with:
path: "src"
- name: Checkout builds
uses: actions/checkout@master
with:
ref: "builds"
path: "builds"
- name: Checkout Aliucord
uses: actions/checkout@master
with:
repository: "Aliucord/Aliucord"
path: "repo"
- name: Setup JDK 21
uses: actions/setup-java@v1
with:
java-version: 21
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Build Plugins
run: |
cd $GITHUB_WORKSPACE/src
chmod +x gradlew
./gradlew make generateUpdaterJson
cp {canary,plugins}/*/build/outputs/*.zip $GITHUB_WORKSPACE/builds
cp build/outputs/updater.json $GITHUB_WORKSPACE/builds
- name: Push builds
run: |
cd $GITHUB_WORKSPACE/builds
git config --local user.email "actions@github.com"
git config --local user.name "GitHub Actions"
git add .
git commit -m "Build $GITHUB_SHA" || exit 0 # do not error if nothing to commit
git push

12
.gitignore vendored
View file

@ -1,12 +0,0 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
**/build
/captures
.externalNativeBuild
.cxx
local.properties
/libs

BIN
AlignThreads.zip Normal file

Binary file not shown.

BIN
Bubbles.zip Normal file

Binary file not shown.

BIN
Clump.zip Normal file

Binary file not shown.

BIN
ComponentsV2Beta.zip Normal file

Binary file not shown.

BIN
Glance.zip Normal file

Binary file not shown.

19
LICENCE
View file

@ -1,19 +0,0 @@
Copyright (c) 2025 Cilly Leang
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,43 +0,0 @@
# Awoocord Plugins
## [Bubbles](plugins/Crocosmia) | [Download](https://github.com/cillynder/Awoocord/raw/builds/Bubbles.zip)
Wrap messages in bubbles inspired by Material 3 Expressive
## [Clump](plugins/Bocchi) | [Download](https://github.com/cillynder/Awoocord/raw/builds/Clump.zip)
Group messages more leniently (e.g. mentions, attachments, etc..), reducing clutter and wasted space.
## [Glance](plugins/Myosotis) | [Download](https://github.com/cillynder/Awoocord/raw/builds/Glance.zip)
Backports DM previews similar to latest RN. Shows you a line of the last message sent in a DM.
## [RoleBlocks](plugins/Zinnia) | [Download](https://github.com/cillynder/Awoocord/raw/builds/RoleBlocks.zip)
Apply the role colour as a background of usernames, improving contrast with some role colours
## [Scout](plugins/Scout) | [Download](https://github.com/cillynder/Awoocord/raw/builds/Scout.zip)
Vastly improves the search experience on Aliucord.
Features:
- Sort by oldest messages first
- Sort by oldest first
- Filter by date (before, during, after)
- Exclude certain messages (opposite of `in:`) (not even desktop has this!)
- Search by user ID
- Search in threads
Fixes:
- Removes the large padding from the top, most noticable if your device has a large status bar
- Removes the unnecessary #0000 discriminator
# WIP Backports
## [SlashCommandsFix](canary/SlashCommandsFix) | [Download](https://github.com/cillynder/Awoocord/raw/builds/SlashCommandsFixBeta.zip)
Fixes slash commands not showing up.
## [ComponentsV2](canary/ComponentsV2) | [Download](https://github.com/cillynder/Awoocord/raw/builds/ComponentsV2Beta.zip)
Fix missing/empty bot messages using the new embed system. Such messages will be marked "CV2" as part of its tag.

BIN
RoleBlocks.zip Normal file

Binary file not shown.

BIN
Scout.zip Normal file

Binary file not shown.

BIN
SlashCommandsFixBeta.zip Normal file

Binary file not shown.

View file

@ -1,78 +0,0 @@
@file:Suppress("UnstableApiUsage")
import com.aliucord.gradle.AliucordExtension
import com.android.build.gradle.LibraryExtension
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidExtension
import org.jlleitschuh.gradle.ktlint.KtlintExtension
plugins {
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.aliucord.plugin) apply true
alias(libs.plugins.ktlint) apply false
alias(libs.plugins.shadow) apply false
}
subprojects {
val libs = rootProject.libs
apply {
plugin(libs.plugins.android.library.get().pluginId)
plugin(libs.plugins.aliucord.plugin.get().pluginId)
plugin(libs.plugins.kotlin.android.get().pluginId)
plugin(libs.plugins.ktlint.get().pluginId)
}
configure<LibraryExtension> {
compileSdk = 36
namespace = "moe.lava.awoocord"
defaultConfig {
minSdk = 21
}
buildFeatures {
aidl = false
buildConfig = true
renderScript = false
shaders = false
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
}
configure<AliucordExtension> {
author("cilly", 368398754077868032L, hyperlink = false)
github("https://github.com/cillynder/Awoocord")
}
configure<KtlintExtension> {
version.set(libs.versions.ktlint.asProvider())
coloredOutput.set(true)
outputColorName.set("RED")
ignoreFailures.set(true)
}
configure<KotlinAndroidExtension> {
compilerOptions {
jvmTarget = JvmTarget.JVM_21
optIn.add("kotlin.RequiresOptIn")
}
}
@Suppress("unused")
dependencies {
val compileOnly by configurations
val implementation by configurations
compileOnly(libs.discord)
compileOnly(libs.aliucord)
compileOnly(libs.aliuhook)
compileOnly(libs.kotlin.stdlib)
}
}

View file

@ -1,69 +0,0 @@
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
version = "8.8.0"
description = "Beta backport of ComponentsV2"
android {
namespace = "moe.lava.corenary.componentsv2"
}
aliucord {
// Changelog of your plugin
changelog.set("""
TODO {fixed}
======================
* File component
* SelectV2: searching
* SelectV2: showing selected items in chat list
Changelog {added marginTop}
======================
# 8.8.0
* Fix a possible weird crash
# 8.7.0
* Prevent ViewRaw crash
* Add a CV2 tag to distinguish new embeds (will not be in core)
# 7.15.1
* Fix broken reply preview >w<
# 7.15.0
* Initial release >w<
""".trimIndent())
deploy.set(true)
}
apply {
plugin(libs.plugins.shadow.get().pluginId)
}
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

@ -1,137 +0,0 @@
package com.aliucord.coreplugins
import android.annotation.SuppressLint
import android.view.View
import android.widget.TextView
import com.aliucord.Constants
import com.aliucord.Utils
import com.aliucord.api.PatcherAPI
import com.aliucord.coreplugins.componentsv2.ComponentV2Type
import com.aliucord.patcher.*
import com.aliucord.utils.GsonUtils
import com.aliucord.utils.GsonUtils.toJson
import com.aliucord.utils.ReflectUtils
import com.discord.api.botuikit.ComponentType
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.domain.Model
import com.discord.models.message.Message
import com.discord.widgets.chat.list.adapter.WidgetChatListAdapterItemMessage
import com.google.gson.stream.JsonReader
import java.io.File
import b.a.b.a as TypeAdapterRegistrar
import b.i.d.c as FieldNamingPolicy
import b.i.d.e as GsonBuilder
fun ComponentsV2.compat(patcher: PatcherAPI) {
// check for old cursed plugin, probably not needed anymore
val oldFile = File("${Constants.PLUGINS_PATH}/ComponentsV2-Beta.zip")
if (oldFile.exists()) {
logger.info("old plugin found, deleting and prompting restart")
oldFile.delete()
Utils.promptRestart()
return
}
// I'm sorry
// ViewRaw crashes without this
val cuteGson = GsonBuilder().run {
c = FieldNamingPolicy.m // LOWER_CASE_WITH_UNDERSCORES
TypeAdapterRegistrar.a(this)
e.add(Model.TypeAdapterFactory())
a().apply {
ReflectUtils.setField(this, "k", true)
}
}
patcher.patch(GsonUtils::class.java.getDeclaredMethod("toJsonPretty", Object::class.java))
{ (param, obj: Any) ->
if (obj is Message && obj.isComponentV2)
param.result = cuteGson.toJson(obj)
}
// add cv2 tag
patcher.after<WidgetChatListAdapterItemMessage>("configureItemTag", Message::class.java, Boolean::class.javaPrimitiveType!!)
{ (_, msg: Message) ->
val textView = ReflectUtils.getField(this, "itemTag") as TextView?
?: return@after
if (!msg.isComponentV2)
return@after
if (textView.text.isEmpty()) {
// this code path shouldn't really ever run (only bots can send cv2, and bots have the tag already)
// but idk maybe someone self-bots or something
textView.visibility = View.VISIBLE
@SuppressLint("SetTextI18n")
textView.text = "CV2"
textView.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0)
} else {
@SuppressLint("SetTextI18n")
textView.text = textView.text.toString() + " | CV2"
}
}
ComponentV2Type.make()
patchGson(patcher)
}
fun ComponentsV2.stopCompat() {
unpatchGson()
ComponentV2Type.unmake(logger)
}
private fun patchGson(patcher: PatcherAPI) {
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)
}
}
object CV2Compat {
/** 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 }

View file

@ -1,165 +0,0 @@
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.discord.api.botuikit.*
import com.discord.models.botuikit.*
import com.discord.models.message.Message
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.lytefast.flexinput.R
import de.robv.android.xposed.XposedBridge
val Message.isComponentV2 get() = ((flags ?: 0) shr 15) and 1 == 1L
@AliucordPlugin(requiresRestart = true)
@Suppress("unused")
class ComponentsV2 : Plugin() {
override fun start(context: Context) {
compat(patcher)
XposedBridge.makeClassInheritable(BotUiComponentEntry::class.java)
// 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
}
}
patcher.after<Message>("shouldShowReplyPreviewAsAttachment") { param ->
if (this.isComponentV2) param.result = true
}
}
override fun stop(context: Context) {
patcher.unpatchAll()
stopCompat()
}
}

View file

@ -1,41 +0,0 @@
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

@ -1,63 +0,0 @@
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

@ -1,44 +0,0 @@
package com.aliucord.coreplugins.componentsv2
import com.aliucord.api.PatcherAPI
import com.aliucord.coreplugins.isComponentV2
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.isComponentV2) {
val fields = BotUiComponentV2Entry.V2Fields(state, meId, channel, guildMembers, guildRoles)
result[index] = BotUiComponentV2Entry.fromV1(entry, fields)
}
}
}
}

View file

@ -1,41 +0,0 @@
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

@ -1,33 +0,0 @@
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

@ -1,37 +0,0 @@
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

@ -1,48 +0,0 @@
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

@ -1,32 +0,0 @@
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

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

View file

@ -1,32 +0,0 @@
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

@ -1,34 +0,0 @@
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

@ -1,86 +0,0 @@
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
import b.a.k.b as FormatUtils
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 =
FormatUtils.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

@ -1,13 +0,0 @@
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

@ -1,45 +0,0 @@
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

@ -1,121 +0,0 @@
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

@ -1,152 +0,0 @@
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

@ -1,90 +0,0 @@
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

@ -1,131 +0,0 @@
@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.CV2Compat
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 = CV2Compat.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

@ -1,57 +0,0 @@
@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

@ -1,85 +0,0 @@
@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

@ -1,42 +0,0 @@
@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

@ -1,121 +0,0 @@
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

@ -1,86 +0,0 @@
@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

@ -1,95 +0,0 @@
@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

@ -1,41 +0,0 @@
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

@ -1,14 +0,0 @@
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

@ -1,12 +0,0 @@
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

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

View file

@ -1,12 +0,0 @@
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

@ -1,9 +0,0 @@
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

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

View file

@ -1,14 +0,0 @@
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

@ -1,14 +0,0 @@
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

@ -1,15 +0,0 @@
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

@ -1,11 +0,0 @@
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

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

View file

@ -1,7 +0,0 @@
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

@ -1,11 +0,0 @@
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

@ -1,9 +0,0 @@
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

@ -1,11 +0,0 @@
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

@ -1,10 +0,0 @@
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

@ -1,14 +0,0 @@
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
}

View file

@ -1,172 +0,0 @@
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,52 +0,0 @@
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
version = "8.18.0"
description = "Beta backport of SlashCommandsFix"
aliucord {
changelog.set("""
# 8.18.0
* Don't use custom props anymore (core has them)
# 7.16.2
* Use new props
# 7.16.1
* Prompt restarts
# 7.16.0
* Initial port >w< thanks @jedenastka
""".trimIndent())
deploy.set(true)
}
apply {
plugin(libs.plugins.shadow.get().pluginId)
}
val shadowDir = File(buildDir, "intermediates/shadowed")
tasks.register<ShadowJar>("relocateJar") {
val javaTask = tasks.findByName("compileDebugJavaWithJavac")!!
val kotlinTask = tasks.findByName("compileDebugKotlin")!!
from(javaTask.outputs, kotlinTask.outputs)
relocate("com.aliucord.coreplugins.slashcommandsfix", "moe.lava.corenary.slashcommandsfix")
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

@ -1,39 +0,0 @@
/*
* This file is part of Aliucord, an Android Discord client mod.
* Copyright (c) 2021 Juby210 & Vendicated
* Licensed under the Open Software License version 3.0
*/
package com.aliucord.coreplugins.slashcommandsfix;
import com.discord.models.user.User;
import com.discord.stores.StoreStream;
import java.util.Optional;
class ApiApplication {
public final long id;
public final String name;
public final String icon;
public final ApiPermissions permissions;
public final Long botId;
public ApiApplication() {
this.id = 0;
this.name = null;
this.icon = null;
this.permissions = null;
this.botId = null;
}
public Application toModel() {
Permissions permissions = null;
if (this.permissions != null) {
permissions = this.permissions.toModel(Optional.empty());
} else {
permissions = new Permissions(null, null, null, null);
}
var usersStore = StoreStream.getUsers();
Optional<User> botUser = Optional.ofNullable(this.botId).map(userId -> usersStore.getUsers().get(userId));
return new Application(this.id, this.name, this.icon, permissions, botUser);
}
}

View file

@ -1,59 +0,0 @@
/*
* This file is part of Aliucord, an Android Discord client mod.
* Copyright (c) 2021 Juby210 & Vendicated
* Licensed under the Open Software License version 3.0
*/
package com.aliucord.coreplugins.slashcommandsfix;
import com.discord.models.commands.ApplicationCommand;
import com.discord.stores.StoreApplicationCommandsKt;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
class ApiApplicationCommand {
public final long id;
public final long applicationId;
public final String name;
public final String description;
public final List<com.discord.api.commands.ApplicationCommandOption> options;
public final ApiPermissions permissions;
public final Long defaultMemberPermissions;
public final Long guildId;
public final String version;
public final int type;
public ApiApplicationCommand() {
this.id = 0;
this.applicationId = 0;
this.name = null;
this.description = null;
this.options = null;
this.permissions = null;
this.defaultMemberPermissions = null;
this.guildId = null;
this.version = null;
this.type = 0;
}
public RemoteApplicationCommand toModel() {
var apiOptions = this.options;
if (apiOptions == null) {
apiOptions = new ArrayList<>();
}
var options = apiOptions
.stream()
.map(option -> StoreApplicationCommandsKt.toSlashCommandOption(option))
.collect(Collectors.toList());
Permissions permissions = null;
var defaultMemberPermissions = Optional.ofNullable(this.defaultMemberPermissions);
if (this.permissions != null) {
permissions = this.permissions.toModel(defaultMemberPermissions);
} else {
permissions = new Permissions(null, null, null, defaultMemberPermissions);
}
return new RemoteApplicationCommand(String.valueOf(this.id), this.applicationId, this.name, this.description, options, permissions, this.guildId, this.version, this.type);
}
}

View file

@ -1,34 +0,0 @@
/*
* This file is part of Aliucord, an Android Discord client mod.
* Copyright (c) 2021 Juby210 & Vendicated
* Licensed under the Open Software License version 3.0
*/
package com.aliucord.coreplugins.slashcommandsfix;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
class ApiApplicationIndex {
public List<ApiApplication> applications;
public List<ApiApplicationCommand> applicationCommands;
public ApiApplicationIndex() {
this.applications = null;
this.applicationCommands = null;
}
public ApplicationIndex toModel() {
var applications = new HashMap<Long, Application>();
for (var application: this.applications) {
applications.put(application.id, application.toModel());
}
var applicationCommands = new HashMap<Long, RemoteApplicationCommand>();
for (var applicationCommand: this.applicationCommands) {
applicationCommands.put(applicationCommand.id, applicationCommand.toModel());
}
return new ApplicationIndex(applications, applicationCommands);
}
}

View file

@ -1,15 +0,0 @@
/*
* This file is part of Aliucord, an Android Discord client mod.
* Copyright (c) 2021 Juby210 & Vendicated
* Licensed under the Open Software License version 3.0
*/
package com.aliucord.coreplugins.slashcommandsfix;
class ApiGuildApplicationCommandIndexUpdate {
public long guildId;
public ApiGuildApplicationCommandIndexUpdate() {
this.guildId = 0;
}
}

View file

@ -1,26 +0,0 @@
/*
* This file is part of Aliucord, an Android Discord client mod.
* Copyright (c) 2021 Juby210 & Vendicated
* Licensed under the Open Software License version 3.0
*/
package com.aliucord.coreplugins.slashcommandsfix;
import java.util.Map;
import java.util.Optional;
class ApiPermissions {
public Boolean user;
public Map<Long, Boolean> roles;
public Map<Long, Boolean> channels;
public ApiPermissions() {
this.user = null;
this.roles = null;
this.channels = null;
}
public Permissions toModel(Optional<Long> defaultMemberPermissions) {
return new Permissions(Optional.ofNullable(user), roles, channels, defaultMemberPermissions);
}
}

View file

@ -1,21 +0,0 @@
/*
* This file is part of Aliucord, an Android Discord client mod.
* Copyright (c) 2021 Juby210 & Vendicated
* Licensed under the Open Software License version 3.0
*/
package com.aliucord.coreplugins.slashcommandsfix;
import com.aliucord.Logger;
import com.discord.models.user.User;
import com.discord.utilities.user.UserUtils;
import java.util.Optional;
class Application extends com.discord.models.commands.Application {
public Permissions permissions_;
public Application(long id, String name, String icon, Permissions permissions, Optional<User> botUser) {
super(id, name, icon, null, -1, botUser.map(user -> UserUtils.INSTANCE.synthesizeApiUser(user)).orElse(null), false);
this.permissions_ = permissions;
}
}

View file

@ -1,44 +0,0 @@
/*
* This file is part of Aliucord, an Android Discord client mod.
* Copyright (c) 2021 Juby210 & Vendicated
* Licensed under the Open Software License version 3.0
*/
package com.aliucord.coreplugins.slashcommandsfix;
import java.lang.IllegalAccessException;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
class ApplicationIndex {
public Map<Long, Application> applications;
public Map<Long, RemoteApplicationCommand> applicationCommands;
public ApplicationIndex(Map<Long, Application> applications, Map<Long, RemoteApplicationCommand> applicationCommands) {
this.applications = applications;
this.applicationCommands = applicationCommands;
}
public ApplicationIndex(List<ApplicationIndex> applicationIndexes) {
this.applications = new HashMap();
this.applicationCommands = new HashMap();
for (var applicationIndex: applicationIndexes) {
this.applications.putAll(applicationIndex.applications);
this.applicationCommands.putAll(applicationIndex.applicationCommands);
}
}
public void populateCommandCounts(Field applicationCommandCountField) throws IllegalAccessException {
var applicationCommandCounts = new HashMap<Long, Integer>();
for (var applicationCommand: this.applicationCommands.values()) {
var count = applicationCommandCounts.getOrDefault(applicationCommand.getApplicationId(), 0);
count += 1;
applicationCommandCounts.put(applicationCommand.getApplicationId(), count);
}
for (var application: this.applications.values()) {
applicationCommandCountField.setInt(application, applicationCommandCounts.getOrDefault(application.getId(), 0));
}
}
}

View file

@ -1,23 +0,0 @@
/*
* This file is part of Aliucord, an Android Discord client mod.
* Copyright (c) 2021 Juby210 & Vendicated
* Licensed under the Open Software License version 3.0
*/
package com.aliucord.coreplugins.slashcommandsfix;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
class ApplicationIndexCache {
public Map<Long, ApplicationIndex> guild;
public Map<Long, ApplicationIndex> dm;
public Optional<ApplicationIndex> user;
public ApplicationIndexCache() {
this.guild = new HashMap<>();
this.dm = new HashMap<>();
this.user = Optional.empty();
}
}

View file

@ -1,16 +0,0 @@
/*
* This file is part of Aliucord, an Android Discord client mod.
* Copyright (c) 2021 Juby210 & Vendicated
* Licensed under the Open Software License version 3.0
*/
package com.aliucord.coreplugins.slashcommandsfix;
import java.util.Optional;
interface ApplicationIndexSource {
String getEndpoint();
Optional<ApplicationIndex> getFromCache(ApplicationIndexCache cache);
void insertIntoCache(ApplicationIndexCache cache, ApplicationIndex index);
void removeFromCache(ApplicationIndexCache cache);
}

View file

@ -1,39 +0,0 @@
/*
* This file is part of Aliucord, an Android Discord client mod.
* Copyright (c) 2021 Juby210 & Vendicated
* Licensed under the Open Software License version 3.0
*/
package com.aliucord.coreplugins.slashcommandsfix;
import java.util.Optional;
class ApplicationIndexSourceDm implements ApplicationIndexSource {
long channelId;
public ApplicationIndexSourceDm(long channelId) {
this.channelId = channelId;
}
@Override
public String getEndpoint() {
return String.format("/channels/%d/application-command-index", this.channelId);
}
@Override
public Optional<ApplicationIndex> getFromCache(ApplicationIndexCache cache) {
return Optional.ofNullable(
cache.dm.get(this.channelId)
);
}
@Override
public void insertIntoCache(ApplicationIndexCache cache, ApplicationIndex index) {
cache.dm.put(this.channelId, index);
}
@Override
public void removeFromCache(ApplicationIndexCache cache) {
cache.dm.remove(this.channelId);
}
}

View file

@ -1,40 +0,0 @@
/*
* This file is part of Aliucord, an Android Discord client mod.
* Copyright (c) 2021 Juby210 & Vendicated
* Licensed under the Open Software License version 3.0
*/
package com.aliucord.coreplugins.slashcommandsfix;
import java.util.Map;
import java.util.Optional;
class ApplicationIndexSourceGuild implements ApplicationIndexSource {
long guildId;
public ApplicationIndexSourceGuild(long guildId) {
this.guildId = guildId;
}
@Override
public String getEndpoint() {
return String.format("/guilds/%d/application-command-index", this.guildId);
}
@Override
public Optional<ApplicationIndex> getFromCache(ApplicationIndexCache cache) {
return Optional.ofNullable(
cache.guild.get(this.guildId)
);
}
@Override
public void insertIntoCache(ApplicationIndexCache cache, ApplicationIndex index) {
cache.guild.put(this.guildId, index);
}
@Override
public void removeFromCache(ApplicationIndexCache cache) {
cache.guild.remove(this.guildId);
}
}

View file

@ -1,33 +0,0 @@
/*
* This file is part of Aliucord, an Android Discord client mod.
* Copyright (c) 2021 Juby210 & Vendicated
* Licensed under the Open Software License version 3.0
*/
package com.aliucord.coreplugins.slashcommandsfix;
import java.util.Optional;
class ApplicationIndexSourceUser implements ApplicationIndexSource {
public ApplicationIndexSourceUser() {}
@Override
public String getEndpoint() {
return "/users/@me/application-command-index";
}
@Override
public Optional<ApplicationIndex> getFromCache(ApplicationIndexCache cache) {
return cache.user;
}
@Override
public void insertIntoCache(ApplicationIndexCache cache, ApplicationIndex index) {
cache.user = Optional.of(index);
}
@Override
public void removeFromCache(ApplicationIndexCache cache) {
cache.user = Optional.empty();
}
}

View file

@ -1,53 +0,0 @@
package com.aliucord.coreplugins.slashcommandsfix
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import com.aliucord.*
import com.aliucord.fragments.ConfirmDialog
import java.io.File
import kotlin.system.exitProcess
object ConflictCheck {
@SuppressLint("SetTextI18n")
@JvmStatic
fun run(context: Context): Boolean {
val hasFix = PluginManager.plugins.containsKey("SlashCommandsFix")
val hasForcedFix = PluginManager.plugins.containsKey("ForceSlashCommandsFixNOW")
val fromStorage = Main.settings.getBool("AC_from_storage", false)
if (hasFix) {
Logger("SlashCommandsFixBeta").warn("conflict detected")
if (hasForcedFix || fromStorage) {
Utils.threadPool.execute {
Thread.sleep(5000) // wait for app to load guh
Utils.mainThread.post {
val dialog = ConfirmDialog()
dialog
.setTitle("SlashCommandsFix Conflict")
.setDescription("You have another variant of SlashCommandsFix installed. Do you want to disable it?")
.setIsDangerous(true)
.setOnOkListener {
File(context.codeCacheDir, "Aliucord.zip").delete()
if (fromStorage)
Main.settings.setBool("AC_from_storage", false)
if (hasForcedFix)
PluginManager.disablePlugin("ForceSlashCommandsFixNOW")
val ctx = it.context
val intent = ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)
Utils.appActivity.startActivity(Intent.makeRestartActivityTask(intent!!.component))
exitProcess(0)
}
.apply { isCancelable = false }
.show(Utils.appActivity.supportFragmentManager, "SlashCommandsFix conflict")
}
}
} else {
Logger("SlashCommandsFixBeta").warn("removing myself... bye!")
File("${Constants.PLUGINS_PATH}/SlashCommandsFixBeta.zip").delete()
}
}
return hasFix
}
}

View file

@ -1,318 +0,0 @@
/*
* This file is part of Aliucord, an Android Discord client mod.
* Copyright (c) 2021 Juby210 & Vendicated
* Licensed under the Open Software License version 3.0
*/
package com.aliucord.coreplugins.slashcommandsfix;
import android.content.Context;
import android.util.Base64;
import com.aliucord.api.GatewayAPI;
import com.aliucord.Http;
import com.aliucord.Logger;
import com.aliucord.patcher.InsteadHook;
import com.aliucord.patcher.Patcher;
import com.aliucord.patcher.PreHook;
import com.aliucord.Utils;
import com.aliucord.utils.GsonUtils;
import com.aliucord.utils.RNSuperProperties;
import com.discord.api.channel.Channel;
import com.discord.models.commands.Application;
import com.discord.models.commands.ApplicationCommand;
import com.discord.models.commands.ApplicationCommandKt;
import com.discord.models.commands.ApplicationCommandLocalSendData;
import com.discord.stores.BuiltInCommandsProvider;
import com.discord.stores.StoreApplicationCommands;
import com.discord.stores.StoreApplicationCommands$requestApplicationCommands$1;
import com.discord.stores.StoreApplicationCommands$requestApplicationCommandsQuery$1;
import com.discord.stores.StoreApplicationInteractions;
import com.discord.stores.StoreChannelsSelected;
import com.discord.stores.StoreStream;
import com.discord.utilities.error.Error;
import com.discord.utilities.messagesend.MessageResult;
import com.discord.utilities.permissions.PermissionUtils;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import kotlin.jvm.functions.Function0;
import kotlin.jvm.functions.Function1;
final class Patches {
private ApplicationIndexCache applicationIndexCache;
private Logger logger;
private Method handleGuildApplicationsUpdateMethod;
private Method handleDiscoverCommandsUpdateMethod;
private Method handleQueryCommandsUpdateMethod;
private Field applicationCommandCountField;
private Field storeApplicationCommandsQueryField;
private Field errorResponseErrorField;
private Field skemaErrorSubErrorsField;
private Field skemaErrorErrorsField;
private Field skemaErrorItemCodeField;
private Field skemaErrorItemMessageField;
private Field storeApplicationCommandsBuiltInCommandsProviderField;
Patches(Logger logger) throws Throwable {
this.logger = logger;
this.applicationIndexCache = new ApplicationIndexCache();
}
@SuppressWarnings("unchecked")
public void loadPatches(Context context) throws Throwable {
this.handleGuildApplicationsUpdateMethod = StoreApplicationCommands.class.getDeclaredMethod("handleGuildApplicationsUpdate", List.class);
this.handleGuildApplicationsUpdateMethod.setAccessible(true);
this.handleDiscoverCommandsUpdateMethod = StoreApplicationCommands.class.getDeclaredMethod("handleDiscoverCommandsUpdate", List.class);
this.handleDiscoverCommandsUpdateMethod.setAccessible(true);
this.handleQueryCommandsUpdateMethod = StoreApplicationCommands.class.getDeclaredMethod("handleQueryCommandsUpdate", List.class);
this.handleQueryCommandsUpdateMethod.setAccessible(true);
this.applicationCommandCountField = Application.class.getDeclaredField("commandCount");
this.applicationCommandCountField.setAccessible(true);
this.storeApplicationCommandsQueryField = StoreApplicationCommands.class.getDeclaredField("query");
this.storeApplicationCommandsQueryField.setAccessible(true);
this.errorResponseErrorField = Error.Response.class.getDeclaredField("skemaError");
this.errorResponseErrorField.setAccessible(true);
this.skemaErrorSubErrorsField = Error.SkemaError.class.getDeclaredField("subErrors");
this.skemaErrorSubErrorsField.setAccessible(true);
this.skemaErrorErrorsField = Error.SkemaError.class.getDeclaredField("errors");
this.skemaErrorErrorsField.setAccessible(true);
this.skemaErrorItemCodeField = Error.SkemaErrorItem.class.getDeclaredField("code");
this.skemaErrorItemCodeField.setAccessible(true);
this.skemaErrorItemMessageField = Error.SkemaErrorItem.class.getDeclaredField("message");
this.skemaErrorItemMessageField.setAccessible(true);
this.storeApplicationCommandsBuiltInCommandsProviderField = StoreApplicationCommands.class.getDeclaredField("builtInCommandsProvider");
this.storeApplicationCommandsBuiltInCommandsProviderField.setAccessible(true);
var storeApplicationCommands = StoreStream.getApplicationCommands();
var storeChannelsSelected = StoreStream.getChannelsSelected();
var storeUsers = StoreStream.getUsers();
var storePermissions = StoreStream.getPermissions();
var storeGuilds = StoreStream.getGuilds();
// Browsing commands (when just a '/' is typed)
Patcher.addPatch(
StoreApplicationCommands$requestApplicationCommands$1.class.getDeclaredMethod("invoke"),
new PreHook(param -> {
var this_ = (StoreApplicationCommands$requestApplicationCommands$1) param.thisObject;
if (this_.$guildId == null) {
return;
}
var applicationIndexSource = Patches.applicationIndexSourceFromContext(this_.$guildId, storeChannelsSelected);
try {
this.passCommandData(this_.this$0, applicationIndexSource, RequestSource.BROWSE);
} catch (Exception e) {
throw new RuntimeException(e);
}
param.setResult(null);
})
);
// Completing commands
Patcher.addPatch(
StoreApplicationCommands$requestApplicationCommandsQuery$1.class.getDeclaredMethod("invoke"),
new PreHook(param -> {
var this_ = (StoreApplicationCommands$requestApplicationCommandsQuery$1) param.thisObject;
if (this_.$guildId == null) {
return;
}
var applicationIndexSource = Patches.applicationIndexSourceFromContext(this_.$guildId, storeChannelsSelected);
try {
storeApplicationCommandsQueryField.set(this_.this$0, this_.$query);
this.passCommandData(this_.this$0, applicationIndexSource, RequestSource.QUERY);
} catch (Exception e) {
throw new RuntimeException(e);
}
param.setResult(null);
})
);
// Command permission check
Patcher.addPatch(
ApplicationCommandKt.class.getDeclaredMethod("hasPermission", ApplicationCommand.class, long.class, List.class),
new InsteadHook(param -> {
var applicationCommand = (ApplicationCommand) param.args[0];
var roleIds = (List<Long>) param.args[2];
if (!(applicationCommand instanceof RemoteApplicationCommand)) {
// Allow all builtin commands
return true;
}
var remoteApplicationCommand = (RemoteApplicationCommand) applicationCommand;
var channel = storeChannelsSelected.getSelectedChannel();
var guildId = channel.i();
if (guildId == 0) {
// Allow all commands in DMs
return true;
}
var applicationId = remoteApplicationCommand.getApplicationId();
var isUser = this.requestApplicationIndex(new ApplicationIndexSourceUser())
.applications
.containsKey(applicationId);
if (isUser) {
// Allow all user application commands
return true;
}
var application = this.requestApplicationIndex(new ApplicationIndexSourceGuild(guildId))
.applications
.get(applicationId);
if (application == null) {
// Discord requested checking a command from the previous guild - ignore
// Some such requests are still processed (if the command exists in both guilds), but it's not an issue as the result doesn't matter for them anyways.
return false;
}
var user = storeUsers.getMe();
var memberPermissions = storePermissions.getGuildPermissions()
.get(guildId);
var guild = storeGuilds.getGuild(guildId);
var applicationPermission = application.permissions_.checkFor(roleIds, channel, guild, memberPermissions, user, true);
var commandPermission = remoteApplicationCommand.permissions_.checkFor(roleIds, channel, guild, memberPermissions, user, applicationPermission);
return commandPermission;
})
);
// Command error handling
Patcher.addPatch(
StoreApplicationInteractions.class.getDeclaredMethod("handleApplicationCommandResult", MessageResult.class, ApplicationCommandLocalSendData.class, Function0.class, Function1.class),
new PreHook(param -> {
var result = (MessageResult) param.args[0];
var localSendData = (ApplicationCommandLocalSendData) param.args[1];
if (result instanceof MessageResult.UnknownFailure) {
boolean invalidCommandVersion = false;
try {
var errorResponse = ((MessageResult.UnknownFailure) result)
.getError()
.getResponse();
var error = this.errorResponseErrorField.get(errorResponse);
var subErrors = ((Map<String, Error.SkemaError>) skemaErrorSubErrorsField.get(error));
var dataErrors = (List<Error.SkemaErrorItem>) skemaErrorErrorsField.get(subErrors.get("data"));
for (var dataError: dataErrors) {
var errorCode = (String) this.skemaErrorItemCodeField.get(dataError);
if (errorCode.equals("INTERACTION_APPLICATION_COMMAND_INVALID_VERSION")) {
ApplicationIndexSource applicationIndexSource = null;
var guildId = localSendData.component3();
if (guildId != null) {
applicationIndexSource = new ApplicationIndexSourceGuild(guildId);
} else {
var channelId = localSendData.component2();
applicationIndexSource = new ApplicationIndexSourceDm(channelId);
}
this.cleanApplicationIndexCache(applicationIndexSource);
var errorMessage = (String) this.skemaErrorItemMessageField.get(dataError);
Utils.showToast(errorMessage);
break;
}
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
})
);
GatewayAPI.onEvent("GUILD_APPLICATION_COMMAND_INDEX_UPDATE", ApiGuildApplicationCommandIndexUpdate.class, guildApplicationCommandIndexUpdate -> {
this.cleanApplicationIndexCache(new ApplicationIndexSourceGuild(guildApplicationCommandIndexUpdate.guildId));
return null;
});
}
private void passCommandData(StoreApplicationCommands storeApplicationCommands, Optional<ApplicationIndexSource> applicationIndexSource, RequestSource requestSource) throws Exception {
var applicationIndexes = new ArrayList();
if (applicationIndexSource.isPresent()) {
applicationIndexes.add(this.requestApplicationIndex(applicationIndexSource.get()));
}
applicationIndexes.add(this.requestApplicationIndex(new ApplicationIndexSourceUser()));
var applicationIndex = new ApplicationIndex(applicationIndexes);
applicationIndex
.applicationCommands
.entrySet()
.removeIf(applicationCommand -> applicationCommand.getValue().type != RemoteApplicationCommand.TYPE_CHAT_INPUT);
applicationIndex.populateCommandCounts(this.applicationCommandCountField);
var applications = new ArrayList<Application>(applicationIndex.applications.values());
Collections.sort(applications, (left, right) -> left.getName().compareTo(right.getName()));
applications.add(((BuiltInCommandsProvider) this.storeApplicationCommandsBuiltInCommandsProviderField.get(storeApplicationCommands)).getBuiltInApplication());
this.handleGuildApplicationsUpdateMethod.invoke(storeApplicationCommands, applications);
switch (requestSource) {
case BROWSE:
this.handleDiscoverCommandsUpdateMethod.invoke(storeApplicationCommands, new ArrayList(applicationIndex.applicationCommands.values()));
break;
case QUERY:
this.handleQueryCommandsUpdateMethod.invoke(storeApplicationCommands, new ArrayList(applicationIndex.applicationCommands.values()));
break;
}
}
private ApplicationIndex requestApplicationIndex(ApplicationIndexSource source) {
// Reuse application index from cache
var applicationIndex = source.getFromCache(applicationIndexCache);
if (!applicationIndex.isPresent()) {
try {
// Request application index from API
applicationIndex = Optional.of(
Http.Request.newDiscordRNRequest(source.getEndpoint())
.execute()
.json(GsonUtils.getGsonRestApi(), ApiApplicationIndex.class)
.toModel()
);
} catch (Exception e) {
throw new RuntimeException(e);
}
source.insertIntoCache(applicationIndexCache, applicationIndex.get());
}
return applicationIndex.get();
}
private void cleanApplicationIndexCache(ApplicationIndexSource source) {
source.removeFromCache(applicationIndexCache);
}
private static Optional<ApplicationIndexSource> applicationIndexSourceFromContext(long guildId, StoreChannelsSelected storeChannelsSelected) {
Optional<ApplicationIndexSource> applicationIndexSource = Optional.empty();
// guildId being 0 means this is a DM or a DM group
if (guildId != 0) {
applicationIndexSource = Optional.of(new ApplicationIndexSourceGuild(guildId));
} else {
// Only create a DM index source for bots
var channel = storeChannelsSelected.getSelectedChannel();
var channelType = channel.D();
if (channelType == Channel.DM) {
var user = channel.z().get(0);
var userIsBot = Optional.ofNullable(user.e())
.orElse(false);
if (userIsBot) {
var channelId = channel.k();
applicationIndexSource = Optional.of(new ApplicationIndexSourceDm(channelId));
}
}
}
return applicationIndexSource;
}
}

View file

@ -1,84 +0,0 @@
/*
* This file is part of Aliucord, an Android Discord client mod.
* Copyright (c) 2021 Juby210 & Vendicated
* Licensed under the Open Software License version 3.0
*/
package com.aliucord.coreplugins.slashcommandsfix;
import com.discord.api.channel.Channel;
import com.discord.api.permission.Permission;
import com.discord.models.guild.Guild;
import com.discord.models.user.MeUser;
import com.discord.utilities.permissions.PermissionUtils;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
class Permissions {
public Optional<Boolean> user;
public Map<Long, Boolean> roles;
public Map<Long, Boolean> channels;
public Optional<Long> defaultMemberPermissions;
public Permissions(Optional<Boolean> user, Map<Long, Boolean> roles, Map<Long, Boolean> channels, Optional<Long> defaultMemberPermissions) {
this.user = Optional.ofNullable(user).orElse(Optional.empty());
this.roles = Optional.ofNullable(roles).orElse(new HashMap<>());
this.channels = Optional.ofNullable(channels).orElse(new HashMap<>());
this.defaultMemberPermissions = Optional.ofNullable(defaultMemberPermissions).orElse(Optional.empty());
}
public boolean checkFor(List<Long> roleIds, Channel channel, Guild guild, long memberPermissions, MeUser user, boolean defaultPermission) {
var guildId = guild.component7();
var defaultChannelPermissionId = guildId - 1;
var defaultChannelPermission = this.channels.getOrDefault(defaultChannelPermissionId, defaultPermission);
var channelType = channel.D();
var channelId = channel.k();
var permissionChannelId = channelId;
// Threads inherit permissions from their parent channels
if (channelType == Channel.ANNOUNCEMENT_THREAD || channelType == Channel.PUBLIC_THREAD || channelType == Channel.PRIVATE_THREAD) {
var channelParentId = channel.u();
permissionChannelId = channelParentId;
}
var channelPermission = Optional.ofNullable(this.channels.get(permissionChannelId))
.orElse(defaultChannelPermission);
var defaultMemberPermission = this.defaultMemberPermissions
.map(
defaultMemberPermissions -> defaultMemberPermissions != 0
&& PermissionUtils.canAndIsElevated(
defaultMemberPermissions,
memberPermissions,
user.getMfaEnabled(),
guild.getMfaLevel()
)
)
.orElse(defaultPermission);
var everyoneRoleId = guildId;
var defaultRolePermission = this.roles.getOrDefault(everyoneRoleId, defaultMemberPermission);
var rolePermission = this.calculateRolePermission(roleIds, defaultRolePermission);
var userPermission = this.user.orElse(defaultMemberPermission);
var administratorPermission = PermissionUtils.canAndIsElevated(
Permission.ADMINISTRATOR,
memberPermissions,
user.getMfaEnabled(),
guild.getMfaLevel()
);
return administratorPermission || (channelPermission && (userPermission || rolePermission));
}
private boolean calculateRolePermission(List<Long> roleIds, boolean defaultPermission) {
var calculatedRolePermission = defaultPermission;
for (var roleId: roleIds) {
var rolePermission = this.roles.get(roleId);
if (rolePermission != null) {
calculatedRolePermission = rolePermission;
if (rolePermission) {
break;
}
}
}
return calculatedRolePermission;
}
}

View file

@ -1,23 +0,0 @@
/*
* This file is part of Aliucord, an Android Discord client mod.
* Copyright (c) 2021 Juby210 & Vendicated
* Licensed under the Open Software License version 3.0
*/
package com.aliucord.coreplugins.slashcommandsfix;
import com.discord.models.commands.ApplicationCommandOption;
import java.util.List;
class RemoteApplicationCommand extends com.discord.models.commands.RemoteApplicationCommand {
public Permissions permissions_;
public int type;
public static final int TYPE_CHAT_INPUT = 1;
public RemoteApplicationCommand(String id, long applicationId, String name, String description, List<ApplicationCommandOption> options, Permissions permissions, Long guildId, String version, int type) {
super(id, applicationId, name, description, options, guildId, version, null, null, null);
this.permissions_ = permissions;
this.type = type;
}
}

View file

@ -1,12 +0,0 @@
/*
* This file is part of Aliucord, an Android Discord client mod.
* Copyright (c) 2021 Juby210 & Vendicated
* Licensed under the Open Software License version 3.0
*/
package com.aliucord.coreplugins.slashcommandsfix;
enum RequestSource {
BROWSE,
QUERY;
}

View file

@ -1,34 +0,0 @@
/*
* This file is part of Aliucord, an Android Discord client mod.
* Copyright (c) 2021 Juby210 & Vendicated
* Licensed under the Open Software License version 3.0
*/
package com.aliucord.coreplugins.slashcommandsfix;
import android.content.Context;
import com.aliucord.annotations.AliucordPlugin;
import com.aliucord.entities.Plugin;
import de.robv.android.xposed.XposedBridge;
@AliucordPlugin(requiresRestart = true)
public final class SlashCommandsFix extends Plugin {
public SlashCommandsFix() {
super();
}
@Override
public void start(Context context) throws Throwable {
if (ConflictCheck.run(context)) return;
XposedBridge.makeClassInheritable(com.discord.models.commands.Application.class);
XposedBridge.makeClassInheritable(com.discord.models.commands.RemoteApplicationCommand.class);
new Patches(this.logger).loadPatches(context);
}
@Override
public void stop(Context context) {}
}

View file

@ -1,12 +0,0 @@
# Gradle
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.configureondemand=true
org.gradle.parallel=true
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# Kotlin
kotlin.code.style=official
# Android
android.useAndroidX=true

View file

@ -1,25 +0,0 @@
[versions]
aliucord = "2.6.0"
aliuhook = "1.1.4"
aliucord-gradle = "2.3.0"
android = "8.13.0"
discord = "126021"
kotlin = "2.2.20"
#noinspection GradleDependency
kotlin-stdlib = "1.5.21"
ktlint = "1.7.1"
ktlint-plugin = "13.1.0"
shadow = "8.3.8"
[libraries]
aliucord = { module = "com.aliucord:Aliucord", version.ref = "aliucord" }
aliuhook = { module = "com.aliucord:Aliuhook", version.ref = "aliuhook" }
discord = { module = "com.discord:discord", version.ref = "discord" }
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin-stdlib" }
[plugins]
aliucord-plugin = { id = "com.aliucord.plugin", version.ref = "aliucord-gradle" }
android-library = { id = "com.android.library", version.ref = "android" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-plugin" }
shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }

Binary file not shown.

View file

@ -1,8 +0,0 @@
#Wed May 28 17:22:29 GMT 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

234
gradlew vendored
View file

@ -1,234 +0,0 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
APP_BASE_NAME=${0##*/}
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

89
gradlew.bat vendored
View file

@ -1,89 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View file

@ -1,25 +0,0 @@
version = "1.0.3"
description = "More lenient message grouping"
android {
namespace = "moe.lava.awoocord.bocchi"
}
aliucord {
// Changelog of your plugin
changelog.set("""
# 1.0.3
* Clump more than 6 messages together
# 1.0.2
* Fix (inverted) webhook clumping
# 1.0.1
* Hide blank space w.r.t attachments and embeds
# 1.0.0
* Initial release >w<
""".trimIndent())
deploy.set(true)
}

View file

@ -1,59 +0,0 @@
package moe.lava.awoocord.bocchi
import android.content.Context
import android.view.View
import com.aliucord.annotations.AliucordPlugin
import com.aliucord.entities.Plugin
import com.aliucord.patcher.*
import com.aliucord.utils.accessField
import com.discord.api.message.MessageTypes
import com.discord.models.message.Message
import com.discord.utilities.view.text.SimpleDraweeSpanTextView
import com.discord.widgets.chat.list.adapter.WidgetChatListAdapterItemMessage
import com.discord.widgets.chat.list.entries.ChatListEntry
import com.discord.widgets.chat.list.entries.MessageEntry
import com.discord.widgets.chat.list.model.WidgetChatListModelMessages
private val WidgetChatListAdapterItemMessage.itemText by accessField<SimpleDraweeSpanTextView>()
@AliucordPlugin(requiresRestart = true)
@Suppress("unused")
class Bocchi : Plugin() {
override fun start(context: Context) {
patcher.after<WidgetChatListAdapterItemMessage>(
"onConfigure",
Int::class.java,
ChatListEntry::class.java,
) { (_, _: Int, entry: MessageEntry) ->
if (entry.type == ChatListEntry.MESSAGE_MINIMAL && entry.message.content.isNullOrEmpty()) {
itemText.visibility = View.GONE
}
}
patcher.instead<WidgetChatListModelMessages.Companion>(
"shouldConcatMessage",
WidgetChatListModelMessages.Items::class.java,
Message::class.java,
Message::class.java,
) { (_, items: WidgetChatListModelMessages.Items, message: Message, message2: Message?) ->
val timeDiff = (message.timestamp?.g() ?: 0L) - (message2?.timestamp?.g() ?: 0L)
return@instead !(
message2 == null ||
message2.isSystemMessage ||
message.hasThread() ||
message2.hasThread() ||
message.type !in arrayOf(MessageTypes.DEFAULT, MessageTypes.LOCAL) ||
message.author.id != message2.author.id ||
timeDiff >= 420000 || // WidgetChatListModelMessages.MESSAGE_CONCAT_TIMESTAMP_DELTA_THRESHOLD
// items.listItemMostRecentlyAdded.type !in arrayOf(0, 1, 4, 21) ||
// message2.hasAttachments() ||
// message2.hasEmbeds() ||
// message2.mentions?.isNotEmpty() == true ||
// message.mentions?.isNotEmpty() == true ||
// message.hasAttachments() ||
// message.hasEmbeds() ||
// items.concatCount >= 5 ||
(message.isWebhook && message.author?.username != message2.author.username)
)
}
}
}

View file

@ -1,12 +0,0 @@
version = "1.0.0"
description = "Bubbled messages"
aliucord {
// Changelog of your plugin
changelog.set("""
# 1.0.0
* Initial release >w<
""".trimIndent())
deploy.set(true)
}

View file

@ -1,475 +0,0 @@
package moe.lava.awoocord.crocosmia
import android.content.Context
import android.graphics.Color
import android.view.View
import android.view.View.GONE
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout
import android.widget.LinearLayout
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID
import com.aliucord.PluginManager
import com.aliucord.Utils
import com.aliucord.annotations.AliucordPlugin
import com.aliucord.api.SettingsAPI
import com.aliucord.entities.Plugin
import com.aliucord.patcher.*
import com.aliucord.utils.DimenUtils.dp
import com.aliucord.utils.ViewUtils.addTo
import com.aliucord.utils.ViewUtils.findViewById
import com.aliucord.utils.accessField
import com.discord.databinding.WidgetChatListAdapterItemBotComponentRowBinding
import com.discord.databinding.WidgetChatListAdapterItemEmbedBinding
import com.discord.utilities.color.ColorCompat
import com.discord.utilities.display.DisplayUtils
import com.discord.utilities.embed.EmbedResourceUtils
import com.discord.widgets.chat.list.adapter.*
import com.discord.widgets.chat.list.entries.*
import com.google.android.material.card.MaterialCardView
import com.google.android.material.shape.CornerFamily
import com.lytefast.flexinput.R
import de.robv.android.xposed.XC_MethodHook
import java.util.WeakHashMap
import kotlin.math.min
private val padding get() = 12.dp
//private val topPad get() = 14.dp
private val topPad get() = 6.dp
private val bigCorner get() = 24.dp.toFloat()
private val smallCorner get() = 4.dp.toFloat()
private val ChatListEntry.connectBefore get() = this.type in arrayOf(
ChatListEntry.MESSAGE_MINIMAL,
ChatListEntry.MESSAGE_EMBED,
ChatListEntry.MESSAGE_ATTACHMENT,
ChatListEntry.STICKER,
ChatListEntry.BOT_UI_COMPONENT,
101,
)
private val ChatListEntry.excepted get() = this.type in arrayOf(
ChatListEntry.REACTIONS,
)
private val WidgetChatListAdapterItemBotComponentRow.binding by accessField<WidgetChatListAdapterItemBotComponentRowBinding>()
private val WidgetChatListAdapterItemAttachment.binding get() = WidgetChatListAdapterItemAttachment.`access$getBinding$p`(this)
private val WidgetChatListAdapterItemEmbed.binding by accessField<WidgetChatListAdapterItemEmbedBinding>()
private val WidgetChatListAdapterItemSticker.binding get() = WidgetChatListAdapterItemSticker.`access$getBinding$p`(this)
private var MessageEntry.keyField by accessField<String>()
private val fullId = Utils.getResId("widget_chat_list_adapter_item_text", "layout")
private val minimalId = Utils.getResId("widget_chat_list_adapter_item_minimal", "layout")
private val bubbleId = View.generateViewId()
private const val messageLayoutTag = R.f.message // Just some random id
@Suppress("UNUSED")
@AliucordPlugin
class Crocosmia : Plugin() {
private fun createBubble(context: Context, parentHandler: View? = null): MaterialCardView {
return MaterialCardView(context).apply {
id = bubbleId
setCardBackgroundColor(
ColorCompat.getThemedColor(
this,
R.b.colorBackgroundSecondary
)
)
parentHandler?.let { parent ->
setOnClickListener { parent.performClick() }
setOnLongClickListener { parent.performLongClick() }
}
elevation = 0f
}
}
private fun WidgetChatListItem.configBubble(entry: ChatListEntry) {
itemView.findViewById<MaterialCardView>(bubbleId)?.let {
configBubble(it, entry)
}
}
private fun WidgetChatListItem.configBubble(view: MaterialCardView, entry: ChatListEntry) {
val idx = adapter.data.list.indexOf(entry)
val previousEntry = adapter.data.list.getOrNull(idx + 1)
val nextEntry = if (idx < 1) null else adapter.data.list[idx - 1]
view.shapeAppearanceModel = view.shapeAppearanceModel.toBuilder().run {
setAllCorners(CornerFamily.ROUNDED, bigCorner)
if (entry.connectBefore && previousEntry?.excepted != true) {
setTopLeftCornerSize(smallCorner)
setTopRightCornerSize(smallCorner)
}
if (nextEntry?.connectBefore == true) {
setBottomLeftCornerSize(smallCorner)
setBottomRightCornerSize(smallCorner)
}
build()
}
view.clipToOutline = true
}
override fun load(context: Context) {
hasCompactMode = PluginManager.isPluginEnabled("CompactMode")
hasHighlightMessages = PluginManager.isPluginEnabled("HighlightOwnMessages")
if (hasCompactMode) {
logger.info("Enabling compatibility with CompactMode")
compactCompatOverride = SettingsAPI("CompactMode").getInt("contentMargin", 8)
}
}
private fun compatHighlightMessages() {
val cls = try {
val cl = PluginManager.plugins["HighlightOwnMessages"]!!.javaClass
val loader = cl.classLoader!!
loader.loadClass(
$$$"cloudburst.plugins.highlightownmessages.HighlightOwnMessages$$ExternalSyntheticLambda0"
)
} catch(e: Throwable) {
logger.warn("Tried to enable compatibility with HighlightOwnMessages, but no lambda class found", e)
return
}
logger.info("Enabling compatibility with HighlightOwnMessages")
val method = cls.getDeclaredMethod("call", Object::class.java)
patcher.patch(method) { mparam ->
val param = mparam.args[0] as XC_MethodHook.MethodHookParam
val self = param.thisObject as? WidgetChatListAdapterItemMessage
?: return@patch logger.warn("Failed to cast thisObject (found: ${param.thisObject.javaClass.name})")
self.run {
val isFull = itemView.getTag(messageLayoutTag) as? Boolean
?: return@patch
itemView.findViewById<View>("chat_list_adapter_item_text").apply {
layoutParams = (layoutParams as ConstraintLayout.LayoutParams).apply {
if (isFull) {
setPadding(padding, 0, padding, padding)
} else {
setPadding(padding, padding + 2.dp, padding, padding)
}
}
}
}
}
}
override fun stop(context: Context) { patcher.unpatchAll() }
var hasCompactMode = false
var compactCompatOverride: Int? = null
var hasHighlightMessages = false
override fun start(context: Context) {
patcher.after<WidgetChatListAdapter>(
"setData",
WidgetChatListAdapter.Data::class.java,
) {
notifyItemChanged(1, Unit.a)
}
patcher.after<WidgetChatListAdapterItemEmbed>(
WidgetChatListAdapter::class.java,
) {
binding.a.layoutParams = binding.a.layoutParams.apply {
width = MATCH_PARENT
}
(binding.f.getChildAt(0) as? ConstraintLayout)?.run {
layoutParams = (layoutParams as FrameLayout.LayoutParams).apply {
width = WRAP_CONTENT
}
}
binding.f.setPadding(padding, padding, padding, padding)
binding.f.layoutParams = (binding.f.layoutParams as ConstraintLayout.LayoutParams).apply {
marginEnd = binding.f.resources.getDimension(R.d.chat_cell_horizontal_spacing_total).toInt()
}
}
patcher.instead<EmbedResourceUtils>(
"computeMaximumImageWidthPx",
Context::class.java,
) { (_, context: Context) ->
val res = context.resources
val screenWidth = DisplayUtils.getScreenSize(context).width()
val space = res.getDimensionPixelSize(R.d.uikit_guideline_chat) + res.getDimensionPixelSize(R.d.chat_cell_horizontal_spacing_total) + padding * 2
return@instead min(1440, screenWidth - space);
}
patchEmbed()
patchAttachmentInit()
patchAttachmentConfig()
patchComponentsConfig()
patchMessageInit()
patchMessageConfig()
patchStickerInit()
patchStickerConfig()
patchPollConfig()
if (hasHighlightMessages) {
compatHighlightMessages()
}
}
private fun patchAttachmentConfig() {
patcher.after<WidgetChatListAdapterItemAttachment>(
"onConfigure",
Int::class.javaPrimitiveType!!,
ChatListEntry::class.java,
) { (_, _: Int, entry: AttachmentEntry) ->
configBubble(entry)
}
}
private fun patchAttachmentInit() {
patcher.after<WidgetChatListAdapterItemAttachment>(
WidgetChatListAdapter::class.java,
) {
val mediaView = binding.h
mediaView.layoutParams =
(mediaView.layoutParams as ConstraintLayout.LayoutParams).apply {
topMargin = padding
bottomMargin = padding
marginStart = padding
marginEnd = padding
}
itemView.layoutParams = (itemView.layoutParams as ViewGroup.MarginLayoutParams).apply {
bottomMargin = 2.dp
}
binding.d.radius = 0f
binding.d.elevation = 0f
binding.d.strokeWidth = 0
binding.d.setCardBackgroundColor(Color.TRANSPARENT)
createBubble(itemView.context, binding.a).addTo(itemView as ConstraintLayout, 1) {
layoutParams = ConstraintLayout.LayoutParams(0, 0).apply {
startToStart = PARENT_ID
topToTop = PARENT_ID
bottomToBottom = PARENT_ID
endToEnd = PARENT_ID
marginStart = compactCompatOverride?.dp
?: resources.getDimension(R.d.uikit_guideline_chat).toInt()
marginEnd = resources.getDimension(R.d.chat_cell_horizontal_spacing_total).toInt()
}
}
}
}
private val marked = WeakHashMap<LinearLayout, Unit>()
private fun patchComponentsConfig() {
patcher.after<WidgetChatListAdapterItemBotComponentRow>(
"onConfigure",
Int::class.javaPrimitiveType!!,
ChatListEntry::class.java,
) { (_, _: Int, entry: BotUiComponentEntry) ->
var i = 0
val layout = binding.b
layout.layoutParams = (layout.layoutParams as ConstraintLayout.LayoutParams).apply {
marginEnd = layout.resources.getDimension(R.d.chat_cell_horizontal_spacing_total).toInt()
}
while (i < layout.childCount) {
val child = layout.getChildAt(i)
?: break
val bubble: MaterialCardView
if (child.javaClass.simpleName == "ContainerComponentView") {
bubble = (child as? ConstraintLayout)?.getChildAt(0) as? MaterialCardView
?: continue
if (i == (layout.childCount - 1)) {
((bubble.getChildAt(0) as? ConstraintLayout)?.getChildAt(1) as? LinearLayout)?.run {
if (!marked.contains(this)) {
marked[this] = Unit.a
setPadding(paddingLeft, paddingTop, paddingRight, paddingBottom + padding)
}
}
}
} else {
layout.removeViewAt(i)
bubble = createBubble(itemView.context).addTo(layout, i) {
layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
child.addTo(this) {
layoutParams = (layoutParams as LinearLayout.LayoutParams).apply {
topMargin += padding
bottomMargin += padding
rightMargin += padding
leftMargin += padding
}
}
}
bubble.setOnClickListener {
adapter.eventHandler.onMessageClicked(entry.message, false)
}
bubble.setOnLongClickListener {
adapter.eventHandler.onMessageLongClicked(entry.message, "", false)
true
}
}
bubble.shapeAppearanceModel = bubble.shapeAppearanceModel.toBuilder().run {
setAllCorners(CornerFamily.ROUNDED, smallCorner)
if (i == (layout.childCount - 1)) {
setBottomLeftCornerSize(bigCorner)
setBottomRightCornerSize(bigCorner)
}
build()
}
bubble.clipToOutline = true
i++
}
}
}
private fun patchEmbed() {
patcher.after<WidgetChatListAdapterItemEmbed>(
WidgetChatListAdapter::class.java,
) { (_) ->
binding.t.layoutParams =
(binding.t.layoutParams as ConstraintLayout.LayoutParams).apply {
topMargin = padding
bottomMargin = padding
marginStart = padding
marginEnd = padding
}
createBubble(itemView.context, binding.a).addTo(itemView as ConstraintLayout, 1) {
visibility = GONE
layoutParams = ConstraintLayout.LayoutParams(0, 0).apply {
startToStart = PARENT_ID
topToTop = PARENT_ID
bottomToBottom = PARENT_ID
endToEnd = PARENT_ID
marginStart = compactCompatOverride?.dp
?: resources.getDimension(R.d.uikit_guideline_chat).toInt()
marginEnd = resources.getDimension(R.d.chat_cell_horizontal_spacing_total).toInt()
}
}
}
patcher.after<WidgetChatListAdapterItemEmbed>(
"onConfigure",
Int::class.javaPrimitiveType!!,
ChatListEntry::class.java,
) { (_, _: Int, entry: EmbedEntry) ->
if (EmbedResourceUtils.INSTANCE.isInlineEmbed(entry.embed)) {
itemView.findViewById<View>(bubbleId).visibility = View.VISIBLE
configBubble(entry)
} else {
itemView.findViewById<View>(bubbleId).visibility = View.GONE
configBubble(binding.f, entry)
}
}
}
private fun patchMessageInit() {
patcher.after<WidgetChatListAdapterItemMessage>(
Int::class.javaPrimitiveType!!,
WidgetChatListAdapter::class.java,
) { (_, layoutId: Int) ->
val isFull = when (layoutId) {
fullId -> !hasCompactMode
minimalId -> false
else -> return@after
}
itemView.layoutParams = (itemView.layoutParams as ViewGroup.MarginLayoutParams).apply {
bottomMargin = 2.dp
}
itemView.setTag(messageLayoutTag, isFull)
if (isFull) {
itemView.findViewById<View?>("chat_list_adapter_item_text_header")?.apply {
layoutParams = (layoutParams as ConstraintLayout.LayoutParams).apply {
setPadding(
paddingLeft + padding,
paddingTop + topPad,
paddingRight + padding,
paddingBottom
)
}
}
}
itemView.findViewById<View>("chat_list_adapter_item_text").apply {
layoutParams = (layoutParams as ConstraintLayout.LayoutParams).apply {
if (isFull) {
setPadding(padding, 0, padding, padding)
} else {
setPadding(padding, padding + 2.dp, padding, padding)
}
}
}
createBubble(itemView.context, itemView).addTo(itemView as ConstraintLayout, 2) {
layoutParams = ConstraintLayout.LayoutParams(0, 0).apply {
if (isFull) {
startToStart = Utils.getResId("uikit_chat_guideline", "id")
topToTop = Utils.getResId("chat_list_adapter_item_text_header", "id")
} else {
startToStart = PARENT_ID
topToTop = Utils.getResId("chat_list_adapter_item_text", "id")
marginStart = compactCompatOverride?.dp
?: resources.getDimension(R.d.uikit_guideline_chat).toInt()
}
bottomToBottom = PARENT_ID
endToEnd = PARENT_ID
marginEnd = resources.getDimension(R.d.chat_cell_horizontal_spacing_total).toInt()
}
}
}
}
private fun patchMessageConfig() {
patcher.after<WidgetChatListAdapterItemMessage>(
"onConfigure",
Int::class.javaPrimitiveType!!,
ChatListEntry::class.java,
) { (_, _: Int, entry: MessageEntry) ->
if (entry.message.content.isNullOrEmpty()) {
itemView.findViewById<View>("chat_list_adapter_item_text").visibility = View.GONE
}
configBubble(entry)
}
}
private fun patchStickerInit() {
patcher.after<WidgetChatListAdapterItemSticker>(
WidgetChatListAdapter::class.java,
) {
binding.b.layoutParams = (binding.b.layoutParams as FrameLayout.LayoutParams).apply {
topMargin = padding
bottomMargin = padding
marginStart = padding
marginEnd = padding
}
binding.a.layoutParams = binding.a.layoutParams.apply {
width = MATCH_PARENT
}
binding.a.removeView(binding.b)
createBubble(itemView.context, binding.b).addTo(binding.a, 0) {
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
binding.b.addTo(this)
}
}
}
private fun patchStickerConfig() {
patcher.after<WidgetChatListAdapterItemSticker>(
"onConfigure",
Int::class.javaPrimitiveType!!,
ChatListEntry::class.java,
) { (_, _: Int, entry: StickerEntry) ->
configBubble(entry)
}
}
private val pollClass = try {
Class.forName("com.aliucord.coreplugins.polls.chatview.WidgetChatListAdapterItemPoll")
} catch(_: Throwable) {
null
}
private val pollField = pollClass?.getDeclaredField("pollView")?.apply { isAccessible = true }
private fun patchPollConfig() {
if (pollClass == null) return
patcher.patch(pollClass.getDeclaredMethod(
"onConfigure",
Int::class.javaPrimitiveType!!,
ChatListEntry::class.java,
)) { (param, _: Int, entry: ChatListEntry) ->
val view = pollField?.get(param.thisObject) as? MaterialCardView
view?.let {
(param.thisObject as WidgetChatListItem).configBubble(it, entry)
}
}
}
}

View file

@ -1,12 +0,0 @@
version = "1.0.0"
description = "Backports DM previews"
aliucord {
// Changelog of your plugin
changelog.set("""
# 1.0.0
* Initial release >w<
""".trimIndent())
deploy.set(true)
}

View file

@ -1,205 +0,0 @@
package moe.lava.awoocord.myosotis
import android.annotation.SuppressLint
import android.content.Context
import android.view.View
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.RecyclerView
import com.aliucord.Http
import com.aliucord.Utils
import com.aliucord.annotations.AliucordPlugin
import com.aliucord.api.GatewayAPI
import com.aliucord.entities.Plugin
import com.aliucord.patcher.after
import com.aliucord.patcher.before
import com.aliucord.patcher.component1
import com.aliucord.patcher.component2
import com.aliucord.patcher.component3
import com.aliucord.utils.ChannelUtils
import com.aliucord.utils.GsonUtils
import com.aliucord.utils.SerializedName
import com.aliucord.utils.accessField
import com.aliucord.wrappers.ChannelWrapper.Companion.id
import com.aliucord.wrappers.users.globalName
import com.discord.api.message.Message
import com.discord.databinding.WidgetChannelsListItemChannelPrivateBinding
import com.discord.models.domain.ModelMessageDelete
import com.discord.stores.StoreStream
import com.discord.utilities.color.ColorCompat
import com.discord.utilities.textprocessing.DiscordParser
import com.discord.utilities.textprocessing.MessagePreprocessor
import com.discord.utilities.textprocessing.MessageRenderContext
import com.discord.utilities.view.text.SimpleDraweeSpanTextView
import com.discord.widgets.channels.list.WidgetChannelsListAdapter
import com.discord.widgets.channels.list.items.ChannelListItem
import com.discord.widgets.channels.list.items.ChannelListItemPrivate
import com.discord.widgets.chat.list.adapter.`WidgetChatListAdapterItemMessage$getMessageRenderContext$1`
import com.discord.widgets.chat.list.adapter.`WidgetChatListAdapterItemMessage$getMessageRenderContext$4`
import com.google.gson.reflect.TypeToken
import com.lytefast.flexinput.R
import java.lang.ref.WeakReference
private val WidgetChannelsListAdapter.ItemChannelPrivate.binding
by accessField<WidgetChannelsListItemChannelPrivateBinding>()
private val responseType = TypeToken.getParameterized(List::class.java, Message::class.java).type
data class ChannelIdsPayload(
@SerializedName("channel_ids") val channelIds: List<Long>,
)
data class MessageItem(
val id: Long,
val content: String?,
)
fun Message.wrap(): MessageItem {
val author = this.e()
val authorName = if (author.id == StoreStream.getUsers().me.id) {
"You"
} else {
author.globalName ?: author.username
}
val content = this.i()
.takeIf { it.isNotEmpty() }
?.let { content -> "$authorName: ${content.takeWhile { it != '\n' }}" }
return MessageItem(
id = this.o(),
content = content,
)
}
fun SimpleDraweeSpanTextView.renderText(content: String, other: Pair<Long, String>) {
val me = StoreStream.getUsers().me
val meId = me.id
val meName = me.globalName ?: me.username
val processor = MessagePreprocessor(meId, listOf(), null, false, 50)
val parseChannelMessage = DiscordParser.parseChannelMessage(
context,
content,
MessageRenderContext(
context,
meId,
false,
mapOf(meId to meName, other),
StoreStream.getChannels().channelNames,
mapOf(),
R.b.colorTextLink,
`WidgetChatListAdapterItemMessage$getMessageRenderContext$1`.INSTANCE,
{ },
ColorCompat.getThemedColor(context, R.b.theme_chat_spoiler_bg),
ColorCompat.getThemedColor(context, R.b.theme_chat_spoiler_bg_visible),
{ },
{ },
`WidgetChatListAdapterItemMessage$getMessageRenderContext$4`(context)
),
processor,
DiscordParser.ParserOptions.DEFAULT,
false
)
setDraweeSpanStringBuilder(parseChannelMessage);
}
@AliucordPlugin
class Myosotis : Plugin() {
var cache = mutableMapOf<Long, MessageItem>()
var adapterRef: WeakReference<WidgetChannelsListAdapter>? = null
override fun stop(context: Context) { patcher.unpatchAll() }
override fun start(context: Context) {
GatewayAPI.onEvent<Any>("READY") { refreshAll() }
GatewayAPI.onEvent<Any>("RESUMED") { refreshAll() }
patcher.after<WidgetChannelsListAdapter.ItemChannelPrivate>(
"onConfigure",
Int::class.java,
ChannelListItem::class.java,
) { (_, _: Int, item: ChannelListItemPrivate) ->
cache[item.channel.id]?.let { msg ->
val content = msg.content
?: return@let
val descView = binding.d
descView.visibility = View.VISIBLE
val user = ChannelUtils.getDMRecipient(item.channel)
descView.renderText(content, user.id to (user.globalName ?: user.username))
}
}
patcher.after<WidgetChannelsListAdapter>(
RecyclerView::class.java,
FragmentManager::class.java,
) {
adapterRef = WeakReference(this)
}
patcher.before<StoreStream>(
"handleMessageCreate",
Message::class.java
) { (_, msg: Message) ->
handleMessageUpdate(msg)
}
patcher.before<StoreStream>(
"handleMessageUpdate",
Message::class.java
) { (_, msg: Message) ->
handleMessageUpdate(msg)
}
patcher.before<StoreStream>(
"handleMessageDelete",
ModelMessageDelete::class.java
) { (_, deleteModel: ModelMessageDelete) ->
cache[deleteModel.channelId]?.let { msg ->
if (msg.id in deleteModel.messageIds) {
cache.remove(deleteModel.channelId)
rerender(deleteModel.channelId)
}
}
}
}
private fun handleMessageUpdate(msg: Message) {
val gid = msg.m()
if (gid == null) {
val channelId = msg.g()
val oldMsgId = cache[channelId]?.id ?: 0
if (msg.o() > oldMsgId) {
cache[channelId] = msg.wrap()
rerender(channelId)
}
}
}
@OptIn(ExperimentalStdlibApi::class)
private fun refreshAll() {
val channels = StoreStream.getChannels().getChannelsForGuild(0)
.filterValues { it.D() == 1 } // type == Type.DM
.keys.take(100)
Utils.threadPool.execute {
val res = Http.Request.newDiscordRNRequest("/channels/preload-messages", "POST")
.executeWithJson(ChannelIdsPayload(channels))
.json<List<Message>>(GsonUtils.gsonRestApi, responseType)
cache = mutableMapOf(*res.map { it.g() to it.wrap() }.toTypedArray())
Utils.mainThread.post {
@SuppressLint("NotifyDataSetChanged") // I DONT CARE HAHAHAAHJAHAAJHDLAHD
adapterRef?.get()?.notifyDataSetChanged()
}
}
}
private fun rerender(id: Long) {
val adapter = adapterRef?.get() ?: return
val idx = adapter.internalData.indexOfFirst { it.key == "3$id" }
logger.info("found $idx for $id")
if (idx != -1) {
Utils.mainThread.post {
adapter.notifyItemChanged(idx)
}
}
}
}

View file

@ -1,57 +0,0 @@
version = "1.4.0"
description = "Backported and improved search functionality"
android {
namespace = "moe.lava.awoocord.scout"
}
aliucord {
// Changelog of your plugin
changelog.set("""
!!! Minimum Aliucord version requirement {fixed}
======================
* Scout now requires Aliucord 2.4.0, please update before reporting issues.
Changelog {added marginTop}
======================
# 1.4.0 - Scout is searching for clues about the elusive MvM update
* Added the authorType filter option to search by user, bot, or webhook
* Moved sort filter to the top of the new ones
* Fixes a Discord bug where typing "mentions" would also suggest "has"
* Some people said the options were getting bloated, so they're all hidden behind a "Show all" button now. They'll still show up in auto suggestions.
# 1.3.0
* Removes empty discriminator when searching with users
# 1.2.2
* Fix possible rare crash related to thread searching
# 1.2.1
* Fixes off-looking thread icon
Only Discord will name an icon "thread_white_24dp", and it's neither white nor 24dp. Seriously, what were they thinking?
# 1.2.0 - Scout is in:to knitting
* Adds support for searching threads; simply use in:
# 1.1.3
* Patch to fix the biggggg top padding in results
# 1.1.2
* Fix month being one month behind after using the date picker
# 1.1.1
* Use proper icons for search filter suggestions
# 1.1.0 - Look out, Scout has:updates
* Add "has:forward" and "has:poll" filters
* Add "exclude:" filter. It is the opposite of "has:" and filters out matching elements
# 1.0.1
* Fix not being able to search more than one page with sort:old
# 1.0.0
* Initial release >w<
""".trimIndent())
deploy.set(true)
}

View file

@ -1,17 +0,0 @@
package moe.lava.awoocord.scout
import com.discord.utilities.search.query.FilterType
object FilterTypeExtension {
lateinit var EXPAND: FilterType
lateinit var SORT: FilterType
lateinit var BEFORE: FilterType
lateinit var DURING: FilterType
lateinit var AFTER: FilterType
lateinit var EXCLUDE: FilterType
lateinit var AUTHOR_TYPE: FilterType
lateinit var dates: Array<FilterType>
lateinit var filters: Array<FilterType>
lateinit var values: Array<FilterType>
}

View file

@ -1,9 +0,0 @@
package moe.lava.awoocord.scout
import com.discord.utilities.search.query.node.answer.HasAnswerOption
object HasAnswerOptionExtension {
lateinit var POLL: HasAnswerOption
lateinit var SNAPSHOT: HasAnswerOption
lateinit var values: Array<HasAnswerOption>
}

View file

@ -1,940 +0,0 @@
@file:Suppress("EnumValuesSoftDeprecate", "CanConvertToMultiDollarString")
/**
* Hi to anyone who might be reading this; I am sorry for the atrocious code in this plugin
* but I promise I'll be fixing it up soon :3
*/
package moe.lava.awoocord.scout
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Resources
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import androidx.core.content.res.ResourcesCompat
import com.aliucord.Utils
import com.aliucord.annotations.AliucordPlugin
import com.aliucord.entities.Plugin
import com.aliucord.patcher.PreHook
import com.aliucord.patcher.after
import com.aliucord.patcher.before
import com.aliucord.patcher.component1
import com.aliucord.patcher.component2
import com.aliucord.patcher.component3
import com.aliucord.patcher.component4
import com.aliucord.patcher.component5
import com.aliucord.patcher.instead
import com.aliucord.utils.DimenUtils.dp
import com.aliucord.utils.RxUtils.subscribe
import com.aliucord.utils.ViewUtils.findViewById
import com.aliucord.utils.accessField
import com.aliucord.wrappers.ChannelWrapper.Companion.id
import com.discord.BuildConfig
import com.discord.api.channel.Channel
import com.discord.api.channel.ChannelUtils
import com.discord.api.channel.`ChannelUtils$getSortByNameAndType$1`
import com.discord.api.permission.Permission
import com.discord.databinding.WidgetSearchSuggestionItemHeaderBinding
import com.discord.databinding.WidgetSearchSuggestionsItemHasBinding
import com.discord.databinding.WidgetSearchSuggestionsItemSuggestionBinding
import com.discord.models.member.GuildMember
import com.discord.models.user.User
import com.discord.restapi.RequiredHeadersInterceptor
import com.discord.restapi.RestAPIBuilder
import com.discord.simpleast.core.parser.ParseSpec
import com.discord.simpleast.core.parser.Parser
import com.discord.simpleast.core.parser.Rule
import com.discord.stores.StoreSearch
import com.discord.stores.StoreSearchInput
import com.discord.stores.StoreStream
import com.discord.utilities.mg_recycler.MGRecyclerDataPayload
import com.discord.utilities.mg_recycler.SingleTypePayload
import com.discord.utilities.rest.RestAPI.AppHeadersProvider
import com.discord.utilities.search.network.`SearchFetcher$getRestObservable$3`
import com.discord.utilities.search.network.SearchQuery
import com.discord.utilities.search.query.FilterType
import com.discord.utilities.search.query.node.QueryNode
import com.discord.utilities.search.query.node.answer.ChannelNode
import com.discord.utilities.search.query.node.answer.HasAnswerOption
import com.discord.utilities.search.query.node.answer.HasNode
import com.discord.utilities.search.query.node.answer.UserNode
import com.discord.utilities.search.query.node.content.ContentNode
import com.discord.utilities.search.query.node.filter.FilterNode
import com.discord.utilities.search.query.parsing.QueryParser
import com.discord.utilities.search.query.parsing.`QueryParser$Companion$getInAnswerRule$1`
import com.discord.utilities.search.strings.ContextSearchStringProvider
import com.discord.utilities.search.strings.SearchStringProvider
import com.discord.utilities.search.suggestion.SearchSuggestionEngine
import com.discord.utilities.search.suggestion.entries.ChannelSuggestion
import com.discord.utilities.search.suggestion.entries.FilterSuggestion
import com.discord.utilities.search.suggestion.entries.HasSuggestion
import com.discord.utilities.search.suggestion.entries.SearchSuggestion
import com.discord.utilities.search.validation.SearchData
import com.discord.widgets.search.results.WidgetSearchResults
import com.discord.widgets.search.suggestions.WidgetSearchSuggestions
import com.discord.widgets.search.suggestions.`WidgetSearchSuggestions$configureUI$1`
import com.discord.widgets.search.suggestions.WidgetSearchSuggestionsAdapter
import com.franmontiel.persistentcookiejar.PersistentCookieJar
import com.franmontiel.persistentcookiejar.cache.SetCookieCache
import com.franmontiel.persistentcookiejar.persistence.SharedPrefsCookiePersistor
import com.lytefast.flexinput.R
import moe.lava.awoocord.scout.api.SearchAPIInterface
import moe.lava.awoocord.scout.entries.AuthorTypeSuggestion
import moe.lava.awoocord.scout.entries.AuthorTypeViewHolder
import moe.lava.awoocord.scout.parsing.AuthorType
import moe.lava.awoocord.scout.parsing.AuthorTypeNode
import moe.lava.awoocord.scout.parsing.DateNode
import moe.lava.awoocord.scout.parsing.SimpleParserRule
import moe.lava.awoocord.scout.parsing.SortNode
import moe.lava.awoocord.scout.parsing.UserIdNode
import moe.lava.awoocord.scout.ui.DatePickerFragment
import moe.lava.awoocord.scout.ui.ScoutResource
import moe.lava.awoocord.scout.ui.ScoutSearchStringProvider
import java.util.regex.Pattern
import b.a.k.b as FormatUtils
private val WidgetSearchSuggestionsAdapter.FilterViewHolder.binding
by accessField<WidgetSearchSuggestionsItemSuggestionBinding>()
private val WidgetSearchSuggestionsAdapter.HeaderViewHolder.binding
by accessField<WidgetSearchSuggestionItemHeaderBinding>()
@AliucordPlugin
@Suppress("unused", "unchecked_cast")
class Scout : Plugin() {
lateinit var scoutRes: ScoutResource
lateinit var ssProvider: ScoutSearchStringProvider
lateinit var searchApi: SearchAPIInterface
var optionsExpanded = false
init {
@Suppress("DEPRECATION")
needsResources = true
}
override fun load(context: Context) {
scoutRes = ScoutResource(resources!!)
ssProvider = ScoutSearchStringProvider(context)
searchApi = buildSearchApi(context)
}
override fun start(context: Context) {
extendFilterType()
extendHasAnswerOption()
extendSuggestionCategory()
fixFiltersKeying()
fixHasFilterSuggestion()
fixSearchPadding()
patchHasAnswerOption()
patchHasNode()
patchQuery()
patchQueryParser()
patchSearchUI(context)
patchThreadSupport()
patchUsernameDiscriminator()
}
override fun stop(context: Context) {
patcher.unpatchAll()
resetFilterType()
resetHasAnswerOption()
resetSuggestionCategory()
}
// Creates a new custom search API implementation, for the extra `min_id` param in search queries
private fun buildSearchApi(context: Context): SearchAPIInterface {
val appHeadersProvider = AppHeadersProvider.INSTANCE
val requiredHeadersInterceptor = RequiredHeadersInterceptor(appHeadersProvider)
val persistentCookieJar = PersistentCookieJar(SetCookieCache(), SharedPrefsCookiePersistor(context))
val restAPIBuilder = RestAPIBuilder(BuildConfig.HOST_API, persistentCookieJar)
return RestAPIBuilder.`build$default`(
restAPIBuilder,
SearchAPIInterface::class.java,
false,
0L,
listOf(requiredHeadersInterceptor),
"client_base",
false,
null,
102,
null
) as SearchAPIInterface
}
private var origFilterTypes: Array<FilterType>? = null
// Creates new pseudo-values of the `FilterType` enum for date filters
@Suppress("LocalVariableName", "AssignedValueIsNeverRead")
private fun extendFilterType() {
val cls = FilterType::class.java
val constructor = cls.declaredConstructors[0]
constructor.isAccessible = true
val field = cls.getDeclaredField("\$VALUES")
field.isAccessible = true
val values = field.get(null) as Array<FilterType>
origFilterTypes = origFilterTypes ?: values
var nextIdx = values.size
val EXPAND = constructor.newInstance("EXPAND", nextIdx++) as FilterType
val SORT = constructor.newInstance("SORT", nextIdx++) as FilterType
val EXCLUDE = constructor.newInstance("EXCLUDE", nextIdx++) as FilterType
val AUTHOR_TYPE = constructor.newInstance("AUTHOR_TYPE", nextIdx++) as FilterType
val BEFORE = constructor.newInstance("BEFORE", nextIdx++) as FilterType
val DURING = constructor.newInstance("DURING", nextIdx++) as FilterType
val AFTER = constructor.newInstance("AFTER", nextIdx++) as FilterType
FilterTypeExtension.EXPAND = EXPAND
FilterTypeExtension.SORT = SORT
FilterTypeExtension.EXCLUDE = EXCLUDE
FilterTypeExtension.AUTHOR_TYPE = AUTHOR_TYPE
FilterTypeExtension.BEFORE = BEFORE
FilterTypeExtension.DURING = DURING
FilterTypeExtension.AFTER = AFTER
FilterTypeExtension.dates = arrayOf(BEFORE, DURING, AFTER)
FilterTypeExtension.filters = arrayOf(SORT, AUTHOR_TYPE, EXCLUDE) + FilterTypeExtension.dates
FilterTypeExtension.values = arrayOf(EXPAND) + FilterTypeExtension.filters
val newValues = values.toMutableList()
newValues.addAll(FilterTypeExtension.values)
field.set(null, newValues.toTypedArray())
}
private fun resetFilterType() {
if (origFilterTypes == null)
return logger.error("No unpatched filter types?", null)
val cls = FilterType::class.java
val field = cls.getDeclaredField("\$VALUES")
field.isAccessible = true
field.set(null, origFilterTypes)
origFilterTypes = null
}
private var origHasAnswerOptions: Array<HasAnswerOption>? = null
// Creates new pseudo-values of the `HasAnswerOption` enum for poll and forwarded filters
@Suppress("LocalVariableName", "AssignedValueIsNeverRead")
private fun extendHasAnswerOption() {
val cls = HasAnswerOption::class.java
val constructor = cls.declaredConstructors[0]
constructor.isAccessible = true
val field = cls.getDeclaredField("\$VALUES")
field.isAccessible = true
val values = field.get(null) as Array<HasAnswerOption>
origHasAnswerOptions = origHasAnswerOptions ?: values
var nextIdx = values.size
val POLL = constructor.newInstance("POLL", nextIdx++, "poll") as HasAnswerOption
val SNAPSHOT = constructor.newInstance("SNAPSHOT", nextIdx++, "snapshot") as HasAnswerOption
HasAnswerOptionExtension.POLL = POLL
HasAnswerOptionExtension.SNAPSHOT = SNAPSHOT
HasAnswerOptionExtension.values = arrayOf(POLL, SNAPSHOT)
val newValues = values.toMutableList()
newValues.addAll(HasAnswerOptionExtension.values)
field.set(null, newValues.toTypedArray())
}
private fun resetHasAnswerOption() {
if (origHasAnswerOptions == null)
return logger.error("No unpatched 'has' options?", null)
val cls = HasAnswerOption::class.java
val field = cls.getDeclaredField("\$VALUES")
field.isAccessible = true
field.set(null, origHasAnswerOptions)
origHasAnswerOptions = null
}
private var origSuggestionCategories: Array<SearchSuggestion.Category>? = null
// Creates new pseudo-values of the suggestion categories to add correct headers
@Suppress("LocalVariableName", "AssignedValueIsNeverRead")
private fun extendSuggestionCategory() {
val cls = SearchSuggestion.Category::class.java
val constructor = cls.declaredConstructors[0]
constructor.isAccessible = true
val field = cls.getDeclaredField("\$VALUES")
field.isAccessible = true
val values = field.get(null) as Array<SearchSuggestion.Category>
origSuggestionCategories = origSuggestionCategories ?: values
var nextIdx = values.size
val AUTHOR_TYPE = constructor.newInstance("AUTHOR_TYPE", nextIdx++) as SearchSuggestion.Category
SuggestionCategoryExtension.AUTHOR_TYPE = AUTHOR_TYPE
SuggestionCategoryExtension.values = arrayOf(AUTHOR_TYPE)
val newValues = values.toMutableList()
newValues.addAll(SuggestionCategoryExtension.values)
field.set(null, newValues.toTypedArray())
}
private fun resetSuggestionCategory() {
if (origSuggestionCategories == null)
return logger.error("No unpatched suggestion categories?", null)
val cls = SearchSuggestion.Category::class.java
val field = cls.getDeclaredField("\$VALUES")
field.isAccessible = true
field.set(null, origSuggestionCategories)
origSuggestionCategories = null
}
// Patch to key filters properly for smoother recycling
// Thank u discord for keying every filter type the same thing!! /s
private fun fixFiltersKeying() {
patcher.instead<WidgetSearchSuggestionsAdapter.Companion>(
"getFilterItem",
FilterSuggestion::class.java,
) { (_, suggestion: FilterSuggestion) ->
SingleTypePayload(suggestion, suggestion.filterType.name, 2) // 2 = WidgetSearchSuggestionsAdapter.TYPE_FILTER
}
}
// YES DISCORD TYPO'ED THIS HAHAHAHAHAHAFAUHFAIUFHAIFBHUKFHYRISFSUOIRN
private fun fixHasFilterSuggestion() {
patcher.before<FilterSuggestion.Companion>(
"getStringRepresentation",
FilterType::class.java,
SearchStringProvider::class.java,
) { (param, filter: FilterType, provider: SearchStringProvider) ->
if (filter == FilterType.HAS) {
param.result = provider.hasFilterString + ":"
}
}
}
// Patch out the gigantic padding in search results
private fun fixSearchPadding() {
patcher.after<WidgetSearchResults>("onViewBound", View::class.java) {
view?.run {
fitsSystemWindows = false
setPadding(paddingLeft, 16.dp, paddingRight, paddingBottom)
}
}
patcher.after<WidgetSearchSuggestions>("onViewBound", View::class.java) {
// Being a bit sneaky and reset the expanded flag here
optionsExpanded = false
view?.run {
fitsSystemWindows = false
setPadding(paddingLeft, 16.dp, paddingRight, paddingBottom)
}
}
}
// Patches various methods that use HasAnswerOption to include our new options
private fun patchHasAnswerOption() {
patcher.before<HasAnswerOption.Companion>(
"getOptionFromString",
String::class.java,
SearchStringProvider::class.java
) { param ->
val str = param.args[0] as String
if (str == ssProvider.hasPollString)
param.result = HasAnswerOptionExtension.POLL
else if (str == ssProvider.hasForwardString)
param.result = HasAnswerOptionExtension.SNAPSHOT
}
patcher.before<HasAnswerOption>(
"getLocalizedInputText",
SearchStringProvider::class.java
) { param ->
if (this == HasAnswerOptionExtension.POLL)
param.result = ssProvider.hasPollString
else if (this == HasAnswerOptionExtension.SNAPSHOT)
param.result = ssProvider.hasForwardString
}
patcher.instead<QueryParser.Companion>(
"createHasAnswerRegex",
SearchStringProvider::class.java
) { param ->
val ossProvider = param.args[0] as SearchStringProvider
val matches = HasAnswerOption.values().joinToString("|") { it.getLocalizedInputText(ossProvider) }
"^\\s*($matches)"
}
// Patch to set icons
patcher.before<WidgetSearchSuggestionsAdapter.HasViewHolder>(
"onConfigure",
Int::class.java,
MGRecyclerDataPayload::class.java,
) { param ->
val suggestion = (param.args[1] as SingleTypePayload<HasSuggestion>).data
val option = suggestion.hasAnswerOption
val resID = when (option) {
HasAnswerOptionExtension.POLL -> "baseline_poll_24"
HasAnswerOptionExtension.SNAPSHOT -> "baseline_forward_to_inbox_24"
else -> null
}
resID?.let {
val bindingField = this::class.java.getDeclaredField("binding")
bindingField.isAccessible = true
val binding = bindingField.get(this) as WidgetSearchSuggestionsItemHasBinding
binding.d.text = option.getLocalizedInputText(null)
binding.b.setOnClickListener {
WidgetSearchSuggestionsAdapter.HasViewHolder.`access$getAdapter$p`(this).onHasClicked.invoke(option)
}
binding.c.setImageDrawable(scoutRes.getDrawable(it))
param.result = null
}
}
patcher.instead<SearchSuggestionEngine>(
"getHasSuggestions",
CharSequence::class.java,
FilterType::class.java,
SearchStringProvider::class.java,
) { (_, query: CharSequence, type: FilterType, provider: SearchStringProvider) ->
// Generate entries for author type
if (type == FilterTypeExtension.AUTHOR_TYPE) {
return@instead AuthorType.values()
.filter { it.value.contains(query) }
.map { AuthorTypeSuggestion(it) }
}
// Generate entries for has options, including new ones
if (type == FilterType.HAS || type == FilterTypeExtension.EXCLUDE)
return@instead HasAnswerOption.values()
.filter { it.getLocalizedInputText(provider).contains(query) }
.map { HasSuggestion(it) }
listOf<Any>()
}
}
// Patching HasNode related methods for our exclude: filter type
private fun patchHasNode() {
patcher.instead<HasNode>("getValidFilters") {
setOf(FilterTypeExtension.EXCLUDE, FilterType.HAS)
}
// Patch updateQuery to either include or exclude our has option
patcher.instead<HasNode>(
"updateQuery",
SearchQuery.Builder::class.java,
SearchData::class.java,
FilterType::class.java,
) { param ->
val builder = param.args[0] as SearchQuery.Builder?
val filterType = param.args[2] as FilterType
checkNotNull(builder) { "queryBuilder" }
val field = HasNode::class.java.getDeclaredField("hasAnswerOption")
field.isAccessible = true
val opt = field.get(this) as HasAnswerOption
if (filterType == FilterType.HAS)
builder.appendParam("has", opt.restParamValue)
else if (filterType == FilterTypeExtension.EXCLUDE)
builder.appendParam("has", "-" + opt.restParamValue)
}
// Patching the behaviour when the has suggestion is clicked
patcher.before<StoreSearchInput>(
"onHasClicked",
HasAnswerOption::class.java,
CharSequence::class.java,
CharSequence::class.java,
List::class.java,
) { param ->
val opt = param.args[0] as HasAnswerOption
val hasFilterText = param.args[1] as CharSequence
val filterAnswer = param.args[2] as CharSequence
val query = param.args[3] as List<QueryNode>
val replaceAndPublish = StoreSearchInput::class.java.getDeclaredMethod(
"replaceAndPublish",
Int::class.javaPrimitiveType!!,
List::class.java,
List::class.java
)
replaceAndPublish.isAccessible = true
val getAnswerReplacementStart = StoreSearchInput::class.java.getDeclaredMethod(
"getAnswerReplacementStart",
List::class.java,
)
getAnswerReplacementStart.isAccessible = true
val replacementIdx = getAnswerReplacementStart.invoke(this, query) as Int
val previousFilterText = query[replacementIdx]
val filterNode = if (previousFilterText.text == ssProvider.excludeFilterString)
FilterNode(FilterTypeExtension.EXCLUDE, ssProvider.excludeFilterString)
else
FilterNode(FilterType.HAS, hasFilterText)
replaceAndPublish.invoke(this, replacementIdx, listOf(filterNode, HasNode(opt, filterAnswer)), query)
}
}
// Patches the search query to also insert `min_id`, required for searching "after:" and "during:"
private fun patchQuery() {
patcher.patch(
`SearchFetcher$getRestObservable$3`::class.java.getDeclaredMethod("call", Integer::class.java),
PreHook { param ->
val self = param.thisObject as `SearchFetcher$getRestObservable$3`<*, *>
val retryAttempts = param.args[0] as Int?
val params = self.`$searchQuery`.params
var minID = params["min_id"]
var maxID = params["max_id"]
val sortOrder = params["sort_order"]
val authorType = params["author_type"]
self.`$oldestMessageId`?.let {
if (sortOrder?.getOrNull(0) == "asc")
minID = listOf(it.toString())
else
maxID = listOf(it.toString())
}
param.result = if (self.`$searchTarget`.type == StoreSearch.SearchTarget.Type.GUILD)
searchApi.searchGuildMessages(
self.`$searchTarget`.id,
minID,
maxID,
params["author_id"],
params["mentions"],
params["channel_id"],
params["has"],
params["content"],
retryAttempts,
self.`$searchQuery`.includeNsfw,
listOf("timestamp"),
sortOrder,
authorType,
)
else
searchApi.searchChannelMessages(
self.`$searchTarget`.id,
minID,
maxID,
params["author_id"],
params["mentions"],
params["has"],
params["content"],
retryAttempts,
self.`$searchQuery`.includeNsfw,
listOf("timestamp"),
sortOrder,
authorType,
)
}
)
}
// Patch parser for date parsing
private fun patchQueryParser() {
patcher.after<QueryParser>(SearchStringProvider::class.java) {
// We need to access and insert into the rules before the rest
val field = Parser::class.java.getDeclaredField("rules").apply { isAccessible = true }
val rules = field.get(this) as ArrayList<Rule<Context, QueryNode, Any>>
rules.addAll(0, listOf(
UserIdNode.getUserIdRule(),
DateNode.getBeforeRule(ssProvider.beforeFilterString),
DateNode.getDuringRule(ssProvider.duringFilterString),
DateNode.getAfterRule(ssProvider.afterFilterString),
DateNode.getDateRule(),
SortNode.getFilterRule(ssProvider.sortFilterString),
SortNode.getSortRule(ssProvider),
AuthorTypeNode.getFilterRule(ssProvider.authorTypeFilter),
AuthorTypeNode.getAuthorTypesRule(),
SimpleParserRule(Pattern.compile("^\\s*?${ssProvider.excludeFilterString}:", 64)) { _, _, obj ->
ParseSpec(FilterNode(FilterTypeExtension.EXCLUDE, ssProvider.excludeFilterString), obj)
}
))
}
}
// This is probably the worst bit of this plugin
@SuppressLint("SetTextI18n")
private fun patchSearchUI(context: Context) {
// Run when a filter suggestion is clicked
// Most of the code is copied from its implementation
// Patch needed to support the new filter types
patcher.before<StoreSearchInput>(
"onFilterClicked",
FilterType::class.java,
SearchStringProvider::class.java,
List::class.java,
) { param ->
val filter = param.args[0] as FilterType
if (filter !in FilterTypeExtension.values)
return@before // Exit if not an extended filter type
val replaceAndPublish = StoreSearchInput::class.java.getDeclaredMethod(
"replaceAndPublish",
Int::class.javaPrimitiveType!!,
List::class.java,
List::class.java
)
replaceAndPublish.isAccessible = true
val getAnswerReplacementStart = StoreSearchInput::class.java.getDeclaredMethod(
"getAnswerReplacementStart",
List::class.java,
)
getAnswerReplacementStart.isAccessible = true
// Original implementation
val filterNode = FilterNode(filter, ssProvider.stringFor(filter))
val list = (param.args[2] as List<QueryNode>).toMutableList()
val lastIndex = if (list.isEmpty()) {
0
} else if (list.last() is ContentNode)
list.lastIndex
else
list.size
// Open a Date Picker
if (filter in FilterTypeExtension.dates) {
replaceAndPublish.invoke(this, lastIndex, listOf(filterNode), list)
DatePickerFragment.open(Utils.appActivity.supportFragmentManager) {
replaceAndPublish.invoke(this,
getAnswerReplacementStart.invoke(this, list),
listOf(filterNode, DateNode(it)),
list
)
}
}
if (filter == FilterTypeExtension.SORT)
replaceAndPublish.invoke(this,
lastIndex,
listOf(filterNode, SortNode(ssProvider.sortOldString)),
list
)
if (filter == FilterTypeExtension.EXCLUDE)
replaceAndPublish.invoke(this,
lastIndex,
listOf(filterNode),
list
)
if (filter == FilterTypeExtension.AUTHOR_TYPE)
replaceAndPublish.invoke(this,
lastIndex,
listOf(filterNode),
list
)
param.result = null
}
// Patch to set icons
@Suppress("ResourceType")
patcher.before<WidgetSearchSuggestionsAdapter.FilterViewHolder>(
"getIconDrawable",
Context::class.java,
FilterType::class.java
) { param ->
val type = param.args[1] as FilterType
val (isDiscord, resID) = when (type) {
FilterTypeExtension.BEFORE -> true to R.e.ic_history_white_24dp
FilterTypeExtension.DURING -> false to scoutRes.getDrawableId("baseline_clock_24")
FilterTypeExtension.AFTER -> false to scoutRes.getDrawableId("baseline_update_24")
FilterTypeExtension.SORT -> true to R.e.ic_sort_white_24dp
FilterTypeExtension.EXCLUDE -> false to scoutRes.getDrawableId("baseline_do_disturb_on_24")
FilterTypeExtension.AUTHOR_TYPE -> true to R.e.ic_members_24dp
else -> false to null
}
resID?.let {
val res = if (isDiscord) context.resources else resources!!
param.result = ResourcesCompat.getDrawable(res, it, null)
}
}
// Patch for retrieving sample filter answer/placeholder
patcher.before<WidgetSearchSuggestionsAdapter.FilterViewHolder>(
"getAnswerText",
FilterType::class.java
) { param ->
val type = param.args[0] as FilterType
if (type in FilterTypeExtension.dates)
param.result = ssProvider.getIdentifier("search_answer_date")
if (type == FilterTypeExtension.SORT)
param.result = ScoutResource.SORT_ANSWER
if (type == FilterTypeExtension.EXCLUDE)
param.result = ssProvider.getIdentifier("search_answer_has")
if (type == FilterTypeExtension.AUTHOR_TYPE)
param.result = ScoutResource.AUTHOR_TYPE_ANSWER
}
// Patch for retrieving filter name
patcher.before<WidgetSearchSuggestionsAdapter.FilterViewHolder>(
"getFilterText",
FilterType::class.java
) { param ->
val type = param.args[0] as FilterType
val res = when (type) {
FilterTypeExtension.EXCLUDE -> ScoutResource.EXCLUDE_FILTER
FilterTypeExtension.BEFORE -> ssProvider.getIdentifier("search_filter_before")
FilterTypeExtension.DURING -> ssProvider.getIdentifier("search_filter_during")
FilterTypeExtension.AFTER -> ssProvider.getIdentifier("search_filter_after")
FilterTypeExtension.SORT -> ScoutResource.SORT_FILTER
FilterTypeExtension.AUTHOR_TYPE -> ScoutResource.AUTHOR_TYPE_FILTER
else -> null
}
res?.let { param.result = it }
}
// Patch formatting utils to use our custom lowercase strings
// This is called by FilterViewHolder.onConfigure, using the results from getAnswerText and getFilterText
patcher.patch(
FormatUtils::class.java.getDeclaredMethod(
"c",
Resources::class.java,
Int::class.javaPrimitiveType!!,
Array::class.java,
Function1::class.java
),
PreHook { param ->
val resID = param.args[1] as Int
val objArr = param.args[2] as Array<*>
val override = when (resID) {
ScoutResource.SORT_FILTER -> ssProvider.sortFilterString
ScoutResource.SORT_ANSWER -> ssProvider.sortOldString
ScoutResource.EXCLUDE_FILTER -> ssProvider.excludeFilterString
ScoutResource.AUTHOR_TYPE_FILTER -> ssProvider.authorTypeFilter
ScoutResource.AUTHOR_TYPE_ANSWER -> ssProvider.authorTypeAnswer
else -> null
}
override?.let {
param.result = FormatUtils.g(it, objArr.copyOf(), param.args[3] as b.a.k.`b$b`)
}
}
)
// Patch to manually configure expander, need to do this to update the suggestions widget
patcher.before<WidgetSearchSuggestionsAdapter.FilterViewHolder>(
"onConfigure",
Int::class.javaPrimitiveType!!,
MGRecyclerDataPayload::class.java,
) { (param, _: Int, payload: SingleTypePayload<FilterSuggestion>) ->
val suggestion = payload.data
if (suggestion.filterType != FilterTypeExtension.EXPAND) {
return@before
}
param.result = null
val sampleText = binding.b
val layout = binding.c
val filterText = binding.d
val icon = binding.e
layout.setOnClickListener {
val onFilter = adapter.onFilterClicked as `WidgetSearchSuggestions$configureUI$1`
val widget = onFilter.`this$0`
optionsExpanded = true
WidgetSearchSuggestions.Model.Companion!!.get(ContextSearchStringProvider(context)).z().subscribe {
WidgetSearchSuggestions.`access$configureUI`(widget, this)
}
}
sampleText.text = null
filterText.text = ssProvider.expandFilterString
val drawable = R.e.ic_chevron_right_primary_300_12dp
icon.setImageDrawable(ResourcesCompat.getDrawable(context.resources, drawable, null))
}
// Patch to add our new filters into the initial suggestions
patcher.after<SearchSuggestionEngine>(
"getFilterSuggestions",
CharSequence::class.java,
SearchStringProvider::class.java,
Boolean::class.javaPrimitiveType!!,
) { (param, query: CharSequence) ->
val res = (param.result as List<SearchSuggestion>).toMutableList()
if (optionsExpanded || query != "") {
for (type in FilterTypeExtension.filters) {
val st = ssProvider.stringFor(type) + ":"
if (st.contains(query))
res.add(FilterSuggestion(type))
}
} else {
res.add(FilterSuggestion(FilterTypeExtension.EXPAND))
}
param.result = res.toList()
}
// Patch to add header for new categories
patcher.before<WidgetSearchSuggestionsAdapter.HeaderViewHolder>(
"onConfigure",
Int::class.javaPrimitiveType!!,
MGRecyclerDataPayload::class.java,
) { (param, _: Int, payload: SingleTypePayload<SearchSuggestion.Category>) ->
val category = payload.data
if (category == SuggestionCategoryExtension.AUTHOR_TYPE) {
binding.b.text = "Author Type"
param.result = null
}
}
// Patch to add entries depending on category
patcher.after<WidgetSearchSuggestions.Model>(
List::class.java,
List::class.java,
) { (_, _: List<QueryNode>, suggestions: List<SearchSuggestion>) ->
var lastCategory: SearchSuggestion.Category? = null
val newItems = mutableListOf<MGRecyclerDataPayload>()
suggestions.forEach {
if (it is AuthorTypeSuggestion) {
if (lastCategory != it.category) {
newItems.add(
SingleTypePayload(it.category, it.category.name, 0)
)
lastCategory = it.category
}
newItems.add(
SingleTypePayload(it, it.type.value, SuggestionCategoryExtension.AdapterType.AUTHOR_TYPE)
)
}
}
suggestionItems.removeAll { it in newItems }
suggestionItems.addAll(0, newItems)
}
// Patch to add new types of suggestion entries
patcher.before<WidgetSearchSuggestionsAdapter>(
"onCreateViewHolder",
ViewGroup::class.java,
Int::class.javaPrimitiveType!!,
) { (param, _: ViewGroup, id: Int) ->
when (id) {
SuggestionCategoryExtension.AdapterType.AUTHOR_TYPE -> {
param.result = AuthorTypeViewHolder(this, scoutRes)
}
}
}
}
// Adds support for searching in threads
private fun patchThreadSupport() {
// Patch query parser for in: to support names with spaces, by wrapping them in quotes
// This enables searching for threads which can have spaces in their names
patcher.instead<QueryParser.Companion>("getInAnswerRule") {
val compile = Pattern.compile("^\\s*#(\".*?\"|[^ ]+)", 64)
`QueryParser$Companion$getInAnswerRule$1`(compile, compile)
}
// Patch Search data model builder to also add in threads
patcher.before<SearchData.Builder>(
"buildForGuild",
Map::class.java,
Map::class.java,
Map::class.java,
Map::class.java
) { (
param,
/* members */ _: Map<Long, GuildMember>,
/* users*/ _: Map<Long, User>,
channels: Map<Long, Channel>,
permissions: Map<Long, Long>
) ->
val threads = StoreStream.getChannels().`getThreadsForGuildInternal$app_productionGoogleRelease`(
StoreStream.getGuildSelected().selectedGuildId
)
val mergedChannels = channels.toMutableMap()
val mergedPermissions = permissions.toMutableMap()
for (thread in threads) {
mergedChannels[thread.id] = thread
mergedPermissions[thread.id] = Permission.VIEW_CHANNEL
}
param.args[2] = mergedChannels
param.args[3] = mergedPermissions
}
// Post-process the name-id map to wrap the names in quotes if they have spaces
patcher.after<SearchData.Builder>(
"buildForGuild",
Map::class.java,
Map::class.java,
Map::class.java,
Map::class.java
) { param ->
val res = param.result as SearchData
val nameMap = res.channelNameIndex as HashMap<String, Long>
nameMap
.filter { (name) -> name.contains(" ") }
.forEach { (name, value) ->
val wrapped = "\"${name}\""
nameMap.remove(name)
nameMap[wrapped] = value
}
}
// Patch the channel node to automatically insert quotes for names with spaces
patcher.before<ChannelNode>(String::class.java) { (param, name: String) ->
if (name.contains(" ") && !name.startsWith("\""))
param.args[0] = "\"${name}\""
}
// Patch the search sorter to place threads last
patcher.before<`ChannelUtils$getSortByNameAndType$1`<*>>(
"compare",
Object::class.java, // ?? :sob:
Object::class.java,
) { (param, ch1: Channel?, ch2: Channel?) ->
if (ch1 == null || ch2 == null) return@before
// ChannelUtils.H <=> ChannelUtils.isThread
if (ChannelUtils.H(ch1) && !ChannelUtils.H(ch2)) {
param.result = 1
}
if (!ChannelUtils.H(ch1) && ChannelUtils.H(ch2)) {
param.result = -1
}
}
// Patch search suggestions to set icon to thread icon if it is a thread
patcher.after<WidgetSearchSuggestionsAdapter.InChannelViewHolder>(
"onConfigure",
Int::class.javaPrimitiveType!!,
MGRecyclerDataPayload::class.java
) { (_, _: Int, payload: SingleTypePayload<ChannelSuggestion>) ->
StoreStream.getChannels().getChannel(payload.data.channelId)?.let {
if (ChannelUtils.H(it)) {
itemView.findViewById<ImageView>("search_suggestions_item_channel_icon")
.setImageDrawable(scoutRes.getDrawable("ic_thread_actually_white_24dp"))
}
}
}
}
// Removes the #0000 discriminator from usernames when searching
private fun patchUsernameDiscriminator() {
// Change the regex for the user rule
// Previously it matches something like <username>#<discrim>
// Now it matches something like @<username>[#<discrim>] (bots still have discriminators)
// The @ is required unfortunately, to distinguish it from literally any other word
patcher.instead<QueryParser.Companion>("getUserRule") {
val regex = Pattern.compile("^\\s*@(?:([^@#:]+)#([0-9]{4})|([a-z0-9._]{2,32}))", 64)
// Returns a new rule to support our optional second group (discriminator)
return@instead SimpleParserRule(regex) { matcher, _, obj ->
val username = matcher.group(3) ?: matcher.group(1)!!
val discrim = matcher.group(2)?.toInt() ?: 0
ParseSpec(UserNode(username, discrim), obj)
}
}
// Patches the node's string representation to add an @ and remove empty discriminators
patcher.after<UserNode>("getText") { param ->
param.result = "@" + (param.result as String).replace("#0000", "")
}
}
}

View file

@ -1,12 +0,0 @@
package moe.lava.awoocord.scout
import com.discord.utilities.search.suggestion.entries.SearchSuggestion
object SuggestionCategoryExtension {
lateinit var AUTHOR_TYPE: SearchSuggestion.Category
lateinit var values: Array<SearchSuggestion.Category>
object AdapterType {
const val AUTHOR_TYPE = 7
}
}

View file

@ -1,46 +0,0 @@
package moe.lava.awoocord.scout.api
import com.discord.models.domain.ModelSearchResponse
import i0.f0.f
import i0.f0.s
import i0.f0.t
import rx.Observable
// io.f0.f = retrofit @GET
// io.f0.s = retrofit @Path
// io.f0.t = retrofit @Query
interface SearchAPIInterface {
@f("channels/{channelId}/messages/search")
fun searchChannelMessages(
@s("channelId") channelId: Long,
@t("min_id") minId: List<String>?,
@t("max_id") maxId: List<String>?,
@t("author_id") authorId: List<String>?,
@t("mentions") mentions: List<String>?,
@t("has") has: List<String>?,
@t("content") content: List<String>?,
@t("attempts") attempts: Int?,
@t("include_nsfw") includeNsfw: Boolean?,
@t("sort_by") sortBy: List<String>?, // "timestamp" is one, not sure about any other sort types
@t("sort_order") sortOrder: List<String>?, // "asc" or "desc"
@t("author_type") authorType: List<String>?,
): Observable<ModelSearchResponse?>
@f("guilds/{guildId}/messages/search")
fun searchGuildMessages(
@s("guildId") guildId: Long,
@t("min_id") minId: List<String>?,
@t("max_id") maxId: List<String>?,
@t("author_id") authorId: List<String>?,
@t("mentions") mentions: List<String>?,
@t("channel_id") channelId: List<String>?,
@t("has") has: List<String>?,
@t("content") content: List<String>?,
@t("attempts") attempts: Int?,
@t("include_nsfw") includeNsfw: Boolean?,
@t("sort_by") sortBy: List<String>?,
@t("sort_order") sortOrder: List<String>?,
@t("author_type") authorType: List<String>?,
): Observable<ModelSearchResponse?>
}

View file

@ -1,9 +0,0 @@
package moe.lava.awoocord.scout.entries
import com.discord.utilities.search.suggestion.entries.SearchSuggestion
import moe.lava.awoocord.scout.SuggestionCategoryExtension
import moe.lava.awoocord.scout.parsing.AuthorType
data class AuthorTypeSuggestion(val type: AuthorType) : SearchSuggestion {
override fun getCategory() = SuggestionCategoryExtension.AUTHOR_TYPE
}

View file

@ -1,77 +0,0 @@
package moe.lava.awoocord.scout.entries
import android.widget.ImageView
import android.widget.TextView
import com.aliucord.Utils
import com.aliucord.utils.ViewUtils.findViewById
import com.discord.stores.StoreSearchInput
import com.discord.stores.StoreStream
import com.discord.utilities.mg_recycler.MGRecyclerDataPayload
import com.discord.utilities.mg_recycler.MGRecyclerViewHolder
import com.discord.utilities.mg_recycler.SingleTypePayload
import com.discord.utilities.search.query.node.filter.FilterNode
import com.discord.widgets.search.suggestions.`WidgetSearchSuggestions$configureUI$4`
import com.discord.widgets.search.suggestions.WidgetSearchSuggestionsAdapter
import com.lytefast.flexinput.R
import moe.lava.awoocord.scout.FilterTypeExtension
import moe.lava.awoocord.scout.parsing.AuthorType
import moe.lava.awoocord.scout.parsing.AuthorTypeNode
import moe.lava.awoocord.scout.ui.ScoutResource
private val replaceAndPublish = StoreSearchInput::class.java.getDeclaredMethod(
"replaceAndPublish",
Int::class.javaPrimitiveType!!,
List::class.java,
List::class.java
).apply { isAccessible = true }
private val getAnswerReplacementStart = StoreSearchInput::class.java.getDeclaredMethod(
"getAnswerReplacementStart",
List::class.java,
).apply { isAccessible = true }
class AuthorTypeViewHolder(
adapter: WidgetSearchSuggestionsAdapter,
// This should be fine (?)
private val scoutRes: ScoutResource,
) : MGRecyclerViewHolder<WidgetSearchSuggestionsAdapter, MGRecyclerDataPayload>(
Utils.getResId("widget_search_suggestions_item_has", "layout"),
adapter,
) {
private val imageView = itemView.findViewById<ImageView>("search_suggestions_item_has_icon")
private val textView = itemView.findViewById<TextView>("search_suggestions_item_has_text")
override fun onConfigure(i: Int, oPayload: MGRecyclerDataPayload) {
super.onConfigure(i, oPayload)
@Suppress("UNCHECKED_CAST")
val payload = oPayload as SingleTypePayload<AuthorTypeSuggestion>
val type = payload.data.type
textView.text = when (type) {
AuthorType.Bot -> "bot"
AuthorType.User -> "user"
AuthorType.Webhook -> "webhook"
}
when (type) {
AuthorType.Bot -> imageView.setImageDrawable(scoutRes.getDrawable("smart_toy_24px"))
AuthorType.User -> imageView.setImageResource(R.e.ic_members_24dp)
AuthorType.Webhook -> imageView.setImageDrawable(scoutRes.getDrawable("webhook_24px"))
}
itemView.setOnClickListener {
val hasHandler = adapter.onHasClicked as `WidgetSearchSuggestions$configureUI$4`
val query = hasHandler.`$model`.query
val storeInput = StoreStream.getSearch().storeSearchInput
replaceAndPublish.invoke(
storeInput,
getAnswerReplacementStart.invoke(storeInput, query) as Int,
listOf(
FilterNode(FilterTypeExtension.AUTHOR_TYPE, "authorType"),
AuthorTypeNode(type)
),
query,
)
}
}
}

View file

@ -1,64 +0,0 @@
@file:Suppress("EnumValuesSoftDeprecate")
package moe.lava.awoocord.scout.parsing
import android.content.Context
import com.discord.simpleast.core.parser.ParseSpec
import com.discord.simpleast.core.parser.Rule
import com.discord.utilities.search.network.SearchQuery
import com.discord.utilities.search.query.FilterType
import com.discord.utilities.search.query.node.QueryNode
import com.discord.utilities.search.query.node.answer.AnswerNode
import com.discord.utilities.search.query.node.filter.FilterNode
import com.discord.utilities.search.validation.SearchData
import moe.lava.awoocord.scout.FilterTypeExtension
import java.util.regex.Pattern
// TODO: not localised, maybe one day
enum class AuthorType(val value: String) {
User("user"),
Bot("bot"),
Webhook("webhook"),
;
companion object {
fun from(value: String) = when (value) {
"user" -> User
"bot" -> Bot
"webhook" -> Webhook
else -> throw IllegalArgumentException("Unknown author type $value")
}
}
}
class AuthorTypeNode(val type: AuthorType): AnswerNode() {
companion object {
fun getAuthorTypesRule(): Rule<Context, QueryNode, Any> {
val joined = AuthorType.values().joinToString("|") { it.value }
val regexStr = "^\\s*(${joined})"
val regex = Pattern.compile(regexStr, Pattern.UNICODE_CASE)
return SimpleParserRule(regex) { matcher, _, obj ->
ParseSpec(AuthorTypeNode(AuthorType.from(matcher.group())), obj)
}
}
fun getFilterRule(str: String): ParserRule {
val regex = Pattern.compile("^\\s*?(${str}):", 64)
return SimpleParserRule(regex) { _, _, obj ->
ParseSpec(FilterNode(FilterTypeExtension.AUTHOR_TYPE, str), obj)
}
}
}
override fun getValidFilters() = setOf(FilterTypeExtension.AUTHOR_TYPE)
override fun isValid(searchData: SearchData?) = true
override fun getText() = type.value
override fun updateQuery(
builder: SearchQuery.Builder,
searchData: SearchData?,
filterType: FilterType?
) {
builder.appendParam("author_type", type.value)
}
}

View file

@ -1,73 +0,0 @@
package moe.lava.awoocord.scout.parsing
import com.discord.simpleast.core.parser.ParseSpec
import com.discord.utilities.SnowflakeUtils
import com.discord.utilities.search.network.SearchQuery
import com.discord.utilities.search.query.FilterType
import com.discord.utilities.search.query.node.answer.AnswerNode
import com.discord.utilities.search.query.node.filter.FilterNode
import com.discord.utilities.search.validation.SearchData
import moe.lava.awoocord.scout.FilterTypeExtension
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.regex.Pattern
class DateNode(private val date: Long?, private val unparsed: String) : AnswerNode() {
constructor(unparsed: String) : this(fmt.parse(unparsed)?.time, unparsed)
companion object {
val fmt = SimpleDateFormat("yyyy-MM-dd", Locale.US)
val regex: Pattern = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}", Pattern.UNICODE_CASE)
fun getDateRule(): ParserRule {
return SimpleParserRule(regex) { matcher, _, obj ->
val match = matcher.group()
val date = fmt.parse(match)
val node = DateNode(date?.time, match)
ParseSpec(node, obj)
}
}
private fun getFilterRule(str: String, type: FilterType): ParserRule {
val regex = Pattern.compile("^\\s*?(${str}):", 64)
return SimpleParserRule(regex) { _, _, obj ->
ParseSpec(FilterNode(type, str), obj)
}
}
fun getBeforeRule(str: String): ParserRule = getFilterRule(str, FilterTypeExtension.BEFORE)
fun getDuringRule(str: String): ParserRule = getFilterRule(str, FilterTypeExtension.DURING)
fun getAfterRule(str: String): ParserRule = getFilterRule(str, FilterTypeExtension.AFTER)
}
override fun getValidFilters(): Set<FilterType> = FilterTypeExtension.dates.toSet()
override fun isValid(searchData: SearchData?): Boolean = date != null
override fun getText(): CharSequence = unparsed
private val snowflake: String?
get() = date?.let { SnowflakeUtils.fromTimestamp(date).toString() }
private val nextDaySnowflake: String?
get() = date?.let { SnowflakeUtils.fromTimestamp(date + 86_400_000).toString() }
override fun updateQuery(
builder: SearchQuery.Builder?,
searchData: SearchData?,
filterType: FilterType?
) {
checkNotNull(builder) { "queryBuilder" }
checkNotNull(date) { "date" }
when (filterType) {
FilterTypeExtension.BEFORE -> {
builder.appendParam("max_id", snowflake)
}
FilterTypeExtension.AFTER -> {
builder.appendParam("min_id", nextDaySnowflake)
}
FilterTypeExtension.DURING -> {
builder.appendParam("min_id", snowflake)
builder.appendParam("max_id", nextDaySnowflake)
}
else -> return
}
}
}

Some files were not shown because too many files have changed in this diff Show more