mirror of
https://github.com/urbit/shrub.git
synced 2024-11-28 22:33:06 +03:00
Merge branch 'release/next-userspace' into lf/settings-screen
This commit is contained in:
commit
a7779025e8
@ -164,10 +164,7 @@
|
||||
(fact-group-update:cc wire !<(update:group-store q.cage.sign))
|
||||
[cards this]
|
||||
::
|
||||
%invite-update
|
||||
=^ cards state
|
||||
(fact-invite-update:cc wire !<(invite-update q.cage.sign))
|
||||
[cards this]
|
||||
%invite-update [~ this]
|
||||
==
|
||||
==
|
||||
::
|
||||
@ -481,17 +478,6 @@
|
||||
[%pass / %agent [our.bol %invite-hook] %poke %invite-action !>(act)]
|
||||
--
|
||||
::
|
||||
++ fact-invite-update
|
||||
|= [wir=wire fact=invite-update]
|
||||
^- (quip card _state)
|
||||
?+ -.fact [~ state]
|
||||
%accepted
|
||||
=/ rid=resource
|
||||
(de-path:resource path.invite.fact)
|
||||
:_ state
|
||||
~[(contact-view-poke %join rid)]
|
||||
==
|
||||
::
|
||||
++ group-hook-poke
|
||||
|= =action:group-hook
|
||||
^- card
|
||||
|
@ -502,8 +502,8 @@
|
||||
^+ +>
|
||||
:: XX needs filter
|
||||
::
|
||||
:: ?: ?=({$show $3} -.mad)
|
||||
:: (dy-rash %tan (dy-show-source q.mad) ~) :: XX separate command
|
||||
?: ?=({$show $3} -.mad)
|
||||
(dy-rash %tan (dy-show-source q.mad) ~)
|
||||
?: ?=($brev -.mad)
|
||||
=. var (~(del by var) p.mad)
|
||||
=< dy-amok
|
||||
@ -589,10 +589,8 @@
|
||||
?- p.p.mad
|
||||
%0 ~
|
||||
%1 [[%rose [~ " " ~] (skol p.q.cay) ~] maar]
|
||||
:: XX actually print something meaningful here
|
||||
::
|
||||
%2 [[%rose [~ " " ~] *tank ~] maar]
|
||||
%3 ~
|
||||
%2 [[%rose [~ " " ~] (dy-show-type-noun p.q.cay) ~] maar]
|
||||
::%3 handled above
|
||||
%4 ~
|
||||
%5 [[%rose [~ " " ~] (xskol p.q.cay) ~] maar]
|
||||
==
|
||||
@ -638,6 +636,70 @@
|
||||
:- i=""
|
||||
t=(turn `wain`?~(r.hit ~ (to-wain:format q.u.r.hit)) trip)
|
||||
==
|
||||
++ dy-show-type-noun
|
||||
|= a/type ^- tank
|
||||
=- >[-]<
|
||||
|- ^- $? $% {$atom @tas (unit @)}
|
||||
{$cell _$ _$}
|
||||
{$face $@(term tune) _$}
|
||||
{$fork (set _$)}
|
||||
{$hold _$ hoon}
|
||||
==
|
||||
wain :: "<|core|>"
|
||||
$?($noun $void)
|
||||
==
|
||||
?+ a a
|
||||
{$face ^} a(q $(a q.a))
|
||||
{$cell ^} a(p $(a p.a), q $(a q.a))
|
||||
{$fork *} a(p (silt (turn ~(tap in p.a) |=(b/type ^$(a b)))))
|
||||
{$hint *} !!
|
||||
{$core ^} `wain`/core
|
||||
{$hold *} a(p $(a p.a))
|
||||
==
|
||||
::
|
||||
:: XX needs filter
|
||||
::
|
||||
++ dy-shown
|
||||
=/ jank-bucwut :: FIXME just $? fishes when defined for some reason
|
||||
|* [a=mold b=mold]
|
||||
|=(c=_`*`*a ?:(& (a c) (b c)))
|
||||
::
|
||||
::$? hoon
|
||||
;: jank-bucwut
|
||||
hoon
|
||||
$^ {dy-shown dy-shown}
|
||||
$% {$ur cord}
|
||||
{$sa mark}
|
||||
{$as mark dy-shown}
|
||||
{$do hoon dy-shown}
|
||||
{$te term (list dy-shown)}
|
||||
{$ge path (list dy-shown) (map term (unit dy-shown))}
|
||||
{$dv path}
|
||||
==
|
||||
==
|
||||
::
|
||||
++ dy-show-source
|
||||
|= a/dojo-source ^- tank
|
||||
=- >[-]<
|
||||
=+ `{@ bil/dojo-build}`a
|
||||
|- ^- dy-shown
|
||||
?- -.bil
|
||||
$?($ur $dv $sa) bil
|
||||
$ex ?. ?=({$cltr *} p.bil) p.bil
|
||||
|- ^- hoon
|
||||
?~ p.p.bil !!
|
||||
?~ t.p.p.bil i.p.p.bil
|
||||
[i.p.p.bil $(p.p.bil t.p.p.bil)]
|
||||
$tu ?~ p.bil !!
|
||||
|-
|
||||
?~ t.p.bil ^$(bil q.i.p.bil)
|
||||
[^$(bil q.i.p.bil) $(p.bil t.p.bil)]
|
||||
$as bil(q $(bil q.q.bil))
|
||||
$do bil(q $(bil q.q.bil))
|
||||
$te bil(q (turn q.bil ..$))
|
||||
$ge :+ %ge q.p.p.bil
|
||||
[(turn p.q.p.bil ..$) (~(run by q.q.p.bil) (lift ..$))]
|
||||
==
|
||||
::
|
||||
++ dy-edit :: handle edit
|
||||
|= cal/sole-change
|
||||
@ -875,6 +937,8 @@
|
||||
?> ?=(~ cud)
|
||||
?: =(nex num)
|
||||
dy-over
|
||||
?: =([%show %3] -.mad) :: just show source
|
||||
dy-over
|
||||
dy-make(cud `[nex (~(got by job) nex)])
|
||||
--
|
||||
::
|
||||
|
@ -227,8 +227,11 @@
|
||||
|
||||
++ peek-group-join
|
||||
|= [rid=resource =ship]
|
||||
=/ =group
|
||||
(~(gut by groups) rid *group)
|
||||
=/ ugroup
|
||||
(~(get by groups) rid)
|
||||
?~ ugroup
|
||||
%.n
|
||||
=* group u.ugroup
|
||||
=* policy policy.group
|
||||
?- -.policy
|
||||
%invite
|
||||
|
@ -161,8 +161,11 @@
|
||||
++ on-peek
|
||||
|= =path
|
||||
^- (unit (unit cage))
|
||||
?+ path (on-peek:def path)
|
||||
[%x %keys ~] ``noun+!>(~(key by tiles))
|
||||
?. (team:title our.bowl src.bowl) ~
|
||||
?+ path [~ ~]
|
||||
[%x %tiles ~] ``noun+!>([tiles tile-ordering])
|
||||
[%x %first-time ~] ``noun+!>(first-time)
|
||||
[%x %keys ~] ``noun+!>(~(key by tiles))
|
||||
==
|
||||
::
|
||||
++ on-arvo
|
||||
|
@ -89,7 +89,18 @@
|
||||
--
|
||||
::
|
||||
++ on-leave on-leave:def
|
||||
++ on-peek on-peek:def
|
||||
++ on-peek
|
||||
~/ %s3-peek
|
||||
|= =path
|
||||
^- (unit (unit cage))
|
||||
?. (team:title our.bowl src.bowl) ~
|
||||
?+ path [~ ~]
|
||||
[%x %credentials ~]
|
||||
[~ ~ %s3-update !>(`update`[%credentials credentials])]
|
||||
::
|
||||
[%x %configuration ~]
|
||||
[~ ~ %s3-update !>(`update`[%configuration configuration])]
|
||||
==
|
||||
++ on-agent on-agent:def
|
||||
++ on-arvo on-arvo:def
|
||||
++ on-fail on-fail:def
|
||||
|
@ -61,8 +61,9 @@
|
||||
^- json
|
||||
%+ frond %chat-update
|
||||
%- pairs
|
||||
:~
|
||||
?: ?=(%initial -.upd)
|
||||
:_ ~
|
||||
?- -.upd
|
||||
%initial
|
||||
:- %initial
|
||||
%- pairs
|
||||
%+ turn ~(tap by inbox.upd)
|
||||
@ -73,27 +74,37 @@
|
||||
:~ [%envelopes [%a (turn envelopes.mailbox envelope)]]
|
||||
[%config (config config.mailbox)]
|
||||
==
|
||||
?: ?=(%message -.upd)
|
||||
:- %message
|
||||
%- pairs
|
||||
:~ [%path (path path.upd)]
|
||||
[%envelope (envelope envelope.upd)]
|
||||
==
|
||||
?: ?=(%messages -.upd)
|
||||
:- %messages
|
||||
%- pairs
|
||||
:~ [%path (path path.upd)]
|
||||
[%start (numb start.upd)]
|
||||
[%end (numb end.upd)]
|
||||
[%envelopes [%a (turn envelopes.upd envelope)]]
|
||||
==
|
||||
?: ?=(%read -.upd)
|
||||
[%read (pairs [%path (path path.upd)]~)]
|
||||
?: ?=(%create -.upd)
|
||||
[%create (pairs [%path (path path.upd)]~)]
|
||||
?: ?=(%delete -.upd)
|
||||
[%delete (pairs [%path (path path.upd)]~)]
|
||||
[*@t *json]
|
||||
::
|
||||
%message
|
||||
:- %message
|
||||
%- pairs
|
||||
:~ [%path (path path.upd)]
|
||||
[%envelope (envelope envelope.upd)]
|
||||
==
|
||||
::
|
||||
%messages
|
||||
:- %messages
|
||||
%- pairs
|
||||
:~ [%path (path path.upd)]
|
||||
[%start (numb start.upd)]
|
||||
[%end (numb end.upd)]
|
||||
[%envelopes [%a (turn envelopes.upd envelope)]]
|
||||
==
|
||||
::
|
||||
%read
|
||||
[%read (pairs [%path (path path.upd)]~)]
|
||||
::
|
||||
%create
|
||||
[%create (pairs [%path (path path.upd)]~)]
|
||||
::
|
||||
%delete
|
||||
[%delete (pairs [%path (path path.upd)]~)]
|
||||
::
|
||||
%keys
|
||||
:- %keys
|
||||
:- %a
|
||||
%+ turn ~(tap by keys.upd)
|
||||
|= pax=^path (path pax)
|
||||
==
|
||||
--
|
||||
++ dejs
|
||||
|
@ -225,7 +225,8 @@
|
||||
++ add
|
||||
|= [=ship =resource]
|
||||
~| resource
|
||||
?< (~(has by tracking) resource)
|
||||
?: (~(has by tracking) resource)
|
||||
[~ state]
|
||||
=. tracking
|
||||
(~(put by tracking) resource ship)
|
||||
:_ state
|
||||
|
81
pkg/interface/package-lock.json
generated
81
pkg/interface/package-lock.json
generated
@ -1810,6 +1810,15 @@
|
||||
"csstype": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"@types/react-native": {
|
||||
"version": "0.63.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.63.4.tgz",
|
||||
"integrity": "sha512-IkQax0q5z5P4ttScELhrfrXtnFuADs/SP9kNwx2rfEuVjwF5xqhGjcY/YkiH2mSx+9QjI5S4zhxXOi3+kcnOkw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"@types/react-router": {
|
||||
"version": "5.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.7.tgz",
|
||||
@ -1837,6 +1846,43 @@
|
||||
"integrity": "sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/styled-components": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/styled-components/-/styled-components-5.1.2.tgz",
|
||||
"integrity": "sha512-HNocYLfrsnNNm8NTS/W53OERSjRA8dx5Bn6wBd2rXXwt4Z3s+oqvY6/PbVt3e6sgtzI63GX//WiWiRhWur08qQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/hoist-non-react-statics": "*",
|
||||
"@types/react": "*",
|
||||
"@types/react-native": "*",
|
||||
"csstype": "^3.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"csstype": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.2.tgz",
|
||||
"integrity": "sha512-ofovWglpqoqbfLNOTBNZLSbMuGrblAf1efvvArGKOZMBrIoJeu5UsAipQolkijtyQx5MtAzT/J9IHj/CEY1mJw==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@types/styled-system": {
|
||||
"version": "5.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/styled-system/-/styled-system-5.1.10.tgz",
|
||||
"integrity": "sha512-OmVjC9OzyUckAgdavJBc+t5oCJrNXTlzWl9vo2x47leqpX1REq2qJC49SEtzbu1OnWSzcD68Uq3Aj8TeX+Kvtg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"csstype": "^3.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"csstype": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.2.tgz",
|
||||
"integrity": "sha512-ofovWglpqoqbfLNOTBNZLSbMuGrblAf1efvvArGKOZMBrIoJeu5UsAipQolkijtyQx5MtAzT/J9IHj/CEY1mJw==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@types/tapable": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.5.tgz",
|
||||
@ -3225,9 +3271,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"codemirror": {
|
||||
"version": "5.53.2",
|
||||
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.53.2.tgz",
|
||||
"integrity": "sha512-wvSQKS4E+P8Fxn/AQ+tQtJnF1qH5UOlxtugFLpubEZ5jcdH2iXTVinb+Xc/4QjshuOxRm4fUsU2QPF1JJKiyXA=="
|
||||
"version": "5.57.0",
|
||||
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.57.0.tgz",
|
||||
"integrity": "sha512-WGc6UL7Hqt+8a6ZAsj/f1ApQl3NPvHY/UQSzG6fB6l4BjExgVdhFaxd7mRTw1UCiYe/6q86zHP+kfvBQcZGvUg=="
|
||||
},
|
||||
"collapse-white-space": {
|
||||
"version": "1.0.6",
|
||||
@ -4963,9 +5009,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"formik": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/formik/-/formik-2.1.4.tgz",
|
||||
"integrity": "sha512-oKz8S+yQBzuQVSEoxkqqJrKQS5XJASWGVn6mrs+oTWrBoHgByVwwI1qHiVc9GKDpZBU9vAxXYAKz2BvujlwunA==",
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/formik/-/formik-2.1.5.tgz",
|
||||
"integrity": "sha512-bWpo3PiqVDYslvrRjTq0Isrm0mFXHiO33D8MS6t6dWcqSFGeYF52nlpCM2xwOJ6tRVRznDkL+zz/iHPL4LDuvQ==",
|
||||
"requires": {
|
||||
"deepmerge": "^2.1.1",
|
||||
"hoist-non-react-statics": "^3.3.0",
|
||||
@ -7823,6 +7869,24 @@
|
||||
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
|
||||
"integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw=="
|
||||
},
|
||||
"react-helmet": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-6.1.0.tgz",
|
||||
"integrity": "sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw==",
|
||||
"requires": {
|
||||
"object-assign": "^4.1.1",
|
||||
"prop-types": "^15.7.2",
|
||||
"react-fast-compare": "^3.1.1",
|
||||
"react-side-effect": "^2.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"react-fast-compare": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz",
|
||||
"integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"react-hot-loader": {
|
||||
"version": "4.12.21",
|
||||
"resolved": "https://registry.npmjs.org/react-hot-loader/-/react-hot-loader-4.12.21.tgz",
|
||||
@ -7904,6 +7968,11 @@
|
||||
"tiny-warning": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"react-side-effect": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.0.tgz",
|
||||
"integrity": "sha512-IgmcegOSi5SNX+2Snh1vqmF0Vg/CbkycU9XZbOHJlZ6kMzTmi3yc254oB1WCkgA7OQtIAoLmcSFuHTc/tlcqXg=="
|
||||
},
|
||||
"react-window": {
|
||||
"version": "1.8.5",
|
||||
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.5.tgz",
|
||||
|
@ -12,7 +12,7 @@
|
||||
"@tlon/indigo-react": "^1.1.15",
|
||||
"aws-sdk": "^2.726.0",
|
||||
"classnames": "^2.2.6",
|
||||
"codemirror": "^5.51.0",
|
||||
"codemirror": "^5.55.0",
|
||||
"css-loader": "^3.5.3",
|
||||
"formik": "^2.1.4",
|
||||
"lodash": "^4.17.15",
|
||||
@ -27,6 +27,7 @@
|
||||
"react-dnd-multi-backend": "^6.0.2",
|
||||
"react-dnd-touch-backend": "^11.1.3",
|
||||
"react-dom": "^16.8.6",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-markdown": "^4.3.1",
|
||||
"react-router-dom": "^5.0.0",
|
||||
"react-window": "^1.8.5",
|
||||
@ -51,6 +52,8 @@
|
||||
"@types/lodash": "^4.14.155",
|
||||
"@types/react": "^16.9.38",
|
||||
"@types/react-router-dom": "^5.1.5",
|
||||
"@types/styled-components": "^5.1.2",
|
||||
"@types/styled-system": "^5.1.10",
|
||||
"@typescript-eslint/eslint-plugin": "^3.8.0",
|
||||
"@typescript-eslint/parser": "^3.8.0",
|
||||
"babel-eslint": "^10.1.0",
|
||||
|
@ -29,7 +29,7 @@ export default class LocalApi extends BaseApi<StoreState> {
|
||||
});
|
||||
}
|
||||
|
||||
setOmnibox() {
|
||||
setOmnibox() {
|
||||
this.store.handleEvent({
|
||||
data: {
|
||||
local: {
|
||||
|
@ -1,6 +1,7 @@
|
||||
import BaseApi from './base';
|
||||
|
||||
import { PublishResponse } from '~/types/publish-response';
|
||||
import { PatpNoSig } from '~/types/noun';
|
||||
import { PatpNoSig, Path } from '~/types/noun';
|
||||
import { BookId, NoteId } from '~/types/publish-update';
|
||||
|
||||
export default class PublishApi extends BaseApi {
|
||||
@ -80,5 +81,105 @@ export default class PublishApi extends BaseApi {
|
||||
publishAction(act: any) {
|
||||
return this.action('publish', 'publish-action', act);
|
||||
}
|
||||
|
||||
newBook(bookId: string, title: string, description: string, group?: Path) {
|
||||
const groupInfo = group ? { 'group-path': group,
|
||||
invitees: [],
|
||||
'use-preexisting': true,
|
||||
'make-managed': true
|
||||
} : {
|
||||
'group-path': `/ship/~${window.ship}/${bookId}`,
|
||||
invitees: [],
|
||||
'use-preexisting': false,
|
||||
'make-managed': false
|
||||
};
|
||||
return this.publishAction({
|
||||
"new-book": {
|
||||
book: bookId,
|
||||
title: title,
|
||||
about: description,
|
||||
coms: true,
|
||||
group: groupInfo
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
editBook(bookId: string, title: string, description: string, coms: boolean) {
|
||||
return this.publishAction({
|
||||
"edit-book": {
|
||||
book: bookId,
|
||||
title: title,
|
||||
about: description,
|
||||
coms,
|
||||
group: null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
delBook(book: string) {
|
||||
return this.publishAction({
|
||||
"del-book": {
|
||||
book
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
newNote(who: PatpNoSig, book: string, note: string, title: string, body: string) {
|
||||
return this.publishAction({
|
||||
'new-note': {
|
||||
who,
|
||||
book,
|
||||
note,
|
||||
title,
|
||||
body
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
editNote(who: PatpNoSig, book: string, note: string, title: string, body: string) {
|
||||
return this.publishAction({
|
||||
'edit-note': {
|
||||
who,
|
||||
book,
|
||||
note,
|
||||
title,
|
||||
body
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
delNote(who: PatpNoSig, book: string, note: string) {
|
||||
return this.publishAction({
|
||||
'del-note': {
|
||||
who,
|
||||
book,
|
||||
note
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateComment(who: PatpNoSig, book: string, note: string, comment: Path, body: string) {
|
||||
return this.publishAction({
|
||||
'edit-comment': {
|
||||
who,
|
||||
book,
|
||||
note,
|
||||
comment,
|
||||
body
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deleteComment(who: PatpNoSig, book: string, note: string, comment: Path ) {
|
||||
return this.publishAction({
|
||||
"del-comment": {
|
||||
who,
|
||||
book,
|
||||
note,
|
||||
comment
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
@ -1,48 +1,39 @@
|
||||
import React, { Component } from 'react';
|
||||
import React, { memo } from 'react';
|
||||
import { sigil, reactRenderer } from 'urbit-sigil-js';
|
||||
|
||||
export class Sigil extends Component {
|
||||
static foregroundFromBackground(background) {
|
||||
const rgb = {
|
||||
r: parseInt(background.slice(1, 3), 16),
|
||||
g: parseInt(background.slice(3, 5), 16),
|
||||
b: parseInt(background.slice(5, 7), 16)
|
||||
};
|
||||
const brightness = ((299 * rgb.r) + (587 * rgb.g) + (114 * rgb.b)) / 1000;
|
||||
const whiteBrightness = 255;
|
||||
export const foregroundFromBackground = (background) => {
|
||||
const rgb = {
|
||||
r: parseInt(background.slice(1, 3), 16),
|
||||
g: parseInt(background.slice(3, 5), 16),
|
||||
b: parseInt(background.slice(5, 7), 16)
|
||||
};
|
||||
const brightness = ((299 * rgb.r) + (587 * rgb.g) + (114 * rgb.b)) / 1000;
|
||||
const whiteBrightness = 255;
|
||||
|
||||
return ((whiteBrightness - brightness) < 50) ? 'black' : 'white';
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props } = this;
|
||||
|
||||
const classes = props.classes || '';
|
||||
|
||||
const foreground = Sigil.foregroundFromBackground(props.color);
|
||||
|
||||
if (props.ship.length > 14) {
|
||||
return (
|
||||
<div
|
||||
className={'bg-black dib ' + classes}
|
||||
style={{ width: props.size, height: props.size }}
|
||||
></div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div
|
||||
className={'dib ' + classes}
|
||||
style={{ flexBasis: props.size, backgroundColor: props.color }}
|
||||
>
|
||||
{sigil({
|
||||
patp: props.ship,
|
||||
renderer: reactRenderer,
|
||||
size: props.size,
|
||||
colors: [props.color, foreground],
|
||||
class: props.svgClass
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
return ((whiteBrightness - brightness) < 50) ? 'black' : 'white';
|
||||
}
|
||||
|
||||
export const Sigil = memo(({ classes = '', color, ship, size, svgClass = '' }) => {
|
||||
return ship.length > 14
|
||||
? (<div
|
||||
className={'bg-black dib ' + classes}
|
||||
style={{ width: size, height: size }}>
|
||||
</div>)
|
||||
: (<div
|
||||
className={'dib ' + classes}
|
||||
style={{ flexBasis: size, backgroundColor: color }}
|
||||
>
|
||||
{sigil({
|
||||
patp: ship,
|
||||
renderer: reactRenderer,
|
||||
size: size,
|
||||
colors: [
|
||||
color,
|
||||
foregroundFromBackground(color)
|
||||
],
|
||||
class: svgClass
|
||||
})}
|
||||
</div>)
|
||||
})
|
||||
|
||||
export default Sigil;
|
57
pkg/interface/src/logic/lib/useDropdown.ts
Normal file
57
pkg/interface/src/logic/lib/useDropdown.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
|
||||
export function useDropdown<C>(
|
||||
candidates: C[],
|
||||
key: (c: C) => string,
|
||||
searchPred: (query: string, c: C) => boolean
|
||||
) {
|
||||
const [options, setOptions] = useState(candidates);
|
||||
const [selected, setSelected] = useState<C | undefined>();
|
||||
const search = useCallback(
|
||||
(s: string) => {
|
||||
const opts = candidates.filter((c) => searchPred(s, c));
|
||||
setOptions(opts);
|
||||
if (selected) {
|
||||
const idx = opts.findIndex((c) => key(c) === key(selected));
|
||||
if (idx < 0) {
|
||||
setSelected(undefined);
|
||||
}
|
||||
}
|
||||
},
|
||||
[candidates, searchPred, key, selected, setOptions, setSelected]
|
||||
);
|
||||
|
||||
const changeSelection = useCallback(
|
||||
(backward = false) => {
|
||||
const select = (idx: number) => {
|
||||
setSelected(options[idx]);
|
||||
};
|
||||
if(!selected) { select(0); return false; }
|
||||
|
||||
const idx = options.findIndex((c) => key(c) === key(selected));
|
||||
if (
|
||||
idx === -1 ||
|
||||
(options.length - 1 <= idx && !backward)
|
||||
) {
|
||||
select(0);
|
||||
} else if (idx === 0 && backward) {
|
||||
select(options.length - 1);
|
||||
} else {
|
||||
select(idx + (backward ? -1 : 1));
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[options, setSelected, selected]
|
||||
);
|
||||
|
||||
const next = useCallback(() => changeSelection(), [changeSelection]);
|
||||
const back = useCallback(() => changeSelection(true), [changeSelection]);
|
||||
|
||||
return {
|
||||
next,
|
||||
back,
|
||||
search,
|
||||
selected,
|
||||
options,
|
||||
};
|
||||
}
|
22
pkg/interface/src/logic/lib/useLocalStorageState.ts
Normal file
22
pkg/interface/src/logic/lib/useLocalStorageState.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export function useLocalStorageState<T>(key: string, initial: T) {
|
||||
const [state, _setState] = useState(() => {
|
||||
const s = localStorage.getItem(key);
|
||||
if(s) {
|
||||
return JSON.parse(s) as T;
|
||||
}
|
||||
return initial;
|
||||
|
||||
});
|
||||
|
||||
const setState = useCallback((s: T) => {
|
||||
_setState(s);
|
||||
localStorage.setItem(key, JSON.stringify(s));
|
||||
|
||||
}, [_setState]);
|
||||
|
||||
return [state, setState] as const;
|
||||
}
|
||||
|
||||
|
30
pkg/interface/src/logic/lib/useQuery.ts
Normal file
30
pkg/interface/src/logic/lib/useQuery.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import _ from 'lodash';
|
||||
|
||||
export function useQuery() {
|
||||
const { search } = useLocation();
|
||||
|
||||
const query = useMemo(() => new URLSearchParams(search), [search]);
|
||||
|
||||
const appendQuery = useCallback(
|
||||
(q: Record<string, string>) => {
|
||||
const newQuery = new URLSearchParams(search);
|
||||
_.forIn(q, (value, key) => {
|
||||
if (!value) {
|
||||
newQuery.delete(key);
|
||||
} else {
|
||||
newQuery.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return newQuery.toString();
|
||||
},
|
||||
[search]
|
||||
);
|
||||
|
||||
return {
|
||||
query,
|
||||
appendQuery,
|
||||
};
|
||||
}
|
36
pkg/interface/src/logic/lib/useWaitForProps.ts
Normal file
36
pkg/interface/src/logic/lib/useWaitForProps.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
|
||||
export function useWaitForProps<P>(props: P, timeout: number) {
|
||||
const [resolve, setResolve] = useState<() => void>(() => () => {});
|
||||
const [ready, setReady] = useState<(p: P) => boolean | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof ready === "function" && ready(props)) {
|
||||
resolve();
|
||||
}
|
||||
}, [props, ready, resolve]);
|
||||
|
||||
/**
|
||||
* Waits until some predicate is true
|
||||
*
|
||||
* @param r - Predicate to wait for
|
||||
* @returns A promise that resolves when `r` returns true, or rejects if the
|
||||
* waiting times out
|
||||
*
|
||||
*/
|
||||
const waiter = useCallback(
|
||||
(r: (props: P) => boolean) => {
|
||||
setReady(() => r);
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
setResolve(() => resolve);
|
||||
setTimeout(() => {
|
||||
reject(new Error("Timed out"));
|
||||
}, timeout);
|
||||
});
|
||||
},
|
||||
[setResolve, setReady, timeout]
|
||||
);
|
||||
|
||||
return waiter;
|
||||
}
|
@ -3,8 +3,7 @@ import { StoreState } from '~/store/type';
|
||||
import { Cage } from '~/types/cage';
|
||||
import { LocalUpdate, BackgroundConfig } from '~/types/local-update';
|
||||
|
||||
|
||||
type LocalState = Pick<StoreState, 'sidebarShown' | 'omniboxShown' | 'baseHash' | 'hideAvatars' | 'hideNicknames' | 'background' | 'dark'>;
|
||||
type LocalState = Pick<StoreState, 'sidebarShown' | 'omniboxShown' | 'baseHash' | 'hideAvatars' | 'hideNicknames' | 'background' | 'dark' | 'suspendedFocus'>;
|
||||
|
||||
export default class LocalReducer<S extends LocalState> {
|
||||
rehydrate(state: S) {
|
||||
@ -43,6 +42,13 @@ export default class LocalReducer<S extends LocalState> {
|
||||
omniboxShown(obj: LocalUpdate, state: S) {
|
||||
if ('omniboxShown' in obj) {
|
||||
state.omniboxShown = !state.omniboxShown;
|
||||
if (state.suspendedFocus) {
|
||||
state.suspendedFocus.focus();
|
||||
state.suspendedFocus = null;
|
||||
} else {
|
||||
state.suspendedFocus = document.activeElement;
|
||||
document.activeElement?.blur();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -78,6 +78,8 @@ export default class PublishResponseReducer<S extends PublishState> {
|
||||
json.data.notebook["subscribers-group-path"];
|
||||
state.notebooks[json.host][json.notebook]["writers-group-path"] =
|
||||
json.data.notebook["writers-group-path"];
|
||||
state.notebooks[json.host][json.notebook].about =
|
||||
json.data.notebook.about;
|
||||
if (state.notebooks[json.host][json.notebook].notes) {
|
||||
for (var key in json.data.notebook.notes) {
|
||||
let oldNote = state.notebooks[json.host][json.notebook].notes[key];
|
||||
|
@ -50,6 +50,7 @@ export default class GlobalStore extends BaseStore<StoreState> {
|
||||
connection: 'connected',
|
||||
sidebarShown: true,
|
||||
omniboxShown: false,
|
||||
suspendedFocus: null,
|
||||
baseHash: null,
|
||||
background: undefined,
|
||||
hideAvatars: false,
|
||||
|
@ -17,6 +17,7 @@ export interface StoreState {
|
||||
// local state
|
||||
sidebarShown: boolean;
|
||||
omniboxShown: boolean;
|
||||
suspendedFocus: HTMLInputElement | null;
|
||||
dark: boolean;
|
||||
connection: ConnectionStatus;
|
||||
baseHash: string | null;
|
||||
|
@ -129,7 +129,7 @@ export interface Notebook {
|
||||
'writers-group-path': Path;
|
||||
}
|
||||
|
||||
type Notes = {
|
||||
export type Notes = {
|
||||
[id in NoteId]: Note;
|
||||
};
|
||||
|
||||
@ -148,7 +148,7 @@ export interface Note {
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Comment {
|
||||
export interface Comment {
|
||||
[date: string]: {
|
||||
author: Patp;
|
||||
content: string;
|
||||
|
@ -4,6 +4,7 @@ import * as React from 'react';
|
||||
import { BrowserRouter as Router, Route, withRouter, Switch } from 'react-router-dom';
|
||||
import styled, { ThemeProvider, createGlobalStyle } from 'styled-components';
|
||||
import { sigil as sigiljs, stringRenderer } from 'urbit-sigil-js';
|
||||
import Helmet from 'react-helmet';
|
||||
|
||||
import Mousetrap from 'mousetrap';
|
||||
import 'mousetrap-global-bind';
|
||||
@ -22,7 +23,7 @@ import GlobalStore from '~/logic/store/store';
|
||||
import GlobalSubscription from '~/logic/subscription/global';
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
import { uxToHex } from '~/logic/lib/util';
|
||||
import { Sigil } from '~/logic/lib/sigil';
|
||||
import { foregroundFromBackground } from '~/logic/lib/sigil';
|
||||
|
||||
const Root = styled.div`
|
||||
font-family: ${p => p.theme.fonts.sans};
|
||||
@ -37,6 +38,8 @@ const Root = styled.div`
|
||||
background-color: ${p.background.color}
|
||||
` : ``
|
||||
}
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
`;
|
||||
|
||||
const StatusBarWithRouter = withRouter(StatusBar);
|
||||
@ -55,7 +58,7 @@ class App extends React.Component {
|
||||
new GlobalSubscription(this.store, this.api, this.appChannel);
|
||||
|
||||
this.updateTheme = this.updateTheme.bind(this);
|
||||
this.setFavicon = this.setFavicon.bind(this);
|
||||
this.faviconString = this.faviconString.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@ -67,41 +70,33 @@ class App extends React.Component {
|
||||
this.store.rehydrate();
|
||||
Mousetrap.bindGlobal(['command+/', 'ctrl+/'], (e) => {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
this.api.local.setOmnibox();
|
||||
});
|
||||
this.setFavicon();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.themeWatcher.removeListener(this.updateTheme);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState, snapshot) {
|
||||
this.setFavicon();
|
||||
}
|
||||
|
||||
updateTheme(e) {
|
||||
this.api.local.setDark(e.matches);
|
||||
}
|
||||
|
||||
setFavicon() {
|
||||
if (window.ship.length < 14) {
|
||||
let background = '#ffffff';
|
||||
if (this.state.contacts.hasOwnProperty('/~/default')) {
|
||||
background = `#${uxToHex(this.state.contacts['/~/default'][window.ship].color)}`;
|
||||
}
|
||||
const foreground = Sigil.foregroundFromBackground(background);
|
||||
const svg = sigiljs({
|
||||
patp: window.ship,
|
||||
renderer: stringRenderer,
|
||||
size: 16,
|
||||
colors: [background, foreground]
|
||||
});
|
||||
const dataurl = 'data:image/svg+xml;base64,' + btoa(svg);
|
||||
const favicon = document.querySelector('[rel=icon]');
|
||||
favicon.href = dataurl;
|
||||
favicon.type = 'image/svg+xml';
|
||||
faviconString() {
|
||||
let background = '#ffffff';
|
||||
if (this.state.contacts.hasOwnProperty('/~/default')) {
|
||||
background = `#${uxToHex(this.state.contacts['/~/default'][window.ship].color)}`;
|
||||
}
|
||||
const foreground = foregroundFromBackground(background);
|
||||
const svg = sigiljs({
|
||||
patp: window.ship,
|
||||
renderer: stringRenderer,
|
||||
size: 16,
|
||||
colors: [background, foreground]
|
||||
});
|
||||
const dataurl = 'data:image/svg+xml;base64,' + btoa(svg);
|
||||
return dataurl;
|
||||
}
|
||||
|
||||
render() {
|
||||
@ -113,6 +108,11 @@ class App extends React.Component {
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<Helmet>
|
||||
{window.ship.length < 14
|
||||
? <link rel="icon" type="image/svg+xml" href={this.faviconString()} />
|
||||
: null}
|
||||
</Helmet>
|
||||
<Root background={background} >
|
||||
<Router>
|
||||
<StatusBarWithRouter
|
||||
|
@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import Helmet from 'react-helmet';
|
||||
|
||||
import './css/custom.css';
|
||||
|
||||
@ -24,14 +25,11 @@ type ChatAppProps = StoreState & {
|
||||
};
|
||||
|
||||
export default class ChatApp extends React.Component<ChatAppProps, {}> {
|
||||
totalUnreads = 0;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
document.title = 'OS1 - Chat';
|
||||
// preload spinner asset
|
||||
new Image().src = '/~landscape/img/Spinner.png';
|
||||
|
||||
@ -79,12 +77,6 @@ export default class ChatApp extends React.Component<ChatAppProps, {}> {
|
||||
}
|
||||
});
|
||||
|
||||
if (totalUnreads !== this.totalUnreads) {
|
||||
document.title =
|
||||
totalUnreads > 0 ? `(${totalUnreads}) OS1 - Chat` : 'OS1 - Chat';
|
||||
this.totalUnreads = totalUnreads;
|
||||
}
|
||||
|
||||
const {
|
||||
invites,
|
||||
s3,
|
||||
@ -115,210 +107,215 @@ export default class ChatApp extends React.Component<ChatAppProps, {}> {
|
||||
);
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Route
|
||||
exact
|
||||
path="/~chat"
|
||||
render={(props) => {
|
||||
return (
|
||||
<Skeleton
|
||||
associations={associations}
|
||||
invites={invites}
|
||||
chatHideonMobile={true}
|
||||
sidebarShown={sidebarShown}
|
||||
sidebar={renderChannelSidebar(props)}
|
||||
>
|
||||
<div className="h-100 w-100 overflow-x-hidden flex flex-column bg-white bg-gray0-d">
|
||||
<div className="pl3 pr3 pt2 dt pb3 w-100 h-100">
|
||||
<p className="f8 pt3 gray2 w-100 h-100 dtc v-mid tc">
|
||||
Select, create, or join a chat to begin.
|
||||
</p>
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{totalUnreads > 0 ? `(${totalUnreads}) ` : ''}OS1 - Chat</title>
|
||||
</Helmet>
|
||||
<Switch>
|
||||
<Route
|
||||
exact
|
||||
path="/~chat"
|
||||
render={(props) => {
|
||||
return (
|
||||
<Skeleton
|
||||
associations={associations}
|
||||
invites={invites}
|
||||
chatHideonMobile={true}
|
||||
sidebarShown={sidebarShown}
|
||||
sidebar={renderChannelSidebar(props)}
|
||||
>
|
||||
<div className="h-100 w-100 overflow-x-hidden flex flex-column bg-white bg-gray0-d">
|
||||
<div className="pl3 pr3 pt2 dt pb3 w-100 h-100">
|
||||
<p className="f8 pt3 gray2 w-100 h-100 dtc v-mid tc">
|
||||
Select, create, or join a chat to begin.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/~chat/new/dm/:ship?"
|
||||
render={(props) => {
|
||||
const ship = props.match.params.ship;
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/~chat/new/dm/:ship?"
|
||||
render={(props) => {
|
||||
const ship = props.match.params.ship;
|
||||
|
||||
return (
|
||||
<Skeleton
|
||||
associations={associations}
|
||||
invites={invites}
|
||||
sidebarHideOnMobile={true}
|
||||
sidebar={renderChannelSidebar(props)}
|
||||
sidebarShown={sidebarShown}
|
||||
>
|
||||
<NewDmScreen
|
||||
api={api}
|
||||
inbox={inbox}
|
||||
groups={groups || {}}
|
||||
contacts={contacts || {}}
|
||||
associations={associations.contacts}
|
||||
chatSynced={chatSynced || {}}
|
||||
autoCreate={ship}
|
||||
{...props}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/~chat/new"
|
||||
render={(props) => {
|
||||
return (
|
||||
<Skeleton
|
||||
associations={associations}
|
||||
invites={invites}
|
||||
sidebarHideOnMobile={true}
|
||||
sidebar={renderChannelSidebar(props)}
|
||||
sidebarShown={sidebarShown}
|
||||
>
|
||||
<NewScreen
|
||||
api={api}
|
||||
inbox={inbox || {}}
|
||||
groups={groups}
|
||||
contacts={contacts || {}}
|
||||
associations={associations.contacts}
|
||||
chatSynced={chatSynced || {}}
|
||||
{...props}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/~chat/join/:ship?/:station?"
|
||||
render={(props) => {
|
||||
let station = `/${props.match.params.ship}/${props.match.params.station}`;
|
||||
return (
|
||||
<Skeleton
|
||||
associations={associations}
|
||||
invites={invites}
|
||||
sidebarHideOnMobile={true}
|
||||
sidebar={renderChannelSidebar(props)}
|
||||
sidebarShown={sidebarShown}
|
||||
>
|
||||
<NewDmScreen
|
||||
api={api}
|
||||
inbox={inbox}
|
||||
groups={groups || {}}
|
||||
contacts={contacts || {}}
|
||||
associations={associations.contacts}
|
||||
chatSynced={chatSynced || {}}
|
||||
autoCreate={ship}
|
||||
{...props}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/~chat/new"
|
||||
render={(props) => {
|
||||
return (
|
||||
<Skeleton
|
||||
associations={associations}
|
||||
invites={invites}
|
||||
sidebarHideOnMobile={true}
|
||||
sidebar={renderChannelSidebar(props)}
|
||||
sidebarShown={sidebarShown}
|
||||
>
|
||||
<NewScreen
|
||||
api={api}
|
||||
inbox={inbox || {}}
|
||||
groups={groups}
|
||||
contacts={contacts || {}}
|
||||
associations={associations.contacts}
|
||||
chatSynced={chatSynced || {}}
|
||||
{...props}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/~chat/join/:ship?/:station?"
|
||||
render={(props) => {
|
||||
let station = `/${props.match.params.ship}/${props.match.params.station}`;
|
||||
|
||||
return (
|
||||
<Skeleton
|
||||
associations={associations}
|
||||
invites={invites}
|
||||
sidebarHideOnMobile={true}
|
||||
sidebar={renderChannelSidebar(props)}
|
||||
sidebarShown={sidebarShown}
|
||||
>
|
||||
<JoinScreen
|
||||
api={api}
|
||||
inbox={inbox}
|
||||
autoJoin={station}
|
||||
chatSynced={chatSynced || {}}
|
||||
{...props}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/~chat/(popout)?/room/(~)?/:ship/:station+"
|
||||
render={(props) => {
|
||||
let station = `/${props.match.params.ship}/${props.match.params.station}`;
|
||||
const mailbox = inbox[station] || {
|
||||
config: {
|
||||
read: 0,
|
||||
length: 0
|
||||
},
|
||||
envelopes: []
|
||||
};
|
||||
return (
|
||||
<Skeleton
|
||||
associations={associations}
|
||||
invites={invites}
|
||||
sidebarHideOnMobile={true}
|
||||
sidebar={renderChannelSidebar(props)}
|
||||
sidebarShown={sidebarShown}
|
||||
>
|
||||
<JoinScreen
|
||||
api={api}
|
||||
inbox={inbox}
|
||||
autoJoin={station}
|
||||
chatSynced={chatSynced || {}}
|
||||
{...props}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/~chat/(popout)?/room/(~)?/:ship/:station+"
|
||||
render={(props) => {
|
||||
let station = `/${props.match.params.ship}/${props.match.params.station}`;
|
||||
const mailbox = inbox[station] || {
|
||||
config: {
|
||||
read: 0,
|
||||
length: 0
|
||||
},
|
||||
envelopes: []
|
||||
};
|
||||
|
||||
let roomContacts = {};
|
||||
const associatedGroup =
|
||||
station in associations['chat'] &&
|
||||
'group-path' in associations.chat[station]
|
||||
? associations.chat[station]['group-path']
|
||||
: '';
|
||||
let roomContacts = {};
|
||||
const associatedGroup =
|
||||
station in associations['chat'] &&
|
||||
'group-path' in associations.chat[station]
|
||||
? associations.chat[station]['group-path']
|
||||
: '';
|
||||
|
||||
if (associations.chat[station] && associatedGroup in contacts) {
|
||||
roomContacts = contacts[associatedGroup];
|
||||
}
|
||||
if (associations.chat[station] && associatedGroup in contacts) {
|
||||
roomContacts = contacts[associatedGroup];
|
||||
}
|
||||
|
||||
const association =
|
||||
station in associations['chat'] ? associations.chat[station] : {};
|
||||
const association =
|
||||
station in associations['chat'] ? associations.chat[station] : {};
|
||||
|
||||
const group = groups[association['group-path']] || groupBunts.group();
|
||||
const group = groups[association['group-path']] || groupBunts.group();
|
||||
|
||||
const popout = props.match.url.includes('/popout/');
|
||||
const popout = props.match.url.includes('/popout/');
|
||||
|
||||
return (
|
||||
<Skeleton
|
||||
associations={associations}
|
||||
invites={invites}
|
||||
sidebarHideOnMobile={true}
|
||||
popout={popout}
|
||||
sidebarShown={sidebarShown}
|
||||
sidebar={renderChannelSidebar(props, station)}
|
||||
>
|
||||
<ChatScreen
|
||||
chatSynced={chatSynced || {}}
|
||||
station={station}
|
||||
association={association}
|
||||
api={api}
|
||||
read={mailbox.config.read}
|
||||
length={mailbox.config.length}
|
||||
envelopes={mailbox.envelopes}
|
||||
inbox={inbox}
|
||||
contacts={roomContacts}
|
||||
group={group}
|
||||
pendingMessages={pendingMessages}
|
||||
s3={s3}
|
||||
return (
|
||||
<Skeleton
|
||||
associations={associations}
|
||||
invites={invites}
|
||||
sidebarHideOnMobile={true}
|
||||
popout={popout}
|
||||
sidebarShown={sidebarShown}
|
||||
chatInitialized={chatInitialized}
|
||||
hideAvatars={hideAvatars}
|
||||
hideNicknames={hideNicknames}
|
||||
{...props}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/~chat/(popout)?/settings/(~)?/:ship/:station+"
|
||||
render={(props) => {
|
||||
let station = `/${props.match.params.ship}/${props.match.params.station}`;
|
||||
const popout = props.match.url.includes('/popout/');
|
||||
sidebar={renderChannelSidebar(props, station)}
|
||||
>
|
||||
<ChatScreen
|
||||
chatSynced={chatSynced || {}}
|
||||
station={station}
|
||||
association={association}
|
||||
api={api}
|
||||
read={mailbox.config.read}
|
||||
length={mailbox.config.length}
|
||||
envelopes={mailbox.envelopes}
|
||||
inbox={inbox}
|
||||
contacts={roomContacts}
|
||||
group={group}
|
||||
pendingMessages={pendingMessages}
|
||||
s3={s3}
|
||||
popout={popout}
|
||||
sidebarShown={sidebarShown}
|
||||
chatInitialized={chatInitialized}
|
||||
hideAvatars={hideAvatars}
|
||||
hideNicknames={hideNicknames}
|
||||
{...props}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/~chat/(popout)?/settings/(~)?/:ship/:station+"
|
||||
render={(props) => {
|
||||
let station = `/${props.match.params.ship}/${props.match.params.station}`;
|
||||
const popout = props.match.url.includes('/popout/');
|
||||
|
||||
const association =
|
||||
station in associations['chat'] ? associations.chat[station] : {};
|
||||
const group = groups[association['group-path']] || groupBunts.group();
|
||||
const association =
|
||||
station in associations['chat'] ? associations.chat[station] : {};
|
||||
const group = groups[association['group-path']] || groupBunts.group();
|
||||
|
||||
return (
|
||||
<Skeleton
|
||||
associations={associations}
|
||||
invites={invites}
|
||||
sidebarHideOnMobile={true}
|
||||
popout={popout}
|
||||
sidebarShown={sidebarShown}
|
||||
sidebar={renderChannelSidebar(props, station)}
|
||||
>
|
||||
<SettingsScreen
|
||||
{...props}
|
||||
station={station}
|
||||
association={association}
|
||||
groups={groups || {}}
|
||||
group={group}
|
||||
contacts={contacts || {}}
|
||||
associations={associations.contacts}
|
||||
api={api}
|
||||
inbox={inbox}
|
||||
return (
|
||||
<Skeleton
|
||||
associations={associations}
|
||||
invites={invites}
|
||||
sidebarHideOnMobile={true}
|
||||
popout={popout}
|
||||
sidebarShown={sidebarShown}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Switch>
|
||||
sidebar={renderChannelSidebar(props, station)}
|
||||
>
|
||||
<SettingsScreen
|
||||
{...props}
|
||||
station={station}
|
||||
association={association}
|
||||
groups={groups || {}}
|
||||
group={group}
|
||||
contacts={contacts || {}}
|
||||
associations={associations.contacts}
|
||||
api={api}
|
||||
inbox={inbox}
|
||||
popout={popout}
|
||||
sidebarShown={sidebarShown}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -128,7 +128,10 @@ export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
|
||||
group={props.group}
|
||||
ship={props.match.params.ship}
|
||||
station={props.station}
|
||||
api={props.api} />
|
||||
api={props.api}
|
||||
hideNicknames={props.hideNicknames}
|
||||
hideAvatars={props.hideAvatars}
|
||||
/>
|
||||
<ChatInput
|
||||
api={props.api}
|
||||
numMsgs={lastMsgNum}
|
||||
|
@ -15,7 +15,7 @@ const MARKDOWN_CONFIG = {
|
||||
name: 'markdown',
|
||||
tokenTypeOverrides: {
|
||||
header: 'presentation',
|
||||
quote: 'presentation',
|
||||
quote: 'quote',
|
||||
list1: 'presentation',
|
||||
list2: 'presentation',
|
||||
list3: 'presentation',
|
||||
@ -121,16 +121,18 @@ export default class ChatEditor extends Component {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="chat fr h-100 flex bg-gray0-d lh-copy pl2 w-100 items-center"
|
||||
style={{ flexGrow: 1, maxHeight: '224px', width: 'calc(100% - 72px)' }}
|
||||
>
|
||||
className={
|
||||
'chat fr h-100 flex bg-gray0-d lh-copy pl2 w-100 items-center' +
|
||||
(props.inCodeMode ? ' code' : '')
|
||||
}
|
||||
style={{ flexGrow: 1, maxHeight: '224px', width: 'calc(100% - 72px)' }}>
|
||||
<CodeEditor
|
||||
value={props.message}
|
||||
options={options}
|
||||
onChange={(e, d, v) => this.messageChange(e, d, v)}
|
||||
editorDidMount={(editor) => {
|
||||
this.editor = editor;
|
||||
if (!(BROWSER_REGEX.test(navigator.userAgent))) {
|
||||
if (!BROWSER_REGEX.test(navigator.userAgent)) {
|
||||
editor.focus();
|
||||
}
|
||||
}}
|
||||
|
@ -11,14 +11,14 @@ export default class CodeContent extends Component {
|
||||
(Boolean(content.code.output) &&
|
||||
content.code.output.length && content.code.output.length > 0) ?
|
||||
(
|
||||
<pre className={`f7 clamp-attachment pa1 mt0 mb0 b--gray4 b--gray1-d bl br bb`}>
|
||||
<pre className={`code f7 clamp-attachment pa1 mt0 mb0`}>
|
||||
{content.code.output[0].join('\n')}
|
||||
</pre>
|
||||
) : null;
|
||||
|
||||
|
||||
return (
|
||||
<div className="mv2">
|
||||
<pre className={`f7 clamp-attachment pa1 mt0 mb0 bg-light-gray b--gray4 b--gray1-d ba`}>
|
||||
<pre className={`code f7 clamp-attachment pa1 mt0 mb0`}>
|
||||
{content.code.expression}
|
||||
</pre>
|
||||
{outputElement}
|
||||
|
@ -6,7 +6,6 @@ import urbitOb from 'urbit-ob';
|
||||
|
||||
const DISABLED_BLOCK_TOKENS = [
|
||||
'indentedCode',
|
||||
'blockquote',
|
||||
'atxHeading',
|
||||
'thematicBreak',
|
||||
'list',
|
||||
|
@ -256,9 +256,16 @@ blockquote {
|
||||
font-family: 'Inter';
|
||||
}
|
||||
|
||||
.chat .CodeMirror.cm-s-code.chat .cm-s-tlon * {
|
||||
font-family: 'Source Code Pro';
|
||||
code, pre.code {
|
||||
background-color: var(--light-gray);
|
||||
}
|
||||
|
||||
code, .code, .chat.code .react-codemirror2 .CodeMirror * {
|
||||
font-family: 'Source Code Pro';
|
||||
}
|
||||
|
||||
.chat .CodeMirror.cm-s-code.chat * {
|
||||
font-family: 'Source Code Pro';
|
||||
}
|
||||
|
||||
.chat .CodeMirror-selected { background:#BAE3FE !important; color: black; }
|
||||
@ -267,6 +274,7 @@ pre.CodeMirror-placeholder.CodeMirror-line-like { color: var(--gray); }
|
||||
.chat .cm-s-tlon span { font-family: "Inter"}
|
||||
.chat .cm-s-tlon span.cm-meta { color: var(--gray); }
|
||||
.chat .cm-s-tlon span.cm-number { color: var(--gray); }
|
||||
.chat .cm-s-tlon span.cm-quote { color: var(--gray); }
|
||||
.chat .cm-s-tlon span.cm-keyword { line-height: 1em; font-weight: bold; color: var(--gray); }
|
||||
.chat .cm-s-tlon span.cm-atom { font-weight: bold; color: var(--gray); }
|
||||
.chat .cm-s-tlon span.cm-def { color: black; }
|
||||
|
@ -1,6 +1,7 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Route, Link } from 'react-router-dom';
|
||||
import classnames from 'classnames';
|
||||
import Helmet from 'react-helmet';
|
||||
|
||||
import { Popout } from './components/lib/icons/popout';
|
||||
import { History } from './components/history';
|
||||
@ -28,8 +29,6 @@ export default class DojoApp extends Component {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
document.title = 'OS1 - Dojo';
|
||||
|
||||
const channel = new window.channel();
|
||||
this.api = new Api(this.props.ship, channel);
|
||||
this.store.api = this.api;
|
||||
@ -46,55 +45,61 @@ export default class DojoApp extends Component {
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
style={{ height: 'calc(100vh - 45px)' }}
|
||||
>
|
||||
<Route
|
||||
exact
|
||||
path="/~dojo/:popout?"
|
||||
render={(props) => {
|
||||
const popout = Boolean(props.match.params.popout);
|
||||
<>
|
||||
<Helmet>
|
||||
<title>OS1 - Dojo</title>
|
||||
</Helmet>
|
||||
<div
|
||||
className="bg-white bg-gray0-d"
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
<Route
|
||||
exact
|
||||
path="/~dojo/:popout?"
|
||||
render={(props) => {
|
||||
const popout = Boolean(props.match.params.popout);
|
||||
|
||||
const popoutClasses = classnames({
|
||||
'mh4-m mh4-l mh4-xl': !popout,
|
||||
'mb4-m mb4-l mb4-xl': !popout,
|
||||
'ba-m ba-l ba-xl': !popout
|
||||
});
|
||||
const popoutClasses = classnames({
|
||||
'mh4-m mh4-l mh4-xl': !popout,
|
||||
'mb4-m mb4-l mb4-xl': !popout,
|
||||
'ba-m ba-l ba-xl': !popout
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-100 h-100 flex-m flex-l flex-xl">
|
||||
<div
|
||||
className="db dn-m dn-l dn-xl inter bg-white bg-gray0-d dt w-100"
|
||||
style={{ height: 40 }}
|
||||
>
|
||||
return (
|
||||
<div className="w-100 h-100 flex-m flex-l flex-xl">
|
||||
<div
|
||||
className="db dn-m dn-l dn-xl inter bg-white bg-gray0-d dt w-100"
|
||||
style={{ height: 40 }}
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
'pa3 bg-white bg-gray0-d black white-d mono w-100 f8 relative' +
|
||||
' h-100-m40-s b--gray2 br1 flex-auto flex flex-column ' +
|
||||
popoutClasses
|
||||
}
|
||||
style={{
|
||||
lineHeight: '1.4',
|
||||
cursor: 'text'
|
||||
}}
|
||||
>
|
||||
<Popout popout={popout} />
|
||||
<History commandLog={this.state.txt} />
|
||||
<Input
|
||||
ship={this.props.ship}
|
||||
cursor={this.state.cursor}
|
||||
prompt={this.state.prompt}
|
||||
input={this.state.input}
|
||||
api={this.api}
|
||||
store={this.store}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
'pa3 bg-white bg-gray0-d black white-d mono w-100 f8 relative' +
|
||||
' h-100-m40-s b--gray2 br1 flex-auto flex flex-column ' +
|
||||
popoutClasses
|
||||
}
|
||||
style={{
|
||||
lineHeight: '1.4',
|
||||
cursor: 'text'
|
||||
}}
|
||||
>
|
||||
<Popout popout={popout} />
|
||||
<History commandLog={this.state.txt} />
|
||||
<Input
|
||||
ship={this.props.ship}
|
||||
cursor={this.state.cursor}
|
||||
prompt={this.state.prompt}
|
||||
input={this.state.input}
|
||||
api={this.api}
|
||||
store={this.store}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -14,10 +14,16 @@ export class Input extends Component {
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (
|
||||
!document.activeElement == document.body
|
||||
|| document.activeElement == this.inputRef.current
|
||||
) {
|
||||
this.inputRef.current.focus();
|
||||
this.inputRef.current.setSelectionRange(this.props.cursor, this.props.cursor);
|
||||
}
|
||||
}
|
||||
|
||||
keyPress = (e) => {
|
||||
keyPress(e) {
|
||||
if ((e.getModifierState('Control') || event.getModifierState('Meta'))
|
||||
&& e.key === 'v') {
|
||||
return;
|
||||
@ -33,75 +39,79 @@ export class Input extends Component {
|
||||
return;
|
||||
}
|
||||
|
||||
// submit on enter
|
||||
if (e.key === 'Enter') {
|
||||
this.setState({ awaiting: true, type: 'Sending to Dojo' });
|
||||
this.props.api.soto('ret').then(() => {
|
||||
this.setState({ awaiting: false });
|
||||
});
|
||||
} else if ((e.key === 'Backspace') && (this.props.cursor > 0)) {
|
||||
this.props.store.doEdit({ del: this.props.cursor - 1 });
|
||||
return this.props.store.setState({ cursor: this.props.cursor - 1 });
|
||||
} else if (e.key === 'Backspace') {
|
||||
return;
|
||||
} else if (e.key.startsWith('Arrow')) {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
if (this.props.cursor > 0) {
|
||||
this.props.store.setState({ cursor: this.props.cursor - 1 });
|
||||
}
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
if (this.props.cursor < this.props.input.length) {
|
||||
this.props.store.setState({ cursor: this.props.cursor + 1 });
|
||||
// submit on enter
|
||||
if (e.key === 'Enter') {
|
||||
this.setState({ awaiting: true, type: 'Sending to Dojo' });
|
||||
this.props.api.soto('ret').then(() => {
|
||||
this.setState({ awaiting: false });
|
||||
});
|
||||
} else if ((e.key === 'Backspace') && (this.props.cursor > 0)) {
|
||||
this.props.store.doEdit({ del: this.props.cursor - 1 });
|
||||
return this.props.store.setState({ cursor: this.props.cursor - 1 });
|
||||
} else if (e.key === 'Backspace') {
|
||||
return;
|
||||
} else if (e.key.startsWith('Arrow')) {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
if (this.props.cursor > 0) {
|
||||
this.props.store.setState({ cursor: this.props.cursor - 1 });
|
||||
}
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
if (this.props.cursor < this.props.input.length) {
|
||||
this.props.store.setState({ cursor: this.props.cursor + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tab completion
|
||||
else if (e.key === 'Tab') {
|
||||
this.setState({ awaiting: true, type: 'Getting suggestions' });
|
||||
this.props.api.soto({ tab: this.props.cursor }).then(() => {
|
||||
this.setState({ awaiting: false });
|
||||
});
|
||||
}
|
||||
|
||||
// capture and transmit most characters
|
||||
else {
|
||||
this.props.store.doEdit({ ins: { cha: e.key, at: this.props.cursor } });
|
||||
this.props.store.setState({ cursor: this.props.cursor + 1 });
|
||||
}
|
||||
}
|
||||
|
||||
// tab completion
|
||||
else if (e.key === 'Tab') {
|
||||
this.setState({ awaiting: true, type: 'Getting suggestions' });
|
||||
this.props.api.soto({ tab: this.props.cursor }).then(() => {
|
||||
this.setState({ awaiting: false });
|
||||
});
|
||||
}
|
||||
|
||||
// capture and transmit most characters
|
||||
else {
|
||||
this.props.store.doEdit({ ins: { cha: e.key, at: this.props.cursor } });
|
||||
this.props.store.setState({ cursor: this.props.cursor + 1 });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="flex flex-row flex-grow-1 relative">
|
||||
<div className="flex-shrink-0">{cite(this.props.ship)}:dojo
|
||||
render() {
|
||||
return (
|
||||
<div className="flex flex-row flex-grow-1 relative">
|
||||
<div className="flex-shrink-0"><span class="dn-s">{cite(this.props.ship)}:</span>dojo
|
||||
</div>
|
||||
<span id="prompt">
|
||||
{this.props.prompt}
|
||||
</span>
|
||||
<input
|
||||
autoFocus
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
tabindex="0"
|
||||
wrap="off"
|
||||
className="mono ml1 flex-auto dib w-100"
|
||||
id="dojo"
|
||||
cursor={this.props.cursor}
|
||||
onClick={e => this.props.store.setState({ cursor: e.target.selectionEnd })}
|
||||
onKeyDown={this.keyPress}
|
||||
onPaste={(e) => {
|
||||
const clipboardData = e.clipboardData || window.clipboardData;
|
||||
const paste = Array.from(clipboardData.getData('Text'));
|
||||
paste.reduce(async (previous, next) => {
|
||||
await previous;
|
||||
this.setState({ cursor: this.props.cursor + 1 });
|
||||
return this.props.store.doEdit({ ins: { cha: next, at: this.props.cursor } });
|
||||
}, Promise.resolve());
|
||||
e.preventDefault();
|
||||
}}
|
||||
ref={this.inputRef}
|
||||
defaultValue={this.props.input}
|
||||
/>
|
||||
<Spinner awaiting={this.state.awaiting} text={`${this.state.type}...`} classes="absolute right-0 bottom-0 inter pa ba pa2 b--gray1-d" />
|
||||
</div>
|
||||
<span id="prompt">
|
||||
{this.props.prompt}
|
||||
</span>
|
||||
<input
|
||||
autoCorrect="false"
|
||||
autoFocus={true}
|
||||
className="mono ml1 flex-auto dib w-100"
|
||||
id="dojo"
|
||||
cursor={this.props.cursor}
|
||||
onClick={e => this.props.store.setState({ cursor: e.target.selectionEnd })}
|
||||
onKeyDown={this.keyPress}
|
||||
onPaste={(e) => {
|
||||
const clipboardData = e.clipboardData || window.clipboardData;
|
||||
const paste = Array.from(clipboardData.getData('Text'));
|
||||
paste.reduce(async (previous, next) => {
|
||||
await previous;
|
||||
this.setState({ cursor: this.props.cursor + 1 });
|
||||
return this.props.store.doEdit({ ins: { cha: next, at: this.props.cursor } });
|
||||
}, Promise.resolve());
|
||||
e.preventDefault();
|
||||
}}
|
||||
ref={this.inputRef}
|
||||
defaultValue={this.props.input}
|
||||
/>
|
||||
<Spinner awaiting={this.state.awaiting} text={`${this.state.type}...`} classes="absolute right-0 bottom-0 inter pa ba pa2 b--gray1-d" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import Helmet from 'react-helmet';
|
||||
|
||||
import './css/custom.css';
|
||||
|
||||
@ -25,7 +26,6 @@ type GroupsAppProps = StoreState & {
|
||||
|
||||
export default class GroupsApp extends Component<GroupsAppProps, {}> {
|
||||
componentDidMount() {
|
||||
document.title = 'OS1 - Groups';
|
||||
// preload spinner asset
|
||||
new Image().src = '/~landscape/img/Spinner.png';
|
||||
|
||||
@ -55,6 +55,10 @@ export default class GroupsApp extends Component<GroupsAppProps, {}> {
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>OS1 - Groups</title>
|
||||
</Helmet>
|
||||
<Switch>
|
||||
<Route exact path="/~groups"
|
||||
render={(props) => {
|
||||
@ -357,6 +361,7 @@ export default class GroupsApp extends Component<GroupsAppProps, {}> {
|
||||
}}
|
||||
/>
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -19,6 +19,7 @@ export class GroupDetail extends Component {
|
||||
this.changeTitle = this.changeTitle.bind(this);
|
||||
this.changeDescription = this.changeDescription.bind(this);
|
||||
this.changePolicy = this.changePolicy.bind(this);
|
||||
this.getShortcode = this.getShortcode.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@ -185,6 +186,41 @@ export class GroupDetail extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
getShortcode(group, path) {
|
||||
if (group?.policy?.open) {
|
||||
return (
|
||||
<div className='mt4'>
|
||||
<p className='f9 mt4 lh-copy'>Share</p>
|
||||
<p className='f9 gray2 mb2'>
|
||||
Share a shortcode to join this group
|
||||
</p>
|
||||
<div
|
||||
className='relative w-100 flex'
|
||||
style={{ maxWidth: '29rem' }}>
|
||||
<input
|
||||
className={'f8 mono ba b--gray3 b--gray2-d bg-gray0-d ' +
|
||||
'white-d pa3 db w-100 flex-auto mr3 pr9'}
|
||||
disabled={true}
|
||||
value={path.substr(6)}
|
||||
/>
|
||||
<span
|
||||
className='lh-solid f8 pointer absolute pa3 inter'
|
||||
style={{ right: 12, top: 1 }}
|
||||
ref='copy'
|
||||
onClick={() => {
|
||||
writeText(path.substr(6));
|
||||
this.refs.copy.innerText = 'Copied';
|
||||
}}>
|
||||
Copy
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <div />;
|
||||
};
|
||||
}
|
||||
|
||||
renderSettings() {
|
||||
const { props } = this;
|
||||
|
||||
@ -201,33 +237,8 @@ export class GroupDetail extends Component {
|
||||
{ description: 'Janitor', tag: 'janitor', addDescription: 'Make Janitor' }
|
||||
];
|
||||
|
||||
let shortcode = <div />;
|
||||
const shortcode = this.getShortcode(group, props.path);
|
||||
|
||||
if (group?.policy?.open) {
|
||||
shortcode = <div className="mt4">
|
||||
<p className="f9 mt4 lh-copy">Share</p>
|
||||
<p className="f9 gray2 mb2">Share a shortcode to join this group</p>
|
||||
<div className="relative w-100 flex"
|
||||
style={{ maxWidth: '29rem' }}
|
||||
>
|
||||
<input
|
||||
className="f8 mono ba b--gray3 b--gray2-d bg-gray0-d white-d pa3 db w-100 flex-auto mr3"
|
||||
disabled={true}
|
||||
value={props.path.substr(6)}
|
||||
/>
|
||||
<span className="lh-solid f8 pointer absolute pa3 inter"
|
||||
style={{ right: 12, top: 1 }}
|
||||
ref="copy"
|
||||
onClick={() => {
|
||||
writeText(props.path.substr(6));
|
||||
this.refs.copy.innerText = 'Copied';
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</span>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
return (
|
||||
<div className="pa4 w-100 h-100 white-d overflow-y-auto">
|
||||
<div className="f8 f9-m f9-l f9-xl w-100">
|
||||
|
@ -103,7 +103,7 @@ export class NewScreen extends Component<NewScreenProps, NewScreenState> {
|
||||
},
|
||||
};
|
||||
|
||||
const { groupName } = this.state;
|
||||
const groupName = this.state.groupName.trim();
|
||||
this.setState(
|
||||
{
|
||||
invites: { ships: [], groups: [] },
|
||||
@ -115,7 +115,7 @@ export class NewScreen extends Component<NewScreenProps, NewScreenState> {
|
||||
.then(() => {
|
||||
this.setState({ awaiting: false });
|
||||
props.history.push(
|
||||
`/~groups/ship/~${window.ship}/${state.groupName}`
|
||||
`/~groups/ship/~${window.ship}/${groupName}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import Helmet from 'react-helmet';
|
||||
|
||||
import { Box } from '@tlon/indigo-react';
|
||||
|
||||
@ -10,7 +11,6 @@ import Welcome from './components/welcome';
|
||||
export default class LaunchApp extends React.Component {
|
||||
|
||||
componentDidMount() {
|
||||
document.title = 'OS1 - Home';
|
||||
// preload spinner asset
|
||||
new Image().src = '/~landscape/img/Spinner.png';
|
||||
|
||||
@ -20,32 +20,37 @@ export default class LaunchApp extends React.Component {
|
||||
const { props } = this;
|
||||
|
||||
return (
|
||||
<div className="h-100 flex flex-column h-100">
|
||||
<div className='v-mid ph2 dtc-m dtc-l dtc-xl flex justify-between flex-wrap' style={{ maxWidth: '40rem' }}>
|
||||
<Welcome firstTime={props.launch.firstTime} api={props.api} />
|
||||
<Tiles
|
||||
tiles={props.launch.tiles}
|
||||
tileOrdering={props.launch.tileOrdering}
|
||||
api={props.api}
|
||||
location={props.userLocation}
|
||||
weather={props.weather}
|
||||
/>
|
||||
</div>
|
||||
<Box
|
||||
position="absolute"
|
||||
fontFamily="mono"
|
||||
left="0"
|
||||
bottom="0"
|
||||
color="gray"
|
||||
bg="white"
|
||||
ml={3}
|
||||
mb={3}
|
||||
borderRadius={2}
|
||||
fontSize={0}
|
||||
p={2}>
|
||||
{props.baseHash}
|
||||
</Box>
|
||||
</div>
|
||||
<>
|
||||
<Helmet>
|
||||
<title>OS1 - Home</title>
|
||||
</Helmet>
|
||||
<div className="h-100 flex flex-column h-100">
|
||||
<div className='v-mid ph2 dtc-m dtc-l dtc-xl flex justify-between flex-wrap' style={{ maxWidth: '40rem' }}>
|
||||
<Welcome firstTime={props.launch.firstTime} api={props.api} />
|
||||
<Tiles
|
||||
tiles={props.launch.tiles}
|
||||
tileOrdering={props.launch.tileOrdering}
|
||||
api={props.api}
|
||||
location={props.userLocation}
|
||||
weather={props.weather}
|
||||
/>
|
||||
</div>
|
||||
<Box
|
||||
position="absolute"
|
||||
fontFamily="mono"
|
||||
left="0"
|
||||
bottom="0"
|
||||
color="gray"
|
||||
bg="white"
|
||||
ml={3}
|
||||
mb={3}
|
||||
borderRadius={2}
|
||||
fontSize={0}
|
||||
p={2}>
|
||||
{props.baseHash}
|
||||
</Box>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Switch, Route } from 'react-router-dom';
|
||||
import Helmet from 'react-helmet';
|
||||
|
||||
import _ from 'lodash';
|
||||
|
||||
@ -22,11 +23,9 @@ import {
|
||||
export class LinksApp extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.totalUnseen = 0;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
document.title = 'OS1 - Links';
|
||||
// preload spinner asset
|
||||
new Image().src = '/~landscape/img/Spinner.png';
|
||||
|
||||
@ -61,11 +60,6 @@ export class LinksApp extends Component {
|
||||
0
|
||||
);
|
||||
|
||||
if(totalUnseen !== this.totalUnseen) {
|
||||
document.title = totalUnseen !== 0 ? `(${totalUnseen}) OS1 - Links` : 'OS1 - Links';
|
||||
this.totalUnseen = totalUnseen;
|
||||
}
|
||||
|
||||
const invites = props.invites ?
|
||||
props.invites : {};
|
||||
|
||||
@ -75,167 +69,77 @@ export class LinksApp extends Component {
|
||||
const { api, sidebarShown } = this.props;
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Route exact path="/~link"
|
||||
render={ (props) => {
|
||||
return (
|
||||
<Skeleton
|
||||
active="collections"
|
||||
associations={associations}
|
||||
invites={invites}
|
||||
groups={groups}
|
||||
rightPanelHide={true}
|
||||
sidebarShown={sidebarShown}
|
||||
links={links}
|
||||
listening={listening}
|
||||
api={api}
|
||||
>
|
||||
<MessageScreen text="Select or create a collection to begin." />
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route exact path="/~link/new"
|
||||
render={(props) => {
|
||||
return (
|
||||
<Skeleton
|
||||
associations={associations}
|
||||
invites={invites}
|
||||
groups={groups}
|
||||
sidebarShown={sidebarShown}
|
||||
links={links}
|
||||
listening={listening}
|
||||
api={api}
|
||||
>
|
||||
<NewScreen
|
||||
associations={associations}
|
||||
groups={groups}
|
||||
contacts={contacts}
|
||||
api={api}
|
||||
{...props}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route exact path="/~link/join/:resource"
|
||||
render={ (props) => {
|
||||
const resourcePath = '/' + props.match.params.resource;
|
||||
|
||||
const autoJoin = () => {
|
||||
try {
|
||||
api.links.joinCollection(resourcePath);
|
||||
props.history.push(makeRoutePath(resourcePath));
|
||||
} catch(err) {
|
||||
setTimeout(autoJoin, 2000);
|
||||
}
|
||||
};
|
||||
autoJoin();
|
||||
}}
|
||||
/>
|
||||
<Route exact path="/~link/(popout)?/:resource/members"
|
||||
render={(props) => {
|
||||
const popout = props.match.url.includes('/popout/');
|
||||
const resourcePath = '/' + props.match.params.resource;
|
||||
const resource = associations.link[resourcePath] || { metadata: {} };
|
||||
|
||||
const contactDetails = contacts[resource['group-path']] || {};
|
||||
const group = groups[resource['group-path']] || new Set([]);
|
||||
const amOwner = amOwnerOfGroup(resource['group-path']);
|
||||
|
||||
return (
|
||||
<Skeleton
|
||||
associations={associations}
|
||||
invites={invites}
|
||||
groups={groups}
|
||||
selected={resourcePath}
|
||||
sidebarShown={sidebarShown}
|
||||
links={links}
|
||||
listening={listening}
|
||||
api={api}
|
||||
>
|
||||
<MemberScreen
|
||||
sidebarShown={sidebarShown}
|
||||
resource={resource}
|
||||
contacts={contacts}
|
||||
contactDetails={contactDetails}
|
||||
groupPath={resource['group-path']}
|
||||
group={group}
|
||||
groups={groups}
|
||||
associations={associations}
|
||||
amOwner={amOwner}
|
||||
resourcePath={resourcePath}
|
||||
popout={popout}
|
||||
api={api}
|
||||
{...props}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route exact path="/~link/(popout)?/:resource/settings"
|
||||
render={ (props) => {
|
||||
const popout = props.match.url.includes('/popout/');
|
||||
const resourcePath = '/' + props.match.params.resource;
|
||||
const resource = associations.link[resourcePath] || false;
|
||||
|
||||
const contactDetails = contacts[resource['group-path']] || {};
|
||||
const group = groups[resource['group-path']] || new Set([]);
|
||||
const amOwner = amOwnerOfGroup(resource['group-path']);
|
||||
|
||||
return (
|
||||
<Skeleton
|
||||
associations={associations}
|
||||
invites={invites}
|
||||
groups={groups}
|
||||
selected={resourcePath}
|
||||
sidebarShown={sidebarShown}
|
||||
popout={popout}
|
||||
links={links}
|
||||
listening={listening}
|
||||
api={api}
|
||||
>
|
||||
<SettingsScreen
|
||||
sidebarShown={sidebarShown}
|
||||
resource={resource}
|
||||
contacts={contacts}
|
||||
contactDetails={contactDetails}
|
||||
groupPath={resource['group-path']}
|
||||
group={group}
|
||||
amOwner={amOwner}
|
||||
resourcePath={resourcePath}
|
||||
popout={popout}
|
||||
api={api}
|
||||
{...props}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route exact path="/~link/(popout)?/:resource/:page?"
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{totalUnseen > 0 ? `(${totalUnseen}) ` : ''}OS1 - Links</title>
|
||||
</Helmet>
|
||||
<Switch>
|
||||
<Route exact path="/~link"
|
||||
render={ (props) => {
|
||||
return (
|
||||
<Skeleton
|
||||
active="collections"
|
||||
associations={associations}
|
||||
invites={invites}
|
||||
groups={groups}
|
||||
rightPanelHide={true}
|
||||
sidebarShown={sidebarShown}
|
||||
links={links}
|
||||
listening={listening}
|
||||
api={api}
|
||||
>
|
||||
<MessageScreen text="Select or create a collection to begin." />
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route exact path="/~link/new"
|
||||
render={(props) => {
|
||||
return (
|
||||
<Skeleton
|
||||
associations={associations}
|
||||
invites={invites}
|
||||
groups={groups}
|
||||
sidebarShown={sidebarShown}
|
||||
links={links}
|
||||
listening={listening}
|
||||
api={api}
|
||||
>
|
||||
<NewScreen
|
||||
associations={associations}
|
||||
groups={groups}
|
||||
contacts={contacts}
|
||||
api={api}
|
||||
{...props}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route exact path="/~link/join/:resource"
|
||||
render={ (props) => {
|
||||
const resourcePath = '/' + props.match.params.resource;
|
||||
|
||||
const autoJoin = () => {
|
||||
try {
|
||||
api.links.joinCollection(resourcePath);
|
||||
props.history.push(makeRoutePath(resourcePath));
|
||||
} catch(err) {
|
||||
setTimeout(autoJoin, 2000);
|
||||
}
|
||||
};
|
||||
autoJoin();
|
||||
}}
|
||||
/>
|
||||
<Route exact path="/~link/(popout)?/:resource/members"
|
||||
render={(props) => {
|
||||
const popout = props.match.url.includes('/popout/');
|
||||
const resourcePath = '/' + props.match.params.resource;
|
||||
const resource = associations.link[resourcePath] || { metadata: {} };
|
||||
|
||||
const amOwner = amOwnerOfGroup(resource['group-path']);
|
||||
|
||||
const contactDetails = contacts[resource['group-path']] || {};
|
||||
|
||||
const page = props.match.params.page || 0;
|
||||
|
||||
const popout = props.match.url.includes('/popout/');
|
||||
|
||||
const channelLinks = links[resourcePath]
|
||||
? links[resourcePath]
|
||||
: { local: {} };
|
||||
|
||||
const channelComments = comments[resourcePath]
|
||||
? comments[resourcePath]
|
||||
: {};
|
||||
|
||||
const channelSeen = seen[resourcePath]
|
||||
? seen[resourcePath]
|
||||
: {};
|
||||
const group = groups[resource['group-path']] || new Set([]);
|
||||
const amOwner = amOwnerOfGroup(resource['group-path']);
|
||||
|
||||
return (
|
||||
<Skeleton
|
||||
@ -244,55 +148,38 @@ export class LinksApp extends Component {
|
||||
groups={groups}
|
||||
selected={resourcePath}
|
||||
sidebarShown={sidebarShown}
|
||||
sidebarHideMobile={true}
|
||||
popout={popout}
|
||||
links={links}
|
||||
listening={listening}
|
||||
api={api}
|
||||
>
|
||||
<Links
|
||||
{...props}
|
||||
contacts={contactDetails}
|
||||
links={channelLinks}
|
||||
comments={channelComments}
|
||||
seen={channelSeen}
|
||||
page={page}
|
||||
resourcePath={resourcePath}
|
||||
resource={resource}
|
||||
amOwner={amOwner}
|
||||
popout={popout}
|
||||
sidebarShown={sidebarShown}
|
||||
api={api}
|
||||
<MemberScreen
|
||||
sidebarShown={sidebarShown}
|
||||
resource={resource}
|
||||
contacts={contacts}
|
||||
contactDetails={contactDetails}
|
||||
groupPath={resource['group-path']}
|
||||
group={group}
|
||||
groups={groups}
|
||||
associations={associations}
|
||||
amOwner={amOwner}
|
||||
resourcePath={resourcePath}
|
||||
popout={popout}
|
||||
api={api}
|
||||
{...props}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route exact path="/~link/(popout)?/:resource/:page/:index/:encodedUrl/:commentpage?"
|
||||
<Route exact path="/~link/(popout)?/:resource/settings"
|
||||
render={ (props) => {
|
||||
const resourcePath = '/' + props.match.params.resource;
|
||||
const resource = associations.link[resourcePath] || { metadata: {} };
|
||||
|
||||
const amOwner = amOwnerOfGroup(resource['group-path']);
|
||||
|
||||
const popout = props.match.url.includes('/popout/');
|
||||
const resourcePath = '/' + props.match.params.resource;
|
||||
const resource = associations.link[resourcePath] || false;
|
||||
|
||||
const contactDetails = contacts[resource['group-path']] || {};
|
||||
|
||||
const index = props.match.params.index || 0;
|
||||
const page = props.match.params.page || 0;
|
||||
const url = base64urlDecode(props.match.params.encodedUrl);
|
||||
|
||||
const data = links[resourcePath]
|
||||
? links[resourcePath][page]
|
||||
? links[resourcePath][page][index]
|
||||
: {}
|
||||
: {};
|
||||
const coms = !comments[resourcePath]
|
||||
? undefined
|
||||
: comments[resourcePath][url];
|
||||
|
||||
const commentPage = props.match.params.commentpage || 0;
|
||||
const group = groups[resource['group-path']] || new Set([]);
|
||||
const amOwner = amOwnerOfGroup(resource['group-path']);
|
||||
|
||||
return (
|
||||
<Skeleton
|
||||
@ -301,34 +188,146 @@ export class LinksApp extends Component {
|
||||
groups={groups}
|
||||
selected={resourcePath}
|
||||
sidebarShown={sidebarShown}
|
||||
sidebarHideMobile={true}
|
||||
popout={popout}
|
||||
links={links}
|
||||
listening={listening}
|
||||
api={api}
|
||||
>
|
||||
<LinkDetail
|
||||
{...props}
|
||||
resource={resource}
|
||||
page={page}
|
||||
url={url}
|
||||
linkIndex={index}
|
||||
contacts={contactDetails}
|
||||
resourcePath={resourcePath}
|
||||
groupPath={resource['group-path']}
|
||||
amOwner={amOwner}
|
||||
popout={popout}
|
||||
sidebarShown={sidebarShown}
|
||||
data={data}
|
||||
comments={coms}
|
||||
commentPage={commentPage}
|
||||
api={api}
|
||||
<SettingsScreen
|
||||
sidebarShown={sidebarShown}
|
||||
resource={resource}
|
||||
contacts={contacts}
|
||||
contactDetails={contactDetails}
|
||||
groupPath={resource['group-path']}
|
||||
group={group}
|
||||
amOwner={amOwner}
|
||||
resourcePath={resourcePath}
|
||||
popout={popout}
|
||||
api={api}
|
||||
{...props}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Switch>
|
||||
<Route exact path="/~link/(popout)?/:resource/:page?"
|
||||
render={ (props) => {
|
||||
const resourcePath = '/' + props.match.params.resource;
|
||||
const resource = associations.link[resourcePath] || { metadata: {} };
|
||||
|
||||
const amOwner = amOwnerOfGroup(resource['group-path']);
|
||||
|
||||
const contactDetails = contacts[resource['group-path']] || {};
|
||||
|
||||
const page = props.match.params.page || 0;
|
||||
|
||||
const popout = props.match.url.includes('/popout/');
|
||||
|
||||
const channelLinks = links[resourcePath]
|
||||
? links[resourcePath]
|
||||
: { local: {} };
|
||||
|
||||
const channelComments = comments[resourcePath]
|
||||
? comments[resourcePath]
|
||||
: {};
|
||||
|
||||
const channelSeen = seen[resourcePath]
|
||||
? seen[resourcePath]
|
||||
: {};
|
||||
|
||||
return (
|
||||
<Skeleton
|
||||
associations={associations}
|
||||
invites={invites}
|
||||
groups={groups}
|
||||
selected={resourcePath}
|
||||
sidebarShown={sidebarShown}
|
||||
sidebarHideMobile={true}
|
||||
popout={popout}
|
||||
links={links}
|
||||
listening={listening}
|
||||
api={api}
|
||||
>
|
||||
<Links
|
||||
{...props}
|
||||
contacts={contactDetails}
|
||||
links={channelLinks}
|
||||
comments={channelComments}
|
||||
seen={channelSeen}
|
||||
page={page}
|
||||
resourcePath={resourcePath}
|
||||
resource={resource}
|
||||
amOwner={amOwner}
|
||||
popout={popout}
|
||||
sidebarShown={sidebarShown}
|
||||
api={api}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route exact path="/~link/(popout)?/:resource/:page/:index/:encodedUrl/:commentpage?"
|
||||
render={ (props) => {
|
||||
const resourcePath = '/' + props.match.params.resource;
|
||||
const resource = associations.link[resourcePath] || { metadata: {} };
|
||||
|
||||
const amOwner = amOwnerOfGroup(resource['group-path']);
|
||||
|
||||
const popout = props.match.url.includes('/popout/');
|
||||
|
||||
const contactDetails = contacts[resource['group-path']] || {};
|
||||
|
||||
const index = props.match.params.index || 0;
|
||||
const page = props.match.params.page || 0;
|
||||
const url = base64urlDecode(props.match.params.encodedUrl);
|
||||
|
||||
const data = links[resourcePath]
|
||||
? links[resourcePath][page]
|
||||
? links[resourcePath][page][index]
|
||||
: {}
|
||||
: {};
|
||||
const coms = !comments[resourcePath]
|
||||
? undefined
|
||||
: comments[resourcePath][url];
|
||||
|
||||
const commentPage = props.match.params.commentpage || 0;
|
||||
|
||||
return (
|
||||
<Skeleton
|
||||
associations={associations}
|
||||
invites={invites}
|
||||
groups={groups}
|
||||
selected={resourcePath}
|
||||
sidebarShown={sidebarShown}
|
||||
sidebarHideMobile={true}
|
||||
popout={popout}
|
||||
links={links}
|
||||
listening={listening}
|
||||
api={api}
|
||||
>
|
||||
<LinkDetail
|
||||
{...props}
|
||||
resource={resource}
|
||||
page={page}
|
||||
url={url}
|
||||
linkIndex={index}
|
||||
contacts={contactDetails}
|
||||
resourcePath={resourcePath}
|
||||
groupPath={resource['group-path']}
|
||||
amOwner={amOwner}
|
||||
popout={popout}
|
||||
sidebarShown={sidebarShown}
|
||||
data={data}
|
||||
comments={coms}
|
||||
commentPage={commentPage}
|
||||
api={api}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -127,7 +127,7 @@ export class LinkSubmit extends Component {
|
||||
</button>
|
||||
<Spinner awaiting={this.state.disabled} classes="mt3 absolute right-0" text="Posting to collection..." />
|
||||
</div>
|
||||
);
|
||||
) ;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,307 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import _ from 'lodash';
|
||||
|
||||
import './css/custom.css';
|
||||
|
||||
import { Skeleton } from './components/skeleton';
|
||||
import { NewScreen } from './components/lib/new';
|
||||
import { JoinScreen } from './components/lib/join';
|
||||
import { Notebook } from './components/lib/notebook';
|
||||
import { Note } from './components/lib/note';
|
||||
import { NewPost } from './components/lib/new-post';
|
||||
import { EditPost } from './components/lib/edit-post';
|
||||
|
||||
export default class PublishApp extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.unreadTotal = 0;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
document.title = 'OS1 - Publish';
|
||||
// preload spinner asset
|
||||
new Image().src = '/~landscape/img/Spinner.png';
|
||||
|
||||
this.props.subscription.startApp('publish');
|
||||
|
||||
this.props.api.publish.fetchNotebooks();
|
||||
|
||||
if (!this.props.sidebarShown) {
|
||||
this.props.api.local.sidebarToggle();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.subscription.stopApp('publish');
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props } = this;
|
||||
|
||||
const contacts = props.contacts ? props.contacts : {};
|
||||
const associations = props.associations ? props.associations : { contacts: {} };
|
||||
|
||||
const notebooks = props.notebooks ? props.notebooks : {};
|
||||
|
||||
const unreadTotal = _.chain(notebooks)
|
||||
.values()
|
||||
.map(_.values)
|
||||
.flatten() // flatten into array of notebooks
|
||||
.map('num-unread')
|
||||
.reduce((acc, count) => acc + count, 0)
|
||||
.value();
|
||||
|
||||
if (this.unreadTotal !== unreadTotal) {
|
||||
document.title = unreadTotal > 0 ? `(${unreadTotal}) OS1 - Publish` : 'OS1 - Publish';
|
||||
this.unreadTotal = unreadTotal;
|
||||
}
|
||||
|
||||
const { api, groups, sidebarShown, invites } = props;
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Route exact path="/~publish"
|
||||
render={(props) => {
|
||||
return (
|
||||
<Skeleton
|
||||
popout={false}
|
||||
active={'sidebar'}
|
||||
rightPanelHide={true}
|
||||
sidebarShown={true}
|
||||
invites={invites}
|
||||
notebooks={notebooks}
|
||||
associations={associations}
|
||||
contacts={contacts}
|
||||
api={api}
|
||||
>
|
||||
<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='f9 pt3 gray2 w-100 h-100 dtc v-mid tc'>
|
||||
Select or create a notebook to begin.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path='/~publish/new'
|
||||
render={props => {
|
||||
return (
|
||||
<Skeleton
|
||||
popout={false}
|
||||
active={'rightPanel'}
|
||||
rightPanelHide={false}
|
||||
sidebarShown={sidebarShown}
|
||||
invites={invites}
|
||||
notebooks={notebooks}
|
||||
associations={associations}
|
||||
contacts={contacts}
|
||||
api={api}
|
||||
>
|
||||
<NewScreen
|
||||
associations={associations.contacts}
|
||||
notebooks={notebooks}
|
||||
groups={groups}
|
||||
contacts={contacts}
|
||||
api={api}
|
||||
{...props}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path='/~publish/join/:ship?/:notebook?'
|
||||
render={props => {
|
||||
const ship = props.match.params.ship || '';
|
||||
const notebook = props.match.params.notebook || '';
|
||||
return (
|
||||
<Skeleton
|
||||
popout={false}
|
||||
active={'rightPanel'}
|
||||
rightPanelHide={false}
|
||||
sidebarShown={sidebarShown}
|
||||
invites={invites}
|
||||
notebooks={notebooks}
|
||||
associations={associations}
|
||||
contacts={contacts}
|
||||
api={api}
|
||||
>
|
||||
<JoinScreen
|
||||
notebooks={notebooks}
|
||||
ship={ship}
|
||||
notebook={notebook}
|
||||
api={api}
|
||||
{...props}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path='/~publish/:popout?/notebook/:ship/:notebook/:view?'
|
||||
render={props => {
|
||||
const view = props.match.params.view
|
||||
? props.match.params.view
|
||||
: 'posts';
|
||||
|
||||
const popout = Boolean(props.match.params.popout) || false;
|
||||
|
||||
const ship = props.match.params.ship || '';
|
||||
const notebook = props.match.params.notebook || '';
|
||||
|
||||
const path = `${ship}/${notebook}`;
|
||||
|
||||
const bookGroupPath =
|
||||
notebooks?.[ship]?.[notebook]?.['subscribers-group-path'];
|
||||
|
||||
const notebookContacts =
|
||||
bookGroupPath in contacts ? contacts[bookGroupPath] : {};
|
||||
|
||||
if (view === 'new') {
|
||||
return (
|
||||
<Skeleton
|
||||
popout={popout}
|
||||
active={'rightPanel'}
|
||||
rightPanelHide={false}
|
||||
sidebarShown={sidebarShown}
|
||||
invites={invites}
|
||||
notebooks={notebooks}
|
||||
associations={associations}
|
||||
contacts={contacts}
|
||||
path={path}
|
||||
api={api}
|
||||
>
|
||||
<NewPost
|
||||
notebooks={notebooks}
|
||||
ship={ship}
|
||||
book={notebook}
|
||||
sidebarShown={sidebarShown}
|
||||
popout={popout}
|
||||
api={api}
|
||||
{...props}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Skeleton
|
||||
popout={popout}
|
||||
active={'rightPanel'}
|
||||
rightPanelHide={false}
|
||||
sidebarShown={sidebarShown}
|
||||
invites={invites}
|
||||
notebooks={notebooks}
|
||||
associations={associations}
|
||||
contacts={contacts}
|
||||
path={path}
|
||||
api={api}
|
||||
>
|
||||
<Notebook
|
||||
notebooks={notebooks}
|
||||
view={view}
|
||||
ship={ship}
|
||||
book={notebook}
|
||||
groups={groups}
|
||||
contacts={contacts}
|
||||
notebookContacts={notebookContacts}
|
||||
associations={associations.contacts}
|
||||
sidebarShown={sidebarShown}
|
||||
popout={popout}
|
||||
api={api}
|
||||
{...props}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path='/~publish/:popout?/note/:ship/:notebook/:note/:edit?'
|
||||
render={props => {
|
||||
const ship = props.match.params.ship || '';
|
||||
const notebook = props.match.params.notebook || '';
|
||||
const path = `${ship}/${notebook}`;
|
||||
const note = props.match.params.note || '';
|
||||
|
||||
const popout = Boolean(props.match.params.popout) || false;
|
||||
|
||||
const bookGroupPath =
|
||||
notebooks?.[ship]?.[notebook]?.['subscribers-group-path'];
|
||||
const notebookContacts = (bookGroupPath in contacts)
|
||||
? contacts[bookGroupPath] : {};
|
||||
|
||||
const edit = Boolean(props.match.params.edit) || false;
|
||||
|
||||
if (edit) {
|
||||
return (
|
||||
<Skeleton
|
||||
popout={popout}
|
||||
active={'rightPanel'}
|
||||
rightPanelHide={false}
|
||||
sidebarShown={sidebarShown}
|
||||
invites={invites}
|
||||
notebooks={notebooks}
|
||||
associations={associations}
|
||||
contacts={contacts}
|
||||
path={path}
|
||||
api={api}
|
||||
>
|
||||
<EditPost
|
||||
notebooks={notebooks}
|
||||
book={notebook}
|
||||
note={note}
|
||||
ship={ship}
|
||||
sidebarShown={sidebarShown}
|
||||
popout={popout}
|
||||
api={api}
|
||||
{...props}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Skeleton
|
||||
popout={popout}
|
||||
active={'rightPanel'}
|
||||
rightPanelHide={false}
|
||||
sidebarShown={sidebarShown}
|
||||
invites={invites}
|
||||
notebooks={notebooks}
|
||||
associations={associations}
|
||||
contacts={contacts}
|
||||
path={path}
|
||||
api={api}
|
||||
>
|
||||
<Note
|
||||
notebooks={notebooks}
|
||||
book={notebook}
|
||||
groups={groups}
|
||||
contacts={notebookContacts}
|
||||
ship={ship}
|
||||
note={note}
|
||||
sidebarShown={sidebarShown}
|
||||
popout={popout}
|
||||
api={api}
|
||||
{...props}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
}
|
165
pkg/interface/src/views/apps/publish/app.tsx
Normal file
165
pkg/interface/src/views/apps/publish/app.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
import React, { useRef, useEffect } from "react";
|
||||
import { Route, Switch, useLocation, withRouter } from "react-router-dom";
|
||||
import { Center, Text } from "@tlon/indigo-react";
|
||||
import _ from "lodash";
|
||||
import Helmet from 'react-helmet';
|
||||
|
||||
import "./css/custom.css";
|
||||
|
||||
import { Skeleton } from "./components/skeleton";
|
||||
import { NewScreen } from "./components/lib/new";
|
||||
import { JoinScreen } from "./components/lib/Join";
|
||||
import { StoreState } from "~/logic/store/type";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import GlobalSubscription from "~/logic/subscription/global";
|
||||
import {NotebookRoutes} from "./components/lib/NotebookRoutes";
|
||||
|
||||
type PublishAppProps = StoreState & {
|
||||
api: GlobalApi;
|
||||
ship: string;
|
||||
subscription: GlobalSubscription;
|
||||
};
|
||||
|
||||
const RouterSkeleton = withRouter(Skeleton);
|
||||
|
||||
export default function PublishApp(props: PublishAppProps) {
|
||||
const unreadTotal = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
document.title =
|
||||
unreadTotal.current > 0
|
||||
? `(${unreadTotal.current}) OS1 - Publish`
|
||||
: "OS1 - Publish";
|
||||
}, [unreadTotal.current]);
|
||||
|
||||
useEffect(() => {
|
||||
props.subscription.startApp("publish");
|
||||
props.api.publish.fetchNotebooks();
|
||||
|
||||
return () => {
|
||||
props.subscription.stopApp("publish");
|
||||
};
|
||||
}, []);
|
||||
|
||||
const contacts = props.contacts ? props.contacts : {};
|
||||
|
||||
const notebooks = props.notebooks ? props.notebooks : {};
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
unreadTotal.current = _.chain(notebooks)
|
||||
.values()
|
||||
.map(_.values)
|
||||
.flatten() // flatten into array of notebooks
|
||||
.map("num-unread")
|
||||
.reduce((acc, count) => acc + count, 0)
|
||||
.value();
|
||||
|
||||
const { api, groups, sidebarShown, invites, associations } = props;
|
||||
|
||||
const active = location.pathname.endsWith("/~publish")
|
||||
? "sidebar"
|
||||
: "rightPanel";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{unreadTotal > 0 ? `(${unreadTotal}) ` : ''}OS1 - Publish</title>
|
||||
</Helmet>
|
||||
<Route
|
||||
path={[
|
||||
"/~publish/notebook/:ship/:notebook/note/:noteId",
|
||||
"/~publish/notebook/:ship/:notebook/*",
|
||||
"/~publish/notebook/:ship/:notebook",
|
||||
"/~publish",
|
||||
]}
|
||||
>
|
||||
<RouterSkeleton
|
||||
popout={location.pathname.includes("popout/")}
|
||||
active={active}
|
||||
sidebarShown={sidebarShown}
|
||||
invites={invites}
|
||||
notebooks={notebooks}
|
||||
associations={associations}
|
||||
contacts={contacts}
|
||||
api={api}
|
||||
>
|
||||
<Switch>
|
||||
<Route exact path="/~publish">
|
||||
<Center width="100%" height="100%">
|
||||
<Text color="lightGray">
|
||||
Select or create a notebook to begin.
|
||||
</Text>
|
||||
</Center>
|
||||
</Route>
|
||||
<Route
|
||||
exact
|
||||
path="/~publish/new"
|
||||
render={(props) => {
|
||||
return (
|
||||
<NewScreen
|
||||
associations={associations}
|
||||
api={api}
|
||||
notebooks={notebooks}
|
||||
groups={groups}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/~publish/join/:ship/:notebook"
|
||||
render={(props) => {
|
||||
const ship = props.match.params.ship || "";
|
||||
const notebook = props.match.params.notebook || "";
|
||||
return (
|
||||
<JoinScreen
|
||||
notebooks={notebooks}
|
||||
ship={ship}
|
||||
book={notebook}
|
||||
api={api}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
path="/~publish/notebook/:ship/:notebook"
|
||||
render={(props) => {
|
||||
const view = props.match.params.view
|
||||
? props.match.params.view
|
||||
: "posts";
|
||||
|
||||
|
||||
const ship = props.match.params.ship || "";
|
||||
const book = props.match.params.notebook || "";
|
||||
|
||||
const bookGroupPath =
|
||||
notebooks?.[ship]?.[book]?.["subscribers-group-path"];
|
||||
|
||||
const notebookContacts =
|
||||
bookGroupPath in contacts ? contacts[bookGroupPath] : {};
|
||||
|
||||
const notebook = notebooks?.[ship]?.[book];
|
||||
return (
|
||||
<NotebookRoutes
|
||||
notebook={notebook}
|
||||
ship={ship}
|
||||
book={book}
|
||||
groups={groups}
|
||||
contacts={contacts}
|
||||
notebookContacts={notebookContacts}
|
||||
sidebarShown={sidebarShown}
|
||||
api={api}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Switch>
|
||||
</RouterSkeleton>
|
||||
</Route>
|
||||
</>
|
||||
);
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
import React, {ReactNode} from "react";
|
||||
import moment from "moment";
|
||||
import { Sigil } from "~/logic/lib/sigil"
|
||||
import { uxToHex, cite } from "~/logic/lib/util";
|
||||
import { Contacts } from "~/types/contact-update";
|
||||
import { Row, Box } from "@tlon/indigo-react";
|
||||
|
||||
interface AuthorProps {
|
||||
contacts: Contacts;
|
||||
ship: string;
|
||||
date: number;
|
||||
showImage?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Author(props: AuthorProps) {
|
||||
const { contacts, ship, date, showImage } = props;
|
||||
const noSig = ship.slice(1);
|
||||
const contact = noSig in contacts ? contacts[noSig] : null;
|
||||
const color = contact?.color ? `#${uxToHex(contact?.color)}` : "#000000";
|
||||
const name = contact?.nickname || cite(ship);
|
||||
|
||||
const dateFmt = moment(date).fromNow();
|
||||
return (
|
||||
<Row alignItems="center" width="auto">
|
||||
{showImage && (
|
||||
<Box>
|
||||
{contact?.avatar ? (
|
||||
<img src={contact?.avatar} height={24} width={24} className="dib" />
|
||||
) : (
|
||||
<Sigil
|
||||
ship={ship}
|
||||
size={24}
|
||||
color={color}
|
||||
classes={contact?.color ? '' : "mix-blend-diff"}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
ml={showImage ? 2 : 0}
|
||||
color="gray"
|
||||
fontFamily={contact?.nickname ? "sans" : "mono"}
|
||||
>
|
||||
{name}
|
||||
</Box>
|
||||
<Box ml={2} color="gray">
|
||||
{dateFmt}
|
||||
</Box>
|
||||
{props.children}
|
||||
</Row>
|
||||
);
|
||||
}
|
@ -0,0 +1,57 @@
|
||||
import React from "react";
|
||||
import * as Yup from "yup";
|
||||
import { Formik, FormikHelpers, Form, useFormikContext } from "formik";
|
||||
import { AsyncButton } from "../../../../components/AsyncButton";
|
||||
import { TextArea } from "@tlon/indigo-react";
|
||||
|
||||
interface FormSchema {
|
||||
comment: string;
|
||||
}
|
||||
|
||||
const formSchema = Yup.object({
|
||||
comment: Yup.string().required("Comment can't be empty"),
|
||||
});
|
||||
|
||||
interface CommentInputProps {
|
||||
onSubmit: (
|
||||
values: FormSchema,
|
||||
actions: FormikHelpers<FormSchema>
|
||||
) => Promise<void>;
|
||||
initial?: string;
|
||||
loadingText?: string;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
const SubmitTextArea = (props) => {
|
||||
const { submitForm } = useFormikContext<FormSchema>();
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.getModifierState("Control") || e.metaKey) && e.key === "Enter") {
|
||||
submitForm();
|
||||
}
|
||||
};
|
||||
return <TextArea onKeyDown={onKeyDown} {...props} />;
|
||||
};
|
||||
|
||||
export default function CommentInput(props: CommentInputProps) {
|
||||
const initialValues: FormSchema = { comment: props.initial || "" };
|
||||
const label = props.label || "Add Comment";
|
||||
const loading = props.loadingText || "Commenting...";
|
||||
|
||||
return (
|
||||
<Formik
|
||||
validationSchema={formSchema}
|
||||
onSubmit={props.onSubmit}
|
||||
initialValues={initialValues}
|
||||
>
|
||||
<Form>
|
||||
<SubmitTextArea
|
||||
id="comment"
|
||||
placeholder={props.placeholder || ""}
|
||||
/>
|
||||
<AsyncButton loadingText={loading} border type="submit">
|
||||
{label}
|
||||
</AsyncButton>
|
||||
</Form>
|
||||
</Formik>
|
||||
);
|
||||
}
|
@ -0,0 +1,98 @@
|
||||
import React, { useState } from "react";
|
||||
import moment from "moment";
|
||||
import { Sigil } from "~/logic/lib/sigil";
|
||||
import CommentInput from "./CommentInput";
|
||||
import { uxToHex, cite } from "~/logic/lib/util";
|
||||
import { Comment, NoteId } from "~/types/publish-update";
|
||||
import { Contacts } from "~/types/contact-update";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { Button, Box, Row, Text } from "@tlon/indigo-react";
|
||||
import styled from "styled-components";
|
||||
import { Author } from "./Author";
|
||||
|
||||
const ClickBox = styled(Box)`
|
||||
cursor: pointer;
|
||||
padding-left: ${p => p.theme.space[2]}px;
|
||||
`;
|
||||
|
||||
interface CommentItemProps {
|
||||
pending?: boolean;
|
||||
comment: Comment;
|
||||
contacts: Contacts;
|
||||
book: string;
|
||||
ship: string;
|
||||
api: GlobalApi;
|
||||
note: NoteId;
|
||||
}
|
||||
|
||||
export function CommentItem(props: CommentItemProps) {
|
||||
const { ship, contacts, book, note, api } = props;
|
||||
const [editing, setEditing] = useState<boolean>(false);
|
||||
const commentPath = Object.keys(props.comment)[0];
|
||||
const commentData = props.comment[commentPath];
|
||||
const content = commentData.content.split("\n").map((line, i) => {
|
||||
return (
|
||||
<Text className="mb2" key={i}>
|
||||
{line}
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
|
||||
const disabled = props.pending || window.ship !== commentData.author.slice(1);
|
||||
|
||||
const onUpdate = async ({ comment }) => {
|
||||
await api.publish.updateComment(
|
||||
ship.slice(1),
|
||||
book,
|
||||
note,
|
||||
commentPath,
|
||||
comment
|
||||
);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
await api.publish.deleteComment(ship.slice(1), book, note, commentPath);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box mb={4} opacity={props.pending ? "60%" : "100%"}>
|
||||
<Row bg="white" my={3}>
|
||||
<Author
|
||||
showImage
|
||||
contacts={contacts}
|
||||
ship={commentData?.author}
|
||||
date={commentData["date-created"]}
|
||||
>
|
||||
{!disabled && !editing && (
|
||||
<>
|
||||
<ClickBox color="green" onClick={() => setEditing(true)}>
|
||||
Edit
|
||||
</ClickBox>
|
||||
<ClickBox color="red" onClick={onDelete}>
|
||||
Delete
|
||||
</ClickBox>
|
||||
</>
|
||||
)}
|
||||
{editing && (
|
||||
<ClickBox onClick={() => setEditing(false)} color="red">
|
||||
Cancel
|
||||
</ClickBox>
|
||||
)}
|
||||
</Author>
|
||||
</Row>
|
||||
<Box mb={2}>
|
||||
{!editing && content}
|
||||
{editing && (
|
||||
<CommentInput
|
||||
onSubmit={onUpdate}
|
||||
initial={commentData.content}
|
||||
label="Update"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommentItem;
|
@ -0,0 +1,95 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Col } from "@tlon/indigo-react";
|
||||
import { CommentItem } from "./CommentItem";
|
||||
import CommentInput from "./CommentInput";
|
||||
import { dateToDa } from "~/logic/lib/util";
|
||||
import { Comment, Note, NoteId } from "~/types/publish-update";
|
||||
import { Contacts } from "~/types/contact-update";
|
||||
import _ from "lodash";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { FormikHelpers } from "formik";
|
||||
|
||||
interface CommentsProps {
|
||||
comments: Comment[];
|
||||
book: string;
|
||||
noteId: NoteId;
|
||||
note: Note;
|
||||
ship: string;
|
||||
contacts: Contacts;
|
||||
api: GlobalApi;
|
||||
numComments: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export function Comments(props: CommentsProps) {
|
||||
const { comments, ship, book, note, api, noteId, numComments } = props;
|
||||
const [pending, setPending] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
_.forEach(comments, (comment: Comment) => {
|
||||
const { content } = comment[Object.keys(comment)[0]];
|
||||
setPending((p) => p.filter((p) => p !== content));
|
||||
});
|
||||
}, [numComments]);
|
||||
|
||||
const onSubmit = async (
|
||||
{ comment },
|
||||
actions: FormikHelpers<{ comment: string }>
|
||||
) => {
|
||||
setPending((p) => [...p, comment]);
|
||||
const action = {
|
||||
"new-comment": {
|
||||
who: ship.slice(1),
|
||||
book: book,
|
||||
note: noteId,
|
||||
body: comment,
|
||||
},
|
||||
};
|
||||
try {
|
||||
await api.publish.publishAction(action);
|
||||
actions.resetForm();
|
||||
actions.setStatus({ success: null });
|
||||
} catch (e) {
|
||||
actions.setStatus({ error: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Col>
|
||||
<CommentInput onSubmit={onSubmit} />
|
||||
{Array.from(pending).map((com, i) => {
|
||||
const da = dateToDa(new Date());
|
||||
const ship = `~${window.ship}`;
|
||||
const comment = {
|
||||
[da]: {
|
||||
author: ship,
|
||||
content: com,
|
||||
"date-created": Math.round(new Date().getTime()),
|
||||
},
|
||||
} as Comment;
|
||||
return (
|
||||
<CommentItem
|
||||
comment={comment}
|
||||
key={i}
|
||||
contacts={props.contacts}
|
||||
ship={ship}
|
||||
pending={true}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{props.comments.map((com, i) => (
|
||||
<CommentItem
|
||||
comment={com}
|
||||
key={i}
|
||||
contacts={props.contacts}
|
||||
api={api}
|
||||
book={book}
|
||||
ship={ship}
|
||||
note={note["note-id"]}
|
||||
/>
|
||||
))}
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
export default Comments;
|
@ -0,0 +1,45 @@
|
||||
import React, { Component } from "react";
|
||||
import { PostFormSchema, PostForm } from "./NoteForm";
|
||||
import { Note } from "../../../../types/publish-update";
|
||||
import { FormikHelpers } from "formik";
|
||||
import GlobalApi from "../../../../api/global";
|
||||
import { RouteComponentProps } from "react-router-dom";
|
||||
interface EditPostProps {
|
||||
ship: string;
|
||||
noteId: string;
|
||||
note: Note;
|
||||
api: GlobalApi;
|
||||
book: string;
|
||||
}
|
||||
|
||||
export function EditPost(props: EditPostProps & RouteComponentProps) {
|
||||
const { note, book, noteId, api, ship, history } = props;
|
||||
const body = note.file.slice(note.file.indexOf(";>") + 2);
|
||||
const initial: PostFormSchema = {
|
||||
title: note.title,
|
||||
body,
|
||||
};
|
||||
|
||||
const onSubmit = async (
|
||||
values: PostFormSchema,
|
||||
actions: FormikHelpers<PostFormSchema>
|
||||
) => {
|
||||
const { title, body } = values;
|
||||
const host = ship.slice(1);
|
||||
try {
|
||||
await api.publish.editNote(host, book, noteId, title, body);
|
||||
history.push(`/~publish/notebook/${ship}/${book}/note/${noteId}`);
|
||||
} catch (e) {
|
||||
actions.setStatus({ error: "Failed to edit notebook" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PostForm
|
||||
initial={initial}
|
||||
onSubmit={onSubmit}
|
||||
submitLabel={`Update ${note.title}`}
|
||||
loadingText="Updating..."
|
||||
/>
|
||||
);
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
import React, { Component } from 'react';
|
||||
import { NotebookItem } from './notebook-item';
|
||||
import { Box, Text } from "@tlon/indigo-react";
|
||||
import { NotebookItem } from './NotebookItem';
|
||||
|
||||
export class GroupItem extends Component {
|
||||
render() {
|
||||
@ -33,10 +34,10 @@ export class GroupItem extends Component {
|
||||
);
|
||||
});
|
||||
return (
|
||||
<div className={first}>
|
||||
<p className="f9 ph4 pb2 fw6 gray3">{title}</p>
|
||||
{notebookItems}
|
||||
</div>
|
||||
<Box className={first}>
|
||||
<Box fontSize={0} px={3} fontWeight="700" pb={2} color="lightGray">{title}</Box>
|
||||
{notebookItems}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
59
pkg/interface/src/views/apps/publish/components/lib/Join.tsx
Normal file
59
pkg/interface/src/views/apps/publish/components/lib/Join.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import React, { useCallback, useState, useRef, useEffect } from "react";
|
||||
import { Col, Text, ErrorMessage } from "@tlon/indigo-react";
|
||||
import { Spinner } from "~/views/components/Spinner";
|
||||
import { Notebooks } from "~/types/publish-update";
|
||||
import { useWaitForProps } from "~/logic/lib/useWaitForProps";
|
||||
import { RouteComponentProps } from "react-router-dom";
|
||||
|
||||
interface JoinScreenProps {
|
||||
api: any; // GlobalApi;
|
||||
ship: string;
|
||||
book: string;
|
||||
notebooks: Notebooks;
|
||||
}
|
||||
|
||||
export function JoinScreen(props: JoinScreenProps & RouteComponentProps) {
|
||||
const { book, ship, api } = props;
|
||||
const [error, setError] = useState(false);
|
||||
const joining = useRef(false);
|
||||
|
||||
const waiter = useWaitForProps(props, 10000);
|
||||
|
||||
const onJoin = useCallback(async () => {
|
||||
joining.current = true;
|
||||
const action = {
|
||||
subscribe: {
|
||||
who: ship.replace("~", ""),
|
||||
book,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await api.publish.publishAction(action);
|
||||
await waiter((p) => !!p.notebooks?.[ship]?.[book]);
|
||||
props.history.push(`/~publish/notebook/${ship}/${book}`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError(true);
|
||||
} finally {
|
||||
joining.current = false;
|
||||
}
|
||||
}, [waiter, api, ship, book]);
|
||||
|
||||
useEffect(() => {
|
||||
if (joining.current) {
|
||||
return;
|
||||
}
|
||||
onJoin();
|
||||
}, [onJoin]);
|
||||
|
||||
return (
|
||||
<Col p={4}>
|
||||
<Text fontSize={1}>Joining Notebook</Text>
|
||||
<Spinner awaiting text="Joining..." />
|
||||
{error && <ErrorMessage>Unable to join notebook</ErrorMessage>}
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
export default JoinScreen;
|
@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { MarkdownEditor as _MarkdownEditor, Box, ErrorMessage } from '@tlon/indigo-react';
|
||||
import { useField } from 'formik';
|
||||
|
||||
const MarkdownEditor = styled(_MarkdownEditor)`
|
||||
border: 1px solid ${(p) => p.theme.colors.lightGray};
|
||||
border-radius: ${(p) => p.theme.radii[2]}px;
|
||||
`;
|
||||
|
||||
export const MarkdownField = ({ id, ...rest }: { id: string; } & Parameters<typeof Box>[0]) => {
|
||||
const [{ value }, { error, touched }, { setValue, setTouched }] = useField(id);
|
||||
|
||||
return (
|
||||
<Box width="100%" display="flex" flexDirection="column" {...rest}>
|
||||
<MarkdownEditor
|
||||
onFocus={() => setTouched(true)}
|
||||
onBlur={() => setTouched(false)}
|
||||
value={value}
|
||||
onBeforeChange={(e, d, v) => setValue(v)}
|
||||
/>
|
||||
<ErrorMessage>{touched && error}</ErrorMessage>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
123
pkg/interface/src/views/apps/publish/components/lib/Note.tsx
Normal file
123
pkg/interface/src/views/apps/publish/components/lib/Note.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Box, Text, Col } from "@tlon/indigo-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Link, RouteComponentProps } from "react-router-dom";
|
||||
import { Spinner } from "~/views/components/Spinner";
|
||||
import { Comments } from "./Comments";
|
||||
import { NoteNavigation } from "./NoteNavigation";
|
||||
import {
|
||||
NoteId,
|
||||
Note as INote,
|
||||
Notebook,
|
||||
} from "~/types/publish-update";
|
||||
import { Contacts } from "~/types/contact-update";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { Author } from "./Author";
|
||||
|
||||
interface NoteProps {
|
||||
ship: string;
|
||||
book: string;
|
||||
noteId: NoteId;
|
||||
note: INote;
|
||||
notebook: Notebook;
|
||||
contacts: Contacts;
|
||||
api: GlobalApi;
|
||||
}
|
||||
|
||||
export function Note(props: NoteProps & RouteComponentProps) {
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const { notebook, note, contacts, ship, book, noteId, api } = props;
|
||||
useEffect(() => {
|
||||
api.publish.fetchNote(ship, book, noteId);
|
||||
}, [ship, book, noteId]);
|
||||
|
||||
const baseUrl = `/~publish/notebook/${props.ship}/${props.book}`;
|
||||
|
||||
const deletePost = async () => {
|
||||
setDeleting(true);
|
||||
await api.publish.delNote(ship.slice(1), book, noteId);
|
||||
props.history.push(baseUrl);
|
||||
};
|
||||
|
||||
const comments = note?.comments || [];
|
||||
const file = note?.file;
|
||||
const newfile = file ? file.slice(file.indexOf(";>") + 2) : "";
|
||||
|
||||
let editPost: JSX.Element | null = null;
|
||||
const editUrl = props.location.pathname + "/edit";
|
||||
if (`~${window.ship}` === note?.author) {
|
||||
editPost = (
|
||||
<Box display="inline-block">
|
||||
<Link to={editUrl}>
|
||||
<Text color="green">Edit</Text>
|
||||
</Link>
|
||||
<Text
|
||||
className="dib f9 red2 ml2 pointer"
|
||||
color="red"
|
||||
ml={2}
|
||||
onClick={deletePost}
|
||||
css={{ cursor: "pointer" }}
|
||||
>
|
||||
Delete
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
my={3}
|
||||
display="grid"
|
||||
gridTemplateColumns="1fr"
|
||||
gridAutoRows="min-content"
|
||||
maxWidth="500px"
|
||||
width="100%"
|
||||
gridRowGap={4}
|
||||
mx="auto"
|
||||
>
|
||||
<Link to={baseUrl}>
|
||||
<Text>{"<- Notebook Index"}</Text>
|
||||
</Link>
|
||||
<Col>
|
||||
<Text display="block" mb={2}>{note?.title || ""}</Text>
|
||||
<Box display="flex">
|
||||
<Author
|
||||
ship={note?.author}
|
||||
contacts={contacts}
|
||||
date={note?.["date-created"]}
|
||||
/>
|
||||
<Text ml={2}>{editPost}</Text>
|
||||
</Box>
|
||||
</Col>
|
||||
<Box color="black" className="md" style={{ overflowWrap: "break-word" }}>
|
||||
<ReactMarkdown source={newfile} linkTarget={"_blank"} />
|
||||
</Box>
|
||||
<NoteNavigation
|
||||
notebook={notebook}
|
||||
prevId={note?.["prev-note"] || undefined}
|
||||
nextId={note?.["next-note"] || undefined}
|
||||
ship={props.ship}
|
||||
book={props.book}
|
||||
/>
|
||||
{notebook.comments && (
|
||||
<Comments
|
||||
ship={ship}
|
||||
book={props.book}
|
||||
noteId={props.noteId}
|
||||
note={props.note}
|
||||
comments={comments}
|
||||
numComments={props.note["num-comments"]}
|
||||
contacts={props.contacts}
|
||||
api={props.api}
|
||||
/>
|
||||
)}
|
||||
<Spinner
|
||||
text="Deleting post..."
|
||||
awaiting={deleting}
|
||||
classes="absolute bottom-1 right-1 ba b--gray1-d pa2"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Note;
|
@ -0,0 +1,59 @@
|
||||
import React from "react";
|
||||
import * as Yup from "yup";
|
||||
import { Box, Input } from "@tlon/indigo-react";
|
||||
import { AsyncButton } from "../../../../components/AsyncButton";
|
||||
import { Formik, Form, FormikHelpers } from "formik";
|
||||
import { MarkdownField } from "./MarkdownField";
|
||||
|
||||
interface PostFormProps {
|
||||
initial: PostFormSchema;
|
||||
onSubmit: (
|
||||
values: PostFormSchema,
|
||||
actions: FormikHelpers<PostFormSchema>
|
||||
) => Promise<any>;
|
||||
submitLabel: string;
|
||||
loadingText: string;
|
||||
}
|
||||
|
||||
const formSchema = Yup.object({
|
||||
title: Yup.string().required("Title cannot be blank"),
|
||||
body: Yup.string().required("Post cannot be blank"),
|
||||
});
|
||||
|
||||
export interface PostFormSchema {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export function PostForm(props: PostFormProps) {
|
||||
const { initial, onSubmit, submitLabel, loadingText } = props;
|
||||
|
||||
return (
|
||||
<Box
|
||||
width="100%"
|
||||
p={[2, 4]}
|
||||
display="grid"
|
||||
justifyItems="start"
|
||||
gridAutoRows="min-content"
|
||||
gridTemplateColumns={["100%", "1fr 1fr"]}
|
||||
gridColumnGap={2}
|
||||
gridRowGap={2}
|
||||
>
|
||||
<Formik
|
||||
validationSchema={formSchema}
|
||||
initialValues={initial}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Form style={{ display: "contents" }}>
|
||||
<Input width="100%" placeholder="Post Title" id="title" />
|
||||
<Box gridRow={["1/2", "auto"]} mt={1} justifySelf={["start", "end"]}>
|
||||
<AsyncButton primary loadingText={loadingText}>
|
||||
{submitLabel}
|
||||
</AsyncButton>
|
||||
</Box>
|
||||
<MarkdownField gridColumn={["1/2", "1/3"]} id="body" />
|
||||
</Form>
|
||||
</Formik>
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -0,0 +1,96 @@
|
||||
import React, { Component } from "react";
|
||||
import moment from "moment";
|
||||
import { Box } from "@tlon/indigo-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Notebook } from "../../../../types/publish-update";
|
||||
|
||||
function NavigationItem(props: {
|
||||
url: string;
|
||||
title: string;
|
||||
date: number;
|
||||
prev?: boolean;
|
||||
}) {
|
||||
const date = moment(date).fromNow();
|
||||
return (
|
||||
<Box
|
||||
justifySelf={props.prev ? "start" : "end"}
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
justifyContent="flex-end"
|
||||
textAlign={props.prev ? "left" : "right"}
|
||||
>
|
||||
<Link to={props.url}>
|
||||
<Box color="gray" mb={2}>
|
||||
{props.prev ? "Previous" : "Next"}
|
||||
</Box>
|
||||
<Box mb={1}>{props.title}</Box>
|
||||
<Box color="gray">{date}</Box>
|
||||
</Link>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface NoteNavigationProps {
|
||||
book: string;
|
||||
nextId?: string;
|
||||
prevId?: string;
|
||||
ship: string;
|
||||
notebook: Notebook;
|
||||
}
|
||||
|
||||
export function NoteNavigation(props: NoteNavigationProps) {
|
||||
let nextComponent = <Box />;
|
||||
let prevComponent = <Box />;
|
||||
let nextUrl = "";
|
||||
let prevUrl = "";
|
||||
const { nextId, prevId, notebook } = props;
|
||||
const next =
|
||||
nextId && nextId in notebook?.notes ? notebook?.notes[nextId] : null;
|
||||
|
||||
const prev =
|
||||
prevId && prevId in notebook?.notes ? notebook?.notes[prevId] : null;
|
||||
if (!next && !prev) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (next) {
|
||||
nextUrl = `/~publish/notebook/${props.ship}/${props.book}/note/${props.nextId}`;
|
||||
nextComponent = (
|
||||
<NavigationItem
|
||||
title={next.title}
|
||||
date={next["date-created"]}
|
||||
url={nextUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (prev) {
|
||||
prevUrl = `/~publish/notebook/${props.ship}/${props.book}/note/${props.prevId}`;
|
||||
prevComponent = (
|
||||
<NavigationItem
|
||||
title={prev.title}
|
||||
date={prev["date-created"]}
|
||||
url={prevUrl}
|
||||
prev
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
px={2}
|
||||
borderTop={1}
|
||||
borderBottom={1}
|
||||
borderColor="washedGray"
|
||||
display="grid"
|
||||
alignItems="center"
|
||||
gridTemplateColumns="1fr 1px 1fr"
|
||||
gridTemplateRows="100px"
|
||||
>
|
||||
{prevComponent}
|
||||
<Box borderRight={1} borderColor="washedGray" height="100%" />
|
||||
{nextComponent}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default NoteNavigation;
|
@ -0,0 +1,65 @@
|
||||
import React from "react";
|
||||
import { Col, Box } from "@tlon/indigo-react";
|
||||
import { cite } from "~/logic/lib/util";
|
||||
import { Note } from "~/types/publish-update";
|
||||
import { Contact } from "~/types/contact-update";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import moment from "moment";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
|
||||
interface NotePreviewProps {
|
||||
host: string;
|
||||
book: string;
|
||||
note: Note;
|
||||
contact?: Contact;
|
||||
}
|
||||
|
||||
const WrappedBox = styled(Box)`
|
||||
overflow-wrap: break-word;
|
||||
`;
|
||||
|
||||
export function NotePreview(props: NotePreviewProps) {
|
||||
const { note, contact } = props;
|
||||
|
||||
let name = note.author;
|
||||
if (contact) {
|
||||
name = contact.nickname.length > 0 ? contact.nickname : note.author;
|
||||
}
|
||||
if (name === note.author) {
|
||||
name = cite(note.author);
|
||||
}
|
||||
let comment = "No Comments";
|
||||
if (note["num-comments"] == 1) {
|
||||
comment = "1 Comment";
|
||||
} else if (note["num-comments"] > 1) {
|
||||
comment = `${note["num-comments"]} Comments`;
|
||||
}
|
||||
const date = moment(note["date-created"]).fromNow();
|
||||
//const popout = props.popout ? "popout/" : "";
|
||||
const url = `/~publish/notebook/${props.host}/${props.book}/note/${note["note-id"]}`;
|
||||
|
||||
return (
|
||||
<Link to={url}>
|
||||
<Col mb={4}>
|
||||
<WrappedBox mb={1}>{note.title}</WrappedBox>
|
||||
<WrappedBox mb={1}>
|
||||
<ReactMarkdown
|
||||
unwrapDisallowed
|
||||
allowedTypes={["text", "root", "break", "paragraph"]}
|
||||
source={note.snippet}
|
||||
/>
|
||||
</WrappedBox>
|
||||
<Box color="gray" display="flex">
|
||||
<Box mr={3} fontFamily={contact?.nickname ? "sans" : "mono"}>
|
||||
{name}
|
||||
</Box>
|
||||
<Box color={note.read ? "gray" : "green"} mr={3}>
|
||||
{date}
|
||||
</Box>
|
||||
<Box>{comment}</Box>
|
||||
</Box>
|
||||
</Col>
|
||||
</Link>
|
||||
);
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
import { Route, Switch } from "react-router-dom";
|
||||
|
||||
import { NoteId, Note as INote, Notebook } from "~/types/publish-update";
|
||||
import { Contacts } from "~/types/contact-update";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { RouteComponentProps } from "react-router-dom";
|
||||
import Note from "./Note";
|
||||
import { EditPost } from "./EditPost";
|
||||
|
||||
interface NoteRoutesProps {
|
||||
ship: string;
|
||||
book: string;
|
||||
noteId: NoteId;
|
||||
note: INote;
|
||||
notebook: Notebook;
|
||||
contacts: Contacts;
|
||||
api: GlobalApi;
|
||||
}
|
||||
|
||||
export function NoteRoutes(props: NoteRoutesProps & RouteComponentProps) {
|
||||
const { ship, book, noteId } = props;
|
||||
|
||||
const baseUrl = `/~publish/notebook/${ship}/${book}/note/${noteId}`;
|
||||
|
||||
const relativePath = (path: string) => `${baseUrl}${path}`;
|
||||
return (
|
||||
<Switch>
|
||||
<Route
|
||||
path={relativePath("/edit")}
|
||||
render={(routeProps) => <EditPost {...routeProps} {...props} />}
|
||||
/>
|
||||
<Route
|
||||
path={baseUrl}
|
||||
exact
|
||||
render={(routeProps) => {
|
||||
return <Note {...routeProps} {...props} />;
|
||||
}}
|
||||
/>
|
||||
|
||||
</Switch>
|
||||
);
|
||||
}
|
139
pkg/interface/src/views/apps/publish/components/lib/Notebook.tsx
Normal file
139
pkg/interface/src/views/apps/publish/components/lib/Notebook.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
import React from "react";
|
||||
import { Link, RouteComponentProps, Route, Switch } from "react-router-dom";
|
||||
import { NotebookPosts } from "./NotebookPosts";
|
||||
import { Subscribers } from "./Subscribers";
|
||||
import { Settings } from "./Settings";
|
||||
import { roleForShip } from "~/logic/lib/group";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Text,
|
||||
Tab as _Tab,
|
||||
Tabs,
|
||||
TabList as _TabList,
|
||||
TabPanels,
|
||||
TabPanel,
|
||||
Row,
|
||||
} from "@tlon/indigo-react";
|
||||
import { Notebook as INotebook } from "~/types/publish-update";
|
||||
import { Groups } from "~/types/group-update";
|
||||
import { Contacts, Rolodex } from "~/types/contact-update";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import styled from "styled-components";
|
||||
|
||||
const TabList = styled(_TabList)`
|
||||
margin-bottom: ${(p) => p.theme.space[4]}px;
|
||||
`;
|
||||
|
||||
const Tab = styled(_Tab)`
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
interface NotebookProps {
|
||||
api: GlobalApi;
|
||||
ship: string;
|
||||
book: string;
|
||||
notebook: INotebook;
|
||||
notebookContacts: Contacts;
|
||||
contacts: Rolodex;
|
||||
groups: Groups;
|
||||
}
|
||||
|
||||
export function Notebook(props: NotebookProps & RouteComponentProps) {
|
||||
const { api, ship, book, notebook, notebookContacts, groups } = props;
|
||||
|
||||
const contact = notebookContacts[ship];
|
||||
const group = groups[notebook?.["writers-group-path"]];
|
||||
const role = group ? roleForShip(group, window.ship) : undefined;
|
||||
const isOwn = `~${window.ship}` === ship;
|
||||
const isAdmin = role === "admin" || isOwn;
|
||||
|
||||
const isWriter =
|
||||
isOwn || group.tags?.publish?.[`writers-${book}`]?.has(window.ship);
|
||||
|
||||
const notesList = notebook?.["notes-by-date"] || [];
|
||||
const notes = notebook?.notes || {};
|
||||
|
||||
return (
|
||||
<Box
|
||||
pt={4}
|
||||
mx="auto"
|
||||
display="grid"
|
||||
gridAutoRows="min-content"
|
||||
gridTemplateColumns={["100%", "1fr 1fr"]}
|
||||
maxWidth="500px"
|
||||
gridRowGap={[4, 6]}
|
||||
gridColumnGap={3}
|
||||
>
|
||||
<Box display={["block", "none"]} gridColumn={["1/2", "1/3"]}>
|
||||
<Link to="/~publish">{"<- All Notebooks"}</Link>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text> {notebook?.title}</Text>
|
||||
<br />
|
||||
<Text color="lightGray">by </Text>
|
||||
<Text fontFamily={contact?.nickname ? "sans" : "mono"}>
|
||||
{contact?.nickname || ship}
|
||||
</Text>
|
||||
</Box>
|
||||
<Row justifyContent={["flex-start", "flex-end"]}>
|
||||
{isWriter && (
|
||||
<Link to={`/~publish/notebook/${ship}/${book}/new`}>
|
||||
<Button primary border>
|
||||
New Post
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{!isOwn && (
|
||||
<Button ml={isWriter ? 2 : 0} error border>
|
||||
Unsubscribe
|
||||
</Button>
|
||||
)}
|
||||
</Row>
|
||||
<Box gridColumn={["1/2", "1/3"]}>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab>All Posts</Tab>
|
||||
<Tab>About</Tab>
|
||||
{isAdmin && <Tab>Subscribers</Tab>}
|
||||
{isOwn && <Tab>Settings</Tab>}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<NotebookPosts
|
||||
notes={notes}
|
||||
list={notesList}
|
||||
host={ship}
|
||||
book={book}
|
||||
contacts={notebookContacts}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<Box color="black">{notebook?.about}</Box>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<Subscribers
|
||||
host={ship}
|
||||
book={book}
|
||||
notebook={notebook}
|
||||
api={api}
|
||||
groups={groups}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<Settings
|
||||
host={ship}
|
||||
book={book}
|
||||
api={api}
|
||||
notebook={notebook}
|
||||
contacts={notebookContacts}
|
||||
/>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Notebook;
|
@ -0,0 +1,51 @@
|
||||
import React, { Component } from "react";
|
||||
import { Box, Text } from "@tlon/indigo-react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { HoverBox } from "../../../../components/HoverBox";
|
||||
|
||||
interface NotebookItemProps {
|
||||
selected: boolean;
|
||||
title: string;
|
||||
path: string;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
function UnreadCount(props: { unread: number }) {
|
||||
return (
|
||||
<Box
|
||||
px={1}
|
||||
fontWeight="700"
|
||||
py={1}
|
||||
borderRadius={1}
|
||||
color="white"
|
||||
bg="lightGray"
|
||||
>
|
||||
{props.unread}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotebookItem(props: NotebookItemProps) {
|
||||
return (
|
||||
<Link to={"/~publish/notebook/" + props.path}>
|
||||
<HoverBox
|
||||
bg="white"
|
||||
bgActive="washedGray"
|
||||
selected={props.selected}
|
||||
width="100%"
|
||||
fontSize={0}
|
||||
px={3}
|
||||
py={1}
|
||||
display="flex"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
>
|
||||
<Box py={1}>{props.title}</Box>
|
||||
{props.unreadCount > 0 && <UnreadCount unread={props.unreadCount} />}
|
||||
</HoverBox>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotebookItem;
|
@ -0,0 +1,38 @@
|
||||
import React, { Component } from "react";
|
||||
import { Col } from "@tlon/indigo-react";
|
||||
import { Notes, NoteId } from "../../../../types/publish-update";
|
||||
import { NotePreview } from "./NotePreview";
|
||||
import { Contacts } from "../../../../types/contact-update";
|
||||
|
||||
interface NotebookPostsProps {
|
||||
list: NoteId[];
|
||||
contacts: Contacts;
|
||||
notes: Notes;
|
||||
host: string;
|
||||
book: string;
|
||||
}
|
||||
|
||||
export function NotebookPosts(props: NotebookPostsProps) {
|
||||
return (
|
||||
<Col>
|
||||
{props.list.map((noteId: NoteId) => {
|
||||
const note = props.notes[noteId];
|
||||
if (!note) {
|
||||
console.log(noteId);
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<NotePreview
|
||||
key={noteId}
|
||||
host={props.host}
|
||||
book={props.book}
|
||||
note={note}
|
||||
contact={props.contacts[note.author.substr(1)]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotebookPosts;
|
@ -0,0 +1,80 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { RouteComponentProps, Link, Route, Switch } from "react-router-dom";
|
||||
import { Box, Text } from "@tlon/indigo-react";
|
||||
import GlobalApi from "../../../../api/global";
|
||||
import { PublishContent } from "./PublishContent";
|
||||
import { Notebook as INotebook } from "../../../../types/publish-update";
|
||||
import { Groups } from "../../../../types/group-update";
|
||||
import { Contacts, Rolodex } from "../../../../types/contact-update";
|
||||
import Notebook from "./Notebook";
|
||||
import NewPost from "./new-post";
|
||||
import { NoteRoutes } from './NoteRoutes';
|
||||
|
||||
interface NotebookRoutesProps {
|
||||
api: GlobalApi;
|
||||
ship: string;
|
||||
book: string;
|
||||
notes: any;
|
||||
notebook: INotebook;
|
||||
notebookContacts: Contacts;
|
||||
contacts: Rolodex;
|
||||
groups: Groups;
|
||||
}
|
||||
|
||||
export function NotebookRoutes(
|
||||
props: NotebookRoutesProps & RouteComponentProps
|
||||
) {
|
||||
const { ship, book, api, notebook, notebookContacts } = props;
|
||||
|
||||
useEffect(() => {
|
||||
api.publish.fetchNotesPage(ship, book, 1, 50);
|
||||
api.publish.fetchNotebook(ship, book);
|
||||
}, [ship, book]);
|
||||
|
||||
|
||||
const baseUrl = `/~publish/notebook/${ship}/${book}`;
|
||||
|
||||
const relativePath = (path: string) => `${baseUrl}${path}`;
|
||||
return (
|
||||
<Switch>
|
||||
<Route
|
||||
path={baseUrl}
|
||||
exact
|
||||
render={(routeProps) => {
|
||||
return <Notebook {...props} />;
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
path={relativePath("/new")}
|
||||
render={(routeProps) => (
|
||||
<NewPost
|
||||
{...routeProps}
|
||||
api={api}
|
||||
book={book}
|
||||
ship={ship}
|
||||
notebook={notebook}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path={relativePath("/note/:noteId")}
|
||||
render={(routeProps) => {
|
||||
const { noteId } = routeProps.match.params;
|
||||
const note = notebook?.notes[noteId];
|
||||
return (
|
||||
<NoteRoutes
|
||||
api={api}
|
||||
book={book}
|
||||
ship={ship}
|
||||
noteId={noteId}
|
||||
notebook={notebook}
|
||||
note={note}
|
||||
contacts={notebookContacts}
|
||||
{...routeProps}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Switch>
|
||||
);
|
||||
}
|
129
pkg/interface/src/views/apps/publish/components/lib/Settings.tsx
Normal file
129
pkg/interface/src/views/apps/publish/components/lib/Settings.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { AsyncButton } from "../../../../components/AsyncButton";
|
||||
import * as Yup from "yup";
|
||||
import {
|
||||
Box,
|
||||
Input,
|
||||
Checkbox,
|
||||
Col,
|
||||
InputLabel,
|
||||
InputCaption,
|
||||
Button,
|
||||
Center,
|
||||
} from "@tlon/indigo-react";
|
||||
import { Formik, Form, useFormikContext, FormikHelpers } from "formik";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { Notebook } from "~/types/publish-update";
|
||||
import { Contacts } from "~/types/contact-update";
|
||||
import { FormError } from "~/views/components/FormError";
|
||||
import { RouteComponentProps, useHistory } from "react-router-dom";
|
||||
|
||||
interface SettingsProps {
|
||||
host: string;
|
||||
book: string;
|
||||
notebook: Notebook;
|
||||
contacts: Contacts;
|
||||
api: GlobalApi;
|
||||
}
|
||||
|
||||
interface FormSchema {
|
||||
name: string;
|
||||
description: string;
|
||||
comments: boolean;
|
||||
}
|
||||
|
||||
const formSchema = Yup.object({
|
||||
name: Yup.string().required("Notebook must have a name"),
|
||||
description: Yup.string(),
|
||||
comments: Yup.boolean(),
|
||||
});
|
||||
|
||||
const ResetOnPropsChange = (props: { init: FormSchema; book: string }) => {
|
||||
const { resetForm } = useFormikContext<FormSchema>();
|
||||
useEffect(() => {
|
||||
resetForm({ values: props.init });
|
||||
}, [props.book]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export function Settings(props: SettingsProps) {
|
||||
const { host, notebook, api, book } = props;
|
||||
const history = useHistory();
|
||||
const initialValues: FormSchema = {
|
||||
name: notebook?.title,
|
||||
description: notebook?.about,
|
||||
comments: notebook?.comments,
|
||||
};
|
||||
|
||||
const onSubmit = async (
|
||||
values: FormSchema,
|
||||
actions: FormikHelpers<FormSchema>
|
||||
) => {
|
||||
try {
|
||||
const { name, description, comments } = values;
|
||||
await api.publish.editBook(book, name, description, comments);
|
||||
api.publish.fetchNotebook(host, book);
|
||||
actions.setStatus({ success: null });
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
actions.setStatus({ error: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
await api.publish.delBook(book);
|
||||
history.push("/~publish");
|
||||
};
|
||||
|
||||
return (
|
||||
<Formik
|
||||
validationSchema={formSchema}
|
||||
initialValues={initialValues}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Form>
|
||||
<Box
|
||||
maxWidth="300px"
|
||||
mb={4}
|
||||
gridTemplateColumns="1fr"
|
||||
gridAutoRows="auto"
|
||||
display="grid"
|
||||
>
|
||||
<Col mb={4}>
|
||||
<InputLabel>Delete Notebook</InputLabel>
|
||||
<InputCaption>
|
||||
Permanently delete this notebook. (All current members will no
|
||||
longer see this notebook.)
|
||||
</InputCaption>
|
||||
<Button onClick={onDelete} mt={1} border error>
|
||||
Delete this notebook
|
||||
</Button>
|
||||
</Col>
|
||||
<Input
|
||||
id="name"
|
||||
label="Rename"
|
||||
caption="Change the name of this notebook"
|
||||
/>
|
||||
<Input
|
||||
id="description"
|
||||
label="Change description"
|
||||
caption="Change the description of this notebook"
|
||||
/>
|
||||
<Checkbox
|
||||
id="comments"
|
||||
label="Comments"
|
||||
caption="Subscribers may comment when enabled"
|
||||
/>
|
||||
<ResetOnPropsChange init={initialValues} book={book} />
|
||||
<AsyncButton loadingText="Updating.." border>
|
||||
Save
|
||||
</AsyncButton>
|
||||
<FormError message="Failed to update settings" />
|
||||
</Box>
|
||||
</Form>
|
||||
</Formik>
|
||||
);
|
||||
}
|
||||
|
||||
export default Settings;
|
127
pkg/interface/src/views/apps/publish/components/lib/Sidebar.tsx
Normal file
127
pkg/interface/src/views/apps/publish/components/lib/Sidebar.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import React, { Component } from "react";
|
||||
import { Box, Text, Col } from "@tlon/indigo-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import SidebarInvite from "~/views/components/SidebarInvite";
|
||||
import { Welcome } from "./Welcome";
|
||||
import { GroupItem } from "./GroupItem";
|
||||
import { alphabetiseAssociations } from "~/logic/lib/util";
|
||||
|
||||
export function Sidebar(props: any) {
|
||||
const sidebarInvites = !(props.invites && props.invites["/publish"])
|
||||
? null
|
||||
: Object.keys(props.invites["/publish"]).map((uid) => {
|
||||
return (
|
||||
<SidebarInvite
|
||||
key={uid}
|
||||
invite={props.invites["/publish"][uid]}
|
||||
onAccept={() => props.api.invite.accept("/publish", uid)}
|
||||
onDecline={() => props.api.invite.decline("/publish", uid)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
const associations =
|
||||
props.associations && "contacts" in props.associations
|
||||
? alphabetiseAssociations(props.associations.contacts)
|
||||
: {};
|
||||
|
||||
const notebooks = {};
|
||||
Object.keys(props.notebooks).map((host) => {
|
||||
Object.keys(props.notebooks[host]).map((notebook) => {
|
||||
const title = `${host}/${notebook}`;
|
||||
notebooks[title] = props.notebooks[host][notebook];
|
||||
});
|
||||
});
|
||||
|
||||
const groupedNotebooks = {};
|
||||
Object.keys(notebooks).map((book) => {
|
||||
const path = notebooks[book]["subscribers-group-path"]
|
||||
? notebooks[book]["subscribers-group-path"]
|
||||
: book;
|
||||
if (path in associations) {
|
||||
if (groupedNotebooks[path]) {
|
||||
const array = groupedNotebooks[path];
|
||||
array.push(book);
|
||||
groupedNotebooks[path] = array;
|
||||
} else {
|
||||
groupedNotebooks[path] = [book];
|
||||
}
|
||||
} else {
|
||||
if (groupedNotebooks["/~/"]) {
|
||||
const array = groupedNotebooks["/~/"];
|
||||
array.push(book);
|
||||
groupedNotebooks["/~/"] = array;
|
||||
} else {
|
||||
groupedNotebooks["/~/"] = [book];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const selectedGroups = props.selectedGroups ? props.selectedGroups : [];
|
||||
const groupedItems = Object.keys(associations)
|
||||
.map((each, i) => {
|
||||
const books = groupedNotebooks[each] || [];
|
||||
if (books.length === 0) return;
|
||||
if (
|
||||
selectedGroups.length === 0 &&
|
||||
groupedNotebooks["/~/"] &&
|
||||
groupedNotebooks["/~/"].length !== 0
|
||||
) {
|
||||
i = i + 1;
|
||||
}
|
||||
return (
|
||||
<GroupItem
|
||||
key={i}
|
||||
index={i}
|
||||
association={associations[each]}
|
||||
groupedBooks={books}
|
||||
notebooks={notebooks}
|
||||
path={props.path}
|
||||
/>
|
||||
);
|
||||
});
|
||||
if (
|
||||
selectedGroups.length === 0 &&
|
||||
groupedNotebooks["/~/"] &&
|
||||
groupedNotebooks["/~/"].length !== 0
|
||||
) {
|
||||
groupedItems.unshift(
|
||||
<GroupItem
|
||||
key={"/~/"}
|
||||
index={0}
|
||||
association={"/~/"}
|
||||
groupedBooks={groupedNotebooks["/~/"]}
|
||||
notebooks={notebooks}
|
||||
path={props.path}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const display = props.path ? ['none', 'block'] : 'block';
|
||||
|
||||
return (
|
||||
<Col
|
||||
borderRight={[0, 1]}
|
||||
borderRightColor={["washedGray", "washedGray"]}
|
||||
height="100%"
|
||||
pt={[3, 0]}
|
||||
overflowY="auto"
|
||||
display={display}
|
||||
maxWidth={["none", "250px"]}
|
||||
>
|
||||
<Box>
|
||||
<Link to="/~publish/new" className="green2 pa4 f9 dib">
|
||||
<Box color="green">New Notebook</Box>
|
||||
</Link>
|
||||
</Box>
|
||||
<Box
|
||||
className="overflow-y-auto pb1"
|
||||
>
|
||||
<Welcome mx={2} />
|
||||
{sidebarInvites}
|
||||
{groupedItems}
|
||||
</Box>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sidebar;
|
@ -1,8 +1,22 @@
|
||||
import React, { Component } from 'react';
|
||||
import { GroupView } from '~/views/components/Group';
|
||||
import { resourceFromPath } from '~/logic/lib/group';
|
||||
import {Notebook} from '~/types/publish-update';
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
import {Groups} from '~/types/group-update';
|
||||
import {Associations} from '~/types/metadata-update';
|
||||
import {Rolodex} from '~/types/contact-update';
|
||||
|
||||
export class Subscribers extends Component {
|
||||
interface SubscribersProps {
|
||||
notebook: Notebook;
|
||||
api: GlobalApi;
|
||||
groups: Groups;
|
||||
book: string;
|
||||
associations: Associations;
|
||||
contacts: Rolodex;
|
||||
}
|
||||
|
||||
export class Subscribers extends Component<SubscribersProps> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.redirect = this.redirect.bind(this);
|
||||
@ -57,6 +71,7 @@ export class Subscribers extends Component {
|
||||
addDesc: 'Allow user to write to this notebook'
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
import { Box } from "@tlon/indigo-react";
|
||||
import { useLocalStorageState } from "~/logic/lib/useLocalStorageState";
|
||||
|
||||
export function Welcome(props: Parameters<typeof Box>[0]) {
|
||||
const [wasWelcomed, setWasWelcomed] = useLocalStorageState(
|
||||
"urbit-publish:wasWelcomed",
|
||||
false
|
||||
);
|
||||
|
||||
if (wasWelcomed) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Box {...props} p={2} border={1} >
|
||||
<Box lineHeight="1.6" fontSize={0}>
|
||||
Notebooks are for longer-form writing and discussion. Each Notebook is a
|
||||
collection of Markdown-formatted notes with optional comments.
|
||||
</Box>
|
||||
<Box
|
||||
fontSize={0}
|
||||
mt={2}
|
||||
className="f8 pt2 dib pointer bb"
|
||||
onClick={() => { setWasWelcomed(true) }}
|
||||
>
|
||||
Close this
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Welcome;
|
@ -1,29 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const CommentInput = React.forwardRef((props, ref) => (
|
||||
<textarea
|
||||
{...props}
|
||||
ref={ref}
|
||||
id="comment"
|
||||
name="comment"
|
||||
placeholder="Leave a comment here"
|
||||
className={
|
||||
'f9 db border-box w-100 ba b--gray3 pt2 ph2 br1 ' +
|
||||
'b--gray2-d mb2 focus-b--black focus-b--white-d white-d bg-gray0-d'
|
||||
}
|
||||
aria-describedby="comment-desc"
|
||||
style={{ height: '4rem', resize: 'vertical' }}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
(e.getModifierState('Control') || event.metaKey) &&
|
||||
e.key === 'Enter'
|
||||
) {
|
||||
props.onSubmit();
|
||||
}
|
||||
}}
|
||||
></textarea>
|
||||
));
|
||||
|
||||
CommentInput.displayName = 'commentInput';
|
||||
|
||||
export default CommentInput;
|
@ -1,155 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import moment from 'moment';
|
||||
import { Sigil } from '~/logic/lib/sigil';
|
||||
import CommentInput from './comment-input';
|
||||
import { uxToHex, cite } from '~/logic/lib/util';
|
||||
|
||||
export class CommentItem extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
commentBody: ''
|
||||
};
|
||||
|
||||
this.commentChange = this.commentChange.bind(this);
|
||||
this.commentEdit = this.commentEdit.bind(this);
|
||||
moment.updateLocale('en', {
|
||||
relativeTime: {
|
||||
past: function(input) {
|
||||
return input === 'just now'
|
||||
? input
|
||||
: input + ' ago';
|
||||
},
|
||||
s : 'just now',
|
||||
future : 'in %s',
|
||||
m : '1m',
|
||||
mm : '%dm',
|
||||
h : '1h',
|
||||
hh : '%dh',
|
||||
d : '1d',
|
||||
dd : '%dd',
|
||||
M : '1 month',
|
||||
MM : '%d months',
|
||||
y : '1 year',
|
||||
yy : '%d years'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
commentEdit() {
|
||||
const commentPath = Object.keys(this.props.comment)[0];
|
||||
const commentBody = this.props.comment[commentPath].content;
|
||||
this.setState({ commentBody });
|
||||
this.props.onEdit();
|
||||
}
|
||||
|
||||
focusTextArea(text) {
|
||||
text && text.focus();
|
||||
}
|
||||
|
||||
commentChange(e) {
|
||||
this.setState({
|
||||
commentBody: e.target.value
|
||||
});
|
||||
}
|
||||
|
||||
onUpdate() {
|
||||
this.props.onUpdate(this.state.commentBody);
|
||||
}
|
||||
|
||||
render() {
|
||||
const pending = this.props.pending ? 'o-60' : '';
|
||||
const commentData = this.props.comment[Object.keys(this.props.comment)[0]];
|
||||
const content = commentData.content.split('\n').map((line, i) => {
|
||||
return (
|
||||
<p className="mb2" key={i}>{line}</p>
|
||||
);
|
||||
});
|
||||
const date = moment(commentData['date-created']).fromNow();
|
||||
|
||||
const contact = commentData.author.substr(1) in this.props.contacts
|
||||
? this.props.contacts[commentData.author.substr(1)] : false;
|
||||
|
||||
let name = commentData.author;
|
||||
let color = '#000000';
|
||||
let classes = 'mix-blend-diff';
|
||||
let avatar = null;
|
||||
if (contact) {
|
||||
name = (contact.nickname.length > 0)
|
||||
? contact.nickname : commentData.author;
|
||||
color = `#${uxToHex(contact.color)}`;
|
||||
classes = '';
|
||||
avatar = contact.avatar;
|
||||
}
|
||||
|
||||
const img = (avatar !== null)
|
||||
? <img src={avatar} height={24} width={24} className="dib" />
|
||||
: <Sigil
|
||||
ship={commentData.author}
|
||||
size={24}
|
||||
color={color}
|
||||
classes={classes}
|
||||
/>;
|
||||
|
||||
if (name === commentData.author) {
|
||||
name = cite(commentData.author);
|
||||
}
|
||||
|
||||
const { editing } = this.props;
|
||||
|
||||
const disabled = this.props.pending
|
||||
|| window.ship !== commentData.author.slice(1);
|
||||
|
||||
return (
|
||||
<div className={'mb8 ' + pending}>
|
||||
<div className="flex mv3 bg-white bg-gray0-d">
|
||||
{img}
|
||||
<div className={'f9 mh2 pt1 ' +
|
||||
(contact.nickname ? null : 'mono')}
|
||||
title={commentData.author}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
<div className="f9 gray3 pt1">{date}</div>
|
||||
{ !editing && !disabled && (
|
||||
<>
|
||||
<div onClick={this.commentEdit.bind(this)} className="green2 pointer ml2 f9 pt1">
|
||||
Edit
|
||||
</div>
|
||||
<div onClick={this.props.onDelete} className="red2 pointer ml2 f9 pt1">
|
||||
Delete
|
||||
</div>
|
||||
</>
|
||||
) }
|
||||
</div>
|
||||
<div className="f8 lh-solid mb2">
|
||||
{ !editing && content }
|
||||
{ editing && (
|
||||
<CommentInput style={{ resize:'vertical' }}
|
||||
ref={(el) => {
|
||||
this.focusTextArea(el);
|
||||
}}
|
||||
onChange={this.commentChange}
|
||||
value={this.state.commentBody}
|
||||
onSubmit={this.onUpdate.bind(this)}
|
||||
>
|
||||
</CommentInput>
|
||||
)}
|
||||
</div>
|
||||
{ editing && (
|
||||
<div className="flex">
|
||||
<div onClick={this.onUpdate.bind(this)} className="br1 green2 pointer f9 pt1 b--green2 ba pa2 dib">
|
||||
Submit
|
||||
</div>
|
||||
<div onClick={this.props.onEditCancel} className="br1 black white-d pointer f9 b--gray2 ba pa2 dib ml2">
|
||||
Cancel
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CommentItem;
|
@ -1,200 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { CommentItem } from './comment-item';
|
||||
import CommentInput from './comment-input';
|
||||
import { dateToDa } from '~/logic/lib/util';
|
||||
import { Spinner } from '~/views/components/Spinner';
|
||||
|
||||
export class Comments extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
commentBody: '',
|
||||
pending: new Set(),
|
||||
awaiting: null,
|
||||
editing: null
|
||||
};
|
||||
this.commentSubmit = this.commentSubmit.bind(this);
|
||||
this.commentChange = this.commentChange.bind(this);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const previousComments = prevProps.comments[0] || {};
|
||||
const currentComments = this.props.comments[0] || {};
|
||||
const previous = Object.keys(previousComments) || [];
|
||||
const current = Object.keys(currentComments) || [];
|
||||
if ((prevProps.comments && this.props.comments) &&
|
||||
(previous !== current)) {
|
||||
const pendingSet = this.state.pending;
|
||||
Object.keys(currentComments).map((com) => {
|
||||
const obj = currentComments[com];
|
||||
for (const each of pendingSet.values()) {
|
||||
if (obj.content === each['new-comment'].body) {
|
||||
pendingSet.delete(each);
|
||||
this.setState({ pending: pendingSet });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
commentSubmit(evt) {
|
||||
const comment = {
|
||||
'new-comment': {
|
||||
who: this.props.ship.slice(1),
|
||||
book: this.props.book,
|
||||
note: this.props.note,
|
||||
body: this.state.commentBody
|
||||
}
|
||||
};
|
||||
|
||||
const pendingState = this.state.pending;
|
||||
pendingState.add(comment);
|
||||
this.setState({ pending: pendingState });
|
||||
|
||||
this.textArea.value = '';
|
||||
this.setState({ commentBody: '', awaiting: 'new' });
|
||||
const submit = this.props.api.publish.publishAction(comment);
|
||||
submit.then(() => {
|
||||
this.setState({ awaiting: null });
|
||||
});
|
||||
}
|
||||
|
||||
commentChange(evt) {
|
||||
this.setState({
|
||||
commentBody: evt.target.value
|
||||
});
|
||||
}
|
||||
|
||||
commentEdit(idx) {
|
||||
this.setState({ editing: idx });
|
||||
}
|
||||
|
||||
commentEditCancel() {
|
||||
this.setState({ editing: null });
|
||||
}
|
||||
|
||||
commentUpdate(idx, body) {
|
||||
const path = Object.keys(this.props.comments[idx])[0];
|
||||
const comment = {
|
||||
'edit-comment': {
|
||||
who: this.props.ship.slice(1),
|
||||
book: this.props.book,
|
||||
note: this.props.note,
|
||||
body: body,
|
||||
comment: path
|
||||
}
|
||||
};
|
||||
|
||||
this.setState({ awaiting: 'edit' });
|
||||
|
||||
this.props.api.publish
|
||||
.publishAction(comment)
|
||||
.then(() => {
|
||||
this.setState({ awaiting: null, editing: null });
|
||||
});
|
||||
}
|
||||
|
||||
commentDelete(idx) {
|
||||
const path = Object.keys(this.props.comments[idx])[0];
|
||||
const comment = {
|
||||
'del-comment': {
|
||||
who: this.props.ship.slice(1),
|
||||
book: this.props.book,
|
||||
note: this.props.note,
|
||||
comment: path
|
||||
}
|
||||
};
|
||||
|
||||
this.setState({ awaiting: { kind: 'del', what: idx } });
|
||||
this.props.api.publish
|
||||
.publishAction(comment)
|
||||
.then(() => {
|
||||
this.setState({ awaiting: null });
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.props.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { editing } = this.state;
|
||||
|
||||
const pendingArray = Array.from(this.state.pending).map((com, i) => {
|
||||
const da = dateToDa(new Date());
|
||||
const comment = {
|
||||
[da]: {
|
||||
author: `~${window.ship}`,
|
||||
content: com['new-comment'].body,
|
||||
'date-created': Math.round(new Date().getTime())
|
||||
}
|
||||
};
|
||||
return (
|
||||
<CommentItem
|
||||
comment={comment}
|
||||
key={i}
|
||||
contacts={this.props.contacts}
|
||||
pending={true}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const commentArray = this.props.comments.map((com, i) => {
|
||||
return (
|
||||
<CommentItem
|
||||
comment={com}
|
||||
key={i}
|
||||
contacts={this.props.contacts}
|
||||
onUpdate={u => this.commentUpdate(i, u)}
|
||||
onDelete={() => this.commentDelete(i)}
|
||||
onEdit={() => this.commentEdit(i)}
|
||||
onEditCancel={this.commentEditCancel.bind(this)}
|
||||
editing={i === editing}
|
||||
disabled={Boolean(this.state.awaiting) || editing}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const disableComment = ((this.state.commentBody === '') || (Boolean(this.state.awaiting)));
|
||||
const commentClass = (disableComment)
|
||||
? 'bg-transparent f9 pa2 br1 ba b--gray2 gray2'
|
||||
: 'bg-transparent f9 pa2 br1 ba b--gray2 black white-d pointer';
|
||||
|
||||
const spinnerText =
|
||||
this.state.awaiting === 'new'
|
||||
? 'Posting commment...'
|
||||
: this.state.awaiting === 'edit'
|
||||
? 'Updating comment...'
|
||||
: 'Deleting comment...';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mv8 relative">
|
||||
<div>
|
||||
<CommentInput style={{ resize:'vertical' }}
|
||||
ref={(el) => {
|
||||
this.textArea = el;
|
||||
}}
|
||||
onChange={this.commentChange}
|
||||
value={this.state.commentBody}
|
||||
disabled={Boolean(this.state.editing)}
|
||||
onSubmit={this.commentSubmit}
|
||||
>
|
||||
</CommentInput>
|
||||
</div>
|
||||
<button disabled={disableComment}
|
||||
onClick={this.commentSubmit}
|
||||
className={commentClass}
|
||||
>
|
||||
Add comment
|
||||
</button>
|
||||
<Spinner text={spinnerText} awaiting={this.state.awaiting} classes="absolute bottom-0 right-0 pb2" />
|
||||
</div>
|
||||
{pendingArray}
|
||||
{commentArray}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Comments;
|
@ -1,89 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
export class Dropdown extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.toggleDropdown = this.toggleDropdown.bind(this);
|
||||
this.handleClickOutside = this.handleClickOutside.bind(this);
|
||||
this.collapseAndDispatch = this.collapseAndDispatch.bind(this);
|
||||
this.state = {
|
||||
open: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
document.addEventListener('mousedown', this.handleClickOutside);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('mousedown', this.handleClickOutside);
|
||||
}
|
||||
|
||||
handleClickOutside(evt) {
|
||||
if (this.optsList && !this.optsList.contains(evt.target) &&
|
||||
this.optsButton && !this.optsButton.contains(evt.target)) {
|
||||
this.setState({ open: false });
|
||||
}
|
||||
}
|
||||
|
||||
toggleDropdown() {
|
||||
this.setState({ open: !this.state.open });
|
||||
}
|
||||
|
||||
collapseAndDispatch(action) {
|
||||
this.setState({ open: false }, action);
|
||||
}
|
||||
|
||||
render() {
|
||||
const alignment = (this.props.align)
|
||||
? this.props.align : 'right';
|
||||
|
||||
const display = (this.state.open)
|
||||
? 'block' : 'none';
|
||||
|
||||
const optionsClass = (this.state.open)
|
||||
? 'open' : 'closed';
|
||||
|
||||
let leftAlign = '';
|
||||
let rightAlign = '0';
|
||||
|
||||
if (alignment === 'left') {
|
||||
leftAlign = '0';
|
||||
rightAlign = '';
|
||||
}
|
||||
|
||||
const optionsList = this.props.options.map((val, i) => {
|
||||
return (
|
||||
<button key={i} className={val.cls}
|
||||
onClick={() => this.collapseAndDispatch(val.action)}
|
||||
>
|
||||
{val.txt}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={'options relative dib pr3 pointer ' + optionsClass}
|
||||
ref={(el) => {
|
||||
this.optsButton = el;
|
||||
}}
|
||||
onClick={this.toggleDropdown}
|
||||
>
|
||||
<button className="bg-transparent white-d pointer mb1 br2 pa2 pr4">
|
||||
{this.props.buttonText}
|
||||
</button>
|
||||
<div className="absolute flex flex-column pv2 ba b--gray4 br2 z-1 bg-white bg-gray0-d"
|
||||
ref={(el) => {
|
||||
this.optsList = el;
|
||||
}}
|
||||
style={{ left: leftAlign, right: rightAlign, width:this.props.width, display: display }}
|
||||
>
|
||||
{optionsList}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Dropdown;
|
@ -1,145 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { SidebarSwitcher } from '~/views/components/SidebarSwitch';
|
||||
import { Spinner } from '~/views/components/Spinner';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Controlled as CodeMirror } from 'react-codemirror2';
|
||||
import { dateToDa } from '~/logic/lib/util';
|
||||
|
||||
import 'codemirror/mode/markdown/markdown';
|
||||
|
||||
export class EditPost extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
body: '',
|
||||
submit: false,
|
||||
awaiting: false
|
||||
};
|
||||
this.postSubmit = this.postSubmit.bind(this);
|
||||
this.bodyChange = this.bodyChange.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.componentDidUpdate();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { props, state } = this;
|
||||
const contents = props.notebooks[props.ship]?.[props.book]?.notes?.[props.note]?.file;
|
||||
if (prevProps && prevProps.api !== props.api) {
|
||||
if (!contents) {
|
||||
props.api?.fetchNote(props.ship, props.book, props.note);
|
||||
}
|
||||
}
|
||||
if (contents && state.body === '') {
|
||||
const notebook = props.notebooks[props.ship][props.book];
|
||||
const note = notebook.notes[props.note];
|
||||
const file = note.file;
|
||||
const body = file.slice(file.indexOf(';>') + 3);
|
||||
this.setState({ body: body });
|
||||
}
|
||||
}
|
||||
|
||||
postSubmit() {
|
||||
const { props, state } = this;
|
||||
const notebook = props.notebooks[props.ship][props.book];
|
||||
const note = notebook.notes[props.note];
|
||||
const title = note.title;
|
||||
const editNote = {
|
||||
'edit-note': {
|
||||
who: props.ship.slice(1),
|
||||
book: props.book,
|
||||
note: props.note,
|
||||
title: title,
|
||||
body: state.body
|
||||
}
|
||||
};
|
||||
this.setState({ awaiting: true });
|
||||
this.props.api.publish.publishAction(editNote).then(() => {
|
||||
const editIndex = props.location.pathname.indexOf('/edit');
|
||||
const noteHref = props.location.pathname.slice(0, editIndex);
|
||||
this.setState({ awaiting: false });
|
||||
props.history.push(noteHref);
|
||||
});
|
||||
}
|
||||
|
||||
bodyChange(editor, data, value) {
|
||||
const submit = !(value === '');
|
||||
this.setState({ body: value, submit: submit });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props, state } = this;
|
||||
const notebook = props.notebooks[props.ship][props.book];
|
||||
const note = notebook.notes[props.note];
|
||||
const title = note.title;
|
||||
let date = dateToDa(new Date(note['date-created']));
|
||||
date = date.slice(1, -10);
|
||||
|
||||
const submitStyle = (state.submit)
|
||||
? { color: '#2AA779', cursor: 'pointer' }
|
||||
: { color: '#B1B2B3', cursor: 'auto' };
|
||||
|
||||
const hrefIndex = props.location.pathname.indexOf('/note/');
|
||||
const publishsubStr = props.location.pathname.substr(hrefIndex);
|
||||
const popoutHref = `/~publish/popout${publishsubStr}`;
|
||||
|
||||
const hiddenOnPopout = (props.popout)
|
||||
? '' : 'dib-m dib-l dib-xl';
|
||||
|
||||
const options = {
|
||||
mode: 'markdown',
|
||||
theme: 'tlon',
|
||||
lineNumbers: false,
|
||||
lineWrapping: true,
|
||||
scrollbarStyle: null,
|
||||
cursorHeight: 0.85
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="f9 h-100 relative publish">
|
||||
<div className="w-100 tl pv4 flex justify-center">
|
||||
<SidebarSwitcher
|
||||
sidebarShown={props.sidebarShown}
|
||||
popout={props.popout}
|
||||
api={this.props.api}
|
||||
/>
|
||||
<button
|
||||
className="v-mid bg-transparent w-100 w-80-m w-90-l mw6 tl h1 pl4"
|
||||
disabled={!state.submit}
|
||||
style={submitStyle}
|
||||
onClick={this.postSubmit}
|
||||
>
|
||||
Save "{title}"
|
||||
</button>
|
||||
<Link
|
||||
className={'dn absolute right-1 top-1 ' + hiddenOnPopout}
|
||||
to={popoutHref}
|
||||
target="_blank"
|
||||
>
|
||||
<img src="/~landscape/img/popout.png"
|
||||
height={16}
|
||||
width={16}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mw6 center">
|
||||
<div className="pl4">
|
||||
<div className="gray2">{date}</div>
|
||||
</div>
|
||||
<div className="EditPost">
|
||||
<CodeMirror
|
||||
value={state.body}
|
||||
options={options}
|
||||
onBeforeChange={(e, d, v) => this.bodyChange(e, d, v)}
|
||||
onChange={(editor, data, value) => {}}
|
||||
/>
|
||||
<Spinner text="Editing post..." awaiting={this.state.awaiting} classes="absolute bottom-1 right-1 ba b--gray1-d pa2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default EditPost;
|
@ -1,176 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Spinner } from '~/views/components/Spinner';
|
||||
import urbitOb from 'urbit-ob';
|
||||
|
||||
export class JoinScreen extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
book: '',
|
||||
error: false,
|
||||
awaiting: null,
|
||||
disable: false
|
||||
};
|
||||
|
||||
this.bookChange = this.bookChange.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.componentDidUpdate();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if ((this.props.ship) && (this.props.notebook)) {
|
||||
const incomingBook = `${this.props.ship}/${this.props.notebook}`;
|
||||
if (this.props.api && (prevProps?.api !== this.props.api)) {
|
||||
this.setState({ book: incomingBook }, () => {
|
||||
this.onClickJoin();
|
||||
});
|
||||
}
|
||||
}
|
||||
// redirect to notebook when we have it
|
||||
if (this.props.notebooks) {
|
||||
if (this.state.awaiting) {
|
||||
const book = this.state.awaiting.split('/');
|
||||
const ship = book[0];
|
||||
const notebook = book[1];
|
||||
if ((ship in this.props.notebooks) &&
|
||||
(notebook in this.props.notebooks[ship])) {
|
||||
this.setState({ disable: false, book: '/' });
|
||||
this.props.history.push(`/~publish/notebook/${ship}/${notebook}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
notebooksInclude(text, notebookObj) {
|
||||
let verdict = false;
|
||||
let keyPair = [];
|
||||
// validate that it's a worthwhile thing to check
|
||||
// certainly a unit would be nice here
|
||||
if (text.indexOf('/') === -1) {
|
||||
return verdict;
|
||||
} else {
|
||||
keyPair = text.split('/');
|
||||
};
|
||||
// check both levels of object
|
||||
if (keyPair[0] in notebookObj) {
|
||||
if (keyPair[1] in notebookObj[keyPair[0]]) {
|
||||
verdict = true;
|
||||
}
|
||||
}
|
||||
return verdict;
|
||||
}
|
||||
|
||||
onClickJoin() {
|
||||
const { props, state } = this;
|
||||
|
||||
const text = state.book;
|
||||
|
||||
let book = text.split('/');
|
||||
const ship = book[0];
|
||||
book.splice(0, 1);
|
||||
book = '/' + book.join('/');
|
||||
|
||||
if (this.notebooksInclude(state.book, props.notebooks)) {
|
||||
const href = `/~publish/notebook/${ship}${book}`;
|
||||
return props.history.push(href);
|
||||
}
|
||||
|
||||
if (book.length < 2 || !urbitOb.isValidPatp(ship)) {
|
||||
this.setState({
|
||||
error: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const actionData = {
|
||||
subscribe: {
|
||||
who: ship.replace('~',''),
|
||||
book: /\/?(.*)/.exec(book)[1]
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: askHistory setting
|
||||
this.setState({ disable: true });
|
||||
this.props.api.publish.publishAction(actionData).catch((err) => {
|
||||
console.log(err);
|
||||
}).then(() => {
|
||||
this.setState({ awaiting: text });
|
||||
});
|
||||
}
|
||||
|
||||
bookChange(event) {
|
||||
this.setState({
|
||||
book: event.target.value
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { state } = this;
|
||||
|
||||
let joinClasses = 'db f9 green2 ba pa2 b--green2 bg-gray0-d pointer';
|
||||
if ((state.disable) || (!state.book) || (state.book === '/')) {
|
||||
joinClasses = 'db f9 gray2 ba pa2 b--gray3 bg-gray0-d';
|
||||
}
|
||||
|
||||
let errElem = (<span />);
|
||||
if (state.error) {
|
||||
errElem = (
|
||||
<span className="f9 inter red2 db">
|
||||
Notebook must have a valid name.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'h-100 w-100 pt4 overflow-x-hidden flex flex-column ' +
|
||||
'bg-gray0-d white-d pa3'}
|
||||
>
|
||||
<div
|
||||
className="w-100 dn-m dn-l dn-xl inter pt1 pb6 f8"
|
||||
>
|
||||
<Link to="/~publish/">{'⟵ All Notebooks'}</Link>
|
||||
</div>
|
||||
<h2 className="mb3 f8">Subscribe to an Existing Notebook</h2>
|
||||
<div className="w-100">
|
||||
<p className="f8 lh-copy mt3 db">Enter a <span className="mono">~ship/notebook-name</span></p>
|
||||
<p className="f9 gray2 mb4">Notebook names use lowercase, hyphens, and slashes.</p>
|
||||
<textarea
|
||||
ref={ (e) => {
|
||||
this.textarea = e;
|
||||
} }
|
||||
className={'f7 mono ba bg-gray0-d white-d pa3 mb2 db ' +
|
||||
'focus-b--black focus-b--white-d b--gray3 b--gray2-d nowrap '}
|
||||
placeholder="~zod/dream-journal"
|
||||
spellCheck="false"
|
||||
rows={1}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.onClickJoin();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
resize: 'none'
|
||||
}}
|
||||
onChange={this.bookChange}
|
||||
value={this.state.book}
|
||||
/>
|
||||
{errElem}
|
||||
<br />
|
||||
<button
|
||||
disabled={(this.state.disable) || (!state.book) || (state.book === '/')}
|
||||
onClick={this.onClickJoin.bind(this)}
|
||||
className={joinClasses}
|
||||
>Join Notebook</button>
|
||||
<Spinner awaiting={this.state.disable} classes="mt4" text="Joining notebook..." />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default JoinScreen;
|
@ -1,196 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { SidebarSwitcher } from '~/views/components/SidebarSwitch';
|
||||
import { Spinner } from '~/views/components/Spinner';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Controlled as CodeMirror } from 'react-codemirror2';
|
||||
import { dateToDa, stringToSymbol } from '~/logic/lib/util';
|
||||
|
||||
import 'codemirror/mode/markdown/markdown';
|
||||
|
||||
export class NewPost extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
body: '',
|
||||
title: '',
|
||||
submit: false,
|
||||
awaiting: null,
|
||||
disabled: false
|
||||
};
|
||||
|
||||
this.postSubmit = this.postSubmit.bind(this);
|
||||
this.titleChange = this.titleChange.bind(this);
|
||||
this.bodyChange = this.bodyChange.bind(this);
|
||||
}
|
||||
|
||||
postSubmit() {
|
||||
const { state } = this;
|
||||
|
||||
// perf testing:
|
||||
/*let closure = () => {
|
||||
let x = 0;
|
||||
for (var i = 0; i < 5; i++) {
|
||||
x++;
|
||||
let rand = Math.floor(Math.random() * 1000);
|
||||
const newNote = {
|
||||
'new-note': {
|
||||
who: this.props.ship.slice(1),
|
||||
book: this.props.book,
|
||||
note: stringToSymbol(this.state.title + '-' + Date.now() + '-' + rand),
|
||||
title: 'asdf-' + rand + '-' + Date.now(),
|
||||
body: 'asdf-' + Date.now()
|
||||
}
|
||||
};
|
||||
|
||||
this.props.api.publishAction(newNote);
|
||||
}
|
||||
setTimeout(closure, 3000);
|
||||
};
|
||||
setTimeout(closure, 2000);*/
|
||||
|
||||
if (state.submit && !state.disabled) {
|
||||
const newNote = {
|
||||
'new-note': {
|
||||
who: this.props.ship.slice(1),
|
||||
book: this.props.book,
|
||||
note: stringToSymbol(this.state.title),
|
||||
title: this.state.title,
|
||||
body: this.state.body
|
||||
}
|
||||
};
|
||||
|
||||
this.setState({ disabled: true });
|
||||
this.props.api.publish.publishAction(newNote).then(() => {
|
||||
this.setState({ awaiting: newNote['new-note'].note });
|
||||
}).catch((err) => {
|
||||
if (err.includes('note already exists')) {
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
newNote['new-note'].note += '-' + timestamp;
|
||||
this.setState({ awaiting: newNote['new-note'].note });
|
||||
this.props.api.publish.publishAction(newNote);
|
||||
} else {
|
||||
this.setState({ disabled: false, awaiting: null });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.componentDidUpdate();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps && prevProps.api !== this.props.api) {
|
||||
this.props.api.publish.fetchNotebook(this.props.ship, this.props.book);
|
||||
}
|
||||
|
||||
const notebook = this.props.notebooks[this.props.ship][this.props.book];
|
||||
if (notebook.notes[this.state.awaiting]) {
|
||||
this.setState({ disabled: false, awaiting: null });
|
||||
const popout = (this.props.popout) ? 'popout/' : '';
|
||||
const redirect =
|
||||
`/~publish/${popout}note/${this.props.ship}/${this.props.book}/${this.state.awaiting}`;
|
||||
this.props.history.push(redirect);
|
||||
}
|
||||
}
|
||||
|
||||
titleChange(evt) {
|
||||
const submit = !(evt.target.value === '' || this.state.body === '');
|
||||
this.setState({ title: evt.target.value, submit: submit });
|
||||
}
|
||||
|
||||
bodyChange(editor, data, value) {
|
||||
const submit = !(value === '' || this.state.title === '');
|
||||
this.setState({ body: value, submit: submit });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props, state } = this;
|
||||
|
||||
const notebook = props.notebooks[props.ship][props.book];
|
||||
|
||||
const options = {
|
||||
mode: 'markdown',
|
||||
theme: 'tlon',
|
||||
lineNumbers: false,
|
||||
lineWrapping: true,
|
||||
scrollbarStyle: null,
|
||||
cursorHeight: 0.85
|
||||
};
|
||||
|
||||
const date = dateToDa(new Date()).slice(1, -10);
|
||||
|
||||
const submitStyle = ((!state.disabled && state.submit) && (state.awaiting === null))
|
||||
? { color: '#2AA779', cursor: 'pointer' }
|
||||
: { color: '#B1B2B3', cursor: 'auto' };
|
||||
|
||||
const hrefIndex = props.location.pathname.indexOf('/notebook/');
|
||||
const publishsubStr = props.location.pathname.substr(hrefIndex);
|
||||
const popoutHref = `/~publish/popout${publishsubStr}`;
|
||||
|
||||
const hiddenOnPopout = (props.popout)
|
||||
? '' : 'dib-m dib-l dib-xl';
|
||||
|
||||
const newIndex = props.location.pathname.indexOf('/new');
|
||||
const backHref = props.location.pathname.slice(0, newIndex);
|
||||
return (
|
||||
<div className='f9 h-100 relative publish'>
|
||||
<div className='w-100 dn-m dn-l dn-xl inter pt4 pb4 f9 pl4'>
|
||||
<Link to={backHref}>{'<- Back'}</Link>
|
||||
</div>
|
||||
<SidebarSwitcher
|
||||
popout={props.popout}
|
||||
sidebarShown={props.sidebarShown}
|
||||
api={this.props.api}
|
||||
classes="absolute top-1 pl4"
|
||||
/>
|
||||
<div className='w-100 tl pv4 flex justify-center'>
|
||||
<button
|
||||
className={'bg-transparent v-mid w-100 w-90-l w-80-m mw6 tl h1 pl4'}
|
||||
disabled={(!state.submit && state.disabled) || (state.awaiting !== null)}
|
||||
style={submitStyle}
|
||||
onClick={this.postSubmit}
|
||||
>
|
||||
Publish To {notebook.title}
|
||||
</button>
|
||||
<Link
|
||||
className={'dn absolute right-1 top-1 ' + hiddenOnPopout}
|
||||
to={popoutHref}
|
||||
target='_blank'
|
||||
>
|
||||
<img src='/~landscape/img/popout.png' height={16} width={16} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className='mw6 center'>
|
||||
<div className='pa4'>
|
||||
<input
|
||||
autoFocus
|
||||
type='text'
|
||||
className='bg-transparent white-d w-100 pb2'
|
||||
onChange={this.titleChange}
|
||||
placeholder='New Post'
|
||||
/>
|
||||
|
||||
<div className='gray2'>{date}</div>
|
||||
</div>
|
||||
|
||||
<div className='NewPost'>
|
||||
<CodeMirror
|
||||
value={state.body}
|
||||
options={options}
|
||||
onBeforeChange={(e, d, v) => this.bodyChange(e, d, v)}
|
||||
onChange={(editor, data, value) => {}}
|
||||
/>
|
||||
<Spinner
|
||||
text='Creating post...'
|
||||
awaiting={this.state.disabled}
|
||||
classes='absolute bottom-1 right-1 ba b--gray1-d pa2'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default NewPost;
|
@ -0,0 +1,65 @@
|
||||
import React from "react";
|
||||
import { stringToSymbol } from "~/logic/lib/util";
|
||||
import { FormikHelpers } from "formik";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { useWaitForProps } from "~/logic/lib/useWaitForProps";
|
||||
import { Notebook } from "~/types/publish-update";
|
||||
import { RouteComponentProps } from "react-router-dom";
|
||||
import { PostForm, PostFormSchema } from "./NoteForm";
|
||||
|
||||
interface NewPostProps {
|
||||
api: GlobalApi;
|
||||
book: string;
|
||||
ship: string;
|
||||
notebook: Notebook;
|
||||
}
|
||||
|
||||
export default function NewPost(props: NewPostProps & RouteComponentProps) {
|
||||
const { api, book, notebook, ship, history } = props;
|
||||
|
||||
const waiter = useWaitForProps(props, 20000);
|
||||
|
||||
const onSubmit = async (
|
||||
values: PostFormSchema,
|
||||
actions: FormikHelpers<PostFormSchema>
|
||||
) => {
|
||||
let noteId = stringToSymbol(values.title);
|
||||
const { title, body } = values;
|
||||
const host = ship.slice(1);
|
||||
|
||||
try {
|
||||
try {
|
||||
await api.publish.newNote(host, book, noteId, title, body);
|
||||
} catch (e) {
|
||||
if (e.includes("note already exists")) {
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
noteId = `${noteId}-${timestamp}`;
|
||||
await api.publish.newNote(host, book, noteId, title, body);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
await waiter((p) => {
|
||||
return !!p?.notebook?.notes[noteId];
|
||||
});
|
||||
history.push(`/~publish/notebook/${ship}/${book}/note/${noteId}`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
actions.setStatus({ error: "Posting note failed" });
|
||||
}
|
||||
};
|
||||
|
||||
const initialValues: PostFormSchema = {
|
||||
title: "",
|
||||
body: "",
|
||||
};
|
||||
|
||||
return (
|
||||
<PostForm
|
||||
initial={initialValues}
|
||||
onSubmit={onSubmit}
|
||||
submitLabel={`Publish to ${notebook?.title}`}
|
||||
loadingText="Posting..."
|
||||
/>
|
||||
);
|
||||
}
|
@ -1,198 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { InviteSearch } from '~/views/components/InviteSearch';
|
||||
import { Spinner } from '~/views/components/Spinner';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { stringToSymbol } from '~/logic/lib/util';
|
||||
|
||||
export class NewScreen extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
idName: '',
|
||||
description: '',
|
||||
invites: {
|
||||
groups: [],
|
||||
ships: []
|
||||
},
|
||||
disabled: false,
|
||||
createGroup: false,
|
||||
awaiting: false
|
||||
};
|
||||
|
||||
this.idChange = this.idChange.bind(this);
|
||||
this.descriptionChange = this.descriptionChange.bind(this);
|
||||
this.setInvite = this.setInvite.bind(this);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { props, state } = this;
|
||||
if (props.notebooks && (('~' + window.ship) in props.notebooks)) {
|
||||
if (state.awaiting in props.notebooks['~' + window.ship]) {
|
||||
const notebook = `/~${window.ship}/${state.awaiting}`;
|
||||
props.history.push('/~publish/notebook' + notebook);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
idChange(event) {
|
||||
this.setState({
|
||||
idName: event.target.value
|
||||
});
|
||||
}
|
||||
|
||||
descriptionChange(event) {
|
||||
this.setState({
|
||||
description: event.target.value
|
||||
});
|
||||
}
|
||||
|
||||
setInvite(value) {
|
||||
this.setState({ invites: value });
|
||||
}
|
||||
|
||||
onClickCreate() {
|
||||
const { props, state } = this;
|
||||
const bookId = stringToSymbol(state.idName);
|
||||
let groupInfo = null;
|
||||
if (state.invites.groups.length > 0) {
|
||||
groupInfo = {
|
||||
'group-path': state.invites.groups[0],
|
||||
'invitees': [],
|
||||
'use-preexisting': true,
|
||||
'make-managed': false
|
||||
};
|
||||
} else if (this.state.createGroup) {
|
||||
groupInfo = {
|
||||
'group-path': `/ship/~${window.ship}/${bookId}`,
|
||||
'invitees': state.invites.ships,
|
||||
'use-preexisting': false,
|
||||
'make-managed': true
|
||||
};
|
||||
} else {
|
||||
groupInfo = {
|
||||
'group-path': `/ship/~${window.ship}/${bookId}`,
|
||||
'invitees': state.invites.ships,
|
||||
'use-preexisting': false,
|
||||
'make-managed': false
|
||||
};
|
||||
}
|
||||
|
||||
const action = {
|
||||
'new-book': {
|
||||
book: bookId,
|
||||
title: state.idName,
|
||||
about: state.description,
|
||||
coms: true,
|
||||
group: groupInfo
|
||||
}
|
||||
};
|
||||
this.setState({ awaiting: bookId, disabled: true }, () => {
|
||||
props.api.publish.publishAction(action).then(() => {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
let createClasses = 'pointer db f9 green2 bg-gray0-d ba pv3 ph4 mv7 b--green2';
|
||||
if (!this.state.idName || this.state.disabled) {
|
||||
createClasses = 'db f9 gray2 ba bg-gray0-d pa2 pv3 ph4 mv7 b--gray3';
|
||||
}
|
||||
|
||||
let idErrElem = <span />;
|
||||
if (this.state.idError) {
|
||||
idErrElem = (
|
||||
<span className="f9 inter red2 db pt2">
|
||||
Notebook must have a valid name.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'h-100 w-100 mw6 pa3 pt4 overflow-x-hidden flex flex-column white-d'
|
||||
}
|
||||
>
|
||||
<div className="w-100 dn-m dn-l dn-xl inter pt1 pb6 f8">
|
||||
<Link to="/~publish/">{'⟵ All Notebooks'}</Link>
|
||||
</div>
|
||||
<h2 className="mb3 f8">New Notebook</h2>
|
||||
<div className="w-100">
|
||||
<p className="f8 mt3 lh-copy db">Name</p>
|
||||
<p className="f9 gray2 db mb2 pt1">
|
||||
Provide a name for your notebook
|
||||
</p>
|
||||
<textarea
|
||||
className={
|
||||
'f7 ba bg-gray0-d white-d pa3 db w-100 ' +
|
||||
'focus-b--black focus-b--white-d b--gray3 b--gray2-d'
|
||||
}
|
||||
placeholder="eg. My Journal"
|
||||
rows={1}
|
||||
style={{
|
||||
resize: 'none'
|
||||
}}
|
||||
onChange={this.idChange}
|
||||
value={this.state.idName}
|
||||
/>
|
||||
{idErrElem}
|
||||
<p className="f8 mt4 lh-copy db">
|
||||
Description
|
||||
<span className="gray3 ml1">(Optional)</span>
|
||||
</p>
|
||||
<p className="f9 gray2 db mb2 pt1">
|
||||
What's your notebook about?
|
||||
</p>
|
||||
<textarea
|
||||
className={
|
||||
'f7 ba bg-gray0-d white-d pa3 db w-100 ' +
|
||||
'focus-b--black focus-b--white-d b--gray3 b--gray2-d'
|
||||
}
|
||||
placeholder="Notebook description"
|
||||
rows={1}
|
||||
style={{
|
||||
resize: 'none'
|
||||
}}
|
||||
onChange={this.descriptionChange}
|
||||
value={this.state.description}
|
||||
/>
|
||||
<div className="mt4 db relative">
|
||||
<p className="f8">
|
||||
Invite
|
||||
<span className="gray3"> (Optional)</span>
|
||||
</p>
|
||||
<Link className="green2 absolute right-0 bottom-0 f9" to="/~groups/new">Create Group</Link>
|
||||
<p className="f9 gray2 db mv1 pb4">
|
||||
Selected ships or group will be invited to read your notebook. Additional writers can be added from the 'subscribers' panel.
|
||||
</p>
|
||||
</div>
|
||||
<InviteSearch
|
||||
associations={this.props.associations}
|
||||
groupResults={true}
|
||||
shipResults={true}
|
||||
groups={this.props.groups}
|
||||
contacts={this.props.contacts}
|
||||
invites={this.state.invites}
|
||||
setInvite={this.setInvite}
|
||||
/>
|
||||
<button
|
||||
disabled={this.state.disabled}
|
||||
onClick={this.onClickCreate.bind(this)}
|
||||
className={createClasses}
|
||||
>
|
||||
Create Notebook
|
||||
</button>
|
||||
<Spinner
|
||||
awaiting={this.state.awaiting}
|
||||
classes="mt3"
|
||||
text="Creating notebook..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default NewScreen;
|
103
pkg/interface/src/views/apps/publish/components/lib/new.tsx
Normal file
103
pkg/interface/src/views/apps/publish/components/lib/new.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { Box, Input, Col } from "@tlon/indigo-react";
|
||||
import { Formik, Form } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { AsyncButton } from "~/views/components/AsyncButton";
|
||||
import { FormError } from "~/views/components/FormError";
|
||||
import { RouteComponentProps } from "react-router-dom";
|
||||
import { stringToSymbol } from "~/logic/lib/util";
|
||||
import GroupSearch from "~/views/components/GroupSearch";
|
||||
import { Associations } from "~/types/metadata-update";
|
||||
import { useWaitForProps } from "~/logic/lib/useWaitForProps";
|
||||
import { Notebooks } from "~/types/publish-update";
|
||||
import { Groups } from "~/types/group-update";
|
||||
|
||||
interface FormSchema {
|
||||
name: string;
|
||||
description: string;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
const formSchema = Yup.object({
|
||||
name: Yup.string().required("Notebook must have a name"),
|
||||
description: Yup.string(),
|
||||
group: Yup.string(),
|
||||
});
|
||||
|
||||
interface NewScreenProps {
|
||||
api: GlobalApi;
|
||||
associations: Associations;
|
||||
notebooks: Notebooks;
|
||||
groups: Groups;
|
||||
}
|
||||
|
||||
export function NewScreen(props: NewScreenProps & RouteComponentProps) {
|
||||
const { history } = props;
|
||||
|
||||
const waiter = useWaitForProps(props, 5000);
|
||||
|
||||
const onSubmit = async (values: FormSchema, actions) => {
|
||||
const bookId = stringToSymbol(values.name);
|
||||
try {
|
||||
const { name, description, group } = values;
|
||||
await props.api.publish.newBook(bookId, name, description, group);
|
||||
await waiter((p) => !!p?.notebooks?.[`~${window.ship}`]?.[bookId]);
|
||||
if (!group) {
|
||||
await waiter((p) => !!p?.groups?.[`/ship/~${window.ship}/${bookId}`]);
|
||||
}
|
||||
actions.setStatus({ success: null });
|
||||
history.push(`/~publish/notebook/~${window.ship}/${bookId}`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
actions.setStatus({ error: "Notebook creation failed" });
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Col p={3}>
|
||||
<Box mb={4} color="black">New Notebook</Box>
|
||||
<Formik
|
||||
validationSchema={formSchema}
|
||||
initialValues={{ name: "", description: "", group: "" }}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Form>
|
||||
<Box
|
||||
display="grid"
|
||||
gridTemplateRows="auto"
|
||||
gridRowGap={2}
|
||||
gridTemplateColumns="300px"
|
||||
>
|
||||
<Input
|
||||
id="name"
|
||||
label="Name"
|
||||
caption="Provide a name for your notebook"
|
||||
placeholder="eg. My Journal"
|
||||
/>
|
||||
<Input
|
||||
id="description"
|
||||
label="Description"
|
||||
caption="What's your notebook about?"
|
||||
placeholder="Notebook description"
|
||||
/>
|
||||
<GroupSearch
|
||||
id="group"
|
||||
label="Group"
|
||||
caption="What group is the notebook for?"
|
||||
associations={props.associations}
|
||||
/>
|
||||
|
||||
<Box justifySelf="start">
|
||||
<AsyncButton loadingText="Creating..." type="submit" border>
|
||||
Create Notebook
|
||||
</AsyncButton>
|
||||
</Box>
|
||||
<FormError message="Notebook Creation failed" />
|
||||
</Box>
|
||||
</Form>
|
||||
</Formik>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
export default NewScreen;
|
@ -1,59 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export class NoteNavigation extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
render() {
|
||||
let nextComponent = null;
|
||||
let prevComponent = null;
|
||||
let nextUrl = '';
|
||||
let prevUrl = '';
|
||||
|
||||
const popout = (this.props.popout) ? 'popout/' : '';
|
||||
|
||||
if (this.props.next && this.props.prev) {
|
||||
nextUrl = `/~publish/${popout}note/${this.props.ship}/${this.props.book}/${this.props.next.id}`;
|
||||
prevUrl = `/~publish/${popout}note/${this.props.ship}/${this.props.book}/${this.props.prev.id}`;
|
||||
nextComponent =
|
||||
<Link to={nextUrl} className="di flex-column flex-auto tr w-100 pv6 bt bb b--gray3">
|
||||
<div className="f9 gray2 mb2">Next</div>
|
||||
<div className="f9 mb1 truncate">{this.props.next.title}</div>
|
||||
<div className="f9 gray2">{this.props.next.date}</div>
|
||||
</Link>;
|
||||
|
||||
prevComponent =
|
||||
<Link to={prevUrl} className="di flex-column flex-auto w-100 pv6 bt br bb b--gray3">
|
||||
<div className="f9 gray2 mb2">Previous</div>
|
||||
<div className="f9 mb1 truncate">{this.props.prev.title}</div>
|
||||
<div className="f9 gray2">{this.props.prev.date}</div>
|
||||
</Link>;
|
||||
} else if (this.props.prev) {
|
||||
prevUrl = `/~publish/${popout}note/${this.props.ship}/${this.props.book}/${this.props.prev.id}`;
|
||||
prevComponent =
|
||||
<Link to={prevUrl} className="di flex-column flex-auto w-100 pv6 bt bb b--gray3">
|
||||
<div className="f9 gray2 mb2">Previous</div>
|
||||
<div className="f9 mb1 truncate">{this.props.prev.title}</div>
|
||||
<div className="f9 gray2">{this.props.prev.date}</div>
|
||||
</Link>;
|
||||
} else if (this.props.next) {
|
||||
nextUrl = `/~publish/${popout}note/${this.props.ship}/${this.props.book}/${this.props.next.id}`;
|
||||
nextComponent =
|
||||
<Link to={nextUrl} className="di flex-column flex-auto tr w-100 pv6 bt bb b--gray3">
|
||||
<div className="f9 gray2 mb2">Next</div>
|
||||
<div className="f9 mb1 truncate">{this.props.next.title}</div>
|
||||
<div className="f9 gray2">{this.props.next.date}</div>
|
||||
</Link>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex pt4">
|
||||
{prevComponent}
|
||||
{nextComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default NoteNavigation;
|
@ -1,269 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { SidebarSwitcher } from '~/views/components/SidebarSwitch';
|
||||
import { Spinner } from '~/views/components/Spinner';
|
||||
import { Comments } from './comments';
|
||||
import { NoteNavigation } from './note-navigation';
|
||||
import moment from 'moment';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { cite } from '~/logic/lib/util';
|
||||
|
||||
export class Note extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
deleting: false,
|
||||
sentRead: false
|
||||
};
|
||||
moment.updateLocale('en', {
|
||||
relativeTime: {
|
||||
past: function(input) {
|
||||
return input === 'just now'
|
||||
? input
|
||||
: input + ' ago';
|
||||
},
|
||||
s : 'just now',
|
||||
future : 'in %s',
|
||||
m : '1m',
|
||||
mm : '%dm',
|
||||
h : '1h',
|
||||
hh : '%dh',
|
||||
d : '1d',
|
||||
dd : '%dd',
|
||||
M : '1 month',
|
||||
MM : '%d months',
|
||||
y : '1 year',
|
||||
yy : '%d years'
|
||||
}
|
||||
});
|
||||
this.scrollElement = React.createRef();
|
||||
this.onScroll = this.onScroll.bind(this);
|
||||
this.deletePost = this.deletePost.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.componentDidUpdate();
|
||||
this.onScroll();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { props, state } = this;
|
||||
if ((prevProps && prevProps.api !== props.api) || props.api) {
|
||||
if (!(props.notebooks[props.ship]?.[props.book]?.notes?.[props.note]?.file)) {
|
||||
props.api.publish.fetchNote(props.ship, props.book, props.note);
|
||||
}
|
||||
|
||||
if (prevProps && prevProps.note !== props.note) {
|
||||
this.setState({ sentRead: false });
|
||||
}
|
||||
|
||||
if (!state.sentRead &&
|
||||
props.notebooks?.[props.ship]?.[props.book]?.notes?.[props.note] &&
|
||||
!props.notebooks[props.ship][props.book].notes[props.note].read) {
|
||||
const readAction = {
|
||||
read: {
|
||||
who: props.ship.slice(1),
|
||||
book: props.book,
|
||||
note: props.note
|
||||
}
|
||||
};
|
||||
this.setState({ sentRead: true }, () => {
|
||||
props.api.publish.publishAction(readAction);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onScroll() {
|
||||
const notebook = this.props.notebooks?.[this.props.ship]?.[this.props.book];
|
||||
const note = notebook?.notes?.[this.props.note];
|
||||
|
||||
if (!note?.comments) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollTop = this.scrollElement.scrollTop;
|
||||
const clientHeight = this.scrollElement.clientHeight;
|
||||
const scrollHeight = this.scrollElement.scrollHeight;
|
||||
|
||||
let atBottom = false;
|
||||
if (scrollHeight - scrollTop - clientHeight < 40) {
|
||||
atBottom = true;
|
||||
}
|
||||
|
||||
const loadedComments = note.comments.length;
|
||||
const allComments = note['num-comments'];
|
||||
|
||||
const fullyLoaded = (loadedComments === allComments);
|
||||
|
||||
if (atBottom && !fullyLoaded) {
|
||||
this.props.api.publish.fetchCommentsPage(this.props.ship,
|
||||
this.props.book, this.props.note, loadedComments, 30);
|
||||
}
|
||||
}
|
||||
|
||||
deletePost() {
|
||||
const { props } = this;
|
||||
const deleteAction = {
|
||||
'del-note': {
|
||||
who: this.props.ship.slice(1),
|
||||
book: this.props.book,
|
||||
note: this.props.note
|
||||
}
|
||||
};
|
||||
const popout = (props.popout) ? 'popout/' : '';
|
||||
const baseUrl = `/~publish/${popout}notebook/${props.ship}/${props.book}`;
|
||||
this.setState({ deleting: true });
|
||||
this.props.api.publish.publishAction(deleteAction)
|
||||
.then(() => {
|
||||
props.history.push(baseUrl);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props } = this;
|
||||
const notebook = props.notebooks?.[props.ship]?.[props.book] || {};
|
||||
const comments = notebook?.notes?.[props.note]?.comments || false;
|
||||
const title = notebook?.notes?.[props.note]?.title || '';
|
||||
const author = notebook?.notes?.[props.note]?.author || '';
|
||||
const file = notebook?.notes?.[props.note]?.file || '';
|
||||
const date = moment(notebook.notes?.[props.note]?.['date-created']).fromNow() || 0;
|
||||
|
||||
const contact = author.substr(1) in props.contacts
|
||||
? props.contacts[author.substr(1)] : false;
|
||||
|
||||
let name = author;
|
||||
if (contact) {
|
||||
name = (contact.nickname.length > 0)
|
||||
? contact.nickname : author;
|
||||
}
|
||||
|
||||
if (name === author) {
|
||||
name = cite(author);
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const newfile = file.slice(file.indexOf(';>')+2);
|
||||
const prevId = notebook?.notes?.[props.note]?.['prev-note'] || null;
|
||||
const nextId = notebook?.notes?.[props.note]?.['next-note'] || null;
|
||||
const prevDate = moment(notebook?.notes?.[prevId]?.['date-created']).fromNow() || 0;
|
||||
const nextDate = moment(notebook?.notes?.[nextId]?.['date-created']).fromNow() || 0;
|
||||
|
||||
const prev = (prevId === null)
|
||||
? null
|
||||
: {
|
||||
id: prevId,
|
||||
title: notebook?.notes?.[prevId]?.title,
|
||||
date: prevDate
|
||||
};
|
||||
const next = (nextId === null)
|
||||
? null
|
||||
: {
|
||||
id: nextId,
|
||||
title: notebook?.notes?.[nextId]?.title,
|
||||
date: nextDate
|
||||
};
|
||||
|
||||
let editPost = null;
|
||||
const editUrl = props.location.pathname + '/edit';
|
||||
if (`~${window.ship}` === author) {
|
||||
editPost = <div className="dib">
|
||||
<Link className="green2 f9" to={editUrl}>Edit</Link>
|
||||
<p className="dib f9 red2 ml2 pointer"
|
||||
onClick={(() => this.deletePost())}
|
||||
>Delete</p>
|
||||
</div>;
|
||||
}
|
||||
|
||||
const popout = (props.popout) ? 'popout/' : '';
|
||||
|
||||
const hrefIndex = props.location.pathname.indexOf('/note/');
|
||||
const publishsubStr = props.location.pathname.substr(hrefIndex);
|
||||
const popoutHref = `/~publish/popout${publishsubStr}`;
|
||||
|
||||
const hiddenOnPopout = props.popout ? '' : 'dib-m dib-l dib-xl';
|
||||
|
||||
const baseUrl = `/~publish/${popout}notebook/${props.ship}/${props.book}`;
|
||||
return (
|
||||
<div
|
||||
className='h-100 overflow-y-scroll'
|
||||
onScroll={this.onScroll}
|
||||
ref={(el) => {
|
||||
this.scrollElement = el;
|
||||
}}
|
||||
>
|
||||
<SidebarSwitcher
|
||||
popout={props.popout}
|
||||
sidebarShown={props.sidebarShown}
|
||||
api={this.props.api}
|
||||
classes="absolute top-1 pl4"
|
||||
/>
|
||||
<div className='h-100 flex flex-column items-center pa4'>
|
||||
<div className='w-100 flex justify-center pb6'>
|
||||
<Link className='f9 w-100 w-90-m w-90-l mw6 tl' to={baseUrl}>
|
||||
{'<- Notebook index'}
|
||||
</Link>
|
||||
<Link
|
||||
to={popoutHref}
|
||||
className={'dn absolute right-1 top-1 ' + hiddenOnPopout}
|
||||
target='_blank'
|
||||
>
|
||||
<img src='/~landscape/img/popout.png' height={16} width={16} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className='w-100 mw6'>
|
||||
<div className='flex flex-column'>
|
||||
<div className='f9 mb1' style={{ overflowWrap: 'break-word' }}>
|
||||
{title}
|
||||
</div>
|
||||
<div className='flex mb6'>
|
||||
<div
|
||||
className={
|
||||
'di f9 gray2 mr2 ' + (contact.nickname ? null : 'mono')
|
||||
}
|
||||
title={author}
|
||||
style={{ lineHeight: 1.6 }}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
<div className='di' style={{ lineHeight: 1 }}>
|
||||
<span className='f9 gray2 dib'>{date}</span>
|
||||
<span className='ml2 dib'>{editPost}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='md' style={{ overflowWrap: 'break-word' }}>
|
||||
<ReactMarkdown source={newfile} linkTarget={'_blank'} />
|
||||
</div>
|
||||
<NoteNavigation
|
||||
popout={props.popout}
|
||||
prev={prev}
|
||||
next={next}
|
||||
ship={props.ship}
|
||||
book={props.book}
|
||||
/>
|
||||
<Comments
|
||||
enabled={notebook.comments}
|
||||
ship={props.ship}
|
||||
book={props.book}
|
||||
note={props.note}
|
||||
comments={comments}
|
||||
contacts={props.contacts}
|
||||
api={this.props.api}
|
||||
/>
|
||||
<Spinner
|
||||
text='Deleting post...'
|
||||
awaiting={this.state.deleting}
|
||||
classes='absolute bottom-1 right-1 ba b--gray1-d pa2'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Note;
|
@ -1,28 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export class NotebookItem extends Component {
|
||||
render() {
|
||||
const { props } = this;
|
||||
|
||||
const selectedClass = (props.selected) ? 'bg-gray5 bg-gray1-d c-default' : 'pointer hover-bg-gray5 hover-bg-gray1-d';
|
||||
|
||||
const unread = (props.unreadCount > 0)
|
||||
? <p className="dib f9 fr"><span className="dib white bg-gray3 bg-gray2-d fw6 br1" style={{ padding: '1px 5px' }}>
|
||||
{props.unreadCount}
|
||||
</span></p> : <span />;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={'/~publish/notebook/' + props.path}
|
||||
>
|
||||
<div className={'w-100 v-mid f9 ph5 pv1 ' + selectedClass}>
|
||||
<p className="dib f9">{props.title}</p>
|
||||
{unread}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default NotebookItem;
|
@ -1,103 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import moment from 'moment';
|
||||
import { Link } from 'react-router-dom';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { cite } from '~/logic/lib/util';
|
||||
|
||||
export class NotebookPosts extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
moment.updateLocale('en', {
|
||||
relativeTime: {
|
||||
past: function(input) {
|
||||
return input === 'just now'
|
||||
? input
|
||||
: input + ' ago';
|
||||
},
|
||||
s : 'just now',
|
||||
future : 'in %s',
|
||||
m : '1m',
|
||||
mm : '%dm',
|
||||
h : '1h',
|
||||
hh : '%dh',
|
||||
d : '1d',
|
||||
dd : '%dd',
|
||||
M : '1 month',
|
||||
MM : '%d months',
|
||||
y : '1 year',
|
||||
yy : '%d years'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props } = this;
|
||||
const notes = [];
|
||||
|
||||
for (let i=0; i<props.list.length; i++) {
|
||||
const noteId = props.list[i];
|
||||
const note = props.notes[noteId];
|
||||
if (!note) {
|
||||
break;
|
||||
}
|
||||
|
||||
const contact = note.author.substr(1) in props.contacts
|
||||
? props.contacts[note.author.substr(1)] : false;
|
||||
|
||||
let name = note.author;
|
||||
if (contact) {
|
||||
name = (contact.nickname.length > 0)
|
||||
? contact.nickname : note.author;
|
||||
}
|
||||
if (name === note.author) {
|
||||
name = cite(note.author);
|
||||
}
|
||||
let comment = 'No Comments';
|
||||
if (note['num-comments'] == 1) {
|
||||
comment = '1 Comment';
|
||||
} else if (note['num-comments'] > 1) {
|
||||
comment = `${note['num-comments']} Comments`;
|
||||
}
|
||||
const date = moment(note['date-created']).fromNow();
|
||||
const popout = (props.popout) ? 'popout/' : '';
|
||||
const url = `/~publish/${popout}note/${props.host}/${props.notebookName}/${noteId}`;
|
||||
|
||||
notes.push(
|
||||
<Link key={i} to={url}>
|
||||
<div className="mv6">
|
||||
<div className="mb1"
|
||||
style={{ overflowWrap: 'break-word' }}
|
||||
>
|
||||
{note.title}
|
||||
</div>
|
||||
<p className="mb1"
|
||||
style={{ overflowWrap: 'break-word' }}
|
||||
>
|
||||
<ReactMarkdown
|
||||
unwrapDisallowed
|
||||
allowedTypes={['text', 'root', 'break', 'paragraph']}
|
||||
source={note.snippet}
|
||||
/>
|
||||
</p>
|
||||
<div className="flex">
|
||||
<div className={(contact.nickname ? null : 'mono') +
|
||||
' gray2 mr3'}
|
||||
title={note.author}
|
||||
>{name}</div>
|
||||
<div className={((note.read) ? "gray2 " : "green2 ") + "mr3"}>{date}</div>
|
||||
<div className="gray2">{comment}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-col">
|
||||
{notes}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default NotebookPosts;
|
@ -1,275 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { SidebarSwitcher } from '~/views/components/SidebarSwitch';
|
||||
import { NotebookPosts } from './notebook-posts';
|
||||
import { Subscribers } from './subscribers';
|
||||
import { Settings } from './settings';
|
||||
import { cite } from '~/logic/lib/util';
|
||||
import { roleForShip } from '~/logic/lib/group';
|
||||
|
||||
export class Notebook extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.onScroll = this.onScroll.bind(this);
|
||||
this.unsubscribe = this.unsubscribe.bind(this);
|
||||
}
|
||||
|
||||
onScroll() {
|
||||
const notebook = this.props.notebooks[this.props.ship][this.props.book];
|
||||
const scrollTop = this.scrollElement.scrollTop;
|
||||
const clientHeight = this.scrollElement.clientHeight;
|
||||
const scrollHeight = this.scrollElement.scrollHeight;
|
||||
|
||||
let atBottom = false;
|
||||
if (scrollHeight - scrollTop - clientHeight < 40) {
|
||||
atBottom = true;
|
||||
}
|
||||
if (!notebook.notes && this.props.api) {
|
||||
this.props.api.publish.fetchNotebook(this.props.ship, this.props.book);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadedNotes = Object.keys(notebook?.notes).length || 0;
|
||||
const allNotes = notebook?.['notes-by-date'].length || 0;
|
||||
|
||||
const fullyLoaded = (loadedNotes === allNotes);
|
||||
|
||||
if (atBottom && !fullyLoaded) {
|
||||
this.props.api.publish.fetchNotesPage(this.props.ship, this.props.book, loadedNotes, 30);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { props } = this;
|
||||
if ((prevProps && (prevProps.api !== props.api)) || props.api) {
|
||||
const notebook = props.notebooks?.[props.ship]?.[props.book];
|
||||
if (!notebook?.subscribers) {
|
||||
props.api.publish.fetchNotebook(props.ship, props.book);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.componentDidUpdate();
|
||||
const notebook = this.props.notebooks?.[this.props.ship]?.[this.props.book];
|
||||
if (notebook?.notes) {
|
||||
this.onScroll();
|
||||
}
|
||||
}
|
||||
|
||||
unsubscribe() {
|
||||
const action = {
|
||||
unsubscribe: {
|
||||
who: this.props.ship.slice(1),
|
||||
book: this.props.book
|
||||
}
|
||||
};
|
||||
this.props.api.publish.publishAction(action);
|
||||
this.props.history.push('/~publish');
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props } = this;
|
||||
|
||||
// popout logic
|
||||
const hrefIndex = props.location.pathname.indexOf('/notebook/');
|
||||
const publishsubStr = props.location.pathname.substr(hrefIndex);
|
||||
const popoutHref = `/~publish/popout${publishsubStr}`;
|
||||
|
||||
const hiddenOnPopout = props.popout ? '' : 'dib-m dib-l dib-xl';
|
||||
|
||||
const notebook = props.notebooks?.[props.ship]?.[props.book];
|
||||
|
||||
const tabStyles = {
|
||||
posts: 'bb b--gray4 b--gray2-d gray2 pv4 ph2',
|
||||
about: 'bb b--gray4 b--gray2-d gray2 pv4 ph2',
|
||||
subscribers: 'bb b--gray4 b--gray2-d gray2 pv4 ph2',
|
||||
settings: 'bb b--gray4 b--gray2-d pr2 gray2 pv4 ph2'
|
||||
};
|
||||
tabStyles[props.view] = 'bb b--black b--white-d black white-d pv4 ph2';
|
||||
|
||||
let inner = null;
|
||||
switch (props.view) {
|
||||
case 'posts': {
|
||||
const notesList = notebook?.['notes-by-date'] || [];
|
||||
const notes = notebook?.notes || null;
|
||||
inner = <NotebookPosts notes={notes}
|
||||
popout={props.popout}
|
||||
list={notesList}
|
||||
host={props.ship}
|
||||
notebookName={props.book}
|
||||
contacts={props.notebookContacts}
|
||||
/>;
|
||||
break;
|
||||
}
|
||||
case 'about':
|
||||
inner = <p className="f8 lh-solid">{notebook?.about}</p>;
|
||||
break;
|
||||
case 'subscribers':
|
||||
inner = <Subscribers
|
||||
host={this.props.ship}
|
||||
book={this.props.book}
|
||||
notebook={notebook}
|
||||
contacts={this.props.contacts}
|
||||
associations={this.props.associations}
|
||||
groups={this.props.groups}
|
||||
api={this.props.api}
|
||||
/>;
|
||||
break;
|
||||
case 'settings':
|
||||
inner = <Settings
|
||||
host={this.props.ship}
|
||||
book={this.props.book}
|
||||
notebook={notebook}
|
||||
groups={this.props.groups}
|
||||
contacts={this.props.contacts}
|
||||
associations={this.props.associations}
|
||||
history={this.props.history}
|
||||
api={this.props.api}
|
||||
/>;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// displaying nicknames, sigil colors for contacts
|
||||
const contact = props.ship.substr(1) in props.notebookContacts
|
||||
? props.notebookContacts[props.ship.substr(1)] : false;
|
||||
let name = props.ship;
|
||||
if (contact) {
|
||||
name = (contact.nickname.length > 0)
|
||||
? contact.nickname : props.ship;
|
||||
}
|
||||
|
||||
if (name === props.ship) {
|
||||
name = cite(props.ship);
|
||||
}
|
||||
|
||||
const popout = (props.popout) ? 'popout/' : '';
|
||||
const base = `/~publish/${popout}notebook/${props.ship}/${props.book}`;
|
||||
const about = base + '/about';
|
||||
const subs = base + '/subscribers';
|
||||
const settings = base + '/settings';
|
||||
const newUrl = base + '/new';
|
||||
|
||||
let newPost = null;
|
||||
if (notebook?.['writers-group-path'] in props.groups) {
|
||||
const group = props.groups[notebook?.['writers-group-path']];
|
||||
const writers = group.tags?.publish?.[`writers-${props.book}`] || new Set();
|
||||
if (props.ship === `~${window.ship}` || writers.has(ship)) {
|
||||
newPost = (
|
||||
<Link
|
||||
to={newUrl}
|
||||
className='NotebookButton bg-light-green green2 pa2'
|
||||
>
|
||||
New Post
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const unsub = (window.ship === props.ship.slice(1))
|
||||
? null
|
||||
: <button onClick={this.unsubscribe}
|
||||
className="NotebookButton bg-white bg-gray0-d black white-d ba b--black b--gray2-d ml3 ph1"
|
||||
>
|
||||
Unsubscribe
|
||||
</button>;
|
||||
|
||||
|
||||
const group = props.groups[notebook?.['writers-group-path']];
|
||||
const role = group ? roleForShip(group, window.ship) : undefined;
|
||||
|
||||
const subsComponent = (this.props.ship.slice(1) === window.ship) || (role === 'admin')
|
||||
? (<Link to={subs} className={tabStyles.subscribers}>
|
||||
Subscribers
|
||||
</Link>)
|
||||
: null
|
||||
|
||||
const settingsComponent = (this.props.ship.slice(1) !== window.ship)
|
||||
? null
|
||||
: <Link to={settings} className={tabStyles.settings}>
|
||||
Settings
|
||||
</Link>;
|
||||
|
||||
return (
|
||||
<div
|
||||
className='overflow-y-scroll h-100'
|
||||
style={{ paddingLeft: 16, paddingRight: 16 }}
|
||||
onScroll={this.onScroll}
|
||||
ref={(el) => {
|
||||
this.scrollElement = el;
|
||||
}}
|
||||
>
|
||||
<div className='w-100 dn-m dn-l dn-xl inter pt4 pb6 f9'>
|
||||
<Link to='/~publish'>{'<- All Notebooks'}</Link>
|
||||
</div>
|
||||
<div style={{ paddingTop: 11 }}>
|
||||
<SidebarSwitcher
|
||||
popout={props.popout}
|
||||
sidebarShown={props.sidebarShown}
|
||||
api={this.props.api}
|
||||
classes="absolute top-1"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className='center mw6 f9 h-100'
|
||||
style={{ paddingLeft: 16, paddingRight: 16 }}
|
||||
>
|
||||
<Link
|
||||
className={'dn absolute right-1 top-1 ' + hiddenOnPopout}
|
||||
to={popoutHref}
|
||||
target='_blank'
|
||||
>
|
||||
<img src='/~landscape/img/popout.png' height={16} width={16} />
|
||||
</Link>
|
||||
<div className='h-100 pt0 pt8-m pt8-l pt8-xl no-scrollbar'>
|
||||
<div className='flex justify-between' style={{ marginBottom: 32 }}>
|
||||
<div className='flex-col'>
|
||||
<div className='mb1'>{notebook?.title}</div>
|
||||
<span>
|
||||
<span className='gray3 mr1'>by</span>
|
||||
<span
|
||||
className={contact.nickname ? null : 'mono'}
|
||||
title={props.ship}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex'>
|
||||
{newPost}
|
||||
{unsub}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex' style={{ marginBottom: 24 }}>
|
||||
<Link to={base} className={tabStyles.posts}>
|
||||
All Posts
|
||||
</Link>
|
||||
<Link to={about} className={tabStyles.about}>
|
||||
About
|
||||
</Link>
|
||||
{subsComponent}
|
||||
{settingsComponent}
|
||||
<div
|
||||
className='bb b--gray4 b--gray2-d gray2 pv4 ph2'
|
||||
style={{ flexGrow: 1 }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{ height: 'calc(100% - 188px)' }}
|
||||
className='f9 lh-solid'
|
||||
>
|
||||
{inner}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Notebook;
|
@ -1,289 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Spinner } from '~/views/components/Spinner';
|
||||
import { InviteSearch } from '~/views/components/InviteSearch';
|
||||
import Toggle from '~/views/components/toggle';
|
||||
|
||||
export class Settings extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
title: '',
|
||||
description: '',
|
||||
comments: false,
|
||||
disabled: false,
|
||||
type: 'Editing',
|
||||
targetGroup: null,
|
||||
inclusive: false
|
||||
};
|
||||
this.deleteNotebook = this.deleteNotebook.bind(this);
|
||||
this.changeTitle = this.changeTitle.bind(this);
|
||||
this.changeDescription = this.changeDescription.bind(this);
|
||||
this.changeComments = this.changeComments.bind(this);
|
||||
this.changeTargetGroup = this.changeTargetGroup.bind(this);
|
||||
this.changeInclusive = this.changeInclusive.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { props } = this;
|
||||
if (props.notebook) {
|
||||
this.setState({
|
||||
title: props.notebook.title,
|
||||
description: props.notebook.about,
|
||||
comments: props.notebook.comments
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { props } = this;
|
||||
if (prevProps !== props) {
|
||||
if (props.notebook) {
|
||||
if (prevProps.notebook && prevProps.notebook !== props.notebook) {
|
||||
if (prevProps.notebook.title !== props.notebook.title) {
|
||||
this.setState({ title: props.notebook.title });
|
||||
}
|
||||
if (prevProps.notebook.about !== props.notebook.about) {
|
||||
this.setState({ description: props.notebook.about });
|
||||
}
|
||||
if (prevProps.notebook.comments !== props.notebook.comments) {
|
||||
this.setState({ comments: props.notebook.comments });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
changeTitle(event) {
|
||||
this.setState({ title: event.target.value });
|
||||
}
|
||||
|
||||
changeDescription(event) {
|
||||
this.setState({ description: event.target.value });
|
||||
}
|
||||
|
||||
changeComments() {
|
||||
this.setState({ comments: !this.state.comments, disabled: true }, (() => {
|
||||
this.props.api.publish.publishAction({
|
||||
'edit-book': {
|
||||
book: this.props.book,
|
||||
title: this.props.notebook.title,
|
||||
about: this.props.notebook.about,
|
||||
coms: this.state.comments,
|
||||
group: null
|
||||
}
|
||||
}).then(() => {
|
||||
this.setState({ disabled: false });
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
deleteNotebook() {
|
||||
const action = {
|
||||
'del-book': {
|
||||
book: this.props.book
|
||||
}
|
||||
};
|
||||
this.setState({ disabled: true, type: 'Deleting' });
|
||||
this.props.api.publish.publishAction(action).then(() => {
|
||||
this.props.history.push('/~publish');
|
||||
});
|
||||
}
|
||||
|
||||
changeTargetGroup(target) {
|
||||
if (target.groups.length === 1) {
|
||||
this.setState({ targetGroup: target.groups[0] });
|
||||
} else {
|
||||
this.setState({ targetGroup: null });
|
||||
}
|
||||
}
|
||||
|
||||
changeInclusive(event) {
|
||||
this.setState({ inclusive: Boolean(event.target.checked) });
|
||||
}
|
||||
|
||||
groupifyNotebook() {
|
||||
const { props, state } = this;
|
||||
|
||||
this.setState({
|
||||
disabled: true,
|
||||
type: 'Converting'
|
||||
}, (() => {
|
||||
this.props.api.publish.publishAction({
|
||||
groupify: {
|
||||
book: props.book,
|
||||
target: state.targetGroup,
|
||||
inclusive: state.inclusive
|
||||
}
|
||||
}).then(() => this.setState({ disabled: false }));
|
||||
}));
|
||||
}
|
||||
|
||||
renderGroupify() {
|
||||
const { props, state } = this;
|
||||
|
||||
const owner = (props.host.slice(1) === window.ship);
|
||||
|
||||
const ownedUnmanaged =
|
||||
owner &&
|
||||
!props.contacts[props.notebook?.['writers-group-path']];
|
||||
|
||||
if (!ownedUnmanaged) {
|
||||
return null;
|
||||
} else {
|
||||
// don't give the option to make inclusive if we don't own the target
|
||||
// group
|
||||
const targetOwned = (state.targetGroup)
|
||||
? Boolean(state.targetGroup.includes(`/~${window.ship}/`))
|
||||
: false;
|
||||
let inclusiveToggle = <div />;
|
||||
if (targetOwned) {
|
||||
inclusiveToggle = (
|
||||
<div className="mt4">
|
||||
<Toggle
|
||||
boolean={state.inclusive}
|
||||
change={this.changeInclusive}
|
||||
/>
|
||||
<span className="dib f9 white-d inter ml3">
|
||||
Add all members to group
|
||||
</span>
|
||||
<p className="f9 gray2 pt1" style={{ paddingLeft: 40 }}>
|
||||
Add notebook members to the group if they aren't in it yet
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={'w-100 fl mt3 mb3'} style={{ maxWidth: '29rem' }}>
|
||||
{this.renderHeader(
|
||||
'Convert Notebook',
|
||||
'Convert this notebook into a group with associated chat, or select a group to add this notebook to.')}
|
||||
<InviteSearch
|
||||
groups={props.groups}
|
||||
contacts={props.contacts}
|
||||
associations={props.associations}
|
||||
groupResults={true}
|
||||
shipResults={false}
|
||||
invites={{
|
||||
groups: state.targetGroup ? [state.targetGroup] : [],
|
||||
ships: []
|
||||
}}
|
||||
setInvite={this.changeTargetGroup}
|
||||
/>
|
||||
{inclusiveToggle}
|
||||
<button
|
||||
onClick={this.groupifyNotebook.bind(this)}
|
||||
className={'dib f9 black gray4-d bg-gray0-d ba pa2 mt4 b--black b--gray1-d pointer'}
|
||||
disabled={this.state.disabled}
|
||||
>
|
||||
{state.targetGroup ? 'Add to group' : 'Convert to group'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
renderHeader(title, subtitle) {
|
||||
return (
|
||||
<>
|
||||
<p className="f9 mt6 lh-copy">{title}</p>
|
||||
<p className="f9 gray2 db mb4">{subtitle}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.host.slice(1) === window.ship) {
|
||||
return (
|
||||
<div className="flex-column">
|
||||
{this.renderGroupify()}
|
||||
{this.renderHeader(
|
||||
'Delete Notebook',
|
||||
'Permanently delete this notebook. (All current members will no longer see this notebook)')}
|
||||
<button
|
||||
className="bg-transparent b--red2 red2 pointer dib f9 ba pa2"
|
||||
onClick={this.deleteNotebook}
|
||||
>
|
||||
Delete this notebook
|
||||
</button>
|
||||
{this.renderHeader('Rename', 'Change the name of this notebook')}
|
||||
<div className="relative w-100 flex" style={{ maxWidth: '29rem' }}>
|
||||
<input
|
||||
className={
|
||||
'f8 ba b--gray3 b--gray2-d bg-gray0-d white-d ' +
|
||||
'focus-b--black focus-b--white-d pa3 db w-100 flex-auto mr3'
|
||||
}
|
||||
value={this.state.title}
|
||||
onChange={this.changeTitle}
|
||||
disabled={this.state.disabled}
|
||||
onBlur={() => {
|
||||
this.setState({ disabled: true });
|
||||
this.props.api.publish
|
||||
.publishAction({
|
||||
'edit-book': {
|
||||
book: this.props.book,
|
||||
title: this.state.title,
|
||||
about: this.props.notebook.about,
|
||||
coms: this.props.notebook.comments,
|
||||
group: null
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
this.setState({ disabled: false });
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{this.renderHeader("Change description", "Change the description of this notebook")}
|
||||
<div className="relative w-100 flex" style={{ maxWidth: '29rem' }}>
|
||||
<input
|
||||
className={
|
||||
'f8 ba b--gray3 b--gray2-d bg-gray0-d white-d ' +
|
||||
'focus-b--black focus-b--white-d pa3 db w-100 flex-auto mr3'
|
||||
}
|
||||
value={this.state.description}
|
||||
onChange={this.changeDescription}
|
||||
onBlur={() => {
|
||||
this.setState({ disabled: true });
|
||||
this.props.api.publish
|
||||
.publishAction({
|
||||
'edit-book': {
|
||||
book: this.props.book,
|
||||
title: this.props.notebook.title,
|
||||
about: this.state.description,
|
||||
coms: this.props.notebook.comments,
|
||||
group: null
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
this.setState({ disabled: false });
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mv6">
|
||||
<Toggle
|
||||
boolean={this.state.comments}
|
||||
change={this.changeComments}
|
||||
/>
|
||||
<span className="dib f9 white-d inter ml3">Comments</span>
|
||||
<p className="f9 gray2 pt1" style={{ paddingLeft: 40 }}>
|
||||
Subscribers may comment when enabled
|
||||
</p>
|
||||
</div>
|
||||
<Spinner
|
||||
awaiting={this.state.disabled}
|
||||
classes="absolute right-1 bottom-1 pa2 ba b--black b--gray0-d white-d"
|
||||
text={`${this.state.type} notebook...`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Settings;
|
@ -1,127 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import SidebarInvite from '~/views/components/SidebarInvite';
|
||||
import { Welcome } from './welcome';
|
||||
import { GroupItem } from './group-item';
|
||||
import { alphabetiseAssociations } from '~/logic/lib/util';
|
||||
|
||||
export class Sidebar extends Component {
|
||||
render() {
|
||||
const { props } = this;
|
||||
const activeClasses = (props.active === 'sidebar') ? ' ' : 'dn-s ';
|
||||
let hiddenClasses = true;
|
||||
if (props.popout) {
|
||||
hiddenClasses = false;
|
||||
} else {
|
||||
hiddenClasses = props.sidebarShown;
|
||||
};
|
||||
|
||||
const sidebarInvites = !(props.invites && props.invites['/publish'])
|
||||
? null
|
||||
: Object.keys(props.invites['/publish'])
|
||||
.map((uid) => {
|
||||
return (
|
||||
<SidebarInvite
|
||||
key={uid}
|
||||
invite={props.invites['/publish'][uid]}
|
||||
onAccept={() => props.api.invite.accept('/publish', uid)}
|
||||
onDecline={() => props.api.invite.decline('/publish', uid)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
const associations =
|
||||
(props.associations && 'contacts' in props.associations)
|
||||
? alphabetiseAssociations(props.associations.contacts) : {};
|
||||
|
||||
const notebooks = {};
|
||||
Object.keys(props.notebooks).map((host) => {
|
||||
Object.keys(props.notebooks[host]).map((notebook) => {
|
||||
const title = `${host}/${notebook}`;
|
||||
notebooks[title] = props.notebooks[host][notebook];
|
||||
});
|
||||
});
|
||||
|
||||
const groupedNotebooks = {};
|
||||
Object.keys(notebooks).map((book) => {
|
||||
const path = notebooks[book]['subscribers-group-path']
|
||||
? notebooks[book]['subscribers-group-path'] : book;
|
||||
if (path in associations) {
|
||||
if (groupedNotebooks[path]) {
|
||||
const array = groupedNotebooks[path];
|
||||
array.push(book);
|
||||
groupedNotebooks[path] = array;
|
||||
} else {
|
||||
groupedNotebooks[path] = [book];
|
||||
}
|
||||
} else {
|
||||
if (groupedNotebooks['/~/']) {
|
||||
const array = groupedNotebooks['/~/'];
|
||||
array.push(book);
|
||||
groupedNotebooks['/~/'] = array;
|
||||
} else {
|
||||
groupedNotebooks['/~/'] = [book];
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const groupedItems = Object.keys(associations)
|
||||
.map((each, i) => {
|
||||
const books = groupedNotebooks[each] || [];
|
||||
if (books.length === 0)
|
||||
return;
|
||||
if (groupedNotebooks['/~/'] &&
|
||||
groupedNotebooks['/~/'].length !== 0) {
|
||||
i = i + 1;
|
||||
}
|
||||
return (
|
||||
<GroupItem
|
||||
key={i}
|
||||
index={i}
|
||||
association={associations[each]}
|
||||
groupedBooks={books}
|
||||
notebooks={notebooks}
|
||||
path={props.path}
|
||||
/>
|
||||
);
|
||||
});
|
||||
if (groupedNotebooks['/~/'] &&
|
||||
groupedNotebooks['/~/'].length !== 0) {
|
||||
groupedItems.unshift(
|
||||
<GroupItem
|
||||
key={'/~/'}
|
||||
index={0}
|
||||
association={'/~/'}
|
||||
groupedBooks={groupedNotebooks['/~/']}
|
||||
notebooks={notebooks}
|
||||
path={props.path}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'bn br-m br-l br-xl b--gray4 b--gray1-d lh-copy h-100 ' +
|
||||
'flex-shrink-0 pt3 pt0-m pt0-l pt0-xl relative ' +
|
||||
'overflow-y-hidden ' + activeClasses +
|
||||
(hiddenClasses ? 'flex-basis-100-s flex-basis-250-ns' : 'dn')
|
||||
}
|
||||
>
|
||||
<div className="w-100 f9">
|
||||
<Link to="/~publish/new" className="green2 pa4 f9 dib">
|
||||
New Notebook
|
||||
</Link>
|
||||
</div>
|
||||
<div className="overflow-y-auto pb1"
|
||||
style={{ height: 'calc(100% - 82px)' }}
|
||||
>
|
||||
<Welcome notebooks={props.notebooks} />
|
||||
{sidebarInvites}
|
||||
{groupedItems}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Sidebar;
|
@ -1,41 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
export class Welcome extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
show: true
|
||||
};
|
||||
this.disableWelcome = this.disableWelcome.bind(this);
|
||||
}
|
||||
|
||||
disableWelcome() {
|
||||
this.setState({ show: false });
|
||||
localStorage.setItem('urbit-publish:wasWelcomed', JSON.stringify(true));
|
||||
}
|
||||
|
||||
render() {
|
||||
let wasWelcomed = localStorage.getItem('urbit-publish:wasWelcomed');
|
||||
if (wasWelcomed === null) {
|
||||
localStorage.setItem('urbit-publish:wasWelcomed', JSON.stringify(false));
|
||||
return wasWelcomed = false;
|
||||
} else {
|
||||
wasWelcomed = JSON.parse(wasWelcomed);
|
||||
}
|
||||
|
||||
const notebooks = this.props.notebooks ? this.props.notebooks : {};
|
||||
|
||||
return ((!wasWelcomed && this.state.show) && (notebooks.length !== 0)) ? (
|
||||
<div className="ma4 pa2 white-d bg-welcome-green bg-gray1-d">
|
||||
<p className="f8 lh-copy">Notebooks are for longer-form writing and discussion. Each Notebook is a collection of Markdown-formatted notes with optional comments.</p>
|
||||
<p className="f8 pt2 dib pointer bb"
|
||||
onClick={(() => this.disableWelcome())}
|
||||
>
|
||||
Close this
|
||||
</p>
|
||||
</div>
|
||||
) : <div />;
|
||||
}
|
||||
}
|
||||
|
||||
export default Welcome;
|
129
pkg/interface/src/views/apps/publish/components/skeleton.tsx
Normal file
129
pkg/interface/src/views/apps/publish/components/skeleton.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import React, { useRef, SyntheticEvent, useEffect } from "react";
|
||||
import { Box, Center } from "@tlon/indigo-react";
|
||||
import { Sidebar } from "./lib/Sidebar";
|
||||
import ErrorBoundary from "~/views/components/ErrorBoundary";
|
||||
import { Notebooks } from "~/types/publish-update";
|
||||
import { Rolodex } from "~/types/contact-update";
|
||||
import { Invites } from "~/types/invite-update";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { Associations } from "~/types/metadata-update";
|
||||
import { RouteComponentProps } from "react-router-dom";
|
||||
|
||||
type SkeletonProps = RouteComponentProps<{
|
||||
ship?: string;
|
||||
notebook?: string;
|
||||
noteId?: string;
|
||||
}> & {
|
||||
notebooks: Notebooks;
|
||||
invites: Invites;
|
||||
associations: Associations;
|
||||
contacts: Rolodex;
|
||||
api: GlobalApi;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function Skeleton(props: SkeletonProps) {
|
||||
const { api, notebooks } = props;
|
||||
const { ship, notebook, noteId } = props.match.params;
|
||||
const scrollRef = useRef<HTMLDivElement>();
|
||||
|
||||
const path =
|
||||
(ship &&
|
||||
notebook &&
|
||||
`${props.match.params.ship}/${props.match.params.notebook}`) ||
|
||||
undefined;
|
||||
|
||||
const onScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const { scrollHeight, scrollTop, clientHeight } = e.target as HTMLDivElement;
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
||||
if (noteId && notebook && ship) {
|
||||
const note = notebooks?.[ship]?.[notebook]?.notes?.[noteId];
|
||||
if (!note || !note.comments) {
|
||||
return;
|
||||
}
|
||||
const loadedComments = note.comments?.length;
|
||||
const fullyLoaded = note["num-comments"] === loadedComments;
|
||||
if (distanceFromBottom < 40) {
|
||||
if (!fullyLoaded) {
|
||||
api.publish.fetchCommentsPage(
|
||||
ship,
|
||||
notebook,
|
||||
noteId,
|
||||
loadedComments,
|
||||
30
|
||||
);
|
||||
}
|
||||
if (!note.read) {
|
||||
api.publish.publishAction({
|
||||
read: {
|
||||
who: ship.slice(1),
|
||||
book: notebook,
|
||||
note: noteId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (notebook && ship) {
|
||||
}
|
||||
};
|
||||
|
||||
// send read immediately if we aren't in a scrollable container
|
||||
useEffect(() => {
|
||||
if(!(noteId && notebook && ship)) {
|
||||
return;
|
||||
}
|
||||
const note = notebooks?.[ship]?.[notebook]?.notes?.[noteId];
|
||||
setTimeout(() => {
|
||||
const { clientHeight, scrollHeight } = scrollRef.current;
|
||||
const isScrolling = clientHeight < scrollHeight;
|
||||
if(!isScrolling && note) {
|
||||
api.publish.publishAction({
|
||||
read: {
|
||||
who: ship.slice(1),
|
||||
book: notebook,
|
||||
note: noteId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
}, 1500);
|
||||
}, [noteId, notebook, ship, notebooks])
|
||||
|
||||
const panelDisplay = !path ? ["none", "block"] : "block";
|
||||
return (
|
||||
<Box height="100%" width="100%" px={[0, 3]} pb={[0, 3]}>
|
||||
<Box
|
||||
display="flex"
|
||||
border={[0, 1]}
|
||||
borderColor={["washedGray", "washedGray"]}
|
||||
borderRadius={1}
|
||||
width="100%"
|
||||
height="100%"
|
||||
>
|
||||
<Sidebar
|
||||
notebooks={props.notebooks}
|
||||
contacts={props.contacts}
|
||||
path={path}
|
||||
invites={props.invites}
|
||||
associations={props.associations}
|
||||
api={props.api}
|
||||
/>
|
||||
<Box
|
||||
ref={scrollRef}
|
||||
display={panelDisplay}
|
||||
width="100%"
|
||||
height="100%"
|
||||
position="relative"
|
||||
px={[3, 4]}
|
||||
fontSize={0}
|
||||
overflowY="scroll"
|
||||
onScroll={onScroll}
|
||||
>
|
||||
<ErrorBoundary>{props.children}</ErrorBoundary>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Skeleton;
|
50
pkg/interface/src/views/components/AsyncButton.tsx
Normal file
50
pkg/interface/src/views/components/AsyncButton.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React, { ReactNode, useState, useEffect } from "react";
|
||||
|
||||
import { Button } from "@tlon/indigo-react";
|
||||
|
||||
import { Spinner } from "./Spinner";
|
||||
import { useFormikContext } from "formik";
|
||||
|
||||
interface AsyncButtonProps {
|
||||
loadingText: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
export function AsyncButton({
|
||||
loadingText,
|
||||
children,
|
||||
...rest
|
||||
}: AsyncButtonProps & Parameters<typeof Button>[0]) {
|
||||
const { isSubmitting, status, isValid } = useFormikContext();
|
||||
const [success, setSuccess] = useState<boolean | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
const s = status || {};
|
||||
let done = false;
|
||||
if ("success" in s) {
|
||||
setSuccess(true);
|
||||
done = true;
|
||||
} else if ("error" in s) {
|
||||
setSuccess(false);
|
||||
done = true;
|
||||
}
|
||||
if (done) {
|
||||
setTimeout(() => {
|
||||
setSuccess(undefined);
|
||||
}, 1500);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
return (
|
||||
<Button border disabled={!isValid} type="submit" {...rest}>
|
||||
{isSubmitting ? (
|
||||
<Spinner awaiting text={loadingText} />
|
||||
) : success === true ? (
|
||||
"Done"
|
||||
) : success === false ? (
|
||||
"Errored"
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
@ -7,6 +7,7 @@ import LaunchApp from '../apps/launch/app';
|
||||
import DojoApp from '../apps/dojo/app';
|
||||
import GroupsApp from '../apps/groups/app';
|
||||
import Profile from '../apps/profile/profile';
|
||||
import ErrorComponent from './Error';
|
||||
|
||||
|
||||
export const Container = styled.div`
|
||||
|
173
pkg/interface/src/views/components/DropdownSearch.tsx
Normal file
173
pkg/interface/src/views/components/DropdownSearch.tsx
Normal file
@ -0,0 +1,173 @@
|
||||
import React, {
|
||||
useRef,
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
ChangeEvent,
|
||||
} from "react";
|
||||
import _ from "lodash";
|
||||
import Mousetrap from "mousetrap";
|
||||
import {
|
||||
Box,
|
||||
InputLabel,
|
||||
ErrorMessage,
|
||||
InputCaption,
|
||||
} from "@tlon/indigo-react";
|
||||
import { useDropdown } from "~/logic/lib/useDropdown";
|
||||
import styled from "styled-components";
|
||||
import { space, color, layout, border } from "styled-system";
|
||||
|
||||
interface RenderChoiceProps<C> {
|
||||
candidate: C;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
interface DropdownSearchProps<C> {
|
||||
label: string;
|
||||
id: string;
|
||||
// Options for dropdown
|
||||
candidates: C[];
|
||||
// Present options in dropdown
|
||||
renderCandidate: (
|
||||
c: C,
|
||||
selected: boolean,
|
||||
onSelect: (c: C) => void
|
||||
) => React.ReactNode;
|
||||
// get a unique key for comparisons/react lists
|
||||
getKey: (c: C) => string;
|
||||
// search predicate
|
||||
search: (s: string, c: C) => boolean;
|
||||
// render selected candidate
|
||||
renderChoice: (props: RenderChoiceProps<C>) => React.ReactNode;
|
||||
onSelect: (c: C) => void;
|
||||
onRemove: (c: C) => void;
|
||||
value: C | undefined;
|
||||
caption?: string;
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const TextArea = styled.input`
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
resize: none;
|
||||
margin-top: ${(p) => p.theme.space[1]}px;
|
||||
padding: ${(p) => p.theme.space[2]}px;
|
||||
font-size: ${(p) => p.theme.fontSizes[0]}px;
|
||||
line-height: 1.2;
|
||||
${space}
|
||||
${color}
|
||||
${layout}
|
||||
${border}
|
||||
`;
|
||||
|
||||
export function DropdownSearch<C>(props: DropdownSearchProps<C>) {
|
||||
const textarea = useRef<HTMLTextAreaElement>();
|
||||
const { candidates, getKey, caption } = props;
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const { next, back, search, selected, options } = useDropdown(
|
||||
candidates,
|
||||
getKey,
|
||||
props.search
|
||||
);
|
||||
|
||||
const onSelect = useCallback(
|
||||
(c: C) => {
|
||||
setQuery("");
|
||||
props.onSelect(c);
|
||||
},
|
||||
[setQuery, props.onSelect]
|
||||
);
|
||||
|
||||
const onEnter = useCallback(() => {
|
||||
if (selected) {
|
||||
onSelect(selected);
|
||||
}
|
||||
return false;
|
||||
}, [onSelect, selected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!textarea.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mousetrap = Mousetrap(textarea.current);
|
||||
mousetrap.bind(["down", "tab"], next);
|
||||
mousetrap.bind(["up", "shift+tab"], back);
|
||||
mousetrap.bind("enter", onEnter);
|
||||
|
||||
return () => {
|
||||
mousetrap.unbind(["down", "tab"]);
|
||||
mousetrap.unbind(["up", "shift+tab"]);
|
||||
mousetrap.unbind("enter", onEnter);
|
||||
};
|
||||
}, [textarea.current, next, back, onEnter]);
|
||||
|
||||
const onChange = useCallback(
|
||||
(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
search(e.target.value);
|
||||
setQuery(e.target.value);
|
||||
},
|
||||
[setQuery]
|
||||
);
|
||||
|
||||
const dropdown = useMemo(
|
||||
() =>
|
||||
_.take(options, 5).map((o, idx) =>
|
||||
props.renderCandidate(
|
||||
o,
|
||||
!_.isUndefined(selected) &&
|
||||
props.getKey(o) === props.getKey(selected),
|
||||
onSelect
|
||||
)
|
||||
),
|
||||
[options, props.getKey, props.renderCandidate, selected]
|
||||
);
|
||||
|
||||
return (
|
||||
<Box position="relative">
|
||||
<InputLabel htmlFor={props.id}>{props.label}</InputLabel>
|
||||
{caption ? <InputCaption>{caption}</InputCaption> : null}
|
||||
{!props.disabled && (
|
||||
<TextArea
|
||||
ref={textarea}
|
||||
border={1}
|
||||
borderColor="washedGray"
|
||||
bg="white"
|
||||
color="black"
|
||||
borderRadius={2}
|
||||
onChange={onChange}
|
||||
value={query}
|
||||
autocomplete="off"
|
||||
/>
|
||||
)}
|
||||
{options.length !== 0 && query.length !== 0 && (
|
||||
<Box
|
||||
mt={1}
|
||||
border={1}
|
||||
borderColor="washedGray"
|
||||
bg="white"
|
||||
width="100%"
|
||||
position="absolute"
|
||||
>
|
||||
{dropdown}
|
||||
</Box>
|
||||
)}
|
||||
{props.value && (
|
||||
<Box mt={2} display="flex">
|
||||
{props.renderChoice({
|
||||
candidate: props.value,
|
||||
onRemove: () => props.onRemove(props.value as C),
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
<ErrorMessage>{props.error}</ErrorMessage>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default DropdownSearch;
|
@ -1,4 +1,5 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Text, Box, Col } from '@tlon/indigo-react';
|
||||
import { RouteComponentProps, withRouter } from 'react-router-dom';
|
||||
|
||||
type ErrorProps = RouteComponentProps & {
|
||||
@ -11,28 +12,30 @@ class ErrorComponent extends Component<ErrorProps> {
|
||||
render () {
|
||||
const { code, error, history, description } = this.props;
|
||||
return (
|
||||
<div className="pa4 inter tc flex flex-column items-center justify-center w-100 h-100">
|
||||
<h1 className="mb4 fw2 f2" style={{
|
||||
fontFeatureSettings: '\'zero\' 1',
|
||||
}}>
|
||||
{code ? code : 'Error'}
|
||||
</h1>
|
||||
{description ? <p className="tc mb4">{description}</p> : null}
|
||||
{error ? (
|
||||
<div className="mb4">
|
||||
<p className="mb4"><code>“{error.message}”</code></p>
|
||||
<details>
|
||||
<summary>Stack trace</summary>
|
||||
<pre className="tl">{error.stack}</pre>
|
||||
</details>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="tc mb4">If this is unexpected, email <code>support@tlon.io</code> or <a className="bb" href="https://github.com/urbit/urbit/issues/new/choose">submit an issue</a>.</p>
|
||||
<Col alignItems="center" justifyContent="center" height="100%" p={4}>
|
||||
<Box mb={4}>
|
||||
<Text fontSize={3}>
|
||||
{code ? code : 'Error'}
|
||||
</Text>
|
||||
</Box>
|
||||
{ description && (<Box mb={4}><Text>{description}</Text></Box>) }
|
||||
{error && (
|
||||
<Box mb={4}>
|
||||
<Box mb={2}>
|
||||
<Text fontFamily="mono"><code>“{error.message}”</code></Text>
|
||||
</Box>
|
||||
<details>
|
||||
<summary>Stack trace</summary>
|
||||
<pre style={{ wordWrap: 'break-word' }} className="tl">{error.stack}</pre>
|
||||
</details>
|
||||
</Box>
|
||||
)}
|
||||
<Text mb={4} textAlign="center">If this is unexpected, email <code>support@tlon.io</code> or <a className="bb" href="https://github.com/urbit/urbit/issues/new/choose">submit an issue</a>.</Text>
|
||||
{history.length > 1
|
||||
? <button className="bg-light-green green2 pa2 pointer" onClick={() => history.go(-1) }>Go back</button>
|
||||
: <button className="bg-light-green green2 pa2 pointer" onClick={() => history.push('/') }>Go home</button>
|
||||
}
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -32,4 +32,4 @@ class ErrorBoundary extends Component<
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(ErrorBoundary);
|
||||
export default withRouter(ErrorBoundary);
|
||||
|
13
pkg/interface/src/views/components/FormError.tsx
Normal file
13
pkg/interface/src/views/components/FormError.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import { useFormikContext } from "formik";
|
||||
import { ErrorMessage } from "@tlon/indigo-react";
|
||||
|
||||
export function FormError(props: { message: string }) {
|
||||
const { status } = useFormikContext();
|
||||
|
||||
let s = status || {};
|
||||
|
||||
return (
|
||||
<ErrorMessage>{"error" in s ? props.message : null}</ErrorMessage>
|
||||
);
|
||||
}
|
@ -2,7 +2,6 @@ import React, { Component } from 'react';
|
||||
import _, { capitalize } from 'lodash';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
|
||||
import { Dropdown } from '../apps/publish/components/lib/dropdown';
|
||||
import { cite, deSig } from '~/logic/lib/util';
|
||||
import { roleForShip, resourceFromPath } from '~/logic/lib/group';
|
||||
import {
|
||||
@ -13,8 +12,8 @@ import {
|
||||
Groups,
|
||||
} from '~/types/group-update';
|
||||
import { Path, PatpNoSig, Patp } from '~/types/noun';
|
||||
import GlobalApi from '../api/global';
|
||||
import { Menu, MenuButton, MenuList, MenuItem } from '@tlon/indigo-react';
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
import { Menu, MenuButton, MenuList, MenuItem, Text } from '@tlon/indigo-react';
|
||||
import InviteSearch, { Invites } from './InviteSearch';
|
||||
import { Spinner } from './Spinner';
|
||||
import { Rolodex } from '~/types/contact-update';
|
||||
@ -27,7 +26,7 @@ class GroupMember extends Component<{ ship: Patp; options: any[] }, {}> {
|
||||
return (
|
||||
<div className='flex justify-between f9 items-center'>
|
||||
<div className='flex flex-column'>
|
||||
<div className='mono mr2'>{`${cite(ship)}`}</div>
|
||||
<Text mono mr='2'>{`${cite(ship)}`}</Text>
|
||||
{children}
|
||||
</div>
|
||||
{options.length > 0 && (
|
||||
@ -49,12 +48,12 @@ class Tag extends Component<{ description: string; onRemove?: () => any }, {}> {
|
||||
render() {
|
||||
const { description, onRemove } = this.props;
|
||||
return (
|
||||
<div className='br-pill ba b-black r-full items-center ph2 f9 mr2 flex'>
|
||||
<div>{description}</div>
|
||||
<div className='br-pill ba b-black b--white-d r-full items-center ph2 f9 mr2 flex'>
|
||||
<Text>{description}</Text>
|
||||
{Boolean(onRemove) && (
|
||||
<div onClick={onRemove} className='ml1 f9 pointer'>
|
||||
<Text onClick={onRemove} ml='1' style={{ cursor: 'pointer' }}>
|
||||
✗
|
||||
</div>
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@ -273,7 +272,7 @@ export class GroupView extends Component<
|
||||
{ships.map((ship) => (
|
||||
<GroupMember key={ship} ship={ship} options={options(ship)} />
|
||||
))}
|
||||
{ships.length === 0 && <div className='f9'>No ships are pending</div>}
|
||||
{ships.length === 0 && <Text>No ships are pending</Text>}
|
||||
{props.inviteShips && this.isAdmin() && (
|
||||
<>
|
||||
<div className='f9 gray2 mt6 mb3'>Invite</div>
|
||||
@ -313,7 +312,7 @@ export class GroupView extends Component<
|
||||
{ships.map((ship) => (
|
||||
<GroupMember key={ship} ship={ship} options={options(ship)} />
|
||||
))}
|
||||
{ships.length === 0 && <div className='f9'>No ships are banned</div>}
|
||||
{ships.length === 0 && <Text>No ships are banned</Text>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -326,9 +325,9 @@ export class GroupView extends Component<
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className='flex flex-column'>
|
||||
<div className='f9 gray2'>Host</div>
|
||||
<Text gray display='block'>Host</Text>
|
||||
<div className='flex justify-between mt3'>
|
||||
<div className='f9 mono mr2'>{cite(resource.ship)}</div>
|
||||
<Text mono mr='2'>{cite(resource.ship)}</Text>
|
||||
</div>
|
||||
</div>
|
||||
{'invite' in group.policy && this.renderInvites(group.policy)}
|
||||
|
112
pkg/interface/src/views/components/GroupSearch.tsx
Normal file
112
pkg/interface/src/views/components/GroupSearch.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import React, { useMemo, useCallback } from "react";
|
||||
import { Box, Text } from "@tlon/indigo-react";
|
||||
import _ from "lodash";
|
||||
import { useField } from "formik";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { DropdownSearch } from "./DropdownSearch";
|
||||
import { Associations, Association } from "~/types/metadata-update";
|
||||
|
||||
interface InviteSearchProps {
|
||||
disabled?: boolean;
|
||||
associations: Associations;
|
||||
label: string;
|
||||
caption?: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
const CandidateBox = styled(Box)<{ selected: boolean }>`
|
||||
background-color: ${(p) =>
|
||||
p.selected ? p.theme.colors.washedGray : p.theme.colors.white};
|
||||
pointer: cursor;
|
||||
&:hover {
|
||||
background-color: ${(p) => p.theme.colors.washedGray};
|
||||
}
|
||||
`;
|
||||
|
||||
const ClickableText = styled(Text)`
|
||||
cursor: pointer;
|
||||
|
||||
`;
|
||||
|
||||
const Candidate = ({ title, selected, onClick }) => (
|
||||
<CandidateBox
|
||||
onClick={onClick}
|
||||
selected={selected}
|
||||
borderColor="washedGray"
|
||||
color="black"
|
||||
fontSize={0}
|
||||
p={1}
|
||||
width="100%"
|
||||
>
|
||||
{title}
|
||||
</CandidateBox>
|
||||
);
|
||||
|
||||
function renderCandidate(
|
||||
a: Association,
|
||||
selected: boolean,
|
||||
onSelect: (a: Association) => void
|
||||
) {
|
||||
const { title } = a.metadata;
|
||||
|
||||
const onClick = () => {
|
||||
onSelect(a);
|
||||
};
|
||||
|
||||
return <Candidate title={title} selected={selected} onClick={onClick} />;
|
||||
}
|
||||
|
||||
export function GroupSearch(props: InviteSearchProps) {
|
||||
const groups = useMemo(
|
||||
() => Object.values(props.associations?.contacts || {}),
|
||||
[props.associations?.contacts]
|
||||
);
|
||||
|
||||
const [{ value }, { error }, { setValue }] = useField(props.id);
|
||||
|
||||
const group = props.associations?.contacts?.[value];
|
||||
|
||||
const onSelect = useCallback(
|
||||
(a: Association) => {
|
||||
setValue(a["group-path"]);
|
||||
},
|
||||
[setValue]
|
||||
);
|
||||
|
||||
const onRemove = useCallback(
|
||||
(a: Association) => {
|
||||
setValue("");
|
||||
},
|
||||
[setValue]
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownSearch<Association>
|
||||
label={props.label}
|
||||
id={props.id}
|
||||
caption={props.caption}
|
||||
candidates={groups}
|
||||
renderCandidate={renderCandidate}
|
||||
disabled={value.length !== 0}
|
||||
search={(s: string, a: Association) =>
|
||||
a.metadata.title.toLowerCase().startsWith(s.toLowerCase())
|
||||
}
|
||||
getKey={(a: Association) => a["group-path"]}
|
||||
onSelect={onSelect}
|
||||
onRemove={onRemove}
|
||||
renderChoice={({ candidate, onRemove }) => (
|
||||
<Box px={2} py={1} border={1} borderColor="washedGrey" color="black" fontSize={0}>
|
||||
{candidate.metadata.title}
|
||||
<ClickableText ml={2} onClick={onRemove} color="black">
|
||||
x
|
||||
</ClickableText>
|
||||
</Box>
|
||||
)}
|
||||
value={group}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroupSearch;
|
16
pkg/interface/src/views/components/HoverBox.tsx
Normal file
16
pkg/interface/src/views/components/HoverBox.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import styled from 'styled-components';
|
||||
import { Box } from "@tlon/indigo-react";
|
||||
interface HoverBoxProps {
|
||||
selected: boolean;
|
||||
bg: string;
|
||||
bgActive: string;
|
||||
}
|
||||
export const HoverBox = styled(Box)<HoverBoxProps>`
|
||||
background-color: ${ p => p.selected ? p.theme.colors[p.bgActive] : p.theme.colors[p.bg] };
|
||||
pointer: cursor;
|
||||
&:hover {
|
||||
background-color: ${ p => p.theme.colors[p.bgActive] };
|
||||
}
|
||||
`;
|
||||
|
||||
|
@ -395,7 +395,7 @@ export class InviteSearch extends Component<
|
||||
);
|
||||
});
|
||||
|
||||
const shipResults = state.searchResults.ships.map((ship) => {
|
||||
const shipResults = Array.from(new Set(state.searchResults.ships)).map((ship) => {
|
||||
const nicknames = (this.state.contacts.get(ship) || [])
|
||||
.filter((e) => {
|
||||
return !(e === '');
|
||||
|
@ -8,10 +8,12 @@ export class SidebarSwitcher extends Component {
|
||||
|
||||
const classes = this.props.classes ? this.props.classes : '';
|
||||
|
||||
const style = this.props.style || {};
|
||||
|
||||
const paddingTop = this.props.classes ? '0px' : '8px';
|
||||
|
||||
return (
|
||||
<div className={classes} style={{ paddingTop: paddingTop }}>
|
||||
<div className={classes} style={{ paddingTop: paddingTop, ...style }}>
|
||||
<a
|
||||
className='pointer flex-shrink-0'
|
||||
onClick={() => {
|
||||
|
@ -1,14 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Box, Text, Row } from '@tlon/indigo-react';
|
||||
|
||||
import { Row, Box, Text, Icon } from '@tlon/indigo-react';
|
||||
import ReconnectButton from './ReconnectButton';
|
||||
import { StatusBarItem } from './StatusBarItem';
|
||||
|
||||
import { Sigil } from '~/logic/lib/sigil';
|
||||
|
||||
function StatusBar(props) {
|
||||
|
||||
const StatusBar = (props) => {
|
||||
|
||||
const location = useLocation();
|
||||
const atHome = Boolean(location.pathname === '/');
|
||||
|
||||
@ -70,7 +70,6 @@ function StatusBar(props) {
|
||||
/>
|
||||
</Row>
|
||||
<Row justifyContent="flex-end" collapse>
|
||||
|
||||
<StatusBarItem onClick={() => props.history.push('/~profile')}>
|
||||
<Sigil ship={props.ship} size={24} color={"#000000"} classes="dib mix-blend-diff" />
|
||||
<Text ml={2} display={["none", "inline"]} fontFamily="mono">{props.ship}</Text>
|
||||
|
Loading…
Reference in New Issue
Block a user