Skip to content

Commit

Permalink
Add block parsing for text ranges
Browse files Browse the repository at this point in the history
This commit adds basic support for parsing the text ranges in the
notification blocks. This essentially means that we are introducing
styling on the note content, though that styling is severaly limited at
this point.

The algorithm for building the formatting uses a form of a topological
sort of the text ranges, then recursively breaks down the remaining text
in the string for each interval. This builds an object specifying
special properties of the range and which nests according to the ranges.

```js
const block = {
	text: 'Math is fun!',
	ranges: [
		{ indices: [ 0, 11 ], type: 'blockquote' },
		{ indices: [ 0, 4 ], type: 'b' }
	]
}

// --> turns into...
const tree = [
	{
		type: 'blockquote',
		children: [
			{
				type: 'b',
				children: [ 'Math' ]
			},
			' is fun!'
		]
	}
]
```

The view layer can take this plain object and translate it according to
the goal: the app currently turns this into a React component hierarchy.

It's worth noting that ranges that aren't understood by the app default
to a textblock that gets surrounded by a span with a CSS class
corresponding to the `type` of the node.
  • Loading branch information
dmsnell committed Mar 24, 2016
1 parent 6e33346 commit 7142b69
Show file tree
Hide file tree
Showing 19 changed files with 266 additions and 22 deletions.
113 changes: 113 additions & 0 deletions src/api-poller/block-parser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import {
complement,
compose,
flip,
init,
isEmpty,
propOr,
tail
} from 'ramda';

const notEmpty = complement( isEmpty );

const rangeSort = ( { indices: [ aStart, aEnd ] }, { indices: [ bStart, bEnd ] } ) => {
if ( aStart === 0 && aEnd === 0 && bEnd !== 0 ) return -1;

if ( aStart < bStart ) return -1;
if ( bStart < aStart ) return 1;

return bEnd - aEnd;
};

const encloses =
( { indices: [ innerStart, innerEnd ] } ) =>
( { indices: [ outerStart, outerEnd ] = [ 0, 0 ]} ) =>
( innerStart !== 0 && innerEnd !== 0 ) &&
( outerStart <= innerStart && outerEnd >= innerEnd );

const addRange = ( ranges, range ) => {
const parent = ranges.find( encloses( range ) );

if ( ! parent ) return [ ...ranges, range ];

return [
...init( ranges ),
{ ...parent, children: addRange( parent.children, range ) }
];
};

const commentNode = ( { id: commentId, post_id: postId, site_id: siteId } ) =>
( { type: 'comment', commentId, postId, siteId } );

const linkNode = ( { url } ) =>
( { type: 'link', url } );

const postNode = ( { id: postId, site_id: siteId } ) =>
( { type: 'post', postId, siteId } );

const siteNode = ( { id: siteId } ) =>
( { type: 'site', siteId } );

const typedNode = ( { type } ) =>
( { type } );

const userNode = ( { id } ) =>
( { type: 'user', id } );

const inferNode = range => {
const { type, url } = range;

if ( type ) return typedNode( range );

if ( url ) return linkNode( range );

return range;
};

const nodeMappings = flip( propOr( inferNode ) )( {
comment: commentNode,
post: postNode,
site: siteNode,
user: userNode
} );

const newNode = ( text, range = {} ) => ( {
...nodeMappings( range.type )( range ),
children: [ { type: 'text', text } ]
} );

const joinResults = ( [ reduced, remainder, ] ) =>
reduced.length
? reduced.concat( remainder ).filter( notEmpty )
: remainder.length ? [ remainder ] : [];

const parse = ( [ prev, text, offset ], nextRange ) => {
const { indices: [ start, end ] } = nextRange;
const offsetStart = start - offset;
const offsetEnd = end - offset;

const preText = ( offsetStart > 0 ) ? [ text.slice( 0, offsetStart ) ] : [];

const children = joinResults(
nextRange
.children
.reduce( parse, [ [], text.slice( offsetStart, offsetEnd ), start ] )
);

const parsed = Object.assign(
newNode( text.slice( offsetStart, offsetEnd ), nextRange ),
children.length && { children }
);

return [ [ ...prev, ...preText, parsed ], text.slice( offsetEnd ), end ];
};

export const parseBlock = block =>
block.ranges
? joinResults(
block.ranges
.map( o => ( { ...o, children: [] } ) )
.sort( rangeSort )
.reduce( addRange, [] )
.reduce( parse, [ [], block.text, 0 ] ) )
: [ newNode( block ) ];
14 changes: 10 additions & 4 deletions src/api-poller/from-api.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import {
apply,
compose,
defaultTo,
flip,
has,
head,
isNil,
mapObjIndexed,
map,
nth,
of,
prop,
propOr
propOr,
reject
} from 'ramda';

import { parseBlock } from './block-parser';

const mapNoticon = key => propOr( 'star', key, {
'\uf814': 'mention',
'\uf300': 'comment',
Expand All @@ -25,14 +31,14 @@ const mapNoticon = key => propOr( 'star', key, {
} );

const avatar = prop( 'icon' );
const body = prop( 'body' );
const body = compose( map( parseBlock ), prop( 'body' ) );
const hasReplied = compose( has( 'reply_comment' ), propOr( {}, 'ids' ), propOr( {}, 'meta' ) );
const header = compose( head, propOr( [], 'header' ) );
const header = compose( defaultTo( [] ), head, map( parseBlock ), reject( isNil ), of, head, propOr( [], 'header' ) );
const headerExcerpt = compose( nth( 1 ), propOr( [], 'header' ) );
const icon = compose( mapNoticon, prop( 'noticon' ) );
const id = prop( 'id' );
const read = prop( 'read' );
const subject = compose( head, prop( 'subject' ) );
const subject = compose( head, map( parseBlock ), of, head, prop( 'subject' ) );
const subjectExcerpt = compose( nth( 1 ), propOr( [], 'subject' ) );
const timestamp = compose( Date.parse, prop( 'timestamp' ) );
const title = prop( 'title' );
Expand Down
67 changes: 67 additions & 0 deletions src/blocks/block-tree-parser.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import React from 'react';
import {
flip,
head,
propOr,
tap
} from 'ramda';

import CommentBlock from './comment-block';
import LinkBlock from './link-block';
import PostBlock from './post-block';
import SiteBlock from './site-block';
import TypedBlock from './typed-block';
import UserBlock from './user-block';

const blockMapping = flip( propOr( TypedBlock ) )( {
comment: CommentBlock,
link: LinkBlock,
post: PostBlock,
site: SiteBlock,
user: UserBlock
} );

export const addKey = ( element, key ) =>
React.cloneElement( element, { key } );

const reduceTree = ( elements, node ) => {
if ( 'string' === typeof node ) {
return [ ...elements, <span>{ node }</span> ];
}

const Element = blockMapping( node.type );

if ( ! node.children ) {
return [
...elements,
<Element { ...node } />
];
}

const firstChild = head( node.children );
if ( 1 === node.children.length && 'string' === typeof firstChild ) {
return [
...elements,
<Element { ...node }>{ firstChild }</Element>
];
}

const children = node
.children
.reduce( reduceTree, [] );

return [
...elements,
<Element { ...node }>{ children.map( addKey ) }</Element>
];
};

export const parseBlockTree = tree => {
return (
<span>
{ tree.reduce( reduceTree, [] ).map( addKey ) }
</span>
);
};

export default parseBlockTree;
8 changes: 8 additions & 0 deletions src/blocks/comment-block.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import React from 'react';

require( './comment-block.scss' );

export const CommentBlock = ( { children, CommentId, siteId } ) =>
<span className="text-range comment">{ children }</span>;

export default CommentBlock;
3 changes: 3 additions & 0 deletions src/blocks/comment-block.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.text-range.comment {
font-style: italic;
}
6 changes: 6 additions & 0 deletions src/blocks/link-block.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import React from 'react';

export const LinkBlock = ( { url, children } ) =>
<a href={ url }>{ children }</a>;

export default LinkBlock;
8 changes: 8 additions & 0 deletions src/blocks/post-block.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import React from 'react';

require( './post-block.scss' );

export const PostBlock = ( { children, postId, siteId } ) =>
<span className="text-range post">{ children }</span>;

export default PostBlock;
3 changes: 3 additions & 0 deletions src/blocks/post-block.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.text-range.post {
font-style: italic;
}
8 changes: 8 additions & 0 deletions src/blocks/site-block.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import React from 'react';

require( './site-block.scss' );

export const SiteBlock = ( { children, siteId } ) =>
<span className="text-range site">{ children }</span>;

export default SiteBlock;
3 changes: 3 additions & 0 deletions src/blocks/site-block.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.text-range.site {
font-weight: bold;
}
8 changes: 8 additions & 0 deletions src/blocks/typed-block.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import React from 'react';

require( './typed-block.scss' );

export const TypedBlock = ( { children, type } ) =>
<span className={ `text-range typed type-${ type }` }>{ children }</span>;

export default TypedBlock;
8 changes: 8 additions & 0 deletions src/blocks/typed-block.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
@import "~styles/color.scss";

.text-range.typed {
&.type-match {
color: $blue-medium;
font-weight: bold;
}
}
8 changes: 8 additions & 0 deletions src/blocks/user-block.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import React from 'react';

require( './user-block.scss' );

export const UserBlock = ( { children, id } ) =>
<span className="text-range user">{ children }</span>;

export default UserBlock;
3 changes: 3 additions & 0 deletions src/blocks/user-block.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.text-range.user {
font-weight: bold;
}
6 changes: 0 additions & 6 deletions src/list-group-filters.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,3 @@ export const before7Days = timeRange(

export const isPlaceholder = element =>
element.type.displayName === 'NoteListPlaceholder';

export const saysWordPress = ( { props: { note } } ) =>
note && note
.getIn( [ 'subject', 'text' ] )
.toLowerCase()
.includes( 'wordpress' );
4 changes: 0 additions & 4 deletions src/list-view-layout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import Gridicon from 'gridicons';
import GroupedList, { GroupHeader } from 'grouped-list';
import {
isPlaceholder,
saysWordPress,
fromToday,
fromYesterday,
before2Days,
Expand Down Expand Up @@ -56,9 +55,6 @@ const ListViewLayout = React.createClass( {
<GroupHeader filter={ isPlaceholder }>
<Gridicon icon="cloud-download" /> Loading notifications…
</GroupHeader>
<GroupHeader filter={ saysWordPress }>
<Gridicon icon="my-sites" /> WordPress
</GroupHeader>
<GroupHeader filter={ fromToday }>
<Gridicon icon="time" /> Today
</GroupHeader>
Expand Down
3 changes: 2 additions & 1 deletion src/note-list-view.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { partial } from 'ramda';
import moment from 'moment';

import Avatar from 'avatar';
import parseBlocks from 'blocks/block-tree-parser';

require( 'note-list-view.scss' );

Expand All @@ -19,7 +20,7 @@ const NoteListView = React.createClass( {
const avatar = note.get( 'avatar' );
const hasReplied = note.get( 'hasReplied' );
const icon = note.get( 'icon' );
const subject = note.getIn( [ 'subject', 'text' ] );
const subject = parseBlocks( note.get( 'subject' ).toJS() );
const subjectExcerpt = note.getIn( [ 'subjectExcerpt', 'text' ] );
const timestamp = moment( note.get( 'timestamp' ) ).fromNow();

Expand Down
1 change: 0 additions & 1 deletion src/note-list-view.scss
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,5 @@

.subject:not(.excerpt) {
color: $gray-dark;
font-weight: bold;
}
}
14 changes: 8 additions & 6 deletions src/note-view.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import React from 'react';

import parseBlocks, { addKey } from 'blocks/block-tree-parser';

require( 'note-view.scss' );

const NoteView = React.createClass( {
Expand All @@ -8,11 +10,11 @@ const NoteView = React.createClass( {
note
} = this.props;

const header = note.getIn( [ 'header', 'text' ] );
const header = parseBlocks( note.get( 'header' ).toJS() );
const headerExcerpt = note.getIn( [ 'headerExcerpt', 'text' ] );
const subject = note.getIn( [ 'subject', 'text' ] );
const subject = parseBlocks( note.get( 'subject' ).toJS() );
const subjectExcerpt = note.getIn( [ 'subjectExcerpt', 'text' ] );
const body = note.get( 'body' );
const body = note.get( 'body' ).toJS().map( parseBlocks );

return (
<div className="note-view">
Expand All @@ -22,9 +24,9 @@ const NoteView = React.createClass( {
<div className="subject">{ subject }</div>
{ subjectExcerpt &&
<div className="subject excerpt">{ subjectExcerpt }</div> }
{ body.map( ( block, key ) => (
<p {...{ key } }>{ block.get( 'text' ) }</p>
) ) }
<div>
{ body.map( addKey ) }
</div>
</div>
);
}
Expand Down

0 comments on commit 7142b69

Please sign in to comment.