Improve photo/video/sticker/GIF export layout.

This commit is contained in:
John Preston 2018-07-08 23:19:31 +03:00
parent 0ef7503917
commit c3736c6fa3
11 changed files with 497 additions and 53 deletions

View File

@ -56,6 +56,7 @@ pre {
}
.page_header {
position: fixed;
z-index: 10;
background-color: #ffffff;
width: 100%;
border-bottom: 1px solid #e3e6e8;
@ -126,7 +127,8 @@ pre {
}
.color_green,
.userpic2,
.media_call.success .fill {
.media_call.success .fill,
.media_photo .fill {
background-color: #64bf47;
}
.color_yellow,
@ -152,7 +154,8 @@ pre {
}
.color_sea,
.userpic7,
.media_location .fill {
.media_location .fill,
.media_video .fill {
background-color: #47bcd1;
}
.color_orange,
@ -313,6 +316,63 @@ a.block_link:hover {
padding-top: 4px;
font-size: 13px;
}
.default .video_file_wrap,
.default .animated_wrap {
position: relative;
}
.default .video_file,
.default .animated,
.default .photo,
.default .sticker {
display: block;
}
.video_duration {
background: rgba(0, 0, 0, .4);
padding: 0px 5px;
position: absolute;
z-index: 2;
border-radius: 2px;
right: 3px;
bottom: 3px;
color: #ffffff;
font-size: 11px;
}
.video_play_bg {
background: rgba(0, 0, 0, .4);
width: 40px;
height: 40px;
line-height: 0;
position: absolute;
z-index: 2;
border-radius: 50%;
overflow: hidden;
margin: -20px auto 0 -20px;
top: 50%;
left: 50%;
pointer-events: none;
}
.video_play {
position: absolute;
display: inline-block;
top: 50%;
left: 50%;
margin-left: -5px;
margin-top: -9px;
z-index: 1;
width: 0;
height: 0;
border-style: solid;
border-width: 9px 0 9px 14px;
border-color: transparent transparent transparent #fff;
}
.gif_play {
font-weight: 700;
color: #FFF;
display: block;
line-height: 40px;
font-size: 13px;
text-align: center;
}
.pagination {
text-align: center;
padding: 20px;
@ -349,32 +409,38 @@ a.block_link:hover {
.page_header a.content {
background-image: url(../images/back.png);
}
.media_call .thumb {
.media_call .fill {
background-image: url(../images/media_call.png)
}
.media_contact .thumb {
.media_contact .fill {
background-image: url(../images/media_contact.png)
}
.media_file .thumb {
.media_file .fill {
background-image: url(../images/media_file.png)
}
.media_game .thumb {
.media_game .fill {
background-image: url(../images/media_game.png)
}
.media_live_location .thumb,
.media_location .thumb,
.media_venue .thumb {
.media_live_location .fill,
.media_location .fill,
.media_venue .fill {
background-image: url(../images/media_location.png)
}
.media_audio_file .thumb {
.media_audio_file .fill {
background-image: url(../images/media_music.png)
}
.media_invoice .thumb {
.media_invoice .fill {
background-image: url(../images/media_shop.png)
}
.media_voice_message .thumb {
.media_voice_message .fill {
background-image: url(../images/media_voice.png)
}
.media_photo .fill {
background-image: url(../images/media_photo.png)
}
.media_video .fill {
background-image: url(../images/media_video.png)
}
@media only screen and (min--moz-device-pixel-ratio: 2), only screen and (-o-min-device-pixel-ratio: 2/1), only screen and (-webkit-min-device-pixel-ratio: 2), only screen and (min-device-pixel-ratio: 2) {
.section.calls {
@ -433,4 +499,10 @@ a.block_link:hover {
.media_voice_message .fill {
background-image: url(../images/media_voice@2x.png)
}
.media_photo .fill {
background-image: url(../images/media_photo@2x.png)
}
.media_video .fill {
background-image: url(../images/media_video@2x.png)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 B

View File

@ -15,8 +15,12 @@
<file alias="images/media_location@2x.png">../export_html/images/media_location@2x.png</file>
<file alias="images/media_music.png">../export_html/images/media_music.png</file>
<file alias="images/media_music@2x.png">../export_html/images/media_music@2x.png</file>
<file alias="images/media_photo.png">../export_html/images/media_photo.png</file>
<file alias="images/media_photo@2x.png">../export_html/images/media_photo@2x.png</file>
<file alias="images/media_shop.png">../export_html/images/media_shop.png</file>
<file alias="images/media_shop@2x.png">../export_html/images/media_shop@2x.png</file>
<file alias="images/media_video.png">../export_html/images/media_video.png</file>
<file alias="images/media_video@2x.png">../export_html/images/media_video@2x.png</file>
<file alias="images/media_voice.png">../export_html/images/media_voice.png</file>
<file alias="images/media_voice@2x.png">../export_html/images/media_voice@2x.png</file>
<file alias="images/section_calls.png">../export_html/images/section_calls.png</file>

View File

@ -501,41 +501,66 @@ UserpicsSlice ParseUserpicsSlice(
return result;
}
QString WriteImageThumb(
std::pair<QString, QSize> WriteImageThumb(
const QString &basePath,
const QString &largePath,
int width,
int height,
Fn<QSize(QSize)> convertSize,
base::optional<QByteArray> format,
base::optional<int> quality,
const QString &postfix) {
if (largePath.isEmpty()) {
return QString();
return {};
}
const auto path = basePath + largePath;
QImageReader reader(path);
if (!reader.canRead()) {
return QString();
return {};
}
const auto size = reader.size();
if (size.isEmpty()
|| size.width() >= kMaxImageSize
|| size.height() >= kMaxImageSize) {
return QString();
return {};
}
auto image = reader.read();
if (image.isNull()) {
return QString();
return {};
}
const auto format = reader.format();
const auto finalSize = convertSize(image.size());
if (finalSize.isEmpty()) {
return {};
}
image = std::move(image).scaled(
finalSize,
Qt::IgnoreAspectRatio,
Qt::SmoothTransformation);
const auto finalFormat = format ? *format : reader.format();
const auto finalQuality = quality ? *quality : reader.quality();
const auto lastSlash = largePath.lastIndexOf('/');
const auto firstDot = largePath.indexOf('.', lastSlash + 1);
const auto thumb = (firstDot >= 0)
? largePath.mid(0, firstDot) + postfix + largePath.mid(firstDot)
: largePath + postfix;
const auto result = Output::File::PrepareRelativePath(basePath, thumb);
if (!image.save(basePath + result, reader.format(), reader.quality())) {
return QString();
if (!image.save(basePath + result, finalFormat, finalQuality)) {
return {};
}
return result;
return { result, finalSize };
}
QString WriteImageThumb(
const QString &basePath,
const QString &largePath,
int width,
int height,
const QString &postfix) {
return WriteImageThumb(
basePath,
largePath,
[=](QSize size) { return QSize(width, height); },
base::none,
base::none,
postfix).first;
}
ContactInfo ParseContactInfo(const MTPUser &data) {
@ -780,7 +805,7 @@ Media ParseMedia(
auto result = Media();
data.match([&](const MTPDmessageMediaPhoto &data) {
result.content = data.has_photo()
auto photo = data.has_photo()
? ParsePhoto(
data.vphoto,
folder + "photos/"
@ -788,7 +813,9 @@ Media ParseMedia(
: Photo();
if (data.has_ttl_seconds()) {
result.ttl = data.vttl_seconds.v;
photo.image.file = File();
}
result.content = photo;
}, [&](const MTPDmessageMediaGeo &data) {
result.content = ParseGeoPoint(data.vgeo);
}, [&](const MTPDmessageMediaContact &data) {
@ -796,12 +823,14 @@ Media ParseMedia(
}, [&](const MTPDmessageMediaUnsupported &data) {
result.content = UnsupportedMedia();
}, [&](const MTPDmessageMediaDocument &data) {
result.content = data.has_document()
auto document = data.has_document()
? ParseDocument(context, data.vdocument, folder)
: Document();
if (data.has_ttl_seconds()) {
result.ttl = data.vttl_seconds.v;
document.file = File();
}
result.content = document;
}, [&](const MTPDmessageMediaWebPage &data) {
// Ignore web pages.
}, [&](const MTPDmessageMediaVenue &data) {
@ -1053,6 +1082,10 @@ Message ParseMessage(
context,
data.vmedia,
mediaFolder);
if (result.media.ttl && !data.is_out()) {
result.media.file() = File();
result.media.thumb().file = File();
}
context.botId = 0;
}
result.text = ParseText(

View File

@ -11,6 +11,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
#include "base/optional.h"
#include "base/variant.h"
#include <QtCore/QSize>
#include <QtCore/QString>
#include <QtCore/QByteArray>
@ -82,6 +83,14 @@ struct Image {
File file;
};
std::pair<QString, QSize> WriteImageThumb(
const QString &basePath,
const QString &largePath,
Fn<QSize(QSize)> convertSize,
base::optional<QByteArray> format = base::none,
base::optional<int> quality = base::none,
const QString &postfix = "_thumb");
QString WriteImageThumb(
const QString &basePath,
const QString &largePath,

View File

@ -1096,6 +1096,11 @@ void ApiWrap::appendChatsSlice(
auto filtered = ranges::view::all(
info.list
) | ranges::view::filter([&](const Data::DialogInfo &info) {
#ifdef _DEBUG
return (info.name == "Anta");
#else
#error "test"
#endif
return (types & SettingsFromDialogsType(info.type)) != 0;
});
auto &list = to.info.list;
@ -1365,10 +1370,12 @@ bool ApiWrap::processFileLoad(
return Type::Photo;
}) : Type(0);
const auto limit = _settings->media.sizeLimit;
if ((_settings->media.types & type) != type) {
file.skipReason = SkipReason::FileType;
return true;
} else if (file.size >= _settings->media.sizeLimit) {
} else if ((message ? message->file().size : file.size) >= limit) {
// Don't load thumbs for large files that we skip.
file.skipReason = SkipReason::FileSize;
return true;
}

View File

@ -9,6 +9,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
#include <QtCore/QFile>
#include <QtCore/QFileInfo>
#include <QtCore/QDir>
#include <QtCore/QSize>
#include <QtCore/QTextStream>
#include <QtCore/QDateTime>
#include <QtCore/QString>

View File

@ -11,6 +11,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
#include "export/data/export_data_types.h"
#include "core/utils.h"
#include <QtCore/QSize>
#include <QtCore/QFile>
#include <QtCore/QDateTime>
@ -28,6 +29,14 @@ constexpr auto kServiceMessagePhotoSize = 60;
constexpr auto kHistoryUserpicSize = 42;
constexpr auto kSavedMessagesColorIndex = 3;
constexpr auto kJoinWithinSeconds = 900;
constexpr auto kPhotoMaxWidth = 520;
constexpr auto kPhotoMaxHeight = 520;
constexpr auto kPhotoMinWidth = 80;
constexpr auto kPhotoMinHeight = 80;
constexpr auto kStickerMaxWidth = 384;
constexpr auto kStickerMaxHeight = 384;
constexpr auto kStickerMinWidth = 80;
constexpr auto kStickerMinHeight = 80;
const auto kLineBreak = QByteArrayLiteral("<br>");
@ -41,6 +50,50 @@ bool IsGlobalLink(const QString &link) {
|| link.startsWith(qstr("https://"), Qt::CaseInsensitive);
}
QByteArray NoFileDescription(Data::File::SkipReason reason) {
using SkipReason = Data::File::SkipReason;
switch (reason) {
case SkipReason::Unavailable:
return "Unavailable, please try again later.";
case SkipReason::FileSize:
return "Exceeds maximum size, "
"change data exporting settings to download.";
case SkipReason::FileType:
return "Not included, "
"change data exporting settings to download.";
case SkipReason::None:
return "";
}
Unexpected("Skip reason in NoFileDescription.");
}
auto CalculateThumbSize(
int maxWidth,
int maxHeight,
int minWidth,
int minHeight,
bool expandForRetina = false) {
return [=](QSize largeSize) {
const auto multiplier = (expandForRetina ? 2 : 1);
const auto checkWidth = largeSize.width() * multiplier;
const auto checkHeight = largeSize.height() * multiplier;
const auto smallSize = (checkWidth > maxWidth
|| checkHeight > maxHeight)
? largeSize.scaled(
maxWidth,
maxHeight,
Qt::KeepAspectRatio)
: largeSize;
const auto retinaSize = QSize(
smallSize.width() & ~0x01,
smallSize.height() & ~0x01);
return (retinaSize.width() < kPhotoMinWidth
|| retinaSize.height() < kPhotoMinHeight)
? QSize()
: retinaSize;
};
}
QByteArray SerializeString(const QByteArray &value) {
const auto size = value.size();
const auto begin = value.data();
@ -517,11 +570,6 @@ public:
const QString &basePath,
const PeersMap &peers,
const QString &internalLinksDomain);
[[nodiscard]] QByteArray pushMedia(
const Data::Message &message,
const QString &basePath,
const PeersMap &peers,
const QString &internalLinksDomain);
[[nodiscard]] Result writeBlock(const QByteArray &block);
@ -554,6 +602,24 @@ private:
const QString &basePath,
const PeersMap &peers,
const QString &internalLinksDomain) const;
[[nodiscard]] QByteArray pushMedia(
const Data::Message &message,
const QString &basePath,
const PeersMap &peers,
const QString &internalLinksDomain);
[[nodiscard]] QByteArray pushGenericMedia(const MediaData &data);
[[nodiscard]] QByteArray pushStickerMedia(
const Data::Document &data,
const QString &basePath);
[[nodiscard]] QByteArray pushAnimatedMedia(
const Data::Document &data,
const QString &basePath);
[[nodiscard]] QByteArray pushVideoFileMedia(
const Data::Document &data,
const QString &basePath);
[[nodiscard]] QByteArray pushPhotoMedia(
const Data::Photo &data,
const QString &basePath);
File _file;
bool _closed = false;
@ -1137,9 +1203,29 @@ QByteArray HtmlWriter::Wrap::pushMedia(
basePath,
peers,
internalLinksDomain);
if (data.classes.isEmpty()) {
return QByteArray();
if (!data.classes.isEmpty()) {
return pushGenericMedia(data);
}
const auto &content = message.media.content;
if (const auto document = base::get_if<Data::Document>(&content)) {
Assert(!message.media.ttl);
if (document->isSticker) {
return pushStickerMedia(*document, basePath);
} else if (document->isAnimated) {
return pushAnimatedMedia(*document, basePath);
} else if (document->isVideoFile) {
return pushVideoFileMedia(*document, basePath);
}
Unexpected("Non generic document in HtmlWriter::Wrap::pushMedia.");
} else if (const auto photo = base::get_if<Data::Photo>(&content)) {
Assert(!message.media.ttl);
return pushPhotoMedia(*photo, basePath);
}
Assert(!content.has_value());
return QByteArray();
}
QByteArray HtmlWriter::Wrap::pushGenericMedia(const MediaData &data) {
auto result = pushDiv("media_wrap clearfix");
if (data.link.isEmpty()) {
result.append(pushDiv("media clearfix pull_left " + data.classes));
@ -1189,6 +1275,222 @@ QByteArray HtmlWriter::Wrap::pushMedia(
return result;
}
QByteArray HtmlWriter::Wrap::pushStickerMedia(
const Data::Document &data,
const QString &basePath) {
using namespace Data;
const auto [thumb, size] = WriteImageThumb(
basePath,
data.file.relativePath,
CalculateThumbSize(
kStickerMaxWidth,
kStickerMaxHeight,
kStickerMinWidth,
kStickerMinHeight),
"PNG",
-1);
if (thumb.isEmpty()) {
auto generic = MediaData();
generic.title = "Sticker";
generic.status = data.stickerEmoji;
if (data.file.relativePath.isEmpty()) {
generic.status += ", " + FormatFileSize(data.file.size);
} else {
generic.link = data.file.relativePath;
}
generic.description = NoFileDescription(data.file.skipReason);
generic.classes = "media_photo";
return pushGenericMedia(generic);
}
auto result = pushDiv("media_wrap clearfix");
result.append(pushTag("a", {
{ "class", "sticker_wrap clearfix pull_left" },
{
"href",
relativePath(data.file.relativePath).toUtf8()
}
}));
const auto sizeStyle = "width: "
+ NumberToString(size.width() / 2)
+ "px; height: "
+ NumberToString(size.height() / 2)
+ "px";
result.append(pushTag("img", {
{ "class", "sticker" },
{ "style", sizeStyle },
{ "src", relativePath(thumb).toUtf8() },
{ "empty", "" }
}));
result.append(popTag());
result.append(popTag());
return result;
}
QByteArray HtmlWriter::Wrap::pushAnimatedMedia(
const Data::Document &data,
const QString &basePath) {
using namespace Data;
auto size = QSize(data.width, data.height);
auto thumbSize = CalculateThumbSize(
kPhotoMaxWidth,
kPhotoMaxHeight,
kPhotoMinWidth,
kPhotoMinHeight,
true)(size);
if (data.thumb.file.relativePath.isEmpty()
|| data.file.relativePath.isEmpty()
|| !thumbSize.width()
|| !thumbSize.height()) {
auto generic = MediaData();
generic.title = "Animation";
generic.status = FormatFileSize(data.file.size);
generic.link = data.file.relativePath;
generic.description = NoFileDescription(data.file.skipReason);
generic.classes = "media_video";
return pushGenericMedia(generic);
}
auto result = pushDiv("media_wrap clearfix");
result.append(pushTag("a", {
{ "class", "animated_wrap clearfix pull_left" },
{
"href",
relativePath(data.file.relativePath).toUtf8()
}
}));
result.append(pushDiv("video_play_bg"));
result.append(pushDiv("gif_play"));
result.append("GIF");
result.append(popTag());
result.append(popTag());
const auto sizeStyle = "width: "
+ NumberToString(thumbSize.width() / 2)
+ "px; height: "
+ NumberToString(thumbSize.height() / 2)
+ "px";
result.append(pushTag("img", {
{ "class", "animated" },
{ "style", sizeStyle },
{ "src", relativePath(data.thumb.file.relativePath).toUtf8() },
{ "empty", "" }
}));
result.append(popTag());
result.append(popTag());
return result;
}
QByteArray HtmlWriter::Wrap::pushVideoFileMedia(
const Data::Document &data,
const QString &basePath) {
using namespace Data;
auto size = QSize(data.width, data.height);
auto thumbSize = CalculateThumbSize(
kPhotoMaxWidth,
kPhotoMaxHeight,
kPhotoMinWidth,
kPhotoMinHeight,
true)(size);
if (data.thumb.file.relativePath.isEmpty()
|| data.file.relativePath.isEmpty()
|| !thumbSize.width()
|| !thumbSize.height()) {
auto generic = MediaData();
generic.title = "Video file";
generic.status = FormatDuration(data.duration);
if (data.file.relativePath.isEmpty()) {
generic.status += ", " + FormatFileSize(data.file.size);
} else {
generic.link = data.file.relativePath;
}
generic.description = NoFileDescription(data.file.skipReason);
generic.classes = "media_video";
return pushGenericMedia(generic);
}
auto result = pushDiv("media_wrap clearfix");
result.append(pushTag("a", {
{ "class", "video_file_wrap clearfix pull_left" },
{
"href",
relativePath(data.file.relativePath).toUtf8()
}
}));
result.append(pushDiv("video_play_bg"));
result.append(pushDiv("video_play"));
result.append(popTag());
result.append(popTag());
result.append(pushDiv("video_duration"));
result.append(FormatDuration(data.duration));
result.append(popTag());
const auto sizeStyle = "width: "
+ NumberToString(thumbSize.width() / 2)
+ "px; height: "
+ NumberToString(thumbSize.height() / 2)
+ "px";
result.append(pushTag("img", {
{ "class", "video_file" },
{ "style", sizeStyle },
{ "src", relativePath(data.thumb.file.relativePath).toUtf8() },
{ "empty", "" }
}));
result.append(popTag());
result.append(popTag());
return result;
}
QByteArray HtmlWriter::Wrap::pushPhotoMedia(
const Data::Photo &data,
const QString &basePath) {
using namespace Data;
const auto [thumb, size] = WriteImageThumb(
basePath,
data.image.file.relativePath,
CalculateThumbSize(
kPhotoMaxWidth,
kPhotoMaxHeight,
kPhotoMinWidth,
kPhotoMinHeight));
if (thumb.isEmpty()) {
auto generic = MediaData();
generic.title = "Photo";
generic.status = NumberToString(data.image.width)
+ "x"
+ NumberToString(data.image.height);
if (data.image.file.relativePath.isEmpty()) {
generic.status += ", " + FormatFileSize(data.image.file.size);
} else {
generic.link = data.image.file.relativePath;
}
generic.description = NoFileDescription(data.image.file.skipReason);
generic.classes = "media_photo";
return pushGenericMedia(generic);
}
auto result = pushDiv("media_wrap clearfix");
result.append(pushTag("a", {
{ "class", "photo_wrap clearfix pull_left" },
{
"href",
relativePath(data.image.file.relativePath).toUtf8()
}
}));
const auto sizeStyle = "width: "
+ NumberToString(size.width() / 2)
+ "px; height: "
+ NumberToString(size.height() / 2)
+ "px";
result.append(pushTag("img", {
{ "class", "photo" },
{ "style", sizeStyle },
{ "src", relativePath(thumb).toUtf8() },
{ "empty", "" }
}));
result.append(popTag());
result.append(popTag());
return result;
}
MediaData HtmlWriter::Wrap::prepareMediaData(
const Data::Message &message,
const QString &basePath,
@ -1222,50 +1524,64 @@ MediaData HtmlWriter::Wrap::prepareMediaData(
return result;
}
message.media.content.match([&](const Photo &photo) {
// #TODO export: photo + self destruct (ttl)
result.title = "Photo";
result.status = NumberToString(photo.image.width)
+ "x"
+ NumberToString(photo.image.height);
result.classes = "media_file"; // #TODO export
result.link = photo.image.file.relativePath;
message.media.content.match([&](const Photo &data) {
if (message.media.ttl) {
result.title = "Self-destructing photo";
result.status = data.id
? "Please view it on your mobile"
: "Expired";
result.classes = "media_photo";
return;
}
// At least try to pushPhotoMedia.
}, [&](const Document &data) {
// #TODO export: sticker + thumb (video, video message) + self destruct (ttl)
if (message.media.ttl) {
result.title = "Self-destructing video";
result.status = data.id
? "Please view it on your mobile"
: "Expired";
result.classes = "media_video";
return;
}
const auto hasFile = !data.file.relativePath.isEmpty();
result.link = data.file.relativePath;
result.description = NoFileDescription(data.file.skipReason);
if (data.isSticker) {
result.title = "Sticker";
result.status = data.stickerEmoji;
result.classes = "media_file"; // #TODO export
// At least try to pushStickerMedia.
} else if (data.isVideoMessage) {
result.title = "Video message";
result.status = FormatDuration(data.duration);
if (!hasFile) {
result.status += ", " + FormatFileSize(data.file.size);
}
result.thumb = data.thumb.file.relativePath;
result.classes = "media_file";
result.classes = "media_video";
} else if (data.isVoiceMessage) {
result.title = "Voice message";
result.status = FormatDuration(data.duration);
if (!hasFile) {
result.status += ", " + FormatFileSize(data.file.size);
}
result.classes = "media_voice_message";
} else if (data.isAnimated) {
result.title = "Animation";
result.status = FormatFileSize(data.duration);
result.classes = "media_file"; // #TODO export
// At least try to pushAnimatedMedia.
} else if (data.isVideoFile) {
result.title = "Video file";
result.status = FormatDuration(data.duration);
result.classes = "media_file"; // #TODO export
// At least try to pushVideoFileMedia.
} else if (data.isAudioFile) {
result.title = (data.songPerformer.isEmpty()
|| data.songTitle.isEmpty())
? QByteArray("Audio file")
: data.songPerformer + " \xe2\x80\x93 " + data.songTitle;
result.status = FormatDuration(data.duration);
if (!hasFile) {
result.status += ", " + FormatFileSize(data.file.size);
}
result.classes = "media_audio_file";
} else {
result.title = data.name.isEmpty()
? QByteArray("File")
: data.name;
result.status = FormatFileSize(data.duration);
result.status = FormatFileSize(data.file.size);
result.classes = "media_file";
}
}, [&](const SharedContact &data) {
@ -1434,7 +1750,9 @@ Result HtmlWriter::start(
"images/media_game.png",
"images/media_location.png",
"images/media_music.png",
"images/media_photo.png",
"images/media_shop.png",
"images/media_video.png",
"images/media_voice.png",
"images/section_calls.png",
"images/section_chats.png",