mirror of
https://github.com/urbit/shrub.git
synced 2024-12-29 15:14:17 +03:00
Merge branch 'lf/global-skeleton-links' (#3626)
* origin/lf/global-skeleton-links: (59 commits) chat: resolve conflicts in #3646 interface: added the ability to make and redirect to a new DM from the ProfileOverlay interface: popover uses scales.black30 not gray chat: indigo-react unread notice chat: f8 group link text chat: set input for light mode groups: truncate long recent group entries interface: moved components/lib to components chat: bumping chat input size interface: removed unused Chat components publish: safety check note view publish: fix horizontal padding on small desktop links: prevent hostname arrow line breaking in safari links: prevent shrinking of comment input links: pass remotecontent policy publish: prevent crash on refreshing notebook groups: properly check and await notebook join groups: allow line breaks in description prompt links: restore flexbox truncation interface: calc skeleton size for safari ... Signed-off-by: Matilde Park <matilde.park@sunshinegardens.org>
This commit is contained in:
commit
7b236104b0
@ -21,6 +21,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="portal-root"></div>
|
||||
<script src="/~landscape/js/channel.js"></script>
|
||||
<script src="/~landscape/js/session.js"></script>
|
||||
<script src="/~landscape/js/bundle/index.4c5a7f72912cc668f0e5.js"></script>
|
||||
|
@ -11,6 +11,7 @@
|
||||
[%2 *]
|
||||
[%3 *]
|
||||
[%4 state-zero]
|
||||
[%5 state-zero]
|
||||
==
|
||||
::
|
||||
+$ state-zero
|
||||
@ -20,7 +21,7 @@
|
||||
==
|
||||
--
|
||||
::
|
||||
=| [%4 state-zero]
|
||||
=| [%5 state-zero]
|
||||
=* state -
|
||||
%- agent:dbug
|
||||
^- agent:gall
|
||||
@ -35,48 +36,51 @@
|
||||
%_ new-state
|
||||
tiles
|
||||
%- ~(gas by *tiles:store)
|
||||
%+ turn `(list term)`[%chat %publish %links %weather %clock %dojo ~]
|
||||
%+ turn `(list term)`[%weather %clock %dojo ~]
|
||||
|= =term
|
||||
:- term
|
||||
^- tile:store
|
||||
?+ term [[%custom ~] %.y]
|
||||
%chat [[%basic 'Chat' '/~landscape/img/Chat.png' '/~chat'] %.y]
|
||||
%links [[%basic 'Links' '/~landscape/img/Links.png' '/~link'] %.y]
|
||||
%dojo [[%basic 'Dojo' '/~landscape/img/Dojo.png' '/~dojo'] %.y]
|
||||
%publish
|
||||
[[%basic 'Publish' '/~landscape/img/Publish.png' '/~publish'] %.y]
|
||||
==
|
||||
tile-ordering [%chat %publish %links %weather %clock %dojo ~]
|
||||
tile-ordering [%weather %clock %dojo ~]
|
||||
==
|
||||
[~ this(state [%4 new-state])]
|
||||
[~ this(state [%5 new-state])]
|
||||
::
|
||||
++ on-save !>(state)
|
||||
++ on-load
|
||||
|= old=vase
|
||||
^- (quip card _this)
|
||||
=/ old-state !<(versioned-state old)
|
||||
|-
|
||||
?: ?=(%5 -.old-state)
|
||||
`this(state old-state)
|
||||
?: ?=(%4 -.old-state)
|
||||
:- [%pass / %arvo %e %disconnect [~ /]]~
|
||||
this(state old-state)
|
||||
=. tiles.old-state
|
||||
(~(del by tiles.old-state) %chat)
|
||||
=. tiles.old-state
|
||||
(~(del by tiles.old-state) %publish)
|
||||
=. tiles.old-state
|
||||
(~(del by tiles.old-state) %links)
|
||||
=. tile-ordering.old-state
|
||||
(skip tile-ordering.old-state |=(=term ?=(?(%links %chat %publish) term)))
|
||||
this(state [%5 +.old-state])
|
||||
=/ new-state *state-zero
|
||||
=. new-state
|
||||
%_ new-state
|
||||
tiles
|
||||
%- ~(gas by *tiles:store)
|
||||
%+ turn `(list term)`[%chat %publish %links %weather %clock %dojo ~]
|
||||
%+ turn `(list term)`[%weather %clock %dojo ~]
|
||||
|= =term
|
||||
:- term
|
||||
^- tile:store
|
||||
?+ term [[%custom ~] %.y]
|
||||
%chat [[%basic 'Chat' '/~landscape/img/Chat.png' '/~chat'] %.y]
|
||||
%links [[%basic 'Links' '/~landscape/img/Links.png' '/~link'] %.y]
|
||||
%dojo [[%basic 'Dojo' '/~landscape/img/Dojo.png' '/~dojo'] %.y]
|
||||
%publish
|
||||
[[%basic 'Publish' '/~landscape/img/Publish.png' '/~publish'] %.y]
|
||||
==
|
||||
tile-ordering [%chat %publish %links %weather %clock %dojo ~]
|
||||
tile-ordering [%weather %clock %dojo ~]
|
||||
==
|
||||
:_ this(state [%4 new-state])
|
||||
:_ this(state [%5 new-state])
|
||||
%+ welp
|
||||
:~ [%pass / %arvo %e %disconnect [~ /]]
|
||||
:* %pass /srv %agent [our.bowl %file-server]
|
||||
|
14
pkg/interface/package-lock.json
generated
14
pkg/interface/package-lock.json
generated
@ -1709,9 +1709,8 @@
|
||||
"integrity": "sha512-3OPSdf9cejP/TSzWXuBaYbzLtAfBzQnc75SlPLkoPfwpxnv1Bvy9hiWngLY0WnKRR6lMOldnkYQCCuNWeDibYQ=="
|
||||
},
|
||||
"@tlon/indigo-react": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@tlon/indigo-react/-/indigo-react-1.2.6.tgz",
|
||||
"integrity": "sha512-Dng+OfQ6ViMrdJXtQvgsVrW9vglPGUmbv0ffi2MSwLfe6FhUdL1CHoOjGxm4pATLOou53Kqcrt4g1Svw7y9THw==",
|
||||
"version": "github:urbit/indigo-react#30c04b3369076ef8086d4f479a890500a1263510",
|
||||
"from": "github:urbit/indigo-react#lf/1.2.8",
|
||||
"requires": {
|
||||
"@reach/menu-button": "^0.10.5",
|
||||
"react": "^16.13.1",
|
||||
@ -1822,6 +1821,15 @@
|
||||
"csstype": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"version": "16.9.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-16.9.8.tgz",
|
||||
"integrity": "sha512-ykkPQ+5nFknnlU6lDd947WbQ6TE3NNzbQAkInC2EKY1qeYdTKp7onFusmYZb+ityzx2YviqT6BXSu+LyWWJwcA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"@types/react-native": {
|
||||
"version": "0.63.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.63.4.tgz",
|
||||
|
@ -9,7 +9,7 @@
|
||||
"@reach/menu-button": "^0.10.5",
|
||||
"@reach/tabs": "^0.10.5",
|
||||
"@tlon/indigo-light": "^1.0.3",
|
||||
"@tlon/indigo-react": "1.2.6",
|
||||
"@tlon/indigo-react": "urbit/indigo-react#lf/1.2.8",
|
||||
"aws-sdk": "^2.726.0",
|
||||
"classnames": "^2.2.6",
|
||||
"codemirror": "^5.55.0",
|
||||
@ -54,6 +54,7 @@
|
||||
"@babel/preset-typescript": "^7.10.1",
|
||||
"@types/lodash": "^4.14.155",
|
||||
"@types/react": "^16.9.38",
|
||||
"@types/react-dom": "^16.9.8",
|
||||
"@types/react-router-dom": "^5.1.5",
|
||||
"@types/styled-components": "^5.1.2",
|
||||
"@types/styled-system": "^5.1.10",
|
||||
|
@ -5,6 +5,7 @@ import { Path, Patp } from '~/types/noun';
|
||||
|
||||
export default class MetadataApi extends BaseApi<StoreState> {
|
||||
|
||||
|
||||
metadataAdd(appName: string, appPath: Path, groupPath: Path, title: string, description: string, dateCreated: string, color: string, moduleName: string) {
|
||||
const creator = `~${this.ship}`;
|
||||
return this.metadataAction({
|
||||
|
1
pkg/interface/src/logic/lib/Home.ts
Normal file
1
pkg/interface/src/logic/lib/Home.ts
Normal file
@ -0,0 +1 @@
|
||||
|
@ -19,29 +19,13 @@ const result = function(title, link, app, host) {
|
||||
};
|
||||
};
|
||||
|
||||
const commandIndex = function () {
|
||||
const commandIndex = function (currentGroup) {
|
||||
// commands are special cased for default suite
|
||||
const commands = [];
|
||||
defaultApps
|
||||
.filter((e) => {
|
||||
return e !== 'dojo';
|
||||
})
|
||||
.map((e) => {
|
||||
let title = e;
|
||||
if (e === 'link') {
|
||||
title = 'Links';
|
||||
}
|
||||
|
||||
title = title.charAt(0).toUpperCase() + title.slice(1);
|
||||
|
||||
let obj = result(`${title}: Create`, `/~${e}/new`, title, null);
|
||||
commands.push(obj);
|
||||
|
||||
if (title === 'Groups') {
|
||||
obj = result(`${title}: Join Group`, `/~${e}/join`, title, null);
|
||||
commands.push(obj);
|
||||
}
|
||||
});
|
||||
const workspace = currentGroup || '/home';
|
||||
commands.push(result(`Groups: Create`, `/~groups/new`, 'Groups', null));
|
||||
commands.push(result(`Groups: Join`, `/~groups/join`, 'Groups', null));
|
||||
commands.push(result(`Channel: Create`, `/~groups${workspace}/new`, 'Groups', null));
|
||||
|
||||
return commands;
|
||||
};
|
||||
@ -70,6 +54,7 @@ const appIndex = function (apps) {
|
||||
applications.push(
|
||||
result('Groups', '/~groups', 'Groups', null)
|
||||
);
|
||||
return [];
|
||||
return applications;
|
||||
};
|
||||
|
||||
@ -81,7 +66,7 @@ const otherIndex = function() {
|
||||
return other;
|
||||
};
|
||||
|
||||
export default function index(associations, apps) {
|
||||
export default function index(associations, apps, currentGroup) {
|
||||
// all metadata from all apps is indexed
|
||||
// into subscriptions and groups
|
||||
const subscriptions = [];
|
||||
@ -112,16 +97,16 @@ export default function index(associations, apps) {
|
||||
if (app === 'groups') {
|
||||
const obj = result(
|
||||
title,
|
||||
`/~${app}${each['app-path']}`,
|
||||
`/~groups${each['app-path']}`,
|
||||
app.charAt(0).toUpperCase() + app.slice(1),
|
||||
cite(shipStart.slice(0, shipStart.indexOf('/')))
|
||||
);
|
||||
groups.push(obj);
|
||||
} else {
|
||||
const app = each.metadata.module || each['app-name'];
|
||||
const obj = result(
|
||||
title,
|
||||
`/~${each['app-name']}/join${each['app-path']}${
|
||||
(each.metadata.module && '/' + each.metadata.module) || ''}`,
|
||||
`/~groups${each['group-path']}/join/${app}${each['app-path']}`,
|
||||
app.charAt(0).toUpperCase() + app.slice(1),
|
||||
(associations?.contacts?.[each['group-path']]?.metadata?.title || null)
|
||||
);
|
||||
@ -130,7 +115,7 @@ export default function index(associations, apps) {
|
||||
});
|
||||
});
|
||||
|
||||
indexes.set('commands', commandIndex());
|
||||
indexes.set('commands', commandIndex(currentGroup));
|
||||
indexes.set('subscriptions', subscriptions);
|
||||
indexes.set('groups', groups);
|
||||
indexes.set('apps', appIndex(apps));
|
||||
|
64
pkg/interface/src/logic/lib/useDrag.ts
Normal file
64
pkg/interface/src/logic/lib/useDrag.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { useState, useCallback, useMemo, useEffect } from "react";
|
||||
|
||||
function validateDragEvent(e: DragEvent): FileList | null {
|
||||
const files = e.dataTransfer?.files;
|
||||
console.log(files);
|
||||
if(!files?.length) {
|
||||
return null;
|
||||
}
|
||||
return files || null;
|
||||
}
|
||||
|
||||
export function useFileDrag(dragged: (f: FileList) => void) {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const onDragEnter = useCallback(
|
||||
(e: DragEvent) => {
|
||||
if (!validateDragEvent(e)) {
|
||||
return;
|
||||
}
|
||||
setDragging(true);
|
||||
},
|
||||
[setDragging]
|
||||
);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(e: DragEvent) => {
|
||||
setDragging(false);
|
||||
e.preventDefault();
|
||||
const files = validateDragEvent(e);
|
||||
if (!files) {
|
||||
return;
|
||||
}
|
||||
dragged(files);
|
||||
},
|
||||
[setDragging, dragged]
|
||||
);
|
||||
|
||||
const onDragOver = useCallback(
|
||||
(e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
},
|
||||
[setDragging]
|
||||
);
|
||||
|
||||
const onDragLeave = useCallback(
|
||||
(e: DragEvent) => {
|
||||
const over = document.elementFromPoint(e.clientX, e.clientY);
|
||||
if (!over || !(e.currentTarget as any)?.contains(over)) {
|
||||
setDragging(false);
|
||||
}
|
||||
},
|
||||
[setDragging]
|
||||
);
|
||||
|
||||
const bind = {
|
||||
onDragLeave,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onDragEnter,
|
||||
};
|
||||
|
||||
return { bind, dragging };
|
||||
}
|
@ -1,22 +1,36 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
|
||||
export function useLocalStorageState<T>(key: string, initial: T) {
|
||||
const [state, _setState] = useState(() => {
|
||||
const s = localStorage.getItem(key);
|
||||
if(s) {
|
||||
function retrieve<T>(key: string, initial: T): T {
|
||||
const s = localStorage.getItem(key);
|
||||
if (s) {
|
||||
try {
|
||||
return JSON.parse(s) as T;
|
||||
} catch (e) {
|
||||
return initial;
|
||||
}
|
||||
return initial;
|
||||
}
|
||||
return initial;
|
||||
}
|
||||
|
||||
});
|
||||
interface SetStateFunc<T> {
|
||||
(t: T): T;
|
||||
}
|
||||
type SetState<T> = T | SetStateFunc<T>;
|
||||
export function useLocalStorageState<T>(key: string, initial: T) {
|
||||
const [state, _setState] = useState(() => retrieve(key, initial));
|
||||
|
||||
const setState = useCallback((s: T) => {
|
||||
_setState(s);
|
||||
localStorage.setItem(key, JSON.stringify(s));
|
||||
useEffect(() => {
|
||||
_setState(retrieve(key, initial));
|
||||
}, [key]);
|
||||
|
||||
}, [_setState]);
|
||||
const setState = useCallback(
|
||||
(s: SetState<T>) => {
|
||||
const updated = typeof s === "function" ? s(state) : s;
|
||||
_setState(updated);
|
||||
localStorage.setItem(key, JSON.stringify(updated));
|
||||
},
|
||||
[_setState, key, state]
|
||||
);
|
||||
|
||||
return [state, setState] as const;
|
||||
}
|
||||
|
||||
|
||||
|
19
pkg/interface/src/logic/lib/useOutsideClick.ts
Normal file
19
pkg/interface/src/logic/lib/useOutsideClick.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { useEffect, RefObject } from "react";
|
||||
|
||||
export function useOutsideClick(
|
||||
ref: RefObject<HTMLElement>,
|
||||
onClick: () => void
|
||||
) {
|
||||
useEffect(() => {
|
||||
function handleClick(event: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(event.target as any)) {
|
||||
onClick();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClick);
|
||||
};
|
||||
}, [ref.current, onClick]);
|
||||
}
|
@ -64,6 +64,9 @@ export function dateToDa(d, mil) {
|
||||
}
|
||||
|
||||
export function deSig(ship) {
|
||||
if(!ship) {
|
||||
return null;
|
||||
}
|
||||
return ship.replace('~', '');
|
||||
}
|
||||
|
||||
@ -78,10 +81,10 @@ export function uxToHex(ux) {
|
||||
}
|
||||
|
||||
export function hexToUx(hex) {
|
||||
const ux = _.chain(hex.split(""))
|
||||
const ux = _.chain(hex.split(''))
|
||||
.chunk(4)
|
||||
.map((x) => _.dropWhile(x, (y) => y === 0).join(""))
|
||||
.join(".");
|
||||
.map(x => _.dropWhile(x, y => y === 0).join(''))
|
||||
.join('.');
|
||||
return `0x${ux}`;
|
||||
}
|
||||
|
||||
@ -148,6 +151,10 @@ export function cite(ship) {
|
||||
return `~${patp}`;
|
||||
}
|
||||
|
||||
export function alphabeticalOrder(a,b) {
|
||||
return a.toLowerCase().localeCompare(b.toLowerCase());
|
||||
}
|
||||
|
||||
export function alphabetiseAssociations(associations) {
|
||||
const result = {};
|
||||
Object.keys(associations).sort((a, b) => {
|
||||
@ -163,7 +170,7 @@ export function alphabetiseAssociations(associations) {
|
||||
? associations[b].metadata.title
|
||||
: b.substr(1);
|
||||
}
|
||||
return aName.toLowerCase().localeCompare(bName.toLowerCase());
|
||||
return alphabeticalOrder(aName,bName);
|
||||
}).map((each) => {
|
||||
result[each] = associations[each];
|
||||
});
|
||||
@ -229,28 +236,28 @@ export function stringToTa(string) {
|
||||
|
||||
export function makeRoutePath(
|
||||
resource,
|
||||
popout = false,
|
||||
page = 0,
|
||||
url = null,
|
||||
index = 0,
|
||||
compage = 0
|
||||
) {
|
||||
let route = "/~link" + (popout ? "/popout" : "") + resource;
|
||||
let route = '/~link' + resource;
|
||||
if (!url) {
|
||||
if (page !== 0) {
|
||||
route = route + "/" + page;
|
||||
route = route + '/' + page;
|
||||
}
|
||||
} else {
|
||||
route = `${route}/${page}/${index}/${base64urlEncode(url)}`;
|
||||
if (compage !== 0) {
|
||||
route = route + "/" + compage;
|
||||
route = route + '/' + compage;
|
||||
}
|
||||
}
|
||||
return route;
|
||||
}
|
||||
|
||||
export function amOwnerOfGroup(groupPath) {
|
||||
if (!groupPath) return false;
|
||||
if (!groupPath)
|
||||
return false;
|
||||
const groupOwner = /(\/~)?\/~([a-z-]{3,})\/.*/.exec(groupPath)[2];
|
||||
return window.ship === groupOwner;
|
||||
}
|
||||
@ -258,20 +265,20 @@ export function amOwnerOfGroup(groupPath) {
|
||||
export function getContactDetails(contact) {
|
||||
const member = !contact;
|
||||
contact = contact || {
|
||||
nickname: "",
|
||||
nickname: '',
|
||||
avatar: null,
|
||||
color: "0x0",
|
||||
color: '0x0'
|
||||
};
|
||||
const nickname = contact.nickname || "";
|
||||
const color = uxToHex(contact.color || "0x0");
|
||||
const nickname = contact.nickname || '';
|
||||
const color = uxToHex(contact.color || '0x0');
|
||||
const avatar = contact.avatar || null;
|
||||
return { nickname, color, member, avatar };
|
||||
}
|
||||
|
||||
export function stringToSymbol(str) {
|
||||
let result = '';
|
||||
for (var i = 0; i < str.length; i++) {
|
||||
var n = str.charCodeAt(i);
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const n = str.charCodeAt(i);
|
||||
if (((n >= 97) && (n <= 122)) ||
|
||||
((n >= 48) && (n <= 57))) {
|
||||
result += str[i];
|
||||
@ -291,12 +298,12 @@ export function stringToSymbol(str) {
|
||||
|
||||
export function scrollIsAtTop(container) {
|
||||
if (
|
||||
(navigator.userAgent.includes("Safari") &&
|
||||
navigator.userAgent.includes("Chrome")) ||
|
||||
navigator.userAgent.includes("Firefox")
|
||||
(navigator.userAgent.includes('Safari') &&
|
||||
navigator.userAgent.includes('Chrome')) ||
|
||||
navigator.userAgent.includes('Firefox')
|
||||
) {
|
||||
return container.scrollTop === 0;
|
||||
} else if (navigator.userAgent.includes("Safari")) {
|
||||
} else if (navigator.userAgent.includes('Safari')) {
|
||||
return (
|
||||
container.scrollHeight + Math.round(container.scrollTop) <=
|
||||
container.clientHeight + 10
|
||||
@ -308,15 +315,15 @@ export function scrollIsAtTop(container) {
|
||||
|
||||
export function scrollIsAtBottom(container) {
|
||||
if (
|
||||
(navigator.userAgent.includes("Safari") &&
|
||||
navigator.userAgent.includes("Chrome")) ||
|
||||
navigator.userAgent.includes("Firefox")
|
||||
(navigator.userAgent.includes('Safari') &&
|
||||
navigator.userAgent.includes('Chrome')) ||
|
||||
navigator.userAgent.includes('Firefox')
|
||||
) {
|
||||
return (
|
||||
container.scrollHeight - Math.round(container.scrollTop) <=
|
||||
container.clientHeight + 10
|
||||
);
|
||||
} else if (navigator.userAgent.includes("Safari")) {
|
||||
} else if (navigator.userAgent.includes('Safari')) {
|
||||
return container.scrollTop === 0;
|
||||
} else {
|
||||
return false;
|
||||
|
24
pkg/interface/src/logic/lib/workspace.ts
Normal file
24
pkg/interface/src/logic/lib/workspace.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Associations, Workspace } from "~/types";
|
||||
|
||||
export function getTitleFromWorkspace(
|
||||
associations: Associations,
|
||||
workspace: Workspace
|
||||
) {
|
||||
switch (workspace.type) {
|
||||
case "home":
|
||||
return "Home";
|
||||
case "group":
|
||||
const association = associations.contacts[workspace.group];
|
||||
return association?.metadata?.title || "";
|
||||
}
|
||||
}
|
||||
|
||||
export function getGroupFromWorkspace(
|
||||
workspace: Workspace
|
||||
): string | undefined {
|
||||
if (workspace.type === "group") {
|
||||
return workspace.group;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
@ -100,5 +100,6 @@ export default class ChatReducer<S extends ChatState> {
|
||||
mailbox.splice(index, 1);
|
||||
}
|
||||
}
|
||||
state.pendingMessages.set(msg.path, mailbox);
|
||||
}
|
||||
}
|
||||
|
@ -18,6 +18,19 @@ import LaunchReducer from '../reducers/launch-update';
|
||||
import LinkListenReducer from '../reducers/listen-update';
|
||||
import ConnectionReducer from '../reducers/connection';
|
||||
|
||||
export const homeAssociation = {
|
||||
"app-path": "/home",
|
||||
"app-name": "contact",
|
||||
"group-path": "/home",
|
||||
metadata: {
|
||||
color: "0x0",
|
||||
title: "Home",
|
||||
description: "",
|
||||
"date-created": "",
|
||||
module: "",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
export default class GlobalStore extends BaseStore<StoreState> {
|
||||
inviteReducer = new InviteReducer();
|
||||
|
@ -38,7 +38,7 @@ export interface StoreState {
|
||||
permissions: Permissions;
|
||||
s3: S3State;
|
||||
graphs: Graphs;
|
||||
graphKeys: Set<String>;
|
||||
graphKeys: Set<string>;
|
||||
|
||||
|
||||
// App specific states
|
||||
|
@ -19,8 +19,8 @@ const publishSubscriptions: AppSubscription[] = [
|
||||
];
|
||||
|
||||
const linkSubscriptions: AppSubscription[] = [
|
||||
['/json/seen', 'link-view'],
|
||||
['/listening', 'link-listen-hook']
|
||||
// ['/json/seen', 'link-view'],
|
||||
// ['/listening', 'link-listen-hook']
|
||||
]
|
||||
|
||||
const groupSubscriptions: AppSubscription[] = [
|
||||
|
@ -17,3 +17,4 @@ export * from './permission-update';
|
||||
export * from './publish-response';
|
||||
export * from './publish-update';
|
||||
export * from './s3-update';
|
||||
export * from './workspace';
|
||||
|
@ -31,7 +31,7 @@ type MetadataUpdateRemove = {
|
||||
|
||||
export type Associations = Record<AppName, AppAssociations>;
|
||||
|
||||
type AppAssociations = {
|
||||
export type AppAssociations = {
|
||||
[p in Path]: Association;
|
||||
}
|
||||
|
||||
@ -51,4 +51,5 @@ interface Metadata {
|
||||
'date-created': string;
|
||||
description: string;
|
||||
title: string;
|
||||
module: string;
|
||||
}
|
||||
|
@ -18,7 +18,7 @@ export type Serial = string;
|
||||
export type Jug<K,V> = Map<K,Set<V>>;
|
||||
|
||||
// name of app
|
||||
export type AppName = 'chat' | 'link' | 'contacts' | 'publish';
|
||||
export type AppName = 'chat' | 'link' | 'contacts' | 'publish' | 'graph';
|
||||
|
||||
export function getTagFromFrond<O>(frond: O): keyof O {
|
||||
const tags = Object.keys(frond) as Array<keyof O>;
|
||||
|
12
pkg/interface/src/types/workspace.ts
Normal file
12
pkg/interface/src/types/workspace.ts
Normal file
@ -0,0 +1,12 @@
|
||||
|
||||
|
||||
interface GroupWorkspace {
|
||||
type: 'group';
|
||||
group: string;
|
||||
}
|
||||
|
||||
interface HomeWorkspace {
|
||||
type: 'home'
|
||||
}
|
||||
|
||||
export type Workspace = HomeWorkspace | GroupWorkspace;
|
@ -45,13 +45,13 @@ const Root = styled.div`
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: ${ p => p.theme.colors.gray } ${ p => p.theme.colors.white };
|
||||
}
|
||||
|
||||
|
||||
/* Works on Chrome/Edge/Safari */
|
||||
*::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
*::-webkit-scrollbar-track {
|
||||
background: ${ p => p.theme.colors.white };
|
||||
background: transparent;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: ${ p => p.theme.colors.gray };
|
||||
@ -131,7 +131,7 @@ class App extends React.Component {
|
||||
? <link rel="icon" type="image/svg+xml" href={this.faviconString()} />
|
||||
: null}
|
||||
</Helmet>
|
||||
<Root background={background} >
|
||||
<Root background={background}>
|
||||
<Router>
|
||||
<ErrorBoundary>
|
||||
<StatusBarWithRouter
|
||||
@ -163,6 +163,7 @@ class App extends React.Component {
|
||||
</ErrorBoundary>
|
||||
</Router>
|
||||
</Root>
|
||||
<div id="portal-root" />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
139
pkg/interface/src/views/apps/chat/ChatResource.tsx
Normal file
139
pkg/interface/src/views/apps/chat/ChatResource.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
import React, { useRef, useCallback } from "react";
|
||||
import { RouteComponentProps } from "react-router-dom";
|
||||
import { Col } from "@tlon/indigo-react";
|
||||
|
||||
import { Association } from "~/types/metadata-update";
|
||||
import { StoreState } from "~/logic/store/type";
|
||||
import { useFileDrag } from "~/logic/lib/useDrag";
|
||||
import ChatWindow from "./components/ChatWindow";
|
||||
import ChatInput from "./components/ChatInput";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { deSig } from "~/logic/lib/util";
|
||||
import { SubmitDragger } from "~/views/components/s3-upload";
|
||||
import { useLocalStorageState } from "~/logic/lib/useLocalStorageState";
|
||||
|
||||
type ChatResourceProps = StoreState & {
|
||||
association: Association;
|
||||
api: GlobalApi;
|
||||
baseUrl: string;
|
||||
} & RouteComponentProps;
|
||||
|
||||
export function ChatResource(props: ChatResourceProps) {
|
||||
const station = props.association["app-path"];
|
||||
if (!props.chatInitialized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { envelopes, config } = props.inbox[station];
|
||||
const { read, length } = config;
|
||||
|
||||
const groupPath = props.association["group-path"];
|
||||
const group = props.groups[groupPath];
|
||||
const contacts = props.contacts[groupPath] || {};
|
||||
|
||||
const pendingMessages = (props.pendingMessages.get(station) || []).map(
|
||||
(value) => ({
|
||||
...value,
|
||||
pending: true,
|
||||
})
|
||||
);
|
||||
|
||||
const isChatMissing =
|
||||
(props.chatInitialized &&
|
||||
!(station in props.inbox) &&
|
||||
props.chatSynced &&
|
||||
!(station in props.chatSynced)) ||
|
||||
false;
|
||||
|
||||
const isChatLoading =
|
||||
(props.chatInitialized &&
|
||||
!(station in props.inbox) &&
|
||||
props.chatSynced &&
|
||||
station in props.chatSynced) ||
|
||||
false;
|
||||
|
||||
const isChatUnsynced =
|
||||
(props.chatSynced &&
|
||||
!(station in props.chatSynced) &&
|
||||
envelopes.length > 0) ||
|
||||
false;
|
||||
|
||||
const unreadCount = length - read;
|
||||
const unreadMsg = unreadCount > 0 && envelopes[unreadCount - 1];
|
||||
|
||||
const [, owner, name] = station.split("/");
|
||||
const ourContact = contacts?.[window.ship];
|
||||
const lastMsgNum = envelopes.length || 0;
|
||||
|
||||
const chatInput = useRef<ChatInput>();
|
||||
|
||||
const onFileDrag = useCallback(
|
||||
(files: FileList) => {
|
||||
if (!chatInput.current) {
|
||||
return;
|
||||
}
|
||||
chatInput.current?.uploadFiles(files);
|
||||
},
|
||||
[chatInput?.current]
|
||||
);
|
||||
|
||||
const { bind, dragging } = useFileDrag(onFileDrag);
|
||||
|
||||
const [unsent, setUnsent] = useLocalStorageState<Record<string, string>>(
|
||||
"chat-unsent",
|
||||
{}
|
||||
);
|
||||
|
||||
const appendUnsent = useCallback(
|
||||
(u: string) => setUnsent((s) => ({ ...s, [station]: u })),
|
||||
[station]
|
||||
);
|
||||
|
||||
const clearUnsent = useCallback(() => setUnsent((s) => _.omit(s, station)), [
|
||||
station,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Col {...bind} height="100%" overflow="hidden" position="relative">
|
||||
{dragging && <SubmitDragger />}
|
||||
<ChatWindow
|
||||
remoteContentPolicy={props.remoteContentPolicy}
|
||||
mailboxSize={length}
|
||||
match={props.match as any}
|
||||
stationPendingMessages={pendingMessages}
|
||||
history={props.history}
|
||||
isChatMissing={isChatMissing}
|
||||
isChatLoading={isChatLoading}
|
||||
isChatUnsynced={isChatUnsynced}
|
||||
unreadCount={unreadCount}
|
||||
unreadMsg={unreadMsg}
|
||||
envelopes={envelopes || []}
|
||||
contacts={contacts}
|
||||
association={props.association}
|
||||
group={group}
|
||||
ship={owner}
|
||||
station={station}
|
||||
allStations={Object.keys(props.inbox)}
|
||||
api={props.api}
|
||||
hideNicknames={props.hideNicknames}
|
||||
hideAvatars={props.hideAvatars}
|
||||
location={props.location}
|
||||
/>
|
||||
<ChatInput
|
||||
ref={chatInput}
|
||||
api={props.api}
|
||||
numMsgs={lastMsgNum}
|
||||
station={station}
|
||||
ourContact={ourContact}
|
||||
envelopes={envelopes || []}
|
||||
contacts={contacts}
|
||||
onUnmount={appendUnsent}
|
||||
s3={props.s3}
|
||||
hideAvatars={props.hideAvatars}
|
||||
placeholder="Message..."
|
||||
message={unsent[station] || ""}
|
||||
deleteMessage={clearUnsent}
|
||||
/>
|
||||
</Col>
|
||||
);
|
||||
}
|
@ -1,327 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import Helmet from 'react-helmet';
|
||||
|
||||
import './css/custom.css';
|
||||
|
||||
import { Skeleton } from './components/skeleton';
|
||||
import { Sidebar } from './components/sidebar';
|
||||
import { ChatScreen } from './components/chat';
|
||||
import { SettingsScreen } from './components/settings';
|
||||
import { NewScreen } from './components/new';
|
||||
import { JoinScreen } from './components/join';
|
||||
import { NewDmScreen } from './components/new-dm';
|
||||
import { PatpNoSig } from '~/types/noun';
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
import { StoreState } from '~/logic/store/type';
|
||||
import GlobalSubscription from '~/logic/subscription/global';
|
||||
import {groupBunts} from '~/types/group-update';
|
||||
|
||||
type ChatAppProps = StoreState & {
|
||||
ship: PatpNoSig;
|
||||
api: GlobalApi;
|
||||
subscription: GlobalSubscription;
|
||||
};
|
||||
|
||||
export default class ChatApp extends React.Component<ChatAppProps, {}> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// preload spinner asset
|
||||
new Image().src = '/~landscape/img/Spinner.png';
|
||||
|
||||
this.props.subscription.startApp('chat');
|
||||
|
||||
if (!this.props.sidebarShown) {
|
||||
this.props.api.local.sidebarToggle();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.subscription.stopApp('chat');
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props } = this;
|
||||
|
||||
const messagePreviews = {};
|
||||
const unreads = {};
|
||||
let totalUnreads = 0;
|
||||
|
||||
const associations = props.associations
|
||||
? props.associations
|
||||
: { chat: {}, contacts: {} };
|
||||
|
||||
Object.keys(props.inbox).forEach((stat) => {
|
||||
const envelopes = props.inbox[stat].envelopes;
|
||||
|
||||
if (envelopes.length === 0) {
|
||||
messagePreviews[stat] = false;
|
||||
} else {
|
||||
messagePreviews[stat] = envelopes[0];
|
||||
}
|
||||
|
||||
const unread = Math.max(
|
||||
props.inbox[stat].config.length - props.inbox[stat].config.read,
|
||||
0
|
||||
);
|
||||
unreads[stat] = Boolean(unread);
|
||||
if (
|
||||
unread &&
|
||||
stat in associations.chat
|
||||
) {
|
||||
totalUnreads += unread;
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
invites,
|
||||
s3,
|
||||
sidebarShown,
|
||||
inbox,
|
||||
contacts,
|
||||
chatSynced,
|
||||
api,
|
||||
chatInitialized,
|
||||
pendingMessages,
|
||||
groups,
|
||||
hideAvatars,
|
||||
hideNicknames,
|
||||
remoteContentPolicy
|
||||
} = props;
|
||||
|
||||
const renderChannelSidebar = (props, station?) => (
|
||||
<Sidebar
|
||||
inbox={inbox}
|
||||
messagePreviews={messagePreviews}
|
||||
associations={associations}
|
||||
contacts={contacts}
|
||||
invites={invites['/chat'] || {}}
|
||||
unreads={unreads}
|
||||
api={api}
|
||||
station={station}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet defer={false}>
|
||||
<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>
|
||||
</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}`;
|
||||
|
||||
// ensure we know joined chats
|
||||
if(!chatInitialized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Skeleton
|
||||
associations={associations}
|
||||
invites={invites}
|
||||
sidebarHideOnMobile={true}
|
||||
sidebar={renderChannelSidebar(props)}
|
||||
sidebarShown={sidebarShown}
|
||||
>
|
||||
<JoinScreen
|
||||
api={api}
|
||||
inbox={inbox}
|
||||
station={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']
|
||||
: '';
|
||||
|
||||
if (associations.chat[station] && associatedGroup in contacts) {
|
||||
roomContacts = contacts[associatedGroup];
|
||||
}
|
||||
|
||||
const association =
|
||||
station in associations['chat'] ? associations.chat[station] : {};
|
||||
|
||||
const group = groups[association['group-path']] || groupBunts.group();
|
||||
|
||||
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}
|
||||
mailboxSize={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}
|
||||
remoteContentPolicy={remoteContentPolicy}
|
||||
{...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();
|
||||
|
||||
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}
|
||||
popout={popout}
|
||||
sidebarShown={sidebarShown}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,7 +1,6 @@
|
||||
import React, { Component } from 'react';
|
||||
import ChatEditor from './chat-editor';
|
||||
import { S3Upload, SubmitDragger } from '~/views/components/s3-upload'
|
||||
;
|
||||
import { S3Upload, SubmitDragger } from '~/views/components/s3-upload' ;
|
||||
import { uxToHex } from '~/logic/lib/util';
|
||||
import { Sigil } from '~/logic/lib/sigil';
|
||||
import tokenizeMessage, { isUrl } from '~/logic/lib/tokenizeMessage';
|
||||
@ -13,8 +12,7 @@ interface ChatInputProps {
|
||||
api: GlobalApi;
|
||||
numMsgs: number;
|
||||
station: any;
|
||||
owner: string;
|
||||
ownerContact: any;
|
||||
ourContact: any;
|
||||
envelopes: Envelope[];
|
||||
contacts: Contacts;
|
||||
onUnmount(msg: string): void;
|
||||
@ -51,7 +49,7 @@ export default class ChatInput extends Component<ChatInputProps, ChatInputState>
|
||||
|
||||
this.submit = this.submit.bind(this);
|
||||
this.toggleCode = this.toggleCode.bind(this);
|
||||
|
||||
|
||||
}
|
||||
|
||||
toggleCode() {
|
||||
@ -83,7 +81,7 @@ export default class ChatInput extends Component<ChatInputProps, ChatInputState>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
submit(text) {
|
||||
const { props, state } = this;
|
||||
@ -135,7 +133,7 @@ export default class ChatInput extends Component<ChatInputProps, ChatInputState>
|
||||
{ url }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
uploadError(error) {
|
||||
@ -170,37 +168,32 @@ export default class ChatInput extends Component<ChatInputProps, ChatInputState>
|
||||
render() {
|
||||
const { props, state } = this;
|
||||
|
||||
const color = props.ownerContact
|
||||
? uxToHex(props.ownerContact.color) : '000000';
|
||||
const color = props.ourContact
|
||||
? uxToHex(props.ourContact.color) : '000000';
|
||||
|
||||
const sigilClass = props.ownerContact
|
||||
const sigilClass = props.ourContact
|
||||
? '' : 'mix-blend-diff';
|
||||
|
||||
const avatar = (
|
||||
props.ownerContact &&
|
||||
((props.ownerContact.avatar !== null) && !props.hideAvatars)
|
||||
props.ourContact &&
|
||||
((props.ourContact.avatar !== null) && !props.hideAvatars)
|
||||
)
|
||||
? <img src={props.ownerContact.avatar} height={24} width={24} className="dib" />
|
||||
? <img src={props.ourContact.avatar} height={16} width={16} className="dib" />
|
||||
: <Sigil
|
||||
ship={window.ship}
|
||||
size={24}
|
||||
size={16}
|
||||
color={`#${color}`}
|
||||
classes={sigilClass}
|
||||
/>;
|
||||
|
||||
return (
|
||||
<div className={
|
||||
"pa3 cf flex black white-d bt b--gray4 b--gray1-d bg-white " +
|
||||
"cf items-center flex black white-d bt b--gray4 b--gray1-d bg-white" +
|
||||
"bg-gray0-d relative"
|
||||
}
|
||||
style={{ flexGrow: 1 }}
|
||||
>
|
||||
<div className="fl"
|
||||
style={{
|
||||
marginTop: 6,
|
||||
flexBasis: 24,
|
||||
height: 24
|
||||
}}>
|
||||
<div className="pa2 flex items-center">
|
||||
{avatar}
|
||||
</div>
|
||||
<ChatEditor
|
||||
@ -212,12 +205,11 @@ export default class ChatInput extends Component<ChatInputProps, ChatInputState>
|
||||
onPaste={this.onPaste.bind(this)}
|
||||
placeholder='Message...'
|
||||
/>
|
||||
<div className="ml2 mr2"
|
||||
<div className="ml2 mr2 flex-shrink-0"
|
||||
style={{
|
||||
height: '16px',
|
||||
width: '16px',
|
||||
flexBasis: 16,
|
||||
marginTop: 10
|
||||
}}>
|
||||
<S3Upload
|
||||
ref={this.s3Uploader}
|
||||
@ -229,17 +221,16 @@ export default class ChatInput extends Component<ChatInputProps, ChatInputState>
|
||||
>
|
||||
<img
|
||||
className="invert-d"
|
||||
src="/~chat/img/ImageUpload.png"
|
||||
src="/~landscape/img/ImageUpload.png"
|
||||
width="16"
|
||||
height="16"
|
||||
/>
|
||||
</S3Upload>
|
||||
</div>
|
||||
<div style={{
|
||||
<div className="mr2 flex-shrink-0" style={{
|
||||
height: '16px',
|
||||
width: '16px',
|
||||
flexBasis: 16,
|
||||
marginTop: 10
|
||||
}}>
|
||||
<img style={{
|
||||
filter: state.inCodeMode ? 'invert(100%)' : '',
|
||||
@ -247,7 +238,7 @@ export default class ChatInput extends Component<ChatInputProps, ChatInputState>
|
||||
width: '14px',
|
||||
}}
|
||||
onClick={this.toggleCode}
|
||||
src="/~chat/img/CodeEval.png"
|
||||
src="/~landscape/img/CodeEval.png"
|
||||
className="contrast-10-d bg-white bg-none-d ba b--gray1-d br1" />
|
||||
</div>
|
||||
</div>
|
@ -14,14 +14,14 @@ import RemoteContent from '~/views/components/RemoteContent';
|
||||
export const DATESTAMP_FORMAT = '[~]YYYY.M.D';
|
||||
|
||||
export const UnreadMarker = React.forwardRef(({ dayBreak, when }, ref) => (
|
||||
<div ref={ref} className="green2 flex items-center f9 absolute w-100 left-0">
|
||||
<hr className="dn-s ma0 w2 b--green2 bt-0" />
|
||||
<p className="mh4" style={{ whiteSpace: 'normal' }}>New messages below</p>
|
||||
<hr className="ma0 flex-grow-1 b--green2 bt-0" />
|
||||
<div ref={ref} style={{ color: "#219dff" }} className="flex items-center f9 absolute w-100 left-0 pv0">
|
||||
<hr style={{ borderColor: "#219dff" }} className="dn-s ma0 w2 bt-0" />
|
||||
<p className="mh4 z-2" style={{ whiteSpace: 'normal' }}>New messages below</p>
|
||||
<hr style={{ borderColor: "#219dff" }} className="ma0 flex-grow-1 bt-0" />
|
||||
{dayBreak
|
||||
? <p className="gray2 mh4">{moment(when).calendar()}</p>
|
||||
: null}
|
||||
<hr style={{ width: "calc(50% - 48px)" }} className="b--green2 ma0 bt-0" />
|
||||
<hr style={{ width: "calc(50% - 48px)" }} style={{ borderColor: "#219dff" }} className="ma0 bt-0" />
|
||||
</div>
|
||||
));
|
||||
|
||||
@ -46,9 +46,12 @@ interface ChatMessageProps {
|
||||
className?: string;
|
||||
isPending: boolean;
|
||||
style?: any;
|
||||
allStations: any;
|
||||
scrollWindow: HTMLDivElement;
|
||||
isLastMessage?: boolean;
|
||||
unreadMarkerRef: React.RefObject<HTMLDivElement>;
|
||||
history: any;
|
||||
api: any;
|
||||
}
|
||||
|
||||
export default class ChatMessage extends Component<ChatMessageProps> {
|
||||
@ -83,15 +86,18 @@ export default class ChatMessage extends Component<ChatMessageProps> {
|
||||
measure,
|
||||
scrollWindow,
|
||||
isLastMessage,
|
||||
unreadMarkerRef
|
||||
unreadMarkerRef,
|
||||
allStations,
|
||||
history,
|
||||
api
|
||||
} = this.props;
|
||||
|
||||
|
||||
const renderSigil = Boolean((nextMsg && msg.author !== nextMsg.author) || !nextMsg || msg.number === 1);
|
||||
const dayBreak = nextMsg && new Date(msg.when).getDate() !== new Date(nextMsg.when).getDate();
|
||||
|
||||
const containerClass = `${renderSigil
|
||||
? `w-100 flex flex-wrap cf pr3 f7 pt4 pl3 lh-copy`
|
||||
: `w-100 flex flex-wrap cf pr3 hide-child`} ${isPending ? 'o-40' : ''} ${isLastMessage ? 'pb3' : ''} ${className}`
|
||||
? `f9 w-100 flex flex-wrap cf pr3 pt4 pl3 lh-copy`
|
||||
: `f9 w-100 flex flex-wrap items-center cf pr3 hide-child`} ${isPending ? 'o-40' : ''} ${isLastMessage ? 'pb3' : ''} ${className}`
|
||||
|
||||
const timestamp = moment.unix(msg.when / 1000).format(renderSigil ? 'hh:mm a' : 'hh:mm');
|
||||
|
||||
@ -112,6 +118,9 @@ export default class ChatMessage extends Component<ChatMessageProps> {
|
||||
style,
|
||||
containerClass,
|
||||
isPending,
|
||||
allStations,
|
||||
history,
|
||||
api,
|
||||
scrollWindow
|
||||
};
|
||||
|
||||
@ -125,7 +134,7 @@ export default class ChatMessage extends Component<ChatMessageProps> {
|
||||
{renderSigil
|
||||
? <MessageWithSigil {...messageProps} />
|
||||
: <MessageWithoutSigil {...messageProps} />}
|
||||
<Box fontSize='0' position='relative' width='100%' overflow='hidden' style={unreadContainerStyle}>{isLastRead
|
||||
<Box fontSize='0' position='relative' width='100%' overflow='visible' style={unreadContainerStyle}>{isLastRead
|
||||
? <UnreadMarker dayBreak={dayBreak} when={msg.when} ref={unreadMarkerRef} />
|
||||
: null}</Box>
|
||||
</div>
|
||||
@ -145,6 +154,7 @@ interface MessageProps {
|
||||
containerClass: string;
|
||||
isPending: boolean;
|
||||
style: any;
|
||||
allStations: any;
|
||||
measure(element): void;
|
||||
scrollWindow: HTMLDivElement;
|
||||
};
|
||||
@ -161,6 +171,9 @@ export class MessageWithSigil extends PureComponent<MessageProps> {
|
||||
hideAvatars,
|
||||
remoteContentPolicy,
|
||||
measure,
|
||||
allStations,
|
||||
history,
|
||||
api,
|
||||
scrollWindow
|
||||
} = this.props;
|
||||
|
||||
@ -194,11 +207,14 @@ export class MessageWithSigil extends PureComponent<MessageProps> {
|
||||
hideAvatars={hideAvatars}
|
||||
hideNicknames={hideNicknames}
|
||||
scrollWindow={scrollWindow}
|
||||
allStations={allStations}
|
||||
history={history}
|
||||
api={api}
|
||||
className="fl pr3 v-top bg-white bg-gray0-d"
|
||||
/>
|
||||
<div className="fr clamp-message white-d" style={{ flexGrow: 1, marginTop: -8 }}>
|
||||
<div className="hide-child" style={{ paddingTop: '6px' }}>
|
||||
<p className="v-mid f9 gray2 dib mr3 c-default">
|
||||
<div className="fr f8 clamp-message white-d" style={{ flexGrow: 1, marginTop: -12 }}>
|
||||
<div className="hide-child" style={{ paddingTop: '8px' }}>
|
||||
<p className="v-mid f9 black white-d dib mr3 c-default">
|
||||
<span
|
||||
className={`mw5 db truncate pointer ${showNickname ? '' : 'mono'}`}
|
||||
ref={e => nameSpan = e}
|
||||
@ -221,8 +237,8 @@ export class MessageWithSigil extends PureComponent<MessageProps> {
|
||||
|
||||
export const MessageWithoutSigil = ({ timestamp, msg, remoteContentPolicy, measure }) => (
|
||||
<>
|
||||
<p className="child pt2 pl2 pr1 mono f9 gray2 dib">{timestamp}</p>
|
||||
<div className="fr f7 clamp-message white-d pr3 lh-copy" style={{ flexGrow: 1 }}>
|
||||
<p className="child pr1 mono f9 gray2 dib">{timestamp}</p>
|
||||
<div className="fr f8 clamp-message white-d pr3 lh-copy" style={{ flexGrow: 1 }}>
|
||||
<MessageContent content={msg.letter} remoteContentPolicy={remoteContentPolicy} measure={measure}/>
|
||||
</div>
|
||||
</>
|
||||
@ -247,7 +263,7 @@ export const MessageContent = ({ content, remoteContentPolicy, measure }) => {
|
||||
);
|
||||
} else if ('me' in content) {
|
||||
return (
|
||||
<p className='f7 i lh-copy v-top'>
|
||||
<p className='f9 i lh-copy v-top'>
|
||||
{content.me}
|
||||
</p>
|
||||
);
|
||||
@ -283,4 +299,4 @@ export const MessagePlaceholder = ({ height, index, className = '', style = {},
|
||||
<span className="bg-gray5 bg-gray1-d db w-100 h-100 db" style={{height: `1em`, width: `${(index % 5) * 20}%`}}></span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
@ -39,6 +39,7 @@ type ChatWindowProps = RouteComponentProps<{
|
||||
group: Group;
|
||||
ship: Patp;
|
||||
station: any;
|
||||
allStations: any;
|
||||
api: GlobalApi;
|
||||
hideNicknames: boolean;
|
||||
hideAvatars: boolean;
|
||||
@ -127,7 +128,7 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
|
||||
const { isChatMissing, history, envelopes, mailboxSize, stationPendingMessages, unreadCount } = this.props;
|
||||
|
||||
if (isChatMissing) {
|
||||
history.push("/~chat");
|
||||
history.push("/~404");
|
||||
} else if (envelopes.length !== prevProps.envelopes.length && this.state.fetchPending) {
|
||||
this.setState({ fetchPending: false });
|
||||
}
|
||||
@ -240,6 +241,8 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
|
||||
hideAvatars,
|
||||
hideNicknames,
|
||||
remoteContentPolicy,
|
||||
allStations,
|
||||
history
|
||||
} = this.props;
|
||||
|
||||
const unreadMarkerRef = this.unreadMarkerRef;
|
||||
@ -262,7 +265,7 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
|
||||
lastMessage = mailboxSize + index;
|
||||
});
|
||||
|
||||
const messageProps = { association, group, contacts, hideAvatars, hideNicknames, remoteContentPolicy, unreadMarkerRef };
|
||||
const messageProps = { association, group, contacts, hideAvatars, hideNicknames, remoteContentPolicy, unreadMarkerRef, allStations, history, api };
|
||||
|
||||
return (
|
||||
<>
|
@ -11,7 +11,7 @@ export const BacklogElement = (props) => {
|
||||
"white-d flex items-center"
|
||||
}>
|
||||
<img className="invert-d spin-active v-mid"
|
||||
src="/~chat/img/Spinner.png"
|
||||
src="/~landscape/img/Spinner.png"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
@ -8,6 +8,8 @@ import 'codemirror/addon/display/placeholder';
|
||||
|
||||
import 'codemirror/lib/codemirror.css';
|
||||
|
||||
import '../css/custom.css';
|
||||
|
||||
const BROWSER_REGEX =
|
||||
new RegExp(String(/Android|webOS|iPhone|iPad|iPod|BlackBerry/i));
|
||||
|
||||
@ -131,10 +133,10 @@ export default class ChatEditor extends Component {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'chat fr h-100 flex bg-gray0-d lh-copy pl2 w-100 items-center' +
|
||||
'chat fr h-100 flex bg-gray0-d lh-copy w-100 items-center ' +
|
||||
(inCodeMode ? ' code' : '')
|
||||
}
|
||||
style={{ flexGrow: 1, maxHeight: '224px', width: 'calc(100% - 72px)' }}>
|
||||
style={{ flexGrow: 1, paddingBottom: '3px', maxHeight: '224px', width: 'calc(100% - 88px)' }}>
|
||||
<CodeEditor
|
||||
value={message}
|
||||
options={options}
|
@ -1,194 +0,0 @@
|
||||
import React, { Component } from "react";
|
||||
import moment from "moment";
|
||||
import { RouteComponentProps } from "react-router-dom";
|
||||
|
||||
import { deSig } from "~/logic/lib/util";
|
||||
import { ChatHookUpdate } from "~/types/chat-hook-update";
|
||||
import { Inbox, Envelope } from "~/types/chat-update";
|
||||
import { Contacts } from "~/types/contact-update";
|
||||
import { Path, Patp } from "~/types/noun";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { Association } from "~/types/metadata-update";
|
||||
import {Group} from "~/types/group-update";
|
||||
import { LocalUpdateRemoteContentPolicy } from "~/types";
|
||||
import { SubmitDragger } from '~/views/components/s3-upload';
|
||||
|
||||
import ChatWindow from './lib/ChatWindow';
|
||||
import ChatHeader from './lib/ChatHeader';
|
||||
import ChatInput from "./lib/ChatInput";
|
||||
|
||||
|
||||
type ChatScreenProps = RouteComponentProps<{
|
||||
ship: Patp;
|
||||
station: string;
|
||||
}> & {
|
||||
chatSynced: ChatHookUpdate;
|
||||
station: any;
|
||||
association: Association;
|
||||
api: GlobalApi;
|
||||
read: number;
|
||||
mailboxSize: number;
|
||||
inbox: Inbox;
|
||||
contacts: Contacts;
|
||||
group: Group;
|
||||
pendingMessages: Map<Path, Envelope[]>;
|
||||
s3: any;
|
||||
popout: boolean;
|
||||
sidebarShown: boolean;
|
||||
chatInitialized: boolean;
|
||||
envelopes: Envelope[];
|
||||
hideAvatars: boolean;
|
||||
hideNicknames: boolean;
|
||||
remoteContentPolicy: LocalUpdateRemoteContentPolicy;
|
||||
};
|
||||
|
||||
interface ChatScreenState {
|
||||
messages: Map<string, string>;
|
||||
dragover: boolean;
|
||||
}
|
||||
|
||||
export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
|
||||
private chatInput: React.RefObject<ChatInput>;
|
||||
lastNumPending = 0;
|
||||
activityTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
messages: new Map(),
|
||||
dragover: false,
|
||||
};
|
||||
|
||||
this.chatInput = React.createRef();
|
||||
|
||||
moment.updateLocale("en", {
|
||||
calendar: {
|
||||
sameDay: "[Today]",
|
||||
nextDay: "[Tomorrow]",
|
||||
nextWeek: "dddd",
|
||||
lastDay: "[Yesterday]",
|
||||
lastWeek: "[Last] dddd",
|
||||
sameElse: "DD/MM/YYYY",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
readyToUpload(): boolean {
|
||||
return Boolean(this.chatInput.current?.s3Uploader.current?.inputRef.current);
|
||||
}
|
||||
|
||||
onDragEnter(event) {
|
||||
if (!this.readyToUpload() || (!event.dataTransfer.files.length && !event.dataTransfer.types.includes('Files'))) {
|
||||
return;
|
||||
}
|
||||
this.setState({ dragover: true });
|
||||
}
|
||||
|
||||
onDrop(event: DragEvent) {
|
||||
this.setState({ dragover: false });
|
||||
event.preventDefault();
|
||||
if (!event.dataTransfer || !event.dataTransfer.files.length) {
|
||||
return;
|
||||
}
|
||||
if (event.dataTransfer.items.length && !event.dataTransfer.files.length) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
this.chatInput.current?.uploadFiles(event.dataTransfer.files);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props, state } = this;
|
||||
|
||||
const lastMsgNum = props.envelopes.length > 0 ? props.envelopes.length : 0;
|
||||
const ownerContact =
|
||||
window.ship in props.contacts ? props.contacts[window.ship] : false;
|
||||
|
||||
const pendingMessages = (props.pendingMessages.get(props.station) || [])
|
||||
.map((value) => ({
|
||||
...value,
|
||||
pending: true
|
||||
}));
|
||||
|
||||
const isChatMissing =
|
||||
props.chatInitialized &&
|
||||
!(props.station in props.inbox) &&
|
||||
props.chatSynced &&
|
||||
!(props.station in props.chatSynced);
|
||||
|
||||
const isChatLoading =
|
||||
props.chatInitialized &&
|
||||
!(props.station in props.inbox) &&
|
||||
props.chatSynced &&
|
||||
(props.station in props.chatSynced);
|
||||
|
||||
const isChatUnsynced =
|
||||
props.chatSynced &&
|
||||
!(props.station in props.chatSynced) &&
|
||||
props.envelopes.length > 0;
|
||||
|
||||
const unreadCount = props.mailboxSize - props.read;
|
||||
const unreadMsg = unreadCount > 0 && props.envelopes[unreadCount - 1];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={props.station}
|
||||
className="h-100 w-100 overflow-hidden flex flex-column relative"
|
||||
onDragEnter={this.onDragEnter.bind(this)}
|
||||
onDragOver={event => {
|
||||
event.preventDefault();
|
||||
if (
|
||||
!this.state.dragover
|
||||
&& (
|
||||
(event.dataTransfer.files.length && event.dataTransfer.files[0].kind === 'file')
|
||||
|| (event.dataTransfer.items.length && event.dataTransfer.items[0].kind === 'file')
|
||||
)
|
||||
) {
|
||||
this.setState({ dragover: true });
|
||||
}
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
const over = document.elementFromPoint(event.clientX, event.clientY);
|
||||
if (!over || !event.currentTarget.contains(over)) {
|
||||
this.setState({ dragover: false });
|
||||
}}
|
||||
}
|
||||
onDrop={this.onDrop.bind(this)}
|
||||
>
|
||||
{this.state.dragover ? <SubmitDragger /> : null}
|
||||
<ChatHeader {...props} />
|
||||
<ChatWindow
|
||||
isChatMissing={isChatMissing}
|
||||
isChatLoading={isChatLoading}
|
||||
isChatUnsynced={isChatUnsynced}
|
||||
unreadCount={unreadCount}
|
||||
unreadMsg={unreadMsg}
|
||||
stationPendingMessages={pendingMessages}
|
||||
ship={props.match.params.ship}
|
||||
{...props} />
|
||||
<ChatInput
|
||||
ref={this.chatInput}
|
||||
api={props.api}
|
||||
numMsgs={lastMsgNum}
|
||||
station={props.station}
|
||||
owner={deSig(props.match.params.ship)}
|
||||
ownerContact={ownerContact}
|
||||
envelopes={props.envelopes}
|
||||
contacts={props.contacts}
|
||||
onUnmount={(msg: string) => this.setState({
|
||||
messages: this.state.messages.set(props.station, msg)
|
||||
})}
|
||||
s3={props.s3}
|
||||
placeholder="Message..."
|
||||
message={this.state.messages.get(props.station) || ""}
|
||||
deleteMessage={() => this.setState({
|
||||
messages: this.state.messages.set(props.station, "")
|
||||
})}
|
||||
hideAvatars={props.hideAvatars}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -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={`code f7 clamp-attachment pa1 mt0 mb0`}>
|
||||
<pre className={`code f9 clamp-attachment pa1 mt0 mb0`}>
|
||||
{content.code.output[0].join('\n')}
|
||||
</pre>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="mv2">
|
||||
<pre className={`code f7 clamp-attachment pa1 mt0 mb0`}>
|
||||
<pre className={`code f9 clamp-attachment pa1 mt0 mb0`}>
|
||||
{content.code.expression}
|
||||
</pre>
|
||||
{outputElement}
|
@ -39,7 +39,7 @@ const MessageMarkdown = React.memo(props => (
|
||||
node.children[0].children[0].value = '>' + node.children[0].children[0].value;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
return true;
|
||||
}}
|
||||
plugins={[[
|
||||
@ -64,14 +64,14 @@ export default class TextContent extends Component {
|
||||
&& (group[0] === content.text))) { // entire message is room name?
|
||||
return (
|
||||
<Link
|
||||
className="bb b--black b--white-d f7 mono lh-copy v-top"
|
||||
className="bb b--black b--white-d f8 mono lh-copy v-top"
|
||||
to={'/~groups/join/' + group.input}>
|
||||
{content.text}
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Box style={{ overflowWrap: 'break-word' }}>
|
||||
<Box fontSize='1' color='darkGray' style={{ overflowWrap: 'break-word' }}>
|
||||
<MessageMarkdown source={content.text} />
|
||||
</Box>
|
||||
);
|
@ -1,109 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Spinner } from '../../../components/Spinner';
|
||||
import urbitOb from 'urbit-ob';
|
||||
import { Box, Text, ManagedTextInputField as Input, Button } from '@tlon/indigo-react';
|
||||
import { Formik, Form } from 'formik'
|
||||
import * as Yup from 'yup';
|
||||
|
||||
|
||||
const schema = Yup.object().shape({
|
||||
station: Yup.string()
|
||||
.lowercase()
|
||||
.trim()
|
||||
.test('is-station',
|
||||
'Chat must have a valid name',
|
||||
(val) =>
|
||||
val &&
|
||||
val.split('/').length === 2 &&
|
||||
urbitOb.isValidPatp(val.split('/')[0])
|
||||
)
|
||||
.required('Required')
|
||||
});
|
||||
|
||||
|
||||
export class JoinScreen extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
awaiting: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.station) {
|
||||
this.onSubmit({ station: this.props.station });
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit(values) {
|
||||
const { props } = this;
|
||||
this.setState({ awaiting: true }, () => {
|
||||
const station = values.station.trim();
|
||||
if (`/${station}` in props.chatSynced) {
|
||||
if (props.station) {
|
||||
props.history.replace(`/~chat/room${station}`);
|
||||
} else {
|
||||
props.history.push(`/~chat/room${station}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const ship = station.substr(1).slice(0,station.substr(1).indexOf('/'));
|
||||
|
||||
props.api.chat.join(ship, station, true).then(() => {
|
||||
if (props.station) {
|
||||
props.history.replace(`/~chat/room${station}`);
|
||||
} else {
|
||||
props.history.push(`/~chat/room${station}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props, state } = this;
|
||||
|
||||
return (
|
||||
<Formik
|
||||
enableReinitialize={true}
|
||||
initialValues={{ station: props.station }}
|
||||
validationSchema={schema}
|
||||
onSubmit={this.onSubmit.bind(this)}>
|
||||
<Form>
|
||||
<Box width="100%" height="100%" p={3} overflowX="hidden">
|
||||
<Box
|
||||
width="100%"
|
||||
pt={1} pb={5}
|
||||
display={['', 'none', 'none', 'none']}
|
||||
fontSize={0}>
|
||||
<Link to="/~chat/">{'⟵ All Chats'}</Link>
|
||||
</Box>
|
||||
<Text mb={3} fontSize={0}>Join Existing Chat</Text>
|
||||
<Box width="100%" maxWidth={350}>
|
||||
<Box mt={3} mb={3} display="block">
|
||||
<Text display="inline" fontSize={0}>
|
||||
Enter a{' '}
|
||||
</Text>
|
||||
<Text display="inline" fontSize={0} fontFamily="mono">
|
||||
~ship/chat-name
|
||||
</Text>
|
||||
</Box>
|
||||
<Input
|
||||
mt={4}
|
||||
id="station"
|
||||
placeholder="~zod/chatroom"
|
||||
fontFamily="mono"
|
||||
caption="Chat names use lowercase, hyphens, and slashes." />
|
||||
<Button>Join Chat</Button>
|
||||
<Spinner
|
||||
awaiting={this.state.awaiting}
|
||||
classes="mt4"
|
||||
text="Joining chat..." />
|
||||
</Box>
|
||||
</Box>
|
||||
</Form>
|
||||
</Formik>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,63 +0,0 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { TabBar } from '~/views/components/chat-link-tabbar';
|
||||
import { SidebarSwitcher } from '~/views/components/SidebarSwitch';
|
||||
import { deSig } from '~/logic/lib/util';
|
||||
|
||||
const ChatHeader = (props) => {
|
||||
const isInPopout = props.popout ? 'popout/' : '';
|
||||
const group = Array.from(props.group.members);
|
||||
let title = props.station.substr(1);
|
||||
if (props.association &&
|
||||
'metadata' in props.association &&
|
||||
props.association.metadata.tile !== '') {
|
||||
title = props.association.metadata.title;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div
|
||||
className="w-100 dn-m dn-l dn-xl inter pt4 pb6 pl3 f8"
|
||||
style={{ height: '1rem' }}
|
||||
>
|
||||
<Link to="/~chat/">{'⟵ All Chats'}</Link>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
'pl4 pt2 bb b--gray4 b--gray1-d bg-gray0-d flex relative ' +
|
||||
'overflow-x-auto overflow-y-hidden flex-shrink-0 '
|
||||
}
|
||||
style={{ height: 48 }}
|
||||
>
|
||||
<SidebarSwitcher
|
||||
sidebarShown={props.sidebarShown}
|
||||
popout={props.popout}
|
||||
api={props.api}
|
||||
/>
|
||||
<Link
|
||||
to={'/~chat/' + isInPopout + 'room' + props.station}
|
||||
className="pt2 white-d"
|
||||
>
|
||||
<h2
|
||||
className={
|
||||
'dib f9 fw4 lh-solid v-top ' +
|
||||
(title === props.station.substr(1) ? 'mono' : '')
|
||||
}
|
||||
style={{ width: 'max-content' }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
</Link>
|
||||
<TabBar
|
||||
location={props.location}
|
||||
popoutHref={`/~chat/popout/room${props.station}`}
|
||||
settings={`/~chat/${isInPopout}settings${props.station}`}
|
||||
popout={props.popout}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatHeader;
|
@ -1,37 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
export class ChannelItem extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
onClick() {
|
||||
const { props } = this;
|
||||
props.history.push('/~chat/room' + props.box);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props } = this;
|
||||
|
||||
const unreadElem = props.unread ? 'fw6 white-d' : '';
|
||||
|
||||
const title = props.title;
|
||||
|
||||
const selectedCss = props.selected
|
||||
? 'bg-gray4 bg-gray1-d gray3-d c-default'
|
||||
: 'bg-white bg-gray0-d gray3-d hover-bg-gray5 hover-bg-gray1-d pointer';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={'z1 ph5 pv1 ' + selectedCss}
|
||||
onClick={this.onClick.bind(this)}
|
||||
>
|
||||
<div className="w-100 v-mid">
|
||||
<p className={'dib f9 ' + unreadElem}>
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,56 +0,0 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
export const DeleteButton = memo(({ isOwner, station, changeLoading, association, contacts, api, history }) => {
|
||||
const leaveButtonClasses = (!isOwner) ? 'pointer' : 'c-default';
|
||||
const deleteButtonClasses = (isOwner) ?
|
||||
'b--red2 red2 pointer bg-gray0-d' :
|
||||
'b--gray3 gray3 bg-gray0-d c-default';
|
||||
|
||||
const deleteChat = () => {
|
||||
changeLoading(
|
||||
true,
|
||||
true,
|
||||
isOwner ? 'Deleting chat...' : 'Leaving chat...',
|
||||
() => {
|
||||
api.chat.delete(station).then(() => {
|
||||
history.push("/~chat");
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const groupPath = association['group-path'];
|
||||
const unmanagedVillage = !contacts[groupPath];
|
||||
|
||||
return (
|
||||
<div className="w-100 cf">
|
||||
<div className={'w-100 fl mt3 ' + ((isOwner) ? 'o-30' : '')}>
|
||||
<p className="f8 mt3 lh-copy db">Leave Chat</p>
|
||||
<p className="f9 gray2 db mb4">
|
||||
Remove this chat from your chat list.{' '}
|
||||
{unmanagedVillage
|
||||
? 'You will need to request for access again'
|
||||
: 'You will need to join again from the group page.'
|
||||
}
|
||||
</p>
|
||||
<a onClick={(!isOwner) ? deleteChat : null}
|
||||
className={
|
||||
'dib f9 black gray4-d bg-gray0-d ba pa2 b--black b--gray1-d ' +
|
||||
leaveButtonClasses
|
||||
}>
|
||||
Leave this chat
|
||||
</a>
|
||||
</div>
|
||||
<div className={'w-100 fl mt3 ' + ((!isOwner) ? 'o-30' : '')}>
|
||||
<p className="f8 mt3 lh-copy db">Delete Chat</p>
|
||||
<p className="f9 gray2 db mb4">
|
||||
Permanently delete this chat.{' '}
|
||||
All current members will no longer see this chat.
|
||||
</p>
|
||||
<a onClick={(isOwner) ? deleteChat : null}
|
||||
className={'dib f9 ba pa2 ' + deleteButtonClasses}
|
||||
>Delete this chat</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
@ -1,100 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ChannelItem } from './channel-item';
|
||||
import { deSig, cite } from "~/logic/lib/util";
|
||||
|
||||
export class GroupItem extends Component {
|
||||
render() {
|
||||
const { props } = this;
|
||||
const association = props.association ? props.association : {};
|
||||
const DEFAULT_TITLE_REGEX = new RegExp(`(( <-> )?~(?:${window.ship}|${deSig(cite(window.ship))})( <-> )?)`);
|
||||
|
||||
let title = association['app-path'] ? association['app-path'] : 'Direct Messages';
|
||||
if (association.metadata && association.metadata.title) {
|
||||
title = association.metadata.title !== ''
|
||||
? association.metadata.title
|
||||
: title;
|
||||
}
|
||||
|
||||
const channels = props.channels ? props.channels : [];
|
||||
const first = (props.index === 0) ? 'mt1 ' : 'mt6 ';
|
||||
|
||||
const channelItems = channels.sort((a, b) => {
|
||||
if (props.index === 'dm') {
|
||||
const aPreview = props.messagePreviews[a];
|
||||
const bPreview = props.messagePreviews[b];
|
||||
const aWhen = aPreview ? aPreview.when : 0;
|
||||
const bWhen = bPreview ? bPreview.when : 0;
|
||||
|
||||
return bWhen - aWhen;
|
||||
} else {
|
||||
const aAssociation = a in props.chatMetadata ? props.chatMetadata[a] : {};
|
||||
const bAssociation = b in props.chatMetadata ? props.chatMetadata[b] : {};
|
||||
let aTitle = a;
|
||||
let bTitle = b;
|
||||
if (aAssociation.metadata && aAssociation.metadata.title) {
|
||||
aTitle = (aAssociation.metadata.title !== '')
|
||||
? aAssociation.metadata.title : a;
|
||||
}
|
||||
if (bAssociation.metadata && bAssociation.metadata.title) {
|
||||
bTitle =
|
||||
bAssociation.metadata.title !== '' ? bAssociation.metadata.title : b;
|
||||
}
|
||||
return aTitle.toLowerCase().localeCompare(bTitle.toLowerCase());
|
||||
}
|
||||
}).map((each, i) => {
|
||||
const unread = props.unreads[each];
|
||||
let title = each.substr(1);
|
||||
if (
|
||||
each in props.chatMetadata &&
|
||||
props.chatMetadata[each].metadata
|
||||
) {
|
||||
if (props.chatMetadata[each].metadata.title) {
|
||||
title = props.chatMetadata[each].metadata.title
|
||||
}
|
||||
}
|
||||
|
||||
if (DEFAULT_TITLE_REGEX.test(title) && props.index === "dm") {
|
||||
title = title.replace(DEFAULT_TITLE_REGEX, '');
|
||||
}
|
||||
const selected = props.station === each;
|
||||
|
||||
return (
|
||||
<ChannelItem
|
||||
key={i}
|
||||
unread={unread}
|
||||
title={title}
|
||||
selected={selected}
|
||||
box={each}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
if (channelItems.length === 0) {
|
||||
channelItems.push(<p className="gray2 mt4 f9 tc">No direct messages</p>);
|
||||
}
|
||||
|
||||
let dmLink = <div />;
|
||||
|
||||
if (props.index === 'dm') {
|
||||
dmLink = <Link
|
||||
key="link"
|
||||
className="absolute right-0 f9 top-0 mr4 green2 bg-gray5 bg-gray1-d b--transparent br1"
|
||||
to="/~chat/new/dm"
|
||||
style={{ padding: '0rem 0.2rem' }}
|
||||
>
|
||||
+ DM
|
||||
</Link>;
|
||||
}
|
||||
return (
|
||||
<div className={first + 'relative'}>
|
||||
<p className="f9 ph4 gray3" key="p">{title}</p>
|
||||
{dmLink}
|
||||
{channelItems}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default GroupItem;
|
@ -1,105 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import Toggle from '~/views/components/toggle';
|
||||
import { InviteSearch } from '~/views/components/InviteSearch';
|
||||
|
||||
|
||||
export class GroupifyButton extends Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
inclusive: false,
|
||||
targetGroup: null
|
||||
};
|
||||
}
|
||||
|
||||
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) });
|
||||
}
|
||||
|
||||
renderInclusiveToggle() {
|
||||
return this.state.targetGroup ? (
|
||||
<div className="mt4">
|
||||
<Toggle
|
||||
boolean={this.state.inclusive}
|
||||
change={this.changeInclusive.bind(this)}
|
||||
/>
|
||||
<span className="dib f9 white-d inter ml3">
|
||||
Add all members to group
|
||||
</span>
|
||||
<p className="f9 gray2 pt1" style={{ paddingLeft: 40 }}>
|
||||
Add chat members to the group if they aren't in it yet
|
||||
</p>
|
||||
</div>
|
||||
) : <div />;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { inclusive, targetGroup } = this.state;
|
||||
const {
|
||||
api,
|
||||
isOwner,
|
||||
association,
|
||||
associations,
|
||||
contacts,
|
||||
groups,
|
||||
station,
|
||||
changeLoading
|
||||
} = this.props;
|
||||
|
||||
const groupPath = association['group-path'];
|
||||
const ownedUnmanagedVillage =
|
||||
isOwner &&
|
||||
!contacts[groupPath];
|
||||
|
||||
if (!ownedUnmanagedVillage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'w-100 fl mt3'} style={{ maxWidth: '29rem' }}>
|
||||
<p className="f8 mt3 lh-copy db">Convert Chat</p>
|
||||
<p className="f9 gray2 db mb4">
|
||||
Convert this chat into a group with associated chat, or select a
|
||||
group to add this chat to.
|
||||
</p>
|
||||
<InviteSearch
|
||||
groups={groups}
|
||||
contacts={contacts}
|
||||
associations={associations}
|
||||
groupResults={true}
|
||||
shipResults={false}
|
||||
invites={{
|
||||
groups: targetGroup ? [targetGroup] : [],
|
||||
ships: []
|
||||
}}
|
||||
setInvite={this.changeTargetGroup.bind(this)}
|
||||
/>
|
||||
{this.renderInclusiveToggle()}
|
||||
<a onClick={() => {
|
||||
changeLoading(true, true, 'Converting to group...', () => {
|
||||
api.chat.groupify(
|
||||
station, targetGroup, inclusive
|
||||
).then(() => {
|
||||
changeLoading(false, false, '', () => {});
|
||||
});
|
||||
});
|
||||
}}
|
||||
className={
|
||||
'dib f9 black gray4-d bg-gray0-d ba pa2 mt4 b--black ' +
|
||||
'b--gray1-d pointer'
|
||||
}>Convert to group</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,84 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { InviteSearch } from '~/views/components/InviteSearch';
|
||||
import { Spinner } from '~/views/components/Spinner';
|
||||
|
||||
export class InviteElement extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
members: [],
|
||||
error: false,
|
||||
success: false,
|
||||
awaiting: false
|
||||
};
|
||||
this.setInvite = this.setInvite.bind(this);
|
||||
}
|
||||
|
||||
modifyMembers() {
|
||||
const { props, state } = this;
|
||||
|
||||
const aud = state.members.map(mem => `~${mem}`);
|
||||
|
||||
if (state.members.length === 0) {
|
||||
this.setState({
|
||||
error: true,
|
||||
success: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
error: false,
|
||||
success: true,
|
||||
members: [],
|
||||
awaiting: true
|
||||
}, () => {
|
||||
props.api.chatView.invite(props.path, aud).then(() => {
|
||||
this.setState({ awaiting: false });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setInvite(invite) {
|
||||
this.setState({ members: invite.ships });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props, state } = this;
|
||||
|
||||
let modifyButtonClasses = 'mt4 db f9 ba pa2 white-d bg-gray0-d b--black b--gray2-d pointer';
|
||||
if (state.error) {
|
||||
modifyButtonClasses = modifyButtonClasses + ' gray3';
|
||||
}
|
||||
|
||||
let buttonText = '';
|
||||
if (props.permissions.kind === 'black') {
|
||||
buttonText = 'Ban';
|
||||
} else if (props.permissions.kind === 'white') {
|
||||
buttonText = 'Invite';
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<InviteSearch
|
||||
groups={{}}
|
||||
contacts={props.contacts}
|
||||
groupResults={false}
|
||||
shipResults={true}
|
||||
invites={{
|
||||
groups: [],
|
||||
ships: this.state.members
|
||||
}}
|
||||
setInvite={this.setInvite}
|
||||
/>
|
||||
<button
|
||||
onClick={this.modifyMembers.bind(this)}
|
||||
className={modifyButtonClasses}
|
||||
>
|
||||
{buttonText}
|
||||
</button>
|
||||
<Spinner awaiting={this.state.awaiting} classes="mt4" text="Inviting to chat..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,55 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Sigil } from '../../../../lib/sigil';
|
||||
import { uxToHex, cite } from '../../../../lib/util';
|
||||
|
||||
export class MemberElement extends Component {
|
||||
onRemove() {
|
||||
const { props } = this;
|
||||
props.api.groups.remove([`~${props.ship}`], props.path);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props } = this;
|
||||
|
||||
let actionElem;
|
||||
if (props.ship === props.owner) {
|
||||
actionElem = (
|
||||
<p className="w-20 dib list-ship black white-d f8 c-default">
|
||||
Host
|
||||
</p>
|
||||
);
|
||||
} else if (window.ship !== props.ship && window.ship === props.owner) {
|
||||
actionElem = (
|
||||
<a onClick={this.onRemove.bind(this)}
|
||||
className="w-20 dib list-ship black white-d f8 pointer"
|
||||
>
|
||||
Ban
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
actionElem = (
|
||||
<span></span>
|
||||
);
|
||||
}
|
||||
|
||||
const name = props.contact
|
||||
? `${props.contact.nickname} (${cite(props.ship)})` : `${cite(props.ship)}`;
|
||||
const color = props.contact ? uxToHex(props.contact.color) : '000000';
|
||||
|
||||
const img = (props.contact && (props.contact.avatar !== null))
|
||||
? <img src={props.contact.avatar} height={32} width={32} className="dib" />
|
||||
: <Sigil ship={props.ship} size={32} color={`#${color}`} />;
|
||||
|
||||
return (
|
||||
<div className="flex mb2">
|
||||
{img}
|
||||
<p className={
|
||||
'w-70 mono list-ship dib v-mid black white-d ml2 nowrap f8'
|
||||
}
|
||||
>{name}</p>
|
||||
{actionElem}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,371 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import _ from 'lodash';
|
||||
import urbitOb from 'urbit-ob';
|
||||
import Mousetrap from 'mousetrap';
|
||||
|
||||
import cn from 'classnames';
|
||||
import { Sigil } from '../../../../lib/sigil';
|
||||
import { hexToRgba, uxToHex, deSig } from '../../../../lib/util';
|
||||
|
||||
function ShipSearchItem({ ship, contacts, selected, onSelect }) {
|
||||
const contact = contacts[ship];
|
||||
let color = '#000000';
|
||||
let sigilClass = 'v-mid mix-blend-diff';
|
||||
let nickname;
|
||||
const nameStyle = {};
|
||||
const isSelected = ship === selected;
|
||||
if (contact) {
|
||||
const hex = uxToHex(contact.color);
|
||||
color = `#${hex}`;
|
||||
nameStyle.color = hexToRgba(hex, 0.7);
|
||||
nameStyle.textShadow = '0px 0px 0px #000';
|
||||
nameStyle.filter = 'contrast(1.3) saturate(1.5)';
|
||||
nameStyle.maxWidth = '200px';
|
||||
sigilClass = 'v-mid';
|
||||
nickname = contact.nickname;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onSelect(ship)}
|
||||
className={cn(
|
||||
'f9 pv1 ph3 pointer hover-bg-gray1-d hover-bg-gray4 relative flex items-center',
|
||||
{
|
||||
'white-d bg-gray0-d bg-white': !isSelected,
|
||||
'black-d bg-gray1-d bg-gray4': isSelected
|
||||
}
|
||||
)}
|
||||
key={ship}
|
||||
>
|
||||
<Sigil ship={'~' + ship} size={24} color={color} classes={sigilClass} />
|
||||
{nickname && (
|
||||
<p style={nameStyle} className="dib ml4 b truncate">
|
||||
{nickname}
|
||||
</p>
|
||||
)}
|
||||
<div className="mono gray2 gray4-d ml4">{'~' + ship}</div>
|
||||
<p className="nowrap ml4">{status}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export class ShipSearch extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.state = {
|
||||
selected: null,
|
||||
suggestions: [],
|
||||
bound: false
|
||||
};
|
||||
|
||||
this.keymap = {
|
||||
Tab: cm =>
|
||||
this.nextAutocompleteSuggestion(),
|
||||
'Shift-Tab': cm =>
|
||||
this.nextAutocompleteSuggestion(true),
|
||||
'Up': cm =>
|
||||
this.nextAutocompleteSuggestion(true),
|
||||
'Escape': cm =>
|
||||
this.props.onClear(),
|
||||
'Down': cm =>
|
||||
this.nextAutocompleteSuggestion(),
|
||||
'Enter': (cm) => {
|
||||
if(this.props.searchTerm !== null) {
|
||||
this.props.onSelect(this.state.selected);
|
||||
}
|
||||
},
|
||||
'Shift-3': cm =>
|
||||
this.toggleCode()
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if(this.props.searchTerm !== null) {
|
||||
this.updateSuggestions(true);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { props, state } = this;
|
||||
|
||||
if(!state.bound && props.inputRef) {
|
||||
this.bindShortcuts();
|
||||
}
|
||||
|
||||
if(props.searchTerm === null) {
|
||||
if(state.suggestions.length > 0) {
|
||||
this.setState({ suggestions: [] });
|
||||
}
|
||||
this.unbindShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
props.searchTerm === null &&
|
||||
props.searchTerm !== prevProps.searchTerm &&
|
||||
props.searchTerm.startsWith(prevProps.searchTerm)
|
||||
) {
|
||||
this.updateSuggestions();
|
||||
} else if (prevProps.searchTerm !== props.searchTerm) {
|
||||
this.updateSuggestions(true);
|
||||
}
|
||||
}
|
||||
|
||||
updateSuggestions(isStale = false) {
|
||||
const needle = this.props.searchTerm;
|
||||
const matchString = (hay) => {
|
||||
hay = hay.toLowerCase();
|
||||
|
||||
return (
|
||||
hay.startsWith(needle) ||
|
||||
_.some(_.words(hay), s => s.startsWith(needle))
|
||||
);
|
||||
};
|
||||
|
||||
let candidates = this.state.suggestions;
|
||||
|
||||
if (isStale || this.state.suggestions.length === 0) {
|
||||
const contacts = _.chain(this.props.contacts)
|
||||
.defaultTo({})
|
||||
.map((details, ship) => ({ ...details, ship }))
|
||||
.filter(
|
||||
({ nickname, ship }) => matchString(nickname) || matchString(ship)
|
||||
)
|
||||
.map('ship')
|
||||
.value();
|
||||
|
||||
const exactMatch = urbitOb.isValidPatp(`~${needle}`) ? [needle] : [];
|
||||
|
||||
candidates = _.chain(this.props.candidates)
|
||||
.defaultTo([])
|
||||
.union(contacts)
|
||||
.union(exactMatch)
|
||||
.value();
|
||||
}
|
||||
|
||||
const suggestions = _.chain(candidates)
|
||||
.filter(matchString)
|
||||
.filter(s => s.length < 28) // exclude comets
|
||||
.value();
|
||||
|
||||
this.bindShortcuts();
|
||||
this.setState({ suggestions, selected: suggestions[0] });
|
||||
}
|
||||
|
||||
bindCmShortcuts() {
|
||||
if(!this.props.cm) {
|
||||
return;
|
||||
}
|
||||
this.props.cm.addKeyMap(this.keymap);
|
||||
}
|
||||
|
||||
unbindCmShortcuts() {
|
||||
if(!this.props.cm) {
|
||||
return;
|
||||
}
|
||||
this.props.cm.removeKeyMap(this.keymap);
|
||||
}
|
||||
|
||||
bindShortcuts() {
|
||||
if (this.state.bound) {
|
||||
return;
|
||||
}
|
||||
if (!this.props.inputRef) {
|
||||
return this.bindCmShortcuts();
|
||||
}
|
||||
this.setState({ bound: true });
|
||||
if (!this.mousetrap) {
|
||||
this.mousetrap = new Mousetrap(this.props.inputRef);
|
||||
}
|
||||
|
||||
this.mousetrap.bind('enter', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (this.state.selected) {
|
||||
this.unbindShortcuts();
|
||||
this.props.onSelect(this.state.selected);
|
||||
}
|
||||
});
|
||||
|
||||
this.mousetrap.bind('tab', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.nextAutocompleteSuggestion(false);
|
||||
});
|
||||
this.mousetrap.bind(['up', 'shift+tab'], (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.nextAutocompleteSuggestion(true);
|
||||
});
|
||||
this.mousetrap.bind('down', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.nextAutocompleteSuggestion(false);
|
||||
});
|
||||
this.mousetrap.bind('esc', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.props.onClear();
|
||||
});
|
||||
}
|
||||
|
||||
unbindShortcuts() {
|
||||
if(!this.props.inputRef) {
|
||||
this.unbindCmShortcuts();
|
||||
}
|
||||
|
||||
if (!this.state.bound) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ bound: false });
|
||||
this.mousetrap.unbind('enter');
|
||||
this.mousetrap.unbind('tab');
|
||||
this.mousetrap.unbind(['up', 'shift+tab']);
|
||||
this.mousetrap.unbind('down');
|
||||
this.mousetrap.unbind('esc');
|
||||
}
|
||||
|
||||
nextAutocompleteSuggestion(backward = false) {
|
||||
const { suggestions } = this.state;
|
||||
let idx = suggestions.findIndex(s => s === this.state.selected);
|
||||
|
||||
idx = backward ? idx - 1 : idx + 1;
|
||||
idx = idx % Math.min(suggestions.length, 5);
|
||||
if (idx < 0) {
|
||||
idx = suggestions.length - 1;
|
||||
}
|
||||
|
||||
this.setState({ selected: suggestions[idx] });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { onSelect, contacts, popover, className } = this.props;
|
||||
const { selected, suggestions } = this.state;
|
||||
|
||||
if (suggestions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const popoverClasses = (popover && ' absolute ') || ' ';
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
popover
|
||||
? {
|
||||
bottom: '90%',
|
||||
left: '48px'
|
||||
}
|
||||
: {}
|
||||
}
|
||||
className={
|
||||
'black white-d bg-white bg-gray0-d ' +
|
||||
'w7 pv3 z-1 mt1 ba b--gray1-d b--gray4' +
|
||||
popoverClasses +
|
||||
className || ''
|
||||
}
|
||||
>
|
||||
{suggestions.slice(0, 5).map(ship => (
|
||||
<ShipSearchItem
|
||||
onSelect={onSelect}
|
||||
key={ship}
|
||||
selected={selected}
|
||||
contacts={contacts}
|
||||
ship={ship}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class ShipSearchInput extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
searchTerm: ''
|
||||
};
|
||||
|
||||
this.inputRef = null;
|
||||
this.popoverRef = null;
|
||||
|
||||
this.search = this.search.bind(this);
|
||||
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.setInputRef = this.setInputRef.bind(this);
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
const { popoverRef } = this;
|
||||
// Do nothing if clicking ref's element or descendent elements
|
||||
if (!popoverRef || popoverRef.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.onClear();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
document.addEventListener('mousedown', this.onClick);
|
||||
document.addEventListener('touchstart', this.onClick);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('mousedown', this.onClick);
|
||||
document.removeEventListener('touchstart', this.onClick);
|
||||
}
|
||||
|
||||
setInputRef(ref) {
|
||||
this.inputRef = ref;
|
||||
if(ref) {
|
||||
ref.focus();
|
||||
}
|
||||
// update this.inputRef prop
|
||||
this.forceUpdate();
|
||||
}
|
||||
|
||||
search(e) {
|
||||
const searchTerm = e.target.value;
|
||||
this.setState({ searchTerm });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { state, props } = this;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref => (this.popoverRef = ref)}
|
||||
style={{ top: '150%', left: '-80px' }}
|
||||
className="b--gray2 b--solid ba absolute bg-white bg-gray0-d"
|
||||
>
|
||||
<textarea
|
||||
style={{ resize: 'none', maxWidth: '200px' }}
|
||||
className="ma2 pa2 b--gray4 ba b--solid w7 db bg-gray0-d white-d"
|
||||
rows={1}
|
||||
autocapitalise="none"
|
||||
autoFocus={
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(
|
||||
navigator.userAgent
|
||||
)
|
||||
? false
|
||||
: true
|
||||
}
|
||||
placeholder="Search for a ship"
|
||||
value={state.searchTerm}
|
||||
onChange={this.search}
|
||||
ref={this.setInputRef}
|
||||
/>
|
||||
<ShipSearch
|
||||
contacts={props.contacts}
|
||||
candidates={props.candidates}
|
||||
searchTerm={deSig(state.searchTerm)}
|
||||
inputRef={this.inputRef}
|
||||
onSelect={props.onSelect}
|
||||
onClear={props.onClear}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,41 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import moment from 'moment';
|
||||
|
||||
export const UnreadNotice = (props) => {
|
||||
const { unreadCount, unreadMsg, dismissUnread, onClick } = props;
|
||||
|
||||
if (!unreadMsg || (unreadCount === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let datestamp = moment.unix(unreadMsg.when / 1000).format('YYYY.M.D');
|
||||
const timestamp = moment.unix(unreadMsg.when / 1000).format('HH:mm');
|
||||
|
||||
if (datestamp === moment().format('YYYY.M.D')) {
|
||||
datestamp = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ left: '0px' }}
|
||||
className="pa4 w-100 absolute z-1 unread-notice">
|
||||
<div className={
|
||||
"ba b--green2 green2 bg-white bg-gray0-d flex items-center " +
|
||||
"pa2 f9 justify-between br1"
|
||||
}>
|
||||
<p className="lh-copy db pointer" onClick={onClick}>
|
||||
{unreadCount} new messages since{' '}
|
||||
{datestamp && (
|
||||
<>
|
||||
<span className="green3">~{datestamp}</span> at{' '}
|
||||
</>
|
||||
)}
|
||||
<span className="green3">{timestamp}</span>
|
||||
</p>
|
||||
<div onClick={dismissUnread}
|
||||
className="ml4 inter b--green2 pointer tr lh-copy">
|
||||
Mark as Read
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,42 +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-chat:wasWelcomed', JSON.stringify(true));
|
||||
}
|
||||
|
||||
render() {
|
||||
let wasWelcomed = localStorage.getItem('urbit-chat:wasWelcomed');
|
||||
if (wasWelcomed === null) {
|
||||
localStorage.setItem('urbit-chat:wasWelcomed', JSON.stringify(false));
|
||||
wasWelcomed = false;
|
||||
return wasWelcomed;
|
||||
} else {
|
||||
wasWelcomed = JSON.parse(wasWelcomed);
|
||||
}
|
||||
|
||||
const inbox = this.props.inbox ? this.props.inbox : {};
|
||||
|
||||
return ((!wasWelcomed && this.state.show) && (inbox.length !== 0)) ? (
|
||||
<div className="ma4 pa2 bg-welcome-green bg-gray1-d white-d">
|
||||
<p className="f8 lh-copy">Chats are instant, linear modes of conversation. Many chats can be bundled under one group.</p>
|
||||
<p className="f8 pt2 dib pointer bb"
|
||||
onClick={(() => this.disableWelcome())}
|
||||
>
|
||||
Close this
|
||||
</p>
|
||||
</div>
|
||||
) : <div />;
|
||||
}
|
||||
}
|
||||
|
||||
export default Welcome;
|
@ -1,237 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Spinner } from '~/views/components/Spinner';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { InviteSearch } from '~/views/components/InviteSearch';
|
||||
import urbitOb from 'urbit-ob';
|
||||
import { deSig, cite } from '~/logic/lib/util';
|
||||
|
||||
export class NewDmScreen extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
ships: [],
|
||||
station: null,
|
||||
awaiting: false,
|
||||
title: '',
|
||||
idName: '',
|
||||
description: ''
|
||||
};
|
||||
|
||||
this.titleChange = this.titleChange.bind(this);
|
||||
this.descriptionChange = this.descriptionChange.bind(this);
|
||||
this.onClickCreate = this.onClickCreate.bind(this);
|
||||
this.setInvite = this.setInvite.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { props } = this;
|
||||
if (props.autoCreate && urbitOb.isValidPatp(props.autoCreate)) {
|
||||
const addedShip = this.state.ships;
|
||||
addedShip.push(props.autoCreate.slice(1));
|
||||
this.setState(
|
||||
{
|
||||
ships: addedShip,
|
||||
awaiting: true
|
||||
},
|
||||
this.onClickCreate
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { props } = this;
|
||||
|
||||
if (prevProps !== props) {
|
||||
const { station } = this.state;
|
||||
if (station && station in props.inbox) {
|
||||
this.setState({ awaiting: false });
|
||||
props.history.push(`/~chat/room${station}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
titleChange(event) {
|
||||
const asciiSafe = event.target.value.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]/g, '-');
|
||||
this.setState({
|
||||
idName: asciiSafe,
|
||||
title: event.target.value
|
||||
});
|
||||
}
|
||||
|
||||
descriptionChange(event) {
|
||||
this.setState({
|
||||
description: event.target.value
|
||||
});
|
||||
}
|
||||
|
||||
setInvite(value) {
|
||||
this.setState({
|
||||
ships: value.ships
|
||||
});
|
||||
}
|
||||
|
||||
onClickCreate() {
|
||||
const { props, state } = this;
|
||||
|
||||
if (state.ships.length === 1) {
|
||||
const station = `/~${window.ship}/dm--${state.ships[0]}`;
|
||||
|
||||
const theirStation = `/~${state.ships[0]}/dm--${window.ship}`;
|
||||
|
||||
if (station in props.inbox) {
|
||||
props.history.push(`/~chat/room${station}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (theirStation in props.inbox) {
|
||||
props.history.push(`/~chat/room${theirStation}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const aud = state.ship !== window.ship ? [`~${state.ships[0]}`] : [];
|
||||
|
||||
let title = `${cite(window.ship)} <-> ${cite(state.ships[0])}`;
|
||||
|
||||
if (state.title !== '') {
|
||||
title = state.title;
|
||||
}
|
||||
this.setState(
|
||||
{
|
||||
station, awaiting: true
|
||||
},
|
||||
() => {
|
||||
const groupPath = `/ship/~${window.ship}/dm--${state.ships[0]}`;
|
||||
props.api.chat.create(
|
||||
title,
|
||||
state.description,
|
||||
station,
|
||||
groupPath,
|
||||
{ invite: { pending: aud } },
|
||||
aud,
|
||||
true,
|
||||
false
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (state.ships.length > 1) {
|
||||
const aud = state.ships.map(mem => `~${deSig(mem.trim())}`);
|
||||
|
||||
let title = 'Direct Message';
|
||||
|
||||
if (state.title !== '') {
|
||||
title = state.title;
|
||||
} else {
|
||||
const asciiSafe = title.toLowerCase()
|
||||
.replace(/[^a-z0-9~_.-]/g, '-');
|
||||
this.setState({ idName: asciiSafe });
|
||||
}
|
||||
|
||||
const station = `/~${window.ship}/${state.idName}-${Math.floor(Math.random() * 10000)}`;
|
||||
|
||||
this.setState(
|
||||
{
|
||||
station, awaiting: true
|
||||
},
|
||||
() => {
|
||||
const groupPath = `/ship${station}`;
|
||||
props.api.chat.create(
|
||||
title,
|
||||
state.description,
|
||||
station,
|
||||
groupPath,
|
||||
{ invite: { pending: aud } },
|
||||
aud,
|
||||
true,
|
||||
false
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props, state } = this;
|
||||
|
||||
const createClasses = (state.idName || state.ships.length >= 1)
|
||||
? 'pointer dib f9 green2 bg-gray0-d ba pv3 ph4 b--green2 mt4'
|
||||
: 'pointer dib f9 gray2 ba bg-gray0-d pa2 pv3 ph4 b--gray3 mt4';
|
||||
|
||||
const idClasses =
|
||||
'f7 ba b--gray3 b--gray2-d bg-gray0-d white-d pa3 db w-100 ' +
|
||||
'focus-b--black focus-b--white-d mt1 ';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'h-100 w-100 mw6 pa3 pt4 overflow-x-hidden ' +
|
||||
'bg-gray0-d white-d flex flex-column'
|
||||
}
|
||||
>
|
||||
<div className="w-100 dn-m dn-l dn-xl inter pt1 pb6 f8">
|
||||
<Link to="/~chat/">{'⟵ All Chats'}</Link>
|
||||
</div>
|
||||
<h2 className="mb3 f8">New Direct Message</h2>
|
||||
<div className="w-100">
|
||||
<p className="f8 mt4 db">
|
||||
Name
|
||||
<span className="gray3"> (Optional)</span>
|
||||
</p>
|
||||
<textarea
|
||||
className={idClasses}
|
||||
placeholder="The Passage"
|
||||
rows={1}
|
||||
style={{
|
||||
resize: 'none'
|
||||
}}
|
||||
onChange={this.titleChange}
|
||||
/>
|
||||
<p className="f8 mt4 db">
|
||||
Description
|
||||
<span className="gray3"> (Optional)</span>
|
||||
</p>
|
||||
<textarea
|
||||
className={idClasses}
|
||||
placeholder="The most beautiful direct message"
|
||||
rows={1}
|
||||
style={{
|
||||
resize: 'none'
|
||||
}}
|
||||
onChange={this.descriptionChange}
|
||||
/>
|
||||
<p className="f8 mt4 db">
|
||||
Invite Members
|
||||
</p>
|
||||
<p className="f9 gray2 db mv1">
|
||||
Selected ships will be invited to the direct message
|
||||
</p>
|
||||
<InviteSearch
|
||||
groups={props.groups}
|
||||
contacts={props.contacts}
|
||||
associations={props.associations}
|
||||
groupResults={false}
|
||||
shipResults={true}
|
||||
invites={{
|
||||
groups: [],
|
||||
ships: state.ships
|
||||
}}
|
||||
setInvite={this.setInvite}
|
||||
/>
|
||||
<button
|
||||
onClick={this.onClickCreate.bind(this)}
|
||||
className={createClasses}
|
||||
>
|
||||
Create Direct Message
|
||||
</button>
|
||||
<Spinner
|
||||
awaiting={this.state.awaiting}
|
||||
classes="mt4"
|
||||
text="Creating Direct Message..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,207 +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 { deSig } from '~/logic/lib/util';
|
||||
|
||||
export class NewScreen extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
title: '',
|
||||
description: '',
|
||||
idName: '',
|
||||
groups: [],
|
||||
ships: [],
|
||||
privacy: 'invite',
|
||||
idError: false,
|
||||
allowHistory: true,
|
||||
createGroup: false,
|
||||
awaiting: false
|
||||
};
|
||||
|
||||
this.titleChange = this.titleChange.bind(this);
|
||||
this.descriptionChange = this.descriptionChange.bind(this);
|
||||
this.setInvite = this.setInvite.bind(this);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
const { props, state } = this;
|
||||
|
||||
if (prevProps !== props) {
|
||||
const station = `/~${window.ship}/${state.idName}`;
|
||||
if (station in props.inbox) {
|
||||
props.history.push('/~chat/room' + station);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
titleChange(event) {
|
||||
const asciiSafe = event.target.value.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]/g, '-');
|
||||
this.setState({
|
||||
idName: asciiSafe,
|
||||
title: event.target.value
|
||||
});
|
||||
}
|
||||
|
||||
descriptionChange(event) {
|
||||
this.setState({
|
||||
description: event.target.value
|
||||
});
|
||||
}
|
||||
|
||||
setInvite(value) {
|
||||
this.setState({
|
||||
groups: value.groups,
|
||||
ships: value.ships
|
||||
});
|
||||
}
|
||||
|
||||
onClickCreate() {
|
||||
const { props, state } = this;
|
||||
const grouped = (this.state.createGroup || (this.state.groups.length > 0));
|
||||
|
||||
if (!state.title) {
|
||||
this.setState({
|
||||
idError: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const station = `/${state.idName}` + (grouped ? `-${Math.floor(Math.random() * 10000)}` : '');
|
||||
|
||||
if (station in props.inbox) {
|
||||
this.setState({
|
||||
idError: true,
|
||||
success: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const aud = state.ships.map(mem => `~${deSig(mem.trim())}`);
|
||||
|
||||
if (this.textarea) {
|
||||
this.textarea.value = '';
|
||||
}
|
||||
|
||||
const policy = state.privacy === 'invite' ? { invite: { pending: aud } } : { open: { banRanks: [], banned: [] } };
|
||||
|
||||
this.setState({
|
||||
error: false,
|
||||
success: true,
|
||||
group: [],
|
||||
ships: [],
|
||||
awaiting: true
|
||||
}, () => {
|
||||
const appPath = `/~${window.ship}${station}`;
|
||||
let groupPath = `/ship${appPath}`;
|
||||
if (state.groups.length > 0) {
|
||||
groupPath = state.groups[0];
|
||||
}
|
||||
const submit = props.api.chat.create(
|
||||
state.title,
|
||||
state.description,
|
||||
appPath,
|
||||
groupPath,
|
||||
policy,
|
||||
aud,
|
||||
state.allowHistory,
|
||||
state.createGroup
|
||||
);
|
||||
submit.then(() => {
|
||||
this.setState({ awaiting: false });
|
||||
props.history.push(`/~chat/room${appPath}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props, state } = this;
|
||||
|
||||
const createClasses = state.idName
|
||||
? 'pointer db f9 green2 bg-gray0-d ba pv3 ph4 b--green2 mt4'
|
||||
: 'pointer db f9 gray2 ba bg-gray0-d pa2 pv3 ph4 b--gray3 mt4';
|
||||
|
||||
const idClasses =
|
||||
'f7 ba b--gray3 b--gray2-d bg-gray0-d white-d pa3 db w-100 ' +
|
||||
'focus-b--black focus-b--white-d mt1 ';
|
||||
|
||||
let idErrElem = (<span />);
|
||||
if (state.idError) {
|
||||
idErrElem = (
|
||||
<span className="f9 inter red2 db pt2">
|
||||
Chat must have a valid name.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'h-100 w-100 mw6 pa3 pt4 overflow-x-hidden ' +
|
||||
'bg-gray0-d white-d flex flex-column'
|
||||
}
|
||||
>
|
||||
<div className="w-100 dn-m dn-l dn-xl inter pt1 pb6 f8">
|
||||
<Link to="/~chat/">{'⟵ All Chats'}</Link>
|
||||
</div>
|
||||
<h2 className="mb4 f8">New Group Chat</h2>
|
||||
<div className="w-100">
|
||||
<p className="f8 mt4 db">Name</p>
|
||||
<textarea
|
||||
className={idClasses}
|
||||
placeholder="Secret Chat"
|
||||
rows={1}
|
||||
style={{
|
||||
resize: 'none'
|
||||
}}
|
||||
onChange={this.titleChange}
|
||||
/>
|
||||
{idErrElem}
|
||||
<p className="f8 mt4 db">
|
||||
Description
|
||||
<span className="gray3"> (Optional)</span>
|
||||
</p>
|
||||
<textarea
|
||||
className={idClasses}
|
||||
placeholder="The coolest chat"
|
||||
rows={1}
|
||||
style={{
|
||||
resize: 'none'
|
||||
}}
|
||||
onChange={this.descriptionChange}
|
||||
/>
|
||||
<div className="mt4 db relative">
|
||||
<p className="f8">
|
||||
Select Group
|
||||
</p>
|
||||
<Link className="green2 absolute right-0 bottom-0 f9" to="/~groups/new">+New</Link>
|
||||
<p className="f9 gray2 db mv1">
|
||||
Chat will be added to selected group
|
||||
</p>
|
||||
</div>
|
||||
<InviteSearch
|
||||
groups={props.groups}
|
||||
contacts={props.contacts}
|
||||
associations={props.associations}
|
||||
groupResults={true}
|
||||
shipResults={false}
|
||||
invites={{
|
||||
groups: state.groups,
|
||||
ships: []
|
||||
}}
|
||||
setInvite={this.setInvite}
|
||||
/>
|
||||
<button
|
||||
onClick={this.onClickCreate.bind(this)}
|
||||
className={createClasses}
|
||||
>
|
||||
Start Chat
|
||||
</button>
|
||||
<Spinner awaiting={this.state.awaiting} classes="mt4" text="Creating chat..." />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -55,13 +55,13 @@ export class OverlaySigil extends PureComponent {
|
||||
|
||||
render() {
|
||||
const { props, state } = this;
|
||||
const { hideAvatars } = props;
|
||||
const { hideAvatars, allStations } = props;
|
||||
|
||||
const img = (props.contact && (props.contact.avatar !== null) && !hideAvatars)
|
||||
? <img src={props.contact.avatar} height={24} width={24} className="dib" />
|
||||
? <img src={props.contact.avatar} height={16} width={16} className="dib" />
|
||||
: <Sigil
|
||||
ship={props.ship}
|
||||
size={24}
|
||||
size={16}
|
||||
color={props.color}
|
||||
classes={props.sigilClass}
|
||||
/>;
|
||||
@ -71,7 +71,7 @@ export class OverlaySigil extends PureComponent {
|
||||
onClick={this.profileShow}
|
||||
className={props.className + ' pointer relative'}
|
||||
ref={this.containerRef}
|
||||
style={{ height: '24px' }}
|
||||
style={{ height: '16px' }}
|
||||
>
|
||||
{state.profileClicked && (
|
||||
<ProfileOverlay
|
||||
@ -83,8 +83,11 @@ export class OverlaySigil extends PureComponent {
|
||||
association={props.association}
|
||||
group={props.group}
|
||||
onDismiss={this.profileHide}
|
||||
allStations={allStations}
|
||||
hideAvatars={hideAvatars}
|
||||
hideNicknames={props.hideNicknames}
|
||||
history={props.history}
|
||||
api={props.api}
|
||||
/>
|
||||
)}
|
||||
{img}
|
@ -3,6 +3,8 @@ import { Link } from 'react-router-dom';
|
||||
import { cite } from '~/logic/lib/util';
|
||||
import { Sigil } from '~/logic/lib/sigil';
|
||||
|
||||
import { Center, Button } from "@tlon/indigo-react";
|
||||
|
||||
export const OVERLAY_HEIGHT = 250;
|
||||
|
||||
export class ProfileOverlay extends PureComponent {
|
||||
@ -11,6 +13,7 @@ export class ProfileOverlay extends PureComponent {
|
||||
|
||||
this.popoverRef = React.createRef();
|
||||
this.onDocumentClick = this.onDocumentClick.bind(this);
|
||||
this.createAndRedirectToDM = this.createAndRedirectToDM.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@ -23,6 +26,42 @@ export class ProfileOverlay extends PureComponent {
|
||||
document.removeEventListener('touchstart', this.onDocumentClick);
|
||||
}
|
||||
|
||||
createAndRedirectToDM() {
|
||||
const { api, ship, history, allStations } = this.props;
|
||||
const station = `/~${window.ship}/dm--${ship}`;
|
||||
const theirStation = `/~${ship}/dm--${window.ship}`;
|
||||
|
||||
if (allStations.indexOf(station) !== -1) {
|
||||
history.push(`/~groups/home/resource/chat${station}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (allStations.indexOf(theirStation) !== -1) {
|
||||
history.push(`/~groups/home/resource/chat${theirStation}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const groupPath = `/ship/~${window.ship}/dm--${ship}`;
|
||||
const aud = ship !== window.ship ? [`~${ship}`] : [];
|
||||
const title = `${cite(window.ship)} <-> ${cite(ship)}`;
|
||||
|
||||
api.chat.create(
|
||||
title,
|
||||
'',
|
||||
station,
|
||||
groupPath,
|
||||
{ invite: { pending: aud } },
|
||||
aud,
|
||||
true,
|
||||
false
|
||||
);
|
||||
|
||||
// TODO: make a pretty loading state
|
||||
setTimeout(() => {
|
||||
history.push(`/~groups/home/resource/chat${station}`);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
onDocumentClick(event) {
|
||||
const { popoverRef } = this;
|
||||
// Do nothing if clicking ref's element or descendent elements
|
||||
@ -65,9 +104,9 @@ export class ProfileOverlay extends PureComponent {
|
||||
/>;
|
||||
const showNickname = contact?.nickname && !hideNicknames;
|
||||
|
||||
if (!group.hidden) {
|
||||
img = <Link to={`/~groups/view${association['group-path']}/${ship}`}>{img}</Link>;
|
||||
}
|
||||
if (!group.hidden) {
|
||||
img = <Link to={`/~groups/view${association['group-path']}/${ship}`}>{img}</Link>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -76,20 +115,17 @@ export class ProfileOverlay extends PureComponent {
|
||||
className="flex-col shadow-6 br2 bg-white bg-gray0-d inter absolute z-1 f9 lh-solid"
|
||||
>
|
||||
<div style={{ height: '160px', width: '160px' }}>
|
||||
{img}
|
||||
{img}
|
||||
</div>
|
||||
<div className="pv3 pl3 pr2">
|
||||
<div className="pv3 pl3 pr3">
|
||||
{showNickname && (
|
||||
<div className="b white-d truncate">{contact.nickname}</div>
|
||||
)}
|
||||
<div className="mono gray2">{cite(`~${ship}`)}</div>
|
||||
{!isOwn && (
|
||||
<Link
|
||||
to={`/~chat/new/dm/~${ship}`}
|
||||
className="b--green0 b--green2-d b--solid ba green2 mt3 tc pa2 pointer db"
|
||||
>
|
||||
<Button mt={2} width="100%" onClick={this.createAndRedirectToDM}>
|
||||
Send Message
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{isOwn && (
|
||||
<Link
|
@ -1,143 +0,0 @@
|
||||
import React, { Component, Fragment } from 'react';
|
||||
|
||||
import { deSig } from '~/logic/lib/util';
|
||||
import { MetadataSettings } from '~/views/components/metadata/settings';
|
||||
import { Spinner } from '~/views/components/Spinner';
|
||||
|
||||
import ChatHeader from './lib/ChatHeader';
|
||||
import { DeleteButton } from './lib/delete-button';
|
||||
import { GroupifyButton } from './lib/groupify-button';
|
||||
|
||||
export class SettingsScreen extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
isLoading: false,
|
||||
awaiting: false,
|
||||
type: 'Editing chat...'
|
||||
};
|
||||
|
||||
this.changeLoading = this.changeLoading.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.state.isLoading && (this.props.station in this.props.inbox)) {
|
||||
this.setState({ isLoading: false });
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { props, state } = this;
|
||||
if (state.isLoading && !(props.station in props.inbox)) {
|
||||
this.setState({
|
||||
isLoading: false
|
||||
}, () => {
|
||||
props.history.push('/~chat');
|
||||
});
|
||||
} else if (state.isLoading && (props.station in props.inbox)) {
|
||||
this.setState({ isLoading: false });
|
||||
}
|
||||
}
|
||||
|
||||
changeLoading(isLoading, awaiting, type, closure) {
|
||||
this.setState({
|
||||
isLoading,
|
||||
awaiting,
|
||||
type
|
||||
}, closure);
|
||||
}
|
||||
|
||||
renderLoading() {
|
||||
return (
|
||||
<Spinner
|
||||
awaiting={this.state.awaiting}
|
||||
classes="absolute right-2 bottom-2 ba pa2 b--gray1-d"
|
||||
text={this.state.type}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderNormal() {
|
||||
const { state } = this;
|
||||
const {
|
||||
associations,
|
||||
association,
|
||||
contacts,
|
||||
groups,
|
||||
api,
|
||||
station,
|
||||
match,
|
||||
history
|
||||
} = this.props;
|
||||
const isOwner = deSig(match.params.ship) === window.ship;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<h2 className="f8 pb2">Chat Settings</h2>
|
||||
<GroupifyButton
|
||||
isOwner={isOwner}
|
||||
association={association}
|
||||
associations={associations}
|
||||
contacts={contacts}
|
||||
groups={groups}
|
||||
api={api}
|
||||
station={station}
|
||||
changeLoading={this.changeLoading} />
|
||||
<DeleteButton
|
||||
isOwner={isOwner}
|
||||
changeLoading={this.changeLoading}
|
||||
station={station}
|
||||
association={association}
|
||||
contacts={contacts}
|
||||
history={history}
|
||||
api={api} />
|
||||
<MetadataSettings
|
||||
isOwner={isOwner}
|
||||
changeLoading={this.changeLoading}
|
||||
api={api}
|
||||
association={association}
|
||||
resource="chat"
|
||||
app="chat"
|
||||
module=""
|
||||
/>
|
||||
<Spinner
|
||||
awaiting={this.state.awaiting}
|
||||
classes="absolute right-2 bottom-2 ba pa2 b--gray1-d"
|
||||
text={this.state.type}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { state } = this;
|
||||
const {
|
||||
api,
|
||||
group,
|
||||
association,
|
||||
station,
|
||||
popout,
|
||||
sidebarShown,
|
||||
match,
|
||||
location
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className="h-100 w-100 overflow-x-hidden flex flex-column white-d">
|
||||
<ChatHeader
|
||||
match={match}
|
||||
location={location}
|
||||
api={api}
|
||||
group={group}
|
||||
association={association}
|
||||
station={station}
|
||||
sidebarShown={sidebarShown}
|
||||
popout={popout} />
|
||||
<div className="w-100 pl3 mt4 cf">
|
||||
{(state.isLoading) ? this.renderLoading() : this.renderNormal() }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,114 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import Welcome from './lib/welcome';
|
||||
import { alphabetiseAssociations } from '~/logic/lib/util';
|
||||
import SidebarInvite from '~/views/components/SidebarInvite';
|
||||
import { GroupItem } from './lib/group-item';
|
||||
|
||||
export class Sidebar extends Component {
|
||||
onClickNew() {
|
||||
this.props.history.push('/~chat/new');
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props } = this;
|
||||
|
||||
const contactAssoc =
|
||||
(props.associations && 'contacts' in props.associations)
|
||||
? alphabetiseAssociations(props.associations.contacts) : {};
|
||||
|
||||
const chatAssoc =
|
||||
(props.associations && 'chat' in props.associations)
|
||||
? alphabetiseAssociations(props.associations.chat) : {};
|
||||
|
||||
const groupedChannels = {};
|
||||
Object.keys(props.inbox).map((box) => {
|
||||
const path = chatAssoc[box]
|
||||
? chatAssoc[box]['group-path'] : box;
|
||||
|
||||
if (path in contactAssoc) {
|
||||
if (groupedChannels[path]) {
|
||||
const array = groupedChannels[path];
|
||||
array.push(box);
|
||||
groupedChannels[path] = array;
|
||||
} else {
|
||||
groupedChannels[path] = [box];
|
||||
}
|
||||
} else {
|
||||
if (groupedChannels['dm']) {
|
||||
const array = groupedChannels['dm'];
|
||||
array.push(box);
|
||||
groupedChannels['dm'] = array;
|
||||
} else {
|
||||
groupedChannels['dm'] = [box];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const sidebarInvites = Object.keys(props.invites)
|
||||
.map((uid) => {
|
||||
return (
|
||||
<SidebarInvite
|
||||
key={uid}
|
||||
invite={props.invites[uid]}
|
||||
onAccept={() => props.api.invite.accept('/chat', uid)}
|
||||
onDecline={() => props.api.invite.decline('/chat', uid)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const groupedItems = Object.keys(contactAssoc)
|
||||
.filter(each => (groupedChannels[each] || []).length !== 0)
|
||||
.map((each, i) => {
|
||||
const channels = groupedChannels[each] || [];
|
||||
return(
|
||||
<GroupItem
|
||||
key={i}
|
||||
index={i}
|
||||
association={contactAssoc[each]}
|
||||
chatMetadata={chatAssoc}
|
||||
channels={channels}
|
||||
inbox={props.inbox}
|
||||
station={props.station}
|
||||
unreads={props.unreads}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
// add direct messages after groups
|
||||
groupedItems.push(
|
||||
<GroupItem
|
||||
association={'dm'}
|
||||
chatMetadata={chatAssoc}
|
||||
channels={groupedChannels['dm']}
|
||||
inbox={props.inbox}
|
||||
station={props.station}
|
||||
unreads={props.unreads}
|
||||
index={'dm'}
|
||||
key={'dm'}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`h-100-minus-96-s h-100 w-100 overflow-x-hidden flex
|
||||
bg-gray0-d flex-column relative z1 lh-solid`}
|
||||
>
|
||||
<div className="w-100 bg-transparent pa4">
|
||||
<a
|
||||
className="dib f9 pointer green2 gray4-d mr4"
|
||||
onClick={this.onClickNew.bind(this)}
|
||||
>
|
||||
New Group Chat
|
||||
</a>
|
||||
</div>
|
||||
<div className="overflow-y-auto h-100">
|
||||
<Welcome inbox={props.inbox} />
|
||||
{sidebarInvites}
|
||||
{groupedItems}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,73 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
import ErrorBoundary from '~/views/components/ErrorBoundary';
|
||||
|
||||
export class Skeleton extends Component {
|
||||
render() {
|
||||
// sidebar and chat panel conditional classes
|
||||
const sidebarHide = (!this.props.sidebarShown || this.props.popout)
|
||||
? 'dn' : '';
|
||||
|
||||
const sidebarHideOnMobile = this.props.sidebarHideOnMobile
|
||||
? 'dn-s' : '';
|
||||
|
||||
const chatHideOnMobile = this.props.chatHideonMobile
|
||||
? 'dn-s' : '';
|
||||
|
||||
// mobile-specific navigation classes
|
||||
const mobileNavClasses = classnames({
|
||||
'dn': this.props.chatHideOnMobile,
|
||||
'db dn-m dn-l dn-xl': !this.props.chatHideOnMobile,
|
||||
'w-100 inter pt4 f8': !this.props.chatHideOnMobile
|
||||
});
|
||||
|
||||
// popout switches out window chrome and borders
|
||||
const popoutWindow = this.props.popout
|
||||
? '' : 'ph4-m ph4-l ph4-xl pb4-m pb4-l pb4-xl';
|
||||
|
||||
const popoutBorder = this.props.popout
|
||||
? '' : 'ba-m ba-l ba-xl b--gray4 b--gray1-d br1 ';
|
||||
|
||||
return (
|
||||
// app outer skeleton
|
||||
<div className={'h-100 w-100 ' + popoutWindow}>
|
||||
{/* app window borders */}
|
||||
<div className={ 'bg-white bg-gray0-d cf w-100 flex h-100 ' + popoutBorder }>
|
||||
{/* sidebar skeleton, hidden on mobile when in chat panel */}
|
||||
<div
|
||||
className={
|
||||
`fl h-100 br b--gray4 b--gray1-d overflow-x-hidden
|
||||
flex-basis-full-s flex-basis-250-m flex-basis-250-l
|
||||
flex-basis-250-xl ` +
|
||||
sidebarHide +
|
||||
' ' +
|
||||
sidebarHideOnMobile
|
||||
}
|
||||
>
|
||||
{/* mobile-specific navigation */}
|
||||
<div className={mobileNavClasses}>
|
||||
<div className="bb b--gray4 b--gray1-d white-d inter f8 pl3 pb3">
|
||||
All Chats
|
||||
</div>
|
||||
</div>
|
||||
{/* sidebar component inside the sidebar skeleton */}
|
||||
{this.props.sidebar}
|
||||
</div>
|
||||
{/* right-hand panel for chat, members, settings */}
|
||||
<div
|
||||
className={'h-100 fr ' + chatHideOnMobile}
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
width: 'calc(100% - 300px)'
|
||||
}}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
{this.props.children}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import moment from 'moment';
|
||||
import { Box, Text } from '@tlon/indigo-react';
|
||||
|
||||
export const UnreadNotice = (props) => {
|
||||
const { unreadCount, unreadMsg, dismissUnread, onClick } = props;
|
||||
|
||||
if (!unreadMsg || (unreadCount === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let datestamp = moment.unix(unreadMsg.when / 1000).format('YYYY.M.D');
|
||||
const timestamp = moment.unix(unreadMsg.when / 1000).format('HH:mm');
|
||||
|
||||
if (datestamp === moment().format('YYYY.M.D')) {
|
||||
datestamp = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box style={{ left: '0px' }}
|
||||
p='4'
|
||||
width='100%'
|
||||
position='absolute'
|
||||
zIndex='1'
|
||||
className='unread-notice'
|
||||
>
|
||||
<Box
|
||||
backgroundColor='white'
|
||||
display='flex'
|
||||
alignItems='center'
|
||||
p='2'
|
||||
fontSize='0'
|
||||
justifyContent='space-between'
|
||||
borderRadius='1'
|
||||
border='1'
|
||||
borderColor='blue'>
|
||||
<Text flexShrink='0' display='block' cursor='pointer' onClick={onClick}>
|
||||
{unreadCount} new messages since{' '}
|
||||
{datestamp && (
|
||||
<>
|
||||
<Text color='blue'>~{datestamp}</Text> at{' '}
|
||||
</>
|
||||
)}
|
||||
<Text color='blue'>{timestamp}</Text>
|
||||
</Text>
|
||||
<Text
|
||||
ml='4'
|
||||
color='blue'
|
||||
cursor='pointer'
|
||||
textAlign='right'
|
||||
onClick={dismissUnread}>
|
||||
Mark as Read
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -161,7 +161,7 @@ h2 {
|
||||
}
|
||||
|
||||
.unread-notice {
|
||||
top: 48px;
|
||||
top: 0px;
|
||||
}
|
||||
|
||||
/* responsive */
|
||||
@ -231,6 +231,11 @@ blockquote {
|
||||
font-family: 'Inter';
|
||||
}
|
||||
|
||||
.chat .cm-s-tlon.CodeMirror {
|
||||
font-size: 16px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
pre, code {
|
||||
background-color: var(--light-gray);
|
||||
}
|
||||
@ -380,6 +385,8 @@ pre.CodeMirror-placeholder.CodeMirror-line-like { color: var(--gray); }
|
||||
.chat .cm-s-tlon.CodeMirror {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.chat .cm-s-tlon span.cm-def {
|
||||
|
@ -1,9 +1,7 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Route, Link } from 'react-router-dom';
|
||||
import classnames from 'classnames';
|
||||
import { Route } from 'react-router-dom';
|
||||
import Helmet from 'react-helmet';
|
||||
|
||||
import { Popout } from './components/lib/icons/popout';
|
||||
import { History } from './components/history';
|
||||
import { Input } from './components/input';
|
||||
|
||||
@ -54,16 +52,8 @@ export default class DojoApp extends Component {
|
||||
>
|
||||
<Route
|
||||
exact
|
||||
path="/~dojo/:popout?"
|
||||
path="/~dojo/"
|
||||
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
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-100 h-100 flex-m flex-l flex-xl">
|
||||
<div
|
||||
@ -75,14 +65,13 @@ export default class DojoApp extends Component {
|
||||
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
|
||||
'mh4-m mh4-l mh4-xl mb4-m mb4-l mb4-xl ba-m ba-l ba-xl'
|
||||
}
|
||||
style={{
|
||||
lineHeight: '1.4',
|
||||
cursor: 'text'
|
||||
}}
|
||||
>
|
||||
<Popout popout={popout} />
|
||||
<History commandLog={this.state.txt} />
|
||||
<Input
|
||||
ship={this.props.ship}
|
||||
|
@ -1,29 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
export class Popout extends Component {
|
||||
render() {
|
||||
const hidePopoutIcon = this.props.popout
|
||||
? 'dn-m dn-l dn-xl'
|
||||
: 'dib-m dib-l dib-xl';
|
||||
return (
|
||||
<div
|
||||
className="db tr z-2"
|
||||
style={{
|
||||
right: 16,
|
||||
top: 16
|
||||
}}
|
||||
>
|
||||
<a href="/~dojo/popout" target="_blank">
|
||||
<img
|
||||
className={'flex-shrink-0 dn ' + hidePopoutIcon}
|
||||
src="/~dojo/img/popout.png"
|
||||
height="16"
|
||||
width="16"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Popout;
|
@ -1,10 +1,11 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import Helmet from 'react-helmet';
|
||||
import { Box, Center } from '@tlon/indigo-react';
|
||||
|
||||
import './css/custom.css';
|
||||
|
||||
import { Skeleton } from './components/skeleton';
|
||||
import { Skeleton as NewSkeleton } from '~/views/components/Skeleton';
|
||||
import { NewScreen } from './components/new';
|
||||
import { ContactSidebar } from './components/lib/contact-sidebar';
|
||||
import { ContactCard } from './components/lib/contact-card';
|
||||
@ -12,10 +13,15 @@ import { AddScreen } from './components/lib/add-contact';
|
||||
import { JoinScreen } from './components/join';
|
||||
import GroupDetail from './components/lib/group-detail';
|
||||
|
||||
import { PatpNoSig } from '~/types/noun';
|
||||
import { PatpNoSig, AppName } from '~/types/noun';
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
import { StoreState } from '~/logic/store/type';
|
||||
import GlobalSubscription from '~/logic/subscription/global';
|
||||
import {Resource} from '~/views/components/Resource';
|
||||
import {PopoverRoutes} from './components/PopoverRoutes';
|
||||
import {UnjoinedResource} from '~/views/components/UnjoinedResource';
|
||||
import {GroupsPane} from '~/views/components/GroupsPane';
|
||||
import {Workspace} from '~/types';
|
||||
|
||||
|
||||
type GroupsAppProps = StoreState & {
|
||||
@ -26,14 +32,22 @@ 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';
|
||||
|
||||
this.props.subscription.startApp('groups')
|
||||
this.props.subscription.startApp('chat')
|
||||
this.props.subscription.startApp('publish');
|
||||
this.props.subscription.startApp('graph');
|
||||
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.subscription.stopApp('groups')
|
||||
this.props.subscription.stopApp('chat')
|
||||
this.props.subscription.stopApp('publish');
|
||||
this.props.subscription.stopApp('graph');
|
||||
}
|
||||
|
||||
|
||||
@ -55,10 +69,6 @@ export default class GroupsApp extends Component<GroupsAppProps, {}> {
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>OS1 - Groups</title>
|
||||
</Helmet>
|
||||
<Switch>
|
||||
<Route exact path="/~groups"
|
||||
render={(props) => {
|
||||
@ -86,22 +96,12 @@ export default class GroupsApp extends Component<GroupsAppProps, {}> {
|
||||
<Route exact path="/~groups/new"
|
||||
render={(props) => {
|
||||
return (
|
||||
<Skeleton
|
||||
<NewScreen
|
||||
history={props.history}
|
||||
api={api}
|
||||
contacts={contacts}
|
||||
groups={groups}
|
||||
invites={invites}
|
||||
associations={associations}
|
||||
activeDrawer="rightPanel"
|
||||
>
|
||||
<NewScreen
|
||||
history={props.history}
|
||||
groups={groups}
|
||||
contacts={contacts}
|
||||
api={api}
|
||||
/>
|
||||
</Skeleton>
|
||||
contacts={contacts}
|
||||
api={api}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@ -110,28 +110,18 @@ export default class GroupsApp extends Component<GroupsAppProps, {}> {
|
||||
const ship = props.match.params.ship || '';
|
||||
const name = props.match.params.name || '';
|
||||
return (
|
||||
<Skeleton
|
||||
<JoinScreen
|
||||
history={props.history}
|
||||
api={api}
|
||||
contacts={contacts}
|
||||
groups={groups}
|
||||
invites={invites}
|
||||
associations={associations}
|
||||
activeDrawer="rightPanel"
|
||||
>
|
||||
<JoinScreen
|
||||
history={props.history}
|
||||
groups={groups}
|
||||
contacts={contacts}
|
||||
api={api}
|
||||
ship={ship}
|
||||
name={name}
|
||||
/>
|
||||
</Skeleton>
|
||||
contacts={contacts}
|
||||
api={api}
|
||||
ship={ship}
|
||||
name={name}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route exact path="/~groups/(detail)?/(settings)?/ship/:ship/:group/"
|
||||
<Route exact path="/~groups/dep/(detail)?/(settings)?/ship/:ship/:group/"
|
||||
render={(props) => {
|
||||
const groupPath =
|
||||
`/ship/${props.match.params.ship}/${props.match.params.group}`;
|
||||
@ -278,6 +268,25 @@ export default class GroupsApp extends Component<GroupsAppProps, {}> {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route path="/~groups/ship/:host/:name"
|
||||
render={routeProps => {
|
||||
const { host, name } = routeProps.match.params as Record<string, string>;
|
||||
const groupPath = `/ship/${host}/${name}`;
|
||||
const baseUrl = `/~groups${groupPath}`;
|
||||
const ws: Workspace = { type: 'group', group: groupPath };
|
||||
|
||||
return (
|
||||
<GroupsPane workspace={ws} baseUrl={baseUrl} {...props} />
|
||||
)
|
||||
}}/>
|
||||
<Route path="/~groups/home"
|
||||
render={routeProps => {
|
||||
const ws: Workspace = { type: 'home' };
|
||||
|
||||
return (<GroupsPane workspace={ws} baseUrl="/~groups/home" {...props} />);
|
||||
|
||||
}}
|
||||
/>
|
||||
<Route exact path="/~groups/view/ship/:ship/:group/:contact"
|
||||
render={(props) => {
|
||||
const groupPath =
|
||||
@ -333,8 +342,34 @@ export default class GroupsApp extends Component<GroupsAppProps, {}> {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route exact path="/~groups/me"
|
||||
render={(props) => {
|
||||
const me = defaultContacts[window.ship] || {};
|
||||
|
||||
return (
|
||||
<Skeleton
|
||||
history={props.history}
|
||||
api={api}
|
||||
contacts={contacts}
|
||||
groups={groups}
|
||||
invites={invites}
|
||||
activeDrawer="rightPanel"
|
||||
selected="me"
|
||||
associations={associations}
|
||||
>
|
||||
<ContactCard
|
||||
api={api}
|
||||
history={props.history}
|
||||
path="/~/default"
|
||||
contact={me}
|
||||
s3={s3}
|
||||
ship={window.ship}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
185
pkg/interface/src/views/apps/groups/components/GroupSwitcher.tsx
Normal file
185
pkg/interface/src/views/apps/groups/components/GroupSwitcher.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Center,
|
||||
Box,
|
||||
Col,
|
||||
Row,
|
||||
Text,
|
||||
IconButton,
|
||||
Button,
|
||||
Icon,
|
||||
} from "@tlon/indigo-react";
|
||||
import { uxToHex } from "~/logic/lib/util";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { Association, Associations } from "~/types/metadata-update";
|
||||
import { Dropdown } from "~/views/components/Dropdown";
|
||||
import { Workspace } from "~/types";
|
||||
import { getTitleFromWorkspace } from "~/logic/lib/workspace";
|
||||
|
||||
const GroupSwitcherItem = ({ to, children, bottom = false, ...rest }) => (
|
||||
<Link to={to}>
|
||||
<Box
|
||||
py={1}
|
||||
{...rest}
|
||||
borderBottom={bottom ? 0 : 1}
|
||||
borderBottomColor="lightGray"
|
||||
>
|
||||
<Row p={2} alignItems="center">
|
||||
{children}
|
||||
</Row>
|
||||
</Box>
|
||||
</Link>
|
||||
);
|
||||
|
||||
function RecentGroups(props: { recent: string[]; associations: Associations }) {
|
||||
const { associations, recent } = props;
|
||||
if (recent.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Col borderBottom={1} borderBottomColor="lightGray" p={1}>
|
||||
<Box fontSize={0} px={1} py={2} color="gray">
|
||||
Recent Groups
|
||||
</Box>
|
||||
{props.recent.slice(1, 5).map((g) => {
|
||||
const assoc = associations.contacts[g];
|
||||
const color = uxToHex(assoc?.metadata?.color || "0x0");
|
||||
return (
|
||||
<Row key={g} px={1} pb={2} alignItems="center">
|
||||
<Box
|
||||
borderRadius={1}
|
||||
border={1}
|
||||
borderColor="lightGray"
|
||||
height="16px"
|
||||
width="16px"
|
||||
bg={color}
|
||||
mr={2}
|
||||
display="block"
|
||||
flexShrink='0'
|
||||
/>
|
||||
<Link style={{ minWidth: 0 }} to={`/~groups${g}`}>
|
||||
<Text verticalAlign='top' maxWidth='100%' overflow='hidden' display='inline-block' style={{ textOverflow: 'ellipsis', whiteSpace: 'pre' }}>{assoc?.metadata?.title}</Text>
|
||||
</Link>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
export function GroupSwitcher(props: {
|
||||
associations: Associations;
|
||||
workspace: Workspace;
|
||||
baseUrl: string;
|
||||
recentGroups: string[];
|
||||
}) {
|
||||
const { associations, workspace } = props;
|
||||
const title = getTitleFromWorkspace(associations, workspace);
|
||||
const navTo = (to: string) => `${props.baseUrl}${to}`;
|
||||
return (
|
||||
<Box zIndex="2" position="sticky" top="0px" p={2}>
|
||||
<Col
|
||||
justifyContent="center"
|
||||
bg="white"
|
||||
borderRadius={1}
|
||||
border={1}
|
||||
borderColor="washedGray"
|
||||
>
|
||||
<Row alignItems="center" justifyContent="space-between">
|
||||
<Dropdown
|
||||
width="231px"
|
||||
alignY="top"
|
||||
options={
|
||||
<Col
|
||||
borderRadius={1}
|
||||
border={1}
|
||||
borderColor="lightGray"
|
||||
bg="white"
|
||||
width="100%"
|
||||
alignItems="stretch"
|
||||
>
|
||||
<GroupSwitcherItem to="">
|
||||
<Icon
|
||||
mr={2}
|
||||
stroke="gray"
|
||||
color="transparent"
|
||||
display="block"
|
||||
icon="Groups"
|
||||
/>
|
||||
<Text>All Groups</Text>
|
||||
</GroupSwitcherItem>
|
||||
<RecentGroups
|
||||
recent={props.recentGroups}
|
||||
associations={props.associations}
|
||||
/>
|
||||
<GroupSwitcherItem to="/~groups/new">
|
||||
<Icon mr="2" color="transparent" stroke="gray" icon="Plus" />
|
||||
<Text> New Group</Text>
|
||||
</GroupSwitcherItem>
|
||||
<GroupSwitcherItem to="/~groups/join">
|
||||
<Icon mr="2" color="transparent" stroke="gray" icon="Boot" />
|
||||
<Text> Join Group</Text>
|
||||
</GroupSwitcherItem>
|
||||
{workspace.type === "group" && (
|
||||
<>
|
||||
<GroupSwitcherItem to={navTo("/popover/participants")}>
|
||||
<Icon
|
||||
mr={2}
|
||||
color="transparent"
|
||||
stroke="gray"
|
||||
icon="Circle"
|
||||
/>
|
||||
<Text> Participants</Text>
|
||||
</GroupSwitcherItem>
|
||||
<GroupSwitcherItem to={navTo("/popover/settings")}>
|
||||
<Icon
|
||||
mr={2}
|
||||
color="transparent"
|
||||
stroke="gray"
|
||||
icon="Gear"
|
||||
/>
|
||||
<Text> Settings</Text>
|
||||
</GroupSwitcherItem>
|
||||
<GroupSwitcherItem bottom to={navTo("/invites")}>
|
||||
<Icon
|
||||
mr={2}
|
||||
color="blue"
|
||||
icon="CreateGroup"
|
||||
/>
|
||||
<Text color="blue">Invite to group</Text>
|
||||
</GroupSwitcherItem>
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
}
|
||||
>
|
||||
<Box p={2} alignItems="center" display="flex">
|
||||
<Box mr={1} flex='1'>
|
||||
<Text overflow='hidden' display='inline-block' maxWidth='144px' style={{ textOverflow: 'ellipsis', whiteSpace: 'pre'}}>{title}</Text>
|
||||
</Box>
|
||||
<Icon mt="0px" display="block" icon="ChevronSouth" />
|
||||
</Box>
|
||||
</Dropdown>
|
||||
<Row collapse pr={1} justifyContent="flex-end" alignItems="center">
|
||||
{workspace.type === "group" && (
|
||||
<>
|
||||
<Link to={navTo("/invites")}>
|
||||
<Icon
|
||||
display="block"
|
||||
color='blue'
|
||||
icon="CreateGroup"
|
||||
/>
|
||||
</Link>
|
||||
<Link to={navTo("/popover/settings")}>
|
||||
<Icon color='gray' display="block" m={2} icon="Gear" />
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
</Row>
|
||||
</Col>
|
||||
</Box>
|
||||
);
|
||||
}
|
106
pkg/interface/src/views/apps/groups/components/InvitePopover.tsx
Normal file
106
pkg/interface/src/views/apps/groups/components/InvitePopover.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import React, { useCallback, useRef, useMemo } from "react";
|
||||
import { Box, Text, Col, Button, Row } from "@tlon/indigo-react";
|
||||
|
||||
import { ShipSearch } from "~/views/components/ShipSearch";
|
||||
import { Association } from "~/types/metadata-update";
|
||||
import { Switch, Route, useHistory } from "react-router-dom";
|
||||
import { Formik, Form } from "formik";
|
||||
import { AsyncButton } from "~/views/components/AsyncButton";
|
||||
import { useOutsideClick } from "~/logic/lib/useOutsideClick";
|
||||
import { FormError } from "~/views/components/FormError";
|
||||
import { resourceFromPath } from "~/logic/lib/group";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { Groups, Rolodex } from "~/types";
|
||||
|
||||
interface InvitePopoverProps {
|
||||
baseUrl: string;
|
||||
association: Association;
|
||||
groups: Groups;
|
||||
contacts: Rolodex;
|
||||
api: GlobalApi;
|
||||
}
|
||||
|
||||
export function InvitePopover(props: InvitePopoverProps) {
|
||||
const { baseUrl, api, association } = props;
|
||||
|
||||
const relativePath = (p: string) => baseUrl + p;
|
||||
const { title } = association?.metadata || '';
|
||||
const innerRef = useRef(null);
|
||||
const history = useHistory();
|
||||
|
||||
const onOutsideClick = useCallback(() => {
|
||||
history.push(props.baseUrl);
|
||||
}, [history.push, props.baseUrl]);
|
||||
useOutsideClick(innerRef, onOutsideClick);
|
||||
|
||||
const onSubmit = async ({ ships }: { ships: string[] }, actions) => {
|
||||
try {
|
||||
const resource = resourceFromPath(association["group-path"]);
|
||||
await ships.reduce(
|
||||
(acc, s) => acc.then(() => api.contacts.invite(resource, `~${s}`)),
|
||||
Promise.resolve()
|
||||
);
|
||||
actions.setStatus({ success: null });
|
||||
onOutsideClick();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
actions.setStatus({ error: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Route path={[relativePath("/invites")]}>
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
bg="gray"
|
||||
left="0px"
|
||||
top="0px"
|
||||
width="100vw"
|
||||
height="100vh"
|
||||
zIndex={4}
|
||||
position="fixed"
|
||||
>
|
||||
<Box
|
||||
ref={innerRef}
|
||||
border={1}
|
||||
borderColor="washedGray"
|
||||
borderRadius={1}
|
||||
maxHeight="472px"
|
||||
width="380px"
|
||||
bg="white"
|
||||
>
|
||||
<Formik initialValues={{ ships: [] }} onSubmit={onSubmit}>
|
||||
<Form>
|
||||
<Col p={3}>
|
||||
<Box mb={2}>
|
||||
<Text>Invite to </Text>
|
||||
<Text fontWeight="800">{title}</Text>
|
||||
</Box>
|
||||
<ShipSearch
|
||||
groups={props.groups}
|
||||
contacts={props.contacts}
|
||||
id="ships"
|
||||
label=""
|
||||
/>
|
||||
<FormError message="Failed to invite" />
|
||||
</Col>
|
||||
<Row
|
||||
borderTop={1}
|
||||
borderTopColor="washedGray"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<AsyncButton border={0} color="blue" loadingText="Inviting...">
|
||||
Send
|
||||
</AsyncButton>
|
||||
</Row>
|
||||
</Form>
|
||||
</Formik>
|
||||
</Box>
|
||||
</Box>
|
||||
</Route>
|
||||
</Switch>
|
||||
);
|
||||
}
|
244
pkg/interface/src/views/apps/groups/components/Participants.tsx
Normal file
244
pkg/interface/src/views/apps/groups/components/Participants.tsx
Normal file
@ -0,0 +1,244 @@
|
||||
import React, { useState, useMemo, SyntheticEvent, ChangeEvent } from "react";
|
||||
import {
|
||||
Col,
|
||||
Box,
|
||||
Row,
|
||||
Text,
|
||||
Icon,
|
||||
Center,
|
||||
Button,
|
||||
Action,
|
||||
} from "@tlon/indigo-react";
|
||||
import _ from "lodash";
|
||||
import { Contact, Contacts } from "~/types/contact-update";
|
||||
import { Sigil } from "~/logic/lib/sigil";
|
||||
import { cite, uxToHex } from "~/logic/lib/util";
|
||||
import { Group, RoleTags } from "~/types/group-update";
|
||||
import { roleForShip } from "~/logic/lib/group";
|
||||
import { Association } from "~/types/metadata-update";
|
||||
import { useHistory, Link } from "react-router-dom";
|
||||
import { Dropdown } from "~/views/components/Dropdown";
|
||||
|
||||
type Participant = Contact & { patp: string; pending: boolean };
|
||||
type ParticipantsTabId = "total" | "pending" | "admin";
|
||||
|
||||
const searchParticipant = (search: string) => (p: Participant) => {
|
||||
if (search.length == 0) {
|
||||
return true;
|
||||
}
|
||||
const s = search.toLowerCase();
|
||||
return p.patp.includes(s) || p.nickname.toLowerCase().includes(search);
|
||||
};
|
||||
|
||||
const emptyContact = (patp: string, pending: boolean): Participant => ({
|
||||
nickname: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
color: "",
|
||||
avatar: null,
|
||||
notes: "",
|
||||
website: "",
|
||||
patp,
|
||||
pending,
|
||||
});
|
||||
|
||||
const Tab = ({ selected, id, label, setSelected }) => (
|
||||
<Box
|
||||
py={2}
|
||||
borderBottom={selected === id ? 1 : 0}
|
||||
borderBottomColor="black"
|
||||
mr={2}
|
||||
onClick={() => setSelected(id)}
|
||||
>
|
||||
<Text color={selected === id ? "black" : "gray"}>{label}</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export function Participants(props: {
|
||||
contacts: Contacts;
|
||||
group: Group;
|
||||
association: Association;
|
||||
}) {
|
||||
const tabFilters: Record<
|
||||
ParticipantsTabId,
|
||||
(p: Participant) => boolean
|
||||
> = useMemo(
|
||||
() => ({
|
||||
total: (p) => !p.pending,
|
||||
pending: (p) => p.pending,
|
||||
admin: (p) => props.group.tags?.role?.admin?.has(p.patp),
|
||||
}),
|
||||
[props.group]
|
||||
);
|
||||
|
||||
const [filter, setFilter] = useState<ParticipantsTabId>("total");
|
||||
|
||||
const [search, _setSearch] = useState("");
|
||||
const setSearch = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
_setSearch(e.target.value);
|
||||
};
|
||||
const contacts: Participant[] = useMemo(
|
||||
() =>
|
||||
_.map(props.contacts, (c, patp) => ({
|
||||
...c,
|
||||
patp,
|
||||
pending: false,
|
||||
})),
|
||||
[props.contacts]
|
||||
);
|
||||
const members: Participant[] = _.map(Array.from(props.group.members), (m) =>
|
||||
emptyContact(m, false)
|
||||
);
|
||||
const allMembers = _.unionBy(contacts, members, "patp");
|
||||
const isInvite = "invite" in props.group.policy;
|
||||
const pending: Participant[] =
|
||||
"invite" in props.group.policy
|
||||
? _.map(Array.from(props.group.policy.invite.pending), (m) =>
|
||||
emptyContact(m, true)
|
||||
)
|
||||
: [];
|
||||
const adminCount = props.group.tags?.role?.admin?.size || 0;
|
||||
|
||||
const allSundry = _.unionBy(allMembers, pending, "patp");
|
||||
|
||||
const filtered = _.chain(allSundry)
|
||||
.filter(tabFilters[filter])
|
||||
.filter(searchParticipant(search))
|
||||
.value();
|
||||
|
||||
return (
|
||||
<Col height="100%" overflowY="auto" p={2} position="relative">
|
||||
<Row
|
||||
bg="white"
|
||||
border={1}
|
||||
borderColor="washedGray"
|
||||
borderRadius={1}
|
||||
position="sticky"
|
||||
top="0px"
|
||||
mb={2}
|
||||
px={2}
|
||||
zIndex={1}
|
||||
>
|
||||
<Row>
|
||||
<Tab
|
||||
selected={filter}
|
||||
setSelected={setFilter}
|
||||
id="total"
|
||||
label={`${allMembers.length} total`}
|
||||
/>
|
||||
{isInvite && (
|
||||
<Tab
|
||||
selected={filter}
|
||||
setSelected={setFilter}
|
||||
id="pending"
|
||||
label={`${pending.length} pending`}
|
||||
/>
|
||||
)}
|
||||
<Tab
|
||||
selected={filter}
|
||||
setSelected={setFilter}
|
||||
id="admin"
|
||||
label={`${adminCount} Admin${adminCount > 1 ? "s" : ""}`}
|
||||
/>
|
||||
</Row>
|
||||
</Row>
|
||||
<Box
|
||||
display="grid"
|
||||
gridAutoRows={["48px 48px 1px", "48px 1px"]}
|
||||
gridTemplateColumns={["48px 1fr", "48px 1fr 144px"]}
|
||||
gridRowGap={2}
|
||||
alignItems="center"
|
||||
>
|
||||
{filtered.map((c) => (
|
||||
<Participant
|
||||
key={c.patp}
|
||||
role="admin"
|
||||
group={props.group}
|
||||
contact={c}
|
||||
association={props.association}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
function Participant(props: {
|
||||
contact: Participant;
|
||||
association: Association;
|
||||
group: Group;
|
||||
role: RoleTags;
|
||||
}) {
|
||||
const history = useHistory();
|
||||
const { contact, association, group } = props;
|
||||
const { title } = association.metadata;
|
||||
|
||||
const color = uxToHex(contact.color);
|
||||
const isInvite = "invite" in group.policy;
|
||||
|
||||
const role = contact.pending
|
||||
? "pending"
|
||||
: roleForShip(group, contact.patp) || "member";
|
||||
const sendMessage = () => {
|
||||
history.push(`/~chat/new/dm/${contact.patp}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box>
|
||||
<Sigil ship={contact.patp} size={32} color={`#${color}`} />
|
||||
</Box>
|
||||
<Col>
|
||||
<Text>{contact.nickname}</Text>
|
||||
<Text color="gray" fontFamily="mono">
|
||||
{cite(contact.patp)}
|
||||
</Text>
|
||||
</Col>
|
||||
<Row
|
||||
width="100%"
|
||||
justifyContent="space-between"
|
||||
gridColumn={["1 / 3", "auto"]}
|
||||
alignItems="center">
|
||||
<Col>
|
||||
<Text mb={1} color="lightGray">
|
||||
Role
|
||||
</Text>
|
||||
<Text>{_.capitalize(role)}</Text>
|
||||
</Col>
|
||||
<Dropdown
|
||||
alignX="right"
|
||||
alignY="top"
|
||||
options={
|
||||
<Col gapY={1} p={2}>
|
||||
<Action onClick={sendMessage}>
|
||||
<Text color="green">Send Message</Text>
|
||||
</Action>
|
||||
{isInvite && (
|
||||
<Action onClick={() => {}}>
|
||||
<Text color="red">Ban from {title}</Text>
|
||||
</Action>
|
||||
)}
|
||||
<Action onSelect={() => {}}>Promote to Admin</Action>
|
||||
</Col>
|
||||
}
|
||||
>
|
||||
<Icon mr={2} icon="Ellipsis" />
|
||||
</Dropdown>
|
||||
</Row>
|
||||
<Box
|
||||
borderBottom={1}
|
||||
borderBottomColor="washedGray"
|
||||
gridColumn={["1 / 3", "1 / 4"]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ParticipantMenu(props: {
|
||||
ourRole?: RoleTags;
|
||||
theirRole?: RoleTags;
|
||||
them: string;
|
||||
}) {
|
||||
const { ourRole, theirRole } = props;
|
||||
let options = [];
|
||||
}
|
150
pkg/interface/src/views/apps/groups/components/PopoverRoutes.tsx
Normal file
150
pkg/interface/src/views/apps/groups/components/PopoverRoutes.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
import React, { useRef, useCallback } from "react";
|
||||
import { Route, Switch, RouteComponentProps, Link } from "react-router-dom";
|
||||
import { Box, Row, Col, Icon, Text } from "@tlon/indigo-react";
|
||||
import { useOutsideClick } from "~/logic/lib/useOutsideClick";
|
||||
import { HoverBoxLink } from "~/views/components/HoverBox";
|
||||
import { GroupSettings } from "./lib/GroupSettings";
|
||||
import { Contacts } from "~/types/contact-update";
|
||||
import { Participants } from "./Participants";
|
||||
import { Group } from "~/types/group-update";
|
||||
import { Association } from "~/types/metadata-update";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { ContactCard } from "./lib/ContactCard";
|
||||
import {S3State} from "~/types";
|
||||
|
||||
const SidebarItem = ({ selected, icon, text, to }) => {
|
||||
return (
|
||||
<HoverBoxLink
|
||||
to={to}
|
||||
selected={selected}
|
||||
bg="white"
|
||||
bgActive="washedGray"
|
||||
display="flex"
|
||||
px={3}
|
||||
py={1}
|
||||
>
|
||||
<Icon icon={icon} />
|
||||
<Text color={selected ? "black" : "gray"}>{text}</Text>
|
||||
</HoverBoxLink>
|
||||
);
|
||||
};
|
||||
|
||||
export function PopoverRoutes(
|
||||
props: {
|
||||
baseUrl: string;
|
||||
contacts: Contacts;
|
||||
group: Group;
|
||||
association: Association;
|
||||
s3: S3State;
|
||||
api: GlobalApi;
|
||||
} & RouteComponentProps
|
||||
) {
|
||||
const relativeUrl = (url: string) => `${props.baseUrl}/popover${url}`;
|
||||
const innerRef = useRef(null);
|
||||
|
||||
const onOutsideClick = useCallback(() => {
|
||||
props.history.push(props.baseUrl);
|
||||
}, [props.history.push, props.baseUrl]);
|
||||
useOutsideClick(innerRef, onOutsideClick);
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Route
|
||||
path={[relativeUrl("/:view"), relativeUrl("")]}
|
||||
render={(routeProps) => {
|
||||
const { view } = routeProps.match.params;
|
||||
return (
|
||||
<Box
|
||||
px={[3, 7, 8]}
|
||||
py={[3, 5]}
|
||||
backgroundColor='scales.black30'
|
||||
left="0px"
|
||||
top="0px"
|
||||
width="100vw"
|
||||
height="100vh"
|
||||
zIndex={4}
|
||||
position="fixed"
|
||||
>
|
||||
<Box
|
||||
ref={innerRef}
|
||||
border={1}
|
||||
borderColor="washedGray"
|
||||
borderRadius={1}
|
||||
width="100%"
|
||||
height="100%"
|
||||
bg="white"
|
||||
>
|
||||
<Box
|
||||
display="grid"
|
||||
gridTemplateRows={["32px 1fr", "100%"]}
|
||||
gridTemplateColumns={["100%", "200px 1fr"]}
|
||||
height="100%"
|
||||
width="100%"
|
||||
>
|
||||
<Col
|
||||
display={!!view ? ["none", "flex"] : "flex"}
|
||||
py={3}
|
||||
borderRight={1}
|
||||
borderRightColor="washedGray"
|
||||
>
|
||||
<SidebarItem
|
||||
icon="Circle"
|
||||
selected={view === "participants"}
|
||||
to={relativeUrl("/participants")}
|
||||
text="Participants"
|
||||
/>
|
||||
<SidebarItem
|
||||
icon="Circle"
|
||||
selected={view === "settings"}
|
||||
to={relativeUrl("/settings")}
|
||||
text="Group Settings"
|
||||
/>
|
||||
<SidebarItem
|
||||
icon="Circle"
|
||||
selected={view === "profile"}
|
||||
to={relativeUrl("/profile")}
|
||||
text="Group Profile"
|
||||
/>
|
||||
</Col>
|
||||
<Box
|
||||
gridArea={"1 / 1 / 2 / 2"}
|
||||
p={2}
|
||||
display={["auto", "none"]}
|
||||
>
|
||||
<Link to={!!view ? relativeUrl("") : props.baseUrl}>
|
||||
<Text>{"<- Back"}</Text>
|
||||
</Link>
|
||||
</Box>
|
||||
<Box overflow="hidden">
|
||||
{view === "settings" && (
|
||||
<GroupSettings
|
||||
group={props.group}
|
||||
association={props.association}
|
||||
api={props.api}
|
||||
/>
|
||||
)}
|
||||
{view === "participants" && (
|
||||
<Participants
|
||||
group={props.group}
|
||||
contacts={props.contacts}
|
||||
association={props.association}
|
||||
/>
|
||||
)}
|
||||
{view === "profile" && (
|
||||
<ContactCard
|
||||
contact={props.contacts[window.ship]}
|
||||
api={props.api}
|
||||
path={props.association["group-path"]}
|
||||
s3={props.s3}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Switch>
|
||||
);
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Spinner } from '~/views/components/Spinner';
|
||||
import { Body } from '~/views/components/Body';
|
||||
import urbitOb from 'urbit-ob';
|
||||
|
||||
export class JoinScreen extends Component {
|
||||
@ -88,6 +89,7 @@ export class JoinScreen extends Component {
|
||||
}
|
||||
|
||||
return (
|
||||
<Body>
|
||||
<div className={'h-100 w-100 pt4 overflow-x-hidden flex flex-column ' +
|
||||
'bg-gray0-d white-d pa3'}
|
||||
>
|
||||
@ -130,6 +132,7 @@ export class JoinScreen extends Component {
|
||||
<Spinner awaiting={this.state.awaiting} classes="mt4" text="Joining group..." />
|
||||
</div>
|
||||
</div>
|
||||
</Body>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,118 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { Link, useHistory } from "react-router-dom";
|
||||
|
||||
import { Icon, Row, Col, Button, Text, Box, Action } from "@tlon/indigo-react";
|
||||
import { Dropdown } from "~/views/components/Dropdown";
|
||||
import { Association } from "~/types";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
|
||||
const ChannelMenuItem = ({
|
||||
icon,
|
||||
color = undefined as string | undefined,
|
||||
children,
|
||||
bottom = false,
|
||||
}) => (
|
||||
<Row
|
||||
alignItems="center"
|
||||
borderBottom={bottom ? 0 : 1}
|
||||
borderBottomColor="lightGray"
|
||||
px={2}
|
||||
py={1}
|
||||
>
|
||||
<Icon color={color} icon={icon} />
|
||||
{children}
|
||||
</Row>
|
||||
);
|
||||
|
||||
interface ChannelMenuProps {
|
||||
association: Association;
|
||||
api: GlobalApi;
|
||||
}
|
||||
|
||||
export function ChannelMenu(props: ChannelMenuProps) {
|
||||
const { association, api } = props;
|
||||
const history = useHistory();
|
||||
const { metadata } = association;
|
||||
const app = metadata.module || association["app-name"];
|
||||
const baseUrl = `/~groups${association?.["group-path"]}/resource/${app}${association["app-path"]}`;
|
||||
const appPath = association["app-path"];
|
||||
|
||||
const [, ship, name] = appPath.startsWith("/ship/")
|
||||
? appPath.slice(5).split("/")
|
||||
: appPath.split("/");
|
||||
|
||||
const isOurs = ship.slice(1) === window.ship;
|
||||
const onUnsubscribe = useCallback(async () => {
|
||||
const app = metadata.module || association["app-name"];
|
||||
switch (app) {
|
||||
case "chat":
|
||||
await api.chat.delete(appPath);
|
||||
break;
|
||||
case "publish":
|
||||
await api.publish.unsubscribeNotebook(ship.slice(1), name);
|
||||
await api.publish.fetchNotebooks();
|
||||
|
||||
break;
|
||||
case "link":
|
||||
await api.graph.leaveGraph(ship, name);
|
||||
break;
|
||||
default:
|
||||
throw new Error("Invalid app name");
|
||||
}
|
||||
history.push(`/~groups${association?.["group-path"]}`);
|
||||
}, [api, association]);
|
||||
|
||||
const onDelete = useCallback(async () => {
|
||||
const app = metadata.module || association["app-name"];
|
||||
switch (app) {
|
||||
case "chat":
|
||||
await api.chat.delete(appPath);
|
||||
break;
|
||||
case "publish":
|
||||
await api.publish.delBook(name);
|
||||
break;
|
||||
case "link":
|
||||
await api.graph.deleteGraph(name);
|
||||
break;
|
||||
default:
|
||||
throw new Error("Invalid app name");
|
||||
}
|
||||
history.push(`/~groups${association?.["group-path"]}`);
|
||||
}, [api, association]);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
options={
|
||||
<Col bg="white" border={1} borderRadius={1} borderColor="lightGray">
|
||||
{isOurs ? (
|
||||
<>
|
||||
<ChannelMenuItem color="red" icon="TrashCan">
|
||||
<Action m="2" destructive onClick={onDelete}>
|
||||
Delete Channel
|
||||
</Action>
|
||||
</ChannelMenuItem>
|
||||
<ChannelMenuItem bottom icon="Gear">
|
||||
<Link to={`${baseUrl}/settings`}>
|
||||
<Box fontSize={0} p="2">
|
||||
Channel Settings
|
||||
</Box>
|
||||
</Link>
|
||||
</ChannelMenuItem>
|
||||
</>
|
||||
) : (
|
||||
<ChannelMenuItem color="red" bottom icon="ArrowEast">
|
||||
<Action bg="white" m="2" destructive onClick={onUnsubscribe}>
|
||||
Unsubscribe from Channel
|
||||
</Action>
|
||||
</ChannelMenuItem>
|
||||
)}
|
||||
</Col>
|
||||
}
|
||||
alignX="right"
|
||||
alignY="top"
|
||||
width="250px"
|
||||
>
|
||||
<Icon display="block" icon="Menu" stroke="gray" />
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
@ -0,0 +1,107 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { AsyncButton } from "../../../../components/AsyncButton";
|
||||
import * as Yup from "yup";
|
||||
import {
|
||||
Box,
|
||||
ManagedTextInputField as Input,
|
||||
Col,
|
||||
Label,
|
||||
Text,
|
||||
} from "@tlon/indigo-react";
|
||||
import { Formik, Form, useFormikContext, FormikHelpers } from "formik";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { uxToHex } from '~/logic/lib/util';
|
||||
import { FormError } from "~/views/components/FormError";
|
||||
import { ColorInput } from "~/views/components/ColorInput";
|
||||
import { Association } from "~/types";
|
||||
|
||||
interface FormSchema {
|
||||
title: string;
|
||||
description: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface ChannelSettingsProps {
|
||||
association: Association;
|
||||
api: GlobalApi;
|
||||
}
|
||||
|
||||
export function ChannelSettings(props: ChannelSettingsProps) {
|
||||
const { api, association } = props;
|
||||
const { metadata } = association;
|
||||
const initialValues: FormSchema = {
|
||||
title: metadata?.title || "",
|
||||
description: metadata?.description || "",
|
||||
color: metadata?.color || "0x0",
|
||||
};
|
||||
|
||||
const onSubmit = async (
|
||||
values: FormSchema,
|
||||
actions: FormikHelpers<FormSchema>
|
||||
) => {
|
||||
try {
|
||||
const app = association["app-name"];
|
||||
const resource = association["app-path"];
|
||||
const group = association["group-path"];
|
||||
const date = metadata["date-created"];
|
||||
const { title, description, color } = values;
|
||||
await api.metadata.metadataAdd(
|
||||
app,
|
||||
resource,
|
||||
group,
|
||||
title,
|
||||
description,
|
||||
date,
|
||||
uxToHex(color),
|
||||
metadata.module
|
||||
);
|
||||
actions.setStatus({ success: null });
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
actions.setStatus({ error: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box overflowY="auto" p={4}>
|
||||
<Formik initialValues={initialValues} onSubmit={onSubmit}>
|
||||
<Form style={{ display: "contents" }}>
|
||||
<Box
|
||||
display="grid"
|
||||
gridTemplateColumns="100%"
|
||||
maxWidth="512px"
|
||||
gridAutoRows="auto"
|
||||
width="100%"
|
||||
gridRowGap={4}
|
||||
>
|
||||
<Col mb={3}>
|
||||
<Text fontWeight="bold">Channel Host Settings</Text>
|
||||
<Label>
|
||||
Adjust channel settings, only available for channel's hosts
|
||||
</Label>
|
||||
</Col>
|
||||
<Input
|
||||
id="title"
|
||||
label="Title"
|
||||
caption="Change the title of this channel"
|
||||
/>
|
||||
<Input
|
||||
id="description"
|
||||
label="Change description"
|
||||
caption="Change the description of this channel"
|
||||
/>
|
||||
<ColorInput
|
||||
id="color"
|
||||
label="Color"
|
||||
caption="Change the color of this channel"
|
||||
/>
|
||||
<AsyncButton primary loadingText="Updating.." border>
|
||||
Save
|
||||
</AsyncButton>
|
||||
<FormError message="Failed to update settings" />
|
||||
</Box>
|
||||
</Form>
|
||||
</Formik>
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -57,6 +57,16 @@ const formSchema = Yup.object({
|
||||
),
|
||||
});
|
||||
|
||||
const emptyContact = {
|
||||
avatar: null,
|
||||
color: '0',
|
||||
nickname: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
website: '',
|
||||
notes: ''
|
||||
};
|
||||
|
||||
export function ContactCard(props: ContactCardProps) {
|
||||
const us = `~${window.ship}`;
|
||||
const { contact } = props;
|
||||
@ -88,13 +98,13 @@ export function ContactCard(props: ContactCardProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const hexColor = contact.color ? `#${uxToHex(contact.color)}` : "#000000";
|
||||
const hexColor = contact?.color ? `#${uxToHex(contact.color)}` : "#000000";
|
||||
|
||||
return (
|
||||
<Box p={4} height="100%" overflowY="auto">
|
||||
<Formik
|
||||
validationSchema={formSchema}
|
||||
initialValues={contact}
|
||||
initialValues={contact || emptyContact}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Form
|
||||
|
@ -0,0 +1,142 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { AsyncButton } from "../../../../components/AsyncButton";
|
||||
import * as Yup from "yup";
|
||||
import {
|
||||
Box,
|
||||
ManagedTextInputField as Input,
|
||||
ManagedCheckboxField as Checkbox,
|
||||
Col,
|
||||
Label,
|
||||
Button,
|
||||
} from "@tlon/indigo-react";
|
||||
import { Formik, Form, useFormikContext, FormikHelpers } from "formik";
|
||||
import { FormError } from "~/views/components/FormError";
|
||||
import { Group, GroupPolicy } from "~/types/group-update";
|
||||
import { Enc } from "~/types/noun";
|
||||
import { Association } from "~/types/metadata-update";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { resourceFromPath, roleForShip } from "~/logic/lib/group";
|
||||
import { StatelessAsyncButton } from "~/views/components/StatelessAsyncButton";
|
||||
|
||||
interface FormSchema {
|
||||
name: string;
|
||||
description?: string;
|
||||
isPrivate: boolean;
|
||||
}
|
||||
|
||||
const formSchema = Yup.object({
|
||||
name: Yup.string().required("Group must have a name"),
|
||||
description: Yup.string(),
|
||||
isPrivate: Yup.boolean(),
|
||||
});
|
||||
|
||||
interface GroupSettingsProps {
|
||||
group: Group;
|
||||
association: Association;
|
||||
api: GlobalApi;
|
||||
}
|
||||
export function GroupSettings(props: GroupSettingsProps) {
|
||||
const { group, association } = props;
|
||||
const { metadata } = association;
|
||||
const currentPrivate = "invite" in props.group.policy;
|
||||
const initialValues: FormSchema = {
|
||||
name: metadata.title,
|
||||
description: metadata.description,
|
||||
isPrivate: currentPrivate,
|
||||
};
|
||||
|
||||
const onSubmit = async (
|
||||
values: FormSchema,
|
||||
actions: FormikHelpers<FormSchema>
|
||||
) => {
|
||||
try {
|
||||
const { name, description, isPrivate } = values;
|
||||
await props.api.metadata.editGroup(props.association, name, description);
|
||||
if (isPrivate !== currentPrivate) {
|
||||
const resource = resourceFromPath(props.association["group-path"]);
|
||||
const newPolicy: Enc<GroupPolicy> = isPrivate
|
||||
? { invite: { pending: [] } }
|
||||
: { open: { banRanks: [], banned: [] } };
|
||||
const diff = { replace: newPolicy };
|
||||
await props.api.groups.changePolicy(resource, diff);
|
||||
}
|
||||
|
||||
actions.setStatus({ success: null });
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
actions.setStatus({ error: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
await props.api.contacts.delete(association["group-path"]);
|
||||
};
|
||||
const disabled =
|
||||
resourceFromPath(association["group-path"]).ship.slice(1) !== window.ship &&
|
||||
roleForShip(group, window.ship) !== "admin";
|
||||
|
||||
return (
|
||||
<Box height="100%" overflowY="auto">
|
||||
<Formik
|
||||
validationSchema={formSchema}
|
||||
initialValues={initialValues}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Form style={{ display: "contents" }}>
|
||||
<Box
|
||||
maxWidth="300px"
|
||||
gridTemplateColumns="1fr"
|
||||
gridAutoRows="auto"
|
||||
display="grid"
|
||||
gridRowGap={4}
|
||||
my={3}
|
||||
mx={4}
|
||||
>
|
||||
{!disabled && (
|
||||
<>
|
||||
<Col>
|
||||
<Label>Delete Group</Label>
|
||||
<Label gray mt="2">
|
||||
Permanently delete this group. (All current members will no
|
||||
longer see this group.)
|
||||
</Label>
|
||||
<StatelessAsyncButton onClick={onDelete} mt={2} destructive>
|
||||
Delete this group
|
||||
</StatelessAsyncButton>
|
||||
</Col>
|
||||
<Box borderBottom={1} borderBottomColor="washedGray" />
|
||||
</>
|
||||
)}
|
||||
<Input
|
||||
id="name"
|
||||
label="Group Name"
|
||||
caption="The name for your group to be called by"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
id="description"
|
||||
label="Group Description"
|
||||
caption="The description of your group"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Checkbox
|
||||
id="isPrivate"
|
||||
label="Private group"
|
||||
caption="If enabled, users must be invited to join the group"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<AsyncButton
|
||||
disabled={disabled}
|
||||
primary
|
||||
loadingText="Updating.."
|
||||
border
|
||||
>
|
||||
Save
|
||||
</AsyncButton>
|
||||
<FormError message="Failed to update settings" />
|
||||
</Box>
|
||||
</Form>
|
||||
</Formik>
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -0,0 +1,172 @@
|
||||
import React, { useCallback } from "react";
|
||||
import {
|
||||
Box,
|
||||
ManagedTextInputField as Input,
|
||||
Col,
|
||||
ManagedRadioButtonField as Radio,
|
||||
Text,
|
||||
} 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";
|
||||
import { ShipSearch } from "~/views/components/ShipSearch";
|
||||
import { Rolodex } from "~/types";
|
||||
|
||||
interface FormSchema {
|
||||
name: string;
|
||||
description: string;
|
||||
ships: string[];
|
||||
type: "chat" | "publish" | "links";
|
||||
}
|
||||
|
||||
const formSchema = Yup.object({
|
||||
name: Yup.string().required("Channel must have a name"),
|
||||
description: Yup.string(),
|
||||
ships: Yup.array(Yup.string()),
|
||||
type: Yup.string().required("Must choose channel type"),
|
||||
});
|
||||
|
||||
interface NewChannelProps {
|
||||
api: GlobalApi;
|
||||
associations: Associations;
|
||||
contacts: Rolodex;
|
||||
groups: Groups;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
const EMPTY_INVITE_POLICY = { invite: { pending: [] } };
|
||||
|
||||
export function NewChannel(props: NewChannelProps & RouteComponentProps) {
|
||||
const { history, api, group } = props;
|
||||
|
||||
const waiter = useWaitForProps(props, 5000);
|
||||
|
||||
const onSubmit = async (values: FormSchema, actions) => {
|
||||
const resId: string = stringToSymbol(values.name);
|
||||
try {
|
||||
const { name, description, type, ships } = values;
|
||||
switch (type) {
|
||||
case "chat":
|
||||
const appPath = `/~${window.ship}/${resId}`;
|
||||
const groupPath = group || `/ship${appPath}`;
|
||||
|
||||
await api.chat.create(
|
||||
name,
|
||||
description,
|
||||
appPath,
|
||||
groupPath,
|
||||
EMPTY_INVITE_POLICY,
|
||||
ships.map((s) => `~${s}`),
|
||||
true,
|
||||
false
|
||||
);
|
||||
break;
|
||||
case "publish":
|
||||
await props.api.publish.newBook(resId, name, description, group);
|
||||
break;
|
||||
case "links":
|
||||
if (group) {
|
||||
await api.graph.createManagedGraph(
|
||||
resId,
|
||||
name,
|
||||
description,
|
||||
group,
|
||||
"link"
|
||||
);
|
||||
} else {
|
||||
await api.graph.createUnmanagedGraph(
|
||||
resId,
|
||||
name,
|
||||
description,
|
||||
EMPTY_INVITE_POLICY,
|
||||
"link"
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log("fallthrough");
|
||||
}
|
||||
|
||||
if (!group) {
|
||||
await waiter((p) => !!p?.groups?.[`/ship/~${window.ship}/${resId}`]);
|
||||
}
|
||||
actions.setStatus({ success: null });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
actions.setStatus({ error: "Channel creation failed" });
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Col overflowY="auto" p={3}>
|
||||
<Box fontWeight="bold" mb={4} color="black">
|
||||
New Channel
|
||||
</Box>
|
||||
<Formik
|
||||
validationSchema={formSchema}
|
||||
initialValues={{
|
||||
type: "chat",
|
||||
name: "",
|
||||
description: "",
|
||||
group: "",
|
||||
ships: [],
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Form>
|
||||
<Box
|
||||
display="grid"
|
||||
gridTemplateRows="auto"
|
||||
gridRowGap={4}
|
||||
gridTemplateColumns="300px"
|
||||
>
|
||||
<Col gapY="2">
|
||||
<Box color="black" mb={2}>Channel Type</Box>
|
||||
<Radio label="Chat" id="chat" name="type" />
|
||||
<Radio label="Notebook" id="publish" name="type" />
|
||||
<Radio label="Collection" id="links" name="type" />
|
||||
</Col>
|
||||
<Input
|
||||
id="name"
|
||||
label="Name"
|
||||
caption="Provide a name for your channel"
|
||||
placeholder="eg. My Channel"
|
||||
/>
|
||||
<Input
|
||||
id="description"
|
||||
label="Description"
|
||||
caption="What's your channel about?"
|
||||
placeholder="Channel description"
|
||||
/>
|
||||
<ShipSearch
|
||||
groups={props.groups}
|
||||
contacts={props.contacts}
|
||||
id="ships"
|
||||
label="Invitees"
|
||||
/>
|
||||
<Box justifySelf="start">
|
||||
<AsyncButton
|
||||
primary
|
||||
loadingText="Creating..."
|
||||
type="submit"
|
||||
border
|
||||
>
|
||||
Create Channel
|
||||
</AsyncButton>
|
||||
</Box>
|
||||
<FormError message="Channel creation failed" />
|
||||
</Box>
|
||||
</Form>
|
||||
</Formik>
|
||||
</Col>
|
||||
);
|
||||
}
|
@ -0,0 +1,91 @@
|
||||
import React, { useCallback, ReactNode } from "react";
|
||||
import { Row, Box, Col, Text } from "@tlon/indigo-react";
|
||||
import styled from "styled-components";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { ChatResource } from "~/views/apps/chat/ChatResource";
|
||||
import { PublishResource } from "~/views/apps/publish/PublishResource";
|
||||
|
||||
import { Association } from "~/types/metadata-update";
|
||||
import { StoreState } from "~/logic/store/type";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { RouteComponentProps, Route, Switch } from "react-router-dom";
|
||||
import { ChannelSettings } from "../apps/groups/components/lib/ChannelSettings";
|
||||
import { ChannelMenu } from "./ChannelMenu";
|
||||
|
||||
const TruncatedBox = styled(Box)`
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
type ResourceSkeletonProps = {
|
||||
association: Association;
|
||||
api: GlobalApi;
|
||||
baseUrl: string;
|
||||
children: ReactNode;
|
||||
atRoot?: boolean;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export function ResourceSkeleton(props: ResourceSkeletonProps) {
|
||||
const { association, api, children, atRoot } = props;
|
||||
const app = association?.metadata?.module || association["app-name"];
|
||||
const appPath = association["app-path"];
|
||||
const selectedGroup = association["group-path"];
|
||||
const title = props.title || association?.metadata?.title;
|
||||
return (
|
||||
<Col width="100%" height="100%" overflowY="hidden">
|
||||
<Box
|
||||
p={2}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
borderBottom={1}
|
||||
borderBottomColor="washedGray"
|
||||
>
|
||||
{atRoot ? (
|
||||
<Box
|
||||
borderRight={1}
|
||||
borderRightColor="gray"
|
||||
pr={2}
|
||||
mr={2}
|
||||
display={["block", "none"]}
|
||||
>
|
||||
<Link to={`/~groups${selectedGroup}`}> {"<- Back"}</Link>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
color="blue"
|
||||
borderRight={1}
|
||||
borderRightColor="gray"
|
||||
pr={2}
|
||||
mr={2}
|
||||
>
|
||||
<Link to={`/~groups${selectedGroup}/resource/${app}${appPath}`}>
|
||||
<Text color="blue">Go back to channel</Text>
|
||||
</Link>
|
||||
</Box>
|
||||
)}
|
||||
<Box pr={1} mr={2}>
|
||||
<Text>{title}</Text>
|
||||
</Box>
|
||||
{atRoot && (
|
||||
<>
|
||||
<TruncatedBox
|
||||
display={["none", "block"]}
|
||||
maxWidth="60%"
|
||||
flexShrink={1}
|
||||
title={association?.metadata?.description}
|
||||
color="gray"
|
||||
>
|
||||
{association?.metadata?.description}
|
||||
</TruncatedBox>
|
||||
<Box flexGrow={1} />
|
||||
<ChannelMenu association={association} api={api} />
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
{children}
|
||||
</Col>
|
||||
);
|
||||
}
|
@ -2,7 +2,7 @@ import React, { Component } from 'react';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
import { GroupItem } from './group-item';
|
||||
import SidebarInvite from '~/views/components/SidebarInvite';
|
||||
import SidebarInvite from '~/views/components/Sidebar/SidebarInvite';
|
||||
import { Welcome } from './welcome';
|
||||
|
||||
import { cite } from '~/logic/lib/util';
|
||||
|
@ -9,7 +9,8 @@ import { RouteComponentProps } from 'react-router-dom';
|
||||
import { Groups, GroupPolicy } from '~/types/group-update';
|
||||
import { Rolodex } from '~/types/contact-update';
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
import { Enc } from '~/types/noun';
|
||||
import { Patp, PatpNoSig, Enc } from '~/types/noun';
|
||||
import {Body} from '~/views/components/Body';
|
||||
|
||||
type NewScreenProps = Pick<RouteComponentProps, 'history'> & {
|
||||
groups: Groups;
|
||||
@ -132,6 +133,7 @@ export class NewScreen extends Component<NewScreenProps, NewScreenState> {
|
||||
}
|
||||
|
||||
return (
|
||||
<Body>
|
||||
<div className='h-100 w-100 mw6 pa3 pt4 overflow-x-hidden bg-gray0-d white-d flex flex-column'>
|
||||
<div className='w-100 dn-m dn-l dn-xl inter pt1 pb6 f8'>
|
||||
<Link to='/~groups/'>{'⟵ All Groups'}</Link>
|
||||
@ -207,7 +209,7 @@ export class NewScreen extends Component<NewScreenProps, NewScreenState> {
|
||||
>
|
||||
Start Group
|
||||
</button>
|
||||
<Link to='/~groups'>
|
||||
<Link to='/'>
|
||||
<button className='f9 ml3 ba pa2 b--black pointer bg-transparent b--white-d white-d'>
|
||||
Cancel
|
||||
</button>
|
||||
@ -219,6 +221,7 @@ export class NewScreen extends Component<NewScreenProps, NewScreenState> {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Body>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,36 @@
|
||||
import React from 'react';
|
||||
import Helmet from 'react-helmet';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Box } from '@tlon/indigo-react';
|
||||
import { Box, Row, Icon, Text, Center } from '@tlon/indigo-react';
|
||||
import { uxToHex } from "~/logic/lib/util";
|
||||
|
||||
import './css/custom.css';
|
||||
|
||||
import { Sigil } from "~/logic/lib/sigil";
|
||||
import Tiles from './components/tiles';
|
||||
import Welcome from './components/welcome';
|
||||
import Groups from './components/Groups';
|
||||
|
||||
const Tile = ({ children, bg, to, ...rest }) => (
|
||||
<Box
|
||||
mt='0'
|
||||
ml='2'
|
||||
mr='2'
|
||||
mb={3}
|
||||
bg="white"
|
||||
width="126px"
|
||||
height="126px"
|
||||
borderRadius={2}
|
||||
overflow="hidden"
|
||||
{...rest}>
|
||||
<Link to={to}>
|
||||
<Box p={2} bg={bg} width="100%" height="100%">
|
||||
{children}
|
||||
</Box>
|
||||
</Link>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default class LaunchApp extends React.Component {
|
||||
|
||||
@ -18,15 +42,46 @@ export default class LaunchApp extends React.Component {
|
||||
|
||||
render() {
|
||||
const { props } = this;
|
||||
const contact = props.contacts?.['/~/default']?.[window.ship];
|
||||
const sigilColor = contact?.color
|
||||
? `#${uxToHex(contact.color)}`
|
||||
: props.dark
|
||||
? "#FFFFFF"
|
||||
: "#000000";
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<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} />
|
||||
<div className="h-100 overflow-y-scroll">
|
||||
<Welcome firstTime={props.launch.firstTime} api={props.api} />
|
||||
<Row ml='2' flexWrap="wrap" mb={4} pitch={4}>
|
||||
<Tile
|
||||
border={1}
|
||||
bg="washedGreen"
|
||||
borderColor="green"
|
||||
to="/~groups/home"
|
||||
>
|
||||
<Row alignItems="center">
|
||||
<Icon
|
||||
stroke="green"
|
||||
fill="rgba(0,0,0,0)"
|
||||
icon="Circle"
|
||||
/>
|
||||
<Text ml="1" color="green">Home</Text>
|
||||
</Row>
|
||||
</Tile>
|
||||
<Tile
|
||||
bg={sigilColor}
|
||||
to="/~profile"
|
||||
borderRadius="3"
|
||||
>
|
||||
<Center height="100%">
|
||||
<Sigil ship={`~${window.ship}`} size={80} color={sigilColor} />
|
||||
</Center>
|
||||
</Tile>
|
||||
<Tiles
|
||||
tiles={props.launch.tiles}
|
||||
tileOrdering={props.launch.tileOrdering}
|
||||
@ -34,8 +89,9 @@ export default class LaunchApp extends React.Component {
|
||||
location={props.userLocation}
|
||||
weather={props.weather}
|
||||
/>
|
||||
</div>
|
||||
<Box
|
||||
</Row>
|
||||
<Groups associations={props.associations} />
|
||||
<Box
|
||||
position="absolute"
|
||||
fontFamily="mono"
|
||||
left="0"
|
||||
|
76
pkg/interface/src/views/apps/launch/components/Groups.tsx
Normal file
76
pkg/interface/src/views/apps/launch/components/Groups.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import React from "react";
|
||||
import { Box } from "@tlon/indigo-react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { useLocalStorageState } from "~/logic/lib/useLocalStorageState";
|
||||
import { Associations, Association } from "~/types";
|
||||
import { alphabeticalOrder } from "~/logic/lib/util";
|
||||
|
||||
interface GroupsProps {
|
||||
associations: Associations;
|
||||
}
|
||||
|
||||
// Sort by recent, then by channel size? Should probably sort
|
||||
// by num unreads when notif-store drops
|
||||
const sortGroupsRecent = (recent: string[]) => (
|
||||
a: Association,
|
||||
b: Association
|
||||
) => {
|
||||
//
|
||||
const aRecency = recent.findIndex((r) => a["group-path"] === r);
|
||||
const bRecency = recent.findIndex((r) => b["group-path"] === r);
|
||||
if(aRecency === -1) {
|
||||
if(bRecency === -1) {
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
if(bRecency === -1) {
|
||||
return -1;
|
||||
}
|
||||
return Math.max(0,bRecency) - Math.max(0, aRecency);
|
||||
};
|
||||
|
||||
const sortGroupsAlph = (a: Association, b: Association) =>
|
||||
alphabeticalOrder(a.metadata.title, b.metadata.title);
|
||||
|
||||
export default function Groups(props: GroupsProps & Parameters<typeof Box>[0]) {
|
||||
const { associations, ...boxProps } = props;
|
||||
const [recentGroups, setRecentGroups] = useLocalStorageState<string[]>(
|
||||
"recent-groups",
|
||||
[]
|
||||
);
|
||||
|
||||
const groups = Object.values(props?.associations?.contacts || {})
|
||||
.sort(sortGroupsAlph)
|
||||
.sort(sortGroupsRecent(recentGroups))
|
||||
|
||||
return (
|
||||
<Box
|
||||
{...boxProps}
|
||||
ml='2'
|
||||
display="grid"
|
||||
gridAutoRows="124px"
|
||||
gridTemplateColumns="repeat(auto-fit, 124px)"
|
||||
gridGap={3}
|
||||
p={2}
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<Link to={`/~groups${group["group-path"]}`}>
|
||||
<Box
|
||||
height="100%"
|
||||
width="100%"
|
||||
bg="white"
|
||||
border={1}
|
||||
borderRadius={1}
|
||||
borderColor="lightGray"
|
||||
p={2}
|
||||
fontSize={0}
|
||||
>
|
||||
{group.metadata.title}
|
||||
</Box>
|
||||
</Link>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -10,7 +10,9 @@ export default class Tiles extends React.PureComponent {
|
||||
const { props } = this;
|
||||
|
||||
const tiles = props.tileOrdering.filter((key) => {
|
||||
return props.tiles[key].isShown;
|
||||
const tile = props.tiles[key];
|
||||
|
||||
return tile.isShown;
|
||||
}).map((key) => {
|
||||
const tile = props.tiles[key];
|
||||
if ('basic' in tile.type) {
|
||||
|
@ -46,7 +46,7 @@ export default class BasicTile extends React.PureComponent {
|
||||
|
||||
return (
|
||||
<Tile>
|
||||
<div className={classnames('w-100 h-100 relative ba b--black b--gray1-d bg-gray0-d',
|
||||
<div className={classnames('w-100 h-100 relative ba b--gray3 b--gray2-d bg-gray0-d br2',
|
||||
{ 'bg-white': props.title !== 'Dojo',
|
||||
'bg-black': props.title === 'Dojo' })}
|
||||
>{tile}</div>
|
||||
|
@ -11,7 +11,7 @@ export default class CustomTile extends React.PureComponent {
|
||||
return (
|
||||
<Tile>
|
||||
<div className={"w-100 h-100 relative bg-white bg-gray0-d ba " +
|
||||
"b--black b--gray1-d"}>
|
||||
"b--black br2 b--gray1-d"}>
|
||||
<img
|
||||
className="absolute invert-d"
|
||||
style={{ left: 38, top: 38 }}
|
||||
|
@ -4,11 +4,11 @@ import React from 'react';
|
||||
export default class Tile extends React.Component {
|
||||
render() {
|
||||
const { transparent } = this.props;
|
||||
const bgClasses = transparent ? ' ' : ' bg-white bg-gray0-d ';
|
||||
const bgClasses = transparent ? ' ' : ' bg-transparent ';
|
||||
return (
|
||||
<div className={"fl ma2 overflow-hidden" + bgClasses}
|
||||
style={{ height: '126px', width: '126px' }}>
|
||||
{this.props.children}
|
||||
<div className={"fl mr2 ml2 mb3 mt0 overflow-hidden" + bgClasses}
|
||||
style={{ height: '126px', width: '126px' }}>
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -117,7 +117,7 @@ export default class WeatherTile extends React.Component {
|
||||
return (
|
||||
<Tile>
|
||||
<div
|
||||
className={'relative ' + weatherStyle.text}
|
||||
className={'relative br2 ba b--gray3 b--gray2-d ' + weatherStyle.text}
|
||||
style={{
|
||||
width: 126,
|
||||
height: 126,
|
||||
@ -151,9 +151,7 @@ export default class WeatherTile extends React.Component {
|
||||
);
|
||||
}
|
||||
return this.renderWrapper(
|
||||
<div className={'pa2 w-100 h-100 bg-white bg-gray0-d black white-d ' +
|
||||
'b--black b--gray1-d ba'}
|
||||
>
|
||||
<div className="pa2 w-100 h-100 bg-white bg-gray0-d black white-d ">
|
||||
<a
|
||||
className="f9 black white-d pointer absolute"
|
||||
style={{ top: 8 }}
|
||||
@ -205,7 +203,7 @@ export default class WeatherTile extends React.Component {
|
||||
renderNoData() {
|
||||
return this.renderWrapper((
|
||||
<div
|
||||
className={'pa2 w-100 h-100 b--black b--gray1-d ba ' +
|
||||
className={'pa2 w-100 h-100 ' +
|
||||
'bg-white bg-gray0-d black white-d'}
|
||||
onClick={() => this.setState({ manualEntry: !this.state.manualEntry })}
|
||||
>
|
||||
@ -230,7 +228,7 @@ export default class WeatherTile extends React.Component {
|
||||
const da = moment.unix(d.sunsetTime).format('h:mm a') || '';
|
||||
|
||||
return this.renderWrapper(
|
||||
<div className="w-100 h-100 b--black b--gray1-d ba"
|
||||
<div className="w-100 h-100"
|
||||
style={{ backdropFilter: 'blur(80px)' }}
|
||||
>
|
||||
<p className="f9 absolute" style={{ left: 8, top: 8 }}>
|
||||
@ -269,7 +267,7 @@ export default class WeatherTile extends React.Component {
|
||||
if (this.props.location) {
|
||||
return this.renderWrapper((
|
||||
<div
|
||||
className={'pa2 w-100 h-100 b--black b--gray1-d ba ' +
|
||||
className={'pa2 w-100 h-100 ' +
|
||||
'bg-white bg-gray0-d black white-d'}>
|
||||
<p className="f9 absolute"
|
||||
style={{ left: 8, top: 8 }}
|
||||
|
@ -27,6 +27,26 @@ textarea, select, input, button { outline: none; }
|
||||
mix-blend-mode: difference;
|
||||
}
|
||||
|
||||
|
||||
button {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
|
||||
/* stolen from indigo-react reset.css
|
||||
* TODO: remove and add reset.css properly
|
||||
*/
|
||||
|
||||
@keyframes loadingSpinnerRotation {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* dark */
|
||||
@media all and (prefers-color-scheme: dark) {
|
||||
body {
|
||||
@ -53,4 +73,4 @@ textarea, select, input, button { outline: none; }
|
||||
.hover-bg-gray1-d:hover {
|
||||
background-color: #4d4d4d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
146
pkg/interface/src/views/apps/links/LinkResource.tsx
Normal file
146
pkg/interface/src/views/apps/links/LinkResource.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { Box, Row, Col, Center } from "@tlon/indigo-react";
|
||||
import { Switch, Route, Link } from "react-router-dom";
|
||||
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { StoreState } from "~/logic/store/type";
|
||||
import { uxToHex } from '~/logic/lib/util';
|
||||
import { Association, GraphNode } from "~/types";
|
||||
import { RouteComponentProps } from "react-router-dom";
|
||||
import { LinkList } from "./components/link-list";
|
||||
import { LinkDetail } from "./components/link-detail";
|
||||
|
||||
import { LinkItem } from "./components/lib/link-item";
|
||||
import { LinkSubmit } from "./components/lib/link-submit";
|
||||
import { LinkPreview } from "./components/lib/link-preview";
|
||||
import { CommentSubmit } from "./components/lib/comment-submit";
|
||||
import { Comments } from "./components/lib/comments";
|
||||
import "./css/custom.css";
|
||||
|
||||
type LinkResourceProps = StoreState & {
|
||||
association: Association;
|
||||
api: GlobalApi;
|
||||
baseUrl: string;
|
||||
} & RouteComponentProps;
|
||||
|
||||
export function LinkResource(props: LinkResourceProps) {
|
||||
const {
|
||||
association,
|
||||
api,
|
||||
baseUrl,
|
||||
graphs,
|
||||
contacts,
|
||||
groups,
|
||||
associations,
|
||||
graphKeys,
|
||||
s3,
|
||||
hideAvatars,
|
||||
hideNicknames,
|
||||
remoteContentPolicy,
|
||||
} = props;
|
||||
|
||||
const appPath = association["app-path"];
|
||||
|
||||
const relativePath = (p: string) => `${baseUrl}/resource/link${appPath}${p}`;
|
||||
|
||||
const [, , ship, name] = appPath.split("/");
|
||||
const resourcePath = `${ship.slice(1)}/${name}`;
|
||||
const resource = associations.graph[appPath]
|
||||
? associations.graph[appPath]
|
||||
: { metadata: {} };
|
||||
const contactDetails = contacts[resource["group-path"]] || {};
|
||||
const graph = graphs[resourcePath] || null;
|
||||
|
||||
useEffect(() => {
|
||||
api.graph.getGraph(ship, name);
|
||||
}, [association]);
|
||||
|
||||
const resourceUrl = `${baseUrl}/resource/link${appPath}`;
|
||||
if (!graph) {
|
||||
return <Center>Loading...</Center>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Col alignItems="center" height="100%" width="100%" overflowY="auto">
|
||||
<Switch>
|
||||
<Route
|
||||
exact
|
||||
path={relativePath("")}
|
||||
render={(props) => {
|
||||
return (
|
||||
<Col width="100%" p={4} alignItems="center" maxWidth="768px">
|
||||
<Row width="100%" flexShrink='0'>
|
||||
<LinkSubmit s3={s3} name={name} ship={ship.slice(1)} api={api} />
|
||||
</Row>
|
||||
{Array.from(graph.values()).map((node: GraphNode) => {
|
||||
const contact = contactDetails[node.post.author];
|
||||
return (
|
||||
<LinkItem
|
||||
resource={resourcePath}
|
||||
node={node}
|
||||
nickname={contact?.nickname}
|
||||
hideAvatars={hideAvatars}
|
||||
hideNicknames={hideNicknames}
|
||||
baseUrl={resourceUrl}
|
||||
color={uxToHex(contact?.color || '0x0')}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Col>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
path={relativePath("/:index")}
|
||||
render={(props) => {
|
||||
const indexArr = props.match.params.index.split("-");
|
||||
|
||||
if (indexArr.length <= 1) {
|
||||
return <div>Malformed URL</div>;
|
||||
}
|
||||
|
||||
const index = parseInt(indexArr[1], 10);
|
||||
const node = !!graph ? graph.get(index) : null;
|
||||
|
||||
if (!node) {
|
||||
return <Box>Not found</Box>;
|
||||
}
|
||||
|
||||
const contact = contactDetails[node.post.author];
|
||||
|
||||
return (
|
||||
<Col width="100%" p={3} maxWidth="640px">
|
||||
<Link to={resourceUrl}>{"<- Back"}</Link>
|
||||
<LinkPreview
|
||||
resourcePath={resourcePath}
|
||||
post={node.post}
|
||||
nickname={contact?.nickname}
|
||||
hideNicknames={hideNicknames}
|
||||
commentNumber={node.children.size}
|
||||
remoteContentPolicy={remoteContentPolicy}
|
||||
/>
|
||||
<Row flexShrink='0'>
|
||||
<CommentSubmit
|
||||
name={name}
|
||||
ship={ship}
|
||||
api={api}
|
||||
parentIndex={node.post.index}
|
||||
/>
|
||||
</Row>
|
||||
<Comments
|
||||
comments={node.children}
|
||||
resourcePath={resourcePath}
|
||||
contacts={contactDetails}
|
||||
api={api}
|
||||
hideAvatars={hideAvatars}
|
||||
hideNicknames={hideNicknames}
|
||||
remoteContentPolicy={remoteContentPolicy}
|
||||
/>
|
||||
</Col>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Switch>
|
||||
</Col>
|
||||
);
|
||||
}
|
@ -91,11 +91,10 @@ export default class LinksApp extends Component {
|
||||
</Skeleton>
|
||||
)}
|
||||
/>
|
||||
<Route exact path="/~link/(popout)?/:ship/:name/settings"
|
||||
<Route exact path="/~link/:ship/:name/settings"
|
||||
render={ (props) => {
|
||||
const resourcePath =
|
||||
const resourcePath =
|
||||
`${props.match.params.ship}/${props.match.params.name}`;
|
||||
const popout = props.match.url.includes('/popout/');
|
||||
const metPath = `/ship/~${resourcePath}`;
|
||||
const resource =
|
||||
associations.graph[metPath] ?
|
||||
@ -113,7 +112,6 @@ export default class LinksApp extends Component {
|
||||
groups={groups}
|
||||
selected={resourcePath}
|
||||
sidebarShown={sidebarShown}
|
||||
popout={popout}
|
||||
graphKeys={graphKeys}
|
||||
api={api}>
|
||||
<SettingsScreen
|
||||
@ -126,16 +124,15 @@ export default class LinksApp extends Component {
|
||||
group={group}
|
||||
amOwner={amOwner}
|
||||
resourcePath={resourcePath}
|
||||
popout={popout}
|
||||
api={api}
|
||||
{...props} />
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route exact path="/~link/(popout)?/:ship/:name"
|
||||
<Route exact path="/~link/:ship/:name"
|
||||
render={ (props) => {
|
||||
const resourcePath =
|
||||
const resourcePath =
|
||||
`${props.match.params.ship}/${props.match.params.name}`;
|
||||
const metPath = `/ship/~${resourcePath}`;
|
||||
const resource =
|
||||
@ -143,7 +140,6 @@ export default class LinksApp extends Component {
|
||||
associations.graph[metPath] : { metadata: {} };
|
||||
|
||||
const contactDetails = contacts[resource['group-path']] || {};
|
||||
const popout = props.match.url.includes('/popout/');
|
||||
const graph = graphs[resourcePath] || null;
|
||||
|
||||
return (
|
||||
@ -154,7 +150,6 @@ export default class LinksApp extends Component {
|
||||
selected={resourcePath}
|
||||
sidebarShown={sidebarShown}
|
||||
sidebarHideMobile={true}
|
||||
popout={popout}
|
||||
api={api}
|
||||
graphKeys={graphKeys}>
|
||||
<LinkList
|
||||
@ -164,7 +159,6 @@ export default class LinksApp extends Component {
|
||||
graph={graph}
|
||||
graphResource={graphKeys.has(resourcePath)}
|
||||
resourcePath={resourcePath}
|
||||
popout={popout}
|
||||
metadata={resource.metadata}
|
||||
contacts={contactDetails}
|
||||
hideAvatars={hideAvatars}
|
||||
@ -177,15 +171,14 @@ export default class LinksApp extends Component {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route exact path="/~link/(popout)?/:ship/:name/:index"
|
||||
<Route exact path="/~link/:ship/:name/:index"
|
||||
render={ (props) => {
|
||||
const resourcePath =
|
||||
const resourcePath =
|
||||
`${props.match.params.ship}/${props.match.params.name}`;
|
||||
const metPath = `/ship/~${resourcePath}`;
|
||||
const resource =
|
||||
associations.graph[metPath] ?
|
||||
associations.graph[metPath] : { metadata: {} };
|
||||
const popout = props.match.url.includes('/popout/');
|
||||
|
||||
const contactDetails = contacts[resource['group-path']] || {};
|
||||
|
||||
@ -197,7 +190,7 @@ export default class LinksApp extends Component {
|
||||
}
|
||||
|
||||
const index = parseInt(indexArr[1], 10);
|
||||
const node = !!graph ? graph.get(index) : null;
|
||||
const node = Boolean(graph) ? graph.get(index) : null;
|
||||
|
||||
return (
|
||||
<Skeleton
|
||||
@ -207,7 +200,6 @@ export default class LinksApp extends Component {
|
||||
selected={resourcePath}
|
||||
sidebarShown={sidebarShown}
|
||||
sidebarHideMobile={true}
|
||||
popout={popout}
|
||||
graphKeys={graphKeys}
|
||||
api={api}>
|
||||
<LinkDetail
|
||||
@ -218,7 +210,6 @@ export default class LinksApp extends Component {
|
||||
name={props.match.params.name}
|
||||
resource={resource}
|
||||
contacts={contactDetails}
|
||||
popout={popout}
|
||||
sidebarShown={sidebarShown}
|
||||
api={api}
|
||||
hideAvatars={hideAvatars}
|
||||
|
@ -2,7 +2,7 @@ import React, { Component } from 'react';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
import { GroupItem } from './group-item';
|
||||
import SidebarInvite from '~/views/components/SidebarInvite';
|
||||
import SidebarInvite from '~/views/components/Sidebar/SidebarInvite';
|
||||
import { Welcome } from './welcome';
|
||||
import { alphabetiseAssociations } from '~/logic/lib/util';
|
||||
|
||||
@ -83,14 +83,13 @@ export const ChannelSidebar = (props) => {
|
||||
}
|
||||
|
||||
const activeClasses = (props.active === 'collections') ? ' ' : 'dn-s ';
|
||||
const hiddenClasses = !!props.popout ? false : props.sidebarShown;
|
||||
|
||||
return (
|
||||
<div className={
|
||||
`bn br-m br-l br-xl b--gray4 b--gray1-d lh-copy h-100` +
|
||||
`flex-shrink-0 mw5-m mw5-l mw5-xl pt3 pt0-m pt0-l pt0-xl relative ` +
|
||||
activeClasses +
|
||||
((hiddenClasses) ? 'flex-basis-100-s flex-basis-30-ns' : 'dn')
|
||||
((props.sidebarShown) ? 'flex-basis-100-s flex-basis-30-ns' : 'dn')
|
||||
}>
|
||||
<div className="overflow-y-scroll h-100">
|
||||
<div className="w-100 bg-transparent">
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import React from 'react';
|
||||
import { Row, Col, Anchor, Box, Text } from '@tlon/indigo-react';
|
||||
|
||||
import { Sigil } from '~/logic/lib/sigil';
|
||||
import { Link } from 'react-router-dom';
|
||||
@ -28,36 +29,38 @@ export const LinkItem = (props) => {
|
||||
const showAvatar = avatar && !hideAvatars;
|
||||
const showNickname = nickname && !hideNicknames;
|
||||
|
||||
const mono = showNickname ? 'inter white-d' : 'mono white-d';
|
||||
|
||||
const img = showAvatar
|
||||
? <img src={avatar} height={38} width={38} className="dib" />
|
||||
: <Sigil ship={`~${author}`} size={38} color={'#' + color} />;
|
||||
? <img src={props.avatar} height={36} width={36} className="dib" />
|
||||
: <Sigil ship={`~${author}`} size={36} color={'#' + props.color} />;
|
||||
|
||||
const baseUrl = props.baseUrl || `/~link/${resource}`;
|
||||
|
||||
return (
|
||||
<div className='w-100 pv3 flex bg-white bg-gray0-d lh-solid'>
|
||||
<Row minWidth='0' flexShrink='0' width="100%" alignItems="center" py={3} bg="white">
|
||||
{img}
|
||||
<div className='flex flex-column ml2 flex-auto'>
|
||||
<a
|
||||
<Col minWidth='0' height="100%" width='100%' justifyContent="space-between" ml={2}>
|
||||
<Anchor
|
||||
lineHeight="tall"
|
||||
display='flex'
|
||||
style={{ textDecoration: 'none' }}
|
||||
href={contents[1].url}
|
||||
className='w-100 flex'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'>
|
||||
<p className='f8 truncate'>{contents[0].text}</p>
|
||||
<span className='gray2 ml2 f8 dib v-btm flex-shrink-0'>
|
||||
{hostname} ↗
|
||||
</span>
|
||||
</a>
|
||||
<div className='w-100'>
|
||||
<span className={'f9 pr2 white-d dib ' + mono} title={author}>
|
||||
{showNickname ? props.nickname : cite(author)}
|
||||
</span>
|
||||
<Link to={`/~link/${resource}/${index}`}>
|
||||
<span className='f9 inter gray2 dib'>{size} comments</span>
|
||||
width="100%"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
<Text display='inline-block' overflow='hidden' style={{ textOverflow: 'ellipsis', whiteSpace: 'pre'}}> {contents[0].text}</Text>
|
||||
<Text ml="2" color="gray" display='inline-block' flexShrink='0'>{hostname} ↗</Text>
|
||||
</Anchor>
|
||||
<Box width="100%">
|
||||
<Text
|
||||
fontFamily={showNickname ? 'sans' : 'mono'} pr={2}>
|
||||
{showNickname ? nickname : cite(author) }
|
||||
</Text>
|
||||
<Link to={`${baseUrl}/${index}`}>
|
||||
<Text color="gray">{size} comments</Text>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -18,7 +18,7 @@ export const LinkPreview = (props) => {
|
||||
const url = props.post.contents[1].url;
|
||||
const hostname = URLparser.exec(url) ? URLparser.exec(url)[4] : null;
|
||||
|
||||
const timeSent =
|
||||
const timeSent =
|
||||
moment.unix(props.post['time-sent'] / 1000).format('hh:mm a');
|
||||
|
||||
const embed = (
|
||||
@ -40,7 +40,7 @@ export const LinkPreview = (props) => {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
<p className="f8 truncate">{title}</p>
|
||||
<span className="gray2 ml2 f8 dib v-btm flex-shrink-0">
|
||||
<span className="gray2 ml2 f8 dib v-btm flex-shrink-0" style={{ whiteSpace: 'nowrap' }}>
|
||||
{hostname} ↗
|
||||
</span>
|
||||
</a>
|
||||
|
@ -191,7 +191,7 @@ export class LinkSubmit extends Component<LinkSubmitProps, LinkSubmitState> {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative ba br1 w-100 mb6 ${focus}`}
|
||||
className={`flex-shrink-0 relative ba br1 w-100 mb6 ${focus}`}
|
||||
onDragEnter={this.onDragEnter.bind(this)}
|
||||
onDragOver={e => {
|
||||
e.preventDefault();
|
||||
|
@ -20,12 +20,12 @@ export class Pagination extends Component {
|
||||
return (
|
||||
<div className="w-100 inter relative pv6">
|
||||
<div className={prevDisplay + ' inter f8'}>
|
||||
<Link to={makeRoutePath(props.resourcePath, props.popout, prevPage)}>
|
||||
<Link to={makeRoutePath(props.resourcePath, prevPage)}>
|
||||
<- Previous Page
|
||||
</Link>
|
||||
</div>
|
||||
<div className={nextDisplay + ' inter f8'}>
|
||||
<Link to={makeRoutePath(props.resourcePath, props.popout, nextPage)}>
|
||||
<Link to={makeRoutePath(props.resourcePath, nextPage)}>
|
||||
Next Page ->
|
||||
</Link>
|
||||
</div>
|
||||
|
@ -48,7 +48,6 @@ export const LinkDetail = (props) => {
|
||||
>
|
||||
<SidebarSwitcher
|
||||
sidebarShown={props.sidebarShown}
|
||||
popout={props.popout}
|
||||
api={props.api}
|
||||
/>
|
||||
<Link className="dib f9 fw4 pt2 gray2 lh-solid"
|
||||
@ -63,8 +62,6 @@ export const LinkDetail = (props) => {
|
||||
</Link>
|
||||
<TabBar
|
||||
location={props.location}
|
||||
popout={props.popout}
|
||||
popoutHref={`/~link/popout/${resourcePath}/${props.match.params.index}`}
|
||||
settings={`/~link/${resourcePath}/settings`}
|
||||
/>
|
||||
</Box>
|
||||
@ -90,7 +87,6 @@ export const LinkDetail = (props) => {
|
||||
comments={props.node.children}
|
||||
resourcePath={resourcePath}
|
||||
contacts={props.contacts}
|
||||
popout={props.popout}
|
||||
api={props.api}
|
||||
hideAvatars={props.hideAvatars}
|
||||
hideNicknames={props.hideNicknames}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { Component, useEffect } from 'react';
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import { TabBar } from '~/views/components/chat-link-tabbar';
|
||||
import { SidebarSwitcher } from '~/views/components/SidebarSwitch';
|
||||
@ -7,37 +7,33 @@ import { LinkItem } from './lib/link-item';
|
||||
import LinkSubmit from './lib/link-submit';
|
||||
import { Box } from '@tlon/indigo-react';
|
||||
|
||||
import { getContactDetails } from '~/logic/lib/util';
|
||||
import { getContactDetails } from "~/logic/lib/util";
|
||||
|
||||
export const LinkList = (props) => {
|
||||
const resource = `${props.ship}/${props.name}`;
|
||||
const title = props.metadata.title || resource;
|
||||
useEffect(() => {
|
||||
props.api.graph.getGraph(
|
||||
`~${props.match.params.ship}`,
|
||||
props.match.params.name
|
||||
);
|
||||
}, [props.match.params.ship, props.match.params.name]);
|
||||
|
||||
if (!props.graph && props.graphResource) {
|
||||
useEffect(() => {
|
||||
props.api.graph.getGraph(
|
||||
`~${props.match.params.ship}`,
|
||||
props.match.params.name
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div>Loading...</div>
|
||||
);
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (!props.graph) {
|
||||
return (
|
||||
<div>Not found</div>
|
||||
);
|
||||
return <div>Not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-100 w-100 overflow-hidden flex flex-column">
|
||||
<div
|
||||
className="w-100 dn-m dn-l dn-xl inter pt4 pb6 pl3 f8"
|
||||
style={{ height: '1rem' }}>
|
||||
<Link to="/~link">{'⟵ All Channels'}</Link>
|
||||
style={{ height: "1rem" }}
|
||||
>
|
||||
<Link to="/~link">{"⟵ All Channels"}</Link>
|
||||
</div>
|
||||
<Box
|
||||
pl='12px'
|
||||
@ -51,7 +47,6 @@ export const LinkList = (props) => {
|
||||
height='48px'>
|
||||
<SidebarSwitcher
|
||||
sidebarShown={props.sidebarShown}
|
||||
popout={props.popout}
|
||||
api={props.api} />
|
||||
<h2
|
||||
className="dib f9 fw4 pt2 lh-solid v-top black white-d"
|
||||
@ -60,8 +55,6 @@ export const LinkList = (props) => {
|
||||
</h2>
|
||||
<TabBar
|
||||
location={props.location}
|
||||
popout={props.popout}
|
||||
popoutHref={`/~link/popout/${resource}`}
|
||||
settings={`/~link/${resource}/settings`}
|
||||
/>
|
||||
</Box>
|
||||
@ -95,5 +88,4 @@ export const LinkList = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
};
|
||||
|
@ -7,10 +7,10 @@ import { Spinner } from '~/views/components/Spinner';
|
||||
import { TabBar } from '~/views/components/chat-link-tabbar';
|
||||
import SidebarSwitcher from '~/views/components/SidebarSwitch';
|
||||
|
||||
import { Box } from '@tlon/indigo-react';
|
||||
|
||||
import { MetadataSettings } from '~/views/components/metadata/settings';
|
||||
|
||||
import { Box, Text, Button, Col, Row } from '@tlon/indigo-react';
|
||||
|
||||
export class SettingsScreen extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@ -79,16 +79,15 @@ export class SettingsScreen extends Component {
|
||||
return null;
|
||||
} else {
|
||||
return (
|
||||
<div className="w-100 fl mt3">
|
||||
<p className="f8 mt3 lh-copy db">Remove Collection</p>
|
||||
<p className="f9 gray2 db mb4">
|
||||
Remove this collection from your collection list.
|
||||
</p>
|
||||
<a onClick={this.removeCollection.bind(this)}
|
||||
className="dib f9 black gray4-d bg-gray0-d ba pa2 b--black b--gray1-d pointer">
|
||||
<Box width='100%' mt='3'>
|
||||
<Text display='block' mt='3' fontSize='1' mb='1'>Remove Collection</Text>
|
||||
<Text display='block' fontSize='0' gray mb='4'>
|
||||
Remove this collection from your collection list
|
||||
</Text>
|
||||
<Button onClick={this.removeCollection.bind(this)}>
|
||||
Remove collection
|
||||
</a>
|
||||
</div>
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -100,16 +99,15 @@ export class SettingsScreen extends Component {
|
||||
return null;
|
||||
} else {
|
||||
return (
|
||||
<div className="w-100 fl mt3">
|
||||
<p className="f8 mt3 lh-copy db">Delete Collection</p>
|
||||
<p className="f9 gray2 db mb4">
|
||||
Delete this collection, for you and all group members.
|
||||
</p>
|
||||
<a onClick={this.deleteCollection.bind(this)}
|
||||
className="dib f9 ba pa2 b--red2 red2 pointer bg-gray0-d mb4">
|
||||
<Box width='100%' mt='3'>
|
||||
<Text fontSize='1' mt='3' display='block' mb='1'>Delete collection</Text>
|
||||
<Text fontSize='0' gray display='block' mb='4'>
|
||||
Delete this collection, for you and all group members
|
||||
</Text>
|
||||
<Button primary onClick={this.deleteCollection.bind(this)} destructive mb='4'>
|
||||
Delete collection
|
||||
</a>
|
||||
</div>
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -125,47 +123,44 @@ export class SettingsScreen extends Component {
|
||||
return <LoadingScreen />;
|
||||
} else if (!props.graphResource) {
|
||||
props.history.push('/~link');
|
||||
return <div></div>;
|
||||
return <Box />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-100 w-100 overflow-x-hidden flex flex-column white-d">
|
||||
<div className="w-100 dn-m dn-l dn-xl inter pt4 pb6 pl3 f8"
|
||||
style={{ height: '1rem' }}>
|
||||
<Col height='100%' width='100' overflowX='hidden'>
|
||||
<Box width='100%' display={['block', 'none']} pt='4' pb='6' pl='3' fontSize='1' height='1rem'>
|
||||
<Link to="/~link">{'⟵ All Collections'}</Link>
|
||||
</div>
|
||||
<Box
|
||||
</Box>
|
||||
<Row
|
||||
pl='12px'
|
||||
pt='2'
|
||||
display='flex'
|
||||
position='relative'
|
||||
overflowX={['scroll', 'auto']}
|
||||
flexShrink='0'
|
||||
borderBottom='1px solid'
|
||||
borderColor='washedGray'
|
||||
height='48px'>
|
||||
flexShrink='0'
|
||||
overflowX={['scroll', 'auto']}
|
||||
height='48px'
|
||||
>
|
||||
<SidebarSwitcher
|
||||
sidebarShown={this.props.sidebarShown}
|
||||
popout={this.props.popout}
|
||||
api={this.props.api}
|
||||
/>
|
||||
<Link className="dib f9 fw4 pt2 gray2 lh-solid"
|
||||
to={`/~link/${props.resourcePath}`}>
|
||||
<h2
|
||||
className="dib f9 fw4 lh-solid v-top"
|
||||
style={{ width: 'max-content' }}>
|
||||
<Text
|
||||
display='inline-block'
|
||||
fontSize='0'
|
||||
verticalAlign='top'
|
||||
width='max-content'>
|
||||
{title}
|
||||
</h2>
|
||||
</Text>
|
||||
</Link>
|
||||
<TabBar
|
||||
location={props.location}
|
||||
popout={props.popout}
|
||||
popoutHref={`/~link/popout/${props.resourcePath}/settings`}
|
||||
settings={`/~link/${props.resourcePath}/settings`}
|
||||
/>
|
||||
</Box>
|
||||
<div className="w-100 pl3 mt4 cf">
|
||||
<h2 className="f8 pb2">Collection Settings</h2>
|
||||
</Row>
|
||||
<Box width='100' pl='3' mt='3'>
|
||||
<Text display='block' fontSize='1' pb='2'>Collection Settings</Text>
|
||||
{this.renderRemove()}
|
||||
{this.renderDelete()}
|
||||
<MetadataSettings
|
||||
@ -182,8 +177,8 @@ export class SettingsScreen extends Component {
|
||||
classes="absolute right-1 bottom-1 pa2 ba b--black b--gray0-d white-d"
|
||||
text={this.state.type}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -6,24 +6,16 @@ export class Skeleton extends Component {
|
||||
render() {
|
||||
const { props } = this;
|
||||
const rightPanelHide = props.rightPanelHide ? 'dn-s' : '';
|
||||
const popout = props.popout ? props.popout : false;
|
||||
|
||||
const popoutWindow = (popout)
|
||||
? '' : 'ph4-m ph4-l ph4-xl pb4-m pb4-l pb4-xl';
|
||||
|
||||
const popoutBorder = (popout)
|
||||
? '' : 'ba-m ba-l ba-xl b--gray4 b--gray1-d br1';
|
||||
|
||||
const linkInvites = ('/link' in props.invites)
|
||||
? props.invites['/link'] : {};
|
||||
|
||||
return (
|
||||
<div className={'absolute w-100 ' + popoutWindow}
|
||||
<div className='absolute w-100 ph4-m ph4-l ph4-xl pb4-m pb4-l pb4-xl'
|
||||
style={{ height: 'calc(100% - 45px)' }}>
|
||||
<div className={'bg-white bg-gray0-d cf w-100 h-100 flex ' + popoutBorder}>
|
||||
<div className='bg-white bg-gray0-d cf w-100 h-100 flex ba-m ba-l ba-xl b--gray4 b--gray1-d br1'>
|
||||
<ChannelSidebar
|
||||
active={props.active}
|
||||
popout={popout}
|
||||
associations={props.associations}
|
||||
invites={linkInvites}
|
||||
groups={props.groups}
|
||||
|
44
pkg/interface/src/views/apps/publish/PublishResource.tsx
Normal file
44
pkg/interface/src/views/apps/publish/PublishResource.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import React from "react";
|
||||
import { Box } from '@tlon/indigo-react';
|
||||
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { StoreState } from "~/logic/store/type";
|
||||
import { Association } from "~/types";
|
||||
import { RouteComponentProps } from "react-router-dom";
|
||||
import { NotebookRoutes } from "./components/lib/NotebookRoutes";
|
||||
|
||||
type PublishResourceProps = StoreState & {
|
||||
association: Association;
|
||||
api: GlobalApi;
|
||||
baseUrl: string;
|
||||
} & RouteComponentProps;
|
||||
|
||||
export function PublishResource(props: PublishResourceProps) {
|
||||
const { association, api, baseUrl, notebooks } = props;
|
||||
const appPath = association["app-path"];
|
||||
const [, ship, book] = appPath.split("/");
|
||||
const notebook = notebooks[ship]?.[book];
|
||||
const notebookContacts = props.contacts[association["group-path"]];
|
||||
|
||||
return (
|
||||
<Box height="100%" width="100%" overflowY="auto">
|
||||
<NotebookRoutes
|
||||
api={api}
|
||||
ship={ship}
|
||||
book={book}
|
||||
contacts={props.contacts}
|
||||
groups={props.groups}
|
||||
notebook={notebook}
|
||||
notebookContacts={notebookContacts}
|
||||
rootUrl={baseUrl}
|
||||
baseUrl={`${baseUrl}/resource/publish/${ship}/${book}`}
|
||||
history={props.history}
|
||||
match={props.match}
|
||||
location={props.location}
|
||||
hideAvatars={props.hideAvatars}
|
||||
hideNicknames={props.hideNicknames}
|
||||
remoteContentPolicy={props.remoteContentPolicy}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -84,7 +84,6 @@ export default function PublishApp(props: PublishAppProps) {
|
||||
]}
|
||||
>
|
||||
<RouterSkeleton
|
||||
popout={location.pathname.includes("popout/")}
|
||||
active={active}
|
||||
sidebarShown={sidebarShown}
|
||||
invites={invites}
|
||||
|
@ -16,7 +16,7 @@ interface AuthorProps {
|
||||
}
|
||||
|
||||
export function Author(props: AuthorProps) {
|
||||
const { contacts, ship, date, showImage } = props;
|
||||
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";
|
||||
|
@ -25,6 +25,7 @@ interface NoteProps {
|
||||
api: GlobalApi;
|
||||
hideAvatars: boolean;
|
||||
hideNicknames: boolean;
|
||||
rootUrl?: string;
|
||||
remoteContentPolicy: LocalUpdateRemoteContentPolicy;
|
||||
}
|
||||
|
||||
@ -35,12 +36,12 @@ export function Note(props: NoteProps & RouteComponentProps) {
|
||||
api.publish.fetchNote(ship, book, noteId);
|
||||
}, [ship, book, noteId]);
|
||||
|
||||
const baseUrl = `/~publish/notebook/${props.ship}/${props.book}`;
|
||||
const rootUrl = props.rootUrl || `/~publish/notebook/${props.ship}/${props.book}`;
|
||||
|
||||
const deletePost = async () => {
|
||||
setDeleting(true);
|
||||
await api.publish.delNote(ship.slice(1), book, noteId);
|
||||
props.history.push(baseUrl);
|
||||
props.history.push(rootUrl);
|
||||
};
|
||||
|
||||
const comments = note?.comments || [];
|
||||
@ -71,6 +72,7 @@ export function Note(props: NoteProps & RouteComponentProps) {
|
||||
return (
|
||||
<Box
|
||||
my={3}
|
||||
px={3}
|
||||
display="grid"
|
||||
gridTemplateColumns="1fr"
|
||||
gridAutoRows="min-content"
|
||||
@ -79,7 +81,7 @@ export function Note(props: NoteProps & RouteComponentProps) {
|
||||
gridRowGap={4}
|
||||
mx="auto"
|
||||
>
|
||||
<Link to={baseUrl}>
|
||||
<Link to={rootUrl}>
|
||||
<Text>{"<- Notebook Index"}</Text>
|
||||
</Link>
|
||||
<Col>
|
||||
@ -105,7 +107,7 @@ export function Note(props: NoteProps & RouteComponentProps) {
|
||||
ship={props.ship}
|
||||
book={props.book}
|
||||
/>
|
||||
{notebook.comments && (
|
||||
{notebook?.comments && (
|
||||
<Comments
|
||||
ship={ship}
|
||||
book={props.book}
|
||||
|
@ -54,7 +54,7 @@ export function NoteNavigation(props: NoteNavigationProps) {
|
||||
}
|
||||
|
||||
if (next) {
|
||||
nextUrl = `/~publish/notebook/${props.ship}/${props.book}/note/${props.nextId}`;
|
||||
nextUrl = `${props.prevId}`;
|
||||
nextComponent = (
|
||||
<NavigationItem
|
||||
title={next.title}
|
||||
@ -64,7 +64,7 @@ export function NoteNavigation(props: NoteNavigationProps) {
|
||||
);
|
||||
}
|
||||
if (prev) {
|
||||
prevUrl = `/~publish/notebook/${props.ship}/${props.book}/note/${props.prevId}`;
|
||||
prevUrl = `${props.prevId}`;
|
||||
prevComponent = (
|
||||
<NavigationItem
|
||||
title={prev.title}
|
||||
|
@ -37,8 +37,7 @@ export function NotePreview(props: NotePreviewProps) {
|
||||
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"]}`;
|
||||
const url = `${props.book}/note/${note["note-id"]}`;
|
||||
|
||||
return (
|
||||
<Link to={url}>
|
||||
|
@ -16,12 +16,16 @@ interface NoteRoutesProps {
|
||||
notebook: Notebook;
|
||||
contacts: Contacts;
|
||||
api: GlobalApi;
|
||||
hideNicknames: boolean;
|
||||
hideAvatars: boolean;
|
||||
baseUrl?: string;
|
||||
rootUrl?: string;
|
||||
}
|
||||
|
||||
export function NoteRoutes(props: NoteRoutesProps & RouteComponentProps) {
|
||||
const { ship, book, noteId } = props;
|
||||
|
||||
const baseUrl = `/~publish/notebook/${ship}/${book}/note/${noteId}`;
|
||||
const baseUrl = props.baseUrl || `/~publish/notebook/${ship}/${book}/note/${noteId}`;
|
||||
|
||||
const relativePath = (path: string) => `${baseUrl}${path}`;
|
||||
return (
|
||||
|
@ -24,6 +24,8 @@ interface NotebookProps {
|
||||
contacts: Rolodex;
|
||||
groups: Groups;
|
||||
hideNicknames: boolean;
|
||||
baseUrl: string;
|
||||
rootUrl: string;
|
||||
associations: Associations;
|
||||
}
|
||||
|
||||
@ -66,6 +68,8 @@ export class Notebook extends PureComponent<
|
||||
const group = groups[notebook?.["writers-group-path"]];
|
||||
if (!group) return null; // Waitin on groups to populate
|
||||
|
||||
const relativePath = (p: string) => this.props.baseUrl + p;
|
||||
|
||||
const contact = notebookContacts[ship];
|
||||
const role = group ? roleForShip(group, window.ship) : undefined;
|
||||
const isOwn = `~${window.ship}` === ship;
|
||||
@ -82,6 +86,7 @@ export class Notebook extends PureComponent<
|
||||
<Box
|
||||
pt={4}
|
||||
mx="auto"
|
||||
px={3}
|
||||
display="grid"
|
||||
gridAutoRows="min-content"
|
||||
gridTemplateColumns={["100%", "1fr 1fr"]}
|
||||
@ -90,7 +95,7 @@ export class Notebook extends PureComponent<
|
||||
gridColumnGap={3}
|
||||
>
|
||||
<Box display={["block", "none"]} gridColumn={["1/2", "1/3"]}>
|
||||
<Link to="/~publish">{"<- All Notebooks"}</Link>
|
||||
<Link to={this.props.rootUrl}>{"<- All Notebooks"}</Link>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text> {notebook?.title}</Text>
|
||||
@ -102,8 +107,10 @@ export class Notebook extends PureComponent<
|
||||
</Box>
|
||||
<Row justifyContent={["flex-start", "flex-end"]}>
|
||||
{isWriter && (
|
||||
<Link to={`/~publish/notebook/${ship}/${book}/new`}>
|
||||
<Button primary>New Post</Button>
|
||||
<Link to={relativePath("/new")}>
|
||||
<Button primary border>
|
||||
New Post
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{!isOwn ? (
|
||||
@ -173,6 +180,7 @@ export class Notebook extends PureComponent<
|
||||
book={book}
|
||||
contacts={notebookContacts}
|
||||
hideNicknames={hideNicknames}
|
||||
|
||||
/>
|
||||
)}
|
||||
{state.tab === "about" && (
|
||||
|
@ -10,6 +10,7 @@ interface NotebookPostsProps {
|
||||
notes: Notes;
|
||||
host: string;
|
||||
book: string;
|
||||
baseUrl: string;
|
||||
hideNicknames?: boolean;
|
||||
}
|
||||
|
||||
@ -19,7 +20,6 @@ export function NotebookPosts(props: NotebookPostsProps) {
|
||||
{props.list.map((noteId: NoteId) => {
|
||||
const note = props.notes[noteId];
|
||||
if (!note) {
|
||||
console.log(noteId);
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
@ -30,6 +30,7 @@ export function NotebookPosts(props: NotebookPostsProps) {
|
||||
note={note}
|
||||
contact={props.contacts[note.author.substr(1)]}
|
||||
hideNicknames={props.hideNicknames}
|
||||
baseUrl={props.baseUrl}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
@ -15,11 +15,12 @@ interface NotebookRoutesProps {
|
||||
api: GlobalApi;
|
||||
ship: string;
|
||||
book: string;
|
||||
notes: any;
|
||||
notebook: INotebook;
|
||||
notebookContacts: Contacts;
|
||||
contacts: Rolodex;
|
||||
groups: Groups;
|
||||
baseUrl?: string;
|
||||
rootUrl?: string;
|
||||
hideAvatars: boolean;
|
||||
hideNicknames: boolean;
|
||||
remoteContentPolicy: LocalUpdateRemoteContentPolicy;
|
||||
@ -36,8 +37,8 @@ export function NotebookRoutes(
|
||||
api.publish.fetchNotebook(ship, book);
|
||||
}, [ship, book]);
|
||||
|
||||
|
||||
const baseUrl = `/~publish/notebook/${ship}/${book}`;
|
||||
const baseUrl = props.baseUrl || `/~publish/notebook/${ship}/${book}`;
|
||||
const rootUrl = props.rootUrl || '/~publish';
|
||||
|
||||
const relativePath = (path: string) => `${baseUrl}${path}`;
|
||||
return (
|
||||
@ -46,7 +47,7 @@ export function NotebookRoutes(
|
||||
path={baseUrl}
|
||||
exact
|
||||
render={(routeProps) => {
|
||||
return <Notebook {...props} />;
|
||||
return <Notebook {...props} rootUrl={rootUrl} baseUrl={baseUrl} />;
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
@ -58,6 +59,7 @@ export function NotebookRoutes(
|
||||
book={book}
|
||||
ship={ship}
|
||||
notebook={notebook}
|
||||
baseUrl={baseUrl}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@ -66,8 +68,11 @@ export function NotebookRoutes(
|
||||
render={(routeProps) => {
|
||||
const { noteId } = routeProps.match.params;
|
||||
const note = notebook?.notes[noteId];
|
||||
const noteUrl = relativePath(`/note/${noteId}`);
|
||||
return (
|
||||
<NoteRoutes
|
||||
rootUrl={baseUrl}
|
||||
baseUrl={noteUrl}
|
||||
api={api}
|
||||
book={book}
|
||||
ship={ship}
|
||||
|
@ -1,7 +1,7 @@
|
||||
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 SidebarInvite from "~/views/components/Sidebar/SidebarInvite";
|
||||
import { Welcome } from "./Welcome";
|
||||
import { GroupItem } from "./GroupItem";
|
||||
import { alphabetiseAssociations } from "~/logic/lib/util";
|
||||
|
@ -12,6 +12,7 @@ interface NewPostProps {
|
||||
book: string;
|
||||
ship: string;
|
||||
notebook: Notebook;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export default function NewPost(props: NewPostProps & RouteComponentProps) {
|
||||
@ -42,7 +43,7 @@ export default function NewPost(props: NewPostProps & RouteComponentProps) {
|
||||
await waiter((p) => {
|
||||
return !!p?.notebook?.notes[noteId];
|
||||
});
|
||||
history.push(`/~publish/notebook/${ship}/${book}/note/${noteId}`);
|
||||
history.push(`${props.baseUrl}/note/${noteId}`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
actions.setStatus({ error: "Posting note failed" });
|
||||
|
@ -34,7 +34,7 @@ export function AsyncButton({
|
||||
}, [status]);
|
||||
|
||||
return (
|
||||
<Button border disabled={!isValid} type="submit" {...rest}>
|
||||
<Button disabled={!isValid} type="submit" {...rest}>
|
||||
{isSubmitting ? (
|
||||
<LoadingSpinner
|
||||
foreground={rest.primary ? "white" : 'black'}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user