├── .gitignore ├── src ├── views │ ├── index.tsx │ ├── PageView.tsx │ ├── TabView.tsx │ ├── HistoryView.tsx │ └── BrowserView.tsx ├── utils │ ├── Bag.ts │ ├── array.ts │ ├── usePrevious.ts │ ├── text.ts │ ├── visitModeFromEvent.ts │ ├── streams.ts │ ├── useLoadEffect.ts │ ├── useScrollRestoration.ts │ ├── useShortcuts.ts │ ├── useFetch.ts │ └── remoteState.tsx ├── gopher │ ├── index.ts │ ├── client.ts │ ├── urls.ts │ └── parser.ts ├── core │ ├── index.ts │ ├── bookmarks.ts │ ├── recents.ts │ ├── windows.ts │ ├── state.ts │ ├── pages.ts │ └── tabs.ts ├── index.tsx ├── assets │ ├── app.html │ └── app.css ├── renderers │ ├── DocumentRenderer.tsx │ ├── Renderer.tsx │ ├── AudioRenderer.tsx │ ├── ExtensionRenderer.tsx │ ├── TextRenderer.tsx │ ├── MarkdownRenderer.tsx │ ├── ImageRenderer.tsx │ ├── index.tsx │ ├── HTMLRenderer.tsx │ ├── BinaryRenderer.tsx │ ├── GopherFolderRenderer.tsx │ └── GopherRenderer.tsx ├── components │ ├── Button.tsx │ ├── LinkButton.tsx │ ├── Layout.tsx │ ├── NavBar.tsx │ └── TabBar.tsx ├── protocols │ ├── index.ts │ └── gopher.ts └── main │ └── index.ts ├── tsconfig.json ├── electron-builder.yml ├── webpack.config.js ├── LICENSE.md ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | dist 4 | lib 5 | *.log 6 | *.db 7 | -------------------------------------------------------------------------------- /src/views/index.tsx: -------------------------------------------------------------------------------- 1 | import Browser from './BrowserView'; 2 | export default Browser; 3 | -------------------------------------------------------------------------------- /src/utils/Bag.ts: -------------------------------------------------------------------------------- 1 | type Bag = { 2 | [key: string]: T, 3 | }; 4 | 5 | export default Bag; 6 | -------------------------------------------------------------------------------- /src/gopher/index.ts: -------------------------------------------------------------------------------- 1 | export * from './client'; 2 | export * from './parser'; 3 | export * from './urls'; 4 | -------------------------------------------------------------------------------- /src/core/index.ts: -------------------------------------------------------------------------------- 1 | export * from './state'; 2 | export * from './pages'; 3 | export * from './tabs'; 4 | export * from './windows'; 5 | -------------------------------------------------------------------------------- /src/utils/array.ts: -------------------------------------------------------------------------------- 1 | export const arrayMoveMutate = (array: any[], from: number, to: number) => { 2 | const startIndex = to < 0 ? array.length + to : to; 3 | const item = array.splice(from, 1)[0]; 4 | array.splice(startIndex, 0, item); 5 | }; 6 | -------------------------------------------------------------------------------- /src/utils/usePrevious.ts: -------------------------------------------------------------------------------- 1 | import {useRef, useEffect} from 'react'; 2 | 3 | export function usePrevious(value: T) { 4 | const ref = useRef(); 5 | useEffect(() => { 6 | ref.current = value; 7 | }); 8 | return ref.current; 9 | } 10 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | require('v8-compile-cache'); 2 | require('immer').enablePatches(); 3 | 4 | import React from 'react'; 5 | import ReactDOM from 'react-dom'; 6 | import App from './views'; 7 | 8 | import './assets/app.html'; 9 | import './assets/app.css'; 10 | 11 | 12 | ReactDOM.render( 13 | , 14 | document.getElementById('app'), 15 | ); 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src", 4 | "target": "es2018", 5 | "lib": ["dom", "esnext"], 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "jsx": "react", 9 | "strict": true, 10 | "sourceMap": true, 11 | "inlineSources": true, 12 | "esModuleInterop": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/text.ts: -------------------------------------------------------------------------------- 1 | export function capitalized(str: string) { 2 | if (!str) return; 3 | return str[0].toUpperCase() + str.slice(1); 4 | } 5 | 6 | export function textMatch(pattern: string, ...fields: (string|undefined)[]) { 7 | pattern = pattern.toLocaleLowerCase(); 8 | return !pattern || fields.some(s => s?.toLocaleLowerCase().includes(pattern)); 9 | } 10 | -------------------------------------------------------------------------------- /src/assets/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Gopher 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/renderers/DocumentRenderer.tsx: -------------------------------------------------------------------------------- 1 | import ExtensionRenderer from './ExtensionRenderer'; 2 | import TextRenderer from './TextRenderer'; 3 | import MarkdownRenderer from './MarkdownRenderer'; 4 | import HTMLRenderer from './HTMLRenderer'; 5 | 6 | export default ExtensionRenderer({ 7 | '*': TextRenderer, 8 | '.md': MarkdownRenderer, 9 | '.html': HTMLRenderer, 10 | }); 11 | -------------------------------------------------------------------------------- /src/renderers/Renderer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {VisitMode} from 'core'; 3 | 4 | export type Renderer = (props: RendererProps) => React.ReactElement | null; 5 | 6 | export interface RendererProps { 7 | url: string, 8 | scroll?: number, 9 | linkTarget?: string, 10 | timestamp?: number, 11 | onScroll: (scroll: number) => void, 12 | visitUrl: (url: string, mode?: VisitMode) => void, 13 | } 14 | 15 | export default Renderer; 16 | -------------------------------------------------------------------------------- /src/gopher/client.ts: -------------------------------------------------------------------------------- 1 | import Net from 'net'; 2 | import {parseGopherUrl} from './urls'; 3 | 4 | export function request(url: string, search?: string) { 5 | const {hostname, port, path, query=search} = parseGopherUrl(url); 6 | const client = new Net.Socket(); 7 | const request = `${decodeURI(path)}${query?`\t${decodeURI(query)}`:''}\r\n`; 8 | client.on('connect', () => client.write(request)); 9 | return client.connect(port, hostname); 10 | } 11 | 12 | -------------------------------------------------------------------------------- /src/utils/visitModeFromEvent.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type VisitEvent = ( 4 | MouseEvent | 5 | KeyboardEvent | 6 | React.MouseEvent | 7 | React.KeyboardEvent 8 | ); 9 | 10 | export default function visitModeFromEvent(e: VisitEvent) { 11 | return ( 12 | e.metaKey && e.shiftKey? 'foreground-tab' : 13 | e.metaKey? 'background-tab' : 14 | e.altKey? 'save-to-disk' : 15 | e.shiftKey? 'replace' : 16 | 'push' 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/streams.ts: -------------------------------------------------------------------------------- 1 | import transform from 'through2'; 2 | 3 | export function scanner(delim: number) { 4 | let buffer = Buffer.from([]); 5 | return transform(function (chunk, enc, done) { 6 | let i; 7 | buffer = Buffer.concat([buffer, chunk]); 8 | while ((i = buffer.indexOf(delim)) !== -1) { 9 | const line = buffer.slice(0, i); 10 | buffer = buffer.slice(i + 1); // Skip newline 11 | this.push(line); 12 | } 13 | done(); 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /electron-builder.yml: -------------------------------------------------------------------------------- 1 | productName: Unnamed Gopher Client 2 | appId: com.zenoamaro.unnamed-gopher-client 3 | copyright: Copyright © 2020 zenoamaro 4 | 5 | directories: 6 | buildResources: build 7 | output: dist 8 | 9 | files: 10 | - build/**/* 11 | 12 | protocols: 13 | - name: Gopher URL 14 | role: Viewer 15 | schemes: ["gopher"] 16 | 17 | mac: 18 | target: dmg 19 | category: public.app-category.productivity 20 | 21 | win: 22 | target: portable 23 | 24 | linux: 25 | target: AppImage 26 | -------------------------------------------------------------------------------- /src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export default styled.button` 4 | appearance: none; 5 | font-family: inherit; 6 | font-size: inherit; 7 | color: inherit; 8 | 9 | padding: 4px; 10 | text-align: center; 11 | vertical-align: middle; 12 | border: none; 13 | border-radius: 5px; 14 | background: transparent; 15 | 16 | &[disabled] { 17 | pointer-events: none; 18 | opacity: 0.15; 19 | } 20 | 21 | &:hover { 22 | background: #f0f0f0; 23 | } 24 | 25 | &:active { 26 | background: #e0e0e0; 27 | } 28 | `; 29 | -------------------------------------------------------------------------------- /src/protocols/index.ts: -------------------------------------------------------------------------------- 1 | import {protocol} from 'electron'; 2 | import {gopherProtocolScheme, gopherProtocolHandler} from './gopher'; 3 | 4 | const protocols = [ 5 | {scheme:gopherProtocolScheme, handler:gopherProtocolHandler}, 6 | ]; 7 | 8 | export function registerProtocolSchemes() { 9 | protocol.registerSchemesAsPrivileged( 10 | protocols.map(p => p.scheme), 11 | ); 12 | } 13 | 14 | export function registerProtocolHandlers() { 15 | for (let {scheme, handler} of protocols) { 16 | protocol.registerStreamProtocol(scheme.scheme, handler); 17 | } 18 | } 19 | 20 | export default protocols; 21 | -------------------------------------------------------------------------------- /src/utils/useLoadEffect.ts: -------------------------------------------------------------------------------- 1 | import {useState, useEffect} from 'react'; 2 | 3 | export function useLoadEffect( 4 | fn: () => Promise, 5 | deps: readonly any[] = [], 6 | ): [ 7 | boolean, 8 | Error | void, 9 | ] { 10 | const [error, setError] = useState(); 11 | const [loading, setLoading] = useState(false); 12 | 13 | useEffect(() => { 14 | setLoading(true); 15 | fn().then(() => {setError(undefined); setLoading(false)}) 16 | .catch((err) => {setError(err); setLoading(false)}); 17 | return () => {}; 18 | }, deps); 19 | 20 | return [loading, error] 21 | } 22 | -------------------------------------------------------------------------------- /src/renderers/AudioRenderer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import {Horizontal} from 'components/Layout'; 4 | import {RendererProps} from './Renderer'; 5 | 6 | 7 | export default function AudioRenderer(p: RendererProps) { 8 | return React.useMemo(() => ( 9 | 10 | 12 | ), [p.url, p.timestamp]); 13 | } 14 | 15 | const Container = styled(Horizontal)` 16 | height: 100%; 17 | align-items: center; 18 | justify-content: center; 19 | width: 644px; 20 | padding: 24px; 21 | overflow: auto scroll; 22 | `; 23 | -------------------------------------------------------------------------------- /src/components/LinkButton.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export default styled.a<{ 4 | disabled?: boolean, 5 | }>` 6 | appearance: none; 7 | font-family: inherit; 8 | font-size: inherit; 9 | text-decoration: none; 10 | color: inherit; 11 | 12 | padding: 4px; 13 | text-align: center; 14 | vertical-align: middle; 15 | border: none; 16 | border-radius: 5px; 17 | background: transparent; 18 | 19 | &[disabled] { 20 | pointer-events: none; 21 | opacity: 0.15; 22 | } 23 | 24 | &:hover { 25 | background: #f0f0f0; 26 | } 27 | 28 | &:active { 29 | background: #e0e0e0; 30 | } 31 | `; 32 | -------------------------------------------------------------------------------- /src/core/bookmarks.ts: -------------------------------------------------------------------------------- 1 | import {uniqueId} from 'lodash'; 2 | import {update} from './state'; 3 | 4 | export interface Bookmark { 5 | id: string, 6 | title: string, 7 | url: string, 8 | type: string, 9 | } 10 | 11 | export function makeBookmark(title: string, type: string, url: string): Bookmark { 12 | return { 13 | id: uniqueId('bookmark'), 14 | title, 15 | url, 16 | type, 17 | }; 18 | } 19 | 20 | export function createBookmark(title: string, type: string, url: string) { 21 | update((state) => { 22 | const bookmark = makeBookmark(title, type, url); 23 | state.bookmarks[bookmark.id] = bookmark; 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /src/core/recents.ts: -------------------------------------------------------------------------------- 1 | import {update} from './state'; 2 | 3 | export interface Recent { 4 | title: string, 5 | url: string, 6 | type: string, 7 | timestamp: number, 8 | } 9 | 10 | export function makeRecent(title: string, type: string, url: string): Recent { 11 | // Convert searches into folders 12 | if (type === '7') type = '1'; 13 | 14 | return { 15 | title, 16 | url, 17 | type, 18 | timestamp: Date.now(), 19 | }; 20 | } 21 | 22 | export function createRecent(title: string, type: string, url: string) { 23 | update((state) => { 24 | const recent = makeRecent(title, type, url); 25 | state.recents[url] = recent; 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/useScrollRestoration.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export function useScrollRestoration( 4 | scroll: number = 0, 5 | setScroll: (scroll: number) => void, 6 | restoreDeps: readonly any[] = [], 7 | ) { 8 | const $scroller = React.useRef(null); 9 | 10 | React.useLayoutEffect(() => { 11 | if (!$scroller.current) return; 12 | $scroller.current.scrollTop = scroll ?? 0; 13 | }, restoreDeps); 14 | 15 | React.useLayoutEffect(() => { 16 | return () => { 17 | if (!$scroller.current) return; 18 | setScroll($scroller.current.scrollTop); 19 | } 20 | }, []); 21 | 22 | return $scroller; 23 | } 24 | -------------------------------------------------------------------------------- /src/assets/app.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | html { 6 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 7 | font-size: 13px; 8 | color: #333; 9 | } 10 | 11 | html, body { 12 | margin: 0; 13 | padding: 0; 14 | height: 100%; 15 | } 16 | 17 | #app { 18 | display: flex; 19 | height: 100%; 20 | } 21 | 22 | input, button, textarea { 23 | font-size: inherit; 24 | font-family: inherit; 25 | color: inherit; 26 | } 27 | 28 | pre { 29 | margin: 0; 30 | } 31 | 32 | svg { 33 | flex: 0 0 auto; 34 | } 35 | 36 | :focus, :active { 37 | z-index: 2; 38 | } 39 | -------------------------------------------------------------------------------- /src/renderers/ExtensionRenderer.tsx: -------------------------------------------------------------------------------- 1 | import Path from 'path'; 2 | import React from 'react'; 3 | import * as Gopher from 'gopher'; 4 | import Renderer, {RendererProps} from './Renderer'; 5 | 6 | export default function ExtensionRenderer(mapping: { 7 | [key: string]: Renderer, 8 | '*': Renderer, 9 | }): Renderer { 10 | return (p: RendererProps) => { 11 | const {pathname} = Gopher.parseGopherUrl(p.url); 12 | const basename = Path.basename(pathname); 13 | const extname = Path.extname(basename); 14 | 15 | const Component = Object.entries(mapping).reduce((Match, [exts, Component]) => { 16 | return exts.split(' ').includes(extname) ? Component : Match; 17 | }, mapping['*']); 18 | 19 | return ; 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export interface LayoutOptions { 4 | flex?: boolean, 5 | align?: string, 6 | justify?: string, 7 | } 8 | 9 | export const Vertical = styled.div` 10 | position: relative; 11 | flex: ${p => p.flex ? 1 : '0 0 auto'}; 12 | display: flex; 13 | flex-direction: column; 14 | align-items: ${p => p.align ?? 'stretch'}; 15 | justify-content: ${p => p.justify ?? 'start'}; 16 | `; 17 | 18 | export const Horizontal = styled.div` 19 | position: relative; 20 | flex: ${p => p.flex ? 1 : '0 0 auto'}; 21 | display: flex; 22 | flex-direction: row; 23 | align-items: ${p => p.align ?? 'stretch'}; 24 | justify-content: ${p => p.justify ?? 'start'}; 25 | `; 26 | 27 | export const Spring = styled.div` 28 | flex: 1; 29 | `; 30 | -------------------------------------------------------------------------------- /src/utils/useShortcuts.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type ShortcutHandler = (e: KeyboardEvent) => false | void; 4 | 5 | export default function useShortcuts(handler: ShortcutHandler) { 6 | React.useEffect(() => { 7 | const wrapper = (e: KeyboardEvent) => { 8 | if (isProtectedInputEvent(e)) return; 9 | if (handler(e) !== false) e.preventDefault(); 10 | } 11 | 12 | document.addEventListener('keydown', wrapper); 13 | return () => document.removeEventListener('keydown', wrapper); 14 | }, [handler]); 15 | } 16 | 17 | export function isProtectedInputEvent(e: KeyboardEvent) { 18 | const target = e.target as HTMLElement; 19 | 20 | if (target.tagName === 'INPUT') return ( 21 | (e.key.startsWith('Arrow')) || 22 | (e.metaKey && ['AZXCV'].includes(e.key)) 23 | ); 24 | 25 | return false; 26 | } 27 | -------------------------------------------------------------------------------- /src/views/PageView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Tab, Page, VisitMode} from 'core'; 3 | import {remoteAction} from 'utils/remoteState'; 4 | import {DetectRenderer} from 'renderers' 5 | 6 | export default function PageView(p: { 7 | tab: Tab, 8 | page: Page, 9 | visitUrl(url: string, mode?: VisitMode): void, 10 | }) { 11 | const {tab, page, visitUrl} = p; 12 | const index = tab.history.findIndex(p => p.id === page.id); 13 | 14 | const setScroll = React.useCallback((scroll: number) => { 15 | if (tab && page) remoteAction('scrollPage', tab.id, page.id, scroll); 16 | }, [tab.id, page.id]); 17 | 18 | if (!tab || !page) return null; 19 | 20 | return ( 21 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/renderers/TextRenderer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import {useFetchText} from 'utils/useFetch'; 4 | import {useScrollRestoration} from 'utils/useScrollRestoration'; 5 | import {RendererProps} from './Renderer'; 6 | 7 | 8 | export default function TextRenderer(p: RendererProps) { 9 | const [text] = useFetchText(p.url, undefined, [p.timestamp]); 10 | const $scroller = useScrollRestoration(p.scroll, p.onScroll, [text]); 11 | 12 | return React.useMemo(() => ( 13 | 14 | {text} 15 | 16 | ), [text]); 17 | } 18 | 19 | const Container = styled.div` 20 | user-select: text; 21 | min-width: 664px; 22 | height: 100%; 23 | padding: 24px; 24 | overflow: hidden scroll; 25 | `; 26 | 27 | const Content = styled.pre` 28 | width: 616px; 29 | margin: 0 auto; 30 | white-space: pre-wrap; 31 | font-family: "SF Mono", Menlo, Monaco, monospace; 32 | font-size: 12px; 33 | `; 34 | -------------------------------------------------------------------------------- /src/utils/useFetch.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useLoadEffect} from 'utils/useLoadEffect'; 3 | 4 | export function useFetchText( 5 | url: RequestInfo, 6 | options?: RequestInit, 7 | deps: readonly any[] = [], 8 | ): [string, boolean, Error|void] { 9 | let currentContent = ''; 10 | const [content, setContent] = React.useState(currentContent); 11 | 12 | const [loading, error] = useLoadEffect(async () => { 13 | const response = await fetch(url, options); 14 | const reader = response.body!.getReader(); 15 | const decoder = new TextDecoder('utf-8'); 16 | 17 | currentContent = ''; 18 | setContent(currentContent); 19 | 20 | while (true) { 21 | const {done, value} = await reader.read(); 22 | const chunk = (!done) ? decoder.decode(value, {stream: true}) : ''; 23 | currentContent += chunk; 24 | setContent(currentContent); 25 | if (done) break; 26 | } 27 | }, [url, ...deps]); 28 | 29 | return [content, loading, error]; 30 | } 31 | -------------------------------------------------------------------------------- /src/renderers/MarkdownRenderer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import Markdown from 'react-markdown'; 4 | import {useFetchText} from 'utils/useFetch'; 5 | import {useScrollRestoration} from 'utils/useScrollRestoration'; 6 | import {RendererProps} from './Renderer'; 7 | 8 | 9 | export default function MarkdownRenderer(p: RendererProps) { 10 | const [text] = useFetchText(p.url, undefined, [p.timestamp]); 11 | const $scroller = useScrollRestoration(p.scroll, p.onScroll, [text]); 12 | 13 | return React.useMemo(() => ( 14 | 15 | 16 | 17 | ), [p.linkTarget, text]); 18 | } 19 | 20 | const Container = styled.div` 21 | user-select: text; 22 | min-width: 664px; 23 | height: 100%; 24 | padding: 24px; 25 | overflow: hidden scroll; 26 | `; 27 | 28 | const Content = styled(Markdown)` 29 | width: 616px; 30 | margin: 0 auto; 31 | `; 32 | -------------------------------------------------------------------------------- /src/renderers/ImageRenderer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import {Horizontal} from 'components/Layout'; 4 | import {RendererProps} from './Renderer'; 5 | 6 | 7 | export default function ImageRenderer(p: RendererProps) { 8 | const [zoomed, setZoomed] = React.useState(false); 9 | 10 | const toggleZoom = React.useCallback(() => { 11 | setZoomed(!zoomed); 12 | }, [zoomed, setZoomed]); 13 | 14 | return React.useMemo(() => ( 15 | 16 | 17 | 18 | ), [p.timestamp, p.url, zoomed, toggleZoom]); 19 | } 20 | 21 | const Container = styled(Horizontal)<{ 22 | zoomed: boolean, 23 | }>` 24 | height: 100%; 25 | align-items: center; 26 | justify-content: center; 27 | min-width: 664px; 28 | padding: 24px; 29 | overflow: auto scroll; 30 | `; 31 | 32 | const Image = styled.img<{ 33 | zoomed: boolean, 34 | }>` 35 | cursor: pointer; 36 | max-width: ${p => p.zoomed ? '1200px' : '616px'}; 37 | `; 38 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const Path = require('path'); 2 | const dir = (...args) => Path.resolve(__dirname, ...args); 3 | 4 | const config = (name, entry, target) => ({ 5 | mode: 'production', 6 | devtool: 'cheap-module-source-map', 7 | target, 8 | 9 | entry: { 10 | [name]: dir(entry), 11 | }, 12 | 13 | resolve: { 14 | extensions: ['.js', '.ts', '.tsx'], 15 | modules: ['src', 'node_modules'], 16 | }, 17 | 18 | module: { 19 | rules: [ 20 | {test:/\.tsx?$/, loader:'ts-loader', exclude:/node_modules/}, 21 | {test:/\.(png|svg|html|css)$/, loader:'file-loader', exclude:/node_modules/, options:{ 22 | name: '[name].[ext]', 23 | }}, 24 | ], 25 | }, 26 | 27 | output: { 28 | path: dir('build'), 29 | filename: '[name].js', 30 | }, 31 | 32 | devServer: { 33 | writeToDisk: true, 34 | contentBase: dir('build'), 35 | stats: 'errors-only', 36 | }, 37 | }); 38 | 39 | module.exports = [ 40 | config('main', 'src/main/index.ts', 'electron-main'), 41 | config('app', 'src/index.tsx', 'electron-renderer'), 42 | ]; 43 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020, zenoamaro 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /src/gopher/urls.ts: -------------------------------------------------------------------------------- 1 | import URL from 'url'; 2 | 3 | export function parseGopherUrl(url: string) { 4 | let query; 5 | [url, query] = url.split('%09'); 6 | if (!url.includes('://')) url = `gopher://${url}`; 7 | 8 | const data = URL.parse(url); 9 | if (data.protocol !== 'gopher:') throw `Invalid URL: ${url}`; 10 | if (!data.hostname) throw `Invalid URL: ${url}`; 11 | 12 | return { 13 | ...data, 14 | hostname: data.hostname, 15 | port: parseInt(data.port||'70'), 16 | // FIXME UGLY 17 | path: `${cleanPath(data.path)}${data.hash?` ${data.hash}`:''}`, 18 | pathname: `${cleanPath(data.pathname)}${data.hash?` ${data.hash}`:''}`, 19 | type: (data.path||'/').match(/^\/(\w)[$\/]/)?.[1], 20 | query, 21 | }; 22 | } 23 | 24 | export function cleanPath(path: string | null) { 25 | if (!path || path === '/') path = ''; 26 | return (path) 27 | .replace(/^\/\w[\/$]/, '/') 28 | .replace(/\/+/g, '/'); 29 | } 30 | 31 | export function makeUrl(host:string, port:number, path:string='/', type?:string) { 32 | if (path.match(/^\/?url:/i)) return path.replace(/^\/?url:/i, ''); 33 | // FIXME UGLY HARDCODE 34 | return `gopher://${host}${port!==70?`:${port}`:''}${(type&&type!=='1')?`/${type}`:''}${path}`; 35 | } 36 | 37 | -------------------------------------------------------------------------------- /src/utils/remoteState.tsx: -------------------------------------------------------------------------------- 1 | import {ipcRenderer, IpcRendererEvent} from 'electron'; 2 | import React, {useEffect, useState} from 'react'; 3 | import {Patch, applyPatches} from 'immer'; 4 | import * as Core from 'core'; 5 | 6 | export function useRemoteState(): Core.State | undefined { 7 | const [state, setState] = useState(); 8 | let pendingState: Core.State; 9 | 10 | function handler(event: IpcRendererEvent, newState: Core.State, patches?: Patch[]) { 11 | setState(pendingState = (pendingState && patches) ? 12 | applyPatches(pendingState, patches) : 13 | newState 14 | ); 15 | } 16 | 17 | useEffect(() => { 18 | ipcRenderer.on('state', handler); 19 | ipcRenderer.send('state'); // Request fresh state 20 | return () => {ipcRenderer.off('state', handler)}; 21 | }, []); 22 | 23 | return state; 24 | } 25 | 26 | export function withRemoteState(Component: React.FC<{state: Core.State}>): React.FC { 27 | return () => { 28 | const state = useRemoteState(); 29 | return state ? : null; 30 | }; 31 | } 32 | 33 | // FIXME try to do reflection here, or create an actual API 34 | export function remoteAction(action: keyof typeof Core, ...args: any[]): any { 35 | return ipcRenderer.send('action', action, ...args); 36 | } 37 | -------------------------------------------------------------------------------- /src/renderers/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Bag from 'utils/Bag'; 3 | 4 | import Renderer, {RendererProps} from './Renderer'; 5 | import GopherRenderer from './GopherRenderer'; 6 | import GopherFolderRenderer from './GopherFolderRenderer'; 7 | import BinaryRenderer from './BinaryRenderer'; 8 | import DocumentRenderer from 'renderers/DocumentRenderer'; 9 | import TextRenderer from './TextRenderer'; 10 | import HTMLRenderer from './HTMLRenderer'; 11 | import ImageRenderer from './ImageRenderer'; 12 | import AudioRenderer from './AudioRenderer'; 13 | 14 | export const renderers: Bag = { 15 | '0': DocumentRenderer, 16 | '17': GopherRenderer, 17 | 'F': GopherFolderRenderer, 18 | '4569': BinaryRenderer, 19 | 'Ipgj': ImageRenderer, 20 | 'h': HTMLRenderer, 21 | 's': AudioRenderer, 22 | '*': TextRenderer, 23 | }; 24 | 25 | export interface DetectRendererProps extends RendererProps { 26 | type: string, 27 | }; 28 | 29 | export function DetectRenderer(p: DetectRendererProps) { 30 | const {type, ...props} = p; 31 | 32 | const Component = Object.entries(renderers).reduce((Match, [exts, Component]) => { 33 | return exts.includes(type) ? Component : Match; 34 | }, renderers['*']); 35 | 36 | return ; 37 | } 38 | 39 | export default renderers; 40 | -------------------------------------------------------------------------------- /src/gopher/parser.ts: -------------------------------------------------------------------------------- 1 | import transform from 'through2'; 2 | import combine from 'multipipe'; 3 | import {scanner} from 'utils/streams'; 4 | import {makeUrl} from './urls'; 5 | 6 | export interface Item { 7 | type: string, 8 | label: string, 9 | url?: string, 10 | } 11 | 12 | export type ItemParser = ( 13 | type: string, 14 | label: string, 15 | path: string, 16 | host: string, 17 | port: number, 18 | ) => Item; 19 | 20 | export const itemParsers: {[type: string]: ItemParser} = { 21 | 'default': (type, label, path, host, port) => ( 22 | {type, label, url:makeUrl(host, port, path, type)} 23 | ), 24 | }; 25 | 26 | export function parseItem(line: string, parsers=itemParsers): Item { 27 | const type = line[0]; 28 | let [label, path, host, port] = line.slice(1).split('\t'); 29 | const parser = parsers[type] ?? parsers.default; 30 | return parser(type, label, path, host, parseInt(port)); 31 | } 32 | 33 | export function parse(content: string, parsers=itemParsers): Item[] { 34 | return content.split('\n').map(line => parseItem(line, parsers)); 35 | } 36 | 37 | export function parser(parsers=itemParsers) { 38 | return combine( 39 | scanner(0x0A), 40 | transform.obj(function (line, enc, done) { 41 | line = line.toString(); 42 | if (!line.startsWith('.')) this.push(parseItem(line)); 43 | done(); 44 | }), 45 | ); 46 | } 47 | 48 | -------------------------------------------------------------------------------- /src/core/windows.ts: -------------------------------------------------------------------------------- 1 | import {update, withState} from './state'; 2 | import {arrayMoveMutate} from 'utils/array'; 3 | 4 | export interface Window { 5 | id: string, 6 | tabs: string[], 7 | selectedTabId: string, 8 | } 9 | 10 | export function makeWindow(tabs: string[]) { 11 | return { 12 | id: 'main', 13 | tabs: tabs, 14 | selectedTabId: tabs[tabs.length-1], 15 | } 16 | } 17 | 18 | export function selectTab(windowId: string, tabId: string) { 19 | update((state) => { 20 | const window = state.windows[windowId]; 21 | window.selectedTabId = tabId; 22 | }); 23 | } 24 | 25 | export function selectPreviousTab(windowId: string) { 26 | const window = withState(state => state.windows[windowId]); 27 | const index = window.tabs.indexOf(window.selectedTabId); 28 | const newIndex = (window.tabs.length + index - 1) % window.tabs.length; 29 | selectTab(window.id, window.tabs[newIndex]); 30 | } 31 | 32 | export function selectNextTab(windowId: string) { 33 | const window = withState(state => state.windows[windowId]); 34 | const index = window.tabs.indexOf(window.selectedTabId); 35 | const newIndex = (window.tabs.length + index + 1) % window.tabs.length; 36 | selectTab(window.id, window.tabs[newIndex]); 37 | } 38 | 39 | export function reorderTab(windowId: string, tabId: string, at: number) { 40 | update((state) => { 41 | const window = state.windows[windowId]; 42 | const idx = window.tabs.indexOf(tabId); 43 | arrayMoveMutate(window.tabs, idx, at); 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /src/renderers/HTMLRenderer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import {useFetchText} from 'utils/useFetch'; 4 | import visitModeFromEvent from 'utils/visitModeFromEvent'; 5 | import {useScrollRestoration} from 'utils/useScrollRestoration'; 6 | import {RendererProps} from './Renderer'; 7 | 8 | 9 | export default function HTMLRenderer(p: RendererProps) { 10 | const [text] = useFetchText(p.url, undefined, [p.timestamp]); 11 | const $scroller = useScrollRestoration(p.scroll, p.onScroll, [text]); 12 | 13 | const captureClick = React.useCallback((e: MouseEvent) => { 14 | const $anchor = e.target as HTMLAnchorElement; 15 | if ($anchor.tagName !== 'A') return; 16 | if (e.metaKey) return; 17 | p.visitUrl($anchor.href, visitModeFromEvent(e)); 18 | e.preventDefault(); 19 | }, [p.linkTarget]); 20 | 21 | const captureFrameEvents = React.useCallback((e: React.SyntheticEvent) => { 22 | const $frame = e.target as HTMLIFrameElement; 23 | $frame.contentDocument?.addEventListener('click', captureClick); 24 | }, [captureClick]); 25 | 26 | const content = React.useMemo(() => { 27 | const doc = `${text}`; 28 | return 29 | }, [p.linkTarget, text]); 30 | 31 | return content; 32 | } 33 | 34 | const Frame = styled.iframe` 35 | display: block; 36 | margin: 0 auto; 37 | min-width: 664px; 38 | height: 100%; 39 | border: none; 40 | `; 41 | -------------------------------------------------------------------------------- /src/renderers/BinaryRenderer.tsx: -------------------------------------------------------------------------------- 1 | import Path from 'path'; 2 | import React from 'react'; 3 | import styled from 'styled-components'; 4 | import * as Gopher from 'gopher'; 5 | import {Vertical} from 'components/Layout'; 6 | import LinkButton from 'components/LinkButton'; 7 | import {RendererProps} from './Renderer'; 8 | import Bag from 'utils/Bag'; 9 | import * as Icons from 'react-icons/io'; 10 | 11 | 12 | const mapping: Bag> = { 13 | '.txt .md .html .pdf .doc .xls': Icons.IoIosDocument, 14 | '.zip .rar .7z .tar .gz .sit .hqx': Icons.IoIosArchive, 15 | '.mp3 .ogg .wav .mid': Icons.IoIosVolumeHigh, 16 | '*': Icons.IoIosCube, 17 | }; 18 | 19 | export default function BinaryRenderer(p: RendererProps) { 20 | return React.useMemo(() => { 21 | const {pathname} = Gopher.parseGopherUrl(p.url); 22 | const basename = Path.basename(pathname); 23 | const extname = Path.extname(basename); 24 | 25 | const Icon = Object.entries(mapping).reduce((Match, [exts, Component]) => { 26 | return exts.split(' ').includes(extname) ? Component : Match; 27 | }, mapping['*']); 28 | 29 | return 30 | 31 | 32 | Save to disk 33 | ; 34 | }, [p.url, p.timestamp]); 35 | } 36 | 37 | 38 | 39 | const Container = styled(Vertical)` 40 | height: 100%; 41 | align-items: center; 42 | justify-content: center; 43 | width: 664px; 44 | padding: 24px; 45 | overflow: auto scroll; 46 | `; 47 | 48 | const IconContainer = styled.div` 49 | display: contents; 50 | color: #aaa; 51 | `; 52 | 53 | const Label = styled.div` 54 | margin: 12px; 55 | font-weight: bold; 56 | `; 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "unnamed-gopher-client", 4 | "version": "0.0.1", 5 | "description": "", 6 | "author": { 7 | "name": "zenoamaro", 8 | "email": "zenoamaro@gmail.com" 9 | }, 10 | "license": "MIT", 11 | "keywords": [ 12 | "gopher", 13 | "gopherhole" 14 | ], 15 | "homepage": "https://github.com/zenoamaro/unnamed-gopher-client", 16 | "repository": "git@github.com:zenojevski/unnamed-gopher-client.git", 17 | "main": "build/main.js", 18 | "scripts": { 19 | "start": "electron .", 20 | "build": "webpack --mode production", 21 | "watch": "webpack-dev-server --watch --mode development", 22 | "dist": "npm run clean && npm run build && electron-builder build", 23 | "clean": "rm -rf build dist", 24 | "prepublishOnly": "npm run clean && npm run build && npm run test" 25 | }, 26 | "dependencies": { 27 | "immer": "^6.0.9", 28 | "into-stream": "^5.1.1", 29 | "lodash": "^4.17.15", 30 | "multipipe": "^4.0.0", 31 | "react": "^16.13.1", 32 | "react-dom": "^16.13.1", 33 | "react-icons": "^3.10.0", 34 | "react-markdown": "^4.3.1", 35 | "react-sortable-hoc": "^1.11.0", 36 | "readable-stream-clone": "0.0.7", 37 | "styled-components": "^5.1.1", 38 | "through2": "^3.0.1", 39 | "use-debounce": "^3.4.2", 40 | "v8-compile-cache": "^2.1.1" 41 | }, 42 | "devDependencies": { 43 | "@types/electron-devtools-installer": "^2.2.0", 44 | "@types/lodash": "^4.14.153", 45 | "@types/multipipe": "^3.0.0", 46 | "@types/node": "^12.12.42", 47 | "@types/react": "^16.9.35", 48 | "@types/react-dom": "^16.9.8", 49 | "@types/react-sortable-hoc": "^0.7.1", 50 | "@types/styled-components": "^5.1.0", 51 | "@types/through2": "^2.0.36", 52 | "electron": "^8.3.1", 53 | "electron-builder": "^22.7.0", 54 | "electron-devtools-installer": "^3.0.0", 55 | "file-loader": "^6.0.0", 56 | "ts-loader": "^7.0.5", 57 | "typescript": "^3.9.3", 58 | "webpack": "^4.43.0", 59 | "webpack-cli": "^3.3.11", 60 | "webpack-dev-server": "^3.11.0" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Unnamed Gopher Client 2 | ===================== 3 | 4 | A modern [Gopher](https://en.wikipedia.org/wiki/Gopher_(protocol)) desktop client for the twenty-twenties. [Help me name it!](#help-me-name-this-app) 5 | 6 | Written in Electron, TypeScript, and React. 7 | 8 | [](https://imgur.com/a/miOTyl7) 9 | 10 | [More screenshots here](https://imgur.com/a/miOTyl7) 11 | 12 | 13 | Project Goals 14 | ------------- 15 | 16 | The goal of this project is to create a client that attracts people to the Gopher community, and entices them to _stay for the ride_: 17 | 18 | - [x] Suggest the most active Gopher communities to get started 19 | - [x] Make it easy to search the Gopherspace and start exploring 20 | - [ ] Aggregate phlogs into feeds to help following creators and giving them a bigger audience 21 | - [ ] Curate the best and most active Gopherholes 22 | - [ ] Figure out a way to let users interact with creators, or show them support, without changing the Gopher experience 23 | - [ ] Show engaging media inline, if desired 24 | 25 | It aims to provide features that are _unique_ to the Gopher experience: 26 | 27 | - [x] Multi-column drill-down navigation like Finder 28 | - [x] View pages as folders and files 29 | - [ ] Inline and tree navigation like Explorer 30 | - [ ] Keyboard and number-based navigation 31 | 32 | And of course, all of the features that we have come to expect: 33 | 34 | - [x] Tabs 35 | - [x] Browsing history 36 | - [x] Recently visited pages 37 | - [x] Aggressive caching 38 | - [x] Downloads 39 | - [x] Combined search and address bar 40 | - [x] A welcome and start page 41 | - [ ] User bookmarks 42 | - [ ] Theming & Dark Mode 43 | - [ ] Deep link support (for all platforms) 44 | - [ ] Full drag and drop capabilities 45 | - [ ] Extensions for new Gopher types and Rendering modes 46 | - [ ] Security via TLS 47 | 48 | 49 | Help me name this app 50 | --------------------- 51 | 52 | Challenge yourself to crack the hardest problem known to mankind: naming things! 53 | 54 | Help me find a short, memorable, and ideally cute or clever name for this project, and be forever immortalized through history. 55 | 56 | 57 | License 58 | ------- 59 | 60 | Copyright (c) 2020, zenoamaro \ 61 | 62 | Licensed under the [MIT LICENSE](LICENSE.md). 63 | -------------------------------------------------------------------------------- /src/views/TabView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import {Tab} from 'core'; 4 | import {Vertical} from 'components/Layout'; 5 | import NavBar from 'components/NavBar'; 6 | import {remoteAction} from 'utils/remoteState'; 7 | import useShortcuts from 'utils/useShortcuts'; 8 | import HistoryView from './HistoryView'; 9 | 10 | 11 | export default function TabView(p: { 12 | tab: Tab, 13 | createTab: (url?: string, select?: boolean) => void, 14 | }) { 15 | const {tab} = p; 16 | const page = tab.history[tab.historyIndex]; 17 | 18 | const createTab = React.useCallback((url?: string, select?: boolean) => { 19 | p.createTab(url, select); 20 | }, [p.createTab]); 21 | 22 | const reloadTab = React.useCallback(() => { 23 | if (tab) remoteAction('reloadTab', tab.id); 24 | }, [tab.id]); 25 | 26 | const navigate = React.useCallback((url: string, mode: string) => { 27 | remoteAction('visit', url, mode); 28 | }, []); 29 | 30 | const navigateBack = React.useCallback(() => { 31 | if (tab) remoteAction('navigateTabBack', tab.id); 32 | }, [tab.id]); 33 | 34 | const navigateForward = React.useCallback(() => { 35 | if (tab) remoteAction('navigateTabForward', tab.id); 36 | }, [tab.id]); 37 | 38 | const openSettings = React.useCallback(() => {}, []); 39 | 40 | useShortcuts(React.useCallback((e: KeyboardEvent) => { 41 | if (e.metaKey && e.key === 'r') reloadTab(); 42 | else if (e.metaKey && e.key === 'ArrowLeft') navigateBack(); 43 | else if (e.metaKey && e.key === 'ArrowRight') navigateForward(); 44 | else return false; 45 | }, [reloadTab])); 46 | 47 | const canReload = tab.history.length > 0; 48 | const canNavigateBack = tab.historyIndex > 0; 49 | const canNavigateForward = tab.historyIndex < tab.history.length -1; 50 | 51 | return 52 | 64 | 65 | 66 | ; 67 | } 68 | 69 | const Container = styled(Vertical)` 70 | flex: 1; 71 | overflow: hidden; 72 | `; 73 | -------------------------------------------------------------------------------- /src/main/index.ts: -------------------------------------------------------------------------------- 1 | require('v8-compile-cache'); 2 | require('immer').enablePatches(); 3 | 4 | import {platform} from 'os'; 5 | import {app, BrowserWindow, ipcMain} from 'electron'; 6 | import installExtension, {REACT_DEVELOPER_TOOLS} from 'electron-devtools-installer'; 7 | import {registerProtocolSchemes, registerProtocolHandlers} from 'protocols'; 8 | import * as Core from 'core'; 9 | 10 | 11 | start().catch((err) => { 12 | console.error(err); 13 | }); 14 | 15 | async function start() { 16 | registerURLSchemeHandler(); 17 | registerProtocolSchemes(); 18 | 19 | await app.whenReady(); 20 | installExtension(REACT_DEVELOPER_TOOLS); 21 | registerProtocolHandlers(); 22 | createWindow(); 23 | } 24 | 25 | function createWindow() { 26 | const wnd = new BrowserWindow({ 27 | width: 1300, 28 | height: 800, 29 | titleBarStyle: platform() === 'darwin' ? 'hiddenInset' : undefined, 30 | webPreferences: { 31 | nodeIntegration: true, 32 | }, 33 | }); 34 | 35 | wnd.webContents.on('new-window', (event, url, frame, disposition, options) => { 36 | const at = (frame != null && frame != '') ? Number(frame) : undefined; 37 | const mode = (at != null && disposition === 'foreground-tab') ? 'push' : disposition; 38 | const visited = Core.visit(url, mode, at); 39 | if (visited) event.preventDefault(); 40 | }); 41 | 42 | wnd.webContents.on('will-navigate', (event, url) => { 43 | const visited = Core.visit(url, 'push'); 44 | if (visited) event.preventDefault(); 45 | }); 46 | 47 | Core.subscribe((state, patches) => { 48 | wnd.webContents.send('state', state, patches); 49 | 50 | // Update window title to focused tab/page 51 | const window = state.windows.main; 52 | const tab = state.tabs[window?.selectedTabId]; 53 | const page = tab?.history[tab.historyIndex]; 54 | if (page?.title !== wnd.getTitle()) wnd.setTitle(page?.title ?? 'New window'); 55 | }); 56 | 57 | ipcMain.on('state', (event) => { 58 | Core.withState((state) => { 59 | event.sender.send('state', state); 60 | }); 61 | }); 62 | 63 | ipcMain.on('action', async (event, action: keyof typeof Core, ...args: any[]) => { 64 | // @ts-ignore 65 | await Core[action](...args); 66 | }); 67 | 68 | wnd.loadFile(`build/app.html`); 69 | } 70 | 71 | function registerURLSchemeHandler() { 72 | // FIXME MacOS-only 73 | app.on('open-url', (e, url) => { 74 | Core.createTab('main', url, true); 75 | }); 76 | } 77 | -------------------------------------------------------------------------------- /src/core/state.ts: -------------------------------------------------------------------------------- 1 | import produce, {Patch} from 'immer'; 2 | import Bag from 'utils/Bag'; 3 | import {fromPairs} from 'lodash'; 4 | 5 | import {Bookmark, makeBookmark} from './bookmarks'; 6 | import {Tab} from './tabs'; 7 | import {Window, makeWindow} from './windows'; 8 | import {Recent} from 'core/recents'; 9 | 10 | 11 | // State ——————————————————————————————————————————————————————————————————————— 12 | 13 | export interface State { 14 | windows: Bag, 15 | tabs: Bag, 16 | bookmarks: Bag, 17 | recents: Bag, 18 | } 19 | 20 | const initialWindow = makeWindow([]); 21 | 22 | const initialBookmarks = fromPairs([ 23 | makeBookmark('Floodgap', '1', 'gopher://gopher.floodgap.com'), 24 | makeBookmark('SDF', '1', 'gopher://sdf.org'), 25 | makeBookmark('Create a forum', '1', 'gopher://createaforum.com'), 26 | makeBookmark('Tilde.land', '1', 'gopher://tilde.land'), 27 | makeBookmark('Quux', '1', 'gopher://quux.org'), 28 | makeBookmark('Bitreich', '1', 'gopher://bitreich.org'), 29 | ].map(bookmark => [bookmark.id, bookmark])); 30 | 31 | let state: State = { 32 | windows: { 33 | [initialWindow.id]: initialWindow, 34 | }, 35 | tabs: {}, 36 | bookmarks: initialBookmarks, 37 | recents: {}, 38 | }; 39 | 40 | 41 | // Updates ————————————————————————————————————————————————————————————————————— 42 | 43 | export type Getter = (state: State) => T; 44 | export type Updater = (state: State) => void; 45 | 46 | const collectedPatches: Patch[] = []; 47 | let nextUpdate: number | null; 48 | 49 | export function withState(receiver: Getter): T { 50 | return receiver(state); 51 | } 52 | 53 | export function update(producer: Updater): void { 54 | state = produce( 55 | state, 56 | producer, 57 | (patches) => collectedPatches.push(...patches), 58 | ); 59 | if (!nextUpdate) nextUpdate = setTimeout(() => { 60 | for (let subscriber of subscribers) { 61 | subscriber(state, collectedPatches); 62 | } 63 | nextUpdate = null; 64 | collectedPatches.splice(0); 65 | }, 16); 66 | } 67 | 68 | 69 | // Listeners ——————————————————————————————————————————————————————————————————— 70 | 71 | export type Subscriber = (state: State, patches: Patch[]) => void; 72 | 73 | let subscribers: Subscriber[] = []; 74 | 75 | export function subscribe(subscriber: Subscriber) { 76 | subscribers.push(subscriber); 77 | } 78 | 79 | export function unsubscribe(subscriber: Subscriber) { 80 | subscribers = subscribers.filter(l => l !== subscriber); 81 | } 82 | -------------------------------------------------------------------------------- /src/core/pages.ts: -------------------------------------------------------------------------------- 1 | import {URL} from 'url'; 2 | import {uniqueId} from 'lodash'; 3 | import * as Gopher from 'gopher'; 4 | import {clearCachedURL} from 'protocols/gopher'; 5 | import {capitalized} from 'utils/text'; 6 | import {update} from './state'; 7 | import {createRecent} from './recents'; 8 | 9 | 10 | export const DEFAULT_SEARCH_ENGINE_URL = `gopher://gopher.floodgap.com/7/v2/vs`; 11 | 12 | export interface Page { 13 | id: string, 14 | title: string, 15 | url: string, 16 | type: string, 17 | scroll: number, 18 | timestamp: number, 19 | } 20 | 21 | export function makePage(title: string, url: string): Page { 22 | return { 23 | id: uniqueId('page'), 24 | title, 25 | url, 26 | type: '1', 27 | scroll: 0, 28 | timestamp: 0, 29 | }; 30 | } 31 | 32 | export function scrollPage(tabId: string, pageId: string, scroll: number) { 33 | update((state) => { 34 | const tab = state.tabs[tabId]!; 35 | if (!tab) return; 36 | const pageIndex = tab.history.findIndex(p => p.id === pageId); 37 | if (pageIndex === -1) return; 38 | tab.history[pageIndex].scroll = scroll; 39 | }); 40 | } 41 | 42 | export function navigatePage(tabId: string, pageId: string, url: string, fresh = false) { 43 | const parsedUrl = Gopher.parseGopherUrl(url); 44 | const title = formatPageDisplayTitle(url); 45 | let changedUrl = false; 46 | 47 | update((state) => { 48 | const tab = state.tabs[tabId]!; 49 | const page = tab.history.find(p => p.id === pageId)!; 50 | if (page.url !== url) { 51 | page.timestamp = Date.now(); 52 | changedUrl = true; 53 | } 54 | page.title = title; 55 | page.type = parsedUrl.type || '1'; 56 | page.url = url; 57 | }); 58 | 59 | if (changedUrl) { 60 | createRecent(title, parsedUrl.type || '1', url); 61 | } 62 | } 63 | 64 | export function reloadPage(tabId: string, pageId: string) { 65 | update((state) => { 66 | const tab = state.tabs[tabId]!; 67 | const page = tab.history.find(p => p.id === pageId)!; 68 | clearCachedURL(page.url); 69 | page.timestamp = Date.now(); 70 | }); 71 | } 72 | 73 | export function formatPageDisplayTitle(url: string) { 74 | if (url === 'gopher://start') return 'Start page'; 75 | else if (url === 'gopher://test') return 'Test page'; 76 | 77 | const parsedUrl = Gopher.parseGopherUrl(url); 78 | const pathTitle = parsedUrl.query ?? 79 | capitalized(parsedUrl.pathname.replace(/\/$/, '').split('/').slice(-1)[0]); 80 | return [pathTitle, parsedUrl.hostname].filter(Boolean).join(' - '); 81 | } 82 | 83 | export function isValidURL(url: string): boolean { 84 | try { 85 | new URL(url); 86 | return true; 87 | } catch (err) { 88 | return false; 89 | } 90 | } 91 | 92 | export function isSearchUrl(url: string): boolean { 93 | return url.includes('%09'); 94 | } 95 | 96 | export function makeSearchUrl(query: string): string { 97 | return `${DEFAULT_SEARCH_ENGINE_URL}%09${query}`; 98 | } 99 | -------------------------------------------------------------------------------- /src/views/HistoryView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import {memoize} from 'lodash'; 4 | import {Tab, VisitMode} from 'core'; 5 | import {useDebouncedCallback} from 'use-debounce'; 6 | import {remoteAction} from 'utils/remoteState'; 7 | import {Horizontal} from 'components/Layout'; 8 | import PageView from './PageView'; 9 | 10 | 11 | const createUrlVisitor = memoize((i: number) => (url: string, mode?: VisitMode) => { 12 | remoteAction('visit', url, mode, i); 13 | }); 14 | 15 | function scrollPane($container: HTMLElement | null, index: number, smooth: boolean) { 16 | if (!$container) return; 17 | const $pane = $container.children[index] as HTMLElement; 18 | if (!$pane) return; 19 | const left = $pane.offsetLeft - ($container.offsetWidth - $pane.offsetWidth) / 2; 20 | $container.scrollTo({left, behavior: smooth? 'smooth' : 'auto'}); 21 | } 22 | 23 | export default function HistoryView(p: { 24 | tab: Tab, 25 | }) { 26 | const {tab} = p; 27 | 28 | const $scroller = React.useRef(null); 29 | 30 | React.useLayoutEffect(() => { 31 | scrollPane($scroller?.current, tab.historyIndex, false); 32 | }, [tab.id]); 33 | 34 | React.useLayoutEffect(() => { 35 | scrollPane($scroller?.current, tab.historyIndex, true); 36 | }, [tab.historyIndex]); 37 | 38 | const [onScroll] = useDebouncedCallback(() => { 39 | if (!$scroller.current) return; 40 | const scroll = $scroller.current.scrollLeft; 41 | const width = $scroller.current.clientWidth; 42 | const children = Array.from($scroller.current.children); 43 | if (!children.length) return; 44 | 45 | const panes = [ 46 | {i:children.length-1, $el:children[children.length-1]}, 47 | {i:0, $el:children[0]}, 48 | ...children.slice(1, -1).map(($el, i) => ({i:i+1, $el})).reverse(), 49 | ]; 50 | 51 | for (let {i, $el} of panes) { 52 | const $pane = $el as HTMLElement; 53 | const left = $pane.offsetLeft - scroll; 54 | const start = Math.abs(left); 55 | const end = Math.abs(width - left - $pane.offsetWidth); 56 | const centering = Math.abs(end - start); 57 | if (start <= 1 || end <= 1 || centering <= 1) { 58 | if (i !== tab!.historyIndex) { 59 | remoteAction('navigateTabAt', tab!.id, i); 60 | } 61 | break; 62 | } 63 | } 64 | }, 64); 65 | 66 | const pages = React.useMemo(() => ( 67 | tab.history.map((page, i) => { 68 | const isCurrentPage = tab.history[tab.historyIndex].id === page.id; 69 | const visitUrl = createUrlVisitor(i); 70 | return 71 | 72 | ; 73 | }) 74 | ), [tab.history, tab.historyIndex]); 75 | 76 | return ( 77 | 78 | {pages} 79 | 80 | ); 81 | } 82 | 83 | const Container = styled(Horizontal)` 84 | flex: 1; 85 | overflow: scroll hidden; 86 | scroll-snap-type: x mandatory; 87 | `; 88 | 89 | const Pane = styled.div<{ 90 | highlight: boolean, 91 | }>` 92 | flex: 1 0 auto; 93 | scroll-snap-align: center; 94 | overflow: hidden; 95 | background: ${p => p.highlight? 'white' : 'transparent'}; 96 | &:first-child, &:not(:last-child){ border-right: solid thin #ddd } 97 | `; 98 | -------------------------------------------------------------------------------- /src/views/BrowserView.tsx: -------------------------------------------------------------------------------- 1 | import {platform} from 'os'; 2 | import React from 'react'; 3 | import styled from 'styled-components'; 4 | import {State} from 'core'; 5 | import {Vertical} from 'components/Layout'; 6 | import TabBar from 'components/TabBar'; 7 | import useShortcuts from 'utils/useShortcuts'; 8 | import {remoteAction, withRemoteState} from 'utils/remoteState'; 9 | import TabView from './TabView'; 10 | 11 | 12 | export default withRemoteState(function BrowserView(p: { 13 | state: State, 14 | }) { 15 | const {state} = p; 16 | const window = state.windows.main; 17 | const tabs = window.tabs.map(tabId => state.tabs[tabId]); 18 | if (!tabs.length) remoteAction('createTab', window.id, 'gopher://start', true, true); 19 | 20 | const tab = state.tabs[window.selectedTabId]; 21 | 22 | const reorderWindowTab = React.useCallback(({oldIndex, newIndex}) => { 23 | const tabId = window.tabs[oldIndex]; 24 | remoteAction('reorderTab', window.id, tabId, newIndex); 25 | }, [window.id, window.tabs]); 26 | 27 | const selectWindowTab = React.useCallback((tabId: string) => { 28 | remoteAction('selectTab', window.id, tabId); 29 | }, [window.id]); 30 | 31 | const createWindowTab = React.useCallback((url?: string, select?: boolean) => { 32 | remoteAction('createTab', window.id, url, select); 33 | }, [window.id]); 34 | 35 | const destroyTab = React.useCallback((tabId: string) => { 36 | remoteAction('destroyTab', tabId); 37 | }, []); 38 | 39 | useShortcuts(React.useCallback((e: KeyboardEvent) => { 40 | if (e.metaKey && e.key === 't') remoteAction('createTab', window.id); 41 | else if (e.metaKey && e.key === 'w') remoteAction('destroyTab', window.selectedTabId); 42 | else if (e.metaKey && e.key === '[') remoteAction('selectPreviousTab', window.id); 43 | else if (e.metaKey && e.key === ']') remoteAction('selectNextTab', window.id); 44 | else return false; 45 | }, [window.id, window.selectedTabId])); 46 | 47 | const tabBarTabs = React.useMemo(() => tabs.map((tab) => { 48 | const {id, history, historyIndex} = tab; 49 | 50 | if (history.length === 0) { 51 | return {id, icon:'IoIosStar', title:'New tab'}; 52 | } 53 | 54 | const page = history[historyIndex]; 55 | 56 | if (page.url.startsWith('gopher://start') || page.url.startsWith('gopher://test')) { 57 | return {id, icon:'IoIosStar', title:page.title}; 58 | } 59 | 60 | const icon = ( 61 | // resource?.state === 'loading' ? 'LoadingIcon' : 62 | // resource?.state === 'error' ? 'IoIosCloseCircleOutline' : 63 | page.type === '1' ? 'IoIosFolderOpen' : 64 | page.type === '7' ? 'IoIosSearch' : 65 | '0d'.includes(page.type) ? 'IoIosDocument' : 66 | '4569'.includes(page.type) ? 'IoIosArchive' : 67 | 'Ipgj'.includes(page.type) ? 'IoIosImage' : 68 | 's'.includes(page.type) ? 'IoIosSpeaker' : 69 | 'IoIosCloseCircleOutline' 70 | ); 71 | 72 | return {id, icon, title: page.title}; 73 | }), [tabs]); 74 | 75 | return 76 | 85 | {tab? : null} 86 | 87 | }); 88 | 89 | const Container = styled(Vertical)` 90 | flex: 1; 91 | background-color: #fafafa; 92 | user-select: none; 93 | overflow: hidden; 94 | `; 95 | -------------------------------------------------------------------------------- /src/components/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import visitModeFromEvent from 'utils/visitModeFromEvent'; 4 | import useShortcuts from 'utils/useShortcuts'; 5 | import {Horizontal} from './Layout'; 6 | import Button from './Button'; 7 | 8 | import { 9 | IoMdArrowBack, 10 | IoMdArrowForward, 11 | IoMdSettings, 12 | IoMdRefresh, 13 | } from 'react-icons/io' 14 | 15 | export default function NavBar(p: { 16 | url?: string, 17 | onNewTab(url?: string, select?: boolean): void, 18 | onNavigate(url: string, mode: string): void, 19 | onReload(): void, 20 | canReload: boolean, 21 | canNavigateBack: boolean, 22 | canNavigateForward: boolean, 23 | onNavigateBack(): void, 24 | onNavigateForward(): void, 25 | 26 | onOpenSettings(): void, 27 | }) { 28 | const $address = React.useRef(null); 29 | const [temporaryUrl, setTemporaryUrl] = React.useState(p.url); 30 | 31 | React.useEffect(() => { 32 | setTemporaryUrl(p.url); 33 | if (!p.url) $address.current?.focus(); 34 | }, [p.url]); 35 | 36 | const changeAddress = React.useCallback((e: React.ChangeEvent) => { 37 | setTemporaryUrl((e.target as HTMLInputElement).value); 38 | }, [setTemporaryUrl]); 39 | 40 | const submitAddress = React.useCallback((e: React.KeyboardEvent) => { 41 | if (e.key === 'Enter') { 42 | const url = (e.target as HTMLInputElement).value.trim(); 43 | if (!url) return; 44 | $address.current?.blur(); 45 | p.onNavigate(url, visitModeFromEvent(e)); 46 | } else if (e.key === 'Escape') { 47 | setTemporaryUrl(p.url); 48 | $address.current?.blur(); 49 | } 50 | }, [p.url, p.onNavigate, p.onNewTab]); 51 | 52 | useShortcuts(React.useCallback((e: KeyboardEvent) => { 53 | if (e.metaKey && e.key === 'l') { 54 | $address.current?.focus(); 55 | $address.current?.select(); 56 | } else return false; 57 | }, [])); 58 | 59 | return 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 80 | 81 | 82 | 83 | 84 | ; 85 | } 86 | 87 | const Container = styled(Horizontal)` 88 | z-index: 10; 89 | height: 38px; 90 | padding: 4px; 91 | background: white; 92 | box-shadow: 0 2px 4px rgba(0, 0, 0, .1); 93 | `; 94 | 95 | const ToolbarButton = styled(Button)` 96 | align-self: center; 97 | height: 30px; 98 | `; 99 | 100 | const AddressField = styled.input` 101 | flex: 1; 102 | font-size: inherit; 103 | padding: 1px 12px 3px; 104 | border: none; 105 | border-radius: 5px; 106 | background: transparent; 107 | -webkit-app-region: no-drag; 108 | 109 | &:hover, &:focus { 110 | background: #f0f0f0; 111 | } 112 | `; 113 | -------------------------------------------------------------------------------- /src/core/tabs.ts: -------------------------------------------------------------------------------- 1 | import {shell} from 'electron'; 2 | import {uniqueId} from 'lodash'; 3 | import {update, withState} from './state'; 4 | import {makePage, Page, navigatePage, reloadPage, isValidURL, makeSearchUrl} from './pages'; 5 | 6 | 7 | export interface Tab { 8 | id: string, 9 | history: Page[], 10 | historyIndex: number, 11 | } 12 | 13 | export function makeTab(url?: string) { 14 | return { 15 | id: uniqueId('tab'), 16 | historyIndex: 0, 17 | history: [], 18 | }; 19 | } 20 | 21 | export type VisitMode = ( 22 | 'push' | 23 | 'replace' | 24 | 'new-window' | 25 | 'background-tab' | 26 | 'foreground-tab' | 27 | 'save-to-disk' | 28 | 'other' | 29 | 'default' 30 | ); 31 | 32 | export function createTab(windowId: string, url?: string, select = true, fresh = false) { 33 | const tab = makeTab(url); 34 | update((state) => { 35 | const window = state.windows.main; 36 | state.tabs[tab.id] = tab; 37 | window.tabs.push(tab.id); 38 | if (select) window.selectedTabId = tab.id; 39 | }); 40 | if (url) navigateTab(tab.id, url, undefined, fresh); 41 | } 42 | 43 | export function destroyTab(tabId: string) { 44 | update((state) => { 45 | delete state.tabs[tabId]; 46 | const window = state.windows.main; 47 | const index = window.tabs.findIndex(t => t === tabId); 48 | window.tabs = window.tabs.filter(t => t !== tabId); 49 | if (window.selectedTabId === tabId) { 50 | const newIndex = Math.min(index, window.tabs.length-1); 51 | window.selectedTabId = window.tabs[newIndex]; 52 | } 53 | }); 54 | } 55 | 56 | export function navigateTab(tabId: string, url: string, at?: number, fresh = false) { 57 | // FIXME 58 | const page = makePage('', ''); 59 | update((state) => { 60 | const tab = state.tabs[tabId]; 61 | if (at == null) at = tab.historyIndex; 62 | tab.history.splice(at); 63 | tab.history.push(page); 64 | tab.historyIndex = tab.history.length -1; 65 | }); 66 | navigatePage(tabId, page.id, url, fresh); 67 | } 68 | 69 | export function reloadTab(tabId: string, at?: number) { 70 | const page = withState((state) => { 71 | const tab = state.tabs[tabId]; 72 | return tab.history[at ?? tab.historyIndex]; 73 | }); 74 | if (page) reloadPage(tabId, page.id); 75 | } 76 | 77 | export function navigateTabBack(tabId: string) { 78 | update((state) => { 79 | const tab = state.tabs[tabId]; 80 | tab.historyIndex = Math.max(0, tab.historyIndex-1); 81 | }); 82 | } 83 | 84 | export function navigateTabForward(tabId: string) { 85 | update((state) => { 86 | const tab = state.tabs[tabId]; 87 | tab.historyIndex = Math.min(tab.historyIndex+1, tab.history.length-1); 88 | }); 89 | } 90 | 91 | export function navigateTabAt(tabId: string, at: number) { 92 | update((state) => { 93 | const tab = state.tabs[tabId]; 94 | tab.historyIndex = Math.max(0, Math.min(at, tab.history.length-1)); 95 | }); 96 | } 97 | 98 | export function visit(url: string, mode: VisitMode = 'push', at?: number) { 99 | // FIXME These checks are not performed in navigatePage 100 | 101 | if (!isValidURL(url)) { 102 | url = makeSearchUrl(url); 103 | } else if (url.startsWith('file://')) { 104 | // FIXME hack, prevent file:// links altogether, except loading the app 105 | return false; 106 | } else if (!url.startsWith('gopher://')) { 107 | // This might fail if no application handles this scheme 108 | shell.openExternal(url).catch(() => {}); 109 | return true; 110 | }; 111 | 112 | const {window, tab} = withState((state) => { 113 | // FIXME HARDCODE windows 114 | const window = state.windows.main; 115 | const tab = state.tabs[window.selectedTabId]; 116 | return {window, tab}; 117 | }); 118 | 119 | if (mode === 'push' || mode === 'replace') { 120 | navigateTab(tab.id, url, (at ?? tab.historyIndex) + (mode === 'replace'? 0 : 1)); 121 | } else if (mode === 'new-window' || mode === 'background-tab') { 122 | createTab(window.id, url, false); 123 | } else if (mode === 'foreground-tab') { 124 | createTab(window.id, url, true); 125 | } else if (mode === 'save-to-disk') { 126 | // 127 | } else { 128 | navigateTab(tab.id, url); 129 | } 130 | 131 | return true; 132 | } 133 | 134 | -------------------------------------------------------------------------------- /src/renderers/GopherFolderRenderer.tsx: -------------------------------------------------------------------------------- 1 | import React, {SyntheticEvent} from 'react'; 2 | import * as Icons from 'react-icons/io'; 3 | import styled from 'styled-components'; 4 | import * as Gopher from 'gopher'; 5 | import Bag from 'utils/Bag'; 6 | import {useFetchText} from 'utils/useFetch'; 7 | import {useScrollRestoration} from 'utils/useScrollRestoration'; 8 | import {Horizontal, Spring} from 'components/Layout'; 9 | import {RendererProps} from './Renderer'; 10 | import {textMatch} from 'utils/text'; 11 | 12 | 13 | const ICON_MAP: Bag> = { 14 | '0': Icons.IoIosDocument, 15 | '1': Icons.IoIosFolder, 16 | '2': Icons.IoIosDesktop, 17 | '4': Icons.IoIosArchive, 18 | '5': Icons.IoIosArchive, 19 | '6': Icons.IoIosArchive, 20 | '8': Icons.IoIosDesktop, 21 | '9': Icons.IoIosArchive, 22 | 'd': Icons.IoIosDocument, 23 | 'g': Icons.IoIosImage, 24 | 'h': Icons.IoIosGlobe, 25 | 'I': Icons.IoIosImage, 26 | 'j': Icons.IoIosImage, 27 | 'p': Icons.IoIosImage, 28 | '?': Icons.IoIosHelpCircleOutline, 29 | }; 30 | 31 | export default function GopherFolderRenderer(p: RendererProps) { 32 | const [content] = useFetchText(p.url, undefined, [p.timestamp]); 33 | const [filter, setFilter] = React.useState(''); 34 | 35 | const items = React.useMemo(() => { 36 | if (!content) return []; 37 | return Gopher.parse(content) 38 | .filter(item => !('.i37'.includes(item.type))) 39 | .map((item, i) => ({...item, id: i})) 40 | }, [content]); 41 | 42 | const filteredItems = React.useMemo(() => { 43 | return items.filter(item => textMatch(filter, item.label, item.url)); 44 | }, [items, filter]); 45 | 46 | const $scroller = useScrollRestoration(p.scroll, p.onScroll, [items]); 47 | 48 | return ( 49 | 50 | 51 | {filteredItems.map((item, i) => ( 52 | 53 | ))} 54 | 55 | ); 56 | } 57 | 58 | const Container = styled.div` 59 | width: 664px; 60 | height: 100%; 61 | padding: 24px 8px 0 24px; 62 | overflow: hidden scroll; 63 | `; 64 | 65 | 66 | export function Filter(p: { 67 | filter: string, 68 | onChangeFilter: (value: string) => void, 69 | }) { 70 | const onChangeFilter = React.useCallback((e: React.ChangeEvent) => { 71 | p.onChangeFilter((e.target as HTMLInputElement).value); 72 | }, [p.onChangeFilter]); 73 | 74 | return ( 75 | 76 | 77 | 78 | 81 | 82 | ); 83 | } 84 | 85 | const FilterSection = styled(Horizontal)` 86 | margin-top: -8px; 87 | margin-left: -8px; 88 | margin-right: 8px; 89 | margin-bottom: 24px; 90 | `; 91 | 92 | const NameFilter = styled.input` 93 | width: 250px; 94 | `; 95 | 96 | 97 | export function GopherItem(p: { 98 | item: Gopher.Item, 99 | linkTarget?: string, 100 | visitUrl: RendererProps['visitUrl'], 101 | }) { 102 | const {item} = p; 103 | const {type, label, url} = item; 104 | if (!type) return null; 105 | 106 | const Icon = ICON_MAP[type] ?? ICON_MAP['?']; 107 | 108 | return ( 109 | 110 | 111 | 112 | {label || ' '} 113 | 114 | 115 | ); 116 | } 117 | 118 | const ItemContainer = styled.div` 119 | display: inline-block; 120 | width: 142px; 121 | margin: 0 16px 24px 0; 122 | text-align: center; 123 | vertical-align: top; 124 | `; 125 | 126 | const ItemLink = styled.a` 127 | display: contents; 128 | text-decoration: none; 129 | color: inherit; 130 | `; 131 | 132 | const ItemIcon = styled.div` 133 | display: inline-block; 134 | width: auto; 135 | margin: 0 auto; 136 | color: #0366d6; 137 | `; 138 | 139 | const ItemTitle = styled.div` 140 | display: -webkit-box; 141 | -webkit-line-clamp: 2; 142 | -webkit-box-orient: vertical; 143 | overflow: hidden; 144 | width: auto; 145 | `; 146 | -------------------------------------------------------------------------------- /src/components/TabBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled, {keyframes} from 'styled-components'; 3 | import {SortableContainer, SortableElement} from 'react-sortable-hoc'; 4 | import * as Icons from 'react-icons/io' 5 | import {Tab} from 'core'; 6 | import {Horizontal} from './Layout'; 7 | import Button from './Button'; 8 | 9 | export interface TabBarTab { 10 | id: string, 11 | icon: string, 12 | title: string, 13 | } 14 | 15 | export default function TabBar(p: { 16 | platform: string, 17 | tabs: TabBarTab[], 18 | selectedTabId: string, 19 | selectTab(tabId: string): void, 20 | createTab(): void, 21 | closeTab(tabId: string): void, 22 | reorderTab(p: any): void, 23 | }) { 24 | const createTab = React.useCallback(() => { 25 | p.createTab(); 26 | }, [p.createTab]); 27 | 28 | return 29 | 37 | 38 | 39 | 40 | 41 | ; 42 | } 43 | 44 | const SortableTabs = SortableContainer((p: { 45 | platform: string, 46 | tabs: TabBarTab[], 47 | selectedTabId: string, 48 | selectTab(tabId: string): void, 49 | createTab(): void, 50 | closeTab(tabId: string): void, 51 | }) => { 52 | const {tabs, selectedTabId} = p; 53 | 54 | const $container = React.useRef(null); 55 | 56 | React.useLayoutEffect(() => { 57 | if (!tabs.length || !$container.current) return; 58 | const index = tabs.findIndex(t => t.id === selectedTabId); 59 | // REFACTOR: Smells like hack 60 | setTimeout( 61 | () => $container.current!.children[index].scrollIntoView({behavior:'smooth'}), 62 | 16 63 | ); 64 | }, [tabs, selectedTabId]); 65 | 66 | return 67 | {tabs.map((tab, i) => ( 68 | 69 | ))} 70 | ; 71 | }); 72 | 73 | const SortableTab = SortableElement((p: { 74 | tab: TabBarTab, 75 | selectedTabId: string, 76 | selectTab(tabId: string): void, 77 | createTab(): void, 78 | closeTab(tabId: string): void, 79 | }) => { 80 | const {tab, selectedTabId} = p; 81 | // @ts-ignore indexing 82 | const Icon = tab.icon === 'LoadingIcon' ? LoadingIcon : Icons[tab.icon]; 83 | 84 | return ( 85 | p.selectTab(tab.id)}> 86 | {Icon? : null} 87 | {tab.title} 88 | {p.closeTab(tab.id); e.stopPropagation()}}/> 89 | 90 | ); 91 | }); 92 | 93 | const Container = styled(Horizontal)<{ 94 | platform: string, 95 | }>` 96 | -webkit-app-region: drag; 97 | overflow: hidden; 98 | background: #f0f0f0; 99 | box-shadow: inset 0 -1px 0 #ddd; 100 | 101 | padding: 0 16px; 102 | padding-left: ${p => p.platform === 'darwin' ? '80px' : '16px'}; 103 | 104 | height: ${p => p.platform === 'darwin' ? '38px' : '30px'}; 105 | `; 106 | 107 | const TabContainer = styled(Horizontal)` 108 | flex: 0 1 auto; 109 | overflow-x: auto; 110 | 111 | ::-webkit-scrollbar { 112 | height: 0; 113 | background: transparent; 114 | } 115 | `; 116 | 117 | const Tab = styled(Horizontal)<{ 118 | selected?: boolean, 119 | }>` 120 | -webkit-app-region: no-drag; 121 | z-index: 2; 122 | align-items: center; 123 | padding: 0 6px 0 12px; 124 | color: ${p => p.selected? 'inherit' : '#aaa'}; 125 | background: ${p => p.selected? 'white' : 'transparent'}; 126 | border-left: solid thin transparent; 127 | border-right: solid thin transparent; 128 | border-color: ${p => p.selected? '#ddd' : 'transparent'}; 129 | `; 130 | 131 | const TabTitle = styled.div` 132 | display: inline-block; 133 | width: 150px; 134 | overflow: hidden; 135 | white-space: nowrap; 136 | text-overflow: ellipsis; 137 | margin: 0 8px; 138 | `; 139 | 140 | const ToolbarButton = styled(Button)` 141 | align-self: center; 142 | height: 30px; 143 | `; 144 | 145 | const spin = keyframes` 146 | from {transform: rotate(0deg)} 147 | to {transform: rotate(360deg)} 148 | `; 149 | 150 | const LoadingIcon = styled(Icons.IoIosSync)` 151 | animation: ${spin} 1s linear infinite; 152 | `; 153 | -------------------------------------------------------------------------------- /src/renderers/GopherRenderer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as Icons from 'react-icons/io'; 3 | import styled from 'styled-components'; 4 | import * as Gopher from 'gopher'; 5 | import Bag from 'utils/Bag'; 6 | import {useFetchText} from 'utils/useFetch'; 7 | import visitModeFromEvent from 'utils/visitModeFromEvent'; 8 | import {useScrollRestoration} from 'utils/useScrollRestoration'; 9 | import {RendererProps} from './Renderer'; 10 | 11 | 12 | const ICON_MAP: Bag> = { 13 | '0': Icons.IoIosDocument, 14 | '1': Icons.IoIosFolder, 15 | '2': Icons.IoIosDesktop, 16 | '3': Icons.IoIosCloseCircle, 17 | '4': Icons.IoIosArchive, 18 | '5': Icons.IoIosArchive, 19 | '6': Icons.IoIosArchive, 20 | '7': Icons.IoIosSearch, 21 | '8': Icons.IoIosDesktop, 22 | '9': Icons.IoIosArchive, 23 | 'd': Icons.IoIosDocument, 24 | 'g': Icons.IoIosImage, 25 | 'h': Icons.IoIosGlobe, 26 | 'I': Icons.IoIosImage, 27 | 'j': Icons.IoIosImage, 28 | 'p': Icons.IoIosImage, 29 | '?': Icons.IoIosHelpCircleOutline, 30 | }; 31 | 32 | export default function GopherRenderer(p: RendererProps) { 33 | const [content] = useFetchText(p.url, undefined, [p.timestamp]); 34 | 35 | const items = React.useMemo(() => { 36 | let items = Gopher.parse(content); 37 | // Collapse sequential texts together 38 | return items.reduce((items: Gopher.Item[], item) => { 39 | const lastItem = items[items.length -1]; 40 | if (item.type === 'i' && lastItem?.type === 'i') { 41 | lastItem.label += `\n${item.label}`; 42 | } else { 43 | items.push(item); 44 | } 45 | return items; 46 | }, []); 47 | }, [content]); 48 | 49 | const $scroller = useScrollRestoration(p.scroll, p.onScroll, [items]); 50 | 51 | return ( 52 | 53 | {items.map((item, i) => ( 54 | 55 | ))} 56 | 57 | ); 58 | } 59 | 60 | const Container = styled.div` 61 | min-width: 664px; 62 | height: 100%; 63 | padding: 24px; 64 | overflow: hidden scroll; 65 | font-family: "SF Mono", Menlo, Monaco, monospace; 66 | font-size: 12px; 67 | `; 68 | 69 | 70 | export function GopherItem(p: { 71 | item: Gopher.Item, 72 | linkTarget?: string, 73 | visitUrl: RendererProps['visitUrl'], 74 | }) { 75 | const {item, visitUrl} = p; 76 | const {type, label, url} = item; 77 | if (type == null || type === '.') return null; 78 | 79 | const Icon = ICON_MAP[type]; 80 | const isLinked = !('i37'.includes(type)); 81 | const isSearch = (type === '7'); 82 | 83 | const search = React.useCallback((e) => { 84 | if (e.key !== 'Enter') return; 85 | const query = (e.target as HTMLInputElement).value.trim(); 86 | if (query) visitUrl(`${url}%09${query}`, visitModeFromEvent(e)); 87 | }, [visitUrl, url]); 88 | 89 | let content = 90 | {Icon? : null} 91 | {isSearch ? ( 92 | 93 | ) : ( 94 | {label || ' '} 95 | )} 96 | ; 97 | 98 | if (isLinked && !isSearch) { 99 | content = {content}; 100 | } 101 | 102 | return content; 103 | } 104 | 105 | 106 | const Line = styled.div` 107 | position: relative; 108 | margin: 0 auto; 109 | width: 616px; 110 | padding: 12px 48px; 111 | border-radius: 8px; 112 | line-height: 1; 113 | 114 | &:not([data-link="true"]) { 115 | user-select: text; 116 | } 117 | 118 | &[data-link="true"] { 119 | cursor: pointer; 120 | color: #0366d6; 121 | &:hover {background: #EAF1F6} 122 | } 123 | 124 | &[data-type="i"] { 125 | padding: 8px 48px; 126 | } 127 | `; 128 | 129 | const LineIcon = styled.div` 130 | position: absolute; 131 | top: 8px; 132 | left: 16px; 133 | color: inherit; 134 | `; 135 | 136 | const LineTitle = styled.div` 137 | white-space: pre-wrap; 138 | `; 139 | 140 | const LineLink = styled.a` 141 | display: contents; 142 | text-decoration: none; 143 | `; 144 | 145 | const LineSearchField = styled.input` 146 | width: 70%; 147 | margin: -7px 0 -6px; 148 | padding: 6px 8px 5px; 149 | border: solid thin #ddd; 150 | border-radius: 3px; 151 | background: white; 152 | box-shadow: inset 0 1px 2px rgba(0, 0, 0, .1); 153 | `; 154 | -------------------------------------------------------------------------------- /src/protocols/gopher.ts: -------------------------------------------------------------------------------- 1 | import Path from 'path'; 2 | import FS from 'fs-extra'; 3 | import Crypto from 'crypto'; 4 | import {app} from 'electron'; 5 | import intoStream from 'into-stream'; 6 | import ReadableStreamClone from 'readable-stream-clone' 7 | import * as Gopher from 'gopher'; 8 | import {withState, isSearchUrl} from 'core'; 9 | import {Bookmark} from 'core/bookmarks'; 10 | import {Recent} from 'core/recents'; 11 | 12 | 13 | const CACHE_DIR = Path.join( 14 | app.getPath('cache'), 15 | 'electron-gopher', 16 | ); 17 | 18 | FS.ensureDir(CACHE_DIR); 19 | 20 | export const gopherProtocolScheme = { 21 | scheme: 'gopher', 22 | privileges: { 23 | standard: true, 24 | supportFetchAPI: true, 25 | }, 26 | }; 27 | 28 | export async function gopherProtocolHandler( 29 | request: Electron.Request, 30 | callback: (stream: Electron.StreamProtocolResponse) => void, 31 | ) { 32 | const {url} = request; 33 | const maxAge = getRequestMaxCacheAge(request); 34 | 35 | if (url.startsWith('gopher://start')) { 36 | return callback(streamResponse(requestStartPage())); 37 | } else if (url.startsWith('gopher://test')) { 38 | return callback(streamResponse(requestTestPage(url))); 39 | } else if (!await shouldRequestFresh(url, maxAge)) { 40 | const filename = getFilenameHash(url); 41 | callback(streamResponse(FS.createReadStream(filename))); 42 | } else { 43 | const filename = getFilenameHash(url); 44 | const gopherRequest = Gopher.request(url); 45 | const cacheOutput = new ReadableStreamClone(gopherRequest); 46 | const responseOutput = new ReadableStreamClone(gopherRequest); 47 | const cacheFile = FS.createWriteStream(filename); 48 | cacheOutput.pipe(cacheFile); 49 | callback(streamResponse(responseOutput)); 50 | } 51 | } 52 | 53 | export function streamResponse(stream: NodeJS.ReadableStream): Electron.StreamProtocolResponse { 54 | return { 55 | statusCode: 200, 56 | headers: {'Cache-Control':'no-store'}, 57 | data: stream, 58 | }; 59 | } 60 | 61 | export function clearCachedURL(url: string) { 62 | // Sync to be atomic. Hopefully not expensive 63 | const filename = getFilenameHash(url); 64 | try {FS.removeSync(filename)} finally {}; 65 | } 66 | 67 | export async function clearCache() { 68 | // Sync to be atomic. Hopefully not expensive 69 | try {FS.removeSync(CACHE_DIR)} finally {}; 70 | FS.ensureDirSync(CACHE_DIR); 71 | } 72 | 73 | export function getFilenameHash(url: string) { 74 | const parsedUrl = Gopher.parseGopherUrl(url); 75 | const extname = Path.extname(parsedUrl.pathname) || '.gopher'; 76 | const fileId = Crypto.createHash('sha256').update(url).digest('hex'); 77 | return Path.join(CACHE_DIR, `${fileId}${extname}`); 78 | } 79 | 80 | export function getRequestMaxCacheAge(request: Electron.Request) { 81 | const cache = request.headers['Cache-Control']; 82 | 83 | if (!cache) { 84 | return Infinity; 85 | } else if (cache.includes('no-cache')) { 86 | return 0; 87 | } else if (cache.includes('max-age')) { 88 | const match = cache.match(/max-age=(\d+)/); 89 | return parseInt(match?.[1] ?? '0'); 90 | } else { 91 | return Infinity; 92 | } 93 | } 94 | 95 | export async function shouldRequestFresh(url: string, maxAge: number) { 96 | if (maxAge === 0) return true; 97 | 98 | // FIXME feels wrong to do this here 99 | if (isSearchUrl(url)) return true; // Search URL 100 | 101 | try { 102 | const filename = getFilenameHash(url); 103 | const stat = await FS.stat(filename); 104 | return (Date.now() - stat.mtimeMs) > (maxAge * 1000); 105 | } catch (err) { 106 | if (err.code == 'ENOENT') return true; 107 | else throw err; 108 | } 109 | } 110 | 111 | export function requestTestPage(url: string) { 112 | return intoStream([ 113 | `[Link](gopher://bitreich.org)`, 114 | ].join('\n')); 115 | } 116 | 117 | export function requestStartPage() { 118 | function renderItem(item: Bookmark | Recent) { 119 | const {type, title, url} = item; 120 | const {hostname, port, path, query} = Gopher.parseGopherUrl(url); 121 | return `${type}${title}\t${path}${query?`%09${query}`:''}\t${hostname}\t${port}`; 122 | } 123 | 124 | const bookmarks = withState((state) => { 125 | return Object.values(state.bookmarks).map(renderItem); 126 | }); 127 | 128 | const recents = withState((state) => { 129 | return Object.values(state.recents) 130 | .sort((a, b) => b.timestamp - a.timestamp) 131 | .map(renderItem); 132 | }); 133 | 134 | const data = [ 135 | `iSearch with Veronica-2\t\t\t`, 136 | `7Search\t/v2/vs\tgopher.floodgap.com\t70`, 137 | `i\t\t\t`, 138 | `iGopher Communities\t\t\t`, 139 | ...bookmarks, 140 | `i\t\t\t`, 141 | `iRecently visited\t\t\t`, 142 | ...recents, 143 | `i\t\t\t`, 144 | `iYou're using the Unnamed Gopher Client\t\t\t`, 145 | `hgithub.com/zenoamaro/unnamed-gopher-client\tURL:https://github.com/zenoamaro/unnamed-gopher-client\t\t` 146 | ].join('\n'); 147 | 148 | return intoStream(data); 149 | } 150 | --------------------------------------------------------------------------------