Hook up WS connection to client UI
This commit is contained in:
parent
9f5d1c5684
commit
5e7c2952a1
5 changed files with 129 additions and 111 deletions
186
client/Main.hs
186
client/Main.hs
|
|
@ -3,132 +3,95 @@
|
||||||
module Main where
|
module Main where
|
||||||
|
|
||||||
import Brick
|
import Brick
|
||||||
import Control.Concurrent.Chan
|
import Brick.BChan
|
||||||
import Control.Exception
|
import Control.Exception
|
||||||
import Control.Monad
|
import Control.Monad
|
||||||
import qualified Data.Set as Set
|
import qualified Data.Set as Set
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
import qualified Graphics.Vty as Vty
|
import qualified Graphics.Vty as Vty
|
||||||
import qualified Network.WebSockets as WS
|
import qualified Network.WebSockets as WS
|
||||||
|
import Options.Applicative
|
||||||
|
import qualified Wuss as WSS
|
||||||
|
|
||||||
import Forest.Api
|
import Forest.Api
|
||||||
import Forest.Client.NodeEditor
|
import Forest.Client.NodeEditor
|
||||||
|
import Forest.Client.Options
|
||||||
import Forest.Client.ResourceName
|
import Forest.Client.ResourceName
|
||||||
import Forest.Client.Tree
|
import Forest.Client.Tree
|
||||||
import Forest.Client.WidgetTree
|
import Forest.Client.WidgetTree
|
||||||
import Forest.Node
|
import Forest.Node
|
||||||
import Forest.Util
|
import Forest.Util
|
||||||
|
|
||||||
{- Listening for server events -}
|
{- First, the UI types -}
|
||||||
|
|
||||||
data Event = EventNode Node | EventConnectionClosed T.Text
|
data Event = EventNode Node
|
||||||
|
| EventConnectionClosed T.Text
|
||||||
sendCloseEvent :: Chan Event -> WS.ConnectionException -> IO ()
|
|
||||||
sendCloseEvent eventChan e = writeChan eventChan $ EventConnectionClosed $ T.pack $ show e
|
|
||||||
|
|
||||||
receiveUpdates :: Chan Event -> Node -> WS.Connection -> IO ()
|
|
||||||
receiveUpdates eventChan node conn = handle (sendCloseEvent eventChan) $ do
|
|
||||||
maybePacket <- receivePacket conn
|
|
||||||
case maybePacket of
|
|
||||||
-- Wait for ws exception since 'receivePacket' should have already closed
|
|
||||||
-- the connection.
|
|
||||||
Nothing -> waitForCloseException conn
|
|
||||||
Just packet -> case packet of
|
|
||||||
ServerUpdate path subnode -> do
|
|
||||||
let newNode = replaceAt subnode path node
|
|
||||||
writeChan eventChan $ EventNode newNode
|
|
||||||
receiveUpdates eventChan newNode conn
|
|
||||||
_ -> do
|
|
||||||
closeWithErrorMessage conn "Invalid packet: Expected update"
|
|
||||||
waitForCloseException conn
|
|
||||||
|
|
||||||
wsClientApp :: Chan Event -> WS.ClientApp ()
|
|
||||||
wsClientApp eventChan conn = handle (sendCloseEvent eventChan) $ do
|
|
||||||
maybePacket <- receivePacket conn
|
|
||||||
case maybePacket of
|
|
||||||
-- Wait for ws exception since 'receivePacket' should have already closed
|
|
||||||
-- the connection.
|
|
||||||
Nothing -> waitForCloseException conn
|
|
||||||
Just (ServerHello _ node) -> do
|
|
||||||
writeChan eventChan $ EventNode node
|
|
||||||
receiveUpdates eventChan node conn
|
|
||||||
_ -> do
|
|
||||||
closeWithErrorMessage conn "Invalid packet: Expected hello"
|
|
||||||
waitForCloseException conn
|
|
||||||
|
|
||||||
{- Brick client application-}
|
|
||||||
|
|
||||||
data ClientState = ClientState
|
data ClientState = ClientState
|
||||||
{ csTree :: Tree
|
{ csTree :: Tree
|
||||||
, csEditor :: Maybe NodeEditor
|
, csEditor :: Maybe NodeEditor
|
||||||
|
, csConn :: WS.Connection
|
||||||
|
, csEventChan :: BChan Event
|
||||||
}
|
}
|
||||||
|
|
||||||
newClientState :: Node -> ClientState
|
newClientState :: BChan Event -> Node -> WS.Connection -> ClientState
|
||||||
newClientState node = ClientState
|
newClientState eventChan node conn = ClientState
|
||||||
{ csTree = newTree node localPath Set.empty
|
{ csTree = newTree node localPath Set.empty
|
||||||
, csEditor = Nothing
|
, csEditor = Nothing
|
||||||
|
, csConn = conn
|
||||||
|
, csEventChan = eventChan
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClientM a = EventM ResourceName a
|
type ClientM a = EventM ResourceName a
|
||||||
|
|
||||||
{- Normal actions -}
|
{- Actions in normal mode -}
|
||||||
|
|
||||||
quitKeys :: [Vty.Key]
|
|
||||||
quitKeys = [Vty.KEsc, Vty.KChar 'q']
|
|
||||||
|
|
||||||
foldKeys :: [Vty.Key]
|
|
||||||
foldKeys = [Vty.KChar '\t']
|
|
||||||
|
|
||||||
foldAction :: ClientState -> ClientM (Next ClientState)
|
foldAction :: ClientState -> ClientM (Next ClientState)
|
||||||
foldAction cs = continue cs{csTree = toggleFold $ csTree cs}
|
foldAction cs = continue cs{csTree = toggleFold $ csTree cs}
|
||||||
|
|
||||||
upKeys :: [Vty.Key]
|
|
||||||
upKeys = [Vty.KUp, Vty.KChar 'k']
|
|
||||||
|
|
||||||
upAction :: ClientState -> ClientM (Next ClientState)
|
upAction :: ClientState -> ClientM (Next ClientState)
|
||||||
upAction cs = continue cs{csTree = moveUp $ csTree cs}
|
upAction cs = continue cs{csTree = moveUp $ csTree cs}
|
||||||
|
|
||||||
downKeys :: [Vty.Key]
|
|
||||||
downKeys = [Vty.KDown, Vty.KChar 'j']
|
|
||||||
|
|
||||||
downAction :: ClientState -> ClientM (Next ClientState)
|
downAction :: ClientState -> ClientM (Next ClientState)
|
||||||
downAction cs = continue cs{csTree = moveDown $ csTree cs}
|
downAction cs = continue cs{csTree = moveDown $ csTree cs}
|
||||||
|
|
||||||
editKeys :: [Vty.Key]
|
|
||||||
editKeys = [Vty.KChar 'e']
|
|
||||||
|
|
||||||
editAction :: ClientState -> ClientM (Next ClientState)
|
editAction :: ClientState -> ClientM (Next ClientState)
|
||||||
editAction cs =
|
editAction cs =
|
||||||
let node = getCurrent $ csTree cs
|
let node = getCurrent $ csTree cs
|
||||||
editor = editNode $ nodeText node
|
editor = editNode $ nodeText node
|
||||||
in continue cs{csEditor = Just editor}
|
in continue cs{csEditor = Just editor}
|
||||||
|
|
||||||
deleteKeys :: [Vty.Key]
|
deleteAction :: ClientState -> ClientM (Next ClientState)
|
||||||
deleteKeys = [Vty.KChar 'e']
|
deleteAction cs = continue cs -- TODO implement
|
||||||
|
|
||||||
replyKeys :: [Vty.Key]
|
|
||||||
replyKeys = [Vty.KChar 'r']
|
|
||||||
|
|
||||||
replyAction :: ClientState -> ClientM (Next ClientState)
|
replyAction :: ClientState -> ClientM (Next ClientState)
|
||||||
replyAction cs = continue cs{csEditor = Just replyToNode}
|
replyAction cs = continue cs{csEditor = Just replyToNode}
|
||||||
|
|
||||||
actKeys :: [Vty.Key]
|
actAction :: ClientState -> ClientM (Next ClientState)
|
||||||
actKeys = [Vty.KEnter, Vty.KChar 'a']
|
actAction cs = continue cs -- TODO implement
|
||||||
|
|
||||||
onKeyWithoutEditor
|
onKeyWithoutEditor :: ClientState -> Vty.Event -> EventM ResourceName (Next ClientState)
|
||||||
:: ClientState
|
|
||||||
-> Vty.Event
|
|
||||||
-> EventM ResourceName (Next ClientState)
|
|
||||||
onKeyWithoutEditor cs (Vty.EvKey k _)
|
onKeyWithoutEditor cs (Vty.EvKey k _)
|
||||||
| k `elem` quitKeys = halt cs
|
| k `elem` quitKeys = halt cs
|
||||||
| k `elem` foldKeys = foldAction cs
|
| k `elem` foldKeys = foldAction cs
|
||||||
| k `elem` upKeys = upAction cs
|
| k `elem` upKeys = upAction cs
|
||||||
| k `elem` downKeys = downAction cs
|
| k `elem` downKeys = downAction cs
|
||||||
| k `elem` editKeys = editAction cs
|
| k `elem` editKeys = editAction cs
|
||||||
| k `elem` replyKeys = replyAction cs
|
| k `elem` deleteKeys = deleteAction cs
|
||||||
|
| k `elem` replyKeys = replyAction cs
|
||||||
|
| k `elem` actKeys = actAction cs
|
||||||
|
where
|
||||||
|
quitKeys = [Vty.KEsc, Vty.KChar 'q']
|
||||||
|
foldKeys = [Vty.KChar '\t']
|
||||||
|
upKeys = [Vty.KUp, Vty.KChar 'k']
|
||||||
|
downKeys = [Vty.KDown, Vty.KChar 'j']
|
||||||
|
editKeys = [Vty.KChar 'e']
|
||||||
|
deleteKeys = [Vty.KChar 'e']
|
||||||
|
replyKeys = [Vty.KChar 'r']
|
||||||
|
actKeys = [Vty.KEnter, Vty.KChar 'a']
|
||||||
onKeyWithoutEditor cs _ = continue cs
|
onKeyWithoutEditor cs _ = continue cs
|
||||||
|
|
||||||
{- Editor actions -}
|
{- Actions in edit mode -}
|
||||||
|
|
||||||
updateEditor :: NodeEditor -> ClientState -> Vty.Event -> ClientM (Next ClientState)
|
updateEditor :: NodeEditor -> ClientState -> Vty.Event -> ClientM (Next ClientState)
|
||||||
updateEditor ed cs ev = do
|
updateEditor ed cs ev = do
|
||||||
|
|
@ -144,17 +107,23 @@ onKeyWithEditor ed cs (Vty.EvKey (Vty.KChar 'n') m)
|
||||||
-- Forward all other events as usual
|
-- Forward all other events as usual
|
||||||
onKeyWithEditor ed cs ev = updateEditor ed cs ev
|
onKeyWithEditor ed cs ev = updateEditor ed cs ev
|
||||||
|
|
||||||
{- Constructing the client app -}
|
{- And the rest of the Brick application -}
|
||||||
|
|
||||||
clientDraw :: ClientState -> [Widget ResourceName]
|
clientDraw :: ClientState -> [Widget ResourceName]
|
||||||
clientDraw cs = [padTop (Pad 1) $ padLeft (Pad 2) tree]
|
clientDraw cs = [padTop (Pad 1) $ padLeft (Pad 2) tree]
|
||||||
where
|
where
|
||||||
tree = renderTree boxDrawingBranching (csEditor cs) (csTree cs)
|
tree = renderTree boxDrawingBranching (csEditor cs) (csTree cs)
|
||||||
|
|
||||||
clientHandleEvent :: ClientState -> BrickEvent ResourceName () -> EventM ResourceName (Next ClientState)
|
clientHandleEvent
|
||||||
|
:: ClientState
|
||||||
|
-> BrickEvent ResourceName Event
|
||||||
|
-> ClientM (Next ClientState)
|
||||||
clientHandleEvent cs (VtyEvent ev) = case csEditor cs of
|
clientHandleEvent cs (VtyEvent ev) = case csEditor cs of
|
||||||
Nothing -> onKeyWithoutEditor cs ev
|
Nothing -> onKeyWithoutEditor cs ev
|
||||||
Just ed -> onKeyWithEditor ed cs ev
|
Just ed -> onKeyWithEditor ed cs ev
|
||||||
|
clientHandleEvent cs (AppEvent ev) = case ev of
|
||||||
|
EventNode node -> continue cs{csTree = replaceNode node $ csTree cs}
|
||||||
|
EventConnectionClosed _ -> halt cs
|
||||||
clientHandleEvent cs _ = continue cs
|
clientHandleEvent cs _ = continue cs
|
||||||
|
|
||||||
clientAttrMap :: AttrMap
|
clientAttrMap :: AttrMap
|
||||||
|
|
@ -164,7 +133,7 @@ clientAttrMap = attrMap Vty.defAttr
|
||||||
, ("flags", Vty.currentAttr `Vty.withForeColor` Vty.brightBlack)
|
, ("flags", Vty.currentAttr `Vty.withForeColor` Vty.brightBlack)
|
||||||
]
|
]
|
||||||
|
|
||||||
clientApp :: App ClientState () ResourceName
|
clientApp :: App ClientState Event ResourceName
|
||||||
clientApp = App
|
clientApp = App
|
||||||
{ appDraw = clientDraw
|
{ appDraw = clientDraw
|
||||||
, appChooseCursor = showFirstCursor
|
, appChooseCursor = showFirstCursor
|
||||||
|
|
@ -173,5 +142,58 @@ clientApp = App
|
||||||
, appAttrMap = const clientAttrMap
|
, appAttrMap = const clientAttrMap
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{- And now for the websocket connection handling -}
|
||||||
|
|
||||||
|
performInitialContact :: WS.Connection -> IO Node
|
||||||
|
performInitialContact conn = do
|
||||||
|
-- First, the client must send a hello packet containing the protocol
|
||||||
|
-- extensions it requests.
|
||||||
|
sendPacket conn $ ClientHello []
|
||||||
|
-- Then, the server must reply with a hello packet containing the extensions
|
||||||
|
-- that will be active for this connection, and an initial node.
|
||||||
|
serverReply <- receivePacket conn
|
||||||
|
case serverReply of
|
||||||
|
(ServerHello [] node) -> pure node
|
||||||
|
-- Since the client never requests any protocol extensions, the server must
|
||||||
|
-- also reply with an empty list of extensions.
|
||||||
|
(ServerHello _ _) -> closeWithErrorMessage conn "Invalid protocol extensions"
|
||||||
|
_ -> closeWithErrorMessage conn "Invalid packet: Expected hello"
|
||||||
|
|
||||||
|
sendCloseEvent :: BChan Event -> WS.ConnectionException -> IO ()
|
||||||
|
sendCloseEvent eventChan =
|
||||||
|
writeBChan eventChan . EventConnectionClosed . T.pack . show
|
||||||
|
|
||||||
|
receiveUpdates :: BChan Event -> Node -> WS.Connection -> IO ()
|
||||||
|
receiveUpdates eventChan node conn = handle (sendCloseEvent eventChan) $ do
|
||||||
|
packet <- receivePacket conn
|
||||||
|
case packet of
|
||||||
|
ServerUpdate path subnode -> do
|
||||||
|
let newNode = replaceAt subnode path node
|
||||||
|
writeBChan eventChan $ EventNode newNode
|
||||||
|
receiveUpdates eventChan newNode conn -- Aaand close the loop :D
|
||||||
|
_ -> closeWithErrorMessage conn "Invalid packet: Expected update"
|
||||||
|
|
||||||
|
runCorrectClient :: ClientOptions -> WS.ClientApp a -> IO a
|
||||||
|
runCorrectClient opts app
|
||||||
|
| ssl = WSS.runSecureClient name (fromInteger $ toInteger port) path app
|
||||||
|
| otherwise = WS.runClient name port path app
|
||||||
|
where
|
||||||
|
-- I found this nicer to read than (ab-)using record syntax in the arguments
|
||||||
|
name = clientHostName opts
|
||||||
|
port = clientPort opts
|
||||||
|
path = clientPath opts
|
||||||
|
ssl = clientSsl opts
|
||||||
|
|
||||||
|
{- Gluing everything together -}
|
||||||
|
|
||||||
main :: IO ()
|
main :: IO ()
|
||||||
main = void $ defaultMain clientApp $ newClientState exampleNode
|
main = do
|
||||||
|
opts <- execParser clientOptionsParserInfo
|
||||||
|
runCorrectClient opts $ \conn -> do
|
||||||
|
node <- performInitialContact conn
|
||||||
|
chan <- newBChan 100
|
||||||
|
let appState = newClientState chan node conn
|
||||||
|
withThread (receiveUpdates chan node conn) $ do
|
||||||
|
let vtyBuilder = Vty.mkVty Vty.defaultConfig
|
||||||
|
initialVty <- vtyBuilder
|
||||||
|
void $ customMain initialVty vtyBuilder (Just chan) clientApp appState
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ dependencies:
|
||||||
- transformers
|
- transformers
|
||||||
- vty
|
- vty
|
||||||
- websockets
|
- websockets
|
||||||
|
- wuss
|
||||||
|
|
||||||
library:
|
library:
|
||||||
source-dirs: src
|
source-dirs: src
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
module Forest.Client.Tree
|
module Forest.Client.Tree
|
||||||
( Tree
|
( Tree
|
||||||
, newTree
|
, newTree
|
||||||
, switchNode
|
, replaceNode
|
||||||
, renderTree
|
, renderTree
|
||||||
-- * Focused element
|
-- * Focused element
|
||||||
, getCurrent
|
, getCurrent
|
||||||
|
|
@ -64,8 +64,8 @@ newTree node focused unfolded = Tree
|
||||||
|
|
||||||
-- | Switch out a tree's node, keeping as much of the focus and folding
|
-- | Switch out a tree's node, keeping as much of the focus and folding
|
||||||
-- information as the type's invariants allow.
|
-- information as the type's invariants allow.
|
||||||
switchNode :: Node -> Tree -> Tree
|
replaceNode :: Node -> Tree -> Tree
|
||||||
switchNode node tree = newTree node (treeFocused tree) (treeUnfolded tree)
|
replaceNode node tree = newTree node (treeFocused tree) (treeUnfolded tree)
|
||||||
|
|
||||||
-- | Render a 'Tree' into a widget.
|
-- | Render a 'Tree' into a widget.
|
||||||
renderTree :: IndentOptions -> Maybe NodeEditor -> Tree -> Widget ResourceName
|
renderTree :: IndentOptions -> Maybe NodeEditor -> Tree -> Widget ResourceName
|
||||||
|
|
|
||||||
|
|
@ -27,16 +27,13 @@ sendUpdatesThread conn nodeChan _ = do
|
||||||
|
|
||||||
receivePackets :: TreeModule a => WS.Connection -> a -> IO ()
|
receivePackets :: TreeModule a => WS.Connection -> a -> IO ()
|
||||||
receivePackets conn treeModule = forever $ do
|
receivePackets conn treeModule = forever $ do
|
||||||
maybePacket <- receivePacket conn
|
packet <- receivePacket conn
|
||||||
case maybePacket of
|
case packet of
|
||||||
Nothing -> pure ()
|
ClientEdit path text -> edit treeModule path text
|
||||||
Just packet ->
|
ClientDelete path -> delete treeModule path
|
||||||
case packet of
|
ClientReply path text -> reply treeModule path text
|
||||||
ClientEdit path text -> edit treeModule path text
|
ClientAct path -> act treeModule path
|
||||||
ClientDelete path -> delete treeModule path
|
ClientHello _ -> closeWithErrorMessage conn "Invalid packet: Hello can only be sent once"
|
||||||
ClientReply path text -> reply treeModule path text
|
|
||||||
ClientAct path -> act treeModule path
|
|
||||||
ClientHello _ -> closeWithErrorMessage conn "Invalid packet: Hello can only be sent once"
|
|
||||||
|
|
||||||
serverApp :: TreeModule a => Int -> ModuleConstructor a -> WS.ServerApp
|
serverApp :: TreeModule a => Int -> ModuleConstructor a -> WS.ServerApp
|
||||||
serverApp pingDelay constructor pendingConnection = do
|
serverApp pingDelay constructor pendingConnection = do
|
||||||
|
|
@ -45,9 +42,8 @@ serverApp pingDelay constructor pendingConnection = do
|
||||||
WS.withPingThread conn pingDelay (pure ()) $ do
|
WS.withPingThread conn pingDelay (pure ()) $ do
|
||||||
firstPacket <- receivePacket conn
|
firstPacket <- receivePacket conn
|
||||||
case firstPacket of
|
case firstPacket of
|
||||||
Nothing -> pure ()
|
ClientHello _ -> do
|
||||||
Just (ClientHello _) -> do
|
|
||||||
sendPacket conn $ ServerHello [] initialNode
|
sendPacket conn $ ServerHello [] initialNode
|
||||||
withThread (sendUpdatesThread conn chan initialNode) $
|
withThread (sendUpdatesThread conn chan initialNode) $
|
||||||
constructor (writeChan chan) $ receivePackets conn
|
constructor (writeChan chan) $ receivePackets conn
|
||||||
Just _ -> closeWithErrorMessage conn "Invalid packet: Expected a hello packet"
|
_ -> closeWithErrorMessage conn "Invalid packet: Expected a hello packet"
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,8 @@ module Forest.Util
|
||||||
, findNext
|
, findNext
|
||||||
, withThread
|
, withThread
|
||||||
, sendPacket
|
, sendPacket
|
||||||
, receivePacket
|
|
||||||
, closeWithErrorMessage
|
, closeWithErrorMessage
|
||||||
, waitForCloseException
|
, receivePacket
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Control.Concurrent.Async
|
import Control.Concurrent.Async
|
||||||
|
|
@ -29,7 +28,14 @@ withThread thread main = withAsync thread $ const main
|
||||||
sendPacket :: ToJSON a => WS.Connection -> a -> IO ()
|
sendPacket :: ToJSON a => WS.Connection -> a -> IO ()
|
||||||
sendPacket conn packet = WS.sendTextData conn $ encode packet
|
sendPacket conn packet = WS.sendTextData conn $ encode packet
|
||||||
|
|
||||||
receivePacket :: FromJSON a => WS.Connection -> IO (Maybe a)
|
waitForCloseException :: WS.Connection -> IO a
|
||||||
|
waitForCloseException conn = forever $ void $ WS.receiveDataMessage conn
|
||||||
|
|
||||||
|
closeWithErrorMessage :: WS.Connection -> T.Text -> IO a
|
||||||
|
closeWithErrorMessage conn text =
|
||||||
|
WS.sendCloseCode conn 1003 text >> waitForCloseException conn
|
||||||
|
|
||||||
|
receivePacket :: FromJSON a => WS.Connection -> IO a
|
||||||
receivePacket conn = do
|
receivePacket conn = do
|
||||||
dataMessage <- WS.receiveDataMessage conn
|
dataMessage <- WS.receiveDataMessage conn
|
||||||
closeOnErrorMessage $ case dataMessage of
|
closeOnErrorMessage $ case dataMessage of
|
||||||
|
|
@ -38,13 +44,6 @@ receivePacket conn = do
|
||||||
Left errorMsg -> Left $ "Invalid packet: " <> T.pack errorMsg
|
Left errorMsg -> Left $ "Invalid packet: " <> T.pack errorMsg
|
||||||
Right packet -> Right packet
|
Right packet -> Right packet
|
||||||
where
|
where
|
||||||
closeOnErrorMessage :: Either T.Text a -> IO (Maybe a)
|
closeOnErrorMessage :: Either T.Text a -> IO a
|
||||||
closeOnErrorMessage (Right a) = pure $ Just a
|
closeOnErrorMessage (Right a) = pure a
|
||||||
closeOnErrorMessage (Left errorMsg) =
|
closeOnErrorMessage (Left errorMsg) = closeWithErrorMessage conn errorMsg
|
||||||
Nothing <$ closeWithErrorMessage conn errorMsg
|
|
||||||
|
|
||||||
closeWithErrorMessage :: WS.Connection -> T.Text -> IO ()
|
|
||||||
closeWithErrorMessage conn = WS.sendCloseCode conn 1003
|
|
||||||
|
|
||||||
waitForCloseException :: WS.Connection -> IO ()
|
|
||||||
waitForCloseException conn = forever $ void $ WS.receiveDataMessage conn
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue