Merge remote-tracking branch 'origin/master' into release/next-sys

This commit is contained in:
Philip Monk 2021-04-28 23:25:16 -07:00
commit ca389b42cf
No known key found for this signature in database
GPG Key ID: B66E1F02604E44EC
101 changed files with 4351 additions and 2769 deletions

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f6b5e33e573818120051651c1182163527edbbe0dff0eb6591e12a55cfccb273
size 10486101
oid sha256:ae362654e444357b86e41d37123eaf79da0d6c97545fa619b8db64a71efa3ff6
size 10551570

View File

@ -82,6 +82,8 @@ haskell-nix.stackProject {
urbit-king.components.tests.urbit-king-tests.testFlags =
[ "--brass-pill=${brass.lfs}" ];
lmdb.components.library.libs = lib.mkForce [ lmdb ];
};
}];
}

View File

@ -5,7 +5,7 @@
/- glob
/+ default-agent, verb, dbug
|%
++ hash 0v3.g6u13.haedt.jt4hd.61ek5.6t30q
++ hash 0v3.m4922.94fro.soub7.2cod3.0rfhh
+$ state-0 [%0 hash=@uv glob=(unit (each glob:glob tid=@ta))]
+$ all-states
$% state-0

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.59e682153138f604d358.js"></script>
<script src="/~landscape/js/bundle/index.d68f6086306286b082be.js"></script>
</body>
</html>

View File

@ -90,7 +90,12 @@
$: tracking=(map resource track)
inner-state=vase
==
::
+$ base-state-3
$: prev-version=@ud
prev-min-version=@ud
base-state-2
==
::
+$ state-0 [%0 base-state-0]
::
@ -100,11 +105,14 @@
::
+$ state-3 [%3 base-state-2]
::
+$ state-4 [%4 base-state-3]
::
+$ versioned-state
$% state-0
state-1
state-2
state-3
state-4
==
::
++ default
@ -198,7 +206,7 @@
++ agent
|* =config
|= =(pull-hook config)
=| state-3
=| state-4
=* state -
^- agent:gall
=<
@ -224,13 +232,20 @@
=| cards=(list card:agent:gall)
|^
?- -.old
%3
%4
=^ og-cards pull-hook
(on-load:og inner-state.old)
=. state old
=/ kick=(list card)
?: ?& =(min-version.config prev-min-version.old)
=(version.config prev-version.old)
==
~
(poke-self:pass kick+!>(%kick))^~
:_ this
:(weld cards og-cards (poke-self:pass kick+!>(%kick))^~)
:(weld cards og-cards kick)
::
%3 $(old [%4 0 0 +.old])
%2 $(old (state-to-3 old))
%1 $(old [%2 +.old ~])
%0 !! :: pre-breach
@ -255,8 +270,10 @@
::
++ on-save
^- vase
=. inner-state
on-save:og
=: inner-state on-save:og
prev-min-version min-version.config
prev-version version.config
==
!>(state)
::
++ on-poke
@ -472,6 +489,7 @@
::
++ tr-add
|= [s=^ship r=resource]
?< =(s our.bowl)
=: ship s
rid r
status [%active ~]

View File

@ -57,13 +57,21 @@
inner-state=vase
==
::
+$ base-state-1
$: prev-version=@ud
prev-min-version=@ud
base-state-0
==
::
+$ state-0 [%0 base-state-0]
::
+$ state-1 [%1 base-state-0]
+$ state-2 [%2 base-state-1]
::
+$ versioned-state
$% state-0
state-1
state-2
==
++ push-hook
|* =config
@ -153,7 +161,7 @@
++ agent
|* =config
|= =(push-hook config)
=| state-1
=| state-2
=* state -
^- agent:gall
=<
@ -179,16 +187,21 @@
=| cards=(list card:agent:gall)
|^
?- -.old
%1
%2
=^ og-cards push-hook
(on-load:og inner-state.old)
=/ old-subs
find-old-subs
(find-old-subs [prev-version prev-min-version]:old)
=/ version-cards
:- (fact:io version+!>(version.config) /version ~)
?~ old-subs ~
(kick:io old-subs)^~
[:(weld cards og-cards version-cards) this(state old)]
::
%1
%_ $
old [%2 0 0 +.old]
==
::
::
%0
@ -205,6 +218,12 @@
==
::
++ find-old-subs
|= [prev-min-version=@ud prev-version=@ud]
?: ?& =(min-version.config prev-min-version)
=(prev-version version.config)
==
:: bail on kick if we didn't change versions
~
%~ tap in
%+ roll
~(val by sup.bowl)
@ -230,8 +249,10 @@
--
::
++ on-save
=. inner-state
on-save:og
=: prev-version version.config
prev-min-version min-version.config
inner-state on-save:og
==
!>(state)
::
++ on-poke

View File

@ -25,12 +25,13 @@ The first two options result in Urbit attempting to boot either the ship named b
In consequence, it is safe to remove the container and start a new container which mounts the same volume, e.g. to upgrade the version of the urbit binary by running a later container version. It is also possible to stop the container and then move the pier away e.g. to a location where you will run it directly with the Urbit binary.
### Ports
The image includes `EXPOSE` directives for TCP port 80 and UDP port 34343. Port `80` is used for Urbit's HTTP interface for both [Landscape](https://urbit.org/docs/glossary/landscape/) and for [API calls](https://urbit.org/using/integrating-api/) to the ship. Port `34343` is used by [Ames](https://urbit.org/docs/glossary/ames/) for ship-to-ship communication.
The image includes `EXPOSE` directives for TCP port 80 and UDP port 34343. Port `80` is used for Urbit's HTTP interface for both [Landscape](https://urbit.org/docs/glossary/landscape/) and for [API calls](https://urbit.org/using/integrating-api/) to the ship. Port `34343` is set by default to be used by [Ames](https://urbit.org/docs/glossary/ames/) for ship-to-ship communication.
You can either pass the `-P` flag to docker to map ports directly to the corresponding ports on the host, or map them individually with `-p` flags. For local testing the latter is often convenient, for instance to remap port 80 to an unprivileged port.
You can either pass the `-P` flag to docker to map ports directly to the corresponding ports on the host, or map them individually with `-p` flags. For local testing the latter is often convenient, for instance to remap port 80 to an unprivileged port.
You should be able to use port mapping for most purposes but you can force Ames to use a custom port.
`--port=$AMES_PORT` can be passed as an argument to the `docker start` command. Passing `--port=13436` for example, would use port 13436.
For best performance, you must map the Ames UDP port to the *same* port on the host. If you map to a different port Ames will not be able to make direct connections and your network performance may suffer somewhat. Note that using the same port is required for direct connections but is not by itself sufficient for them. If you are behind a NAT router or the host is not on a public IP address or you are firewalled, you may not achive direct connections regardless.
For this purpose you can force Ames to use a custom port. `/bin/start-urbit --port=$AMES_PORT` can be passed as an argument to the `docker start` command. Passing `/bin/start-urbit --port=13436` for example, would use port 13436. You must pass the name of the start script `/bin/start-urbit` in order to also pass arguments, if this is omitted your container will not start.
### Examples
Creating a volume for ~sampel=palnet:
@ -38,23 +39,23 @@ Creating a volume for ~sampel=palnet:
docker volume create sampel-palnet
```
Copying key to sampel-palnet's volume (assumes default docker location)
Copying key to sampel-palnet's volume (assumes default docker location):
```
sudo cp ~/sampel-palnet.key /var/lib/docker/volumes/sampel-palnet/_data/sampel-palnet.key
```
Using that volume and launching ~sampel-palnet on host port 8080 with Ames talking on host port 27000:
Using that volume and launching ~sampel-palnet on host port 8080 with Ames talking on the default host port 34343:
```
docker run -d -p 8080:80 -p 27000:34343/udp --name sampel-palnet \
docker run -d -p 8080:80 -p 34343:34343/udp --name sampel-palnet \
--mount type=volume,source=sampel-palnet,destination=/urbit \
tloncorp/urbit
```
Using host port 8088 with Ames talking on host port 23232 while forcing Ames to start internally on port 13436:
Using host port 8088 with Ames talking on host port 23232:
```
docker run -d -p 8088:80 -p 23232:13436/udp --name sampel-palnet \
docker run -d -p 8088:80 -p 23232:23232/udp --name sampel-palnet \
--mount type=volume,source=sampel-palnet,destination=/urbit \
tloncorp/urbit --port=13436
tloncorp/urbit /bin/start-urbit --port=23232
```
### Getting and resetting the Landscape +code

View File

@ -25,10 +25,10 @@ module Urbit.Arvo.Common
import Urbit.Prelude
import Control.Monad.Fail (fail)
import Data.Bits
import Data.Serialize
import qualified Network.HTTP.Types.Method as H
import qualified Network.Socket as N
import qualified Urbit.Ob as Ob
@ -159,6 +159,19 @@ deriveNoun ''JsonNode
-- Ames Destinations -------------------------------------------------
serializeToNoun :: Serialize a => a -> Noun
serializeToNoun = A . bytesAtom . encode
serializeParseNoun :: Serialize a => String -> Int -> Noun -> Parser a
serializeParseNoun desc len = named (pack desc) . \case
A (atomBytes -> bs)
-- Atoms lose leading 0s, but since lsb, these become trailing NULs
| length bs <= len -> case decode $ bs <> replicate (len - length bs) 0 of
Right aa -> pure aa
Left msg -> fail msg
| otherwise -> fail ("putative " <> desc <> " " <> show bs <> " too long")
C{} -> fail ("unexpected cell in " <> desc)
newtype Patp a = Patp { unPatp :: a }
deriving newtype (Eq, Ord, Enum, Real, Integral, Num, ToNoun, FromNoun)
@ -167,17 +180,29 @@ newtype Port = Port { unPort :: Word16 }
deriving newtype (Eq, Ord, Show, Enum, Real, Integral, Num, ToNoun, FromNoun)
-- @if
newtype Ipv4 = Ipv4 { unIpv4 :: Word32 }
deriving newtype (Eq, Ord, Enum, Real, Integral, Num, ToNoun, FromNoun)
newtype Ipv4 = Ipv4 { unIpv4 :: N.HostAddress }
deriving newtype (Eq, Ord, Enum)
instance Serialize Ipv4 where
get = (\a b c d -> Ipv4 $ N.tupleToHostAddress $ (d, c, b, a))
<$> getWord8 <*> getWord8 <*> getWord8 <*> getWord8
put (Ipv4 (N.hostAddressToTuple -> (a, b, c, d))) = for_ [d, c, b, a] putWord8
instance ToNoun Ipv4 where
toNoun = serializeToNoun
instance FromNoun Ipv4 where
parseNoun = serializeParseNoun "Ipv4" 4
instance Show Ipv4 where
show (Ipv4 i) =
show ((shiftR i 24) .&. 0xff) ++ "." ++
show ((shiftR i 16) .&. 0xff) ++ "." ++
show ((shiftR i 8) .&. 0xff) ++ "." ++
show (i .&. 0xff)
show (Ipv4 (N.hostAddressToTuple -> (a, b, c, d))) =
show a ++ "." ++
show b ++ "." ++
show c ++ "." ++
show d
-- @is
-- should probably use hostAddress6ToTuple here, but no one uses it right now
newtype Ipv6 = Ipv6 { unIpv6 :: Word128 }
deriving newtype (Eq, Ord, Show, Enum, Real, Integral, Num, ToNoun, FromNoun)
@ -190,21 +215,14 @@ data AmesAddress = AAIpv4 Ipv4 Port
deriving (Eq, Ord, Show)
instance Serialize AmesAddress where
get = AAIpv4 <$> (Ipv4 <$> getWord32le) <*> (Port <$> getWord16le)
put (AAIpv4 (Ipv4 ip) (Port port)) = putWord32le ip >> putWord16le port
get = AAIpv4 <$> get <*> (Port <$> getWord16le)
put (AAIpv4 ip (Port port)) = put ip >> putWord16le port
instance FromNoun AmesAddress where
parseNoun = named "AmesAddress" . \case
A (atomBytes -> bs)
-- Atoms lose leading 0s, but since lsb, these become trailing NULs
| length bs <= 6 -> case decode $ bs <> replicate (6 - length bs) 0 of
Right aa -> pure aa
Left msg -> fail msg
| otherwise -> fail ("putative address " <> show bs <> " too long")
C{} -> fail "unexpected cell in ames address"
parseNoun = serializeParseNoun "AmesAddress" 6
instance ToNoun AmesAddress where
toNoun = A . bytesAtom . encode
toNoun = serializeToNoun
type AmesDest = Each Galaxy AmesAddress

View File

@ -80,10 +80,6 @@ data ShipClass
muk :: ByteString -> Word20
muk bs = mugBS bs .&. (2 ^ 20 - 1)
-- XX check this
getAmesAddress :: Get AmesAddress
getAmesAddress = AAIpv4 <$> (Ipv4 <$> getWord32le) <*> (Port <$> getWord16le)
putAmesAddress :: Putter AmesAddress
putAmesAddress = \case
AAIpv4 (Ipv4 ip) (Port port) -> putWord32le ip >> putWord16le port
@ -104,7 +100,7 @@ instance Serialize Packet where
guard isAmes
pktOrigin <- if isRelayed
then Just <$> getAmesAddress
then Just <$> get
else pure Nothing
-- body
@ -157,9 +153,10 @@ instance Serialize Packet where
putWord32le head
case pktOrigin of
Just o -> putAmesAddress o
Just o -> put o
Nothing -> pure ()
putByteString body
where
putShipGetRank s@(Ship (LargeKey p q)) = case () of
_ | s < 2 ^ 16 -> (0, putWord16le $ fromIntegral s) -- lord

View File

@ -4,8 +4,12 @@
1. Opens a UDP socket and makes sure that it stays open.
- If can't open the port, wait and try again repeatedly.
- If there is an error reading or writting from the open socket,
close it and open another.
- If there is an error reading to or writing from the open socket,
close it and open another, making sure, however, to reuse the
same port
NOTE: It's not clear what, if anything, closing and reopening
the socket does. We're keeping this behavior out of conservatism
until we understand it better.
2. Receives packets from the socket.
@ -158,7 +162,7 @@ realUdpServ
-> HostAddress
-> AmesStat
-> RIO e UdpServ
realUdpServ por hos sat = do
realUdpServ startPort hos sat = do
logInfo $ displayShow ("AMES", "UDP", "Starting real UDP server.")
env <- ask
@ -202,23 +206,30 @@ realUdpServ por hos sat = do
did <- atomically (tryWriteTBQueue qSend (a, b))
when (did == False) $ do
logWarn "AMES: UDP: Dropping outbound packet because queue is full."
let opener por = do
logInfo $ displayShow $ ("AMES", "UDP", "Trying to open socket, port",)
por
sk <- forceBind por hos
sn <- io $ getSocketName sk
sp <- io $ socketPort sk
logInfo $ displayShow $ ("AMES", "UDP", "Got socket", sn, sp)
tOpen <- async $ forever $ do
sk <- forceBind por hos
sn <- io $ getSocketName sk
let waitForRelease = do
atomically (writeTVar vSock (Just sk))
broken <- atomically (takeTMVar vFail)
logWarn "AMES: UDP: Closing broken socket."
io (close broken)
let waitForRelease = do
atomically (writeTVar vSock (Just sk))
broken <- atomically (takeTMVar vFail)
logWarn "AMES: UDP: Closing broken socket."
io (close broken)
case sn of
(SockAddrInet boundPort _) ->
-- When we're on IPv4, maybe port forward at the NAT.
rwith (requestPortAccess $ fromIntegral boundPort) $
\() -> waitForRelease
_ -> waitForRelease
case sn of
(SockAddrInet boundPort _) ->
-- When we're on IPv4, maybe port forward at the NAT.
rwith (requestPortAccess $ fromIntegral boundPort) $
\() -> waitForRelease
_ -> waitForRelease
opener sp
tOpen <- async $ opener startPort
tSend <- async $ forever $ join $ atomically $ do
(adr, byt) <- readTBQueue qSend

View File

@ -37,12 +37,12 @@ textPlain = Path [(MkKnot "text"), (MkKnot "plain")]
-- | Filter for dotfiles, tempfiles and backup files.
validClaySyncPath :: FilePath -> Bool
validClaySyncPath fp = hasPeriod && notTildeFile && notDotHash && notDoubleHash
validClaySyncPath fp = hasPeriod && notTildeFile && notDotFile && notDoubleHash
where
fileName = takeFileName fp
hasPeriod = elem '.' fileName
notTildeFile = not $ "~" `isSuffixOf` fileName
notDotHash = not $ ".#" `isPrefixOf` fileName
notDotFile = not $ "." `isPrefixOf` fileName
notDoubleHash =
not $ ("#" `isPrefixOf` fileName) && ("#" `isSuffixOf` fileName)

View File

@ -1,5 +1,5 @@
name: urbit-king
version: 1.3
version: 1.5
license: MIT
license-file: LICENSE
data-files:

View File

@ -111,7 +111,7 @@ module.exports = {
]
}
},
exclude: /node_modules\/(?!(@tlon\/indigo-dark|@tlon\/indigo-light)\/).*/
exclude: /node_modules\/(?!(@tlon\/indigo-dark|@tlon\/indigo-light|@tlon\/indigo-react)\/).*/
},
{
test: /\.css$/i,

View File

@ -30,7 +30,7 @@ module.exports = {
]
}
},
exclude: /node_modules\/(?!(@tlon\/indigo-dark|@tlon\/indigo-light)\/).*/
exclude: /node_modules\/(?!(@tlon\/indigo-dark|@tlon\/indigo-light|@tlon\/indigo-react)\/).*/
},
{
test: /\.css$/i,

File diff suppressed because it is too large Load Diff

View File

@ -9,8 +9,8 @@
"@reach/menu-button": "^0.10.5",
"@reach/tabs": "^0.10.5",
"@tlon/indigo-dark": "^1.0.6",
"@tlon/indigo-light": "^1.0.6",
"@tlon/indigo-react": "^1.2.19",
"@tlon/indigo-light": "^1.0.7",
"@tlon/indigo-react": "^1.2.21",
"@tlon/sigil-js": "^1.4.3",
"@urbit/api": "file:../npm/api",
"any-ascii": "^0.1.7",
@ -88,7 +88,7 @@
"react-hot-loader": "^4.13.0",
"sass": "^1.32.5",
"sass-loader": "^8.0.2",
"typescript": "^3.9.7",
"typescript": "^4.2.4",
"webpack": "^4.46.0",
"webpack-cli": "^3.3.12",
"webpack-dev-server": "^3.11.2"

View File

@ -2,6 +2,7 @@ import BaseApi from './base';
import { StoreState } from '../store/type';
import { Patp } from '@urbit/api';
import { ContactEdit } from '@urbit/api/contacts';
import _ from 'lodash';
export default class ContactsApi extends BaseApi<StoreState> {
add(ship: Patp, contact: any) {
@ -73,6 +74,28 @@ export default class ContactsApi extends BaseApi<StoreState> {
);
}
async disallowedShipsForOurContact(ships: string[]): Promise<string[]> {
return _.compact(
await Promise.all(
ships.map(
async s => {
const ship = `~${s}`;
if(s === window.ship) {
return null
}
const allowed = await this.fetchIsAllowed(
`~${window.ship}`,
'personal',
ship,
true
)
return allowed ? null : ship;
}
)
)
);
}
retrieve(ship: string) {
const resource = { ship, name: '' };
return this.action('contact-pull-hook', 'pull-hook-action', {

View File

@ -1,234 +0,0 @@
import bigInt, { BigInteger } from 'big-integer';
import { immerable } from 'immer';
interface NonemptyNode<V> {
n: [BigInteger, V];
l: MapNode<V>;
r: MapNode<V>;
}
type MapNode<V> = NonemptyNode<V> | null;
/**
* An implementation of ordered maps for JS
* Plagiarised wholesale from sys/zuse
*/
export class BigIntOrderedMap<V> implements Iterable<[BigInteger, V]> {
private root: MapNode<V> = null;
[immerable] = true;
size = 0;
constructor(initial: [BigInteger, V][] = []) {
initial.forEach(([key, val]) => {
this.set(key, val);
});
}
/**
* Retrieve an value for a key
*/
get(key: BigInteger): V | null {
const inner = (node: MapNode<V>) => {
if (!node) {
return node;
}
const [k, v] = node.n;
if (key.eq(k)) {
return v;
}
if (key.gt(k)) {
return inner(node.l);
} else {
return inner(node.r);
}
};
return inner(this.root);
}
/**
* Put an item by a key
*/
set(key: BigInteger, value: V): void {
const inner = (node: MapNode<V>) => {
if (!node) {
return {
n: [key, value],
l: null,
r: null
};
}
const [k] = node.n;
if (key.eq(k)) {
this.size--;
return {
...node,
n: [k, value]
};
}
if (key.gt(k)) {
const l = inner(node.l);
if (!l) {
throw new Error('invariant violation');
}
return {
...node,
l
};
}
const r = inner(node.r);
if (!r) {
throw new Error('invariant violation');
}
return { ...node, r };
};
this.size++;
this.root = inner(this.root);
}
/**
* Remove all entries
*/
clear() {
this.root = null;
}
/**
* Predicate testing if map contains key
*/
has(key: BigInteger): boolean {
const inner = (node: MapNode<V>) => {
if (!node) {
return false;
}
const [k] = node.n;
if (k.eq(key)) {
return true;
}
if (key.gt(k)) {
return inner(node.l);
}
return inner(node.r);
};
return inner(this.root);
}
/**
* Remove value associated with key, returning whether that key
* existed in the first place
*/
delete(key: BigInteger) {
const inner = (node: MapNode<V>): [boolean, MapNode<V>] => {
if (!node) {
return [false, null];
}
const [k] = node.n;
if (k.eq(key)) {
return [true, this.nip(node)];
}
if (key.gt(k)) {
const [bool, l] = inner(node.l);
return [
bool,
{
...node,
l
}
];
}
const [bool, r] = inner(node.r);
return [
bool,
{
...node,
r
}
];
};
const [ret, newRoot] = inner(this.root);
if(ret) {
this.size--;
}
this.root = newRoot;
return ret;
}
private nip(nod: NonemptyNode<V>): MapNode<V> {
const inner = (node: NonemptyNode<V>) => {
if (!node.l) {
return node.r;
}
if (!node.r) {
return node.l;
}
return {
...node.l,
r: inner(node.r)
};
};
return inner(nod);
}
peekLargest(): [BigInteger, V] | undefined {
const inner = (node: MapNode<V>) => {
if(!node) {
return undefined;
}
if(node.l) {
return inner(node.l);
}
return node.n;
};
return inner(this.root);
}
peekSmallest(): [BigInteger, V] | undefined {
const inner = (node: MapNode<V>) => {
if(!node) {
return undefined;
}
if(node.r) {
return inner(node.r);
}
return node.n;
};
return inner(this.root);
}
keys(): BigInteger[] {
const list = Array.from(this);
return list.map(([key]) => key);
}
forEach(f: (value: V, key: BigInteger) => void) {
const list = Array.from(this);
return list.forEach(([k,v]) => f(v,k));
}
[Symbol.iterator](): IterableIterator<[BigInteger, V]> {
const result: [BigInteger, V][] = [];
const inner = (node: MapNode<V>) => {
if (!node) {
return;
}
inner(node.l);
result.push(node.n);
inner(node.r);
};
inner(this.root);
let idx = 0;
return {
[Symbol.iterator]: this[Symbol.iterator],
next: (): IteratorResult<[BigInteger, V]> => {
if (idx < result.length) {
return { value: result[idx++], done: false };
}
return { done: true, value: null };
}
};
}
}

View File

@ -99,9 +99,11 @@ export default function index(contacts, associations, apps, currentGroup, groups
Object.keys(associations).filter((e) => {
// skip apps with no metadata
return Object.keys(associations[e]).length > 0;
}).map((e) => {
// iterate through each app's metadata object
Object.keys(associations[e]).map((association) => {
}).map((e) => {
// iterate through each app's metadata object
Object.keys(associations[e])
.filter((association) => !associations?.[e]?.[association]?.metadata?.hidden)
.map((association) => {
const each = associations[e][association];
let title = each.resource;
if (each.metadata.title !== '') {

View File

@ -1,13 +1,13 @@
import { Post, GraphNode, TextContent, Graph, NodeMap } from '@urbit/api';
import { Post, GraphNode, TextContent } from '@urbit/api';
import { buntPost } from '~/logic/lib/post';
import { unixToDa } from '~/logic/lib/util';
import { BigIntOrderedMap } from './BigIntOrderedMap';
import BigIntOrderedMap from "@urbit/api/lib/BigIntOrderedMap";
import bigInt, { BigInteger } from 'big-integer';
export function newPost(
title: string,
body: string
): [BigInteger, NodeMap] {
): [BigInteger, any] {
const now = Date.now();
const nowDa = unixToDa(now);
const root: Post = {
@ -73,13 +73,16 @@ export function editPost(rev: number, noteId: BigInteger, title: string, body: s
}
export function getLatestRevision(node: GraphNode): [number, string, string, Post] {
const revs = node.children.get(bigInt(1));
const revs = node.children?.get(bigInt(1));
const empty = [1, '', '', buntPost()] as [number, string, string, Post];
if(!revs) {
return empty;
}
const [revNum, rev] = [...revs.children][0];
if(!rev) {
let revNum, rev;
if (revs?.children !== null) {
[revNum, rev] = [...revs.children][0];
}
if (!rev) {
return empty;
}
const [title, body] = rev.post.contents as TextContent[];
@ -88,18 +91,22 @@ export function getLatestRevision(node: GraphNode): [number, string, string, Pos
export function getLatestCommentRevision(node: GraphNode): [number, Post] {
const empty = [1, buntPost()] as [number, Post];
if (node.children.size <= 0) {
const childSize = node?.children?.size ?? 0;
if (childSize <= 0) {
return empty;
}
const [revNum, rev] = [...node.children][0];
if(!rev) {
let revNum, rev;
if (node?.children !== null) {
[revNum, rev] = [...node.children][0];
}
if (!rev) {
return empty;
}
return [revNum.toJSNumber(), rev.post];
}
export function getComments(node: GraphNode): GraphNode {
const comments = node.children.get(bigInt(2));
const comments = node.children?.get(bigInt(2));
if(!comments) {
return { post: buntPost(), children: new BigIntOrderedMap() };
}

View File

@ -1,4 +1,5 @@
import { TutorialProgress, Associations } from '@urbit/api';
import { Associations } from '@urbit/api';
import { TutorialProgress } from '~/types';
import { AlignX, AlignY } from '~/logic/lib/relativePosition';
import { Direction } from '~/views/components/Triangle';
@ -22,7 +23,7 @@ interface StepDetail {
alignY: AlignY | AlignY[];
offsetX: number;
offsetY: number;
arrow: Direction;
arrow?: Direction;
}
export function hasTutorialGroup(props: { associations: Associations }) {

View File

@ -15,7 +15,8 @@ function retrieve<T>(key: string, initial: T): T {
interface SetStateFunc<T> {
(t: T): T;
}
type SetState<T> = T | SetStateFunc<T>;
// See microsoft/typescript#37663 for filed bug
type SetState<T> = T extends any ? SetStateFunc<T> : never;
export function useLocalStorageState<T>(key: string, initial: T) {
const [state, _setState] = useState(() => retrieve(key, initial));

View File

@ -2,18 +2,14 @@ import React, {
useState,
ReactNode,
useCallback,
SyntheticEvent,
useMemo,
useEffect,
useRef
} from 'react';
import { Box } from '@tlon/indigo-react';
import { useOutsideClick } from './useOutsideClick';
import { ModalOverlay } from '~/views/components/ModalOverlay';
import { Portal } from '~/views/components/Portal';
import { ModalPortal } from '~/views/components/ModalPortal';
import { PropFunc } from '@urbit/api';
import { PropFunc } from '~/types';
type ModalFunc = (dismiss: () => void) => JSX.Element;
interface UseModalProps {

View File

@ -1,5 +1,5 @@
import { useRef } from 'react';
import { Primitive } from '@urbit/api';
import { Primitive } from '~/types';
export default function usePreviousValue<T extends Primitive>(value: T): T {
const prev = useRef<T | null>(null);

View File

@ -1,5 +1,4 @@
import { useState, useEffect } from "react";
import { useWaitForProps } from "./useWaitForProps";
import {unstable_batchedUpdates} from "react-dom";
export type IOInstance<I, P, O> = (
@ -10,7 +9,7 @@ export function useRunIO<I, O>(
io: (i: I) => Promise<O>,
after: (o: O) => void,
key: string
): () => Promise<void> {
): (i: I) => Promise<unknown> {
const [resolve, setResolve] = useState<() => void>(() => () => {});
const [reject, setReject] = useState<(e: any) => void>(() => () => {});
const [output, setOutput] = useState<O | null>(null);

View File

@ -5,7 +5,7 @@ export function useStatelessAsyncClickable(
onClick: (e: MouseEvent) => Promise<void>,
name: string
) {
const [state, setState] = useState<ButtonState>('waiting');
const [state, setState] = useState<AsyncClickableState>('waiting');
const handleClick = useCallback(
async (e: MouseEvent) => {
try {

View File

@ -16,7 +16,7 @@ export interface IuseStorage {
upload: (file: File, bucket: string) => Promise<string>;
uploadDefault: (file: File) => Promise<string>;
uploading: boolean;
promptUpload: () => Promise<string | undefined>;
promptUpload: () => Promise<unknown>;
}
const useStorage = ({ accept = '*' } = { accept: '*' }): IuseStorage => {

View File

@ -30,7 +30,7 @@ export const getModuleIcon = (mod: string) => {
}
if (mod === 'post') {
return 'Spaces';
return 'Dashboard';
}
return _.capitalize(mod);
@ -192,7 +192,10 @@ export function uxToHex(ux: string) {
export const hexToUx = (hex) => {
const ux = f.flow(
f.chunk(4),
f.map(x => _.dropWhile(x, y => y === 0).join('')),
// eslint-disable-next-line prefer-arrow-callback
f.map(x => _.dropWhile(x, function(y: unknown) {
return y === 0;
}).join('')),
f.join('.')
)(hex.split(''));
return `0x${ux}`;
@ -417,7 +420,7 @@ export const useHovering = (): useHoveringInterface => {
onMouseLeave,
}), [onMouseLeave, onMouseOver]);
return useMemo(() => ({ hovering, bind }), [hovering, bind]);
};

View File

@ -7,6 +7,7 @@ import React, {
useEffect,
} from "react";
import usePreviousValue from "./usePreviousValue";
import {Primitive} from "~/types";
export interface VirtualContextProps {
save: () => void;
@ -42,14 +43,14 @@ export function useVirtualResizeState(s: boolean) {
[_setState, save]
);
useLayoutEffect(() => {
restore();
useEffect(() => {
requestAnimationFrame(restore);
}, [state]);
return [state, setState] as const;
}
export function useVirtualResizeProp<T>(prop: T) {
export function useVirtualResizeProp(prop: Primitive) {
const { save, restore } = useVirtual();
const oldProp = usePreviousValue(prop)
@ -57,8 +58,8 @@ export function useVirtualResizeProp<T>(prop: T) {
save();
}
useLayoutEffect(() => {
restore();
useEffect(() => {
requestAnimationFrame(restore);
}, [prop]);

View File

@ -1,4 +1,5 @@
import { Associations, Workspace } from '@urbit/api';
import { Associations } from '@urbit/api';
import { Workspace } from '~/types';
export function getTitleFromWorkspace(
associations: Associations,

View File

@ -1,5 +1,4 @@
import _ from 'lodash';
import { StoreState } from '../../store/type';
import { StoreState } from '../store/type';
import { Cage } from '~/types/cage';
type LocalState = Pick<StoreState, 'connection'>;

View File

@ -1,5 +1,6 @@
import _ from 'lodash';
import { BigIntOrderedMap } from "~/logic/lib/BigIntOrderedMap";
import BigIntOrderedMap from "@urbit/api/lib/BigIntOrderedMap";
import produce from 'immer';
import bigInt, { BigInteger } from "big-integer";
import useGraphState, { GraphState } from '../state/graph';
import { reduceState } from '../state/base';
@ -51,23 +52,18 @@ const keys = (json, state: GraphState): GraphState => {
const processNode = (node) => {
// is empty
if (!node.children) {
node.children = new BigIntOrderedMap();
return node;
return produce(node, draft => {
draft.children = new BigIntOrderedMap();
});
}
// is graph
let converted = new BigIntOrderedMap();
for (let idx in node.children) {
let item = node.children[idx];
let index = bigInt(idx);
converted.set(
index,
processNode(item)
);
}
node.children = converted;
return node;
return produce(node, draft => {
draft.children = new BigIntOrderedMap()
.gas(_.map(draft.children, (item, idx) =>
[bigInt(idx), processNode(item)] as [BigInteger, any]
));
});
};
@ -85,17 +81,10 @@ const addGraph = (json, state: GraphState): GraphState => {
state.graphTimesentMap[resource] = {};
for (let idx in data.graph) {
let item = data.graph[idx];
let index = bigInt(idx);
let node = processNode(item);
state.graphs[resource] = state.graphs[resource].gas(Object.keys(data.graph).map(idx => {
return [bigInt(idx), processNode(data.graph[idx])];
}));
state.graphs[resource].set(
index,
node
);
}
state.graphKeys.add(resource);
}
return state;
@ -116,7 +105,7 @@ const removeGraph = (json, state: GraphState): GraphState => {
};
const mapifyChildren = (children) => {
return new BigIntOrderedMap(
return new BigIntOrderedMap().gas(
_.map(children, (node, idx) => {
idx = idx && idx.startsWith('/') ? idx.slice(1) : idx;
const nd = {...node, children: mapifyChildren(node.children || {}) };
@ -128,8 +117,7 @@ const addNodes = (json, state) => {
const _addNode = (graph, index, node) => {
// set child of graph
if (index.length === 1) {
graph.set(index[0], node);
return graph;
return graph.set(index[0], node);
}
// set parent of graph
@ -138,19 +126,20 @@ const addNodes = (json, state) => {
console.error('parent node does not exist, cannot add child');
return graph;
}
parNode.children = _addNode(parNode.children, index.slice(1), node);
graph.set(index[0], parNode);
return graph;
return graph.set(index[0], produce(parNode, draft => {
draft.children = _addNode(draft.children, index.slice(1), node);
}));
};
const _remove = (graph, index) => {
if (index.length === 1) {
graph.delete(index[0]);
return graph.delete(index[0]);
} else {
const child = graph.get(index[0]);
if (child) {
child.children = _remove(child.children, index.slice(1));
graph.set(index[0], child);
return graph.set(index[0], produce(child, draft => {
draft.children = _remove(draft.children, index.slice(1));
}));
}
}
@ -166,10 +155,9 @@ const addNodes = (json, state) => {
return bigInt(ind);
});
graph = _remove(graph, indexArr);
delete state.graphTimesentMap[resource][timestamp];
return _remove(graph, indexArr);
}
return graph;
};
@ -208,11 +196,12 @@ const addNodes = (json, state) => {
return aArr.length - bArr.length;
});
let graph = state.graphs[resource];
indices.forEach((index) => {
let node = data.nodes[index];
graph = _removePending(graph, node.post, resource);
const old = state.graphs[resource].size;
state.graphs[resource] = _removePending(state.graphs[resource], node.post, resource);
const newSize = state.graphs[resource].size;
if (index.split('/').length === 0) { return; }
let indexArr = index.split('/').slice(1).map((ind) => {
@ -225,17 +214,21 @@ const addNodes = (json, state) => {
state.graphTimesentMap[resource][node.post['time-sent']] = index;
}
node.children = mapifyChildren(node?.children || {});
graph = _addNode(
graph,
state.graphs[resource] = _addNode(
state.graphs[resource],
indexArr,
node
produce(node, draft => {
draft.children = mapifyChildren(draft?.children || {});
})
);
if(newSize !== old) {
console.log(`${resource}, (${old}, ${newSize}, ${state.graphs[resource].size})`);
}
});
state.graphs[resource] = graph;
}
return state;
};
@ -243,13 +236,15 @@ const addNodes = (json, state) => {
const removeNodes = (json, state: GraphState): GraphState => {
const _remove = (graph, index) => {
if (index.length === 1) {
graph.delete(index[0]);
return graph.delete(index[0]);
} else {
const child = graph.get(index[0]);
if (child) {
_remove(child.children, index.slice(1));
graph.set(index[0], child);
return graph.set(index[0], produce(draft => {
draft.children = _remove(draft.children, index.slice(1))
}));
}
return graph;
}
};
@ -264,7 +259,7 @@ const removeNodes = (json, state: GraphState): GraphState => {
let indexArr = index.split('/').slice(1).map((ind) => {
return bigInt(ind);
});
_remove(state.graphs[res], indexArr);
state.graphs[res] = _remove(state.graphs[res], indexArr);
});
}
return state;

View File

@ -5,16 +5,14 @@ import {
Group,
Tags,
GroupPolicy,
GroupPolicyDiff,
OpenPolicyDiff,
OpenPolicy,
InvitePolicyDiff,
InvitePolicy
} from '@urbit/api/groups';
import { Enc, PatpNoSig } from '@urbit/api';
import { Enc } from '@urbit/api';
import { resourceAsPath } from '../lib/util';
import useGroupState, { GroupState } from '../state/group';
import { compose } from 'lodash/fp';
import { reduceState } from '../state/base';
function decodeGroup(group: Enc<Group>): Group {
@ -125,9 +123,9 @@ const addMembers = (json: GroupUpdate, state: GroupState): GroupState => {
state.groups[resourcePath].members.add(member);
if (
'invite' in state.groups[resourcePath].policy &&
state.groups[resourcePath].policy.invite.pending.has(member)
state.groups[resourcePath].policy['invite'].pending.has(member)
) {
state.groups[resourcePath].policy.invite.pending.delete(member)
state.groups[resourcePath].policy['invite'].pending.delete(member);
}
}
}
@ -159,7 +157,7 @@ const addTag = (json: GroupUpdate, state: GroupState): GroupState => {
_.set(tags, tagAccessors, tagged);
}
return state;
}
};
const removeTag = (json: GroupUpdate, state: GroupState): GroupState => {
if ('removeTag' in json) {

View File

@ -1,18 +1,14 @@
import {
Notifications,
NotifIndex,
NotificationGraphConfig,
GroupNotificationsConfig,
UnreadStats,
Timebox
} from '@urbit/api';
import { makePatDa } from '~/logic/lib/util';
import _ from 'lodash';
import { BigIntOrderedMap } from '../lib/BigIntOrderedMap';
import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap';
import useHarkState, { HarkState } from '../state/hark';
import { compose } from 'lodash/fp';
import { reduceState } from '../state/base';
import bigInt, {BigInteger} from 'big-integer';
import {BigInteger} from 'big-integer';
export const HarkReducer = (json: any) => {
const data = _.get(json, 'harkUpdate', false);
@ -151,7 +147,7 @@ function graphWatchSelf(json: any, state: HarkState): HarkState {
function readAll(json: any, state: HarkState): HarkState {
const data = _.get(json, 'read-all');
if(data) {
if(data) {
clearState(state);
}
return state;
@ -264,7 +260,7 @@ function updateUnreads(state: HarkState, index: NotifIndex, f: (us: Set<string>)
if(!('graph' in index)) {
return state;
}
let unreads = _.get(state.unreads.graph, [index.graph.graph, index.graph.index, 'unreads'], new Set<string>());
let unreads: any = _.get(state.unreads.graph, [index.graph.graph, index.graph.index, 'unreads'], new Set<string>());
f(unreads);
_.set(state.unreads.graph, [index.graph.graph, index.graph.index, 'unreads'], unreads);
@ -278,7 +274,7 @@ function addNotificationToUnread(state: HarkState, index: NotifIndex, time: BigI
_.set(state.unreads.graph, path,
[
...curr.filter(c => !(c.time.eq(time) && notifIdxEqual(c.index, index))),
{ time, index}
{ time, index }
]
);
} else if ('group' in index) {
@ -287,7 +283,7 @@ function addNotificationToUnread(state: HarkState, index: NotifIndex, time: BigI
_.set(state.unreads.group, path,
[
...curr.filter(c => !(c.time.eq(time) && notifIdxEqual(c.index, index))),
{ time, index}
{ time, index }
]
);
}
@ -312,10 +308,10 @@ function removeNotificationFromUnread(state: HarkState, index: NotifIndex, time:
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);
const curr: any = _.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);
const curr: any = _.get(state.unreads.group, [index.group.group, statField], 0);
_.set(state.unreads.group, [index.group.group, statField], f(curr));
}
}
@ -333,9 +329,9 @@ function added(json: any, state: HarkState): HarkState {
);
if (arrIdx !== -1) {
timebox[arrIdx] = { index, notification };
state.notifications.set(time, timebox);
state.notifications = state.notifications.set(time, timebox);
} else {
state.notifications.set(time, [...timebox, { index, notification }]);
state.notifications = state.notifications.set(time, [...timebox, { index, notification }]);
}
}
return state;
@ -354,7 +350,7 @@ const timebox = (json: any, state: HarkState): HarkState => {
if (data) {
const time = makePatDa(data.time);
if (!data.archive) {
state.notifications.set(time, data.notifications);
state.notifications = state.notifications.set(time, data.notifications);
}
}
return state;
@ -407,7 +403,7 @@ function setRead(
return state;
}
timebox[arrIdx].notification.read = read;
state.notifications.set(patDa, timebox);
state.notifications = state.notifications.set(patDa, timebox);
return state;
}

View File

@ -18,7 +18,7 @@ export default class LaunchReducer {
]);
}
const weatherData: WeatherState = _.get(json, 'weather', false);
const weatherData: WeatherState | boolean | Record<string, never> = _.get(json, 'weather', false);
if (weatherData) {
useLaunchState.getState().set(state => {
state.weather = weatherData;

View File

@ -1,7 +1,8 @@
import _ from 'lodash';
import useSettingsState, { SettingsState } from "~/logic/state/settings";
import { SettingsUpdate } from '@urbit/api/dist/settings';
import useSettingsState, { SettingsState } from '~/logic/state/settings';
import { SettingsUpdate } from '@urbit/api/settings';
import { reduceState } from '../state/base';
import { string } from 'prop-types';
export default class SettingsReducer {
reduce(json: any) {
@ -40,21 +41,21 @@ export default class SettingsReducer {
return state;
}
putEntry(json: SettingsUpdate, state: SettingsState): SettingsState {
const data = _.get(json, 'put-entry', false);
putEntry(json: SettingsUpdate, state: any): SettingsState {
const data: Record<string, string> = _.get(json, 'put-entry', false);
if (data) {
if (!state[data["bucket-key"]]) {
state[data["bucket-key"]] = {};
if (!state[data['bucket-key']]) {
state[data['bucket-key']] = {};
}
state[data["bucket-key"]][data["entry-key"]] = data.value;
state[data['bucket-key']][data['entry-key']] = data.value;
}
return state;
}
delEntry(json: SettingsUpdate, state: SettingsState): SettingsState {
delEntry(json: SettingsUpdate, state: any): SettingsState {
const data = _.get(json, 'del-entry', false);
if (data) {
delete state[data["bucket-key"]][data["entry-key"]];
delete state[data['bucket-key']][data['entry-key']];
}
return state;
}
@ -76,7 +77,7 @@ export default class SettingsReducer {
return state;
}
getEntry(json: any, state: SettingsState) {
getEntry(json: any, state: any) {
const bucketKey = _.get(json, 'bucket-key', false);
const entryKey = _.get(json, 'entry-key', false);
const entry = _.get(json, 'entry', false);

View File

@ -1,8 +1,10 @@
import produce from "immer";
import produce, { setAutoFreeze } from "immer";
import { compose } from "lodash/fp";
import create, { State, UseStore } from "zustand";
import { persist, devtools } from "zustand/middleware";
setAutoFreeze(false);
export const stateSetter = <StateType>(
fn: (state: StateType) => void,

View File

@ -35,4 +35,8 @@ export function useContact(ship: string) {
);
}
export function useOurContact() {
return useContact(`~${window.ship}`)
}
export default useContactState;

View File

@ -1,4 +1,5 @@
import { Graphs, decToUd, numToUd, GraphNode } from "@urbit/api";
import { Graphs, decToUd, numToUd, GraphNode, deSig, Association, resourceFromPath } from "@urbit/api";
import {useCallback} from "react";
import { BaseState, createState } from "./base";
@ -128,6 +129,20 @@ const useGraphState = createState<GraphState>('Graph', {
// });
// graphReducer(node);
// },
}, ['graphs', 'graphKeys', 'looseNodes']);
}, ['graphs', 'graphKeys', 'looseNodes', 'graphTimesentMap']);
export function useGraph(ship: string, name: string) {
return useGraphState(
useCallback(s => s.graphs[`${deSig(ship)}/${name}`], [ship, name])
);
}
export function useGraphForAssoc(association: Association) {
const { resource } = association;
const { ship, name } = resourceFromPath(resource);
return useGraph(ship, name);
}
window.useGraphState = useGraphState;
export default useGraphState;

View File

@ -9,7 +9,7 @@ export interface LaunchState extends BaseState<LaunchState> {
tiles: {
[app: string]: Tile;
},
weather: WeatherState | null,
weather: WeatherState | null | Record<string, never> | boolean,
userLocation: string | null;
baseHash: string | null;
};

View File

@ -90,8 +90,8 @@ const useLocalState = create<LocalStateZus>(persist((set, get) => ({
name: 'localReducer'
}));
function withLocalState<P, S extends keyof LocalState>(Component: any, stateMemberKeys?: S[]) {
return React.forwardRef((props: Omit<P, S>, ref) => {
function withLocalState<P, S extends keyof LocalState, C extends React.ComponentType<P>>(Component: C, stateMemberKeys?: S[]) {
return React.forwardRef<C, Omit<P, S>>((props, ref) => {
const localState = stateMemberKeys ? useLocalState(
state => stateMemberKeys.reduce(
(object, key) => ({ ...object, [key]: state[key] }), {}

View File

@ -3,10 +3,8 @@ import _ from 'lodash';
import BaseStore from './base';
import InviteReducer from '../reducers/invite-update';
import MetadataReducer from '../reducers/metadata-update';
import LocalReducer from '../reducers/local';
import { StoreState } from './type';
import { Timebox } from '@urbit/api';
import { Cage } from '~/types/cage';
import S3Reducer from '../reducers/s3-update';
import { GraphReducer } from '../reducers/graph-update';
@ -17,8 +15,6 @@ import LaunchReducer from '../reducers/launch-update';
import ConnectionReducer from '../reducers/connection';
import SettingsReducer from '../reducers/settings-update';
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';

View File

@ -1,8 +1,14 @@
import React, { useRef, useCallback, useEffect, useState } from 'react';
import React, {
useRef,
useCallback,
useEffect,
useState,
useMemo,
} from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { Col } from '@tlon/indigo-react';
import _ from 'lodash';
import bigInt from 'big-integer';
import bigInt, { BigInteger } from 'big-integer';
import { Association } from '@urbit/api/metadata';
import { StoreState } from '~/logic/store/type';
@ -16,176 +22,148 @@ import { useLocalStorageState } from '~/logic/lib/useLocalStorageState';
import { Loading } from '~/views/components/Loading';
import { isWriter, resourceFromPath } from '~/logic/lib/group';
import './css/custom.css';
import useContactState from '~/logic/state/contact';
import useGraphState from '~/logic/state/graph';
import useGroupState from '~/logic/state/group';
import useGraphState, { useGraphForAssoc } from '~/logic/state/graph';
import useGroupState, { useGroupForAssoc } from '~/logic/state/group';
import useHarkState from '~/logic/state/hark';
import {Post} from '@urbit/api';
import {getPermalinkForGraph} from '~/logic/lib/permalinks';
import { Content, createPost, Post } from '@urbit/api';
import { getPermalinkForGraph } from '~/logic/lib/permalinks';
import { ChatPane } from './components/ChatPane';
const getCurrGraphSize = (ship: string, name: string) => {
const { graphs } = useGraphState.getState();
const graph = graphs[`${ship}/${name}`];
return graph?.size ?? 0;
};
type ChatResourceProps = StoreState & {
association: Association;
api: GlobalApi;
baseUrl: string;
} & RouteComponentProps;
};
export function ChatResource(props: ChatResourceProps) {
const station = props.association.resource;
const groupPath = props.association.group;
const groups = useGroupState(state => state.groups);
const group = groups[groupPath];
const contacts = useContactState(state => state.contacts);
const graphs = useGraphState(state => state.graphs);
const graphPath = station.slice(7);
const graph = graphs[graphPath];
const unreads = useHarkState(state => state.unreads);
const unreadCount = unreads.graph?.[station]?.['/']?.unreads || 0;
const graphTimesentMap = useGraphState(state => state.graphTimesentMap);
const [,, owner, name] = station.split('/');
const ourContact = contacts?.[`~${window.ship}`];
const chatInput = useRef<ChatInput>();
const canWrite = isWriter(group, station);
function ChatResource(props: ChatResourceProps) {
const { association, api } = props;
const { resource } = association;
const [toShare, setToShare] = useState<string[] | string | undefined>();
const group = useGroupForAssoc(association)!;
const graph = useGraphForAssoc(association);
const unreads = useHarkState((state) => state.unreads);
const unreadCount =
(unreads.graph?.[resource]?.['/']?.unreads as number) || 0;
const canWrite = group ? isWriter(group, resource) : false;
useEffect(() => {
const count = 100 + unreadCount;
props.api.graph.getNewest(owner, name, count);
}, [station]);
const onFileDrag = useCallback(
(files: FileList | File[]) => {
if (!chatInput.current) {
return;
}
chatInput.current?.uploadFiles(files);
},
[chatInput.current]
);
const { bind, dragging } = useFileDrag(onFileDrag);
const [unsent, setUnsent] = useLocalStorageState<Record<string, string>>(
'chat-unsent',
{}
);
const appendUnsent = useCallback(
(u: string) => setUnsent(s => ({ ...s, [station]: u })),
[station]
);
const clearUnsent = useCallback(
() => setUnsent(s => _.omit(s, station)),
[station]
);
const scrollTo = new URLSearchParams(location.search).get('msg');
const [showBanner, setShowBanner] = useState(false);
const [hasLoadedAllowed, setHasLoadedAllowed] = useState(false);
const [recipients, setRecipients] = useState([]);
const res = resourceFromPath(groupPath);
const onReply = useCallback((msg: Post) => {
const url = getPermalinkForGraph(
props.association.group,
props.association.resource,
msg.index
);
const message = `${url}\n~${msg.author} : `;
setUnsent(s => ({...s, [props.association.resource]: message }));
}, [props.association, group, setUnsent]);
useEffect(() => {
(async () => {
if (!res) { return; }
if (!group) { return; }
if (group.hidden) {
const members = _.compact(await Promise.all(
const count = Math.min(400, 100 + unreadCount);
const { ship, name } = resourceFromPath(resource);
props.api.graph.getNewest(ship, name, count);
setToShare(undefined);
(async function() {
if(group.hidden) {
const members = await props.api.contacts.disallowedShipsForOurContact(
Array.from(group.members)
.map(s => {
const ship = `~${s}`;
if(s === window.ship) {
return Promise.resolve(null);
}
return props.api.contacts.fetchIsAllowed(
`~${window.ship}`,
'personal',
ship,
true
).then(isAllowed => {
return isAllowed ? null : ship;
});
})
));
);
if(members.length > 0) {
setShowBanner(true);
setRecipients(members);
} else {
setShowBanner(false);
setToShare(members);
}
} else {
const groupShared = await props.api.contacts.fetchIsAllowed(
const { ship: groupHost } = resourceFromPath(association.group);
const shared = await props.api.contacts.fetchIsAllowed(
`~${window.ship}`,
'personal',
res.ship,
groupHost,
true
);
setShowBanner(!groupShared);
if(!shared) {
setToShare(association.group);
}
}
setHasLoadedAllowed(true);
})();
}, [groupPath, group]);
}, [resource]);
if(!graph) {
const onReply = useCallback(
(msg: Post) => {
const url = getPermalinkForGraph(
props.association.group,
props.association.resource,
msg.index
);
return `${url}\n~${msg.author} : `;
},
[association]
);
const isAdmin = useMemo(
() => (group ? group.tags.role.admin.has(`~${window.ship}`) : false),
[group]
);
const fetchMessages = useCallback(async (newer: boolean) => {
const { api } = props;
const pageSize = 100;
const [, , ship, name] = resource.split('/');
const graphSize = graph?.size ?? 0;
const expectedSize = graphSize + pageSize;
if (newer) {
const index = graph.peekLargest()?.[0];
if(!index) {
return true;
}
await api.graph.getYoungerSiblings(
ship,
name,
pageSize,
`/${index.toString()}`
);
return expectedSize !== getCurrGraphSize(ship.slice(1), name);
} else {
const index = graph.peekSmallest()?.[0];
if(!index) {
return true;
}
await api.graph.getOlderSiblings(ship, name, pageSize, `/${index.toString()}`);
const done = expectedSize !== getCurrGraphSize(ship.slice(1), name);
return done;
}
}, [graph, resource]);
const onSubmit = useCallback((contents: Content[]) => {
const { ship, name } = resourceFromPath(resource);
api.graph.addPost(ship, name, createPost(window.ship, contents))
}, [resource]);
const dismissUnread = useCallback(() => {
api.hark.markCountAsRead(association, '/', 'message');
}, [association]);
const getPermalink = useCallback(
(index: BigInteger) =>
getPermalinkForGraph(association.group, resource, `/${index.toString()}`),
[association]
);
if (!graph) {
return <Loading />;
}
return (
<Col {...bind} height="100%" overflow="hidden" position="relative">
<ShareProfile
our={ourContact}
api={props.api}
recipient={owner}
recipients={recipients}
showBanner={showBanner}
setShowBanner={setShowBanner}
group={group}
groupPath={groupPath}
/>
{dragging && <SubmitDragger />}
<ChatWindow
key={station}
history={props.history}
graph={graph}
graphSize={graph.size}
unreadCount={unreadCount}
showOurContact={ !showBanner && hasLoadedAllowed }
association={props.association}
pendingSize={Object.keys(graphTimesentMap[graphPath] || {}).length}
group={group}
ship={owner}
onReply={onReply}
station={station}
api={props.api}
scrollTo={scrollTo ? bigInt(scrollTo) : undefined}
/>
{ canWrite && (
<ChatInput
ref={chatInput}
api={props.api}
station={station}
ourContact={
(!showBanner && hasLoadedAllowed) ? ourContact : null
}
envelopes={[]}
onUnmount={appendUnsent}
placeholder="Message..."
message={unsent[station] || ''}
deleteMessage={clearUnsent}
/> )}
</Col>
<ChatPane
id={resource.slice(7)}
graph={graph}
unreadCount={unreadCount}
api={api}
canWrite={canWrite}
onReply={onReply}
fetchMessages={fetchMessages}
dismissUnread={dismissUnread}
getPermalink={getPermalink}
isAdmin={isAdmin}
onSubmit={onSubmit}
promptShare={toShare}
/>
);
}
export { ChatResource };

View File

@ -1,4 +1,4 @@
import React, { Component } from 'react';
import React, { Component, ReactNode } from 'react';
import ChatEditor from './chat-editor';
import { IuseStorage } from '~/logic/lib/useStorage';
import { uxToHex } from '~/logic/lib/util';
@ -8,28 +8,29 @@ import tokenizeMessage, { isUrl } from '~/logic/lib/tokenizeMessage';
import GlobalApi from '~/logic/api/global';
import { Envelope } from '~/types/chat-update';
import { StorageState } from '~/types';
import { Contacts, Content } from '@urbit/api';
import { Contact, Contacts, Content, Post } from '@urbit/api';
import { Row, BaseImage, Box, Icon, LoadingSpinner } from '@tlon/indigo-react';
import withStorage from '~/views/components/withStorage';
import { withLocalState } from '~/logic/state/local';
import { MOBILE_BROWSER_REGEX } from "~/logic/lib/util";
type ChatInputProps = IuseStorage & {
api: GlobalApi;
numMsgs: number;
station: unknown;
ourContact: unknown;
envelopes: Envelope[];
ourContact?: Contact;
onUnmount(msg: string): void;
placeholder: string;
message: string;
deleteMessage(): void;
hideAvatars: boolean;
onSubmit: (contents: Content[]) => void;
children?: ReactNode;
};
interface ChatInputState {
inCodeMode: boolean;
submitFocus: boolean;
uploadingPaste: boolean;
currentInput: string;
}
class ChatInput extends Component<ChatInputProps, ChatInputState> {
@ -41,7 +42,8 @@ class ChatInput extends Component<ChatInputProps, ChatInputState> {
this.state = {
inCodeMode: false,
submitFocus: false,
uploadingPaste: false
uploadingPaste: false,
currentInput: props.message
};
this.chatEditor = React.createRef();
@ -50,6 +52,7 @@ class ChatInput extends Component<ChatInputProps, ChatInputState> {
this.toggleCode = this.toggleCode.bind(this);
this.uploadSuccess = this.uploadSuccess.bind(this);
this.uploadError = this.uploadError.bind(this);
this.eventHandler = this.eventHandler.bind(this);
}
toggleCode() {
@ -58,39 +61,28 @@ class ChatInput extends Component<ChatInputProps, ChatInputState> {
});
}
submit(text) {
async submit(text) {
const { props, state } = this;
const [, , ship, name] = props.station.split('/');
if (state.inCodeMode) {
this.setState(
{
inCodeMode: false
},
async () => {
const output = await props.api.graph.eval(text);
const contents: Content[] = [{ code: { output, expression: text } }];
const post = createPost(contents);
props.api.graph.addPost(ship, name, post);
}
);
return;
}
const post = createPost(tokenizeMessage(text));
const { onSubmit, api } = this.props;
this.setState({
inCodeMode: false
});
props.deleteMessage();
props.api.graph.addPost(ship, name, post);
if(state.inCodeMode) {
const output = await api.graph.eval(text) as string[];
onSubmit([{ code: { output, expression: text } }]);
} else {
onSubmit(tokenizeMessage(text));
}
}
uploadSuccess(url) {
uploadSuccess(url: string) {
const { props } = this;
if (this.state.uploadingPaste) {
this.chatEditor.current.editor.setValue(url);
this.setState({ uploadingPaste: false });
} else {
const [, , ship, name] = props.station.split('/');
props.api.graph.addPost(ship, name, createPost([{ url }]));
props.onSubmit([{ url }])
}
}
@ -120,6 +112,10 @@ class ChatInput extends Component<ChatInputProps, ChatInputState> {
});
}
eventHandler(value) {
this.setState({ currentInput: value });
}
render() {
const { props, state } = this;
@ -130,6 +126,7 @@ class ChatInput extends Component<ChatInputProps, ChatInputState> {
const avatar =
props.ourContact && props.ourContact?.avatar && !props.hideAvatars ? (
<BaseImage
flexShrink={0}
src={props.ourContact.avatar}
height={24}
width={24}
@ -170,7 +167,7 @@ class ChatInput extends Component<ChatInputProps, ChatInputState> {
className='cf'
zIndex={0}
>
<Row p='12px 4px 12px 12px' alignItems='center'>
<Row p='12px 4px 12px 12px' flexShrink={0} alignItems='center'>
{avatar}
</Row>
<ChatEditor
@ -180,15 +177,25 @@ class ChatInput extends Component<ChatInputProps, ChatInputState> {
onUnmount={props.onUnmount}
message={props.message}
onPaste={this.onPaste.bind(this)}
changeEvent={this.eventHandler}
placeholder='Message...'
/>
<Box mx={2} flexShrink={0} height='16px' width='16px' flexBasis='16px'>
<Box mx='12px' flexShrink={0} height='16px' width='16px' flexBasis='16px'>
<Icon
icon='Dojo'
cursor='pointer'
onClick={this.toggleCode}
color={state.inCodeMode ? 'blue' : 'black'}
/>
</Box>
<Box ml='12px' mr={3} flexShrink={0} height='16px' width='16px' flexBasis='16px'>
{this.props.canUpload ? (
this.props.uploading ? (
<LoadingSpinner />
) : (
<Icon
icon='Links'
icon='Attachment'
cursor='pointer'
width='16'
height='16'
onClick={() =>
@ -198,18 +205,30 @@ class ChatInput extends Component<ChatInputProps, ChatInputState> {
)
) : null}
</Box>
<Box mr={2} flexShrink={0} height='16px' width='16px' flexBasis='16px'>
<Icon
icon='Dojo'
onClick={this.toggleCode}
color={state.inCodeMode ? 'blue' : 'black'}
/>
</Box>
{MOBILE_BROWSER_REGEX.test(navigator.userAgent) ?
<Box
ml={2}
mr="12px"
flexShrink={0}
display="flex"
justifyContent="center"
alignItems="center"
width="24px"
height="24px"
borderRadius="50%"
backgroundColor={state.currentInput !== '' ? 'blue' : 'gray'}
cursor={state.currentInput !== '' ? 'pointer' : 'default'}
onClick={() => this.chatEditor.current.submit()}
>
<Icon icon="ArrowEast" color="white" />
</Box>
: null}
</Row>
);
}
}
export default withLocalState(withStorage(ChatInput, { accept: 'image/*' }), [
'hideAvatars'
]);
export default withLocalState<Omit<ChatInputProps, keyof IuseStorage>, 'hideAvatars', ChatInput>(
withStorage<ChatInputProps, ChatInput>(ChatInput, { accept: 'image/*' }),
['hideAvatars']
)

View File

@ -3,6 +3,7 @@ import bigInt from 'big-integer';
import React, {
useState,
useEffect,
useMemo,
useRef,
Component,
PureComponent,
@ -40,11 +41,13 @@ import styled from 'styled-components';
import useLocalState from '~/logic/state/local';
import useSettingsState, { selectCalmState } from '~/logic/state/settings';
import Timestamp from '~/views/components/Timestamp';
import useContactState from '~/logic/state/contact';
import useContactState, {useContact} from '~/logic/state/contact';
import { useIdlingState } from '~/logic/lib/idling';
import ProfileOverlay from '~/views/components/ProfileOverlay';
import {useCopy} from '~/logic/lib/useCopy';
import {GraphContentWide} from '~/views/landscape/components/Graph/GraphContentWide';
import {Contact} from '@urbit/api';
import GlobalApi from '~/logic/api/global';
export const DATESTAMP_FORMAT = '[~]YYYY.M.D';
@ -67,7 +70,7 @@ export const DayBreak = ({ when, shimTop = false }: DayBreakProps) => (
<Rule borderColor='lightGray' />
<Text
gray
flexShrink='0'
flexShrink={0}
whiteSpace='nowrap'
textAlign='center'
fontSize={0}
@ -80,16 +83,13 @@ export const DayBreak = ({ when, shimTop = false }: DayBreakProps) => (
);
export const UnreadMarker = React.forwardRef(
({ dayBreak, when, api, association }, ref) => {
({ dismissUnread }: any, ref) => {
const [visible, setVisible] = useState(false);
const idling = useIdlingState();
const dismiss = useCallback(() => {
api.hark.markCountAsRead(association, '/', 'message');
}, [api, association]);
useEffect(() => {
if (visible && !idling) {
dismiss();
dismissUnread();
}
}, [visible, idling]);
@ -109,7 +109,7 @@ export const UnreadMarker = React.forwardRef(
<Text
color='blue'
fontSize={0}
flexShrink='0'
flexShrink={0}
whiteSpace='nowrap'
textAlign='center'
px={2}
@ -141,10 +141,9 @@ const MessageActionItem = (props) => {
);
};
const MessageActions = ({ api, onReply, association, history, msg, group }) => {
const isAdmin = () => group.tags.role.admin.has(window.ship);
const MessageActions = ({ api, onReply, association, msg, isAdmin, permalink }) => {
const isOwn = () => msg.author === window.ship;
const { doCopy, copyDisplay } = useCopy(`web+urbitgraph://group${association.group.slice(5)}/graph${association.resource.slice(5)}${msg.index}`, 'Copy Message Link');
const { doCopy, copyDisplay } = useCopy(permalink, 'Copy Message Link');
return (
<Box
@ -170,7 +169,7 @@ const MessageActions = ({ api, onReply, association, history, msg, group }) => {
width='auto'
alignY='top'
alignX='right'
flexShrink={'0'}
flexShrink={0}
offsetY={8}
offsetX={-24}
options={
@ -235,176 +234,161 @@ interface ChatMessageProps {
previousMsg?: Post;
nextMsg?: Post;
isLastRead: boolean;
group: Group;
association: Association;
permalink: string;
transcluded?: number;
className?: string;
isPending: boolean;
style?: unknown;
scrollWindow: HTMLDivElement;
isLastMessage?: boolean;
unreadMarkerRef: React.RefObject<HTMLDivElement>;
history: unknown;
dismissUnread: () => void;
api: GlobalApi;
highlighted?: boolean;
renderSigil?: boolean;
hideHover?: boolean;
innerRef: (el: HTMLDivElement | null) => void;
onReply?: (msg: Post) => void;
showOurContact: boolean;
}
class ChatMessage extends Component<ChatMessageProps> {
private divRef: React.RefObject<HTMLDivElement>;
function ChatMessage(props: ChatMessageProps) {
let { highlighted } = props;
const {
msg,
previousMsg,
nextMsg,
isLastRead,
group,
association,
className = '',
isPending,
style,
isLastMessage,
api,
showOurContact,
fontSize,
hideHover,
dismissUnread,
permalink
} = props;
constructor(props) {
super(props);
this.divRef = React.createRef();
}
let onReply = props?.onReply ?? (() => {});
const transcluded = props?.transcluded ?? 0;
const renderSigil = props.renderSigil ?? (Boolean(nextMsg && msg.author !== nextMsg.author) ||
!nextMsg ||
msg.number === 1
);
componentDidMount() {}
const ourMention = msg?.contents?.some((e) => {
return e?.mention && e?.mention === window.ship;
});
render() {
const {
msg,
previousMsg,
nextMsg,
isLastRead,
group,
association,
className = '',
isPending,
style,
scrollWindow,
isLastMessage,
unreadMarkerRef,
history,
api,
highlighted,
showOurContact,
fontSize,
hideHover
} = this.props;
let onReply = this.props?.onReply ?? (() => {});
const transcluded = this.props?.transcluded ?? 0;
let { renderSigil } = this.props;
if (renderSigil === undefined) {
renderSigil = Boolean(
(nextMsg && msg.author !== nextMsg.author) ||
!nextMsg ||
msg.number === 1
);
if (!highlighted) {
if (ourMention) {
highlighted = true;
}
}
const date = daToUnix(bigInt(msg.index.split('/')[1]));
const nextDate = nextMsg ? (
daToUnix(bigInt(nextMsg.index.split('/')[1]))
) : null;
const date = useMemo(() => daToUnix(bigInt(msg.index.split('/')[1])), [msg.index]);
const nextDate = useMemo(() => nextMsg ? (
daToUnix(bigInt(nextMsg.index.split('/')[1]))
) : null,
[nextMsg]
);
const dayBreak =
nextMsg &&
new Date(date).getDate() !==
new Date(nextDate).getDate();
const dayBreak = useMemo(() =>
nextDate &&
new Date(date).getDate() !==
new Date(nextDate).getDate()
, [nextDate, date])
const containerClass = `${isPending ? 'o-40' : ''} ${className}`;
const containerClass = `${isPending ? 'o-40' : ''} ${className}`;
const timestamp = moment
.unix(date / 1000)
.format(renderSigil ? 'h:mm A' : 'h:mm');
const timestamp = useMemo(() => moment
.unix(date / 1000)
.format(renderSigil ? 'h:mm A' : 'h:mm'),
[date, renderSigil]
);
const messageProps = {
msg,
timestamp,
association,
group,
style,
containerClass,
isPending,
showOurContact,
history,
api,
scrollWindow,
highlighted,
fontSize,
hideHover,
transcluded,
onReply
};
const messageProps = {
msg,
timestamp,
association,
isPending,
showOurContact,
api,
highlighted,
fontSize,
hideHover,
transcluded,
onReply
};
const unreadContainerStyle = {
height: isLastRead ? '2rem' : '0'
};
const message = useMemo(() => (
<Message
msg={msg}
timestamp={timestamp}
timestampHover={!renderSigil}
api={api}
transcluded={transcluded}
showOurContact={showOurContact}
/>
), [renderSigil, msg, timestamp, api, transcluded, showOurContact]);
return (
<Box
ref={this.props.innerRef}
pt={renderSigil ? 2 : 0}
width="100%"
pb={isLastMessage ? '20px' : 0}
className={containerClass}
style={style}
>
{dayBreak && !isLastRead ? (
<DayBreak when={date} shimTop={renderSigil} />
const unreadContainerStyle = {
height: isLastRead ? '2rem' : '0'
};
return (
<Box
ref={props.innerRef}
pt={renderSigil ? 2 : 0}
width="100%"
pb={isLastMessage ? '20px' : 0}
className={containerClass}
style={style}
>
{dayBreak && !isLastRead ? (
<DayBreak when={date} shimTop={renderSigil} />
) : null}
<MessageWrapper permalink={permalink} {...messageProps}>
{ renderSigil && <MessageAuthor {...messageProps} />}
{message}
</MessageWrapper>
<Box style={unreadContainerStyle}>
{isLastRead ? (
<UnreadMarker dismissUnread={dismissUnread} />
) : null}
{renderSigil ? (
<MessageWrapper {...messageProps}>
<MessageAuthor pb={1} {...messageProps} />
<Message pl={'44px'} pr={4} {...messageProps} />
</MessageWrapper>
) : (
<MessageWrapper {...messageProps}>
<Message pl={'44px'} pr={4} timestampHover {...messageProps} />
</MessageWrapper>
)}
<Box style={unreadContainerStyle}>
{isLastRead ? (
<UnreadMarker
association={association}
api={api}
dayBreak={dayBreak}
when={date}
ref={unreadMarkerRef}
/>
) : null}
</Box>
</Box>
);
}
</Box>
);
}
export default React.forwardRef((props, ref) => (
export default React.forwardRef((props: Omit<ChatMessageProps, 'innerRef'>, ref: any) => (
<ChatMessage {...props} innerRef={ref} />
));
export const MessageAuthor = ({
timestamp,
msg,
group,
api,
history,
scrollWindow,
showOurContact,
...rest
}) => {
const osDark = useLocalState((state) => state.dark);
const theme = useSettingsState((s) => s.display.theme);
const dark = theme === 'dark' || (theme === 'auto' && osDark);
const contacts = useContactState((state) => state.contacts);
let contact: Contact | null = useContact(`~${msg.author}`);
const date = daToUnix(bigInt(msg.index.split('/')[1]));
const datestamp = moment
.unix(date / 1000)
.format(DATESTAMP_FORMAT);
const contact =
contact =
((msg.author === window.ship && showOurContact) ||
msg.author !== window.ship) &&
`~${msg.author}` in contacts
? contacts[`~${msg.author}`]
: undefined;
msg.author !== window.ship)
? contact
: null;
const showNickname = useShowNickname(contact);
const { hideAvatars } = useSettingsState(selectCalmState);
@ -457,7 +441,7 @@ export const MessageAuthor = ({
</Box>
);
return (
<Box display='flex' alignItems='flex-start' {...rest}>
<Box pb="1" display='flex' alignItems='flex-start'>
<Box
height={24}
pr={2}
@ -509,20 +493,20 @@ export const MessageAuthor = ({
);
};
export const Message = ({
type MessageProps = { timestamp: string; timestampHover: boolean; }
& Pick<ChatMessageProps, "msg" | "api" | "transcluded" | "showOurContact">
export const Message = React.memo(({
timestamp,
msg,
group,
api,
scrollWindow,
timestampHover,
transcluded,
showOurContact,
...rest
}) => {
showOurContact
}: MessageProps) => {
const { hovering, bind } = useHovering();
return (
<Box width="100%" position='relative' {...rest}>
<Box pl="44px" width="100%" position='relative'>
{timestampHover ? (
<Text
display={hovering ? 'block' : 'none'}
@ -549,7 +533,9 @@ export const Message = ({
/>
</Box>
);
};
});
Message.displayName = 'Message';
export const MessagePlaceholder = ({
height,
@ -578,7 +564,7 @@ export const MessagePlaceholder = ({
>
<Text
display='block'
background='gray'
background='washedGray'
width='24px'
height='24px'
borderRadius='50%'
@ -601,12 +587,13 @@ export const MessagePlaceholder = ({
display='inline-block'
verticalAlign='middle'
fontSize='0'
gray
washedGray
cursor='default'
>
<Text maxWidth='32rem' display='block'>
<Text
backgroundColor='gray'
backgroundColor='washedGray'
borderRadius='2'
display='block'
width='100%'
height='100%'
@ -618,10 +605,11 @@ export const MessagePlaceholder = ({
mono
verticalAlign='middle'
fontSize='0'
gray
washedGray
>
<Text
background='gray'
background='washedGray'
borderRadius='2'
display='block'
height='1em'
style={{ width: `${((index % 3) + 1) * 3}em` }}
@ -632,12 +620,14 @@ export const MessagePlaceholder = ({
verticalAlign='middle'
fontSize='0'
ml='2'
gray
washedGray
borderRadius='2'
display={['none', 'inline-block']}
className='child'
>
<Text
backgroundColor='gray'
backgroundColor='washedGray'
borderRadius='2'
display='block'
width='100%'
height='100%'
@ -646,7 +636,8 @@ export const MessagePlaceholder = ({
</Box>
<Text
display='block'
backgroundColor='gray'
backgroundColor='washedGray'
borderRadius='2'
height='1em'
style={{ width: `${(index % 5) * 20}%` }}
></Text>

View File

@ -0,0 +1,183 @@
import React, { useRef, useCallback, useEffect, useState } from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { Col } from '@tlon/indigo-react';
import _ from 'lodash';
import bigInt, { BigInteger } from 'big-integer';
import { Association } from '@urbit/api/metadata';
import { StoreState } from '~/logic/store/type';
import { useFileDrag } from '~/logic/lib/useDrag';
import ChatWindow from './ChatWindow';
import ChatInput from './ChatInput';
import GlobalApi from '~/logic/api/global';
import { ShareProfile } from '~/views/apps/chat/components/ShareProfile';
import SubmitDragger from '~/views/components/SubmitDragger';
import { useLocalStorageState } from '~/logic/lib/useLocalStorageState';
import { Loading } from '~/views/components/Loading';
import { isWriter, resourceFromPath } from '~/logic/lib/group';
import useContactState, { useOurContact } from '~/logic/state/contact';
import useGraphState from '~/logic/state/graph';
import useGroupState from '~/logic/state/group';
import useHarkState from '~/logic/state/hark';
import { Post, Graph, Content } from '@urbit/api';
import { getPermalinkForGraph } from '~/logic/lib/permalinks';
interface ChatPaneProps {
/**
* A key to uniquely identify a ChatPane instance. Should be either the
* resource for group chats or the @p for DMs
*/
id: string;
/**
* The graph of the chat to render
*/
graph: Graph;
unreadCount: number;
/**
* User able to write to chat
*/
canWrite: boolean;
api: GlobalApi;
/**
* Get contents of reply message
*/
onReply: (msg: Post) => string;
/**
* Fetch more messages
*
* @param newer Get newer or older backlog
* @returns Whether backlog is finished loading in that direction
*/
fetchMessages: (newer: boolean) => Promise<boolean>;
/**
* Dismiss unreads for chat
*/
dismissUnread: () => void;
/**
* Get permalink for a node
*/
getPermalink: (idx: BigInteger) => string;
isAdmin: boolean;
/**
* Post message with contents to channel
*/
onSubmit: (contents: Content[]) => void;
/**
*
* Users or group we haven't shared our contact with yet
*
* string[] - array of ships
* string - path of group
*/
promptShare?: string[] | string;
}
export function ChatPane(props: ChatPaneProps) {
const {
api,
graph,
unreadCount,
canWrite,
id,
getPermalink,
isAdmin,
dismissUnread,
onSubmit,
promptShare = [],
fetchMessages
} = props;
const graphTimesentMap = useGraphState((state) => state.graphTimesentMap);
const ourContact = useOurContact();
const chatInput = useRef<ChatInput>();
const onFileDrag = useCallback(
(files: FileList | File[]) => {
if (!chatInput.current) {
return;
}
chatInput.current?.uploadFiles(files);
},
[chatInput.current]
);
const { bind, dragging } = useFileDrag(onFileDrag);
const [unsent, setUnsent] = useLocalStorageState<Record<string, string>>(
'chat-unsent',
{}
);
const appendUnsent = useCallback(
(u: string) => setUnsent((s) => ({ ...s, [id]: u })),
[id]
);
const clearUnsent = useCallback(() => {
setUnsent((s) => {
if (id in s) {
return _.omit(s, id);
}
return s;
});
}, [id]);
const scrollTo = new URLSearchParams(location.search).get('msg');
const [showBanner, setShowBanner] = useState(false);
useEffect(() => {
setShowBanner(promptShare.length > 0);
}, [promptShare]);
const onReply = useCallback(
(msg: Post) => {
const message = props.onReply(msg);
setUnsent((s) => ({ ...s, [id]: message }));
},
[id, props.onReply]
);
if (!graph) {
return <Loading />;
}
return (
<Col {...bind} height="100%" overflow="hidden" position="relative">
<ShareProfile
our={ourContact}
api={api}
recipients={showBanner ? promptShare : []}
onShare={() => setShowBanner(false)}
/>
{dragging && <SubmitDragger />}
<ChatWindow
key={id}
graph={graph}
graphSize={graph.size}
unreadCount={unreadCount}
showOurContact={promptShare.length === 0 && !showBanner}
pendingSize={Object.keys(graphTimesentMap[id] || {}).length}
onReply={onReply}
dismissUnread={dismissUnread}
fetchMessages={fetchMessages}
isAdmin={isAdmin}
getPermalink={getPermalink}
api={api}
scrollTo={scrollTo ? bigInt(scrollTo) : undefined}
/>
{canWrite && (
<ChatInput
ref={chatInput}
api={props.api}
onSubmit={onSubmit}
ourContact={(promptShare.length === 0 && ourContact) || undefined}
onUnmount={appendUnsent}
placeholder="Message..."
message={unsent[id] || ''}
deleteMessage={clearUnsent}
/>
)}
</Col>
);
}

View File

@ -1,4 +1,4 @@
import React, { Component } from 'react';
import React, { useEffect, Component, useRef, useState, useCallback } from 'react';
import { RouteComponentProps } from 'react-router-dom';
import _ from 'lodash';
import bigInt, { BigInteger } from 'big-integer';
@ -11,7 +11,9 @@ import {
Associations,
Group,
Groups,
Graph
Graph,
Post,
GraphNode
} from '@urbit/api';
import GlobalApi from '~/logic/api/global';
@ -30,20 +32,21 @@ const DEFAULT_BACKLOG_SIZE = 100;
const IDLE_THRESHOLD = 64;
const MAX_BACKLOG_SIZE = 1000;
type ChatWindowProps = RouteComponentProps<{
ship: Patp;
station: string;
}> & {
type ChatWindowProps = {
unreadCount: number;
graph: Graph;
graphSize: number;
association: Association;
group: Group;
ship: Patp;
station: any;
fetchMessages: (newer: boolean) => Promise<boolean>;
api: GlobalApi;
scrollTo?: BigInteger;
onReply: (msg: Post) => void;
dismissUnread: () => void;
pendingSize?: number;
showOurContact: boolean;
getPermalink: (index: BigInteger) => string;
isAdmin: boolean;
};
interface ChatWindowState {
@ -55,12 +58,12 @@ interface ChatWindowState {
const virtScrollerStyle = { height: '100%' };
class ChatWindow extends Component<
ChatWindowProps,
ChatWindowState
> {
private virtualList: VirtualScroller | null;
private unreadMarkerRef: React.RefObject<HTMLDivElement>;
private virtualList: VirtualScroller<GraphNode> | null;
private prevSize = 0;
private unreadSet = false;
@ -76,14 +79,12 @@ class ChatWindow extends Component<
unreadIndex: bigInt.zero
};
this.dismissUnread = this.dismissUnread.bind(this);
this.scrollToUnread = this.scrollToUnread.bind(this);
this.handleWindowBlur = this.handleWindowBlur.bind(this);
this.handleWindowFocus = this.handleWindowFocus.bind(this);
this.stayLockedIfActive = this.stayLockedIfActive.bind(this);
this.virtualList = null;
this.unreadMarkerRef = React.createRef();
this.prevSize = props.graph.size;
}
@ -92,10 +93,9 @@ class ChatWindow extends Component<
setTimeout(() => {
this.setState({ initialized: true }, () => {
if(this.props.scrollTo) {
this.virtualList.scrollToIndex(this.props.scrollTo);
this.virtualList!.scrollLocked = false;
this.virtualList!.scrollToIndex(this.props.scrollTo);
}
});
}, this.INITIALIZATION_MAX_TIME);
@ -109,9 +109,11 @@ class ChatWindow extends Component<
}
const unreadIndex = graph.keys()[unreadCount];
if (!unreadIndex || unreadCount === 0) {
this.setState({
unreadIndex: bigInt.zero
});
if(state.unreadIndex.neq(bigInt.zero)) {
this.setState({
unreadIndex: bigInt.zero
});
}
return;
}
this.setState({
@ -122,8 +124,8 @@ class ChatWindow extends Component<
dismissedInitialUnread() {
const { unreadCount, graph } = this.props;
return this.state.unreadIndex.neq(bigInt.zero) &&
this.state.unreadIndex.neq(graph.keys()?.[unreadCount]?.[0] ?? bigInt.zero);
return this.state.unreadIndex.eq(bigInt.zero) ? unreadCount > graph.size :
this.state.unreadIndex.neq(graph.keys()?.[unreadCount]?.[0] ?? bigInt.zero);
}
handleWindowBlur() {
@ -133,12 +135,12 @@ class ChatWindow extends Component<
handleWindowFocus() {
this.setState({ idle: false });
if (this.virtualList?.window?.scrollTop === 0) {
this.dismissUnread();
this.props.dismissUnread();
}
}
componentDidUpdate(prevProps: ChatWindowProps, prevState) {
const { history, graph, unreadCount, graphSize, station } = this.props;
const { graph, unreadCount, graphSize, station } = this.props;
if(unreadCount === 0 && prevProps.unreadCount !== unreadCount) {
this.unreadSet = true;
}
@ -150,8 +152,8 @@ class ChatWindow extends Component<
}
if(this.unreadSet &&
this.dismissedInitialUnread() &&
this.virtualList?.startOffset() < 5) {
this.dismissUnread();
this.virtualList!.startOffset() < 5) {
this.props.dismissUnread();
}
}
@ -169,7 +171,7 @@ class ChatWindow extends Component<
stayLockedIfActive() {
if (this.virtualList && !this.state.idle) {
this.virtualList.resetScroll();
this.dismissUnread();
this.props.dismissUnread();
}
}
@ -188,45 +190,6 @@ class ChatWindow extends Component<
this.virtualList?.scrollToIndex(this.state.unreadIndex);
}
dismissUnread() {
const { association } = this.props;
if (this.state.fetchPending) return;
if (this.props.unreadCount === 0) return;
this.props.api.hark.markCountAsRead(association, '/', 'message');
}
setActive = () => {
if(this.state.idle) {
this.setState({ idle: false });
}
}
fetchMessages = async (newer: boolean): Promise<boolean> => {
const { api, station, graph } = this.props;
const pageSize = 100;
const [, , ship, name] = station.split('/');
const expectedSize = graph.size + pageSize;
if (newer) {
const [index] = graph.peekLargest()!;
await api.graph.getYoungerSiblings(
ship,
name,
pageSize,
`/${index.toString()}`
);
return expectedSize !== graph.size;
} else {
const [index] = graph.peekSmallest()!;
await api.graph.getOlderSiblings(ship, name, pageSize, `/${index.toString()}`);
const done = expectedSize !== graph.size;
if(done) {
this.calculateUnreadIndex();
}
return done;
}
}
onScroll = ({ scrollTop, scrollHeight, windowHeight }) => {
if (!this.state.idle && scrollTop > IDLE_THRESHOLD) {
this.setState({ idle: true });
@ -237,26 +200,21 @@ class ChatWindow extends Component<
renderer = React.forwardRef(({ index, scrollWindow }, ref) => {
const {
api,
association,
group,
showOurContact,
graph,
history,
groups,
associations,
onReply
onReply,
getPermalink,
dismissUnread,
isAdmin,
} = this.props;
const { unreadMarkerRef } = this;
const permalink = getPermalink(index);
const messageProps = {
association,
group,
showOurContact,
unreadMarkerRef,
history,
api,
groups,
associations,
onReply
onReply,
permalink,
dismissUnread,
isAdmin
};
const msg = graph.get(index)?.post;
@ -275,10 +233,10 @@ class ChatWindow extends Component<
graph.peekLargest()?.[0] ?? bigInt.zero
);
const highlighted = index.eq(this.props.scrollTo ?? bigInt.zero);
const keys = graph.keys().reverse();
const keys = graph.keys();
const graphIdx = keys.findIndex((idx) => idx.eq(index));
const prevIdx = keys[graphIdx + 1];
const nextIdx = keys[graphIdx - 1];
const prevIdx = keys[graphIdx - 1];
const nextIdx = keys[graphIdx + 1];
const isLastRead: boolean = this.state.unreadIndex.eq(index);
const props = {
highlighted,
@ -305,32 +263,13 @@ class ChatWindow extends Component<
const {
unreadCount,
api,
association,
group,
graph,
history,
groups,
associations,
showOurContact,
pendingSize,
onReply,
pendingSize = 0,
} = this.props;
const unreadMarkerRef = this.unreadMarkerRef;
const messageProps = {
association,
group,
unreadMarkerRef,
history,
api,
associations
};
const unreadMsg = graph.get(this.state.unreadIndex);
// hack to force a re-render when we toggle showing contact
const contactsModified =
showOurContact ? 0 : 100;
return (
<Col height='100%' overflow='hidden' position='relative'>
{ this.dismissedInitialUnread() &&
@ -343,34 +282,29 @@ class ChatWindow extends Component<
? false
: unreadMsg
}
dismissUnread={this.dismissUnread}
dismissUnread={this.props.dismissUnread}
onClick={this.scrollToUnread}
/>)}
<VirtualScroller
<VirtualScroller<GraphNode>
ref={(list) => {
this.virtualList = list;
}}
offset={unreadCount}
origin='bottom'
style={virtScrollerStyle}
onStartReached={this.setActive}
onBottomLoaded={this.onBottomLoaded}
onScroll={this.onScroll}
data={graph}
size={graph.size}
pendingSize={pendingSize + contactsModified}
id={association.resource}
pendingSize={pendingSize}
averageHeight={22}
renderer={this.renderer}
loadRows={this.fetchMessages}
loadRows={this.props.fetchMessages}
/>
</Col>
);
}
}
export default withState(ChatWindow, [
[useGroupState, ['groups']],
[useMetadataState, ['associations']],
[useGraphState, ['pendingSize']]
]);
export default ChatWindow

View File

@ -40,27 +40,27 @@ export const ShareProfile = (props) => {
);
const onClick = async () => {
if(group.hidden && recipients.length > 0) {
await api.contacts.allowShips(recipients);
await Promise.all(recipients.map(r => api.contacts.share(r)))
setShowBanner(false);
} else if (!group.hidden) {
const [,,ship,name] = groupPath.split('/');
if(typeof recipients === 'string') {
const [,,ship,name] = recipients.split('/');
await api.contacts.allowGroup(ship,name);
if(ship !== `~${window.ship}`) {
await api.contacts.share(ship);
}
setShowBanner(false);
}
} else if(recipients.length > 0) {
await api.contacts.allowShips(recipients);
await Promise.all(recipients.map(r => api.contacts.share(r)))
}
props.onShare();
};
return showBanner ? (
return props.recipients?.length > 0 ? (
<Row
height="48px"
alignItems="center"
justifyContent="space-between"
borderBottom={1}
borderColor="lightGray"
flexShrink={0}
>
<Row pl={3} alignItems="center">
{image}

View File

@ -162,6 +162,7 @@ export default class ChatEditor extends Component {
editor.showHint(['test', 'foo']);
}
if (this.state.message !== '' && value == '') {
this.props.changeEvent(value);
this.setState({
message: value
});
@ -169,6 +170,7 @@ export default class ChatEditor extends Component {
if (value == this.props.message || value == '' || value == ' ') {
return;
}
this.props.changeEvent(value);
this.setState({
message: value
});

View File

@ -8,22 +8,11 @@ import Timestamp from '~/views/components/Timestamp';
export const UnreadNotice = (props) => {
const { unreadCount, unreadMsg, dismissUnread, onClick } = props;
if (!unreadMsg || unreadCount === 0) {
if (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');
if (datestamp === moment().format('YYYY.M.D')) {
datestamp = null;
}
const stamp = unreadMsg && moment.unix(unreadMsg.post['time-sent'] / 1000);
return (
<Box
@ -52,15 +41,20 @@ export const UnreadNotice = (props) => {
whiteSpace='pre'
overflow='hidden'
display='flex'
cursor='pointer'
cursor={unreadMsg ? 'pointer' : null}
onClick={onClick}
>
{unreadCount} new message{unreadCount > 1 ? 's' : ''} since{' '}
<Timestamp stamp={stamp} color='black' date={true} fontSize={1} />
{unreadCount} new message{unreadCount > 1 ? 's' : ''}
{unreadMsg && (
<>
{' '}since{' '}
<Timestamp stamp={stamp} color='black' date={true} fontSize={1} />
</>
)}
</Text>
<Icon
icon='X'
ml='4'
ml={unreadMsg ? 4 : 1}
color='black'
cursor='pointer'
textAlign='right'

View File

@ -220,7 +220,7 @@ export default function LaunchApp(props) {
<NewGroup {...props} />
</ModalButton>
<ModalButton
icon="Boot"
icon="BootNode"
bg="washedGray"
color="black"
text="Join Group"

View File

@ -23,7 +23,7 @@ type LinkResourceProps = StoreState & {
association: Association;
api: GlobalApi;
baseUrl: string;
} & RouteComponentProps;
};
export function LinkResource(props: LinkResourceProps) {
const {

View File

@ -6,7 +6,7 @@ import React, {
Component,
} from "react";
import { Col, Text } from "@tlon/indigo-react";
import { Box, Col, Text } from "@tlon/indigo-react";
import bigInt from "big-integer";
import { Association, Graph, Unreads, Group, Rolodex } from "@urbit/api";
@ -48,7 +48,7 @@ class LinkWindow extends Component<LinkWindowProps, {}> {
return isWriter(group, association.resource);
}
renderItem = ({ index, scrollWindow }) => {
renderItem = React.forwardRef(({ index, scrollWindow }, ref) => {
const { props } = this;
const { association, graph, api } = props;
const [, , ship, name] = association.resource.split("/");
@ -80,12 +80,14 @@ class LinkWindow extends Component<LinkWindowProps, {}> {
api={api}
/>
</Col>
<LinkItem {...linkProps} />
<LinkItem ref={ref} {...linkProps} />
</React.Fragment>
);
}
return <LinkItem key={index.toString()} {...linkProps} />;
};
return <Box ref={ref}>
<LinkItem ref={ref} key={index.toString()} {...linkProps} />;
</Box>
});
render() {
const { graph, api, association } = this.props;
@ -136,4 +138,4 @@ class LinkWindow extends Component<LinkWindowProps, {}> {
}
}
export default LinkWindow;
export default LinkWindow;

View File

@ -19,7 +19,7 @@ interface LinkItemProps {
node: GraphNode;
association: Association;
resource: string; api: GlobalApi; group: Group; path: string; }
export const LinkItem = (props: LinkItemProps): ReactElement => {
export const LinkItem = React.forwardRef((props: LinkItemProps, ref): ReactElement => {
const {
association,
node,
@ -30,7 +30,6 @@ export const LinkItem = (props: LinkItemProps): ReactElement => {
...rest
} = props;
const ref = useRef<HTMLDivElement | null>(null);
const remoteRef = useRef<typeof RemoteContent | null>(null);
const index = node.post.index.split('/')[1];
@ -86,7 +85,7 @@ export const LinkItem = (props: LinkItemProps): ReactElement => {
permalink,
'Copy reference'
);
const deleteLink = () => {
if (confirm('Are you sure you want to delete this link?')) {
api.graph.removeNodes(`~${ship}`, name, [node.post.index]);
@ -167,9 +166,11 @@ export const LinkItem = (props: LinkItemProps): ReactElement => {
<Row minWidth='0' flexShrink={0} width="100%" justifyContent="space-between" py={3} bg="white">
<Author
showImage
isRelativeTime
ship={author}
date={node.post['time-sent']}
group={group}
lineHeight="1"
/>
<Box ml="auto">
<Link
@ -208,5 +209,5 @@ export const LinkItem = (props: LinkItemProps): ReactElement => {
</Row>
</Box>);
};
});

View File

@ -92,6 +92,7 @@ export default function NotificationsScreen(props: any): ReactElement {
<StatelessAsyncAction
overflow="hidden"
color="black"
backgroundColor="white"
onClick={onReadAll}
>
Mark All Read
@ -106,7 +107,7 @@ export default function NotificationsScreen(props: any): ReactElement {
{!view && <Inbox
pendingJoin={pendingJoin}
{...props}
filter={filter.groups}
filter={filter.groups}
/>}
</Col>
</Body>

View File

@ -4,6 +4,7 @@ import ChatMessage from "../chat/components/ChatMessage";
import { Association, GraphNode, Post, Group } from "@urbit/api";
import { useGroupForAssoc } from "~/logic/state/group";
import { MentionText } from "~/views/components/MentionText";
import { GraphContentWide } from '~/views/landscape/components/Graph/GraphContentWide';
import Author from "~/views/components/Author";
import { NoteContent } from "../publish/components/Note";
import { PostContent } from "~/views/landscape/components/Home/Post/PostContent";
@ -31,7 +32,7 @@ function TranscludedLinkNode(props: {
return <PermalinkEmbed transcluded={transcluded + 1} api={api} link={permalink} association={assoc} />
}
return (
<Box borderRadius="2" p="2" bg="scales.black05">
<Anchor underline={false} target="_blank" color="black" href={link.url}>
@ -74,11 +75,11 @@ function TranscludedComment(props: {
group={group}
/>
<Box p="2">
<MentionText
<GraphContentWide
api={api}
transcluded={transcluded}
content={comment.post.contents}
group={group}
post={comment.post}
showOurContact={false}
/>
</Box>
</Col>
@ -200,8 +201,8 @@ export function TranscludedNode(props: {
<TranscludedPost
api={props.api}
post={node.post}
group={group}
transcluded={transcluded}
group={group}
transcluded={transcluded}
/>)
;
default:

View File

@ -18,7 +18,7 @@ import { GroupLink } from "~/views/components/GroupLink";
import GlobalApi from "~/logic/api/global";
import { getModuleIcon } from "~/logic/lib/util";
import useMetadataState from "~/logic/state/metadata";
import { Association, resourceFromPath } from "@urbit/api";
import { Association, resourceFromPath, GraphNode } from "@urbit/api";
import { Link } from "react-router-dom";
import useGraphState from "~/logic/state/graph";
import { GraphNodeContent } from "../notifications/graph";
@ -51,7 +51,7 @@ function GraphPermalink(
const { full = false, showOurContact, pending, link, graph, group, index, api, transcluded } = props;
const { ship, name } = resourceFromPath(graph);
const node = useGraphState(
useCallback((s) => s.looseNodes?.[`${ship.slice(1)}/${name}`]?.[index], [
useCallback((s) => s.looseNodes?.[`${ship.slice(1)}/${name}`]?.[index] as GraphNode, [
graph,
index,
])
@ -63,7 +63,7 @@ function GraphPermalink(
])
);
useVirtualResizeProp(node)
useVirtualResizeProp(!!node)
useEffect(() => {
(async () => {
if (pending || !index) {

View File

@ -11,7 +11,7 @@ type PublishResourceProps = StoreState & {
association: Association;
api: GlobalApi;
baseUrl: string;
} & RouteComponentProps;
};
export function PublishResource(props: PublishResourceProps) {
const { association, api, baseUrl, notebooks } = props;

View File

@ -73,14 +73,14 @@ export function Note(props: NoteProps & RouteComponentProps) {
if (window.ship === note?.post?.author) {
adminLinks.push(
<Link to={`${baseUrl}/edit`}>
<Action>Update</Action>
<Action backgroundColor="white">Update</Action>
</Link>
)
};
if (window.ship === note?.post?.author || ourRole === "admin") {
adminLinks.push(
<Action destructive onClick={deletePost}>
<Action backgroundColor="white" destructive onClick={deletePost}>
Delete
</Action>
)
@ -115,11 +115,12 @@ export function Note(props: NoteProps & RouteComponentProps) {
<Row alignItems="center">
<Author
showImage
isRelativeTime
ship={post?.author}
date={post?.['time-sent']}
group={group}
>
<Row px="2" gapX="2" alignItems="flex-end">
<Row px="2" gapX="2" alignItems="flex-end" height="14px">
<Action bg="white" onClick={doCopy}>{copyDisplay}</Action>
{adminLinks}
</Row>

View File

@ -44,7 +44,7 @@ export function PostForm(props: PostFormProps) {
validateOnBlur
>
<Form style={{ display: 'contents' }}>
<Row flexShrink='0' flexDirection={['column-reverse', 'row']} mb={4} gapX={4} justifyContent='space-between'>
<Row flexShrink={0} flexDirection={['column-reverse', 'row']} mb={4} gapX={4} justifyContent='space-between'>
<Input maxWidth='40rem' width='100%' flexShrink={[0, 1]} placeholder="Post Title" id="title" />
<Row flexDirection={['column', 'row']} mb={[4,0]}>
<AsyncButton

View File

@ -107,7 +107,7 @@ export default function SettingsScreen(props: any) {
</Text>
<Col>
<SidebarItem
icon='Inbox'
icon='Notifications'
text='Notifications'
hash='notifications'
/>

View File

@ -24,6 +24,7 @@ interface AuthorProps {
unread?: boolean;
api?: GlobalApi;
size?: number;
lineHeight?: string;
}
// eslint-disable-next-line max-lines-per-function
@ -38,10 +39,11 @@ export default function Author(props: AuthorProps & PropFunc<typeof Box>): React
group,
isRelativeTime,
dontShowTime,
lineHeight = 'tall',
...rest
} = props;
const time = props.time || false;
const time = props.time || props.date || false;
const size = props.size || 16;
const sigilPadding = props.sigilPadding || 2;
@ -89,7 +91,7 @@ export default function Author(props: AuthorProps & PropFunc<typeof Box>): React
) : sigil;
return (
<Row height="20px" {...rest} alignItems='center' width='auto'>
<Row {...rest} alignItems='center' width='auto'>
<Box
onClick={(e) => {
e.stopPropagation();
@ -110,7 +112,7 @@ export default function Author(props: AuthorProps & PropFunc<typeof Box>): React
color='black'
fontSize='1'
cursor='pointer'
lineHeight='tall'
lineHeight={lineHeight}
fontFamily={showNickname ? 'sans' : 'mono'}
fontWeight={showNickname ? '500' : '400'}
mr={showNickname ? 0 : "2px"}
@ -121,6 +123,7 @@ export default function Author(props: AuthorProps & PropFunc<typeof Box>): React
</Box>
{ !dontShowTime && time && (
<Timestamp
height="fit-content"
relative={isRelativeTime}
stamp={stamp}
fontSize={1}

View File

@ -115,8 +115,8 @@ export function ChipInput(props: ChipInputProps): ReactElement {
<Input
width="auto"
height="24px"
flexShrink="1"
flexGrow="1"
flexShrink={1}
flexGrow={1}
pl="0"
ref={inputRef}
onChange={onChange}

View File

@ -73,7 +73,7 @@ export function ColorInput(props: ColorInputProps) {
height='100%'
alignSelf='stretch'
onChange={onChange}
value={`#${padded}`}
value={padded}
disabled={disabled || false}
type='color'
opacity={0}

View File

@ -35,6 +35,7 @@ interface CommentItemProps {
}
export function CommentItem(props: CommentItemProps): ReactElement {
let { highlighted } = props;
const { ship, name, api, comment, group } = props;
const association = useMetadataState(
useCallback(s => s.associations.graph[`/ship/${ship}/${name}`], [ship,name])
@ -47,6 +48,16 @@ export function CommentItem(props: CommentItemProps): ReactElement {
await api.graph.removeNodes(ship, name, [comment.post?.index]);
};
const ourMention = post?.contents?.some((e) => {
return e?.mention && e?.mention === window.ship;
});
if (!highlighted) {
if (ourMention) {
highlighted = true;
}
}
const commentIndexArray = (comment.post?.index || '/').split('/');
const commentIndex = commentIndexArray[commentIndexArray.length - 1];
@ -95,6 +106,7 @@ export function CommentItem(props: CommentItemProps): ReactElement {
date={post?.['time-sent']}
unread={props.unread}
group={group}
isRelativeTime
>
<Row px="2" gapX="2" height="18px">
<Action bg="white" onClick={doCopy}>{copyDisplay}</Action>
@ -106,7 +118,7 @@ export function CommentItem(props: CommentItemProps): ReactElement {
borderRadius="1"
p="1"
mb="1"
backgroundColor={props.highlighted ? 'washedBlue' : 'white'}
backgroundColor={highlighted ? 'washedBlue' : 'white'}
transcluded={0}
api={api}
post={post}

View File

@ -130,7 +130,7 @@ export function Comments(props: CommentsProps & PropFunc<typeof Col>) {
const canComment = isWriter(group, association.resource) || association.metadata.vip === 'reader-comments';
return (
<Col {...rest}>
<Col {...rest} minWidth='0'>
{( !editCommentId && canComment ? <CommentInput onSubmit={onSubmit} /> : null )}
{( editCommentId ? (
<CommentInput

View File

@ -25,6 +25,7 @@ interface DropdownProps {
offsetY?: number;
width?: string;
dropWidth?: string;
flexShrink?: number;
}
const ClickBox = styled(Box)`
@ -39,7 +40,7 @@ const DropdownOptions = styled(Box)`
`;
export function Dropdown(props: DropdownProps): ReactElement {
const { children, options, offsetX = 0, offsetY = 0 } = props;
const { children, options, offsetX = 0, offsetY = 0, flexShrink = 1 } = props;
const dropdownRef = useRef<HTMLElement>(null);
const anchorRef = useRef<HTMLElement>(null);
const { pathname } = useLocation();
@ -47,6 +48,9 @@ export function Dropdown(props: DropdownProps): ReactElement {
const [coords, setCoords] = useState({});
const updatePos = useCallback(() => {
if(!anchorRef.current) {
return;
}
const newCoords = getRelativePosition(anchorRef.current, props.alignX, props.alignY, offsetX, offsetY);
if(newCoords) {
setCoords(newCoords);
@ -86,7 +90,7 @@ export function Dropdown(props: DropdownProps): ReactElement {
}, []);
return (
<Box flexShrink={props?.flexShrink ? props.flexShrink : 1} position={open ? 'relative' : 'static'} minWidth='0' width={props?.width ? props.width : 'auto'}>
<Box flexShrink={flexShrink} position={open ? 'relative' : 'static'} minWidth='0' width={props?.width ? props.width : 'auto'}>
<ClickBox width='100%' ref={anchorRef} onClick={onOpen}>
{children}
</ClickBox>

View File

@ -23,6 +23,7 @@ import RichText from './RichText';
import { ProfileStatus } from './ProfileStatus';
import useSettingsState from '~/logic/state/settings';
import {useOutsideClick} from '~/logic/lib/useOutsideClick';
import {useCopy} from '~/logic/lib/useCopy';
import {useContact} from '~/logic/state/contact';
import {useHistory} from 'react-router-dom';
import {Portal} from './Portal';
@ -59,6 +60,7 @@ const ProfileOverlay = (props: ProfileOverlayProps) => {
const hideAvatars = useSettingsState(state => state.calm.hideAvatars);
const hideNicknames = useSettingsState(state => state.calm.hideNicknames);
const isOwn = useMemo(() => window.ship === ship, [ship]);
const { copyDisplay, doCopy, didCopy } = useCopy(`~${ship}`);
const contact = useContact(`~${ship}`)
const color = `#${uxToHex(contact?.color ?? '0x0')}`;
@ -188,9 +190,18 @@ const ProfileOverlay = (props: ProfileOverlayProps) => {
overflow='hidden'
whiteSpace='pre'
marginBottom='0'
cursor='pointer'
display={didCopy ? 'none' : 'block'}
onClick={doCopy}
>
{showNickname ? contact?.nickname : cite(ship)}
</Text>
<Text
fontWeight='600'
marginBottom='0'
>
{copyDisplay}
</Text>
</Row>
{isOwn ? (
<ProfileStatus

View File

@ -48,12 +48,14 @@ class RemoteContent extends Component<RemoteContentProps, RemoteContentState> {
this.state = {
unfold: props.unfold || false,
embed: undefined,
noCors: false
noCors: false,
showArrow: false
};
this.unfoldEmbed = this.unfoldEmbed.bind(this);
this.loadOembed = this.loadOembed.bind(this);
this.wrapInLink = this.wrapInLink.bind(this);
this.onError = this.onError.bind(this);
this.toggleArrow = this.toggleArrow.bind(this);
}
save = () => {
@ -128,7 +130,7 @@ return;
});
}
wrapInLink(contents, textOnly = false, unfold = false, unfoldEmbed = null, embedContainer = null) {
wrapInLink(contents, textOnly = false, unfold = false, unfoldEmbed = null, embedContainer = null, flushPadding = false, noOp = false) {
const { style } = this.props;
return (
<Box borderRadius="1" backgroundColor="washedGray" maxWidth="min(100%, 20rem)">
@ -145,8 +147,8 @@ return;
)}
<BaseAnchor
display="flex"
p="2"
onClick={(e) => { e.stopPropagation(); }}
p={flushPadding ? 0 : 2}
onClick={(e) => { noOp ? e.preventDefault() : e.stopPropagation() }}
href={this.props.url}
whiteSpace="nowrap"
overflow="hidden"
@ -157,7 +159,8 @@ return;
style={{ color: 'inherit', textDecoration: 'none', ...style }}
target="_blank"
rel="noopener noreferrer"
>
cursor={noOp ? 'default' : 'pointer'}
>
{contents}
</BaseAnchor>
</Row>
@ -171,11 +174,16 @@ return;
this.setState({ noCors: true });
}
toggleArrow() {
this.setState({showArrow: !this.state.showArrow})
}
render() {
const {
remoteContentPolicy,
url,
text,
transcluded,
renderUrl = true,
imageProps = {},
audioProps = {},
@ -192,22 +200,60 @@ return;
const isVideo = VIDEO_REGEX.test(url);
const isOembed = hasProvider(url);
const isTranscluded = () => {
return transcluded;
}
if (isImage && remoteContentPolicy.imageShown) {
return this.wrapInLink(
<BaseImage
{...(noCors ? {} : { crossOrigin: "anonymous" })}
referrerPolicy="no-referrer"
flexShrink={0}
src={url}
style={style}
onLoad={onLoad}
onError={this.onError}
height="100%"
width="100%"
objectFit="contain"
{...imageProps}
{...props}
/>
<Box
position='relative'
onMouseEnter={this.toggleArrow}
onMouseLeave={this.toggleArrow}
>
<BaseAnchor
position='absolute'
top={2}
right={2}
display={this.state.showArrow ? 'block' : 'none'}
target='_blank'
rel='noopener noreferrer'
onClick={(e) => {
e.stopPropagation();
}}
href={url}
>
<Box
backgroundColor='white'
padding={2}
borderRadius='50%'
display='flex'
>
<Icon icon='ArrowNorthEast' />
</Box>
</BaseAnchor>
<BaseImage
{...(noCors ? {} : { crossOrigin: 'anonymous' })}
referrerPolicy='no-referrer'
flexShrink={0}
src={url}
style={style}
onLoad={onLoad}
onError={this.onError}
height='100%'
width='100%'
objectFit='contain'
borderRadius={2}
{...imageProps}
{...props}
/>
</Box>,
false,
false,
null,
null,
true,
isTranscluded()
);
} else if (isAudio && remoteContentPolicy.audioShown) {
return (
@ -271,7 +317,6 @@ return;
display={this.state.unfold ? 'block' : 'none'}
className='embed-container'
style={style}
flexShrink={0}
onLoad={this.onLoad}
{...oembedProps}
{...props}

View File

@ -83,7 +83,7 @@ const StatusBar = (props) => {
onClick={() => history.push('/')}
{...props}
>
<Icon icon='Spaces' color='black' />
<Icon icon='Dashboard' color='black' />
</Button>
<StatusBarItem float={floatLeap} mr={2} onClick={() => toggleOmnibox()}>
{!doNotDisturb && (notificationsCount > 0 || invites.length > 0) && (
@ -134,7 +134,7 @@ const StatusBar = (props) => {
mr={2}
onClick={() => props.history.push('/~landscape/messages')}
>
<Icon icon='Users' />
<Icon icon='Messages' />
</StatusBarItem>
<Dropdown
dropWidth='250px'

View File

@ -12,6 +12,7 @@ export type TimestampProps = BoxProps & {
date?: boolean;
time?: boolean;
relative?: boolean;
height?: string;
};
const Timestamp = (props: TimestampProps): ReactElement | null => {

View File

@ -1,4 +1,4 @@
import React, { Component, useCallback } from 'react';
import React, { Component, useCallback, SyntheticEvent } from 'react';
import _ from 'lodash';
import normalizeWheel from 'normalize-wheel';
import bigInt, { BigInteger } from 'big-integer';
@ -76,7 +76,7 @@ interface VirtualScrollerProps<T> {
}
interface VirtualScrollerState<T> {
visibleItems: BigIntOrderedMap<T>;
visibleItems: BigInteger[];
scrollbar: number;
loaded: {
top: boolean;
@ -91,10 +91,9 @@ const log = (level: LogLevel, message: string) => {
if(logLevel.includes(level)) {
console.log(`[${level}]: ${message}`);
}
}
const ZONE_SIZE = IS_IOS ? 10 : 40;
const ZONE_SIZE = IS_IOS ? 10 : 80;
// nb: in this file, an index refers to a BigInteger and an offset refers to a
@ -114,7 +113,11 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
/**
* A map of child refs, used to calculate scroll position
*/
private childRefs = new BigIntOrderedMap<HTMLElement>();
private childRefs = new Map<string, HTMLElement>();
/**
* A set of child refs which have been unmounted
*/
private orphans = new Set<string>();
/**
* If saving, the bottommost visible element that we pin our scroll to
*/
@ -140,10 +143,12 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
private scrollRef: HTMLElement | null = null;
private cleanupRefInterval: NodeJS.Timeout | null = null;
constructor(props: VirtualScrollerProps<T>) {
super(props);
this.state = {
visibleItems: new BigIntOrderedMap(),
visibleItems: [],
scrollbar: 0,
loaded: {
top: false,
@ -154,18 +159,33 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
this.updateVisible = this.updateVisible.bind(this);
this.invertedKeyHandler = this.invertedKeyHandler.bind(this);
this.onScroll = IS_IOS ? _.debounce(this.onScroll.bind(this), 400) : this.onScroll.bind(this);
this.onScroll = IS_IOS ? _.debounce(this.onScroll.bind(this), 200) : this.onScroll.bind(this);
this.scrollKeyMap = this.scrollKeyMap.bind(this);
this.setWindow = this.setWindow.bind(this);
this.restore = this.restore.bind(this);
this.startOffset = this.startOffset.bind(this);
}
componentDidMount() {
this.updateVisible(0);
this.resetScroll();
this.loadTop();
this.loadBottom();
this.cleanupRefInterval = setInterval(this.cleanupRefs, 5000);
}
cleanupRefs = () => {
if(this.saveDepth > 0) {
return;
}
[...this.orphans].forEach(o => {
const index = bigInt(o);
this.childRefs.delete(index.toString());
});
this.orphans.clear();
};
// manipulate scrollbar manually, to dodge change detection
updateScroll = IS_IOS ? () => {} : _.throttle(() => {
if(!this.window || !this.scrollRef) {
@ -186,27 +206,32 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
componentDidUpdate(prevProps: VirtualScrollerProps<T>, _prevState: VirtualScrollerState<T>) {
const { id, size, data, offset, pendingSize } = this.props;
const { visibleItems } = this.state;
if(size !== prevProps.size || pendingSize !== prevProps.pendingSize) {
if(this.scrollLocked) {
this.updateVisible(0);
this.resetScroll();
}
}
}
componentWillUnmount() {
window.removeEventListener('keydown', this.invertedKeyHandler);
if(this.cleanupRefInterval) {
clearInterval(this.cleanupRefInterval);
}
this.cleanupRefs();
this.childRefs.clear();
}
startOffset() {
const startIndex = this.state?.visibleItems?.peekLargest()?.[0];
const { data } = this.props;
const startIndex = this.state.visibleItems?.[0];
if(!startIndex) {
return 0;
}
const offset = [...this.props.data].findIndex(([i]) => i.eq(startIndex))
const dataList = Array.from(data);
const offset = dataList.findIndex(([i]) => i.eq(startIndex))
if(offset === -1) {
// TODO: revisit when we remove nodes for any other reason than
// pending indices being removed
@ -226,22 +251,17 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
log('reflow', `from: ${this.startOffset()} to: ${newOffset}`);
const { data, onCalculateVisibleItems } = this.props;
const visibleItems = new BigIntOrderedMap<any>(
[...data].slice(newOffset, newOffset + this.pageSize)
);
const visibleItems = data.keys().slice(newOffset, newOffset + this.pageSize);
this.save();
this.setState({
visibleItems,
}, () => {
requestAnimationFrame(() => {
this.restore();
requestAnimationFrame(() => {
});
});
});
requestAnimationFrame(() => {
this.restore();
});
}
scrollKeyMap(): Map<string, number> {
@ -273,7 +293,6 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
setWindow(element) {
if (!element)
return;
console.log('resetting window');
this.save();
if (this.window) {
@ -286,8 +305,8 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
const { averageHeight } = this.props;
this.window = element;
this.pageSize = Math.floor(element.offsetHeight / Math.floor(averageHeight / 5.5));
this.pageDelta = Math.floor(this.pageSize / 3);
this.pageSize = Math.floor(element.offsetHeight / Math.floor(averageHeight / 2));
this.pageDelta = Math.floor(this.pageSize / 4);
if (this.props.origin === 'bottom') {
element.addEventListener('wheel', (event) => {
event.preventDefault();
@ -333,7 +352,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
}
};
onScroll(event: UIEvent) {
onScroll(event: SyntheticEvent<HTMLElement, ScrollEvent>) {
this.updateScroll();
if(!this.window) {
// bail if we're going to adjust scroll anyway
@ -348,19 +367,19 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
const { scrollTop, scrollHeight } = this.window;
const startOffset = this.startOffset();
const scrollEnd = scrollTop + windowHeight;
if (scrollTop < ZONE_SIZE) {
log('scroll', `Entered start zone ${scrollTop}`);
if (startOffset === 0 && onStartReached) {
onStartReached();
if (startOffset === 0) {
onStartReached && onStartReached();
this.scrollLocked = true;
}
const newOffset = Math.max(0, startOffset - this.pageDelta);
if(newOffset < 10) {
this.loadBottom();
}
if(newOffset === 0) {
this.scrollLocked = true;
}
if(newOffset !== startOffset) {
this.updateVisible(newOffset);
}
@ -394,20 +413,37 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
log('bail', 'Deep restore');
return;
}
if(this.scrollLocked) {
this.resetScroll();
requestAnimationFrame(() => {
this.savedIndex = null;
this.savedDistance = 0;
this.saveDepth--;
});
return;
}
let ref = this.childRefs.get(this.savedIndex.toString())
if(!ref) {
return;
}
const newScrollTop = this.props.origin === 'top'
? this.savedDistance + ref.offsetTop
: this.window.scrollHeight - ref.offsetTop - this.savedDistance;
const ref = this.childRefs.get(this.savedIndex)!;
const newScrollTop = this.window.scrollHeight - ref.offsetTop - this.savedDistance;
this.window.scrollTo(0, newScrollTop);
requestAnimationFrame(() => {
this.savedIndex = null;
this.savedDistance = 0;
this.saveDepth--;
});
this.savedIndex = null;
this.savedDistance = 0;
this.saveDepth--;
});
}
scrollToIndex = (index: BigInteger) => {
let ref = this.childRefs.get(index);
let ref = this.childRefs.get(index.toString());
if(!ref) {
const offset = [...this.props.data].findIndex(([idx]) => idx.eq(index));
if(offset === -1) {
@ -415,7 +451,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
}
this.updateVisible(Math.max(offset - this.pageDelta, 0));
requestAnimationFrame(() => {
ref = this.childRefs.get(index);
ref = this.childRefs.get(index.toString());
this.savedIndex = null;
this.savedDistance = 0;
this.saveDepth = 0;
@ -435,17 +471,21 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
if(!this.window || this.savedIndex) {
return;
}
this.saveDepth++;
if(this.saveDepth !== 1) {
console.log('bail', 'deep save');
if(this.saveDepth !== 0) {
return;
}
let bottomIndex: BigInteger | null = null;
log('scroll', 'saving...');
this.saveDepth++;
const { visibleItems } = this.state;
let bottomIndex = visibleItems[visibleItems.length - 1];
const { scrollTop, scrollHeight } = this.window;
const topSpacing = scrollHeight - scrollTop;
[...Array.from(this.state.visibleItems)].reverse().forEach(([index, datum]) => {
const el = this.childRefs.get(index);
const topSpacing = this.props.origin === 'top' ? scrollTop : scrollHeight - scrollTop;
const items = this.props.origin === 'top' ? visibleItems : [...visibleItems].reverse();
items.forEach((index) => {
const el = this.childRefs.get(index.toString());
if(!el) {
return;
}
@ -458,24 +498,30 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
if(!bottomIndex) {
// weird, shouldn't really happen
this.saveDepth--;
log('bail', 'no index found');
return;
}
this.savedIndex = bottomIndex;
const ref = this.childRefs.get(bottomIndex)!;
const ref = this.childRefs.get(bottomIndex.toString())!;
if(!ref) {
this.saveDepth--;
log('bail', 'missing ref');
return;
}
const { offsetTop } = ref;
this.savedDistance = topSpacing - offsetTop
}
shiftLayout = { save: this.save.bind(this), restore: this.restore.bind(this) };
// disabled until we work out race conditions with loading new nodes
shiftLayout = { save: () => {}, restore: () => {} };
setRef = (element: HTMLElement | null, index: BigInteger) => {
if(element) {
this.childRefs.set(index, element);
this.childRefs.set(index.toString(), element);
this.orphans.delete(index.toString());
} else {
setTimeout(() => {
this.childRefs.delete(index);
});
this.orphans.add(index.toString());
}
}
@ -494,12 +540,11 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
const isTop = origin === 'top';
const indexesToRender = isTop ? visibleItems.keys() : visibleItems.keys().reverse();
const transform = isTop ? 'scale3d(1, 1, 1)' : 'scale3d(1, -1, 1)';
const children = isTop ? visibleItems : [...visibleItems].reverse();
const atStart = (this.props.data.peekLargest()?.[0] ?? bigInt.zero).eq(visibleItems.peekLargest()?.[0] || bigInt.zero);
const atEnd = this.state.loaded.top;
const atStart = (this.props.data.peekLargest()?.[0] ?? bigInt.zero).eq(visibleItems?.[0] || bigInt.zero);
const atEnd = (this.props.data.peekSmallest()?.[0] ?? bigInt.zero).eq(visibleItems?.[visibleItems.length -1 ] || bigInt.zero);
return (
<>
@ -511,7 +556,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
<LoadingSpinner />
</Center>)}
<VirtualContext.Provider value={this.shiftLayout}>
{indexesToRender.map(index => (
{children.map(index => (
<VirtualChild
key={index.toString()}
setRef={this.setRef}
@ -544,8 +589,10 @@ function VirtualChild(props: VirtualChildProps) {
const ref = useCallback((el: HTMLElement | null) => {
setRef(el, props.index);
}, [setRef, props.index])
// VirtualChild should always be keyed on the index, so the index should be
// valid for the entire lifecycle of the component, hence no dependencies
}, []);
return (<Renderer ref={ref} {...rest} />);
return <Renderer ref={ref} {...rest} />
};

View File

@ -65,7 +65,7 @@ export class OmniboxResult extends Component {
<Icon
display='inline-block'
verticalAlign='middle'
icon='Inbox'
icon='Notifications'
mr='2'
size='18px'
color={iconFill}
@ -85,7 +85,7 @@ export class OmniboxResult extends Component {
<Icon
display='inline-block'
verticalAlign='middle'
icon='SignOut'
icon='LogOut'
mr='2'
size='18px'
color={iconFill}
@ -119,7 +119,7 @@ export class OmniboxResult extends Component {
<Icon
display='inline-block'
verticalAlign='middle'
icon='Inbox'
icon='Notifications'
mr='2'
size='18px'
color={iconFill}
@ -130,7 +130,7 @@ export class OmniboxResult extends Component {
<Icon
display='inline-block'
verticalAlign='middle'
icon='Users'
icon='Messages'
mr='2'
size='18px'
color={iconFill}

View File

@ -1,8 +1,8 @@
import React from 'react';
import useStorage from '~/logic/lib/useStorage';
import useStorage, {IuseStorage} from '~/logic/lib/useStorage';
const withStorage = (Component, params = {}) => {
return React.forwardRef((props: any, ref) => {
const withStorage = <P, C extends React.ComponentType<P>>(Component: C, params = {}) => {
return React.forwardRef<C, Omit<C, keyof IuseStorage>>((props, ref) => {
const storage = useStorage(params);
return <Component ref={ref} {...storage} {...props} />;

View File

@ -27,13 +27,13 @@ export function ChannelPopoverRoutesSidebar(props: {
Preferences
</Text>
<SidebarItem
icon="Inbox"
icon='Notifications'
text="Notifications"
to={relativePath('/settings#notifications')}
/>
{!isOwner && (
<SidebarItem
icon="SignOut"
icon="LogOut"
text="Unsubscribe"
color="red"
to={relativePath('/settings#unsubscribe')}
@ -45,7 +45,7 @@ export function ChannelPopoverRoutesSidebar(props: {
Administration
</Text>
<SidebarItem
icon="Boot"
icon="BootNode"
text="Channel Details"
to={relativePath('/settings#details')}
/>
@ -56,14 +56,14 @@ export function ChannelPopoverRoutesSidebar(props: {
/>
{ isOwner ? (
<SidebarItem
icon="TrashCan"
icon="X"
text="Archive Channel"
to={relativePath('/settings#archive')}
color="red"
/>
) : (
<SidebarItem
icon="TrashCan"
icon="X"
text="Archive Channel"
to={relativePath('/settings#remove')}
color="red"

View File

@ -36,7 +36,7 @@ return;
? 'Permanently delete this group. (All current members will no longer see this group.)'
: 'You can rejoin if it is an open group, or if you are reinvited';
const icon = props.owner ? 'X' : 'SignOut';
const icon = props.owner ? 'X' : 'LogOut';
const { modal, showModal } = useModal({ modal:
(dismiss: () => void) => {
const onCancel = (e) => {

View File

@ -58,7 +58,11 @@ function GraphContentWideInner(
width="fit-content"
maxWidth="min(500px, 100%)"
>
<RemoteContent key={content.url} url={content.url} />
<RemoteContent
key={content.url}
url={content.url}
transcluded={transcluded}
/>
</Box>
);
case "mention":

View File

@ -29,9 +29,9 @@ export function GroupSummary(props: GroupSummaryProps & PropFunc<typeof Col>): R
width="40px"
height="40px"
metadata={metadata}
flexShrink="0"
flexShrink={0}
/>
<Col justifyContent="space-between" flexGrow="1" overflow="hidden">
<Col justifyContent="space-between" flexGrow={1} overflow="hidden">
<Text
fontSize="1"
textOverflow="ellipsis"

View File

@ -180,7 +180,7 @@ export function GroupSwitcher(props: {
>
<Row flexGrow={1} alignItems="center" width='100%' minWidth='0' flexShrink={0}>
{ metadata && <MetadataIcon flexShrink={0} mr="2" metadata={metadata} height="24px" width="24px" /> }
<Text flexShrink={1} lineHeight="1.1" fontSize='2' fontWeight="600" overflow='hidden' display='inline-block' flexShrink='1' style={{ textOverflow: 'ellipsis', whiteSpace: 'pre' }}>{title}</Text>
<Text flexShrink={1} lineHeight="1.1" fontSize='2' fontWeight="600" overflow='hidden' display='inline-block' style={{ textOverflow: 'ellipsis', whiteSpace: 'pre' }}>{title}</Text>
</Row>
</Dropdown>
<Row pr='3' verticalAlign="middle">

View File

@ -69,7 +69,7 @@ export function GroupifyForm(props: GroupifyFormProps) {
onSubmit={onGroupify}
>
<Form>
<Col flexShrink="0" gapY="4" maxWidth="512px">
<Col flexShrink={0} gapY="4" maxWidth="512px">
<Box>
<Text fontWeight="500">Groupify this channel</Text>
</Box>

View File

@ -124,7 +124,6 @@ export function GroupsPane(props: GroupsPaneProps) {
>
<Resource
{...props}
{...routeProps}
association={association}
baseUrl={baseUrl}
/>

View File

@ -2,7 +2,7 @@ import React from 'react';
import bigInt from 'big-integer';
import VirtualScroller from "~/views/components/VirtualScroller";
import PostItem from './PostItem/PostItem';
import { Col } from '@tlon/indigo-react';
import { Col, Box } from '@tlon/indigo-react';
import { resourceFromPath } from '~/logic/lib/group';
@ -15,102 +15,103 @@ export class PostFeed extends React.Component {
super(props);
this.isFetching = false;
this.renderItem = React.forwardRef(({ index, scrollWindow }, ref) => {
const {
graph,
graphPath,
api,
history,
baseUrl,
parentNode,
grandparentNode,
association,
group,
vip
} = this.props;
const graphResource = resourceFromPath(graphPath);
const node = graph.get(index);
if (!node) { return null; }
const first = graph.peekLargest()?.[0];
const post = node?.post;
if (!node || !post) {
return null;
}
let nodeIndex = parentNode ? parentNode.post.index.split('/').slice(1).map((ind) => {
return bigInt(ind);
}) : [];
if (parentNode && index.eq(first ?? bigInt.zero)) {
return (
<React.Fragment key={index.toString()}>
<Col
key={index.toString()}
mb="3"
width="100%"
flexShrink={0}
>
<PostItem
key={parentNode.post.index}
ref={ref}
parentPost={grandparentNode?.post}
node={parentNode}
parentNode={grandparentNode}
graphPath={graphPath}
association={association}
api={api}
index={nodeIndex}
baseUrl={baseUrl}
history={history}
isParent={true}
isRelativeTime={false}
vip={vip}
group={group}
/>
</Col>
<PostItem
ref={ref}
node={node}
graphPath={graphPath}
association={association}
api={api}
index={[...nodeIndex, index]}
baseUrl={baseUrl}
history={history}
isReply={true}
parentPost={parentNode.post}
isRelativeTime={true}
vip={vip}
group={group}
/>
</React.Fragment>
);
}
return (
<PostItem
key={index.toString()}
ref={ref}
node={node}
graphPath={graphPath}
association={association}
api={api}
index={[...nodeIndex, index]}
baseUrl={baseUrl}
history={history}
parentPost={parentNode?.post}
isReply={!!parentNode}
isRelativeTime={true}
vip={vip}
group={group}
/>
);
});
this.fetchPosts = this.fetchPosts.bind(this);
this.doNotFetch = this.doNotFetch.bind(this);
}
renderItem = React.forwardRef(({ index, scrollWindow }, ref) => {
const {
graph,
graphPath,
api,
history,
baseUrl,
parentNode,
grandparentNode,
association,
group,
vip
} = this.props;
const graphResource = resourceFromPath(graphPath);
const node = graph.get(index);
if (!node) { return null; }
const first = graph.peekLargest()?.[0];
const post = node?.post;
if (!node || !post) {
return null;
}
let nodeIndex = parentNode ? parentNode.post.index.split('/').slice(1).map((ind) => {
return bigInt(ind);
}) : [];
if (parentNode && index.eq(first ?? bigInt.zero)) {
return (
<React.Fragment key={index.toString()}>
<Col
key={index.toString()}
ref={ref}
mb="3"
width="100%"
flexShrink={0}
>
<PostItem
key={parentNode.post.index}
parentPost={grandparentNode?.post}
node={parentNode}
parentNode={grandparentNode}
graphPath={graphPath}
association={association}
api={api}
index={nodeIndex}
baseUrl={baseUrl}
history={history}
isParent={true}
isRelativeTime={false}
vip={vip}
group={group}
/>
</Col>
<PostItem
node={node}
graphPath={graphPath}
association={association}
api={api}
index={[...nodeIndex, index]}
baseUrl={baseUrl}
history={history}
isReply={true}
parentPost={parentNode.post}
isRelativeTime={true}
vip={vip}
group={group}
/>
</React.Fragment>
);
}
return (
<Box key={index.toString()} ref={ref}>
<PostItem
node={node}
graphPath={graphPath}
association={association}
api={api}
index={[...nodeIndex, index]}
baseUrl={baseUrl}
history={history}
parentPost={parentNode?.post}
isReply={!!parentNode}
isRelativeTime={true}
vip={vip}
group={group}
/>
</Box>
);
});
async fetchPosts(newer) {
const { graph, graphPath, api } = this.props;
const graphResource = resourceFromPath(graphPath);

View File

@ -24,7 +24,7 @@ function canWrite(props) {
if(vip === 'host-feed') {
return isHost(association.group);
}
return isWriter(group, association.resource);
}
@ -147,7 +147,7 @@ export function PostInput(props) {
<LoadingSpinner />
) : (
<Icon
icon='Links'
icon='Attachment'
width='16'
height='16'
onClick={uploadImage}

View File

@ -46,6 +46,7 @@ export function PostHeader(props) {
isRelativeTime={true}
showTime={false}
time={true}
lineHeight='1'
/>
<Dropdown
dropWidth="200px"

View File

@ -177,7 +177,7 @@ export function JoinGroup(props: JoinGroupProps): ReactElement {
<Text gray fontSize="1">
Channels
</Text>
<Box width="100%" flexShrink="0">
<Box width="100%" flexShrink={0}>
{Object.values(preview.channels).map(({ metadata }: any) => (
<Row width="100%">
<Icon

View File

@ -11,7 +11,7 @@ import * as Yup from 'yup';
import GlobalApi from '~/logic/api/global';
import { AsyncButton } from '~/views/components/AsyncButton';
import { FormError } from '~/views/components/FormError';
import { RouteComponentProps } from 'react-router-dom';
import { RouteComponentProps, useHistory } from 'react-router-dom';
import { stringToSymbol, parentPath, deSig } from '~/logic/lib/util';
import { resourceFromPath } from '~/logic/lib/group';
import { Associations } from '@urbit/api/metadata';
@ -46,12 +46,13 @@ interface NewChannelProps {
workspace: Workspace;
}
export function NewChannel(props: NewChannelProps & RouteComponentProps): ReactElement {
const { history, api, group, workspace } = props;
export function NewChannel(props: NewChannelProps): ReactElement {
const history = useHistory();
const { api, group, workspace } = props;
const groups = useGroupState(state => state.groups);
const waiter = useWaitForProps({ groups }, 5000);
const onSubmit = async (values: FormSchema, actions) => {
const name = (values.name) ? values.name : values.moduleType;
const resId: string = stringToSymbol(values.name)
@ -152,7 +153,7 @@ export function NewChannel(props: NewChannelProps & RouteComponentProps): ReactE
name="moduleType"
/>
<IconRadio
icon="Publish"
icon="Notebook"
label="Notebook"
id="publish"
name="moduleType"

View File

@ -181,9 +181,9 @@ export function Participants(props: {
mb={2}
px={2}
zIndex={1}
flexShrink="0"
flexShrink={0}
>
<Row mr="4" flexShrink="0">
<Row mr="4" flexShrink={0}>
<Tab
selected={filter}
setSelected={setFilter}
@ -206,9 +206,9 @@ export function Participants(props: {
/>
</Row>
</Row>
<Col flexShrink="0" width="100%" height="fit-content">
<Col flexShrink={0} width="100%" height="fit-content">
<Row alignItems="center" bg="washedGray" borderRadius="1" px="2" my="2">
<Icon color="gray" icon="MagnifyingGlass" />
<Icon color="gray" icon="Search" />
<Input
maxWidth="256px"
color="gray"
@ -304,7 +304,7 @@ function Participant(props: {
}, [api, contact, association]);
const avatar =
contact?.avatar !== null && !hideAvatars ? (
contact?.avatar && !hideAvatars ? (
<Image
src={contact.avatar}
height={32}

View File

@ -76,7 +76,7 @@ export function PopoverRoutes(
<Col gapY="2">
<Text my="1" mx="3" gray>Group</Text>
<SidebarItem
icon="Inbox"
icon='Notifications'
to={relativeUrl('/settings#notifications')}
text="Notifications"
/>
@ -98,7 +98,7 @@ export function PopoverRoutes(
text="Group Details"
/>
<SidebarItem
icon="Spaces"
icon="Dashboard"
to={relativeUrl('/settings#channels')}
text="Channel Management"
/>

View File

@ -15,12 +15,14 @@ import useGroupState from '~/logic/state/group';
import useContactState from '~/logic/state/contact';
import useHarkState from '~/logic/state/hark';
import useMetadataState from '~/logic/state/metadata';
import {Workspace} from '~/types';
type ResourceProps = StoreState & {
association: Association;
api: GlobalApi;
baseUrl: string;
} & RouteComponentProps;
workspace: Workspace;
};
export function Resource(props: ResourceProps): ReactElement {
const { association, api, notificationsGraphConfig } = props;

View File

@ -77,7 +77,7 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
fontSize='1'
mr='12px'
my='1'
flexShrink='0'
flexShrink={0}
display={['block','none']}
>
<Link to={`/~landscape${workspace}`}>
@ -98,7 +98,7 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
maxWidth={association?.metadata?.description ? ['100%', '50%'] : 'none'}
mr='2'
ml='1'
flexShrink={['1', '0']}
flexShrink={[1, 0]}
>
{title}
</Text>
@ -112,7 +112,7 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
mb='0'
minWidth='0'
maxWidth='50%'
flexShrink='1'
flexShrink={1}
disableRemoteContent
>
{workspace === '/messages'
@ -145,7 +145,7 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
return (
<Col width='100%' height='100%' overflow='hidden'>
<Box
flexShrink='0'
flexShrink={0}
height='48px'
py='2'
px='2'
@ -159,7 +159,7 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
display='flex'
alignItems='baseline'
width={`calc(100% - ${actionsWidth}px - 16px)`}
flexShrink='0'
flexShrink={0}
>
<BackLink />
<Title />
@ -169,7 +169,7 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
ml={3}
display='flex'
alignItems='center'
flexShrink='0'
flexShrink={0}
ref={actionsRef}
>
{canWrite && <WriterControls />}

View File

@ -1,6 +1,6 @@
import React, { ReactElement, useCallback } from 'react';
import { FormikHelpers } from 'formik';
import { Link } from 'react-router-dom';
import { Link, useHistory } from 'react-router-dom';
import {
Row,
@ -34,6 +34,7 @@ export function SidebarListHeader(props: {
workspace: Workspace;
handleSubmit: (c: SidebarListConfig) => void;
}): ReactElement {
const history = useHistory();
const onSubmit = useCallback(
(values: SidebarListConfig, actions: FormikHelpers<SidebarListConfig>) => {
props.handleSubmit(values);
@ -65,7 +66,7 @@ export function SidebarListHeader(props: {
<Box>
{( !!feedPath) ? (
<Row
flexShrink="0"
flexShrink={0}
alignItems="center"
justifyContent="space-between"
py={2}
@ -74,18 +75,18 @@ export function SidebarListHeader(props: {
borderBottom={1}
borderColor="lightGray"
backgroundColor={['transparent',
props.history.location.pathname.includes(`/~landscape${groupPath}/feed`)
history.location.pathname.includes(`/~landscape${groupPath}/feed`)
? (
'washedGray'
) : (
'transparent'
)]}
cursor={['pointer', (
props.history.location.pathname === `/~landscape${groupPath}/feed`
cursor={(
history.location.pathname === `/~landscape${groupPath}/feed`
? 'default' : 'pointer'
)]}
)}
onClick={() => {
props.history.push(`/~landscape${groupPath}/feed`);
history.push(`/~landscape${groupPath}/feed`);
}}
>
<Text>
@ -98,14 +99,14 @@ export function SidebarListHeader(props: {
) : null
}
<Row
flexShrink="0"
flexShrink={0}
alignItems="center"
justifyContent="space-between"
py={2}
px={3}
height='48px'
>
<Box flexShrink='0'>
<Box flexShrink={0}>
<Text>
{props.initialValues.hideUnjoined ? `Joined ${noun}` : `All ${noun}`}
</Text>
@ -131,7 +132,6 @@ export function SidebarListHeader(props: {
>
<NewChannel
api={props.api}
history={props.history}
workspace={props.workspace}
/>
</Col>
@ -152,7 +152,7 @@ export function SidebarListHeader(props: {
)
}
<Dropdown
flexShrink='0'
flexShrink={0}
width="auto"
alignY="top"
alignX={['right', 'left']}

View File

@ -1,227 +1,79 @@
import { BigInteger } from "big-integer";
import { immerable } from 'immer';
import produce, { immerable, castImmutable, castDraft, setAutoFreeze, enablePatches } from 'immer';
import bigInt, { BigInteger } from "big-integer";
interface NonemptyNode<V> {
n: [BigInteger, V];
l: MapNode<V>;
r: MapNode<V>;
setAutoFreeze(false);
enablePatches();
function sortBigInt(a: BigInteger, b: BigInteger) {
if (a.lt(b)) {
return 1;
} else if (a.eq(b)) {
return 0;
} else {
return -1;
}
}
type MapNode<V> = NonemptyNode<V> | null;
/**
* An implementation of ordered maps for JS
* Plagiarised wholesale from sys/zuse
*/
export default class BigIntOrderedMap<V> implements Iterable<[BigInteger, V]> {
private root: MapNode<V> = null;
root: Record<string, V> = {}
cachedIter: [BigInteger, V][] = [];
[immerable] = true;
size: number = 0;
constructor(initial: [BigInteger, V][] = []) {
initial.forEach(([key, val]) => {
constructor(items: [BigInteger, V][] = []) {
items.forEach(([key, val]) => {
this.set(key, val);
});
}
/**
* Retrieve an value for a key
*/
get(key: BigInteger): V | null {
const inner = (node: MapNode<V>): V | null => {
if (!node) {
return null;
}
const [k, v] = node.n;
if (key.eq(k)) {
return v;
}
if (key.gt(k)) {
return inner(node.l);
} else {
return inner(node.r);
}
};
return inner(this.root);
get size() {
return Object.keys(this.root).length;
}
/**
* Put an item by a key
*/
set(key: BigInteger, value: V): void {
const inner = (node: MapNode<V>): MapNode<V> => {
if (!node) {
return {
n: [key, value],
l: null,
r: null,
};
}
const [k] = node.n;
if (key.eq(k)) {
this.size--;
return {
...node,
n: [k, value],
};
}
if (key.gt(k)) {
const l = inner(node.l);
if (!l) {
throw new Error("invariant violation");
}
return {
...node,
l,
};
}
const r = inner(node.r);
if (!r) {
throw new Error("invariant violation");
}
return { ...node, r };
};
this.size++;
this.root = inner(this.root);
get(key: BigInteger) {
return this.root[key.toString()] ?? null;
}
gas(items: [BigInteger, V][]) {
return produce(this, draft => {
items.forEach(([key, value]) => {
draft.root[key.toString()] = castDraft(value);
});
draft.generateCachedIter();
},
(patches) => {
//console.log(`gassed with ${JSON.stringify(patches, null, 2)}`);
});
}
set(key: BigInteger, value: V) {
return produce(this, draft => {
draft.root[key.toString()] = castDraft(value);
draft.generateCachedIter();
});
}
/**
* Remove all entries
*/
clear() {
this.root = null;
return produce(this, draft => {
draft.cachedIter = [];
draft.root = {}
});
}
/**
* Predicate testing if map contains key
*/
has(key: BigInteger): boolean {
const inner = (node: MapNode<V>): boolean => {
if (!node) {
return false;
}
const [k] = node.n;
if (k.eq(key)) {
return true;
}
if (key.gt(k)) {
return inner(node.l);
}
return inner(node.r);
};
return inner(this.root);
has(key: BigInteger) {
return key.toString() in this.root;
}
/**
* Remove value associated with key, returning whether that key
* existed in the first place
*/
delete(key: BigInteger) {
const inner = (node: MapNode<V>): [boolean, MapNode<V>] => {
if (!node) {
return [false, null];
}
const [k] = node.n;
if (k.eq(key)) {
return [true, this.nip(node)];
}
if (key.gt(k)) {
const [bool, l] = inner(node.l);
return [
bool,
{
...node,
l,
},
];
}
const [bool, r] = inner(node.r);
return [
bool,
{
...node,
r,
},
];
};
const [ret, newRoot] = inner(this.root);
if(ret) {
this.size--;
}
this.root = newRoot;
return ret;
}
private nip(nod: NonemptyNode<V>): MapNode<V> {
const inner = (node: NonemptyNode<V>): MapNode<V> => {
if (!node.l) {
return node.r;
}
if (!node.r) {
return node.l;
}
return {
...node.l,
r: inner(node.r),
};
};
return inner(nod);
}
peekLargest(): [BigInteger, V] | undefined {
const inner = (node: MapNode<V>): [BigInteger, V] | undefined => {
if(!node) {
return undefined;
}
if(node.l) {
return inner(node.l);
}
return node.n;
}
return inner(this.root);
}
peekSmallest(): [BigInteger, V] | undefined {
const inner = (node: MapNode<V>): [BigInteger, V] | undefined => {
if(!node) {
return undefined;
}
if(node.r) {
return inner(node.r);
}
return node.n;
}
return inner(this.root);
}
keys(): BigInteger[] {
const list = Array.from(this);
return list.map(([key]) => key);
}
forEach(f: (value: V, key: BigInteger) => void) {
const list = Array.from(this);
return list.forEach(([k,v]) => f(v,k));
return produce(this, draft => {
delete draft.root[key.toString()];
draft.cachedIter = draft.cachedIter.filter(([x]) => x.eq(key));
});
}
[Symbol.iterator](): IterableIterator<[BigInteger, V]> {
let result: [BigInteger, V][] = [];
const inner = (node: MapNode<V>) => {
if (!node) {
return;
}
inner(node.l);
result.push(node.n);
inner(node.r);
};
inner(this.root);
let idx = 0;
let result = [...this.cachedIter];
return {
[Symbol.iterator]: this[Symbol.iterator],
next: (): IteratorResult<[BigInteger, V]> => {
@ -232,4 +84,27 @@ export default class BigIntOrderedMap<V> implements Iterable<[BigInteger, V]> {
},
};
}
peekLargest() {
const sorted = Array.from(this);
return sorted[0] as [BigInteger, V] | null;
}
peekSmallest() {
const sorted = Array.from(this);
return sorted[sorted.length - 1] as [BigInteger, V] | null;
}
keys() {
return Array.from(this).map(([k,v]) => k);
}
generateCachedIter() {
const result = Object.keys(this.root).map(key => {
const num = bigInt(key);
return [num, this.root[key]] as [BigInteger, V];
}).sort(([a], [b]) => sortBigInt(a,b));
this.cachedIter = result;
}
}

View File

@ -65,3 +65,13 @@
*/
u3_noun
u3s_cue_atom(u3_atom a);
/* u3s_sift_ud_bytes: parse @ud.
*/
u3_weak
u3s_sift_ud_bytes(c3_w len_w, c3_y* byt_y);
/* u3s_sift_ud: parse @ud.
*/
u3_weak
u3s_sift_ud(u3_atom a);

View File

@ -5,49 +5,16 @@
#include <ctype.h>
/* functions
*/
u3_noun
_parse_ud(u3_noun txt) {
c3_c* c = u3a_string(txt);
static inline u3_noun
_parse_ud(u3_noun a)
{
u3_weak pro;
// First character must represent a digit
c3_c* cur = c;
if (cur[0] > '9' || cur[0] < '0') {
u3a_free(c);
return u3_none;
}
u3_atom total = cur[0] - '0';
cur++;
int since_last_period = 0;
while (cur[0] != 0) {
since_last_period++;
if (cur[0] == '.') {
since_last_period = 0;
cur++;
continue;
}
if (cur[0] > '9' || cur[0] < '0') {
u3a_free(c);
u3z(total);
return u3_none;
}
total = u3ka_mul(total, 10);
total = u3ka_add(total, cur[0] - '0');
cur++;
if (since_last_period > 3) {
u3a_free(c);
u3z(total);
return u3_none;
}
if ( u3_none == (pro = u3s_sift_ud(u3x_atom(a))) ) {
return u3_nul;
}
u3a_free(c);
return u3nc(0, total);
return u3nc(u3_nul, pro);
}
static

View File

@ -313,12 +313,17 @@ u3i_word(c3_w dat_w)
u3_atom
u3i_chub(c3_d dat_d)
{
c3_w dat_w[2] = {
dat_d & 0xffffffffULL,
dat_d >> 32
};
if ( c3y == u3a_is_cat(dat_d) ) {
return (u3_atom)dat_d;
}
else {
c3_w dat_w[2] = {
dat_d & 0xffffffffULL,
dat_d >> 32
};
return u3i_words(2, dat_w);
return u3i_words(2, dat_w);
}
}
/* u3i_bytes(): Copy [a] bytes from [b] to an LSB first atom.

View File

@ -98,28 +98,22 @@ _cm_punt(u3_noun tax)
}
#endif
static void _write(int fd, const void *buf, size_t count)
{
if (count != write(fd, buf, count)){
u3l_log("write failed\r\n");
c3_assert(0);
}
}
/* _cm_emergency(): write emergency text to stderr, never failing.
*/
static void
_cm_emergency(c3_c* cap_c, c3_l sig_l)
{
_write(2, "\r\n", 2);
_write(2, cap_c, strlen(cap_c));
c3_i ret_i;
ret_i = write(2, "\r\n", 2);
ret_i = write(2, cap_c, strlen(cap_c));
if ( sig_l ) {
_write(2, ": ", 2);
_write(2, &sig_l, 4);
ret_i = write(2, ": ", 2);
ret_i = write(2, &sig_l, 4);
}
_write(2, "\r\n", 2);
ret_i = write(2, "\r\n", 2);
}
static void _cm_overflow(void *arg1, void *arg2, void *arg3)
@ -127,7 +121,7 @@ static void _cm_overflow(void *arg1, void *arg2, void *arg3)
(void)(arg1);
(void)(arg2);
(void)(arg3);
siglongjmp(u3_Signal, c3__over);
u3m_signal(c3__over);
}
/* _cm_signal_handle(): handle a signal in general.
@ -139,7 +133,7 @@ _cm_signal_handle(c3_l sig_l)
sigsegv_leave_handler(_cm_overflow, NULL, NULL, NULL);
}
else {
siglongjmp(u3_Signal, sig_l);
u3m_signal(sig_l);
}
}
@ -661,7 +655,10 @@ u3m_dump(void)
c3_i
u3m_bail(u3_noun how)
{
if ( (c3__exit == how) && (u3R == &u3H->rod_u) ) {
if ( &(u3H->rod_u) == u3R ) {
// XX set exit code
//
fprintf(stderr, "home: bailing out\r\n");
abort();
}
@ -688,8 +685,9 @@ u3m_bail(u3_noun how)
//
switch ( how ) {
case c3__foul:
case c3__meme:
case c3__oops: {
// XX set exit code
//
fprintf(stderr, "bailing out\r\n");
abort();
}
@ -700,6 +698,9 @@ u3m_bail(u3_noun how)
// choice but to use the signal process; and we require the flat
// form of how.
//
// XX JB: these seem unrecoverable, at least wrt memory management,
// so they've been disabled above for now
//
c3_assert(_(u3a_is_cat(how)));
u3m_signal(how);
}

View File

@ -1205,16 +1205,11 @@ u3r_mp(mpz_t a_mp,
else {
u3a_atom* b_u = u3a_to_ptr(b);
c3_w len_w = b_u->len_w;
c3_d bit_d = (c3_d)len_w << 5;
// avoid reallocation on import, if possible
//
if ( (len_w >> 27) ) {
mpz_init(a_mp);
}
else {
mpz_init2(a_mp, len_w << 5);
}
mpz_init2(a_mp, (c3_w)c3_min(bit_d, UINT32_MAX));
mpz_import(a_mp, len_w, -1, sizeof(c3_w), 0, 0, b_u->buf_w);
}
}

View File

@ -856,3 +856,128 @@ u3s_cue_atom(u3_atom a)
return u3s_cue_bytes((c3_d)len_w, byt_y);
}
#define DIGIT(a) ( ((a) >= '0') && ((a) <= '9') )
#define BLOCK(a) ( ('.' == (a)[0]) \
&& DIGIT(a[1]) \
&& DIGIT(a[2]) \
&& DIGIT(a[3]) )
/* u3s_sift_ud_bytes: parse @ud
*/
u3_weak
u3s_sift_ud_bytes(c3_w len_w, c3_y* byt_y)
{
c3_y num_y = len_w % 4; // leading digits length
c3_s val_s = 0; // leading digits value
// +ape:ag: just 0
//
if ( !len_w ) return u3_none;
if ( '0' == *byt_y ) return ( 1 == len_w ) ? (u3_noun)0 : u3_none;
// +ted:ab: leading nonzero (checked above), plus up to 2 digits
//
#define NEXT() do { \
if ( !DIGIT(*byt_y) ) return u3_none; \
val_s *= 10; \
val_s += *byt_y++ - '0'; \
} while (0)
switch ( num_y ) {
case 3: NEXT();
case 2: NEXT();
case 1: NEXT(); break;
case 0: return u3_none;
}
#undef NEXT
len_w -= num_y;
// +tid:ab: dot-prefixed 3-digit blocks
//
// avoid gmp allocation if possible
// - 19 decimal digits fit in 64 bits
// - 18 digits is 24 bytes with separators
//
if ( ((1 == num_y) && (24 >= len_w))
|| (20 >= len_w) )
{
c3_d val_d = val_s;
while ( len_w ) {
if ( !BLOCK(byt_y) ) return u3_none;
byt_y++;
val_d *= 10;
val_d += *byt_y++ - '0';
val_d *= 10;
val_d += *byt_y++ - '0';
val_d *= 10;
val_d += *byt_y++ - '0';
len_w -= 4;
}
return u3i_chub(val_d);
}
{
// avoid gmp realloc if possible
//
mpz_t a_mp;
{
c3_d bit_d = (c3_d)(len_w / 4) * 10;
mpz_init2(a_mp, (c3_w)c3_min(bit_d, UINT32_MAX));
mpz_set_ui(a_mp, val_s);
}
while ( len_w ) {
if ( !BLOCK(byt_y) ) {
mpz_clear(a_mp);
return u3_none;
}
byt_y++;
val_s = *byt_y++ - '0';
val_s *= 10;
val_s += *byt_y++ - '0';
val_s *= 10;
val_s += *byt_y++ - '0';
mpz_mul_ui(a_mp, a_mp, 1000);
mpz_add_ui(a_mp, a_mp, val_s);
len_w -= 4;
}
return u3i_mp(a_mp);
}
}
#undef BLOCK
#undef DIGIT
/* u3s_sift_ud: parse @ud.
*/
u3_weak
u3s_sift_ud(u3_atom a)
{
c3_w len_w = u3r_met(3, a);
c3_y* byt_y;
// XX assumes little-endian
//
if ( c3y == u3a_is_cat(a) ) {
byt_y = (c3_y*)&a;
}
else {
u3a_atom* vat_u = u3a_to_ptr(a);
byt_y = (c3_y*)vat_u->buf_w;
}
return u3s_sift_ud_bytes(len_w, byt_y);
}

View File

@ -9,6 +9,108 @@ _setup(void)
u3m_pave(c3y);
}
static inline c3_i
_ud_good(c3_w num_w, const c3_c* num_c)
{
u3_weak out;
if ( num_w != (out = u3s_sift_ud_bytes(strlen(num_c), (c3_y*)num_c)) ) {
if ( u3_none == out ) {
fprintf(stderr, "sift_ud: %s fail; expected %u\r\n", num_c, num_w);
}
else {
fprintf(stderr, "sift_ud: %s wrong; expected %u: actual %u\r\n", num_c, num_w, out);
}
return 0;
}
return 1;
}
static inline c3_i
_ud_fail(const c3_c* num_c)
{
u3_weak out;
if ( u3_none != (out = u3s_sift_ud_bytes(strlen(num_c), (c3_y*)num_c)) ) {
u3m_p("out", out);
fprintf(stderr, "sift_ud: %s expected fail\r\n", num_c);
return 0;
}
return 1;
}
static c3_i
_test_sift_ud(void)
{
c3_i ret_i = 1;
ret_i &= _ud_good(0, "0");
ret_i &= _ud_good(1, "1");
ret_i &= _ud_good(12, "12");
ret_i &= _ud_good(123, "123");
ret_i &= _ud_good(1234, "1.234");
ret_i &= _ud_good(12345, "12.345");
ret_i &= _ud_good(123456, "123.456");
ret_i &= _ud_good(1234567, "1.234.567");
ret_i &= _ud_good(12345678, "12.345.678");
ret_i &= _ud_good(123456789, "123.456.789");
ret_i &= _ud_good(100000000, "100.000.000");
ret_i &= _ud_good(101101101, "101.101.101");
ret_i &= _ud_good(201201201, "201.201.201");
ret_i &= _ud_good(302201100, "302.201.100");
ret_i &= _ud_fail("01");
ret_i &= _ud_fail("02");
ret_i &= _ud_fail("003");
ret_i &= _ud_fail("1234");
ret_i &= _ud_fail("1234.5");
ret_i &= _ud_fail("1234.567.8");
ret_i &= _ud_fail("1234.56..78.");
ret_i &= _ud_fail("123.45a");
ret_i &= _ud_fail(".123.456");
{
c3_c* num_c = "4.294.967.296";
u3_weak out = u3s_sift_ud_bytes(strlen(num_c), (c3_y*)num_c);
u3_atom pro = u3qc_bex(32);
if ( u3_none == out ) {
fprintf(stderr, "sift_ud: (bex 32) fail\r\n");
ret_i = 0;
}
if ( c3n == u3r_sing(pro, out) ) {
u3m_p("out", out);
fprintf(stderr, "sift_ud: (bex 32) wrong\r\n");
ret_i = 0;
}
u3z(out); u3z(pro);
}
{
c3_c* num_c = "340.282.366.920.938.463.463.374.607.431.768.211.456";
u3_weak out = u3s_sift_ud_bytes(strlen(num_c), (c3_y*)num_c);
u3_atom pro = u3qc_bex(128);
if ( u3_none == out ) {
fprintf(stderr, "sift_ud: (bex 128) fail\r\n");
ret_i = 0;
}
if ( c3n == u3r_sing(pro, out) ) {
u3m_p("out", out);
fprintf(stderr, "sift_ud: (bex 128) wrong\r\n");
ret_i = 0;
}
u3z(out); u3z(pro);
}
return ret_i;
}
static c3_i
_test_en_base16(void)
{
@ -400,6 +502,11 @@ _test_jets(void)
{
c3_i ret_i = 1;
if ( !_test_sift_ud() ) {
fprintf(stderr, "test jets: sift_ud: failed\r\n");
ret_i = 0;
}
if ( !_test_base16() ) {
fprintf(stderr, "test jets: base16: failed\r\n");
ret_i = 0;

View File

@ -20,7 +20,8 @@
u3_csat_init = 0, // initialized
u3_csat_addr = 1, // address resolution begun
u3_csat_quit = 2, // cancellation requested
u3_csat_ripe = 3 // passed to libh2o
u3_csat_conn = 3, // sync connect phase
u3_csat_ripe = 4 // passed to libh2o
} u3_csat;
/* u3_cres: response to http client.
@ -35,26 +36,26 @@
/* u3_creq: outgoing http request.
*/
typedef struct _u3_creq { // client request
c3_l num_l; // request number
c3_l num_l; // request number
h2o_http1client_t* cli_u; // h2o client
u3_csat sat_e; // connection state
c3_o sec; // yes == https
c3_w ipf_w; // IP
c3_c* ipf_c; // IP (string)
c3_c* hot_c; // host
c3_s por_s; // port
c3_c* por_c; // port (string)
c3_c* met_c; // method
c3_c* url_c; // url
u3_hhed* hed_u; // headers
u3_hbod* bod_u; // body
u3_hbod* rub_u; // exit of send queue
u3_hbod* bur_u; // entry of send queue
h2o_iovec_t* vec_u; // send-buffer array
u3_cres* res_u; // nascent response
struct _u3_creq* nex_u; // next in list
struct _u3_creq* pre_u; // previous in list
struct _u3_cttp* ctp_u; // cttp backpointer
u3_csat sat_e; // connection state
c3_o sec; // yes == https
c3_w ipf_w; // IP
c3_c* ipf_c; // IP (string)
c3_c* hot_c; // host
c3_s por_s; // port
c3_c* por_c; // port (string)
c3_c* met_c; // method
c3_c* url_c; // url
u3_hhed* hed_u; // headers
u3_hbod* bod_u; // body
u3_hbod* rub_u; // exit of send queue
u3_hbod* bur_u; // entry of send queue
h2o_iovec_t* vec_u; // send-buffer array
u3_cres* res_u; // nascent response
struct _u3_creq* nex_u; // next in list
struct _u3_creq* pre_u; // previous in list
struct _u3_cttp* ctp_u; // cttp backpointer
} u3_creq;
/* u3_cttp: http client.
@ -559,13 +560,18 @@ _cttp_creq_new(u3_cttp* ctp_u, c3_l num_l, u3_noun hes)
return 0;
}
// Parse the url out of the new style url passed to us.
// parse the url out of the new style url passed to us.
//
u3_noun unit_pul = u3do("de-purl:html", u3k(url));
if (c3n == u3r_du(unit_pul)) {
u3l_log("cttp: url parsing failed\n");
if ( c3n == u3r_du(unit_pul) ) {
c3_c* url_c = u3r_string(url);
u3l_log("cttp: unable to parse url:\n %s\n", url_c);
c3_free(url_c);
u3z(hes);
return 0;
}
u3_noun pul = u3t(unit_pul);
u3_noun hat = u3h(pul); // +hart
@ -817,20 +823,33 @@ _cttp_creq_on_head(h2o_http1client_t* cli_u, const c3_c* err_c, c3_i ver_i,
*/
static h2o_http1client_head_cb
_cttp_creq_on_connect(h2o_http1client_t* cli_u, const c3_c* err_c,
h2o_iovec_t** vec_p, size_t* vec_t, c3_i* hed_i)
h2o_iovec_t** vec_u, size_t* vec_i, c3_i* hed_i)
{
u3_creq* ceq_u = (u3_creq *)cli_u->data;
if ( 0 != err_c ) {
_cttp_creq_fail(ceq_u, err_c);
// if synchronously connecting, caller will cleanup
//
if ( u3_csat_conn == ceq_u->sat_e ) {
ceq_u->sat_e = u3_csat_quit;
}
else {
c3_assert( u3_csat_ripe == ceq_u->sat_e );
_cttp_creq_fail(ceq_u, err_c);
}
return 0;
}
// serialize request (populate rub_u)
//
_cttp_creq_fire(ceq_u);
{
c3_w len_w;
ceq_u->vec_u = _cttp_bods_to_vec(ceq_u->rub_u, &len_w);
*vec_t = len_w;
*vec_p = ceq_u->vec_u;
*vec_i = len_w;
*vec_u = ceq_u->vec_u;
*hed_i = (0 == strcmp(ceq_u->met_c, "HEAD"));
}
@ -842,24 +861,43 @@ _cttp_creq_on_connect(h2o_http1client_t* cli_u, const c3_c* err_c,
static void
_cttp_creq_connect(u3_creq* ceq_u)
{
c3_assert(u3_csat_ripe == ceq_u->sat_e);
c3_assert(ceq_u->ipf_c);
c3_assert( u3_csat_conn == ceq_u->sat_e );
c3_assert( ceq_u->ipf_c );
h2o_iovec_t ipf_u = h2o_iovec_init(ceq_u->ipf_c, strlen(ceq_u->ipf_c));
c3_s por_s = ceq_u->por_s ? ceq_u->por_s :
( c3y == ceq_u->sec ) ? 443 : 80;
// connect by ip/port, avoiding synchronous getaddrinfo()
//
{
h2o_iovec_t ipf_u = h2o_iovec_init(ceq_u->ipf_c, strlen(ceq_u->ipf_c));
c3_t tls_t = ( c3y == ceq_u->sec );
c3_s por_s = ( ceq_u->por_s )
? ceq_u->por_s
: ( tls_t ) ? 443 : 80;
// connect by IP
h2o_http1client_connect(&ceq_u->cli_u, ceq_u, &ceq_u->ctp_u->ctx_u, ipf_u,
por_s, c3y == ceq_u->sec, _cttp_creq_on_connect);
// set hostname for TLS handshake
if ( ceq_u->hot_c && c3y == ceq_u->sec ) {
c3_free(ceq_u->cli_u->ssl.server_name);
ceq_u->cli_u->ssl.server_name = strdup(ceq_u->hot_c);
h2o_http1client_connect(&ceq_u->cli_u, ceq_u, &ceq_u->ctp_u->ctx_u,
ipf_u, por_s, tls_t, _cttp_creq_on_connect);
}
_cttp_creq_fire(ceq_u);
// connect() failed, cb invoked synchronously
//
if ( u3_csat_conn != ceq_u->sat_e ) {
c3_assert( u3_csat_quit == ceq_u->sat_e );
// only one such failure case
//
_cttp_creq_fail(ceq_u, "socket create error");
}
else {
ceq_u->sat_e = u3_csat_ripe;
// fixup hostname for TLS handshake
//
// must be synchronous, after successfull connect() call
//
if ( ceq_u->hot_c && (c3y == ceq_u->sec) ) {
c3_assert( ceq_u->cli_u );
c3_free(ceq_u->cli_u->ssl.server_name);
ceq_u->cli_u->ssl.server_name = strdup(ceq_u->hot_c);
}
}
}
/* _cttp_creq_resolve_cb(): cb upon IP address resolution
@ -882,7 +920,7 @@ _cttp_creq_resolve_cb(uv_getaddrinfo_t* adr_u,
ceq_u->ipf_w = ntohl(((struct sockaddr_in *)aif_u->ai_addr)->sin_addr.s_addr);
ceq_u->ipf_c = _cttp_creq_ip(ceq_u->ipf_w);
ceq_u->sat_e = u3_csat_ripe;
ceq_u->sat_e = u3_csat_conn;
_cttp_creq_connect(ceq_u);
}
@ -926,7 +964,7 @@ static void
_cttp_creq_start(u3_creq* ceq_u)
{
if ( ceq_u->ipf_c ) {
ceq_u->sat_e = u3_csat_ripe;
ceq_u->sat_e = u3_csat_conn;
_cttp_creq_connect(ceq_u);
} else {
ceq_u->sat_e = u3_csat_addr;
@ -981,7 +1019,6 @@ _cttp_ef_http_client(u3_cttp* ctp_u, u3_noun tag, u3_noun dat)
ret_o = c3y;
}
else {
u3l_log("cttp: strange request (unparsable url)\n");
ret_o = c3n;
}
}

Some files were not shown because too many files have changed in this diff Show More