diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index c129db314e1a..a9295a450b8a 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -50,6 +50,7 @@ import { handleMessageSend } from '../util/handleMessageSend'; import { getConversationMembers } from '../util/getConversationMembers'; import { sendReadReceiptsFor } from '../util/sendReadReceiptsFor'; import { updateConversationsWithUuidLookup } from '../updateConversationsWithUuidLookup'; +import { filter, map, take } from '../util/iterables'; /* eslint-disable more/no-then */ window.Whisper = window.Whisper || {}; @@ -2855,53 +2856,54 @@ export class ConversationModel extends window.Backbone sticker: WhatIsThis ): Promise { if (attachments && attachments.length) { - return Promise.all( - attachments - .filter( - attachment => attachment && !attachment.pending && !attachment.error - ) - .slice(0, 1) - .map(async attachment => { - const { fileName, thumbnail, contentType } = attachment; + const validAttachments = filter( + attachments, + attachment => attachment && !attachment.pending && !attachment.error + ); + const attachmentsToUse = take(validAttachments, 1); - return { - contentType, - // Our protos library complains about this field being undefined, so we - // force it to null - fileName: fileName || null, - thumbnail: thumbnail - ? { - ...(await loadAttachmentData(thumbnail)), - objectUrl: getAbsoluteAttachmentPath(thumbnail.path), - } - : null, - }; - }) + return Promise.all( + map(attachmentsToUse, async attachment => { + const { fileName, thumbnail, contentType } = attachment; + + return { + contentType, + // Our protos library complains about this field being undefined, so we force + // it to null + fileName: fileName || null, + thumbnail: thumbnail + ? { + ...(await loadAttachmentData(thumbnail)), + objectUrl: getAbsoluteAttachmentPath(thumbnail.path), + } + : null, + }; + }) ); } if (preview && preview.length) { - return Promise.all( - preview - .filter(item => item && item.image) - .slice(0, 1) - .map(async attachment => { - const { image } = attachment; - const { contentType } = image; + const validPreviews = filter(preview, item => item && item.image); + const previewsToUse = take(validPreviews, 1); - return { - contentType, - // Our protos library complains about this field being undefined, so we - // force it to null - fileName: null, - thumbnail: image - ? { - ...(await loadAttachmentData(image)), - objectUrl: getAbsoluteAttachmentPath(image.path), - } - : null, - }; - }) + return Promise.all( + map(previewsToUse, async attachment => { + const { image } = attachment; + const { contentType } = image; + + return { + contentType, + // Our protos library complains about this field being undefined, so we + // force it to null + fileName: null, + thumbnail: image + ? { + ...(await loadAttachmentData(image)), + objectUrl: getAbsoluteAttachmentPath(image.path), + } + : null, + }; + }) ); } diff --git a/ts/test-both/util/iterables_test.ts b/ts/test-both/util/iterables_test.ts index b4c02e4193d5..a1fb9101ee92 100644 --- a/ts/test-both/util/iterables_test.ts +++ b/ts/test-both/util/iterables_test.ts @@ -4,7 +4,7 @@ import { assert } from 'chai'; import * as sinon from 'sinon'; -import { isIterable, size, map, take } from '../../util/iterables'; +import { isIterable, size, filter, map, take } from '../../util/iterables'; describe('iterable utilities', () => { describe('isIterable', () => { @@ -82,6 +82,61 @@ describe('iterable utilities', () => { }); }); + describe('filter', () => { + it('returns an empty iterable when passed an empty iterable', () => { + const fn = sinon.fake(); + + assert.deepEqual([...filter([], fn)], []); + assert.deepEqual([...filter(new Set(), fn)], []); + assert.deepEqual([...filter(new Map(), fn)], []); + + sinon.assert.notCalled(fn); + }); + + it('returns a new iterator with some values removed', () => { + const isOdd = sinon.fake((n: number) => Boolean(n % 2)); + const result = filter([1, 2, 3, 4], isOdd); + + sinon.assert.notCalled(isOdd); + + assert.deepEqual([...result], [1, 3]); + assert.notInstanceOf(result, Array); + + sinon.assert.callCount(isOdd, 4); + }); + + it('can filter an infinite iterable', () => { + const everyNumber = { + *[Symbol.iterator]() { + for (let i = 0; true; i += 1) { + yield i; + } + }, + }; + + const isOdd = (n: number) => Boolean(n % 2); + const result = filter(everyNumber, isOdd); + const iterator = result[Symbol.iterator](); + + assert.deepEqual(iterator.next(), { value: 1, done: false }); + assert.deepEqual(iterator.next(), { value: 3, done: false }); + assert.deepEqual(iterator.next(), { value: 5, done: false }); + assert.deepEqual(iterator.next(), { value: 7, done: false }); + }); + + it('respects TypeScript type assertion signatures', () => { + // This tests TypeScript, not the actual runtime behavior. + function isString(value: unknown): value is string { + return typeof value === 'string'; + } + + const input: Array = [1, 'two', 3, 'four']; + const result: Iterable = filter(input, isString); + + assert.deepEqual([...result], ['two', 'four']); + }); + }); + describe('map', () => { it('returns an empty iterable when passed an empty iterable', () => { const fn = sinon.fake(); diff --git a/ts/util/iterables.ts b/ts/util/iterables.ts index 2bd250be370a..1664d592d88c 100644 --- a/ts/util/iterables.ts +++ b/ts/util/iterables.ts @@ -28,6 +28,49 @@ export function size(iterable: Iterable): number { return result; } +export function filter( + iterable: Iterable, + predicate: (value: T) => value is S +): Iterable; +export function filter( + iterable: Iterable, + predicate: (value: T) => unknown +): Iterable; +export function filter( + iterable: Iterable, + predicate: (value: T) => unknown +): Iterable { + return new FilterIterable(iterable, predicate); +} + +class FilterIterable implements Iterable { + constructor( + private readonly iterable: Iterable, + private readonly predicate: (value: T) => unknown + ) {} + + [Symbol.iterator](): Iterator { + return new FilterIterator(this.iterable[Symbol.iterator](), this.predicate); + } +} + +class FilterIterator implements Iterator { + constructor( + private readonly iterator: Iterator, + private readonly predicate: (value: T) => unknown + ) {} + + next(): IteratorResult { + // eslint-disable-next-line no-constant-condition + while (true) { + const nextIteration = this.iterator.next(); + if (nextIteration.done || this.predicate(nextIteration.value)) { + return nextIteration; + } + } + } +} + export function map( iterable: Iterable, fn: (value: T) => ResultT