+ )
+ });
+
+ let activeClasses = (this.props.active === "channels") ? " " : "dn-s ";
+
+ let hiddenClasses = true;
+
+ // probably a more concise way to write this
+
+ if (this.props.popout) {
+ hiddenClasses = false;
+ } else {
+ hiddenClasses = this.props.sidebarShown;
+ }
+
+ return (
+
+
⟵ Landscape
+
+
+ Your Collections
+
+ {privateChannel}
+ {channelItems}
+
+
+ );
+ }
+}
+
diff --git a/pkg/interface/link/src/js/components/lib/channels-item.js b/pkg/interface/link/src/js/components/lib/channels-item.js
new file mode 100644
index 0000000000..4475dd82fd
--- /dev/null
+++ b/pkg/interface/link/src/js/components/lib/channels-item.js
@@ -0,0 +1,30 @@
+import React, { Component } from 'react'
+
+import { Route, Link } from 'react-router-dom';
+
+export class ChannelsItem extends Component {
+ render() {
+ const { props } = this;
+
+ let selectedClass = (props.selected)
+ ? "bg-gray5 bg-gray1-d b--gray4 b--gray2-d"
+ : "b--transparent";
+
+ let memberCount = Object.keys(props.members).length;
+
+ return (
+
+
+
{props.name}
+
+ {memberCount + " contributor" + ((memberCount === 1) ? "" : "s")}
+
+
+ {props.linkCount + " link" + ((props.linkCount === 1) ? "" : "s")}
+
+
+
+ );
+ }
+}
+
diff --git a/pkg/interface/link/src/js/components/lib/comment-item.js b/pkg/interface/link/src/js/components/lib/comment-item.js
new file mode 100644
index 0000000000..d2d8fa61c3
--- /dev/null
+++ b/pkg/interface/link/src/js/components/lib/comment-item.js
@@ -0,0 +1,57 @@
+import React, { Component } from 'react'
+import { Sigil } from './icons/sigil';
+import moment from 'moment';
+
+export class CommentItem extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ timeSinceComment: this.getTimeSinceComment()
+ };
+ }
+
+ componentDidMount() {
+ this.updateTimeSinceNewestMessageInterval = setInterval( () => {
+ this.setState({timeSinceComment: this.getTimeSinceComment()});
+ }, 60000);
+ }
+
+ componentWillUnmount() {
+ if (this.updateTimeSinceNewestMessageInterval) {
+ clearInterval(this.updateTimeSinceNewestMessageInterval);
+ this.updateTimeSinceNewestMessageInterval = null;
+ }
+ }
+
+ getTimeSinceComment() {
+ return !!this.props.time ?
+ moment.unix(this.props.time / 1000).from(moment.utc())
+ : '';
+ }
+
+ render() {
+ let props = this.props;
+ return (
+
+
+
+
+
+ {((props.nickname) ? props.nickname : props.ship)}
+
+
+ {this.state.timeSinceComment}
+
+
+
+
{props.content}
+
+ )
+ }
+}
+
+export default CommentItem
diff --git a/pkg/interface/link/src/js/components/lib/comments-pagination.js b/pkg/interface/link/src/js/components/lib/comments-pagination.js
new file mode 100644
index 0000000000..8c6f9356db
--- /dev/null
+++ b/pkg/interface/link/src/js/components/lib/comments-pagination.js
@@ -0,0 +1,48 @@
+import React, { Component } from 'react';
+import { Route, Link } from 'react-router-dom';
+
+export class CommentsPagination extends Component {
+ render() {
+ let props = this.props;
+
+ let prevPage = "/" + (Number(props.commentPage) - 1);
+ let nextPage = "/" + (Number(props.commentPage) + 1);
+
+ let prevDisplay = ((Number(props.commentPage) > 0))
+ ? "dib"
+ : "dn";
+
+ let nextDisplay = (Number(props.commentPage + 1) < Number(props.total))
+ ? "dib"
+ : "dn";
+
+ let popout = (props.popout) ? "/popout" : "";
+
+ return (
+
+
+ <- Previous Page
+
+
+ Next Page ->
+
+
+ )
+ }
+}
+
+export default CommentsPagination;
\ No newline at end of file
diff --git a/pkg/interface/link/src/js/components/lib/comments.js b/pkg/interface/link/src/js/components/lib/comments.js
new file mode 100644
index 0000000000..5d7ec7f65e
--- /dev/null
+++ b/pkg/interface/link/src/js/components/lib/comments.js
@@ -0,0 +1,104 @@
+import React, { Component } from 'react'
+import { CommentItem } from './comment-item';
+import { CommentsPagination } from './comments-pagination';
+
+import { uxToHex } from '../../lib/util';
+import { api } from '../../api';
+
+export class Comments extends Component {
+
+ componentDidMount() {
+ let page = "page" + this.props.commentPage;
+ let comments = !!this.props.comments;
+ if ((!comments[page]) && (page !== "page0")) {
+ api.getCommentsPage(
+ this.props.path,
+ this.props.url,
+ this.props.linkPage,
+ this.props.linkIndex,
+ this.props.commentPage);
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ let page = "page" + this.props.commentPage;
+ if (prevProps !== this.props) {
+ if (!!this.props.comments) {
+ if ((page !== "page0") && (!this.props.comments[page])) {
+ api.getCommentsPage(
+ this.props.path,
+ this.props.url,
+ this.props.linkPage,
+ this.props.linkIndex,
+ this.props.commentPage);
+ }
+ }
+ }
+ }
+
+ render() {
+ let props = this.props;
+
+ let page = "page" + props.commentPage;
+
+ let commentsObj = !!props.comments
+ ? props.comments
+ : {};
+
+ let commentsPage = !!commentsObj[page]
+ ? commentsObj[page]
+ : {};
+
+ let total = !!props.comments
+ ? props.comments["total-pages"]
+ : {};
+
+ let commentsList = Object.keys(commentsPage)
+ .map((entry) => {
+
+ let commentObj = commentsPage[entry]
+ let { ship, time, udon } = commentObj;
+
+ let members = !!props.members
+ ? props.members
+ : {};
+
+ let nickname = !!members[ship]
+ ? members[ship].nickname
+ : "";
+
+ let nameClass = nickname ? "inter" : "mono";
+
+ let color = !!members[ship]
+ ? uxToHex(members[ship].color)
+ : "000000";
+
+ return(
+
+ )
+ })
+ return (
+
+ {commentsList}
+
+
+ )
+ }
+}
+
+export default Comments;
\ No newline at end of file
diff --git a/pkg/interface/link/src/js/components/lib/header-bar.js b/pkg/interface/link/src/js/components/lib/header-bar.js
new file mode 100644
index 0000000000..bbd7fb5e9c
--- /dev/null
+++ b/pkg/interface/link/src/js/components/lib/header-bar.js
@@ -0,0 +1,56 @@
+import React, { Component } from 'react';
+import classnames from 'classnames';
+import { IconHome } from '/components/lib/icons/icon-home';
+import { IconSpinner } from '/components/lib/icons/icon-spinner';
+import { Sigil } from '/components/lib/icons/sigil';
+
+export class HeaderBar extends Component {
+ render() {
+ // let spin = (this.props.spinner)
+ // ?
+ //
+ //
+ // : null;
+
+ let popout = (window.location.href.includes("popout/"))
+ ? "dn"
+ : "dn db-m db-l db-xl";
+
+ let title = (document.title === "Home")
+ ? ""
+ : document.title;
+
+ return (
+
+
+
+
+ Home
+
+
+
{title}
+ {/* {spin} */}
+
+
+ {"~" + window.ship}
+
+
+ );
+ }
+}
+
diff --git a/pkg/interface/link/src/js/components/lib/icons/icon-home.js b/pkg/interface/link/src/js/components/lib/icons/icon-home.js
new file mode 100644
index 0000000000..ddd33b94d4
--- /dev/null
+++ b/pkg/interface/link/src/js/components/lib/icons/icon-home.js
@@ -0,0 +1,12 @@
+import React, { Component } from 'react';
+
+export class IconHome extends Component {
+ render() {
+ return (
+ //TODO relocate to ~launch when OS1 is ported
+
+ );
+ }
+}
diff --git a/pkg/interface/link/src/js/components/lib/icons/icon-sidebar-switch.js b/pkg/interface/link/src/js/components/lib/icons/icon-sidebar-switch.js
new file mode 100644
index 0000000000..644442693a
--- /dev/null
+++ b/pkg/interface/link/src/js/components/lib/icons/icon-sidebar-switch.js
@@ -0,0 +1,34 @@
+import React, { Component } from 'react'
+import { api } from '../../../api'
+
+export class SidebarSwitcher extends Component {
+ render() {
+
+ let popoutSwitcher = this.props.popout
+ ? "dn-m dn-l dn-xl"
+ : "dib-m dib-l dib-xl";
+
+ return (
+
+ );
+ }
+}
+
+export default SidebarSwitcher
diff --git a/pkg/interface/link/src/js/components/lib/icons/icon-spinner.js b/pkg/interface/link/src/js/components/lib/icons/icon-spinner.js
new file mode 100644
index 0000000000..ee2730f6a5
--- /dev/null
+++ b/pkg/interface/link/src/js/components/lib/icons/icon-spinner.js
@@ -0,0 +1,9 @@
+import React, { Component } from 'react';
+
+export class IconSpinner extends Component {
+ render() {
+ return (
+
+ );
+ }
+}
diff --git a/pkg/interface/link/src/js/components/lib/icons/sigil.js b/pkg/interface/link/src/js/components/lib/icons/sigil.js
new file mode 100644
index 0000000000..146b3a0651
--- /dev/null
+++ b/pkg/interface/link/src/js/components/lib/icons/sigil.js
@@ -0,0 +1,27 @@
+import React, { Component } from 'react';
+import { sigil, reactRenderer } from 'urbit-sigil-js';
+
+
+export class Sigil extends Component {
+ render() {
+ const { props } = this;
+
+ if (props.ship.length > 14) {
+ return (
+
+
+ );
+ } else {
+ return (
+
+ {sigil({
+ patp: props.ship,
+ renderer: reactRenderer,
+ size: props.size,
+ colors: [props.color, "white"]
+ })}
+
+ );
+ }
+ }
+}
diff --git a/pkg/interface/link/src/js/components/lib/link-item.js b/pkg/interface/link/src/js/components/lib/link-item.js
new file mode 100644
index 0000000000..f9210569a6
--- /dev/null
+++ b/pkg/interface/link/src/js/components/lib/link-item.js
@@ -0,0 +1,84 @@
+import React, { Component } from 'react'
+import moment from 'moment';
+
+import { Sigil } from '/components/lib/icons/sigil';
+import { Route, Link } from 'react-router-dom';
+
+export class LinkItem extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ timeSinceLinkPost: this.getTimeSinceLinkPost()
+ };
+ }
+
+ componentDidMount() {
+ this.updateTimeSinceNewestMessageInterval = setInterval( () => {
+ this.setState({timeSinceLinkPost: this.getTimeSinceLinkPost()});
+ }, 60000);
+ }
+
+ componentWillUnmount() {
+ if (this.updateTimeSinceNewestMessageInterval) {
+ clearInterval(this.updateTimeSinceNewestMessageInterval);
+ this.updateTimeSinceNewestMessageInterval = null;
+ }
+ }
+
+ getTimeSinceLinkPost() {
+ return !!this.props.timestamp ?
+ moment.unix(this.props.timestamp / 1000).from(moment.utc())
+ : '';
+ }
+
+ render() {
+
+ let props = this.props;
+
+ let mono = (props.nickname) ? "inter white-d" : "mono white-d";
+
+ let URLparser = new RegExp(/((?:([\w\d\.-]+)\:\/\/?){1}(?:(www)\.?){0,1}(((?:[\w\d-]+\.)*)([\w\d-]+\.[\w\d]+))){1}(?:\:(\d+)){0,1}((\/(?:(?:[^\/\s\?]+\/)*))(?:([^\?\/\s#]+?(?:.[^\?\s]+){0,1}){0,1}(?:\?([^\s#]+)){0,1})){0,1}(?:#([^#\s]+)){0,1}/);
+
+ let hostname = URLparser.exec(props.url);
+
+ if (hostname) {
+ hostname = hostname[4];
+ }
+
+ let comments = props.comments + " comment" + ((props.comments === 1) ? "" : "s");
+
+ return (
+
+ )
+ }
+}
+
+export default LinkItem
diff --git a/pkg/interface/link/src/js/components/lib/link-submit.js b/pkg/interface/link/src/js/components/lib/link-submit.js
new file mode 100644
index 0000000000..63ea9f1b2b
--- /dev/null
+++ b/pkg/interface/link/src/js/components/lib/link-submit.js
@@ -0,0 +1,117 @@
+import React, { Component } from 'react'
+import { api } from '../../api';
+
+
+export class LinkSubmit extends Component {
+ constructor() {
+ super();
+ this.state = {
+ linkValue: "",
+ linkTitle: "",
+ linkValid: false
+ }
+ this.setLinkValue = this.setLinkValue.bind(this);
+ this.setLinkTitle = this.setLinkTitle.bind(this);
+ }
+
+ onClickPost() {
+ let link = this.state.linkValue;
+ let title = (this.state.linkTitle)
+ ? this.state.linkTitle
+ : this.state.linkValue;
+ let request = api.postLink(this.props.path, link, title);
+
+ if (request) {
+ this.setState({linkValue: "", linkTitle: ""})
+ }
+ }
+
+ setLinkValid(link) {
+ let URLparser = new RegExp(/((?:([\w\d\.-]+)\:\/\/?){1}(?:(www)\.?){0,1}(((?:[\w\d-]+\.)*)([\w\d-]+\.[\w\d]+))){1}(?:\:(\d+)){0,1}((\/(?:(?:[^\/\s\?]+\/)*))(?:([^\?\/\s#]+?(?:.[^\?\s]+){0,1}){0,1}(?:\?([^\s#]+)){0,1})){0,1}(?:#([^#\s]+)){0,1}/);
+
+ let validURL = URLparser.exec(link);
+
+ if (!validURL) {
+ let checkProtocol = URLparser.exec("http://" + link);
+ if (checkProtocol) {
+ this.setState({linkValid: true});
+ this.setState({linkValue: "http://" + link});
+ } else {
+ this.setState({linkValid: false})
+ }
+ } else if (validURL) {
+ this.setState({linkValid: true});
+ }
+ }
+
+ setLinkValue(event) {
+ this.setState({linkValue: event.target.value});
+ this.setLinkValid(event.target.value);
+ }
+
+ setLinkTitle(event) {
+ this.setState({linkTitle: event.target.value});
+ }
+
+ render() {
+
+ let activeClasses = (this.state.linkValid)
+ ? "green2 pointer"
+ : "gray2";
+
+ return (
+
+
+ )
+ }
+}
+
+export default LinkSubmit;
diff --git a/pkg/interface/link/src/js/components/lib/links-tabbar.js b/pkg/interface/link/src/js/components/lib/links-tabbar.js
new file mode 100644
index 0000000000..fafcc96510
--- /dev/null
+++ b/pkg/interface/link/src/js/components/lib/links-tabbar.js
@@ -0,0 +1,51 @@
+import React, { Component } from 'react'
+
+export class LinksTabBar extends Component {
+ render() {
+ let props = this.props;
+
+ let memColor = '',
+ popout = '';
+
+ if (props.location.pathname.includes('/members')) {
+ memColor = 'black';
+ } else {
+ memColor = 'gray3';
+ }
+
+ (props.location.pathname.includes('/popout'))
+ ? popout = "popout/"
+ : popout = "";
+
+ let hidePopoutIcon = (this.props.popout)
+ ? "dn-m dn-l dn-xl"
+ : "dib-m dib-l dib-xl";
+
+
+ return (
+
+ {!!props.isOwner ? (
+
+
+ Members
+
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+ }
+}
+
+export default LinksTabBar
diff --git a/pkg/interface/link/src/js/components/lib/pagination.js b/pkg/interface/link/src/js/components/lib/pagination.js
new file mode 100644
index 0000000000..2f0474d95d
--- /dev/null
+++ b/pkg/interface/link/src/js/components/lib/pagination.js
@@ -0,0 +1,36 @@
+import React, { Component } from 'react';
+import { Route, Link } from 'react-router-dom';
+
+export class Pagination extends Component {
+ render() {
+ let props = this.props;
+
+ let prevPage = "/" + (Number(props.page) - 1);
+ let nextPage = "/" + (Number(props.page) + 1);
+
+ let prevDisplay = ((props.currentPage > 0))
+ ? "dib absolute left-0"
+ : "dn";
+
+ let nextDisplay = ((props.currentPage + 1) < props.totalPages)
+ ? "dib absolute right-0"
+ : "dn";
+
+ return (
+
+
+
+ <- Previous Page
+
+
+
+
+ Next Page ->
+
+
+
+ )
+ }
+}
+
+export default Pagination
diff --git a/pkg/interface/link/src/js/components/link.js b/pkg/interface/link/src/js/components/link.js
new file mode 100644
index 0000000000..ceea0b3be6
--- /dev/null
+++ b/pkg/interface/link/src/js/components/link.js
@@ -0,0 +1,217 @@
+import React, { Component } from 'react'
+import { LinksTabBar } from './lib/links-tabbar';
+import { SidebarSwitcher } from '/components/lib/icons/icon-sidebar-switch.js';
+import { api } from '../api';
+import { Route, Link } from 'react-router-dom';
+import { Sigil } from '/components/lib/icons/sigil';
+import { Comments } from './lib/comments';
+import { uxToHex } from '../lib/util';
+import moment from 'moment'
+
+export class LinkDetail extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ timeSinceLinkPost: this.getTimeSinceLinkPost(),
+ comment: ""
+ };
+
+ this.setComment = this.setComment.bind(this);
+ }
+
+ componentDidMount() {
+ // if we have preloaded our data,
+ // but no comments, grab the comments
+ if (!!this.props.data.url) {
+ let props = this.props;
+ let comments = !!props.data.comments;
+
+ if (!comments) {
+ api.getComments(props.path, props.data.url, props.page, props.link);
+ }
+ }
+
+ this.updateTimeSinceNewestMessageInterval = setInterval( () => {
+ this.setState({timeSinceLinkPost: this.getTimeSinceLinkPost()});
+ }, 60000);
+ }
+
+ componentDidUpdate(prevProps) {
+ // if we came to this page *directly*,
+ // load the comments -- DidMount will fail
+ if (this.props.data.url !== prevProps.data.url) {
+ let props = this.props;
+ let comments = !!this.props.data.comments;
+
+ if (!comments && this.props.data.url) {
+ api.getComments(props.path, props.data.url, props.page, props.link);
+ }
+ }
+
+ if (this.props.data.timestamp !== prevProps.data.timestamp) {
+ this.setState({timeSinceLinkPost: this.getTimeSinceLinkPost()})
+ }
+ }
+
+ componentWillUnmount() {
+ if (this.updateTimeSinceNewestMessageInterval) {
+ clearInterval(this.updateTimeSinceNewestMessageInterval);
+ this.updateTimeSinceNewestMessageInterval = null;
+ }
+ }
+
+ getTimeSinceLinkPost() {
+ return !!this.props.data.timestamp ?
+ moment.unix(this.props.data.timestamp / 1000).from(moment.utc())
+ : '';
+ }
+
+ onClickPost() {
+ let url = this.props.data.url || "";
+
+ let request = api.postComment(
+ this.props.path,
+ url,
+ this.state.comment,
+ this.props.page,
+ this.props.link
+ );
+
+ if (request) {
+ this.setState({comment: ""})
+ }
+ }
+
+ setComment(event) {
+ this.setState({comment: event.target.value});
+ }
+
+ render() {
+ let props = this.props;
+ let popout = (props.popout) ? "/popout" : "";
+ let path = props.path + "/" + props.page + "/" + props.link;
+
+ let ship = props.data.ship || "zod";
+ let title = props.data.title || "";
+ let url = props.data.url || "";
+
+ let URLparser = new RegExp(/((?:([\w\d\.-]+)\:\/\/?){1}(?:(www)\.?){0,1}(((?:[\w\d-]+\.)*)([\w\d-]+\.[\w\d]+))){1}(?:\:(\d+)){0,1}((\/(?:(?:[^\/\s\?]+\/)*))(?:([^\?\/\s#]+?(?:.[^\?\s]+){0,1}){0,1}(?:\?([^\s#]+)){0,1})){0,1}(?:#([^#\s]+)){0,1}/);
+
+ let hostname = URLparser.exec(url);
+
+ if (hostname) {
+ hostname = hostname[4];
+ }
+
+ let commentCount = props.data.commentCount || 0;
+
+ let comments = commentCount + " comment" + ((commentCount === 1) ? "" : "s");
+
+ let nickname = !!props.members[props.data.ship]
+ ? props.members[props.data.ship].nickname
+ : "";
+
+ let nameClass = nickname ? "inter" : "mono";
+
+ let color = !!props.members[props.data.ship]
+ ? uxToHex(props.members[props.data.ship].color)
+ : "000000";
+
+ let activeClasses = (this.state.comment)
+ ? "black b--black pointer"
+ : "gray2 b--gray2";
+
+ return (
+
+
+
+
+ {"<- Collection index"}
+
+
+
+
+
+ )
+ }
+ }
+
+ export default LinkDetail;
+
\ No newline at end of file
diff --git a/pkg/interface/link/src/js/components/links-list.js b/pkg/interface/link/src/js/components/links-list.js
new file mode 100644
index 0000000000..a57b6d6289
--- /dev/null
+++ b/pkg/interface/link/src/js/components/links-list.js
@@ -0,0 +1,144 @@
+import React, { Component } from 'react'
+import { LinksTabBar } from './lib/links-tabbar';
+import { SidebarSwitcher } from '/components/lib/icons/icon-sidebar-switch.js';
+import { Route, Link } from "react-router-dom";
+import { LinkItem } from '/components/lib/link-item.js';
+import { LinkSubmit } from '/components/lib/link-submit.js';
+import { Pagination } from '/components/lib/pagination.js';
+
+//TODO look at uxToHex wonky functionality
+import { uxToHex } from '../lib/util';
+
+//TODO Avatar support once it's in
+export class Links extends Component {
+
+ componentDidMount() {
+ let linkPage = "page" + this.props.page;
+ if ((this.props.page !== 0) && (!this.props.links[linkPage])) {
+ api.getPage(this.props.path, this.props.page);
+ }
+ }
+
+ componentDidUpdate() {
+ let linkPage = "page" + this.props.page;
+ if ((this.props.page !== 0) && (!this.props.links[linkPage])) {
+ api.getPage(this.props.path, this.props.page);
+ }
+ }
+
+ render() {
+ let props = this.props;
+ let popout = (props.popout) ? "/popout" : "";
+ let channel = props.path.substr(1);
+ let linkPage = "page" + props.page;
+
+ let links = !!props.links[linkPage]
+ ? props.links[linkPage]
+ : {};
+
+ let currentPage = !!props.page
+ ? Number(props.page)
+ : 0;
+
+ let totalPages = !!props.links
+ ? Number(props.links["total-pages"])
+ : 1;
+
+ let LinkList = Object.keys(links)
+ .map((link) => {
+ let linksObj = props.links[linkPage];
+ let { title, url, timestamp, ship, commentCount } = linksObj[link];
+ let members = {};
+
+ if (!props.members[ship]) {
+ members[ship] = {'nickname': '', 'avatar': 'TODO', 'color': '0x0'};
+ } else {
+ members = props.members;
+ }
+
+ let color = uxToHex('0x0');
+ let nickname = "";
+
+ // restore this to props.members
+ if (members[ship].nickname) {
+ nickname = members[ship].nickname;
+ }
+
+ if (members[ship].color !== "") {
+ color = uxToHex(members[ship].color);
+ }
+
+ return (
+
+ )
+ })
+
+ return (
+
+
+ {"⟵ All Channels"}
+
+
+
+
+
+ {(props.path.includes("/~/"))
+ ? "Private"
+ : channel}
+
+
+
+
+
+
+ )
+ }
+}
+
+export default Links;
\ No newline at end of file
diff --git a/pkg/interface/link/src/js/components/root.js b/pkg/interface/link/src/js/components/root.js
new file mode 100644
index 0000000000..8e3053f255
--- /dev/null
+++ b/pkg/interface/link/src/js/components/root.js
@@ -0,0 +1,142 @@
+import React, { Component } from 'react';
+import { BrowserRouter, Route, Link } from "react-router-dom";
+import classnames from 'classnames';
+import _ from 'lodash';
+
+import { api } from '/api';
+import { subscription } from '/subscription';
+import { store } from '/store';
+import { Skeleton } from '/components/skeleton';
+import { Links } from '/components/links-list';
+import { LinkDetail } from '/components/link';
+
+
+export class Root extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = store.state;
+ store.setStateHandler(this.setState.bind(this));
+ this.setSpinner = this.setSpinner.bind(this);
+ }
+
+ setSpinner(spinner) {
+ this.setState({
+ spinner
+ });
+ }
+
+ render() {
+ const { props, state } = this;
+
+ let paths = !!state.contacts ? state.contacts : {};
+
+ let links = !!state.links ? state.links : {};
+
+ return (
+
+ {
+ return (
+
+
+
+
+ Channels are shared across groups. To create a new channel, create a group.
+
+
+
+
+ );
+ }} />
+ {
+ // groups/contacts and link channels are the same thing in ver 1
+
+ let groupPath =
+ `/${props.match.params.ship}/${props.match.params.channel}`;
+ let groupMembers = paths[groupPath] || {};
+
+ let page = props.match.params.page || 0;
+
+ let popout = props.match.url.includes("/popout/");
+
+ let channelLinks = !!links[groupPath]
+ ? links[groupPath]
+ : {};
+
+ return (
+
+
+
+ )
+ }}
+ />
+ {
+ let groupPath =
+ `/${props.match.params.ship}/${props.match.params.channel}`;
+
+ let popout = props.match.url.includes("/popout/");
+
+ let groupMembers = paths[groupPath] || {};
+
+ let index = props.match.params.index || 0;
+ let page = props.match.params.page || 0;
+
+ let data = !!links[groupPath]
+ ? links[groupPath]["page" + page][index]
+ : {};
+
+ let commentPage = props.match.params.commentpage || 0;
+
+ return (
+
+
+
+ )
+ }}
+ />
+
+ )
+ }
+}
\ No newline at end of file
diff --git a/pkg/interface/link/src/js/components/skeleton.js b/pkg/interface/link/src/js/components/skeleton.js
new file mode 100644
index 0000000000..e2eae620b2
--- /dev/null
+++ b/pkg/interface/link/src/js/components/skeleton.js
@@ -0,0 +1,44 @@
+import React, { Component } from 'react';
+import classnames from 'classnames';
+import { ChannelsSidebar } from './lib/channel-sidebar';
+
+
+export class Skeleton extends Component {
+ render() {
+
+ let rightPanelHide = this.props.rightPanelHide
+ ? "dn-s"
+ : "";
+
+ let popout = !!this.props.popout
+ ? this.props.popout
+ : false;
+
+ let popoutWindow = (popout)
+ ? ""
+ : "h-100-m-40-ns ph4-m ph4-l ph4-xl pb4-m pb4-l pb4-xl"
+
+ let popoutBorder = (popout)
+ ? ""
+ : "ba-m ba-l ba-xl b--gray2 br1"
+
+ return (
+
+
+
+
+ {this.props.children}
+
+
+
+ );
+ }
+}
diff --git a/pkg/interface/link/src/js/lib/util.js b/pkg/interface/link/src/js/lib/util.js
new file mode 100644
index 0000000000..19574a0cdb
--- /dev/null
+++ b/pkg/interface/link/src/js/lib/util.js
@@ -0,0 +1,68 @@
+import _ from 'lodash';
+import classnames from 'classnames';
+
+
+export function uuid() {
+ let str = "0v"
+ str += Math.ceil(Math.random()*8)+"."
+ for (var i = 0; i < 5; i++) {
+ let _str = Math.ceil(Math.random()*10000000).toString(32);
+ _str = ("00000"+_str).substr(-5,5);
+ str += _str+".";
+ }
+
+ return str.slice(0,-1);
+}
+
+export function isPatTa(str) {
+ const r = /^[a-z,0-9,\-,\.,_,~]+$/.exec(str)
+ return !!r;
+}
+
+/*
+ Goes from:
+ ~2018.7.17..23.15.09..5be5 // urbit @da
+ To:
+ (javascript Date object)
+*/
+export function daToDate(st) {
+ var dub = function(n) {
+ return parseInt(n) < 10 ? "0" + parseInt(n) : n.toString();
+ };
+ var da = st.split('..');
+ var bigEnd = da[0].split('.');
+ var lilEnd = da[1].split('.');
+ var ds = `${bigEnd[0].slice(1)}-${dub(bigEnd[1])}-${dub(bigEnd[2])}T${dub(lilEnd[0])}:${dub(lilEnd[1])}:${dub(lilEnd[2])}Z`;
+ return new Date(ds);
+}
+
+/*
+ Goes from:
+ (javascript Date object)
+ To:
+ ~2018.7.17..23.15.09..5be5 // urbit @da
+*/
+
+export function dateToDa(d, mil) {
+ var fil = function(n) {
+ return n >= 10 ? n : "0" + n;
+ };
+ return (
+ `~${d.getUTCFullYear()}.` +
+ `${(d.getUTCMonth() + 1)}.` +
+ `${fil(d.getUTCDate())}..` +
+ `${fil(d.getUTCHours())}.` +
+ `${fil(d.getUTCMinutes())}.` +
+ `${fil(d.getUTCSeconds())}` +
+ `${mil ? "..0000" : ""}`
+ );
+}
+
+export function deSig(ship) {
+ return ship.replace('~', '');
+}
+
+export function uxToHex(ux) {
+ let value = ux.substr(2).replace('.', '').padStart(6, '0');
+ return value;
+}
diff --git a/pkg/interface/link/src/js/reducers/initial.js b/pkg/interface/link/src/js/reducers/initial.js
new file mode 100644
index 0000000000..2ef0c6810a
--- /dev/null
+++ b/pkg/interface/link/src/js/reducers/initial.js
@@ -0,0 +1,37 @@
+import _ from 'lodash';
+
+
+export class InitialReducer {
+ reduce(json, state) {
+ let data = _.get(json, 'contact-initial', false);
+ if (data) {
+ state.contacts = data;
+ }
+
+ data = _.get(json, 'group-initial', false);
+ if (data) {
+ for (let group in data) {
+ state.groups[group] = new Set(data[group]);
+ }
+ }
+
+ data = _.get(json, 'link', false);
+ if (data) {
+ let name = Object.keys(data)[0];
+ let initial = {};
+ initial[name] = {};
+ initial[name]["total-pages"] = data[name]["total-pages"];
+ initial[name]["total-items"] = data[name]["total-items"];
+ initial[name]["page0"] = data[name]["page"];
+
+ if (!!state.links[name]) {
+ let origin = state.links[name];
+ _.extend(initial[name], origin);
+ } else {
+ state.links[name] = {};
+ }
+ state.links[name] = initial[name];
+ }
+ }
+}
+
diff --git a/pkg/interface/link/src/js/reducers/invite-update.js b/pkg/interface/link/src/js/reducers/invite-update.js
new file mode 100644
index 0000000000..5fbc24236d
--- /dev/null
+++ b/pkg/interface/link/src/js/reducers/invite-update.js
@@ -0,0 +1,53 @@
+import _ from 'lodash';
+
+
+export class InviteUpdateReducer {
+ reduce(json, state) {
+ let data = _.get(json, 'invite-update', false);
+ if (data) {
+ this.create(data, state);
+ this.delete(data, state);
+ this.invite(data, state);
+ this.accepted(data, state);
+ this.decline(data, state);
+ }
+ }
+
+ create(json, state) {
+ let data = _.get(json, 'create', false);
+ if (data) {
+ state.invites[data.path] = {};
+ }
+ }
+
+ delete(json, state) {
+ let data = _.get(json, 'delete', false);
+ if (data) {
+ delete state.invites[data.path];
+ }
+ }
+
+ invite(json, state) {
+ let data = _.get(json, 'invite', false);
+ if (data) {
+ state.invites[data.path][data.uid] = data.invite;
+ }
+ }
+
+ accepted(json, state) {
+ let data = _.get(json, 'accepted', false);
+ if (data) {
+ console.log(data);
+ delete state.invites[data.path][data.uid];
+ }
+ }
+
+ decline(json, state) {
+ let data = _.get(json, 'decline', false);
+ if (data) {
+ delete state.invites[data.path][data.uid];
+ }
+ }
+
+}
+
diff --git a/pkg/interface/link/src/js/reducers/link-update.js b/pkg/interface/link/src/js/reducers/link-update.js
new file mode 100644
index 0000000000..ab6053fcb6
--- /dev/null
+++ b/pkg/interface/link/src/js/reducers/link-update.js
@@ -0,0 +1,91 @@
+import _ from 'lodash';
+
+export class LinkUpdateReducer {
+ reduce(json, state) {
+ let data = _.get(json, 'link-update', false);
+ if (data) {
+ this.add(data, state);
+ this.comments(data, state);
+ this.commentAdd(data, state);
+ this.commentPage(data, state);
+ this.page(data, state);
+ }
+ }
+
+ add(json, state) {
+ // pin ok'd link POSTs to top of page0
+ let data = _.get(json, 'add', false);
+ if (data) {
+ let path = Object.keys(data)[0];
+ let tempArray = state.links[path].page0;
+ tempArray.unshift(data[path]);
+ state.links[path].page0 = tempArray;
+ }
+ }
+
+ comments(json, state) {
+ let data = _.get(json, 'comments', false);
+ if (data) {
+ let path = data.path;
+ let page = "page" + data.page;
+ let index = data.index;
+ let storage = state.links[path][page][index];
+
+ storage.comments = {};
+ storage.comments["page0"] = data.data.page;
+ storage.comments["total-items"] = data.data["total-items"];
+ storage.comments["total-pages"] = data.data["total-pages"];
+
+ state.links[path][page][index] = storage;
+ }
+ }
+
+ commentAdd(json, state) {
+ let data = _.get(json, 'commentAdd', false);
+ if (data) {
+ let path = data.path;
+ let page = "page" + data.page;
+ let index = data.index;
+
+ let ship = window.ship;
+ let time = data.time;
+ let udon = data.udon;
+ let tempObj = {
+ 'ship': ship,
+ 'time': time,
+ 'udon': udon
+ }
+ let tempArray = state.links[path][page][index].comments.page;
+ tempArray.unshift(tempObj);
+ state.links[path][page][index].comments.page = tempArray;
+ }
+ }
+
+ commentPage(json, state) {
+ let data = _.get(json, 'commentPage', false);
+ if (data) {
+ let path = data.path;
+ let linkPage = "page" + data.linkPage;
+ let linkIndex = data.index;
+ let commentPage = "page" + data.comPageNo;
+
+ if (!state.links[path]) {
+ return false;
+ }
+
+ state.links[path][linkPage][linkIndex].comments[commentPage] = data.data;
+ }
+ }
+
+ page(json, state) {
+ let data = _.get(json, 'page', false);
+ if (data) {
+ let path = Object.keys(data)[0];
+ let page = "page" + data[path].page;
+ if (!state.links[path]) {
+ state.links[path] = {};
+ }
+ state.links[path][page] = data[path].links;
+ }
+ }
+}
\ No newline at end of file
diff --git a/pkg/interface/link/src/js/reducers/local.js b/pkg/interface/link/src/js/reducers/local.js
new file mode 100644
index 0000000000..29946b7ed5
--- /dev/null
+++ b/pkg/interface/link/src/js/reducers/local.js
@@ -0,0 +1,17 @@
+import _ from 'lodash';
+
+export class LocalReducer {
+ reduce(json, state) {
+ let data = _.get(json, 'local', false);
+ if (data) {
+ this.sidebarToggle(data, state);
+ }
+ }
+
+ sidebarToggle(obj, state) {
+ let data = _.has(obj, 'sidebarToggle', false);
+ if (data) {
+ state.sidebarShown = obj.sidebarToggle;
+ }
+ }
+}
\ No newline at end of file
diff --git a/pkg/interface/link/src/js/reducers/permission-update.js b/pkg/interface/link/src/js/reducers/permission-update.js
new file mode 100644
index 0000000000..420066e0d2
--- /dev/null
+++ b/pkg/interface/link/src/js/reducers/permission-update.js
@@ -0,0 +1,51 @@
+import _ from 'lodash';
+
+
+export class PermissionUpdateReducer {
+ reduce(json, state) {
+ let data = _.get(json, 'permission-update', false);
+ if (data) {
+ this.create(data, state);
+ this.delete(data, state);
+ this.add(data, state);
+ this.remove(data, state);
+ }
+ }
+
+ create(json, state) {
+ let data = _.get(json, 'create', false);
+ if (data) {
+ state.permissions[data.path] = {
+ kind: data.kind,
+ who: new Set(data.who)
+ };
+ }
+ }
+
+ delete(json, state) {
+ let data = _.get(json, 'delete', false);
+ if (data) {
+ delete state.permissions[data.path];
+ }
+ }
+
+ add(json, state) {
+ let data = _.get(json, 'add', false);
+ if (data) {
+ for (let member of data.who) {
+ state.permissions[data.path].who.add(member);
+ }
+ }
+ }
+
+ remove(json, state) {
+ let data = _.get(json, 'remove', false);
+ if (data) {
+ for (let member of data.who) {
+ state.permissions[data.path].who.delete(member);
+ }
+ }
+ }
+
+}
+
diff --git a/pkg/interface/link/src/js/store.js b/pkg/interface/link/src/js/store.js
new file mode 100644
index 0000000000..ddda0349ab
--- /dev/null
+++ b/pkg/interface/link/src/js/store.js
@@ -0,0 +1,68 @@
+import { InitialReducer } from '/reducers/initial';
+import { PermissionUpdateReducer } from '/reducers/permission-update';
+import { LinkUpdateReducer } from '/reducers/link-update';
+import { LocalReducer } from '/reducers/local.js';
+import _ from 'lodash';
+
+
+class Store {
+ constructor() {
+ this.state = {
+ contacts: {},
+ groups: {},
+ links: {},
+ permissions: {},
+ sidebarShown: true,
+ spinner: false
+ };
+
+ this.initialReducer = new InitialReducer();
+ this.permissionUpdateReducer = new PermissionUpdateReducer();
+ this.localReducer = new LocalReducer();
+ this.linkUpdateReducer = new LinkUpdateReducer();
+ this.setState = () => {};
+ }
+
+ async loadLinks(json) {
+ // if initial contacts, queue up getting these paths from link-store
+ let data = _.get(json, 'group-initial', false);
+ if (data) {
+ for (let each of Object.keys(data)) {
+ let linkUrl = "/~link/submissions" + each + ".json?p=0";
+ let promise = await fetch(linkUrl);
+ if (promise.ok) {
+ let resolvedData = {}
+ resolvedData.link = {};
+ resolvedData.link[each] = {};
+ resolvedData.link[each] = await promise.json();
+ this.handleEvent(resolvedData);
+ }
+ }
+ }
+ }
+
+ setStateHandler(setState) {
+ this.setState = setState;
+ }
+
+ handleEvent(data) {
+ let json;
+ if (data.data) {
+ json = data.data;
+ } else {
+ json = data;
+ }
+
+ console.log(json);
+ this.loadLinks(json);
+ this.initialReducer.reduce(json, this.state);
+ this.permissionUpdateReducer.reduce(json, this.state);
+ this.localReducer.reduce(json, this.state);
+ this.linkUpdateReducer.reduce(json, this.state);
+
+ this.setState(this.state);
+ }
+}
+
+export let store = new Store();
+window.store = store;
diff --git a/pkg/interface/link/src/js/subscription.js b/pkg/interface/link/src/js/subscription.js
new file mode 100644
index 0000000000..d2f02d12dc
--- /dev/null
+++ b/pkg/interface/link/src/js/subscription.js
@@ -0,0 +1,47 @@
+import { api } from '/api';
+import { store } from '/store';
+
+import urbitOb from 'urbit-ob';
+
+
+export class Subscription {
+ start() {
+ if (api.authTokens) {
+ this.initializeLinks();
+ } else {
+ console.error("~~~ ERROR: Must set api.authTokens before operation ~~~");
+ }
+ }
+
+ initializeLinks() {
+ // add invite, permissions flows once link stores are more than
+ // group-specific
+ api.bind('/all', 'PUT', api.authTokens.ship, 'group-store',
+ this.handleEvent.bind(this),
+ this.handleError.bind(this),
+ this.handleQuitAndResubscribe.bind(this));
+ api.bind('/primary', 'PUT', api.authTokens.ship, 'contact-view',
+ this.handleEvent.bind(this),
+ this.handleError.bind(this),
+ this.handleQuitAndResubscribe.bind(this));
+ }
+
+ handleEvent(diff) {
+ store.handleEvent(diff);
+ }
+
+ handleError(err) {
+ console.error(err);
+ }
+
+ handleQuitSilently(quit) {
+ // no-op
+ }
+
+ handleQuitAndResubscribe(quit) {
+ // TODO: resubscribe
+ }
+
+}
+
+export let subscription = new Subscription();
diff --git a/pkg/interface/link/tile/tile.js b/pkg/interface/link/tile/tile.js
new file mode 100644
index 0000000000..f799235a7b
--- /dev/null
+++ b/pkg/interface/link/tile/tile.js
@@ -0,0 +1,33 @@
+import React, { Component } from 'react';
+import classnames from 'classnames';
+import _ from 'lodash';
+
+
+export default class LinkTile extends Component {
+
+ render() {
+ const { props } = this;
+
+ return (
+
+ );
+ }
+
+}
+
+window['link-server-hookTile'] = LinkTile;