diff --git a/plugins/AvatarInHeader/build.gradle.kts b/plugins/AvatarInHeader/build.gradle.kts new file mode 100644 index 0000000..be1599b --- /dev/null +++ b/plugins/AvatarInHeader/build.gradle.kts @@ -0,0 +1,7 @@ +version = "1.0.1" +description = "Displays configurable user, group, and server avatars in the header" + +aliucord.changelog.set(""" + # 1.0.1 + * Fix positioning of the avatar +""".trimIndent()) diff --git a/plugins/AvatarInHeader/src/main/kotlin/com.github.ushie/AvatarInHeader.kt b/plugins/AvatarInHeader/src/main/kotlin/com.github.ushie/AvatarInHeader.kt new file mode 100644 index 0000000..34380fb --- /dev/null +++ b/plugins/AvatarInHeader/src/main/kotlin/com.github.ushie/AvatarInHeader.kt @@ -0,0 +1,114 @@ +package com.github.ushie + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import androidx.constraintlayout.widget.ConstraintLayout +import com.aliucord.Utils +import com.aliucord.annotations.AliucordPlugin +import com.aliucord.entities.Plugin +import com.aliucord.patcher.after +import com.aliucord.utils.DimenUtils +import com.aliucord.wrappers.ChannelWrapper.Companion.guildId +import com.aliucord.wrappers.ChannelWrapper.Companion.isDM +import com.aliucord.wrappers.ChannelWrapper.Companion.isGuild +import com.aliucord.wrappers.ChannelWrapper.Companion.recipients +import com.discord.databinding.WidgetHomeBinding +import com.discord.models.user.CoreUser +import com.discord.stores.StoreStream +import com.discord.utilities.icon.IconUtils +import com.discord.utilities.images.MGImages +import com.discord.widgets.home.WidgetHome +import com.discord.widgets.home.WidgetHomeHeaderManager +import com.discord.widgets.home.WidgetHomeModel +import com.facebook.drawee.view.SimpleDraweeView + +private enum class AvatarType(val settingKey: String, val defaultValue: Boolean) { + DM("showDmAvatar", true), + GROUP("showGroupAvatar", true), + GUILD("showServerAvatar", false) +} + +@Suppress("unused") +@AliucordPlugin +class AvatarInHeader : Plugin() { + init { + settingsTab = SettingsTab(PluginSettings::class.java).withArgs(settings) + } + + override fun start(context: Context) { + val iconId = Utils.getResId("toolbar_icon", "id") + val viewId = View.generateViewId() + + val size = DimenUtils.dpToPx(24f) + + patcher.after( + "configure", + WidgetHome::class.java, + WidgetHomeModel::class.java, + WidgetHomeBinding::class.java + ) { param -> + val model = param.args[1] as WidgetHomeModel + val layout = (param.args[0] as WidgetHome).actionBarTitleLayout ?: return@after + val icon = layout.findViewById(iconId) ?: return@after + val avatar = layout.findViewWithTag("AvatarInHeaderTag") + + val channel = model.channel ?: return@after + val recipients = channel.recipients.orEmpty() + + val avatarType = when { + channel.isDM() && recipients.size == 1 -> AvatarType.DM + channel.isDM() -> AvatarType.GROUP + channel.isGuild() -> AvatarType.GUILD + else -> null + } + + val iconUri = avatarType + ?.takeIf { settings.getBool(it.settingKey, it.defaultValue) } + ?.let { + when (it) { + AvatarType.DM -> + IconUtils.getForUser(CoreUser(recipients.first())) + + AvatarType.GROUP -> + IconUtils.getForChannel(channel, 2048) + + AvatarType.GUILD -> + StoreStream.getGuilds().getGuild(channel.guildId)?.let { guild -> + IconUtils.getForGuild(guild.id, guild.icon, guild.icon, false) + } + } + } + + if (iconUri == null) { + icon.tag = null + icon.alpha = 1f + avatar?.visibility = View.GONE + return@after + } + + if (icon.tag == iconUri) return@after + icon.tag = iconUri + icon.alpha = 0f + + val avatarView = avatar ?: SimpleDraweeView(layout.context).apply { + tag = "AvatarInHeaderTag" + id = viewId + + val params = icon.layoutParams as ConstraintLayout.LayoutParams + layoutParams = ConstraintLayout.LayoutParams(size, size).apply { + topToTop = params.topToTop + bottomToBottom = params.bottomToBottom + } + + (icon.parent as ViewGroup).addView(this) + MGImages.setRoundingParams(this, size / 2f, false, null, null, 0f) + } + + avatarView.visibility = View.VISIBLE + avatarView.setImageURI(iconUri) + } + } + + override fun stop(context: Context) = patcher.unpatchAll() +} diff --git a/plugins/AvatarInHeader/src/main/kotlin/com.github.ushie/PluginSettings.kt b/plugins/AvatarInHeader/src/main/kotlin/com.github.ushie/PluginSettings.kt new file mode 100644 index 0000000..b4e8d18 --- /dev/null +++ b/plugins/AvatarInHeader/src/main/kotlin/com.github.ushie/PluginSettings.kt @@ -0,0 +1,31 @@ +package com.github.ushie + +import android.view.View +import com.aliucord.Utils +import com.aliucord.api.SettingsAPI +import com.aliucord.fragments.SettingsPage +import com.discord.views.CheckedSetting + +class PluginSettings(private val settings: SettingsAPI) : SettingsPage() { + override fun onViewBound(view: View) { + super.onViewBound(view) + setActionBarTitle("Avatar In Header") + setActionBarSubtitle("Settings") + + val ctx = requireContext() + + listOf( + Triple("Show DM avatar", "Show the user's avatar in the header when in a DM", "showDmAvatar" to true), + Triple("Show group avatar", "Show the group icon in the header when in a group DM", "showGroupAvatar" to true), + Triple("Show server icon", "Show the server icon in the header when in a channel", "showServerAvatar" to false), + ).forEach { (title, subtitle, setting) -> + val (key, default) = setting + addView( + Utils.createCheckedSetting(ctx, CheckedSetting.ViewType.SWITCH, title, subtitle).apply { + isChecked = settings.getBool(key, default) + setOnCheckedListener { settings.setBool(key, it) } + } + ) + } + } +} diff --git a/plugins/BetterUserDetails/build.gradle.kts b/plugins/BetterUserDetails/build.gradle.kts new file mode 100644 index 0000000..fde55c1 --- /dev/null +++ b/plugins/BetterUserDetails/build.gradle.kts @@ -0,0 +1,2 @@ +version = "1.0.0" +description = "Shows account creation, server join, and last message dates in the selected server or DM. Tap to toggle relative time." diff --git a/plugins/BetterUserDetails/src/main/kotlin/com.github.ushie/BetterUserDetails.kt b/plugins/BetterUserDetails/src/main/kotlin/com.github.ushie/BetterUserDetails.kt new file mode 100644 index 0000000..b8bbb40 --- /dev/null +++ b/plugins/BetterUserDetails/src/main/kotlin/com.github.ushie/BetterUserDetails.kt @@ -0,0 +1,198 @@ +package com.github.ushie + +import android.content.Context +import android.view.Gravity +import android.view.View +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.core.widget.TextViewCompat +import com.aliucord.Utils +import com.aliucord.annotations.AliucordPlugin +import com.aliucord.entities.Plugin +import com.aliucord.patcher.after +import com.aliucord.utils.DimenUtils +import com.aliucord.utils.ReflectUtils +import com.aliucord.utils.RxUtils.subscribe +import com.discord.models.message.Message +import com.discord.stores.StoreSearch +import com.discord.stores.StoreStream +import com.discord.utilities.SnowflakeUtils +import com.discord.utilities.icon.IconUtils +import com.discord.utilities.images.MGImages +import com.discord.utilities.search.network.SearchFetcher +import com.discord.utilities.search.network.SearchQuery +import com.discord.utilities.time.ClockFactory +import com.discord.utilities.time.TimeUtils +import com.discord.widgets.user.usersheet.WidgetUserSheet +import com.discord.widgets.user.usersheet.WidgetUserSheetViewModel +import com.facebook.drawee.view.SimpleDraweeView +import com.lytefast.flexinput.R +import java.text.DateFormat +import java.util.concurrent.TimeUnit + +@AliucordPlugin +class BetterUserDetails : Plugin() { + private val userDetailsViewId = View.generateViewId() + private var searchFetcher: SearchFetcher? = null + + private val timeViews = mutableListOf>() + private var showReadableTime = false + + private val iconSize = DimenUtils.dpToPx(14) + private val iconMarginEnd = DimenUtils.dpToPx(8) + private val entryMarginEnd = DimenUtils.dpToPx(12) + private val drawablePadding = DimenUtils.dpToPx(6) + + override fun start(context: Context) { + patcher.after( + "configureNote", + WidgetUserSheetViewModel.ViewState.Loaded::class.java + ) { param -> + val loaded = param.args[0] as? WidgetUserSheetViewModel.ViewState.Loaded ?: return@after + val user = loaded.user ?: return@after + + val layout = WidgetUserSheet.`access$getBinding$p`( + param.thisObject as WidgetUserSheet + ).a.findViewById( + Utils.getResId("user_sheet_content", "id") + ) ?: return@after + + val ctx = layout.context + + if (layout.findViewById(userDetailsViewId) != null) return@after + + val aboutMeCard = layout.findViewById(Utils.getResId("about_me_card", "id")) + ?: return@after + + val guildId = StoreStream.getGuildSelected().selectedGuildId + val isGuild = guildId != 0L + val dp = DimenUtils.defaultPadding + + layout.addView(LinearLayout(ctx).apply { + timeViews.clear() + showReadableTime = false + id = userDetailsViewId + gravity = Gravity.CENTER_VERTICAL + setPadding(dp, dp, dp, dp) + layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) + + addTime(ctx, SnowflakeUtils.toTimestamp(user.id), icon = R.e.ic_tab_home) + + if (isGuild) { + StoreStream.getGuilds() + .getMember(guildId, user.id) + ?.joinedAt?.g() + ?.let { + addTime( + ctx, + it, + customIcon = { + addView(SimpleDraweeView(ctx).apply { + scaleType = ImageView.ScaleType.CENTER_CROP + setImageURI(IconUtils.getForGuild(guildId, loaded.guildIcon, "", true, 2048)) + MGImages.setRoundingParams(this, 20f, false, null, null, 0f) + + layoutParams = LinearLayout.LayoutParams(iconSize, iconSize).apply { + marginEnd = iconMarginEnd + } + }) + } + ) + } + } + + val targetId = if (isGuild) guildId else StoreStream.getChannelsSelected().id + + getLastMessageTimestamp(user.id, targetId, isGuild) { + it?.let { addTime(ctx, it, icon = R.e.ic_guild_list_dms_24dp) } + } + }, layout.indexOfChild(aboutMeCard) + 1) + } + } + + override fun stop(context: Context) { + patcher.unpatchAll() + } + + private fun getLastMessageTimestamp(userId: Long, targetId: Long, isGuild: Boolean, onResult: (Long?) -> Unit) { + val fetcher = searchFetcher ?: (ReflectUtils.getField( + StoreStream.getSearch().storeSearchQuery, "searchFetcher" + ) as SearchFetcher).also { searchFetcher = it } + + val targetType = if (isGuild) StoreSearch.SearchTarget.Type.GUILD else StoreSearch.SearchTarget.Type.CHANNEL + + fetcher.makeQuery( + StoreSearch.SearchTarget(targetType, targetId), + null, + SearchQuery(mapOf("author_id" to listOf(userId.toString())), true) + ).subscribe { + val lastMessageDate = takeIf { it?.errorCode == null && (it?.totalResults ?: 0) > 0 } + ?.hits?.firstOrNull() + ?.let { Message(it).id } + + Utils.mainThread.post { + onResult(lastMessageDate?.let(SnowflakeUtils::toTimestamp)) + } + } + } + + private fun LinearLayout.addTime( + ctx: Context, + timestamp: Long, + icon: Int? = null, + customIcon: (LinearLayout.() -> Unit)? = null + ): TextView { + customIcon?.invoke(this) + + return TextView(ctx, null, 0, R.i.UserProfile_Section_Header).apply { + text = renderDate(ctx, timestamp) + setPadding(0, 0, 0, 0) + layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { + marginEnd = entryMarginEnd + } + + setOnClickListener { + showReadableTime = !showReadableTime + timeViews.forEach { (view, timestamp) -> + view.text = if (showReadableTime) { + toReadable(timestamp) + } else { + renderDate(ctx, timestamp) + } + } + } + + icon?.let { iconRes -> + ContextCompat.getDrawable(ctx, iconRes)?.mutate()?.also { + it.setBounds(0, 0, iconSize, iconSize) + setCompoundDrawablesRelative(it, null, null, null) + TextViewCompat.setCompoundDrawableTintList(this, textColors) + compoundDrawablePadding = drawablePadding + } + } + + timeViews += this to timestamp + addView(this) + } + } +} + +private fun toReadable(timestamp: Long): String { + val days = TimeUnit.DAYS.convert( + ClockFactory.get().currentTimeMillis() - timestamp, + TimeUnit.MILLISECONDS + ) + + return when (days) { + 0L -> "Today" + 1L -> "Yesterday" + else -> "$days days ago" + } +} + +private fun renderDate(ctx: Context, timestamp: Long) = + TimeUtils.INSTANCE.renderUtcDate(timestamp, ctx, DateFormat.MEDIUM) diff --git a/plugins/ClearURLs/build.gradle.kts b/plugins/ClearURLs/build.gradle.kts new file mode 100644 index 0000000..5a2e8d5 --- /dev/null +++ b/plugins/ClearURLs/build.gradle.kts @@ -0,0 +1,6 @@ +version = "1.0.0" +description = """ + Automatically removes tracking elements from URLs you send. + + Uses data from the ClearURLs browser extension. +""".trimIndent() diff --git a/plugins/ClearURLs/src/main/kotlin/com.github.ushie/ClearURLs.kt b/plugins/ClearURLs/src/main/kotlin/com.github.ushie/ClearURLs.kt new file mode 100644 index 0000000..bfba9d9 --- /dev/null +++ b/plugins/ClearURLs/src/main/kotlin/com.github.ushie/ClearURLs.kt @@ -0,0 +1,127 @@ +/* + * Based on Vencord's ClearURLs plugin: + * Copyright (c) 2022 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.github.ushie + +import android.content.Context +import com.aliucord.Http +import com.aliucord.annotations.AliucordPlugin +import com.aliucord.entities.Plugin +import com.aliucord.patcher.PreHook +import com.aliucord.utils.lazyField +import com.discord.widgets.chat.MessageContent +import com.discord.widgets.chat.MessageManager +import com.discord.widgets.chat.input.ChatInputViewModel +import org.json.JSONObject +import java.net.URI +import java.util.regex.Pattern + +private const val RULES_URL = + "https://raw.githubusercontent.com/ClearURLs/Rules/master/data.min.json" + +private val URL_REGEX = Regex("""https?://[^\s<]+[^<.,:;"')\]\s]""") + +@AliucordPlugin +class ClearURLs : Plugin() { + private val rules = mutableListOf() + private val textContentField by lazyField("textContent") + + private data class RuleSet( + val url: Pattern, + val params: List, + val raw: List, + val exceptions: List + ) + + override fun start(context: Context) { + loadRules() + + patcher.patch( + ChatInputViewModel::class.java.getDeclaredMethod( + "sendMessage", + Context::class.java, + MessageManager::class.java, + MessageContent::class.java, + List::class.java, + Boolean::class.javaPrimitiveType, + Function1::class.java + ), + PreHook { + val msg = it.args[2] as? MessageContent ?: return@PreHook + val content = textContentField.get(msg) as? String ?: return@PreHook + clean(content).takeIf { cleaned -> cleaned != content }?.let { cleaned -> + textContentField.set(msg, cleaned) + } + } + ) + } + + override fun stop(context: Context) = patcher.unpatchAll() + + private fun loadRules() = Thread { + val providers = JSONObject(Http.simpleGet(RULES_URL)).getJSONObject("providers") + + rules.clear() + providers.keys().forEach { key -> + val provider = providers.getJSONObject(key) + + rules += RuleSet( + url = provider.pattern("urlPattern"), + params = provider.patterns("rules"), + raw = provider.patterns("rawRules"), + exceptions = provider.patterns("exceptions") + ) + } + }.start() + + private fun clean(content: String) = + if ("http" !in content) content else URL_REGEX.replace(content) { cleanUrl(it.value) } + + private fun cleanUrl(match: String): String { + var uri = runCatching { URI(match) }.getOrNull() ?: return match + + for (rule in rules) { + val url = uri.toString() + val query = uri.rawQuery ?: continue + + if (!rule.url.matcher(url).find()) continue + if (rule.exceptions.any { it.matcher(url).find() }) continue + + var cleaned = URI( + uri.scheme, + uri.authority, + uri.path, + query.split("&") + .filterNot { param -> + rule.params.any { it.matcher(param.substringBefore("=")).find() } + } + .joinToString("&") + .takeIf(String::isNotEmpty), + uri.fragment + ).toString() + + rule.raw.forEach { + cleaned = it.matcher(cleaned).replaceAll("") + } + + uri = runCatching { URI(cleaned) }.getOrNull() ?: return cleaned + } + + return uri.toString() + } + + private fun JSONObject.pattern(key: String) = + Pattern.compile(getString(key), Pattern.CASE_INSENSITIVE) + + private fun JSONObject.patterns(key: String) = + optJSONArray(key)?.let { + List(it.length()) { i -> Pattern.compile(it.getString(i), Pattern.CASE_INSENSITIVE) } + }.orEmpty() +} diff --git a/plugins/DMsToBottom/build.gradle.kts b/plugins/DMsToBottom/build.gradle.kts new file mode 100644 index 0000000..7f1017a --- /dev/null +++ b/plugins/DMsToBottom/build.gradle.kts @@ -0,0 +1,2 @@ +version = "1.0.0" +description = "Moves DMs to the bottom of the server list" diff --git a/plugins/DMsToBottom/src/main/kotlin/com.github.ushie/DMsToBottom.kt b/plugins/DMsToBottom/src/main/kotlin/com.github.ushie/DMsToBottom.kt new file mode 100644 index 0000000..c4a309b --- /dev/null +++ b/plugins/DMsToBottom/src/main/kotlin/com.github.ushie/DMsToBottom.kt @@ -0,0 +1,51 @@ +package com.github.ushie + +import android.content.Context +import androidx.recyclerview.widget.RecyclerView +import com.aliucord.Utils +import com.aliucord.annotations.AliucordPlugin +import com.aliucord.entities.Plugin +import com.aliucord.patcher.Hook +import com.aliucord.utils.ReflectUtils +import com.discord.databinding.WidgetGuildsListBinding +import com.discord.widgets.guilds.list.GuildListItem +import com.discord.widgets.guilds.list.WidgetGuildListAdapter +import com.discord.widgets.guilds.list.WidgetGuildsList +import com.discord.widgets.guilds.list.WidgetGuildsListViewModel + +@Suppress("unused") +@AliucordPlugin +class DMsToBottom : Plugin() { + override fun start(context: Context) { + patcher.patch( + WidgetGuildsList::class.java, + "configureUI", + arrayOf>(WidgetGuildsListViewModel.ViewState::class.java), + Hook { callFrame -> + val viewState = callFrame.args[0] as? WidgetGuildsListViewModel.ViewState.Loaded ?: return@Hook + val adapter = ReflectUtils.getField(callFrame.thisObject, "adapter") as? WidgetGuildListAdapter ?: return@Hook + val items = viewState.items + + val dmItems = items.extractAll() + items.extractAll() + val topItems = items.extractAll() + items.extractAll() + items.extractAll() + + val insertAt = items.indexOfLast { it is GuildListItem.SpaceItem }.takeIf { it >= 0 } ?: (items.size - 1) + + val reordered = topItems + items.subList(0, insertAt) + GuildListItem.DividerItem.INSTANCE + dmItems + items.subList(insertAt, items.size) + items.clear() + items.addAll(reordered) + + adapter.setItems(items, false) + + val binding = ReflectUtils.getField(callFrame.thisObject, "binding") as? WidgetGuildsListBinding + binding?.root?.findViewById(Utils.getResId("guild_list", "id"))?.scrollToPosition(items.size - 1) + } + ) + } + + private inline fun MutableList.extractAll(): List = + filterIsInstance().also { removeAll(it.toSet()) } + + override fun stop(context: Context) = patcher.unpatchAll() +} diff --git a/plugins/GlobalProfilesOnly/build.gradle.kts b/plugins/GlobalProfilesOnly/build.gradle.kts new file mode 100644 index 0000000..3f9928c --- /dev/null +++ b/plugins/GlobalProfilesOnly/build.gradle.kts @@ -0,0 +1,7 @@ +version = "1.0.1" +description = "Disable server-specific avatars, banners, and nicknames" + +aliucord.changelog.set(""" + # 1.0.1 + * Fix nickname setting not doing anything +""".trimIndent()) diff --git a/plugins/GlobalProfilesOnly/src/main/kotlin/com.github.ushie/GlobalProfilesOnly.kt b/plugins/GlobalProfilesOnly/src/main/kotlin/com.github.ushie/GlobalProfilesOnly.kt new file mode 100644 index 0000000..be9393e --- /dev/null +++ b/plugins/GlobalProfilesOnly/src/main/kotlin/com.github.ushie/GlobalProfilesOnly.kt @@ -0,0 +1,45 @@ +package com.github.ushie + +import android.content.Context +import com.aliucord.annotations.AliucordPlugin +import com.aliucord.entities.Plugin +import com.aliucord.patcher.before +import com.discord.api.utcdatetime.UtcDateTime +import com.discord.models.member.GuildMember + +@Suppress("unused") +@AliucordPlugin +class GlobalProfilesOnly : Plugin() { + init { + settingsTab = SettingsTab(PluginSettings::class.java).withArgs(settings) + } + + override fun start(context: Context) { + val disableNick = settings.getBool("disableNick", false) + val disableAvatar = settings.getBool("disableAvatar", true) + val disableBanner = settings.getBool("disableBanner", true) + patcher.before( + Int::class.javaPrimitiveType!!, + Long::class.javaPrimitiveType!!, + List::class.java, + String::class.java, + String::class.java, + Boolean::class.javaPrimitiveType!!, + UtcDateTime::class.java, + Long::class.javaPrimitiveType!!, + Long::class.javaPrimitiveType!!, + String::class.java, + String::class.java, + String::class.java, + UtcDateTime::class.java + ) { + if (disableNick) it.args[3] = null // nick + if (disableAvatar) it.args[9] = null // avatarHash + if (disableBanner) it.args[10] = null // bannerHash + } + } + + override fun stop(context: Context) { + patcher.unpatchAll() + } +} diff --git a/plugins/GlobalProfilesOnly/src/main/kotlin/com.github.ushie/PluginSettings.kt b/plugins/GlobalProfilesOnly/src/main/kotlin/com.github.ushie/PluginSettings.kt new file mode 100644 index 0000000..6f36ffa --- /dev/null +++ b/plugins/GlobalProfilesOnly/src/main/kotlin/com.github.ushie/PluginSettings.kt @@ -0,0 +1,57 @@ +package com.github.ushie + +import android.view.View +import com.aliucord.Utils +import com.aliucord.api.SettingsAPI +import com.aliucord.fragments.SettingsPage +import com.discord.views.CheckedSetting + +class PluginSettings(private val settings: SettingsAPI) : SettingsPage() { + override fun onViewBound(view: View) { + super.onViewBound(view) + setActionBarTitle("No Server Profiles") + setActionBarSubtitle("Settings") + + val ctx = requireContext() + + fun addSwitch( + title: String, + subtitle: String, + key: String, + default: Boolean + ) { + addView( + Utils.createCheckedSetting( + ctx, + CheckedSetting.ViewType.SWITCH, + title, + subtitle + ).apply { + isChecked = settings.getBool(key, default) + setOnCheckedListener { settings.setBool(key, it) } + } + ) + } + + addSwitch( + "Disable server nicknames", + "Show display name instead of server nicknames", + "disableNick", + false + ) + + addSwitch( + "Disable server avatar", + "Show regular avatars instead of server avatars", + "disableAvatar", + true + ) + + addSwitch( + "Disable server banner", + "Show regular banners instead of server banners", + "disableBanner", + true + ) + } +} diff --git a/plugins/HideEvents/build.gradle.kts b/plugins/HideEvents/build.gradle.kts new file mode 100644 index 0000000..719500a --- /dev/null +++ b/plugins/HideEvents/build.gradle.kts @@ -0,0 +1,3 @@ +version = "1.0.0" +description = "Hide events from the channel list" + diff --git a/plugins/HideEvents/src/main/kotlin/com.github.ushie/HideEvents.kt b/plugins/HideEvents/src/main/kotlin/com.github.ushie/HideEvents.kt new file mode 100644 index 0000000..b02f208 --- /dev/null +++ b/plugins/HideEvents/src/main/kotlin/com.github.ushie/HideEvents.kt @@ -0,0 +1,35 @@ +package com.github.ushie + +import android.content.Context +import android.view.View +import com.aliucord.annotations.AliucordPlugin +import com.aliucord.entities.Plugin +import com.aliucord.patcher.before +import com.discord.widgets.channels.list.WidgetChannelsListAdapter +import com.discord.widgets.channels.list.items.ChannelListItem + +@Suppress("unused") +@AliucordPlugin +class HideEvents : Plugin() { + override fun start(context: Context) { + patcher.before( + "onConfigure", + Int::class.javaPrimitiveType!!, + ChannelListItem::class.java + ) { + itemView.apply { + visibility = View.GONE + layoutParams = itemView.layoutParams.apply { + height = 0 + } + } + + it.result = null + } + } + + + override fun stop(context: Context) { + patcher.unpatchAll() + } +} diff --git a/plugins/HideServers/build.gradle.kts b/plugins/HideServers/build.gradle.kts new file mode 100644 index 0000000..0dc9725 --- /dev/null +++ b/plugins/HideServers/build.gradle.kts @@ -0,0 +1,23 @@ +version = "1.2.1" +description = "Hide servers from the context menu, with a toggle to show hidden ones" + +aliucord.changelog.set( + """ + # 1.2.1 + * Don't auto-reset to hide mode when moving servers in show/edit mode + + # 1.2.0 + * Handle moving servers in hide mode + * Improve the hidden indicator in edit mode + + # 1.1.1 + * Don't automatically go back to hide mode when moving screens + + # 1.1.0 + * Add hiding folders + * Add edit mode + * Show icons to represent the current modes instead of the help icon + + In edit mode, you can hold a folder to show/hide it or click it to open it + """.trimIndent() +) diff --git a/plugins/HideServers/src/main/kotlin/com.github.ushie/HideServers.kt b/plugins/HideServers/src/main/kotlin/com.github.ushie/HideServers.kt new file mode 100644 index 0000000..f3fec10 --- /dev/null +++ b/plugins/HideServers/src/main/kotlin/com.github.ushie/HideServers.kt @@ -0,0 +1,420 @@ +package com.github.ushie + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.appcompat.content.res.AppCompatResources +import androidx.fragment.app.FragmentManager +import androidx.recyclerview.widget.RecyclerView +import com.aliucord.Utils +import com.aliucord.annotations.AliucordPlugin +import com.aliucord.entities.Plugin +import com.aliucord.patcher.after +import com.aliucord.patcher.before +import com.aliucord.utils.lazyMethod +import com.discord.databinding.WidgetFolderContextMenuBinding +import com.discord.databinding.WidgetGuildContextMenuBinding +import com.discord.utilities.color.ColorCompat +import com.discord.widgets.guilds.contextmenu.FolderContextMenuViewModel +import com.discord.widgets.guilds.contextmenu.GuildContextMenuViewModel +import com.discord.widgets.guilds.contextmenu.WidgetFolderContextMenu +import com.discord.widgets.guilds.contextmenu.WidgetGuildContextMenu +import com.discord.widgets.guilds.list.GuildListItem +import com.discord.widgets.guilds.list.GuildListViewHolder +import com.discord.widgets.guilds.list.WidgetGuildListAdapter +import com.discord.widgets.guilds.list.WidgetGuildsListViewModel +import com.google.gson.reflect.TypeToken +import com.lytefast.flexinput.R + +@AliucordPlugin +class HideServers : Plugin() { + init { + settingsTab = SettingsTab(PluginSettings::class.java).withArgs(settings) + } + + private enum class VisibilityMode { HIDE, SHOW, EDIT } + + private var visibilityMode = VisibilityMode.HIDE + private val hiddenServers = mutableSetOf() + private val hiddenFolders = mutableSetOf() + private val hideServerViewId = View.generateViewId() + private val hideFolderViewId = View.generateViewId() + private val visibilityBadgeViewId = View.generateViewId() + private val hiddenEntryType = object : TypeToken>() {}.type + private val getServerBindingMethod by lazyMethod("getBinding") + private val getFolderBindingMethod by lazyMethod("getBinding") + private var adapter: WidgetGuildListAdapter? = null + private var currentItems: List = emptyList() + private var originalItems: List = emptyList() + private var shouldHideAfterDrop = false + + override fun start(context: Context) { + hiddenServers += settings.getObject("hiddenServers", mutableSetOf(), hiddenEntryType) + hiddenFolders += settings.getObject("hiddenFolders", mutableSetOf(), hiddenEntryType) + + patchSetItems() + patchGuildBinding() + patchVisibilityToggle() + patchDragAndDrop() + patchContextMenus() + } + + override fun stop(context: Context) { + patcher.unpatchAll() + adapter = null + visibilityMode = VisibilityMode.HIDE + hiddenServers.clear() + hiddenFolders.clear() + currentItems = emptyList() + originalItems = emptyList() + } + + private fun patchSetItems() { + patcher.before( + "setItems", + List::class.java, + Boolean::class.java + ) { param -> + adapter = param.thisObject as? WidgetGuildListAdapter + + @Suppress("UNCHECKED_CAST") + val incomingItems = param.args[0] as? List ?: return@before + + if (GuildListItem.HelpItem.INSTANCE !in incomingItems) { + originalItems = incomingItems + } + + val shouldShowVisibilityToggle = settings.getBool("showVisibilityToggle", true) + + var items = originalItems + + if (shouldShowVisibilityToggle && GuildListItem.HelpItem.INSTANCE !in items) { + items = items.toMutableList().apply { + add(lastIndex, GuildListItem.HelpItem.INSTANCE) + } + } + + if (visibilityMode == VisibilityMode.HIDE) { + items = items.mapNotNull { item -> + when (item) { + is GuildListItem.GuildItem -> + item.takeUnless { it.guild.id in hiddenServers } + + is GuildListItem.FolderItem -> + item.takeUnless { it.folderId in hiddenFolders } + ?.filterHiddenServersOrNull(hiddenServers) + + else -> item + } + } + } + + currentItems = items + param.args[0] = items + } + } + + private fun patchGuildBinding() { + patcher.after( + "onBindViewHolder", + GuildListViewHolder::class.java, + Int::class.javaPrimitiveType!! + ) { param -> + val holder = param.args[0] as? GuildListViewHolder ?: return@after + val position = param.args[1] as Int + val item = currentItems.getOrNull(position) ?: return@after + + when (item) { + is GuildListItem.GuildItem -> + bindVisibilityItem(holder, item.guild.id, hiddenServers, "hiddenServers") + + is GuildListItem.FolderItem -> + bindVisibilityItem(holder, item.folderId, hiddenFolders, "hiddenFolders", longClick = true) + + GuildListItem.HelpItem.INSTANCE -> { + val icon = holder.itemView.findViewById( + Utils.getResId("guilds_item_profile_avatar", "id") + ) ?: return@after + + icon.setImageResource(getVisibilityIcon(mode = visibilityMode)) + icon.imageTintList = ColorStateList.valueOf( + ColorCompat.getThemedColor(icon.context, R.b.colorInteractiveNormal) + ) + } + } + } + } + + private fun patchVisibilityToggle() { + patcher.before( + "onItemClicked", + GuildListItem::class.java, + Context::class.java, + FragmentManager::class.java + ) { param -> + if (param.args[0] != GuildListItem.HelpItem.INSTANCE) return@before + param.result = null + + visibilityMode = when (visibilityMode) { + VisibilityMode.HIDE -> VisibilityMode.SHOW + VisibilityMode.SHOW -> VisibilityMode.EDIT + VisibilityMode.EDIT -> VisibilityMode.HIDE + } + + refreshList() + } + } + + private fun patchDragAndDrop() { + patcher.before( + "onDragStarted", + RecyclerView.ViewHolder::class.java + ) { + if (visibilityMode != VisibilityMode.HIDE) return@before + + visibilityMode = VisibilityMode.SHOW + shouldHideAfterDrop = true + refreshList() + } + + patcher.after("onDrop") { + if (!shouldHideAfterDrop) return@after + + Utils.mainThread.postDelayed({ + if (visibilityMode == VisibilityMode.SHOW) { + visibilityMode = VisibilityMode.HIDE + refreshList() + } + + shouldHideAfterDrop = false + }, 2_000) + } + } + + private fun patchContextMenus() { + patcher.after( + "configureUI", + GuildContextMenuViewModel.ViewState::class.java + ) { param -> + val state = param.args[0] as? GuildContextMenuViewModel.ViewState.Valid ?: return@after + val binding = getServerBindingMethod.invoke(param.thisObject) as? WidgetGuildContextMenuBinding + ?: return@after + val layout = binding.e.parent as? LinearLayout ?: return@after + val guild = state.guild + + if (layout.findViewById(hideServerViewId) != null) return@after + + createHideOption( + layout = layout, + id = hideServerViewId, + layoutParams = binding.e.layoutParams, + targetId = guild.id, + hiddenSet = hiddenServers, + settingsKey = "hiddenServers", + label = "Server" + ) + } + + patcher.after( + "configureUI", + FolderContextMenuViewModel.ViewState::class.java + ) { param -> + val state = param.args[0] as? FolderContextMenuViewModel.ViewState.Valid ?: return@after + val folderId = state.folder.id ?: return@after + val binding = getFolderBindingMethod.invoke(param.thisObject) as? WidgetFolderContextMenuBinding + ?: return@after + val layout = binding.c.parent as? LinearLayout ?: return@after + + if (layout.findViewById(hideFolderViewId) != null) return@after + + createHideOption( + layout = layout, + id = hideFolderViewId, + layoutParams = binding.d.layoutParams, + targetId = folderId, + hiddenSet = hiddenFolders, + settingsKey = "hiddenFolders", + label = "Folder" + ) + } + } + + private fun bindVisibilityItem( + holder: GuildListViewHolder, + id: Long, + hiddenSet: MutableSet, + settingsKey: String, + longClick: Boolean = false + ) { + if (visibilityMode == VisibilityMode.EDIT) { + if (longClick) { + holder.itemView.setOnLongClickListener { + toggleAndSave(id, hiddenSet, settingsKey) + true + } + } else { + holder.itemView.setOnClickListener { + toggleAndSave(id, hiddenSet, settingsKey) + } + } + } + + addHiddenBadge( + holder.itemView, + isHidden = id in hiddenSet, + show = visibilityMode == VisibilityMode.EDIT && id in hiddenSet + ) + } + + private fun addHiddenBadge(root: View, isHidden: Boolean, show: Boolean) { + val container = root.findViewById( + Utils.getResId("guilds_item_avatar_wrap", "id") + ) ?: root.findViewById( + Utils.getResId("guilds_item_folder_container", "id") + ) ?: return + + container.findViewById(visibilityBadgeViewId)?.let { overlay -> + overlay.fadeVisibility(show) + val icon = overlay.getChildAt(0) as? ImageView + icon?.setImageResource(getVisibilityIcon(isHidden)) + + return + } + + container.addView(FrameLayout(container.context).apply { + id = visibilityBadgeViewId + visibility = if (show) View.VISIBLE else View.GONE + setBackgroundColor(0x99000000.toInt()) + + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + + addView(ImageView(context).apply { + setImageResource(getVisibilityIcon(isHidden)) + imageTintList = ColorStateList.valueOf(Color.WHITE) + + layoutParams = FrameLayout.LayoutParams( + 20.dp(container), + 20.dp(container), + Gravity.CENTER + ) + }) + + bringToFront() + }) + } + + @SuppressLint("SetTextI18n") + private fun createHideOption( + layout: LinearLayout, + id: Int, + layoutParams: ViewGroup.LayoutParams, + targetId: Long, + hiddenSet: MutableSet, + settingsKey: String, + label: String + ) { + val isHidden = targetId in hiddenSet + + layout.addView(TextView(layout.context, null, 0, R.i.ContextMenuTextOption).apply { + this.id = id + this.layoutParams = layoutParams + text = if (isHidden) "Unhide $label" else "Hide $label" + setCompoundDrawablesRelativeWithIntrinsicBounds( + AppCompatResources.getDrawable( + layout.context, + getVisibilityIcon(isHidden) + )?.tinted(layout.context), + null, null, null + ) + setOnClickListener { + toggleAndSave(targetId, hiddenSet, settingsKey) + layout.visibility = View.GONE + } + }) + } + + private fun getVisibilityIcon( + isHidden: Boolean? = null, + mode: VisibilityMode? = null + ): Int { + mode?.let { + return when (it) { + VisibilityMode.HIDE -> R.e.design_ic_visibility_off + VisibilityMode.SHOW -> R.e.design_ic_visibility + VisibilityMode.EDIT -> R.e.ic_edit_24dp + } + } + + return if (isHidden == true) { + R.e.design_ic_visibility_off + } else { + R.e.design_ic_visibility + } + } + + private fun toggleAndSave(id: Long, set: MutableSet, key: String) { + if (!set.add(id)) set.remove(id) + settings.setObject(key, set) + refreshList() + } + + private fun refreshList() { + Utils.mainThread.post { + adapter?.setItems(originalItems, false) + } + } +} + +private fun View.fadeVisibility(show: Boolean) { + if ((visibility == View.VISIBLE) == show) { + animate().cancel() + alpha = if (show) 1f else 0f + return + } + + animate().cancel() + + if (show) { + alpha = 0f + visibility = View.VISIBLE + } + + animate() + .alpha(if (show) 1f else 0f) + .setDuration(150) + .withEndAction { + visibility = if (show) View.VISIBLE else View.GONE + } + .start() +} + +private fun GuildListItem.FolderItem.filterHiddenServersOrNull( + hiddenServers: Set +): GuildListItem.FolderItem? { + val visibleGuilds = guilds.filterNot { it.id in hiddenServers } + if (visibleGuilds.isEmpty()) return null + return copy( + folderId, color, name, isOpen, visibleGuilds, isAnyGuildSelected, + isAnyGuildConnectedToVoice, isAnyGuildConnectedToStageChannel, + mentionCount, isUnread, isTargetedForFolderAddition + ) +} + +private fun Drawable.tinted(context: Context): Drawable = mutate().apply { + setTint(ColorCompat.getThemedColor(context, R.b.colorInteractiveNormal)) +} + +private fun Int.dp(view: View): Int = + (this * view.resources.displayMetrics.density).toInt() diff --git a/plugins/HideServers/src/main/kotlin/com.github.ushie/PluginSettings.kt b/plugins/HideServers/src/main/kotlin/com.github.ushie/PluginSettings.kt new file mode 100644 index 0000000..ef574b1 --- /dev/null +++ b/plugins/HideServers/src/main/kotlin/com.github.ushie/PluginSettings.kt @@ -0,0 +1,47 @@ +package com.github.ushie + +import android.view.View +import android.widget.Button +import com.aliucord.Utils +import com.aliucord.api.SettingsAPI +import com.aliucord.fragments.SettingsPage +import com.discord.views.CheckedSetting + +class PluginSettings( + private val settings: SettingsAPI +) : SettingsPage() { + override fun onViewBound(view: View) { + super.onViewBound(view) + setActionBarTitle("Hide Servers") + setActionBarSubtitle("Settings") + + val ctx = requireContext() + addView( + Utils.createCheckedSetting( + ctx, + CheckedSetting.ViewType.SWITCH, + "Show toggle in server list", + "Adds a toggle to quickly show or hide all servers" + ).apply { + isChecked = settings.getBool("showVisibilityToggle", true) + setOnCheckedListener { settings.setBool("showVisibilityToggle", it) } + } + ) + addView( + Button(ctx).apply { + text = "Clear hidden servers" + setOnClickListener { + settings.remove("hiddenServers") + } + } + ) + addView( + Button(ctx).apply { + text = "Clear hidden folders" + setOnClickListener { + settings.remove("hiddenFolders") + } + } + ) + } +} diff --git a/plugins/HoldAuthorToCopyId/build.gradle.kts b/plugins/HoldAuthorToCopyId/build.gradle.kts new file mode 100644 index 0000000..bc420c2 --- /dev/null +++ b/plugins/HoldAuthorToCopyId/build.gradle.kts @@ -0,0 +1,6 @@ +version = "1.0.0" +description = "Hold the avatar or username to copy user ID" + +aliucord { + deploy.set(true) +} diff --git a/plugins/HoldAuthorToCopyId/src/main/kotlin/com.github.ushie/HoldAuthorToCopyId.kt b/plugins/HoldAuthorToCopyId/src/main/kotlin/com.github.ushie/HoldAuthorToCopyId.kt new file mode 100644 index 0000000..a1d640b --- /dev/null +++ b/plugins/HoldAuthorToCopyId/src/main/kotlin/com.github.ushie/HoldAuthorToCopyId.kt @@ -0,0 +1,50 @@ +package com.github.ushie + +import android.annotation.SuppressLint +import android.content.Context +import com.aliucord.Utils +import com.aliucord.annotations.AliucordPlugin +import com.aliucord.entities.Plugin +import com.aliucord.patcher.instead +import com.aliucord.wrappers.users.globalName +import com.discord.models.message.Message +import com.discord.widgets.chat.list.adapter.WidgetChatListAdapterEventsHandler +import com.github.ushie.PluginSettings.Companion.defaultShowConfirmationToast + +@Suppress("unused") +@AliucordPlugin +class HoldAuthorToCopyId : Plugin() { + init { + settingsTab = SettingsTab(PluginSettings::class.java).withArgs(settings) + } + + @SuppressLint("ServiceCast") + override fun start(context: Context) { + patcher.instead( + "onMessageAuthorLongClicked", + Message::class.java, + java.lang.Long::class.java + ) { param -> + val message = param.args[0] as Message + + if (message.isWebhook) { + Utils.showToast("Uh oh! We can’t view details for this user") + return@instead null + } + + val author = message.author + if (author != null) { + Utils.setClipboard("Author ID", author.id.toString()) + if (settings.getBool("showConfirmationToast", defaultShowConfirmationToast)) { + Utils.showToast("Copied ${author.globalName ?: author.username}'s userID to clipboard") + } + } + } + } + + + override fun stop(context: Context) { + patcher.unpatchAll() + commands.unregisterAll() + } +} diff --git a/plugins/HoldAuthorToCopyId/src/main/kotlin/com.github.ushie/Settings.kt b/plugins/HoldAuthorToCopyId/src/main/kotlin/com.github.ushie/Settings.kt new file mode 100644 index 0000000..d39768e --- /dev/null +++ b/plugins/HoldAuthorToCopyId/src/main/kotlin/com.github.ushie/Settings.kt @@ -0,0 +1,33 @@ +package com.github.ushie + +import android.os.Build +import android.view.View +import com.aliucord.Utils +import com.aliucord.api.SettingsAPI +import com.aliucord.fragments.SettingsPage +import com.discord.views.CheckedSetting + +class PluginSettings(private val settings: SettingsAPI) : SettingsPage() { + companion object { + val defaultShowConfirmationToast = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU + } + + override fun onViewBound(view: View) { + super.onViewBound(view) + setActionBarTitle("Hold Author To Copy ID") + setActionBarSubtitle("Settings") + + val ctx = requireContext() + addView( + Utils.createCheckedSetting( + ctx, + CheckedSetting.ViewType.SWITCH, + "Show confirmation toast message", + "Shows a toast when copying to clipboard (Android 13+ already shows system confirmation)" + ).apply { + isChecked = settings.getBool("showConfirmationToast", defaultShowConfirmationToast) + setOnCheckedListener { settings.setBool("showConfirmationToast", it) } + } + ) + } +} diff --git a/plugins/KeepExpressionsOpen/build.gradle.kts b/plugins/KeepExpressionsOpen/build.gradle.kts new file mode 100644 index 0000000..a8e05a9 --- /dev/null +++ b/plugins/KeepExpressionsOpen/build.gradle.kts @@ -0,0 +1,2 @@ +version = "1.0.0" +description = "Keep emoji, GIF, sticker, and quick reaction pickers open after use, with quick in-app toggles to switch persistent behavior anytime." diff --git a/plugins/KeepExpressionsOpen/src/main/kotlin/com.github.ushie/KeepExpressionsOpen.kt b/plugins/KeepExpressionsOpen/src/main/kotlin/com.github.ushie/KeepExpressionsOpen.kt new file mode 100644 index 0000000..9ecb126 --- /dev/null +++ b/plugins/KeepExpressionsOpen/src/main/kotlin/com.github.ushie/KeepExpressionsOpen.kt @@ -0,0 +1,131 @@ +package com.github.ushie + +import android.content.Context +import android.view.View +import android.widget.ImageView +import androidx.appcompat.content.res.AppCompatResources +import com.aliucord.Utils +import com.aliucord.annotations.AliucordPlugin +import com.aliucord.entities.Plugin +import com.aliucord.patcher.after +import com.aliucord.patcher.instead +import com.aliucord.settings.SettingsDelegate +import com.aliucord.settings.delegate +import com.aliucord.utils.lazyField +import com.aliucord.utils.lazyMethod +import com.discord.api.sticker.Sticker +import com.discord.models.domain.emoji.Emoji +import com.discord.stores.StoreStream +import com.discord.widgets.chat.input.emoji.EmojiPickerListener +import com.discord.widgets.chat.input.emoji.WidgetEmojiPicker +import com.discord.widgets.chat.input.emoji.WidgetEmojiPickerSheet +import com.discord.widgets.chat.input.expression.WidgetExpressionTray +import com.discord.widgets.chat.input.sticker.StickerPickerViewModel +import com.discord.widgets.chat.list.actions.`WidgetChatListActions$addReaction$1` +import com.lytefast.flexinput.R + + +@Suppress("unused") +@AliucordPlugin +class KeepExpressionsOpen : Plugin() { + init { + settingsTab = SettingsTab(PluginSettings::class.java).withArgs(settings) + } + + private val keepEmojiPickerOpen = settings.delegate("keepEmojiPickerOpen", true) + private val keepQuickReactOpen = settings.delegate("keepQuickReactOpen", true) + private val keepGifPickerOpen = settings.delegate("keepGifPickerOpen", true) + private val keepStickerPickerOpen = settings.delegate("keepStickerPickerOpen", true) + private val showTrayToggleButton = settings.delegate("showTrayToggleButton", true) + private val showEmojiToggleButton = settings.delegate("showEmojiToggleButton", true) + + private val traySearchIconViewId = Utils.getResId("expression_tray_search_icon", "id") + private val emojiSearchIconViewId = Utils.getResId("emoji_search_clear", "id") + private val emojiContentId = Utils.getResId("expression_tray_emoji_picker_content", "id") + private val gifContentId = Utils.getResId("expression_tray_gif_picker_content", "id") + private val addReactionIconViewId = Utils.getResId("ic_chat_list_actions_add_reaction", "id") + + private val emojiPickerListenerDelegateField by lazyField("emojiPickerListenerDelegate") + private val cancelDialogMethod by lazyMethod("cancelDialog") + + override fun start(context: Context) { + val offIcon = AppCompatResources.getDrawable(context, R.e.ic_remove_reaction_24dp) + val onIcon = AppCompatResources.getDrawable(context, R.e.ic_chat_list_actions_add_reaction) + + fun bindToggle(icon: ImageView, delegate: () -> SettingsDelegate) { + fun update() { + val value by delegate() + icon.setImageDrawable(if (value) onIcon else offIcon) + } + + update() + + icon.setOnClickListener { + var value by delegate() + value = !value + update() + } + } + + patcher.after("onViewBound", View::class.java) { params -> + val show by showTrayToggleButton + if (!show) return@after + + val root = params.args[0] as View + val icon = root.findViewById(traySearchIconViewId) + + fun current() = when { + root.findViewById(emojiContentId)?.visibility == View.VISIBLE -> keepEmojiPickerOpen + root.findViewById(gifContentId)?.visibility == View.VISIBLE -> keepGifPickerOpen + else -> keepStickerPickerOpen + } + + bindToggle(icon, ::current) + } + + patcher.after("onViewBound", View::class.java) { params -> + val show by showEmojiToggleButton + if (!show) return@after + + val icon = (params.args[0] as View).findViewById(emojiSearchIconViewId) + bindToggle(icon, ::keepEmojiPickerOpen) + } + + patcher.instead("onGifSelected") { + val keepOpen by keepGifPickerOpen + + if (!keepOpen && isAdded) WidgetExpressionTray.`access$getFlexInputViewModel$p`(this) + .showKeyboardAndHideExpressionTray() + + null + } + + patcher.instead<`WidgetChatListActions$addReaction$1`>( + "invoke", + Void::class.java + ) { + var keepOpen by keepQuickReactOpen + StoreStream.getEmojis().onEmojiUsed(`$emoji`) + if (!keepOpen) `this$0`.dismiss() + } + + patcher.instead("onEmojiPicked", Emoji::class.java) { + val keepOpen by keepEmojiPickerOpen + val emoji = it.args[0] as Emoji + + (emojiPickerListenerDelegateField.get(this) as EmojiPickerListener).onEmojiPicked(emoji) + + if (!keepOpen) { + cancelDialogMethod.invoke(this) + this.dismiss() + } + } + + patcher.after("onStickerSelected", Sticker::class.java) { + val keepOpen by keepStickerPickerOpen + if (keepOpen && it.result == true) it.result = false + } + } + + override fun stop(context: Context) = patcher.unpatchAll() +} diff --git a/plugins/KeepExpressionsOpen/src/main/kotlin/com.github.ushie/PluginSettings.kt b/plugins/KeepExpressionsOpen/src/main/kotlin/com.github.ushie/PluginSettings.kt new file mode 100644 index 0000000..d03a179 --- /dev/null +++ b/plugins/KeepExpressionsOpen/src/main/kotlin/com.github.ushie/PluginSettings.kt @@ -0,0 +1,57 @@ +package com.github.ushie + +import android.view.View +import com.aliucord.Utils +import com.aliucord.api.SettingsAPI +import com.aliucord.fragments.SettingsPage +import com.discord.views.CheckedSetting + +class PluginSettings(private val settings: SettingsAPI) : SettingsPage() { + override fun onViewBound(view: View) { + super.onViewBound(view) + setActionBarTitle("Keep Expressions Open") + setActionBarSubtitle("Settings") + + val ctx = requireContext() + + + fun addSwitch(title: String, subtitle: String, key: String) = addView( + Utils.createCheckedSetting( + ctx, + CheckedSetting.ViewType.SWITCH, + title, + subtitle + ).apply { + isChecked = settings.getBool(key, true) + setOnCheckedListener { settings.setBool(key, it) } + } + ) + + listOf( + Triple("Show tray toggle button", "Show a toggle button in the expression tray", "showTrayToggleButton"), + Triple("Show emoji toggle button", "Show a toggle button in the emoji picker", "showEmojiToggleButton"), + Triple( + "Keep quick react open", + "Keep the quick react menu open after selecting a reaction, this can only be toggled here for now", + "keepQuickReactOpen" + ), + Triple( + "Keep emoji picker open", + "Keep the emoji picker open after selecting an emoji", + "keepEmojiPickerOpen" + ), + Triple( + "Keep GIF picker open", + "Keep the GIF picker open after selecting a GIF", + "keepGifPickerOpen" + ), + Triple( + "Keep stickers open", + "Keep the sticker picker open after selecting a sticker", + "keepStickerPickerOpen" + ), + ).forEach { (title, subtitle, key) -> + addSwitch(title, subtitle, key) + } + } +} diff --git a/plugins/LowerCaseCategoryNames/build.gradle.kts b/plugins/LowerCaseCategoryNames/build.gradle.kts new file mode 100644 index 0000000..7ef72f3 --- /dev/null +++ b/plugins/LowerCaseCategoryNames/build.gradle.kts @@ -0,0 +1,2 @@ +version = "1.0.0" +description = "Stops Discord from forcing channel category names to appear in all caps" diff --git a/plugins/LowerCaseCategoryNames/src/main/kotlin/com.github.ushie/LowerCaseCategoryNames.kt b/plugins/LowerCaseCategoryNames/src/main/kotlin/com.github.ushie/LowerCaseCategoryNames.kt new file mode 100644 index 0000000..79ce100 --- /dev/null +++ b/plugins/LowerCaseCategoryNames/src/main/kotlin/com.github.ushie/LowerCaseCategoryNames.kt @@ -0,0 +1,28 @@ +package com.github.ushie + +import android.content.Context +import android.widget.TextView +import com.aliucord.Utils +import com.aliucord.annotations.AliucordPlugin +import com.aliucord.entities.Plugin +import com.aliucord.patcher.after +import com.discord.widgets.channels.list.WidgetChannelsListAdapter +import com.discord.widgets.channels.list.items.ChannelListItem + +@Suppress("unused") +@AliucordPlugin +class LowerCaseCategoryNames : Plugin() { + override fun start(ctx: Context) { + patcher.after( + "onConfigure", + Int::class.java, + ChannelListItem::class.java + ) { + itemView.findViewById(Utils.getResId("channels_item_category_name", "id")).isAllCaps = false + } + } + + override fun stop(context: Context) { + patcher.unpatchAll() + } +} diff --git a/plugins/ModernMentions/build.gradle.kts b/plugins/ModernMentions/build.gradle.kts new file mode 100644 index 0000000..4c391d9 --- /dev/null +++ b/plugins/ModernMentions/build.gradle.kts @@ -0,0 +1,2 @@ +version = "1.0.1" +description = "Adds customizable mention styling with optional avatars, colors, and spacing" diff --git a/plugins/ModernMentions/src/main/kotlin/com.github.ushie/ModernMentions.kt b/plugins/ModernMentions/src/main/kotlin/com.github.ushie/ModernMentions.kt new file mode 100644 index 0000000..8cb57fe --- /dev/null +++ b/plugins/ModernMentions/src/main/kotlin/com.github.ushie/ModernMentions.kt @@ -0,0 +1,246 @@ +package com.github.ushie + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.style.BackgroundColorSpan +import android.text.style.ForegroundColorSpan +import android.text.style.ReplacementSpan +import androidx.core.graphics.ColorUtils +import com.aliucord.Utils +import com.aliucord.annotations.AliucordPlugin +import com.aliucord.entities.Plugin +import com.aliucord.patcher.after +import com.discord.models.user.User +import com.discord.stores.StoreStream +import com.discord.utilities.textprocessing.node.UserMentionNode +import com.github.ushie.util.AvatarUtils +import java.util.concurrent.ConcurrentHashMap + +@Suppress("unused", "UseKtx") +@AliucordPlugin +class ModernMentions : Plugin() { + init { + settingsTab = SettingsTab(PluginSettings::class.java).withArgs(settings) + } + + private val avatars = ConcurrentHashMap() + private val fetching = ConcurrentHashMap() + + override fun start(context: Context) { + val users = StoreStream.getUsers() + val guilds = StoreStream.getGuilds() + val padding = settings.getInt("padding", 12) + val avatarGap = settings.getInt("avatar_gap", 8) + val radius = settings.getInt("radius", 12) + val showAvatar = settings.getBool("show_avatar", true) + val useRoleColor = settings.getBool("use_role_color", true) + + patcher.after>( + "renderUserMention", + SpannableStringBuilder::class.java, + UserMentionNode.RenderContext::class.java + ) { + val builder = it.args[0] as? SpannableStringBuilder ?: return@after + val renderContext = it.args[1] as? UserMentionNode.RenderContext ?: return@after + val node = it.thisObject as? UserMentionNode<*> ?: return@after + + try { + val userId = node.userId + val mentionText = "@${renderContext.userNames?.get(userId) ?: "invalid-user"}" + + val end = builder.length + val start = end - mentionText.length + if (start < 0) return@after + + val memberColor = guilds.getGuild(StoreStream.getGuildSelected().selectedGuildId) + ?.let { guilds.getMember(it.id, userId) } + ?.color + + val user = users.users[userId] + val avatar = if (showAvatar) avatars[userId] else null + + with(builder) { + if (useRoleColor) { + getSpans(start, end, ForegroundColorSpan::class.java).forEach(::removeSpan) + getSpans(start, end, BackgroundColorSpan::class.java).forEach(::removeSpan) + } + + applyMentionSpan( + context, + start, + end, + memberColor, + avatar, + padding.toFloat(), + avatarGap.toFloat(), + radius.toFloat(), + useRoleColor + ) + } + + if (showAvatar && avatar == null && user != null) { + fetchAvatar(context, user) + } + } catch (t: Throwable) { + logger.error("Failed to render mention", t) + } + } + } + + private fun fetchAvatar(context: Context, user: User) { + if (fetching.putIfAbsent(user.id, true) != null) return + + Utils.threadPool.execute { + try { + val avatar = AvatarUtils(context, user).toBitmap() ?: run { + logger.warn("Failed to decode avatar bitmap for ${user.username}") + return@execute + } + + avatars[user.id] = AvatarUtils.makeCircle(avatar) + logger.info("Cached avatar for ${user.username}") + } catch (t: Throwable) { + logger.error("Failed to fetch avatar for ${user.username}", t) + } finally { + fetching.remove(user.id) + } + } + } + + override fun stop(context: Context) { + patcher.unpatchAll() + avatars.clear() + fetching.clear() + } +} + +@SuppressLint("UseKtx") +private class MentionSpan( + private val avatar: Drawable? = null, + private val avatarSize: Int = 0, + private val padding: Float, + private val avatarGap: Float, + private val radius: Float, + private val backgroundColor: Int? = null, + private val textColor: Int? = null +) : ReplacementSpan() { + private val avatarWidth get() = avatar?.let { avatarSize + avatarGap } ?: 0f + + override fun getSize( + paint: Paint, + text: CharSequence, + start: Int, + end: Int, + fm: Paint.FontMetricsInt? + ): Int { + val textWidth = paint.measureText(text, start, end) + + return (padding + avatarWidth + textWidth + padding).toInt() + } + + override fun draw( + canvas: Canvas, + text: CharSequence, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) { + val oldColor = paint.color + val width = padding + avatarWidth + paint.measureText(text, start, end) + padding + + backgroundColor?.let { + paint.color = it + canvas.drawRoundRect(x, top.toFloat(), x + width, bottom.toFloat(), radius, radius, paint) + } + + avatar?.let { + val centerY = y + (paint.fontMetrics.ascent + paint.fontMetrics.descent) / 2f + canvas.save() + canvas.translate(x + padding, centerY - avatarSize / 2f) + it.draw(canvas) + canvas.restore() + } + + textColor?.let { + paint.color = it + } + + canvas.drawText(text, start, end, x + padding + avatarWidth, y.toFloat(), paint) + + paint.color = oldColor + } +} + +@Suppress("UseKtx") +private fun SpannableStringBuilder.applyMentionSpan( + context: Context, + start: Int, + end: Int, + memberColor: Int?, + avatar: Bitmap?, + padding: Float, + avatarGap: Float, + radius: Float, + useRoleColor: Boolean +) { + val textColor = if (useRoleColor) { + memberColor?.takeUnless { it == Color.BLACK } ?: Color.WHITE + } else { + null + } + + val backgroundColor = textColor?.let { + ColorUtils.setAlphaComponent( + ColorUtils.blendARGB(it, Color.BLACK, 0.65f), + 70 + ) + } + + val span = if (avatar == null) { + MentionSpan( + padding = padding, + avatarGap = avatarGap, + radius = radius, + backgroundColor = backgroundColor, + textColor = textColor + ) + } else { + val size = (context.resources.displayMetrics.density * 16).toInt() + + val drawable = BitmapDrawable( + context.resources, + Bitmap.createScaledBitmap(avatar, size, size, true) + ).apply { + setBounds(0, 0, size, size) + } + + MentionSpan( + avatar = drawable, + avatarSize = size, + padding = padding, + avatarGap = avatarGap, + radius = radius, + backgroundColor = backgroundColor, + textColor = textColor + ) + } + + setSpan( + span, + start, + end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) +} diff --git a/plugins/ModernMentions/src/main/kotlin/com.github.ushie/PluginSettings.kt b/plugins/ModernMentions/src/main/kotlin/com.github.ushie/PluginSettings.kt new file mode 100644 index 0000000..db89611 --- /dev/null +++ b/plugins/ModernMentions/src/main/kotlin/com.github.ushie/PluginSettings.kt @@ -0,0 +1,82 @@ +package com.github.ushie + +import android.content.Context +import android.text.Editable +import android.text.InputType +import android.text.TextWatcher +import android.view.View +import android.widget.LinearLayout +import com.aliucord.Utils +import com.aliucord.Utils.promptRestart +import com.aliucord.api.SettingsAPI +import com.aliucord.fragments.SettingsPage +import com.aliucord.utils.DimenUtils +import com.aliucord.views.TextInput +import com.discord.views.CheckedSetting + +// https://github.com/Juby210/Aliucord-plugins/blob/ffa31ecfbd3d2e0e1e104b8c94d7d17675c6e02e/BetterStatusIndicators/src/main/java/io/github/juby210/acplugins/bsi/PluginSettings.kt#L24-L29 +class SimpleTextWatcher(private val after: (Editable?) -> Unit) : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + + override fun afterTextChanged(s: Editable?) = after(s) +} + +class PluginSettings(private val settings: SettingsAPI) : SettingsPage() { + override fun onViewBound(view: View) { + super.onViewBound(view) + + setActionBarTitle("Avatar In Mentions") + setActionBarSubtitle("Settings") + + val ctx = requireContext() + + val paddingInput = createInput(ctx,"padding", "Padding", 12) + val gapInput = createInput(ctx,"avatar_gap", "Avatar Gap", 8) + val radiusInput = createInput(ctx,"radius", "Corner Radius", 12) + + + addSwitch(ctx, "show_avatar", "Show avatars", default = true) + addSwitch(ctx, "use_role_color", "Use role colors", default = true) + + addView(paddingInput) + addView(gapInput) + addView(radiusInput) + } + + // https://github.com/Aliucord/aliucord/blob/4161d5eca10fdba7935efaa50338ea5522c08d7b/Aliucord/src/main/java/com/aliucord/settings/AliucordPage.kt#L83-L99 + private fun addSwitch( + ctx: Context, + setting: String, + title: String, + subtitle: String? = null, + default: Boolean = false, + ) { + Utils.createCheckedSetting(ctx, CheckedSetting.ViewType.SWITCH, title, subtitle).run { + isChecked = settings.getBool(setting, default) + setOnCheckedListener { + settings.setBool(setting, it) + } + linearLayout.addView(this) + } + } + + // https://github.com/Juby210/Aliucord-plugins/blob/ffa31ecfbd3d2e0e1e104b8c94d7d17675c6e02e/BetterStatusIndicators/src/main/java/io/github/juby210/acplugins/bsi/PluginSettings.kt#L230-L245 + private fun createInput( + context: Context, + key: String, + label: String, + default: Int + ) = TextInput(context, "$label (default $default)", settings.getInt(key, default).toString(), SimpleTextWatcher { + it?.run { + val str = toString() + if (str != "") settings.setInt(key, str.toInt()) + } + promptRestart() + }).apply { + editText.inputType = InputType.TYPE_CLASS_NUMBER + layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply { + DimenUtils.defaultPadding.let { setMargins(it, 0, it, it) } + } + } +} diff --git a/plugins/ModernMentions/src/main/kotlin/com.github.ushie/util/AvatarUtils.kt b/plugins/ModernMentions/src/main/kotlin/com.github.ushie/util/AvatarUtils.kt new file mode 100644 index 0000000..fd689aa --- /dev/null +++ b/plugins/ModernMentions/src/main/kotlin/com.github.ushie/util/AvatarUtils.kt @@ -0,0 +1,73 @@ +package com.github.ushie.util + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.Rect +import com.discord.models.user.User +import com.discord.utilities.icon.IconUtils +import java.net.URL + +// COPIED FROM https://github.com/wingio/plugins/blob/92bd1fb2abe4e2388bd60e9b2a50eb86f97c4f30/FavoriteMessages/src/main/java/xyz/wingio/plugins/favoritemessages/util/AvatarUtils.java +class AvatarUtils( + private val context: Context, + private val user: User +) { + fun toBitmap(): Bitmap? { + val url = IconUtils.getForUser(user, false, 64) ?: return null + return fromUrl(context, url) + } + + companion object { + @JvmStatic + fun fromUrl(context: Context, url: String): Bitmap? { + return try { + when { + url.startsWith("asset://asset/") -> { + val assetPath = url.removePrefix("asset://asset/") + context.assets.open(assetPath).use { input -> + BitmapFactory.decodeStream(input) + } + } + + url.startsWith("http://") || url.startsWith("https://") -> { + URL(url).openStream().use { input -> + BitmapFactory.decodeStream(input) + } + } + + else -> null + } + } catch (_: Throwable) { + null + } + } + + @JvmStatic + fun makeCircle(bitmap: Bitmap): Bitmap { + val size = minOf(bitmap.width, bitmap.height) + val x = (bitmap.width - size) / 2 + val y = (bitmap.height - size) / 2 + + val squared = Bitmap.createBitmap(bitmap, x, y, size, size) + val output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + + val canvas = Canvas(output) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + val rect = Rect(0, 0, size, size) + val radius = size / 2f + + canvas.drawARGB(0, 0, 0, 0) + canvas.drawCircle(radius, radius, radius, paint) + + paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN) + canvas.drawBitmap(squared, rect, rect, paint) + + return output + } + } +} diff --git a/plugins/ModernNitroIcons/build.gradle.kts b/plugins/ModernNitroIcons/build.gradle.kts new file mode 100644 index 0000000..79052cb --- /dev/null +++ b/plugins/ModernNitroIcons/build.gradle.kts @@ -0,0 +1,2 @@ +version = "1.0.0" +description = "Backports modern Discord Nitro badges" \ No newline at end of file diff --git a/plugins/ModernNitroIcons/src/main/kotlin/com.github.ushie/ModernNitroIcons.kt b/plugins/ModernNitroIcons/src/main/kotlin/com.github.ushie/ModernNitroIcons.kt new file mode 100644 index 0000000..d1b95c5 --- /dev/null +++ b/plugins/ModernNitroIcons/src/main/kotlin/com.github.ushie/ModernNitroIcons.kt @@ -0,0 +1,71 @@ +package com.github.ushie + +import android.content.Context +import com.aliucord.annotations.AliucordPlugin +import com.aliucord.api.rn.user.RNUserProfile +import com.aliucord.entities.Plugin +import com.aliucord.patcher.after +import com.aliucord.patcher.component1 +import com.aliucord.patcher.component2 +import com.aliucord.utils.lazyField +import com.discord.api.user.UserProfile +import com.discord.databinding.UserProfileHeaderBadgeBinding +import com.discord.models.user.User +import com.discord.widgets.user.Badge +import com.discord.widgets.user.profile.UserProfileHeaderView + +// https://github.com/Aliucord/aliucord/blob/d1f7b7cfdce7b4d7531e62543652cf6a65d1bc9c/Aliucord/src/main/java/com/aliucord/coreplugins/badges/DiscordBadges.kt +@AliucordPlugin +class ModernNitroIcons : Plugin() { + private val f_badgeViewHolderBinding by lazyField("binding") + + @Suppress("UNCHECKED_CAST") + override fun start(context: Context) { + patcher.after( + "getBadgesForUser", + User::class.java, + UserProfile::class.java, + java.lang.Boolean.TYPE, + java.lang.Boolean.TYPE, + Context::class.java + ) { params -> + val badges = params.result as? MutableList ?: return@after + val profile = params.args[1] as? RNUserProfile ?: return@after + + val nitroBadge = profile.badges + ?.firstOrNull { it.id.startsWith("premium_tenure_") } + ?: return@after + + val iconUrl = "https://cdn.discordapp.com/badge-icons/${nitroBadge.icon}.png" + + val replacement = Badge( + 0, + null, + nitroBadge.description, + false, + iconUrl + ) + + val oldIndex = badges.indexOfFirst { it.icon == 2131232057 } + if (oldIndex >= 0) { + badges[oldIndex] = replacement + } + } + + patcher.after( + "bind", Badge:: + class.java + ) { (_, badge: Badge) -> + val url = badge.objectType + + // Check that badge is ours (has icon = 0 and url set) + if (badge.icon != 0 || url == null) return@after + + val binding = f_badgeViewHolderBinding[this] as UserProfileHeaderBadgeBinding + binding.b.setCacheableImage(url) + } + } + + + override fun stop(context: Context) = patcher.unpatchAll() +} \ No newline at end of file diff --git a/plugins/ModernNitroIcons/src/main/kotlin/com.github.ushie/Utils.kt b/plugins/ModernNitroIcons/src/main/kotlin/com.github.ushie/Utils.kt new file mode 100644 index 0000000..a0df098 --- /dev/null +++ b/plugins/ModernNitroIcons/src/main/kotlin/com.github.ushie/Utils.kt @@ -0,0 +1,42 @@ +package com.github.ushie + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.widget.ImageView +import com.aliucord.BuildConfig +import com.aliucord.Http +import com.aliucord.Logger +import com.aliucord.Utils + +// https://github.com/Aliucord/aliucord/blob/34d68dd1fc3364e1da135d86a757a14bd6fe3105/Aliucord/src/main/java/com/aliucord/coreplugins/badges/Utils.kt +private val imageCache = HashMap() + +/** + * Set a remote image to be displayed in this view. + * This will be cached in memory for the lifecycle of the application. + * As such, this should only be used for long-living and often used images. + */ +internal fun ImageView.setCacheableImage(url: String) { + val cachedImage = imageCache[url] + + if (cachedImage != null) { + setImageBitmap(cachedImage) + } else { + Utils.threadPool.execute { + try { + val image = Http.Request(url) + .setHeader("User-Agent", "Aliucord/${BuildConfig.VERSION}") + .execute() + .stream() + .let(BitmapFactory::decodeStream) + + Utils.mainThread.post { + imageCache[url] = image + setImageBitmap(image) + } + } catch (e: Exception) { + Logger("ImageCache").warn("Failed to retrieve image $url", e) + } + } + } +} \ No newline at end of file diff --git a/plugins/MuteVoiceAndStageChannels/build.gradle.kts b/plugins/MuteVoiceAndStageChannels/build.gradle.kts new file mode 100644 index 0000000..98e28c8 --- /dev/null +++ b/plugins/MuteVoiceAndStageChannels/build.gradle.kts @@ -0,0 +1,2 @@ +version = "1.0.0" +description = "Backports voice/stage channel muting. Long press to mute/unmute vocal channels and hide them when Hide Muted Channels is enabled." diff --git a/plugins/MuteVoiceAndStageChannels/src/main/kotlin/com.github.ushie/MuteVoiceAndStageChannels.kt b/plugins/MuteVoiceAndStageChannels/src/main/kotlin/com.github.ushie/MuteVoiceAndStageChannels.kt new file mode 100644 index 0000000..2eccf62 --- /dev/null +++ b/plugins/MuteVoiceAndStageChannels/src/main/kotlin/com.github.ushie/MuteVoiceAndStageChannels.kt @@ -0,0 +1,169 @@ +package com.github.ushie + +import android.content.Context +import android.content.res.ColorStateList +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.aliucord.Utils +import com.aliucord.annotations.AliucordPlugin +import com.aliucord.entities.Plugin +import com.aliucord.patcher.after +import com.aliucord.utils.lazyField +import com.aliucord.wrappers.ChannelWrapper.Companion.guildId +import com.aliucord.wrappers.ChannelWrapper.Companion.id +import com.discord.api.channel.Channel +import com.discord.databinding.WidgetChannelsListItemChannelStageVoiceBinding +import com.discord.databinding.WidgetChannelsListItemChannelVoiceBinding +import com.discord.models.domain.ModelNotificationSettings +import com.discord.stores.StoreStream +import com.discord.stores.StoreUserGuildSettings +import com.discord.utilities.color.ColorCompat +import com.discord.widgets.channels.list.WidgetChannelListModel +import com.discord.widgets.channels.list.WidgetChannelsListAdapter +import com.discord.widgets.channels.list.items.ChannelListItem +import com.discord.widgets.channels.list.items.ChannelListItemStageVoiceChannel +import com.discord.widgets.channels.list.items.ChannelListItemVoiceChannel +import com.lytefast.flexinput.R +import rx.Observable + +@Suppress("UNCHECKED_CAST", "unused") +@AliucordPlugin +class MuteVoiceAndStageChannels : Plugin() { + private val voiceNameId = Utils.getResId("channels_item_voice_channel_name", "id") + private val voiceIconId = Utils.getResId("channels_item_voice_channel_speaker", "id") + private val stageNameId = Utils.getResId("stage_channel_item_voice_channel_name", "id") + private val stageIconId = Utils.getResId("stage_channel_item_stage_channel_icon", "id") + + private val guildSettingsField by lazyField("guildSettings") + private val hideMutedGuildsField by lazyField("guildsToHideMutedChannelsIn") + + private val voiceBindingField by lazyField("binding") + private val stageBindingField by lazyField("binding") + + private val userGuildSettings + get() = StoreStream.getUserGuildSettings() + + private val mutedOverrides = mutableMapOf() + + override fun start(context: Context) { + patcher.after("get") { param -> + val observable = param.result as? Observable ?: return@after + + param.result = observable.G { model -> + model.copy( + model.selectedGuild, + model.items.filterNot { item -> + when (item) { + is ChannelListItemVoiceChannel -> item.channel.shouldHide() + is ChannelListItemStageVoiceChannel -> item.channel.shouldHide() + else -> false + } + }, + model.isGuildSelected, + model.showPremiumGuildHint, + model.showEmptyState, + model.guildScheduledEvents + ) + } + } + + patcher.after( + "onConfigure", + Int::class.javaPrimitiveType!!, + ChannelListItem::class.java + ) { param -> + val data = param.args[1] as? ChannelListItemVoiceChannel ?: return@after + val binding = voiceBindingField.get(param.thisObject) + as WidgetChannelsListItemChannelVoiceBinding + binding.a.setOnLongClickListener { + data.channel.toggleMuted(it) + true + } + if (!data.channel.isMuted() || data.voiceSelected) return@after + val itemView = (param.thisObject as WidgetChannelsListAdapter.ItemChannelVoice).itemView + tintMutedVoiceItem( + itemView.context, + (param.thisObject as WidgetChannelsListAdapter.ItemChannelVoice).itemView, + voiceNameId, + voiceIconId + ) + } + + patcher.after( + "onConfigure", + Int::class.javaPrimitiveType!!, + ChannelListItem::class.java + ) { param -> + val data = param.args[1] as? ChannelListItemStageVoiceChannel ?: return@after + val binding = stageBindingField.get(param.thisObject) + as WidgetChannelsListItemChannelStageVoiceBinding + binding.a.setOnLongClickListener { + data.channel.toggleMuted(it) + true + } + + if (!data.channel.isMuted() || data.selected) return@after + + val itemView = (param.thisObject as WidgetChannelsListAdapter.ItemChannelStageVoice).itemView + tintMutedVoiceItem( + itemView.context, + (param.thisObject as WidgetChannelsListAdapter.ItemChannelStageVoice).itemView, + stageNameId, + stageIconId + ) + } + } + + override fun stop(context: Context) { + patcher.unpatchAll() + } + + private fun Channel.toggleMuted(view: View) { + val muted = !isMuted() + mutedOverrides[id] = muted + + StoreStream.getUserGuildSettings().setChannelMuted( + view.context, + id, + muted, + null + ) + + view.post { + (view.parent as? RecyclerView) + ?.adapter + ?.notifyDataSetChanged() + } + } + + private fun Channel.isMuted(): Boolean { + mutedOverrides[id]?.let { return it } + + val settings = + guildSettingsField.get(userGuildSettings) + as Map + + return settings[guildId] + ?.getChannelOverride(id) + ?.isMuted == true + } + + private fun Channel.shouldHide() = + isMuted() && + guildId in (hideMutedGuildsField.get(userGuildSettings) as Set) + + private fun tintMutedVoiceItem( + context: Context, + itemView: View, + nameId: Int, + iconId: Int + ) { + val color = ColorCompat.getThemedColor(context, R.b.colorInteractiveMuted) + + itemView.findViewById(nameId)?.setTextColor(color) + itemView.findViewById(iconId)?.imageTintList = + ColorStateList.valueOf(color) + } +} diff --git a/plugins/NewMemberBadge/build.gradle.kts b/plugins/NewMemberBadge/build.gradle.kts new file mode 100644 index 0000000..3505775 --- /dev/null +++ b/plugins/NewMemberBadge/build.gradle.kts @@ -0,0 +1,9 @@ +version = "1.0.1" +description = "Show the New Member badge on new members in chat" + +aliucord.changelog.set( + """ + # 1.0.1 + * Fix badge randomly appearing in DMs + """.trimIndent() +) diff --git a/plugins/NewMemberBadge/src/main/kotlin/com/github/ushie/NewMemberBadge.kt b/plugins/NewMemberBadge/src/main/kotlin/com/github/ushie/NewMemberBadge.kt new file mode 100644 index 0000000..aa59a20 --- /dev/null +++ b/plugins/NewMemberBadge/src/main/kotlin/com/github/ushie/NewMemberBadge.kt @@ -0,0 +1,110 @@ +package com.github.ushie + +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.ImageView +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID +import com.aliucord.Utils +import com.aliucord.annotations.AliucordPlugin +import com.aliucord.entities.Plugin +import com.aliucord.patcher.after +import com.aliucord.utils.DimenUtils.dp +import com.aliucord.utils.ViewUtils.addTo +import com.aliucord.utils.ViewUtils.findViewById +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.roles.RoleIconView +import com.github.ushie.ui.NewMemberBadgeResource +import kotlin.time.Duration +import kotlin.time.DurationUnit +import kotlin.time.ExperimentalTime + +@Suppress("unused") +@AliucordPlugin +class NewMemberBadge : Plugin() { + private val newMemberBadgeId = View.generateViewId() + private lateinit var newMemberBadgeResource: NewMemberBadgeResource + + init { + settingsTab = SettingsTab(PluginSettings::class.java).withArgs(settings) + } + + override fun load(context: Context) { + newMemberBadgeResource = NewMemberBadgeResource(resources!!) + } + + @OptIn(ExperimentalTime::class) + override fun start(context: Context) { + val daysNeeded = settings.getInt("days", 7) + + patcher.after( + "onConfigure", + Int::class.java, + ChatListEntry::class.java + ) { param -> + val entry = param.args[1] as? MessageEntry ?: return@after + if (entry.message.isLoading) return@after + + itemView.findViewById(newMemberBadgeId)?.visibility = View.GONE + val joinedAt = runCatching { entry.author.joinedAt }.getOrNull() ?: return@after + + if (Duration + .milliseconds(System.currentTimeMillis() - joinedAt.g()) + .toInt(DurationUnit.DAYS) >= daysNeeded + ) return@after + + val headerView = itemView.findViewById("chat_list_adapter_item_text_header") + val roleIconView = headerView.findViewById("chat_list_adapter_item_text_role_icon") + val botTagView = headerView.findViewById("chat_list_adapter_item_text_tag") + + headerView.findViewById(newMemberBadgeId)?.apply { + visibility = View.VISIBLE + return@after + } + + ImageView(context).apply { + id = newMemberBadgeId + visibility = View.VISIBLE + setImageDrawable(newMemberBadgeResource.getDrawable("ic_new_member_badge_24dp")) + contentDescription = "New Member Badge" + setOnClickListener { + Utils.showToast("I'm new here, say hi!") + } + }.addBetween(headerView, roleIconView, botTagView) + } + } + + override fun stop(context: Context) { + patcher.unpatchAll() + } +} + +// https://github.com/Aliucord/aliucord/blob/cb3acaeb44c27d477d3caaecbc37d2790ecddece/Aliucord/src/main/java/com/aliucord/coreplugins/decorations/guildtags/GuildTagView.kt +fun View.addBetween(parent: ConstraintLayout, left: View, right: View): View { + addTo(parent) { + left.layoutParams = (left.layoutParams as ConstraintLayout.LayoutParams).apply { + endToStart = id + } + + right.layoutParams = (right.layoutParams as ConstraintLayout.LayoutParams).apply { + startToEnd = id + } + + layoutParams = ConstraintLayout.LayoutParams(WRAP_CONTENT, MATCH_PARENT).apply { + marginStart = 4.dp + verticalBias = 0.5f + + topToTop = PARENT_ID + bottomToBottom = PARENT_ID + startToEnd = left.id + endToStart = right.id + } + } + + return this +} diff --git a/plugins/NewMemberBadge/src/main/kotlin/com/github/ushie/PluginSettings.kt b/plugins/NewMemberBadge/src/main/kotlin/com/github/ushie/PluginSettings.kt new file mode 100644 index 0000000..397b8c9 --- /dev/null +++ b/plugins/NewMemberBadge/src/main/kotlin/com/github/ushie/PluginSettings.kt @@ -0,0 +1,52 @@ +package com.github.ushie + +import android.text.InputType +import android.view.View +import android.widget.Button +import com.aliucord.Utils.promptRestart +import com.aliucord.api.SettingsAPI +import com.aliucord.fragments.SettingsPage +import com.aliucord.views.TextInput + +class PluginSettings(private val settings: SettingsAPI) : SettingsPage() { + override fun onViewBound(view: View) { + super.onViewBound(view) + + setActionBarTitle("New Member Badge") + setActionBarSubtitle("Settings") + + val ctx = requireContext() + + val daysInput = TextInput( + ctx, + "Days", + settings.getInt("days", 7).toString() + ).apply { + editText.inputType = InputType.TYPE_CLASS_NUMBER + } + + addView(daysInput) + + addView( + Button(ctx).apply { + text = "Reset to default" + setOnClickListener { + daysInput.editText.setText("7") + settings.setInt("days", 7) + promptRestart() + } + } + ) + + addView( + Button(ctx).apply { + text = "Save and restart" + setOnClickListener { + val value = daysInput.editText.text.toString().toIntOrNull() ?: 7 + settings.setInt("days", value) + promptRestart() + } + } + ) + } +} diff --git a/plugins/NewMemberBadge/src/main/kotlin/com/github/ushie/ui/NewMemberBadgeResource.kt b/plugins/NewMemberBadge/src/main/kotlin/com/github/ushie/ui/NewMemberBadgeResource.kt new file mode 100644 index 0000000..2eeb62e --- /dev/null +++ b/plugins/NewMemberBadge/src/main/kotlin/com/github/ushie/ui/NewMemberBadgeResource.kt @@ -0,0 +1,21 @@ +package com.github.ushie.ui + +import android.content.res.Resources +import android.view.View +import androidx.annotation.DrawableRes +import androidx.core.content.res.ResourcesCompat + +class NewMemberBadgeResource(private val resources: Resources) { + + fun getId(name: String, type: String) = + resources.getIdentifier(name, type, "com.github.ushie") + + @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/NewMemberBadge/src/main/res/drawable/ic_new_member_badge_24dp.xml b/plugins/NewMemberBadge/src/main/res/drawable/ic_new_member_badge_24dp.xml new file mode 100644 index 0000000..84c3eea --- /dev/null +++ b/plugins/NewMemberBadge/src/main/res/drawable/ic_new_member_badge_24dp.xml @@ -0,0 +1,12 @@ + + + + diff --git a/plugins/PronounsInChat/build.gradle.kts b/plugins/PronounsInChat/build.gradle.kts new file mode 100644 index 0000000..2ab9379 --- /dev/null +++ b/plugins/PronounsInChat/build.gradle.kts @@ -0,0 +1,6 @@ +version = "1.0.0" +description = "Shows the user pronouns in chat messages." + +aliucord { + deploy.set(true) +} diff --git a/plugins/PronounsInChat/src/main/kotlin/com.github.ushie/PronounsInChat.kt b/plugins/PronounsInChat/src/main/kotlin/com.github.ushie/PronounsInChat.kt new file mode 100644 index 0000000..7ae5837 --- /dev/null +++ b/plugins/PronounsInChat/src/main/kotlin/com.github.ushie/PronounsInChat.kt @@ -0,0 +1,106 @@ +package com.github.ushie + +import android.content.Context +import android.widget.TextView +import com.aliucord.Utils +import com.aliucord.annotations.AliucordPlugin +import com.aliucord.api.rn.user.RNUserProfile +import com.aliucord.entities.Plugin +import com.aliucord.patcher.after +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.user.profile.UserProfileHeaderView +import com.discord.widgets.user.profile.UserProfileHeaderViewModel + +@Suppress("unused") +@AliucordPlugin +class PronounsInChat : Plugin() { + private val sheetProfileHeaderViewId = + Utils.getResId("user_sheet_profile_header_view", "id") + private val timeStampViewId = + Utils.getResId("chat_list_adapter_item_text_timestamp", "id") + + private val cache = HashMap() + private val log = com.aliucord.Logger("PronounsInChat") + + override fun start(context: Context) { + + patcher.after( + "configureSecondaryName", + UserProfileHeaderViewModel.ViewState.Loaded::class.java + ) { + val state = + it.args[0] as? UserProfileHeaderViewModel.ViewState.Loaded + ?: return@after + + val profile = + state.userProfile as? RNUserProfile + ?: return@after + + val userId = profile.g().id + + val pronouns = + profile.guildMemberProfile?.pronouns?.ifEmpty { null } + ?: profile.userProfile?.pronouns?.ifEmpty { null } + ?: return@after + + savePronouns(userId, pronouns) + cache[userId] = pronouns + } + + patcher.after( + "onConfigure", + Int::class.java, + ChatListEntry::class.java + ) { param -> + val entry = param.args[1] as MessageEntry + val message = entry.message + if (message.isLoading) return@after + + val authorId = message.author.id + + val timestampView = + itemView.findViewById(timeStampViewId) + ?: return@after + + cache[authorId]?.let { + setPronounsTextView(timestampView, it) + return@after + } + + getPronouns(authorId)?.let { + cache[authorId] = it + setPronounsTextView(timestampView, it) + return@after + } + } + } + + private fun pronounKey(userId: Long) = + "pronouns.user.$userId" + + private fun savePronouns(userId: Long, pronouns: String) { + val key = pronounKey(userId) + if (settings.getString(key, null) != pronouns) { + settings.setString(key, pronouns) + } + } + + private fun getPronouns(userId: Long): String? = + settings.getString(pronounKey(userId), null) + + private fun setPronounsTextView( + textView: TextView, + pronouns: String + ) { + if (!textView.text.contains("•")) { + textView.text = "${textView.text} • $pronouns" + } + } + + override fun stop(context: Context) { + cache.clear() + patcher.unpatchAll() + } +} diff --git a/plugins/ShowBlockedMessagesAuthor/build.gradle.kts b/plugins/ShowBlockedMessagesAuthor/build.gradle.kts new file mode 100644 index 0000000..90942f2 --- /dev/null +++ b/plugins/ShowBlockedMessagesAuthor/build.gradle.kts @@ -0,0 +1,15 @@ +version = "1.0.0" +description = "Shows who sent the blocked messages in chat without opening them" + + +aliucord { + changelog.set( + """ + # 1.0.0 + * Initial release + * TODO: Support multiple authors + """.trimIndent() + ) + + deploy.set(true) +} diff --git a/plugins/ShowBlockedMessagesAuthor/src/main/kotlin/com.github.ushie/ShowBlockedMessagesAuthor.kt b/plugins/ShowBlockedMessagesAuthor/src/main/kotlin/com.github.ushie/ShowBlockedMessagesAuthor.kt new file mode 100644 index 0000000..87f39f1 --- /dev/null +++ b/plugins/ShowBlockedMessagesAuthor/src/main/kotlin/com.github.ushie/ShowBlockedMessagesAuthor.kt @@ -0,0 +1,37 @@ +package com.github.ushie + +import android.content.Context +import android.widget.TextView +import com.aliucord.Logger +import com.aliucord.Utils +import com.aliucord.annotations.AliucordPlugin +import com.aliucord.entities.Plugin +import com.aliucord.wrappers.users.globalName +import com.discord.widgets.chat.list.adapter.WidgetChatListAdapterItemBlocked +import com.discord.widgets.chat.list.entries.BlockedMessagesEntry +import com.discord.widgets.chat.list.entries.ChatListEntry + +@Suppress("unused") +@AliucordPlugin +class ShowBlockedMessagesAuthor : Plugin() { + val log: Logger = Logger("ShowBlockedMessagesAuthor") + + override fun start(context: Context) { + with(WidgetChatListAdapterItemBlocked::class.java) { + patcher.patch(getDeclaredMethod("onConfigure", Int::class.java, ChatListEntry::class.java)) { param -> + val entry = param.args[1] as BlockedMessagesEntry + val view = param.thisObject as WidgetChatListAdapterItemBlocked + + view.itemView.findViewById(Utils.getResId("chat_list_adapter_item_blocked", "id")).apply { + text = + "${entry.blockedCount} blocked message${if (entry.blockedCount > 1) "s" else ""} from ${entry.message.author.globalName ?: entry.message.author.username}" + } + } + } + } + + override fun stop(context: Context) { + patcher.unpatchAll() + commands.unregisterAll() + } +}