Merge branch 'release/next-js' into release/next-userspace

This commit is contained in:
Matilde Park 2021-03-24 19:10:27 -04:00
commit 7fd4928d96
33 changed files with 478 additions and 286 deletions

View File

@ -32,7 +32,29 @@
name: build
on: [push, pull_request]
on:
push:
paths:
- 'pkg/arvo'
- 'pkg/docker-image'
- 'pkg/ent'
- 'pkg/ge-additions'
- 'pkg/hs'
- 'pkg/libaes_siv'
- 'pkg/urbit'
- 'bin'
- 'nix'
pull_request:
paths:
- 'pkg/arvo'
- 'pkg/docker-image'
- 'pkg/ent'
- 'pkg/ge-additions'
- 'pkg/hs'
- 'pkg/libaes_siv'
- 'pkg/urbit'
- 'bin'
- 'nix'
jobs:
urbit:

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:384f4a66399e32fac3698465d69ddfea1e80f8245f6a5f5cc9e24ff437cc3f61
size 9556792
oid sha256:b99c4ea8e73d810e4e8b515a662cdd36933affbed47c461fe4258dcdb3b656b1
size 9601365

View File

@ -5,7 +5,7 @@
/- glob
/+ default-agent, verb, dbug
|%
++ hash 0vtbs05.a6mkl.r5ark.jfj84.3fa4h
++ hash 0v3.nmpms.bgjpu.9gd2j.klgii.qgeal
+$ state-0 [%0 hash=@uv glob=(unit (each glob:glob tid=@ta))]
+$ all-states
$% state-0

View File

@ -348,7 +348,7 @@
~(tap by unreads-count)
|= [=stats-index:store count=@ud]
:* stats-index
~(wyt in (~(gut by by-index) stats-index ~))
(~(gut by by-index) stats-index ~)
[%count count]
(~(gut by last-seen) stats-index *time)
==
@ -359,7 +359,7 @@
~(tap by unreads-each)
|= [=stats-index:store indices=(set index:graph-store)]
:* stats-index
~(wyt in (~(gut by by-index) stats-index ~))
(~(gut by by-index) stats-index ~)
[%each indices]
(~(gut by last-seen) stats-index *time)
==
@ -372,7 +372,7 @@
~
:- ~
:* stats-index
~(wyt in nots)
nots
[%count 0]
*time
==

View File

@ -24,6 +24,6 @@
<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.6bb292037c57ac3279cf.js"></script>
<script src="/~landscape/js/bundle/index.1010a94eb2978c30be9f.js"></script>
</body>
</html>

View File

@ -151,7 +151,7 @@
^- json
%- pairs
:~ unreads+(unread unreads.s)
notifications+(numb notifications.s)
notifications+a+(turn ~(tap in notifications.s) notif-ref)
last+(time last-seen.s)
==
++ added

View File

@ -182,7 +182,7 @@
[index notification]
::
+$ stats
[notifications=@ud =unreads last-seen=@da]
[notifications=(set [time index]) =unreads last-seen=@da]
::
+$ unreads
$% [%count num=@ud]

View File

@ -3988,6 +3988,14 @@
"prr": "~1.0.1"
}
},
"error-stack-parser": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.0.6.tgz",
"integrity": "sha512-d51brTeqC+BHlwF0BhPtcYgF5nlzf9ZZ0ZIUQNZpc9ZB9qw5IJ2diTrBY9jlCJkTLITYPjmiX6OWCwH+fuyNgQ==",
"requires": {
"stackframe": "^1.1.1"
}
},
"es-abstract": {
"version": "1.18.0-next.2",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.2.tgz",
@ -8757,6 +8765,45 @@
"figgy-pudding": "^3.5.1"
}
},
"stack-generator": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/stack-generator/-/stack-generator-2.0.5.tgz",
"integrity": "sha512-/t1ebrbHkrLrDuNMdeAcsvynWgoH/i4o8EGGfX7dEYDoTXOYVAkEpFdtshlvabzc6JlJ8Kf9YdFEoz7JkzGN9Q==",
"requires": {
"stackframe": "^1.1.1"
}
},
"stackframe": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.2.0.tgz",
"integrity": "sha512-GrdeshiRmS1YLMYgzF16olf2jJ/IzxXY9lhKOskuVziubpTYcYqyOwYeJKzQkwy7uN0fYSsbsC4RQaXf9LCrYA=="
},
"stacktrace-gps": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/stacktrace-gps/-/stacktrace-gps-3.0.4.tgz",
"integrity": "sha512-qIr8x41yZVSldqdqe6jciXEaSCKw1U8XTXpjDuy0ki/apyTn/r3w9hDAAQOhZdxvsC93H+WwwEu5cq5VemzYeg==",
"requires": {
"source-map": "0.5.6",
"stackframe": "^1.1.1"
},
"dependencies": {
"source-map": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz",
"integrity": "sha1-dc449SvwczxafwwRjYEzSiu19BI="
}
}
},
"stacktrace-js": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/stacktrace-js/-/stacktrace-js-2.0.2.tgz",
"integrity": "sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==",
"requires": {
"error-stack-parser": "^2.0.6",
"stack-generator": "^2.0.5",
"stacktrace-gps": "^3.0.4"
}
},
"state-toggle": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/state-toggle/-/state-toggle-1.0.3.tgz",

View File

@ -42,6 +42,7 @@
"react-visibility-sensor": "^5.1.1",
"remark-breaks": "^2.0.1",
"remark-disable-tokenizers": "^1.0.24",
"stacktrace-js": "^2.0.2",
"style-loader": "^1.3.0",
"styled-components": "^5.1.1",
"styled-system": "^5.1.5",

View File

@ -76,7 +76,8 @@ class GcpManager {
if (this.isConfigured()) {
this.refreshLoop();
} else {
this.refreshAfter(10_000);
console.log('GcpManager: GCP storage not configured; stopping.');
this.stop();
}
})
.catch((reason) => {

View File

@ -8,11 +8,11 @@ import {
} from '@urbit/api';
import { makePatDa } from '~/logic/lib/util';
import _ from 'lodash';
import { StoreState } from '../store/type';
import { BigIntOrderedMap } from '../lib/BigIntOrderedMap';
import useHarkState, { HarkState } from '../state/hark';
import { compose } from 'lodash/fp';
import { reduceState } from '../state/base';
import bigInt, {BigInteger} from 'big-integer';
export const HarkReducer = (json: any) => {
const data = _.get(json, 'harkUpdate', false);
@ -56,9 +56,24 @@ function reduce(data) {
seenIndex,
removeGraph,
readAll,
calculateCount
]);
}
function calculateCount(json: any, state: HarkState) {
let count = 0;
_.forEach(state.unreads.graph, (graphs) => {
_.forEach(graphs, graph => {
count += (graph?.notifications || []).length;
});
});
_.forEach(state.unreads.group, group => {
count += (group?.notifications || []).length;
})
state.notificationsCount = count;
return state;
}
function groupInitial(json: any, state: HarkState): HarkState {
const data = _.get(json, 'initial', false);
if (data) {
@ -191,8 +206,10 @@ function unreads(json: any, state: HarkState): HarkState {
if(data) {
data.forEach(({ index, stats }) => {
const { unreads, notifications, last } = stats;
state = updateNotificationStats(state, index, 'notifications', x => x + notifications);
state = updateNotificationStats(state, index, 'last', () => last);
updateNotificationStats(state, index, 'last', () => last);
_.each(notifications, ({ time, index }) => {
addNotificationToUnread(state, index, makePatDa(time));
});
if('count' in unreads) {
state = updateUnreadCount(state, index, (u = 0) => u + unreads.count);
} else {
@ -251,18 +268,53 @@ function updateUnreads(state: HarkState, index: NotifIndex, f: (us: Set<string>)
return state;
}
function updateNotificationStats(state: HarkState, index: NotifIndex, statField: 'notifications' | 'unreads' | 'last', f: (x: number) => number): HarkState {
if(statField === 'notifications') {
state.notificationsCount = f(state.notificationsCount);
function addNotificationToUnread(state: HarkState, index: NotifIndex, time: BigInteger) {
if('graph' in index) {
const path = [index.graph.graph, index.graph.index, 'notifications'];
const curr = _.get(state.unreads.graph, path, []);
_.set(state.unreads.graph, path,
[
...curr.filter(c => !(c.time.eq(time) && notifIdxEqual(c.index, index))),
{ time, index}
]
);
} else if ('group' in index) {
const path = [index.group.group, 'notifications'];
const curr = _.get(state.unreads.group, path, []);
_.set(state.unreads.group, path,
[
...curr.filter(c => !(c.time.eq(time) && notifIdxEqual(c.index, index))),
{ time, index}
]
);
}
if ('graph' in index) {
const curr = _.get(state.unreads.graph, [index.graph.graph, index.graph.index, statField], 0);
_.set(state.unreads.graph, [index.graph.graph, index.graph.index, statField], f(curr));
} else if('group' in index) {
const curr = _.get(state.unreads.group, [index.group, statField], 0);
_.set(state.unreads.group, [index.group, statField], f(curr));
}
function removeNotificationFromUnread(state: HarkState, index: NotifIndex, time: BigInteger) {
if('graph' in index) {
const path = [index.graph.graph, index.graph.index, 'notifications'];
const curr = _.get(state.unreads.graph, path, []);
_.set(state.unreads.graph, path,
curr.filter(c => !(c.time.eq(time) && notifIdxEqual(c.index, index))),
);
} else if ('group' in index) {
const path = [index.group.group, 'notifications'];
const curr = _.get(state.unreads.group, path, []);
_.set(state.unreads.group, path,
curr.filter(c => !(c.time.eq(time) && notifIdxEqual(c.index, index))),
);
}
return state;
}
function updateNotificationStats(state: HarkState, index: NotifIndex, statField: 'unreads' | 'last', f: (x: number) => number) {
if('graph' in index) {
const curr = _.get(state.unreads.graph, [index.graph.graph, index.graph.index, statField], 0);
_.set(state.unreads.graph, [index.graph.graph, index.graph.index, statField], f(curr));
} else if('group' in index) {
const curr = _.get(state.unreads.group, [index.group.group, statField], 0);
_.set(state.unreads.group, [index.group.group, statField], f(curr));
}
}
function added(json: any, state: HarkState): HarkState {
@ -271,18 +323,15 @@ function added(json: any, state: HarkState): HarkState {
const { index, notification } = data;
const time = makePatDa(data.time);
const timebox = state.notifications.get(time) || [];
addNotificationToUnread(state, index, time);
const arrIdx = timebox.findIndex(idxNotif =>
notifIdxEqual(index, idxNotif.index)
);
if (arrIdx !== -1) {
if (timebox[arrIdx]?.notification?.read) {
state = updateNotificationStats(state, index, 'notifications', x => x+1);
}
timebox[arrIdx] = { index, notification };
state.notifications.set(time, timebox);
} else {
state = updateNotificationStats(state, index, 'notifications', x => x+1);
state.notifications.set(time, [...timebox, { index, notification }]);
}
}
@ -361,7 +410,7 @@ function read(json: any, state: HarkState): HarkState {
const data = _.get(json, 'read-note', false);
if (data) {
const { time, index } = data;
state = updateNotificationStats(state, index, 'notifications', x => x-1);
removeNotificationFromUnread(state, index, makePatDa(time));
setRead(time, index, true, state);
}
return state;
@ -371,7 +420,7 @@ function unread(json: any, state: HarkState): HarkState {
const data = _.get(json, 'unread-note', false);
if (data) {
const { time, index } = data;
state = updateNotificationStats(state, index, 'notifications', x => x+1);
addNotificationToUnread(state, index, makePatDa(time));
setRead(time, index, false, state);
}
return state;
@ -381,6 +430,7 @@ function archive(json: any, state: HarkState): HarkState {
const data = _.get(json, 'archive', false);
if (data) {
const { index } = data;
removeNotificationFromUnread(state, index, makePatDa(data.time))
const time = makePatDa(data.time);
const timebox = state.notifications.get(time);
if (!timebox) {
@ -396,8 +446,6 @@ function archive(json: any, state: HarkState): HarkState {
} else {
state.notifications.set(time, unarchived);
}
const newlyRead = archived.filter(x => !x.notification.read).length;
state = updateNotificationStats(state, index, 'notifications', x => x - newlyRead);
}
return state;
}

View File

@ -20,6 +20,7 @@ import GcpReducer from '../reducers/gcp-reducer';
import { OrderedMap } from '../lib/OrderedMap';
import { BigIntOrderedMap } from '../lib/BigIntOrderedMap';
import { GroupViewReducer } from '../reducers/group-view';
import { unstable_batchedUpdates } from 'react-dom';
export default class GlobalStore extends BaseStore<StoreState> {
inviteReducer = new InviteReducer();
@ -50,21 +51,23 @@ export default class GlobalStore extends BaseStore<StoreState> {
}
reduce(data: Cage, state: StoreState) {
// debug shim
const tag = Object.keys(data)[0];
const oldActions = this.pastActions[tag] || [];
this.pastActions[tag] = [data[tag], ...oldActions.slice(0,14)];
this.inviteReducer.reduce(data);
this.metadataReducer.reduce(data);
this.s3Reducer.reduce(data);
this.groupReducer.reduce(data);
GroupViewReducer(data);
this.launchReducer.reduce(data);
this.connReducer.reduce(data, this.state);
GraphReducer(data);
HarkReducer(data);
ContactReducer(data);
this.settingsReducer.reduce(data);
this.gcpReducer.reduce(data);
unstable_batchedUpdates(() => {
// debug shim
const tag = Object.keys(data)[0];
const oldActions = this.pastActions[tag] || [];
this.pastActions[tag] = [data[tag], ...oldActions.slice(0, 14)];
this.inviteReducer.reduce(data);
this.metadataReducer.reduce(data);
this.s3Reducer.reduce(data);
this.groupReducer.reduce(data);
GroupViewReducer(data);
this.launchReducer.reduce(data);
this.connReducer.reduce(data, this.state);
GraphReducer(data);
HarkReducer(data);
ContactReducer(data);
this.settingsReducer.reduce(data);
this.gcpReducer.reduce(data);
});
}
}

View File

@ -160,6 +160,7 @@ export function ChatResource(props: ChatResourceProps) {
key={station}
history={props.history}
graph={graph}
graphSize={graph.size}
unreadCount={unreadCount}
showOurContact={ !showBanner && hasLoadedAllowed }
association={props.association}

View File

@ -36,6 +36,7 @@ type ChatWindowProps = RouteComponentProps<{
}> & {
unreadCount: number;
graph: Graph;
graphSize: number;
association: Association;
group: Group;
ship: Patp;
@ -101,6 +102,10 @@ class ChatWindow extends Component<
calculateUnreadIndex() {
const { graph, unreadCount } = this.props;
const { state } = this;
if(state.unreadIndex.neq(bigInt.zero)) {
return;
}
const unreadIndex = graph.keys()[unreadCount];
if (!unreadIndex || unreadCount === 0) {
this.setState({
@ -113,6 +118,11 @@ class ChatWindow extends Component<
});
}
dismissedInitialUnread() {
const { unreadCount, graph } = this.props;
return this.state.unreadIndex.neq(graph.keys()?.[unreadCount]?.[0] ?? bigInt.zero)
}
handleWindowBlur() {
this.setState({ idle: true });
}
@ -125,11 +135,15 @@ class ChatWindow extends Component<
}
componentDidUpdate(prevProps: ChatWindowProps, prevState) {
const { history, graph, unreadCount, station } = this.props;
const { history, graph, unreadCount, graphSize, station } = this.props;
if (graph.size !== this.prevSize) {
this.prevSize = graph.size;
if(!this.state.unreadIndex && this.virtualList?.loaded.top) {
if(this.prevSize !== graphSize) {
this.prevSize = graphSize;
if(this.dismissedInitialUnread() &&
this.virtualList?.startOffset() < 5) {
this.dismissUnread();
}
if(this.state.unreadIndex.eq(bigInt.zero)) {
this.calculateUnreadIndex();
}
}
@ -151,6 +165,12 @@ class ChatWindow extends Component<
}
}
onBottomLoaded = () => {
if(this.state.unreadIndex.eq(bigInt.zero)) {
this.calculateUnreadIndex();
}
}
scrollToUnread() {
const { unreadIndex } = this.state;
if (unreadIndex.eq(bigInt.zero)) {
@ -305,7 +325,8 @@ class ChatWindow extends Component<
return (
<Col height='100%' overflow='hidden' position='relative'>
<UnreadNotice
{ !this.dismissedInitialUnread() &&
(<UnreadNotice
unreadCount={unreadCount}
unreadMsg={
unreadCount === 1 &&
@ -316,7 +337,7 @@ class ChatWindow extends Component<
}
dismissUnread={this.dismissUnread}
onClick={this.scrollToUnread}
/>
/>)}
<VirtualScroller
ref={(list) => {
this.virtualList = list;
@ -325,6 +346,7 @@ class ChatWindow extends Component<
origin='bottom'
style={virtScrollerStyle}
onStartReached={this.setActive}
onBottomLoaded={this.onBottomLoaded}
onScroll={this.onScroll}
data={graph}
size={graph.size}

View File

@ -204,6 +204,7 @@ export default class ChatEditor extends Component {
width='calc(100% - 88px)'
className={inCodeMode ? 'chat code' : 'chat'}
color="black"
overflow='auto'
>
{MOBILE_BROWSER_REGEX.test(navigator.userAgent)
? <MobileBox

View File

@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import moment from 'moment';
import { Box, Text } from '@tlon/indigo-react';
import { Box, Text, Center, Icon } from '@tlon/indigo-react';
import VisibilitySensor from 'react-visibility-sensor';
import Timestamp from '~/views/components/Timestamp';
@ -8,51 +8,67 @@ import Timestamp from '~/views/components/Timestamp';
export const UnreadNotice = (props) => {
const { unreadCount, unreadMsg, dismissUnread, onClick } = props;
if (!unreadMsg || (unreadCount === 0)) {
if (!unreadMsg || unreadCount === 0) {
return null;
}
const stamp = moment.unix(unreadMsg.post['time-sent'] / 1000);
let datestamp = moment.unix(unreadMsg.post['time-sent'] / 1000).format('YYYY.M.D');
const timestamp = moment.unix(unreadMsg.post['time-sent'] / 1000).format('HH:mm');
let datestamp = moment
.unix(unreadMsg.post['time-sent'] / 1000)
.format('YYYY.M.D');
const timestamp = moment
.unix(unreadMsg.post['time-sent'] / 1000)
.format('HH:mm');
if (datestamp === moment().format('YYYY.M.D')) {
datestamp = null;
}
return (
<Box style={{ left: '0px', top: '0px' }}
p='4'
<Box
style={{ left: '0px', top: '0px' }}
p='12px'
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='1' textOverflow='ellipsis' whiteSpace='pre' overflow='hidden' display='flex' cursor='pointer' onClick={onClick}>
{unreadCount} new message{unreadCount > 1 ? 's' : ''} since{' '}
<Timestamp stamp={stamp} color='blue' date={true} fontSize={1} />
</Text>
<Text
ml='4'
color='blue'
cursor='pointer'
textAlign='right'
flexShrink='0'
onClick={dismissUnread}>
Mark as Read
</Text>
</Box>
<Center>
<Box backgroundColor='white' borderRadius='2'>
<Box
backgroundColor='washedBlue'
display='flex'
alignItems='center'
p='2'
fontSize='0'
justifyContent='space-between'
borderRadius='3'
border='1'
borderColor='lightBlue'
>
<Text
textOverflow='ellipsis'
whiteSpace='pre'
overflow='hidden'
display='flex'
cursor='pointer'
onClick={onClick}
>
{unreadCount} new message{unreadCount > 1 ? 's' : ''} since{' '}
<Timestamp stamp={stamp} color='black' date={true} fontSize={1} />
</Text>
<Icon
icon='X'
ml='4'
color='black'
cursor='pointer'
textAlign='right'
onClick={dismissUnread}
/>
</Box>
</Box>
</Center>
</Box>
);
}
};

View File

@ -93,7 +93,10 @@ function Group(props: GroupProps) {
);
const { hideUnreads } = useSettingsState(selectCalmState);
const joined = useSettingsState(selectJoined);
const days = Math.floor(moment.duration(moment(joined).add(14, 'days').diff(moment())).as('days'));
const days = Math.max(0, Math.floor(moment.duration(moment(joined)
.add(14, 'days')
.diff(moment()))
.as('days'))) || 0;
return (
<Tile ref={anchorRef} position="relative" bg={isTutorialGroup ? 'lightBlue' : undefined} to={`/~landscape${path}`} gridColumnStart={first ? '1' : null}>
<Col height="100%" justifyContent="space-between">

View File

@ -1,7 +1,7 @@
import React, { ReactElement, ReactNode } from "react";
import _ from "lodash";
import React, { ReactElement, ReactNode } from 'react';
import _ from 'lodash';
import { Col, Box, Text } from "@tlon/indigo-react";
import { Col, Box, Text } from '@tlon/indigo-react';
import {
Invites as IInvites,
Associations,
@ -11,18 +11,18 @@ import {
Contacts,
AppInvites,
JoinProgress,
JoinRequest,
} from "@urbit/api";
JoinRequest
} from '@urbit/api';
import GlobalApi from '~/logic/api/global';
import { resourceAsPath, alphabeticalOrder } from '~/logic/lib/util';
import InviteItem from '~/views/components/Invite';
import useInviteState from '~/logic/state/invite';
import useGroupState from '~/logic/state/group';
interface InvitesProps {
api: GlobalApi;
pendingJoin: JoinRequests;
pendingJoin?: any;
}
interface InviteRef {
@ -50,11 +50,11 @@ export function Invites(props: InvitesProps): ReactElement {
[]
);
const pendingJoin = _.omitBy(props.pendingJoin, "hidden");
const pendingJoin = _.omitBy(props.pendingJoin, 'hidden');
const invitesAndStatus: { [rid: string]: JoinRequest | InviteRef } = {
..._.keyBy(inviteArr, ({ invite }) => resourceAsPath(invite.resource)),
...pendingJoin,
...pendingJoin
};
return (
@ -70,12 +70,15 @@ export function Invites(props: InvitesProps): ReactElement {
.sort(alphabeticalOrder)
.map((resource) => {
const inviteOrStatus = invitesAndStatus[resource];
if ("progress" in inviteOrStatus) {
const join = pendingJoin[resource];
if (typeof inviteOrStatus === 'string') {
if ('progress' in inviteOrStatus) {
return (
<InviteItem
key={resource}
resource={resource}
api={api}
pendingJoin={join}
/>
);
} else {
@ -91,6 +94,7 @@ export function Invites(props: InvitesProps): ReactElement {
/>
);
}
}
})
}
</>

View File

@ -112,6 +112,7 @@ export function ProfileStatus(props: any): ReactElement {
display='inline-block'
verticalAlign='middle'
color='gray'
title={contact?.status ?? ''}
>
{contact?.status ?? ''}
</RichText>

View File

@ -142,6 +142,10 @@
margin-bottom: 16px;
}
.md ul ul {
margin-bottom: 0px;
}
.md h2, .md h3, .md h4, .md h5, .md p, .md a, .md ul {
font-weight: 400;
}

View File

@ -54,10 +54,10 @@ export function CalmPrefs(props: {
hideUnreads,
hideGroups,
hideUtilities,
imageShown,
videoShown,
oembedShown,
audioShown,
imageShown: !imageShown,
videoShown: !videoShown,
oembedShown: !oembedShown,
audioShown: !audioShown
};
const onSubmit = useCallback(async (v: FormSchema, actions: FormikHelpers<FormSchema>) => {
@ -67,10 +67,10 @@ export function CalmPrefs(props: {
api.settings.putEntry('calm', 'hideUnreads', v.hideUnreads),
api.settings.putEntry('calm', 'hideGroups', v.hideGroups),
api.settings.putEntry('calm', 'hideUtilities', v.hideUtilities),
api.settings.putEntry('remoteContentPolicy', 'imageShown', v.imageShown),
api.settings.putEntry('remoteContentPolicy', 'videoShown', v.videoShown),
api.settings.putEntry('remoteContentPolicy', 'audioShown', v.audioShown),
api.settings.putEntry('remoteContentPolicy', 'oembedShown', v.oembedShown),
api.settings.putEntry('remoteContentPolicy', 'imageShown', !v.imageShown),
api.settings.putEntry('remoteContentPolicy', 'videoShown', !v.videoShown),
api.settings.putEntry('remoteContentPolicy', 'audioShown', !v.audioShown),
api.settings.putEntry('remoteContentPolicy', 'oembedShown', !v.oembedShown),
]);
actions.setStatus({ success: null });
}, [api]);
@ -115,24 +115,24 @@ export function CalmPrefs(props: {
id="hideNicknames"
caption="Do not show user-set nicknames"
/>
<Text fontWeight="medium">Remote Content</Text>
<Text fontWeight="medium">Remote content</Text>
<Toggle
label="Load images"
label="Disable images"
id="imageShown"
caption="Images will be replaced with an inline placeholder that must be clicked to be viewed"
/>
<Toggle
label="Load audio files"
label="Disable audio files"
id="audioShown"
caption="Audio content will be replaced with an inline placeholder that must be clicked to be viewed"
/>
<Toggle
label="Load video files"
label="Disable video files"
id="videoShown"
caption="Video content will be replaced with an inline placeholder that must be clicked to be viewed"
/>
<Toggle
label="Load embedded content"
label="Disable embedded content"
id="oembedShown"
caption="Embedded content may contain scripts that can track you"
/>

View File

@ -1,85 +0,0 @@
import React from 'react';
import {
Box,
Button,
ManagedCheckboxField as Checkbox
} from '@tlon/indigo-react';
import { Formik, Form } from 'formik';
import * as Yup from 'yup';
import GlobalApi from '~/logic/api/global';
import useSettingsState, {selectSettingsState} from '~/logic/state/settings';
const formSchema = Yup.object().shape({
imageShown: Yup.boolean(),
audioShown: Yup.boolean(),
videoShown: Yup.boolean(),
oembedShown: Yup.boolean()
});
interface FormSchema {
imageShown: boolean;
audioShown: boolean;
videoShown: boolean;
oembedShown: boolean;
}
interface RemoteContentFormProps {
api: GlobalApi;
}
const selState = selectSettingsState(['remoteContentPolicy', 'set']);
export default function RemoteContentForm(props: RemoteContentFormProps) {
const { api } = props;
const { remoteContentPolicy, set: setRemoteContentPolicy} = useSettingsState(selState);
const imageShown = remoteContentPolicy.imageShown;
const audioShown = remoteContentPolicy.audioShown;
const videoShown = remoteContentPolicy.videoShown;
const oembedShown = remoteContentPolicy.oembedShown;
return (
<Formik
validationSchema={formSchema}
initialValues={
{
imageShown,
audioShown,
videoShown,
oembedShown
} as FormSchema
}
onSubmit={(values, actions) => {
setRemoteContentPolicy((state) => {
Object.assign(state.remoteContentPolicy, values);
});
actions.setSubmitting(false);
}}
>
{props => (
<Form>
<Box
display="grid"
gridTemplateColumns="1fr"
gridTemplateRows="audio"
gridRowGap={5}
>
<Box color="black" fontSize={1} fontWeight={900}>
Remote Content
</Box>
<Checkbox label="Load images" id="imageShown" />
<Checkbox label="Load audio files" id="audioShown" />
<Checkbox label="Load video files" id="videoShown" />
<Checkbox
label="Load embedded content"
id="oembedShown"
caption="Embedded content may contain scripts"
/>
<Button style={{ cursor: 'pointer' }} border={1} borderColor="washedGray" type="submit">
Save
</Button>
</Box>
</Form>
)}
</Formik>
);
}

View File

@ -1,5 +1,5 @@
import React, { ReactElement, useCallback } from 'react';
import { Formik } from 'formik';
import { Formik, FormikHelpers } from 'formik';
import {
ManagedTextInputField as Input,
@ -10,6 +10,7 @@ import {
Col,
Anchor
} from '@tlon/indigo-react';
import { AsyncButton } from "~/views/components/AsyncButton";
import GlobalApi from '~/logic/api/global';
import { BucketList } from './BucketList';
@ -35,19 +36,19 @@ export default function S3Form(props: S3FormProps): ReactElement {
const { api } = props;
const s3 = useStorageState((state) => state.s3);
const onSubmit = useCallback(
(values: FormSchema) => {
const onSubmit = useCallback(async (values: FormSchema, actions: FormikHelpers<FormSchema>) => {
if (values.s3secretAccessKey !== s3.credentials?.secretAccessKey) {
api.s3.setSecretAccessKey(values.s3secretAccessKey);
await api.s3.setSecretAccessKey(values.s3secretAccessKey);
}
if (values.s3endpoint !== s3.credentials?.endpoint) {
api.s3.setEndpoint(values.s3endpoint);
await api.s3.setEndpoint(values.s3endpoint);
}
if (values.s3accessKeyId !== s3.credentials?.accessKeyId) {
api.s3.setAccessKeyId(values.s3accessKeyId);
await api.s3.setAccessKeyId(values.s3accessKeyId);
}
actions.setStatus({ success: null });
},
[api, s3]
);
@ -95,9 +96,9 @@ export default function S3Form(props: S3FormProps): ReactElement {
label='Secret Access Key'
id='s3secretAccessKey'
/>
<Button style={{ cursor: 'pointer' }} type='submit'>
<AsyncButton primary style={{ cursor: 'pointer' }} type='submit'>
Submit
</Button>
</AsyncButton>
</Col>
</Form>
</Formik>

View File

@ -7,7 +7,6 @@ import { StoreState } from "~/logic/store/type";
import DisplayForm from "./lib/DisplayForm";
import S3Form from "./lib/S3Form";
import SecuritySettings from "./lib/Security";
import RemoteContentForm from "./lib/RemoteContent";
import { NotificationPreferences } from "./lib/NotificationPref";
import { CalmPrefs } from "./lib/CalmPref";
import { Link } from "react-router-dom";

View File

@ -18,6 +18,16 @@ const Details = styled.details``;
class ErrorComponent extends Component<ErrorProps> {
render () {
const { code, error, history, description } = this.props;
let title = '';
if (error) {
title = error.message;
} else if (description) {
title = description;
}
let body = '';
if (error) {
body =`\`\`\`%0A${error.stack?.replaceAll('\n', '%0A')}%0A\`\`\``;
}
return (
<Col alignItems="center" justifyContent="center" height="100%" p="4" backgroundColor="white" maxHeight="100%">
<Box mb={4}>
@ -32,12 +42,21 @@ class ErrorComponent extends Component<ErrorProps> {
<Text mono>&ldquo;{error.message}&rdquo;</Text>
</Box>
<Details>
<Summary>Stack trace</Summary>
<Text mono p='1' borderRadius='1' display='block' overflow='auto' backgroundColor='washedGray' style={{ whiteSpace: 'pre', wordWrap: 'break-word' }}>{error.stack}</Text>
<Summary>Stack trace</Summary>
<Text
mono
p={1}
borderRadius={1}
display='block'
overflow='scroll'
maxHeight='50vh'
backgroundColor='washedGray'
style={{ whiteSpace: 'pre', wordWrap: 'break-word' }}
>{error.stack}</Text>
</Details>
</Box>
)}
<Text mb={4} textAlign="center">If this is unexpected, email <code>support@tlon.io</code> or <BaseAnchor color='black' href="https://github.com/urbit/urbit/issues/new/choose">submit an issue</BaseAnchor>.</Text>
<Text mb={4} textAlign="center">If this is unexpected, email <code>support@tlon.io</code> or <BaseAnchor borderBottom={1} color='black' href={`https://github.com/urbit/landscape/issues/new?assignees=&labels=bug&title=${title}&body=${body}`} target="_blank">submit an issue</BaseAnchor>.</Text>
{history.length > 1
? <Button primary onClick={() => history.go(-1) }>Go back</Button>
: <Button primary onClick={() => history.push('/') }>Go home</Button>

View File

@ -1,10 +1,12 @@
import React, { Component } from 'react';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import StackTrace from 'stacktrace-js';
import ErrorComponent from './Error';
import { Spinner } from '~/views/components/Spinner';
class ErrorBoundary extends Component<
RouteComponentProps,
{ error?: Error }
{ error?: Error | true}
> {
constructor(props) {
super(props);
@ -19,13 +21,31 @@ class ErrorBoundary extends Component<
});
}
componentDidCatch(error) {
this.setState({ error });
componentDidCatch(error: Error) {
this.setState({ error: true });
StackTrace.fromError(error).then(stackframes => {
const stack = stackframes.map(frame => {
return `${frame.functionName} (${frame.fileName} ${frame.lineNumber}:${frame.columnNumber})`;
}).join('\n');
error = { name: error.name, message: error.message, stack };
this.setState({ error })
});
return false;
}
render() {
if (this.state.error) {
if (this.state.error === true) {
return (
<div className="relative h-100 w-100">
<Spinner
text="Encountered error, gathering information"
awaiting={true}
classes="absolute top-0 bottom-0 left-0 right-0 flex items-center justify-center"
/>
</div>
);
}
return (<ErrorComponent error={this.state.error} />);
}
return this.props.children;

View File

@ -11,7 +11,7 @@ import {
import { Invite } from '@urbit/api/invite';
import { Text, Icon, Row } from '@tlon/indigo-react';
import { cite } from '~/logic/lib/util';
import { cite, useShowNickname } from '~/logic/lib/util';
import GlobalApi from '~/logic/api/global';
import { resourceFromPath } from '~/logic/lib/group';
import { GroupInvite } from './Group';
@ -19,11 +19,14 @@ import { InviteSkeleton } from './InviteSkeleton';
import { JoinSkeleton } from './JoinSkeleton';
import { useWaitForProps } from '~/logic/lib/useWaitForProps';
import useGroupState from '~/logic/state/group';
import useContactState from '~/logic/state/contact';
import useMetadataState from '~/logic/state/metadata';
import useGraphState from '~/logic/state/graph';
interface InviteItemProps {
invite?: Invite;
resource: string;
pendingJoin?: string;
app?: string;
uid?: string;
api: GlobalApi;
@ -31,13 +34,18 @@ interface InviteItemProps {
export function InviteItem(props: InviteItemProps) {
const [preview, setPreview] = useState<MetadataUpdatePreview | null>(null);
const { invite, resource, uid, app, api } = props;
const { pendingJoin, invite, resource, uid, app, api } = props;
const { ship, name } = resourceFromPath(resource);
const pendingJoin = useGroupState(state => state.pendingJoin);
const status = pendingJoin[resource];
const groups = useGroupState(state => state.groups);
const graphKeys = useGraphState(s => s.graphKeys);
const associations = useMetadataState(state => state.associations);
const waiter = useWaitForProps({ associations, groups, pendingJoin}, 50000);
const contacts = useContactState(state => state.contacts);
const contact = contacts?.[`~${invite?.ship}`] ?? {};
const showNickname = useShowNickname(contact);
const waiter = useWaitForProps(
{ associations, groups, pendingJoin, graphKeys: Array.from(graphKeys) },
50000
);
const history = useHistory();
const inviteAccept = useCallback(async () => {
@ -50,7 +58,7 @@ export function InviteItem(props: InviteItemProps) {
}
api.groups.join(ship, name);
await waiter(p => resource in p.pendingJoin);
await waiter(p => !!p.pendingJoin);
api.invite.accept(app, uid);
await waiter((p) => {
@ -62,6 +70,7 @@ export function InviteItem(props: InviteItemProps) {
});
if (groups?.[resource]?.hidden) {
await waiter(p => p.graphKeys.includes(resource.slice(7)));
const { metadata } = associations.graph[resource];
if (metadata && 'graph' in metadata.config) {
if (metadata.config.graph === 'chat') {
@ -75,7 +84,7 @@ export function InviteItem(props: InviteItemProps) {
} else {
history.push(`/~landscape${resource}`);
}
}, [app, invite, uid, resource, groups, associations]);
}, [app, history, waiter, invite, uid, resource, groups, associations]);
const inviteDecline = useCallback(async () => {
if(!(app && uid)) {
@ -124,8 +133,10 @@ export function InviteItem(props: InviteItemProps) {
>
<Row py="1" alignItems="center">
<Icon display="block" color="blue" icon="Bullet" mr="2" />
<Text mr="1" mono>
{cite(`~${invite!.ship}`)}
<Text mr="1"
mono={!showNickname}
fontWeight={showNickname ? '500' : '400'}>
{showNickname ? contact?.nickname : cite(`~${invite!.ship}`)}
</Text>
<Text mr="1">invited you to a DM</Text>
</Row>
@ -151,8 +162,10 @@ export function InviteItem(props: InviteItemProps) {
>
<Row py="1" alignItems="center">
<Icon display="block" color="blue" icon="Bullet" mr="2" />
<Text mr="1" mono>
{cite(`~${invite!.ship}`)}
<Text mr="1"
mono={!showNickname}
fontWeight={showNickname ? '500' : '400'}>
{showNickname ? contact?.nickname : cite(`~${invite!.ship}`)}
</Text>
<Text mr="1">
invited you to ~{invite.resource.ship}/{invite.resource.name}
@ -160,10 +173,10 @@ export function InviteItem(props: InviteItemProps) {
</Row>
</InviteSkeleton>
);
} else if (status) {
} else if (pendingJoin) {
const [, , ship, name] = resource.split('/');
return (
<JoinSkeleton api={api} resource={resource} status={status}>
<JoinSkeleton api={api} resource={resource} status={pendingJoin}>
<Row py="1" alignItems="center">
<Icon display="block" color="blue" icon="Bullet" mr="2" />
<Text mr="1">

View File

@ -14,7 +14,9 @@ import {
Text,
BaseImage,
Icon,
BoxProps
BoxProps,
ColProps,
Center
} from '@tlon/indigo-react';
import RichText from './RichText';
import { ProfileStatus } from './ProfileStatus';
@ -87,7 +89,7 @@ const ProfileOverlay = (props: ProfileOverlayProps) => {
const spaceAtTop = top > 300;
const spaceAtRight = right > 300 || right > left;
setCoords(getRelativePosition(
outer,
outer,
spaceAtRight ? 'left' : 'right',
spaceAtTop ? 'bottom' : 'top',
-1* outer.clientWidth,
@ -106,19 +108,23 @@ const ProfileOverlay = (props: ProfileOverlayProps) => {
}, [open]);
const img =
contact?.avatar && !hideAvatars ? (
<BaseImage
referrerPolicy="no-referrer"
display='inline-block'
style={{ objectFit: 'cover' }}
src={contact.avatar}
height={72}
width={72}
borderRadius={2}
/>
) : (
<Sigil ship={ship} size={72} color={color} />
);
contact?.avatar && !hideAvatars ? (
<BaseImage
referrerPolicy='no-referrer'
display='inline-block'
style={{ objectFit: 'cover' }}
src={contact.avatar}
height={60}
width={60}
borderRadius={2}
/>
) : (
<Box size={60} backgroundColor={color}>
<Center height={60}>
<Sigil ship={ship} size={32} color={color} />
</Center>
</Box>
);
return (
<Box ref={outerRef} {...rest} onClick={setOpen} cursor="pointer">
@ -143,15 +149,17 @@ const ProfileOverlay = (props: ProfileOverlayProps) => {
padding={3}
justifyContent='center'
>
<Row color='black' padding={3} position='absolute' top={0} left={0}>
{!isOwn && (
<Icon
icon='Chat'
size={16}
cursor='pointer'
onClick={() => history.push(`/~landscape/dm/${ship}`)}
/>
)}
<Row width='100%'>
<Text
fontWeight='600'
mono={!showNickname}
textOverflow='ellipsis'
overflow='hidden'
whiteSpace='pre'
marginBottom='0'
>
{showNickname ? contact?.nickname : cite(ship)}
</Text>
</Row>
<Box
alignSelf='center'
@ -201,6 +209,7 @@ const ProfileOverlay = (props: ProfileOverlayProps) => {
marginBottom='0'
disableRemoteContent
gray
title={contact?.status ? contact.status : ''}
>
{contact?.status ? contact.status : ''}
</RichText>

View File

@ -11,20 +11,24 @@ export type TimestampProps = BoxProps & {
stamp: MomentType;
date?: boolean;
time?: boolean;
}
};
const Timestamp = (props: TimestampProps): ReactElement | null=> {
const Timestamp = (props: TimestampProps): ReactElement | null => {
const { stamp, date, time, color, fontSize, ...rest } = {
time: true, color: 'gray', fontSize: 0, ...props
time: true,
color: 'gray',
fontSize: 0,
...props
};
if (!stamp) return null;
const { hovering, bind } = date === true
? { hovering: true, bind: {} }
: useHovering();
const { hovering, bind } =
date === true ? { hovering: true, bind: {} } : useHovering();
let datestamp = stamp.format(DateFormat);
if (stamp.format(DateFormat) === moment().format(DateFormat)) {
datestamp = 'Today';
} else if (stamp.format(DateFormat) === moment().subtract(1, 'day').format(DateFormat)) {
} else if (
stamp.format(DateFormat) === moment().subtract(1, 'day').format(DateFormat)
) {
datestamp = 'Yesterday';
}
const timestamp = stamp.format(TimeFormat);
@ -33,22 +37,28 @@ const Timestamp = (props: TimestampProps): ReactElement | null=> {
{...bind}
display='flex'
flex='row'
flexWrap="nowrap"
flexWrap='nowrap'
{...rest}
title={stamp.format(DateFormat + ' ' + TimeFormat)}
>
{time && <Text flexShrink={0} color={color} fontSize={fontSize}>{timestamp}</Text>}
{date !== false && <Text
flexShrink={0}
color={color}
fontSize={fontSize}
ml={time ? 2 : 0}
display={time ? ['none', hovering ? 'block' : 'none'] : 'block'}
>
{datestamp}
</Text>}
{time && (
<Text flexShrink={0} color={color} fontSize={fontSize}>
{timestamp}
</Text>
)}
{date !== false && (
<Text
flexShrink={0}
color={color}
fontSize={fontSize}
display={time ? ['none', hovering ? 'block' : 'none'] : 'block'}
>
{time ? '\u00A0' : ''}
{datestamp}
</Text>
)}
</Box>
)
}
);
};
export default Timestamp;
export default Timestamp;

View File

@ -69,6 +69,10 @@ interface VirtualScrollerProps<T> {
*/
offset: number;
style?: any;
/**
* Callback to execute when finished loading from start
*/
onBottomLoaded?: () => void;
}
interface VirtualScrollerState<T> {
@ -155,8 +159,8 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
componentDidMount() {
this.updateVisible(0);
this.resetScroll();
this.loadRows(false);
this.loadRows(true);
this.loadTop();
this.loadBottom();
}
// manipulate scrollbar manually, to dodge change detection
@ -303,8 +307,10 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
this.savedDistance = 0;
this.saveDepth = 0;
}
loadTop = _.throttle(() => this.loadRows(false), 100);
loadBottom = _.throttle(() => this.loadRows(true), 100);
loadRows = _.throttle(async (newer: boolean) => {
loadRows = async (newer: boolean) => {
const dir = newer ? 'bottom' : 'top';
if(this.loaded[dir]) {
return;
@ -313,8 +319,11 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
const done = await this.props.loadRows(newer);
if(done) {
this.loaded[dir] = true;
if(newer && this.props.onBottomLoaded) {
this.props.onBottomLoaded()
}
}
}, 100);
};
onScroll(event: UIEvent) {
this.updateScroll();
@ -338,7 +347,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
}
const newOffset = Math.max(0, startOffset - this.pageDelta);
if(newOffset < 10) {
this.loadRows(true);
this.loadBottom();
}
if(newOffset === 0) {
@ -358,7 +367,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
}
if((newOffset + (3 * this.pageSize) > this.props.data.size)) {
this.loadRows(false)
this.loadTop();
}
if(newOffset !== startOffset) {

View File

@ -30,7 +30,7 @@ interface OmniboxProps {
notifications: number;
}
const SEARCHED_CATEGORIES = ['ships', 'other', 'commands', 'groups', 'subscriptions', 'apps'];
const SEARCHED_CATEGORIES = ['commands', 'ships', 'other', 'groups', 'subscriptions', 'apps'];
const settingsSel = (s: SettingsState) => s.leap;
export function Omnibox(props: OmniboxProps) {
@ -251,6 +251,15 @@ export function Omnibox(props: OmniboxProps) {
setQuery(event.target.value);
}, []);
// Sort Omnibox results alphabetically
const sortResults = (a: Record<'title', string>, b: Record<'title', string>) => {
// Do not sort unless searching (preserves order of menu actions)
if (query === '') { return 0 };
if (a.title < b.title) { return -1 };
if (a.title > b.title) { return 1 };
return 0;
}
const renderResults = useCallback(() => {
return <Box
maxHeight={['200px', '400px']}
@ -268,16 +277,18 @@ export function Omnibox(props: OmniboxProps) {
const sel = selected?.length ? selected[1] : '';
return (<Box key={i} width='max(50vw, 300px)' maxWidth='600px'>
{categoryTitle}
{categoryResults.map((result, i2) => (
<OmniboxResult
key={i2}
icon={result.app}
text={result.title}
subtext={result.host}
link={result.link}
navigate={() => navigate(result.app, result.link)}
selected={sel}
/>
{categoryResults
.sort(sortResults)
.map((result, i2) => (
<OmniboxResult
key={i2}
icon={result.app}
text={result.title}
subtext={result.host}
link={result.link}
navigate={() => navigate(result.app, result.link)}
selected={sel}
/>
))}
</Box>
);

View File

@ -64,7 +64,7 @@ export class OmniboxResult extends Component {
graphic = <Icon display='inline-block' verticalAlign='middle' icon='Users' mr='2' size='18px' color={iconFill} />;
} else if (icon === 'tutorial') {
graphic = <Icon display='inline-block' verticalAlign='middle' icon='Tutorial' mr='2' size='18px' color={iconFill} />;
}
}
else {
graphic = <Icon display='inline-block' icon='NullIcon' verticalAlign="middle" mr='2' size="16px" color={iconFill} />;
}
@ -102,6 +102,12 @@ export class OmniboxResult extends Component {
<Text
mono={(icon == 'profile' && text.startsWith('~'))}
color={this.state.hovered || selected === link ? 'white' : 'black'}
display='inline-block'
verticalAlign='middle'
width='100%'
overflow='hidden'
textOverflow='ellipsis'
whiteSpace='pre'
mr='1'
>
{text.startsWith("~") ? cite(text) : text}

View File

@ -1,15 +1,21 @@
import { Post } from "../graph/types";
import { GroupUpdate } from "../groups/types";
import BigIntOrderedMap from "../lib/BigIntOrderedMap";
import {BigInteger} from "big-integer";
export type GraphNotifDescription = "link" | "comment" | "note" | "mention" | "message";
export interface UnreadStats {
unreads: Set<string> | number;
notifications: number;
notifications: NotifRef[];
last: number;
}
interface NotifRef {
time: BigInteger;
index: NotifIndex;
}
export interface GraphNotifIndex {
graph: string;
group: string;