Files
bundesmessenger-ios/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m
Doug 4ef6fecfbc Only use the sender when rendering the room creation event. (#7640)
On Room v11 it is possible that the sender could put in a fake creator. Don't allow this to happen.
2023-08-09 18:18:33 +01:00

2497 lines
97 KiB
Objective-C

/*
Copyright 2015 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
#import "MXKEventFormatter.h"
@import MatrixSDK;
@import DTCoreText;
#import "MXEvent+MatrixKit.h"
#import "NSBundle+MatrixKit.h"
#import "MXKSwiftHeader.h"
#import "MXKTools.h"
#import "MXRoom+Sync.h"
#import "MXKRoomNameStringLocalizer.h"
#import "GeneratedInterface-Swift.h"
static NSString *const kHTMLATagRegexPattern = @"<a href=(?:'|\")(.*?)(?:'|\")>([^<]*)</a>";
static NSString *const kRepliedTextPattern = @"<mx-reply>.*<blockquote>.*<br>(.*)</blockquote></mx-reply>";
@interface MXKEventFormatter ()
{
/**
The default CSS converted in DTCoreText object.
*/
DTCSSStylesheet *dtCSS;
/**
Links detector in strings.
*/
NSDataDetector *linkDetector;
}
@end
@implementation MXKEventFormatter
- (instancetype)initWithMatrixSession:(MXSession *)matrixSession
{
self = [super init];
if (self)
{
mxSession = matrixSession;
[self initDateTimeFormatters];
// Use the same list as matrix-react-sdk ( https://github.com/matrix-org/matrix-react-sdk/blob/24223ae2b69debb33fa22fcda5aeba6fa93c93eb/src/HtmlUtils.js#L25 )
_allowedHTMLTags = @[
@"font", // custom to matrix for IRC-style font coloring
@"del", // for markdown
@"body", // added internally by DTCoreText
@"mx-reply",
@"h1", @"h2", @"h3", @"h4", @"h5", @"h6", @"blockquote", @"p", @"a", @"ul", @"ol",
@"nl", @"li", @"b", @"i", @"u", @"strong", @"em", @"strike", @"code", @"hr", @"br", @"div",
@"table", @"thead", @"caption", @"tbody", @"tr", @"th", @"td", @"pre"
];
self.defaultCSS = @" \
pre,code { \
background-color: #eeeeee; \
display: inline; \
font-family: monospace; \
white-space: pre; \
-coretext-fontname: Menlo-Regular; \
font-size: small; \
} \
h1,h2 { \
font-size: 1.2em; \
}"; // match the size of h1/h2 to h3 to stop people shouting.
// Set default colors
_defaultTextColor = [UIColor blackColor];
_subTitleTextColor = [UIColor blackColor];
_prefixTextColor = [UIColor blackColor];
_bingTextColor = [UIColor blueColor];
_encryptingTextColor = [UIColor lightGrayColor];
_sendingTextColor = [UIColor lightGrayColor];
_errorTextColor = [UIColor redColor];
_linksColor = [UIColor linkColor];
_htmlBlockquoteBorderColor = [MXKTools colorWithRGBValue:0xDDDDDD];
_defaultTextFont = [UIFont systemFontOfSize:14];
_prefixTextFont = [UIFont systemFontOfSize:14];
_bingTextFont = [UIFont systemFontOfSize:14];
_stateEventTextFont = [UIFont italicSystemFontOfSize:14];
_callNoticesTextFont = [UIFont italicSystemFontOfSize:14];
_encryptedMessagesTextFont = [UIFont italicSystemFontOfSize:14];
_eventTypesFilterForMessages = nil;
// Consider the shared app settings by default
_settings = [MXKAppSettings standardAppSettings];
defaultRoomSummaryUpdater = [MXRoomSummaryUpdater roomSummaryUpdaterForSession:matrixSession];
defaultRoomSummaryUpdater.lastMessageEventTypesAllowList = MXKAppSettings.standardAppSettings.lastMessageEventTypesAllowList;
defaultRoomSummaryUpdater.ignoreRedactedEvent = !_settings.showRedactionsInRoomHistory;
defaultRoomSummaryUpdater.roomNameStringLocalizer = [MXKRoomNameStringLocalizer new];
linkDetector = [NSDataDetector dataDetectorWithTypes:NSTextCheckingTypeLink error:nil];
_markdownToHTMLRenderer = [MarkdownToHTMLRendererHardBreaks new];
}
return self;
}
- (void)initDateTimeFormatters
{
// Prepare internal date formatter
dateFormatter = [[NSDateFormatter alloc] init];
[dateFormatter setLocale:[[NSLocale alloc] initWithLocaleIdentifier:[[[NSBundle mainBundle] preferredLocalizations] objectAtIndex:0]]];
[dateFormatter setFormatterBehavior:NSDateFormatterBehavior10_4];
// Set default date format
[dateFormatter setDateFormat:@"MMM dd"];
// Create a time formatter to get time string by considered the current system time formatting.
timeFormatter = [[NSDateFormatter alloc] init];
[timeFormatter setDateStyle:NSDateFormatterNoStyle];
[timeFormatter setTimeStyle:NSDateFormatterShortStyle];
}
#pragma mark - Event formatter settings
// Checks whether the event is related to an attachment and if it is supported
- (BOOL)isSupportedAttachment:(MXEvent*)event
{
BOOL isSupportedAttachment = NO;
if (event.eventType == MXEventTypeRoomMessage)
{
NSString *msgtype;
MXJSONModelSetString(msgtype, event.content[@"msgtype"]);
NSString *urlField;
NSDictionary *fileField;
MXJSONModelSetString(urlField, event.content[@"url"]);
MXJSONModelSetDictionary(fileField, event.content[@"file"]);
BOOL hasUrl = urlField.length;
BOOL hasFile = NO;
if (fileField)
{
NSString *fileUrlField;
MXJSONModelSetString(fileUrlField, fileField[@"url"]);
NSString *fileIvField;
MXJSONModelSetString(fileIvField, fileField[@"iv"]);
NSDictionary *fileHashesField;
MXJSONModelSetDictionary(fileHashesField, fileField[@"hashes"]);
NSDictionary *fileKeyField;
MXJSONModelSetDictionary(fileKeyField, fileField[@"key"]);
hasFile = fileUrlField.length && fileIvField.length && fileHashesField && fileKeyField;
}
if ([msgtype isEqualToString:kMXMessageTypeImage])
{
isSupportedAttachment = hasUrl || hasFile;
}
else if ([msgtype isEqualToString:kMXMessageTypeAudio])
{
isSupportedAttachment = hasUrl || hasFile;
}
else if ([msgtype isEqualToString:kMXMessageTypeVideo])
{
isSupportedAttachment = hasUrl || hasFile;
}
else if ([msgtype isEqualToString:kMXMessageTypeFile])
{
isSupportedAttachment = hasUrl || hasFile;
}
}
else if (event.eventType == MXEventTypeSticker)
{
NSString *urlField;
NSDictionary *fileField;
MXJSONModelSetString(urlField, event.content[@"url"]);
MXJSONModelSetDictionary(fileField, event.content[@"file"]);
BOOL hasUrl = urlField.length;
BOOL hasFile = NO;
// @TODO: Check whether the encrypted sticker uses the same `file dict than other media
if (fileField)
{
NSString *fileUrlField;
MXJSONModelSetString(fileUrlField, fileField[@"url"]);
NSString *fileIvField;
MXJSONModelSetString(fileIvField, fileField[@"iv"]);
NSDictionary *fileHashesField;
MXJSONModelSetDictionary(fileHashesField, fileField[@"hashes"]);
NSDictionary *fileKeyField;
MXJSONModelSetDictionary(fileKeyField, fileField[@"key"]);
hasFile = fileUrlField.length && fileIvField.length && fileHashesField && fileKeyField;
}
isSupportedAttachment = hasUrl || hasFile;
}
return isSupportedAttachment;
}
#pragma mark event sender/target info
- (NSString*)senderDisplayNameForEvent:(MXEvent*)event withRoomState:(MXRoomState*)roomState
{
// Check whether the sender name is updated by the current event. This happens in case of a
// newly joined member. Otherwise, fall back to the current display name defined in the provided
// room state (note: this room state is supposed to not take the new event into account).
return [self userDisplayNameFromContentInEvent:event withMembershipFilter:@"join"] ?: [roomState.members memberName:event.sender];
}
- (NSString*)targetDisplayNameForEvent:(MXEvent*)event withRoomState:(MXRoomState*)roomState
{
if (![event.type isEqualToString:kMXEventTypeStringRoomMember])
{
return nil; // Non-membership events don't have a target
}
return [self userDisplayNameFromContentInEvent:event withMembershipFilter:nil] ?: [roomState.members memberName:event.stateKey];
}
- (NSString*)userDisplayNameFromContentInEvent:(MXEvent*)event withMembershipFilter:(NSString *)filter
{
NSString* membership;
MXJSONModelSetString(membership, event.content[@"membership"]);
NSString* displayname;
MXJSONModelSetString(displayname, event.content[@"displayname"]);
if (membership && (!filter || [membership isEqualToString:filter]) && [displayname length])
{
return displayname;
}
return nil;
}
- (NSString*)senderAvatarUrlForEvent:(MXEvent*)event withRoomState:(MXRoomState*)roomState
{
// Check whether the avatar URL is updated by the current event. This happens in case of a
// newly joined member. Otherwise, fall back to the avatar URL defined in the provided room
// state (note: this room state is supposed to not take the new event into account).
NSString *avatarUrl = [self userAvatarUrlFromContentInEvent:event withMembershipFilter:@"join"] ?: [roomState.members memberWithUserId:event.sender].avatarUrl;
// Handle here the case where no avatar is defined
return avatarUrl ?: [self fallbackAvatarUrlForUserId:event.sender];
}
- (NSString*)targetAvatarUrlForEvent:(MXEvent*)event withRoomState:(MXRoomState*)roomState
{
if (![event.type isEqualToString:kMXEventTypeStringRoomMember])
{
return nil; // Non-membership events don't have a target
}
NSString *avatarUrl = [self userAvatarUrlFromContentInEvent:event withMembershipFilter:nil] ?: [roomState.members memberWithUserId:event.stateKey].avatarUrl;
return avatarUrl ?: [self fallbackAvatarUrlForUserId:event.stateKey];
}
- (NSString*)userAvatarUrlFromContentInEvent:(MXEvent*)event withMembershipFilter:(NSString *)filter
{
NSString* membership;
MXJSONModelSetString(membership, event.content[@"membership"]);
NSString* avatarUrl;
MXJSONModelSetString(avatarUrl, event.content[@"avatar_url"]);
if (membership && (!filter || [membership isEqualToString:filter]) && [avatarUrl length])
{
// We ignore non mxc avatar url
if ([avatarUrl hasPrefix:kMXContentUriScheme])
{
return avatarUrl;
}
}
return nil;
}
- (NSString*)fallbackAvatarUrlForUserId:(NSString*)userId {
if ([MXSDKOptions sharedInstance].disableIdenticonUseForUserAvatar)
{
return nil;
}
return [mxSession.mediaManager urlOfIdenticon:userId];
}
#pragma mark - Events to strings conversion methods
- (NSString*)stringFromEvent:(MXEvent*)event
withRoomState:(MXRoomState*)roomState
andLatestRoomState:(MXRoomState*)latestRoomState
error:(MXKEventFormatterError*)error
{
NSString *stringFromEvent;
NSAttributedString *attributedStringFromEvent = [self attributedStringFromEvent:event
withRoomState:roomState
andLatestRoomState:latestRoomState
error:error];
if (*error == MXKEventFormatterErrorNone)
{
stringFromEvent = attributedStringFromEvent.string;
}
return stringFromEvent;
}
- (NSAttributedString *)attributedStringFromEvent:(MXEvent*)event
withRoomState:(MXRoomState*)roomState
andLatestRoomState:(MXRoomState*)latestRoomState
error:(MXKEventFormatterError *)error
{
// Check we can output the error
NSParameterAssert(error);
*error = MXKEventFormatterErrorNone;
// Filter the events according to their type.
if (_eventTypesFilterForMessages && ([_eventTypesFilterForMessages indexOfObject:event.type] == NSNotFound))
{
// Ignore this event
return nil;
}
BOOL isEventSenderMyUser = [event.sender isEqualToString:mxSession.myUserId];
// Check first whether the event has been redacted
NSString *redactedInfo = nil;
BOOL isRedacted = event.isRedactedEvent;
if (isRedacted)
{
// Check whether the event is a thread root or redacted information is required
if ((RiotSettings.shared.enableThreads && [mxSession.threadingService isEventThreadRoot:event])
|| _settings.showRedactionsInRoomHistory)
{
MXLogDebug(@"[MXKEventFormatter] Redacted event %@ (%@)", event.eventId, event.redactedBecause);
NSString *redactorId = event.redactedBecause[@"sender"];
NSString *redactedBy = @"";
// Consider live room state to resolve redactor name if no roomState is provided
MXRoomState *aRoomState = roomState ? roomState : [mxSession roomWithRoomId:event.roomId].dangerousSyncState;
redactedBy = [aRoomState.members memberName:redactorId];
NSString *redactedReason = (event.redactedBecause[@"content"])[@"reason"];
if (redactedReason.length)
{
if ([redactorId isEqualToString:mxSession.myUserId])
{
redactedBy = [NSString stringWithFormat:@"%@%@", [VectorL10n noticeEventRedactedByYou], [VectorL10n noticeEventRedactedReason:redactedReason]];
}
else if (redactedBy.length)
{
redactedBy = [NSString stringWithFormat:@"%@%@", [VectorL10n noticeEventRedactedBy:redactedBy], [VectorL10n noticeEventRedactedReason:redactedReason]];
}
else
{
redactedBy = [VectorL10n noticeEventRedactedReason:redactedReason];
}
}
else if ([redactorId isEqualToString:mxSession.myUserId])
{
redactedBy = [VectorL10n noticeEventRedactedByYou];
}
else if (redactedBy.length)
{
redactedBy = [VectorL10n noticeEventRedactedBy:redactedBy];
}
redactedInfo = [VectorL10n noticeEventRedacted:redactedBy];
}
}
// Prepare returned description
NSString *displayText = nil;
NSAttributedString *attributedDisplayText = nil;
BOOL isRoomDirect = [mxSession roomWithRoomId:event.roomId].isDirect;
// Prepare the display name of the sender
NSString *senderDisplayName;
senderDisplayName = roomState ? [self senderDisplayNameForEvent:event withRoomState:roomState] : event.sender;
switch (event.eventType)
{
case MXEventTypeRoomName:
{
NSString *roomName;
MXJSONModelSetString(roomName, event.content[@"name"]);
if (isRedacted)
{
if (!redactedInfo)
{
// Here the event is ignored (no display)
return nil;
}
roomName = redactedInfo;
}
if (roomName.length)
{
if (isEventSenderMyUser)
{
if (isRoomDirect)
{
displayText = [VectorL10n noticeRoomNameChangedByYouForDm:roomName];
}
else
{
displayText = [VectorL10n noticeRoomNameChangedByYou:roomName];
}
}
else
{
if (isRoomDirect)
{
displayText = [VectorL10n noticeRoomNameChangedForDm:senderDisplayName :roomName];
}
else
{
displayText = [VectorL10n noticeRoomNameChanged:senderDisplayName :roomName];
}
}
}
else
{
if (isEventSenderMyUser)
{
if (isRoomDirect)
{
displayText = [VectorL10n noticeRoomNameRemovedByYouForDm];
}
else
{
displayText = [VectorL10n noticeRoomNameRemovedByYou];
}
}
else
{
if (isRoomDirect)
{
displayText = [VectorL10n noticeRoomNameRemovedForDm:senderDisplayName];
}
else
{
displayText = [VectorL10n noticeRoomNameRemoved:senderDisplayName];
}
}
}
break;
}
case MXEventTypeRoomTopic:
{
NSString *roomTopic;
MXJSONModelSetString(roomTopic, event.content[@"topic"]);
if (isRedacted)
{
if (!redactedInfo)
{
// Here the event is ignored (no display)
return nil;
}
roomTopic = redactedInfo;
}
if (roomTopic.length)
{
if (isEventSenderMyUser)
{
displayText = [VectorL10n noticeTopicChangedByYou:roomTopic];
}
else
{
displayText = [VectorL10n noticeTopicChanged:senderDisplayName :roomTopic];
}
}
else
{
if (isEventSenderMyUser)
{
displayText = [VectorL10n noticeRoomTopicRemovedByYou];
}
else
{
displayText = [VectorL10n noticeRoomTopicRemoved:senderDisplayName];
}
}
break;
}
case MXEventTypeRoomMember:
{
// Presently only change on membership, display name and avatar are supported
// Check whether the sender has updated his profile
if (event.isUserProfileChange)
{
// Is redacted event?
if (isRedacted)
{
if (!redactedInfo)
{
// Here the event is ignored (no display)
return nil;
}
if (isEventSenderMyUser)
{
displayText = [VectorL10n noticeProfileChangeRedactedByYou:redactedInfo];
}
else
{
displayText = [VectorL10n noticeProfileChangeRedacted:senderDisplayName :redactedInfo];
}
}
else
{
// Check whether the display name has been changed
NSString *displayname;
MXJSONModelSetString(displayname, event.content[@"displayname"]);
NSString *prevDisplayname;
MXJSONModelSetString(prevDisplayname, event.prevContent[@"displayname"]);
if (!displayname.length)
{
displayname = nil;
}
if (!prevDisplayname.length)
{
prevDisplayname = nil;
}
if ((displayname || prevDisplayname) && ([displayname isEqualToString:prevDisplayname] == NO))
{
if (!prevDisplayname)
{
if (isEventSenderMyUser)
{
displayText = [VectorL10n noticeDisplayNameSetByYou:displayname];
}
else
{
displayText = [VectorL10n noticeDisplayNameSet:event.sender :displayname];
}
}
else if (!displayname)
{
if (isEventSenderMyUser)
{
displayText = [VectorL10n noticeDisplayNameRemovedByYou];
}
else
{
displayText = [VectorL10n noticeDisplayNameRemoved:event.sender];
}
}
else
{
if (isEventSenderMyUser)
{
displayText = [VectorL10n noticeDisplayNameChangedFromByYou:prevDisplayname :displayname];
}
else
{
displayText = [VectorL10n noticeDisplayNameChangedTo:prevDisplayname :displayname];
}
}
}
// Check whether the avatar has been changed
NSString *avatar;
MXJSONModelSetString(avatar, event.content[@"avatar_url"]);
NSString *prevAvatar;
MXJSONModelSetString(prevAvatar, event.prevContent[@"avatar_url"]);
if (!avatar.length)
{
avatar = nil;
}
if (!prevAvatar.length)
{
prevAvatar = nil;
}
if ((prevAvatar || avatar) && ([avatar isEqualToString:prevAvatar] == NO))
{
if (displayText)
{
displayText = [NSString stringWithFormat:@"%@ %@", displayText, [VectorL10n noticeAvatarChangedToo]];
}
else
{
if (isEventSenderMyUser)
{
displayText = [VectorL10n noticeAvatarUrlChangedByYou];
}
else
{
displayText = [VectorL10n noticeAvatarUrlChanged:senderDisplayName];
}
}
}
}
}
else
{
// Retrieve membership
NSString* membership;
MXJSONModelSetString(membership, event.content[@"membership"]);
// Prepare targeted member display name
NSString *targetDisplayName = event.stateKey;
// Retrieve content displayname
NSString *contentDisplayname;
MXJSONModelSetString(contentDisplayname, event.content[@"displayname"]);
NSString *prevContentDisplayname;
MXJSONModelSetString(prevContentDisplayname, event.prevContent[@"displayname"]);
// Consider here a membership change
if ([membership isEqualToString:@"invite"])
{
if (event.content[@"third_party_invite"])
{
if ([event.stateKey isEqualToString:mxSession.myUserId])
{
displayText = [VectorL10n noticeRoomThirdPartyRegisteredInviteByYou:event.content[@"third_party_invite"][@"display_name"]];
}
else
{
displayText = [VectorL10n noticeRoomThirdPartyRegisteredInvite:targetDisplayName :event.content[@"third_party_invite"][@"display_name"]];
}
}
else
{
if ([MXCallManager isConferenceUser:event.stateKey])
{
if (isEventSenderMyUser)
{
displayText = [VectorL10n noticeConferenceCallRequestByYou];
}
else
{
displayText = [VectorL10n noticeConferenceCallRequest:senderDisplayName];
}
}
else
{
// The targeted member display name (if any) is available in content
if (isEventSenderMyUser)
{
displayText = [VectorL10n noticeRoomInviteByYou:targetDisplayName];
}
else if ([targetDisplayName isEqualToString:mxSession.myUserId])
{
displayText = [VectorL10n noticeRoomInviteYou:senderDisplayName];
}
else
{
if (contentDisplayname.length)
{
targetDisplayName = contentDisplayname;
}
displayText = [VectorL10n noticeRoomInvite:senderDisplayName :targetDisplayName];
}
}
}
}
else if ([membership isEqualToString:@"join"])
{
if ([MXCallManager isConferenceUser:event.stateKey])
{
displayText = [VectorL10n noticeConferenceCallStarted];
}
else
{
// The targeted member display name (if any) is available in content
if (isEventSenderMyUser)
{
displayText = [VectorL10n noticeRoomJoinByYou];
}
else
{
if (contentDisplayname.length)
{
targetDisplayName = contentDisplayname;
}
displayText = [VectorL10n noticeRoomJoin:targetDisplayName];
}
}
}
else if ([membership isEqualToString:@"leave"])
{
NSString *prevMembership = nil;
if (event.prevContent)
{
MXJSONModelSetString(prevMembership, event.prevContent[@"membership"]);
}
// The targeted member display name (if any) is available in prevContent
if (prevContentDisplayname.length)
{
targetDisplayName = prevContentDisplayname;
}
if ([event.sender isEqualToString:event.stateKey])
{
if ([MXCallManager isConferenceUser:event.stateKey])
{
displayText = [VectorL10n noticeConferenceCallFinished];
}
else
{
if (prevMembership && [prevMembership isEqualToString:@"invite"])
{
if (isEventSenderMyUser)
{
displayText = [VectorL10n noticeRoomRejectByYou];
}
else
{
displayText = [VectorL10n noticeRoomReject:targetDisplayName];
}
}
else
{
if (isEventSenderMyUser)
{
displayText = [VectorL10n noticeRoomLeaveByYou];
}
else
{
displayText = [VectorL10n noticeRoomLeave:targetDisplayName];
}
}
}
}
else if (prevMembership)
{
if ([prevMembership isEqualToString:@"invite"])
{
if (isEventSenderMyUser)
{
displayText = [VectorL10n noticeRoomWithdrawByYou:targetDisplayName];
}
else
{
displayText = [VectorL10n noticeRoomWithdraw:senderDisplayName :targetDisplayName];
}
if (event.content[@"reason"])
{
displayText = [displayText stringByAppendingString:[VectorL10n noticeRoomReason:event.content[@"reason"]]];
}
}
else if ([prevMembership isEqualToString:@"join"])
{
if (isEventSenderMyUser)
{
displayText = [VectorL10n noticeRoomKickByYou:targetDisplayName];
}
else
{
displayText = [VectorL10n noticeRoomKick:senderDisplayName :targetDisplayName];
}
// add reason if exists
if (event.content[@"reason"])
{
displayText = [displayText stringByAppendingString:[VectorL10n noticeRoomReason:event.content[@"reason"]]];
}
}
else if ([prevMembership isEqualToString:@"ban"])
{
if (isEventSenderMyUser)
{
displayText = [VectorL10n noticeRoomUnbanByYou:targetDisplayName];
}
else
{
displayText = [VectorL10n noticeRoomUnban:senderDisplayName :targetDisplayName];
}
}
}
}
else if ([membership isEqualToString:@"ban"])
{
// The targeted member display name (if any) is available in prevContent
if (prevContentDisplayname.length)
{
targetDisplayName = prevContentDisplayname;
}
if (isEventSenderMyUser)
{
displayText = [VectorL10n noticeRoomBanByYou:targetDisplayName];
}
else
{
displayText = [VectorL10n noticeRoomBan:senderDisplayName :targetDisplayName];
}
if (event.content[@"reason"])
{
displayText = [displayText stringByAppendingString:[VectorL10n noticeRoomReason:event.content[@"reason"]]];
}
}
// Append redacted info if any
if (redactedInfo)
{
displayText = [NSString stringWithFormat:@"%@ %@", displayText, redactedInfo];
}
}
if (!displayText)
{
*error = MXKEventFormatterErrorUnexpected;
}
break;
}
case MXEventTypeRoomCreate:
{
// Room version 11 removes `creator` in favour of `sender`.
// https://github.com/matrix-org/matrix-spec-proposals/pull/2175
// Just use the sender as it is possible to create a v11 room and spoof the `creator`.
NSString *creatorId = event.sender;
if ([creatorId isEqualToString:mxSession.myUserId])
{
if (isRoomDirect)
{
displayText = [VectorL10n noticeRoomCreatedByYouForDm];
}
else
{
displayText = [VectorL10n noticeRoomCreatedByYou];
}
}
else
{
if (isRoomDirect)
{
displayText = [VectorL10n noticeRoomCreatedForDm:(roomState ? [roomState.members memberName:creatorId] : creatorId)];
}
else
{
displayText = [VectorL10n noticeRoomCreated:(roomState ? [roomState.members memberName:creatorId] : creatorId)];
}
}
// Append redacted info if any
if (redactedInfo)
{
displayText = [NSString stringWithFormat:@"%@ %@", displayText, redactedInfo];
}
break;
}
case MXEventTypeRoomJoinRules:
{
NSString *joinRule;
MXJSONModelSetString(joinRule, event.content[@"join_rule"]);
if (joinRule)
{
if ([event.sender isEqualToString:mxSession.myUserId])
{
if ([joinRule isEqualToString:kMXRoomJoinRulePublic])
{
if (isRoomDirect)
{
displayText = [VectorL10n noticeRoomJoinRulePublicByYouForDm];
}
else
{
displayText = [VectorL10n noticeRoomJoinRulePublicByYou];
}
}
else if ([joinRule isEqualToString:kMXRoomJoinRuleInvite])
{
if (isRoomDirect)
{
displayText = [VectorL10n noticeRoomJoinRuleInviteByYouForDm];
}
else
{
displayText = [VectorL10n noticeRoomJoinRuleInviteByYou];
}
}
}
else
{
NSString *displayName = roomState ? [roomState.members memberName:event.sender] : event.sender;
if ([joinRule isEqualToString:kMXRoomJoinRulePublic])
{
if (isRoomDirect)
{
displayText = [VectorL10n noticeRoomJoinRulePublicForDm:displayName];
}
else
{
displayText = [VectorL10n noticeRoomJoinRulePublic:displayName];
}
}
else if ([joinRule isEqualToString:kMXRoomJoinRuleInvite])
{
if (isRoomDirect)
{
displayText = [VectorL10n noticeRoomJoinRuleInviteForDm:displayName];
}
else
{
displayText = [VectorL10n noticeRoomJoinRuleInvite:displayName];
}
}
}
if (!displayText)
{
// use old string for non-handled cases: "knock" and "private"
displayText = [VectorL10n noticeRoomJoinRule:joinRule];
}
// Append redacted info if any
if (redactedInfo)
{
displayText = [NSString stringWithFormat:@"%@ %@", displayText, redactedInfo];
}
}
break;
}
case MXEventTypeRoomPowerLevels:
{
if (isRoomDirect)
{
displayText = [VectorL10n noticeRoomPowerLevelIntroForDm];
}
else
{
displayText = [VectorL10n noticeRoomPowerLevelIntro];
}
NSDictionary *users;
MXJSONModelSetDictionary(users, event.content[@"users"]);
for (NSString *key in users.allKeys)
{
displayText = [NSString stringWithFormat:@"%@\n\u2022 %@: %@", displayText, key, [users objectForKey:key]];
}
if (event.content[@"users_default"])
{
displayText = [NSString stringWithFormat:@"%@\n\u2022 %@: %@", displayText, [VectorL10n default], event.content[@"users_default"]];
}
displayText = [NSString stringWithFormat:@"%@\n%@", displayText, [VectorL10n noticeRoomPowerLevelActingRequirement]];
if (event.content[@"ban"])
{
displayText = [NSString stringWithFormat:@"%@\n\u2022 ban: %@", displayText, event.content[@"ban"]];
}
if (event.content[@"kick"])
{
displayText = [NSString stringWithFormat:@"%@\n\u2022 remove: %@", displayText, event.content[@"kick"]];
}
if (event.content[@"redact"])
{
displayText = [NSString stringWithFormat:@"%@\n\u2022 redact: %@", displayText, event.content[@"redact"]];
}
if (event.content[@"invite"])
{
displayText = [NSString stringWithFormat:@"%@\n\u2022 invite: %@", displayText, event.content[@"invite"]];
}
displayText = [NSString stringWithFormat:@"%@\n%@", displayText, [VectorL10n noticeRoomPowerLevelEventRequirement]];
NSDictionary *events;
MXJSONModelSetDictionary(events, event.content[@"events"]);
for (NSString *key in events.allKeys)
{
displayText = [NSString stringWithFormat:@"%@\n\u2022 %@: %@", displayText, key, [events objectForKey:key]];
}
if (event.content[@"events_default"])
{
displayText = [NSString stringWithFormat:@"%@\n\u2022 %@: %@", displayText, @"events_default", event.content[@"events_default"]];
}
if (event.content[@"state_default"])
{
displayText = [NSString stringWithFormat:@"%@\n\u2022 %@: %@", displayText, @"state_default", event.content[@"state_default"]];
}
// Append redacted info if any
if (redactedInfo)
{
displayText = [NSString stringWithFormat:@"%@\n %@", displayText, redactedInfo];
}
break;
}
case MXEventTypeRoomAliases:
{
NSArray *aliases;
MXJSONModelSetArray(aliases, event.content[@"aliases"]);
if (aliases)
{
if (isRoomDirect)
{
displayText = [VectorL10n noticeRoomAliasesForDm:[aliases componentsJoinedByString:@", "]];
}
else
{
displayText = [VectorL10n noticeRoomAliases:[aliases componentsJoinedByString:@", "]];
}
// Append redacted info if any
if (redactedInfo)
{
displayText = [NSString stringWithFormat:@"%@\n %@", displayText, redactedInfo];
}
}
break;
}
case MXEventTypeRoomEncrypted:
{
// Is redacted?
if (isRedacted)
{
if (!redactedInfo)
{
// Here the event is ignored (no display)
return nil;
}
displayText = redactedInfo;
}
else
{
// If the message still appears as encrypted, there was propably an error for decryption
// Show this error
if (event.decryptionError)
{
NSString *errorDescription;
if ([event.decryptionError.domain isEqualToString:MXDecryptingErrorDomain]
&& [MXKAppSettings standardAppSettings].hideUndecryptableEvents)
{
// Hide this event, it cannot be decrypted
displayText = nil;
}
else if ([event.decryptionError.domain isEqualToString:MXDecryptingErrorDomain]
&& event.decryptionError.code == MXDecryptingErrorUnknownInboundSessionIdCode)
{
// Hide the decryption error for VoiceBroadcast chunks
BOOL isVoiceBroadcastChunk = NO;
if ([event.relatesTo.relationType isEqualToString:MXEventRelationTypeReference]) {
MXEvent *startEvent = [mxSession.store eventWithEventId:event.relatesTo.eventId
inRoom:event.roomId];
if (startEvent) {
isVoiceBroadcastChunk = (startEvent.eventType == MXEventTypeCustom && [startEvent.type isEqualToString:VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType]);
}
}
if (isVoiceBroadcastChunk) {
displayText = nil;
} else {
// Make the unknown inbound session id error description more user friendly
errorDescription = [VectorL10n noticeCryptoErrorUnknownInboundSessionId];
}
}
else if ([event.decryptionError.domain isEqualToString:MXDecryptingErrorDomain]
&& event.decryptionError.code == MXDecryptingErrorDuplicateMessageIndexCode)
{
// Hide duplicate message warnings
MXLogDebug(@"[MXKEventFormatter] Warning: Duplicate message with error description %@", event.decryptionError);
displayText = nil;
}
else
{
errorDescription = event.decryptionError.localizedDescription;
}
if (errorDescription)
{
displayText = [VectorL10n noticeCryptoUnableToDecrypt:errorDescription];
}
}
else
{
displayText = [VectorL10n noticeEncryptedMessage];
}
}
break;
}
case MXEventTypeRoomEncryption:
{
NSString *algorithm;
MXJSONModelSetString(algorithm, event.content[@"algorithm"]);
if (isRedacted)
{
if (!redactedInfo)
{
// Here the event is ignored (no display)
return nil;
}
algorithm = redactedInfo;
}
if ([algorithm isEqualToString:kMXCryptoMegolmAlgorithm])
{
if (isEventSenderMyUser)
{
displayText = [VectorL10n noticeEncryptionEnabledOkByYou];
}
else
{
displayText = [VectorL10n noticeEncryptionEnabledOk:senderDisplayName];
}
}
else
{
if (isEventSenderMyUser)
{
displayText = [VectorL10n noticeEncryptionEnabledUnknownAlgorithmByYou:algorithm];
}
else
{
displayText = [VectorL10n noticeEncryptionEnabledUnknownAlgorithm:senderDisplayName :algorithm];
}
}
break;
}
case MXEventTypeRoomHistoryVisibility:
{
if (isRedacted)
{
displayText = redactedInfo;
}
else
{
MXRoomHistoryVisibility historyVisibility;
MXJSONModelSetString(historyVisibility, event.content[@"history_visibility"]);
if (historyVisibility)
{
if ([historyVisibility isEqualToString:kMXRoomHistoryVisibilityWorldReadable])
{
if (!isRoomDirect)
{
if (isEventSenderMyUser)
{
displayText = [VectorL10n noticeRoomHistoryVisibleToAnyoneByYou];
}
else
{
displayText = [VectorL10n noticeRoomHistoryVisibleToAnyone:senderDisplayName];
}
}
}
else if ([historyVisibility isEqualToString:kMXRoomHistoryVisibilityShared])
{
if (isEventSenderMyUser)
{
if (isRoomDirect)
{
displayText = [VectorL10n noticeRoomHistoryVisibleToMembersByYouForDm];
}
else
{
displayText = [VectorL10n noticeRoomHistoryVisibleToMembersByYou];
}
}
else
{
if (isRoomDirect)
{
displayText = [VectorL10n noticeRoomHistoryVisibleToMembersForDm:senderDisplayName];
}
else
{
displayText = [VectorL10n noticeRoomHistoryVisibleToMembers:senderDisplayName];
}
}
}
else if ([historyVisibility isEqualToString:kMXRoomHistoryVisibilityInvited])
{
if (isEventSenderMyUser)
{
if (isRoomDirect)
{
displayText = [VectorL10n noticeRoomHistoryVisibleToMembersFromInvitedPointByYouForDm];
}
else
{
displayText = [VectorL10n noticeRoomHistoryVisibleToMembersFromInvitedPointByYou];
}
}
else
{
if (isRoomDirect)
{
displayText = [VectorL10n noticeRoomHistoryVisibleToMembersFromInvitedPointForDm:senderDisplayName];
}
else
{
displayText = [VectorL10n noticeRoomHistoryVisibleToMembersFromInvitedPoint:senderDisplayName];
}
}
}
else if ([historyVisibility isEqualToString:kMXRoomHistoryVisibilityJoined])
{
if (isEventSenderMyUser)
{
if (isRoomDirect)
{
displayText = [VectorL10n noticeRoomHistoryVisibleToMembersFromJoinedPointByYouForDm];
}
else
{
displayText = [VectorL10n noticeRoomHistoryVisibleToMembersFromJoinedPointByYou];
}
}
else
{
if (isRoomDirect)
{
displayText = [VectorL10n noticeRoomHistoryVisibleToMembersFromJoinedPointForDm:senderDisplayName];
}
else
{
displayText = [VectorL10n noticeRoomHistoryVisibleToMembersFromJoinedPoint:senderDisplayName];
}
}
}
}
}
break;
}
case MXEventTypeRoomMessage:
{
// Is redacted?
if (isRedacted)
{
if (!redactedInfo)
{
// Here the event is ignored (no display)
return nil;
}
displayText = redactedInfo;
}
else if (event.isEditEvent)
{
return nil;
}
else
{
NSDictionary *contentToUse;
if (event.content[kMXMessageContentKeyNewContent])
{
// use new content if exists
contentToUse = event.content[kMXMessageContentKeyNewContent];
}
else
{
// fallback to default content
contentToUse = event.content;
}
NSString *msgtype;
MXJSONModelSetString(msgtype, contentToUse[kMXMessageTypeKey]);
NSString *body;
BOOL isHTML = NO;
NSString *eventThreadId = event.threadId;
// Use the HTML formatted string if provided
if ([contentToUse[@"format"] isEqualToString:kMXRoomMessageFormatHTML])
{
isHTML =YES;
MXJSONModelSetString(body, contentToUse[@"formatted_body"]);
}
else if (event.isReplyEvent || (eventThreadId && !RiotSettings.shared.enableThreads))
{
NSString *repliedEventId = event.relatesTo.inReplyTo.eventId ?: eventThreadId;
isHTML = YES;
MXJSONModelSetString(body, contentToUse[kMXMessageBodyKey]);
MXEvent *repliedEvent = [mxSession.store eventWithEventId:repliedEventId
inRoom:event.roomId];
NSString *repliedEventContent;
MXJSONModelSetString(repliedEventContent, repliedEvent.content[kMXMessageBodyKey]);
body = [NSString stringWithFormat:@"<mx-reply><blockquote><a href=\"%@\">In reply to</a> <a href=\"%@\">%@</a><br>%@</blockquote></mx-reply>%@",
[MXTools permalinkToEvent:repliedEventId inRoom:event.roomId],
[MXTools permalinkToUserWithUserId:repliedEvent.sender],
repliedEvent.sender,
repliedEventContent,
body];
}
else
{
MXJSONModelSetString(body, contentToUse[kMXMessageBodyKey]);
}
if (body)
{
if ([msgtype isEqualToString:kMXMessageTypeImage])
{
body = body? body : [VectorL10n noticeImageAttachment];
// Check attachment validity
if (![self isSupportedAttachment:event])
{
MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment in event %@", event.eventId);
body = [VectorL10n noticeInvalidAttachment];
*error = MXKEventFormatterErrorUnsupported;
}
}
else if ([msgtype isEqualToString:kMXMessageTypeAudio])
{
body = body? body : [VectorL10n noticeAudioAttachment];
if (![self isSupportedAttachment:event])
{
MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment in event %@", event.eventId);
if (_isForSubtitle || !_settings.showUnsupportedEventsInRoomHistory)
{
body = [VectorL10n noticeInvalidAttachment];
}
else
{
body = [VectorL10n noticeUnsupportedAttachment:event.description];
}
*error = MXKEventFormatterErrorUnsupported;
}
}
else if ([msgtype isEqualToString:kMXMessageTypeVideo])
{
body = body? body : [VectorL10n noticeVideoAttachment];
if (![self isSupportedAttachment:event])
{
MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment in event %@", event.eventId);
if (_isForSubtitle || !_settings.showUnsupportedEventsInRoomHistory)
{
body = [VectorL10n noticeInvalidAttachment];
}
else
{
body = [VectorL10n noticeUnsupportedAttachment:event.description];
}
*error = MXKEventFormatterErrorUnsupported;
}
}
else if ([msgtype isEqualToString:kMXMessageTypeFile])
{
// Check attachment validity
if ([self isSupportedAttachment:event])
{
body = body? body : [VectorL10n noticeFileAttachment];
NSDictionary *fileInfo;
MXJSONModelSetDictionary(fileInfo, contentToUse[@"info"]);
if (fileInfo)
{
NSNumber *fileSize;
MXJSONModelSetNumber(fileSize, fileInfo[@"size"])
if (fileSize)
{
body = [NSString stringWithFormat:@"%@ (%@)", body, [MXTools fileSizeToString: fileSize.longValue]];
}
else
{
MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported m.file format in event: %@", event.eventId);
*error = MXKEventFormatterErrorUnsupported;
}
}
}
else
{
MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment in event %@", event.eventId);
body = [VectorL10n noticeInvalidAttachment];
*error = MXKEventFormatterErrorUnsupported;
}
}
if (isHTML)
{
// Build the attributed string from the HTML string
attributedDisplayText = [self renderHTMLString:body
forEvent:event
withRoomState:roomState
andLatestRoomState:latestRoomState];
}
else
{
// Build the attributed string with the right font and color for the event
attributedDisplayText = [self renderString:body forEvent:event];
}
// Build the full emote string after the body message formatting
if ([msgtype isEqualToString:kMXMessageTypeEmote])
{
__block NSUInteger insertAt = 0;
// For replies, look for the end of the parent message
// This helps us insert the emote prefix in the right place
if (event.relatesTo.inReplyTo || (!RiotSettings.shared.enableThreads && event.isInThread))
{
[attributedDisplayText enumerateAttribute:kMXKToolsBlockquoteMarkAttribute
inRange:NSMakeRange(0, attributedDisplayText.length)
options:(NSAttributedStringEnumerationReverse)
usingBlock:^(id value, NSRange range, BOOL *stop) {
insertAt = range.location;
*stop = YES;
}];
}
// Always use default font and color for the emote prefix
NSString *emotePrefix = [NSString stringWithFormat:@"* %@ ", senderDisplayName];
NSAttributedString *attributedEmotePrefix =
[[NSAttributedString alloc] initWithString:emotePrefix
attributes:@{
NSForegroundColorAttributeName: _defaultTextColor,
NSFontAttributeName: _defaultTextFont
}];
// Then, insert the emote prefix at the start of the message
// (location varies depending on whether it was a reply)
NSMutableAttributedString *newAttributedDisplayText =
[[NSMutableAttributedString alloc] initWithAttributedString:attributedDisplayText];
[newAttributedDisplayText insertAttributedString:attributedEmotePrefix
atIndex:insertAt];
attributedDisplayText = newAttributedDisplayText;
}
}
}
break;
}
case MXEventTypeRoomMessageFeedback:
{
NSString *type;
MXJSONModelSetString(type, event.content[@"type"]);
NSString *eventId;
MXJSONModelSetString(eventId, event.content[@"target_event_id"]);
if (type && eventId)
{
displayText = [VectorL10n noticeFeedback:eventId :type];
// Append redacted info if any
if (redactedInfo)
{
displayText = [NSString stringWithFormat:@"%@ %@", displayText, redactedInfo];
}
}
break;
}
case MXEventTypeRoomRedaction:
{
NSString *eventId = event.redacts;
if (isEventSenderMyUser)
{
displayText = [VectorL10n noticeRedactionByYou:eventId];
}
else
{
displayText = [VectorL10n noticeRedaction:senderDisplayName :eventId];
}
break;
}
case MXEventTypeRoomThirdPartyInvite:
{
NSString *displayname;
MXJSONModelSetString(displayname, event.content[@"display_name"]);
if (displayname)
{
if (isEventSenderMyUser)
{
if (isRoomDirect)
{
displayText = [VectorL10n noticeRoomThirdPartyInviteByYouForDm:displayname];
}
else
{
displayText = [VectorL10n noticeRoomThirdPartyInviteByYou:displayname];
}
}
else
{
if (isRoomDirect)
{
displayText = [VectorL10n noticeRoomThirdPartyInviteForDm:senderDisplayName :displayname];
}
else
{
displayText = [VectorL10n noticeRoomThirdPartyInvite:senderDisplayName :displayname];
}
}
}
else
{
// Consider the invite has been revoked
MXJSONModelSetString(displayname, event.prevContent[@"display_name"]);
if (isEventSenderMyUser)
{
if (isRoomDirect)
{
displayText = [VectorL10n noticeRoomThirdPartyRevokedInviteByYouForDm:displayname];
}
else
{
displayText = [VectorL10n noticeRoomThirdPartyRevokedInviteByYou:displayname];
}
}
else
{
if (isRoomDirect)
{
displayText = [VectorL10n noticeRoomThirdPartyRevokedInviteForDm:senderDisplayName :displayname];
}
else
{
displayText = [VectorL10n noticeRoomThirdPartyRevokedInvite:senderDisplayName :displayname];
}
}
}
break;
}
case MXEventTypeCallInvite:
{
MXCallInviteEventContent *callInviteEventContent = [MXCallInviteEventContent modelFromJSON:event.content];
if (callInviteEventContent.isVideoCall)
{
if (isEventSenderMyUser)
{
displayText = [VectorL10n noticePlacedVideoCallByYou];
}
else
{
displayText = [VectorL10n noticePlacedVideoCall:senderDisplayName];
}
}
else
{
if (isEventSenderMyUser)
{
displayText = [VectorL10n noticePlacedVoiceCallByYou];
}
else
{
displayText = [VectorL10n noticePlacedVoiceCall:senderDisplayName];
}
}
break;
}
case MXEventTypeCallAnswer:
{
if (isEventSenderMyUser)
{
displayText = [VectorL10n noticeAnsweredVideoCallByYou];
}
else
{
displayText = [VectorL10n noticeAnsweredVideoCall:senderDisplayName];
}
break;
}
case MXEventTypeCallHangup:
{
if (isEventSenderMyUser)
{
displayText = [VectorL10n noticeEndedVideoCallByYou];
}
else
{
displayText = [VectorL10n noticeEndedVideoCall:senderDisplayName];
}
break;
}
case MXEventTypeCallReject:
{
if (isEventSenderMyUser)
{
displayText = [VectorL10n noticeDeclinedVideoCallByYou];
}
else
{
displayText = [VectorL10n noticeDeclinedVideoCall:senderDisplayName];
}
break;
}
case MXEventTypeSticker:
{
// Is redacted?
if (isRedacted)
{
if (!redactedInfo)
{
// Here the event is ignored (no display)
return nil;
}
displayText = redactedInfo;
}
else
{
NSString *body;
if (event.content[kMXMessageContentKeyNewContent])
{
MXJSONModelSetString(body, event.content[kMXMessageContentKeyNewContent][kMXMessageBodyKey]);
}
else
{
MXJSONModelSetString(body, event.content[kMXMessageBodyKey]);
}
// Check sticker validity
if (![self isSupportedAttachment:event])
{
MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported sticker in event %@", event.eventId);
body = [VectorL10n noticeInvalidAttachment];
*error = MXKEventFormatterErrorUnsupported;
}
displayText = body? body : [VectorL10n noticeSticker];
}
break;
}
case MXEventTypePollEnd:
{
if (event.isEditEvent)
{
return nil;
}
MXEvent* pollStartedEvent = [self->mxSession.store eventWithEventId:event.relatesTo.eventId inRoom:event.roomId];
if (pollStartedEvent) {
displayText = [MXEventContentPollStart modelFromJSON:pollStartedEvent.content].question;
} else {
displayText = [VectorL10n pollTimelineEndedText];
}
break;
}
case MXEventTypePollStart:
{
if (event.isEditEvent)
{
return nil;
}
displayText = [MXEventContentPollStart modelFromJSON:event.content].question;
break;
}
case MXEventTypeBeaconInfo:
{
displayText = [MXBeaconInfo modelFromJSON:event.content].desc;
break;
}
default:
*error = MXKEventFormatterErrorUnknownEventType;
break;
}
if (!attributedDisplayText && displayText)
{
// Build the attributed string with the right font and color for the event
attributedDisplayText = [self renderString:displayText forEvent:event];
}
if (!attributedDisplayText)
{
MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported event %@)", event.eventId);
if (_settings.showUnsupportedEventsInRoomHistory)
{
if (MXKEventFormatterErrorNone == *error)
{
*error = MXKEventFormatterErrorUnsupported;
}
NSString *shortDescription = nil;
switch (*error)
{
case MXKEventFormatterErrorUnsupported:
shortDescription = [VectorL10n noticeErrorUnsupportedEvent];
break;
case MXKEventFormatterErrorUnexpected:
shortDescription = [VectorL10n noticeErrorUnexpectedEvent];
break;
case MXKEventFormatterErrorUnknownEventType:
shortDescription = [VectorL10n noticeErrorUnknownEventType];
break;
default:
break;
}
if (!_isForSubtitle)
{
// Return event content as unsupported event
displayText = [NSString stringWithFormat:@"%@: %@", shortDescription, event.description];
}
else
{
// Return a short error description
displayText = shortDescription;
}
// Build the attributed string with the right font for the event
attributedDisplayText = [self renderString:displayText forEvent:event];
}
}
return attributedDisplayText;
}
- (NSAttributedString*)attributedStringFromEvents:(NSArray<MXEvent*>*)events
withRoomState:(MXRoomState*)roomState
andLatestRoomState:(MXRoomState*)latestRoomState
error:(MXKEventFormatterError*)error
{
// TODO: Do a full summary
return nil;
}
- (NSAttributedString*)renderString:(NSString*)string forEvent:(MXEvent*)event
{
// Sanity check
if (!string)
{
return nil;
}
NSMutableAttributedString *str = [[NSMutableAttributedString alloc] initWithString:string];
NSRange wholeString = NSMakeRange(0, str.length);
UIFont *fontForWholeString = [self fontForEvent:event string:string];
// Apply color and font corresponding to the event state
[str addAttribute:NSForegroundColorAttributeName value:[self textColorForEvent:event] range:wholeString];
[str addAttribute:NSFontAttributeName
value:fontForWholeString
range:wholeString];
// If enabled, make links clickable
if (!([[_settings httpLinkScheme] isEqualToString: @"http"] &&
[[_settings httpsLinkScheme] isEqualToString: @"https"]))
{
NSArray *matches = [linkDetector matchesInString:[str string] options:0 range:wholeString];
for (NSTextCheckingResult *match in matches)
{
NSRange matchRange = [match range];
NSURL *matchUrl = [match URL];
NSURLComponents *url = [[NSURLComponents new] initWithURL:matchUrl resolvingAgainstBaseURL:NO];
if (url)
{
if ([url.scheme isEqualToString: @"http"])
{
url.scheme = [_settings httpLinkScheme];
}
else if ([url.scheme isEqualToString: @"https"])
{
url.scheme = [_settings httpsLinkScheme];
}
if (url.URL)
{
[str addAttribute:NSLinkAttributeName value:url.URL range:matchRange];
[str addAttribute:NSForegroundColorAttributeName value:self.linksColor range:matchRange];
}
}
}
}
UIFont *fontForBody = [self fontForEvent:event string:nil];
if ([fontForWholeString isEqual:fontForBody])
{
// body font is the same with the whole string font, no need to change body font
// apply additional treatments
[self postRenderAttributedString:str];
return str;
}
NSString *body;
if (event.content[kMXMessageContentKeyNewContent])
{
MXJSONModelSetString(body, event.content[kMXMessageContentKeyNewContent][kMXMessageBodyKey]);
}
else
{
MXJSONModelSetString(body, event.content[kMXMessageBodyKey]);
}
NSRange bodyRange = [str.string rangeOfString:body];
if (bodyRange.location == NSNotFound)
{
// body not found in the whole string
// apply additional treatments
[self postRenderAttributedString:str];
return str;
}
[str addAttribute:NSFontAttributeName value:fontForBody range:bodyRange];
// apply additional treatments
[self postRenderAttributedString:str];
return str;
}
- (NSAttributedString*)renderHTMLString:(NSString*)htmlString
forEvent:(MXEvent*)event
withRoomState:(MXRoomState*)roomState
andLatestRoomState:(MXRoomState*)latestRoomState
{
NSString *html = htmlString;
MXEvent *repliedEvent;
// Special treatment for "In reply to" message
if (roomState && (event.isReplyEvent || (!RiotSettings.shared.enableThreads && event.isInThread)))
{
repliedEvent = [self->mxSession.store eventWithEventId:event.relatesTo.inReplyTo.eventId inRoom:roomState.roomId];
if (repliedEvent)
{
// Try to construct rich reply.
html = [self buildHTMLStringForEvent:event inReplyToEvent:repliedEvent] ?: html;
}
html = [self renderReplyTo:html withRoomState:roomState];
html = [self renderPollEndedReplyTo:html repliedEvent:repliedEvent];
}
// Apply the css style that corresponds to the event state
UIFont *fontForWholeString = [self fontForEvent:event string:htmlString];
MXWeakify(self);
NSAttributedString *str = [HTMLFormatter formatHTML:html
withAllowedTags:_allowedHTMLTags
font:fontForWholeString
andImageHandler:_htmlImageHandler
extraOptions:@{ DTDefaultTextColor: [self textColorForEvent:event],
DTDefaultStyleSheet: dtCSS }
postFormatOperations:^(NSMutableAttributedString *mutableStr) {
MXStrongifyAndReturnIfNil(self);
[self postFormatMutableAttributedString:mutableStr
forEvent:event
andRepliedEvent:repliedEvent
defaultFont:fontForWholeString];
}];
return str;
}
- (NSAttributedString*)redactedMessageReplacementAttributedString
{
return [[NSAttributedString alloc] initWithString:VectorL10n.eventFormatterMessageDeleted];
}
/**
Build the HTML body of a reply from its related event (rich replies).
@param event the reply event.
@param repliedEvent the event it replies to.
@return an html string containing the updated content of both events.
*/
- (NSString*)buildHTMLStringForEvent:(MXEvent*)event inReplyToEvent:(MXEvent*)repliedEvent
{
NSString *repliedEventContent;
NSString *eventContent;
NSString *html;
if (repliedEvent.isRedactedEvent)
{
repliedEventContent = nil;
}
else
{
if (repliedEvent.content[kMXMessageContentKeyNewContent])
{
MXJSONModelSetString(repliedEventContent, repliedEvent.content[kMXMessageContentKeyNewContent][@"formatted_body"]);
if (!repliedEventContent)
{
MXJSONModelSetString(repliedEventContent, repliedEvent.content[kMXMessageContentKeyNewContent][kMXMessageBodyKey]);
}
}
else
{
MXReplyEventParser *parser = [[MXReplyEventParser alloc] init];
MXReplyEventParts *parts = [parser parse:repliedEvent];
MXJSONModelSetString(repliedEventContent, parts.formattedBodyParts.replyText)
if (!repliedEventContent)
{
MXJSONModelSetString(repliedEventContent, parts.bodyParts.replyText)
}
if (!repliedEventContent)
{
MXJSONModelSetString(repliedEventContent, repliedEvent.content[@"formatted_body"]);
}
if (!repliedEventContent)
{
MXJSONModelSetString(repliedEventContent, repliedEvent.content[kMXMessageBodyKey]);
}
if (!repliedEventContent && repliedEvent.eventType == MXEventTypePollStart) {
repliedEventContent = [MXEventContentPollStart modelFromJSON:repliedEvent.content].question;
}
if (!repliedEventContent && repliedEvent.eventType == MXEventTypePollEnd) {
repliedEventContent = MXSendReplyEventDefaultStringLocalizer.new.endedPollMessage;
}
}
// No message content in a non-redacted event. Formatter should use fallback.
if (!repliedEventContent)
{
MXLogWarning(@"[MXKEventFormatter] Unable to retrieve content from replied event %@", repliedEvent.eventId)
return nil;
}
}
if (event.content[kMXMessageContentKeyNewContent])
{
MXJSONModelSetString(eventContent, event.content[kMXMessageContentKeyNewContent][@"formatted_body"]);
if (!eventContent)
{
MXJSONModelSetString(eventContent, event.content[kMXMessageContentKeyNewContent][kMXMessageBodyKey]);
}
}
else
{
MXReplyEventParser *parser = [[MXReplyEventParser alloc] init];
MXReplyEventParts *parts = [parser parse:event];
MXJSONModelSetString(eventContent, parts.formattedBodyParts.replyText)
if (!eventContent)
{
MXJSONModelSetString(eventContent, parts.bodyParts.replyText)
}
}
if (eventContent && repliedEvent.sender)
{
html = [NSString stringWithFormat:@"<mx-reply><blockquote><a href=\"%@\">In reply to</a> <a href=\"%@\">%@</a><br>%@</blockquote></mx-reply>%@",
[MXTools permalinkToEvent:repliedEvent.eventId inRoom:repliedEvent.roomId],
[MXTools permalinkToUserWithUserId:repliedEvent.sender],
repliedEvent.sender,
repliedEventContent,
eventContent];
}
else
{
MXLogWarning(@"[MXKEventFormatter] Unable to build reply event %@", event.eventId)
}
return html;
}
/**
Special treatment for "In reply to" message.
According to https://docs.google.com/document/d/1BPd4lBrooZrWe_3s_lHw_e-Dydvc7bXbm02_sV2k6Sc/edit.
@param htmlString an html string containing a reply-to message.
@param roomState the room state right before the event.
@return a displayable internationalised html string.
*/
- (NSString*)renderReplyTo:(NSString*)htmlString withRoomState:(MXRoomState*)roomState
{
NSInteger mxReplyEndLocation = [htmlString rangeOfString:@"</mx-reply>"].location;
if (mxReplyEndLocation == NSNotFound)
{
MXLogWarning(@"[MXKEventFormatter] Missing mx-reply block in html string");
return htmlString;
}
NSString *html = htmlString;
static NSRegularExpression *htmlATagRegex;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
htmlATagRegex = [NSRegularExpression regularExpressionWithPattern:kHTMLATagRegexPattern options:NSRegularExpressionCaseInsensitive error:nil];
});
__block NSUInteger hrefCount = 0;
__block NSRange inReplyToLinkRange = NSMakeRange(NSNotFound, 0);
__block NSRange inReplyToTextRange = NSMakeRange(NSNotFound, 0);
__block NSRange userIdRange = NSMakeRange(NSNotFound, 0);
[htmlATagRegex enumerateMatchesInString:html
options:0
range:NSMakeRange(0, mxReplyEndLocation)
usingBlock:^(NSTextCheckingResult *match, NSMatchingFlags flags, BOOL *stop) {
if (hrefCount > 1)
{
*stop = YES;
}
else if (hrefCount == 0 && match.numberOfRanges >= 2)
{
inReplyToLinkRange = [match rangeAtIndex:1];
inReplyToTextRange = [match rangeAtIndex:2];
}
else if (hrefCount == 1 && match.numberOfRanges >= 2)
{
userIdRange = [match rangeAtIndex:2];
}
hrefCount++;
}];
// Note: Take care to replace text starting with the end
// Replace <a href=\"https://matrix.to/#/mxid\">mxid</a>
// By <a href=\"https://matrix.to/#/mxid\">Display name</a>
// To replace the user Matrix ID by his display name when available.
// This link is the second <a> HTML node of the html string
if (userIdRange.location != NSNotFound)
{
NSString *userId = [html substringWithRange:userIdRange];
NSString *senderDisplayName = [roomState.members memberName:userId];
if (senderDisplayName)
{
html = [html stringByReplacingCharactersInRange:userIdRange withString:senderDisplayName.stringByAddingHTMLEntities];
}
}
// Replace <mx-reply><blockquote><a href=\"__permalink__\">In reply to</a>
// By <mx-reply><blockquote><a href=\"__permalink__\">['In reply to' from resources]</a>
// To localize the "In reply to" string
// This link is the first <a> HTML node of the html string
if (inReplyToTextRange.location != NSNotFound)
{
html = [html stringByReplacingCharactersInRange:inReplyToTextRange withString:[VectorL10n noticeInReplyTo]];
}
return html;
}
- (NSString*)renderPollEndedReplyTo:(NSString*)htmlString repliedEvent:(MXEvent*)repliedEvent {
static NSRegularExpression *endedPollRegex;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
endedPollRegex = [NSRegularExpression regularExpressionWithPattern:kRepliedTextPattern options:NSRegularExpressionCaseInsensitive error:nil];
});
NSString* finalString = htmlString;
if (repliedEvent.eventType != MXEventTypePollEnd) {
return finalString;
}
NSTextCheckingResult* match = [endedPollRegex firstMatchInString:htmlString options:0 range:NSMakeRange(0, htmlString.length)];
if (!(match && match.numberOfRanges > 1)) {
// no useful match found
return finalString;
}
NSRange groupRange = [match rangeAtIndex:1];
NSString* replacementText;
if (repliedEvent) {
MXEvent* pollStartedEvent = [mxSession.store eventWithEventId:repliedEvent.relatesTo.eventId inRoom:repliedEvent.roomId];
replacementText = [MXEventContentPollStart modelFromJSON:pollStartedEvent.content].question;
}
if (replacementText == nil) {
replacementText = VectorL10n.pollTimelineReplyEndedPoll;
}
finalString = [htmlString stringByReplacingCharactersInRange:groupRange withString:replacementText];
return finalString;
}
- (void)postFormatMutableAttributedString:(NSMutableAttributedString*)mutableAttributedString
forEvent:(MXEvent*)event
andRepliedEvent:(MXEvent*)repliedEvent
defaultFont:(UIFont*)defaultFont
{
[self postRenderAttributedString:mutableAttributedString];
[MXKTools removeMarkedBlockquotesArtifacts:mutableAttributedString];
if (repliedEvent && repliedEvent.isRedactedEvent)
{
// Replace the description of an empty replied event
NSRange nullRange = [mutableAttributedString.string rangeOfString:@"(null)"];
if (nullRange.location != NSNotFound)
{
[mutableAttributedString replaceCharactersInRange:nullRange withAttributedString:[self redactedMessageReplacementAttributedString]];
}
}
UIFont *fontForBody = [self fontForEvent:event string:nil];
if ([defaultFont isEqual:fontForBody])
{
// body font is the same with the whole string font, no need to change body font
return;
}
NSString *body;
if (event.content[kMXMessageContentKeyNewContent])
{
MXJSONModelSetString(body, event.content[kMXMessageContentKeyNewContent][kMXMessageBodyKey]);
}
else
{
MXJSONModelSetString(body, event.content[kMXMessageBodyKey]);
}
NSRange bodyRange = [mutableAttributedString.string rangeOfString:body];
if (bodyRange.location == NSNotFound)
{
// body not found in the whole string
return;
}
[mutableAttributedString addAttribute:NSFontAttributeName value:fontForBody range:bodyRange];
}
- (void)postRenderAttributedString:(NSMutableAttributedString*)mutableAttributedString
{
if (!mutableAttributedString)
{
return;
}
NSInteger enabledMatrixIdsBitMask= 0;
// If enabled, make user id clickable
if (_treatMatrixUserIdAsLink)
{
enabledMatrixIdsBitMask |= MXKTOOLS_USER_IDENTIFIER_BITWISE;
}
// If enabled, make room id clickable
if (_treatMatrixRoomIdAsLink)
{
enabledMatrixIdsBitMask |= MXKTOOLS_ROOM_IDENTIFIER_BITWISE;
}
// If enabled, make room alias clickable
if (_treatMatrixRoomAliasAsLink)
{
enabledMatrixIdsBitMask |= MXKTOOLS_ROOM_ALIAS_BITWISE;
}
// If enabled, make event id clickable
if (_treatMatrixEventIdAsLink)
{
enabledMatrixIdsBitMask |= MXKTOOLS_EVENT_IDENTIFIER_BITWISE;
}
[MXKTools createLinksInMutableAttributedString:mutableAttributedString forEnabledMatrixIds:enabledMatrixIdsBitMask];
}
- (NSAttributedString *)renderString:(NSString *)string withPrefix:(NSString *)prefix forEvent:(MXEvent *)event
{
NSMutableAttributedString *str;
if (prefix)
{
str = [[NSMutableAttributedString alloc] initWithString:prefix];
// Apply the prefix font and color on the prefix
NSRange prefixRange = NSMakeRange(0, prefix.length);
[str addAttribute:NSForegroundColorAttributeName value:_prefixTextColor range:prefixRange];
[str addAttribute:NSFontAttributeName value:_prefixTextFont range:prefixRange];
// And append the string rendered according to event state
[str appendAttributedString:[self renderString:string forEvent:event]];
return str;
}
else
{
// Use the legacy method
return [self renderString:string forEvent:event];
}
}
- (void)setDefaultCSS:(NSString*)defaultCSS
{
// Make sure we mark HTML blockquote blocks for later computation
_defaultCSS = [NSString stringWithFormat:@"%@%@", [MXKTools cssToMarkBlockquotes], defaultCSS];
dtCSS = [[DTCSSStylesheet alloc] initWithStyleBlock:_defaultCSS];
}
#pragma mark - MXRoomSummaryUpdating
- (BOOL)session:(MXSession *)session updateRoomSummary:(MXRoomSummary *)summary withStateEvents:(NSArray<MXEvent *> *)stateEvents roomState:(MXRoomState *)roomState
{
// We build strings containing the sender displayname (ex: "Bob: Hello!")
// If a sender changes his displayname, we need to update the lastMessage.
MXRoomLastMessage *lastMessage;
for (MXEvent *event in stateEvents)
{
if (event.isUserProfileChange)
{
if (!lastMessage)
{
// Load lastMessageEvent on demand to save I/O
lastMessage = summary.lastMessage;
}
if ([event.sender isEqualToString:lastMessage.sender])
{
// The last message must be recomputed
[summary resetLastMessage:nil failure:nil commit:YES];
break;
}
}
else if (event.eventType == MXEventTypeRoomJoinRules)
{
summary.joinRule = roomState.joinRule;
}
}
return [defaultRoomSummaryUpdater session:session updateRoomSummary:summary withStateEvents:stateEvents roomState:roomState];
}
- (BOOL)session:(MXSession *)session updateRoomSummary:(MXRoomSummary *)summary withLastEvent:(MXEvent *)event eventState:(MXRoomState *)eventState roomState:(MXRoomState *)roomState
{
// Use the default updater as first pass
MXRoomLastMessage *currentlastMessage = summary.lastMessage;
BOOL updated = [defaultRoomSummaryUpdater session:session updateRoomSummary:summary withLastEvent:event eventState:eventState roomState:roomState];
if (updated)
{
// Then customise
// Compute the text message
// Note that we use the current room state (roomState) because when we display
// users displaynames, we want current displaynames
MXKEventFormatterError error;
NSString *lastMessageString = [self stringFromEvent:event
withRoomState:roomState
andLatestRoomState:nil
error:&error];
if (0 == lastMessageString.length)
{
// @TODO: there is a conflict with what [defaultRoomSummaryUpdater updateRoomSummary] did :/
updated = NO;
// Restore the previous lastMessageEvent
[summary updateLastMessage:currentlastMessage];
}
else
{
summary.lastMessage.text = lastMessageString;
if (summary.lastMessage.others == nil)
{
summary.lastMessage.others = [NSMutableDictionary dictionary];
}
// Store the potential error
summary.lastMessage.others[@"mxkEventFormatterError"] = @(error);
summary.lastMessage.others[@"lastEventDate"] = [self dateStringFromEvent:event withTime:YES];
// Check whether the sender name has to be added
NSString *prefix = nil;
if (event.eventType == MXEventTypeRoomMessage)
{
NSString *msgtype = event.content[kMXMessageTypeKey];
if ([msgtype isEqualToString:kMXMessageTypeEmote] == NO)
{
NSString *senderDisplayName = [self senderDisplayNameForEvent:event withRoomState:roomState];
prefix = [NSString stringWithFormat:@"%@: ", senderDisplayName];
}
}
else if (event.eventType == MXEventTypeSticker)
{
NSString *senderDisplayName = [self senderDisplayNameForEvent:event withRoomState:roomState];
prefix = [NSString stringWithFormat:@"%@: ", senderDisplayName];
}
// Compute the attribute text message
summary.lastMessage.attributedText = [self renderString:summary.lastMessage.text withPrefix:prefix forEvent:event];
}
}
return updated;
}
- (BOOL)session:(MXSession *)session updateRoomSummary:(MXRoomSummary *)summary withServerRoomSummary:(MXRoomSyncSummary *)serverRoomSummary roomState:(MXRoomState *)roomState
{
return [defaultRoomSummaryUpdater session:session updateRoomSummary:summary withServerRoomSummary:serverRoomSummary roomState:roomState];
}
#pragma mark - Conversion private methods
/**
Get the text color to use according to the event state.
@param event the event.
@return the text color.
*/
- (UIColor*)textColorForEvent:(MXEvent*)event
{
// Select the text color
UIColor *textColor;
// Check whether an error occurred during event formatting.
if (event.mxkEventFormatterError != MXKEventFormatterErrorNone)
{
textColor = _errorTextColor;
}
// Check whether the message is highlighted.
else if (event.mxkIsHighlighted || (mxSession && [event shouldBeHighlightedInSession:mxSession]))
{
textColor = _bingTextColor;
}
else
{
// Consider here the sending state of the event, and the property `isForSubtitle`.
switch (event.sentState)
{
case MXEventSentStateSent:
if (_isForSubtitle)
{
textColor = _subTitleTextColor;
}
else
{
textColor = _defaultTextColor;
}
break;
case MXEventSentStateEncrypting:
textColor = _encryptingTextColor;
break;
case MXEventSentStatePreparing:
case MXEventSentStateUploading:
case MXEventSentStateSending:
textColor = _sendingTextColor;
break;
case MXEventSentStateFailed:
textColor = _errorTextColor;
break;
default:
if (_isForSubtitle)
{
textColor = _subTitleTextColor;
}
else
{
textColor = _defaultTextColor;
}
break;
}
}
return textColor;
}
/**
Get the text font to use according to the event state.
@param event the event.
@param string the string to be rendered for the event. It may be different from event.content.body. Pass nil to get font just according to event.content.body.
@return the text font.
*/
- (UIFont*)fontForEvent:(MXEvent*)event string:(NSString*)string
{
// Select text font
UIFont *font = _defaultTextFont;
if (event.isState)
{
font = _stateEventTextFont;
}
else if (event.eventType == MXEventTypeCallInvite || event.eventType == MXEventTypeCallAnswer || event.eventType == MXEventTypeCallHangup)
{
font = _callNoticesTextFont;
}
else if (event.mxkIsHighlighted || (mxSession && [event shouldBeHighlightedInSession:mxSession]))
{
font = _bingTextFont;
}
else if (event.eventType == MXEventTypeRoomEncrypted)
{
font = _encryptedMessagesTextFont;
}
else if (!_isForSubtitle && !string && event.eventType == MXEventTypeRoomMessage && (_emojiOnlyTextFont || _singleEmojiTextFont))
{
NSString *message;
if (event.content[kMXMessageContentKeyNewContent])
{
MXJSONModelSetString(message, event.content[kMXMessageContentKeyNewContent][kMXMessageBodyKey]);
}
else
{
MXJSONModelSetString(message, event.content[kMXMessageBodyKey]);
}
if (_emojiOnlyTextFont && [MXKTools isEmojiOnlyString:message])
{
font = _emojiOnlyTextFont;
}
else if (_singleEmojiTextFont && [MXKTools isSingleEmojiString:message])
{
font = _singleEmojiTextFont;
}
}
return font;
}
#pragma mark - Conversion tools
- (NSString *)htmlStringFromMarkdownString:(NSString *)markdownString
{
NSString *htmlString = [_markdownToHTMLRenderer renderToHTMLWithMarkdown:markdownString];
// Strip off the trailing newline, if it exists.
if ([htmlString hasSuffix:@"\n"])
{
htmlString = [htmlString substringToIndex:htmlString.length - 1];
}
// Strip start and end <p> tags else you get 'orrible spacing.
// But only do this if it's a single paragraph we're dealing with,
// otherwise we'll produce some garbage (`something</p><p>another`).
if ([htmlString hasPrefix:@"<p>"] && [htmlString hasSuffix:@"</p>"])
{
NSArray *components = [htmlString componentsSeparatedByString:@"<p>"];
NSUInteger paragrapsCount = components.count - 1;
if (paragrapsCount == 1) {
htmlString = [htmlString substringFromIndex:3];
htmlString = [htmlString substringToIndex:htmlString.length - 4];
}
}
return htmlString;
}
#pragma mark - Timestamp formatting
- (NSString*)dateStringFromDate:(NSDate *)date withTime:(BOOL)time
{
// Get first date string without time (if a date format is defined, else only time string is returned)
NSString *dateString = nil;
if (dateFormatter.dateFormat)
{
dateString = [dateFormatter stringFromDate:date];
}
if (time)
{
NSString *timeString = [self timeStringFromDate:date];
if (dateString.length)
{
// Add time string
dateString = [NSString stringWithFormat:@"%@ %@", dateString, timeString];
}
else
{
dateString = timeString;
}
}
return dateString;
}
- (NSString*)dateStringFromTimestamp:(uint64_t)timestamp withTime:(BOOL)time
{
NSDate *date = [NSDate dateWithTimeIntervalSince1970:timestamp / 1000];
return [self dateStringFromDate:date withTime:time];
}
- (NSString*)dateStringFromEvent:(MXEvent *)event withTime:(BOOL)time
{
if (event.originServerTs != kMXUndefinedTimestamp)
{
return [self dateStringFromTimestamp:event.originServerTs withTime:time];
}
return nil;
}
- (NSString*)timeStringFromDate:(NSDate *)date
{
NSString *timeString = [timeFormatter stringFromDate:date];
return timeString.lowercaseString;
}
@end