2488 lines
96 KiB
Objective-C
2488 lines
96 KiB
Objective-C
/*
|
|
Copyright 2018-2024 New Vector Ltd.
|
|
Copyright 2017 Vector Creations Ltd
|
|
Copyright 2015 OpenMarket Ltd
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-only
|
|
Please see LICENSE in the repository root for full details.
|
|
*/
|
|
|
|
#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
|