Compare commits

..

30 commits

Author SHA1 Message Date
8ea6536414
chore(Bocchi): 1.0.3 2026-02-19 07:48:28 +11:00
a419914b6b
fix(Bocchi): concat more than 6 messages 2026-02-19 07:44:22 +11:00
2f2a929c97
chore(readme): add glance 2026-02-19 01:16:28 +11:00
3a460de488
feat(Myosotis): init 2026-02-19 01:13:10 +11:00
f7c91222fc
chore(Zinnia): 1.2.1 2026-02-18 17:32:40 +11:00
e415b22dbd
fix(Zinnia): use correct default colour for unchanged mode 2026-02-18 17:31:44 +11:00
5fb72078f6
fix(Zinnia): use correct colours in reply author 2026-02-18 17:20:58 +11:00
4904866f51
chore(Scout): 1.4.0 2026-02-18 03:21:07 +11:00
e50bb66c40
lint(Scout): minor linting 2026-02-18 03:15:33 +11:00
e4ab9f936d
feat(Scout): add authorType filter
whew, the code is getting quite big... A refactor will be coming soon

Some light linting was also done here
2026-02-18 03:14:40 +11:00
302ea0094a
feat(Scout): move sort filter to the top 2026-02-17 22:47:38 +11:00
0116199838
fix(Scout): fix discord's has filter typo
also refactored a few things into `fix...()` methods instead
2026-02-17 22:45:01 +11:00
0741b7951d
feat(Scout): hide all new filters under a "See All" 2026-02-17 22:35:25 +11:00
ca3960e279
chore(readme): expand on scout features 2026-02-17 17:58:15 +11:00
fb4926cb04
chore: update repo links 2026-02-17 17:25:58 +11:00
8deb4d95da
chore(Zinnia): 1.2.0 2026-02-17 17:24:11 +11:00
69580d72b0
fix(Zinnia): use a different patch that's more consistent on reply names 2026-02-17 17:23:17 +11:00
4fb5486a39
feat(Zinnia): add configurable thresholds and previews for each size
Currently thresholds are unused, one day they should be hooked up to
some formula based on real device pixels
2026-02-17 17:12:36 +11:00
e0b86e0fb4
feat(Zinnia): refactor and add like, a bunch of stuff
- refactor: move colour utilities to APCAUtil, for sharing with settings
  preview
- feat: speaking of which, a nice preview in settings!! featuring hsv
  bars for all your previewing needs
- feat: changed apca threshold to 45, I found this to be nicer than 75
- feat: added transparency option, alongside "unchanged" colour option
  which pairs nicely together for a translucent glass effect
2026-02-17 16:33:00 +11:00
bc4aa09fff
feat(Crocosmia): deploy 2026-02-06 21:25:08 +11:00
5bee566297
feat(Crocosmia): init 2026-02-06 21:24:51 +11:00
cf89612cb2
fix(Bocchi): bleh 2026-01-24 11:40:03 +11:00
782538bb10
chore(Bocchi): 1.0.2 2026-01-24 11:35:05 +11:00
e0a2d0e030
fix(Bocchi): clump webhooks properly 2026-01-24 11:34:14 +11:00
6af545070e
chore(Bocchi): 1.0.1 2026-01-23 00:02:12 +11:00
53248f08fe
fix(Bocchi): remove blank space when grouping embeds 2026-01-23 00:01:58 +11:00
62fe1cfb47
chore(workflow): change to new paths 2026-01-22 23:37:02 +11:00
be88f41d31
fix(Bocchi): require restart 2026-01-22 23:35:57 +11:00
e051d37483
chore(workflow): use jdk21 2026-01-22 23:26:03 +11:00
1bc5341a31
feat(Bocchi): init 2026-01-22 23:25:19 +11:00
29 changed files with 1186 additions and 263 deletions

View file

@ -33,10 +33,10 @@ jobs:
repository: "Aliucord/Aliucord"
path: "repo"
- name: Setup JDK 11
- name: Setup JDK 21
uses: actions/setup-java@v1
with:
java-version: 11
java-version: 21
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
@ -46,8 +46,8 @@ jobs:
cd $GITHUB_WORKSPACE/src
chmod +x gradlew
./gradlew make generateUpdaterJson
cp {canary,plugins}/*/build/*.zip $GITHUB_WORKSPACE/builds
cp build/updater.json $GITHUB_WORKSPACE/builds
cp {canary,plugins}/*/build/outputs/*.zip $GITHUB_WORKSPACE/builds
cp build/outputs/updater.json $GITHUB_WORKSPACE/builds
- name: Push builds
run: |

View file

@ -1,26 +1,43 @@
# Awoocord Plugins
## [Bubbles](plugins/Zinnia) | [Download](https://github.com/LavaDesu/Awoocord/raw/builds/RoleBlocks.zip)
## [Bubbles](plugins/Crocosmia) | [Download](https://github.com/cillynder/Awoocord/raw/builds/Bubbles.zip)
Wrap messages in bubbles inspired by Material 3 Expressive
## [RoleBlocks](plugins/Zinnia) | [Download](https://github.com/LavaDesu/Awoocord/raw/builds/RoleBlocks.zip)
## [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/LavaDesu/Awoocord/raw/builds/Scout.zip)
## [Scout](plugins/Scout) | [Download](https://github.com/cillynder/Awoocord/raw/builds/Scout.zip)
Reimplemented features from search of other clients:
- Sorting by oldest first
- Filter by date
- Search from user ID
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/LavaDesu/Awoocord/raw/builds/SlashCommandsFixBeta.zip)
## [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/LavaDesu/Awoocord/raw/builds/ComponentsV2Beta.zip)
## [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.

View file

@ -46,8 +46,8 @@ subprojects {
}
configure<AliucordExtension> {
author("Lava", 368398754077868032L, hyperlink = true)
github("https://github.com/LavaDesu/Awoocord")
author("cilly", 368398754077868032L, hyperlink = false)
github("https://github.com/cillynder/Awoocord")
}
configure<KtlintExtension> {

View file

@ -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)
}

View file

@ -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<SimpleDraweeSpanTextView>()
@AliucordPlugin(requiresRestart = true)
@Suppress("unused")
class Bocchi : Plugin() {
override fun start(context: Context) {
patcher.after<WidgetChatListAdapterItemMessage>(
"onConfigure",
Int::class.java,
ChatListEntry::class.java,
) { (_, _: Int, entry: MessageEntry) ->
if (entry.type == ChatListEntry.MESSAGE_MINIMAL && entry.message.content.isNullOrEmpty()) {
itemText.visibility = View.GONE
}
}
patcher.instead<WidgetChatListModelMessages.Companion>(
"shouldConcatMessage",
WidgetChatListModelMessages.Items::class.java,
Message::class.java,
Message::class.java,
) { (_, items: WidgetChatListModelMessages.Items, message: Message, message2: Message?) ->
val timeDiff = (message.timestamp?.g() ?: 0L) - (message2?.timestamp?.g() ?: 0L)
return@instead !(
message2 == null ||
message2.isSystemMessage ||
message.hasThread() ||
message2.hasThread() ||
message.type !in arrayOf(MessageTypes.DEFAULT, MessageTypes.LOCAL) ||
message.author.id != message2.author.id ||
timeDiff >= 420000 || // WidgetChatListModelMessages.MESSAGE_CONCAT_TIMESTAMP_DELTA_THRESHOLD
// items.listItemMostRecentlyAdded.type !in arrayOf(0, 1, 4, 21) ||
// message2.hasAttachments() ||
// message2.hasEmbeds() ||
// message2.mentions?.isNotEmpty() == true ||
// message.mentions?.isNotEmpty() == true ||
// message.hasAttachments() ||
// message.hasEmbeds() ||
// items.concatCount >= 5 ||
(message.isWebhook && message.author?.username != message2.author.username)
)
}
}
}

View file

@ -8,5 +8,5 @@ aliucord {
* Initial release >w<
""".trimIndent())
deploy.set(false)
deploy.set(true)
}

View file

@ -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)
}

View file

@ -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<WidgetChannelsListItemChannelPrivateBinding>()
private val responseType = TypeToken.getParameterized(List::class.java, Message::class.java).type
data class ChannelIdsPayload(
@SerializedName("channel_ids") val channelIds: List<Long>,
)
data class MessageItem(
val id: Long,
val content: String?,
)
fun Message.wrap(): MessageItem {
val author = this.e()
val authorName = if (author.id == StoreStream.getUsers().me.id) {
"You"
} else {
author.globalName ?: author.username
}
val content = this.i()
.takeIf { it.isNotEmpty() }
?.let { content -> "$authorName: ${content.takeWhile { it != '\n' }}" }
return MessageItem(
id = this.o(),
content = content,
)
}
fun SimpleDraweeSpanTextView.renderText(content: String, other: Pair<Long, String>) {
val me = StoreStream.getUsers().me
val meId = me.id
val meName = me.globalName ?: me.username
val processor = MessagePreprocessor(meId, listOf(), null, false, 50)
val parseChannelMessage = DiscordParser.parseChannelMessage(
context,
content,
MessageRenderContext(
context,
meId,
false,
mapOf(meId to meName, other),
StoreStream.getChannels().channelNames,
mapOf(),
R.b.colorTextLink,
`WidgetChatListAdapterItemMessage$getMessageRenderContext$1`.INSTANCE,
{ },
ColorCompat.getThemedColor(context, R.b.theme_chat_spoiler_bg),
ColorCompat.getThemedColor(context, R.b.theme_chat_spoiler_bg_visible),
{ },
{ },
`WidgetChatListAdapterItemMessage$getMessageRenderContext$4`(context)
),
processor,
DiscordParser.ParserOptions.DEFAULT,
false
)
setDraweeSpanStringBuilder(parseChannelMessage);
}
@AliucordPlugin
class Myosotis : Plugin() {
var cache = mutableMapOf<Long, MessageItem>()
var adapterRef: WeakReference<WidgetChannelsListAdapter>? = null
override fun stop(context: Context) { patcher.unpatchAll() }
override fun start(context: Context) {
GatewayAPI.onEvent<Any>("READY") { refreshAll() }
GatewayAPI.onEvent<Any>("RESUMED") { refreshAll() }
patcher.after<WidgetChannelsListAdapter.ItemChannelPrivate>(
"onConfigure",
Int::class.java,
ChannelListItem::class.java,
) { (_, _: Int, item: ChannelListItemPrivate) ->
cache[item.channel.id]?.let { msg ->
val content = msg.content
?: return@let
val descView = binding.d
descView.visibility = View.VISIBLE
val user = ChannelUtils.getDMRecipient(item.channel)
descView.renderText(content, user.id to (user.globalName ?: user.username))
}
}
patcher.after<WidgetChannelsListAdapter>(
RecyclerView::class.java,
FragmentManager::class.java,
) {
adapterRef = WeakReference(this)
}
patcher.before<StoreStream>(
"handleMessageCreate",
Message::class.java
) { (_, msg: Message) ->
handleMessageUpdate(msg)
}
patcher.before<StoreStream>(
"handleMessageUpdate",
Message::class.java
) { (_, msg: Message) ->
handleMessageUpdate(msg)
}
patcher.before<StoreStream>(
"handleMessageDelete",
ModelMessageDelete::class.java
) { (_, deleteModel: ModelMessageDelete) ->
cache[deleteModel.channelId]?.let { msg ->
if (msg.id in deleteModel.messageIds) {
cache.remove(deleteModel.channelId)
rerender(deleteModel.channelId)
}
}
}
}
private fun handleMessageUpdate(msg: Message) {
val gid = msg.m()
if (gid == null) {
val channelId = msg.g()
val oldMsgId = cache[channelId]?.id ?: 0
if (msg.o() > oldMsgId) {
cache[channelId] = msg.wrap()
rerender(channelId)
}
}
}
@OptIn(ExperimentalStdlibApi::class)
private fun refreshAll() {
val channels = StoreStream.getChannels().getChannelsForGuild(0)
.filterValues { it.D() == 1 } // type == Type.DM
.keys.take(100)
Utils.threadPool.execute {
val res = Http.Request.newDiscordRNRequest("/channels/preload-messages", "POST")
.executeWithJson(ChannelIdsPayload(channels))
.json<List<Message>>(GsonUtils.gsonRestApi, responseType)
cache = mutableMapOf(*res.map { it.g() to it.wrap() }.toTypedArray())
Utils.mainThread.post {
@SuppressLint("NotifyDataSetChanged") // I DONT CARE HAHAHAAHJAHAAJHDLAHD
adapterRef?.get()?.notifyDataSetChanged()
}
}
}
private fun rerender(id: Long) {
val adapter = adapterRef?.get() ?: return
val idx = adapter.internalData.indexOfFirst { it.key == "3$id" }
logger.info("found $idx for $id")
if (idx != -1) {
Utils.mainThread.post {
adapter.notifyItemChanged(idx)
}
}
}
}

View file

@ -1,4 +1,4 @@
version = "1.3.0"
version = "1.4.0"
description = "Backported and improved search functionality"
android {
@ -14,6 +14,12 @@ aliucord {
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

View file

@ -3,12 +3,15 @@ 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 SORT: FilterType
lateinit var EXCLUDE: FilterType
lateinit var AUTHOR_TYPE: FilterType
lateinit var dates: Array<FilterType>
lateinit var filters: Array<FilterType>
lateinit var values: Array<FilterType>
}

View file

@ -1,27 +1,54 @@
@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.*
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.*
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.*
import com.discord.stores.*
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
@ -29,35 +56,60 @@ 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.*
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.*
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.parsing.*
import moe.lava.awoocord.scout.ui.*
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
@AliucordPlugin()
private val WidgetSearchSuggestionsAdapter.FilterViewHolder.binding
by accessField<WidgetSearchSuggestionsItemSuggestionBinding>()
private val WidgetSearchSuggestionsAdapter.HeaderViewHolder.binding
by accessField<WidgetSearchSuggestionItemHeaderBinding>()
@AliucordPlugin
@Suppress("unused", "unchecked_cast")
class Scout : Plugin() {
lateinit var scoutRes: ScoutResource
lateinit var ssProvider: ScoutSearchStringProvider
lateinit var searchApi: SearchAPIInterface
var optionsExpanded = false
init {
@Suppress("DEPRECATION")
needsResources = true
@ -72,20 +124,24 @@ class Scout : Plugin() {
override fun start(context: Context) {
extendFilterType()
extendHasAnswerOption()
extendSuggestionCategory()
fixFiltersKeying()
fixHasFilterSuggestion()
fixSearchPadding()
patchHasAnswerOption()
patchHasNode()
patchQuery()
patchQueryParser()
patchSearchUI(context)
patchSearchPadding()
patchThreadSupport()
patchUsernameDiscriminator()
}
override fun stop(context: Context) {
patcher.unpatchAll()
resetFilterType()
resetHasAnswerOption()
patcher.unpatchAll()
resetSuggestionCategory()
}
// Creates a new custom search API implementation, for the extra `min_id` param in search queries
@ -111,7 +167,7 @@ class Scout : Plugin() {
private var origFilterTypes: Array<FilterType>? = null
// Creates new pseudo-values of the `FilterType` enum for date filters
@Suppress("LocalVariableName")
@Suppress("LocalVariableName", "AssignedValueIsNeverRead")
private fun extendFilterType() {
val cls = FilterType::class.java
val constructor = cls.declaredConstructors[0]
@ -123,18 +179,23 @@ class Scout : Plugin() {
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
val SORT = constructor.newInstance("SORT", 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.SORT = SORT
FilterTypeExtension.dates = arrayOf(BEFORE, DURING, AFTER)
FilterTypeExtension.values = arrayOf(EXCLUDE, BEFORE, DURING, AFTER, SORT)
FilterTypeExtension.filters = arrayOf(SORT, AUTHOR_TYPE, EXCLUDE) + FilterTypeExtension.dates
FilterTypeExtension.values = arrayOf(EXPAND) + FilterTypeExtension.filters
val newValues = values.toMutableList()
newValues.addAll(FilterTypeExtension.values)
@ -154,7 +215,7 @@ class Scout : Plugin() {
private var origHasAnswerOptions: Array<HasAnswerOption>? = null
// Creates new pseudo-values of the `HasAnswerOption` enum for poll and forwarded filters
@Suppress("LocalVariableName")
@Suppress("LocalVariableName", "AssignedValueIsNeverRead")
private fun extendHasAnswerOption() {
val cls = HasAnswerOption::class.java
val constructor = cls.declaredConstructors[0]
@ -167,7 +228,7 @@ class Scout : Plugin() {
var nextIdx = values.size
val POLL = constructor.newInstance("POLL", nextIdx++, "poll") as HasAnswerOption
val SNAPSHOT = constructor.newInstance("SNAPSHOT", nextIdx, "snapshot") as HasAnswerOption
val SNAPSHOT = constructor.newInstance("SNAPSHOT", nextIdx++, "snapshot") as HasAnswerOption
HasAnswerOptionExtension.POLL = POLL
HasAnswerOptionExtension.SNAPSHOT = SNAPSHOT
HasAnswerOptionExtension.values = arrayOf(POLL, SNAPSHOT)
@ -188,6 +249,83 @@ class Scout : Plugin() {
origHasAnswerOptions = null
}
private var origSuggestionCategories: Array<SearchSuggestion.Category>? = null
// Creates new pseudo-values of the suggestion categories to add correct headers
@Suppress("LocalVariableName", "AssignedValueIsNeverRead")
private fun extendSuggestionCategory() {
val cls = SearchSuggestion.Category::class.java
val constructor = cls.declaredConstructors[0]
constructor.isAccessible = true
val field = cls.getDeclaredField("\$VALUES")
field.isAccessible = true
val values = field.get(null) as Array<SearchSuggestion.Category>
origSuggestionCategories = origSuggestionCategories ?: values
var nextIdx = values.size
val AUTHOR_TYPE = constructor.newInstance("AUTHOR_TYPE", nextIdx++) as SearchSuggestion.Category
SuggestionCategoryExtension.AUTHOR_TYPE = AUTHOR_TYPE
SuggestionCategoryExtension.values = arrayOf(AUTHOR_TYPE)
val newValues = values.toMutableList()
newValues.addAll(SuggestionCategoryExtension.values)
field.set(null, newValues.toTypedArray())
}
private fun resetSuggestionCategory() {
if (origSuggestionCategories == null)
return logger.error("No unpatched suggestion categories?", null)
val cls = SearchSuggestion.Category::class.java
val field = cls.getDeclaredField("\$VALUES")
field.isAccessible = true
field.set(null, origSuggestionCategories)
origSuggestionCategories = null
}
// Patch to key filters properly for smoother recycling
// Thank u discord for keying every filter type the same thing!! /s
private fun fixFiltersKeying() {
patcher.instead<WidgetSearchSuggestionsAdapter.Companion>(
"getFilterItem",
FilterSuggestion::class.java,
) { (_, suggestion: FilterSuggestion) ->
SingleTypePayload(suggestion, suggestion.filterType.name, 2) // 2 = WidgetSearchSuggestionsAdapter.TYPE_FILTER
}
}
// YES DISCORD TYPO'ED THIS HAHAHAHAHAHAFAUHFAIUFHAIFBHUKFHYRISFSUOIRN
private fun fixHasFilterSuggestion() {
patcher.before<FilterSuggestion.Companion>(
"getStringRepresentation",
FilterType::class.java,
SearchStringProvider::class.java,
) { (param, filter: FilterType, provider: SearchStringProvider) ->
if (filter == FilterType.HAS) {
param.result = provider.hasFilterString + ":"
}
}
}
// Patch out the gigantic padding in search results
private fun fixSearchPadding() {
patcher.after<WidgetSearchResults>("onViewBound", View::class.java) {
view?.run {
fitsSystemWindows = false
setPadding(paddingLeft, 16.dp, paddingRight, paddingBottom)
}
}
patcher.after<WidgetSearchSuggestions>("onViewBound", View::class.java) {
// Being a bit sneaky and reset the expanded flag here
optionsExpanded = false
view?.run {
fitsSystemWindows = false
setPadding(paddingLeft, 16.dp, paddingRight, paddingBottom)
}
}
}
// Patches various methods that use HasAnswerOption to include our new options
private fun patchHasAnswerOption() {
patcher.before<HasAnswerOption.Companion>(
@ -258,24 +396,22 @@ class Scout : Plugin() {
CharSequence::class.java,
FilterType::class.java,
SearchStringProvider::class.java,
) { param ->
val query = param.args[0] as CharSequence
val filterType = param.args[1] as FilterType
val ossProvider = param.args[2] as SearchStringProvider
if (filterType != FilterType.HAS && filterType != FilterTypeExtension.EXCLUDE)
return@instead listOf<Any>()
val res = mutableListOf<HasSuggestion>()
for (opt in HasAnswerOption.values()) {
val filterText = opt.getLocalizedInputText(ossProvider)
if (filterText.contains(query))
res.add(HasSuggestion(opt))
) { (_, 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) }
}
res.toList()
}
// Generate entries for has options, including new ones
if (type == FilterType.HAS || type == FilterTypeExtension.EXCLUDE)
return@instead HasAnswerOption.values()
.filter { it.getLocalizedInputText(provider).contains(query) }
.map { HasSuggestion(it) }
listOf<Any>()
}
}
// Patching HasNode related methods for our exclude: filter type
@ -301,9 +437,9 @@ class Scout : Plugin() {
val opt = field.get(this) as HasAnswerOption
if (filterType == FilterType.HAS)
builder.appendParam("has", opt.restParamValue);
builder.appendParam("has", opt.restParamValue)
else if (filterType == FilterTypeExtension.EXCLUDE)
builder.appendParam("has", "-" + opt.restParamValue);
builder.appendParam("has", "-" + opt.restParamValue)
}
// Patching the behaviour when the has suggestion is clicked
@ -333,8 +469,6 @@ class Scout : Plugin() {
)
getAnswerReplacementStart.isAccessible = true
logger.info(query.joinToString("|") { it.text })
val replacementIdx = getAnswerReplacementStart.invoke(this, query) as Int
val previousFilterText = query[replacementIdx]
val filterNode = if (previousFilterText.text == ssProvider.excludeFilterString)
@ -358,6 +492,7 @@ class Scout : Plugin() {
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())
@ -378,7 +513,8 @@ class Scout : Plugin() {
retryAttempts,
self.`$searchQuery`.includeNsfw,
listOf("timestamp"),
sortOrder
sortOrder,
authorType,
)
else
searchApi.searchChannelMessages(
@ -392,7 +528,8 @@ class Scout : Plugin() {
retryAttempts,
self.`$searchQuery`.includeNsfw,
listOf("timestamp"),
sortOrder
sortOrder,
authorType,
)
}
)
@ -412,6 +549,8 @@ class Scout : Plugin() {
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)
}
@ -420,6 +559,7 @@ class Scout : Plugin() {
}
// 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
@ -432,7 +572,7 @@ class Scout : Plugin() {
) { param ->
val filter = param.args[0] as FilterType
if (filter !in FilterTypeExtension.values)
return@before; // Exit if not an extended filter type
return@before // Exit if not an extended filter type
val replaceAndPublish = StoreSearchInput::class.java.getDeclaredMethod(
"replaceAndPublish",
@ -466,7 +606,7 @@ class Scout : Plugin() {
getAnswerReplacementStart.invoke(this, list),
listOf(filterNode, DateNode(it)),
list
);
)
}
}
@ -475,14 +615,21 @@ class Scout : Plugin() {
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
}
@ -501,6 +648,7 @@ class Scout : Plugin() {
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
}
@ -522,6 +670,8 @@ class Scout : Plugin() {
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
@ -536,6 +686,7 @@ class Scout : Plugin() {
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 }
@ -544,7 +695,8 @@ class Scout : Plugin() {
// 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",
FormatUtils::class.java.getDeclaredMethod(
"c",
Resources::class.java,
Int::class.javaPrimitiveType!!,
Array::class.java,
@ -557,6 +709,8 @@ class Scout : Plugin() {
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 {
@ -565,38 +719,105 @@ class Scout : Plugin() {
}
)
// Patch to manually configure expander, need to do this to update the suggestions widget
patcher.before<WidgetSearchSuggestionsAdapter.FilterViewHolder>(
"onConfigure",
Int::class.javaPrimitiveType!!,
MGRecyclerDataPayload::class.java,
) { (param, _: Int, payload: SingleTypePayload<FilterSuggestion>) ->
val suggestion = payload.data
if (suggestion.filterType != FilterTypeExtension.EXPAND) {
return@before
}
param.result = null
val sampleText = binding.b
val layout = binding.c
val filterText = binding.d
val icon = binding.e
layout.setOnClickListener {
val onFilter = adapter.onFilterClicked as `WidgetSearchSuggestions$configureUI$1`
val widget = onFilter.`this$0`
optionsExpanded = true
WidgetSearchSuggestions.Model.Companion!!.get(ContextSearchStringProvider(context)).z().subscribe {
WidgetSearchSuggestions.`access$configureUI`(widget, this)
}
}
sampleText.text = null
filterText.text = ssProvider.expandFilterString
val drawable = R.e.ic_chevron_right_primary_300_12dp
icon.setImageDrawable(ResourcesCompat.getDrawable(context.resources, drawable, null))
}
// Patch to add our new filters into the initial suggestions
patcher.after<SearchSuggestionEngine>(
"getFilterSuggestions",
CharSequence::class.java,
SearchStringProvider::class.java,
Boolean::class.javaPrimitiveType!!,
) { param ->
val query = param.args[0] as CharSequence
) { (param, query: CharSequence) ->
val res = (param.result as List<SearchSuggestion>).toMutableList()
for (type in FilterTypeExtension.values) {
val st = ssProvider.stringFor(type) + ":"
if (st.contains(query))
res.add(FilterSuggestion(type))
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 out the gigantic padding in search results
private fun patchSearchPadding() {
patcher.after<WidgetSearchResults>("onViewBound", View::class.java) {
view?.run {
fitsSystemWindows = false
setPadding(paddingLeft, 16.dp, paddingRight, paddingBottom)
// Patch to add header for new categories
patcher.before<WidgetSearchSuggestionsAdapter.HeaderViewHolder>(
"onConfigure",
Int::class.javaPrimitiveType!!,
MGRecyclerDataPayload::class.java,
) { (param, _: Int, payload: SingleTypePayload<SearchSuggestion.Category>) ->
val category = payload.data
if (category == SuggestionCategoryExtension.AUTHOR_TYPE) {
binding.b.text = "Author Type"
param.result = null
}
}
patcher.after<WidgetSearchSuggestions>("onViewBound", View::class.java) {
view?.run {
fitsSystemWindows = false
setPadding(paddingLeft, 16.dp, paddingRight, paddingBottom)
// Patch to add entries depending on category
patcher.after<WidgetSearchSuggestions.Model>(
List::class.java,
List::class.java,
) { (_, _: List<QueryNode>, suggestions: List<SearchSuggestion>) ->
var lastCategory: SearchSuggestion.Category? = null
val newItems = mutableListOf<MGRecyclerDataPayload>()
suggestions.forEach {
if (it is AuthorTypeSuggestion) {
if (lastCategory != it.category) {
newItems.add(
SingleTypePayload(it.category, it.category.name, 0)
)
lastCategory = it.category
}
newItems.add(
SingleTypePayload(it, it.type.value, SuggestionCategoryExtension.AdapterType.AUTHOR_TYPE)
)
}
}
suggestionItems.removeAll { it in newItems }
suggestionItems.addAll(0, newItems)
}
// Patch to add new types of suggestion entries
patcher.before<WidgetSearchSuggestionsAdapter>(
"onCreateViewHolder",
ViewGroup::class.java,
Int::class.javaPrimitiveType!!,
) { (param, _: ViewGroup, id: Int) ->
when (id) {
SuggestionCategoryExtension.AdapterType.AUTHOR_TYPE -> {
param.result = AuthorTypeViewHolder(this, scoutRes)
}
}
}
}
@ -606,7 +827,7 @@ class Scout : Plugin() {
// Patch query parser for in: to support names with spaces, by wrapping them in quotes
// This enables searching for threads which can have spaces in their names
patcher.instead<QueryParser.Companion>("getInAnswerRule") {
val compile = Pattern.compile("^\\s*#(\".*?\"|[^ ]+)", 64);
val compile = Pattern.compile("^\\s*#(\".*?\"|[^ ]+)", 64)
`QueryParser$Companion$getInAnswerRule$1`(compile, compile)
}
@ -701,7 +922,7 @@ class Scout : Plugin() {
// Now it matches something like @<username>[#<discrim>] (bots still have discriminators)
// The @ is required unfortunately, to distinguish it from literally any other word
patcher.instead<QueryParser.Companion>("getUserRule") {
val regex = Pattern.compile("^\\s*@(?:([^@#:]+)#([0-9]{4})|([a-z0-9._]{2,32}))", 64);
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 ->

View file

@ -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<SearchSuggestion.Category>
object AdapterType {
const val AUTHOR_TYPE = 7
}
}

View file

@ -24,6 +24,7 @@ interface SearchAPIInterface {
@t("include_nsfw") includeNsfw: Boolean?,
@t("sort_by") sortBy: List<String>?, // "timestamp" is one, not sure about any other sort types
@t("sort_order") sortOrder: List<String>?, // "asc" or "desc"
@t("author_type") authorType: List<String>?,
): Observable<ModelSearchResponse?>
@f("guilds/{guildId}/messages/search")
@ -40,5 +41,6 @@ interface SearchAPIInterface {
@t("include_nsfw") includeNsfw: Boolean?,
@t("sort_by") sortBy: List<String>?,
@t("sort_order") sortOrder: List<String>?,
@t("author_type") authorType: List<String>?,
): Observable<ModelSearchResponse?>
}

View file

@ -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
}

View file

@ -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<WidgetSearchSuggestionsAdapter, MGRecyclerDataPayload>(
Utils.getResId("widget_search_suggestions_item_has", "layout"),
adapter,
) {
private val imageView = itemView.findViewById<ImageView>("search_suggestions_item_has_icon")
private val textView = itemView.findViewById<TextView>("search_suggestions_item_has_text")
override fun onConfigure(i: Int, oPayload: MGRecyclerDataPayload) {
super.onConfigure(i, oPayload)
@Suppress("UNCHECKED_CAST")
val payload = oPayload as SingleTypePayload<AuthorTypeSuggestion>
val type = payload.data.type
textView.text = when (type) {
AuthorType.Bot -> "bot"
AuthorType.User -> "user"
AuthorType.Webhook -> "webhook"
}
when (type) {
AuthorType.Bot -> imageView.setImageDrawable(scoutRes.getDrawable("smart_toy_24px"))
AuthorType.User -> imageView.setImageResource(R.e.ic_members_24dp)
AuthorType.Webhook -> imageView.setImageDrawable(scoutRes.getDrawable("webhook_24px"))
}
itemView.setOnClickListener {
val hasHandler = adapter.onHasClicked as `WidgetSearchSuggestions$configureUI$4`
val query = hasHandler.`$model`.query
val storeInput = StoreStream.getSearch().storeSearchInput
replaceAndPublish.invoke(
storeInput,
getAnswerReplacementStart.invoke(storeInput, query) as Int,
listOf(
FilterNode(FilterTypeExtension.AUTHOR_TYPE, "authorType"),
AuthorTypeNode(type)
),
query,
)
}
}
}

View file

@ -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<Context, QueryNode, Any> {
val joined = AuthorType.values().joinToString("|") { it.value }
val regexStr = "^\\s*(${joined})"
val regex = Pattern.compile(regexStr, Pattern.UNICODE_CASE)
return SimpleParserRule(regex) { matcher, _, obj ->
ParseSpec(AuthorTypeNode(AuthorType.from(matcher.group())), obj)
}
}
fun getFilterRule(str: String): ParserRule {
val regex = Pattern.compile("^\\s*?(${str}):", 64)
return SimpleParserRule(regex) { _, _, obj ->
ParseSpec(FilterNode(FilterTypeExtension.AUTHOR_TYPE, str), obj)
}
}
}
override fun getValidFilters() = setOf(FilterTypeExtension.AUTHOR_TYPE)
override fun isValid(searchData: SearchData?) = true
override fun getText() = type.value
override fun updateQuery(
builder: SearchQuery.Builder,
searchData: SearchData?,
filterType: FilterType?
) {
builder.appendParam("author_type", type.value)
}
}

View file

@ -20,9 +20,7 @@ class DateNode(private val date: Long?, private val unparsed: String) : AnswerNo
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, parser, obj ->
checkNotNull(matcher) { "matcher" }
checkNotNull(parser) { "parser" }
return SimpleParserRule(regex) { matcher, _, obj ->
val match = matcher.group()
val date = fmt.parse(match)
val node = DateNode(date?.time, match)
@ -31,7 +29,7 @@ class DateNode(private val date: Long?, private val unparsed: String) : AnswerNo
}
private fun getFilterRule(str: String, type: FilterType): ParserRule {
val regex = Pattern.compile("^\\s*?(${str}):", 64);
val regex = Pattern.compile("^\\s*?(${str}):", 64)
return SimpleParserRule(regex) { _, _, obj ->
ParseSpec(FilterNode(type, str), obj)
}
@ -44,7 +42,7 @@ class DateNode(private val date: Long?, private val unparsed: String) : AnswerNo
override fun getValidFilters(): Set<FilterType> = FilterTypeExtension.dates.toSet()
override fun isValid(searchData: SearchData?): Boolean = date != null
override fun getText(): CharSequence? = unparsed
override fun getText(): CharSequence = unparsed
private val snowflake: String?
get() = date?.let { SnowflakeUtils.fromTimestamp(date).toString() }

View file

@ -1,7 +1,9 @@
package moe.lava.awoocord.scout.parsing
import android.content.Context
import com.discord.simpleast.core.parser.*
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
@ -16,12 +18,10 @@ internal class SimpleParserRule(
) -> ParseSpec<Context, Any?>
) : ParserRule(regex) {
override fun parse(
matcher: Matcher?,
matcher: Matcher,
parser: Parser<Context, in QueryNode, in Any?>,
obj: Any?
): ParseSpec<Context, in Any?> {
checkNotNull(matcher) { "matcher" }
checkNotNull(parser) { "parser" }
return parseMethod(matcher, parser, obj)
}
}

View file

@ -24,7 +24,7 @@ class SortNode(private val text: String): AnswerNode() {
}
fun getFilterRule(str: String): ParserRule {
val regex = Pattern.compile("^\\s*?(${str}):", 64);
val regex = Pattern.compile("^\\s*?(${str}):", 64)
return SimpleParserRule(regex) { _, _, obj ->
ParseSpec(FilterNode(FilterTypeExtension.SORT, str), obj)
}
@ -36,11 +36,10 @@ class SortNode(private val text: String): AnswerNode() {
override fun getText() = this.text
override fun updateQuery(
builder: SearchQuery.Builder?,
builder: SearchQuery.Builder,
searchData: SearchData?,
filterType: FilterType?
) {
checkNotNull(builder) { "queryBuilder" }
builder.appendParam("sort_order", "asc")
}
}

View file

@ -22,7 +22,7 @@ class UserIdNode(private val userID: String) : AnswerNode() {
override fun getValidFilters() = setOf(FilterType.FROM, FilterType.MENTIONS)
override fun isValid(searchData: SearchData?) = true
override fun getText() = userID.toString()
override fun getText() = userID
override fun updateQuery(
builder: SearchQuery.Builder?,

View file

@ -10,6 +10,8 @@ class ScoutResource(private val resources: Resources) {
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) =

View file

@ -21,6 +21,7 @@ class ScoutSearchStringProvider(private val context: Context) {
FilterTypeExtension.DURING -> duringFilterString
FilterTypeExtension.AFTER -> afterFilterString
FilterTypeExtension.SORT -> sortFilterString
FilterTypeExtension.AUTHOR_TYPE -> authorTypeFilter
else -> throw IllegalArgumentException("invalid extended filter type")
}
@ -41,6 +42,8 @@ class ScoutSearchStringProvider(private val context: Context) {
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
@ -49,4 +52,9 @@ class ScoutSearchStringProvider(private val context: Context) {
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"
}

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="#FFFFFF">
<path
android:fillColor="@android:color/white"
android:pathData="M160,600Q110,600 75,565Q40,530 40,480Q40,430 75,395Q110,360 160,360L160,280Q160,247 183.5,223.5Q207,200 240,200L360,200Q360,150 395,115Q430,80 480,80Q530,80 565,115Q600,150 600,200L720,200Q753,200 776.5,223.5Q800,247 800,280L800,360Q850,360 885,395Q920,430 920,480Q920,530 885,565Q850,600 800,600L800,760Q800,793 776.5,816.5Q753,840 720,840L240,840Q207,840 183.5,816.5Q160,793 160,760L160,600ZM402.5,502.5Q420,485 420,460Q420,435 402.5,417.5Q385,400 360,400Q335,400 317.5,417.5Q300,435 300,460Q300,485 317.5,502.5Q335,520 360,520Q385,520 402.5,502.5ZM642.5,502.5Q660,485 660,460Q660,435 642.5,417.5Q625,400 600,400Q575,400 557.5,417.5Q540,435 540,460Q540,485 557.5,502.5Q575,520 600,520Q625,520 642.5,502.5ZM320,680L640,680L640,600L320,600L320,680ZM240,760L720,760Q720,760 720,760Q720,760 720,760L720,280Q720,280 720,280Q720,280 720,280L240,280Q240,280 240,280Q240,280 240,280L240,760Q240,760 240,760Q240,760 240,760ZM480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520Z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="#FFFFFF">
<path
android:fillColor="@android:color/white"
android:pathData="M280,840Q197,840 138.5,781.5Q80,723 80,640Q80,567 125.5,512.5Q171,458 240,444L240,527Q205,539 182.5,570Q160,601 160,640Q160,690 195,725Q230,760 280,760Q330,760 365,725Q400,690 400,640L400,600L635,600Q643,591 654.5,585.5Q666,580 680,580Q705,580 722.5,597.5Q740,615 740,640Q740,665 722.5,682.5Q705,700 680,700Q666,700 654.5,694.5Q643,689 635,680L476,680Q462,749 407.5,794.5Q353,840 280,840ZM680,840Q624,840 578.5,812.5Q533,785 507,740L614,740Q628,750 645,755Q662,760 680,760Q730,760 765,725Q800,690 800,640Q800,590 765,555Q730,520 680,520Q660,520 643,525.5Q626,531 611,542L489,339Q468,335 454,319Q440,303 440,280Q440,255 457.5,237.5Q475,220 500,220Q525,220 542.5,237.5Q560,255 560,280Q560,285 560,288.5Q560,292 558,297L645,443Q653,441 662,440.5Q671,440 680,440Q763,440 821.5,498.5Q880,557 880,640Q880,723 821.5,781.5Q763,840 680,840ZM280,700Q255,700 237.5,682.5Q220,665 220,640Q220,618 234,602Q248,586 268,581L362,425Q333,398 316.5,360.5Q300,323 300,280Q300,197 358.5,138.5Q417,80 500,80Q583,80 641.5,138.5Q700,197 700,280L620,280Q620,230 585,195Q550,160 500,160Q450,160 415,195Q380,230 380,280Q380,323 406,355.5Q432,388 472,397L337,622Q339,627 339.5,631Q340,635 340,640Q340,665 322.5,682.5Q305,700 280,700Z"/>
</vector>

View file

@ -1,9 +1,19 @@
version = "1.1.1"
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

View file

@ -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
}
}

View file

@ -2,28 +2,32 @@ package moe.lava.awoocord.zinnia
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.view.View
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.graphics.ColorUtils
import com.aliucord.annotations.AliucordPlugin
import com.aliucord.entities.Plugin
import com.aliucord.patcher.*
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.ViewUtils.findViewById
import com.aliucord.utils.accessField
import com.discord.databinding.WidgetChannelMembersListItemUserBinding
import com.discord.stores.StoreStream
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
import kotlin.math.abs
private val ChannelMembersListViewHolderMember.binding
by accessField<WidgetChannelMembersListItemUserBinding>()
private val WidgetChatListAdapterItemMessage.itemName
by accessField<TextView?>()
private val WidgetChatListAdapterItemMessage.replyName
by accessField<TextView?>()
data class Colours(
val fgP: Int,
@ -36,8 +40,6 @@ data class Colours(
class Zinnia : Plugin() {
companion object { const val NAME = "RoleBlocks" }
private val localSettings = ZinniaSettings
init {
settingsTab = SettingsTab(ZinniaSettings.Page::class.java, SettingsTab.Type.PAGE)
}
@ -49,93 +51,6 @@ class Zinnia : Plugin() {
override fun stop(context: Context) { patcher.unpatchAll() }
private fun configureOn(view: TextView, colour: Int?) {
when (localSettings.mode) {
Mode.Block -> configureBlock(view, colour ?: Color.BLACK)
Mode.RoleDot -> configureRoleDot(view, colour ?: Color.BLACK)
}
}
private fun configureRoleDot(view: TextView, colour: Int) { }
private fun configureBlock(view: TextView, colourP: Int) {
val isLight = StoreStream.getUserSettingsSystem().theme == "light"
var colour = colourP
val bcol = GradientDrawable()
bcol.cornerRadius = 4.dp.toFloat()
view.background = bcol
if (colour == Color.BLACK) {
if (localSettings.blockAlsoDefault) {
colour = if (isLight && !localSettings.blockInverted) Color.WHITE else Color.BLACK
} else {
view.background = null
view.setPadding(0, 0, 0, 0)
return
}
}
view.setPadding(4.dp, 0, 4.dp, 0)
var (preferred, other) = if (isLight) {
Color.WHITE to Color.BLACK
} else {
Color.BLACK to Color.WHITE
}
when (localSettings.blockMode) {
BlockMode.InvertedThemeOnly -> preferred = other
BlockMode.WhiteOnly -> preferred = Color.WHITE
BlockMode.BlackOnly -> preferred = Color.BLACK
else -> {}
}
val colours = if (!localSettings.blockInverted) {
Colours(
fgP = preferred,
fgO = other,
bgP = colour,
bgO = colour,
)
} else {
Colours(
fgP = colour,
fgO = colour,
bgP = preferred,
bgO = other,
)
}
val usePreferred = when (localSettings.blockMode) {
BlockMode.ApcaOnly -> isApca(colours)
BlockMode.WcagOnly -> isWcag(colours)
BlockMode.ApcaLightWcagDark -> if (isLight) isApca(colours) else isWcag(colours)
BlockMode.WcagLightApcaDark -> if (isLight) isWcag(colours) else isApca(colours)
BlockMode.ThemeOnly,
BlockMode.InvertedThemeOnly,
BlockMode.WhiteOnly,
BlockMode.BlackOnly -> true
}
if (usePreferred) {
view.setTextColor(colours.fgP)
bcol.setColor(colours.bgP)
} else {
view.setTextColor(colours.fgO)
bcol.setColor(colours.bgO)
}
}
private fun isApca(c: Colours): Boolean {
val cPref = abs(APCA.contrast(c.fgP, c.bgP))
val cOth = abs(APCA.contrast(c.fgO, c.bgO))
return cPref > localSettings.blockApcaThreshold || 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 > localSettings.blockWcagThreshold || cPref > cOth
}
private fun patchMemberList() {
// Patches the method that configures the username in members list
patcher.after<ChannelMembersListViewHolderMember>(
@ -153,7 +68,7 @@ class Zinnia : Plugin() {
}
}
configureOn(usernameTextView, member.color)
APCAUtil.configureOn(usernameTextView, member.color, Threshold.Medium)
}
}
@ -164,20 +79,28 @@ class Zinnia : Plugin() {
Int::class.javaPrimitiveType!!,
ChatListEntry::class.java,
) { (_, _: Int, entry: MessageEntry) ->
val username = itemView.findViewById<TextView?>("chat_list_adapter_item_text_name")
?: return@after
configureOn(username, entry.author?.color)
itemName?.let {
APCAUtil.configureOn(it, entry.author?.color, Threshold.Large)
}
}
patcher.instead<WidgetChatListAdapterItemMessage>(
"getAuthorTextColor",
GuildMember::class.java,
) { (_, member: GuildMember?) ->
member?.color ?: Color.BLACK
}
// Configures for reply preview username
patcher.after<WidgetChatListAdapterItemMessage>(
"configureReplyPreview",
MessageEntry::class.java,
) { (_, entry: MessageEntry) ->
val referencedAuthor = entry.replyData?.messageEntry?.author
val replyUsername = itemView.findViewById<TextView?>("chat_list_adapter_item_text_decorator_reply_name")
?: return@after
configureOn(replyUsername, referencedAuthor?.color)
"configureReplyName",
String::class.java,
Int::class.javaPrimitiveType!!,
Boolean::class.javaPrimitiveType!!,
) { (_, _: String, colour: Int) ->
replyName?.let {
APCAUtil.configureOn(it, colour, Threshold.Small)
}
}
}
}

View file

@ -1,13 +1,26 @@
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.discord.views.RadioManager
import com.lytefast.flexinput.R
import kotlin.math.roundToInt
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
@ -25,6 +38,7 @@ enum class BlockMode {
InvertedThemeOnly,
WhiteOnly,
BlackOnly,
Unchanged,
}
class SettingsDelegateEnum<T : Enum<T>>(
@ -49,27 +63,59 @@ private inline fun <T : View> T.addTo(parent: ViewGroup, block: T.() -> Unit = {
parent.addView(this)
}
private typealias Delegate<Type> = ReadWriteProperty<Any, Type>
fun <T> basicDelegate(initial: T) = object : Delegate<T> {
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<T>(
private val inner: Delegate<T>,
private val update: (T) -> Unit,
) : Delegate<T> {
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)
var mode by api.delegateEnum(Mode.Block)
private var onStateUpdate = {}
var dotKeepNameColour by api.delegate(false)
private inline fun <T> reactive(backing: () -> Delegate<T>): StateDelegate<T> {
return StateDelegate(backing()) { onStateUpdate() }
}
var blockAlsoDefault by api.delegate(true)
var blockInverted by api.delegate(false)
var blockMode by api.delegateEnum(BlockMode.ApcaLightWcagDark)
var blockApcaThreshold by api.delegate(75.0)
var blockWcagThreshold by api.delegate(4.5)
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 lateinit var manager: RadioManager
private lateinit var mRoleDot: CheckedSetting
private lateinit var mBlock: CheckedSetting
private val checks = mutableListOf<CheckedSetting>()
private fun createRadio(newMode: BlockMode, text: String, subtext: String? = null): CheckedSetting {
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 {
@ -81,6 +127,88 @@ object ZinniaSettings {
}
}
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<Int>, 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)
@ -92,61 +220,20 @@ object ZinniaSettings {
val roleDotSettings = mutableListOf<CheckedSetting>()
addHeader(ctx, "Text colour")
createRadio(BlockMode.ApcaLightWcagDark, "Automatic", "Adjusts text colour based on role colour")
createRadio(BlockMode.ThemeOnly, "By theme", "Adjusts text colour based on theme")
createRadio(BlockMode.InvertedThemeOnly, "By theme (inverted)", "Same as above, but inverted")
createRadio(BlockMode.WhiteOnly, "White", "Force text colour to be white")
createRadio(BlockMode.BlackOnly, "Black", "Force text colour to be black")
/*
addHeader(ctx, "Mode")
mBlock = Utils.createCheckedSetting(
ctx,
CheckedSetting.ViewType.RADIO,
"Block mode",
"Wraps the username in a coloured block",
).addTo(this) {
isChecked = mode == Mode.Block
setOnCheckedListener {
mode = Mode.Block
mRoleDot.isChecked = false
}
}
mRoleDot = Utils.createCheckedSetting(
ctx,
CheckedSetting.ViewType.RADIO,
"Role dot mode",
"Adds a coloured role dot next to the username, similar to how Discord does it in their new accessibility settings",
).addTo(this) {
isChecked = mode == Mode.RoleDot
setOnCheckedListener {
mode = Mode.RoleDot
mBlock.isChecked = false
}
}
*/
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")
Utils.createCheckedSetting(
ctx,
CheckedSetting.ViewType.SWITCH,
"Also block up default colours",
"Blocks up usernames that have no role colour",
).addTo(this) {
isChecked = blockAlsoDefault
setOnCheckedListener {
blockAlsoDefault = !blockAlsoDefault
}
blockSettings.add(this)
}
Utils.createCheckedSetting(
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 instead makes the block black or white, and the text stays coloured.",
"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 {
@ -154,7 +241,52 @@ object ZinniaSettings {
}
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<Threshold, TextView>) {
val (threshold, preview) = pair
val colour = Color.HSVToColor(floatArrayOf(previewH.toFloat(), previewS / 100f, previewV / 100f))
APCAUtil.configureOn(preview, colour, threshold)
}
}
}

View file

@ -25,8 +25,10 @@ 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())