diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..6fe7679 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,59 @@ +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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f5a8eb6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +**/build +/captures +.externalNativeBuild +.cxx +local.properties +/libs diff --git a/AlignThreads.zip b/AlignThreads.zip deleted file mode 100644 index 8ebe38e..0000000 Binary files a/AlignThreads.zip and /dev/null differ diff --git a/Bubbles.zip b/Bubbles.zip deleted file mode 100644 index 83b48d6..0000000 Binary files a/Bubbles.zip and /dev/null differ diff --git a/Clump.zip b/Clump.zip deleted file mode 100644 index 799aff1..0000000 Binary files a/Clump.zip and /dev/null differ diff --git a/ComponentsV2Beta.zip b/ComponentsV2Beta.zip deleted file mode 100644 index 3f2c186..0000000 Binary files a/ComponentsV2Beta.zip and /dev/null differ diff --git a/Glance.zip b/Glance.zip deleted file mode 100644 index 155421c..0000000 Binary files a/Glance.zip and /dev/null differ diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..4452855 --- /dev/null +++ b/LICENCE @@ -0,0 +1,19 @@ +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..63011cc --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# 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. diff --git a/RoleBlocks.zip b/RoleBlocks.zip deleted file mode 100644 index a3a74b5..0000000 Binary files a/RoleBlocks.zip and /dev/null differ diff --git a/Scout.zip b/Scout.zip deleted file mode 100644 index 1aae370..0000000 Binary files a/Scout.zip and /dev/null differ diff --git a/SlashCommandsFixBeta.zip b/SlashCommandsFixBeta.zip deleted file mode 100644 index 97981c3..0000000 Binary files a/SlashCommandsFixBeta.zip and /dev/null differ diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..6cfa17b --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,78 @@ +@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 { + 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 { + author("cilly", 368398754077868032L, hyperlink = false) + github("https://github.com/cillynder/Awoocord") + } + + configure { + version.set(libs.versions.ktlint.asProvider()) + + coloredOutput.set(true) + outputColorName.set("RED") + ignoreFailures.set(true) + } + + configure { + 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) + } +} diff --git a/canary/ComponentsV2/build.gradle.kts b/canary/ComponentsV2/build.gradle.kts new file mode 100644 index 0000000..a568738 --- /dev/null +++ b/canary/ComponentsV2/build.gradle.kts @@ -0,0 +1,69 @@ +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("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("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) + } +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/CV2Compat.kt b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/CV2Compat.kt new file mode 100644 index 0000000..598aed3 --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/CV2Compat.kt @@ -0,0 +1,137 @@ +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("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("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 } diff --git a/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/ComponentsV2.kt b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/ComponentsV2.kt new file mode 100644 index 0000000..7302dd9 --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/ComponentsV2.kt @@ -0,0 +1,165 @@ +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( + "toMessageLayoutComponent", + LayoutComponent::class.java, + Int::class.javaPrimitiveType!!, + List::class.java, + ComponentExperiments::class.java + ) { (_, layout: LayoutComponent, index: Int, components: List) -> + 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("configureView", ComponentActionListener::class.java, MessageComponent::class.java, ComponentView::class.java) + { (_, listener: ComponentActionListener, component: MessageComponent, view: ComponentView?) -> + view?.configure(component, this, listener) + } + + patcher.instead("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(WidgetChatListAdapter::class.java) + { + val rootLayout = itemView.findViewById(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( + "createActionMessageComponent", + ActionComponent::class.java, + Int::class.javaPrimitiveType!!, + ComponentStoreState::class.java, + ComponentExperiments::class.java, + ) { ( + _, + actionComponent: ActionComponent, + index: Int, + componentStoreState: ComponentStoreState, + ) -> + val interactionState: Map? = 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("shouldShowReplyPreviewAsAttachment") { param -> + if (this.isComponentV2) param.result = true + } + } + + override fun stop(context: Context) { + patcher.unpatchAll() + stopCompat() + } +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/BotUiComponentV2Entry.kt b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/BotUiComponentV2Entry.kt new file mode 100644 index 0000000..18a68b0 --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/BotUiComponentV2Entry.kt @@ -0,0 +1,41 @@ +package com.aliucord.coreplugins.componentsv2 + +import com.discord.api.channel.Channel +import com.discord.api.role.GuildRole +import com.discord.models.botuikit.MessageComponent +import com.discord.models.member.GuildMember +import com.discord.models.message.Message +import com.discord.stores.StoreMessageState +import com.discord.widgets.chat.list.entries.BotUiComponentEntry + +@Suppress("EqualsOrHashCode") +class BotUiComponentV2Entry( + message: Message, appId: Long, guildId: Long?, components: MutableList, + 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, + val guildRoles: Map, + // val channelNames: Map, + ) + + 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() +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/ComponentV2Type.kt b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/ComponentV2Type.kt new file mode 100644 index 0000000..79c35e0 --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/ComponentV2Type.kt @@ -0,0 +1,63 @@ +package com.aliucord.coreplugins.componentsv2 + +import com.aliucord.Logger +import com.discord.api.botuikit.* + +// Values added by smali patch +object ComponentV2Type { + lateinit var USER_SELECT: ComponentType + lateinit var ROLE_SELECT: ComponentType + lateinit var MENTIONABLE_SELECT: ComponentType + lateinit var CHANNEL_SELECT: ComponentType + lateinit var SECTION: ComponentType + lateinit var TEXT_DISPLAY: ComponentType + lateinit var THUMBNAIL: ComponentType + lateinit var MEDIA_GALLERY: ComponentType + lateinit var FILE: ComponentType + lateinit var SEPARATOR: ComponentType + lateinit var CONTAINER: ComponentType + + var newValues: Array? = null + private var oldValues: Array? = 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 + } +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/PatchMessageItems.kt b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/PatchMessageItems.kt new file mode 100644 index 0000000..9c13ff1 --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/PatchMessageItems.kt @@ -0,0 +1,44 @@ +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, + guildRoles: Map, + _blockedRelationships: Map?, + _referencedChannel: Channel?, + _threadStoreState: StoreThreadMessages.ThreadState?, + _message: Message, + state: StoreMessageState.State?, + _repliedMessages: Map?, + _isBlockedExpanded: Boolean, + _isMinimal: Boolean, + ) -> + @Suppress("UNCHECKED_CAST") + val result = (param.result as MutableList) + 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) + } + } + } +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/models/ContainerMessageComponent.kt b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/models/ContainerMessageComponent.kt new file mode 100644 index 0000000..62213c7 --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/models/ContainerMessageComponent.kt @@ -0,0 +1,41 @@ +package com.aliucord.coreplugins.componentsv2.models + +import com.discord.api.botuikit.ComponentType +import com.discord.api.botuikit.ContainerComponent +import com.discord.models.botuikit.MessageComponent + +data class ContainerMessageComponent( + private val type: ComponentType, + private val index: Int, + + override val id: Int, + val components: List, + 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, + ): ContainerMessageComponent { + components.forEach { + if (it is MediaGalleryMessageComponent) + it.markedContained = true + } + return component.run { + ContainerMessageComponent( + type, + index, + id, + components, + accentColor, + spoiler, + ) + } + } + } +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/models/MediaGalleryMessageComponent.kt b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/models/MediaGalleryMessageComponent.kt new file mode 100644 index 0000000..350c17c --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/models/MediaGalleryMessageComponent.kt @@ -0,0 +1,33 @@ +package com.aliucord.coreplugins.componentsv2.models + +import com.discord.api.botuikit.* +import com.discord.models.botuikit.MessageComponent + +data class MediaGalleryMessageComponent( + private val type: ComponentType, + private val index: Int, + + val id: Int, + val items: List, + // 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, + ) + } + } + } +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/models/SectionMessageComponent.kt b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/models/SectionMessageComponent.kt new file mode 100644 index 0000000..6b189ea --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/models/SectionMessageComponent.kt @@ -0,0 +1,37 @@ +package com.aliucord.coreplugins.componentsv2.models + +import com.discord.api.botuikit.ComponentType +import com.discord.api.botuikit.SectionComponent +import com.discord.models.botuikit.MessageComponent + +data class SectionMessageComponent( + private val type: ComponentType, + private val index: Int, + + val id: Int, + val components: List, + val accessory: MessageComponent?, +) : MessageComponent { + override fun getType() = type + override fun getIndex() = index + + companion object { + fun mergeToMessageComponent( + component: SectionComponent, + index: Int, + components: List, + ): SectionMessageComponent { + return component.run { + val realComponents = components.toMutableList() + val accessory = realComponents.removeAt(realComponents.lastIndex) + SectionMessageComponent( + type, + index, + id, + realComponents, + accessory, + ) + } + } + } +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/models/SelectV2MessageComponent.kt b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/models/SelectV2MessageComponent.kt new file mode 100644 index 0000000..3560f3f --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/models/SelectV2MessageComponent.kt @@ -0,0 +1,48 @@ +package com.aliucord.coreplugins.componentsv2.models + +import com.discord.api.botuikit.* +import com.discord.models.botuikit.ActionInteractionComponentState +import com.discord.models.botuikit.ActionMessageComponent +import com.discord.widgets.botuikit.ComponentChatListState + +data class SelectV2MessageComponent( + private val type: ComponentType, + private val index: Int, + private val stateInteraction: ActionInteractionComponentState, + + val id: Int, + val customId: String, + val placeholder: String, + val minValues: Int, + val maxValues: Int, + val defaultValues: List, + 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 + ) + } + } + } +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/models/SeparatorMessageComponent.kt b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/models/SeparatorMessageComponent.kt new file mode 100644 index 0000000..e055d36 --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/models/SeparatorMessageComponent.kt @@ -0,0 +1,32 @@ +package com.aliucord.coreplugins.componentsv2.models + +import com.discord.api.botuikit.ComponentType +import com.discord.api.botuikit.SeparatorComponent +import com.discord.models.botuikit.MessageComponent + +data class SeparatorMessageComponent( + private val type: ComponentType, + private val index: Int, + + val divider: Boolean, + val spacing: Int, // 1 = small padding, 2 = large padding +) : MessageComponent { + override fun getType() = type + override fun getIndex() = index + + companion object { + fun mergeToMessageComponent( + component: SeparatorComponent, + index: Int + ): SeparatorMessageComponent { + return component.run { + SeparatorMessageComponent( + type, + index, + divider, + spacing, + ) + } + } + } +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/models/SpoilableMessageComponent.kt b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/models/SpoilableMessageComponent.kt new file mode 100644 index 0000000..4219856 --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/models/SpoilableMessageComponent.kt @@ -0,0 +1,8 @@ +package com.aliucord.coreplugins.componentsv2.models + +import com.discord.models.botuikit.MessageComponent + +interface SpoilableMessageComponent : MessageComponent { + val id: Int + val spoiler: Boolean +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/models/TextDisplayMessageComponent.kt b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/models/TextDisplayMessageComponent.kt new file mode 100644 index 0000000..aeb3e75 --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/models/TextDisplayMessageComponent.kt @@ -0,0 +1,32 @@ +package com.aliucord.coreplugins.componentsv2.models + +import com.discord.api.botuikit.ComponentType +import com.discord.api.botuikit.TextDisplayComponent +import com.discord.models.botuikit.MessageComponent + +data class TextDisplayMessageComponent( + private val type: ComponentType, + private val index: Int, + + val id: Int, + val content: String, +) : MessageComponent { + override fun getType() = type + override fun getIndex() = index + + companion object { + fun mergeToMessageComponent( + component: TextDisplayComponent, + index: Int + ): TextDisplayMessageComponent { + return component.run { + TextDisplayMessageComponent( + type, + index, + id, + content + ) + } + } + } +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/models/ThumbnailMessageComponent.kt b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/models/ThumbnailMessageComponent.kt new file mode 100644 index 0000000..d4bd625 --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/models/ThumbnailMessageComponent.kt @@ -0,0 +1,34 @@ +package com.aliucord.coreplugins.componentsv2.models + +import com.discord.api.botuikit.* + +data class ThumbnailMessageComponent( + private val type: ComponentType, + private val index: Int, + + override val id: Int, + val media: UnfurledMediaItem, + val description: String?, + override val spoiler: Boolean, +) : SpoilableMessageComponent { + override fun getType() = type + override fun getIndex() = index + + companion object { + fun mergeToMessageComponent( + component: ThumbnailComponent, + index: Int + ): ThumbnailMessageComponent { + return component.run { + ThumbnailMessageComponent( + type, + index, + id, + media, + description, + spoiler, + ) + } + } + } +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/selectsheet/SelectSheet.kt b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/selectsheet/SelectSheet.kt new file mode 100644 index 0000000..bc731af --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/selectsheet/SelectSheet.kt @@ -0,0 +1,86 @@ +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) + } +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/selectsheet/SelectSheetAdapter.kt b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/selectsheet/SelectSheetAdapter.kt new file mode 100644 index 0000000..d63a58a --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/selectsheet/SelectSheetAdapter.kt @@ -0,0 +1,13 @@ +package com.aliucord.coreplugins.componentsv2.selectsheet + +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.discord.utilities.mg_recycler.MGRecyclerAdapterSimple +import com.discord.utilities.mg_recycler.MGRecyclerViewHolder + +internal class SelectSheetAdapter(recycler: RecyclerView, val viewModel: SelectSheetViewModel) + : MGRecyclerAdapterSimple(recycler) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MGRecyclerViewHolder<*, SelectSheetItem> { + return SelectSheetItemViewHolder(this) + } +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/selectsheet/SelectSheetItem.kt b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/selectsheet/SelectSheetItem.kt new file mode 100644 index 0000000..414de8d --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/selectsheet/SelectSheetItem.kt @@ -0,0 +1,45 @@ +package com.aliucord.coreplugins.componentsv2.selectsheet + +import com.aliucord.wrappers.ChannelWrapper.Companion.id +import com.discord.api.channel.Channel +import com.discord.api.role.GuildRole +import com.discord.models.member.GuildMember +import com.discord.models.user.User +import com.discord.utilities.mg_recycler.MGRecyclerDataPayload + +sealed class SelectSheetItem( + private val type: Int, + val id: Long, +) : MGRecyclerDataPayload { + override fun getKey() = id.toString() + override fun getType() = type + + abstract val checked: Boolean + abstract val disabled: Boolean + abstract fun copy(checked: Boolean = this.checked, disabled: Boolean = this.disabled) : SelectSheetItem + + internal data class UserSelectItem( + val user: User, + val member: GuildMember, + override val checked: Boolean, + override val disabled: Boolean = false, + ) : SelectSheetItem(1, user.id) { + override fun copy(checked: Boolean, disabled: Boolean): SelectSheetItem = copy(checked = checked, disabled = disabled, user = user) + } + + internal data class RoleSelectItem( + val role: GuildRole, + override val checked: Boolean, + override val disabled: Boolean = false, + ) : SelectSheetItem(2, role.id) { + override fun copy(checked: Boolean, disabled: Boolean): SelectSheetItem = copy(checked = checked, disabled = disabled, role = role) + } + + internal data class ChannelSelectItem( + val channel: Channel, + override val checked: Boolean, + override val disabled: Boolean = false, + ) : SelectSheetItem(3, channel.id) { + override fun copy(checked: Boolean, disabled: Boolean): SelectSheetItem = copy(checked = checked, disabled = disabled, channel = channel) + } +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/selectsheet/SelectSheetItemViewHolder.kt b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/selectsheet/SelectSheetItemViewHolder.kt new file mode 100644 index 0000000..700c95c --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/selectsheet/SelectSheetItemViewHolder.kt @@ -0,0 +1,121 @@ +package com.aliucord.coreplugins.componentsv2.selectsheet + +import android.annotation.SuppressLint +import android.view.View +import android.view.View.GONE +import android.view.View.VISIBLE +import androidx.constraintlayout.widget.ConstraintLayout +import com.aliucord.Utils +import com.aliucord.utils.DimenUtils.dp +import com.aliucord.wrappers.ChannelWrapper.Companion.name +import com.aliucord.wrappers.ChannelWrapper.Companion.type +import com.aliucord.wrappers.GuildRoleWrapper.Companion.name +import com.discord.api.channel.Channel +import com.discord.models.member.GuildMember +import com.discord.utilities.color.ColorCompat +import com.discord.utilities.drawable.DrawableCompat +import com.discord.utilities.guilds.RoleUtils +import com.discord.utilities.icon.IconUtils +import com.discord.utilities.images.MGImages +import com.discord.utilities.mg_recycler.MGRecyclerViewHolder +import com.discord.utilities.user.UserUtils +import com.discord.utilities.view.extensions.ViewExtensions +import com.facebook.drawee.view.SimpleDraweeView +import com.google.android.material.checkbox.MaterialCheckBox +import com.google.android.material.textview.MaterialTextView +import com.lytefast.flexinput.R + +@SuppressLint("SetTextI18n") +internal class SelectSheetItemViewHolder(adapter: SelectSheetAdapter) + : MGRecyclerViewHolder(Utils.getResId("widget_select_component_bottom_sheet_item", "layout"), adapter) { + + private val description = itemView.findViewById(Utils.getResId("select_component_sheet_item_description", "id"))!! + private val divider = itemView.findViewById(Utils.getResId("select_component_sheet_item_divider", "id"))!! + private val dividerWithIcon = itemView.findViewById(Utils.getResId("select_component_sheet_item_divider_icon", "id"))!! + private val icon = itemView.findViewById(Utils.getResId("select_component_sheet_item_icon", "id"))!! + private val checkbox = itemView.findViewById(Utils.getResId("select_component_sheet_item_selected", "id"))!! + private val title = itemView.findViewById(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 + } + } +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/selectsheet/SelectSheetViewModel.kt b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/selectsheet/SelectSheetViewModel.kt new file mode 100644 index 0000000..16c8271 --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/selectsheet/SelectSheetViewModel.kt @@ -0,0 +1,152 @@ +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, + 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() + 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() + } +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/views/ContainerComponentView.kt b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/views/ContainerComponentView.kt new file mode 100644 index 0000000..36d9d4d --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/views/ContainerComponentView.kt @@ -0,0 +1,90 @@ +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 { + 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) + } +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/views/MediaGalleryComponentView.kt b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/views/MediaGalleryComponentView.kt new file mode 100644 index 0000000..64c44d4 --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/views/MediaGalleryComponentView.kt @@ -0,0 +1,131 @@ +@file:Suppress("MISSING_DEPENDENCY_CLASS", "MISSING_DEPENDENCY_SUPERCLASS") + +package com.aliucord.coreplugins.componentsv2.views + +import android.content.Context +import android.view.View +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.FrameLayout +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID +import com.aliucord.Logger +import com.aliucord.coreplugins.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 { + 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>? = 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>() + 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() + } +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/views/SectionComponentView.kt b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/views/SectionComponentView.kt new file mode 100644 index 0000000..750c28b --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/views/SectionComponentView.kt @@ -0,0 +1,57 @@ +@file:Suppress("MISSING_DEPENDENCY_CLASS", "MISSING_DEPENDENCY_SUPERCLASS") + +package com.aliucord.coreplugins.componentsv2.views + +import android.content.Context +import android.view.View +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.FrameLayout +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID +import com.aliucord.coreplugins.componentsv2.ComponentV2Type +import com.aliucord.coreplugins.componentsv2.models.SectionMessageComponent +import com.aliucord.utils.DimenUtils.dp +import com.aliucord.utils.ViewUtils.addTo +import com.aliucord.widgets.LinearLayout +import com.discord.widgets.botuikit.ComponentProvider +import com.discord.widgets.botuikit.views.ComponentActionListener +import com.discord.widgets.botuikit.views.ComponentView +import com.discord.widgets.chat.list.adapter.WidgetChatListAdapterItemBotComponentRowKt + +class SectionComponentView(ctx: Context) : ConstraintLayout(ctx), ComponentView { + 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)) + } + } +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/views/SelectV2ComponentView.kt b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/views/SelectV2ComponentView.kt new file mode 100644 index 0000000..79026ba --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/views/SelectV2ComponentView.kt @@ -0,0 +1,85 @@ +@file:Suppress("MISSING_DEPENDENCY_CLASS", "MISSING_DEPENDENCY_SUPERCLASS") + +package com.aliucord.coreplugins.componentsv2.views + +import android.annotation.SuppressLint +import android.content.Context +import android.widget.ImageView +import androidx.constraintlayout.widget.ConstraintLayout +import com.aliucord.Logger +import com.aliucord.coreplugins.componentsv2.BotUiComponentV2Entry +import com.aliucord.coreplugins.componentsv2.models.SelectV2MessageComponent +import com.aliucord.coreplugins.componentsv2.selectsheet.SelectSheet +import com.aliucord.utils.ViewUtils.addTo +import com.discord.api.botuikit.ComponentType +import com.discord.models.botuikit.SelectMessageComponent +import com.discord.views.typing.TypingDots +import com.discord.widgets.botuikit.ComponentProvider +import com.discord.widgets.botuikit.views.ComponentActionListener +import com.discord.widgets.botuikit.views.ComponentView +import com.discord.widgets.botuikit.views.select.SelectComponentView +import com.discord.widgets.chat.list.adapter.WidgetChatListAdapterItemBotComponentRow +import com.facebook.drawee.view.SimpleDraweeView +import com.google.android.flexbox.FlexboxLayout +import com.google.android.material.textview.MaterialTextView + +@SuppressLint("ViewConstructor") +internal class SelectV2ComponentView(context: Context, private val type: ComponentType) + : ConstraintLayout(context), ComponentView { + 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) + } + } +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/views/SeparatorComponentView.kt b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/views/SeparatorComponentView.kt new file mode 100644 index 0000000..855e7ef --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/views/SeparatorComponentView.kt @@ -0,0 +1,42 @@ +@file:Suppress("MISSING_DEPENDENCY_CLASS", "MISSING_DEPENDENCY_SUPERCLASS") + +package com.aliucord.coreplugins.componentsv2.views + +import android.content.Context +import androidx.constraintlayout.widget.ConstraintLayout +import com.aliucord.Logger +import com.aliucord.coreplugins.componentsv2.BotUiComponentV2Entry +import com.aliucord.coreplugins.componentsv2.ComponentV2Type +import com.aliucord.coreplugins.componentsv2.models.SeparatorMessageComponent +import com.aliucord.utils.DimenUtils.dp +import com.aliucord.utils.ViewUtils.addTo +import com.aliucord.views.Divider +import com.discord.utilities.color.ColorCompat +import com.discord.widgets.botuikit.ComponentProvider +import com.discord.widgets.botuikit.views.ComponentActionListener +import com.discord.widgets.botuikit.views.ComponentView +import com.discord.widgets.chat.list.adapter.WidgetChatListAdapterItemBotComponentRow +import com.lytefast.flexinput.R + +class SeparatorComponentView(ctx: Context) : ConstraintLayout(ctx), ComponentView { + 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) + } + } +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/views/SpoilerView.kt b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/views/SpoilerView.kt new file mode 100644 index 0000000..24ba988 --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/views/SpoilerView.kt @@ -0,0 +1,121 @@ +package com.aliucord.coreplugins.componentsv2.views + +import android.annotation.SuppressLint +import android.content.Context +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.* +import androidx.cardview.widget.CardView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID +import com.aliucord.coreplugins.componentsv2.BotUiComponentV2Entry +import com.aliucord.coreplugins.componentsv2.models.SpoilableMessageComponent +import com.aliucord.utils.DimenUtils.dp +import com.aliucord.utils.ViewUtils.addTo +import com.discord.stores.StoreMessageState +import com.discord.stores.StoreStream +import com.discord.utilities.color.ColorCompat +import com.lytefast.flexinput.R + +/** + * A view that can be spoilered. + * + * @param ctx Context + * @param type 1 for full (spoiler text and button), 2 for mini (eye icon) + */ +@SuppressLint("ViewConstructor") +internal class SpoilerView(ctx: Context, type: Int) : ConstraintLayout(ctx) { + companion object { + fun constraintLayoutParamsAround(viewId: Int) = + LayoutParams(0, 0).apply { + topToTop = viewId + bottomToBottom = viewId + startToStart = viewId + endToEnd = viewId + } + } + + private val spoilerView = ConstraintLayout(ctx).addTo(this) { + visibility = GONE + setBackgroundColor(ColorCompat.getThemedColor(ctx, R.b.theme_chat_spoiler_bg)) + layoutParams = LayoutParams(0, 0).apply { + bottomToBottom = PARENT_ID + endToEnd = PARENT_ID + startToStart = PARENT_ID + topToTop = PARENT_ID + } + isClickable = true + + when (type) { + 1 -> { + CardView(ctx).addTo(this) { + elevation = ctx.resources.getDimension(R.d.app_elevation) + setCardBackgroundColor(ColorCompat.getThemedColor(ctx, R.b.colorBackgroundFloating)) + radius = 16.dp.toFloat() + + layoutParams = LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { + startToStart = PARENT_ID + endToEnd = PARENT_ID + topToTop = PARENT_ID + bottomToBottom = PARENT_ID + } + + TextView(ctx, null, 0, R.i.UiKit_TextView_H2).addTo(this) { + setText(R.h.spoiler) + isAllCaps = true + setPadding(8.dp, 4.dp, 8.dp, 4.dp) + setTextColor(ColorCompat.getThemedColor(ctx, R.b.colorTextNormal)) + layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { + marginStart = 4.dp + marginEnd = 4.dp + } + } + } + } + 2 -> { + ImageView(ctx).addTo(this) { + setImageResource(R.e.ic_spoiler) + layoutParams = LayoutParams(0, 0).apply { + startToStart = PARENT_ID + endToEnd = PARENT_ID + topToTop = PARENT_ID + bottomToBottom = PARENT_ID + dimensionRatio = "1:1" + matchConstraintPercentWidth = 0.5f + } + } + } + else -> throw IllegalArgumentException("Invalid spoiler view type") + } + } + + fun configure(entry: BotUiComponentV2Entry, component: SpoilableMessageComponent, key: String? = null) { + configure(component.spoiler, entry.state, entry.message.id, Pair(component.id, key)) + } + + fun configure( + isSpoiler: Boolean, + state: StoreMessageState.State?, + messageId: Long, + key: Pair, + ) { + 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 + } +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/views/TextDisplayComponentView.kt b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/views/TextDisplayComponentView.kt new file mode 100644 index 0000000..8db35ca --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/views/TextDisplayComponentView.kt @@ -0,0 +1,86 @@ +@file:Suppress("MISSING_DEPENDENCY_CLASS", "MISSING_DEPENDENCY_SUPERCLASS") + +package com.aliucord.coreplugins.componentsv2.views + +import android.content.Context +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import androidx.appcompat.view.ContextThemeWrapper +import androidx.constraintlayout.widget.ConstraintLayout +import com.aliucord.Logger +import com.aliucord.coreplugins.componentsv2.BotUiComponentV2Entry +import com.aliucord.coreplugins.componentsv2.ComponentV2Type +import com.aliucord.coreplugins.componentsv2.models.TextDisplayMessageComponent +import com.aliucord.utils.DimenUtils.dp +import com.aliucord.utils.ViewUtils.addTo +import com.discord.stores.StoreStream +import com.discord.utilities.color.ColorCompat +import com.discord.utilities.message.MessageUtils +import com.discord.utilities.textprocessing.* +import com.discord.utilities.textprocessing.node.SpoilerNode +import com.discord.utilities.view.text.LinkifiedTextView +import com.discord.widgets.botuikit.ComponentProvider +import com.discord.widgets.botuikit.views.ComponentActionListener +import com.discord.widgets.botuikit.views.ComponentView +import com.discord.widgets.chat.list.adapter.* +import com.lytefast.flexinput.R + +class TextDisplayComponentView(ctx: Context) : ConstraintLayout(ctx), ComponentView { + 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? + 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); + } +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/views/ThumbnailComponentView.kt b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/views/ThumbnailComponentView.kt new file mode 100644 index 0000000..1c043dd --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/aliucord/coreplugins/componentsv2/views/ThumbnailComponentView.kt @@ -0,0 +1,95 @@ +@file:Suppress("MISSING_DEPENDENCY_CLASS", "MISSING_DEPENDENCY_SUPERCLASS") + +package com.aliucord.coreplugins.componentsv2.views + +import android.content.Context +import android.view.View +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.FrameLayout +import androidx.constraintlayout.widget.ConstraintLayout +import com.aliucord.Logger +import com.aliucord.coreplugins.componentsv2.BotUiComponentV2Entry +import com.aliucord.coreplugins.componentsv2.ComponentV2Type +import com.aliucord.coreplugins.componentsv2.models.ThumbnailMessageComponent +import com.aliucord.utils.DimenUtils.dp +import com.aliucord.utils.ViewUtils.addTo +import com.discord.utilities.color.ColorCompat +import com.discord.utilities.embed.EmbedResourceUtils +import com.discord.utilities.images.MGImages +import com.discord.widgets.botuikit.ComponentProvider +import com.discord.widgets.botuikit.views.ComponentActionListener +import com.discord.widgets.botuikit.views.ComponentView +import com.discord.widgets.chat.list.adapter.WidgetChatListAdapterItemBotComponentRow +import com.facebook.drawee.view.SimpleDraweeView +import com.google.android.material.card.MaterialCardView +import com.lytefast.flexinput.R + +class ThumbnailComponentView(ctx: Context) : ConstraintLayout(ctx), ComponentView { + 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) + } +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/aliucord/utils/ViewUtils.kt b/canary/ComponentsV2/src/main/kotlin/com/aliucord/utils/ViewUtils.kt new file mode 100644 index 0000000..2ed27e1 --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/aliucord/utils/ViewUtils.kt @@ -0,0 +1,41 @@ +package com.aliucord.utils + +import android.view.View +import android.view.ViewGroup +import androidx.constraintlayout.widget.ConstraintLayout +import com.discord.views.CheckedSetting + +object ViewUtils { + /** + * Shorthand extension function to add a View into a ViewGroup, and then + * run a scoped function + * + * @param group ViewGroup to add this View into + * @param block A scoped function, with the View as its receiver + * @return The View + */ + fun T.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.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() +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/ChannelSelectComponent.kt b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/ChannelSelectComponent.kt new file mode 100644 index 0000000..05ec6a2 --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/ChannelSelectComponent.kt @@ -0,0 +1,14 @@ +package com.discord.api.botuikit + +data class ChannelSelectComponent( + private val type: ComponentType, + override val id: Int, + @b.i.d.p.b("custom_id") override val customId: String, + override val placeholder: String, + override val defaultValues: List?, + override val minValues: Int, + override val maxValues: Int, + override val disabled: Boolean, +) : SelectV2Component() { + override fun getType() = type +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/ContainerComponent.kt b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/ContainerComponent.kt new file mode 100644 index 0000000..a52183b --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/ContainerComponent.kt @@ -0,0 +1,12 @@ +package com.discord.api.botuikit + +data class ContainerComponent( + private val type: ComponentType, + val id: Int, + val components: List, + @b.i.d.p.b("accent_color") val accentColor: Int?, + val spoiler: Boolean, +): LayoutComponent() { + override fun getType() = type + override fun a() = components +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/ContentComponent.kt b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/ContentComponent.kt new file mode 100644 index 0000000..f1e520b --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/ContentComponent.kt @@ -0,0 +1,7 @@ +package com.discord.api.botuikit + +import java.io.Serializable + +abstract class ContentComponent : LayoutComponent(), Serializable { + final override fun a(): List = listOf() +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/FileComponent.kt b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/FileComponent.kt new file mode 100644 index 0000000..14b7701 --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/FileComponent.kt @@ -0,0 +1,12 @@ +package com.discord.api.botuikit + +data class FileComponent( + private val type: ComponentType, + val id: Int, + val file: UnfurledMediaItem, + val spoiler: Boolean, + val name: String, + val size: Int, +) : ContentComponent() { + override fun getType() = type +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/MediaGalleryComponent.kt b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/MediaGalleryComponent.kt new file mode 100644 index 0000000..f14a0d2 --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/MediaGalleryComponent.kt @@ -0,0 +1,9 @@ +package com.discord.api.botuikit + +data class MediaGalleryComponent( + private val type: ComponentType, + val id: Int, + val items: List, +) : ContentComponent() { + override fun getType() = type +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/MediaGalleryItem.kt b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/MediaGalleryItem.kt new file mode 100644 index 0000000..3cd1390 --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/MediaGalleryItem.kt @@ -0,0 +1,7 @@ +package com.discord.api.botuikit + +data class MediaGalleryItem( + val media: UnfurledMediaItem, + val description: String?, + val spoiler: Boolean, +) diff --git a/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/MentionableSelectComponent.kt b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/MentionableSelectComponent.kt new file mode 100644 index 0000000..7e76023 --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/MentionableSelectComponent.kt @@ -0,0 +1,14 @@ +package com.discord.api.botuikit + +data class MentionableSelectComponent( + private val type: ComponentType, + override val id: Int, + @b.i.d.p.b("custom_id") override val customId: String, + override val placeholder: String, + override val defaultValues: List?, + override val minValues: Int, + override val maxValues: Int, + override val disabled: Boolean, +) : SelectV2Component() { + override fun getType() = type +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/RoleSelectComponent.kt b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/RoleSelectComponent.kt new file mode 100644 index 0000000..eca7f18 --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/RoleSelectComponent.kt @@ -0,0 +1,14 @@ +package com.discord.api.botuikit + +data class RoleSelectComponent( + private val type: ComponentType, + override val id: Int, + @b.i.d.p.b("custom_id") override val customId: String, + override val placeholder: String, + override val defaultValues: List?, + override val minValues: Int, + override val maxValues: Int, + override val disabled: Boolean, +) : SelectV2Component() { + override fun getType() = type +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/SectionComponent.kt b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/SectionComponent.kt new file mode 100644 index 0000000..3175618 --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/SectionComponent.kt @@ -0,0 +1,15 @@ +package com.discord.api.botuikit + +data class SectionComponent( + private val type: ComponentType, + val id: Int, + val components: List, + 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 +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/SelectV2Component.kt b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/SelectV2Component.kt new file mode 100644 index 0000000..4c22334 --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/SelectV2Component.kt @@ -0,0 +1,11 @@ +package com.discord.api.botuikit + +abstract class SelectV2Component() : ActionComponent() { + abstract val id: Int + abstract val customId: String + abstract val placeholder: String + abstract val defaultValues: List? + abstract val minValues: Int + abstract val maxValues: Int + abstract val disabled: Boolean +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/SelectV2DefaultValue.kt b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/SelectV2DefaultValue.kt new file mode 100644 index 0000000..c6f6992 --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/SelectV2DefaultValue.kt @@ -0,0 +1,6 @@ +package com.discord.api.botuikit + +data class SelectV2DefaultValue( + val id: Long, + val type: SelectV2DefaultValueType, +) diff --git a/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/SelectV2DefaultValueType.kt b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/SelectV2DefaultValueType.kt new file mode 100644 index 0000000..2caae0e --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/SelectV2DefaultValueType.kt @@ -0,0 +1,7 @@ +package com.discord.api.botuikit + +enum class SelectV2DefaultValueType { + @b.i.d.p.b("user") USER, + @b.i.d.p.b("role") ROLE, + @b.i.d.p.b("channel") CHANNEL, +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/SeparatorComponent.kt b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/SeparatorComponent.kt new file mode 100644 index 0000000..b773f49 --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/SeparatorComponent.kt @@ -0,0 +1,11 @@ +package com.discord.api.botuikit + +data class SeparatorComponent( + private val type: ComponentType, + val id: Int, + val divider: Boolean, + val spacing: Int, // 1 = small padding, 2 = large padding +): LayoutComponent() { + override fun getType() = type + override fun a(): List = listOf() +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/TextDisplayComponent.kt b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/TextDisplayComponent.kt new file mode 100644 index 0000000..c8d190d --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/TextDisplayComponent.kt @@ -0,0 +1,9 @@ +package com.discord.api.botuikit + +data class TextDisplayComponent( + private val type: ComponentType, + val id: Int, + val content: String, +) : ContentComponent() { + override fun getType() = type +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/ThumbnailComponent.kt b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/ThumbnailComponent.kt new file mode 100644 index 0000000..7fbea89 --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/ThumbnailComponent.kt @@ -0,0 +1,11 @@ +package com.discord.api.botuikit + +data class ThumbnailComponent( + private val type: ComponentType, + val id: Int, + val media: UnfurledMediaItem, + val description: String?, + val spoiler: Boolean, +) : ContentComponent() { + override fun getType() = type +} diff --git a/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/UnfurledMediaItem.kt b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/UnfurledMediaItem.kt new file mode 100644 index 0000000..d9bdc8d --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/UnfurledMediaItem.kt @@ -0,0 +1,10 @@ +package com.discord.api.botuikit + +data class UnfurledMediaItem( + val url: String, + @b.i.d.p.b("proxy_url") val proxyUrl: String, + val height: Int, + val width: Int, + @b.i.d.p.b("content_type") val contentType: String?, + @b.i.d.p.b("attachment_id") val attachmentId: Long?, +) diff --git a/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/UserSelectComponent.kt b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/UserSelectComponent.kt new file mode 100644 index 0000000..fc0a370 --- /dev/null +++ b/canary/ComponentsV2/src/main/kotlin/com/discord/api/botuikit/UserSelectComponent.kt @@ -0,0 +1,14 @@ +package com.discord.api.botuikit + +data class UserSelectComponent( + private val type: ComponentType, + override val id: Int, + @b.i.d.p.b("custom_id") override val customId: String, + override val placeholder: String, + override val defaultValues: List?, + override val minValues: Int, + override val maxValues: Int, + override val disabled: Boolean, +) : SelectV2Component() { + override fun getType() = type +} diff --git a/canary/LICENSE b/canary/LICENSE new file mode 100644 index 0000000..95230e3 --- /dev/null +++ b/canary/LICENSE @@ -0,0 +1,172 @@ +Open Software License ("OSL") v. 3.0 + +This Open Software License (the "License") applies to any original work of +authorship (the "Original Work") whose owner (the "Licensor") has placed the +following licensing notice adjacent to the copyright notice for the Original +Work: + +Licensed under the Open Software License version 3.0 + +1) Grant of Copyright License. Licensor grants You a worldwide, royalty-free, +non-exclusive, sublicensable license, for the duration of the copyright, to do +the following: + + a) to reproduce the Original Work in copies, either alone or as part of a + collective work; + + b) to translate, adapt, alter, transform, modify, or arrange the Original + Work, thereby creating derivative works ("Derivative Works") based upon the + Original Work; + + c) to distribute or communicate copies of the Original Work and Derivative + Works to the public, with the proviso that copies of Original Work or + Derivative Works that You distribute or communicate shall be licensed under + this Open Software License; + + d) to perform the Original Work publicly; and + + e) to display the Original Work publicly. + +2) Grant of Patent License. Licensor grants You a worldwide, royalty-free, +non-exclusive, sublicensable license, under patent claims owned or controlled +by the Licensor that are embodied in the Original Work as furnished by the +Licensor, for the duration of the patents, to make, use, sell, offer for sale, +have made, and import the Original Work and Derivative Works. + +3) Grant of Source Code License. The term "Source Code" means the preferred +form of the Original Work for making modifications to it and all available +documentation describing how to modify the Original Work. Licensor agrees to +provide a machine-readable copy of the Source Code of the Original Work along +with each copy of the Original Work that Licensor distributes. Licensor +reserves the right to satisfy this obligation by placing a machine-readable +copy of the Source Code in an information repository reasonably calculated to +permit inexpensive and convenient access by You for as long as Licensor +continues to distribute the Original Work. + +4) Exclusions From License Grant. Neither the names of Licensor, nor the names +of any contributors to the Original Work, nor any of their trademarks or +service marks, may be used to endorse or promote products derived from this +Original Work without express prior permission of the Licensor. Except as +expressly stated herein, nothing in this License grants any license to +Licensor's trademarks, copyrights, patents, trade secrets or any other +intellectual property. No patent license is granted to make, use, sell, offer +for sale, have made, or import embodiments of any patent claims other than the +licensed claims defined in Section 2. No license is granted to the trademarks +of Licensor even if such marks are included in the Original Work. Nothing in +this License shall be interpreted to prohibit Licensor from licensing under +terms different from this License any Original Work that Licensor otherwise +would have a right to license. + +5) External Deployment. The term "External Deployment" means the use, +distribution, or communication of the Original Work or Derivative Works in any +way such that the Original Work or Derivative Works may be used by anyone +other than You, whether those works are distributed or communicated to those +persons or made available as an application intended for use over a network. +As an express condition for the grants of license hereunder, You must treat +any External Deployment by You of the Original Work or a Derivative Work as a +distribution under section 1(c). + +6) Attribution Rights. You must retain, in the Source Code of any Derivative +Works that You create, all copyright, patent, or trademark notices from the +Source Code of the Original Work, as well as any notices of licensing and any +descriptive text identified therein as an "Attribution Notice." You must cause +the Source Code for any Derivative Works that You create to carry a prominent +Attribution Notice reasonably calculated to inform recipients that You have +modified the Original Work. + +7) Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that +the copyright in and to the Original Work and the patent rights granted herein +by Licensor are owned by the Licensor or are sublicensed to You under the +terms of this License with the permission of the contributor(s) of those +copyrights and patent rights. Except as expressly stated in the immediately +preceding sentence, the Original Work is provided under this License on an "AS +IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without +limitation, the warranties of non-infringement, merchantability or fitness for +a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK +IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this +License. No license to the Original Work is granted by this License except +under this disclaimer. + +8) Limitation of Liability. Under no circumstances and under no legal theory, +whether in tort (including negligence), contract, or otherwise, shall the +Licensor be liable to anyone for any indirect, special, incidental, or +consequential damages of any character arising as a result of this License or +the use of the Original Work including, without limitation, damages for loss +of goodwill, work stoppage, computer failure or malfunction, or any and all +other commercial damages or losses. This limitation of liability shall not +apply to the extent applicable law prohibits such limitation. + +9) Acceptance and Termination. If, at any time, You expressly assented to this +License, that assent indicates your clear and irrevocable acceptance of this +License and all of its terms and conditions. If You distribute or communicate +copies of the Original Work or a Derivative Work, You must make a reasonable +effort under the circumstances to obtain the express assent of recipients to +the terms of this License. This License conditions your rights to undertake +the activities listed in Section 1, including your right to create Derivative +Works based upon the Original Work, and doing so without honoring these terms +and conditions is prohibited by copyright law and international treaty. +Nothing in this License is intended to affect copyright exceptions and +limitations (including "fair use" or "fair dealing"). This License shall +terminate immediately and You may no longer exercise any of the rights granted +to You by this License upon your failure to honor the conditions in Section +1(c). + +10) Termination for Patent Action. This License shall terminate automatically +and You may no longer exercise any of the rights granted to You by this +License as of the date You commence an action, including a cross-claim or +counterclaim, against Licensor or any licensee alleging that the Original Work +infringes a patent. This termination provision shall not apply for an action +alleging patent infringement by combinations of the Original Work with other +software or hardware. + +11) Jurisdiction, Venue and Governing Law. Any action or suit relating to this +License may be brought only in the courts of a jurisdiction wherein the +Licensor resides or in which Licensor conducts its primary business, and under +the laws of that jurisdiction excluding its conflict-of-law provisions. The +application of the United Nations Convention on Contracts for the +International Sale of Goods is expressly excluded. Any use of the Original +Work outside the scope of this License or after its termination shall be +subject to the requirements and penalties of copyright or patent law in the +appropriate jurisdiction. This section shall survive the termination of this +License. + +12) Attorneys' Fees. In any action to enforce the terms of this License or +seeking damages relating thereto, the prevailing party shall be entitled to +recover its costs and expenses, including, without limitation, reasonable +attorneys' fees and costs incurred in connection with such action, including +any appeal of such action. This section shall survive the termination of this +License. + +13) Miscellaneous. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent necessary +to make it enforceable. + +14) Definition of "You" in This License. "You" throughout this License, +whether in upper or lower case, means an individual or a legal entity +exercising rights under, and complying with all of the terms of, this License. +For legal entities, "You" includes any entity that controls, is controlled by, +or is under common control with you. For purposes of this definition, +"control" means (i) the power, direct or indirect, to cause the direction or +management of such entity, whether by contract or otherwise, or (ii) ownership +of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial +ownership of such entity. + +15) Right to Use. You may use the Original Work in all ways not otherwise +restricted or conditioned by this License or by law, and Licensor promises not +to interfere with or be responsible for such uses by You. + +16) Modification of This License. This License is Copyright © 2005 Lawrence +Rosen. Permission is granted to copy, distribute, or communicate this License +without modification. Nothing in this License permits You to modify this +License as applied to the Original Work or to Derivative Works. However, You +may modify the text of this License and copy, distribute or communicate your +modified version (the "Modified License") and apply it to other original works +of authorship subject to the following conditions: (i) You may not indicate in +any way that your Modified License is the "Open Software License" or "OSL" and +you may not use those names in the name of your Modified License; (ii) You +must replace the notice specified in the first paragraph above with the notice +"Licensed under " 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. diff --git a/canary/SlashCommandsFix/build.gradle.kts b/canary/SlashCommandsFix/build.gradle.kts new file mode 100644 index 0000000..4ace2f2 --- /dev/null +++ b/canary/SlashCommandsFix/build.gradle.kts @@ -0,0 +1,52 @@ +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("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("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) + } +} diff --git a/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApiApplication.java b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApiApplication.java new file mode 100644 index 0000000..d1778d9 --- /dev/null +++ b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApiApplication.java @@ -0,0 +1,39 @@ +/* + * 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 botUser = Optional.ofNullable(this.botId).map(userId -> usersStore.getUsers().get(userId)); + return new Application(this.id, this.name, this.icon, permissions, botUser); + } +} diff --git a/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApiApplicationCommand.java b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApiApplicationCommand.java new file mode 100644 index 0000000..62e129b --- /dev/null +++ b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApiApplicationCommand.java @@ -0,0 +1,59 @@ +/* + * 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 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); + } +} diff --git a/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApiApplicationIndex.java b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApiApplicationIndex.java new file mode 100644 index 0000000..188b6c1 --- /dev/null +++ b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApiApplicationIndex.java @@ -0,0 +1,34 @@ +/* + * 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 applications; + public List applicationCommands; + + public ApiApplicationIndex() { + this.applications = null; + this.applicationCommands = null; + } + + public ApplicationIndex toModel() { + var applications = new HashMap(); + for (var application: this.applications) { + applications.put(application.id, application.toModel()); + } + var applicationCommands = new HashMap(); + for (var applicationCommand: this.applicationCommands) { + applicationCommands.put(applicationCommand.id, applicationCommand.toModel()); + } + + return new ApplicationIndex(applications, applicationCommands); + } +} diff --git a/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApiGuildApplicationCommandIndexUpdate.java b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApiGuildApplicationCommandIndexUpdate.java new file mode 100644 index 0000000..91c5b4e --- /dev/null +++ b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApiGuildApplicationCommandIndexUpdate.java @@ -0,0 +1,15 @@ +/* + * 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; + } +} diff --git a/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApiPermissions.java b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApiPermissions.java new file mode 100644 index 0000000..2bbe6e8 --- /dev/null +++ b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApiPermissions.java @@ -0,0 +1,26 @@ +/* + * 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 roles; + public Map channels; + + public ApiPermissions() { + this.user = null; + this.roles = null; + this.channels = null; + } + + public Permissions toModel(Optional defaultMemberPermissions) { + return new Permissions(Optional.ofNullable(user), roles, channels, defaultMemberPermissions); + } +} diff --git a/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/Application.java b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/Application.java new file mode 100644 index 0000000..67ecf2b --- /dev/null +++ b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/Application.java @@ -0,0 +1,21 @@ +/* + * 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 botUser) { + super(id, name, icon, null, -1, botUser.map(user -> UserUtils.INSTANCE.synthesizeApiUser(user)).orElse(null), false); + this.permissions_ = permissions; + } +} diff --git a/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApplicationIndex.java b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApplicationIndex.java new file mode 100644 index 0000000..a3fab96 --- /dev/null +++ b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApplicationIndex.java @@ -0,0 +1,44 @@ +/* + * 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 applications; + public Map applicationCommands; + + public ApplicationIndex(Map applications, Map applicationCommands) { + this.applications = applications; + this.applicationCommands = applicationCommands; + } + + public ApplicationIndex(List 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(); + 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)); + } + } +} diff --git a/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApplicationIndexCache.java b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApplicationIndexCache.java new file mode 100644 index 0000000..87022ce --- /dev/null +++ b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApplicationIndexCache.java @@ -0,0 +1,23 @@ +/* + * 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 guild; + public Map dm; + public Optional user; + + public ApplicationIndexCache() { + this.guild = new HashMap<>(); + this.dm = new HashMap<>(); + this.user = Optional.empty(); + } +} diff --git a/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApplicationIndexSource.java b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApplicationIndexSource.java new file mode 100644 index 0000000..84b11a4 --- /dev/null +++ b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApplicationIndexSource.java @@ -0,0 +1,16 @@ +/* + * 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 getFromCache(ApplicationIndexCache cache); + void insertIntoCache(ApplicationIndexCache cache, ApplicationIndex index); + void removeFromCache(ApplicationIndexCache cache); +} diff --git a/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApplicationIndexSourceDm.java b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApplicationIndexSourceDm.java new file mode 100644 index 0000000..3fb0c94 --- /dev/null +++ b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApplicationIndexSourceDm.java @@ -0,0 +1,39 @@ +/* + * 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 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); + } +} diff --git a/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApplicationIndexSourceGuild.java b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApplicationIndexSourceGuild.java new file mode 100644 index 0000000..5588134 --- /dev/null +++ b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApplicationIndexSourceGuild.java @@ -0,0 +1,40 @@ +/* + * 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 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); + } +} diff --git a/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApplicationIndexSourceUser.java b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApplicationIndexSourceUser.java new file mode 100644 index 0000000..19cbf66 --- /dev/null +++ b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ApplicationIndexSourceUser.java @@ -0,0 +1,33 @@ +/* + * 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 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(); + } +} diff --git a/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ConflictCheck.kt b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ConflictCheck.kt new file mode 100644 index 0000000..7b3bf0c --- /dev/null +++ b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/ConflictCheck.kt @@ -0,0 +1,53 @@ +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 + } +} diff --git a/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/Patches.java b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/Patches.java new file mode 100644 index 0000000..740e528 --- /dev/null +++ b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/Patches.java @@ -0,0 +1,318 @@ +/* + * 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) 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) skemaErrorSubErrorsField.get(error)); + var dataErrors = (List) 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, 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(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 applicationIndexSourceFromContext(long guildId, StoreChannelsSelected storeChannelsSelected) { + Optional 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; + } +} diff --git a/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/Permissions.java b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/Permissions.java new file mode 100644 index 0000000..d80d0e7 --- /dev/null +++ b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/Permissions.java @@ -0,0 +1,84 @@ +/* + * 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 user; + public Map roles; + public Map channels; + public Optional defaultMemberPermissions; + + public Permissions(Optional user, Map roles, Map channels, Optional 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 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 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; + } +} diff --git a/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/RemoteApplicationCommand.java b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/RemoteApplicationCommand.java new file mode 100644 index 0000000..995a856 --- /dev/null +++ b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/RemoteApplicationCommand.java @@ -0,0 +1,23 @@ +/* + * 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 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; + } +} diff --git a/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/RequestSource.java b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/RequestSource.java new file mode 100644 index 0000000..1422bf5 --- /dev/null +++ b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/RequestSource.java @@ -0,0 +1,12 @@ +/* + * 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; +} diff --git a/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/SlashCommandsFix.java b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/SlashCommandsFix.java new file mode 100644 index 0000000..9b1280a --- /dev/null +++ b/canary/SlashCommandsFix/src/main/java/com/aliucord/coreplugins/slashcommandsfix/SlashCommandsFix.java @@ -0,0 +1,34 @@ +/* + * 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) {} +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..de388ea --- /dev/null +++ b/gradle.properties @@ -0,0 +1,12 @@ +# 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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..b0247db --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,25 @@ +[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" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f6b961f Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c7ed4bd --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +#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 diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..005bcde --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100755 index 0000000..6a68175 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@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 diff --git a/plugins/Bocchi/build.gradle.kts b/plugins/Bocchi/build.gradle.kts new file mode 100644 index 0000000..1803a3c --- /dev/null +++ b/plugins/Bocchi/build.gradle.kts @@ -0,0 +1,25 @@ +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) +} diff --git a/plugins/Bocchi/src/main/kotlin/moe/lava/awoocord/bocchi/Bocchi.kt b/plugins/Bocchi/src/main/kotlin/moe/lava/awoocord/bocchi/Bocchi.kt new file mode 100644 index 0000000..d4d28da --- /dev/null +++ b/plugins/Bocchi/src/main/kotlin/moe/lava/awoocord/bocchi/Bocchi.kt @@ -0,0 +1,59 @@ +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() + +@AliucordPlugin(requiresRestart = true) +@Suppress("unused") +class Bocchi : Plugin() { + override fun start(context: Context) { + patcher.after( + "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( + "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) + ) + } + } +} diff --git a/plugins/Crocosmia/build.gradle.kts b/plugins/Crocosmia/build.gradle.kts new file mode 100644 index 0000000..a3aaca9 --- /dev/null +++ b/plugins/Crocosmia/build.gradle.kts @@ -0,0 +1,12 @@ +version = "1.0.0" +description = "Bubbled messages" + +aliucord { + // Changelog of your plugin + changelog.set(""" + # 1.0.0 + * Initial release >w< + """.trimIndent()) + + deploy.set(true) +} diff --git a/plugins/Crocosmia/src/main/kotlin/moe/lava/awoocord/crocosmia/Crocosmia.kt b/plugins/Crocosmia/src/main/kotlin/moe/lava/awoocord/crocosmia/Crocosmia.kt new file mode 100644 index 0000000..0cd9eca --- /dev/null +++ b/plugins/Crocosmia/src/main/kotlin/moe/lava/awoocord/crocosmia/Crocosmia.kt @@ -0,0 +1,475 @@ +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() +private val WidgetChatListAdapterItemAttachment.binding get() = WidgetChatListAdapterItemAttachment.`access$getBinding$p`(this) +private val WidgetChatListAdapterItemEmbed.binding by accessField() +private val WidgetChatListAdapterItemSticker.binding get() = WidgetChatListAdapterItemSticker.`access$getBinding$p`(this) + +private var MessageEntry.keyField by accessField() + +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(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("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( + "setData", + WidgetChatListAdapter.Data::class.java, + ) { + notifyItemChanged(1, Unit.a) + } + + patcher.after( + 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( + "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( + "onConfigure", + Int::class.javaPrimitiveType!!, + ChatListEntry::class.java, + ) { (_, _: Int, entry: AttachmentEntry) -> + configBubble(entry) + } + } + + private fun patchAttachmentInit() { + patcher.after( + 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() + private fun patchComponentsConfig() { + patcher.after( + "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( + 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( + "onConfigure", + Int::class.javaPrimitiveType!!, + ChatListEntry::class.java, + ) { (_, _: Int, entry: EmbedEntry) -> + if (EmbedResourceUtils.INSTANCE.isInlineEmbed(entry.embed)) { + itemView.findViewById(bubbleId).visibility = View.VISIBLE + configBubble(entry) + } else { + itemView.findViewById(bubbleId).visibility = View.GONE + configBubble(binding.f, entry) + } + } + } + + private fun patchMessageInit() { + patcher.after( + 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("chat_list_adapter_item_text_header")?.apply { + layoutParams = (layoutParams as ConstraintLayout.LayoutParams).apply { + setPadding( + paddingLeft + padding, + paddingTop + topPad, + paddingRight + padding, + paddingBottom + ) + } + } + } + itemView.findViewById("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( + "onConfigure", + Int::class.javaPrimitiveType!!, + ChatListEntry::class.java, + ) { (_, _: Int, entry: MessageEntry) -> + if (entry.message.content.isNullOrEmpty()) { + itemView.findViewById("chat_list_adapter_item_text").visibility = View.GONE + } + configBubble(entry) + } + } + + private fun patchStickerInit() { + patcher.after( + 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( + "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) + } + } + } +} diff --git a/plugins/Myosotis/build.gradle.kts b/plugins/Myosotis/build.gradle.kts new file mode 100644 index 0000000..7bd110c --- /dev/null +++ b/plugins/Myosotis/build.gradle.kts @@ -0,0 +1,12 @@ +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) +} diff --git a/plugins/Myosotis/src/main/kotlin/moe/lava/awoocord/myosotis/Myosotis.kt b/plugins/Myosotis/src/main/kotlin/moe/lava/awoocord/myosotis/Myosotis.kt new file mode 100644 index 0000000..60ed811 --- /dev/null +++ b/plugins/Myosotis/src/main/kotlin/moe/lava/awoocord/myosotis/Myosotis.kt @@ -0,0 +1,205 @@ +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() + +private val responseType = TypeToken.getParameterized(List::class.java, Message::class.java).type + +data class ChannelIdsPayload( + @SerializedName("channel_ids") val channelIds: List, +) + +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) { + 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() + var adapterRef: WeakReference? = null + + override fun stop(context: Context) { patcher.unpatchAll() } + + override fun start(context: Context) { + GatewayAPI.onEvent("READY") { refreshAll() } + GatewayAPI.onEvent("RESUMED") { refreshAll() } + + patcher.after( + "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( + RecyclerView::class.java, + FragmentManager::class.java, + ) { + adapterRef = WeakReference(this) + } + + patcher.before( + "handleMessageCreate", + Message::class.java + ) { (_, msg: Message) -> + handleMessageUpdate(msg) + } + + patcher.before( + "handleMessageUpdate", + Message::class.java + ) { (_, msg: Message) -> + handleMessageUpdate(msg) + } + + patcher.before( + "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>(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) + } + } + } +} diff --git a/plugins/Scout/build.gradle.kts b/plugins/Scout/build.gradle.kts new file mode 100644 index 0000000..5f7859a --- /dev/null +++ b/plugins/Scout/build.gradle.kts @@ -0,0 +1,57 @@ +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) +} diff --git a/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/FilterTypeExtension.kt b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/FilterTypeExtension.kt new file mode 100644 index 0000000..58afef0 --- /dev/null +++ b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/FilterTypeExtension.kt @@ -0,0 +1,17 @@ +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 + lateinit var filters: Array + lateinit var values: Array +} + diff --git a/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/HasAnswerOptionExtension.kt b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/HasAnswerOptionExtension.kt new file mode 100644 index 0000000..b23ec8f --- /dev/null +++ b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/HasAnswerOptionExtension.kt @@ -0,0 +1,9 @@ +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 +} diff --git a/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/Scout.kt b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/Scout.kt new file mode 100644 index 0000000..c32ddda --- /dev/null +++ b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/Scout.kt @@ -0,0 +1,940 @@ +@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() + +private val WidgetSearchSuggestionsAdapter.HeaderViewHolder.binding + by accessField() + +@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? = 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 + 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? = 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 + 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? = 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 + 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( + "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( + "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("onViewBound", View::class.java) { + view?.run { + fitsSystemWindows = false + setPadding(paddingLeft, 16.dp, paddingRight, paddingBottom) + } + } + + patcher.after("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( + "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( + "getLocalizedInputText", + SearchStringProvider::class.java + ) { param -> + if (this == HasAnswerOptionExtension.POLL) + param.result = ssProvider.hasPollString + else if (this == HasAnswerOptionExtension.SNAPSHOT) + param.result = ssProvider.hasForwardString + } + + patcher.instead( + "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( + "onConfigure", + Int::class.java, + MGRecyclerDataPayload::class.java, + ) { param -> + val suggestion = (param.args[1] as SingleTypePayload).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( + "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() + } + } + + // Patching HasNode related methods for our exclude: filter type + private fun patchHasNode() { + patcher.instead("getValidFilters") { + setOf(FilterTypeExtension.EXCLUDE, FilterType.HAS) + } + + // Patch updateQuery to either include or exclude our has option + patcher.instead( + "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( + "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 + + 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(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> + 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( + "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).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( + "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( + "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( + "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( + "onConfigure", + Int::class.javaPrimitiveType!!, + MGRecyclerDataPayload::class.java, + ) { (param, _: Int, payload: SingleTypePayload) -> + 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( + "getFilterSuggestions", + CharSequence::class.java, + SearchStringProvider::class.java, + Boolean::class.javaPrimitiveType!!, + ) { (param, query: CharSequence) -> + val res = (param.result as List).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( + "onConfigure", + Int::class.javaPrimitiveType!!, + MGRecyclerDataPayload::class.java, + ) { (param, _: Int, payload: SingleTypePayload) -> + 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( + List::class.java, + List::class.java, + ) { (_, _: List, suggestions: List) -> + var lastCategory: SearchSuggestion.Category? = null + val newItems = mutableListOf() + 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( + "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("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( + "buildForGuild", + Map::class.java, + Map::class.java, + Map::class.java, + Map::class.java + ) { ( + param, + /* members */ _: Map, + /* users*/ _: Map, + channels: Map, + permissions: Map + ) -> + 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( + "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 + 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(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( + "onConfigure", + Int::class.javaPrimitiveType!!, + MGRecyclerDataPayload::class.java + ) { (_, _: Int, payload: SingleTypePayload) -> + StoreStream.getChannels().getChannel(payload.data.channelId)?.let { + if (ChannelUtils.H(it)) { + itemView.findViewById("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 # + // Now it matches something like @[#] (bots still have discriminators) + // The @ is required unfortunately, to distinguish it from literally any other word + patcher.instead("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("getText") { param -> + param.result = "@" + (param.result as String).replace("#0000", "") + } + } +} diff --git a/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/SuggestionCategoryExtension.kt b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/SuggestionCategoryExtension.kt new file mode 100644 index 0000000..2fcc40b --- /dev/null +++ b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/SuggestionCategoryExtension.kt @@ -0,0 +1,12 @@ +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 + + object AdapterType { + const val AUTHOR_TYPE = 7 + } +} diff --git a/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/api/SearchAPIInterface.kt b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/api/SearchAPIInterface.kt new file mode 100644 index 0000000..c3b45e4 --- /dev/null +++ b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/api/SearchAPIInterface.kt @@ -0,0 +1,46 @@ +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?, + @t("max_id") maxId: List?, + @t("author_id") authorId: List?, + @t("mentions") mentions: List?, + @t("has") has: List?, + @t("content") content: List?, + @t("attempts") attempts: Int?, + @t("include_nsfw") includeNsfw: Boolean?, + @t("sort_by") sortBy: List?, // "timestamp" is one, not sure about any other sort types + @t("sort_order") sortOrder: List?, // "asc" or "desc" + @t("author_type") authorType: List?, + ): Observable + + @f("guilds/{guildId}/messages/search") + fun searchGuildMessages( + @s("guildId") guildId: Long, + @t("min_id") minId: List?, + @t("max_id") maxId: List?, + @t("author_id") authorId: List?, + @t("mentions") mentions: List?, + @t("channel_id") channelId: List?, + @t("has") has: List?, + @t("content") content: List?, + @t("attempts") attempts: Int?, + @t("include_nsfw") includeNsfw: Boolean?, + @t("sort_by") sortBy: List?, + @t("sort_order") sortOrder: List?, + @t("author_type") authorType: List?, + ): Observable +} diff --git a/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/entries/AuthorTypeSuggestion.kt b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/entries/AuthorTypeSuggestion.kt new file mode 100644 index 0000000..b989e03 --- /dev/null +++ b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/entries/AuthorTypeSuggestion.kt @@ -0,0 +1,9 @@ +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 +} diff --git a/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/entries/AuthorTypeViewHolder.kt b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/entries/AuthorTypeViewHolder.kt new file mode 100644 index 0000000..afb1161 --- /dev/null +++ b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/entries/AuthorTypeViewHolder.kt @@ -0,0 +1,77 @@ +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( + Utils.getResId("widget_search_suggestions_item_has", "layout"), + adapter, +) { + private val imageView = itemView.findViewById("search_suggestions_item_has_icon") + private val textView = itemView.findViewById("search_suggestions_item_has_text") + + override fun onConfigure(i: Int, oPayload: MGRecyclerDataPayload) { + super.onConfigure(i, oPayload) + + @Suppress("UNCHECKED_CAST") + val payload = oPayload as SingleTypePayload + 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, + ) + } + } +} diff --git a/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/parsing/AuthorTypeNode.kt b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/parsing/AuthorTypeNode.kt new file mode 100644 index 0000000..ecee669 --- /dev/null +++ b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/parsing/AuthorTypeNode.kt @@ -0,0 +1,64 @@ +@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 { + 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) + } +} diff --git a/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/parsing/DateNode.kt b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/parsing/DateNode.kt new file mode 100644 index 0000000..f72084b --- /dev/null +++ b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/parsing/DateNode.kt @@ -0,0 +1,73 @@ +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 = 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 + } + } +} diff --git a/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/parsing/SimpleParserRule.kt b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/parsing/SimpleParserRule.kt new file mode 100644 index 0000000..c78c23b --- /dev/null +++ b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/parsing/SimpleParserRule.kt @@ -0,0 +1,27 @@ +package moe.lava.awoocord.scout.parsing + +import android.content.Context +import com.discord.simpleast.core.parser.ParseSpec +import com.discord.simpleast.core.parser.Parser +import com.discord.simpleast.core.parser.Rule +import com.discord.utilities.search.query.node.QueryNode +import java.util.regex.Matcher +import java.util.regex.Pattern + +internal typealias ParserRule = Rule +internal class SimpleParserRule( + regex: Pattern, + private val parseMethod: ( + matcher: Matcher, + parser: Parser, + obj: Any? + ) -> ParseSpec +) : ParserRule(regex) { + override fun parse( + matcher: Matcher, + parser: Parser, + obj: Any? + ): ParseSpec { + return parseMethod(matcher, parser, obj) + } +} diff --git a/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/parsing/SortNode.kt b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/parsing/SortNode.kt new file mode 100644 index 0000000..e839712 --- /dev/null +++ b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/parsing/SortNode.kt @@ -0,0 +1,45 @@ +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 moe.lava.awoocord.scout.ui.ScoutSearchStringProvider +import java.util.regex.Pattern + +class SortNode(private val text: String): AnswerNode() { + companion object { + fun getSortRule(ssProvider: ScoutSearchStringProvider): Rule { + val regexStr = "^\\s*(${ssProvider.sortOldString})" + val regex = Pattern.compile(regexStr, Pattern.UNICODE_CASE) + return SimpleParserRule(regex) { _, _, obj -> + ParseSpec(SortNode(ssProvider.sortOldString), obj) + } + } + + fun getFilterRule(str: String): ParserRule { + val regex = Pattern.compile("^\\s*?(${str}):", 64) + return SimpleParserRule(regex) { _, _, obj -> + ParseSpec(FilterNode(FilterTypeExtension.SORT, str), obj) + } + } + } + + override fun getValidFilters() = setOf(FilterTypeExtension.SORT) + override fun isValid(searchData: SearchData?) = true + override fun getText() = this.text + + override fun updateQuery( + builder: SearchQuery.Builder, + searchData: SearchData?, + filterType: FilterType? + ) { + builder.appendParam("sort_order", "asc") + } +} diff --git a/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/parsing/UserIdNode.kt b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/parsing/UserIdNode.kt new file mode 100644 index 0000000..85ea6c1 --- /dev/null +++ b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/parsing/UserIdNode.kt @@ -0,0 +1,41 @@ +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.validation.SearchData +import java.util.regex.Pattern + +class UserIdNode(private val userID: String) : AnswerNode() { + companion object { + fun getUserIdRule(): Rule { + val regex = Pattern.compile("^\\d{17,19}", Pattern.UNICODE_CASE) + return SimpleParserRule(regex) { matcher, _, obj -> + ParseSpec(UserIdNode(matcher.group()), obj) + } + } + } + + override fun getValidFilters() = setOf(FilterType.FROM, FilterType.MENTIONS) + override fun isValid(searchData: SearchData?) = true + override fun getText() = userID + + override fun updateQuery( + builder: SearchQuery.Builder?, + searchData: SearchData?, + filterType: FilterType? + ) { + checkNotNull(builder) { "queryBuilder" } + checkNotNull(searchData) { "searchData" } + val str = when (filterType) { + FilterType.FROM -> "author_id" + FilterType.MENTIONS -> "mentions" + else -> return + } + builder.appendParam(str, userID) + } +} diff --git a/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/ui/DatePickerFragment.kt b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/ui/DatePickerFragment.kt new file mode 100644 index 0000000..43e967e --- /dev/null +++ b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/ui/DatePickerFragment.kt @@ -0,0 +1,35 @@ +package moe.lava.awoocord.scout.ui + +import android.app.DatePickerDialog +import android.app.Dialog +import android.os.Bundle +import android.widget.DatePicker +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentManager +import java.util.Calendar + +class DatePickerFragment( + private val callback: (String) -> Unit +) : DialogFragment(), DatePickerDialog.OnDateSetListener { + companion object { + fun open(fragmentManager: FragmentManager, callback: (date: String) -> Unit) { + DatePickerFragment(callback).show(fragmentManager, "datePicker") + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val calendar = Calendar.getInstance() + + val year = calendar.get(Calendar.YEAR) + val month = calendar.get(Calendar.MONTH) + val day = calendar.get(Calendar.DAY_OF_MONTH) + + return DatePickerDialog(requireContext(), android.R.style.Theme_DeviceDefault_Dialog, this, year, month, day).apply { + datePicker.maxDate = calendar.timeInMillis + } + } + + override fun onDateSet(picker: DatePicker, year: Int, month: Int, day: Int) { + callback("%04d-%02d-%02d".format(year, month + 1, day)) + } +} diff --git a/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/ui/ScoutResource.kt b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/ui/ScoutResource.kt new file mode 100644 index 0000000..59b9ed7 --- /dev/null +++ b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/ui/ScoutResource.kt @@ -0,0 +1,28 @@ +package moe.lava.awoocord.scout.ui + +import android.content.res.Resources +import android.view.View +import androidx.annotation.DrawableRes +import androidx.core.content.res.ResourcesCompat + +class ScoutResource(private val resources: Resources) { + companion object { + val SORT_FILTER = View.generateViewId() + val SORT_ANSWER = View.generateViewId() + val EXCLUDE_FILTER = View.generateViewId() + val AUTHOR_TYPE_FILTER = View.generateViewId() + val AUTHOR_TYPE_ANSWER = View.generateViewId() + } + + fun getId(name: String, type: String) = + resources.getIdentifier(name, type, "moe.lava.awoocord.scout") + + @DrawableRes fun getDrawableId(name: String) = + getId(name, "drawable") + + fun getDrawable(@DrawableRes id: Int) = + ResourcesCompat.getDrawable(resources, id, null) + + fun getDrawable(name: String) = + getDrawable(getDrawableId(name)) +} diff --git a/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/ui/ScoutSearchStringProvider.kt b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/ui/ScoutSearchStringProvider.kt new file mode 100644 index 0000000..d4accb2 --- /dev/null +++ b/plugins/Scout/src/main/kotlin/moe/lava/awoocord/scout/ui/ScoutSearchStringProvider.kt @@ -0,0 +1,60 @@ +package moe.lava.awoocord.scout.ui + +import android.content.Context +import com.discord.utilities.search.query.FilterType +import com.discord.utilities.search.query.node.answer.HasAnswerOption +import moe.lava.awoocord.scout.FilterTypeExtension +import moe.lava.awoocord.scout.HasAnswerOptionExtension + +private fun String.decapitalise(context: Context) = + this.replaceFirstChar { it.lowercase(context.resources.configuration.locales[0]) } + +class ScoutSearchStringProvider(private val context: Context) { + fun getIdentifier(name: String) = + context.resources.getIdentifier(name, "string", "com.discord") + fun getString(name: String) = + context.getString(getIdentifier(name)) + + fun stringFor(type: FilterType) = when (type) { + FilterTypeExtension.EXCLUDE -> excludeFilterString + FilterTypeExtension.BEFORE -> beforeFilterString + FilterTypeExtension.DURING -> duringFilterString + FilterTypeExtension.AFTER -> afterFilterString + FilterTypeExtension.SORT -> sortFilterString + FilterTypeExtension.AUTHOR_TYPE -> authorTypeFilter + else -> throw IllegalArgumentException("invalid extended filter type") + } + + fun stringFor(type: HasAnswerOption) = when (type) { + HasAnswerOptionExtension.POLL -> hasPollString + HasAnswerOptionExtension.SNAPSHOT -> hasForwardString + else -> throw IllegalArgumentException("invalid extended filter type") + } + + // Surprising!! Discord has localised strings of these + val beforeFilterString: String + get() = getString("search_filter_before") + val duringFilterString: String + get() = getString("search_filter_during") + val afterFilterString: String + get() = getString("search_filter_after") + val sortFilterString: String + get() = getString("sort").decapitalise(context) + val sortOldString: String + get() = getString("search_oldest_short").decapitalise(context) + val expandFilterString: String + get() = getString("friends_pending_request_expand") + + // Not localised + val hasPollString: String + get() = "poll" + val hasForwardString: String + get() = "forward" + val excludeFilterString: String + get() = "exclude" + val authorTypeFilter: String + get() = "authorType" + val authorTypeAnswer: String + // TODO, could probably be localisable by joining each part together + get() = "user, bot or webhook" +} diff --git a/plugins/Scout/src/main/res/drawable/baseline_clock_24.xml b/plugins/Scout/src/main/res/drawable/baseline_clock_24.xml new file mode 100644 index 0000000..c95f574 --- /dev/null +++ b/plugins/Scout/src/main/res/drawable/baseline_clock_24.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/plugins/Scout/src/main/res/drawable/baseline_do_disturb_on_24.xml b/plugins/Scout/src/main/res/drawable/baseline_do_disturb_on_24.xml new file mode 100644 index 0000000..2c537f4 --- /dev/null +++ b/plugins/Scout/src/main/res/drawable/baseline_do_disturb_on_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/plugins/Scout/src/main/res/drawable/baseline_forward_to_inbox_24.xml b/plugins/Scout/src/main/res/drawable/baseline_forward_to_inbox_24.xml new file mode 100644 index 0000000..2ae86b4 --- /dev/null +++ b/plugins/Scout/src/main/res/drawable/baseline_forward_to_inbox_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/plugins/Scout/src/main/res/drawable/baseline_poll_24.xml b/plugins/Scout/src/main/res/drawable/baseline_poll_24.xml new file mode 100644 index 0000000..80380fb --- /dev/null +++ b/plugins/Scout/src/main/res/drawable/baseline_poll_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/plugins/Scout/src/main/res/drawable/baseline_update_24.xml b/plugins/Scout/src/main/res/drawable/baseline_update_24.xml new file mode 100644 index 0000000..579fa47 --- /dev/null +++ b/plugins/Scout/src/main/res/drawable/baseline_update_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/plugins/Scout/src/main/res/drawable/ic_thread_actually_white_24dp.xml b/plugins/Scout/src/main/res/drawable/ic_thread_actually_white_24dp.xml new file mode 100644 index 0000000..560be79 --- /dev/null +++ b/plugins/Scout/src/main/res/drawable/ic_thread_actually_white_24dp.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/plugins/Scout/src/main/res/drawable/smart_toy_24px.xml b/plugins/Scout/src/main/res/drawable/smart_toy_24px.xml new file mode 100644 index 0000000..30dd4b2 --- /dev/null +++ b/plugins/Scout/src/main/res/drawable/smart_toy_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/plugins/Scout/src/main/res/drawable/webhook_24px.xml b/plugins/Scout/src/main/res/drawable/webhook_24px.xml new file mode 100644 index 0000000..74a3dc7 --- /dev/null +++ b/plugins/Scout/src/main/res/drawable/webhook_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/plugins/Zinnia/build.gradle.kts b/plugins/Zinnia/build.gradle.kts new file mode 100644 index 0000000..eec7d36 --- /dev/null +++ b/plugins/Zinnia/build.gradle.kts @@ -0,0 +1,29 @@ +version = "1.2.1" +description = "Coloured usernames to be a bit more pleasing on the eyes" + +aliucord { + // Changelog of your plugin + changelog.set(""" + # 1.2.1 + * Use correct default block colour in replies + * Use correct default block colour in "unchanged" mode + + # 1.2.0 + * Finally fixes the annoying padding issue in replies + * Adds nice preview blocks in settings with configurable hsv bars for all your previewing needs + * Tweaked constrast ratio a bit which may improve some colours' legibility + * Added transparency option, alongside "unchanged" colour option which pairs nicely together for a translucent glass effect + + # 1.1.1 + * Revert incorrect spacing fix, since it just breaks replies. Proper fix soon + + # 1.1.0 + * Fix incorrect spacing in replies + * Allow setting static text colours + + # 1.0.0 + * Initial release >w< + """.trimIndent()) + + deploy.set(true) +} diff --git a/plugins/Zinnia/src/main/kotlin/moe/lava/awoocord/zinnia/APCA.kt b/plugins/Zinnia/src/main/kotlin/moe/lava/awoocord/zinnia/APCA.kt new file mode 100644 index 0000000..e098626 --- /dev/null +++ b/plugins/Zinnia/src/main/kotlin/moe/lava/awoocord/zinnia/APCA.kt @@ -0,0 +1,77 @@ +package moe.lava.awoocord.zinnia + +import kotlin.math.abs +import kotlin.math.pow + +// https://github.com/Myndex/apca-w3/blob/c012257167d822f91bc417120bdb82e1b854b4a4/src/apca-w3.js +object APCA { + @Suppress("ConstPropertyName") + private object SA98G { + const val mainTRC = 2.4 + + const val sRco = 0.2126729 + const val sGco = 0.7151522 + const val sBco = 0.0721750 + + const val normBG = 0.56 + const val normTXT = 0.57 + const val revTXT = 0.62 + const val revBG = 0.65 + + const val blkThrs = 0.022 + const val blkClmp = 1.414 + const val scaleBoW = 1.14 + const val scaleWoB = 1.14 + const val loBoWoffset = 0.027 + const val loWoBoffset = 0.027 + const val deltaYmin = 0.0005 + const val loClip = 0.1 + } + + private fun exp(c: Int) = + (c.toDouble() / 255.0).pow(SA98G.mainTRC) + + private fun argbToY(color: Int): Double { + val r = (color shr 16) and 0xff + val g = (color shr 8) and 0xff + val b = color and 0xff + + return SA98G.run { + sRco * exp(r) + sGco * exp(g) + sBco * exp(b) + } + } + + fun contrast(fgC: Int, bgC: Int): Double { + var fg = argbToY(fgC) + var bg = argbToY(bgC) + + if (fg.coerceAtMost(bg) < 0 || fg.coerceAtLeast(bg) > 1.1) + return 0.0 + + if (fg <= SA98G.blkThrs) + fg += (SA98G.blkThrs - fg).pow(SA98G.blkClmp) + if (bg <= SA98G.blkThrs) + bg += (SA98G.blkThrs - bg).pow(SA98G.blkClmp) + + if (abs(bg - fg) < SA98G.deltaYmin) + return 0.0 + + val outputContrast = if (bg > fg) { + val sapc = (bg.pow(SA98G.normBG) - fg.pow(SA98G.normTXT)) * SA98G.scaleBoW + + if (sapc < SA98G.loClip) + 0.0 + else + sapc - SA98G.loBoWoffset + } else { + val sapc = (bg.pow(SA98G.revBG) - fg.pow(SA98G.revTXT)) * SA98G.scaleWoB + + if (sapc > -SA98G.loClip) + 0.0 + else + sapc + SA98G.loWoBoffset + } + + return outputContrast * 100 + } +} diff --git a/plugins/Zinnia/src/main/kotlin/moe/lava/awoocord/zinnia/APCAUtil.kt b/plugins/Zinnia/src/main/kotlin/moe/lava/awoocord/zinnia/APCAUtil.kt new file mode 100644 index 0000000..fbf9466 --- /dev/null +++ b/plugins/Zinnia/src/main/kotlin/moe/lava/awoocord/zinnia/APCAUtil.kt @@ -0,0 +1,117 @@ +package moe.lava.awoocord.zinnia + +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.widget.TextView +import androidx.core.graphics.ColorUtils +import com.aliucord.utils.DimenUtils.dp +import com.discord.stores.StoreStream +import kotlin.math.abs + +enum class Threshold { + Large, + Medium, + Small +} + +internal object APCAUtil { + private val settings = ZinniaSettings + + internal fun configureOn(view: TextView, colour: Int?, threshold: Threshold) { + when (settings.mode) { + Mode.Block -> configureBlock(view, colour ?: Color.BLACK, threshold) + Mode.RoleDot -> configureRoleDot(view, colour ?: Color.BLACK) + } + } + + private fun configureRoleDot(view: TextView, colour: Int) { } + + private fun configureBlock(view: TextView, colourP: Int, threshold: Threshold) { + val isLight = StoreStream.getUserSettingsSystem().theme == "light" + var colour = colourP + val bcol = GradientDrawable() + bcol.cornerRadius = 4.dp.toFloat() + view.background = bcol + view.setPadding(4.dp, 0, 4.dp, 0) + + if (colour == Color.BLACK) { + if (settings.blockAlsoDefault) { + colour = if (isLight && (settings.blockInverted || settings.blockMode == BlockMode.Unchanged)) { + Color.BLACK + } else { + Color.WHITE + } + } else { + view.background = null + view.setPadding(0, 0, 0, 0) + return + } + } + + var (preferred, other) = if (isLight) { + Color.WHITE to Color.BLACK + } else { + Color.BLACK to Color.WHITE + } + when (settings.blockMode) { + BlockMode.InvertedThemeOnly -> preferred = other + BlockMode.WhiteOnly -> preferred = Color.WHITE + BlockMode.BlackOnly -> preferred = Color.BLACK + BlockMode.Unchanged -> preferred = colour + else -> {} + } + + val colours = if (!settings.blockInverted) { + Colours( + fgP = preferred, + fgO = other, + bgP = colour, + bgO = colour, + ) + } else { + Colours( + fgP = colour, + fgO = colour, + bgP = preferred, + bgO = other, + ) + } + + val usePreferred = when (settings.blockMode) { + BlockMode.ApcaOnly -> isApca(colours, threshold) + BlockMode.WcagOnly -> isWcag(colours) + BlockMode.ApcaLightWcagDark -> if (isLight) isApca(colours, threshold) else isWcag(colours) + BlockMode.WcagLightApcaDark -> if (isLight) isWcag(colours) else isApca(colours, threshold) + BlockMode.ThemeOnly, + BlockMode.InvertedThemeOnly, + BlockMode.WhiteOnly, + BlockMode.BlackOnly, + BlockMode.Unchanged -> true + } + + if (usePreferred) { + view.setTextColor(colours.fgP) + bcol.setColor(ColorUtils.setAlphaComponent(colours.bgP, settings.alpha)) + } else { + view.setTextColor(colours.fgO) + bcol.setColor(ColorUtils.setAlphaComponent(colours.bgO, settings.alpha)) + } + } + + private fun isApca(c: Colours, threshold: Threshold): Boolean { + val cPref = abs(APCA.contrast(c.fgP, c.bgP)) + val cOth = abs(APCA.contrast(c.fgO, c.bgO)) + val thresholdValue = when (threshold) { + Threshold.Large -> settings.blockApcaThresholdLarge + Threshold.Medium -> settings.blockApcaThresholdMedium + Threshold.Small -> settings.blockApcaThresholdSmall + } + return cPref > thresholdValue || cPref > cOth + } + + private fun isWcag(c: Colours): Boolean { + val cPref = ColorUtils.calculateContrast(c.fgP, c.bgP) + val cOth = ColorUtils.calculateContrast(c.fgO, c.bgO) + return cPref > settings.blockWcagThreshold || cPref > cOth + } +} diff --git a/plugins/Zinnia/src/main/kotlin/moe/lava/awoocord/zinnia/Zinnia.kt b/plugins/Zinnia/src/main/kotlin/moe/lava/awoocord/zinnia/Zinnia.kt new file mode 100644 index 0000000..f70ebde --- /dev/null +++ b/plugins/Zinnia/src/main/kotlin/moe/lava/awoocord/zinnia/Zinnia.kt @@ -0,0 +1,106 @@ +package moe.lava.awoocord.zinnia + +import android.content.Context +import android.graphics.Color +import android.view.View +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import com.aliucord.annotations.AliucordPlugin +import com.aliucord.entities.Plugin +import com.aliucord.patcher.after +import com.aliucord.patcher.component1 +import com.aliucord.patcher.component2 +import com.aliucord.patcher.component3 +import com.aliucord.patcher.instead +import com.aliucord.utils.DimenUtils.dp +import com.aliucord.utils.accessField +import com.discord.databinding.WidgetChannelMembersListItemUserBinding +import com.discord.models.member.GuildMember +import com.discord.widgets.channels.memberlist.adapter.ChannelMembersListAdapter +import com.discord.widgets.channels.memberlist.adapter.ChannelMembersListViewHolderMember +import com.discord.widgets.chat.list.adapter.WidgetChatListAdapterItemMessage +import com.discord.widgets.chat.list.entries.ChatListEntry +import com.discord.widgets.chat.list.entries.MessageEntry + +private val ChannelMembersListViewHolderMember.binding + by accessField() +private val WidgetChatListAdapterItemMessage.itemName + by accessField() +private val WidgetChatListAdapterItemMessage.replyName + by accessField() + +data class Colours( + val fgP: Int, + val bgP: Int, + val fgO: Int, + val bgO: Int, +) + +@AliucordPlugin +class Zinnia : Plugin() { + companion object { const val NAME = "RoleBlocks" } + + init { + settingsTab = SettingsTab(ZinniaSettings.Page::class.java, SettingsTab.Type.PAGE) + } + + override fun start(context: Context) { + patchMemberList() + patchMessageAuthor() + } + + override fun stop(context: Context) { patcher.unpatchAll() } + + private fun patchMemberList() { + // Patches the method that configures the username in members list + patcher.after( + "bind", + ChannelMembersListAdapter.Item.Member::class.java, + Function0::class.java, + ) { (_, member: ChannelMembersListAdapter.Item.Member) -> + val presenceTextView = binding.d + val usernameView = binding.f + val usernameTextView = usernameView.j.c + + if (presenceTextView.visibility == View.VISIBLE) { + usernameView.layoutParams = (usernameView.layoutParams as ConstraintLayout.LayoutParams).apply { + bottomMargin = 2.dp + } + } + + APCAUtil.configureOn(usernameTextView, member.color, Threshold.Medium) + } + } + + private fun patchMessageAuthor() { + // Configures for message author username + patcher.after( + "onConfigure", + Int::class.javaPrimitiveType!!, + ChatListEntry::class.java, + ) { (_, _: Int, entry: MessageEntry) -> + itemName?.let { + APCAUtil.configureOn(it, entry.author?.color, Threshold.Large) + } + } + + patcher.instead( + "getAuthorTextColor", + GuildMember::class.java, + ) { (_, member: GuildMember?) -> + member?.color ?: Color.BLACK + } + + // Configures for reply preview username + patcher.after( + "configureReplyName", + String::class.java, + Int::class.javaPrimitiveType!!, + Boolean::class.javaPrimitiveType!!, + ) { (_, _: String, colour: Int) -> + replyName?.let { + APCAUtil.configureOn(it, colour, Threshold.Small) + } + } + } +} diff --git a/plugins/Zinnia/src/main/kotlin/moe/lava/awoocord/zinnia/ZinniaSettings.kt b/plugins/Zinnia/src/main/kotlin/moe/lava/awoocord/zinnia/ZinniaSettings.kt new file mode 100644 index 0000000..72ca84c --- /dev/null +++ b/plugins/Zinnia/src/main/kotlin/moe/lava/awoocord/zinnia/ZinniaSettings.kt @@ -0,0 +1,292 @@ +package moe.lava.awoocord.zinnia + +import android.graphics.Color +import android.util.TypedValue +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.LinearLayout +import android.widget.SeekBar +import android.widget.TextView +import androidx.core.content.res.ResourcesCompat +import com.aliucord.Constants +import com.aliucord.Utils +import com.aliucord.api.SettingsAPI +import com.aliucord.fragments.SettingsPage +import com.aliucord.settings.delegate +import com.aliucord.utils.DimenUtils.dp +import com.aliucord.wrappers.users.globalName +import com.discord.stores.StoreStream +import com.discord.utilities.color.ColorCompat +import com.discord.views.CheckedSetting +import com.lytefast.flexinput.R +import kotlin.math.roundToInt +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +enum class Mode { + RoleDot, + Block, +} + +enum class BlockMode { + ApcaLightWcagDark, + WcagLightApcaDark, + ApcaOnly, + WcagOnly, + ThemeOnly, + InvertedThemeOnly, + WhiteOnly, + BlackOnly, + Unchanged, +} + +class SettingsDelegateEnum>( + private val defaultValue: T, + private val settings: SettingsAPI, + private val deserialiser: (String) -> T, +) : ReadWriteProperty { + override fun getValue(thisRef: Any, property: KProperty<*>): T = + deserialiser(settings.getString(property.name, defaultValue.name)) + + override fun setValue(thisRef: Any, property: KProperty<*>, value: T) = + settings.setString(property.name, value.name) +} + +inline fun > SettingsAPI.delegateEnum( + defaultValue: T +) = SettingsDelegateEnum(defaultValue, this) { enumValueOf(it) } + +private inline fun T.addTo(parent: ViewGroup, block: T.() -> Unit = {}) = + apply { + block() + parent.addView(this) + } + +private typealias Delegate = ReadWriteProperty + +fun basicDelegate(initial: T) = object : Delegate { + private var current = initial + override fun getValue(self: Any?, prop: KProperty<*>): T = current + override fun setValue(self: Any?, prop: KProperty<*>, value: T) { current = value } +} + +private class StateDelegate( + private val inner: Delegate, + private val update: (T) -> Unit, +) : Delegate { + override fun getValue(self: Any?, prop: KProperty<*>): T = inner.getValue(self, prop) + + override fun setValue(self: Any?, prop: KProperty<*>, value: T) { + inner.setValue(self, prop, value) + update(value) + } +} + +object ZinniaSettings { + private val api = SettingsAPI(Zinnia.NAME) + + private var onStateUpdate = {} + + private inline fun reactive(backing: () -> Delegate): StateDelegate { + return StateDelegate(backing()) { onStateUpdate() } + } + + var mode by reactive { api.delegateEnum(Mode.Block) } + + var blockAlsoDefault by reactive { api.delegate(true) } + var blockInverted by reactive { api.delegate(false) } + var blockMode by reactive { api.delegateEnum(BlockMode.ApcaLightWcagDark) } + var blockApcaThresholdLarge by reactive { api.delegate(45.0f) } + var blockApcaThresholdMedium by reactive { api.delegate(45.0f) } + var blockApcaThresholdSmall by reactive { api.delegate(45.0f) } + var blockWcagThreshold by reactive { api.delegate(4.5f) } + + private val _alpha = reactive { api.delegate("alpha", 255) } + var alpha by _alpha + + class Page : SettingsPage() { + private val checks = mutableListOf() + + private val _previewH = reactive { basicDelegate(0) } + private var previewH by _previewH + private val _previewS = reactive { basicDelegate(100) } + private var previewS by _previewS + private val _previewV = reactive { basicDelegate(100) } + private var previewV by _previewV + + private fun addRadio(newMode: BlockMode, text: String, subtext: String? = null): CheckedSetting { + return Utils.createCheckedSetting(requireContext(), CheckedSetting.ViewType.RADIO, text, subtext).addTo(linearLayout) { + isChecked = blockMode == newMode + setOnCheckedListener { + for (check in checks) check.isChecked = false + blockMode = newMode + isChecked = true + } + checks.add(this) + } + } + + private fun createLabel(text: String? = null): TextView { + return TextView(context, null, 0, R.i.UiKit_TextView).apply { + textSize = 16.0f + typeface = ResourcesCompat.getFont(context, Constants.Fonts.whitney_medium) + this.text = text + layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { + bottomMargin = 4.dp + } + } + } + + private fun addSlider( + min: Int, + max: Int, + initial: Int = min, + onChange: (value: Int, commit: Boolean) -> String + ): LinearLayout { + var pendingValue = initial + return LinearLayout(requireContext(), null, 0, R.i.UiKit_Settings_Item).addTo(linearLayout) { + orientation = LinearLayout.VERTICAL + val display = createLabel(onChange(initial, false)).addTo(this) + SeekBar(context, null, 0, R.i.UiKit_SeekBar).addTo(this) { + this.max = max - min + progress = initial + setPadding(12.dp, 0, 12.dp, 0) + setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged( + seekBar: SeekBar, + progress: Int, + fromUser: Boolean, + ) { + pendingValue = min + progress + display.text = onChange(pendingValue, false) + } + + override fun onStartTrackingTouch(seekBar: SeekBar) {} + override fun onStopTrackingTouch(seekBar: SeekBar) { + onChange(pendingValue, true) + } + }) + } + } + } + + private fun addSlider(binding: Delegate, min: Int, max: Int, immediate: Boolean = false, label: (Int) -> String): LinearLayout { + var value by binding + return addSlider(min, max, value) { newValue, commit -> + @Suppress("AssignedValueIsNeverRead") // kt so dumb + if (immediate || commit) value = newValue + label(newValue) + } + } + + private fun createPreview( + label: String, + styleRes: Int, + ): TextView { + val ctx = requireContext() + val view = TextView(ctx, null, 0, styleRes).apply { + val me = StoreStream.getUsers().me + text = me.globalName ?: me.username + layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { + marginStart = 16.dp + marginEnd = 16.dp + } + } + LinearLayout(ctx, null, 0, R.i.UiKit_Settings_Item).addTo(linearLayout) { + view.addTo(this) + createLabel(label).addTo(this) { + layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { + bottomMargin = 0 + } + } + } + return view + } + + override fun onDestroyView() { + onStateUpdate = {} + super.onDestroyView() + } + + override fun onViewBound(view: View) { + super.onViewBound(view) + setActionBarTitle(Zinnia.NAME) + setPadding(0) + + val ctx = requireContext() + linearLayout.run { + val blockSettings = mutableListOf() + val roleDotSettings = mutableListOf() + + addHeader(ctx, "Text colour") + addRadio(BlockMode.ApcaLightWcagDark, "Automatic", "Adjusts text colour based on optimal contrast with role colour") + addRadio(BlockMode.ThemeOnly, "By theme", "Adjusts text colour based on system theme (dark/light)") + addRadio(BlockMode.InvertedThemeOnly, "By theme (inverted)", "Same as above, but inverted") + addRadio(BlockMode.WhiteOnly, "White", "Force text colour to be white") + addRadio(BlockMode.BlackOnly, "Black", "Force text colour to be black") + addRadio(BlockMode.Unchanged, "Unchanged", "Keep text colour; ideal for using with a translucent block") + + addHeader(ctx, "Block Settings") + + val invertSwitch = Utils.createCheckedSetting( + ctx, + CheckedSetting.ViewType.SWITCH, + "Invert block colours", + "By default, the role colour is applied as the block background. Turning this setting on inverts this.\nHas no effect with \"Unchanged\" colour option", + ).addTo(this) { + isChecked = blockInverted + setOnCheckedListener { + blockInverted = !blockInverted + } + blockSettings.add(this) + } + + addSlider(_alpha, 0, 255, true) { "Alpha: ${(it / 2.55f).roundToInt()}%" } + +// createSlider(0, 255, blockApcaThreshold.roundToInt()) { value, commit -> +// blockApcaThreshold = value.toFloat() +// "Apca Threshold: $value" +// } + + addHeader(ctx, "Preview") + val previews = mutableListOf( + Threshold.Large to createPreview("Message header username", R.i.UiKit_TextView_Large_SingleLine), + Threshold.Medium to createPreview("Channels list", R.i.UiKit_TextView).apply { + setTextSize(TypedValue.COMPLEX_UNIT_PX, resources.getDimension(R.d.uikit_textsize_medium)) + }, + Threshold.Small to createPreview("Message reply username", R.i.UiKit_TextView).apply { + setTextSize(TypedValue.COMPLEX_UNIT_PX, resources.getDimension(R.d.uikit_textsize_small)) + }, + ) + + val hsv = floatArrayOf(0f, 0f, 0f) + Color.colorToHSV(ColorCompat.getThemedColor(this, R.b.color_brand), hsv) + previewH = hsv[0].roundToInt() + previewS = (hsv[1] * 100).roundToInt() + previewV = (hsv[2] * 100).roundToInt() + addSlider(_previewH, 0, 360, true) { "Hue: $it" } + addSlider(_previewS, 0, 100, true) { "Saturation: $it%" } + addSlider(_previewV, 0, 100, true) { "Value: $it%" } + + onStateUpdate = { + previews.forEach { updatePreview(it) } + if (blockMode != BlockMode.Unchanged) { + invertSwitch.l.b().isClickable = true + invertSwitch.alpha = 1f + } else { + invertSwitch.l.b().isClickable = false + invertSwitch.alpha = 0.3f + } + } + onStateUpdate() + } + } + + fun updatePreview(pair: Pair) { + val (threshold, preview) = pair + val colour = Color.HSVToColor(floatArrayOf(previewH.toFloat(), previewS / 100f, previewV / 100f)) + APCAUtil.configureOn(preview, colour, threshold) + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..f6d7bdf --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,40 @@ +@file:Suppress("UnstableApiUsage") + +pluginManagement { + repositories { + google() + gradlePluginPortal() + maven("https://maven.aliucord.com/releases") + maven("https://maven.aliucord.com/snapshots") + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + maven("https://maven.aliucord.com/releases") + maven("https://maven.aliucord.com/snapshots") + } +} + +rootProject.name = "Awoocord" + +val plugins = mapOf( + "ComponentsV2Beta" to "canary/ComponentsV2", + "SlashCommandsFixBeta" to "canary/SlashCommandsFix", + "Bubbles" to "plugins/Crocosmia", + "Clump" to "plugins/Bocchi", + "Scout" to "plugins/Scout", + "RoleBlocks" to "plugins/Zinnia", + "Glance" to "plugins/Myosotis", +) + +include(*plugins.keys.toTypedArray()) + +rootProject.children.forEach { project -> + plugins[project.name]?.let { + project.projectDir = file(it) + } +} diff --git a/updater.json b/updater.json deleted file mode 100644 index 3cc5ad4..0000000 --- a/updater.json +++ /dev/null @@ -1 +0,0 @@ -{"RoleBlocks":{"version":"1.2.1","build":"https://cdn.jsdelivr.net/gh/cillynder/Awoocord@refs/heads/builds/RoleBlocks.zip","buildCrc32":"F6D9538A","changelog":"# 1.2.1\n* Use correct default block colour in replies\n* Use correct default block colour in \"unchanged\" mode\n\n# 1.2.0\n* Finally fixes the annoying padding issue in replies\n* Adds nice preview blocks in settings with configurable hsv bars for all your previewing needs\n* Tweaked constrast ratio a bit which may improve some colours' legibility\n* Added transparency option, alongside \"unchanged\" colour option which pairs nicely together for a translucent glass effect\n\n# 1.1.1\n* Revert incorrect spacing fix, since it just breaks replies. Proper fix soon\n\n# 1.1.0\n* Fix incorrect spacing in replies\n* Allow setting static text colours\n\n# 1.0.0\n* Initial release >w<","minimumDiscordVersion":126021,"minimumAliucordVersion":"2.6.0","minimumKotlinVersion":"1.5.21","minimumApiLevel":21},"Bubbles":{"version":"1.0.0","build":"https://cdn.jsdelivr.net/gh/cillynder/Awoocord@refs/heads/builds/Bubbles.zip","buildCrc32":"519E0D3A","changelog":"# 1.0.0\n* Initial release >w<","minimumDiscordVersion":126021,"minimumAliucordVersion":"2.6.0","minimumKotlinVersion":"1.5.21","minimumApiLevel":21},"Glance":{"version":"1.0.0","build":"https://cdn.jsdelivr.net/gh/cillynder/Awoocord@refs/heads/builds/Glance.zip","buildCrc32":"7BC2ECDD","changelog":"# 1.0.0\n* Initial release >w<","minimumDiscordVersion":126021,"minimumAliucordVersion":"2.6.0","minimumKotlinVersion":"1.5.21","minimumApiLevel":21},"ComponentsV2Beta":{"version":"8.8.0","build":"https://cdn.jsdelivr.net/gh/cillynder/Awoocord@refs/heads/builds/ComponentsV2Beta.zip","buildCrc32":"9BC12E76","changelog":"TODO {fixed}\n======================\n* File component\n* SelectV2: searching\n* SelectV2: showing selected items in chat list\n\nChangelog {added marginTop}\n======================\n# 8.8.0\n* Fix a possible weird crash\n\n# 8.7.0\n* Prevent ViewRaw crash\n* Add a CV2 tag to distinguish new embeds (will not be in core)\n\n# 7.15.1\n* Fix broken reply preview >w<\n\n# 7.15.0\n* Initial release >w<","minimumDiscordVersion":126021,"minimumAliucordVersion":"2.6.0","minimumKotlinVersion":"1.5.21","minimumApiLevel":21},"SlashCommandsFixBeta":{"version":"8.18.0","build":"https://cdn.jsdelivr.net/gh/cillynder/Awoocord@refs/heads/builds/SlashCommandsFixBeta.zip","buildCrc32":"BCA561A9","changelog":"# 8.18.0\n* Don't use custom props anymore (core has them)\n\n# 7.16.2\n* Use new props\n\n# 7.16.1\n* Prompt restarts\n\n# 7.16.0\n* Initial port >w< thanks @jedenastka","minimumDiscordVersion":126021,"minimumAliucordVersion":"2.6.0","minimumKotlinVersion":"1.5.21","minimumApiLevel":21},"Clump":{"version":"1.0.3","build":"https://cdn.jsdelivr.net/gh/cillynder/Awoocord@refs/heads/builds/Clump.zip","buildCrc32":"7D59ED3F","changelog":"# 1.0.3\n* Clump more than 6 messages together\n\n# 1.0.2\n* Fix (inverted) webhook clumping\n\n# 1.0.1\n* Hide blank space w.r.t attachments and embeds\n\n# 1.0.0\n* Initial release >w<","minimumDiscordVersion":126021,"minimumAliucordVersion":"2.6.0","minimumKotlinVersion":"1.5.21","minimumApiLevel":21},"Scout":{"version":"1.4.0","build":"https://cdn.jsdelivr.net/gh/cillynder/Awoocord@refs/heads/builds/Scout.zip","buildCrc32":"21C04736","changelog":"!!! Minimum Aliucord version requirement {fixed}\n======================\n* Scout now requires Aliucord 2.4.0, please update before reporting issues.\n\nChangelog {added marginTop}\n======================\n# 1.4.0 - Scout is searching for clues about the elusive MvM update\n* Added the authorType filter option to search by user, bot, or webhook\n* Moved sort filter to the top of the new ones\n* Fixes a Discord bug where typing \"mentions\" would also suggest \"has\"\n* 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.\n\n# 1.3.0\n* Removes empty discriminator when searching with users\n\n# 1.2.2\n* Fix possible rare crash related to thread searching\n\n# 1.2.1\n* Fixes off-looking thread icon\nOnly Discord will name an icon \"thread_white_24dp\", and it's neither white nor 24dp. Seriously, what were they thinking?\n\n# 1.2.0 - Scout is in:to knitting\n* Adds support for searching threads; simply use in:\n\n# 1.1.3\n* Patch to fix the biggggg top padding in results\n\n# 1.1.2\n* Fix month being one month behind after using the date picker\n\n# 1.1.1\n* Use proper icons for search filter suggestions\n\n# 1.1.0 - Look out, Scout has:updates\n* Add \"has:forward\" and \"has:poll\" filters\n* Add \"exclude:\" filter. It is the opposite of \"has:\" and filters out matching elements\n\n# 1.0.1\n* Fix not being able to search more than one page with sort:old\n\n# 1.0.0\n* Initial release >w<","minimumDiscordVersion":126021,"minimumAliucordVersion":"2.6.0","minimumKotlinVersion":"1.5.21","minimumApiLevel":21}} \ No newline at end of file