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 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) { 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 * Message will mention all users who are online and can see the channel.
MentionsOnline(1 shl 2) *
* This cannot be true if [MentionsEveryone] is true.
*
* > **Cannot be set on send**
*/
MentionsOnline(1 shl 3)
} }
operator fun Int.plus(other: MessageFlag): Int { operator fun Int.plus(other: MessageFlag): Int {
@ -20,6 +40,6 @@ fun Int.hasMessageFlag(flag: MessageFlag): Boolean {
return this and flag.value == flag.value 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) return this != null && this.hasMessageFlag(flag)
} }

View File

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

View File

@ -22,6 +22,11 @@ data class Message(
val system: SystemInfo? = null, val system: SystemInfo? = null,
val webhook: WebHook? = null, val webhook: WebHook? = null,
val interactions: InteractionsDescription? = 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 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 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() 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 { object FeatureFlags {
@FeatureFlag("LabsAccessControl") @FeatureFlag("LabsAccessControl")
var labsAccessControl by mutableStateOf<LabsAccessControlVariates>( var labsAccessControl by mutableStateOf<LabsAccessControlVariates>(
@ -81,4 +94,14 @@ object FeatureFlags {
is UserCardsVariates.Enabled -> true is UserCardsVariates.Enabled -> true
is UserCardsVariates.Restricted -> (userCards as UserCardsVariates.Restricted).predicate() 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.REVOLT_FILES
import chat.revolt.api.RevoltAPI import chat.revolt.api.RevoltAPI
import chat.revolt.api.internals.BrushCompat import chat.revolt.api.internals.BrushCompat
import chat.revolt.api.internals.MessageFlag
import chat.revolt.api.internals.Roles import chat.revolt.api.internals.Roles
import chat.revolt.api.internals.SpecialUsers import chat.revolt.api.internals.SpecialUsers
import chat.revolt.api.internals.ULID import chat.revolt.api.internals.ULID
import chat.revolt.api.internals.has
import chat.revolt.api.internals.solidColor import chat.revolt.api.internals.solidColor
import chat.revolt.api.routes.channel.react import chat.revolt.api.routes.channel.react
import chat.revolt.api.routes.channel.unreact import chat.revolt.api.routes.channel.unreact
@ -262,7 +264,11 @@ fun Message(
} else { } else {
Column( Column(
modifier = Modifier.then( 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( Modifier.background(
MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
) )

View File

@ -135,6 +135,10 @@ sealed class AutocompleteSuggestion {
val id: String, val id: String,
val query: String val query: String
) : AutocompleteSuggestion() ) : AutocompleteSuggestion()
data class MassMention(
val content: String
) : AutocompleteSuggestion()
} }
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@ -289,6 +293,7 @@ fun MessageField(
is AutocompleteSuggestion.Channel -> item.channel.id!! is AutocompleteSuggestion.Channel -> item.channel.id!!
is AutocompleteSuggestion.Emoji -> item.shortcode is AutocompleteSuggestion.Emoji -> item.shortcode
is AutocompleteSuggestion.Role -> item.id is AutocompleteSuggestion.Role -> item.id
is AutocompleteSuggestion.MassMention -> item.content
} }
}) { }) {
when (val item = autocompleteSuggestions[it]) { when (val item = autocompleteSuggestions[it]) {
@ -453,6 +458,35 @@ fun MessageField(
modifier = Modifier.animateItem() 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 package chat.revolt.internals
import chat.revolt.api.RevoltAPI 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.schemas.ChannelType
import chat.revolt.api.settings.FeatureFlags
import chat.revolt.composables.chat.AutocompleteSuggestion import chat.revolt.composables.chat.AutocompleteSuggestion
object Autocomplete { object Autocomplete {
@ -44,6 +48,18 @@ object Autocomplete {
): List<AutocompleteSuggestion> { ): List<AutocompleteSuggestion> {
val channel = RevoltAPI.channelCache[channelId] ?: return emptyList() 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) { return when (channel.channelType) {
ChannelType.DirectMessage -> { ChannelType.DirectMessage -> {
val otherUser = channel.recipients?.find { it != RevoltAPI.selfId } val otherUser = channel.recipients?.find { it != RevoltAPI.selfId }
@ -102,7 +118,9 @@ object Autocomplete {
if (serverId == null) return emptyList() if (serverId == null) return emptyList()
if (query.length < 2) 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) val byNickname = RevoltAPI.members.filterNamesFor(serverId, query)
.map { m -> m to RevoltAPI.userCache[m.id?.user] }.filter { (_, u) -> .map { m -> m to RevoltAPI.userCache[m.id?.user] }.filter { (_, u) ->
u != null u != null
@ -156,7 +174,9 @@ object Autocomplete {
} }
null -> emptyList() null -> emptyList()
} } + if (selfPermissions has PermissionBit.MentionEveryone && FeatureFlags.massMentionsGranted) massMentionSuggestions.map { mention ->
AutocompleteSuggestion.MassMention(mention)
} else listOf()
} }
fun channel( fun channel(

View File

@ -12,6 +12,9 @@ object RSMElementTypes {
@JvmField @JvmField
val ROLE_MENTION: IElementType = MarkdownElementType("ROLE_MENTION") val ROLE_MENTION: IElementType = MarkdownElementType("ROLE_MENTION")
@JvmField
val MASS_MENTION: IElementType = MarkdownElementType("MASS_MENTION")
@JvmField @JvmField
val CUSTOM_EMOTE: IElementType = MarkdownElementType("EMOJI") 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.ChannelMentionParser
import chat.revolt.markdown.jbm.sequentialparsers.CustomEmoteParser 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.RoleMentionParser
import chat.revolt.markdown.jbm.sequentialparsers.UserMentionParser import chat.revolt.markdown.jbm.sequentialparsers.UserMentionParser
import org.intellij.markdown.MarkdownTokenTypes import org.intellij.markdown.MarkdownTokenTypes
@ -26,6 +27,7 @@ class RSMFlavourDescriptor : GFMFlavourDescriptor() {
UserMentionParser(), UserMentionParser(),
ChannelMentionParser(), ChannelMentionParser(),
RoleMentionParser(), RoleMentionParser(),
MassMentionParser(),
CustomEmoteParser(), CustomEmoteParser(),
AutolinkParser(listOf(MarkdownTokenTypes.AUTOLINK, GFMTokenTypes.GFM_AUTOLINK)), AutolinkParser(listOf(MarkdownTokenTypes.AUTOLINK, GFMTokenTypes.GFM_AUTOLINK)),
BacktickParser(), 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>