diff --git a/src/components/groupedLayout.ts b/src/components/groupedLayout.ts new file mode 100644 index 00000000..4b12c4cf --- /dev/null +++ b/src/components/groupedLayout.ts @@ -0,0 +1,442 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ + +type Size = {w: number, h: number}; +export type GroupMediaLayout = { + geometry: { + x: number, + y: number, + width: number, + height: number + }, + sides: number +}; +type Attempt = { + lineCounts: number[], + heights: number[] +}; +export const RectPart = { + None: 0, + Top: 1, + Right: 2, + Bottom: 4, + Left: 8 +}; + +let accumulate = (arr: number[], initialValue: number) => arr.reduce((acc, value) => acc + value, initialValue); + +// https://github.com/telegramdesktop/tdesktop/blob/74d848311b31ef0eb6d2c43a4d30ade8f1d2d9fb/Telegram/SourceFiles/core/utils.h#L128 +function snap(v: T, _min: T, _max: T): T { + return (v < _min) ? _min : ((v > _max) ? _max : v); +} + +// https://github.com/telegramdesktop/tdesktop/blob/4669c07dc5335cbf4795bbbe5b0ab7c007b9aee2/Telegram/SourceFiles/ui/grouped_layout.cpp +export class Layouter { + private count: number; + private ratios: number[]; + private proportions: string; + private averageRatio: number; + private maxSizeRatio: number; + + constructor(private sizes: Size[], private maxWidth: number, private minWidth: number, private spacing: number, private maxHeight = maxWidth) { + this.count = sizes.length; + this.ratios = Layouter.countRatios(sizes); + this.proportions = Layouter.countProportions(this.ratios); + this.averageRatio = accumulate(this.ratios, 1) / this.count; // warn + this.maxSizeRatio = maxWidth / this.maxHeight; + } + + public layout(): GroupMediaLayout[] { + if(!this.count) return []; + //else if(this.count == 1) return this.layoutOne(); + + if(this.count >= 5 || this.ratios.find(r => r > 2)) { + return new ComplexLayouter(this.ratios, this.averageRatio, this.maxWidth, this.minWidth, this.spacing).layout(); + } + + if(this.count == 2) return this.layoutTwo(); + else if(this.count == 3) return this.layoutThree(); + return this.layoutFour(); + } + + private layoutTwo(): ReturnType { + if((this.proportions == "ww") + && (this.averageRatio > 1.4 * this.maxSizeRatio) + && (this.ratios[1] - this.ratios[0] < 0.2)) { + return this.layoutTwoTopBottom(); + } else if(this.proportions == "ww" || this.proportions == "qq") { + return this.layoutTwoLeftRightEqual(); + } + return this.layoutTwoLeftRight(); + } + + private layoutThree(): ReturnType { + console.log('layoutThree:', this); + if(this.proportions[0] == 'n') { + return this.layoutThreeLeftAndOther(); + } + return this.layoutThreeTopAndOther(); + } + + private layoutFour(): ReturnType { + if(this.proportions[0] == 'w') { + return this.layoutFourTopAndOther(); + } + return this.layoutFourLeftAndOther(); + } + + private layoutTwoTopBottom(): ReturnType { + const width = this.maxWidth; + const height = Math.round(Math.min( + width / this.ratios[0], + Math.min( + width / this.ratios[1], + (this.maxHeight - this.spacing) / 2))); + + return [ + { + geometry: {x: 0, y: 0, width, height}, + sides: RectPart.Left | RectPart.Top | RectPart.Right + }, + { + geometry: {x: 0, y: height + this.spacing, width, height}, + sides: RectPart.Left | RectPart.Bottom | RectPart.Right + }, + ]; + } + + private layoutTwoLeftRightEqual(): ReturnType { + const width = (this.maxWidth - this.spacing) / 2; + const height = Math.round(Math.min( + width / this.ratios[0], + Math.min(width / this.ratios[1], this.maxHeight * 1))); + + return [ + { + geometry: {x: 0, y: 0, width, height}, + sides: RectPart.Top | RectPart.Left | RectPart.Bottom + }, + { + geometry: {x: width + this.spacing, y: 0, width, height}, + sides: RectPart.Top | RectPart.Right | RectPart.Bottom + }, + ]; + } + + private layoutTwoLeftRight(): ReturnType { + const minimalWidth = Math.round(this.minWidth * 1.5); + const secondWidth = Math.min( + Math.round(Math.max( + 0.4 * (this.maxWidth - this.spacing), + (this.maxWidth - this.spacing) / this.ratios[0] + / (1 / this.ratios[0] + 1 / this.ratios[1]))), + this.maxWidth - this.spacing - minimalWidth); + const firstWidth = this.maxWidth + - secondWidth + - this.spacing; + const height = Math.min( + this.maxHeight, + Math.round(Math.min( + firstWidth / this.ratios[0], + secondWidth / this.ratios[1]))); + + return [ + { + geometry: {x: 0, y: 0, width: firstWidth, height}, + sides: RectPart.Top | RectPart.Left | RectPart.Bottom + }, + { + geometry: {x: firstWidth + this.spacing, y: 0, width: secondWidth, height}, + sides: RectPart.Top | RectPart.Right | RectPart.Bottom + }, + ]; + } + + private layoutThreeLeftAndOther(): ReturnType { + const firstHeight = this.maxHeight; + const thirdHeight = Math.round(Math.min( + (this.maxHeight - this.spacing) / 2., + (this.ratios[1] * (this.maxWidth - this.spacing) + / (this.ratios[2] + this.ratios[1])))); + const secondHeight = firstHeight + - thirdHeight + - this.spacing; + const rightWidth = Math.max( + this.minWidth, + Math.round(Math.min( + (this.maxWidth - this.spacing) / 2., + Math.min( + thirdHeight * this.ratios[2], + secondHeight * this.ratios[1])))); + const leftWidth = Math.min( + Math.round(firstHeight * this.ratios[0]), + this.maxWidth - this.spacing - rightWidth); + + return [ + { + geometry: {x: 0, y: 0, width: leftWidth, height: firstHeight}, + sides: RectPart.Top | RectPart.Left | RectPart.Bottom + }, + { + geometry: {x: leftWidth + this.spacing, y: 0, width: rightWidth, height: secondHeight}, + sides: RectPart.Top | RectPart.Right + }, + { + geometry: {x: leftWidth + this.spacing, y: secondHeight + this.spacing, width: rightWidth, height: thirdHeight}, + sides: RectPart.Bottom | RectPart.Right + }, + ]; + } + + private layoutThreeTopAndOther(): ReturnType { + const firstWidth = this.maxWidth; + const firstHeight = Math.round(Math.min( + firstWidth / this.ratios[0], + (this.maxHeight - this.spacing) * 0.66)); + const secondWidth = (this.maxWidth - this.spacing) / 2; + const secondHeight = Math.min( + this.maxHeight - firstHeight - this.spacing, + Math.round(Math.min( + secondWidth / this.ratios[1], + secondWidth / this.ratios[2]))); + const thirdWidth = firstWidth - secondWidth - this.spacing; + + return [ + { + geometry: {x: 0, y: 0, width: firstWidth, height: firstHeight}, + sides: RectPart.Left | RectPart.Top | RectPart.Right + }, + { + geometry: {x: 0, y: firstHeight + this.spacing, width: secondWidth, height: secondHeight}, + sides: RectPart.Bottom | RectPart.Left + }, + { + geometry: {x: secondWidth + this.spacing, y: firstHeight + this.spacing, width: thirdWidth, height: secondHeight}, + sides: RectPart.Bottom | RectPart.Right + }, + ]; + } + + private layoutFourTopAndOther(): ReturnType { + const w = this.maxWidth; + const h0 = Math.round(Math.min( + w / this.ratios[0], + (this.maxHeight - this.spacing) * 0.66)); + const h = Math.round( + (this.maxWidth - 2 * this.spacing) + / (this.ratios[1] + this.ratios[2] + this.ratios[3])); + const w0 = Math.max( + this.minWidth, + Math.round(Math.min( + (this.maxWidth - 2 * this.spacing) * 0.4, + h * this.ratios[1]))); + const w2 = Math.round(Math.max( + Math.max( + this.minWidth * 1., + (this.maxWidth - 2 * this.spacing) * 0.33), + h * this.ratios[3])); + const w1 = w - w0 - w2 - 2 * this.spacing; + const h1 = Math.min( + this.maxHeight - h0 - this.spacing, + h); + + return [ + { + geometry: {x: 0, y: 0, width: w, height: h0}, + sides: RectPart.Left | RectPart.Top | RectPart.Right + }, + { + geometry: {x: 0, y: h0 + this.spacing, width: w0, height: h1}, + sides: RectPart.Bottom | RectPart.Left + }, + { + geometry: {x: w0 + this.spacing, y: h0 + this.spacing, width: w1, height: h1}, + sides: RectPart.Bottom, + }, + { + geometry: {x: w0 + this.spacing + w1 + this.spacing, y: h0 + this.spacing, width: w2, height: h1}, + sides: RectPart.Right | RectPart.Bottom + }, + ]; + } + + private layoutFourLeftAndOther(): ReturnType { + const h = this.maxHeight; + const w0 = Math.round(Math.min( + h * this.ratios[0], + (this.maxWidth - this.spacing) * 0.6)); + + const w = Math.round( + (this.maxHeight - 2 * this.spacing) + / (1. / this.ratios[1] + 1. / this.ratios[2] + 1. / this.ratios[3]) + ); + const h0 = Math.round(w / this.ratios[1]); + const h1 = Math.round(w / this.ratios[2]); + const h2 = h - h0 - h1 - 2 * this.spacing; + const w1 = Math.max( + this.minWidth, + Math.min(this.maxWidth - w0 - this.spacing, w)); + + return [ + { + geometry: {x: 0, y: 0, width: w0, height: h}, + sides: RectPart.Top | RectPart.Left | RectPart.Bottom + }, + { + geometry: {x: w0 + this.spacing, y: 0, width: w1, height: h0}, + sides: RectPart.Top | RectPart.Right + }, + { + geometry: {x: w0 + this.spacing, y: h0 + this.spacing, width: w1, height: h1}, + sides: RectPart.Right + }, + { + geometry: {x: w0 + this.spacing, y: h0 + h1 + 2 * this.spacing, width: w1, height: h2}, + sides: RectPart.Bottom | RectPart.Right + }, + ]; + } + + private static countRatios(sizes: Size[]) { + return sizes.map(size => size.w / size.h); + } + + private static countProportions(ratios: number[]) { + return ratios.map(ratio => (ratio > 1.2) ? 'w' : (ratio < 0.8) ? 'n' : 'q').join(''); + } +} + +class ComplexLayouter { + private ratios: number[]; + private count: number; + + constructor(ratios: number[], private averageRatio: number, private maxWidth: number, private minWidth: number, private spacing: number, private maxHeight = maxWidth * 4 / 3) { + this.ratios = ComplexLayouter.cropRatios(ratios, averageRatio); + this.count = ratios.length; + } + + private static cropRatios(ratios: number[], averageRatio: number) { + const kMaxRatio = 2.75; + const kMinRatio = 0.6667; + return ratios.map(ratio => { + return averageRatio > 1.1 + ? snap(ratio, 1., kMaxRatio) + : snap(ratio, kMinRatio, 1.); + }); + } + + public layout(): GroupMediaLayout[] { + let result = new Array(this.count); + + let attempts: Attempt[] = []; + const multiHeight = (offset: number, count: number) => { + const ratios = this.ratios.slice(offset, offset + count); // warn + const sum = accumulate(ratios, 0); + return (this.maxWidth - (count - 1) * this.spacing) / sum; + }; + const pushAttempt = (lineCounts: number[]) => { + let heights: number[] = []; + let offset = 0; + for(let count of lineCounts) { + heights.push(multiHeight(offset, count)); + offset += count; + } + attempts.push({lineCounts, heights}); // warn + }; + + for(let first = 1; first != this.count; ++first) { + const second = this.count - first; + if(first > 3 || second > 3) { + continue; + } + pushAttempt([first, second]); + } + for(let first = 1; first != this.count - 1; ++first) { + for(let second = 1; second != this.count - first; ++second) { + const third = this.count - first - second; + if((first > 3) + || (second > ((this.averageRatio < 0.85) ? 4 : 3)) + || (third > 3)) { + continue; + } + pushAttempt([first, second, third]); + } + } + for(let first = 1; first != this.count - 1; ++first) { + for(let second = 1; second != this.count - first; ++second) { + for(let third = 1; third != this.count - first - second; ++third) { + const fourth = this.count - first - second - third; + if(first > 3 || second > 3 || third > 3 || fourth > 3) { + continue; + } + pushAttempt([first, second, third, fourth]); + } + } + } + + let optimalAttempt: Attempt = null; + let optimalDiff = 0; + for(const attempt of attempts) { + const {heights, lineCounts: counts} = attempt; + const lineCount = counts.length; + const totalHeight = accumulate(heights, 0) + + this.spacing * (lineCount - 1); + const minLineHeight = Math.min(...heights); + const maxLineHeight = Math.max(...heights); + const bad1 = (minLineHeight < this.minWidth) ? 1.5 : 1; + const bad2 = (() => { + for(let line = 1; line != lineCount; ++line) { + if(counts[line - 1] > counts[line]) { + return 1.5; + } + } + return 1.; + })(); + const diff = Math.abs(totalHeight - this.maxHeight) * bad1 * bad2; + if(!optimalAttempt || diff < optimalDiff) { + optimalAttempt = attempt; + optimalDiff = diff; + } + } + + const optimalCounts = optimalAttempt.lineCounts; + const optimalHeights = optimalAttempt.heights; + const rowCount = optimalCounts.length; + + let index = 0; + let y = 0; + for(let row = 0; row != rowCount; ++row) { + const colCount = optimalCounts[row]; + const lineHeight = optimalHeights[row]; + const height = Math.round(lineHeight); + + let x = 0; + for(let col = 0; col != colCount; ++col) { + const sides = RectPart.None + | (row == 0 ? RectPart.Top : RectPart.None) + | (row == rowCount - 1 ? RectPart.Bottom : RectPart.None) + | (col == 0 ? RectPart.Left : RectPart.None) + | (col == colCount - 1 ? RectPart.Right : RectPart.None); + + const ratio = this.ratios[index]; + const width = (col == colCount - 1) + ? (this.maxWidth - x) + : Math.round(ratio * lineHeight); + result[index] = { + geometry: {x, y, width, height}, + sides + }; + + x += width + this.spacing; + ++index; + } + y += height + this.spacing; + } + + return result; + } +} \ No newline at end of file diff --git a/src/lib/appManagers/appImManager.ts b/src/lib/appManagers/appImManager.ts index 83177b02..6e602f19 100644 --- a/src/lib/appManagers/appImManager.ts +++ b/src/lib/appManagers/appImManager.ts @@ -17,14 +17,15 @@ import appSidebarLeft from "./appSidebarLeft"; import appChatsManager from "./appChatsManager"; import appMessagesIDsManager from "./appMessagesIDsManager"; import apiUpdatesManager from './apiUpdatesManager'; -import { wrapDocument, wrapPhoto, wrapVideo, wrapSticker, wrapReply } from '../../components/wrappers'; +import { wrapDocument, wrapPhoto, wrapVideo, wrapSticker, wrapReply, MTPhotoSize } from '../../components/wrappers'; import ProgressivePreloader from '../../components/preloader'; -import { openBtnMenu } from '../../components/misc'; +import { openBtnMenu, renderImageFromUrl } from '../../components/misc'; import { ChatInput } from '../../components/chatInput'; import Scrollable from '../../components/scrollable'; import BubbleGroups from '../../components/bubbleGroups'; import LazyLoadQueue from '../../components/lazyLoadQueue'; import appDocsManager from './appDocsManager'; +import { Layouter, RectPart } from '../../components/groupedLayout'; console.log('appImManager included!'); @@ -1195,6 +1196,13 @@ export class AppImManager { public renderMessage(message: any, reverse = false, multipleRender = false, bubble: HTMLDivElement = null, updatePosition = true) { this.log('message to render:', message); if(message.deleted) return; + else if(message.grouped_id) { // will render only last album's message + let storage = appMessagesManager.groupedMessagesStorage[message.grouped_id]; + let maxID = Math.max(...Object.keys(storage).map(i => +i)); + if(message.mid < maxID) { + return; + } + } let peerID = this.peerID; let our = message.fromID == this.myID; @@ -1408,10 +1416,111 @@ export class AppImManager { ////////this.log('messageMediaPhoto', photo); bubble.classList.add('hide-name', 'photo'); - - wrapPhoto(photo.id, message, attachmentDiv, undefined, undefined, true, our, this.lazyLoadQueue, () => { - return this.peerID == peerID; - }); + + if(message.grouped_id) { + bubble.classList.add('is-album'); + + let items: {size: MTPhotoSize, media: any}[] = []; + + // higher msgID will be the last in album + let storage = appMessagesManager.groupedMessagesStorage[message.grouped_id]; + for(let mid in storage) { + let m = appMessagesManager.getMessage(+mid); + let media = m.media.photo || m.media.document; + + let size = appPhotosManager.choosePhotoSize(media, 380, 380); + items.push({size, media}); + } + + let spacing = 2; + let layouter = new Layouter(items.map(i => ({w: i.size.w, h: i.size.h})), 451, 100, spacing); + let layout = layouter.layout(); + this.log('layout:', layout); + + /* let borderRadius = window.getComputedStyle(realParent).getPropertyValue('border-radius'); + let brSplitted = fillPropertyValue(borderRadius); */ + + for(let {geometry, sides} of layout) { + let {size, media} = items.shift(); + let div = document.createElement('div'); + div.classList.add('album-item'); + + div.style.width = geometry.width + 'px'; + div.style.height = geometry.height + 'px'; + div.style.top = geometry.y + 'px'; + div.style.left = geometry.x + 'px'; + + if(sides & RectPart.Right) { + attachmentDiv.style.width = geometry.width + geometry.x + 'px'; + } + + if(sides & RectPart.Bottom) { + attachmentDiv.style.height = geometry.height + geometry.y + 'px'; + } + + if(sides & RectPart.Left && sides & RectPart.Top) { + div.style.borderTopLeftRadius = 'inherit'; + } + + if(sides & RectPart.Left && sides & RectPart.Bottom) { + div.style.borderBottomLeftRadius = 'inherit'; + } + + if(sides & RectPart.Right && sides & RectPart.Top) { + div.style.borderTopRightRadius = 'inherit'; + } + + if(sides & RectPart.Right && sides & RectPart.Bottom) { + div.style.borderBottomRightRadius = 'inherit'; + } + + /* if(geometry.y != 0) { + div.style.marginTop = spacing + 'px'; + } + + if(geometry.x != 0) { + div.style.marginLeft = spacing + 'px'; + } */ + + let preloader = new ProgressivePreloader(div); + + let load = () => appPhotosManager.preloadPhoto(media._ == 'photo' ? media.id : media, size) + .then((blob) => { + if(this.peerID != peerID) { + this.log.warn('peer changed'); + return; + } + + preloader.detach(); + if(media && media.url) { + renderImageFromUrl(div, media.url); + }/* else { + let url = URL.createObjectURL(blob); + this.urlsToRevoke.push(url); + + let img = new Image(); + img.src = url; + img.onload = () => { + div.style.backgroundImage = 'url(' + url + ')'; + }; + } */ + + //div.style.backgroundImage = 'url(' + url + ')'; + }); + + load(); + + // @ts-ignore + //div.style.backgroundColor = '#' + Math.floor(Math.random() * (2 ** 24 - 1)).toString(16).padStart(6, '0'); + + attachmentDiv.append(div); + } + } else { + wrapPhoto(photo.id, message, attachmentDiv, undefined, undefined, true, our, this.lazyLoadQueue, () => { + return this.peerID == peerID; + }); + } + break; } diff --git a/src/lib/appManagers/appMediaViewer.ts b/src/lib/appManagers/appMediaViewer.ts index c2f7d291..2aa8fdb9 100644 --- a/src/lib/appManagers/appMediaViewer.ts +++ b/src/lib/appManagers/appMediaViewer.ts @@ -4,7 +4,7 @@ import appMessagesManager from "./appMessagesManager"; import { RichTextProcessor } from "../richtextprocessor"; import { logger } from "../polyfill"; import ProgressivePreloader from "../../components/preloader"; -import { findUpClassName, $rootScope, generatePathData } from "../utils"; +import { findUpClassName, $rootScope, generatePathData, fillPropertyValue } from "../utils"; import appDocsManager from "./appDocsManager"; import VideoPlayer from "../mediaPlayer"; import { renderImageFromUrl } from "../../components/misc"; @@ -213,13 +213,7 @@ export class AppMediaViewer { } let borderRadius = window.getComputedStyle(realParent).getPropertyValue('border-radius'); - let brSplitted = borderRadius.split(' '); - if(brSplitted.length != 4) { - if(!brSplitted[0]) brSplitted[0] = '0px'; - for(let i = brSplitted.length; i < 4; ++i) { - brSplitted[i] = brSplitted[i % 2] || brSplitted[0] || '0px'; - } - } + let brSplitted = fillPropertyValue(borderRadius) as string[]; borderRadius = brSplitted.map(r => (parseInt(r) / scaleX) + 'px').join(' '); if(!wasActive) { mover.style.borderRadius = borderRadius; diff --git a/src/lib/utils.js b/src/lib/utils.js index 995b3be3..c6dd4b9c 100644 --- a/src/lib/utils.js +++ b/src/lib/utils.js @@ -632,6 +632,18 @@ export function encodeEntities (value) { }).replace(//g, '>') } +export function fillPropertyValue(str) { + let splitted = str.split(' '); + if(splitted.length != 4) { + if(!splitted[0]) splitted[0] = '0px'; + for(let i = splitted.length; i < 4; ++i) { + splitted[i] = splitted[i % 2] || splitted[0] || '0px'; + } + } + + return splitted; +} + export function calcImageInBox (imageW, imageH, boxW, boxH, noZooom) { if(imageW < boxW && imageH < boxH) { return {w: imageW, h: imageH}; diff --git a/src/scss/partials/_chat.scss b/src/scss/partials/_chat.scss index a4497a47..b8afdeb7 100644 --- a/src/scss/partials/_chat.scss +++ b/src/scss/partials/_chat.scss @@ -554,6 +554,23 @@ $chat-max-width: 696px; height: 100%; } } + + &.is-album { + .attachment { + max-width: 451px; + max-height: none; + + > div { + background-color: #000; + background-size: cover; + background-position: center center; + /* flex: 1 0 auto; */ + max-width: 100%; + cursor: pointer; + position: absolute; + } + } + } //&.video { //.attachment { diff --git a/src/scss/partials/_chatlist.scss b/src/scss/partials/_chatlist.scss index 70152c05..f33ff7d4 100644 --- a/src/scss/partials/_chatlist.scss +++ b/src/scss/partials/_chatlist.scss @@ -59,15 +59,16 @@ } li { - padding: 2px 0; + //padding: 0 0 2px 0; + padding-bottom: 4px; //overflow: hidden; background-color: #fff; } li > .rp { - height: 70px; - max-height: 70px; - border-radius: $border-radius; + height: 72px; + max-height: 72px; + border-radius: $border-radius-medium; //align-items: center; /* display: grid; grid-template-columns: 64px calc(100% - 64px - 6.5px); */ @@ -76,8 +77,8 @@ flex-direction: row; position: relative; cursor: pointer; - padding: 7px 8.5px; - margin: 0px 8px 2px 7px; + padding: 9px 8.5px; + margin: 0px 8px 0px 7px; overflow: hidden; &:hover { @@ -91,7 +92,7 @@ .pinned-delimiter { display: flex; - padding: 6px 0 6px; + padding: 8px 0 4px; span { margin: 0; @@ -271,6 +272,12 @@ &-contacts { padding: 16px 0 7px; + li { + //margin-bottom: 2px; + padding-bottom: 4px; + padding-top: 2px; + } + li > .rp { padding: 9px 11.5px !important; height: 66px;