feat: mass and role mentions

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2025-07-02 02:53:46 +02:00
parent a9e22bcb98
commit 62a9a6c022
11 changed files with 158 additions and 12 deletions

View File

@ -1,15 +1,35 @@
package chat.revolt.api.internals
/**
* Flags for messages that can be set to modify their behavior.
*
* See [Reference](https://docs.rs/revolt-models/latest/revolt_models/v0/enum.MessageFlags.html) for
* values
*
* `shl 0` is not used.
*
* @property value The integer value representing the flag.
*/
enum class MessageFlag(val value: Int) {
// Message will not send push / desktop notifications
SuppressNotifications(1 shl 0),
/**
* Message will not send push / desktop notifications.
*/
SuppressNotifications(1 shl 1),
// Message will mention all users who can see the channel
MentionsEveryone(1 shl 1),
/**
* Message will mention all users who can see the channel
* > **Cannot be set on send**
*/
MentionsEveryone(1 shl 2),
// Message will mention all users who are online and can see the channel.
// This cannot be true if MentionsEveryone is true
MentionsOnline(1 shl 2)
/**
* Message will mention all users who are online and can see the channel.
*
* This cannot be true if [MentionsEveryone] is true.
*
* > **Cannot be set on send**
*/
MentionsOnline(1 shl 3)
}
operator fun Int.plus(other: MessageFlag): Int {
@ -20,6 +40,6 @@ fun Int.hasMessageFlag(flag: MessageFlag): Boolean {
return this and flag.value == flag.value
}
infix fun Int?.hasMessageFlag(flag: MessageFlag): Boolean {
infix fun Int?.has(flag: MessageFlag): Boolean {
return this != null && this.hasMessageFlag(flag)
}

View File

@ -42,8 +42,14 @@ enum class PermissionBit(val value: Long) {
DeafenMembers(1L shl 34),
MoveMembers(1L shl 35),
// % 1 bit reserved
// * Channel permissions cont.
MentionEveryone(1L shl 37),
MentionRoles(1L shl 38),
// * Misc. permissions
// % Bits 36 to 52: free area
// % Bits 38 to 52: free area
// % Bits 53 to 64: do not use
// * Grant all permissions

View File

@ -22,6 +22,11 @@ data class Message(
val system: SystemInfo? = null,
val webhook: WebHook? = null,
val interactions: InteractionsDescription? = null,
val pinned: Boolean? = null,
/**
* See [chat.revolt.api.internals.MessageFlag]
*/
val flags: Int? = null,
val type: String? = null, // this is _only_ used for websocket events!
val tail: Boolean? = null // this is used to determine if the message is the last in a message group
) {

View File

@ -43,6 +43,19 @@ sealed class UserCardsVariates {
data class Restricted(val predicate: () -> Boolean) : UserCardsVariates()
}
@FeatureFlag("MassMentions")
sealed class MassMentionsVariates {
@Treatment(
"Enable mass mentions and role mentions for all users"
)
object Enabled : MassMentionsVariates()
@Treatment(
"Disable mass mentions and role mentions for all users"
)
object Disabled : MassMentionsVariates()
}
object FeatureFlags {
@FeatureFlag("LabsAccessControl")
var labsAccessControl by mutableStateOf<LabsAccessControlVariates>(
@ -81,4 +94,14 @@ object FeatureFlags {
is UserCardsVariates.Enabled -> true
is UserCardsVariates.Restricted -> (userCards as UserCardsVariates.Restricted).predicate()
}
@FeatureFlag("MassMentions")
var massMentions by mutableStateOf<MassMentionsVariates>(
MassMentionsVariates.Enabled
)
val massMentionsGranted: Boolean
get() = when (massMentions) {
is MassMentionsVariates.Enabled -> true
is MassMentionsVariates.Disabled -> false
}
}

View File

@ -64,9 +64,11 @@ import chat.revolt.activities.media.VideoViewActivity
import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.RevoltAPI
import chat.revolt.api.internals.BrushCompat
import chat.revolt.api.internals.MessageFlag
import chat.revolt.api.internals.Roles
import chat.revolt.api.internals.SpecialUsers
import chat.revolt.api.internals.ULID
import chat.revolt.api.internals.has
import chat.revolt.api.internals.solidColor
import chat.revolt.api.routes.channel.react
import chat.revolt.api.routes.channel.unreact
@ -262,7 +264,11 @@ fun Message(
} else {
Column(
modifier = Modifier.then(
if ((message.mentions?.contains(RevoltAPI.selfId) == true) || mentionsSelfRole) {
if ((message.mentions?.contains(RevoltAPI.selfId) == true)
|| mentionsSelfRole
|| message.flags has MessageFlag.MentionsOnline
|| message.flags has MessageFlag.MentionsEveryone
) {
Modifier.background(
MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
)

View File

@ -135,6 +135,10 @@ sealed class AutocompleteSuggestion {
val id: String,
val query: String
) : AutocompleteSuggestion()
data class MassMention(
val content: String
) : AutocompleteSuggestion()
}
@OptIn(ExperimentalFoundationApi::class)
@ -289,6 +293,7 @@ fun MessageField(
is AutocompleteSuggestion.Channel -> item.channel.id!!
is AutocompleteSuggestion.Emoji -> item.shortcode
is AutocompleteSuggestion.Role -> item.id
is AutocompleteSuggestion.MassMention -> item.content
}
}) {
when (val item = autocompleteSuggestions[it]) {
@ -453,6 +458,35 @@ fun MessageField(
modifier = Modifier.animateItem()
)
}
is AutocompleteSuggestion.MassMention -> {
SuggestionChip(
onClick = {
textFieldState.edit {
val lastWordStartsAt =
textFieldState.text
.substring(0, textFieldState.selection.max)
.lastWordStartsAt()
replace(
if (lastWordStartsAt == -1) 0 else (lastWordStartsAt + 1),
textFieldState.selection.max,
"@${item.content} "
)
}
},
label = { Text("@${item.content}") },
icon = {
Icon(
painter = painterResource(R.drawable.icn_campaign_24dp),
contentDescription = null,
modifier = Modifier
.size(SuggestionChipDefaults.IconSize)
.align(Alignment.CenterHorizontally)
)
},
modifier = Modifier.animateItem()
)
}
}
}
}

View File

@ -1,7 +1,11 @@
package chat.revolt.internals
import chat.revolt.api.RevoltAPI
import chat.revolt.api.internals.PermissionBit
import chat.revolt.api.internals.Roles
import chat.revolt.api.internals.has
import chat.revolt.api.schemas.ChannelType
import chat.revolt.api.settings.FeatureFlags
import chat.revolt.composables.chat.AutocompleteSuggestion
object Autocomplete {
@ -44,6 +48,18 @@ object Autocomplete {
): List<AutocompleteSuggestion> {
val channel = RevoltAPI.channelCache[channelId] ?: return emptyList()
val member = serverId?.let { RevoltAPI.members.getMember(serverId, RevoltAPI.selfId ?: "") }
val massMentionSuggestions = listOf("everyone", "online")
.filter { it.startsWith(query, ignoreCase = true) }
val selfPermissions = RevoltAPI.channelCache[channelId]?.let { ch ->
Roles.permissionFor(
ch,
RevoltAPI.userCache[RevoltAPI.selfId],
member
)
}
return when (channel.channelType) {
ChannelType.DirectMessage -> {
val otherUser = channel.recipients?.find { it != RevoltAPI.selfId }
@ -102,7 +118,9 @@ object Autocomplete {
if (serverId == null) return emptyList()
if (query.length < 2) return emptyList()
val roles = RevoltAPI.serverCache[serverId]?.roles ?: emptyMap()
val roles =
if (selfPermissions has PermissionBit.MentionRoles && FeatureFlags.massMentionsGranted) RevoltAPI.serverCache[serverId]?.roles
?: emptyMap() else emptyMap()
val byNickname = RevoltAPI.members.filterNamesFor(serverId, query)
.map { m -> m to RevoltAPI.userCache[m.id?.user] }.filter { (_, u) ->
u != null
@ -156,7 +174,9 @@ object Autocomplete {
}
null -> emptyList()
}
} + if (selfPermissions has PermissionBit.MentionEveryone && FeatureFlags.massMentionsGranted) massMentionSuggestions.map { mention ->
AutocompleteSuggestion.MassMention(mention)
} else listOf()
}
fun channel(

View File

@ -12,6 +12,9 @@ object RSMElementTypes {
@JvmField
val ROLE_MENTION: IElementType = MarkdownElementType("ROLE_MENTION")
@JvmField
val MASS_MENTION: IElementType = MarkdownElementType("MASS_MENTION")
@JvmField
val CUSTOM_EMOTE: IElementType = MarkdownElementType("EMOJI")

View File

@ -2,6 +2,7 @@ package chat.revolt.markdown.jbm
import chat.revolt.markdown.jbm.sequentialparsers.ChannelMentionParser
import chat.revolt.markdown.jbm.sequentialparsers.CustomEmoteParser
import chat.revolt.markdown.jbm.sequentialparsers.MassMentionParser
import chat.revolt.markdown.jbm.sequentialparsers.RoleMentionParser
import chat.revolt.markdown.jbm.sequentialparsers.UserMentionParser
import org.intellij.markdown.MarkdownTokenTypes
@ -26,6 +27,7 @@ class RSMFlavourDescriptor : GFMFlavourDescriptor() {
UserMentionParser(),
ChannelMentionParser(),
RoleMentionParser(),
MassMentionParser(),
CustomEmoteParser(),
AutolinkParser(listOf(MarkdownTokenTypes.AUTOLINK, GFMTokenTypes.GFM_AUTOLINK)),
BacktickParser(),

View File

@ -0,0 +1,17 @@
package chat.revolt.markdown.jbm.sequentialparsers
import org.intellij.markdown.parser.sequentialparsers.RangesListBuilder
import org.intellij.markdown.parser.sequentialparsers.SequentialParser
import org.intellij.markdown.parser.sequentialparsers.TokensCache
class MassMentionParser : SequentialParser {
override fun parse(
tokens: TokensCache,
rangesToGlue: List<IntRange>
): SequentialParser.ParsingResult {
// TODO - Implement
val result = SequentialParser.ParsingResultBuilder()
val delegateIndices = RangesListBuilder()
return result.withFurtherProcessing(delegateIndices.get())
}
}

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="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M720,520L720,440L880,440L880,520L720,520ZM768,800L640,704L688,640L816,736L768,800ZM688,320L640,256L768,160L816,224L688,320ZM200,760L200,600L160,600Q127,600 103.5,576.5Q80,553 80,520L80,440Q80,407 103.5,383.5Q127,360 160,360L320,360L520,240L520,720L320,600L280,600L280,760L200,760ZM440,578L440,382L342,440L160,440Q160,440 160,440Q160,440 160,440L160,520Q160,520 160,520Q160,520 160,520L342,520L440,578ZM560,614L560,346Q587,370 603.5,404.5Q620,439 620,480Q620,521 603.5,555.5Q587,590 560,614ZM300,480L300,480L300,480Q300,480 300,480Q300,480 300,480L300,480Q300,480 300,480Q300,480 300,480L300,480L300,480Z"/>
</vector>