interface: add links FE source code

This commit is contained in:
Matilde Park 2020-01-22 18:16:44 -05:00
parent 200d7548f6
commit d3f57fbf9d
37 changed files with 9009 additions and 0 deletions

View File

@ -0,0 +1,183 @@
var gulp = require('gulp');
var cssimport = require('gulp-cssimport');
var rollup = require('gulp-better-rollup');
var cssnano = require('cssnano');
var postcss = require('gulp-postcss');
var sucrase = require('@sucrase/gulp-plugin');
var minify = require('gulp-minify');
var rename = require('gulp-rename');
var del = require('del');
var resolve = require('rollup-plugin-node-resolve');
var commonjs = require('rollup-plugin-commonjs');
var rootImport = require('rollup-plugin-root-import');
var globals = require('rollup-plugin-node-globals');
/***
Main config options
***/
var urbitrc = require('../urbitrc');
/***
End main config options
***/
gulp.task('css-bundle', function() {
let plugins = [
cssnano()
];
return gulp
.src('src/index.css')
.pipe(cssimport())
.pipe(postcss(plugins))
.pipe(gulp.dest('../../arvo/app/link/css'));
});
gulp.task('jsx-transform', function(cb) {
return gulp.src('src/**/*.js')
.pipe(sucrase({
transforms: ['jsx']
}))
.pipe(gulp.dest('dist'));
});
gulp.task('tile-jsx-transform', function(cb) {
return gulp.src('tile/**/*.js')
.pipe(sucrase({
transforms: ['jsx']
}))
.pipe(gulp.dest('dist'));
});
gulp.task('js-imports', function(cb) {
return gulp.src('dist/index.js')
.pipe(rollup({
plugins: [
commonjs({
namedExports: {
'node_modules/react/index.js': [ 'Component' ],
'node_modules/react-is/index.js': [ 'isValidElementType' ],
}
}),
rootImport({
root: `${__dirname}/dist/js`,
useEntry: 'prepend',
extensions: '.js'
}),
globals(),
resolve()
]
}, 'umd'))
.on('error', function(e){
console.log(e);
cb();
})
.pipe(gulp.dest('../../arvo/app/link/js/'))
.on('end', cb);
});
gulp.task('tile-js-imports', function(cb) {
return gulp.src('dist/tile.js')
.pipe(rollup({
plugins: [
commonjs({
namedExports: {
'node_modules/react/index.js': [ 'Component' ],
}
}),
rootImport({
root: `${__dirname}/dist/js`,
useEntry: 'prepend',
extensions: '.js'
}),
globals(),
resolve()
]
}, 'umd'))
.on('error', function(e){
console.log(e);
cb();
})
.pipe(gulp.dest('../../arvo/app/link/js/'))
.on('end', cb);
});
gulp.task('js-minify', function () {
return gulp.src('../../arvo/app/link/js/index.js')
.pipe(minify())
.pipe(gulp.dest('../../arvo/app/link/js/'));
});
gulp.task('tile-js-minify', function () {
return gulp.src('../../arvo/app/link/js/tile.js')
.pipe(minify())
.pipe(gulp.dest('../../arvo/app/link/js/'));
});
gulp.task('rename-index-min', function() {
return gulp.src('../../arvo/app/link/js/index-min.js')
.pipe(rename('index.js'))
.pipe(gulp.dest('../../arvo/app/link/js/'))
});
gulp.task('rename-tile-min', function() {
return gulp.src('../../arvo/app/link/js/tile-min.js')
.pipe(rename('tile.js'))
.pipe(gulp.dest('../../arvo/app/link/js/'))});
gulp.task('clean-min', function() {
return del(['../../arvo/app/link/js/index-min.js', '../../arvo/app/link/js/tile-min.js'], {force: true})
});
gulp.task('urbit-copy', function () {
let ret = gulp.src('../../arvo/**/*');
urbitrc.URBIT_PIERS.forEach(function(pier) {
ret = ret.pipe(gulp.dest(pier));
});
return ret;
});
gulp.task('js-bundle-dev', gulp.series('jsx-transform', 'js-imports'));
gulp.task('tile-js-bundle-dev', gulp.series('tile-jsx-transform', 'tile-js-imports'));
gulp.task('js-bundle-prod', gulp.series('jsx-transform', 'js-imports', 'js-minify'))
gulp.task('tile-js-bundle-prod',
gulp.series('tile-jsx-transform', 'tile-js-imports', 'tile-js-minify'));
gulp.task('bundle-dev',
gulp.series(
gulp.parallel(
'css-bundle',
'js-bundle-dev',
'tile-js-bundle-dev'
),
'urbit-copy'
)
);
gulp.task('bundle-prod',
gulp.series(
gulp.parallel(
'css-bundle',
'js-bundle-prod',
'tile-js-bundle-prod',
),
'rename-index-min',
'rename-tile-min',
'clean-min',
'urbit-copy'
)
);
gulp.task('default', gulp.series('bundle-dev'));
gulp.task('watch', gulp.series('default', function() {
gulp.watch('tile/**/*.js', gulp.parallel('tile-js-bundle-dev'));
gulp.watch('src/**/*.js', gulp.parallel('js-bundle-dev'));
gulp.watch('src/**/*.css', gulp.parallel('css-bundle'));
gulp.watch('../../arvo/**/*', gulp.parallel('urbit-copy'));
}));

6511
pkg/interface/link/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,42 @@
{
"name": "urbit-apps",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"devDependencies": {
"@sucrase/gulp-plugin": "^2.0.0",
"cssnano": "^4.1.10",
"gulp": "^4.0.0",
"gulp-better-rollup": "^4.0.1",
"gulp-cssimport": "^7.0.0",
"gulp-minify": "^3.1.0",
"gulp-postcss": "^8.0.0",
"gulp-rename": "^1.4.0",
"moment": "^2.24.0",
"rollup": "^1.6.0",
"rollup-plugin-commonjs": "^9.2.0",
"rollup-plugin-node-globals": "^1.4.0",
"rollup-plugin-node-resolve": "^4.0.0",
"rollup-plugin-root-import": "^0.2.3",
"sucrase": "^3.8.0"
},
"dependencies": {
"classnames": "^2.2.6",
"del": "^5.1.0",
"lodash": "^4.17.11",
"mousetrap": "^1.6.3",
"react": "^16.5.2",
"react-dom": "^16.8.6",
"react-router-dom": "^5.0.0",
"urbit-ob": "^4.1.2",
"urbit-sigil-js": "^1.3.2"
},
"resolutions": {
"natives": "1.1.3"
}
}

View File

@ -0,0 +1,146 @@
html, body {
height: 100%;
width: 100%;
-webkit-font-smoothing: antialiased;
overflow: hidden;
}
p, h1, h2, h3, h4, h5, h6, a, input, textarea, button {
margin-block-end: unset;
margin-block-start: unset;
-webkit-margin-before: unset;
-webkit-margin-after: unset;
font-family: Inter, sans-serif;
padding: 0;
}
textarea, input, button {
outline: none;
-webkit-appearance: none;
border: none;
background-color: #fff;
}
h2 {
font-weight: 400;
}
a {
color: #000;
text-decoration: none;
}
.inter {
font-family: Inter, sans-serif;
}
.clamp-3 {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.clamp-message {
max-width: calc(100% - 36px - 1.5rem);
}
.clamp-attachment {
overflow: scroll;
max-height: 10em;
max-width: 100%;
}
.lh-16 {
line-height: 16px;
}
.mono {
font-family: "Source Code Pro", monospace;
}
.c-default {
cursor: default;
}
.m0a {
margin: 0 auto;
}
/* responsive */
@media all and (max-width: 34.375em) {
.dn-s {
display: none;
}
.flex-basis-100-s, .flex-basis-full-s {
flex-basis: 100%;
}
.h-100-m-40-s {
height: calc(100% - 40px);
}
.black-s {
color: #000;
}
}
@media all and (min-width: 34.375em) {
.db-ns {
display: block;
}
.flex-basis-30-ns {
flex-basis: 30vw;
}
.h-100-m-40-ns {
height: calc(100% - 40px);
}
}
@media all and (prefers-color-scheme: dark) {
body {
background-color: #333;
}
.bg-black-d {
background-color: black;
}
.white-d {
color: white;
}
.gray1-d {
color: #4d4d4d;
}
.gray2-d {
color: #7f7f7f;
}
.gray3-d {
color: #b1b2b3;
}
.gray4-d {
color: #e6e6e6;
}
.bg-gray0-d {
background-color: #333;
}
.bg-gray1-d {
background-color: #4d4d4d;
}
.b--gray0-d {
border-color: #333;
}
.b--gray2-d {
border-color: #7f7f7f;
}
.bb-d {
border-bottom-width: 1px;
border-bottom-style: solid;
}
.invert-d {
filter: invert(1);
}
.o-60-d {
opacity: .6;
}
a {
color: #fff;
}
}

View File

@ -0,0 +1,63 @@
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
src: url("https://media.urbit.org/fonts/Inter-Regular.woff2") format("woff2");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 400;
src: url("https://media.urbit.org/fonts/Inter-Italic.woff2") format("woff2");
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
src: url("https://media.urbit.org/fonts/Inter-Bold.woff2") format("woff2");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 700;
src: url("https://media.urbit.org/fonts/Inter-BoldItalic.woff2") format("woff2");
}
@font-face {
font-family: "Source Code Pro";
src: url("https://storage.googleapis.com/media.urbit.org/fonts/scp-extralight.woff");
font-weight: 200;
}
@font-face {
font-family: "Source Code Pro";
src: url("https://storage.googleapis.com/media.urbit.org/fonts/scp-light.woff");
font-weight: 300;
}
@font-face {
font-family: "Source Code Pro";
src: url("https://storage.googleapis.com/media.urbit.org/fonts/scp-regular.woff");
font-weight: 400;
}
@font-face {
font-family: "Source Code Pro";
src: url("https://storage.googleapis.com/media.urbit.org/fonts/scp-medium.woff");
font-weight: 500;
}
@font-face {
font-family: "Source Code Pro";
src: url("https://storage.googleapis.com/media.urbit.org/fonts/scp-semibold.woff");
font-weight: 600;
}
@font-face {
font-family: "Source Code Pro";
src: url("https://storage.googleapis.com/media.urbit.org/fonts/scp-bold.woff");
font-weight: 700;
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,38 @@
.spinner-pending {
position: relative;
content: "";
border-radius: 100%;
height: 16px;
width: 16px;
background-color: rgba(255,255,255,1);
}
.spinner-pending::after {
content: "";
background-color: rgba(128,128,128,1);
width: 16px;
height: 16px;
position: absolute;
border-radius: 100%;
clip: rect(0, 16px, 16px, 8px);
animation: spin 1s cubic-bezier(0.745, 0.045, 0.355, 1.000) infinite;
}
@keyframes spin {
0% {transform:rotate(0deg)}
25% {transform:rotate(90deg)}
50% {transform:rotate(180deg)}
75% {transform:rotate(270deg)}
100% {transform:rotate(360deg)}
}
.spinner-nostart {
width: 8px;
height: 8px;
border-radius: 100%;
content:'';
background-color: black;
}

View File

@ -0,0 +1,5 @@
@import "css/indigo-static.css";
@import "css/fonts.css";
@import "css/spinner.css";
@import "css/custom.css";

View File

@ -0,0 +1,21 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { Root } from '/components/root';
import { HeaderBar } from '/components/lib/header-bar.js';
import { api } from '/api';
import { store } from '/store';
import { subscription } from "/subscription";
api.setAuthTokens({
ship: window.ship
});
subscription.start();
ReactDOM.render((
<HeaderBar />
), document.getElementById("header"));
ReactDOM.render((
<Root />
), document.querySelectorAll("#root")[0]);

View File

@ -0,0 +1,231 @@
import React from 'react';
import ReactDOM from 'react-dom';
import _ from 'lodash';
import { uuid } from '/lib/util';
import { store } from '/store';
import moment from 'moment';
class UrbitApi {
setAuthTokens(authTokens) {
this.authTokens = authTokens;
this.bindPaths = [];
this.invite = {
accept: this.inviteAccept.bind(this),
decline: this.inviteDecline.bind(this),
invite: this.inviteInvite.bind(this)
};
}
bind(path, method, ship = this.authTokens.ship, app, success, fail, quit) {
this.bindPaths = _.uniq([...this.bindPaths, path]);
window.subscriptionId = window.urb.subscribe(ship, app, path,
(err) => {
fail(err);
},
(event) => {
success({
data: event,
from: {
ship,
path
}
});
},
(qui) => {
quit(qui);
});
}
action(appl, mark, data) {
return new Promise((resolve, reject) => {
window.urb.poke(ship, appl, mark, data,
(json) => {
resolve(json);
},
(err) => {
reject(err);
});
});
}
inviteAction(data) {
this.action("invite-store", "json", data);
}
inviteInvite(path, ship) {
this.action("invite-hook", "json",
{
invite: {
path: '/chat',
invite: {
path,
ship: `~${window.ship}`,
recipient: ship,
app: 'chat-hook',
text: `You have been invited to /${window.ship}${path}`,
},
uid: uuid()
}
}
);
}
inviteAccept(uid) {
this.inviteAction({
accept: {
path: '/chat',
uid
}
});
}
inviteDecline(uid) {
this.inviteAction({
decline: {
path: '/chat',
uid
}
});
}
async getComments(path, url, page, index) {
let endpoint = "/~link/discussions" + path + "/" + window.btoa(url) + ".json?p=0";
let promise = await fetch(endpoint);
if (promise.ok) {
let comments = {};
comments["link-update"] = {};
comments["link-update"].comments = {};
comments["link-update"].comments.path = path;
comments["link-update"].comments.page = page;
comments["link-update"].comments.index = index;
comments["link-update"].comments.data = await promise.json();
store.handleEvent(comments);
}
}
async getCommentsPage(path, url, page, index, commentPage) {
let endpoint = "/~link/discussions" + path + "/" + window.btoa(url) + ".json?p=" + commentPage;
let promise = await fetch(endpoint);
if (promise.ok) {
let comPage = "page" + commentPage;
let responseData = await promise.json();
let update = {};
update["link-update"] = {};
update["link-update"].commentPage = {};
update["link-update"].commentPage.path = path;
update["link-update"].commentPage.linkPage = page;
update["link-update"].commentPage.index = index;
update["link-update"].commentPage.comPageNo = commentPage;
update["link-update"].commentPage.data = responseData.page;
store.handleEvent(update);
}
}
async getPage(path, page) {
let endpoint = "/~link/submissions" + path + ".json?p=" + page;
let promise = await fetch(endpoint);
if (promise.ok) {
let resolvedPage = await promise.json();
let update = {};
update["link-update"] = {};
update["link-update"].page = {};
update["link-update"].page[path] = {
"page": page
};
update["link-update"].page[path].links = resolvedPage.page;
store.handleEvent(update);
}
}
async postLink(path, url, title) {
let json =
{ 'path': path,
'title': title,
'url': url
};
let endpoint = "/~link/save";
let post = await fetch(endpoint, {
method: "POST",
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(json)
});
if (post.ok) {
let update = {};
update["link-update"] = {};
update["link-update"].add = {};
update["link-update"].add[path] = {};
update["link-update"].add[path] = {
"title": title,
"url": url,
"timestamp": moment.now(),
"ship": window.ship,
"commentCount": 0
}
store.handleEvent(update);
return true;
} else {
return false;
}
}
async postComment(path, url, comment, page, index) {
let json = {
'path': path,
'url': url,
'udon': comment
}
let endpoint = "/~link/note";
let post = await fetch(endpoint, {
method: "POST",
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(json)
});
if (post.ok) {
let update = {};
update["link-update"] = {};
update["link-update"].commentAdd = {};
update["link-update"].commentAdd = {
"path": path,
"url": url,
"udon": comment,
"page": page,
"index": index,
"time": moment.now()
}
store.handleEvent(update);
return true;
} else {
return false;
}
}
sidebarToggle() {
let sidebarBoolean = true;
if (store.state.sidebarShown === true) {
sidebarBoolean = false;
}
store.handleEvent({
data: {
local: {
'sidebarToggle': sidebarBoolean
}
}
});
}
}
export let api = new UrbitApi();
window.api = api;

View File

@ -0,0 +1,91 @@
import React, { Component } from 'react';
import { Route, Link } from 'react-router-dom';
import { ChannelsItem } from '/components/lib/channels-item';
export class ChannelsSidebar extends Component {
// drawer to the left
render() {
const { props, state } = this;
let privateChannel =
Object.keys(props.paths)
.filter((path) => {
return (path === "/~/default")
})
.map((path) => {
let name = "Private"
let selected = (props.selected === path);
let linkCount = !!props.links[path] ? props.links[path]['total-items'] : 0;
return (
<ChannelsItem
key={path}
link={path}
members={props.paths[path]}
selected={selected}
linkCount={linkCount}
name={name}/>
)
})
let channelItems =
Object.keys(props.paths)
.filter((path) => {
return (!path.startsWith("/~/"))
})
.map((path) => {
let name = path.substr(1);
let nameSeparator = name.indexOf("/");
name = name.substr(nameSeparator + 1);
let selected = (props.selected === path);
let linkCount = !!props.links[path] ? props.links[path]['total-items'] : 0;
return (
<ChannelsItem
key={path}
link={path}
members={props.paths[path]}
selected={selected}
linkCount={linkCount}
name={name}/>
)
});
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 (
<div className={`bn br-m br-l br-xl b--gray4 b--gray2-d lh-copy h-100
flex-shrink-0 mw5-m mw5-l mw5-xl pt3 pt0-m pt0-l pt0-xl
relative ` + activeClasses + ((hiddenClasses)
? "flex-basis-100-s flex-basis-30-ns"
: "dn")}>
<a className="db dn-m dn-l dn-xl f8 pb3 pl3" href="/"> Landscape</a>
<div className="overflow-y-scroll h-100">
<h2 className={`f8 f9-m f9-l f9-xl
pt1 pt4-m pt4-l pt4-xl
pr4 pb3 pb2-m pb2-l pb2-xl
pl3 pl4-m pl4-l pl4-xl
black-s gray2 white-d c-default
bb bn-m bn-l bn-xl b--gray4 mb2 mb0-m mb0-l mb0-xl`}>
Your Collections
</h2>
{privateChannel}
{channelItems}
</div>
</div>
);
}
}

View File

@ -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 (
<Link to={"/~link" + props.link}>
<div className={"w-100 v-mid f9 pl4 bt bb " + selectedClass}>
<p className="f9 pt1">{props.name}</p>
<p className="f9 gray2">
{memberCount + " contributor" + ((memberCount === 1) ? "" : "s")}
</p>
<p className="f9 pb1">
{props.linkCount + " link" + ((props.linkCount === 1) ? "" : "s")}
</p>
</div>
</Link>
);
}
}

View File

@ -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 (
<div className="w-100 pv3">
<div className="flex">
<Sigil
ship={"~" + props.ship}
size={36}
color={"#" + props.color}
/>
<p className="gray2 f9 flex items-center ml2">
<span className={"black white-d " + props.nameClass}>
{((props.nickname) ? props.nickname : props.ship)}
</span>
<span className="ml2">
{this.state.timeSinceComment}
</span>
</p>
</div>
<p className="inter f8 pv3 white-d">{props.content}</p>
</div>
)
}
}
export default CommentItem

View File

@ -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 (
<div className="w-100 relative pt4 pb6">
<Link
className={"pb6 absolute inter f8 left-0 " + prevDisplay}
to={"/~link"
+ popout
+ props.path
+ "/" + props.linkPage
+ "/" + props.linkIndex
+ "/comments" + prevPage}>
&#60;- Previous Page
</Link>
<Link
className={"pb6 absolute inter f8 right-0 " + nextDisplay}
to={"/~link"
+ popout
+ props.path
+ "/" + props.linkPage
+ "/" + props.linkIndex
+ "/comments" + nextPage}>
Next Page ->
</Link>
</div>
)
}
}
export default CommentsPagination;

View File

@ -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(
<CommentItem
key={time}
ship={ship}
time={time}
content={udon}
nickname={nickname}
nameClass={nameClass}
color={color}
/>
)
})
return (
<div>
{commentsList}
<CommentsPagination
key={props.path + props.commentPage}
path={props.path}
popout={props.popout}
linkPage={props.linkPage}
linkIndex={props.linkIndex}
commentPage={props.commentPage}
total={total}/>
</div>
)
}
}
export default Comments;

View File

@ -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)
// ? <div className="absolute"
// style={{width: 16, height: 16, top: 16, left: 55}}>
// <IconSpinner/>
// </div>
// : null;
let popout = (window.location.href.includes("popout/"))
? "dn"
: "dn db-m db-l db-xl";
let title = (document.title === "Home")
? ""
: document.title;
return (
<div className={"bg-white bg-gray0-d w-100 justify-between relative tc pt3 "
+ popout}
style={{ height: 40 }}>
<a className="dib gray2 f9 inter absolute left-1"
href='/'
style={{top: 14}}>
<IconHome/>
<span className="ml2 white-d v-top lh-title"
style={{paddingTop: 3}}>
Home
</span>
</a>
<span className="f9 white-d inter dib"
style={{
verticalAlign: "text-top",
paddingTop: 3
}}>{title}</span>
{/* {spin} */}
<div className="absolute right-1 lh-copy"
style={{top: 12}}>
<Sigil
ship={"~" + window.ship}
size={16}
color={"#000000"}
/>
<span className="mono white-d f9 ml2 v-top">{"~" + window.ship}</span>
</div>
</div>
);
}
}

View File

@ -0,0 +1,12 @@
import React, { Component } from 'react';
export class IconHome extends Component {
render() {
return (
//TODO relocate to ~launch when OS1 is ported
<img
className="invert-d"
src="/~link/img/Home.png" width={16} height={16} />
);
}
}

View File

@ -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 (
<div className="pt2">
<a
className="pointer flex-shrink-0"
onClick={() => {
api.sidebarToggle();
}}>
<img
className={`pr3 invert-d dn ` + popoutSwitcher}
src={
this.props.sidebarShown
? "/~link/img/SwitcherOpen.png"
: "/~link/img/SwitcherClosed.png"
}
height="16"
width="16"
/>
</a>
</div>
);
}
}
export default SidebarSwitcher

View File

@ -0,0 +1,9 @@
import React, { Component } from 'react';
export class IconSpinner extends Component {
render() {
return (
<div className="spinner-pending"></div>
);
}
}

View File

@ -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 (
<div className="bg-black flex-shrink-0" style={{width: props.size, height: props.size}}>
</div>
);
} else {
return (
<div className="dib flex-shrink-0" style={{ flexBasis: 32, backgroundColor: props.color }}>
{sigil({
patp: props.ship,
renderer: reactRenderer,
size: props.size,
colors: [props.color, "white"]
})}
</div>
);
}
}
}

View File

@ -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 (
<div className="w-100 pv3 flex">
<Sigil
ship={"~" + props.ship}
size={36}
color={"#" + props.color}
/>
<div className="flex flex-column ml2">
<a href={props.url}
className="w-100 flex"
target="_blank">
<p className="f8 truncate">{props.title}
<span className="gray2 dib truncate-m mw4-m v-btm ml2">{hostname} </span>
</p>
</a>
<div className="w-100 pt1">
<span className={"f9 pr2 v-mid " + mono}>{(props.nickname)
? props.nickname
: "~" + props.ship}</span>
<span className="f9 inter gray2 pr3 v-mid">{this.state.timeSinceLinkPost}</span>
<Link to=
{"/~link" + props.popout + "/" + props.channel + "/" + props.page + "/" + props.index}
className="v-top">
<span className="f9 inter gray2">
{comments}
</span>
</Link>
</div>
</div>
</div>
)
}
}
export default LinkItem

View File

@ -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 (
<div className="relative ba b--gray4 b--gray2-d br1 w-100 mb6">
<textarea
className="pl2 bg-gray0-d white-d w-100 f8"
style={{
resize: "none",
height: 40,
paddingTop: 10
}}
placeholder="Paste link here"
onChange={this.setLinkValue}
spellCheck="false"
rows={1}
onKeyPress={e => {
if (e.key === "Enter") {
e.preventDefault();
this.onClickPost();
}
}}
value={this.state.linkValue}
/>
<textarea
className="pl2 bg-gray0-d white-d w-100 f8"
style={{
resize: "none",
height: 40,
paddingTop: 16
}}
placeholder="Enter title"
onChange={this.setLinkTitle}
spellCheck="false"
rows={1}
onKeyPress={e => {
if (e.key === "Enter") {
e.preventDefault();
this.onClickPost();
}
}}
value={this.state.linkTitle}
/>
<button
className={"absolute bg-gray0-d f8 ml2 flex-shrink-0 " + activeClasses}
disabled={!this.state.linkValid}
onClick={this.onClickPost.bind(this)}
style={{
bottom: 12,
right: 8
}}>
Post
</button>
</div>
)
}
}
export default LinkSubmit;

View File

@ -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 (
<div className="dib pt2 flex-shrink-0 flex-grow-1">
{!!props.isOwner ? (
<div className={"dib f8 pl6"}>
<Link
className={"no-underline " + memColor}
to={`/~link/` + popout + `members` + props.path}>
Members
</Link>
</div>
) : (
<div className="dib" style={{ width: 0 }}></div>
)}
<a href={`/~link/popout` + props.path} target="_blank"
className="dib fr">
<img
className={`flex-shrink-0 pr4 dn invert-d ` + hidePopoutIcon}
src="/~link/img/popout.png"
height="16"
width="16"/>
</a>
</div>
);
}
}
export default LinksTabBar

View File

@ -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 (
<div className="w-100 inter relative pv6">
<div className={prevDisplay + " inter f8"}>
<Link to={"/~link" + props.popout + props.path + prevPage}>
&#60;- Previous Page
</Link>
</div>
<div className={nextDisplay + " inter f8"}>
<Link to={"/~link" + props.popout + props.path + nextPage}>
Next Page ->
</Link>
</div>
</div>
)
}
}
export default Pagination

View File

@ -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 (
<div className="h-100 w-100 overflow-hidden flex flex-column">
<div
className={`pl3 pt2 flex relative overflow-x-scroll
overflow-x-auto-l overflow-x-auto-xl flex-shrink-0
bb bn-m bn-l bn-xl b--gray4`}
style={{ height: 48 }}>
<SidebarSwitcher
sidebarShown={props.sidebarShown}
popout={props.popout}/>
<Link
className="dib f8 fw4 v-top pt2 gray2"
to={"/~link" + popout + props.path + "/" + props.page}>
{"<- Collection index"}
</Link>
<LinksTabBar
{...props}
popout={popout}
path={path}/>
</div>
<div className="w-100 mt2 flex justify-center overflow-y-scroll ph4 pb4">
<div className="w-100 mw7">
<div className="pb6 flex">
<Sigil
ship={"~" + ship}
size={36}
color={"#" + color}
/>
<div className="flex flex-column ml2">
<a href={url}
className="w-100 flex"
target="_blank">
<p className="f8 truncate">{title}
<span className="gray2 ml2 flex-shrink-0">{hostname} </span>
</p>
</a>
<div className="w-100 pt1">
<span className={"f9 pr2 white-d v-mid " + nameClass}>{(nickname)
? nickname
: "~" + ship}
</span>
<span className="f9 inter gray2 pr3 v-mid">
{this.state.timeSinceLinkPost}
</span>
<Link to={"/~link" + props.path + "/" + props.page + "/" + props.link} className="v-top">
<span className="f9 inter gray2">
{comments}
</span>
</Link>
</div>
</div>
</div>
<div className="relative ba br1 b--gray4 b--gray2-d mt6 mb6">
<textarea
className="w-100 bg-gray0-d white-d f8 pa2 pr8"
style={{
resize: "none",
height: 75
}}
placeholder="Leave a comment on this link"
onChange={this.setComment}
value={this.state.comment}
/>
<button className={"f8 bg-gray0-d white-d ml2 absolute "
+ activeClasses}
disabled={!this.state.comment}
onClick={this.onClickPost.bind(this)}
style={{
bottom: 12,
right: 8
}}>
Post
</button>
</div>
<Comments
path={props.path}
key={props.path + props.commentPage}
comments={props.data.comments}
commentPage={props.commentPage}
members={props.members}
popout={props.popout}
url={props.data.url}
linkPage={props.page}
linkIndex={props.link}
/>
</div>
</div>
</div>
)
}
}
export default LinkDetail;

View File

@ -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 (
<LinkItem
key={timestamp}
title={title}
page={props.page}
index={link}
url={url}
timestamp={timestamp}
nickname={nickname}
ship={ship}
color={color}
comments={commentCount}
channel={channel}
popout={popout}
/>
)
})
return (
<div
className="h-100 w-100 overflow-hidden flex flex-column">
<div
className="w-100 dn-m dn-l dn-xl inter pt4 pb6 pl3 f8"
style={{ height: "1rem" }}>
<Link to="/~link/">{"⟵ All Channels"}</Link>
</div>
<div
className={`pl3 pt2 flex relative overflow-x-scroll
overflow-x-auto-l overflow-x-auto-xl flex-shrink-0
bb bn-m bn-l bn-xl b--gray4`}
style={{ height: 48 }}>
<SidebarSwitcher
sidebarShown={props.sidebarShown}
popout={props.popout}/>
<Link to={`/~link` + popout + props.path} className="pt2">
<h2
className={`dib f8 fw4 v-top ` +
(props.path.includes("/~/")
? ""
: "mono")}>
{(props.path.includes("/~/"))
? "Private"
: channel}
</h2>
</Link>
<LinksTabBar
{...props}
popout={popout}
path={props.path}/>
</div>
<div className="w-100 mt2 flex justify-center overflow-y-scroll ph4 pb4">
<div className="w-100 mw7">
<div className="flex">
<LinkSubmit path={props.path}/>
</div>
<div className="pb4">
{LinkList}
<Pagination
{...props}
key={props.path + props.page}
popout={popout}
path={props.path}
currentPage={currentPage}
totalPages={totalPages}
/>
</div>
</div>
</div>
</div>
)
}
}
export default Links;

View File

@ -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 (
<BrowserRouter>
<Route exact path="/~link"
render={ (props) => {
return (
<Skeleton
active="channels"
paths={paths}
rightPanelHide={true}
sidebarShown={true}
links={links}>
<div className="h-100 w-100 overflow-x-hidden flex flex-column bg-white bg-gray0-d dn db-ns">
<div className="pl3 pr3 pt2 dt pb3 w-100 h-100">
<p className="f8 pt3 gray2 w-100 h-100 dtc v-mid tc">
Channels are shared across groups. To create a new channel, <a className="black white-d" href="/~contacts">create a group</a>.
</p>
</div>
</div>
</Skeleton>
);
}} />
<Route exact path="/~link/(popout)?/:ship/:channel/:page?"
render={ (props) => {
// 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 (
<Skeleton
spinner={state.spinner}
paths={paths}
active="links"
selected={groupPath}
sidebarShown={state.sidebarShown}
sidebarHideMobile={true}
popout={popout}
links={links}>
<Links
{...props}
members={groupMembers}
links={channelLinks}
page={page}
path={groupPath}
popout={popout}
sidebarShown={state.sidebarShown}
/>
</Skeleton>
)
}}
/>
<Route exact path="/~link/(popout)?/:ship/:channel/:page/:index/(comments)?/:commentpage?"
render={ (props) => {
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 (
<Skeleton
spinner={state.spinner}
paths={paths}
active="links"
selected={groupPath}
sidebarShown={state.sidebarShown}
sidebarHideMobile={true}
popout={popout}
links={links}>
<LinkDetail
{...props}
page={page}
link={index}
members={groupMembers}
path={groupPath}
popout={popout}
sidebarShown={state.sidebarShown}
data={data}
commentPage={commentPage}
/>
</Skeleton>
)
}}
/>
</BrowserRouter>
)
}
}

View File

@ -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 (
<div className={"h-100 w-100 " + popoutWindow}>
<div className={`cf w-100 h-100 flex ` + popoutBorder}>
<ChannelsSidebar
popout={popout}
paths={this.props.paths}
active={this.props.active}
selected={this.props.selected}
sidebarShown={this.props.sidebarShown}
links={this.props.links}/>
<div className={"h-100 w-100 " + rightPanelHide} style={{
flexGrow: 1,
}}>
{this.props.children}
</div>
</div>
</div>
);
}
}

View File

@ -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;
}

View File

@ -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];
}
}
}

View File

@ -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];
}
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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);
}
}
}
}

View File

@ -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;

View File

@ -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();

View File

@ -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 (
<div className="w-100 h-100 relative ba b--black" style={{ background: "#FFFFFF" }}>
<a className="w-100 h-100 db pa2 no-underline" href="/~link">
<p
className="label-regular b absolute"
style={{ left: 8, top: 4 }}>
Links
</p>
<img
className="absolute"
style={{ left: 69, top: 69 }}
src="/~link/img/Tile.png"
width={96}
height={96}
/>
</a>
</div>
);
}
}
window['link-server-hookTile'] = LinkTile;