mirror of
https://github.com/urbit/shrub.git
synced 2025-01-04 10:32:34 +03:00
Merge branch 'release/next-js' into release/next-userspace
This commit is contained in:
commit
7fd4928d96
24
.github/workflows/build.yml
vendored
24
.github/workflows/build.yml
vendored
@ -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:
|
||||
|
@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:384f4a66399e32fac3698465d69ddfea1e80f8245f6a5f5cc9e24ff437cc3f61
|
||||
size 9556792
|
||||
oid sha256:b99c4ea8e73d810e4e8b515a662cdd36933affbed47c461fe4258dcdb3b656b1
|
||||
size 9601365
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
==
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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]
|
||||
|
47
pkg/interface/package-lock.json
generated
47
pkg/interface/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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) => {
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@ -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">
|
||||
|
@ -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 {
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</>
|
||||
|
@ -112,6 +112,7 @@ export function ProfileStatus(props: any): ReactElement {
|
||||
display='inline-block'
|
||||
verticalAlign='middle'
|
||||
color='gray'
|
||||
title={contact?.status ?? ''}
|
||||
>
|
||||
{contact?.status ?? ''}
|
||||
</RichText>
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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"
|
||||
/>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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";
|
||||
|
@ -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>“{error.message}”</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>
|
||||
|
@ -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;
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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) {
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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}
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user