Forráskód Böngészése

feat: command window

    * feat: command override window with example logout command
    * fix: lock prior to sending over socket connection
Fela Maslen 5 éve
szülő
commit
0971cba816

+ 1 - 0
gmus/package.json

@@ -59,6 +59,7 @@
     "styled-components": "^5.2.1",
     "styled-reset": "^4.3.3",
     "typescript": "^4.0.3",
+    "use-onclickoutside": "^0.3.1",
     "web-vitals": "^0.2.4"
   }
 }

+ 4 - 0
gmus/src/actions/actions.ts

@@ -10,6 +10,9 @@ export type ActionClientListUpdated = ActionRemote<ActionTypeRemote.ClientListUp
 
 export type RemoteAction = ActionStateSetRemote | ActionClientListUpdated;
 
+export type LoggedOut = ActionLocal<ActionTypeLocal.LoggedOut, void>;
+export const loggedOut = (): LoggedOut => ({ type: ActionTypeLocal.LoggedOut, payload: undefined });
+
 export type ActionNameSet = ActionLocal<ActionTypeLocal.NameSet, string>;
 
 export const nameSet = (name: string): ActionNameSet => ({
@@ -42,6 +45,7 @@ export const masterRetaken = (): ActionMasterRetaken => ({
 });
 
 export type LocalAction =
+  | LoggedOut
   | ActionErrorOccurred
   | ActionNameSet
   | ActionStateSetLocal

+ 1 - 0
gmus/src/actions/types.ts

@@ -6,6 +6,7 @@ export enum ActionTypeRemote {
 
 // Local actions - these are dispatched from this client
 export enum ActionTypeLocal {
+  LoggedOut = '@@local/LOGGED_OUT',
   ErrorOccurred = '@@local/ERROR_OCCURRED',
   NameSet = '@@local/NAME_SET',
   StateSet = '@@local/STATE_SET',

+ 6 - 7
gmus/src/components/ui/cmus/actions.ts

@@ -1,13 +1,12 @@
 import { Action } from '../../../actions';
 import { ActionKeyPressed } from '../../../hooks/vim';
 import { Song } from '../../../types';
-import { LibraryModeWindow } from './types';
 
 export enum CmusUIActionType {
   ArtistsSet = '@@ui/cmus/ARTISTS_SET',
   ArtistAlbumsLoaded = '@@ui/cmus/ARTIST_ALBUMS_LOADED',
   ArtistSongsLoaded = '@@ui/cmus/ARTIST_SONGS_LOADED',
-  LibraryModeSet = '@@ui/cmus/library/MODE_SET',
+  CommandSet = '@@ui/cmus/COMMAND_SET',
 }
 
 export type ArtistsSet = Action<CmusUIActionType.ArtistsSet, string[]>;
@@ -43,16 +42,16 @@ export const artistSongsLoaded = (artist: string, songs: Song[]): ArtistSongsLoa
   payload: { artist, songs },
 });
 
-export type LibraryModeSet = Action<CmusUIActionType.LibraryModeSet, LibraryModeWindow>;
+export type CommandSet = Action<CmusUIActionType.CommandSet, string | null>;
 
-export const libraryModeSet = (mode: LibraryModeWindow): LibraryModeSet => ({
-  type: CmusUIActionType.LibraryModeSet,
-  payload: mode,
+export const commandSet = (command: string | null): CommandSet => ({
+  type: CmusUIActionType.CommandSet,
+  payload: command,
 });
 
 export type CmusUIAction =
   | ArtistsSet
   | ArtistAlbumsLoaded
   | ArtistSongsLoaded
-  | LibraryModeSet
+  | CommandSet
   | ActionKeyPressed;

+ 79 - 0
gmus/src/components/ui/cmus/command/index.tsx

@@ -0,0 +1,79 @@
+import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
+import useOnClickOutside from 'use-onclickoutside';
+
+import { Keys } from '../../../../hooks/vim';
+import { commandSet } from '../actions';
+import { CmusUIDispatchContext, CmusUIStateContext } from '../reducer';
+
+import * as Styled from './styles';
+
+enum EndState {
+  Start,
+  Confirm,
+  Cancel,
+}
+
+const CommandViewActive: React.FC = () => {
+  const dispatchUI = useContext(CmusUIDispatchContext);
+
+  const [tempCommand, setTempCommand] = useState<string>('');
+
+  const ref = useRef<HTMLInputElement>(null);
+
+  const onChange = useCallback(
+    (event: React.ChangeEvent<HTMLInputElement>) => setTempCommand(event.target.value),
+    [],
+  );
+
+  const [finished, setFinished] = useState<EndState>(EndState.Start);
+
+  const onKeyDown = useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
+    if (event.key === Keys.enter) {
+      setFinished(EndState.Confirm);
+    } else if (event.key === Keys.esc) {
+      setFinished(EndState.Cancel);
+    }
+  }, []);
+
+  useEffect(() => {
+    if (finished === EndState.Confirm) {
+      dispatchUI(commandSet(tempCommand));
+    } else if (finished === EndState.Cancel) {
+      dispatchUI(commandSet(null));
+    }
+  }, [dispatchUI, finished, tempCommand]);
+
+  const onFocus = useCallback(() => {
+    setImmediate(() => ref.current?.focus());
+  }, []);
+
+  useEffect(onFocus, [onFocus]);
+  useOnClickOutside(ref, onFocus);
+
+  return (
+    <Styled.CommandWrapper>
+      <Styled.Before active={true}>:</Styled.Before>
+      <input
+        ref={ref}
+        value={tempCommand}
+        onChange={onChange}
+        onKeyDown={onKeyDown}
+        spellCheck={false}
+      />
+    </Styled.CommandWrapper>
+  );
+};
+
+export const CommandView: React.FC = () => {
+  const { commandMode } = useContext(CmusUIStateContext);
+
+  if (!commandMode) {
+    return (
+      <Styled.CommandWrapper>
+        <Styled.Before active={false}>:</Styled.Before>
+      </Styled.CommandWrapper>
+    );
+  }
+
+  return <CommandViewActive />;
+};

+ 31 - 0
gmus/src/components/ui/cmus/command/styles.ts

@@ -0,0 +1,31 @@
+import { rem } from 'polished';
+import styled from 'styled-components';
+
+import { FlexRow } from '../styled/layout';
+import { colors } from '../styled/variables';
+
+export const CommandWrapper = styled(FlexRow)`
+  background: ${colors.background};
+  color: ${colors.foreground};
+  flex: 0 0 ${rem(16)};
+  white-space: nowrap;
+
+  input {
+    border: none;
+    color: transparent;
+    flex: 1;
+    font: inherit;
+    background: transparent;
+    line-height: inherit;
+    outline: none;
+    margin: 0;
+    padding: 0;
+    text-shadow: 0 0 1px ${colors.foreground};
+  }
+`;
+
+export const Before = styled.span<{ active: boolean }>`
+  color: ${({ active }): string => (active ? colors.foreground : 'transparent')};
+  flex: 0 0 auto;
+  padding-left: ${rem(8)};
+`;

+ 3 - 1
gmus/src/components/ui/cmus/index.tsx

@@ -5,6 +5,7 @@ import { useVimBindings } from '../../../hooks/vim';
 
 import { UIProviderComponent } from '../types';
 import { CmusUIAction } from './actions';
+import { CommandView } from './command';
 import {
   CmusUIDispatchContext,
   cmusUIReducer,
@@ -30,7 +31,7 @@ export const CmusUIProvider: UIProviderComponent = () => {
     }
   }, [dispatch, stateUI.globalAction, stateUI.globalActionSerialNumber]);
 
-  useVimBindings(dispatchUI);
+  useVimBindings(dispatchUI, stateUI.commandMode);
 
   return (
     <CmusUIStateContext.Provider value={stateUI}>
@@ -38,6 +39,7 @@ export const CmusUIProvider: UIProviderComponent = () => {
         <Styled.Wrapper>
           <Styled.View>{stateUI.view === View.Library && <ViewLibrary />}</Styled.View>
           <PlayerStatus />
+          <CommandView />
         </Styled.Wrapper>
       </CmusUIDispatchContext.Provider>
     </CmusUIStateContext.Provider>

+ 29 - 9
gmus/src/components/ui/cmus/reducer.spec.ts

@@ -1,4 +1,4 @@
-import { stateSet } from '../../../actions';
+import { loggedOut, stateSet } from '../../../actions';
 import { ActionKeyPressed, ActionTypeKeyPressed, Keys } from '../../../hooks/vim';
 import { Song } from '../../../types';
 import {
@@ -6,7 +6,7 @@ import {
   artistSongsLoaded,
   artistsSet,
   CmusUIActionType,
-  libraryModeSet,
+  commandSet,
 } from './actions';
 import { cmusUIReducer, initialCmusUIState } from './reducer';
 import { CmusUIState, LibraryModeWindow, View } from './types';
@@ -17,6 +17,11 @@ describe(cmusUIReducer.name, () => {
     view: View.Library,
   };
 
+  const stateCommandMode: CmusUIState = {
+    ...stateLibrary,
+    commandMode: true,
+  };
+
   describe(CmusUIActionType.ArtistsSet, () => {
     const action = artistsSet(['Artist A', 'Artist B']);
 
@@ -97,22 +102,27 @@ describe(cmusUIReducer.name, () => {
     });
   });
 
-  describe(CmusUIActionType.LibraryModeSet, () => {
-    const action = libraryModeSet(LibraryModeWindow.SongList);
+  describe(CmusUIActionType.CommandSet, () => {
+    describe('q', () => {
+      const action = commandSet('q');
 
-    it('should set the library mode window', () => {
-      expect.assertions(1);
-      const result = cmusUIReducer(initialCmusUIState, action);
-      expect(result.library.modeWindow).toBe(LibraryModeWindow.SongList);
+      it('should set a log out global action', () => {
+        expect.assertions(2);
+        const result = cmusUIReducer(stateCommandMode, action);
+        expect(result.commandMode).toBe(false);
+        expect(result.globalAction).toStrictEqual(loggedOut());
+      });
     });
   });
 
   describe('Keypress actions', () => {
     describe(Keys['1'], () => {
+      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys['1'] };
+
       it('should set the view to Library', () => {
         expect.assertions(1);
         const state = ({ ...initialCmusUIState, view: undefined } as unknown) as CmusUIState;
-        const result = cmusUIReducer(state, { type: ActionTypeKeyPressed, key: Keys['1'] });
+        const result = cmusUIReducer(state, action);
 
         expect(result.view).toBe(View.Library);
       });
@@ -370,5 +380,15 @@ describe(cmusUIReducer.name, () => {
         });
       });
     });
+
+    describe(Keys.colon, () => {
+      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.colon };
+
+      it('should enter command mode', () => {
+        expect.assertions(1);
+        const result = cmusUIReducer(stateLibrary, action);
+        expect(result.commandMode).toBe(true);
+      });
+    });
   });
 });

+ 28 - 8
gmus/src/components/ui/cmus/reducer.ts

@@ -1,6 +1,6 @@
 import { createContext, Dispatch } from 'react';
 
-import { stateSet } from '../../../actions';
+import { LocalAction, loggedOut, stateSet } from '../../../actions';
 import { nullDispatch } from '../../../context/state';
 import { ActionTypeKeyPressed, Keys } from '../../../hooks/vim';
 import { scrollThroughItems } from '../../../utils/delta';
@@ -17,6 +17,7 @@ export const initialCmusUIState: CmusUIState = {
   globalAction: null,
   globalActionSerialNumber: 0,
   view: View.Library,
+  commandMode: false,
   artists: [],
   artistAlbums: {},
   artistSongs: {},
@@ -35,6 +36,12 @@ export const CmusUIDispatchContext = createContext<Dispatch<CmusUIAction>>(nullD
 
 const libraryModeWindows: LibraryModeWindow[] = Object.values(LibraryModeWindow);
 
+const withGlobalAction = (state: CmusUIState, action: LocalAction): CmusUIState => ({
+  ...state,
+  globalAction: action,
+  globalActionSerialNumber: state.globalActionSerialNumber + 1,
+});
+
 const switchLibraryMode = (state: CmusUIState): CmusUIState => ({
   ...state,
   library: {
@@ -127,6 +134,9 @@ function handleScrollUp(state: CmusUIState): CmusUIState {
 
 function handleKeyPress(state: CmusUIState, key: string): CmusUIState {
   switch (key) {
+    case Keys.colon:
+      return { ...state, commandMode: true };
+
     case Keys['1']:
       return { ...state, view: View.Library };
     case Keys.tab:
@@ -150,16 +160,15 @@ function handleKeyPress(state: CmusUIState, key: string): CmusUIState {
             return state;
           }
 
-          return {
-            ...state,
-            globalAction: stateSet({
+          return withGlobalAction(
+            state,
+            stateSet({
               playing: true,
               songId: state.library.activeSongId,
               currentTime: 0,
               seekTime: 0,
             }),
-            globalActionSerialNumber: state.globalActionSerialNumber + 1,
-          };
+          );
         }
       }
 
@@ -198,6 +207,17 @@ const setArtistSongs = (state: CmusUIState, action: ArtistSongsLoaded): CmusUISt
   },
 });
 
+function onCommand(state: CmusUIState, command: string | null): CmusUIState {
+  const nextState: CmusUIState = { ...state, commandMode: false };
+
+  switch (command) {
+    case 'q':
+      return withGlobalAction(nextState, loggedOut());
+    default:
+      return nextState;
+  }
+}
+
 export function cmusUIReducer(state: CmusUIState, action: CmusUIAction): CmusUIState {
   switch (action.type) {
     case ActionTypeKeyPressed:
@@ -210,8 +230,8 @@ export function cmusUIReducer(state: CmusUIState, action: CmusUIAction): CmusUIS
     case CmusUIActionType.ArtistSongsLoaded:
       return setArtistSongs(state, action);
 
-    case CmusUIActionType.LibraryModeSet:
-      return { ...state, library: { ...state.library, modeWindow: action.payload } };
+    case CmusUIActionType.CommandSet:
+      return onCommand(state, action.payload);
 
     default:
       return state;

+ 1 - 0
gmus/src/components/ui/cmus/types.ts

@@ -14,6 +14,7 @@ export type CmusUIState = {
   globalAction: LocalAction | null;
   globalActionSerialNumber: number;
   view: View;
+  commandMode: boolean;
   artists: string[];
   artistAlbums: Record<string, string[]>;
   artistSongs: Record<string, Song[]>;

+ 11 - 14
gmus/src/hooks/socket.ts

@@ -2,7 +2,7 @@ import { nanoid } from 'nanoid';
 import { Dispatch, useCallback, useEffect, useRef, useState } from 'react';
 import { useStorageState } from 'react-storage-hooks';
 
-import { AnyAction, LocalAction, RemoteAction } from '../actions';
+import { ActionTypeLocal, AnyAction, LocalAction, RemoteAction } from '../actions';
 import { errorOccurred } from '../actions/error';
 import { socketKeepaliveTimeout } from '../constants/system';
 import { globalEffects } from '../effects';
@@ -36,10 +36,14 @@ export function useDispatchWithEffects(
 
   const dispatchWithEffects = useCallback(
     (action: LocalAction): void => {
-      setLastAction(action);
-      dispatch(action);
+      if (action.type === ActionTypeLocal.LoggedOut) {
+        socket?.close();
+      } else {
+        setLastAction(action);
+        dispatch(action);
+      }
     },
-    [dispatch],
+    [dispatch, socket],
   );
 
   useEffect(() => {
@@ -68,17 +72,13 @@ export function useSocket(
 } {
   const [storedName, saveName] = useStorageState<string>(localStorage, 'client-name', '');
   const [uniqueName, setUniqueName] = useState<string>(getUniqueName(storedName));
-  const [tempName, setTempName] = useState<string>(storedName);
+  const [tempName, onIdentify] = useState<string>(storedName);
 
   const [socket, setSocket] = useState<WebSocket | null>(null);
   const [error, setError] = useState<boolean>(false);
 
   const [connecting, setConnecting] = useState<boolean>(false);
 
-  const onIdentify = useCallback((newName: string) => {
-    setTempName(newName);
-  }, []);
-
   useEffect(() => {
     let cancelled = false;
     let ws: WebSocket | undefined;
@@ -93,6 +93,7 @@ export function useSocket(
           setError(false);
           setConnecting(false);
 
+          onIdentify('');
           saveName(tempName);
           setUniqueName(uniqueTempName);
 
@@ -104,12 +105,9 @@ export function useSocket(
       ws.onmessage = onMessage;
 
       ws.onclose = (): void => {
-        if (cancelled) {
-          return;
-        }
-
         setError(false);
         setSocket(null);
+        saveName('');
       };
     } else {
       setConnecting(false);
@@ -118,7 +116,6 @@ export function useSocket(
 
     return (): void => {
       cancelled = true;
-      ws?.close();
     };
   }, [onMessage, onLogin, tempName, saveName]);
 

+ 20 - 2
gmus/src/hooks/vim.spec.tsx

@@ -4,8 +4,11 @@ import React, { Dispatch } from 'react';
 import { ActionKeyPressed, ActionTypeKeyPressed, useVimBindings } from './vim';
 
 describe(useVimBindings.name, () => {
-  const TestComponent: React.FC<{ dispatch: Dispatch<ActionKeyPressed> }> = ({ dispatch }) => {
-    useVimBindings(dispatch);
+  const TestComponent: React.FC<{ dispatch: Dispatch<ActionKeyPressed>; skip?: boolean }> = ({
+    dispatch,
+    skip,
+  }) => {
+    useVimBindings(dispatch, skip);
     return null;
   };
 
@@ -44,4 +47,19 @@ describe(useVimBindings.name, () => {
       expect(dispatch).not.toHaveBeenCalled();
     });
   });
+
+  describe('when skipping', () => {
+    it('should not listen to any keys', () => {
+      expect.assertions(1);
+      render(<TestComponent dispatch={dispatch} skip={true} />);
+
+      act(() => {
+        fireEvent.keyDown(window, {
+          key: 'Tab',
+        });
+      });
+
+      expect(dispatch).not.toHaveBeenCalled();
+    });
+  });
 });

+ 10 - 2
gmus/src/hooks/vim.ts

@@ -3,7 +3,9 @@ import { Dispatch, useEffect } from 'react';
 export const Keys = {
   tab: 'Tab',
   enter: 'Enter',
+  esc: 'Escape',
   space: ' ',
+  colon: ':',
   '1': '1',
   J: 'j',
   K: 'k',
@@ -18,8 +20,14 @@ export type ActionKeyPressed = {
   key: string;
 };
 
-export function useVimBindings(dispatch: Dispatch<ActionKeyPressed>): void {
+export function useVimBindings(dispatch: Dispatch<ActionKeyPressed>, skip = false): void {
   useEffect(() => {
+    if (skip) {
+      return (): void => {
+        // pass
+      };
+    }
+
     const listener = (event: KeyboardEvent): void => {
       if (!availableKeys.includes(event.key)) {
         return;
@@ -35,5 +43,5 @@ export function useVimBindings(dispatch: Dispatch<ActionKeyPressed>): void {
     return (): void => {
       window.removeEventListener('keydown', listener);
     };
-  }, [dispatch]);
+  }, [dispatch, skip]);
 }

+ 25 - 0
gmus/yarn.lock

@@ -2475,6 +2475,11 @@ aproba@^1.1.1:
   resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a"
   integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==
 
+are-passive-events-supported@^1.1.0:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/are-passive-events-supported/-/are-passive-events-supported-1.1.1.tgz#3db180a1753a2186a2de50a32cded3ac0979f5dc"
+  integrity sha512-5wnvlvB/dTbfrCvJ027Y4L4gW/6Mwoy1uFSavney0YO++GU+0e/flnjiBBwH+1kh7xNCgCOGvmJC3s32joYbww==
+
 argparse@^1.0.7:
   version "1.0.10"
   resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911"
@@ -11170,6 +11175,26 @@ url@^0.11.0:
     punycode "1.3.2"
     querystring "0.2.0"
 
+use-isomorphic-layout-effect@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.0.tgz#4db2111e0d53ca694187ea5fd5cb2ba610286fe0"
+  integrity sha512-kady5Z1O1qx5RitodCCKbpJSVEtECXYcnBnb5Q48Bz5V6gBmTu85ZcGdVwVFs8+DaOurNb/L5VdGHoQRMknghw==
+
+use-latest@^1.0.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/use-latest/-/use-latest-1.2.0.tgz#a44f6572b8288e0972ec411bdd0840ada366f232"
+  integrity sha512-d2TEuG6nSLKQLAfW3By8mKr8HurOlTkul0sOpxbClIv4SQ4iOd7BYr7VIzdbktUCnv7dua/60xzd8igMU6jmyw==
+  dependencies:
+    use-isomorphic-layout-effect "^1.0.0"
+
+use-onclickoutside@^0.3.1:
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/use-onclickoutside/-/use-onclickoutside-0.3.1.tgz#fdd723a6a499046b6bc761e4a03af432eee5917b"
+  integrity sha512-aahvbW5+G0XJfzj31FJeLsvc6qdKbzeTsQ8EtkHHq5qTg6bm/qkJeKLcgrpnYeHDDbd7uyhImLGdkbM9BRzOHQ==
+  dependencies:
+    are-passive-events-supported "^1.1.0"
+    use-latest "^1.0.0"
+
 use@^3.1.0:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"

+ 1 - 1
music-player/pkg/server/actions.go

@@ -23,7 +23,7 @@ func broadcastAction(thisPodClients *map[string]*Client, action *Action) []error
   var errors []error
 
   for _, client := range(*thisPodClients) {
-    if err := client.conn.WriteJSON(action); err != nil {
+    if err := client.send(action); err != nil {
       errors = append(errors, err)
     }
   }

+ 7 - 1
music-player/pkg/server/clients.go

@@ -47,6 +47,12 @@ func publishClientList(l *logger.Logger, rdb *redis.Client) error {
   return nil
 }
 
+func (c *Client) send(message interface{}) error {
+  c.mu.Lock()
+  defer c.mu.Unlock()
+  return c.conn.WriteJSON(message)
+}
+
 func (c *Client) exposeToNetwork(l *logger.Logger, rdb *redis.Client) error {
   // Expose the client to all pods running the server
   now := time.Now().Unix()
@@ -86,7 +92,7 @@ func (c *Client) subscribeToMe(l *logger.Logger, rdb *redis.Client) {
     }
 
     if actionFromClient.Type == "PING" {
-      c.conn.WriteJSON(Action{
+      c.send(Action{
 	Type: "PONG",
       })
       c.exposeToNetwork(l, rdb)

+ 3 - 0
music-player/pkg/server/state.go

@@ -1,6 +1,8 @@
 package server
 
 import (
+	"sync"
+
 	"github.com/gorilla/websocket"
 )
 
@@ -8,6 +10,7 @@ type Client struct {
   name string
   conn *websocket.Conn
   closeChan chan bool
+  mu sync.Mutex
 }
 
 type Member struct {