link fe: metadata integration

- finds resources & displays details using metadata-store
- supports creating new collections for groups
- supports creating new collections with new unmanaged group
- supports receiving invites for new (unmanaged) collections
This commit is contained in:
Fang 2020-03-01 01:48:54 +01:00
parent 115fd1c14c
commit 0539f3ca1f
No known key found for this signature in database
GPG Key ID: EB035760C1BBA972
25 changed files with 1062 additions and 135 deletions

View File

@ -10,7 +10,10 @@
:: /json/[n]/submission/[wood-url]/[some-group] nth matching submission
:: /json/seen mark-as-read updates
::
/+ *link, *server, default-agent, verb
/- *link-view,
metadata-store, *invite-store, group-store,
group-hook, permission-hook, metadata-hook
/+ *link, *server, default-agent, verb, dbug
::
|%
+$ state-0
@ -25,6 +28,7 @@
=* state -
::
%+ verb |
%- agent:dbug
^- agent:gall
=<
|_ =bowl:gall
@ -42,6 +46,12 @@
::
=+ [dap.bowl /tile '/~link/js/tile.js']
[%pass /launch %agent [our.bowl %launch] %poke %launch-action !>(-)]
::
=+ [%invite-action !>([%create /link])]
[%pass /invitatory/create %agent [our.bowl %invite-store] %poke -]
::
=+ /invitatory/link
[%pass - %agent [our.bowl %invite-store] %watch -]
==
::
++ on-save !>(state)
@ -65,6 +75,9 @@
::
%link-action
[(handle-action:do !<(action vase)) ~]
::
%link-view-action
(handle-view-action:do !<(view-action vase))
==
::
++ on-watch
@ -104,13 +117,18 @@
?+ -.sign (on-agent:def wire sign)
%kick
:_ this
[%pass wire %agent [our.bowl %link-store] %watch wire]~
=/ app=term
?: ?=([%invites *] wire)
%invite-store
%link-store
[%pass wire %agent [our.bowl app] %watch wire]~
::
%fact
=* mark p.cage.sign
=* vase q.cage.sign
?+ mark (on-agent:def wire sign)
%link-initial [~ this]
%invite-update [(handle-invite-update:do !<(invite-update vase)) this]
%link-initial [~ this]
::
%link-update
:_ this
@ -217,10 +235,116 @@
%- as-octs:mimes:html
.^(@ %cx path)
::
++ handle-invite-update
|= upd=invite-update
^- (list card)
?. ?=(%accepted -.upd) ~
?. =(/link path.upd) ~
|^
:~ :: sync the group
::
%^ do-poke %group-hook
%group-hook-action
!> ^- group-hook-action:group-hook
[%add ship path]:invite.upd
::
:: sync the metadata
::
%^ do-poke %metadata-hook
%metadata-hook-action
!> ^- metadata-hook-action:metadata-hook
[%add-synced ship path]:invite.upd
==
::
++ do-poke
|= [app=term =mark =vase]
^- card
[%pass /create/[app]/[mark] %agent [our.bowl app] %poke mark vase]
--
::
++ handle-action
|= =action
^- card
[%pass /action %agent [our.bowl %link-store] %poke %link-action !>(action)]
::
++ handle-view-action
|= act=view-action
^- (list card)
?> ?=(%create -.act)
=/ group-path=path
?- -.members.act
%group path.members.act
%ships [~.~ (scot %p our.bowl) path.act]
==
|^
=; group-setup=(list card)
%+ weld group-setup
:~ :: add collection to metadata-store
::
%^ do-poke %metadata-store
%metadata-action
!> ^- metadata-action:metadata-store
:^ %add group-path
[%link path.act]
%* . *metadata:metadata-store
title title.act
description description.act
date-created now.bowl
creator our.bowl
==
==
?: ?=(%group -.members.act) ~
:* :: create the new group
::
%^ do-poke %group-store
%group-action
!> ^- group-action:group-store
[%bundle group-path]
::
:: fill the new group
::
%^ do-poke %group-store
%group-action
!> ^- group-action:group-store
[%add (~(put in ships.members.act) our.bowl) group-path]
::
:: make group available
::
%^ do-poke %group-hook
%group-hook-action
!> ^- group-hook-action:group-hook
[%add our.bowl group-path]
::
:: make a permission equivalent
::
%^ do-poke %permission-hook
%permission-hook-action
!> ^- permission-hook-action:permission-hook
[%add-owned group-path group-path]
::
:: send invites
::
%+ turn ~(tap in ships.members.act)
|= =ship
^- card
%^ do-poke %invite-hook
%invite-action
!> ^- invite-action
:^ %invite /link
(sham group-path eny.bowl)
:* our.bowl
%group-hook
group-path
ship
title.act
==
==
::
++ do-poke
|= [app=term =mark =vase]
^- card
[%pass /create/[app]/[mark] %agent [our.bowl app] %poke mark vase]
--
:: +give-tile-data: total unread count as json object
::
::NOTE the full recalc of totals here probably isn't the end of the world.

View File

@ -0,0 +1,24 @@
/- *link-view
=, dejs:format
|_ act=view-action
++ grab
|%
++ noun view-action
++ json
|^ %- of
:~ %create^(ot 'path'^pa 'title'^so 'description'^so 'members'^mems ~)
==
::
++ mems
%- of
:~ %group^pa
%ships^(cu sy (ar (su ;~(pfix sig fed:ag))))
==
--
--
::
++ grow
|%
++ noun act
--
--

View File

@ -0,0 +1,12 @@
:: link-view: encapsulating link management
::
|%
++ view-action
$% $: %create
=path
title=@t
description=@t
members=$%([%group =path] [%ships ships=(set ship)])
==
==
--

View File

@ -86,7 +86,7 @@ class UrbitApi {
inviteAccept(uid) {
this.inviteAction({
accept: {
path: '/chat',
path: '/link',
uid
}
});
@ -95,7 +95,7 @@ class UrbitApi {
inviteDecline(uid) {
this.inviteAction({
decline: {
path: '/chat',
path: '/link',
uid
}
});
@ -144,6 +144,13 @@ class UrbitApi {
);
}
createCollection(path, title, description, members) {
// members is either {group:'/group-path'} or {'ships':[~zod]}
return this.action("link-view", "link-view-action", {
create: {path, title, description, members}
})
}
linkAction(data) {
return this.action("link-store", "link-action", data);
}

View File

@ -2,6 +2,7 @@ import React, { Component } from 'react';
import { Route, Link } from 'react-router-dom';
import { ChannelsItem } from '/components/lib/channels-item';
import { SidebarInvite } from '/components/lib/sidebar-invite';
export class ChannelsSidebar extends Component {
// drawer to the left
@ -9,42 +10,21 @@ export class ChannelsSidebar extends Component {
render() {
const { props, state } = this;
let privateChannel =
Object.keys(props.groups)
.filter((path) => {
return (path === "/~/default")
})
.map((path) => {
let name = "Private"
let selected = (props.selected === path);
let linkCount = !!props.links[path] ? props.links[path].totalItems : 0;
const unseenCount = !!props.links[path]
? props.links[path].unseenCount
: linkCount
let sidebarInvites = Object.keys(props.invites)
.map((uid) => {
return (
<ChannelsItem
key={path}
link={path}
memberList={props.groups[path]}
selected={selected}
linkCount={linkCount}
unseenCount={unseenCount}
name={name}/>
)
})
<SidebarInvite
uid={uid}
invite={props.invites[uid]}
api={props.api} />
);
});
let channelItems =
Object.keys(props.groups)
.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].totalItems : 0;
const channelItems =
Object.keys(props.resources).map((path) => {
const meta = props.resources[path];
const selected = (props.selected === path);
const linkCount = !!props.links[path] ? props.links[path].totalItems : 0;
const unseenCount = !!props.links[path]
? props.links[path].unseenCount
: linkCount
@ -53,12 +33,12 @@ export class ChannelsSidebar extends Component {
<ChannelsItem
key={path}
link={path}
memberList={props.groups[path]}
memberList={props.groups[meta.group]}
selected={selected}
linkCount={linkCount}
unseenCount={unseenCount}
name={name}/>
)
name={meta.title}/>
);
});
let activeClasses = (this.props.active === "channels") ? " " : "dn-s ";
@ -70,7 +50,7 @@ export class ChannelsSidebar extends Component {
if (this.props.popout) {
hiddenClasses = false;
} else {
hiddenClasses = this.props.sidebarShown;
hiddenClasses = this.props.sidebarShown;
}
return (
@ -81,15 +61,15 @@ export class ChannelsSidebar extends Component {
: "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 pb3-m pb3-l pb3-xl
pl3 pl4-m pl4-l pl4-xl
black-s gray2 white-d c-default
bb b--gray4 mb2 mb0-m mb0-l mb0-xl`}>
Your Collections
</h2>
{privateChannel}
<div className="w-100 bg-transparent pa4 bb b--gray4 b--gray1-d"
style={{paddingBottom: 13}}>
<Link
className="dib f9 pointer green2 gray4-d mr4"
to={"/~link/new"}>
New Collection
</Link>
</div>
{sidebarInvites}
{channelItems}
</div>
</div>

View File

@ -1,13 +1,13 @@
import React, { Component } from 'react'
import { Route, Link } from 'react-router-dom';
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"
let selectedClass = (props.selected)
? "bg-gray5 bg-gray1-d b--gray4 b--gray2-d"
: "b--gray4 b--gray2-d";
let memberCount = props.memberList
@ -18,7 +18,7 @@ export class ChannelsItem extends Component {
: null;
return (
<Link to={"/~link" + props.link}>
<Link to={"/~link/list/0" + props.link}>
<div className={"w-100 v-mid f9 pl4 bb z1 pa3 pt4 pb4 b--gray4 b--gray1-d gray3-d pointer " + selectedClass}>
<p className="f9 pt1">{props.name}</p>
<p className="f9 gray2">

View File

@ -6,8 +6,8 @@ export class CommentsPagination extends Component {
render() {
let props = this.props;
let prevPage = "/" + (Number(props.commentPage) - 1);
let nextPage = "/" + (Number(props.commentPage) + 1);
let prevPage = (Number(props.commentPage) - 1);
let nextPage = (Number(props.commentPage) + 1);
let prevDisplay = ((Number(props.commentPage) > 0))
? "dib"
@ -26,22 +26,24 @@ export class CommentsPagination extends Component {
className={"pb6 absolute inter f8 left-0 " + prevDisplay}
to={"/~link"
+ popout
+ props.groupPath
+ "/item"
+ "/" + props.linkPage
+ "/" + props.linkIndex
+ "/" + prevPage
+ "/" + encodedUrl
+ "/comments" + prevPage}>
+ props.resourcePath}>
&#60;- Previous Page
</Link>
<Link
className={"pb6 absolute inter f8 right-0 " + nextDisplay}
to={"/~link"
to={"/~link"
+ popout
+ props.groupPath
+ "/" + props.linkPage
+ "/" + props.linkIndex
+ "/item"
+ "/" + props.linkPage
+ "/" + props.linkIndex
+ "/" + nextPage
+ "/" + encodedUrl
+ "/comments" + nextPage}>
+ props.resourcePath}>
Next Page ->
</Link>
</div>

View File

@ -19,7 +19,7 @@ export class Comments extends Component {
) {
this.setState({requested: this.props.commentPage});
api.getCommentsPage(
this.props.groupPath,
this.props.resourcePath,
this.props.url,
this.props.commentPage);
}
@ -73,8 +73,8 @@ export class Comments extends Component {
<div>
{commentsList}
<CommentsPagination
key={props.groupPath + props.commentPage}
groupPath={props.groupPath}
key={props.resourcePath + props.commentPage}
resourcePath={props.resourcePath}
popout={props.popout}
linkPage={props.linkPage}
linkIndex={props.linkIndex}

View File

@ -0,0 +1,369 @@
import React, { Component } from "react";
import urbitOb from "urbit-ob";
import { Sigil } from "../lib/icons/sigil";
export class InviteSearch extends Component {
constructor(props) {
super(props);
this.state = {
groups: [],
peers: [],
contacts: new Map,
searchValue: "",
searchResults: {
groups: [],
ships: []
},
inviteError: false
};
this.search = this.search.bind(this);
}
componentDidMount() {
this.peerUpdate();
}
componentDidUpdate(prevProps) {
if (prevProps !== this.props) {
this.peerUpdate();
}
}
peerUpdate() {
let groups = Array.from(Object.keys(this.props.groups));
groups = groups.filter(e => !e.startsWith("/~/"));
let peers = [],
peerSet = new Set(),
contacts = new Map;
Object.keys(this.props.groups).map(group => {
if (this.props.groups[group].size > 0) {
let groupEntries = this.props.groups[group].values();
for (let member of groupEntries) {
peerSet.add(member);
}
}
if (this.props.contacts[group]) {
let groupEntries = this.props.groups[group].values();
for (let member of groupEntries) {
if (this.props.contacts[group][member]) {
if (contacts.has(member)) {
contacts.get(member).push(this.props.contacts[group][member].nickname);
}
else {
contacts.set(member, [this.props.contacts[group][member].nickname]);
}
}
}
}
});
peers = Array.from(peerSet);
this.setState({ groups: groups, peers: peers, contacts: contacts });
}
search(event) {
let searchTerm = event.target.value.toLowerCase().replace("~", "");
this.setState({ searchValue: event.target.value });
if (searchTerm.length < 2) {
this.setState({ searchResults: { groups: [], ships: [] } });
}
if (searchTerm.length > 2) {
if (this.state.inviteError === true) {
this.setState({ inviteError: false });
}
let groupMatches = [];
if (this.props.groupResults) {
groupMatches = this.state.groups.filter(e => {
return e.includes(searchTerm);
});
}
let shipMatches = this.state.peers.filter(e => {
return e.includes(searchTerm) && !this.props.invites.ships.includes(e);
});
for (let contact of this.state.contacts.keys()) {
let thisContact = this.state.contacts.get(contact);
let match = thisContact.filter(e => {
return e.toLowerCase().includes(searchTerm);
});
if (match.length > 0) {
if (!(contact in shipMatches)) {
shipMatches.push(contact);
}
}
}
this.setState({
searchResults: { groups: groupMatches, ships: shipMatches }
});
let isValid = true;
if (!urbitOb.isValidPatp("~" + searchTerm)) {
isValid = false;
}
if (shipMatches.length === 0 && isValid) {
shipMatches.push(searchTerm);
this.setState({
searchResults: { groups: groupMatches, ships: shipMatches }
});
}
}
}
deleteGroup() {
let { ships } = this.props.invites;
this.setState({
searchValue: "",
searchResults: { groups: [], ships: [] }
});
this.props.setInvite({ groups: [], ships: ships });
}
deleteShip(ship) {
let { groups, ships } = this.props.invites;
this.setState({
searchValue: "",
searchResults: { groups: [], ships: [] }
});
ships = ships.filter(e => {
return e !== ship;
});
this.props.setInvite({ groups: groups, ships: ships });
}
addGroup(group) {
this.setState({
searchValue: "",
searchResults: { groups: [], ships: [] }
});
this.props.setInvite({ groups: [group], ships: [] });
}
addShip(ship) {
let { groups, ships } = this.props.invites;
this.setState({
searchValue: "",
searchResults: { groups: [], ships: [] }
});
if (!ships.includes(ship)) {
ships.push(ship);
}
if (groups.length > 0) {
return false;
}
this.props.setInvite({ groups: groups, ships: ships });
}
submitShipToAdd(ship) {
let searchTerm = ship
.toLowerCase()
.replace("~", "")
.trim();
let isValid = true;
if (!urbitOb.isValidPatp("~" + searchTerm)) {
isValid = false;
}
if (!isValid) {
this.setState({ inviteError: true, searchValue: "" });
} else if (isValid) {
this.addShip(searchTerm);
this.setState({ searchValue: "" });
}
}
render() {
const { props, state } = this;
let searchDisabled = false;
if (props.invites.groups) {
if (props.invites.groups.length > 0) {
searchDisabled = true;
}
}
let participants = <div />;
let searchResults = <div />;
let invErrElem = <span />;
if (state.inviteError) {
invErrElem = (
<span className="f9 inter red2 db pt2">
Invited ships must be validly formatted ship names.
</span>
);
}
if (
state.searchResults.groups.length > 0 ||
state.searchResults.ships.length > 0
) {
let groupHeader =
state.searchResults.groups.length > 0 ? (
<p className="f9 gray2 ph3">Groups</p>
) : (
""
);
let shipHeader =
state.searchResults.ships.length > 0 ? (
<p className="f9 gray2 pv2 ph3">Ships</p>
) : (
""
);
let groupResults = state.searchResults.groups.map(group => {
return (
<li
key={group}
className={
"list mono white-d f8 pv2 ph3 pointer" +
" hover-bg-gray4 hover-black-d"
}
onClick={e => this.addGroup(group)}>
{group}
</li>
);
});
let shipResults = state.searchResults.ships.map(ship => {
let nicknames = (this.state.contacts.has(ship))
? this.state.contacts.get(ship).join(", ")
: "";
return (
<li
key={ship}
className={
"list mono white-d f8 pv1 ph3 pointer" +
" hover-bg-gray4 hover-black-d relative"
}
onClick={e => this.addShip(ship)}>
<Sigil
ship={"~" + ship}
size={24}
color="#000000"
classes="mix-blend-diff v-mid"
/>
<span className="v-mid ml2 mw5 truncate dib">{"~" + ship}</span>
<span className="absolute right-1 di truncate mw4 inter f9 pt1">{nicknames}</span>
</li>
);
});
searchResults = (
<div
className={
"absolute bg-white bg-gray0-d white-d" +
" pv3 z-1 w-100 mt1 ba b--white-d overflow-y-scroll mh-16"
}>
{groupHeader}
{groupResults}
{shipHeader}
{shipResults}
</div>
);
}
let groupInvites = props.invites.groups || [];
let shipInvites = props.invites.ships || [];
if (groupInvites.length > 0 || shipInvites.length > 0) {
let groups = groupInvites.map(group => {
return (
<span
key={group}
className={
"f9 mono black pa2 bg-gray5 bg-gray1-d" +
" ba b--gray4 b--gray2-d white-d dib mr2 mt2 c-default"
}>
{group}
<span
className="white-d ml3 mono pointer"
onClick={e => this.deleteGroup(group)}>
x
</span>
</span>
);
});
let ships = shipInvites.map(ship => {
return (
<span
key={ship}
className={
"f9 mono black pa2 bg-gray5 bg-gray1-d" +
" ba b--gray4 b--gray2-d white-d dib mr2 mt2 c-default"
}>
{"~" + ship}
<span
className="white-d ml3 mono pointer"
onClick={e => this.deleteShip(ship)}>
x
</span>
</span>
);
});
participants = (
<div
className={
"f9 gray2 bb bl br b--gray3 b--gray2-d bg-gray0-d " +
"white-d pa3 db w-100 inter"
}>
<span className="db gray2">Participants</span>
{groups} {ships}
</div>
);
}
return (
<div className="relative">
<img
src="/~chat/img/search.png"
className="absolute invert-d"
style={{
height: 16,
width: 16,
top: 14,
left: 12
}}
/>
<textarea
ref={e => {
this.textarea = e;
}}
className={
"f7 ba b--gray3 b--gray2-d bg-gray0-d white-d pa3 w-100" +
" db focus-b--black focus-b--white-d"
}
placeholder="Search for ships or existing groups"
disabled={searchDisabled}
rows={1}
spellCheck={false}
style={{
resize: "none",
paddingLeft: 36
}}
onKeyPress={e => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
this.submitShipToAdd(this.state.searchValue);
}
}}
onChange={this.search}
value={state.searchValue}
/>
{searchResults}
{participants}
{invErrElem}
</div>
);
}
}
export default InviteSearch;

View File

@ -115,14 +115,13 @@ export class LinkPreview extends Component {
</span>
<Link
to={
"/~link" +
props.groupPath +
"/" +
"/~link/item/" +
props.page +
"/" +
props.linkIndex +
"/" +
base64urlEncode(props.url)
"/0/" +
base64urlEncode(props.url) +
props.resourcePath
}
className="v-top">
<span className="f9 inter gray2">{props.comments}</span>

View File

@ -34,7 +34,7 @@ export class LinkItem extends Component {
}
markPostAsSeen() {
api.seenLink(this.props.groupPath, this.props.url);
api.seenLink(this.props.resourcePath, this.props.url);
}
render() {
@ -90,7 +90,7 @@ export class LinkItem extends Component {
{this.state.timeSinceLinkPost}
</span>
<Link to=
{"/~link" + props.popout + props.groupPath + "/" + props.page + "/" + props.linkIndex + "/" + encodedUrl}
{"/~link" + props.popout + "/item/" + props.page + "/" + props.linkIndex + "/0/" + encodedUrl + props.resourcePath}
className="v-top"
onClick={this.markPostAsSeen}>
<span className="f9 inter gray2">

View File

@ -20,7 +20,7 @@ export class LinkSubmit extends Component {
? this.state.linkTitle
: this.state.linkValue;
api.setSpinner(true);
api.postLink(this.props.groupPath, link, title).then(r => {
api.postLink(this.props.resourcePath, link, title).then(r => {
api.setSpinner(false);
this.setState({ linkValue: "", linkTitle: "" });
});

View File

@ -28,14 +28,14 @@ export class LinksTabBar extends Component {
<div className={"dib f8 pl6"}>
<Link
className={"no-underline " + memColor}
to={`/~link/` + popout + `members` + props.groupPath}>
to={`/~link/` + popout + `members` + props.resourcePath}>
Members
</Link>
</div>
) : (
<div className="dib" style={{ width: 0 }}></div>
)}
<a href={`/~link/popout` + props.groupPath} target="_blank"
<a href={`/~link/popout/list/${props.page}${props.resourcePath}`} target="_blank"
className="dib fr">
<img
className={`flex-shrink-0 pr4 dn` + hidePopoutIcon}

View File

@ -5,26 +5,26 @@ export class Pagination extends Component {
render() {
let props = this.props;
let prevPage = "/" + (Number(props.page) - 1);
let nextPage = "/" + (Number(props.page) + 1);
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"
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.groupPath + prevPage}>
<Link to={"/~link" + props.popout + "/list/" + prevPage + props.resourcePath}>
&#60;- Previous Page
</Link>
</div>
<div className={nextDisplay + " inter f8"}>
<Link to={"/~link" + props.popout + props.groupPath + nextPage}>
<Link to={"/~link" + props.popout + "/list/" + nextPage + props.resourcePath}>
Next Page ->
</Link>
</div>

View File

@ -0,0 +1,39 @@
import React, { Component } from 'react';
import classnames from 'classnames';
import _ from 'lodash';
export class SidebarInvite extends Component {
onAccept() {
api.invite.accept(this.props.uid);
}
onDecline() {
api.invite.decline(this.props.uid);
}
render() {
const { props } = this;
return (
<div className='w-100 bg-transparent pa4 bb b--gray4 b--gray1-d'>
<div className='w-100 v-mid'>
<p className="dib f8 mono gray4-d">
{props.invite.text}
</p>
</div>
<a
className="dib pointer pa2 f9 bg-green2 white mt4"
onClick={this.onAccept.bind(this)}>
Accept Invite
</a>
<a
className="dib pointer ml4 pa2 f9 bg-black bg-gray0-d white mt4"
onClick={this.onDecline.bind(this)}>
Decline
</a>
</div>
)
}
}

View File

@ -29,7 +29,7 @@ export class LinkDetail extends Component {
// if we have no preloaded data, and we aren't expecting it, get it
if (!this.state.data.title) {
api.getSubmission(
this.props.groupPath, this.props.url, this.updateData.bind(this)
this.props.resourcePath, this.props.url, this.updateData.bind(this)
);
}
}
@ -46,7 +46,7 @@ export class LinkDetail extends Component {
api.setSpinner(true);
api.postComment(
this.props.groupPath,
this.props.resourcePath,
url,
this.state.comment
).then(() => {
@ -98,10 +98,10 @@ export class LinkDetail extends Component {
/>
<Link
className="dib f9 fw4 pt2 gray2 lh-solid"
to={"/~link" + popout + props.groupPath + "/" + props.page}>
to={"/~link" + popout + "/list/" + props.page + props.resourcePath}>
{"<- Collection index"}
</Link>
<LinksTabBar {...props} popout={popout} groupPath={props.groupPath} />
<LinksTabBar {...props} popout={popout} resourcePath={props.resourcePath} />
</div>
<div className="w-100 mt2 flex justify-center overflow-y-scroll ph4 pb4">
<div className="w-100 mw7">
@ -111,7 +111,7 @@ export class LinkDetail extends Component {
comments={comments}
nickname={nickname}
ship={ship}
groupPath={props.groupPath}
resourcePath={props.resourcePath}
page={props.page}
linkIndex={props.linkIndex}
time={this.state.data.time}
@ -143,8 +143,8 @@ export class LinkDetail extends Component {
</button>
</div>
<Comments
groupPath={props.groupPath}
key={props.groupPath + props.commentPage}
resourcePath={props.resourcePath}
key={props.resourcePath + props.commentPage}
comments={props.comments}
commentPage={props.commentPage}
contacts={props.contacts}

View File

@ -25,16 +25,29 @@ export class Links extends Component {
(!this.props.links[linkPage] ||
this.props.links.local[linkPage])
) {
api.getPage(this.props.groupPath, this.props.page);
api.getPage(this.props.resourcePath, this.props.page);
}
}
markAllAsSeen() {
api.seenLink(this.props.groupPath);
api.seenLink(this.props.resourcePath);
}
render() {
let props = this.props;
if (!props.resource.title) {
return (
<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">
Loading...
</p>
</div>
</div>
);
}
let popout = (props.popout) ? "/popout" : "";
let linkPage = props.page;
@ -77,7 +90,7 @@ export class Links extends Component {
color={color}
member={member}
comments={commentCount}
groupPath={props.groupPath}
resourcePath={props.resourcePath}
popout={popout}
/>
)
@ -99,26 +112,30 @@ export class Links extends Component {
<SidebarSwitcher
sidebarShown={props.sidebarShown}
popout={props.popout}/>
<Link to={`/~link` + popout + props.groupPath} className="pt2">
<Link to={`/~link${popout}/list/${props.page}${props.resourcePath}`} className="pt2">
<h2
className={`dib f9 fw4 v-top lh-solid` +
(props.groupPath.includes("/~/")
(props.resource.group.includes("/~/")
? ""
: " mono")}>
{(props.groupPath.includes("/~/"))
? "Private"
: props.groupPath.substr(1)}
{ props.resource.title +
( props.resource.description
? ": " + props.resource.description
: ""
)
}
</h2>
</Link>
<LinksTabBar
{...props}
popout={popout}
groupPath={props.groupPath + "/" + props.page}/>
page={props.page}
resourcePath={props.resourcePath}/>
</div>
<div className="w-100 mt2 flex justify-center overflow-y-scroll ph4 pb4">
<div className="w-100 mw7">
<div className="flex">
<LinkSubmit groupPath={props.groupPath}/>
<LinkSubmit resourcePath={props.resourcePath}/>
</div>
<div className="pb4">
<span
@ -129,9 +146,9 @@ export class Links extends Component {
{LinkList}
<Pagination
{...props}
key={props.groupPath + props.page}
key={props.resourcePath + props.page}
popout={popout}
groupPath={props.groupPath}
resourcePath={props.resourcePath}
currentPage={currentPage}
totalPages={totalPages}
/>

View File

@ -0,0 +1,252 @@
import React, { Component } from 'react';
import { InviteSearch } from './lib/invite-search';
import { Route, Link } from 'react-router-dom';
import { uuid, isPatTa, deSig } from '/lib/util';
import urbitOb from 'urbit-ob';
export class NewScreen extends Component {
constructor(props) {
super(props);
this.state = {
title: '',
description: '',
idName: '',
groups: [],
ships: [],
idError: false,
inviteError: false,
createGroup: true
};
this.titleChange = this.titleChange.bind(this);
this.descriptionChange = this.descriptionChange.bind(this);
this.setInvite = this.setInvite.bind(this);
this.createGroupChange = this.createGroupChange.bind(this);
}
componentDidUpdate(prevProps, prevState) {
const { props, state } = this;
if (prevProps !== props) {
let station = `/~${window.ship}/${state.idName}`;
if (station in props.resources) {
console.log('TODO nav', '/~chat/room' + station);
}
}
}
titleChange(event) {
let asciiSafe = event.target.value.toLowerCase()
.replace(/[^a-z0-9-]/g, "-");
this.setState({
idName: asciiSafe + '-' + Math.floor(Math.random()*10000), // uniqueness
title: event.target.value
});
}
descriptionChange(event) {
this.setState({
description: event.target.value
});
}
setInvite(value) {
this.setState({
groups: value.groups,
ships: value.ships
});
}
createGroupChange(event) {
if (event.target.checked) {
this.setState({
createGroup: !!event.target.checked,
security: 'village'
});
} else {
this.setState({
createGroup: !!event.target.checked,
});
}
}
onClickCreate() {
const { props, state } = this;
if (!state.title) {
this.setState({
idError: true,
inviteError: false
});
return;
}
let appPath = `/${state.idName}`;
if (appPath in props.resources) {
this.setState({
inviteError: false,
idError: true,
success: false
});
return;
}
let isValid = true;
let aud = state.ships.map(mem => `~${deSig(mem.trim())}`);
aud.forEach((mem) => {
if (!urbitOb.isValidPatp(mem)) {
isValid = false;
}
});
if (!isValid) {
this.setState({
inviteError: true,
idError: false,
success: false
});
return;
}
const target = aud.length === 0
? {group: state.groups[0]}
: {ships: aud};
if (this.textarea) {
this.textarea.value = '';
}
this.setState({
error: false,
success: true,
group: [],
ships: []
}, () => {
api.setSpinner(true);
//TODO account for state.createGroup
let submit = api.createCollection(
appPath,
state.title,
state.description,
target
);
submit.then(() => {
api.setSpinner(false);
console.log('TODO nav', `/~link/list/0${appPath}`);
})
});
}
render() {
const { props, state } = this;
let inviteSwitchClasses = (state.security === "village")
? "relative checked bg-green2 br3 h1 toggle v-mid z-0"
: "relative bg-gray4 bg-gray1-d br3 h1 toggle v-mid z-0";
if (state.createGroup) {
inviteSwitchClasses = inviteSwitchClasses + " o-50";
}
let createGroupClasses = state.createGroup
? "relative checked bg-green2 br3 h1 toggle v-mid z-0"
: "relative bg-gray4 bg-gray1-d br3 h1 toggle v-mid z-0";
let createClasses = !!state.idName
? "pointer db f9 mt7 green2 bg-gray0-d ba pv3 ph4 b--green2"
: "pointer db f9 mt7 gray2 ba bg-gray0-d pa2 pv3 ph4 b--gray3";
let idClasses =
"f7 ba b--gray3 b--gray2-d bg-gray0-d white-d pa3 db w-100 " +
"focus-b--black focus-b--white-d ";
let idErrElem = (<span />);
if (state.idError) {
idErrElem = (
<span className="f9 inter red2 db pt2">
Chat must have a valid name.
</span>
);
}
let createGroupToggle = <div/>
if (state.groups.length === 0) {
createGroupToggle = (
<div className="mt7">
<input
type="checkbox"
style={{ WebkitAppearance: "none", width: 28 }}
className={createGroupClasses}
onChange={this.createGroupChange}
/>
<span className="dib f9 white-d inter ml3">Create Group</span>
<p className="f9 gray2 pt1" style={{ paddingLeft: 40 }}>
Participants will share this group across applications
</p>
</div>
);
}
return (
<div
className={
"h-100 w-100 mw6 pa3 pt4 overflow-x-hidden " +
"bg-gray0-d white-d flex flex-column"
}>
<div className="w-100 dn-m dn-l dn-xl inter pt1 pb6 f8">
<Link to="/~link/">{"⟵ All Collections"}</Link>
</div>
<h2 className="mb3 f8">New Chat</h2>
<div className="w-100">
<p className="f8 mt3 lh-copy db">Name</p>
<textarea
className={idClasses}
placeholder="Cool Collection"
rows={1}
style={{
resize: "none"
}}
onChange={this.titleChange}
/>
{idErrElem}
<p className="f8 mt3 lh-copy db">
Description
<span className="gray3"> (Optional)</span>
</p>
<textarea
className={idClasses}
placeholder="The hippest links"
rows={1}
style={{
resize: "none"
}}
onChange={this.descriptionChange}
/>
<p className="f8 mt4 lh-copy db">
Invite
<span className="gray3"> (Optional)</span>
</p>
<p className="f9 gray2 db mb2 pt1">
Selected entities will be able to post to chat
</p>
<InviteSearch
groups={props.groups}
contacts={props.contacts}
groupResults={true}
invites={{
groups: state.groups,
ships: state.ships
}}
setInvite={this.setInvite}
/>
{createGroupToggle}
<button
onClick={this.onClickCreate.bind(this)}
className={createClasses}>
Create Collection
</button>
</div>
</div>
);
}
}

View File

@ -7,6 +7,7 @@ import { api } from '/api';
import { subscription } from '/subscription';
import { store } from '/store';
import { Skeleton } from '/components/skeleton';
import { NewScreen } from '/components/new';
import { Links } from '/components/links-list';
import { LinkDetail } from '/components/link';
import { base64urlDecode } from '../lib/util';
@ -26,10 +27,16 @@ export class Root extends Component {
let contacts = !!state.contacts ? state.contacts : {};
const groups = !!state.groups ? state.groups : {};
const resources = !!state.resources ? state.resources : {};
let links = !!state.links ? state.links : {};
let comments = !!state.comments ? state.comments : {};
const seen = !!state.seen ? state.seen : {};
//TODO update /join/resource route in contacts
const invites = '/link' in state.invites ?
state.invites['/link'] : {};
console.log('invites', invites);
return (
<BrowserRouter>
<Route exact path="/~link"
@ -38,6 +45,8 @@ export class Root extends Component {
<Skeleton
active="channels"
spinner={state.spinner}
resources={resources}
invites={invites}
groups={groups}
rightPanelHide={true}
sidebarShown={true}
@ -45,43 +54,63 @@ export class Root extends Component {
<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">
Collections are shared across groups. To create a new collection, <a className="black white-d" href="/~contacts">create a group</a>.
Select or create a collection to begin.
</p>
</div>
</div>
</Skeleton>
);
}} />
<Route exact path="/~link/(popout)?/:ship/:channel/:page?"
<Route exact path="/~link/new"
render={(props) => {
return (
<Skeleton
active="channels"
spinner={state.spinner}
resources={resources}
invites={invites}
groups={groups}
rightPanelHide={true}
sidebarShown={true}
links={links}>
<NewScreen
resources={resources}
groups={groups}
contacts={contacts}/>
</Skeleton>
);
}}/>
<Route exact path="/~link/(popout)?/list/:page/:resource([^#]+)"
render={ (props) => {
// groups/contacts and link channels are the same thing in ver 1
const resourcePath = '/' + props.match.params.resource;
const resource = resources[resourcePath] || {};
let groupPath =
`/${props.match.params.ship}/${props.match.params.channel}`;
let contactDetails = contacts[groupPath] || {};
let contactDetails = contacts[resource.group] || {};
let page = props.match.params.page || 0;
let popout = props.match.url.includes("/popout/");
let channelLinks = !!links[groupPath]
? links[groupPath]
let channelLinks = !!links[resourcePath]
? links[resourcePath]
: {local: {}};
let channelComments = !!comments[groupPath]
? comments[groupPath]
let channelComments = !!comments[resourcePath]
? comments[resourcePath]
: {};
const channelSeen = !!seen[groupPath]
? seen[groupPath]
const channelSeen = !!seen[resourcePath]
? seen[resourcePath]
: {};
return (
<Skeleton
spinner={state.spinner}
resources={resources}
invites={invites}
groups={groups}
active="links"
selected={groupPath}
selected={resourcePath}
sidebarShown={state.sidebarShown}
sidebarHideMobile={true}
popout={popout}
@ -93,7 +122,8 @@ export class Root extends Component {
comments={channelComments}
seen={channelSeen}
page={page}
groupPath={groupPath}
resourcePath={resourcePath}
resource={resource}
popout={popout}
sidebarShown={state.sidebarShown}
/>
@ -101,36 +131,38 @@ export class Root extends Component {
)
}}
/>
<Route exact path="/~link/(popout)?/:ship/:channel/:page/:index/:encodedUrl/(comments)?/:commentpage?"
<Route exact path="/~link/(popout)?/item/:page/:index/:commentpage/:encodedUrl/:resource([^#]+)"
render={ (props) => {
let groupPath =
`/${props.match.params.ship}/${props.match.params.channel}`;
const resourcePath = '/' + props.match.params.resource;
const resource = resources[resourcePath] || {};
let popout = props.match.url.includes("/popout/");
let contactDetails = contacts[groupPath] || {};
let contactDetails = contacts[resource.group] || {};
let index = props.match.params.index || 0;
let page = props.match.params.page || 0;
let url = base64urlDecode(props.match.params.encodedUrl);
let data = !!links[groupPath]
? !!links[groupPath][page]
? links[groupPath][page][index]
let data = !!links[resourcePath]
? !!links[resourcePath][page]
? links[resourcePath][page][index]
: {}
: {};
let coms = !comments[groupPath]
let coms = !comments[resourcePath]
? undefined
: comments[groupPath][url];
: comments[resourcePath][url];
let commentPage = props.match.params.commentpage || 0;
return (
<Skeleton
spinner={state.spinner}
resources={resources}
invites={invites}
groups={groups}
active="links"
selected={groupPath}
selected={resourcePath}
sidebarShown={state.sidebarShown}
sidebarHideMobile={true}
popout={popout}
@ -141,7 +173,8 @@ export class Root extends Component {
url={url}
linkIndex={index}
contacts={contactDetails}
groupPath={groupPath}
resourcePath={resourcePath}
groupPath={resource.group}
popout={popout}
sidebarShown={state.sidebarShown}
data={data}

View File

@ -25,6 +25,8 @@ export class Skeleton extends Component {
<div className={`cf w-100 h-100 flex ` + popoutBorder}>
<ChannelsSidebar
popout={popout}
resources={this.props.resources}
invites={this.props.invites}
groups={this.props.groups}
active={this.props.active}
selected={this.props.selected}

View File

@ -11,6 +11,10 @@ export class InviteUpdateReducer {
this.accepted(data, state);
this.decline(data, state);
}
data = _.get(json, 'invite-initial', false);
if (data) {
state.invites = data;
}
}
create(json, state) {
@ -37,7 +41,6 @@ export class InviteUpdateReducer {
accepted(json, state) {
let data = _.get(json, 'accepted', false);
if (data) {
console.log(data);
delete state.invites[data.path][data.uid];
}
}

View File

@ -3,7 +3,7 @@ import _ from 'lodash';
// page size as expected from link-view.
// must change in parallel with the +page-size in /app/link-view to
// ensure sane behavior.
const PAGE_SIZE = 25;
const PAGE_SIZE = 3;
export class LinkUpdateReducer {
reduce(json, state) {

View File

@ -0,0 +1,47 @@
import _ from 'lodash';
export class MetadataReducer {
reduce(json, state) {
let data = _.get(json, 'metadata-update', false);
if (data) {
this.associations(data, state);
this.add(data, state);
}
}
associations(json, state) {
let data = _.get(json, 'associations', false);
if (data) {
let metadata = new Map;
Object.keys(data).map((key) => {
let assoc = data[key];
if (assoc['app-name'] !== 'link') {
return;
}
if (state.resources[assoc['app-path']]) {
console.error('beware! overwriting previous data', data['app-path']);
}
state.resources[assoc['app-path']] = {
group: assoc['group-path'],
...assoc.metadata
};
});
}
}
add(json, state) {
let data = _.get(json, 'add', false);
if (data) {
if (data['app-name'] !== 'link') {
return;
}
if (state.resources[data['app-path']]) {
console.error('beware! overwriting previous data', data['app-path']);
}
state.resources[data['app-path']] = {
group: data['group-path'],
...data.metadata
};
}
}
}

View File

@ -1,6 +1,8 @@
import { InitialReducer } from '/reducers/initial';
import { ContactUpdateReducer } from '/reducers/contact-update.js';
import { PermissionUpdateReducer } from '/reducers/permission-update';
import { MetadataReducer } from '/reducers/metadata-update.js';
import { InviteUpdateReducer } from '/reducers/invite-update';
import { LinkUpdateReducer } from '/reducers/link-update';
import { LocalReducer } from '/reducers/local.js';
import _ from 'lodash';
@ -11,6 +13,8 @@ class Store {
this.state = {
contacts: {},
groups: {},
resources: {},
invites: {},
links: {},
comments: {},
seen: {},
@ -22,6 +26,8 @@ class Store {
this.initialReducer = new InitialReducer();
this.contactUpdateReducer = new ContactUpdateReducer();
this.permissionUpdateReducer = new PermissionUpdateReducer();
this.metadataReducer = new MetadataReducer();
this.inviteUpdateReducer = new InviteUpdateReducer();
this.localReducer = new LocalReducer();
this.linkUpdateReducer = new LinkUpdateReducer();
this.setState = () => {};
@ -43,6 +49,8 @@ class Store {
this.initialReducer.reduce(json, this.state);
this.contactUpdateReducer.reduce(json, this.state);
this.permissionUpdateReducer.reduce(json, this.state);
this.metadataReducer.reduce(json, this.state);
this.inviteUpdateReducer.reduce(json, this.state);
this.localReducer.reduce(json, this.state);
this.linkUpdateReducer.reduce(json, this.state);

View File

@ -11,16 +11,25 @@ export class Subscription {
}
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));
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)
);
api.bind('/primary', 'PUT', api.authTokens.ship, 'invite-view',
this.handleEvent.bind(this),
this.handleError.bind(this),
this.handleQuitAndResubscribe.bind(this));
api.bind('/app-name/link', 'PUT', api.authTokens.ship, 'metadata-store',
this.handleEvent.bind(this),
this.handleError.bind(this),
this.handleQuitAndResubscribe.bind(this)
);
// open a subscription for all submissions
api.getPage('', 0);