Add reactions to export chat history (#28252)

Co-authored-by: 23rd <23rd@vivaldi.net>
This commit is contained in:
Bohdan Tkachenko 2024-09-30 06:02:22 -04:00 committed by GitHub
parent 91f5c72cf0
commit a970fe93c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 365 additions and 54 deletions

View File

@ -582,3 +582,55 @@ div.toast_shown {
.bot_button_column_separator {
width: 2px
}
.reactions {
margin: 5px 0;
}
.reactions .reaction {
display: inline-flex;
height: 20px;
border-radius: 15px;
background-color: #e8f5fc;
color: #168acd;
font-weight: bold;
margin-bottom: 5px;
}
.reactions .reaction.active {
background-color: #40a6e2;
color: #fff;
}
.reactions .reaction.paid {
background-color: #fdf6e1;
color: #c58523;
}
.reactions .reaction.active.paid {
background-color: #ecae0a;
color: #fdf6e1;
}
.reactions .reaction .emoji {
line-height: 20px;
margin: 0 5px;
font-size: 15px;
}
.reactions .reaction .userpic:not(:first-child) {
margin-left: -8px;
}
.reactions .reaction .userpic {
display: inline-block;
}
.reactions .reaction .userpic .initials {
font-size: 8px;
}
.reactions .reaction .count {
margin-right: 8px;
line-height: 20px;
}

View File

@ -325,6 +325,80 @@ std::vector<TextPart> ParseText(
return result;
}
Utf8String Reaction::TypeToString(const Reaction &reaction) {
switch (reaction.type) {
case Reaction::Type::Empty: return "empty";
case Reaction::Type::Emoji: return "emoji";
case Reaction::Type::CustomEmoji: return "custom_emoji";
case Reaction::Type::Paid: return "paid";
}
Unexpected("Type in Reaction::Type.");
}
Utf8String Reaction::Id(const Reaction &reaction) {
auto id = Utf8String();
switch (reaction.type) {
case Reaction::Type::Emoji:
id = reaction.emoji.toUtf8();
break;
case Reaction::Type::CustomEmoji:
id = reaction.documentId;
break;
}
return Reaction::TypeToString(reaction) + id;
}
Reaction ParseReaction(const MTPReaction& reaction) {
auto result = Reaction();
reaction.match([&](const MTPDreactionEmoji &data) {
result.type = Reaction::Type::Emoji;
result.emoji = qs(data.vemoticon());
}, [&](const MTPDreactionCustomEmoji &data) {
result.type = Reaction::Type::CustomEmoji;
result.documentId = NumberToString(data.vdocument_id().v);
}, [&](const MTPDreactionPaid &data) {
result.type = Reaction::Type::Paid;
}, [&](const MTPDreactionEmpty &data) {
result.type = Reaction::Type::Empty;
});
return result;
}
std::vector<Reaction> ParseReactions(const MTPMessageReactions &data) {
auto reactionsMap = std::map<QString, Reaction>();
auto reactionsOrder = std::vector<Utf8String>();
for (const auto &single : data.data().vresults().v) {
auto reaction = ParseReaction(single.data().vreaction());
reaction.count = single.data().vcount().v;
auto id = Reaction::Id(reaction);
auto const &[_, inserted] = reactionsMap.try_emplace(id, reaction);
if (inserted) {
reactionsOrder.push_back(id);
}
}
if (data.data().vrecent_reactions().has_value()) {
if (const auto list = data.data().vrecent_reactions()) {
for (const auto &single : list->v) {
auto reaction = ParseReaction(single.data().vreaction());
auto id = Reaction::Id(reaction);
auto const &[it, inserted] = reactionsMap.try_emplace(id, reaction);
if (inserted) {
reactionsOrder.push_back(id);
}
it->second.recent.push_back({
.peerId = ParsePeerId(single.data().vpeer_id()),
.date = single.data().vdate().v,
});
}
}
}
std::vector<Reaction> results;
for (const auto &id : reactionsOrder) {
results.push_back(reactionsMap[id]);
}
return results;
}
Utf8String FillLeft(const Utf8String &data, int length, char filler) {
if (length <= data.size()) {
return data;
@ -1739,6 +1813,9 @@ Message ParseMessage(
result.text = ParseText(
data.vmessage(),
data.ventities().value_or_empty());
if (data.vreactions().has_value()) {
result.reactions = ParseReactions(*data.vreactions());
}
}, [&](const MTPDmessageService &data) {
result.action = ParseServiceAction(
context,

View File

@ -702,6 +702,30 @@ struct TextPart {
}
};
struct Reaction {
enum class Type {
Empty,
Emoji,
CustomEmoji,
Paid,
};
static Utf8String TypeToString(const Reaction &);
static Utf8String Id(const Reaction &);
struct Recent {
PeerId peerId = 0;
TimeId date = 0;
};
Type type;
QString emoji;
Utf8String documentId;
uint32 count = 0;
std::vector<Recent> recent;
};
struct MessageId {
ChannelId channelId;
int32 msgId = 0;
@ -775,6 +799,7 @@ struct Message {
int32 replyToMsgId = 0;
PeerId replyToPeerId = 0;
std::vector<TextPart> text;
std::vector<Reaction> reactions;
Media media;
ServiceAction action;
bool out = false;

View File

@ -1726,6 +1726,15 @@ void ApiWrap::collectMessagesCustomEmoji(const Data::MessagesSlice &slice) {
}
}
}
for (const auto &reaction : message.reactions) {
if (reaction.type == Data::Reaction::Type::CustomEmoji) {
if (const auto id = reaction.documentId.toULongLong()) {
if (!_resolvedCustomEmoji.contains(id)) {
_unresolvedCustomEmoji.emplace(id);
}
}
}
}
}
}
@ -1803,38 +1812,57 @@ Data::FileOrigin ApiWrap::currentFileMessageOrigin() const {
return result;
}
std::optional<QByteArray> ApiWrap::getCustomEmoji(QByteArray &data) {
if (const auto id = data.toULongLong()) {
const auto i = _resolvedCustomEmoji.find(id);
if (i == end(_resolvedCustomEmoji)) {
return Data::TextPart::UnavailableEmoji();
}
auto &file = i->second.file;
const auto fileProgress = [=](FileProgress value) {
return loadMessageEmojiProgress(value);
};
const auto ready = processFileLoad(
file,
{ .customEmojiId = id },
fileProgress,
[=](const QString &path) {
loadMessageEmojiDone(id, path);
});
if (!ready) {
return std::nullopt;
}
using SkipReason = Data::File::SkipReason;
if (file.skipReason == SkipReason::Unavailable) {
return Data::TextPart::UnavailableEmoji();
} else if (file.skipReason == SkipReason::FileType
|| file.skipReason == SkipReason::FileSize) {
return QByteArray();
} else {
return file.relativePath.toUtf8();
}
}
return data;
}
bool ApiWrap::messageCustomEmojiReady(Data::Message &message) {
for (auto &part : message.text) {
if (part.type == Data::TextPart::Type::CustomEmoji) {
if (const auto id = part.additional.toULongLong()) {
const auto i = _resolvedCustomEmoji.find(id);
if (i == end(_resolvedCustomEmoji)) {
part.additional = Data::TextPart::UnavailableEmoji();
} else {
auto &file = i->second.file;
const auto fileProgress = [=](FileProgress value) {
return loadMessageEmojiProgress(value);
};
const auto ready = processFileLoad(
file,
{ .customEmojiId = id },
fileProgress,
[=](const QString &path) {
loadMessageEmojiDone(id, path);
});
if (!ready) {
return false;
}
using SkipReason = Data::File::SkipReason;
if (file.skipReason == SkipReason::Unavailable) {
part.additional = Data::TextPart::UnavailableEmoji();
} else if (file.skipReason == SkipReason::FileType
|| file.skipReason == SkipReason::FileSize) {
part.additional = QByteArray();
} else {
part.additional = file.relativePath.toUtf8();
}
}
auto data = getCustomEmoji(part.additional);
if (data.has_value()) {
part.additional = *data;
} else {
return false;
}
}
}
for (auto &reaction : message.reactions) {
if (reaction.type == Data::Reaction::Type::CustomEmoji) {
auto data = getCustomEmoji(reaction.documentId);
if (data.has_value()) {
reaction.documentId = *data;
} else {
return false;
}
}
}

View File

@ -183,6 +183,7 @@ private:
void resolveCustomEmoji();
void loadMessagesFiles(Data::MessagesSlice &&slice);
void loadNextMessageFile();
std::optional<QByteArray> getCustomEmoji(QByteArray &data);
bool messageCustomEmojiReady(Data::Message &message);
bool loadMessageFileProgress(FileProgress value);
void loadMessageFileDone(const QString &relativePath);

View File

@ -231,6 +231,21 @@ QByteArray JoinList(
return result;
}
QByteArray FormatCustomEmoji(
const Data::Utf8String &custom_emoji,
const QByteArray &text,
const QString &relativeLinkBase) {
return (custom_emoji.isEmpty()
? "<a href=\"\" onclick=\"return ShowNotLoadedEmoji();\">"
: (custom_emoji == Data::TextPart::UnavailableEmoji())
? "<a href=\"\" onclick=\"return ShowNotAvailableEmoji();\">"
: ("<a href = \""
+ (relativeLinkBase + custom_emoji).toUtf8()
+ "\">"))
+ text
+ "</a>";
}
QByteArray FormatText(
const std::vector<Data::TextPart> &data,
const QString &internalLinksDomain,
@ -288,15 +303,8 @@ QByteArray FormatText(
"onclick=\"ShowSpoiler(this)\">"
"<span aria-hidden=\"true\">"
+ text + "</span></span>";
case Type::CustomEmoji: return (part.additional.isEmpty()
? "<a href=\"\" onclick=\"return ShowNotLoadedEmoji();\">"
: (part.additional == Data::TextPart::UnavailableEmoji())
? "<a href=\"\" onclick=\"return ShowNotAvailableEmoji();\">"
: ("<a href = \""
+ (relativeLinkBase + part.additional).toUtf8()
+ "\">"))
+ text
+ "</a>";
case Type::CustomEmoji:
return FormatCustomEmoji(part.additional, text, relativeLinkBase);
}
Unexpected("Type in text entities serialization.");
}) | ranges::to_vector);
@ -1545,6 +1553,73 @@ auto HtmlWriter::Wrap::pushMessage(
if (showForwardedInfo) {
block.append(popTag());
}
if (!message.reactions.empty()) {
block.append(pushDiv("reactions"));
for (const auto &reaction : message.reactions) {
QByteArray reactionClass = "reaction";
for (const auto &recent : reaction.recent) {
auto peer = peers.peer(recent.peerId);
if (peer.user() && peer.user()->isSelf) {
reactionClass += " active";
break;
}
}
if (reaction.type == Reaction::Type::Paid) {
reactionClass += " paid";
}
block.append(pushTag("div", {
{ "class", reactionClass },
}));
block.append(pushTag("div", {
{ "class", "emoji" },
}));
switch (reaction.type) {
case Reaction::Type::Emoji:
block.append(SerializeString(reaction.emoji.toUtf8()));
break;
case Reaction::Type::CustomEmoji:
block.append(FormatCustomEmoji(
reaction.documentId,
"\U0001F44B",
_base));
break;
case Reaction::Type::Paid:
block.append(SerializeString("\u2B50"));
break;
}
block.append(popTag());
if (!reaction.recent.empty()) {
block.append(pushTag("div", {
{ "class", "userpics" },
}));
for (const auto &recent : reaction.recent) {
auto peer = peers.peer(recent.peerId);
block.append(pushUserpic(UserpicData({
.colorIndex = peer.colorIndex(),
.pixelSize = 20,
.firstName = peer.user()
? peer.user()->info.firstName
: peer.name(),
.lastName = peer.user()
? peer.user()->info.lastName
: "",
.tooltip = peer.name(),
})));
}
block.append(popTag());
}
if (reaction.recent.empty() || reaction.count > reaction.recent.size()) {
block.append(pushTag("div", {
{ "class", "count" },
}));
block.append(NumberToString(reaction.count));
block.append(popTag());
}
block.append(popTag());
}
block.append(popTag());
}
block.append(popTag());
block.append(popTag());

View File

@ -290,6 +290,17 @@ QByteArray SerializeMessage(
pushBare("edited_unixtime", SerializeDateRaw(message.edited));
}
const auto wrapPeerId = [&](PeerId peerId) {
if (const auto chat = peerToChat(peerId)) {
return SerializeString("chat"
+ Data::NumberToString(chat.bare));
} else if (const auto channel = peerToChannel(peerId)) {
return SerializeString("channel"
+ Data::NumberToString(channel.bare));
}
return SerializeString("user"
+ Data::NumberToString(peerToUser(peerId).bare));
};
const auto push = [&](const QByteArray &key, const auto &value) {
using V = std::decay_t<decltype(value)>;
if constexpr (std::is_same_v<V, bool>) {
@ -297,22 +308,7 @@ QByteArray SerializeMessage(
} else if constexpr (std::is_arithmetic_v<V>) {
pushBare(key, Data::NumberToString(value));
} else if constexpr (std::is_same_v<V, PeerId>) {
if (const auto chat = peerToChat(value)) {
pushBare(
key,
SerializeString("chat"
+ Data::NumberToString(chat.bare)));
} else if (const auto channel = peerToChannel(value)) {
pushBare(
key,
SerializeString("channel"
+ Data::NumberToString(channel.bare)));
} else {
pushBare(
key,
SerializeString("user"
+ Data::NumberToString(peerToUser(value).bare)));
}
pushBare(key, wrapPeerId(value));
} else {
const auto wrapped = QByteArray(value);
if (!wrapped.isEmpty()) {
@ -919,6 +915,63 @@ QByteArray SerializeMessage(
pushBare("inline_bot_buttons", SerializeArray(context, rows));
}
if (!message.reactions.empty()) {
const auto serializeReaction = [&](const Reaction &reaction) {
context.nesting.push_back(Context::kObject);
const auto guard = gsl::finally([&] { context.nesting.pop_back(); });
auto pairs = std::vector<std::pair<QByteArray, QByteArray>>();
pairs.push_back({
"type",
SerializeString(Reaction::TypeToString(reaction)),
});
pairs.push_back({
"count",
NumberToString(reaction.count),
});
switch (reaction.type) {
case Reaction::Type::Emoji:
pairs.push_back({
"emoji",
SerializeString(reaction.emoji.toUtf8()),
});
break;
case Reaction::Type::CustomEmoji:
pairs.push_back({
"document_id",
SerializeString(reaction.documentId),
});
break;
}
if (!reaction.recent.empty()) {
context.nesting.push_back(Context::kArray);
const auto recents = ranges::views::all(
reaction.recent
) | ranges::views::transform([&](const Reaction::Recent &recent) {
context.nesting.push_back(Context::kArray);
const auto guard = gsl::finally([&] { context.nesting.pop_back(); });
return SerializeObject(context, {
{ "from", wrapPeerName(recent.peerId) },
{ "from_id", wrapPeerId(recent.peerId) },
{ "date", SerializeDate(recent.date) },
});
}) | ranges::to_vector;
pairs.push_back({"recent", SerializeArray(context, recents)});
context.nesting.pop_back();
}
return SerializeObject(context, pairs);
};
context.nesting.push_back(Context::kArray);
const auto reactions = ranges::views::all(
message.reactions
) | ranges::views::transform(serializeReaction) | ranges::to_vector;
pushBare("reactions", SerializeArray(context, reactions));
context.nesting.pop_back();
}
return serialized();
}