diff --git a/src/message/__tests__/renderMessages-test.js b/src/message/__tests__/renderMessages-test.js index 4e487d63375..f0f3921fbe1 100644 --- a/src/message/__tests__/renderMessages-test.js +++ b/src/message/__tests__/renderMessages-test.js @@ -23,8 +23,9 @@ describe('renderMessages', () => { const messageList = renderMessages(messages, narrow); expect(messageList).toHaveLength(2); - expect(messageList[0].data[0].key).toEqual('time123'); - expect(messageList[1].data[0].key).toEqual(12345); + expect(messageList[1].key).toEqual('header12345'); + expect(messageList[1].data[0].key).toEqual('time123'); + expect(messageList[1].data[1].key).toEqual(12345); }); test('several messages in same stream, from same person result in time row, header for the stream, three messages, only first of which is full detail', () => { @@ -62,8 +63,9 @@ describe('renderMessages', () => { const messageKeys = messageList[1].data.map(x => x.key); const messageBriefs = messageList[1].data.map(x => x.isBrief); - expect(messageKeys).toEqual([1, 2, 3]); - expect(messageBriefs).toEqual([false, true, true]); + expect(messageList[1].key).toEqual('header1'); + expect(messageKeys).toEqual(['time123', 1, 2, 3]); + expect(messageBriefs).toEqual([undefined, false, true, true]); }); test('several messages in same stream, from different people result in time row, header for the stream, three messages, only all full detail', () => { @@ -101,8 +103,9 @@ describe('renderMessages', () => { const messageKeys = messageList[1].data.map(x => x.key); const messageBriefs = messageList[1].data.map(x => x.isBrief); - expect(messageKeys).toEqual([1, 2, 3]); - expect(messageBriefs).toEqual([false, false, false]); + expect(messageList[1].key).toEqual('header1'); + expect(messageKeys).toEqual(['time123', 1, 2, 3]); + expect(messageBriefs).toEqual([undefined, false, false, false]); }); test('private messages between two people, results in time row, header and two full messages', () => { @@ -129,7 +132,8 @@ describe('renderMessages', () => { const messageKeys = messageList[1].data.map(x => x.key); const messageBriefs = messageList[1].data.map(x => x.isBrief); - expect(messageKeys).toEqual([1, 2]); - expect(messageBriefs).toEqual([false, false]); + expect(messageList[1].key).toEqual('header1'); + expect(messageKeys).toEqual(['time123', 1, 2]); + expect(messageBriefs).toEqual([undefined, false, false]); }); }); diff --git a/src/message/renderMessages.js b/src/message/renderMessages.js index e231f3e0d71..d7e732e2ec7 100644 --- a/src/message/renderMessages.js +++ b/src/message/renderMessages.js @@ -15,14 +15,6 @@ export default ( messages.forEach(item => { const diffDays = prevItem && !isSameDay(new Date(prevItem.timestamp * 1000), new Date(item.timestamp * 1000)); - if (!prevItem || diffDays) { - sections[sections.length - 1].data.push({ - key: `time${item.timestamp}`, - type: 'time', - timestamp: item.timestamp, - firstMessage: item, - }); - } const diffRecipient = !isSameRecipient(prevItem, item); if (showHeader && diffRecipient) { sections.push({ @@ -31,6 +23,14 @@ export default ( data: [], }); } + if (!prevItem || diffDays) { + sections[sections.length - 1].data.push({ + key: `time${item.timestamp}`, + type: 'time', + timestamp: item.timestamp, + firstMessage: item, + }); + } const shouldGroupWithPrev = !diffRecipient && !diffDays diff --git a/src/webview/MessageList.js b/src/webview/MessageList.js index 0f06b21dab1..b62698e829f 100644 --- a/src/webview/MessageList.js +++ b/src/webview/MessageList.js @@ -51,6 +51,7 @@ import renderMessagesAsHtml from './html/renderMessagesAsHtml'; import { getUpdateEvents } from './webViewHandleUpdates'; import { handleMessageListEvent } from './webViewEventHandlers'; import { base64Utf8Encode } from '../utils/encoding'; +import { isPrivateOrGroupNarrow } from '../utils/narrow'; // ESLint doesn't notice how `this.props` escapes, and complains about some // props not being used here. @@ -174,7 +175,7 @@ class MessageList extends Component { } = this.props; const messagesHtml = renderMessagesAsHtml(backgroundData, narrow, renderedMessages); const { auth } = backgroundData; - const html = getHtml(messagesHtml, theme, { + const html = getHtml(messagesHtml, theme, !isPrivateOrGroupNarrow(narrow), { anchor, auth, showMessagePlaceholders, diff --git a/src/webview/css/css.js b/src/webview/css/css.js index ba62740b84e..9aeb5801ef0 100644 --- a/src/webview/css/css.js +++ b/src/webview/css/css.js @@ -4,7 +4,7 @@ import { BRAND_COLOR } from '../../styles'; import cssEmojis from './cssEmojis'; import cssNight from './cssNight'; -const cssBase = ` +const cssBase = (hasRecipientHeaders: boolean) => ` html { -webkit-user-select: none; /* Safari 3.1+ */ -moz-user-select: none; /* Firefox 2+ */ @@ -76,24 +76,32 @@ hr { justify-content: space-between; margin-bottom: 6px; } +#date-pill-sticky { + position: fixed; + top: ${hasRecipientHeaders ? '2.3em' : '0.3em'}; + left: 50%; + z-index: 100; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.2), 0 2px 4px 0 rgba(0, 0, 0, 0.2); + transform: translateX(-50%); + transition: top 0.2s; +} +#date-pill-sticky.hide { + top: -2.5em; +} +#date-pill-sticky:empty { + display: none; +} .timerow { text-align: center; - color: hsl(0, 0%, 60%); - display: flex; - align-items: center; - padding: 8px 0; -} -.timerow-left, -.timerow-right { - flex: 1; - height: 1px; margin: 8px; } -.timerow-left { - background: -webkit-linear-gradient(left, transparent 10%, hsl(0, 0%, 60%) 100%); +.date-pill, #date-pill-sticky { + color: hsla(0, 0%, 0%, 0.65); + background: hsl(0, 0%, 92%); + border-radius: 3px; } -.timerow-right { - background: -webkit-linear-gradient(left, hsl(0, 0%, 60%) 0%, transparent 90%); +.date-pill, #date-pill-sticky { + padding: 0.25em 0.5em; } .message, .loading { @@ -177,7 +185,7 @@ hr { position: -webkit-sticky; position: sticky; top: -1px; - z-index: 100; + z-index: 10; display: flex; justify-content: space-between; } @@ -494,9 +502,9 @@ blockquote { } `; -export default (theme: ThemeName) => ` +export default (theme: ThemeName, hasRecipientHeaders: boolean) => ` diff --git a/src/webview/css/cssNight.js b/src/webview/css/cssNight.js index 5800a0ac942..6aa87e1c839 100644 --- a/src/webview/css/cssNight.js +++ b/src/webview/css/cssNight.js @@ -11,6 +11,10 @@ body { .timestamp { background: hsl(212, 28%, 25%); } +.date-pill, #date-pill-sticky { + color: hsla(0, 0%, 100%, 0.5); + background: hsl(213, 14%, 34%); +} .highlight { background-color: hsla(51, 100%, 64%, 0.42); } diff --git a/src/webview/html/html.js b/src/webview/html/html.js index 5598f402077..acf91380454 100644 --- a/src/webview/html/html.js +++ b/src/webview/html/html.js @@ -11,9 +11,14 @@ type InitOptionsType = {| showMessagePlaceholders: boolean, |}; -export default (content: string, theme: ThemeName, initOptions: InitOptionsType) => template` +export default ( + content: string, + theme: ThemeName, + hasRecipientHeaders: boolean, + initOptions: InitOptionsType, +) => template` $!${script(initOptions.anchor, initOptions.auth)} -$!${css(theme)} +$!${css(theme, hasRecipientHeaders)} diff --git a/src/webview/html/renderMessagesAsHtml.js b/src/webview/html/renderMessagesAsHtml.js index 1d715e090ce..4e4cad7d4f3 100644 --- a/src/webview/html/renderMessagesAsHtml.js +++ b/src/webview/html/renderMessagesAsHtml.js @@ -12,6 +12,7 @@ export default ( renderedMessages: RenderedSectionDescriptor[], ): string => { const pieces = []; + pieces.push('
'); renderedMessages.forEach(section => { pieces.push(messageHeaderAsHtml(backgroundData, narrow, section.message)); section.data.forEach(item => { diff --git a/src/webview/html/timeRowAsHtml.js b/src/webview/html/timeRowAsHtml.js index ae17e03d32a..88afc777db1 100644 --- a/src/webview/html/timeRowAsHtml.js +++ b/src/webview/html/timeRowAsHtml.js @@ -5,8 +5,6 @@ import { humanDate } from '../../utils/date'; export default (timestamp: number, nextMessage: Message | Outbox): string => template`
-
- ${humanDate(new Date(timestamp * 1000))} -
+ ${humanDate(new Date(timestamp * 1000))}
`; diff --git a/src/webview/js/generatedEs3.js b/src/webview/js/generatedEs3.js index 3efbafee84c..85b34e8f70a 100644 --- a/src/webview/js/generatedEs3.js +++ b/src/webview/js/generatedEs3.js @@ -121,10 +121,10 @@ function midMessagePeer(top, bottom) { return midElements[midElements.length - 3]; } -function walkToMessage(start, step) { +function walkToElement(start, elementType, step) { var element = start; - while (element && !element.classList.contains('message')) { + while (element && !element.classList.contains(elementType)) { element = element[step]; } @@ -132,27 +132,25 @@ function walkToMessage(start, step) { } function firstMessage() { - return walkToMessage(documentBody.firstElementChild, 'nextElementSibling'); + return walkToElement(documentBody.firstElementChild, 'message', 'nextElementSibling'); } function lastMessage() { - return walkToMessage(documentBody.lastElementChild, 'previousElementSibling'); + return walkToElement(documentBody.lastElementChild, 'message', 'previousElementSibling'); } -var minOverlap = 20; - -function isVisible(element, top, bottom) { +function isVisible(element, top, bottom, minOverlap) { var rect = element.getBoundingClientRect(); return top + minOverlap < rect.bottom && rect.top + minOverlap < bottom; } function someVisibleMessage(top, bottom) { function checkVisible(candidate) { - return candidate && isVisible(candidate, top, bottom) ? candidate : null; + return candidate && isVisible(candidate, top, bottom, 20) ? candidate : null; } var midPeer = midMessagePeer(top, bottom); - return checkVisible(walkToMessage(midPeer, 'previousElementSibling')) || checkVisible(walkToMessage(midPeer, 'nextElementSibling')) || checkVisible(firstMessage()) || checkVisible(lastMessage()); + return checkVisible(walkToElement(midPeer, 'message', 'previousElementSibling')) || checkVisible(walkToElement(midPeer, 'message', 'nextElementSibling')) || checkVisible(firstMessage()) || checkVisible(lastMessage()); } function idFromMessage(element) { @@ -174,7 +172,7 @@ function visibleMessageIds() { function walkElements(start, step) { var element = start; - while (element && isVisible(element, top, bottom)) { + while (element && isVisible(element, top, bottom, 20)) { if (element.classList.contains('message')) { var id = idFromMessage(element); first = Math.min(first, id); @@ -194,6 +192,29 @@ function visibleMessageIds() { }; } +function getFirstVisibleMessage() { + var header = document.getElementsByClassName('header')[0]; + var top = header ? header.offsetHeight : 0; + var bottom = viewportHeight; + var message = document.createElement('null'); + + function walkElements(start) { + var element = start; + + while (element && isVisible(element, top, bottom, 0)) { + if (element.classList.contains('message') || element.classList.contains('header')) { + message = element; + } + + element = element.previousElementSibling; + } + } + + var start = someVisibleMessage(top, bottom); + walkElements(start); + return message; +} + var getMessageNode = function getMessageNode(node) { var curNode = node; @@ -246,6 +267,39 @@ var sendScrollMessage = function sendScrollMessage() { prevMessageRange = messageRange; }; +var dateTimeout; + +var handleStickyDatePill = function handleStickyDatePill() { + var firstVisibleMessage = getFirstVisibleMessage(); + var timerowAbove = walkToElement(firstVisibleMessage, 'timerow', 'previousElementSibling'); + + if (!(firstVisibleMessage && timerowAbove)) { + return; + } + + var replaceableDate = timerowAbove.getElementsByClassName('date-pill')[0].innerHTML; + var datePillSticky = document.getElementById('date-pill-sticky'); + + if (!datePillSticky) { + throw new Error('No date-pill-sticky element!'); + } + + if (!replaceableDate) { + throw new Error('No date-pill element in timerow!'); + } + + datePillSticky.classList.remove('hide'); + + if (datePillSticky.innerHTML !== replaceableDate) { + datePillSticky.innerHTML = replaceableDate; + } + + clearTimeout(dateTimeout); + dateTimeout = setTimeout(function () { + return datePillSticky.classList.add('hide'); + }, 1000); +}; + var sendScrollMessageIfListShort = function sendScrollMessageIfListShort() { if (documentBody.scrollHeight === documentBody.clientHeight) { sendScrollMessage(); @@ -592,6 +646,7 @@ documentBody.addEventListener('touchcancel', function (e) { clearTimeout(longPressTimeout); }); documentBody.addEventListener('touchmove', function (e) { + handleStickyDatePill(); clearTimeout(longPressTimeout); }); documentBody.addEventListener('drag', function (e) { diff --git a/src/webview/js/js.js b/src/webview/js/js.js index 37643e015d5..bbee43f77ff 100644 --- a/src/webview/js/js.js +++ b/src/webview/js/js.js @@ -214,12 +214,13 @@ function midMessagePeer(top: number, bottom: number): ?Element { return midElements[midElements.length - 3]; } -function walkToMessage( +function walkToElement( start: ?Element, + elementType: 'message' | 'timerow', step: 'nextElementSibling' | 'previousElementSibling', ): ?Element { let element: ?Element = start; - while (element && !element.classList.contains('message')) { + while (element && !element.classList.contains(elementType)) { // $FlowFixMe: doesn't use finite type of `step` element = element[step]; } @@ -227,17 +228,15 @@ function walkToMessage( } function firstMessage(): ?Element { - return walkToMessage(documentBody.firstElementChild, 'nextElementSibling'); + return walkToElement(documentBody.firstElementChild, 'message', 'nextElementSibling'); } function lastMessage(): ?Element { - return walkToMessage(documentBody.lastElementChild, 'previousElementSibling'); + return walkToElement(documentBody.lastElementChild, 'message', 'previousElementSibling'); } -/** The minimum height (in px) to see of a message to call it visible. */ -const minOverlap = 20; - -function isVisible(element: Element, top: number, bottom: number): boolean { +/** minOverlap: The minimum height (in px) to see of a message to call it visible. */ +function isVisible(element: Element, top: number, bottom: number, minOverlap: number): boolean { const rect = element.getBoundingClientRect(); return top + minOverlap < rect.bottom && rect.top + minOverlap < bottom; } @@ -245,7 +244,7 @@ function isVisible(element: Element, top: number, bottom: number): boolean { /** Returns some message element which is visible, if any. */ function someVisibleMessage(top: number, bottom: number): ?Element { function checkVisible(candidate: ?Element): ?Element { - return candidate && isVisible(candidate, top, bottom) ? candidate : null; + return candidate && isVisible(candidate, top, bottom, 20) ? candidate : null; } // Algorithm: if some message-peer is visible, then either the message // just before or after it should be visible. If not, we must be at one @@ -253,8 +252,8 @@ function someVisibleMessage(top: number, bottom: number): ?Element { // (or both) should be visible. const midPeer = midMessagePeer(top, bottom); return ( - checkVisible(walkToMessage(midPeer, 'previousElementSibling')) - || checkVisible(walkToMessage(midPeer, 'nextElementSibling')) + checkVisible(walkToElement(midPeer, 'message', 'previousElementSibling')) + || checkVisible(walkToElement(midPeer, 'message', 'nextElementSibling')) || checkVisible(firstMessage()) || checkVisible(lastMessage()) ); @@ -285,7 +284,7 @@ function visibleMessageIds(): { first: number, last: number } { // Walk through visible elements, observing message IDs. function walkElements(start: ?Element, step: 'nextElementSibling' | 'previousElementSibling') { let element = start; - while (element && isVisible(element, top, bottom)) { + while (element && isVisible(element, top, bottom, 20)) { if (element.classList.contains('message')) { const id = idFromMessage(element); first = Math.min(first, id); @@ -303,6 +302,29 @@ function visibleMessageIds(): { first: number, last: number } { return { first, last }; } +function getFirstVisibleMessage(): Element { + // Find if a header exists, use its height as top if it does. + const header = document.getElementsByClassName('header')[0]; + const top = header ? header.offsetHeight : 0; + const bottom = viewportHeight; + let message = document.createElement('null'); + + function walkElements(start: ?Element) { + let element = start; + while (element && isVisible(element, top, bottom, 0)) { + if (element.classList.contains('message') || element.classList.contains('header')) { + message = element; + } + element = element.previousElementSibling; + } + } + + const start = someVisibleMessage(top, bottom); + walkElements(start); + + return message; +} + /** DEPRECATED */ const getMessageNode = (node: ?Node): ?Node => { let curNode = node; @@ -369,6 +391,32 @@ const sendScrollMessage = () => { prevMessageRange = messageRange; }; +let dateTimeout: TimeoutID; +const handleStickyDatePill = () => { + const firstVisibleMessage = getFirstVisibleMessage(); + const timerowAbove = walkToElement(firstVisibleMessage, 'timerow', 'previousElementSibling'); + if (!(firstVisibleMessage && timerowAbove)) { + return; + } + + const replaceableDate = timerowAbove.getElementsByClassName('date-pill')[0].innerHTML; + const datePillSticky = document.getElementById('date-pill-sticky'); + if (!datePillSticky) { + throw new Error('No date-pill-sticky element!'); + } + if (!replaceableDate) { + throw new Error('No date-pill element in timerow!'); + } + + datePillSticky.classList.remove('hide'); + if (datePillSticky.innerHTML !== replaceableDate) { + datePillSticky.innerHTML = replaceableDate; + } + + clearTimeout(dateTimeout); + dateTimeout = setTimeout(() => datePillSticky.classList.add('hide'), 1000); +}; + // If the message list is too short to scroll, fake a scroll event // in order to cause the messages to be marked as read. const sendScrollMessageIfListShort = () => { @@ -756,6 +804,7 @@ documentBody.addEventListener('touchcancel', (e: TouchEvent) => { }); documentBody.addEventListener('touchmove', (e: TouchEvent) => { + handleStickyDatePill(); clearTimeout(longPressTimeout); });