ui: group service events channel texts (#6781)

This commit is contained in:
spaced4ndy
2026-04-10 18:25:06 +00:00
committed by GitHub
parent 393e11c0c4
commit 75d62b08ca
9 changed files with 108 additions and 77 deletions
@@ -195,7 +195,7 @@ struct ChatItemContentView<Content: View>: View {
}
private func pendingReviewEventItemText() -> Text {
Text(chatItem.content.text)
Text(chatItem.content.text(isChannel: chat.chatInfo.isChannel))
.font(.caption)
.foregroundColor(theme.colors.secondary)
.fontWeight(.bold)
@@ -209,9 +209,9 @@ struct ChatItemContentView<Content: View>: View {
.font(.caption)
.foregroundColor(secondaryColor)
.fontWeight(.light)
+ chatEventText(chatItem, secondaryColor)
+ chatEventText(chatItem, secondaryColor, isChannel: chat.chatInfo.isChannel)
} else {
return chatEventText(chatItem, secondaryColor)
return chatEventText(chatItem, secondaryColor, isChannel: chat.chatInfo.isChannel)
}
}
@@ -234,7 +234,7 @@ struct ChatItemContentView<Content: View>: View {
return if count <= 1 {
nil
} else if ns.count == 0 {
Text("\(count) group events")
chat.chatInfo.isChannel ? Text("\(count) channel events") : Text("\(count) group events")
} else if count > ns.count {
Text(members) + textSpace + Text("and \(count - ns.count) other events")
} else {
@@ -275,8 +275,8 @@ func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text, _ secondaryColor
chatEventText(Text(eventText) + textSpace + ts, secondaryColor)
}
func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text {
chatEventText("\(ci.content.text)", ci.timestampText, secondaryColor)
func chatEventText(_ ci: ChatItem, _ secondaryColor: Color, isChannel: Bool = false) -> Text {
chatEventText("\(ci.content.text(isChannel: isChannel))", ci.timestampText, secondaryColor)
}
struct ChatItemView_Previews: PreviewProvider {
@@ -296,7 +296,7 @@ struct ChatPreviewView: View {
}
func chatItemPreview(_ cItem: ChatItem) -> (Text, Bool) {
let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText()
let itemText = cItem.meta.itemDeleted == nil ? cItem.text(isChannel: chat.chatInfo.isChannel) : markedDeletedText()
let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil
let r = messageText(itemText, itemFormattedText, sender: cItem.meta.showGroupAsSender ? nil : cItem.memberDisplayName, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: nil, backgroundColor: UIColor(theme.colors.background), prefix: prefix())
return (Text(AttributedString(r.string)), r.hasSecrets)
+60 -44
View File
@@ -1652,6 +1652,10 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
}
}
public var isChannel: Bool {
groupInfo?.useRelays == true
}
// this works for features that are common for contacts and groups
public func featureEnabled(_ feature: ChatFeature) -> Bool {
switch self {
@@ -3191,11 +3195,13 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
public var timestampText: Text { meta.timestampText }
public var text: String {
switch (content.text, content.msgContent, file) {
public var text: String { text(isChannel: false) }
public func text(isChannel: Bool) -> String {
switch (content.text(isChannel: isChannel), content.msgContent, file) {
case let ("", .some(.voice(_, duration)), _): return "Voice message (\(durationText(duration)))"
case let ("", _, .some(file)): return file.fileName
default: return content.text
default: return content.text(isChannel: isChannel)
}
}
@@ -4047,42 +4053,42 @@ public enum CIContent: Decodable, ItemContent, Hashable {
case chatBanner
case invalidJSON(json: Data?)
public var text: String {
get {
switch self {
case let .sndMsgContent(mc): return mc.text
case let .rcvMsgContent(mc): return mc.text
case .sndDeleted: return NSLocalizedString("deleted", comment: "deleted chat item")
case .rcvDeleted: return NSLocalizedString("deleted", comment: "deleted chat item")
case let .sndCall(status, duration): return status.text(duration)
case let .rcvCall(status, duration): return status.text(duration)
case let .rcvIntegrityError(msgError): return msgError.text
case let .rcvDecryptionError(msgDecryptError, _): return msgDecryptError.text
case let .rcvGroupInvitation(groupInvitation, _): return groupInvitation.text
case let .sndGroupInvitation(groupInvitation, _): return groupInvitation.text
case let .rcvDirectEvent(rcvDirectEvent): return rcvDirectEvent.text
case let .rcvGroupEvent(rcvGroupEvent): return rcvGroupEvent.text
case let .sndGroupEvent(sndGroupEvent): return sndGroupEvent.text
case let .rcvConnEvent(rcvConnEvent): return rcvConnEvent.text
case let .sndConnEvent(sndConnEvent): return sndConnEvent.text
case let .rcvChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param)
case let .sndChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param)
case let .rcvChatPreference(feature, allowed, param): return CIContent.preferenceText(feature, allowed, param)
case let .sndChatPreference(feature, allowed, param): return CIContent.preferenceText(feature, allowed, param)
case let .rcvGroupFeature(feature, preference, param, role): return CIContent.featureText(feature, preference.enable.text, param, role)
case let .sndGroupFeature(feature, preference, param, role): return CIContent.featureText(feature, preference.enable.text, param, role)
case let .rcvChatFeatureRejected(feature): return String.localizedStringWithFormat("%@: received, prohibited", feature.text)
case let .rcvGroupFeatureRejected(groupFeature): return String.localizedStringWithFormat("%@: received, prohibited", groupFeature.text)
case .sndModerated: return NSLocalizedString("moderated", comment: "moderated chat item")
case .rcvModerated: return NSLocalizedString("moderated", comment: "moderated chat item")
case .rcvBlocked: return NSLocalizedString("blocked by admin", comment: "blocked chat item")
case let .sndDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo)
case let .rcvDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo)
case .sndGroupE2EEInfo: return e2eeInfoNoPQStr
case .rcvGroupE2EEInfo: return e2eeInfoNoPQStr
case .chatBanner: return ""
case .invalidJSON: return NSLocalizedString("invalid data", comment: "invalid chat item")
}
public var text: String { text(isChannel: false) }
public func text(isChannel: Bool) -> String {
switch self {
case let .sndMsgContent(mc): return mc.text
case let .rcvMsgContent(mc): return mc.text
case .sndDeleted: return NSLocalizedString("deleted", comment: "deleted chat item")
case .rcvDeleted: return NSLocalizedString("deleted", comment: "deleted chat item")
case let .sndCall(status, duration): return status.text(duration)
case let .rcvCall(status, duration): return status.text(duration)
case let .rcvIntegrityError(msgError): return msgError.text
case let .rcvDecryptionError(msgDecryptError, _): return msgDecryptError.text
case let .rcvGroupInvitation(groupInvitation, _): return groupInvitation.text
case let .sndGroupInvitation(groupInvitation, _): return groupInvitation.text
case let .rcvDirectEvent(rcvDirectEvent): return rcvDirectEvent.text
case let .rcvGroupEvent(rcvGroupEvent): return rcvGroupEvent.text(isChannel: isChannel)
case let .sndGroupEvent(sndGroupEvent): return sndGroupEvent.text(isChannel: isChannel)
case let .rcvConnEvent(rcvConnEvent): return rcvConnEvent.text
case let .sndConnEvent(sndConnEvent): return sndConnEvent.text
case let .rcvChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param)
case let .sndChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param)
case let .rcvChatPreference(feature, allowed, param): return CIContent.preferenceText(feature, allowed, param)
case let .sndChatPreference(feature, allowed, param): return CIContent.preferenceText(feature, allowed, param)
case let .rcvGroupFeature(feature, preference, param, role): return CIContent.featureText(feature, preference.enable.text, param, role)
case let .sndGroupFeature(feature, preference, param, role): return CIContent.featureText(feature, preference.enable.text, param, role)
case let .rcvChatFeatureRejected(feature): return String.localizedStringWithFormat("%@: received, prohibited", feature.text)
case let .rcvGroupFeatureRejected(groupFeature): return String.localizedStringWithFormat("%@: received, prohibited", groupFeature.text)
case .sndModerated: return NSLocalizedString("moderated", comment: "moderated chat item")
case .rcvModerated: return NSLocalizedString("moderated", comment: "moderated chat item")
case .rcvBlocked: return NSLocalizedString("blocked by admin", comment: "blocked chat item")
case let .sndDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo)
case let .rcvDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo)
case .sndGroupE2EEInfo: return e2eeInfoNoPQStr
case .rcvGroupE2EEInfo: return e2eeInfoNoPQStr
case .chatBanner: return ""
case .invalidJSON: return NSLocalizedString("invalid data", comment: "invalid chat item")
}
}
@@ -5153,7 +5159,9 @@ public enum RcvGroupEvent: Decodable, Hashable {
case memberProfileUpdated(fromProfile: Profile, toProfile: Profile)
case newMemberPendingReview
var text: String {
var text: String { text(isChannel: false) }
func text(isChannel: Bool) -> String {
switch self {
case let .memberAdded(_, profile):
return String.localizedStringWithFormat(NSLocalizedString("invited %@", comment: "rcv group event chat item"), profile.profileViewName)
@@ -5175,8 +5183,12 @@ public enum RcvGroupEvent: Decodable, Hashable {
case let .memberDeleted(_, profile):
return String.localizedStringWithFormat(NSLocalizedString("removed %@", comment: "rcv group event chat item"), profile.profileViewName)
case .userDeleted: return NSLocalizedString("removed you", comment: "rcv group event chat item")
case .groupDeleted: return NSLocalizedString("deleted group", comment: "rcv group event chat item")
case .groupUpdated: return NSLocalizedString("updated group profile", comment: "rcv group event chat item")
case .groupDeleted: return isChannel
? NSLocalizedString("deleted channel", comment: "rcv group event chat item")
: NSLocalizedString("deleted group", comment: "rcv group event chat item")
case .groupUpdated: return isChannel
? NSLocalizedString("updated channel profile", comment: "rcv group event chat item")
: NSLocalizedString("updated group profile", comment: "rcv group event chat item")
case .invitedViaGroupLink: return NSLocalizedString("invited via your group link", comment: "rcv group event chat item")
case .memberCreatedContact: return NSLocalizedString("requested connection", comment: "rcv group event chat item")
case let .memberProfileUpdated(fromProfile, toProfile): return profileUpdatedText(fromProfile, toProfile)
@@ -5208,7 +5220,9 @@ public enum SndGroupEvent: Decodable, Hashable {
case memberAccepted(groupMemberId: Int64, profile: Profile)
case userPendingReview
var text: String {
var text: String { text(isChannel: false) }
func text(isChannel: Bool) -> String {
switch self {
case let .memberRole(_, profile, role):
return String.localizedStringWithFormat(NSLocalizedString("you changed role of %@ to %@", comment: "snd group event chat item"), profile.profileViewName, role.text)
@@ -5223,7 +5237,9 @@ public enum SndGroupEvent: Decodable, Hashable {
case let .memberDeleted(_, profile):
return String.localizedStringWithFormat(NSLocalizedString("you removed %@", comment: "snd group event chat item"), profile.profileViewName)
case .userLeft: return NSLocalizedString("you left", comment: "snd group event chat item")
case .groupUpdated: return NSLocalizedString("group profile updated", comment: "snd group event chat item")
case .groupUpdated: return isChannel
? NSLocalizedString("channel profile updated", comment: "snd group event chat item")
: NSLocalizedString("group profile updated", comment: "snd group event chat item")
case .memberAccepted: return NSLocalizedString("you accepted this member", comment: "snd group event chat item")
case .userPendingReview:
return NSLocalizedString("Please wait for group moderators to review your request to join the group.", comment: "snd group event chat item")
+3 -3
View File
@@ -74,7 +74,7 @@ public func createMessageReceivedNtf(_ user: any UserLike, _ cInfo: ChatInfo, _
return createNotification(
categoryIdentifier: ntfCategoryMessageReceived,
title: title,
body: previewMode == .message ? hideSecrets(cItem) : NSLocalizedString("new message", comment: "notification"),
body: previewMode == .message ? hideSecrets(cItem, isChannel: cInfo.isChannel) : NSLocalizedString("new message", comment: "notification"),
targetContentIdentifier: cInfo.id,
userInfo: ["userId": user.userId],
// userInfo: ["chatId": cInfo.id, "chatItemId": cItem.id]
@@ -197,7 +197,7 @@ public func createNotification(
}
// Spec: spec/services/notifications.md#hideSecrets
func hideSecrets(_ cItem: ChatItem) -> String {
func hideSecrets(_ cItem: ChatItem, isChannel: Bool = false) -> String {
if let md = cItem.formattedText {
var res = ""
for ft in md {
@@ -213,7 +213,7 @@ func hideSecrets(_ cItem: ChatItem) -> String {
if case let .report(text, reason) = mc {
return String.localizedStringWithFormat(NSLocalizedString("Report: %@", comment: "report in notification"), text.isEmpty ? reason.text : text)
} else {
return cItem.text
return cItem.text(isChannel: isChannel)
}
}
}
@@ -1748,6 +1748,9 @@ sealed class ChatInfo: SomeChat, NamedChat {
is Group -> groupInfo
else -> null
}
val isChannel: Boolean
get() = groupInfo_?.useRelays == true
}
@Serializable
@@ -2891,12 +2894,14 @@ data class ChatItem (
val id: Long get() = meta.itemId
val timestampText: String get() = meta.timestampText
val text: String get() {
val text: String get() = text(isChannel = false)
fun text(isChannel: Boolean): String {
val mc = content.msgContent
return when {
content.text == "" && file != null && mc is MsgContent.MCVoice -> String.format(generalGetString(MR.strings.voice_message_with_duration), durationText(mc.duration))
content.text == "" && file != null -> file.fileName
else -> content.text
content.text(isChannel) == "" && file != null && mc is MsgContent.MCVoice -> String.format(generalGetString(MR.strings.voice_message_with_duration), durationText(mc.duration))
content.text(isChannel) == "" && file != null -> file.fileName
else -> content.text(isChannel)
}
}
@@ -3754,7 +3759,9 @@ sealed class CIContent: ItemContent {
@Serializable @SerialName("chatBanner") object ChatBanner: CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("invalidJSON") data class InvalidJSON(val json: String): CIContent() { override val msgContent: MsgContent? get() = null }
override val text: String get() = when (this) {
override val text: String get() = text(isChannel = false)
fun text(isChannel: Boolean): String = when (this) {
is SndMsgContent -> msgContent.text
is RcvMsgContent -> msgContent.text
is SndDeleted -> generalGetString(MR.strings.deleted_description)
@@ -3766,8 +3773,8 @@ sealed class CIContent: ItemContent {
is RcvGroupInvitation -> groupInvitation.text
is SndGroupInvitation -> groupInvitation.text
is RcvDirectEventContent -> rcvDirectEvent.text
is RcvGroupEventContent -> rcvGroupEvent.text
is SndGroupEventContent -> sndGroupEvent.text
is RcvGroupEventContent -> rcvGroupEvent.text(isChannel)
is SndGroupEventContent -> sndGroupEvent.text(isChannel)
is RcvConnEventContent -> rcvConnEvent.text
is SndConnEventContent -> sndConnEvent.text
is RcvChatFeature -> featureText(feature, enabled.text, param)
@@ -4764,7 +4771,9 @@ sealed class RcvGroupEvent() {
@Serializable @SerialName("memberProfileUpdated") class MemberProfileUpdated(val fromProfile: Profile, val toProfile: Profile): RcvGroupEvent()
@Serializable @SerialName("newMemberPendingReview") class NewMemberPendingReview(): RcvGroupEvent()
val text: String get() = when (this) {
val text: String get() = text(isChannel = false)
fun text(isChannel: Boolean): String = when (this) {
is MemberAdded -> String.format(generalGetString(MR.strings.rcv_group_event_member_added), profile.profileViewName)
is MemberConnected -> generalGetString(MR.strings.rcv_group_event_member_connected)
is MemberAccepted -> String.format(generalGetString(MR.strings.rcv_group_event_member_accepted), profile.profileViewName)
@@ -4779,8 +4788,8 @@ sealed class RcvGroupEvent() {
is UserRole -> String.format(generalGetString(MR.strings.rcv_group_event_changed_your_role), role.text)
is MemberDeleted -> String.format(generalGetString(MR.strings.rcv_group_event_member_deleted), profile.profileViewName)
is UserDeleted -> generalGetString(MR.strings.rcv_group_event_user_deleted)
is GroupDeleted -> generalGetString(MR.strings.rcv_group_event_group_deleted)
is GroupUpdated -> generalGetString(MR.strings.rcv_group_event_updated_group_profile)
is GroupDeleted -> generalGetString(if (isChannel) MR.strings.rcv_channel_event_channel_deleted else MR.strings.rcv_group_event_group_deleted)
is GroupUpdated -> generalGetString(if (isChannel) MR.strings.rcv_channel_event_updated_channel_profile else MR.strings.rcv_group_event_updated_group_profile)
is InvitedViaGroupLink -> generalGetString(MR.strings.rcv_group_event_invited_via_your_group_link)
is MemberCreatedContact -> generalGetString(MR.strings.rcv_group_event_member_created_contact)
is MemberProfileUpdated -> profileUpdatedText(fromProfile, toProfile)
@@ -4812,7 +4821,9 @@ sealed class SndGroupEvent() {
@Serializable @SerialName("memberAccepted") class MemberAccepted(val groupMemberId: Long, val profile: Profile): SndGroupEvent()
@Serializable @SerialName("userPendingReview") class UserPendingReview(): SndGroupEvent()
val text: String get() = when (this) {
val text: String get() = text(isChannel = false)
fun text(isChannel: Boolean): String = when (this) {
is MemberRole -> String.format(generalGetString(MR.strings.snd_group_event_changed_member_role), profile.profileViewName, role.text)
is UserRole -> String.format(generalGetString(MR.strings.snd_group_event_changed_role_for_yourself), role.text)
is MemberBlocked -> if (blocked) {
@@ -4822,7 +4833,7 @@ sealed class SndGroupEvent() {
}
is MemberDeleted -> String.format(generalGetString(MR.strings.snd_group_event_member_deleted), profile.profileViewName)
is UserLeft -> generalGetString(MR.strings.snd_group_event_user_left)
is GroupUpdated -> generalGetString(MR.strings.snd_group_event_group_profile_updated)
is GroupUpdated -> generalGetString(if (isChannel) MR.strings.snd_channel_event_channel_profile_updated else MR.strings.snd_group_event_group_profile_updated)
is MemberAccepted -> generalGetString(MR.strings.snd_group_event_member_accepted)
is UserPendingReview -> generalGetString(MR.strings.snd_group_event_user_pending_review)
}
@@ -44,7 +44,7 @@ abstract class NtfManager {
chatModel.chatId.value != cInfo.id ||
chatModel.remoteHostId() != rhId)
) {
displayNotification(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
displayNotification(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem, cInfo.isChannel))
}
}
@@ -119,7 +119,7 @@ abstract class NtfManager {
}
}
private fun hideSecrets(cItem: ChatItem): String {
private fun hideSecrets(cItem: ChatItem, isChannel: Boolean = false): String {
val md = cItem.formattedText
return if (md != null) {
var res = ""
@@ -130,9 +130,9 @@ abstract class NtfManager {
} else {
val mc = cItem.content.msgContent
if (mc is MsgContent.MCReport) {
generalGetString(MR.strings.notification_group_report).format(cItem.text.ifEmpty { mc.reason.text })
generalGetString(MR.strings.notification_group_report).format(cItem.text(isChannel).ifEmpty { mc.reason.text })
} else {
cItem.text
cItem.text(isChannel)
}
}
}
@@ -48,8 +48,8 @@ private val msgTailMaxHeightDp = msgTailWidthDp * 1.732f // 60deg
val chatEventStyle = SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = CurrentColors.value.colors.secondary)
fun chatEventText(ci: ChatItem): AnnotatedString =
chatEventText(ci.content.text, ci.timestampText)
fun chatEventText(ci: ChatItem, isChannel: Boolean = false): AnnotatedString =
chatEventText(ci.content.text(isChannel), ci.timestampText)
fun chatEventText(eventText: String, ts: String): AnnotatedString =
buildAnnotatedString {
@@ -612,7 +612,7 @@ fun ChatItemView(
return if (count <= 1) {
null
} else if (ns.isEmpty()) {
generalGetString(MR.strings.rcv_group_events_count).format(count)
generalGetString(if (cInfo.isChannel) MR.strings.rcv_channel_events_count else MR.strings.rcv_group_events_count).format(count)
} else if (count > ns.size) {
members + " " + generalGetString(MR.strings.rcv_group_and_other_events).format(count - ns.size)
} else {
@@ -629,9 +629,9 @@ fun ChatItemView(
buildAnnotatedString {
withStyle(chatEventStyle) { append(memberDisplayName) }
append(" ")
}.plus(chatEventText(cItem))
}.plus(chatEventText(cItem, cInfo.isChannel))
} else {
chatEventText(cItem)
chatEventText(cItem, cInfo.isChannel)
}
}
@@ -643,7 +643,7 @@ fun ChatItemView(
@Composable fun PendingReviewEventItemView() {
Text(
buildAnnotatedString {
withStyle(chatEventStyle.copy(fontWeight = FontWeight.Bold)) { append(cItem.content.text) }
withStyle(chatEventStyle.copy(fontWeight = FontWeight.Bold)) { append(cItem.content.text(cInfo.isChannel)) }
},
Modifier.padding(horizontal = 6.dp, vertical = 6.dp)
)
@@ -241,7 +241,7 @@ fun ChatPreviewView(
Text(previewText.first, color = previewText.second)
} else if (ci != null && showChatPreviews) {
val (text: CharSequence, inlineTextContent) = when {
ci.meta.itemDeleted == null -> ci.text to null
ci.meta.itemDeleted == null -> ci.text(chat.chatInfo.isChannel) to null
else -> markedDeletedText(ci, chat.chatInfo) to null
}
val formattedText = when {
@@ -1707,7 +1707,9 @@
<string name="rcv_group_event_member_deleted">removed %1$s</string>
<string name="rcv_group_event_user_deleted">removed you</string>
<string name="rcv_group_event_group_deleted">deleted group</string>
<string name="rcv_channel_event_channel_deleted">deleted channel</string>
<string name="rcv_group_event_updated_group_profile">updated group profile</string>
<string name="rcv_channel_event_updated_channel_profile">updated channel profile</string>
<string name="rcv_group_event_invited_via_your_group_link">invited via your group link</string>
<string name="rcv_group_event_member_created_contact">requested connection</string>
<string name="rcv_group_event_new_member_pending_review">New member wants to join the group.</string>
@@ -1718,6 +1720,7 @@
<string name="snd_group_event_member_deleted">you removed %1$s</string>
<string name="snd_group_event_user_left">you left</string>
<string name="snd_group_event_group_profile_updated">group profile updated</string>
<string name="snd_channel_event_channel_profile_updated">channel profile updated</string>
<string name="snd_group_event_member_accepted">you accepted this member</string>
<string name="snd_group_event_user_pending_review">Please wait for group moderators to review your request to join the group.</string>
@@ -1726,6 +1729,7 @@
<string name="rcv_group_event_3_members_connected">%s, %s and %s connected</string>
<string name="rcv_group_event_n_members_connected">%s, %s and %d other members connected</string>
<string name="rcv_group_events_count">%d group events</string>
<string name="rcv_channel_events_count">%d channel events</string>
<string name="rcv_group_and_other_events">and %d other events</string>
<string name="group_members_2">%s and %s</string>
<string name="group_members_n">%s, %s and %d members</string>