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:
Matilde Park 2020-10-05 20:56:28 -04:00
commit 7b236104b0
136 changed files with 4082 additions and 2952 deletions

View File

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

View File

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

View File

@ -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",

View File

@ -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",

View File

@ -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({

View File

@ -0,0 +1 @@

View File

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

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

View File

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

View 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]);
}

View File

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

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

View File

@ -100,5 +100,6 @@ export default class ChatReducer<S extends ChatState> {
mailbox.splice(index, 1);
}
}
state.pendingMessages.set(msg.path, mailbox);
}
}

View File

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

View File

@ -38,7 +38,7 @@ export interface StoreState {
permissions: Permissions;
s3: S3State;
graphs: Graphs;
graphKeys: Set<String>;
graphKeys: Set<string>;
// App specific states

View File

@ -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[] = [

View File

@ -17,3 +17,4 @@ export * from './permission-update';
export * from './publish-response';
export * from './publish-update';
export * from './s3-update';
export * from './workspace';

View File

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

View File

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

View File

@ -0,0 +1,12 @@
interface GroupWorkspace {
type: 'group';
group: string;
}
interface HomeWorkspace {
type: 'home'
}
export type Workspace = HomeWorkspace | GroupWorkspace;

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View 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 = [];
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)}>
&#60;- 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 -&gt;
</Link>
</div>

View File

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

View File

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

View File

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

View File

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

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

View File

@ -84,7 +84,6 @@ export default function PublishApp(props: PublishAppProps) {
]}
>
<RouterSkeleton
popout={location.pathname.includes("popout/")}
active={active}
sidebarShown={sidebarShown}
invites={invites}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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" && (

View File

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

View File

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

View File

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

View File

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

View File

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