├── style ├── app.less ├── irc-view.less ├── base.less ├── topic-view.less ├── main.less ├── common.less ├── scrollbar.less ├── input-box.less ├── tab-nav.less ├── name-view.less ├── buffer-view.less ├── select.less ├── font.less ├── log-content.less └── server-form.less ├── config ├── defaults.yml ├── app.yml └── keys.yml ├── resource ├── doc │ └── logo.png ├── image │ └── logo.png └── font │ ├── Lato-Bold.ttf │ ├── JKG-Regular.ttf │ ├── Lato-Italic.ttf │ ├── Lato-Light.ttf │ ├── Lato-Regular.ttf │ ├── Lato-BoldItalic.ttf │ └── Lato-LightItalic.ttf ├── main.js ├── lib.d ├── perfect-scrollbar │ └── perfect-scrollbar.d.ts ├── irc │ └── irc.d.ts ├── default │ └── default.d.ts ├── electron │ └── electron.d.ts ├── koko │ └── koko.d.ts ├── js-yaml │ └── js-yaml.d.ts ├── moment │ └── moment.d.ts └── react │ └── react.d.ts ├── renderer ├── lib │ ├── notification.ts │ ├── id-generator.ts │ ├── topic.ts │ ├── ipc.ts │ ├── image.ts │ ├── configuration.ts │ ├── react-component.ts │ ├── app-error-handler.ts │ ├── autocompleter.ts │ ├── input-history.ts │ ├── name.ts │ ├── irc-color.ts │ ├── log.tsx │ ├── channel.ts │ └── shortcut-manager.ts ├── tsconfig.json ├── lib.d.ts ├── tab-nav.tsx ├── name-view.tsx ├── topic-view.tsx ├── app.tsx ├── select.tsx ├── server-form.tsx ├── buffer-view.tsx ├── input-box.tsx ├── log-content.tsx └── irc-view.tsx ├── browser ├── tsconfig.json ├── lib.d.ts ├── ipc.ts ├── app.ts ├── configuration.ts ├── irc-window.ts ├── menu.ts ├── irc-command.ts └── irc.ts ├── index.html ├── .travis.yml ├── doc ├── SCREENSHOTS.md ├── CONFIGURATION.md ├── CONTRIBUTION.md └── USERGUIDE.md ├── .gitignore ├── package.json ├── LICENSE ├── CHANGELOG.md ├── README.md └── Makefile /style/app.less: -------------------------------------------------------------------------------- 1 | #app { 2 | height: 100%; 3 | } 4 | -------------------------------------------------------------------------------- /config/defaults.yml: -------------------------------------------------------------------------------- 1 | server: 2 | port: '6667' 3 | encoding: 'UTF-8' 4 | -------------------------------------------------------------------------------- /resource/doc/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KokoIRC/koko/HEAD/resource/doc/logo.png -------------------------------------------------------------------------------- /resource/image/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KokoIRC/koko/HEAD/resource/image/logo.png -------------------------------------------------------------------------------- /resource/font/Lato-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KokoIRC/koko/HEAD/resource/font/Lato-Bold.ttf -------------------------------------------------------------------------------- /resource/font/JKG-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KokoIRC/koko/HEAD/resource/font/JKG-Regular.ttf -------------------------------------------------------------------------------- /resource/font/Lato-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KokoIRC/koko/HEAD/resource/font/Lato-Italic.ttf -------------------------------------------------------------------------------- /resource/font/Lato-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KokoIRC/koko/HEAD/resource/font/Lato-Light.ttf -------------------------------------------------------------------------------- /resource/font/Lato-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KokoIRC/koko/HEAD/resource/font/Lato-Regular.ttf -------------------------------------------------------------------------------- /resource/font/Lato-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KokoIRC/koko/HEAD/resource/font/Lato-BoldItalic.ttf -------------------------------------------------------------------------------- /resource/font/Lato-LightItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KokoIRC/koko/HEAD/resource/font/Lato-LightItalic.ttf -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | var browserApp = require('./build/browser/app'); 2 | 3 | browserApp.run('file://' + __dirname + '/index.html'); 4 | -------------------------------------------------------------------------------- /config/app.yml: -------------------------------------------------------------------------------- 1 | scrollback-limit: 1000 2 | input-history-limit: 100 3 | shortcut-serial-input-timeout: 800 4 | command-symbol: '/' 5 | root-channel-name: '~' 6 | -------------------------------------------------------------------------------- /lib.d/perfect-scrollbar/perfect-scrollbar.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'perfect-scrollbar' { 2 | export function initialize(element: HTMLElement); 3 | export function update(element: HTMLElement); 4 | } 5 | -------------------------------------------------------------------------------- /renderer/lib/notification.ts: -------------------------------------------------------------------------------- 1 | export function show(target: string, nick: string, body: string) { 2 | let title = target === nick ? nick : `${nick} in ${target}`; 3 | new Notification(title, {title, body}); 4 | } 5 | -------------------------------------------------------------------------------- /style/irc-view.less: -------------------------------------------------------------------------------- 1 | #irc-view { 2 | padding: 20px 0 25px; 3 | box-sizing: border-box; 4 | position: relative; 5 | height: 100%; 6 | 7 | &.with-topic { 8 | padding-top: 40px; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /browser/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "removeComments": true, 5 | "preserveConstEnums": true, 6 | "outDir": "../build/browser", 7 | "sourceMap": true, 8 | "target": "ES5" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /renderer/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "removeComments": true, 5 | "preserveConstEnums": true, 6 | "outDir": "../build/renderer", 7 | "sourceMap": true, 8 | "jsx": "react", 9 | "target": "ES5" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /renderer/lib/id-generator.ts: -------------------------------------------------------------------------------- 1 | import _ = require('underscore'); 2 | 3 | let ids: IDict = {}; 4 | 5 | function generate(category: string) { 6 | if (_.isUndefined(ids[category])) { 7 | ids[category] = 0; 8 | } 9 | ids[category] += 1; 10 | return ids[category]; 11 | } 12 | 13 | export = generate; 14 | -------------------------------------------------------------------------------- /renderer/lib/topic.ts: -------------------------------------------------------------------------------- 1 | class Topic { 2 | text: string; 3 | by: string; 4 | constructor(text: string, by?: string) { 5 | this.text = text; 6 | this.by = by; 7 | } 8 | 9 | get fullText() { 10 | return this.by ? `"${this.text}" set by ${this.by}` : this.text; 11 | } 12 | } 13 | 14 | export = Topic; 15 | -------------------------------------------------------------------------------- /style/base.less: -------------------------------------------------------------------------------- 1 | @import (reference) "common"; 2 | 3 | input:focus, 4 | select:focus, 5 | textarea:focus, 6 | button:focus { 7 | outline: none; 8 | } 9 | body { 10 | font-family: 'Lato', sans-serif; 11 | background-color: @background; 12 | font-weight: 300; 13 | } 14 | a, a:visited { 15 | color: @blue; 16 | } 17 | -------------------------------------------------------------------------------- /renderer/lib/ipc.ts: -------------------------------------------------------------------------------- 1 | let ipc = _require('ipc'); 2 | 3 | export = { 4 | on(eventName: string, handler: IJsonCallback) { 5 | ipc.on(eventName, function (arg: string) { 6 | handler(JSON.parse(arg)); 7 | }); 8 | }, 9 | 10 | send(eventName: string, dataObj: any) { 11 | ipc.send(eventName, JSON.stringify(dataObj)); 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /config/keys.yml: -------------------------------------------------------------------------------- 1 | message: ["i"] 2 | command: ["/"] 3 | exit: ["esc", "ctrl+c"] 4 | next-tab: ["g>t"] 5 | previous-tab: ["g>T"] 6 | scroll-down: ["j"] 7 | scroll-up: ["k"] 8 | scroll-top: ["g>g"] 9 | scroll-bottom: ["G"] 10 | page-down: ["ctrl+f"] 11 | page-up: ["ctrl+b"] 12 | input-history-back: ["up"] 13 | input-history-forward: ["down"] 14 | autocomplete: ["tab"] 15 | -------------------------------------------------------------------------------- /browser/lib.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | /// 5 | /// 6 | /// 7 | /// 8 | -------------------------------------------------------------------------------- /style/topic-view.less: -------------------------------------------------------------------------------- 1 | @import (reference) "common"; 2 | 3 | #topic-view { 4 | position: fixed; 5 | top: 20px; 6 | left: 0; 7 | background-color: @background; 8 | height: 20px; 9 | line-height: 20px; 10 | width: 100%; 11 | font-size: 14px; 12 | padding: 0 3px; 13 | overflow: hidden; 14 | white-space: nowrap; 15 | text-overflow: ellipsis; 16 | box-shadow: 0 5px 10px -5px @lightshadow; 17 | } 18 | -------------------------------------------------------------------------------- /style/main.less: -------------------------------------------------------------------------------- 1 | @import 'font'; 2 | @import 'base'; 3 | 4 | html, body { 5 | height: 100%; 6 | overflow: hidden; 7 | } 8 | 9 | // custom elements 10 | @import 'select'; 11 | 12 | // app 13 | @import 'app'; 14 | @import 'server-form'; 15 | @import 'irc-view'; 16 | @import 'tab-nav'; 17 | @import 'name-view'; 18 | @import 'buffer-view'; 19 | @import 'input-box'; 20 | @import 'scrollbar'; 21 | @import 'topic-view'; 22 | -------------------------------------------------------------------------------- /style/common.less: -------------------------------------------------------------------------------- 1 | @gray: #999; 2 | @lightblack: #555; 3 | @lightgray: #ccc; 4 | @blue: #0074D9; 5 | @green: #3D9970; 6 | @red: #FF4136; 7 | @magenta: #F012BE; 8 | @orange: #FF851B; 9 | @yellow: #FFDC00; 10 | @cyan: #39CCCC; 11 | @white: white; 12 | 13 | // campfire 14 | @cf-green: #599c7e; 15 | @cf-yellow: #f2e394; 16 | @cf-orange: #f2ae72; 17 | @cf-red: #d96459; 18 | @cf-darkred: #8c4646; 19 | 20 | @background: #f3f3f3; 21 | @lightshadow: #dedede; 22 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | koko 6 | 7 | 8 | 9 | 10 |
11 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /style/scrollbar.less: -------------------------------------------------------------------------------- 1 | @import (reference) "common"; 2 | 3 | ::-webkit-scrollbar { 4 | width: 3px; 5 | height: 9; 6 | } 7 | ::-webkit-scrollbar-button { 8 | width: 0; 9 | height: 0; 10 | } 11 | ::-webkit-scrollbar-thumb { 12 | background: @lightgray; 13 | border: 0px none @white; 14 | } 15 | ::-webkit-scrollbar-track { 16 | background: transparent; 17 | border: 0px none @white; 18 | } 19 | ::-webkit-scrollbar-corner { 20 | background: transparent; 21 | } 22 | -------------------------------------------------------------------------------- /renderer/lib/image.ts: -------------------------------------------------------------------------------- 1 | interface ImageSizeCallback { 2 | (width: number, height: number): void; 3 | } 4 | 5 | export = { 6 | getMeta(src: string, onload: ImageSizeCallback, onerror: (e) => void) { 7 | let img = new Image(); 8 | img.src = src; 9 | img.onload = function () { 10 | let width = parseInt(this.width, 10); 11 | let height = parseInt(this.height, 10); 12 | onload(width, height); 13 | }; 14 | img.onerror = onerror; 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /renderer/lib/configuration.ts: -------------------------------------------------------------------------------- 1 | let configs: any = _require('remote').getGlobal('configuration'); 2 | 3 | export = { 4 | setConfigs(loadedConfigs: any) { 5 | configs = loadedConfigs; 6 | }, 7 | getConfig(configName: string): any { 8 | return configs[configName]; 9 | }, 10 | get(configName: string, fieldName: string): any { 11 | let config = this.getConfig(configName); 12 | if (config) { 13 | return config[fieldName]; 14 | } else { 15 | return undefined; 16 | } 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /lib.d/irc/irc.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'irc' { 2 | import net = require("net"); 3 | 4 | export class Client { 5 | _nick: string; 6 | constructor(server: string, nick: string, opt: any); 7 | connect(); 8 | disconnect(message?: string); 9 | on(eventName: string, handler: (...args: any[]) => void); 10 | say(target: string, text: string); 11 | conn: net.Socket; 12 | } 13 | 14 | interface Colors { 15 | codes: {[colorName: string]: string}; 16 | } 17 | 18 | export var colors: Colors; 19 | } 20 | -------------------------------------------------------------------------------- /renderer/lib.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | /// 5 | /// 6 | /// 7 | /// 8 | /// 9 | /// 10 | -------------------------------------------------------------------------------- /style/input-box.less: -------------------------------------------------------------------------------- 1 | @import (reference) "common"; 2 | 3 | #input-box { 4 | position: fixed; 5 | left: 0; 6 | bottom: 0; 7 | width: 100%; 8 | height: 25px; 9 | box-shadow: 0 0 10px @lightshadow; 10 | 11 | form { 12 | display: block; 13 | height: 100%; 14 | 15 | input { 16 | display: block; 17 | height: 100%; 18 | width: 100%; 19 | margin: 0; 20 | border: none; 21 | box-sizing: border-box; 22 | padding: 1px 5px; 23 | font-size: 14px; 24 | line-height: 100%; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /browser/ipc.ts: -------------------------------------------------------------------------------- 1 | import ipc = require('ipc'); 2 | 3 | class Ipc { 4 | _webContents: WebContents; 5 | 6 | constructor(w: BrowserWindow) { 7 | this._webContents = w.webContents; 8 | } 9 | 10 | on(eventName: string, handler: IJsonCallback) { 11 | ipc.on(eventName, (event , arg) => { 12 | if (event.sender === this._webContents) { 13 | handler(JSON.parse(arg)); 14 | } 15 | }); 16 | } 17 | 18 | send(eventName: string, dataObj: any) { 19 | this._webContents.send(eventName, JSON.stringify(dataObj)); 20 | } 21 | } 22 | 23 | export = Ipc; 24 | -------------------------------------------------------------------------------- /lib.d/default/default.d.ts: -------------------------------------------------------------------------------- 1 | interface KeyboardEvent { 2 | keyIdentifier: string; 3 | } 4 | 5 | interface String { 6 | startsWith(needle: string): boolean; 7 | endsWith(needle: string): boolean; 8 | includes(needle: string): boolean; 9 | repeat(times: number): string; 10 | } 11 | 12 | interface Error { 13 | stack?: string[]; 14 | } 15 | 16 | interface ErrorConstructor { 17 | captureStackTrace: (_this: any, _constructor: any) => void; 18 | } 19 | 20 | declare class Notification { 21 | constructor(title: string, option: { 22 | title: string, 23 | body: string 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /renderer/lib/react-component.ts: -------------------------------------------------------------------------------- 1 | import React = require('react'); 2 | 3 | class ReactComponent extends React.Component { 4 | constructor(props?: Props) { 5 | super(props); 6 | this.state = this.initialState(); 7 | this.bindThisToMethods(); 8 | } 9 | 10 | initialState(): States { 11 | return {} as States; 12 | } 13 | 14 | bindThisToMethods() { 15 | Object.getOwnPropertyNames(this.constructor.prototype) 16 | .filter(key => typeof this[key] === 'function') 17 | .forEach(key => this[key] = this[key].bind(this)); 18 | } 19 | } 20 | 21 | export = ReactComponent; 22 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '0.10' 4 | deploy: 5 | provider: releases 6 | api_key: 7 | secure: GNtVxookccXE3FNDWAO3uONRSyvTK6yMhCJlWNhumAQNuQVRyE+Kv3XoWDYONAKVdxTTjdtG4Wu+Lj8onAyNrhg6FRwpGx7uw/M3E1lvE6QRCbPKMS6gl0SpJuqwbu++aEh1ErRbgC8+R+yGf313iJX51tT3GBilPznjmpExZ9k= 8 | file: 9 | - "./build/koko-mac.zip" 10 | - "./build/koko-win-32.zip" 11 | - "./build/koko-win-64.zip" 12 | on: 13 | repo: noraesae/koko 14 | branch: master 15 | tags: true 16 | before_deploy: 17 | - make 18 | - make package 19 | - cd build 20 | - zip -r --symlinks koko-mac Koko.app 21 | - zip -r koko-win-32 win32 22 | - zip -r koko-win-64 win64 23 | - cd .. 24 | -------------------------------------------------------------------------------- /style/tab-nav.less: -------------------------------------------------------------------------------- 1 | @import (reference) "common"; 2 | 3 | #tab-nav { 4 | position: fixed; 5 | top: 0; 6 | left: 0; 7 | width: 100%; 8 | height: 20px; 9 | background-color: @white; 10 | line-height: 18px; 11 | box-shadow: 0 0 10px @lightshadow; 12 | 13 | ul { 14 | display: block; 15 | padding: 0; 16 | margin: 0; 17 | 18 | li { 19 | display: inline-block; 20 | padding: 0 10px; 21 | margin: 0; 22 | height: 20px; 23 | color: @lightgray; 24 | 25 | &.current { 26 | background-color: @background; 27 | color: @cf-green; 28 | } 29 | &.unread { 30 | color: @cf-red; 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /style/name-view.less: -------------------------------------------------------------------------------- 1 | @import (reference) "common"; 2 | 3 | #name-view { 4 | float: right; 5 | width: 120px; 6 | text-align: left; 7 | height: 100%; 8 | overflow-y: auto; 9 | overflow-x: hidden; 10 | background-color: @white; 11 | 12 | ul { 13 | margin: 0; 14 | padding: 0; 15 | 16 | li { 17 | list-style-type: none; 18 | font-size: 13px; 19 | height: 17px; 20 | line-height: 17px; 21 | padding: 1px 0 1px 5px; 22 | cursor: default; 23 | 24 | .mode { 25 | color: @cf-green; 26 | } 27 | 28 | &.me .nick { 29 | color: @cf-orange; 30 | } 31 | 32 | &:hover { 33 | background-color: @background; 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /doc/SCREENSHOTS.md: -------------------------------------------------------------------------------- 1 | # Screenshots 2 | 3 | Here are some screenshots selling `ココ`. 4 | 5 | ## Server form 6 | ![server form](https://cloud.githubusercontent.com/assets/499192/7789162/85ebe892-0257-11e5-93a4-f328b2b447a3.png) 7 | 8 | ## IRC window 9 | ![IRC window](https://cloud.githubusercontent.com/assets/499192/7789163/86044e28-0257-11e5-83e5-af8f17967ed7.png) 10 | 11 | ## Media 12 | ![media](https://cloud.githubusercontent.com/assets/499192/7789168/b234ef48-0257-11e5-89d4-a8723bf37f2a.png) 13 | 14 | ## I have better screenshots! 15 | 16 | Please... 17 | 18 | * Upload a [pull request](https://github.com/noraesae/koko/pulls) 19 | * Report in [Issues](https://github.com/noraesae/koko/issues) 20 | * Drop an email to [contributors](https://github.com/noraesae/koko/graphs/contributors) 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Commenting this out is preferred by some people, see 24 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 25 | node_modules 26 | 27 | # Users Environment Variables 28 | .lock-wscript 29 | 30 | # Built files 31 | /build 32 | /asar 33 | 34 | # Downloaded shell 35 | /shell 36 | -------------------------------------------------------------------------------- /renderer/tab-nav.tsx: -------------------------------------------------------------------------------- 1 | import Channel = require('./lib/channel'); 2 | import React = require('react'); 3 | import ReactComponent = require('./lib/react-component'); 4 | 5 | interface TabNavProps { 6 | channels: Channel[]; 7 | } 8 | 9 | class TabNav extends ReactComponent { 10 | render() { 11 | let tabs = this.props.channels.map(channel => { 12 | let className = ''; 13 | if (channel.current) { 14 | className += ' current'; 15 | } 16 | if (channel.unread) { 17 | className += ' unread'; 18 | } 19 | return
  • {channel.name}
  • ; 20 | }); 21 | 22 | return ( 23 |
    24 |
      {tabs}
    25 |
    26 | ); 27 | } 28 | } 29 | 30 | export = TabNav; 31 | -------------------------------------------------------------------------------- /renderer/lib/app-error-handler.ts: -------------------------------------------------------------------------------- 1 | interface AppErrorCallback { 2 | (error: Error): void; 3 | } 4 | 5 | interface AppErrorCallbacks { 6 | [type: string]: AppErrorCallback; 7 | } 8 | 9 | class AppErrorHandler { 10 | private _handlers: AppErrorCallbacks; 11 | 12 | constructor() { 13 | this._handlers = {}; 14 | } 15 | 16 | on(type: string, handler: AppErrorCallback) { 17 | this._handlers[type] = handler; 18 | } 19 | 20 | emit(type: string, data: Error) { 21 | let handler = this._handlers[type]; 22 | if (handler) { 23 | setTimeout(handler.bind(null, data), 0); 24 | } 25 | } 26 | 27 | handle(data: {type: string, error: Error}) { 28 | // FIXME 29 | console.error(data.error); 30 | this.emit(data.type, data.error); 31 | } 32 | } 33 | 34 | export = AppErrorHandler; 35 | -------------------------------------------------------------------------------- /browser/app.ts: -------------------------------------------------------------------------------- 1 | import app = require('app'); 2 | import configuration = require('./configuration'); 3 | import crashReporter = require('crash-reporter'); 4 | import IrcWindow = require('./irc-window'); 5 | import menu = require('./menu'); 6 | import os = require('os'); 7 | 8 | export function run(mainUrl: string) { 9 | crashReporter.start(); 10 | global['configuration'] = configuration.load(); 11 | 12 | function openNewWindow() { 13 | menu.initialize(app, mainUrl); 14 | IrcWindow.create(mainUrl); 15 | } 16 | 17 | app.on('ready', openNewWindow); 18 | app.on('activate-with-no-open-windows', openNewWindow); 19 | app.on('window-all-closed', function () { 20 | switch (os.platform()) { 21 | case 'darwin': 22 | // OS X: do nothing 23 | break; 24 | default: 25 | app.quit(); 26 | } 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /style/buffer-view.less: -------------------------------------------------------------------------------- 1 | @import (reference) "common"; 2 | 3 | #buffer-view { 4 | height: 100%; 5 | position: relative; 6 | overflow-y: auto; 7 | overflow-x: hidden; 8 | word-wrap: break-word; 9 | 10 | ul { 11 | margin: 0; 12 | padding: 0; 13 | 14 | li { 15 | padding: 2px 4px; 16 | font-size: 14px; 17 | 18 | .info { 19 | .nick { 20 | font-size: 15px; 21 | margin-right: 6px; 22 | font-weight: bold; 23 | } 24 | .datetime { 25 | color: @gray; 26 | font-size: 12px; 27 | } 28 | } 29 | @import "log-content"; 30 | &.adjacent { 31 | margin-top: -6px; 32 | .info { 33 | display: none; 34 | } 35 | } 36 | &.sent-by-me { 37 | .nick { 38 | color: @cf-orange; 39 | } 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /renderer/name-view.tsx: -------------------------------------------------------------------------------- 1 | import Name = require('./lib/name'); 2 | import React = require('react'); 3 | import ReactComponent = require('./lib/react-component'); 4 | 5 | interface NameViewProps { 6 | names: Name[]; 7 | } 8 | 9 | class NameView extends ReactComponent { 10 | render() { 11 | if (!this.props.names || this.props.names.length === 0) { 12 | return null; 13 | } 14 | 15 | return ( 16 |
    17 |
      {this.names()}
    18 |
    19 | ); 20 | } 21 | 22 | names() { 23 | return this.props.names.map(function (name) { 24 | let cls = name.isMe ? 'me' : ''; 25 | return ( 26 |
  • 27 | {name.mode} 28 | {name.nick} 29 |
  • 30 | ); 31 | }); 32 | } 33 | } 34 | 35 | export = NameView; 36 | -------------------------------------------------------------------------------- /style/select.less: -------------------------------------------------------------------------------- 1 | @import (reference) "common"; 2 | 3 | .select { 4 | border: 1px solid @lightgray; 5 | background-color: @white; 6 | position: relative; 7 | font-size: 18px; 8 | border-radius: 5px; 9 | padding: 3px 7px; 10 | cursor: pointer; 11 | 12 | .arrow { 13 | float: right; 14 | color: @lightgray; 15 | line-height: 20px; 16 | } 17 | 18 | .option-wrapper { 19 | display: none; 20 | position: absolute; 21 | top: -1px; 22 | left: -1px; 23 | width: 100%; 24 | background-color: @white; 25 | border: inherit; 26 | border-radius: inherit; 27 | overflow: hidden; 28 | 29 | .option { 30 | padding: 3px 7px; 31 | 32 | &:hover { 33 | background-color: @background; 34 | } 35 | &.current { 36 | color: @cf-green; 37 | } 38 | } 39 | } 40 | &.selecting { 41 | .option-wrapper { 42 | display: block; 43 | } 44 | } 45 | &:hover { 46 | background-color: @background; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "koko", 3 | "version": "0.2.1", 4 | "description": "Yet another IRC client for me and you", 5 | "author": "Jun ", 6 | "contributors": [ 7 | { 8 | "name": "Hyunje Alex Jun", 9 | "email": "me@noraesae.net" 10 | } 11 | ], 12 | "main": "main.js", 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/KokoIRC/koko.git" 16 | }, 17 | "bugs": { 18 | "url": "https://github.com/KokoIRC/koko/issues" 19 | }, 20 | "keywords": [ 21 | "irc", 22 | "electron" 23 | ], 24 | "license": "MIT", 25 | "dependencies": { 26 | "irc": "git://github.com/noraesae/irc", 27 | "js-yaml": "^3.3.1", 28 | "moment": "^2.10.3", 29 | "normalize.css": "^3.0.2", 30 | "react": "^0.13.3", 31 | "underscore": "^1.8.3" 32 | }, 33 | "devDependencies": { 34 | "asar": "^0.6.1", 35 | "browserify": "^9.0.3", 36 | "electron-prebuilt": "^0.31.2", 37 | "less": "^2.4.0", 38 | "typescript": "^1.6.0-dev.20150815" 39 | }, 40 | "scripts": { 41 | "test": "make" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib.d/electron/electron.d.ts: -------------------------------------------------------------------------------- 1 | declare class BrowserWindow { 2 | constructor(opt: any); 3 | loadUrl(url: string); 4 | on(eventName: string, callback: () => void); 5 | webContents: WebContents; 6 | toggleDevTools(): void; 7 | } 8 | 9 | declare module 'browser-window' { 10 | export = BrowserWindow; 11 | } 12 | 13 | interface WebContents { 14 | on(eventName: string, callback: (e: Event, url: string) => void); 15 | send(eventName: string, data: string); 16 | } 17 | 18 | declare module 'ipc' { 19 | export function on(eventName: string, callback: (event: {sender: WebContents}, args: string) => void); 20 | } 21 | 22 | declare module 'shell' { 23 | export function openExternal(url: string); 24 | } 25 | 26 | interface Menu { 27 | } 28 | 29 | declare module 'menu' { 30 | export function buildFromTemplate(template: any): Menu; 31 | export function setApplicationMenu(menu: Menu); 32 | } 33 | 34 | declare module 'app' { 35 | function on(eventName: string, callback: () => void); 36 | function getPath(pathName: string): string; 37 | function quit(); 38 | } 39 | 40 | declare module 'crash-reporter' { 41 | function start(); 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Jun 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.2.1 (10 November 2016) 2 | 3 | * Added password field for easy identification. 4 | * Koko -> koko 5 | 6 | # 0.2.0 (4 September 2015) 7 | 8 | * Move from TypedReact to native React support of TypeScript 9 | * Windows support 10 | * Fix typo and refactor code 11 | 12 | # 0.1.5 (1 June 2015) 13 | 14 | * Focus input onclick windows. 15 | * Add `quote` and `raw` commands. 16 | * Fix that messages containing spaces can't be fully parsed in commands. 17 | * Don't autoinsert `#` for optional channel parameters. 18 | 19 | # 0.1.4 (26 May 2015) 20 | 21 | * Format datetime again. 22 | * Bug fixes. 23 | * Regression bug from 0.1.3: wrong text content. 24 | 25 | # 0.1.3 (26 May 2015) 26 | 27 | * Highlight my nick in logs. 28 | * Blur input when blurring windows. 29 | * Fix slow log rendering. 30 | 31 | # 0.1.2 (25 May 2015) 32 | 33 | * Add notification. 34 | * Bug fixes. 35 | * `/part` in root channel. 36 | * `onChangeNick` didn't work. 37 | 38 | # 0.1.1 (25 May 2015) 39 | 40 | * Disconnect from server when closing window. 41 | * Don't create channel on join if it already exists. 42 | * koko -> Koko 43 | 44 | # 0.1.0 (24 May 2015) 45 | 46 | * Initial public release. 47 | -------------------------------------------------------------------------------- /renderer/topic-view.tsx: -------------------------------------------------------------------------------- 1 | import React = require('react'); 2 | import ReactComponent = require('./lib/react-component'); 3 | import Topic = require('./lib/topic'); 4 | 5 | interface TopicViewProps { 6 | topic: Topic; 7 | } 8 | 9 | const urlRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([^\s'"`]*)/; 10 | 11 | class TopicView extends ReactComponent { 12 | render() { 13 | if (!this.props.topic) { 14 | return null; 15 | } 16 | 17 | return ( 18 |
    19 | {this.topicElement()} 20 |
    21 | ); 22 | } 23 | 24 | topicElement(): React.ReactElement { 25 | let urlMatch = urlRegex.exec(this.props.topic.text); 26 | if (urlMatch) { 27 | let index = urlMatch.index; 28 | let url = urlMatch[0]; 29 | return ( 30 | 31 | {this.props.topic.text.substring(0, index)} 32 | {url} 33 | {this.props.topic.text.substring(index + url.length)} 34 | 35 | ); 36 | } else { 37 | return {this.props.topic.text}; 38 | } 39 | } 40 | } 41 | 42 | export = TopicView; 43 | -------------------------------------------------------------------------------- /renderer/lib/autocompleter.ts: -------------------------------------------------------------------------------- 1 | import _ = require('underscore'); 2 | 3 | class Autocompleter { 4 | private completeIdx: number; 5 | private wordsToComplete: string[]; 6 | private names: string[]; 7 | 8 | constructor() { 9 | this.completeIdx = -1; 10 | this.wordsToComplete = []; 11 | this.names = []; 12 | } 13 | 14 | setNames(names: string[]) { 15 | if (_.isArray(names)) { 16 | this.names = names; 17 | } 18 | } 19 | 20 | complete(word: string): string { 21 | if (this.wordsToComplete.length > 0 && this.completeIdx >= 0) { 22 | this.completeIdx = (this.completeIdx + 1) % this.wordsToComplete.length; 23 | return this.wordsToComplete[this.completeIdx]; 24 | } else { 25 | let properNames = this.names.filter(n => n.startsWith(word)); 26 | if (properNames.length === 0) { 27 | return null; 28 | } else if (properNames.length === 1) { 29 | return properNames[0]; 30 | } else { 31 | this.wordsToComplete = properNames; 32 | this.completeIdx = 0; 33 | return properNames[0]; 34 | } 35 | } 36 | } 37 | 38 | reset() { 39 | this.wordsToComplete = []; 40 | this.completeIdx = -1; 41 | } 42 | } 43 | 44 | export = Autocompleter; 45 | -------------------------------------------------------------------------------- /style/font.less: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Lato'; 3 | src: url(../resource/font/Lato-Regular.ttf) format('truetype'); 4 | font-weight: 400; 5 | font-style: normal; 6 | } 7 | 8 | @font-face { 9 | font-family: 'Lato'; 10 | src: url(../resource/font/Lato-Italic.ttf) format('truetype'); 11 | font-weight: 400; 12 | font-style: italic; 13 | } 14 | 15 | @font-face { 16 | font-family: 'Lato'; 17 | src: url(../resource/font/Lato-Bold.ttf) format('truetype'); 18 | font-weight: 700; 19 | font-style: normal; 20 | } 21 | 22 | @font-face { 23 | font-family: 'Lato'; 24 | src: url(../resource/font/Lato-BoldItalic.ttf) format('truetype'); 25 | font-weight: 700; 26 | font-style: italic; 27 | } 28 | 29 | @font-face { 30 | font-family: 'Lato'; 31 | src: url(../resource/font/Lato-Light.ttf) format('truetype'); 32 | font-weight: 300; 33 | font-style: normal; 34 | } 35 | 36 | @font-face { 37 | font-family: 'Lato'; 38 | src: url(../resource/font/Lato-LightItalic.ttf) format('truetype'); 39 | font-weight: 300; 40 | font-style: italic; 41 | } 42 | 43 | @font-face { 44 | font-family: 'JK Gothic'; 45 | src: url(../resource/font/JKG-Regular.ttf) format('truetype'); 46 | font-weight: 400; 47 | font-style: normal; 48 | } 49 | -------------------------------------------------------------------------------- /lib.d/koko/koko.d.ts: -------------------------------------------------------------------------------- 1 | interface IDict { 2 | [key: string]: T; 3 | } 4 | 5 | interface ICommandContext { 6 | target: string; 7 | } 8 | 9 | interface IIrcCommand { 10 | name: string; 11 | args: string[]; 12 | } 13 | 14 | interface IConnectionData { 15 | host: string; 16 | nick: string; 17 | username: string; 18 | password: string; 19 | realname: string; 20 | port: string; 21 | encoding: string; 22 | } 23 | 24 | interface IJsonCallback { 25 | (json: any): void; 26 | } 27 | 28 | declare function _require(moduleName: string): any; 29 | 30 | interface IIrcRawMessage { 31 | user: string; 32 | host: string; 33 | } 34 | 35 | interface IShortcutCallback { 36 | (): void; 37 | } 38 | 39 | interface IShortcutKeyInput { 40 | key: string; 41 | modifier: string; 42 | } 43 | 44 | interface IShortcutKeyConfig { 45 | action: string; 46 | shortcuts: (IShortcutKeyInput[])[]; 47 | } 48 | 49 | interface IModifierState { 50 | [mod: string]: boolean; 51 | alt: boolean; 52 | control: boolean; 53 | meta: boolean; 54 | shift: boolean; 55 | } 56 | 57 | interface IServerInterface { 58 | nick?: string; 59 | username?: string; 60 | password?: string; 61 | realname?: string; 62 | name: string; 63 | host: string; 64 | port?: string; 65 | encoding?: string; 66 | } 67 | 68 | interface IServerFormField { 69 | label: string, 70 | inputName: string, 71 | inputType?: string 72 | } 73 | -------------------------------------------------------------------------------- /renderer/app.tsx: -------------------------------------------------------------------------------- 1 | import AppErrorHandler = require('./lib/app-error-handler'); 2 | import ipc = require('./lib/ipc'); 3 | import IrcView = require('./irc-view'); 4 | import shortcut = require('./lib/shortcut-manager'); 5 | import React = require('react'); 6 | import ReactComponent = require('./lib/react-component'); 7 | import ServerForm = require('./server-form'); 8 | 9 | interface AppState { 10 | connected: boolean; 11 | title: string; 12 | } 13 | 14 | class App extends ReactComponent<{}, AppState> { 15 | errorHandler: AppErrorHandler; 16 | 17 | constructor() { 18 | super(); 19 | this.errorHandler = new AppErrorHandler(); 20 | shortcut.Manager.initialize(); 21 | } 22 | 23 | initialState(): AppState { 24 | return { 25 | connected: false, 26 | title: 'koko' 27 | }; 28 | } 29 | 30 | componentDidMount() { 31 | ipc.on('error', (data) => this.errorHandler.handle(data)); 32 | } 33 | 34 | connect(data) { 35 | this.setState({ 36 | connected: true, 37 | title: data.name || data.host 38 | }); 39 | } 40 | 41 | setWindowTitle() { 42 | let titleTag = document.querySelector('title'); 43 | titleTag.textContent = this.state.title; 44 | } 45 | 46 | render() { 47 | this.setWindowTitle(); 48 | return this.state.connected ? 49 | : 50 | ; 51 | } 52 | }; 53 | 54 | React.render(, 55 | document.getElementById('app')); 56 | -------------------------------------------------------------------------------- /browser/configuration.ts: -------------------------------------------------------------------------------- 1 | import _ = require('underscore'); 2 | import app = require('app'); 3 | import fs = require('fs'); 4 | import path = require('path'); 5 | import yaml = require('js-yaml'); 6 | 7 | const configDir = path.join(__dirname, '../config'); 8 | const userConfigPath = path.join(app.getPath('home'), '.koko.yml'); 9 | 10 | function customize(configs: any, userConfigs: any) { 11 | if (configs.constructor === Array) { 12 | return configs.concat(userConfigs); 13 | } else if (configs.constructor === Object) { 14 | if (userConfigs.constructor === Object) { 15 | Object.keys(userConfigs).forEach(key => { 16 | if (configs[key]) { 17 | configs[key] = customize(configs[key], userConfigs[key]); 18 | } else { 19 | configs[key] = userConfigs[key]; 20 | } 21 | }); 22 | } 23 | return configs; 24 | } else { 25 | return userConfigs; 26 | } 27 | } 28 | 29 | export function load(): any { 30 | let configs = fs.readdirSync(configDir) 31 | .filter(c => c.endsWith('.yml')) 32 | .reduce((result: any, c) => { 33 | let ymlContent = fs.readFileSync(path.join(configDir, c)).toString(); 34 | result[path.basename(c, path.extname(c))] = yaml.load(ymlContent); 35 | return result; 36 | }, {}); 37 | 38 | try { 39 | let userConfigs = yaml.load(fs.readFileSync(userConfigPath).toString()); 40 | return customize(configs, userConfigs); 41 | } catch (_) { 42 | // no or invalid user config 43 | return configs; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /renderer/lib/input-history.ts: -------------------------------------------------------------------------------- 1 | import _ = require('underscore'); 2 | import configuration = require('./configuration'); 3 | 4 | const inputHistoryLimit = configuration.get('app', 'input-history-limit'); 5 | 6 | class InputHistory { 7 | private _history: string[]; 8 | private _idx: number; 9 | private _tempInput: string; 10 | 11 | constructor() { 12 | this._history = []; 13 | this._idx = -1; // -1 is the initial index 14 | this._tempInput = ''; 15 | } 16 | 17 | index(): number { 18 | return this._idx; 19 | } 20 | 21 | moveIndex(diff: number) { 22 | let originalIdx = this._idx; 23 | this._idx += diff; 24 | if (this._idx < 0) { 25 | this._idx = -1; 26 | } else if (this._idx >= inputHistoryLimit) { 27 | this._idx = inputHistoryLimit - 1; 28 | } else if (_.isUndefined(this._history[this._idx])) { 29 | this._idx = originalIdx; // rollback 30 | } 31 | } 32 | 33 | currentText(): string { 34 | if (this._idx < 0) { 35 | return this._tempInput; 36 | } else { 37 | return this._history[this._idx]; 38 | } 39 | } 40 | 41 | setTempInput(tempInput: string) { 42 | this._tempInput = tempInput; 43 | } 44 | 45 | add(text: string) { 46 | this._history = [text].concat(this._history); 47 | if (this._history.length > inputHistoryLimit) { 48 | this._history = this._history.splice(0, inputHistoryLimit); 49 | } 50 | } 51 | 52 | reset() { 53 | this._tempInput = ''; 54 | this._idx = -1; 55 | } 56 | } 57 | 58 | export = InputHistory; 59 | -------------------------------------------------------------------------------- /style/log-content.less: -------------------------------------------------------------------------------- 1 | @import (reference) "common"; 2 | 3 | .text { 4 | padding: 2px 5px; 5 | color: @lightblack; 6 | .black { color: @lightblack; } 7 | .dark-blue, .light-blue { color: @blue; } 8 | .dark-green, .light-green { color: @green; } 9 | .dark-red, .light-red { color: @red; } 10 | .magenta, .light-magenta { color: @magenta; } 11 | .orange { color: @orange; } 12 | .yellow { color: @yellow; } 13 | .cyan, .light-cyan { color: @cyan; } 14 | .white, .light-gray { color: @lightgray; } 15 | .gray { color: @gray; } 16 | .bg-black { background-color: @lightblack; } 17 | .bg-dark-blue, .bg-light-blue { background-color: @blue; } 18 | .bg-dark-green, .bg-light-green { background-color: @green; } 19 | .bg-dark-red, .bg-light-red { background-color: @red; } 20 | .bg-magenta, .bg-light-magenta { background-color: @magenta; } 21 | .bg-orange { background-color: @orange; } 22 | .bg-yellow { background-color: @yellow; } 23 | .bg-cyan, .bg-light-cyan { background-color: @cyan; } 24 | .bg-white, .bg-light-gray { background-color: @lightgray; } 25 | .bg-gray { background-color: @gray; } 26 | .bold { font-weight: bold; } 27 | .underline { text-decoration: underline; } 28 | .reverse { color: @white; background-color: @lightblack; } 29 | 30 | .highlight { color: @red; } 31 | } 32 | 33 | .media { 34 | overflow: auto; 35 | a { 36 | display: block; 37 | margin: 0; 38 | padding: 4px; 39 | border: 1px solid @lightgray; 40 | float: left; 41 | border-radius: 5px; 42 | 43 | &:hover { 44 | border: 1px solid @blue; 45 | } 46 | } 47 | img { 48 | display: none; 49 | max-height: 150px; 50 | &.loaded { 51 | display: block; 52 | } 53 | } 54 | iframe { 55 | width: 356px; 56 | height: 200px; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![travis-ci](https://travis-ci.org/noraesae/koko.svg)](https://travis-ci.org/noraesae/koko) 2 | *** 3 | 4 | ![koko](./resource/doc/logo.png) 5 | 6 | ###### Yet another IRC client for me and you :koko: 7 | 8 | ##### [Screenshots](doc/SCREENSHOTS.md) | [Download](https://github.com/noraesae/koko/releases) | [User Guide](doc/USERGUIDE.md) | [Configuration](doc/CONFIGURATION.md) | [Contribution](doc/CONTRIBUTION.md) | [Issues](https://github.com/noraesae/koko/issues) 9 | 10 | *** 11 | 12 | # About `ココ` 13 | 14 | ## ⚠️ Sadly, this project is no longer being maintained. See similar project [Lax](https://github.com/brandly/Lax) 15 | 16 | `ココ` is pronounced `koko`, which means `here` in Japanese. 17 | 18 | `ココ` is an open source and free IRC client mainly for me and hopefully for others. 19 | 20 | It provides minimalistic UI and some VIM-like shortcuts. It depends on the power of 21 | HTML5, CSS and JavaScript through [Electron](http://electron.atom.io). 22 | 23 | The logo is strongly inspired by 24 | [Campfire](https://color.adobe.com/Campfire-color-theme-2528696/) color theme. 25 | 26 | # Screenshots 27 | 28 | ![server form](https://cloud.githubusercontent.com/assets/499192/7789162/85ebe892-0257-11e5-93a4-f328b2b447a3.png) 29 | ![IRC window](https://cloud.githubusercontent.com/assets/499192/7789163/86044e28-0257-11e5-83e5-af8f17967ed7.png) 30 | ![media](https://cloud.githubusercontent.com/assets/499192/7789168/b234ef48-0257-11e5-89d4-a8723bf37f2a.png) 31 | 32 | # Features 33 | 34 | * Built on Electron 35 | * Cross platform (currently for OS X and Windows) 36 | * VIM-like shortcuts 37 | * Minimalistic design 38 | * Several encoding support 39 | * Completely free 40 | 41 | # Documentation 42 | 43 | For more details, please refer to documentations. You can find them in the 44 | [`doc`](doc) directory. 45 | 46 | # License 47 | MIT 48 | -------------------------------------------------------------------------------- /renderer/lib/name.ts: -------------------------------------------------------------------------------- 1 | import _ = require('underscore'); 2 | import generateId = require('./id-generator'); 3 | 4 | class Name { 5 | id: number; 6 | nick: string; 7 | mode: string; 8 | isMe: boolean; 9 | 10 | constructor(nick: string, mode: string = '', isMe: boolean = false) { 11 | this.id = generateId('name'); 12 | this.nick = nick; 13 | this.mode = mode; 14 | this.isMe = isMe; 15 | } 16 | 17 | static sort(names: Name[]): Name[] { 18 | return names.sort(function (a, b) { 19 | let aMode = a.mode === '@' ? 2 : (a.mode === '+' ? 1 : 0); 20 | let bMode = b.mode === '@' ? 2 : (b.mode === '+' ? 1 : 0); 21 | if (aMode !== bMode) { 22 | return bMode - aMode; 23 | } 24 | 25 | return a.nick.localeCompare(b.nick); 26 | }); 27 | } 28 | 29 | static remove(names: Name[], nick: string): Name[] { 30 | return _.reject(names, function (name: Name) { 31 | return name.nick === nick; 32 | }); 33 | } 34 | 35 | static update(names: Name[], oldNick: string, newNick: string): Name[] { 36 | return names.map(name => { 37 | if (name.nick === oldNick) { 38 | name.nick = newNick; 39 | } 40 | return name; 41 | }); 42 | } 43 | 44 | static giveMode(names: Name[], nick: string, mode: string): Name[] { 45 | return names.map(function (name) { 46 | if (name.nick === nick) { 47 | name.mode = mode === 'o' ? '@' : (mode === 'v' ? '+' : name.mode); 48 | } 49 | return name; 50 | }); 51 | } 52 | 53 | static takeMode(names: Name[], nick: string, mode: string): Name[] { 54 | if (mode === 'o' || mode === 'v') { 55 | return names.map(function (name) { 56 | if (name.nick === nick) { 57 | name.mode = ''; 58 | } 59 | return name; 60 | }); 61 | } else { 62 | return names; 63 | } 64 | } 65 | } 66 | 67 | export = Name; 68 | -------------------------------------------------------------------------------- /style/server-form.less: -------------------------------------------------------------------------------- 1 | @import (reference) "common"; 2 | 3 | #server-form { 4 | height: 100%; 5 | .logo { 6 | background-color: @cf-yellow; 7 | display: inline-block; 8 | vertical-align: top; 9 | width: 50%; 10 | height: 100%; 11 | box-shadow: 0 0 10px 2px @lightgray; 12 | .logo-wrapper { 13 | width: 70%; 14 | margin: 0 auto; 15 | position: relative; 16 | top: 50%; 17 | transform: translateY(-50%); 18 | img { 19 | width: 100%; 20 | } 21 | div { 22 | text-align: center; 23 | color: @cf-red; 24 | font-family: 'JK Gothic'; 25 | font-size: 80px; 26 | line-height: 80px; 27 | } 28 | } 29 | } 30 | .form-wrapper { 31 | display: inline-block; 32 | vertical-align: top; 33 | width: 50%; 34 | height: 100%; 35 | box-sizing: border-box; 36 | padding: 0 50px; 37 | form { 38 | position: relative; 39 | top: 50%; 40 | transform: translateY(-50%); 41 | .field-name { 42 | font-size: 18px; 43 | margin: 7px 0 3px; 44 | color: @lightblack; 45 | } 46 | .select { 47 | box-sizing: border-box; 48 | width: 80%; 49 | } 50 | input { 51 | border: 1px solid @lightgray; 52 | font-size: 18px; 53 | border-radius: 5px; 54 | padding: 3px 7px; 55 | width: 80%; 56 | box-sizing: border-box; 57 | color: @lightblack; 58 | } 59 | button { 60 | background-color: @white; 61 | border: 1px solid @cf-green; 62 | font-size: 18px; 63 | border-radius: 7px; 64 | color: @cf-green; 65 | padding: 5px 10px; 66 | margin-top: 10px; 67 | cursor: pointer; 68 | &:hover { 69 | background-color: @cf-green; 70 | color: white; 71 | } 72 | } 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /renderer/select.tsx: -------------------------------------------------------------------------------- 1 | import React = require('react'); 2 | import ReactComponent = require('./lib/react-component'); 3 | 4 | interface SelectProps { 5 | name: string; 6 | options: string[]; 7 | onChange: (newValue: string) => void; 8 | } 9 | 10 | interface SelectState { 11 | value: string; 12 | } 13 | 14 | class Select extends ReactComponent { 15 | initialState() { 16 | let options = this.props.options; 17 | let initialValue = options.length > 0 ? options[0] : ''; 18 | return {value: initialValue}; 19 | } 20 | 21 | options() { 22 | return this.props.options 23 | .sort(options => { 24 | return options === this.state.value ? 0 : 1; 25 | }) 26 | .map((option, key) => { 27 | let className = 'option'; 28 | if (option === this.state.value) { 29 | className += ' current'; 30 | } 31 | return ( 32 |
    34 | {option} 35 |
    36 | ); 37 | }); 38 | } 39 | 40 | render() { 41 | return ( 42 |
    43 |
    44 |
    {this.state.value}
    45 | 46 |
    {this.options()}
    47 |
    48 | ); 49 | } 50 | 51 | node(): HTMLDivElement { 52 | return React.findDOMNode(this.refs['select']); 53 | } 54 | 55 | onChange(e) { 56 | let value = e.target.textContent; 57 | this.setState({value}); 58 | this.props.onChange(value); 59 | this.node().classList.remove('selecting'); 60 | e.stopPropagation(); 61 | } 62 | 63 | onClick() { 64 | this.node().classList.add('selecting'); 65 | } 66 | } 67 | 68 | export = Select; 69 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NODE_BIN=./node_modules/.bin 2 | ELECTRON=$(NODE_BIN)/electron 3 | BROWSERIFY=$(NODE_BIN)/browserify 4 | TSC=$(NODE_BIN)/tsc 5 | LESS=$(NODE_BIN)/lessc 6 | ASAR=$(NODE_BIN)/asar 7 | 8 | all: dep build 9 | 10 | run: build 11 | @$(ELECTRON) . 12 | 13 | dep: 14 | @npm install 15 | 16 | build: clean 17 | @mkdir build 18 | @mkdir build/browser 19 | @mkdir build/renderer 20 | # move config 21 | cp -r config build/ 22 | # build renderer scripts 23 | $(TSC) -p ./renderer 24 | $(BROWSERIFY) ./build/renderer/app.js -o build/renderer.js --ignore ipc --debug 25 | # build browser scripts 26 | $(TSC) -p ./browser 27 | # build styles 28 | $(LESS) ./style/main.less > build/built.css 29 | 30 | clean: clean-asar 31 | @rm -rf ./build 32 | 33 | asar: clean-asar build 34 | @mkdir asar 35 | @cp ./main.js asar/ 36 | @cp ./index.html asar/ 37 | @cp ./package.json asar/ 38 | @cp -r ./build asar/ 39 | @cp -r ./config asar/ 40 | @cp -r ./resource asar/ 41 | @cd asar; npm install --production; cd .. 42 | $(ASAR) pack asar build/app.asar 43 | 44 | clean-asar: 45 | @rm -rf ./asar 46 | 47 | download-shell: clean-shell 48 | @mkdir shell 49 | @curl -o shell/osx.zip https://raw.githubusercontent.com/KokoIRC/koko-shell/master/zip/osx.zip 50 | @curl -o shell/win32.zip https://raw.githubusercontent.com/KokoIRC/koko-shell/master/zip/win32.zip 51 | @curl -o shell/win64.zip https://raw.githubusercontent.com/KokoIRC/koko-shell/master/zip/win64.zip 52 | 53 | clean-shell: 54 | @rm -rf ./shell 55 | 56 | package: package-mac package-win 57 | 58 | package-mac: clean asar 59 | @echo "packaging an executable for OS X executable" 60 | @if [ ! -d ./shell ]; then make download-shell; fi 61 | @unzip shell/osx.zip -d build 62 | @cp build/app.asar build/koko.app/Contents/Resources/ 63 | @echo "done" 64 | 65 | package-win: clean asar 66 | @echo "packaging executables for Windows done" 67 | @if [ ! -d ./shell ]; then make download-shell; fi 68 | @unzip shell/win32.zip -d build/win32 69 | @unzip shell/win64.zip -d build/win64 70 | @cp build/app.asar build/win32/resources/ 71 | @cp build/app.asar build/win64/resources/ 72 | @echo "done" 73 | 74 | .PHONY: run dep build clean 75 | -------------------------------------------------------------------------------- /lib.d/js-yaml/js-yaml.d.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for js-yaml 3.0.2 2 | // Project: https://github.com/nodeca/js-yaml 3 | // Definitions by: Bart van der Schoor 4 | // Definitions: https://github.com/borisyankov/DefinitelyTyped 5 | 6 | declare module jsyaml { 7 | export function safeLoad(str: string, opts?: LoadOptions): any; 8 | export function load(str: string, opts?: LoadOptions): any; 9 | 10 | export function safeLoadAll(str: string, iterator: (doc: any) => void, opts?: LoadOptions): any; 11 | export function loadAll(str: string, iterator: (doc: any) => void, opts?: LoadOptions): any; 12 | 13 | export function safeDump(obj: any, opts?: DumpOptions): string; 14 | export function dump(obj: any, opts?: DumpOptions): string 15 | 16 | export interface LoadOptions { 17 | // string to be used as a file path in error/warning messages. 18 | filename?: string; 19 | // makes the loader to throw errors instead of warnings. 20 | strict?: boolean; 21 | // specifies a schema to use. 22 | schema?: any; 23 | } 24 | 25 | export interface DumpOptions { 26 | // indentation width to use (in spaces). 27 | indent?: number; 28 | // do not throw on invalid types (like function in the safe schema) and skip pairs and single values with such types. 29 | skipInvalid?: boolean; 30 | // specifies level of nesting, when to switch from block to flow style for collections. -1 means block style everwhere 31 | flowLevel?: number; 32 | // Each tag may have own set of styles. - "tag" => "style" map. 33 | styles?: Object; 34 | // specifies a schema to use. 35 | schema?: any; 36 | } 37 | 38 | // only strings, arrays and plain objects: http://www.yaml.org/spec/1.2/spec.html#id2802346 39 | export var FAILSAFE_SCHEMA: any; 40 | // only strings, arrays and plain objects: http://www.yaml.org/spec/1.2/spec.html#id2802346 41 | export var JSON_SCHEMA: any; 42 | // same as JSON_SCHEMA: http://www.yaml.org/spec/1.2/spec.html#id2804923 43 | export var CORE_SCHEMA: any; 44 | // all supported YAML types, without unsafe ones (!!js/undefined, !!js/regexp and !!js/function): http://yaml.org/type/ 45 | export var DEFAULT_SAFE_SCHEMA: any; 46 | // all supported YAML types. 47 | export var DEFAULT_FULL_SCHEMA: any; 48 | } 49 | 50 | declare module 'js-yaml' { 51 | export = jsyaml; 52 | } 53 | -------------------------------------------------------------------------------- /browser/irc-window.ts: -------------------------------------------------------------------------------- 1 | import BrowserWindow = require('browser-window'); 2 | import Ipc = require('./ipc'); 3 | import irc = require('./irc'); 4 | import nodeIrc = require('irc'); 5 | import shell = require('shell'); 6 | 7 | class IrcWindow { 8 | private _window: BrowserWindow; 9 | ipc: Ipc; 10 | focused: boolean; 11 | client: nodeIrc.Client; 12 | 13 | constructor(url: string) { 14 | this._window = new BrowserWindow({width: 800, height: 600}); 15 | 16 | this.ipc = new Ipc(this._window); 17 | 18 | this._window.loadUrl(url); 19 | this._window.on('closed', () => { 20 | this._window = null; 21 | IrcWindow.remove(this); 22 | if (this.client) { 23 | this.client.disconnect('bye'); 24 | } 25 | }); 26 | this._window.on('focus', () => { 27 | this.ipc.send('focus', {}); 28 | IrcWindow.focus(this); 29 | }); 30 | this._window.on('blur', () => { 31 | this.ipc.send('blur', {}); 32 | IrcWindow.blur(); 33 | }); 34 | this._window.webContents.on('new-window', function (e, url) { 35 | shell.openExternal(url); 36 | e.preventDefault(); 37 | }); 38 | 39 | this.ipc.on('connect', (data) => { 40 | this.client = irc.connect(data, this.ipc); 41 | }); 42 | 43 | this.focused = true; 44 | IrcWindow.focus(this); 45 | } 46 | 47 | focus() { 48 | this.focused = true; 49 | } 50 | 51 | blur() { 52 | this.focused = false; 53 | } 54 | 55 | static windows: IrcWindow[] = []; 56 | 57 | 58 | static create(url: string) { 59 | IrcWindow.windows.push(new IrcWindow(url)); 60 | } 61 | 62 | static remove(wToRemove: IrcWindow) { 63 | IrcWindow.windows = IrcWindow.windows.filter(function (w, idx) { 64 | if (w === wToRemove) { 65 | delete IrcWindow.windows[idx]; 66 | return false; 67 | } else { 68 | return true; 69 | } 70 | }); 71 | } 72 | 73 | static focus(wToFocus: IrcWindow) { 74 | IrcWindow.windows.forEach(function (w) { 75 | if (wToFocus === w) { 76 | w.focus(); 77 | } else { 78 | w.blur(); 79 | } 80 | }); 81 | } 82 | 83 | static blur() { 84 | IrcWindow.windows.forEach(function (w) { 85 | w.blur(); 86 | }); 87 | } 88 | 89 | static currentBrowserWindow(): BrowserWindow { 90 | return IrcWindow.windows.filter(w => w.focused)[0]._window; 91 | } 92 | } 93 | 94 | export = IrcWindow; 95 | -------------------------------------------------------------------------------- /browser/menu.ts: -------------------------------------------------------------------------------- 1 | import IrcWindow = require('./irc-window'); 2 | import Menu = require('menu'); 3 | 4 | export = { 5 | initialize(app, mainUrl: string) { 6 | let template = [ 7 | { label: 'koko', 8 | submenu: [ 9 | { label: 'About koko', 10 | selector: 'orderFrontStandardAboutPanel:' }, 11 | { type: 'separator' }, 12 | { label: 'Services', 13 | submenu: [] }, 14 | { type: 'separator' }, 15 | { label: 'Hide koko', 16 | accelerator: 'Command+H', 17 | selector: 'hide:' }, 18 | { label: 'Hide Others', 19 | accelerator: 'Command+Shift+H', 20 | selector: 'hideOtherApplications:' }, 21 | { label: 'Show All', 22 | selector: 'unhideAllApplications:' }, 23 | { type: 'separator' }, 24 | { label: 'Quit', 25 | accelerator: 'Command+Q', 26 | click: function() { app.quit(); } }, 27 | ] }, 28 | { label: 'Edit', 29 | submenu: [ 30 | { label: 'Undo', 31 | accelerator: 'Command+Z', 32 | selector: 'undo:' }, 33 | { label: 'Redo', 34 | accelerator: 'Shift+Command+Z', 35 | selector: 'redo:' }, 36 | { type: 'separator' }, 37 | { label: 'Cut', 38 | accelerator: 'Command+X', 39 | selector: 'cut:' }, 40 | { label: 'Copy', 41 | accelerator: 'Command+C', 42 | selector: 'copy:' }, 43 | { label: 'Paste', 44 | accelerator: 'Command+V', 45 | selector: 'paste:' }, 46 | { label: 'Select All', 47 | accelerator: 'Command+A', 48 | selector: 'selectAll:' }, 49 | ] }, 50 | { label: 'View', 51 | submenu: [ 52 | { label: 'Toggle DevTools', 53 | accelerator: 'Alt+Command+I', 54 | click: function() { 55 | IrcWindow.currentBrowserWindow().toggleDevTools(); } }, 56 | ] }, 57 | { label: 'Window', 58 | submenu: [ 59 | { label: 'Minimize', 60 | accelerator: 'Command+M', 61 | selector: 'performMiniaturize:' }, 62 | { label: 'Close', 63 | accelerator: 'Command+W', 64 | selector: 'performClose:' }, 65 | { type: 'separator' }, 66 | { label: 'New Window', 67 | accelerator: 'Command+N', 68 | click: IrcWindow.create.bind(null, mainUrl) }, 69 | ] }, 70 | { label: 'Help', 71 | submenu: [] }, 72 | ]; 73 | 74 | let menu = Menu.buildFromTemplate(template); 75 | Menu.setApplicationMenu(menu); 76 | } 77 | }; 78 | -------------------------------------------------------------------------------- /renderer/lib/irc-color.ts: -------------------------------------------------------------------------------- 1 | import _ = require('underscore'); 2 | 3 | const ircColors = [ 4 | 'white', 5 | 'black', 6 | 'dark-blue', 7 | 'dark-green', 8 | 'light-red', 9 | 'dark-red', 10 | 'magenta', 11 | 'orange', 12 | 'yellow', 13 | 'light-green', 14 | 'cyan', 15 | 'light-cyan', 16 | 'light-blue', 17 | 'light-magenta', 18 | 'gray', 19 | 'light-gray', 20 | ]; 21 | 22 | class Color { 23 | type: string; 24 | index: number; 25 | length: number; 26 | color: string; 27 | bgColor: string; 28 | 29 | constructor(type: string, index: number, length: number, color?: string, bgColor?: string) { 30 | this.index = index; 31 | this.length = length; 32 | this.type = type; 33 | this.color = color; 34 | this.bgColor = bgColor; 35 | } 36 | 37 | static parse(text: string): Color[] { 38 | let result: Color[] = []; 39 | result = result.concat(Color.colorsOf(text)); 40 | result = result.concat(Color.symbolsOf(text, '\u0002', 'bold')); 41 | result = result.concat(Color.symbolsOf(text, '\u001f', 'underline')); 42 | result = result.concat(Color.symbolsOf(text, '\u0016', 'reverse')); 43 | result = result.concat(Color.symbolsOf(text, '\u000f', 'close')); 44 | return _.sortBy(result, 'index'); 45 | } 46 | 47 | static colorsOf(text: string): Color[] { 48 | let result: Color[] = []; 49 | let idx = 0; 50 | while (true) { 51 | idx = text.indexOf('\u0003', idx); 52 | if (idx < 0) { 53 | break; 54 | } 55 | 56 | let startIdx = idx; 57 | let color, bgColor; 58 | let colorCode = ''; 59 | idx++; 60 | while (/[0-9]/.test(text[idx]) && colorCode.length <= 2) { 61 | colorCode += text[idx]; 62 | idx++; 63 | } 64 | colorCode = '0'.repeat(2 - colorCode.length) + colorCode; 65 | color = ircColors[parseInt(colorCode, 10) % 16]; 66 | if (text[idx] === ',') { 67 | colorCode = ''; 68 | idx++; 69 | while (/[0-9]/.test(text[idx]) && colorCode.length <= 2) { 70 | colorCode += text[idx]; 71 | idx++; 72 | } 73 | colorCode = '0'.repeat(2 - colorCode.length) + colorCode; 74 | bgColor = ircColors[parseInt(colorCode, 10) % 16]; 75 | } 76 | result.push(new Color('color', startIdx, idx - startIdx, color, bgColor)); 77 | } 78 | return result; 79 | } 80 | 81 | static symbolsOf(text: string, code: string, type: string): Color[] { 82 | let result: Color[] = []; 83 | let idx = 0; 84 | while (true) { 85 | idx = text.indexOf(code, idx); 86 | if (idx < 0) { 87 | break; 88 | } 89 | result.push(new Color(type, idx, 1)); 90 | idx++; 91 | } 92 | return result; 93 | } 94 | } 95 | 96 | export = Color; 97 | -------------------------------------------------------------------------------- /browser/irc-command.ts: -------------------------------------------------------------------------------- 1 | import _ = require('underscore'); 2 | 3 | const commands: IDict = { 4 | 'join': ['channel'], 5 | 'part': ['?channel', '!message'], 6 | 'ctcp': ['target', 'type', '!text'], 7 | 'action': ['target', '!message'], 8 | 'whois': ['nick'], 9 | 'list': [], 10 | 'nick': ['nick'], 11 | 'mode': ['?channel', 'mode', 'nick'], 12 | 'kick': ['?channel', 'nick', '!message'], 13 | 'ban': ['?channel', 'nick'], 14 | 'unban': ['?channel', 'nick'], 15 | 'kickban': ['?channel', 'nick', '!message'], 16 | 'topic': ['?channel', '!topic'], 17 | 'quote': ['command', '...args'], 18 | 'raw': ['command', '...args'], 19 | }; 20 | 21 | export class CommandError implements Error { 22 | message: string; 23 | name: string; 24 | constructor(message: string) { 25 | this.message = message; 26 | } 27 | } 28 | 29 | export function parse(raw: string, context: ICommandContext): IIrcCommand { 30 | let tokens = raw.split(' '); 31 | let commandName = tokens[0]; 32 | let args = tokens.splice(1); 33 | let command = commands[commandName]; 34 | 35 | if (!command) { 36 | throw new CommandError(`Invalid command name: ${commandName}`); 37 | } 38 | 39 | args = parseArgs(commandName, _.clone(command), args, context); 40 | return {name: commandName, args}; 41 | } 42 | 43 | function parseArgs(commandName: string, argList: string[], args: string[], context: ICommandContext): string[] { 44 | let parsedArgs = []; 45 | while (true) { 46 | let argNeeded = argList.shift(); 47 | if (_.isUndefined(argNeeded)) { 48 | break; 49 | } 50 | 51 | if (argNeeded.startsWith('?')) { 52 | if (argNeeded === '?channel' && !(args[0] && args[0].startsWith('#'))) { 53 | parsedArgs.push(context.target); 54 | } else { 55 | parsedArgs.push(args.shift()); 56 | } 57 | } else if (argNeeded.startsWith('!')) { 58 | if (args.length > 0) { 59 | parsedArgs.push(args.join(' ')); 60 | } 61 | break; 62 | } else if (argNeeded.startsWith('...')) { 63 | parsedArgs.push(args); 64 | break; 65 | } else { 66 | let arg = args.shift(); 67 | if (_.isUndefined(arg)) { 68 | throw new CommandError(`Command argument needed: [${argNeeded}]`); 69 | } else { 70 | parsedArgs.push(arg); 71 | } 72 | } 73 | } 74 | 75 | return parsedArgs.map(reprocess.bind(null, commandName)); 76 | } 77 | 78 | function reprocess(commandName: string, value: string, idx: number): string { 79 | switch (commandName) { 80 | case 'join': 81 | if (idx === 0) { 82 | if (value[0] !== '#') { 83 | value = '#' + value; 84 | } 85 | } 86 | break; 87 | case 'mode': 88 | if (idx === 1) { 89 | if (!(value[0] === '+' || value[0] === '0')) { 90 | value = '+' + value; 91 | } 92 | } 93 | break; 94 | } 95 | return value; 96 | } 97 | -------------------------------------------------------------------------------- /browser/irc.ts: -------------------------------------------------------------------------------- 1 | import _ = require('underscore'); 2 | import Ipc = require('./ipc'); 3 | import irc = require('irc'); 4 | import IrcCommand = require('./irc-command'); 5 | 6 | const Client = irc.Client; 7 | 8 | export function connect(data: IConnectionData, ipc: Ipc) { 9 | function sendRootMessage(text: string, nick: string) { 10 | ipc.send('message', { 11 | to: '~', 12 | nick, 13 | text, 14 | }); 15 | } 16 | 17 | let client = new Client(data.host, data.nick, { 18 | userName: data.username, 19 | realName: data.realname, 20 | port: data.port, 21 | encoding: data.encoding, 22 | autoConnect: false, 23 | password: data.password, 24 | }); 25 | 26 | client.connect(); 27 | 28 | client.on('registered', function (message) { 29 | ipc.send('registered', {nick: message.args[0]}); 30 | }); 31 | 32 | function propagate(eventName: string, parameters: string[]) { 33 | client.on(eventName, function () { 34 | let args = arguments; 35 | ipc.send(eventName, parameters.reduce(function (result, key, idx) { 36 | result[key] = args[idx]; 37 | return result; 38 | }, {})) 39 | }); 40 | }; 41 | 42 | propagate('join', ['channel', 'nick', 'message']); 43 | propagate('part', ['channel', 'nick', 'reason', 'message']); 44 | propagate('message', ['nick', 'to', 'text']); 45 | propagate('nick', ['oldnick', 'newnick', 'channels']); 46 | propagate('names', ['channel', 'names']); 47 | propagate('quit', ['nick', 'reason', 'channels', 'message']); 48 | propagate('+mode', ['channel', 'by', 'mode', 'target', 'message']); 49 | propagate('-mode', ['channel', 'by', 'mode', 'target', 'message']); 50 | propagate('whois', ['info']); 51 | propagate('kick', ['channel', 'nick', 'by', 'reason', 'message']); 52 | propagate('topic', ['channel', 'topic', 'nick', 'message']); 53 | 54 | client.on('notice', function (nick, to, text) { 55 | sendRootMessage(text, nick); 56 | if (nick && client._nick === to) { 57 | ipc.send('message', {nick, to, text, isNotice: true}); 58 | } 59 | }); 60 | 61 | client.on('ctcp', function (from, to, text, type) { 62 | // FIXME 63 | }); 64 | 65 | client.on('motd', sendRootMessage); 66 | 67 | client.on('error', function (error) { 68 | ipc.send('error', {type: 'irc', error}); 69 | }); 70 | 71 | sendRootMessage('Looking up host...', 'Connection'); 72 | client.conn.on('lookup', function (err: Error) { 73 | if (err) { 74 | sendRootMessage('Error in looking up: ' + err, 'Connection'); 75 | } else { 76 | sendRootMessage('Connecting to server...', 'Connection'); 77 | } 78 | }); 79 | client.conn.on('connect', function () { 80 | sendRootMessage('Connected.', 'Connection'); 81 | }); 82 | client.conn.on('error', function (err: Error) { 83 | sendRootMessage('Error: ' + err, 'Connection'); 84 | }); 85 | client.conn.on('close', function () { 86 | sendRootMessage('Connection closed.', 'Connection'); 87 | }); 88 | 89 | ipc.on('message', function (data) { 90 | client.say(data.context.target, data.raw); 91 | }); 92 | 93 | ipc.on('command', function (data: {raw: string, context: ICommandContext}) { 94 | try { 95 | let command = IrcCommand.parse(data.raw, data.context); 96 | client[command.name].apply(client, command.args); 97 | } catch (error) { 98 | if (error instanceof IrcCommand.CommandError) { 99 | ipc.send('error', {type: 'normal', error: _.pick(error, 'name', 'message')}); 100 | } else { 101 | throw error; 102 | } 103 | } 104 | }); 105 | 106 | return client; 107 | } 108 | -------------------------------------------------------------------------------- /renderer/server-form.tsx: -------------------------------------------------------------------------------- 1 | import ipc = require('./lib/ipc'); 2 | import configuration = require('./lib/configuration'); 3 | import React = require('react'); 4 | import ReactComponent = require('./lib/react-component'); 5 | import Select = require('./select'); 6 | 7 | const user = configuration.getConfig('user') || {}; 8 | const servers = configuration.getConfig('servers') || []; 9 | const serverDefaults = configuration.get('defaults', 'server'); 10 | 11 | function getServer(name: string): IServerInterface { 12 | return servers.filter(s => s.name === name)[0]; 13 | } 14 | 15 | interface ServerFormProps { 16 | connect: (any) => void; 17 | } 18 | 19 | class ServerForm extends ReactComponent { 20 | private server: IServerInterface; 21 | 22 | constructor() { 23 | super(); 24 | this.server = servers[0] || {}; 25 | } 26 | 27 | onChange(newValue: string) { 28 | this.server = getServer(newValue); 29 | this.applyValues(); 30 | } 31 | 32 | val(field: string): string { 33 | return this.server[field] || user[field] || serverDefaults[field] || ''; 34 | } 35 | 36 | applyValues() { 37 | let fields = ['host', 'port', 'encoding', 'nick', 'username', 'password', 'realname']; 38 | fields.forEach(fieldName => { 39 | React.findDOMNode(this.refs[fieldName]).value = this.val(fieldName); 40 | }); 41 | } 42 | 43 | componentDidMount() { 44 | this.applyValues(); 45 | } 46 | 47 | componentDidUpdate() { 48 | this.applyValues(); 49 | } 50 | 51 | render() { 52 | let select = servers.length > 0 ? 53 | 89 | 90 | ); 91 | } 92 | 93 | formToJSON(): any { 94 | let form = React.findDOMNode(this); 95 | let inputs = form.querySelectorAll('input'); 96 | let result = Array.prototype.reduce.call(inputs, function (obj, input) { 97 | obj[input.name] = input.value; 98 | return obj; 99 | }, {}); 100 | 101 | if (result.name && getServer(result.name).host !== result.host) { 102 | // user may re-input the host, so the name may be wrong 103 | delete result.name; 104 | } 105 | 106 | return result; 107 | } 108 | 109 | connect(e) { 110 | e.preventDefault(); 111 | 112 | let data = this.formToJSON(); 113 | this.props.connect(data); 114 | ipc.send('connect', data); 115 | } 116 | } 117 | 118 | export = ServerForm; 119 | -------------------------------------------------------------------------------- /renderer/lib/log.tsx: -------------------------------------------------------------------------------- 1 | import _ = require('underscore'); 2 | import configuration = require('./configuration'); 3 | import generateId = require('./id-generator'); 4 | import LogContent = require('../log-content'); 5 | import React = require('react'); 6 | import Topic = require('./topic'); 7 | 8 | const scrollbackLimit = configuration.get('app', 'scrollback-limit'); 9 | 10 | class Log { 11 | id: number; 12 | nick: string; 13 | text: string; 14 | datetime: Date; 15 | adjacent: boolean; 16 | textContent: string; 17 | htmlContent: string; 18 | sentByMe: boolean; 19 | includesUserNick: boolean; 20 | 21 | constructor(nick: string, text: string) { 22 | this.id = generateId('log'); 23 | this.nick = nick; 24 | this.text = text; 25 | this.datetime = new Date(); 26 | this.adjacent = false; 27 | let content = this.render(); 28 | this.htmlContent = content.innerHTML; 29 | this.textContent = content.querySelector('.text').textContent; 30 | this.sentByMe = this.nick === Log.userNick; 31 | this.includesUserNick = this.textContent.includes(Log.userNick); 32 | } 33 | 34 | render(element: React.ReactElement): HTMLDivElement { 35 | let tag = document.createElement('div'); 36 | tag.innerHTML = React.renderToStaticMarkup(element); 37 | return tag.children[0] as HTMLDivElement; 38 | } 39 | 40 | static userNick: string; 41 | 42 | static setCurrentNick(nick: string) { 43 | Log.userNick = nick; 44 | } 45 | 46 | static append(logs: Log[], newLog: Log): Log[] { 47 | if (logs.length === scrollbackLimit) { 48 | // remove the oldest log 49 | logs = _.tail(logs); 50 | } 51 | let lastLog = _.last(logs); 52 | if (lastLog && 53 | lastLog.nick === newLog.nick && 54 | newLog.datetime.getTime() - lastLog.datetime.getTime() < 20000) { 55 | newLog.adjacent = true; 56 | } 57 | return logs.concat(newLog); 58 | } 59 | 60 | static say(nick: string, text: string): Log { 61 | return new Log(nick, text); 62 | } 63 | 64 | static join(nick: string, message: IIrcRawMessage): Log { 65 | // FIXME 66 | let text = `The user has joined. (${message.user}@${message.host})`; 67 | return new Log(nick, text); 68 | } 69 | 70 | static part(nick: string, reason: string, message: IIrcRawMessage): Log { 71 | // FIXME 72 | reason = reason ? reason : 'no reason'; 73 | let text = `The user has left. (${reason})`; 74 | return new Log(nick, text); 75 | } 76 | 77 | static updateName(oldNick: string, newNick: string): Log { 78 | // FIXME 79 | let text = `${oldNick} has changed the nickname.`; 80 | return new Log(newNick, text); 81 | } 82 | 83 | static giveMode(mode: string, by: string, to: string): Log { 84 | // FIXME 85 | let m = mode === 'o' ? 'op' : (mode === 'v' ? 'voice' : mode); 86 | let text = `${by} gives ${m} to ${to}.`; 87 | return new Log(to, text); 88 | } 89 | 90 | static takeMode(mode: string, by: string, to: string): Log { 91 | // FIXME 92 | let m = mode === 'o' ? 'op' : (mode === 'v' ? 'voice' : mode); 93 | let text = `${by} removes ${m} from ${to}.`; 94 | return new Log(to, text); 95 | } 96 | 97 | static whois(info: IDict): Log { 98 | return new Log(info['nick'], _.keys(info).reduce((result, key) => { 99 | return result + `${key}: ${info[key]}\n`; 100 | }, '')); 101 | } 102 | 103 | static kick(channel: string, nick: string, by: string, reason: string): Log { 104 | // FIXME 105 | let text = `${nick} has been kicked from ${channel} by ${by}. (${reason})`; 106 | return new Log(nick, text); 107 | } 108 | 109 | static topic(channel: string, topic: Topic): Log { 110 | let text = `Topic: ${topic.fullText}`; 111 | return new Log(channel, text); 112 | } 113 | } 114 | 115 | export = Log; 116 | -------------------------------------------------------------------------------- /doc/CONFIGURATION.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | There are two kinds of configuration. One is default config, and the other is 4 | surely user config. Basically every default config is also configurable with 5 | the user config. So it may be worth describing the default config first. 6 | 7 | ## Default configuration 8 | 9 | The default config files are placed in the [config](../config) directory of the 10 | project. Each config is described below in the form of `key: default`. 11 | 12 | ### `app` 13 | 14 | It contains configs of the app's basic behaviour. 15 | 16 | ##### `scrollback-limit: 1000` 17 | The maximum number of logs per buffer 18 | 19 | ##### `input-history-limit: 100` 20 | The maximum number of input histories preserved per window 21 | 22 | ##### `shortcut-serial-input-timeout: 800` 23 | The timeout to input a serial shortcut inputs, in milliseconds 24 | 25 | ##### `command-symbol: '/'` 26 | The symbol with which commands start 27 | 28 | ##### `root-channel-name: '~'` 29 | The name of the root channel 30 | 31 | * it must be different from possible channel names 32 | * it must be different from possible nick names 33 | 34 | ### `keys` 35 | 36 | It contains shortcut configs. For more information of each shortcut, 37 | please refer to the [default shortcuts](USERGUIDE.md#default-shortcuts) section 38 | of the [user guide] (USERGUIDE.md). 39 | 40 | ##### `message: ["i"]` 41 | ##### `command: ["/"]` 42 | ##### `exit: ["esc", "ctrl+c"]` 43 | ##### `next-tab: ["g>t"]` 44 | ##### `previous-tab: ["g>T"]` 45 | ##### `scroll-down: ["j"]` 46 | ##### `scroll-up: ["k"]` 47 | ##### `scroll-top: ["g>g"]` 48 | ##### `scroll-bottom: ["G"]` 49 | ##### `page-down: ["ctrl+f"]` 50 | ##### `page-up: ["ctrl+b"]` 51 | ##### `input-history-back: ["up"]` 52 | ##### `input-history-forward: ["down"]` 53 | ##### `autocomplete: ["tab"]` 54 | 55 | ## User configuration 56 | 57 | User config can be set with done with `.koko.yml` in each user's home 58 | directory. 59 | 60 | ```bash 61 | $ vi ~/.koko.yml 62 | ``` 63 | 64 | Basically, every category and field of the default config can be overwritten. 65 | 66 | ```yml 67 | # .koko.yml 68 | app: 69 | scrollback-limit: 20000 70 | root-channel-name: '$' 71 | 72 | keys: 73 | message: ["M"] # or "shift+m" works too 74 | next-tab: ["ctrl+tab"] 75 | scroll-down: ["d>d"] 76 | ``` 77 | 78 | Also, there are configs which is configurable only in the user config. The 79 | following categories are some configs of the case. 80 | 81 | ### `user` 82 | 83 | Default user information for every server. Each field is configurable in 84 | server config too. 85 | 86 | ```yml 87 | user: 88 | nick: octocat 89 | username: octocat 90 | realname: octocat 91 | ``` 92 | 93 | ### `servers` 94 | 95 | Servers to be shown in a select box in the server form of `ココ`. Not to fill 96 | the form everytime connecting to servers, it's mandatory to set this config. 97 | 98 | `servers` config is a list of server configs. Each server config 99 | should contain `name` and `host`. `port` and `encoding` are optional. If there's 100 | no value set for the optional fields, default values are used(`6667` and 101 | `UTF-8`). Lastly, the fields in the `user` config(`nick`, `username` 102 | and `realname`) can be set in each server config too. 103 | 104 | ```yml 105 | servers: 106 | - name: HanIRC 107 | host: irc.hanirc.org 108 | encoding: CP949 109 | nick: doge 110 | - name: Freenode 111 | host: irc.freenode.net 112 | port: 8080 113 | realname: octokat 114 | ``` 115 | 116 | ## Example 117 | 118 | Here is an example `.koko.yml` 119 | 120 | ```yml 121 | app: 122 | scrollback-limit: 20000 123 | root-channel-name: '$' 124 | 125 | keys: 126 | message: ["M"] 127 | next-tab: ["ctrl+tab"] 128 | scroll-down: ["d>d"] 129 | 130 | user: 131 | nick: octocat 132 | username: octocat 133 | realname: octocat 134 | 135 | servers: 136 | - name: HanIRC 137 | host: irc.hanirc.org 138 | encoding: CP949 139 | nick: doge 140 | - name: Freenode 141 | host: irc.freenode.net 142 | port: 8080 143 | realname: octokat 144 | ``` 145 | 146 | ## Need more help! 147 | 148 | Please... 149 | 150 | * Upload an issue in [Issues](https://github.com/noraesae/koko/issues) 151 | * Drop an email to [contributors](https://github.com/noraesae/koko/graphs/contributors) 152 | -------------------------------------------------------------------------------- /doc/CONTRIBUTION.md: -------------------------------------------------------------------------------- 1 | # Contribution 2 | 3 | ## Opening 4 | 5 | First of all, thank you very much for your contribution on `ココ`. We started 6 | this project mainly for our own pleasure, and we will be really happy to share 7 | the pleasure with you guys. Please come and help us! 8 | 9 | We welcome every kind of contribution including reporting bugs, suggesting new 10 | features, or uploading pull requests(will love this the most!). 11 | 12 | ## What and how to do 13 | 14 | If you have any suggestion or bugs to fix, it's good to start from implementing 15 | or fixing them by yourself. It gives us brilliant feeling that we're contributing 16 | and helping. Most of all, the project is maintained by a very small group and 17 | without your help, it will take tons of time to improve. Please don't worry 18 | and try doing it first. This document is to help you, and we will also help as 19 | much as we can. 20 | 21 | If you don't have anything you'd like to add for the time being, 22 | [`help wanted` issues](https://github.com/noraesae/koko/labels/help%20wanted) 23 | are the best point to start from. We will keep posting parts to implement. However, 24 | we will be happier if others do it. To participate in the process, 25 | just drop a reply and that may be it. 26 | 27 | Lastly, Jun who wrote the first version of this documentations is not a native 28 | English speaker, so there must be grammatical errors everywhere. It will be really 29 | helpful if they can be corrected! 30 | 31 | ## 3rd parties 32 | 33 | Before implementing something, here is the list of components used in this 34 | project. If you already know them, please just skip this section. Unless, 35 | please check them out as they are awesome libraries and tools by themselves. 36 | 37 | * [Electron](http://electron.atom.io): `nw.js` based shell where `ココ` is running 38 | * [TypeScript](http://typescriptlang.org): JavaScript superset helping us use JavaScript in type-safe and elegant way 39 | * [React](http://reactjs.com): Frontend framework built by Facebook 40 | * [Browserify](http://browserify.org): Frontend JavaScript packager letting us `require('modules')` 41 | * [Less](http://lesscss.org): A CSS preprocessor 42 | * [noraesae/irc](https://github.com/noraesae/irc): A clone of [martynsmith/node-irc](https://github.com/martynsmith/node-irc) 43 | to add encoding support and some additional sugars. 44 | * [Documentation](https://node-irc.readthedocs.org/en/latest/) of the original `node-irc`. 45 | 46 | ## How to run `ココ` 47 | 48 | ##### Currently only for OS X. Cross platform support is also a thing to be done. Please help! 49 | 50 | ```bash 51 | $ git clone git@github.com:noraesae/koko.git 52 | ``` 53 | 54 | To install deps, usually only for the first time unless there are additional modules. 55 | 56 | ```bash 57 | $ make dep 58 | ``` 59 | 60 | To build and run `ココ` in Electron: 61 | 62 | ```bash 63 | $ make build # Only to build, mainly to check if it builds well 64 | $ make run # build and run 65 | ``` 66 | 67 | To pack the app and create an executable 68 | 69 | ```bash 70 | $ make package 71 | ``` 72 | 73 | For the details, please refer to `Makefile` as it's simple enough. 74 | 75 | ## Project structure 76 | 77 | Firstly, here is the directory structure of `ココ`. 78 | 79 | * browser: backend scripts to be run by browser process 80 | * renderer: frontend scripts to be run by renderer process, mainly React components 81 | * lib: also frontend scripts, which are not React components 82 | * config: config yml files 83 | * doc: documentations 84 | * lib.d: TypeScript type definition files, mainly from [DefinitelyTyped](https://github.com/borisyankov/DefinitelyTyped) 85 | * resource: static assets 86 | * style: `.less` stylesheets 87 | 88 | Basically, there are two main parts in an Electron app. One is browser process 89 | and the other is renderer process. You can think that the browser process is 90 | backend in a normal Node.js app and the renderer one is frontend. The browser 91 | process manages the entire windows and runs backend logic. The renderer process 92 | draws a browser view and runs client-side logic. Electron basically provides ways 93 | for them to communicate with each other, mainly by IPC(socket). In `ココ`, we 94 | also separate browser and renderer scripts and make them communicate with IPC. 95 | 96 | The logic inside the scripts is not so complicated. If you have basic knowledge 97 | of the 3rd parties described above, you may not have difficulty in looking 98 | around source codes. But if any, please feel free to ask us. 99 | 100 | ## Need more help! 101 | 102 | Please... 103 | 104 | * Upload an issue in [Issues](https://github.com/noraesae/koko/issues) 105 | * Drop an email to [contributors](https://github.com/noraesae/koko/graphs/contributors) 106 | -------------------------------------------------------------------------------- /renderer/lib/channel.ts: -------------------------------------------------------------------------------- 1 | import _ = require('underscore'); 2 | import configuration = require('./configuration'); 3 | import generateId = require('./id-generator'); 4 | import Log = require('./log'); 5 | import Name = require('./name'); 6 | import Notification = require('./notification'); 7 | import Topic = require('./topic'); 8 | 9 | const rootChannelName = configuration.get('app', 'root-channel-name'); 10 | 11 | class Channel { 12 | id: number; 13 | current: boolean; 14 | logs: Log[]; 15 | name: string; 16 | names: Name[]; 17 | topic: Topic; 18 | unread: boolean; 19 | 20 | constructor(name: string, current: boolean = false) { 21 | this.id = generateId('channel'); 22 | this.logs = []; 23 | this.name = name; 24 | this.names = []; 25 | this.current = current; 26 | this.topic = null; 27 | this.unread = false; 28 | } 29 | 30 | get personal(): boolean { 31 | return this.name !== rootChannelName && !this.name.startsWith('#'); 32 | } 33 | 34 | send(nick: string, text: string, isNotice?: boolean) { 35 | let log = Log.say(nick, text); 36 | this.logs = Log.append(this.logs, log); 37 | 38 | if (this.shouldNotify(log, isNotice)) { 39 | if (!this.current) { 40 | this.unread = true; 41 | } 42 | Notification.show(this.name, nick, log.textContent); 43 | } 44 | } 45 | 46 | shouldNotify(log: Log, isNotice: boolean): boolean { 47 | if (log.sentByMe) { 48 | return false; 49 | } 50 | 51 | let hasFocus = document.hasFocus(); 52 | if (isNotice) { 53 | return !hasFocus && (this.personal || log.includesUserNick); 54 | } else { 55 | let current = this.current && hasFocus; 56 | return !current && (this.personal || log.includesUserNick); 57 | } 58 | } 59 | 60 | join(nick: string, message: IIrcRawMessage) { 61 | this.logs = Log.append(this.logs, Log.join(nick, message)); 62 | } 63 | 64 | part(nick: string, reason: string, message: IIrcRawMessage) { 65 | this.logs = Log.append(this.logs, Log.part(nick, reason, message)); 66 | } 67 | 68 | whois(info: IDict) { 69 | this.logs = Log.append(this.logs, Log.whois(info)); 70 | } 71 | 72 | kick(channel: string, nick: string, by: string, reason: string) { 73 | this.logs = Log.append(this.logs, Log.kick(channel, nick, by, reason)); 74 | } 75 | 76 | setNames(names: Name[]) { 77 | this.names = Name.sort(names); 78 | } 79 | 80 | addName(nick: string) { 81 | this.names.push(new Name(nick)); 82 | this.names = Name.sort(this.names); 83 | } 84 | 85 | removeName(nick: string) { 86 | this.names = Name.remove(this.names, nick); 87 | } 88 | 89 | updateName(oldNick: string, newNick: string) { 90 | this.logs = Log.append(this.logs, Log.updateName(oldNick, newNick)); 91 | this.names = Name.sort(Name.update(this.names, oldNick, newNick)); 92 | } 93 | 94 | giveMode(mode: string, by: string, to: string) { 95 | this.logs = Log.append(this.logs, Log.giveMode(mode, by, to)); 96 | this.names = Name.sort(Name.giveMode(this.names, to, mode)); 97 | } 98 | 99 | takeMode(mode: string, by: string, from: string) { 100 | this.logs = Log.append(this.logs, Log.takeMode(mode, by, from)); 101 | this.names = Name.sort(Name.takeMode(this.names, from, mode)); 102 | } 103 | 104 | setTopic(topic: string, by: string) { 105 | this.topic = new Topic(topic, by); 106 | this.showTopic(); 107 | } 108 | 109 | showTopic() { 110 | if (this.topic) { 111 | this.logs = Log.append(this.logs, Log.topic(this.name, this.topic)); 112 | } else { 113 | let noTopic = new Topic('no topic'); 114 | this.logs = Log.append(this.logs, Log.topic(this.name, noTopic)); 115 | } 116 | } 117 | 118 | static current(channels: Channel[]): Channel { 119 | return _.find(channels, c => c.current); 120 | } 121 | 122 | static get(channels: Channel[], name: string): Channel { 123 | return _.find(channels, c => (c.name.toLowerCase() === name.toLowerCase())); 124 | } 125 | 126 | static setCurrent(channels: Channel[], name: string): Channel[] { 127 | return channels.map(function (channel) { 128 | if (channel.name === name) { 129 | channel.current = true; 130 | channel.unread = false; 131 | } else { 132 | channel.current = false; 133 | } 134 | return channel; 135 | }); 136 | } 137 | 138 | static next(channels: Channel[]): Channel { 139 | let currentIndex = channels.indexOf(Channel.current(channels)); 140 | let nextIndex = (currentIndex + 1) % channels.length; 141 | return channels[nextIndex]; 142 | } 143 | 144 | static previous(channels: Channel[]): Channel { 145 | let currentIndex = channels.indexOf(Channel.current(channels)); 146 | let previousIndex = (currentIndex - 1) % channels.length; 147 | if (previousIndex < 0) { 148 | previousIndex += channels.length; 149 | } 150 | return channels[previousIndex]; 151 | } 152 | 153 | static remove(channels: Channel[], name: string): Channel[] { 154 | return _.reject(channels, c => (c.name === name)); 155 | } 156 | } 157 | 158 | export = Channel; 159 | -------------------------------------------------------------------------------- /renderer/buffer-view.tsx: -------------------------------------------------------------------------------- 1 | import _ = require('underscore'); 2 | import Channel = require('./lib/channel'); 3 | import imageLib = require('./lib/image'); 4 | import Log = require('./lib/log'); 5 | import React = require('react'); 6 | import ReactComponent = require('./lib/react-component'); 7 | import shortcut = require('./lib/shortcut-manager'); 8 | 9 | const followLogBuffer = 20; 10 | const minimumScrollHeight = 10; 11 | 12 | interface BufferViewProps { 13 | channels: Channel[]; 14 | } 15 | 16 | class BufferView extends ReactComponent { 17 | isFollowingLog: boolean; 18 | currentBuffer: string; 19 | 20 | constructor() { 21 | super(); 22 | this.isFollowingLog = true; 23 | this.currentBuffer = null; 24 | } 25 | 26 | view(): HTMLDivElement { 27 | return React.findDOMNode(this.refs['view']); 28 | } 29 | 30 | componentDidMount() { 31 | shortcut.Manager.on('scroll-down', this.scrollDown); 32 | shortcut.Manager.on('scroll-up', this.scrollUp); 33 | shortcut.Manager.on('scroll-top', this.scrollTop); 34 | shortcut.Manager.on('scroll-bottom', this.scrollBottom); 35 | shortcut.Manager.on('page-down', this.pageDown); 36 | shortcut.Manager.on('page-up', this.pageUp); 37 | } 38 | 39 | current(): Channel { 40 | return Channel.current(this.props.channels); 41 | } 42 | 43 | logElement(log: Log): React.ReactElement { 44 | let className = 'log'; 45 | if (log.adjacent) { 46 | className += ' adjacent'; 47 | } 48 | if (log.sentByMe) { 49 | className += ' sent-by-me'; 50 | } 51 | 52 | return
  • ; 54 | } 55 | 56 | componentWillUpdate(nextProps: BufferViewProps) { 57 | let view = this.view(); 58 | let isAtBottom = view.scrollHeight - view.clientHeight - view.scrollTop < followLogBuffer; 59 | let isChanged = this.currentBuffer !== Channel.current(nextProps.channels).name; 60 | this.isFollowingLog = isAtBottom || isChanged; 61 | } 62 | 63 | render() { 64 | this.currentBuffer = this.current().name; 65 | return ( 66 |
    67 |
      {this.current().logs.map(this.logElement)}
    68 |
    69 | ); 70 | } 71 | 72 | componentDidUpdate() { 73 | if (this.isFollowingLog) { 74 | let view = this.view(); 75 | view.scrollTop = view.scrollHeight; 76 | } 77 | 78 | this.loadImages(); 79 | } 80 | 81 | scrollDown() { 82 | let view = this.view(); 83 | let logs = _.toArray(view.getElementsByTagName('li')); 84 | 85 | let logToScroll = null; 86 | for (let i = 0; i < logs.length; i++) { 87 | let log = logs[i]; 88 | if (log.offsetTop < view.scrollTop - minimumScrollHeight) { 89 | logToScroll = log; 90 | } else { 91 | break; 92 | } 93 | } 94 | let scrollTop = logToScroll ? logToScroll.offsetTop : 0; 95 | view.scrollTop = scrollTop; 96 | } 97 | 98 | scrollUp() { 99 | let view = this.view(); 100 | let logs = _.toArray(view.getElementsByTagName('li')); 101 | 102 | let logToScroll = null; 103 | for (let i = 0; i < logs.length; i++) { 104 | let log = logs[i]; 105 | if (log.offsetTop > view.scrollTop + minimumScrollHeight) { 106 | logToScroll = log; 107 | break; 108 | } 109 | } 110 | let scrollTop = logToScroll ? logToScroll.offsetTop : 0; 111 | view.scrollTop = scrollTop; 112 | } 113 | 114 | scrollTop() { 115 | let view = this.view(); 116 | view.scrollTop = 0; 117 | } 118 | 119 | scrollBottom() { 120 | let view = this.view(); 121 | view.scrollTop = view.scrollHeight; 122 | } 123 | 124 | pageDown() { 125 | let view = this.view(); 126 | view.scrollTop = view.scrollTop + view.clientHeight; 127 | } 128 | 129 | pageUp() { 130 | let view = this.view(); 131 | view.scrollTop = view.scrollTop - view.clientHeight; 132 | } 133 | 134 | loadImages() { 135 | let view = this.view(); 136 | let images = view.getElementsByTagName('img'); 137 | Array.prototype.forEach.call(images, (image) => { 138 | if (!image.classList.contains('loaded')) { 139 | imageLib.getMeta(image.src, (width, height) => { 140 | let mediaElement = image.parentElement.parentElement; 141 | let maxWidth = parseInt(mediaElement.clientWidth, 10); 142 | let maxHeight = parseInt(getComputedStyle(image).maxHeight, 10); 143 | if (maxWidth < width) { 144 | height = height * maxWidth / width; 145 | width = maxWidth; 146 | } 147 | if (maxHeight < height) { 148 | width = width * maxHeight / height; 149 | height = maxHeight; 150 | } 151 | image.style.width = width + 'px'; 152 | image.style.height = height + 'px'; 153 | image.classList.add('loaded'); 154 | if (this.isFollowingLog) { 155 | view.scrollTop += mediaElement.offsetHeight; 156 | } 157 | }, function () { 158 | image.remove(); 159 | }); 160 | } 161 | }); 162 | } 163 | } 164 | 165 | export = BufferView; 166 | -------------------------------------------------------------------------------- /renderer/input-box.tsx: -------------------------------------------------------------------------------- 1 | import _ = require('underscore'); 2 | import Autocompleter = require('./lib/autocompleter'); 3 | import configuration = require('./lib/configuration'); 4 | import InputHistory = require('./lib/input-history'); 5 | import ipc = require('./lib/ipc'); 6 | import Name = require('./lib/name'); 7 | import React = require('react'); 8 | import ReactComponent = require('./lib/react-component'); 9 | import shortcut = require('./lib/shortcut-manager'); 10 | 11 | const rootBufferName = configuration.get('app', 'root-buffer-name'); 12 | const commandSymbol = configuration.get('app', 'command-symbol'); 13 | 14 | interface InputBoxProps { 15 | names: Name[]; 16 | channel: string; 17 | submit: (text: string) => void; 18 | } 19 | 20 | class InputBox extends ReactComponent { 21 | inputHistory: InputHistory; 22 | autocompleter: Autocompleter; 23 | 24 | constructor() { 25 | super(); 26 | this.inputHistory = new InputHistory(); 27 | this.autocompleter = new Autocompleter(); 28 | } 29 | 30 | componentDidMount() { 31 | shortcut.Manager.on('message', this.onMessageKey); 32 | shortcut.Manager.on('command', this.onCommandKey); 33 | shortcut.Manager.on('exit', this.onExitKey); 34 | shortcut.Manager.on('input-history-back', () => this.onInputHistoryKey(+1)); 35 | shortcut.Manager.on('input-history-forward', () => this.onInputHistoryKey(-1)); 36 | shortcut.Manager.on('autocomplete', this.onAutocompleteKey); 37 | 38 | // window events 39 | window.addEventListener('click', this.focus); 40 | ipc.on('focus', this.focus); 41 | ipc.on('blur', this.blur); 42 | } 43 | 44 | onMessageKey() { 45 | let input = React.findDOMNode(this.refs['input']); 46 | if (input.value.startsWith(commandSymbol)) { 47 | input.value = ''; 48 | } 49 | this.focus(); 50 | } 51 | 52 | onCommandKey() { 53 | let input = React.findDOMNode(this.refs['input']); 54 | if (!input.value.startsWith(commandSymbol)) { 55 | input.value = commandSymbol; 56 | } 57 | this.focus(); 58 | } 59 | 60 | onExitKey() { 61 | this.blur(); 62 | } 63 | 64 | onInputHistoryKey(idxDiff: number) { 65 | let input = React.findDOMNode(this.refs['input']); 66 | if ((input as any).matches(':focus')) { 67 | if (this.inputHistory.index() < 0) { 68 | this.inputHistory.setTempInput(input.value); 69 | } 70 | this.inputHistory.moveIndex(idxDiff); 71 | input.value = this.inputHistory.currentText(); 72 | } 73 | } 74 | 75 | onAutocompleteKey() { 76 | let input = React.findDOMNode(this.refs['input']); 77 | let value = input.value; 78 | if ((input as any).matches(':focus') && value.length > 0) { 79 | let caretIdx = input.selectionStart; 80 | let wordIdx = value.lastIndexOf(' ', caretIdx - 1) + 1; 81 | let word = value.substring(wordIdx, caretIdx); 82 | 83 | if (word) { 84 | this.autocompleter.setNames(this.props.names.map(n => n.nick)); 85 | let wordToReplace = this.autocompleter.complete(word); 86 | if (wordToReplace) { 87 | input.value = value.substring(0, wordIdx) + wordToReplace + value.substring(caretIdx); 88 | let newCaretIdx = wordIdx + wordToReplace.length; 89 | input.setSelectionRange(newCaretIdx, newCaretIdx); 90 | } 91 | } 92 | } 93 | } 94 | 95 | render() { 96 | return ( 97 |
    98 |
    99 | 100 |
    101 |
    102 | ); 103 | } 104 | 105 | shouldComponentUpdate(nextProps: InputBoxProps): boolean { 106 | let input = React.findDOMNode(this.refs['input']); 107 | return !(input as any).matches(':focus'); 108 | } 109 | 110 | submit(e: React.FormEvent) { 111 | e.preventDefault(); 112 | let input = React.findDOMNode(this.refs['input']); 113 | let inputValue = input.value; 114 | if (inputValue.length > 0) { 115 | this.props.submit(inputValue); 116 | this.inputHistory.add(inputValue); 117 | this.inputHistory.reset(); 118 | } 119 | input.value = ''; 120 | this.autocompleter.reset(); 121 | } 122 | 123 | focus() { 124 | let input = React.findDOMNode(this.refs['input']); 125 | input.focus(); 126 | } 127 | 128 | blur() { 129 | let input = React.findDOMNode(this.refs['input']); 130 | input.blur(); 131 | } 132 | 133 | keyDown(e: React.KeyboardEvent) { 134 | let nativeEvent = e.nativeEvent as KeyboardEvent; 135 | let modified = _.some(['Alt', 'Control', 'Meta', 'Shift'], e.getModifierState.bind(e)); 136 | let special = _.contains(_.keys(shortcut.specialKeys), nativeEvent.keyIdentifier); 137 | let arrow = _.contains(['Up', 'Down'], nativeEvent.keyIdentifier); 138 | if (!(modified || special || arrow)) { 139 | e.stopPropagation(); 140 | } 141 | 142 | if (nativeEvent.keyIdentifier === _.invert(shortcut.specialKeys)['tab']) { 143 | e.preventDefault(); 144 | } else { 145 | this.autocompleter.reset(); 146 | } 147 | } 148 | } 149 | 150 | export = InputBox; 151 | -------------------------------------------------------------------------------- /renderer/lib/shortcut-manager.ts: -------------------------------------------------------------------------------- 1 | import _ = require('underscore'); 2 | import configuration = require('./configuration'); 3 | 4 | export const specialKeys: IDict = { 5 | 'U+001B': 'escape', 6 | 'U+0020': 'space', 7 | 'U+0008': 'backspace', 8 | 'U+007F': 'delete', 9 | 'U+0009': 'tab', 10 | }; 11 | 12 | const keyAlias: IDict = { 13 | 'esc': 'escape', 14 | 'ctrl': 'control', 15 | 'cmd': 'meta', 16 | }; 17 | 18 | const waiterClearTimeout = configuration.get('app', 'shortcut-serial-input-timeout'); 19 | 20 | class KeyWaiter { 21 | eventName: string; 22 | private _waitingKeys: IShortcutKeyInput[]; 23 | 24 | constructor(eventName: string, keys) { 25 | this.eventName = eventName; 26 | this._waitingKeys = keys; 27 | } 28 | 29 | static matches(configInput: IShortcutKeyInput, key: string, modifierState: IModifierState) { 30 | return configInput.key === key && 31 | (!configInput.modifier || modifierState[configInput.modifier]); 32 | } 33 | 34 | isWaiting(key: string, modifierState: IModifierState) { 35 | return KeyWaiter.matches(this._waitingKeys[0], key, modifierState); 36 | } 37 | 38 | consumeOne() { 39 | this._waitingKeys.shift(); 40 | } 41 | 42 | consume(key: string, modifierState: IModifierState) { 43 | if (this.isWaiting(key, modifierState)) { 44 | this.consumeOne(); 45 | } 46 | } 47 | 48 | isDone(): boolean { 49 | return this._waitingKeys.length === 0; 50 | } 51 | } 52 | 53 | class ShortcutManager { 54 | private config: IShortcutKeyConfig[]; 55 | private _handlers: {[eventName: string]: IShortcutCallback[]}; 56 | private _waiters: KeyWaiter[]; 57 | private _waiterClearTimer: number; 58 | 59 | constructor(rawConfig: any) { 60 | this.config = this.parseRawConfig(rawConfig); 61 | this._handlers = {}; 62 | this._waiters = []; 63 | this._waiterClearTimer = null; 64 | } 65 | 66 | parseRawConfig(rawConfig: any): IShortcutKeyConfig[] { 67 | return _.pairs(rawConfig).map(function (pair) { 68 | let action = pair[0]; 69 | let shortcuts = pair[1].map(function (keyStr) { 70 | let keys = keyStr.includes('>') ? keyStr.split('>') : [keyStr]; 71 | return keys.map(function (keyString) { 72 | let pair = keyString.split('+'); 73 | let key, modifier; 74 | if (pair.length === 2) { 75 | modifier = pair[0].toLowerCase(); 76 | key = pair[1].toLowerCase(); 77 | } else { 78 | key = pair[0]; 79 | } 80 | 81 | modifier = keyAlias[modifier] ? keyAlias[modifier] : modifier; 82 | key = keyAlias[key] ? keyAlias[key] : key; 83 | return {key, modifier}; 84 | }); 85 | }); 86 | return {action, shortcuts}; 87 | }); 88 | } 89 | 90 | initialize() { 91 | window.addEventListener('keydown', (e: KeyboardEvent) => { 92 | let key = e.keyIdentifier; 93 | if (key.startsWith('U+')) { 94 | key = specialKeys[key] 95 | ? specialKeys[key] 96 | : (String).fromCodePoint(parseInt(key.substring(2), 16)); 97 | } 98 | 99 | if (typeof key === 'string') { 100 | let modifierState = this.modifierState(e); 101 | key = (/^[a-zA-Z]$/.exec(key) && modifierState.shift) ? key.toUpperCase() : key.toLowerCase(); 102 | this.keyEventHandler(key, modifierState); 103 | } 104 | }); 105 | } 106 | 107 | modifierState(e: KeyboardEvent): IModifierState { 108 | return ['Alt', 'Control', 'Meta', 'Shift'].reduce((result, key) => 109 | _.extend(result, {[key.toLowerCase()]: e.getModifierState(key)}), {} as IModifierState); 110 | } 111 | 112 | keyEventHandler(key: string, modifierState: IModifierState) { 113 | if (this._waiters.length > 0) { 114 | for (let waiter of this._waiters) { 115 | waiter.consume(key, modifierState); 116 | if (waiter.isDone()) { 117 | this.happen(waiter.eventName); 118 | this.clearWaiters(); 119 | return; 120 | } 121 | this.resetWaiterClearTimer(); 122 | } 123 | } else { 124 | for (let config of this.config) { 125 | for (let inputs of config.shortcuts) { 126 | if (KeyWaiter.matches(inputs[0], key, modifierState)) { 127 | if (inputs.length === 1) { 128 | this.happen(config.action); 129 | return; 130 | } else { 131 | this._waiters.push(new KeyWaiter(config.action, 132 | _.tail(inputs))); 133 | this.resetWaiterClearTimer(); 134 | } 135 | } 136 | } 137 | } 138 | } 139 | } 140 | 141 | resetWaiterClearTimer() { 142 | clearTimeout(this._waiterClearTimer); 143 | this._waiterClearTimer = setTimeout(() => this.clearWaiters(), 144 | waiterClearTimeout); 145 | } 146 | 147 | clearWaiters() { 148 | clearTimeout(this._waiterClearTimer); 149 | this._waiterClearTimer = null; 150 | this._waiters = []; 151 | } 152 | 153 | on(eventName: string, handler: IShortcutCallback) { 154 | let eventList = this._handlers[eventName]; 155 | if (_.isUndefined(eventList)) { 156 | this._handlers[eventName] = []; 157 | eventList = this._handlers[eventName]; 158 | } 159 | eventList.push(handler); 160 | } 161 | 162 | happen(eventName: string) { 163 | let eventList = this._handlers[eventName]; 164 | if (eventList) { 165 | eventList.forEach(eventHandler => 166 | setTimeout(eventHandler, 0)); 167 | } 168 | } 169 | 170 | off(eventName: string) { 171 | delete this._handlers[eventName]; 172 | } 173 | } 174 | 175 | export let Manager = new ShortcutManager(configuration.getConfig('keys')); 176 | -------------------------------------------------------------------------------- /doc/USERGUIDE.md: -------------------------------------------------------------------------------- 1 | # User Guide 2 | 3 | ## Install 4 | 5 | Currently, the application pacakge only for OS X is downloadable in the 6 | [releases](https://github.com/noraesae/koko/releases) page. To install, move 7 | the `koko.app` to `Applications` and that's it. Security setting may be needed 8 | to run `ココ` for recent versions of OS X. 9 | 10 | Packages for other platforms will be supported in the near future. 11 | 12 | ## Basic usage 13 | 14 | When connecting to a server properly, you'll see an IRC view. Basically, there's 15 | no much mouse interaction needed to use `ココ`. Everything can be done with 16 | a keyboard, so it's important to check the basic shortcuts and commands. 17 | shortcuts are surely configurable. How to configure them is described in the 18 | [configuration](CONFIGURATION.md) documentation. 19 | 20 | First, like using VIM's commmand and insert modes, we can focus the text 21 | input of the view pressing `i` or `/`. To return to the normal mode just press 22 | `esc` in the input mode. 23 | 24 | In the input mode, you can input commmands or just a message. The type and 25 | usage of commands are described later. 26 | 27 | In the normal mode, you can scroll logs, move to tabs or etc. The shortcuts are 28 | described in the following section. 29 | 30 | ## Default shortcuts 31 | 32 | The shortcuts are described in the format like below: 33 | 34 | ###### `(key)` (Working modes) 35 | (Description) 36 | 37 | * `(key)>(key)` means pressing the keys sequentially. 38 | * `(key)+(key)` means pressing the keys simultaniously. 39 | * `shift+(lower character)` can also be written `(upper character)`. 40 | 41 | #### `i` Normal 42 | Enter to input mode 43 | 44 | #### `/` Normal 45 | Enter to input mode, but with a command character `/` at the beginning 46 | 47 | #### `esc`, `ctrl+c` Input 48 | Exit from input mode, enter to normal mode 49 | 50 | #### `g>t` Normal 51 | Move to a next tab 52 | 53 | #### `g>T` Normal 54 | Move to a previous tab 55 | 56 | #### `j`, `k` Normal 57 | Scroll logs down and up 58 | 59 | #### `g>g`, `G` Normal 60 | Scroll logs to top and bottom 61 | 62 | #### `ctrl+f`, `ctrl+b` Normal 63 | Scroll logs one page down and up 64 | 65 | #### `up`, `down` Input 66 | Looking up input histories 67 | 68 | #### `tab` Input 69 | Autocomplete usernames 70 | 71 | 72 | ## Commands 73 | 74 | As explained in the [basic usage](#basic-usage) section, to input commands, `ココ` 75 | should be in the input mode. Commands start with a command character, `/` by default. 76 | This character is also configurable. About the configuration, please refer to 77 | the [configuration](CONFIGURATION.md) documentation. In this section, we'll explain 78 | with the default symbol `/`. 79 | 80 | The commands are described in the format like below: 81 | 82 | ###### `/(command) (...arguments)` 83 | (Description) 84 | 85 | * Arguments are space-separated. 86 | * `(?arg)` means it's optional. 87 | * `(...arg)` means it's a space-separated array. 88 | * `(!arg)` means it's a text that can contain spaces. It's always optional. 89 | 90 | #### `/join (channel)` 91 | Join a channel. 92 | 93 | * `#` can be omitted from the channel name. 94 | 95 | #### `/part (?channel) (!message)` 96 | Part a channel. 97 | 98 | * If no channel name is provided, part the current channel or personal chat. 99 | * If no message is provided, use the default message used in `node-irc`. 100 | 101 | #### `/ctcp (target) (type) (!text)` 102 | Send a ctcp. 103 | 104 | #### `/action (target) (!message)` 105 | Do an action. 106 | 107 | #### `/whois (nick)` 108 | See the information of a user. 109 | 110 | 111 | #### `/nick (nick)` 112 | Change my nick to the provided `nick`. 113 | 114 | #### `/mode (?channel) (mode) (nick)` 115 | Give a mode to a user in a channel 116 | 117 | * If no channel name is provided, use the current channel. 118 | * `mode` can be `+(mode character)`, `-(mode character)` and just `(mode character)`. 119 | If no `+` or `-` is provided, `+` is used by default. 120 | The mode character is like `v` or `o`. 121 | 122 | #### `/kick (?channel) (nick) (!message)` 123 | Kick a user from a channel. 124 | 125 | * If no channel name is provided, use the current channel. 126 | * If no message is provided, use the default message used in `node-irc`. 127 | 128 | #### `/ban (?channel) (nick)`, `/unban (?channel) (nick)` 129 | Just sugars for `/mode (channel) +/-b (nick)`. Ban or unban a user from a channel. 130 | 131 | #### `/kickban (?channel) (nick) (!message)` 132 | Just a sugar to do `/kick` and `/ban` at the same time. Kick and ban a user from a channel. 133 | 134 | #### `/topic (?channel) (!topic)` 135 | Set or show the topic of a channel. 136 | 137 | * If no channel name is provided, use the current channel. 138 | * If no parameter is provided at all, just show the topic of the current channel. 139 | 140 | #### `/msg (nick) (!message)` 141 | Send a personal message and start a personal chat. 142 | 143 | #### `/quote (command) (...args)`, `/raw (command) (...args)` 144 | Send a raw IRC message. 145 | 146 | ### For developers 147 | 148 | This small section is for developers finding information about implementing 149 | or fixing commands. If not interested, please ignore this section. 150 | 151 | Most of the commands are placed in `irc-command.ts` in browser. However, some 152 | cases are handled in renderer process, specifically in `irc-view.ts`. Here are 153 | the cases. 154 | 155 | * `/topic` without any parameter 156 | * `/part` from a personal chat 157 | * `/msg` to open a new buffer. 158 | 159 | The reason they aren't handled in `irc-command.ts`, but in `irc-view.ts`, is that 160 | the cases cannot or needn't be handled in backend with `node-irc`. You can find 161 | the local handler, `tryHandleLocally()` in `irc-view.ts`. 162 | 163 | We know it doesn't seem good, but we're afriad they'll remain for the time being. 164 | 165 | ## Need more help! 166 | 167 | Please... 168 | 169 | * Upload an issue in [Issues](https://github.com/noraesae/koko/issues) 170 | * Drop an email to [contributors](https://github.com/noraesae/koko/graphs/contributors) 171 | -------------------------------------------------------------------------------- /renderer/log-content.tsx: -------------------------------------------------------------------------------- 1 | import _ = require('underscore'); 2 | import Color = require('./lib/irc-color'); 3 | import Log = require('./lib/log'); 4 | import moment = require('moment'); 5 | import React = require('react'); 6 | import ReactComponent = require('./lib/react-component'); 7 | 8 | const urlRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([^\s'"`]*)/; 9 | const youtubeRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\?(?:\S*?&?v\=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/; 10 | 11 | interface LogContentProps { 12 | userNick: string; 13 | log: Log; 14 | } 15 | 16 | interface Media { 17 | type: string; 18 | uuid?: string; 19 | url?: string; 20 | } 21 | 22 | class LogContent extends ReactComponent { 23 | media: Media 24 | render() { 25 | let date = moment(this.props.log.datetime).format('ddd D MMM h:mma'); 26 | return ( 27 |
    28 |
    29 | {this.props.log.nick} 30 | {date} 31 |
    32 |
    33 |
    {this.textNode()}
    34 |
    {this.mediaNode()}
    35 |
    36 |
    37 | ); 38 | } 39 | 40 | textNode(): React.ReactElement[] { 41 | let lines = this.props.log.text.split("\n").map(line => { 42 | let colors = Color.parse(line); 43 | if (colors.length === 0) { 44 | return this.parseURL(line); 45 | } else { 46 | return this.coloredText(line, colors); 47 | } 48 | }); 49 | 50 | return lines.reduce((result, line, idx) => { 51 | if (idx === lines.length - 1) { 52 | return result.concat(line); 53 | } else { 54 | return result.concat([line,
    ]); 55 | } 56 | }, []); 57 | } 58 | 59 | subtractIdx(idx: number) { 60 | return color => { 61 | color.index -= idx; 62 | return color; 63 | } 64 | } 65 | 66 | coloredText(text: string, colors: Color[]): React.ReactElement | string { 67 | let head = _.head(colors); 68 | let tail = _.tail(colors); 69 | if (!head) { 70 | return this.parseURL(text); 71 | } else { 72 | if (head.type === 'close') { 73 | return this.coloredText(text, tail); 74 | } 75 | 76 | let className; 77 | if (head.type === 'color') { 78 | className = head.color; 79 | if (head.bgColor) { 80 | className += ` bg-${head.bgColor}`; 81 | } 82 | } else { 83 | className = head.type; 84 | } 85 | 86 | let close; 87 | if (head.type === 'reverse') { 88 | close = _.find(tail, c => (c.type === 'close' || c.type === 'reverse')); 89 | } else { 90 | close = _.find(tail, c => c.type === 'close'); 91 | } 92 | 93 | if (close) { 94 | let colorIdx = head.index + head.length; 95 | let closeIdx = close.index + close.length; 96 | return ( 97 | 98 | {this.parseURL(text.substring(0, head.index))} 99 | 100 | {this.coloredText(text.substring(colorIdx, close.index), 101 | _.first(tail, tail.indexOf(close)).map(this.subtractIdx(colorIdx)))} 102 | 103 | {this.coloredText(text.substring(closeIdx), 104 | _.rest(tail, tail.indexOf(close) + 1).map(this.subtractIdx(closeIdx)))} 105 | 106 | ); 107 | } else { 108 | let colorIdx = head.index + head.length; 109 | return ( 110 | 111 | {this.parseURL(text.substring(0, head.index))} 112 | 113 | {this.coloredText(text.substring(head.index + head.length), 114 | tail.map(this.subtractIdx(colorIdx)))} 115 | 116 | 117 | ); 118 | } 119 | } 120 | } 121 | 122 | parseURL(text: string): React.ReactElement | string { 123 | let urlMatch = urlRegex.exec(text); 124 | if (urlMatch) { 125 | let url = urlMatch[0]; 126 | let lowercasedURL = url.toLowerCase(); 127 | let youtubeMatch; 128 | if (youtubeMatch = youtubeRegex.exec(url)) { 129 | this.media = { 130 | type: 'youtube', 131 | uuid: youtubeMatch[1], 132 | }; 133 | } else if (lowercasedURL.endsWith('.jpg') || lowercasedURL.endsWith('.jpeg') 134 | || lowercasedURL.endsWith('.png') || lowercasedURL.endsWith('.gif')) { 135 | this.media = { 136 | type: 'image', 137 | url: url, 138 | }; 139 | } 140 | return ( 141 | 142 | {this.highlightNickname(text.substring(0, urlMatch.index))} 143 | {url} 144 | {this.parseURL(text.substring(urlMatch.index + url.length))} 145 | 146 | ); 147 | } else { 148 | return this.highlightNickname(text); 149 | } 150 | } 151 | 152 | highlightNickname(text: string): React.ReactElement | string { 153 | let userNick = this.props.userNick; 154 | if (!userNick) { 155 | return this.processSpace(text); 156 | } 157 | 158 | if (this.props.log.nick === this.props.userNick) { 159 | // don't highlight channel messages 160 | return this.processSpace(text); 161 | } 162 | 163 | let nickIndex = text.indexOf(userNick); 164 | if (nickIndex >= 0) { 165 | return ( 166 | 167 | {this.processSpace(text.substring(0, nickIndex))} 168 | {userNick} 169 | {this.highlightNickname(text.substring(nickIndex + userNick.length))} 170 | 171 | ); 172 | } else { 173 | return this.processSpace(text); 174 | } 175 | } 176 | 177 | processSpace(text: string): string { 178 | return text.replace(/ /g, '\u00A0'); 179 | } 180 | 181 | mediaNode(): React.ReactElement { 182 | let mediaElement = null; 183 | if (this.media) { 184 | switch (this.media.type) { 185 | case 'image': 186 | mediaElement = 187 | 188 | ; 189 | break; 190 | case 'youtube': 191 | let embedSrc = `https://www.youtube.com/embed/${this.media.uuid}?rel=0`; 192 | mediaElement =