diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..d6906c1 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,55 @@ +{ + "env": { + "browser": true, + "es6": true, + "node": true + }, + "extends": [ + "plugin:react/recommended", + "plugin:prettier/recommended", + "prettier/react" + ], + "globals": { + "Atomics": "readonly", + "SharedArrayBuffer": "readonly", + "React": true + }, + "parserOptions": { + "ecmaFeatures": { + "jsx": true + }, + "ecmaVersion": 2018, + "sourceType": "module" + }, + "plugins": [ + "prettier", + "react" + ], + "settings": { + "react": { + "version": "detect" + } + }, + "ignorePatterns": [ + "src/infrastructure/", + "src/react-lazyload/" + ], + "rules": { + "prettier/prettier": "warn", + "react/jsx-indent": [ + "error", + 2, + { + "indentLogicalExpressions": true + } + ], + "react/prop-types": "off", + "react/jsx-no-target-blank": "off", + "no-unused-vars": [ + "warn", + { + "args": "none" + } + ] + } +} \ No newline at end of file diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..a0d5e07 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,7 @@ +module.exports = { + trailingComma: 'all', + tabWidth: 2, + semi: true, + singleQuote: true, + endOfLine: 'auto', +} \ No newline at end of file diff --git a/src/App.js b/src/App.js index 397367e..a94a640 100644 --- a/src/App.js +++ b/src/App.js @@ -1,131 +1,151 @@ -import React, {Component} from 'react'; -import {Flow} from './Flows'; -import {Title} from './Title'; -import {Sidebar} from './Sidebar'; -import {PressureHelper} from './PressureHelper'; -import {TokenCtx} from './UserAction'; -import {load_config,bgimg_style} from './Config'; -import {listen_darkmode} from './infrastructure/functions'; -import {LoginPopup, TitleLine} from './infrastructure/widgets'; +import React, { Component } from 'react'; +import { Flow } from './Flows'; +import { Title } from './Title'; +import { Sidebar } from './Sidebar'; +import { PressureHelper } from './PressureHelper'; +import { TokenCtx } from './UserAction'; +import { load_config, bgimg_style } from './Config'; +import { listen_darkmode } from './infrastructure/functions'; +import { LoginPopup, TitleLine } from './infrastructure/widgets'; -const MAX_SIDEBAR_STACK_SIZE=10; +const MAX_SIDEBAR_STACK_SIZE = 10; function DeprecatedAlert(props) { - return ( -
- ); + return
; } class App extends Component { - constructor(props) { - super(props); - load_config(); - listen_darkmode({default: undefined, light: false, dark: true}[window.config.color_scheme]); - this.state={ - sidebar_stack: [[null,null]], // list of [status, content] - mode: 'list', // list, single, search, attention - search_text: null, - flow_render_key: +new Date(), - token: localStorage['TOKEN']||null, - }; - this.show_sidebar_bound=this.show_sidebar.bind(this); - this.set_mode_bound=this.set_mode.bind(this); - this.on_pressure_bound=this.on_pressure.bind(this); - // a silly self-deceptive approach to ban guests, enough to fool those muggles - // document cookie 'pku_ip_flag=yes' - this.inthu_flag=window[atob('ZG9jdW1lbnQ')][atob('Y29va2ll')].indexOf(atob('dGh1X2lwX2ZsYWc9eWVz'))!==-1; - } + constructor(props) { + super(props); + load_config(); + listen_darkmode( + { default: undefined, light: false, dark: true }[ + window.config.color_scheme + ], + ); + this.state = { + sidebar_stack: [[null, null]], // list of [status, content] + mode: 'list', // list, single, search, attention + search_text: null, + flow_render_key: +new Date(), + token: localStorage['TOKEN'] || null, + }; + this.show_sidebar_bound = this.show_sidebar.bind(this); + this.set_mode_bound = this.set_mode.bind(this); + this.on_pressure_bound = this.on_pressure.bind(this); + // a silly self-deceptive approach to ban guests, enough to fool those muggles + // document cookie 'pku_ip_flag=yes' + this.inthu_flag = + window[atob('ZG9jdW1lbnQ')][atob('Y29va2ll')].indexOf( + atob('dGh1X2lwX2ZsYWc9eWVz'), + ) !== -1; + } - static is_darkmode() { - if(window.config.color_scheme==='dark') return true; - if(window.config.color_scheme==='light') return false; - else { // 'default' - return window.matchMedia('(prefers-color-scheme: dark)').matches; - } + static is_darkmode() { + if (window.config.color_scheme === 'dark') return true; + if (window.config.color_scheme === 'light') return false; + else { + // 'default' + return window.matchMedia('(prefers-color-scheme: dark)').matches; } + } - on_pressure() { - if(this.state.sidebar_stack.length>1) - this.show_sidebar(null,null,'clear'); - else - this.set_mode('list',null); - } + on_pressure() { + if (this.state.sidebar_stack.length > 1) + this.show_sidebar(null, null, 'clear'); + else this.set_mode('list', null); + } - show_sidebar(title,content,mode='push') { - this.setState((prevState)=>{ - let ns=prevState.sidebar_stack.slice(); - if(mode==='push') { - if(ns.length>MAX_SIDEBAR_STACK_SIZE) - ns.splice(1,1); - ns=ns.concat([[title,content]]); - } else if(mode==='pop') { - if(ns.length===1) return; - ns.pop(); - } else if(mode==='replace') { - ns.pop(); - ns=ns.concat([[title,content]]); - } else if(mode==='clear') { - ns=[[null,null]]; - } else - throw new Error('bad show_sidebar mode'); - return { - sidebar_stack: ns, - }; - }); - } + show_sidebar(title, content, mode = 'push') { + this.setState((prevState) => { + let ns = prevState.sidebar_stack.slice(); + if (mode === 'push') { + if (ns.length > MAX_SIDEBAR_STACK_SIZE) ns.splice(1, 1); + ns = ns.concat([[title, content]]); + } else if (mode === 'pop') { + if (ns.length === 1) return; + ns.pop(); + } else if (mode === 'replace') { + ns.pop(); + ns = ns.concat([[title, content]]); + } else if (mode === 'clear') { + ns = [[null, null]]; + } else throw new Error('bad show_sidebar mode'); + return { + sidebar_stack: ns, + }; + }); + } - set_mode(mode,search_text) { - this.setState({ - mode: mode, - search_text: search_text, - flow_render_key: +new Date(), - }); - } + set_mode(mode, search_text) { + this.setState({ + mode: mode, + search_text: search_text, + flow_render_key: +new Date(), + }); + } - render() { - return ( - { - localStorage['TOKEN']=x||''; - this.setState({ - token: x, - }); - }, - }}> - -
- - <TokenCtx.Consumer>{(token)=>( - <div className="left-container"> - <DeprecatedAlert token={token.value} /> - {!token.value && - <div className="flow-item-row aux-margin"> - <div className="box box-tip"> - <p> - <LoginPopup token_callback={token.set_value}>{(do_popup)=>( - <a onClick={do_popup}> - <span className="icon icon-login" /> -  登录到 T大树洞 - </a> - )}</LoginPopup> - </p> - </div> - </div> - } - {this.inthu_flag||token.value ? - <Flow key={this.state.flow_render_key} show_sidebar={this.show_sidebar_bound} - mode={this.state.mode} search_text={this.state.search_text} token={token.value} - /> : - <TitleLine text="请登录后查看内容" /> - } - <br /> - </div> - )}</TokenCtx.Consumer> - <Sidebar show_sidebar={this.show_sidebar_bound} stack={this.state.sidebar_stack} /> - </TokenCtx.Provider> - ); - } + render() { + return ( + <TokenCtx.Provider + value={{ + value: this.state.token, + set_value: (x) => { + localStorage['TOKEN'] = x || ''; + this.setState({ + token: x, + }); + }, + }} + > + <PressureHelper callback={this.on_pressure_bound} /> + <div className="bg-img" style={bgimg_style()} /> + <Title + show_sidebar={this.show_sidebar_bound} + set_mode={this.set_mode_bound} + /> + <TokenCtx.Consumer> + {(token) => ( + <div className="left-container"> + <DeprecatedAlert token={token.value} /> + {!token.value && ( + <div className="flow-item-row aux-margin"> + <div className="box box-tip"> + <p> + <LoginPopup token_callback={token.set_value}> + {(do_popup) => ( + <a onClick={do_popup}> + <span className="icon icon-login" /> +  登录到 T大树洞 + </a> + )} + </LoginPopup> + </p> + </div> + </div> + )} + {this.inthu_flag || token.value ? ( + <Flow + key={this.state.flow_render_key} + show_sidebar={this.show_sidebar_bound} + mode={this.state.mode} + search_text={this.state.search_text} + token={token.value} + /> + ) : ( + <TitleLine text="请登录后查看内容" /> + )} + <br /> + </div> + )} + </TokenCtx.Consumer> + <Sidebar + show_sidebar={this.show_sidebar_bound} + stack={this.state.sidebar_stack} + /> + </TokenCtx.Provider> + ); + } } export default App; diff --git a/src/AudioWidget.js b/src/AudioWidget.js index 1298db8..a1b8937 100644 --- a/src/AudioWidget.js +++ b/src/AudioWidget.js @@ -1,91 +1,92 @@ -import React, {Component} from 'react'; +import React, { Component } from 'react'; import load from 'load-script'; -window.audio_cache={}; +window.audio_cache = {}; function load_amrnb() { - return new Promise((resolve,reject)=>{ - if(window.AMR) - resolve(); - else - load('static/amr_all.min.js', (err)=>{ - if(err) - reject(err); - else - resolve(); - }); - }); + return new Promise((resolve, reject) => { + if (window.AMR) resolve(); + else + load('static/amr_all.min.js', (err) => { + if (err) reject(err); + else resolve(); + }); + }); } export class AudioWidget extends Component { - constructor(props) { - super(props); - this.state={ - url: this.props.src, - state: 'waiting', - data: null, - }; + constructor(props) { + super(props); + this.state = { + url: this.props.src, + state: 'waiting', + data: null, + }; + } + + load() { + if (window.audio_cache[this.state.url]) { + this.setState({ + state: 'loaded', + data: window.audio_cache[this.state.url], + }); + return; } - load() { - if(window.audio_cache[this.state.url]) { - this.setState({ - state: 'loaded', - data: window.audio_cache[this.state.url], - }); + console.log('fetching audio', this.state.url); + this.setState({ + state: 'loading', + }); + Promise.all([fetch(this.state.url), load_amrnb()]).then((res) => { + res[0].blob().then((blob) => { + const reader = new FileReader(); + reader.onload = (event) => { + const raw = new window.AMR().decode(event.target.result); + if (!raw) { + alert('audio decoding failed'); return; - } - - console.log('fetching audio',this.state.url); - this.setState({ - state: 'loading', - }); - Promise.all([ - fetch(this.state.url), - load_amrnb(), - ]) - .then((res)=>{ - res[0].blob().then((blob)=>{ - const reader=new FileReader(); - reader.onload=(event)=>{ - const raw=new window.AMR().decode(event.target.result); - if(!raw) { - alert('audio decoding failed'); - return; - } - const wave=window.PCMData.encode({ - sampleRate: 8000, - channelCount: 1, - bytesPerSample: 2, - data: raw - }); - const binary_wave=new Uint8Array(wave.length); - for(let i=0;i<wave.length;i++) - binary_wave[i]=wave.charCodeAt(i); + } + const wave = window.PCMData.encode({ + sampleRate: 8000, + channelCount: 1, + bytesPerSample: 2, + data: raw, + }); + const binary_wave = new Uint8Array(wave.length); + for (let i = 0; i < wave.length; i++) + binary_wave[i] = wave.charCodeAt(i); - const objurl=URL.createObjectURL(new Blob([binary_wave], {type: 'audio/wav'})); - window.audio_cache[this.state.url]=objurl; - this.setState({ - state: 'loaded', - data: objurl, - }); - }; - reader.readAsBinaryString(blob); - }); - this.setState({ - state: 'decoding', - }); - }); - } + const objurl = URL.createObjectURL( + new Blob([binary_wave], { type: 'audio/wav' }), + ); + window.audio_cache[this.state.url] = objurl; + this.setState({ + state: 'loaded', + data: objurl, + }); + }; + reader.readAsBinaryString(blob); + }); + this.setState({ + state: 'decoding', + }); + }); + } - render() { - if(this.state.state==='waiting') - return (<p><a onClick={this.load.bind(this)}>加载音频</a></p>); - if(this.state.state==='loading') - return (<p>正在下载……</p>); - else if(this.state.state==='decoding') - return (<p>正在解码……</p>); - else if(this.state.state==='loaded') - return (<p><audio src={this.state.data} controls /></p>); - } -} \ No newline at end of file + render() { + if (this.state.state === 'waiting') + return ( + <p> + <a onClick={this.load.bind(this)}>加载音频</a> + </p> + ); + if (this.state.state === 'loading') return <p>正在下载……</p>; + else if (this.state.state === 'decoding') return <p>正在解码……</p>; + else if (this.state.state === 'loaded') + return ( + <p> + <audio src={this.state.data} controls /> + </p> + ); + } +} diff --git a/src/Common.js b/src/Common.js index 99d5986..dd234d5 100644 --- a/src/Common.js +++ b/src/Common.js @@ -1,310 +1,415 @@ -import React, {Component, PureComponent} from 'react'; -import {format_time,Time,TitleLine} from './infrastructure/widgets'; -import {THUHOLE_API_ROOT} from './flows_api'; +import React, { Component, PureComponent } from 'react'; +import { format_time, Time, TitleLine } from './infrastructure/widgets'; +import { THUHOLE_API_ROOT } from './flows_api'; -import HtmlToReact from 'html-to-react' +import HtmlToReact from 'html-to-react'; import './Common.css'; -import { URL_PID_RE, URL_RE, PID_RE, NICKNAME_RE, split_text } from './text_splitter'; +import { + URL_PID_RE, + URL_RE, + PID_RE, + NICKNAME_RE, + split_text, +} from './text_splitter'; -import renderMd from './Markdown' +import renderMd from './Markdown'; -export {format_time,Time,TitleLine}; +export { format_time, Time, TitleLine }; -export const API_BASE=THUHOLE_API_ROOT+'services/thuhole'; +export const API_BASE = THUHOLE_API_ROOT + 'services/thuhole'; // https://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex function escape_regex(string) { - return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string } -export function build_highlight_re(txt,split,option='g') { - return txt ? new RegExp(`(${txt.split(split).filter((x)=>!!x).map(escape_regex).join('|')})`,option) : /^$/g; +export function build_highlight_re(txt, split, option = 'g') { + return txt + ? new RegExp( + `(${txt + .split(split) + .filter((x) => !!x) + .map(escape_regex) + .join('|')})`, + option, + ) + : /^$/g; } export function ColoredSpan(props) { - return ( - <span className="colored-span" style={{ - '--coloredspan-bgcolor-light': props.colors[0], - '--coloredspan-bgcolor-dark': props.colors[1], - }}>{props.children}</span> - ) + return ( + <span + className="colored-span" + style={{ + '--coloredspan-bgcolor-light': props.colors[0], + '--coloredspan-bgcolor-dark': props.colors[1], + }} + > + {props.children} + </span> + ); } - function normalize_url(url) { - return /^https?:\/\//.test(url) ? url : 'http://'+url; + return /^https?:\/\//.test(url) ? url : 'http://' + url; } export class HighlightedText extends PureComponent { - render() { - return ( - <pre> - {this.props.parts.map((part,idx)=>{ - let [rule,p]=part; - return ( - <span key={idx}>{ - rule==='url_pid' ? <span className="url-pid-link" title={p}>/##</span> : - rule==='url' ? <a href={normalize_url(p)} target="_blank" rel="noopener">{p}</a> : - rule==='pid' ? <a href={'#'+p} onClick={(e)=>{e.preventDefault(); this.props.show_pid(p.substring(1));}}>{p}</a> : - rule==='nickname' ? <ColoredSpan colors={this.props.color_picker.get(p)}>{p}</ColoredSpan> : - rule==='search' ? <span className="search-query-highlight">{p}</span> : - p - }</span> - ); - })} - </pre> - ) - } + render() { + return ( + <pre> + {this.props.parts.map((part, idx) => { + let [rule, p] = part; + return ( + <span key={idx}> + {rule === 'url_pid' ? ( + <span className="url-pid-link" title={p}> + /## + </span> + ) : rule === 'url' ? ( + <a href={normalize_url(p)} target="_blank" rel="noopener"> + {p} + </a> + ) : rule === 'pid' ? ( + <a + href={'#' + p} + onClick={(e) => { + e.preventDefault(); + this.props.show_pid(p.substring(1)); + }} + > + {p} + </a> + ) : rule === 'nickname' ? ( + <ColoredSpan colors={this.props.color_picker.get(p)}> + {p} + </ColoredSpan> + ) : rule === 'search' ? ( + <span className="search-query-highlight">{p}</span> + ) : ( + p + )} + </span> + ); + })} + </pre> + ); + } } // props: text, show_pid, color_picker export class HighlightedMarkdown extends Component { - render() { - const props = this.props - const processDefs = new HtmlToReact.ProcessNodeDefinitions(React) - const processInstructions = [ - { - shouldProcessNode: (node) => node.name === 'img', // disable images - processNode (node, children, index) { - return (<div key={index}>[图片]</div>) - } - }, - { - shouldProcessNode: (node) => (/^h[123456]$/.test(node.name)), - processNode (node, children, index) { - let currentLevel = +(node.name[1]) - if (currentLevel < 3) currentLevel = 3; - const HeadingTag = `h${currentLevel}` - return ( - <HeadingTag key={index}>{children}</HeadingTag> - ) - } - }, - { - shouldProcessNode: (node) => node.name === 'a', - processNode (node, children, index) { - return ( - <a href={normalize_url(node.attribs.href)} target="_blank" rel="noopenner noreferrer" className="ext-link" key={index}> - {children} - <span className="icon icon-new-tab" /> - </a> - ) - } - }, - { - shouldProcessNode (node) { - return node.type === 'text' && (!node.parent || !node.parent.attribs || node.parent.attribs['encoding'] != "application/x-tex") // pid, nickname, search - }, - processNode (node, children, index) { - const originalText = node.data - const splitted = split_text(originalText, [ - ['url_pid', URL_PID_RE], - ['url',URL_RE], - ['pid',PID_RE], - ['nickname',NICKNAME_RE], - ]) + render() { + const props = this.props; + const processDefs = new HtmlToReact.ProcessNodeDefinitions(React); + const processInstructions = [ + { + shouldProcessNode: (node) => node.name === 'img', // disable images + processNode(node, children, index) { + return <div key={index}>[图片]</div>; + }, + }, + { + shouldProcessNode: (node) => /^h[123456]$/.test(node.name), + processNode(node, children, index) { + let currentLevel = +node.name[1]; + if (currentLevel < 3) currentLevel = 3; + const HeadingTag = `h${currentLevel}`; + return <HeadingTag key={index}>{children}</HeadingTag>; + }, + }, + { + shouldProcessNode: (node) => node.name === 'a', + processNode(node, children, index) { + return ( + <a + href={normalize_url(node.attribs.href)} + target="_blank" + rel="noopenner noreferrer" + className="ext-link" + key={index} + > + {children} + <span className="icon icon-new-tab" /> + </a> + ); + }, + }, + { + shouldProcessNode(node) { + return ( + node.type === 'text' && + (!node.parent || + !node.parent.attribs || + node.parent.attribs['encoding'] != 'application/x-tex') + ); // pid, nickname, search + }, + processNode(node, children, index) { + const originalText = node.data; + const splitted = split_text(originalText, [ + ['url_pid', URL_PID_RE], + ['url', URL_RE], + ['pid', PID_RE], + ['nickname', NICKNAME_RE], + ]); - return ( - <React.Fragment key={index}> - {splitted.map(([rule, p], idx) => { - return (<span key={idx}> - { - rule==='url_pid' ? <span className="url-pid-link" title={p}>/##</span> : - rule==='url' ? <a href={normalize_url(p)} className="ext-link" target="_blank" rel="noopener noreferrer"> - {p} - <span className="icon icon-new-tab" /> - </a> : - rule==='pid' ? <a href={'#'+p} onClick={(e)=>{e.preventDefault(); props.show_pid(p.substring(1));}}>{p}</a> : - rule==='nickname' ? <ColoredSpan colors={props.color_picker.get(p)}>{p}</ColoredSpan> : - rule==='search' ? <span className="search-query-highlight">{p}</span> : - p} - </span>) - })} - </React.Fragment> - ) - } - }, - { - shouldProcessNode: () => true, - processNode: processDefs.processDefaultNode - } - ] - const parser = new HtmlToReact.Parser() - if (props.author && props.text.match(/^(?:#+ |>|```|\t|\s*-|\s*\d+\.)/)) { - const renderedMarkdown = renderMd(props.text) - return ( - <> - {props.author} - {parser.parseWithInstructions(renderedMarkdown, node => node.type !== 'script', processInstructions) || ''} - </> - ) - } else { - let rawMd = props.text - if (props.author) rawMd = props.author + ' ' + rawMd - const renderedMarkdown = renderMd(rawMd) - return (parser.parseWithInstructions(renderedMarkdown, node => node.type !== 'script', processInstructions) || null) - } + return ( + <React.Fragment key={index}> + {splitted.map(([rule, p], idx) => { + return ( + <span key={idx}> + {rule === 'url_pid' ? ( + <span className="url-pid-link" title={p}> + /## + </span> + ) : rule === 'url' ? ( + <a + href={normalize_url(p)} + className="ext-link" + target="_blank" + rel="noopener noreferrer" + > + {p} + <span className="icon icon-new-tab" /> + </a> + ) : rule === 'pid' ? ( + <a + href={'#' + p} + onClick={(e) => { + e.preventDefault(); + props.show_pid(p.substring(1)); + }} + > + {p} + </a> + ) : rule === 'nickname' ? ( + <ColoredSpan colors={props.color_picker.get(p)}> + {p} + </ColoredSpan> + ) : rule === 'search' ? ( + <span className="search-query-highlight">{p}</span> + ) : ( + p + )} + </span> + ); + })} + </React.Fragment> + ); + }, + }, + { + shouldProcessNode: () => true, + processNode: processDefs.processDefaultNode, + }, + ]; + const parser = new HtmlToReact.Parser(); + if (props.author && props.text.match(/^(?:#+ |>|```|\t|\s*-|\s*\d+\.)/)) { + const renderedMarkdown = renderMd(props.text); + return ( + <> + {props.author} + {parser.parseWithInstructions( + renderedMarkdown, + (node) => node.type !== 'script', + processInstructions, + ) || ''} + </> + ); + } else { + let rawMd = props.text; + if (props.author) rawMd = props.author + ' ' + rawMd; + const renderedMarkdown = renderMd(rawMd); + return ( + parser.parseWithInstructions( + renderedMarkdown, + (node) => node.type !== 'script', + processInstructions, + ) || null + ); } + } } -window.TEXTAREA_BACKUP={}; +window.TEXTAREA_BACKUP = {}; export class SafeTextarea extends Component { - constructor(props) { - super(props); - this.state={ - text: '', - }; - this.on_change_bound=this.on_change.bind(this); - this.on_keydown_bound=this.on_keydown.bind(this); - this.clear=this.clear.bind(this); - this.area_ref=React.createRef(); - this.change_callback=props.on_change||(()=>{}); - this.submit_callback=props.on_submit||(()=>{}); - } - - componentDidMount() { - this.setState({ - text: window.TEXTAREA_BACKUP[this.props.id]||'' - },()=>{ - this.change_callback(this.state.text); - }); - } + constructor(props) { + super(props); + this.state = { + text: '', + }; + this.on_change_bound = this.on_change.bind(this); + this.on_keydown_bound = this.on_keydown.bind(this); + this.clear = this.clear.bind(this); + this.area_ref = React.createRef(); + this.change_callback = props.on_change || (() => {}); + this.submit_callback = props.on_submit || (() => {}); + } - componentWillUnmount() { - window.TEXTAREA_BACKUP[this.props.id]=this.state.text; + componentDidMount() { + this.setState( + { + text: window.TEXTAREA_BACKUP[this.props.id] || '', + }, + () => { this.change_callback(this.state.text); - } + }, + ); + } - on_change(event) { - this.setState({ - text: event.target.value, - }); - this.change_callback(event.target.value); - } - on_keydown(event) { - if(event.key==='Enter' && event.ctrlKey && !event.altKey) { - event.preventDefault(); - this.submit_callback(); - } - } + componentWillUnmount() { + window.TEXTAREA_BACKUP[this.props.id] = this.state.text; + this.change_callback(this.state.text); + } - clear() { - this.setState({ - text: '', - }); - } - set(text) { - this.change_callback(text); - this.setState({ - text: text, - }); - } - get() { - return this.state.text; - } - focus() { - this.area_ref.current.focus(); + on_change(event) { + this.setState({ + text: event.target.value, + }); + this.change_callback(event.target.value); + } + on_keydown(event) { + if (event.key === 'Enter' && event.ctrlKey && !event.altKey) { + event.preventDefault(); + this.submit_callback(); } + } - render() { - return ( - <textarea ref={this.area_ref} onChange={this.on_change_bound} value={this.state.text} onKeyDown={this.on_keydown_bound} /> - ) - } + clear() { + this.setState({ + text: '', + }); + } + set(text) { + this.change_callback(text); + this.setState({ + text: text, + }); + } + get() { + return this.state.text; + } + focus() { + this.area_ref.current.focus(); + } + + render() { + return ( + <textarea + ref={this.area_ref} + onChange={this.on_change_bound} + value={this.state.text} + onKeyDown={this.on_keydown_bound} + /> + ); + } } -let pwa_prompt_event=null; +let pwa_prompt_event = null; window.addEventListener('beforeinstallprompt', (e) => { - console.log('pwa: received before install prompt'); - pwa_prompt_event=e; + console.log('pwa: received before install prompt'); + pwa_prompt_event = e; }); export function PromotionBar(props) { - let is_ios=/iPhone|iPad|iPod/i.test(window.navigator.userAgent); - let is_installed=(window.matchMedia('(display-mode: standalone)').matches) || (window.navigator.standalone); + let is_ios = /iPhone|iPad|iPod/i.test(window.navigator.userAgent); + let is_installed = + window.matchMedia('(display-mode: standalone)').matches || + window.navigator.standalone; - if(is_installed) - return null; + if (is_installed) return null; - if(is_ios) - // noinspection JSConstructorReturnsPrimitive - return !navigator.standalone ? ( - <div className="box promotion-bar"> - <span className="icon icon-about" />  - 用 Safari 把树洞 <b>添加到主屏幕</b> 更好用 - </div> - ) : null; - else - // noinspection JSConstructorReturnsPrimitive - return pwa_prompt_event ? ( - <div className="box promotion-bar"> - <span className="icon icon-about" />  - 把网页版树洞 <b><a onClick={()=>{ - if(pwa_prompt_event) - pwa_prompt_event.prompt(); - }}>安装到桌面</a></b> 更好用 - </div> - ) : null; + if (is_ios) + // noinspection JSConstructorReturnsPrimitive + return !navigator.standalone ? ( + <div className="box promotion-bar"> + <span className="icon icon-about" /> +   用 Safari 把树洞 <b>添加到主屏幕</b> 更好用 + </div> + ) : null; + // noinspection JSConstructorReturnsPrimitive + else + return pwa_prompt_event ? ( + <div className="box promotion-bar"> + <span className="icon icon-about" /> +   把网页版树洞{' '} + <b> + <a + onClick={() => { + if (pwa_prompt_event) pwa_prompt_event.prompt(); + }} + > + 安装到桌面 + </a> + </b>{' '} + 更好用 + </div> + ) : null; } export class ClickHandler extends PureComponent { - constructor(props) { - super(props); - this.state={ - moved: true, - init_y: 0, - init_x: 0, - }; - this.on_begin_bound=this.on_begin.bind(this); - this.on_move_bound=this.on_move.bind(this); - this.on_end_bound=this.on_end.bind(this); + constructor(props) { + super(props); + this.state = { + moved: true, + init_y: 0, + init_x: 0, + }; + this.on_begin_bound = this.on_begin.bind(this); + this.on_move_bound = this.on_move.bind(this); + this.on_end_bound = this.on_end.bind(this); - this.MOVE_THRESHOLD=3; - this.last_fire=0; - } + this.MOVE_THRESHOLD = 3; + this.last_fire = 0; + } - on_begin(e) { - //console.log('click',(e.touches?e.touches[0]:e).screenY,(e.touches?e.touches[0]:e).screenX); - this.setState({ - moved: false, - init_y: (e.touches?e.touches[0]:e).screenY, - init_x: (e.touches?e.touches[0]:e).screenX, - }); - } - on_move(e) { - if(!this.state.moved) { - let mvmt=Math.abs((e.touches?e.touches[0]:e).screenY-this.state.init_y)+Math.abs((e.touches?e.touches[0]:e).screenX-this.state.init_x); - //console.log('move',mvmt); - if(mvmt>this.MOVE_THRESHOLD) - this.setState({ - moved: true, - }); - } - } - on_end(event) { - //console.log('end'); - if(!this.state.moved) - this.do_callback(event); + on_begin(e) { + //console.log('click',(e.touches?e.touches[0]:e).screenY,(e.touches?e.touches[0]:e).screenX); + this.setState({ + moved: false, + init_y: (e.touches ? e.touches[0] : e).screenY, + init_x: (e.touches ? e.touches[0] : e).screenX, + }); + } + on_move(e) { + if (!this.state.moved) { + let mvmt = + Math.abs((e.touches ? e.touches[0] : e).screenY - this.state.init_y) + + Math.abs((e.touches ? e.touches[0] : e).screenX - this.state.init_x); + //console.log('move',mvmt); + if (mvmt > this.MOVE_THRESHOLD) this.setState({ - moved: true, + moved: true, }); } + } + on_end(event) { + //console.log('end'); + if (!this.state.moved) this.do_callback(event); + this.setState({ + moved: true, + }); + } - do_callback(event) { - if(this.last_fire+100>+new Date()) return; - this.last_fire=+new Date(); - this.props.callback(event); - } + do_callback(event) { + if (this.last_fire + 100 > +new Date()) return; + this.last_fire = +new Date(); + this.props.callback(event); + } - render() { - return ( - <div onTouchStart={this.on_begin_bound} onMouseDown={this.on_begin_bound} - onTouchMove={this.on_move_bound} onMouseMove={this.on_move_bound} - onClick={this.on_end_bound} > - {this.props.children} - </div> - ) - } + render() { + return ( + <div + onTouchStart={this.on_begin_bound} + onMouseDown={this.on_begin_bound} + onTouchMove={this.on_move_bound} + onMouseMove={this.on_move_bound} + onClick={this.on_end_bound} + > + {this.props.children} + </div> + ); + } } diff --git a/src/Config.js b/src/Config.js index ecd582b..e9869ca 100644 --- a/src/Config.js +++ b/src/Config.js @@ -1,255 +1,327 @@ -import React, {Component, PureComponent} from 'react'; +import React, { Component, PureComponent } from 'react'; import './Config.css'; -const BUILTIN_IMGS={ - 'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/gbp.jpg': '寻觅繁星(默认)', - 'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/eriri.jpg': '平成著名画师', - 'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/yurucamp.jpg': '露营天下第一', - 'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/minecraft.jpg': '麦恩·库拉夫特', - 'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/cyberpunk.jpg': '赛博城市', - 'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/bj.jpg': '城市的星光', - 'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/sif.jpg': '梦开始的地方', +const BUILTIN_IMGS = { + 'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/gbp.jpg': + '寻觅繁星(默认)', + 'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/eriri.jpg': + '平成著名画师', + 'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/yurucamp.jpg': + '露营天下第一', + 'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/minecraft.jpg': + '麦恩·库拉夫特', + 'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/cyberpunk.jpg': + '赛博城市', + 'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/bj.jpg': + '城市的星光', + 'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/sif.jpg': + '梦开始的地方', }; -const DEFAULT_CONFIG={ - background_img: 'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/gbp.jpg', - background_color: '#113366', - pressure: false, - easter_egg: true, - color_scheme: 'default', - fold: true +const DEFAULT_CONFIG = { + background_img: + 'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/gbp.jpg', + background_color: '#113366', + pressure: false, + easter_egg: true, + color_scheme: 'default', + fold: true, }; export function load_config() { - let config=Object.assign({},DEFAULT_CONFIG); - let loaded_config; - try { - loaded_config=JSON.parse(localStorage['hole_config']||'{}'); - } catch(e) { - alert('设置加载失败,将重置为默认设置!\n'+e); - delete localStorage['hole_config']; - loaded_config={}; - } + let config = Object.assign({}, DEFAULT_CONFIG); + let loaded_config; + try { + loaded_config = JSON.parse(localStorage['hole_config'] || '{}'); + } catch (e) { + alert('设置加载失败,将重置为默认设置!\n' + e); + delete localStorage['hole_config']; + loaded_config = {}; + } - // unrecognized configs are removed - Object.keys(loaded_config).forEach((key)=>{ - if(config[key]!==undefined) - config[key]=loaded_config[key]; - }); + // unrecognized configs are removed + Object.keys(loaded_config).forEach((key) => { + if (config[key] !== undefined) config[key] = loaded_config[key]; + }); - console.log('config loaded',config); - window.config=config; + console.log('config loaded', config); + window.config = config; } export function save_config() { - localStorage['hole_config']=JSON.stringify(window.config); - load_config(); + localStorage['hole_config'] = JSON.stringify(window.config); + load_config(); } -export function bgimg_style(img,color) { - if(img===undefined) img=window.config.background_img; - if(color===undefined) color=window.config.background_color; - return { - background: 'transparent center center', - backgroundImage: img===null ? 'unset' : 'url("'+encodeURI(img)+'")', - backgroundColor: color, - backgroundSize: 'cover', - }; +export function bgimg_style(img, color) { + if (img === undefined) img = window.config.background_img; + if (color === undefined) color = window.config.background_color; + return { + background: 'transparent center center', + backgroundImage: img === null ? 'unset' : 'url("' + encodeURI(img) + '")', + backgroundColor: color, + backgroundSize: 'cover', + }; } class ConfigBackground extends PureComponent { - constructor(props) { - super(props); - this.state={ - img: window.config.background_img, - color: window.config.background_color, - }; - } + constructor(props) { + super(props); + this.state = { + img: window.config.background_img, + color: window.config.background_color, + }; + } - save_changes() { - this.props.callback({ - background_img: this.state.img, - background_color: this.state.color, - }); - } + save_changes() { + this.props.callback({ + background_img: this.state.img, + background_color: this.state.color, + }); + } - on_select(e) { - let value=e.target.value; - this.setState({ - img: value==='##other' ? '' : - value==='##color' ? null : value, - },this.save_changes.bind(this)); - } - on_change_img(e) { - this.setState({ - img: e.target.value, - },this.save_changes.bind(this)); - } - on_change_color(e) { - this.setState({ - color: e.target.value, - },this.save_changes.bind(this)); - } + on_select(e) { + let value = e.target.value; + this.setState( + { + img: value === '##other' ? '' : value === '##color' ? null : value, + }, + this.save_changes.bind(this), + ); + } + on_change_img(e) { + this.setState( + { + img: e.target.value, + }, + this.save_changes.bind(this), + ); + } + on_change_color(e) { + this.setState( + { + color: e.target.value, + }, + this.save_changes.bind(this), + ); + } - render() { - let img_select= this.state.img===null ? '##color' : - Object.keys(BUILTIN_IMGS).indexOf(this.state.img)===-1 ? '##other' : this.state.img; - return ( - <div> - <p> - <b>背景图片:</b> - <select value={img_select} onChange={this.on_select.bind(this)}> - {Object.keys(BUILTIN_IMGS).map((key)=>( - <option key={key} value={key}>{BUILTIN_IMGS[key]}</option> - ))} - <option value="##other">输入图片网址……</option> - <option value="##color">纯色背景……</option> - </select> -   - {img_select==='##other' && - <input type="url" placeholder="图片网址" value={this.state.img} onChange={this.on_change_img.bind(this)} /> - } - {img_select==='##color' && - <input type="color" value={this.state.color} onChange={this.on_change_color.bind(this)} /> - } - </p> - <div className="bg-preview" style={bgimg_style(this.state.img,this.state.color)} /> - </div> - ); - } + render() { + let img_select = + this.state.img === null + ? '##color' + : Object.keys(BUILTIN_IMGS).indexOf(this.state.img) === -1 + ? '##other' + : this.state.img; + return ( + <div> + <p> + <b>背景图片:</b> + <select value={img_select} onChange={this.on_select.bind(this)}> + {Object.keys(BUILTIN_IMGS).map((key) => ( + <option key={key} value={key}> + {BUILTIN_IMGS[key]} + </option> + ))} + <option value="##other">输入图片网址……</option> + <option value="##color">纯色背景……</option> + </select> +   + {img_select === '##other' && ( + <input + type="url" + placeholder="图片网址" + value={this.state.img} + onChange={this.on_change_img.bind(this)} + /> + )} + {img_select === '##color' && ( + <input + type="color" + value={this.state.color} + onChange={this.on_change_color.bind(this)} + /> + )} + </p> + <div + className="bg-preview" + style={bgimg_style(this.state.img, this.state.color)} + /> + </div> + ); + } } class ConfigColorScheme extends PureComponent { - constructor(props) { - super(props); - this.state={ - color_scheme: window.config.color_scheme, - }; - } + constructor(props) { + super(props); + this.state = { + color_scheme: window.config.color_scheme, + }; + } - save_changes() { - this.props.callback({ - color_scheme: this.state.color_scheme, - }); - } + save_changes() { + this.props.callback({ + color_scheme: this.state.color_scheme, + }); + } - on_select(e) { - let value=e.target.value; - this.setState({ - color_scheme: value, - },this.save_changes.bind(this)); - } + on_select(e) { + let value = e.target.value; + this.setState( + { + color_scheme: value, + }, + this.save_changes.bind(this), + ); + } - render() { - return ( - <div> - <p> - <b>夜间模式:</b> - <select value={this.state.color_scheme} onChange={this.on_select.bind(this)}> - <option value="default">跟随系统</option> - <option value="light">始终浅色模式</option> - <option value="dark">始终深色模式</option> - </select> -   <small>#color_scheme</small> - </p> - <p> - 选择浅色或深色模式,深色模式下将会调暗图片亮度 - </p> - </div> - ) - } + render() { + return ( + <div> + <p> + <b>夜间模式:</b> + <select + value={this.state.color_scheme} + onChange={this.on_select.bind(this)} + > + <option value="default">跟随系统</option> + <option value="light">始终浅色模式</option> + <option value="dark">始终深色模式</option> + </select> +   <small>#color_scheme</small> + </p> + <p>选择浅色或深色模式,深色模式下将会调暗图片亮度</p> + </div> + ); + } } class ConfigSwitch extends PureComponent { - constructor(props) { - super(props); - this.state={ - switch: window.config[this.props.id], - }; - } + constructor(props) { + super(props); + this.state = { + switch: window.config[this.props.id], + }; + } - on_change(e) { - let val=e.target.checked; - this.setState({ - switch: val, - },()=>{ - this.props.callback({ - [this.props.id]: val, - }); + on_change(e) { + let val = e.target.checked; + this.setState( + { + switch: val, + }, + () => { + this.props.callback({ + [this.props.id]: val, }); - } + }, + ); + } - render() { - return ( - <div> - <p> - <label> - <input name={'config-'+this.props.id} type="checkbox" checked={this.state.switch} onChange={this.on_change.bind(this)} /> - <b>{this.props.name}</b> -   <small>#{this.props.id}</small> - </label> - </p> - <p> - {this.props.description} - </p> - </div> - ); - } + render() { + return ( + <div> + <p> + <label> + <input + name={'config-' + this.props.id} + type="checkbox" + checked={this.state.switch} + onChange={this.on_change.bind(this)} + /> + <b>{this.props.name}</b> +   <small>#{this.props.id}</small> + </label> + </p> + <p>{this.props.description}</p> + </div> + ); + } } export class ConfigUI extends PureComponent { - constructor(props) { - super(props); - this.save_changes_bound=this.save_changes.bind(this); - } + constructor(props) { + super(props); + this.save_changes_bound = this.save_changes.bind(this); + } - save_changes(chg) { - console.log(chg); - Object.keys(chg).forEach((key)=>{ - window.config[key]=chg[key]; - }); - save_config(); - } + save_changes(chg) { + console.log(chg); + Object.keys(chg).forEach((key) => { + window.config[key] = chg[key]; + }); + save_config(); + } - reset_settings() { - if(window.confirm('重置所有设置?')) { - window.config={}; - save_config(); - window.location.reload(); - } + reset_settings() { + if (window.confirm('重置所有设置?')) { + window.config = {}; + save_config(); + window.location.reload(); } + } - render() { - return ( - <div> - <div className="box config-ui-header"> - <p>这些功能仍在测试,可能不稳定(<a onClick={this.reset_settings.bind(this)}>全部重置</a>)</p> - <p><b>修改设置后 <a onClick={()=>{window.location.reload()}}>刷新页面</a> 方可生效</b></p> - </div> - <div className="box"> - <ConfigBackground callback={this.save_changes_bound} /> - <hr /> - <ConfigColorScheme callback={this.save_changes_bound} /> - <hr /> - <ConfigSwitch callback={this.save_changes_bound} id="pressure" name="快速返回" - description="短暂按住 Esc 键或重压屏幕(3D Touch)可以快速返回或者刷新树洞" - /> - <hr /> - <ConfigSwitch callback={this.save_changes_bound} id="easter_egg" name="允许彩蛋" - description="在某些情况下显示彩蛋" - /> - <hr /> - <ConfigSwitch callback={this.save_changes_bound} id="fold" name="折叠树洞" - description="在时间线中折叠可能引起不适的树洞" - /> - <hr /> - <p> - 新功能建议或问题反馈请在  - <a href="https://github.com/thuhole/thuhole-go-backend/issues" target="_blank">GitHub <span className="icon icon-github" /></a> -  提出。 - </p> - </div> - </div> - ) - } -} \ No newline at end of file + render() { + return ( + <div> + <div className="box config-ui-header"> + <p> + 这些功能仍在测试,可能不稳定( + <a onClick={this.reset_settings.bind(this)}>全部重置</a>) + </p> + <p> + <b> + 修改设置后{' '} + <a + onClick={() => { + window.location.reload(); + }} + > + 刷新页面 + </a>{' '} + 方可生效 + </b> + </p> + </div> + <div className="box"> + <ConfigBackground callback={this.save_changes_bound} /> + <hr /> + <ConfigColorScheme callback={this.save_changes_bound} /> + <hr /> + <ConfigSwitch + callback={this.save_changes_bound} + id="pressure" + name="快速返回" + description="短暂按住 Esc 键或重压屏幕(3D Touch)可以快速返回或者刷新树洞" + /> + <hr /> + <ConfigSwitch + callback={this.save_changes_bound} + id="easter_egg" + name="允许彩蛋" + description="在某些情况下显示彩蛋" + /> + <hr /> + <ConfigSwitch + callback={this.save_changes_bound} + id="fold" + name="折叠树洞" + description="在时间线中折叠可能引起不适的树洞" + /> + <hr /> + <p> + 新功能建议或问题反馈请在  + <a + href="https://github.com/thuhole/thuhole-go-backend/issues" + target="_blank" + > + GitHub <span className="icon icon-github" /> + </a> +  提出。 + </p> + </div> + </div> + ); + } +} diff --git a/src/Flows.js b/src/Flows.js index eee709e..0f8018e 100644 --- a/src/Flows.js +++ b/src/Flows.js @@ -1,824 +1,1151 @@ -import React, {Component, PureComponent} from 'react'; +import React, { Component, PureComponent } from 'react'; import copy from 'copy-to-clipboard'; -import {ColorPicker} from './color_picker'; -import {split_text, NICKNAME_RE, PID_RE, URL_RE, URL_PID_RE} from './text_splitter'; -import {format_time, build_highlight_re, Time, TitleLine, HighlightedText, ClickHandler, ColoredSpan, HighlightedMarkdown} from './Common'; +import { ColorPicker } from './color_picker'; +import { + split_text, + NICKNAME_RE, + PID_RE, + URL_RE, + URL_PID_RE, +} from './text_splitter'; +import { + format_time, + build_highlight_re, + Time, + TitleLine, + HighlightedText, + ClickHandler, + ColoredSpan, + HighlightedMarkdown, +} from './Common'; import './Flows.css'; import LazyLoad from './react-lazyload/src'; -import {AudioWidget} from './AudioWidget'; -import {TokenCtx, ReplyForm} from './UserAction'; +import { AudioWidget } from './AudioWidget'; +import { TokenCtx, ReplyForm } from './UserAction'; -import {API, THUHOLE_API_ROOT} from './flows_api'; +import { API, THUHOLE_API_ROOT } from './flows_api'; -const IMAGE_BASE='https://img.thuhole.com/'; -const IMAGE_BAK_BASE='https://img2.thuhole.com/'; +const IMAGE_BASE = 'https://img.thuhole.com/'; +const IMAGE_BAK_BASE = 'https://img2.thuhole.com/'; // const AUDIO_BASE=THUHOLE_API_ROOT+'services/thuhole/audios/'; -const CLICKABLE_TAGS={a: true, audio: true}; -const PREVIEW_REPLY_COUNT=10; +const CLICKABLE_TAGS = { a: true, audio: true }; +const PREVIEW_REPLY_COUNT = 10; // const QUOTE_BLACKLIST=['23333','233333','66666','666666','10086','10000','100000','99999','999999','55555','555555']; -const QUOTE_BLACKLIST=[]; -const FOLD_TAGS = ['性相关', '政治相关', '性话题', '政治话题', '折叠', 'NSFW', '刷屏', '真实性可疑', '用户举报较多', '举报较多', '重复内容'] +const QUOTE_BLACKLIST = []; +const FOLD_TAGS = [ + '性相关', + '政治相关', + '性话题', + '政治话题', + '折叠', + 'NSFW', + '刷屏', + '真实性可疑', + '用户举报较多', + '举报较多', + '重复内容', +]; -window.LATEST_POST_ID=parseInt(localStorage['_LATEST_POST_ID'],10)||0; +window.LATEST_POST_ID = parseInt(localStorage['_LATEST_POST_ID'], 10) || 0; -const DZ_NAME='洞主'; +const DZ_NAME = '洞主'; -function load_single_meta(show_sidebar,token) { - return (pid,replace=false)=>{ - let color_picker=new ColorPicker(); - let title_elem='树洞 #'+pid; +function load_single_meta(show_sidebar, token) { + return (pid, replace = false) => { + let color_picker = new ColorPicker(); + let title_elem = '树洞 #' + pid; + show_sidebar( + title_elem, + <div className="box box-tip">正在加载 #{pid}</div>, + replace ? 'replace' : 'push', + ); + API.get_single(pid, token) + .then((single) => { + single.data.variant = {}; + return new Promise((resolve, reject) => { + API.load_replies_with_cache( + pid, + token, + color_picker, + parseInt(single.data.reply), + ) + .then((replies) => { + resolve([single, replies]); + }) + .catch(reject); + }); + }) + .then((res) => { + let [single, replies] = res; show_sidebar( - title_elem, - <div className="box box-tip"> - 正在加载 #{pid} - </div>, - replace?'replace':'push' + title_elem, + <FlowSidebar + key={+new Date()} + info={single.data} + replies={replies.data} + attention={replies.attention} + token={token} + show_sidebar={show_sidebar} + color_picker={color_picker} + deletion_detect={localStorage['DELETION_DETECT'] === 'on'} + />, + 'replace', ); - API.get_single(pid,token) - .then((single)=>{ - single.data.variant={}; - return new Promise((resolve,reject)=>{ - API.load_replies_with_cache(pid,token,color_picker,parseInt(single.data.reply)) - .then((replies)=>{resolve([single,replies])}) - .catch(reject); - }); - }) - .then((res)=>{ - let [single,replies]=res; - show_sidebar( - title_elem, - <FlowSidebar key={+new Date()} - info={single.data} replies={replies.data} attention={replies.attention} - token={token} show_sidebar={show_sidebar} color_picker={color_picker} - deletion_detect={localStorage['DELETION_DETECT']==='on'} - />, - 'replace' - ) - }) - .catch((e)=>{ - console.error(e); - show_sidebar( - title_elem, - <div className="box box-tip"> - <p><a onClick={()=>load_single_meta(show_sidebar,token)(pid,true)}>重新加载</a></p> - <p>{''+e}</p> - </div>, - 'replace' - ); - }) - }; + }) + .catch((e) => { + console.error(e); + show_sidebar( + title_elem, + <div className="box box-tip"> + <p> + <a + onClick={() => load_single_meta(show_sidebar, token)(pid, true)} + > + 重新加载 + </a> + </p> + <p>{'' + e}</p> + </div>, + 'replace', + ); + }); + }; } class Reply extends PureComponent { - constructor(props) { - super(props); - } + constructor(props) { + super(props); + } - render() { - const replyContent = (this.props.info.text) - const splitIdx = replyContent.indexOf(']') - - const author = replyContent.substr(0, splitIdx + 1), - replyText = replyContent.substr(splitIdx + 2) - return ( - <div className={'flow-reply box'} style={this.props.info._display_color ? { + render() { + const replyContent = this.props.info.text; + const splitIdx = replyContent.indexOf(']'); + + const author = replyContent.substr(0, splitIdx + 1), + replyText = replyContent.substr(splitIdx + 2); + return ( + <div + className={'flow-reply box'} + style={ + this.props.info._display_color + ? { '--box-bgcolor-light': this.props.info._display_color[0], '--box-bgcolor-dark': this.props.info._display_color[1], - } : null}> - <div className="box-header"> - <code className="box-id">#{this.props.info.cid}</code> - {!!this.props.do_filter_name && - <span className="reply-header-badge clickable" onClick={()=>{this.props.do_filter_name(this.props.info.name);}}> - <span className="icon icon-locate" /> - </span> - } -   - {this.props.info.tag!==null && - <span className="box-header-tag"> - {this.props.info.tag} - </span> - } - <Time stamp={this.props.info.timestamp} short={false} /> - </div> - <div className="box-content"> - <HighlightedMarkdown author={author} - text={replyText} color_picker={this.props.color_picker} show_pid={this.props.show_pid} /> - </div> - </div> - ); - } + } + : null + } + > + <div className="box-header"> + <code className="box-id">#{this.props.info.cid}</code> + {!!this.props.do_filter_name && ( + <span + className="reply-header-badge clickable" + onClick={() => { + this.props.do_filter_name(this.props.info.name); + }} + > + <span className="icon icon-locate" /> + </span> + )} +   + {this.props.info.tag !== null && ( + <span className="box-header-tag">{this.props.info.tag}</span> + )} + <Time stamp={this.props.info.timestamp} short={false} /> + </div> + <div className="box-content"> + <HighlightedMarkdown + author={author} + text={replyText} + color_picker={this.props.color_picker} + show_pid={this.props.show_pid} + /> + </div> + </div> + ); + } } class FlowItem extends PureComponent { - constructor(props) { - super(props); - } + constructor(props) { + super(props); + } - copy_link(event) { - event.preventDefault(); - copy( - `${event.target.href}${this.props.info.tag ? ' 【'+this.props.info.tag+'】' : ''}\n`+ - `${this.props.info.text}${this.props.info.type==='image'?' [图片]':this.props.info.type==='audio'?' [语音]':''}\n`+ - `(${format_time(new Date(this.props.info.timestamp*1000))} ${this.props.info.likenum}关注 ${this.props.info.reply}回复)\n`+ - this.props.replies.map((r)=>( - (r.tag ? '【'+r.tag+'】' : '')+ - r.text - )).join('\n') - ); - } + copy_link(event) { + event.preventDefault(); + copy( + `${event.target.href}${ + this.props.info.tag ? ' 【' + this.props.info.tag + '】' : '' + }\n` + + `${this.props.info.text}${ + this.props.info.type === 'image' + ? ' [图片]' + : this.props.info.type === 'audio' + ? ' [语音]' + : '' + }\n` + + `(${format_time(new Date(this.props.info.timestamp * 1000))} ${ + this.props.info.likenum + }关注 ${this.props.info.reply}回复)\n` + + this.props.replies + .map((r) => (r.tag ? '【' + r.tag + '】' : '') + r.text) + .join('\n'), + ); + } - render() { - let props=this.props; - return ( - <div className={'flow-item'+(props.is_quote ? ' flow-item-quote' : '')}> - {!!props.is_quote && - <div className="quote-tip black-outline"> - <div><span className="icon icon-quote" /></div> - <div><small>提到</small></div> - </div> - } - <div className="box"> - {!!window.LATEST_POST_ID && parseInt(props.info.pid,10)>window.LATEST_POST_ID && - <div className="flow-item-dot" /> - } - <div className="box-header"> - {!!this.props.do_filter_name && - <span className="reply-header-badge clickable" onClick={()=>{this.props.do_filter_name(DZ_NAME);}}> - <span className="icon icon-locate" /> - </span> - } - {!!parseInt(props.info.likenum,10) && - <span className="box-header-badge"> - {props.info.likenum}  - <span className={'icon icon-'+(props.attention ? 'star-ok' : 'star')} /> - </span> - } - {!!parseInt(props.info.reply,10) && - <span className="box-header-badge"> - {props.info.reply}  - <span className="icon icon-reply" /> - </span> - } - <code className="box-id"><a href={'##'+props.info.pid} onClick={this.copy_link.bind(this)}>#{props.info.pid}</a></code> -   - {(props.info.tag!==null && props.info.tag!=='折叠') && - <span className="box-header-tag"> - {props.info.tag} - </span> - } - <Time stamp={props.info.timestamp} short={!props.img_clickable} /> - </div> - <div className="box-content"> - <HighlightedMarkdown text={props.fold ? '_单击以查看树洞_' : props.info.text} color_picker={props.color_picker} show_pid={props.show_pid} /> - {((props.info.type==='image') && (!props.fold)) && - <p className="img"> - {props.img_clickable ? - <a className="no-underline" href={IMAGE_BASE+props.info.url} target="_blank"> - <img src={IMAGE_BASE+props.info.url} - onError={(e)=>{ if (e.target.src === IMAGE_BASE+props.info.url){ - e.target.src=IMAGE_BAK_BASE+props.info.url - }}} alt={IMAGE_BASE+props.info.url}/> - </a> : - <img src={IMAGE_BASE+props.info.url} - onError={(e)=>{ if (e.target.src === IMAGE_BASE+props.info.url){ - e.target.src=IMAGE_BAK_BASE+props.info.url - }}} alt={IMAGE_BASE+props.info.url}/> - } - </p> - } - {/*{props.info.type==='audio' && <AudioWidget src={AUDIO_BASE+props.info.url} />}*/} - </div> - {!!(props.attention && props.info.variant.latest_reply) && - <p className="box-footer">最新回复 <Time stamp={props.info.variant.latest_reply} short={false} /></p> - } - </div> + render() { + let props = this.props; + return ( + <div className={'flow-item' + (props.is_quote ? ' flow-item-quote' : '')}> + {!!props.is_quote && ( + <div className="quote-tip black-outline"> + <div> + <span className="icon icon-quote" /> </div> - ); - } + <div> + <small>提到</small> + </div> + </div> + )} + <div className="box"> + {!!window.LATEST_POST_ID && + parseInt(props.info.pid, 10) > window.LATEST_POST_ID && ( + <div className="flow-item-dot" /> + )} + <div className="box-header"> + {!!this.props.do_filter_name && ( + <span + className="reply-header-badge clickable" + onClick={() => { + this.props.do_filter_name(DZ_NAME); + }} + > + <span className="icon icon-locate" /> + </span> + )} + {!!parseInt(props.info.likenum, 10) && ( + <span className="box-header-badge"> + {props.info.likenum}  + <span + className={ + 'icon icon-' + (props.attention ? 'star-ok' : 'star') + } + /> + </span> + )} + {!!parseInt(props.info.reply, 10) && ( + <span className="box-header-badge"> + {props.info.reply}  + <span className="icon icon-reply" /> + </span> + )} + <code className="box-id"> + <a + href={'##' + props.info.pid} + onClick={this.copy_link.bind(this)} + > + #{props.info.pid} + </a> + </code> +   + {props.info.tag !== null && props.info.tag !== '折叠' && ( + <span className="box-header-tag">{props.info.tag}</span> + )} + <Time stamp={props.info.timestamp} short={!props.img_clickable} /> + </div> + <div className="box-content"> + <HighlightedMarkdown + text={props.fold ? '_单击以查看树洞_' : props.info.text} + color_picker={props.color_picker} + show_pid={props.show_pid} + /> + {props.info.type === 'image' && !props.fold && ( + <p className="img"> + {props.img_clickable ? ( + <a + className="no-underline" + href={IMAGE_BASE + props.info.url} + target="_blank" + > + <img + src={IMAGE_BASE + props.info.url} + onError={(e) => { + if (e.target.src === IMAGE_BASE + props.info.url) { + e.target.src = IMAGE_BAK_BASE + props.info.url; + } + }} + alt={IMAGE_BASE + props.info.url} + /> + </a> + ) : ( + <img + src={IMAGE_BASE + props.info.url} + onError={(e) => { + if (e.target.src === IMAGE_BASE + props.info.url) { + e.target.src = IMAGE_BAK_BASE + props.info.url; + } + }} + alt={IMAGE_BASE + props.info.url} + /> + )} + </p> + )} + {/*{props.info.type==='audio' && <AudioWidget src={AUDIO_BASE+props.info.url} />}*/} + </div> + {!!(props.attention && props.info.variant.latest_reply) && ( + <p className="box-footer"> + 最新回复{' '} + <Time stamp={props.info.variant.latest_reply} short={false} /> + </p> + )} + </div> + </div> + ); + } } class FlowSidebar extends PureComponent { - constructor(props) { - super(props); - this.state={ - attention: props.attention, - info: props.info, - replies: props.replies, + constructor(props) { + super(props); + this.state = { + attention: props.attention, + info: props.info, + replies: props.replies, + loading_status: 'done', + error_msg: null, + filter_name: null, + rev: false, + }; + this.color_picker = props.color_picker; + this.syncState = props.sync_state || (() => {}); + this.reply_ref = React.createRef(); + } + + set_variant(cid, variant) { + this.setState( + (prev) => { + if (cid) + return { + replies: prev.replies.map((reply) => { + if (reply.cid === cid) + return Object.assign({}, reply, { + variant: Object.assign({}, reply.variant, variant), + }); + else return reply; + }), + }; + else + return { + info: Object.assign({}, prev.info, { + variant: Object.assign({}, prev.info.variant, variant), + }), + }; + }, + function () { + this.syncState({ + info: this.state.info, + replies: this.state.replies, + }); + }, + ); + } + + load_replies(update_count = true) { + this.setState({ + loading_status: 'loading', + error_msg: null, + }); + API.load_replies( + this.state.info.pid, + this.props.token, + this.color_picker, + null, + ) + .then((json) => { + this.setState( + (prev, props) => ({ + replies: json.data, + info: update_count + ? Object.assign({}, prev.info, { + reply: '' + json.data.length, + }) + : prev.info, + attention: !!json.attention, loading_status: 'done', error_msg: null, - filter_name: null, - rev: false, - }; - this.color_picker=props.color_picker; - this.syncState=props.sync_state||(()=>{}); - this.reply_ref=React.createRef(); - } - - set_variant(cid,variant) { - this.setState((prev)=>{ - if(cid) - return { - replies: prev.replies.map((reply)=>{ - if(reply.cid===cid) - return Object.assign({},reply,{variant: Object.assign({},reply.variant,variant)}); - else - return reply; - }), - }; - else - return { - info: Object.assign({},prev.info,{variant: Object.assign({},prev.info.variant,variant)}), - } - },function() { + }), + () => { this.syncState({ - info: this.state.info, - replies: this.state.replies, + replies: this.state.replies, + attention: this.state.attention, + info: this.state.info, }); + if (this.state.replies.length) + this.set_variant(null, { + latest_reply: Math.max.apply( + null, + this.state.replies.map((r) => parseInt(r.timestamp)), + ), + }); + }, + ); + }) + .catch((e) => { + console.error(e); + this.setState({ + replies: [], + loading_status: 'done', + error_msg: '' + e, }); - } + }); + } - load_replies(update_count=true) { + toggle_attention() { + this.setState({ + loading_status: 'loading', + }); + const next_attention = !this.state.attention; + API.set_attention(this.state.info.pid, next_attention, this.props.token) + .then((json) => { this.setState({ - loading_status: 'loading', - error_msg: null, + loading_status: 'done', + attention: next_attention, }); - API.load_replies(this.state.info.pid,this.props.token,this.color_picker,null) - .then((json)=>{ - this.setState((prev,props)=>({ - replies: json.data, - info: update_count ? Object.assign({}, prev.info, { - reply: ''+json.data.length, - }) : prev.info, - attention: !!json.attention, - loading_status: 'done', - error_msg: null, - }), ()=>{ - this.syncState({ - replies: this.state.replies, - attention: this.state.attention, - info: this.state.info, - }); - if(this.state.replies.length) - this.set_variant(null,{latest_reply: Math.max.apply(null,this.state.replies.map((r)=>parseInt(r.timestamp)))}); - }); - }) - .catch((e)=>{ - console.error(e); - this.setState({ - replies: [], - loading_status: 'done', - error_msg: ''+e, - }); - }); - } - - toggle_attention() { + this.syncState({ + attention: next_attention, + }); + }) + .catch((e) => { this.setState({ - loading_status: 'loading', + loading_status: 'done', }); - const next_attention=!this.state.attention; - API.set_attention(this.state.info.pid,next_attention,this.props.token) - .then((json)=>{ - this.setState({ - loading_status: 'done', - attention: next_attention, - }); - this.syncState({ - attention: next_attention, - }); - }) - .catch((e)=>{ - this.setState({ - loading_status: 'done' - }); - alert('设置关注失败'); - console.error(e); - }); - } + alert('设置关注失败'); + console.error(e); + }); + } - report() { - let reason=prompt(`举报 #${this.state.info.pid} 的理由:`); - if(reason!==null) { - API.report(this.state.info.pid,reason,this.props.token) - .then((json)=>{ - alert('举报成功'); - }) - .catch((e)=>{ - alert('举报失败'); - console.error(e); - }) - } + report() { + let reason = prompt(`举报 #${this.state.info.pid} 的理由:`); + if (reason !== null) { + API.report(this.state.info.pid, reason, this.props.token) + .then((json) => { + alert('举报成功'); + }) + .catch((e) => { + alert('举报失败'); + console.error(e); + }); } + } - set_filter_name(name) { - this.setState((prevState)=>({ - filter_name: name===prevState.filter_name ? null : name, - })); - } + set_filter_name(name) { + this.setState((prevState) => ({ + filter_name: name === prevState.filter_name ? null : name, + })); + } - toggle_rev() { - this.setState((prevState)=>({ - rev: !prevState.rev, - })); - } + toggle_rev() { + this.setState((prevState) => ({ + rev: !prevState.rev, + })); + } - show_reply_bar(name,event) { - if(this.reply_ref.current && !event.target.closest('a, .clickable')) { - let text=this.reply_ref.current.get(); - if(/^\s*(?:Re (?:|洞主|(?:[A-Z][a-z]+ )?(?:[A-Z][a-z]+)|You Win(?: \d+)?):)?\s*$/.test(text)) {// text is nearly empty so we can replace it - let should_text='Re '+name+': '; - if(should_text===this.reply_ref.current.get()) - this.reply_ref.current.set(''); - else - this.reply_ref.current.set(should_text); - } - } + show_reply_bar(name, event) { + if (this.reply_ref.current && !event.target.closest('a, .clickable')) { + let text = this.reply_ref.current.get(); + if ( + /^\s*(?:Re (?:|洞主|(?:[A-Z][a-z]+ )?(?:[A-Z][a-z]+)|You Win(?: \d+)?):)?\s*$/.test( + text, + ) + ) { + // text is nearly empty so we can replace it + let should_text = 'Re ' + name + ': '; + if (should_text === this.reply_ref.current.get()) + this.reply_ref.current.set(''); + else this.reply_ref.current.set(should_text); + } } + } - render() { - if(this.state.loading_status==='loading') - return (<p className="box box-tip">加载中……</p>); + render() { + if (this.state.loading_status === 'loading') + return <p className="box box-tip">加载中……</p>; - let show_pid=load_single_meta(this.props.show_sidebar,this.props.token); + let show_pid = load_single_meta(this.props.show_sidebar, this.props.token); - let replies_to_show=this.state.filter_name ? this.state.replies.filter((r)=>r.name===this.state.filter_name) : this.state.replies.slice(); - if(this.state.rev) replies_to_show.reverse(); + let replies_to_show = this.state.filter_name + ? this.state.replies.filter((r) => r.name === this.state.filter_name) + : this.state.replies.slice(); + if (this.state.rev) replies_to_show.reverse(); - // key for lazyload elem - let view_mode_key=(this.state.rev ? 'y-' : 'n-')+(this.state.filter_name||'null'); + // key for lazyload elem + let view_mode_key = + (this.state.rev ? 'y-' : 'n-') + (this.state.filter_name || 'null'); - let replies_cnt={[DZ_NAME]:1}; - replies_to_show.forEach((r)=>{ - if(replies_cnt[r.name]===undefined) - replies_cnt[r.name]=0; - replies_cnt[r.name]++; - }); + let replies_cnt = { [DZ_NAME]: 1 }; + replies_to_show.forEach((r) => { + if (replies_cnt[r.name] === undefined) replies_cnt[r.name] = 0; + replies_cnt[r.name]++; + }); - // hide main thread when filtered - let main_thread_elem=(this.state.filter_name && this.state.filter_name!==DZ_NAME) ? null : ( - <ClickHandler callback={(e)=>{this.show_reply_bar('',e);}}> - <FlowItem info={this.state.info} attention={this.state.attention} img_clickable={true} fold={false} - color_picker={this.color_picker} show_pid={show_pid} replies={this.state.replies} - set_variant={(variant)=>{this.set_variant(null,variant);}} - do_filter_name={replies_cnt[DZ_NAME]>1 ? this.set_filter_name.bind(this) : null} - /> - </ClickHandler> - ); + // hide main thread when filtered + let main_thread_elem = + this.state.filter_name && this.state.filter_name !== DZ_NAME ? null : ( + <ClickHandler + callback={(e) => { + this.show_reply_bar('', e); + }} + > + <FlowItem + info={this.state.info} + attention={this.state.attention} + img_clickable={true} + fold={false} + color_picker={this.color_picker} + show_pid={show_pid} + replies={this.state.replies} + set_variant={(variant) => { + this.set_variant(null, variant); + }} + do_filter_name={ + replies_cnt[DZ_NAME] > 1 ? this.set_filter_name.bind(this) : null + } + /> + </ClickHandler> + ); - return ( - <div className="flow-item-row sidebar-flow-item"> - <div className="box box-tip"> - {!!this.props.token && - <span> - <a onClick={this.report.bind(this)}> - <span className="icon icon-flag" /><label>举报</label> - </a> -    - </span> - } - <a onClick={this.load_replies.bind(this)}> - <span className="icon icon-refresh" /><label>刷新</label> - </a> - {(this.state.replies.length>=1 || this.state.rev) && - <span> -    - <a onClick={this.toggle_rev.bind(this)}> - <span className="icon icon-order-rev" /><label>{this.state.rev ? '还原' : '逆序'}</label> - </a> - </span> - } - {!!this.props.token && - <span> -    - <a onClick={()=>{ - this.toggle_attention(); - }}> - {this.state.attention ? - <span><span className="icon icon-star-ok" /><label>已关注</label></span> : - <span><span className="icon icon-star" /><label>未关注</label></span> - } - </a> - </span> - } - </div> - {!!this.state.filter_name && - <div className="box box-tip flow-item filter-name-bar"> - <p> - <span style={{float: 'left'}}><a onClick={()=>{this.set_filter_name(null)}}>还原</a></span> - <span className="icon icon-locate" /> 当前只看  - <ColoredSpan colors={this.color_picker.get(this.state.filter_name)}>{this.state.filter_name}</ColoredSpan> - </p> - </div> - } - {!this.state.rev && - main_thread_elem - } - {!!this.state.error_msg && - <div className="box box-tip flow-item"> - <p>回复加载失败</p> - <p>{this.state.error_msg}</p> - </div> - } - {(this.props.deletion_detect && parseInt(this.state.info.reply)>this.state.replies.length) && !!this.state.replies.length && - <div className="box box-tip flow-item box-danger"> - {parseInt(this.state.info.reply)-this.state.replies.length} 条回复被删除 - </div> - } - {replies_to_show.map((reply)=>( - <LazyLoad key={reply.cid+view_mode_key} offset={1500} height="5em" overflow={true} once={true}> - <ClickHandler callback={(e)=>{this.show_reply_bar(reply.name,e);}}> - <Reply - info={reply} color_picker={this.color_picker} show_pid={show_pid} - set_variant={(variant)=>{this.set_variant(reply.cid,variant);}} - do_filter_name={replies_cnt[reply.name]>1 ? this.set_filter_name.bind(this) : null} - /> - </ClickHandler> - </LazyLoad> - ))} - {this.state.rev && - main_thread_elem - } - {!!this.props.token ? - <ReplyForm pid={this.state.info.pid} token={this.props.token} - area_ref={this.reply_ref} on_complete={this.load_replies.bind(this)} /> : - <div className="box box-tip flow-item">登录后可以回复树洞</div> - } + return ( + <div className="flow-item-row sidebar-flow-item"> + <div className="box box-tip"> + {!!this.props.token && ( + <span> + <a onClick={this.report.bind(this)}> + <span className="icon icon-flag" /> + <label>举报</label> + </a> +    + </span> + )} + <a onClick={this.load_replies.bind(this)}> + <span className="icon icon-refresh" /> + <label>刷新</label> + </a> + {(this.state.replies.length >= 1 || this.state.rev) && ( + <span> +    + <a onClick={this.toggle_rev.bind(this)}> + <span className="icon icon-order-rev" /> + <label>{this.state.rev ? '还原' : '逆序'}</label> + </a> + </span> + )} + {!!this.props.token && ( + <span> +    + <a + onClick={() => { + this.toggle_attention(); + }} + > + {this.state.attention ? ( + <span> + <span className="icon icon-star-ok" /> + <label>已关注</label> + </span> + ) : ( + <span> + <span className="icon icon-star" /> + <label>未关注</label> + </span> + )} + </a> + </span> + )} + </div> + {!!this.state.filter_name && ( + <div className="box box-tip flow-item filter-name-bar"> + <p> + <span style={{ float: 'left' }}> + <a + onClick={() => { + this.set_filter_name(null); + }} + > + 还原 + </a> + </span> + <span className="icon icon-locate" /> +  当前只看  + <ColoredSpan + colors={this.color_picker.get(this.state.filter_name)} + > + {this.state.filter_name} + </ColoredSpan> + </p> + </div> + )} + {!this.state.rev && main_thread_elem} + {!!this.state.error_msg && ( + <div className="box box-tip flow-item"> + <p>回复加载失败</p> + <p>{this.state.error_msg}</p> + </div> + )} + {this.props.deletion_detect && + parseInt(this.state.info.reply) > this.state.replies.length && + !!this.state.replies.length && ( + <div className="box box-tip flow-item box-danger"> + {parseInt(this.state.info.reply) - this.state.replies.length}{' '} + 条回复被删除 </div> - ) - } + )} + {replies_to_show.map((reply) => ( + <LazyLoad + key={reply.cid + view_mode_key} + offset={1500} + height="5em" + overflow={true} + once={true} + > + <ClickHandler + callback={(e) => { + this.show_reply_bar(reply.name, e); + }} + > + <Reply + info={reply} + color_picker={this.color_picker} + show_pid={show_pid} + set_variant={(variant) => { + this.set_variant(reply.cid, variant); + }} + do_filter_name={ + replies_cnt[reply.name] > 1 + ? this.set_filter_name.bind(this) + : null + } + /> + </ClickHandler> + </LazyLoad> + ))} + {this.state.rev && main_thread_elem} + {!!this.props.token ? ( + <ReplyForm + pid={this.state.info.pid} + token={this.props.token} + area_ref={this.reply_ref} + on_complete={this.load_replies.bind(this)} + /> + ) : ( + <div className="box box-tip flow-item">登录后可以回复树洞</div> + )} + </div> + ); + } } class FlowItemRow extends PureComponent { - constructor(props) { - super(props); - this.state={ - replies: [], - reply_status: 'done', - reply_error: null, - info: Object.assign({},props.info,{variant: {}}), - attention: props.attention_override===null ? false : props.attention_override, - }; - this.color_picker=new ColorPicker(); - } + constructor(props) { + super(props); + this.state = { + replies: [], + reply_status: 'done', + reply_error: null, + info: Object.assign({}, props.info, { variant: {} }), + attention: + props.attention_override === null ? false : props.attention_override, + }; + this.color_picker = new ColorPicker(); + } - componentDidMount() { - if(parseInt(this.state.info.reply,10)) { - this.load_replies(null,/*update_count=*/false); - } + componentDidMount() { + if (parseInt(this.state.info.reply, 10)) { + this.load_replies(null, /*update_count=*/ false); } + } - load_replies(callback,update_count=true) { - console.log('fetching reply',this.state.info.pid); - this.setState({ - reply_status: 'loading', + load_replies(callback, update_count = true) { + console.log('fetching reply', this.state.info.pid); + this.setState({ + reply_status: 'loading', + reply_error: null, + }); + API.load_replies_with_cache( + this.state.info.pid, + this.props.token, + this.color_picker, + parseInt(this.state.info.reply), + ) + .then((json) => { + this.setState( + (prev, props) => ({ + replies: json.data, + info: Object.assign({}, prev.info, { + reply: update_count ? '' + json.data.length : prev.info.reply, + variant: json.data.length + ? { + latest_reply: Math.max.apply( + null, + json.data.map((r) => parseInt(r.timestamp)), + ), + } + : {}, + }), + attention: !!json.attention, + reply_status: 'done', reply_error: null, - }); - API.load_replies_with_cache(this.state.info.pid,this.props.token,this.color_picker,parseInt(this.state.info.reply)) - .then((json)=>{ - this.setState((prev,props)=>({ - replies: json.data, - info: Object.assign({}, prev.info, { - reply: update_count ? ''+json.data.length : prev.info.reply, - variant: json.data.length ? { - latest_reply: Math.max.apply(null,json.data.map((r)=>parseInt(r.timestamp))), - } : {}, - }), - attention: !!json.attention, - reply_status: 'done', - reply_error: null, - }),callback); - }) - .catch((e)=>{ - console.error(e); - this.setState({ - replies: [], - reply_status: 'failed', - reply_error: ''+e, - },callback); - }); - } - - show_sidebar() { - this.props.show_sidebar( - '树洞 #'+this.state.info.pid, - <FlowSidebar key={+new Date()} - info={this.state.info} replies={this.state.replies} attention={this.state.attention} sync_state={this.setState.bind(this)} - token={this.props.token} show_sidebar={this.props.show_sidebar} color_picker={this.color_picker} - deletion_detect={this.props.deletion_detect} - /> + }), + callback, ); - } - - render() { - let show_pid=load_single_meta(this.props.show_sidebar,this.props.token,[this.state.info.pid]); - - let hl_rules=[ - ['url_pid',URL_PID_RE], - ['url',URL_RE], - ['pid',PID_RE], - ['nickname',NICKNAME_RE], - ]; - if(this.props.search_param) - hl_rules.push(['search',build_highlight_re(this.props.search_param,' ','gi')]); - let parts=split_text(this.state.info.text,hl_rules); - - let quote_id=null; - if(!this.props.is_quote) - for(let [mode,content] of parts) { - content = content.length > 0 ? content.substring(1) : content - if(mode==='pid' && QUOTE_BLACKLIST.indexOf(content)===-1 && parseInt(content)<parseInt(this.state.info.pid)) - if(quote_id===null) - quote_id=parseInt(content); - else { - quote_id=null; - break; - } - } - let needFold = (FOLD_TAGS.indexOf(this.state.info.tag) > -1) && (this.props.search_param === '热榜' || !this.props.search_param) && window.config.fold - - let res=( - <div className={'flow-item-row flow-item-row-with-prompt'+(this.props.is_quote ? ' flow-item-row-quote' : '')} onClick={(event)=>{ - if(!CLICKABLE_TAGS[event.target.tagName.toLowerCase()]) - this.show_sidebar(); - }}> - <FlowItem parts={parts} info={this.state.info} attention={this.state.attention} img_clickable={false} is_quote={this.props.is_quote} - color_picker={this.color_picker} show_pid={show_pid} replies={this.state.replies} fold={needFold}/> - {(!needFold) && <div className="flow-reply-row"> - {this.state.reply_status==='loading' && <div className="box box-tip">加载中</div>} - {this.state.reply_status==='failed' && - <div className="box box-tip"> - <p><a onClick={()=>{this.load_replies()}}>重新加载评论</a></p> - <p>{this.state.reply_error}</p> - </div> - } - {this.state.replies.slice(0,PREVIEW_REPLY_COUNT).map((reply)=>( - <Reply key={reply.cid} info={reply} color_picker={this.color_picker} show_pid={show_pid} /> - ))} - {this.state.replies.length>PREVIEW_REPLY_COUNT && - <div className="box box-tip">还有 {this.state.replies.length-PREVIEW_REPLY_COUNT} 条</div> - } - </div>} - </div> + }) + .catch((e) => { + console.error(e); + this.setState( + { + replies: [], + reply_status: 'failed', + reply_error: '' + e, + }, + callback, ); + }); + } - return ((!needFold) && quote_id) ? ( - <div> - {res} - <FlowItemQuote pid={quote_id} show_sidebar={this.props.show_sidebar} token={this.props.token} - deletion_detect={this.props.deletion_detect} /> - </div> - ) : res; - } + show_sidebar() { + this.props.show_sidebar( + '树洞 #' + this.state.info.pid, + <FlowSidebar + key={+new Date()} + info={this.state.info} + replies={this.state.replies} + attention={this.state.attention} + sync_state={this.setState.bind(this)} + token={this.props.token} + show_sidebar={this.props.show_sidebar} + color_picker={this.color_picker} + deletion_detect={this.props.deletion_detect} + />, + ); + } + + render() { + let show_pid = load_single_meta(this.props.show_sidebar, this.props.token, [ + this.state.info.pid, + ]); + + let hl_rules = [ + ['url_pid', URL_PID_RE], + ['url', URL_RE], + ['pid', PID_RE], + ['nickname', NICKNAME_RE], + ]; + if (this.props.search_param) + hl_rules.push([ + 'search', + build_highlight_re(this.props.search_param, ' ', 'gi'), + ]); + let parts = split_text(this.state.info.text, hl_rules); + + let quote_id = null; + if (!this.props.is_quote) + for (let [mode, content] of parts) { + content = content.length > 0 ? content.substring(1) : content; + if ( + mode === 'pid' && + QUOTE_BLACKLIST.indexOf(content) === -1 && + parseInt(content) < parseInt(this.state.info.pid) + ) + if (quote_id === null) quote_id = parseInt(content); + else { + quote_id = null; + break; + } + } + let needFold = + FOLD_TAGS.indexOf(this.state.info.tag) > -1 && + (this.props.search_param === '热榜' || !this.props.search_param) && + window.config.fold; + + let res = ( + <div + className={ + 'flow-item-row flow-item-row-with-prompt' + + (this.props.is_quote ? ' flow-item-row-quote' : '') + } + onClick={(event) => { + if (!CLICKABLE_TAGS[event.target.tagName.toLowerCase()]) + this.show_sidebar(); + }} + > + <FlowItem + parts={parts} + info={this.state.info} + attention={this.state.attention} + img_clickable={false} + is_quote={this.props.is_quote} + color_picker={this.color_picker} + show_pid={show_pid} + replies={this.state.replies} + fold={needFold} + /> + {!needFold && ( + <div className="flow-reply-row"> + {this.state.reply_status === 'loading' && ( + <div className="box box-tip">加载中</div> + )} + {this.state.reply_status === 'failed' && ( + <div className="box box-tip"> + <p> + <a + onClick={() => { + this.load_replies(); + }} + > + 重新加载评论 + </a> + </p> + <p>{this.state.reply_error}</p> + </div> + )} + {this.state.replies.slice(0, PREVIEW_REPLY_COUNT).map((reply) => ( + <Reply + key={reply.cid} + info={reply} + color_picker={this.color_picker} + show_pid={show_pid} + /> + ))} + {this.state.replies.length > PREVIEW_REPLY_COUNT && ( + <div className="box box-tip"> + 还有 {this.state.replies.length - PREVIEW_REPLY_COUNT} 条 + </div> + )} + </div> + )} + </div> + ); + + return !needFold && quote_id ? ( + <div> + {res} + <FlowItemQuote + pid={quote_id} + show_sidebar={this.props.show_sidebar} + token={this.props.token} + deletion_detect={this.props.deletion_detect} + /> + </div> + ) : ( + res + ); + } } class FlowItemQuote extends PureComponent { - constructor(props) { - super(props); - this.state={ - loading_status: 'empty', - error_msg: null, - info: null, - }; - } + constructor(props) { + super(props); + this.state = { + loading_status: 'empty', + error_msg: null, + info: null, + }; + } - componentDidMount() { - this.load(); - } + componentDidMount() { + this.load(); + } - load() { - this.setState({ - loading_status: 'loading', - },()=>{ - API.get_single(this.props.pid,this.props.token) - .then((json)=>{ - this.setState({ - loading_status: 'done', - info: json.data, - }); - }) - .catch((err)=>{ - if((''+err).indexOf('没有这条树洞')!==-1) - this.setState({ - loading_status: 'empty', - }); - else - this.setState({ - loading_status: 'error', - error_msg: ''+err, - }); - }); - }); - } + load() { + this.setState( + { + loading_status: 'loading', + }, + () => { + API.get_single(this.props.pid, this.props.token) + .then((json) => { + this.setState({ + loading_status: 'done', + info: json.data, + }); + }) + .catch((err) => { + if (('' + err).indexOf('没有这条树洞') !== -1) + this.setState({ + loading_status: 'empty', + }); + else + this.setState({ + loading_status: 'error', + error_msg: '' + err, + }); + }); + }, + ); + } - render() { - if(this.state.loading_status==='empty') - return null; - else if(this.state.loading_status==='loading') - return ( - <div className="aux-margin"> - <div className="box box-tip"> - <span className="icon icon-loading" /> - 提到了 #{this.props.pid} - </div> - </div> - ); - else if(this.state.loading_status==='error') - return ( - <div className="aux-margin"> - <div className="box box-tip"> - <p><a onClick={this.load.bind(this)}>重新加载</a></p> - <p>{this.state.error_msg}</p> - </div> - </div> - ); - else // 'done' - return ( - <FlowItemRow info={this.state.info} show_sidebar={this.props.show_sidebar} token={this.props.token} - is_quote={true} deletion_detect={this.props.deletion_detect} /> - ); - } + render() { + if (this.state.loading_status === 'empty') return null; + else if (this.state.loading_status === 'loading') + return ( + <div className="aux-margin"> + <div className="box box-tip"> + <span className="icon icon-loading" /> + 提到了 #{this.props.pid} + </div> + </div> + ); + else if (this.state.loading_status === 'error') + return ( + <div className="aux-margin"> + <div className="box box-tip"> + <p> + <a onClick={this.load.bind(this)}>重新加载</a> + </p> + <p>{this.state.error_msg}</p> + </div> + </div> + ); + // 'done' + else + return ( + <FlowItemRow + info={this.state.info} + show_sidebar={this.props.show_sidebar} + token={this.props.token} + is_quote={true} + deletion_detect={this.props.deletion_detect} + /> + ); + } } function FlowChunk(props) { - return ( - <TokenCtx.Consumer>{({value: token})=>( - <div className="flow-chunk"> - {!!props.title && <TitleLine text={props.title} />} - {props.list.map((info,ind)=>( - <LazyLoad key={info.pid} offset={1500} height="15em" hiddenIfInvisible={true}> - <div> - {!!(props.deletion_detect && props.mode==='list' && ind && props.list[ind-1].pid-info.pid>1) && - <div className="flow-item-row"> - <div className="box box-tip flow-item box-danger"> - {props.list[ind-1].pid-info.pid-1} 条被删除 - </div> - </div> - } - <FlowItemRow info={info} show_sidebar={props.show_sidebar} token={token} - attention_override={props.mode==='attention_finished' ? true : null} - deletion_detect={props.deletion_detect} search_param={props.search_param} /> - </div> - </LazyLoad> - ))} - </div> - )}</TokenCtx.Consumer> - ); + return ( + <TokenCtx.Consumer> + {({ value: token }) => ( + <div className="flow-chunk"> + {!!props.title && <TitleLine text={props.title} />} + {props.list.map((info, ind) => ( + <LazyLoad + key={info.pid} + offset={1500} + height="15em" + hiddenIfInvisible={true} + > + <div> + {!!( + props.deletion_detect && + props.mode === 'list' && + ind && + props.list[ind - 1].pid - info.pid > 1 + ) && ( + <div className="flow-item-row"> + <div className="box box-tip flow-item box-danger"> + {props.list[ind - 1].pid - info.pid - 1} 条被删除 + </div> + </div> + )} + <FlowItemRow + info={info} + show_sidebar={props.show_sidebar} + token={token} + attention_override={ + props.mode === 'attention_finished' ? true : null + } + deletion_detect={props.deletion_detect} + search_param={props.search_param} + /> + </div> + </LazyLoad> + ))} + </div> + )} + </TokenCtx.Consumer> + ); } export class Flow extends PureComponent { - constructor(props) { - super(props); - this.state={ - mode: props.mode, - search_param: props.search_text, - loaded_pages: 0, - chunks: { - title: '', - data: [], - }, - loading_status: 'done', - error_msg: null, - }; - this.on_scroll_bound=this.on_scroll.bind(this); - window.LATEST_POST_ID=parseInt(localStorage['_LATEST_POST_ID'],10)||0; - } + constructor(props) { + super(props); + this.state = { + mode: props.mode, + search_param: props.search_text, + loaded_pages: 0, + chunks: { + title: '', + data: [], + }, + loading_status: 'done', + error_msg: null, + }; + this.on_scroll_bound = this.on_scroll.bind(this); + window.LATEST_POST_ID = parseInt(localStorage['_LATEST_POST_ID'], 10) || 0; + } - load_page(page) { - const failed=(err)=>{ - console.error(err); - this.setState((prev,props)=>({ - loaded_pages: prev.loaded_pages-1, - loading_status: 'failed', - error_msg: ''+err, - })); - }; - - if(page>this.state.loaded_pages+1) - throw new Error('bad page'); - if(page===this.state.loaded_pages+1) { - console.log('fetching page',page); - if(this.state.mode==='list') { - API.get_list(page,this.props.token) - .then((json)=>{ - if(page===1 && json.data.length) { // update latest_post_id - let max_id=-1; - json.data.forEach((x)=>{ - if(parseInt(x.pid,10)>max_id) - max_id=parseInt(x.pid,10); - }); - localStorage['_LATEST_POST_ID']=''+max_id; - } - this.setState((prev,props)=>({ - chunks: { - title: 'News Feed', - data: prev.chunks.data.concat(json.data.filter((x)=>( - prev.chunks.data.length===0 || - !(prev.chunks.data.slice(-100).some((p)=>p.pid===x.pid)) - ))), - }, - loading_status: 'done', - })); - }) - .catch(failed); - } else if(this.state.mode==='search') { - API.get_search(page,this.state.search_param,this.props.token) - .then((json)=>{ - const finished=json.data.length===0; - this.setState((prev,props)=>({ - chunks: { - title: 'Result for "'+this.state.search_param+'"', - data: prev.chunks.data.concat(json.data.filter((x)=>( - prev.chunks.data.length===0 || - !(prev.chunks.data.slice(-100).some((p)=>p.pid===x.pid)) - ))), - }, - mode: finished ? 'search_finished' : 'search', - loading_status: 'done', - })); - }) - .catch(failed); - } else if(this.state.mode==='single') { - const pid=parseInt(this.state.search_param.substr(1),10); - API.get_single(pid,this.props.token) - .then((json)=>{ - this.setState({ - chunks: { - title: 'PID = '+pid, - data: [json.data], - }, - mode: 'single_finished', - loading_status: 'done', - }); - }) - .catch(failed); - } else if(this.state.mode==='attention') { - API.get_attention(this.props.token) - .then((json)=>{ - this.setState({ - chunks: { - title: 'Attention List', - data: json.data, - }, - mode: 'attention_finished', - loading_status: 'done', - }); - }) - .catch(failed); - } else { - console.log('nothing to load'); - return; - } + load_page(page) { + const failed = (err) => { + console.error(err); + this.setState((prev, props) => ({ + loaded_pages: prev.loaded_pages - 1, + loading_status: 'failed', + error_msg: '' + err, + })); + }; - this.setState((prev,props)=>({ - loaded_pages: prev.loaded_pages+1, - loading_status: 'loading', - error_msg: null, + if (page > this.state.loaded_pages + 1) throw new Error('bad page'); + if (page === this.state.loaded_pages + 1) { + console.log('fetching page', page); + if (this.state.mode === 'list') { + API.get_list(page, this.props.token) + .then((json) => { + if (page === 1 && json.data.length) { + // update latest_post_id + let max_id = -1; + json.data.forEach((x) => { + if (parseInt(x.pid, 10) > max_id) max_id = parseInt(x.pid, 10); + }); + localStorage['_LATEST_POST_ID'] = '' + max_id; + } + this.setState((prev, props) => ({ + chunks: { + title: 'News Feed', + data: prev.chunks.data.concat( + json.data.filter( + (x) => + prev.chunks.data.length === 0 || + !prev.chunks.data + .slice(-100) + .some((p) => p.pid === x.pid), + ), + ), + }, + loading_status: 'done', })); - } - } + }) + .catch(failed); + } else if (this.state.mode === 'search') { + API.get_search(page, this.state.search_param, this.props.token) + .then((json) => { + const finished = json.data.length === 0; + this.setState((prev, props) => ({ + chunks: { + title: 'Result for "' + this.state.search_param + '"', + data: prev.chunks.data.concat( + json.data.filter( + (x) => + prev.chunks.data.length === 0 || + !prev.chunks.data + .slice(-100) + .some((p) => p.pid === x.pid), + ), + ), + }, + mode: finished ? 'search_finished' : 'search', + loading_status: 'done', + })); + }) + .catch(failed); + } else if (this.state.mode === 'single') { + const pid = parseInt(this.state.search_param.substr(1), 10); + API.get_single(pid, this.props.token) + .then((json) => { + this.setState({ + chunks: { + title: 'PID = ' + pid, + data: [json.data], + }, + mode: 'single_finished', + loading_status: 'done', + }); + }) + .catch(failed); + } else if (this.state.mode === 'attention') { + API.get_attention(this.props.token) + .then((json) => { + this.setState({ + chunks: { + title: 'Attention List', + data: json.data, + }, + mode: 'attention_finished', + loading_status: 'done', + }); + }) + .catch(failed); + } else { + console.log('nothing to load'); + return; + } - on_scroll(event) { - if(event.target===document) { - const avail=document.body.scrollHeight-window.scrollY-window.innerHeight; - if(avail<window.innerHeight && this.state.loading_status==='done') - this.load_page(this.state.loaded_pages+1); - } + this.setState((prev, props) => ({ + loaded_pages: prev.loaded_pages + 1, + loading_status: 'loading', + error_msg: null, + })); } + } - componentDidMount() { - this.load_page(1); - window.addEventListener('scroll',this.on_scroll_bound); - window.addEventListener('resize',this.on_scroll_bound); - } - componentWillUnmount() { - window.removeEventListener('scroll',this.on_scroll_bound); - window.removeEventListener('resize',this.on_scroll_bound); + on_scroll(event) { + if (event.target === document) { + const avail = + document.body.scrollHeight - window.scrollY - window.innerHeight; + if (avail < window.innerHeight && this.state.loading_status === 'done') + this.load_page(this.state.loaded_pages + 1); } + } - render() { - const should_deletion_detect=localStorage['DELETION_DETECT']==='on'; - return ( - <div className="flow-container"> - <FlowChunk - title={this.state.chunks.title} list={this.state.chunks.data} mode={this.state.mode} - search_param={this.state.search_param||null} - show_sidebar={this.props.show_sidebar} deletion_detect={should_deletion_detect} - /> - {this.state.loading_status==='failed' && - <div className="aux-margin"> - <div className="box box-tip"> - <p><a onClick={()=>{this.load_page(this.state.loaded_pages+1)}}>重新加载</a></p> - <p>{this.state.error_msg}</p> - </div> - </div> - } - <TitleLine text={ - this.state.loading_status==='loading' ? - <span><span className="icon icon-loading" /> Loading...</span> : - '© thuhole' - } /> + componentDidMount() { + this.load_page(1); + window.addEventListener('scroll', this.on_scroll_bound); + window.addEventListener('resize', this.on_scroll_bound); + } + componentWillUnmount() { + window.removeEventListener('scroll', this.on_scroll_bound); + window.removeEventListener('resize', this.on_scroll_bound); + } + + render() { + const should_deletion_detect = localStorage['DELETION_DETECT'] === 'on'; + return ( + <div className="flow-container"> + <FlowChunk + title={this.state.chunks.title} + list={this.state.chunks.data} + mode={this.state.mode} + search_param={this.state.search_param || null} + show_sidebar={this.props.show_sidebar} + deletion_detect={should_deletion_detect} + /> + {this.state.loading_status === 'failed' && ( + <div className="aux-margin"> + <div className="box box-tip"> + <p> + <a + onClick={() => { + this.load_page(this.state.loaded_pages + 1); + }} + > + 重新加载 + </a> + </p> + <p>{this.state.error_msg}</p> </div> - ); - } -} \ No newline at end of file + </div> + )} + <TitleLine + text={ + this.state.loading_status === 'loading' ? ( + <span> + <span className="icon icon-loading" /> +  Loading... + </span> + ) : ( + '© thuhole' + ) + } + /> + </div> + ); + } +} diff --git a/src/Markdown.js b/src/Markdown.js index ef3d7be..a3b7a15 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -1,29 +1,33 @@ -import MarkdownIt from 'markdown-it' -import MarkdownItKaTeX from 'markdown-it-katex' -import hljs from 'highlight.js' -import 'highlight.js/styles/atom-one-dark.css' -import './Markdown.css' +import MarkdownIt from 'markdown-it'; +import MarkdownItKaTeX from 'markdown-it-katex'; +import hljs from 'highlight.js'; +import 'highlight.js/styles/atom-one-dark.css'; +import './Markdown.css'; -import 'katex/dist/katex.min.css' +import 'katex/dist/katex.min.css'; let md = new MarkdownIt({ html: false, linkify: false, breaks: true, inline: true, - highlight (str, lang) { + highlight(str, lang) { if (lang && hljs.getLanguage(lang)) { try { - return '<pre class="hljs"><code>' + - hljs.highlight(lang, str, true).value + - '</code></pre>'; + return ( + '<pre class="hljs"><code>' + + hljs.highlight(lang, str, true).value + + '</code></pre>' + ); } catch (__) {} } - return '<pre class="hljs"><code>' + md.utils.escapeHtml(str) + '</code></pre>'; - } + return ( + '<pre class="hljs"><code>' + md.utils.escapeHtml(str) + '</code></pre>' + ); + }, }).use(MarkdownItKaTeX, { - "throwOnError" : false, - "errorColor" : "#aa0000" -}) + throwOnError: false, + errorColor: '#aa0000', +}); -export default (text) => md.render(text) \ No newline at end of file +export default (text) => md.render(text); diff --git a/src/Message.js b/src/Message.js index 1af97bb..7e98712 100644 --- a/src/Message.js +++ b/src/Message.js @@ -1,65 +1,80 @@ -import React, {Component, PureComponent} from 'react'; -import {THUHOLE_API_ROOT, get_json, API_VERSION_PARAM} from './flows_api'; -import {Time} from './Common'; +import React, { Component, PureComponent } from 'react'; +import { THUHOLE_API_ROOT, get_json, API_VERSION_PARAM } from './flows_api'; +import { Time } from './Common'; export class MessageViewer extends PureComponent { - constructor(props) { - super(props); - this.state={ - loading_status: 'idle', - msg: [], - }; - } + constructor(props) { + super(props); + this.state = { + loading_status: 'idle', + msg: [], + }; + } - componentDidMount() { - this.load(); - } + componentDidMount() { + this.load(); + } - load() { - if(this.state.loading_status==='loading') return; - this.setState({ - loading_status: 'loading', - },()=>{ - fetch(THUHOLE_API_ROOT+'api_xmcp/hole/system_msg?user_token='+encodeURIComponent(this.props.token)+API_VERSION_PARAM()) - .then(get_json) - .then((json)=>{ - if(json.error) - throw new Error(json.error); - else - this.setState({ - loading_status: 'done', - msg: json.result, - }); - }) - .catch((err)=>{ - console.error(err); - alert(''+err); - this.setState({ - loading_status: 'failed', - }); - }) + load() { + if (this.state.loading_status === 'loading') return; + this.setState( + { + loading_status: 'loading', + }, + () => { + fetch( + THUHOLE_API_ROOT + + 'api_xmcp/hole/system_msg?user_token=' + + encodeURIComponent(this.props.token) + + API_VERSION_PARAM(), + ) + .then(get_json) + .then((json) => { + if (json.error) throw new Error(json.error); + else + this.setState({ + loading_status: 'done', + msg: json.result, + }); + }) + .catch((err) => { + console.error(err); + alert('' + err); + this.setState({ + loading_status: 'failed', + }); + }); + }, + ); + } - }); - } - - render() { - if(this.state.loading_status==='loading') - return (<p className="box box-tip">加载中……</p>); - else if(this.state.loading_status==='failed') - return (<div className="box box-tip"><a onClick={()=>{this.load()}}>重新加载</a></div>); - else if(this.state.loading_status==='done') - return this.state.msg.map((msg)=>( - <div className="box"> - <div className="box-header"> - <Time stamp={msg.timestamp} short={false} /> -   <b>{msg.title}</b> - </div> - <div className="box-content"> - <pre>{msg.content}</pre> - </div> - </div> - )); - else - return null; - } -} \ No newline at end of file + render() { + if (this.state.loading_status === 'loading') + return <p className="box box-tip">加载中……</p>; + else if (this.state.loading_status === 'failed') + return ( + <div className="box box-tip"> + <a + onClick={() => { + this.load(); + }} + > + 重新加载 + </a> + </div> + ); + else if (this.state.loading_status === 'done') + return this.state.msg.map((msg) => ( + <div className="box"> + <div className="box-header"> + <Time stamp={msg.timestamp} short={false} /> +   <b>{msg.title}</b> + </div> + <div className="box-content"> + <pre>{msg.content}</pre> + </div> + </div> + )); + else return null; + } +} diff --git a/src/PressureHelper.js b/src/PressureHelper.js index 278562f..a93f347 100644 --- a/src/PressureHelper.js +++ b/src/PressureHelper.js @@ -1,113 +1,120 @@ -import React, {Component} from 'react'; +import React, { Component } from 'react'; import Pressure from 'pressure'; import './PressureHelper.css'; -const THRESHOLD=.4; -const MULTIPLIER=25; -const BORDER_WIDTH=500; // also change css! +const THRESHOLD = 0.4; +const MULTIPLIER = 25; +const BORDER_WIDTH = 500; // also change css! -export class PressureHelper extends Component { - constructor(props) { - super(props); - this.state={ - level: 0, - fired: false, - }; - this.callback=props.callback; - this.esc_interval=null; - } +export class PressureHelper extends Component { + constructor(props) { + super(props); + this.state = { + level: 0, + fired: false, + }; + this.callback = props.callback; + this.esc_interval = null; + } - do_fire() { - if(this.esc_interval) { - clearInterval(this.esc_interval); - this.esc_interval=null; - } - this.setState({ - level: 1, - fired: true, - }); - this.callback(); - window.setTimeout(()=>{ - this.setState({ - level: 0, - fired: false, - }); - },300); + do_fire() { + if (this.esc_interval) { + clearInterval(this.esc_interval); + this.esc_interval = null; } + this.setState({ + level: 1, + fired: true, + }); + this.callback(); + window.setTimeout(() => { + this.setState({ + level: 0, + fired: false, + }); + }, 300); + } - componentDidMount() { - if(window.config.pressure) { - Pressure.set(document.body, { - change: (force)=>{ - if(!this.state.fired) { - if(force>=.999) { - this.do_fire(); - } - else - this.setState({ - level: force, - }); - } - }, - end: ()=>{ - this.setState({ - level: 0, - fired: false, - }); - }, - }, { - polyfill: false, - only: 'touch', - preventSelect: false, + componentDidMount() { + if (window.config.pressure) { + Pressure.set( + document.body, + { + change: (force) => { + if (!this.state.fired) { + if (force >= 0.999) { + this.do_fire(); + } else + this.setState({ + level: force, + }); + } + }, + end: () => { + this.setState({ + level: 0, + fired: false, }); + }, + }, + { + polyfill: false, + only: 'touch', + preventSelect: false, + }, + ); - document.addEventListener('keydown',(e)=>{ - if(!e.repeat && e.key==='Escape') { - if(this.esc_interval) - clearInterval(this.esc_interval); - this.setState({ - level: THRESHOLD/2, - },()=>{ - this.esc_interval=setInterval(()=>{ - let new_level=this.state.level+.1; - if(new_level>=.999) - this.do_fire(); - else - this.setState({ - level: new_level, - }); - },30); - }); - } - }); - document.addEventListener('keyup',(e)=>{ - if(e.key==='Escape') { - if(this.esc_interval) { - clearInterval(this.esc_interval); - this.esc_interval=null; - } - this.setState({ - level: 0, - }); - } - }); + document.addEventListener('keydown', (e) => { + if (!e.repeat && e.key === 'Escape') { + if (this.esc_interval) clearInterval(this.esc_interval); + this.setState( + { + level: THRESHOLD / 2, + }, + () => { + this.esc_interval = setInterval(() => { + let new_level = this.state.level + 0.1; + if (new_level >= 0.999) this.do_fire(); + else + this.setState({ + level: new_level, + }); + }, 30); + }, + ); } + }); + document.addEventListener('keyup', (e) => { + if (e.key === 'Escape') { + if (this.esc_interval) { + clearInterval(this.esc_interval); + this.esc_interval = null; + } + this.setState({ + level: 0, + }); + } + }); } + } - render() { - const pad=MULTIPLIER*(this.state.level-THRESHOLD)-BORDER_WIDTH; - return ( - <div className={ - 'pressure-box' - +(this.state.fired ? ' pressure-box-fired' : '') - +(this.state.level<=.0001 ? ' pressure-box-empty' : '') - } style={{ - left: pad, - right: pad, - top: pad, - bottom: pad, - }} /> - ) - } -} \ No newline at end of file + render() { + const pad = MULTIPLIER * (this.state.level - THRESHOLD) - BORDER_WIDTH; + return ( + <div + className={ + 'pressure-box' + + (this.state.fired ? ' pressure-box-fired' : '') + + (this.state.level <= 0.0001 ? ' pressure-box-empty' : '') + } + style={{ + left: pad, + right: pad, + top: pad, + bottom: pad, + }} + /> + ); + } +} diff --git a/src/Sidebar.js b/src/Sidebar.js index 693e2be..eec3264 100644 --- a/src/Sidebar.js +++ b/src/Sidebar.js @@ -1,45 +1,66 @@ -import React, {Component, PureComponent} from 'react'; +import React, { Component, PureComponent } from 'react'; import './Sidebar.css'; export class Sidebar extends PureComponent { - constructor(props) { - super(props); - this.sidebar_ref=React.createRef(); - this.do_close_bound=this.do_close.bind(this); - this.do_back_bound=this.do_back.bind(this); - } + constructor(props) { + super(props); + this.sidebar_ref = React.createRef(); + this.do_close_bound = this.do_close.bind(this); + this.do_back_bound = this.do_back.bind(this); + } - componentDidUpdate(nextProps) { - if(this.props.stack!==nextProps.stack) { - //console.log('sidebar top'); - if(this.sidebar_ref.current) - this.sidebar_ref.current.scrollTop=0; - } + componentDidUpdate(nextProps) { + if (this.props.stack !== nextProps.stack) { + //console.log('sidebar top'); + if (this.sidebar_ref.current) this.sidebar_ref.current.scrollTop = 0; } + } - do_close() { - this.props.show_sidebar(null,null,'clear'); - } - do_back() { - this.props.show_sidebar(null,null,'pop'); - } + do_close() { + this.props.show_sidebar(null, null, 'clear'); + } + do_back() { + this.props.show_sidebar(null, null, 'pop'); + } - render() { - let [cur_title,cur_content]=this.props.stack[this.props.stack.length-1]; - return ( - <div className={'sidebar-container '+(cur_title!==null ? 'sidebar-on' : 'sidebar-off')}> - <div className="sidebar-shadow" onClick={this.do_back_bound} onTouchEnd={(e)=>{e.preventDefault();e.target.click();}} /> - <div ref={this.sidebar_ref} className="sidebar"> - {cur_content} - </div> - <div className="sidebar-title"> - <a className="no-underline" onClick={this.do_close_bound}> <span className="icon icon-close" /> </a> - {this.props.stack.length>2 && - <a className="no-underline" onClick={this.do_back_bound}> <span className="icon icon-back" /> </a> - } - {cur_title} - </div> - </div> - ); - } -} \ No newline at end of file + render() { + let [cur_title, cur_content] = this.props.stack[ + this.props.stack.length - 1 + ]; + return ( + <div + className={ + 'sidebar-container ' + + (cur_title !== null ? 'sidebar-on' : 'sidebar-off') + } + > + <div + className="sidebar-shadow" + onClick={this.do_back_bound} + onTouchEnd={(e) => { + e.preventDefault(); + e.target.click(); + }} + /> + <div ref={this.sidebar_ref} className="sidebar"> + {cur_content} + </div> + <div className="sidebar-title"> + <a className="no-underline" onClick={this.do_close_bound}> +   + <span className="icon icon-close" /> +   + </a> + {this.props.stack.length > 2 && ( + <a className="no-underline" onClick={this.do_back_bound}> +   + <span className="icon icon-back" /> +   + </a> + )} + {cur_title} + </div> + </div> + ); + } +} diff --git a/src/Title.js b/src/Title.js index 9d6321a..5958c19 100644 --- a/src/Title.js +++ b/src/Title.js @@ -1,143 +1,186 @@ -import React, {Component, PureComponent} from 'react'; +import React, { Component, PureComponent } from 'react'; // import {AppSwitcher} from './infrastructure/widgets'; -import {InfoSidebar, PostForm} from './UserAction'; -import {TokenCtx} from './UserAction'; +import { InfoSidebar, PostForm } from './UserAction'; +import { TokenCtx } from './UserAction'; import './Title.css'; -const flag_re=/^\/\/setflag ([a-zA-Z0-9_]+)=(.*)$/; +const flag_re = /^\/\/setflag ([a-zA-Z0-9_]+)=(.*)$/; class ControlBar extends PureComponent { - constructor(props) { - super(props); - this.state={ - search_text: '', - }; - this.set_mode=props.set_mode; + constructor(props) { + super(props); + this.state = { + search_text: '', + }; + this.set_mode = props.set_mode; - this.on_change_bound=this.on_change.bind(this); - this.on_keypress_bound=this.on_keypress.bind(this); - this.do_refresh_bound=this.do_refresh.bind(this); - this.do_attention_bound=this.do_attention.bind(this); - } - - componentDidMount() { - if(window.location.hash) { - let text=decodeURIComponent(window.location.hash).substr(1); - if(text.lastIndexOf('?')!==-1) - text=text.substr(0,text.lastIndexOf('?')); // fuck wechat '#param?nsukey=...' - this.setState({ - search_text: text, - }, ()=>{ - this.on_keypress({key: 'Enter'}); - }); - } - } + this.on_change_bound = this.on_change.bind(this); + this.on_keypress_bound = this.on_keypress.bind(this); + this.do_refresh_bound = this.do_refresh.bind(this); + this.do_attention_bound = this.do_attention.bind(this); + } - on_change(event) { - this.setState({ - search_text: event.target.value, - }); + componentDidMount() { + if (window.location.hash) { + let text = decodeURIComponent(window.location.hash).substr(1); + if (text.lastIndexOf('?') !== -1) + text = text.substr(0, text.lastIndexOf('?')); // fuck wechat '#param?nsukey=...' + this.setState( + { + search_text: text, + }, + () => { + this.on_keypress({ key: 'Enter' }); + }, + ); } + } - on_keypress(event) { - if(event.key==='Enter') { - let flag_res=flag_re.exec(this.state.search_text); - if(flag_res) { - if(flag_res[2]) { - localStorage[flag_res[1]]=flag_res[2]; - alert('Set Flag '+flag_res[1]+'='+flag_res[2]+'\nYou may need to refresh this webpage.'); - } else { - delete localStorage[flag_res[1]]; - alert('Clear Flag '+flag_res[1]+'\nYou may need to refresh this webpage.'); - } - return; - } + on_change(event) { + this.setState({ + search_text: event.target.value, + }); + } - const mode=this.state.search_text.startsWith('#') ? 'single' : 'search'; - this.set_mode(mode,this.state.search_text||''); + on_keypress(event) { + if (event.key === 'Enter') { + let flag_res = flag_re.exec(this.state.search_text); + if (flag_res) { + if (flag_res[2]) { + localStorage[flag_res[1]] = flag_res[2]; + alert( + 'Set Flag ' + + flag_res[1] + + '=' + + flag_res[2] + + '\nYou may need to refresh this webpage.', + ); + } else { + delete localStorage[flag_res[1]]; + alert( + 'Clear Flag ' + + flag_res[1] + + '\nYou may need to refresh this webpage.', + ); } - } + return; + } - do_refresh() { - window.scrollTo(0,0); - this.setState({ - search_text: '', - }); - this.set_mode('list',null); + const mode = this.state.search_text.startsWith('#') ? 'single' : 'search'; + this.set_mode(mode, this.state.search_text || ''); } + } - do_attention() { - window.scrollTo(0,0); - this.setState({ - search_text: '', - }); - this.set_mode('attention',null); - } + do_refresh() { + window.scrollTo(0, 0); + this.setState({ + search_text: '', + }); + this.set_mode('list', null); + } - render() { - return ( - <TokenCtx.Consumer>{({value: token})=>( - <div className="control-bar"> - <a className="no-underline control-btn" onClick={this.do_refresh_bound}> - <span className="icon icon-refresh" /> - <span className="control-btn-label">最新</span> - </a> - {!!token && - <a className="no-underline control-btn" onClick={this.do_attention_bound}> - <span className="icon icon-attention" /> - <span className="control-btn-label">关注</span> - </a> - } - <input className="control-search" value={this.state.search_text} placeholder="搜索 或 #树洞号" - onChange={this.on_change_bound} onKeyPress={this.on_keypress_bound} - /> - <a className="no-underline control-btn" onClick={()=>{ - this.props.show_sidebar( - 'T大树洞', - <InfoSidebar show_sidebar={this.props.show_sidebar} /> - ) - }}> - <span className={'icon icon-'+(token ? 'about' : 'login')} /> - <span className="control-btn-label">{token ? '账户' : '登录'}</span> - </a> - {!!token && - <a className="no-underline control-btn" onClick={()=>{ - this.props.show_sidebar( - '发表树洞', - <PostForm token={token} on_complete={()=>{ - this.props.show_sidebar(null,null); - this.do_refresh(); - }} /> - ) - }}> - <span className="icon icon-plus" /> - <span className="control-btn-label">发表</span> - </a> - } - </div> - )}</TokenCtx.Consumer> - ) - } + do_attention() { + window.scrollTo(0, 0); + this.setState({ + search_text: '', + }); + this.set_mode('attention', null); + } + + render() { + return ( + <TokenCtx.Consumer> + {({ value: token }) => ( + <div className="control-bar"> + <a + className="no-underline control-btn" + onClick={this.do_refresh_bound} + > + <span className="icon icon-refresh" /> + <span className="control-btn-label">最新</span> + </a> + {!!token && ( + <a + className="no-underline control-btn" + onClick={this.do_attention_bound} + > + <span className="icon icon-attention" /> + <span className="control-btn-label">关注</span> + </a> + )} + <input + className="control-search" + value={this.state.search_text} + placeholder="搜索 或 #树洞号" + onChange={this.on_change_bound} + onKeyPress={this.on_keypress_bound} + /> + <a + className="no-underline control-btn" + onClick={() => { + this.props.show_sidebar( + 'T大树洞', + <InfoSidebar show_sidebar={this.props.show_sidebar} />, + ); + }} + > + <span className={'icon icon-' + (token ? 'about' : 'login')} /> + <span className="control-btn-label"> + {token ? '账户' : '登录'} + </span> + </a> + {!!token && ( + <a + className="no-underline control-btn" + onClick={() => { + this.props.show_sidebar( + '发表树洞', + <PostForm + token={token} + on_complete={() => { + this.props.show_sidebar(null, null); + this.do_refresh(); + }} + />, + ); + }} + > + <span className="icon icon-plus" /> + <span className="control-btn-label">发表</span> + </a> + )} + </div> + )} + </TokenCtx.Consumer> + ); + } } export function Title(props) { - return ( - <div className="title-bar"> - {/* <AppSwitcher appid="hole" /> */} - <div className="aux-margin"> - <div className="title"> - <p className="centered-line"> - <span onClick={()=>props.show_sidebar( - 'T大树洞', - <InfoSidebar show_sidebar={props.show_sidebar} /> - )}> - T大树洞 - </span> - </p> - </div> - <ControlBar show_sidebar={props.show_sidebar} set_mode={props.set_mode} /> - </div> + return ( + <div className="title-bar"> + {/* <AppSwitcher appid="hole" /> */} + <div className="aux-margin"> + <div className="title"> + <p className="centered-line"> + <span + onClick={() => + props.show_sidebar( + 'T大树洞', + <InfoSidebar show_sidebar={props.show_sidebar} />, + ) + } + > + T大树洞 + </span> + </p> </div> - ) -} \ No newline at end of file + <ControlBar + show_sidebar={props.show_sidebar} + set_mode={props.set_mode} + /> + </div> + </div> + ); +} diff --git a/src/UserAction.js b/src/UserAction.js index 53d7f78..10a6b01 100644 --- a/src/UserAction.js +++ b/src/UserAction.js @@ -1,24 +1,35 @@ -import React, {Component, PureComponent} from 'react'; -import {API_BASE, SafeTextarea, PromotionBar, HighlightedMarkdown} from './Common'; -import {MessageViewer} from './Message'; -import {LoginPopup} from './infrastructure/widgets'; -import {ColorPicker} from './color_picker'; -import {ConfigUI} from './Config'; +import React, { Component, PureComponent } from 'react'; +import { + API_BASE, + SafeTextarea, + PromotionBar, + HighlightedMarkdown, +} from './Common'; +import { MessageViewer } from './Message'; +import { LoginPopup } from './infrastructure/widgets'; +import { ColorPicker } from './color_picker'; +import { ConfigUI } from './Config'; import fixOrientation from 'fix-orientation'; import copy from 'copy-to-clipboard'; -import {cache} from './cache'; -import {API_VERSION_PARAM, THUHOLE_API_ROOT, API, get_json, token_param} from './flows_api'; +import { cache } from './cache'; +import { + API_VERSION_PARAM, + THUHOLE_API_ROOT, + API, + get_json, + token_param, +} from './flows_api'; import './UserAction.css'; -const BASE64_RATE=4/3; -const MAX_IMG_DIAM=8000; -const MAX_IMG_PX=5000000; -const MAX_IMG_FILESIZE=450000*BASE64_RATE; +const BASE64_RATE = 4 / 3; +const MAX_IMG_DIAM = 8000; +const MAX_IMG_PX = 5000000; +const MAX_IMG_FILESIZE = 450000 * BASE64_RATE; -export const TokenCtx=React.createContext({ - value: null, - set_value: ()=>{}, +export const TokenCtx = React.createContext({ + value: null, + set_value: () => {}, }); // class LifeInfoBox extends Component { @@ -196,559 +207,711 @@ export const TokenCtx=React.createContext({ // } export function InfoSidebar(props) { - return ( - <div> - <PromotionBar /> - <LoginForm show_sidebar={props.show_sidebar} /> - <div className="box list-menu"> - <a onClick={()=>{props.show_sidebar( - '设置', - <ConfigUI /> - )}}> - <span className="icon icon-settings" /><label>设置</label> - </a> -    - <a href="https://thuhole.com/policy.html" target="_blank"> - <span className="icon icon-textfile" /><label>树洞规范(试行)</label> - </a> -    - <a href="https://github.com/thuhole/thuhole-go-backend/issues" target="_blank"> - <span className="icon icon-github" /><label>意见反馈</label> - </a> - </div> - <div className="box help-desc-box"> - <p> - <a onClick={()=>{ - if('serviceWorker' in navigator) { - navigator.serviceWorker.getRegistrations() - .then((registrations)=>{ - for(let registration of registrations) { - console.log('unregister',registration); - registration.unregister(); - } - }); - } - cache().clear(); - setTimeout(()=>{ - window.location.reload(true); - },200); - }}>强制检查更新</a> - (当前版本:【{process.env.REACT_APP_BUILD_INFO||'---'} {process.env.NODE_ENV}】 会自动在后台检查更新并在下次访问时更新) - </p> - </div> - <div className="box help-desc-box"> - <p> - 联系我们:thuhole at protonmail dot com - </p> - </div> - <div className="box help-desc-box"> - <p> - T大树洞 网页版 by @thuhole, - 基于  - <a href="https://www.gnu.org/licenses/gpl-3.0.zh-cn.html" target="_blank">GPLv3</a> -  协议在 <a href="https://github.com/thuhole/webhole" target="_blank">GitHub</a> 开源 - </p> - <p> - T大树洞 网页版的诞生离不开  - <a href="https://github.com/pkuhelper-web/webhole" target="_blank" rel="noopener">P大树洞网页版 by @xmcp</a> - 、 - <a href="https://reactjs.org/" target="_blank" rel="noopener">React</a> - 、 - <a href="https://icomoon.io/#icons" target="_blank" rel="noopener">IcoMoon</a> -  等开源项目 - </p> - <p> - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - </p> - <p> - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the  - <a href="https://www.gnu.org/licenses/gpl-3.0.zh-cn.html" target="_blank">GNU General Public License</a> -  for more details. - </p> - </div> - </div> - ); + return ( + <div> + <PromotionBar /> + <LoginForm show_sidebar={props.show_sidebar} /> + <div className="box list-menu"> + <a + onClick={() => { + props.show_sidebar('设置', <ConfigUI />); + }} + > + <span className="icon icon-settings" /> + <label>设置</label> + </a> +    + <a href="https://thuhole.com/policy.html" target="_blank"> + <span className="icon icon-textfile" /> + <label>树洞规范(试行)</label> + </a> +    + <a + href="https://github.com/thuhole/thuhole-go-backend/issues" + target="_blank" + > + <span className="icon icon-github" /> + <label>意见反馈</label> + </a> + </div> + <div className="box help-desc-box"> + <p> + <a + onClick={() => { + if ('serviceWorker' in navigator) { + navigator.serviceWorker + .getRegistrations() + .then((registrations) => { + for (let registration of registrations) { + console.log('unregister', registration); + registration.unregister(); + } + }); + } + cache().clear(); + setTimeout(() => { + window.location.reload(true); + }, 200); + }} + > + 强制检查更新 + </a> + (当前版本:【{process.env.REACT_APP_BUILD_INFO || '---'}{' '} + {process.env.NODE_ENV}】 会自动在后台检查更新并在下次访问时更新) + </p> + </div> + <div className="box help-desc-box"> + <p>联系我们:thuhole at protonmail dot com</p> + </div> + <div className="box help-desc-box"> + <p> + T大树洞 网页版 by @thuhole, 基于  + <a + href="https://www.gnu.org/licenses/gpl-3.0.zh-cn.html" + target="_blank" + > + GPLv3 + </a> +  协议在{' '} + <a href="https://github.com/thuhole/webhole" target="_blank"> + GitHub + </a>{' '} + 开源 + </p> + <p> + T大树洞 网页版的诞生离不开  + <a + href="https://github.com/pkuhelper-web/webhole" + target="_blank" + rel="noopener" + > + P大树洞网页版 by @xmcp + </a> + 、 + <a href="https://reactjs.org/" target="_blank" rel="noopener"> + React + </a> + 、 + <a href="https://icomoon.io/#icons" target="_blank" rel="noopener"> + IcoMoon + </a> +  等开源项目 + </p> + <p> + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or (at + your option) any later version. + </p> + <p> + This program is distributed in the hope that it will be useful, but + WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the  + <a + href="https://www.gnu.org/licenses/gpl-3.0.zh-cn.html" + target="_blank" + > + GNU General Public License + </a> +  for more details. + </p> + </div> + </div> + ); } class ResetUsertokenWidget extends Component { - constructor(props) { - super(props); - this.state={ - loading_status: 'done', - }; - } + constructor(props) { + super(props); + this.state = { + loading_status: 'done', + }; + } + + do_reset() { + if ( + window.confirm( + '您正在重置 UserToken!\n您的账号将会在【所有设备】上注销,您需要手动重新登录!', + ) + ) { + let uid = window.prompt( + '您正在重置 UserToken!\n请输入您的学号以确认身份:', + ); + if (uid) + this.setState( + { + loading_status: 'loading', + }, + () => { + fetch(THUHOLE_API_ROOT + 'api_xmcp/hole/reset_usertoken', { + method: 'post', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + user_token: this.props.token, + uid: uid, + }), + }) + .then(get_json) + .then((json) => { + if (json.error) throw new Error(json.error); + else alert('重置成功!您需要在所有设备上重新登录。'); - do_reset() { - if(window.confirm('您正在重置 UserToken!\n您的账号将会在【所有设备】上注销,您需要手动重新登录!')) { - let uid=window.prompt('您正在重置 UserToken!\n请输入您的学号以确认身份:'); - if(uid) this.setState({ - loading_status: 'loading', - },()=>{ - fetch(THUHOLE_API_ROOT+'api_xmcp/hole/reset_usertoken', { - method: 'post', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - user_token: this.props.token, - uid: uid, - }), - }) - .then(get_json) - .then((json)=>{ - if(json.error) - throw new Error(json.error); - else - alert('重置成功!您需要在所有设备上重新登录。'); - - this.setState({ - loading_status: 'done', - }); - }) - .catch((e)=>{ - alert('重置失败:'+e); - this.setState({ - loading_status: 'done', - }); - }) + loading_status: 'done', }); - } - } - - render() { - if(this.state.loading_status==='done') - return (<a onClick={this.do_reset.bind(this)}>重置</a>); - else if(this.state.loading_status==='loading') - return (<a><span className="icon icon-loading" /></a>); + }) + .catch((e) => { + alert('重置失败:' + e); + this.setState({ + loading_status: 'done', + }); + }); + }, + ); } + } + + render() { + if (this.state.loading_status === 'done') + return <a onClick={this.do_reset.bind(this)}>重置</a>; + else if (this.state.loading_status === 'loading') + return ( + <a> + <span className="icon icon-loading" /> + </a> + ); + } } export class LoginForm extends Component { - copy_token(token) { - if(copy(token)) - alert('复制成功!\n请一定不要泄露哦'); - } + copy_token(token) { + if (copy(token)) alert('复制成功!\n请一定不要泄露哦'); + } - render() { - return ( - <TokenCtx.Consumer>{(token)=> + render() { + return ( + <TokenCtx.Consumer> + {(token) => ( + <div> + {/*{!!token.value &&*/} + {/* <LifeInfoBox token={token.value} set_token={token.set_value} />*/} + {/*}*/} + <div className="login-form box"> + {token.value ? ( <div> - {/*{!!token.value &&*/} - {/* <LifeInfoBox token={token.value} set_token={token.set_value} />*/} - {/*}*/} - <div className="login-form box"> - {token.value ? - <div> - <p> - <b>您已登录。</b> - <button type="button" onClick={()=>{token.set_value(null);}}> - <span className="icon icon-logout" /> 注销 - </button> - <br /> - </p> - {/*<p>*/} - {/*根据计算中心要求,访问授权三个月内有效,过期需重新登录。*/} - {/*T大树洞将会单向加密(i.e. 哈希散列)您的邮箱后再存入数据库,因此您的发帖具有较强的匿名性。具体可见我们的<a href="https://github.com/thuhole/thuhole-go-backend/blob/76f56e6b75257b59e552b6bdba77e114151fcad1/src/db.go#L184">后端开源代码</a>。*/} - {/*</p>*/} - <p> - <a onClick={()=>{this.props.show_sidebar( - '系统消息', - <MessageViewer token={token.value} /> - )}}>查看系统消息</a><br /> - 当您发送的内容违规时,我们将用系统消息提示您 - </p> - <p> - <a onClick={this.copy_token.bind(this,token.value)}>复制 User Token</a><br /> - 复制 User Token 可以在新设备登录,切勿告知他人。若怀疑被盗号请重新邮箱验证码登录以重置Token。{/*,若怀疑被盗号请尽快 <ResetUsertokenWidget token={token.value} />*/} - </p> - </div> : - <LoginPopup token_callback={token.set_value}>{(do_popup)=>( - <div> - <p> - <button type="button" onClick={do_popup}> - <span className="icon icon-login" /> -  登录 - </button> - </p> - <p><small> - T大树洞 面向T大学生,通过T大邮箱验证您的身份并提供服务。 - </small></p> - </div> - )}</LoginPopup> - } - </div> + <p> + <b>您已登录。</b> + <button + type="button" + onClick={() => { + token.set_value(null); + }} + > + <span className="icon icon-logout" /> 注销 + </button> + <br /> + </p> + {/*<p>*/} + {/*根据计算中心要求,访问授权三个月内有效,过期需重新登录。*/} + {/*T大树洞将会单向加密(i.e. 哈希散列)您的邮箱后再存入数据库,因此您的发帖具有较强的匿名性。具体可见我们的<a href="https://github.com/thuhole/thuhole-go-backend/blob/76f56e6b75257b59e552b6bdba77e114151fcad1/src/db.go#L184">后端开源代码</a>。*/} + {/*</p>*/} + <p> + <a + onClick={() => { + this.props.show_sidebar( + '系统消息', + <MessageViewer token={token.value} />, + ); + }} + > + 查看系统消息 + </a> + <br /> + 当您发送的内容违规时,我们将用系统消息提示您 + </p> + <p> + <a onClick={this.copy_token.bind(this, token.value)}> + 复制 User Token + </a> + <br /> + 复制 User Token + 可以在新设备登录,切勿告知他人。若怀疑被盗号请重新邮箱验证码登录以重置Token。 + {/*,若怀疑被盗号请尽快 <ResetUsertokenWidget token={token.value} />*/} + </p> </div> - }</TokenCtx.Consumer> - ) - } + ) : ( + <LoginPopup token_callback={token.set_value}> + {(do_popup) => ( + <div> + <p> + <button type="button" onClick={do_popup}> + <span className="icon icon-login" /> +  登录 + </button> + </p> + <p> + <small> + T大树洞 + 面向T大学生,通过T大邮箱验证您的身份并提供服务。 + </small> + </p> + </div> + )} + </LoginPopup> + )} + </div> + </div> + )} + </TokenCtx.Consumer> + ); + } } export class ReplyForm extends Component { - constructor(props) { - super(props); - this.state={ - text: '', - loading_status: 'done', - preview: false, - }; - this.on_change_bound=this.on_change.bind(this); - this.area_ref=this.props.area_ref||React.createRef(); - this.global_keypress_handler_bound=this.global_keypress_handler.bind(this); - this.color_picker=new ColorPicker(); + constructor(props) { + super(props); + this.state = { + text: '', + loading_status: 'done', + preview: false, + }; + this.on_change_bound = this.on_change.bind(this); + this.area_ref = this.props.area_ref || React.createRef(); + this.global_keypress_handler_bound = this.global_keypress_handler.bind( + this, + ); + this.color_picker = new ColorPicker(); + } + + global_keypress_handler(e) { + if ( + e.code === 'Enter' && + !e.ctrlKey && + !e.altKey && + ['input', 'textarea'].indexOf(e.target.tagName.toLowerCase()) === -1 + ) { + if (this.area_ref.current) { + e.preventDefault(); + this.area_ref.current.focus(); + } } - - global_keypress_handler(e) { - if(e.code==='Enter' && !e.ctrlKey && !e.altKey && ['input','textarea'].indexOf(e.target.tagName.toLowerCase())===-1) { - if(this.area_ref.current) { - e.preventDefault(); - this.area_ref.current.focus(); - } + } + componentDidMount() { + document.addEventListener('keypress', this.global_keypress_handler_bound); + } + componentWillUnmount() { + document.removeEventListener( + 'keypress', + this.global_keypress_handler_bound, + ); + } + + on_change(value) { + this.setState({ + text: value, + }); + } + + on_submit(event) { + if (event) event.preventDefault(); + if (this.state.loading_status === 'loading') return; + this.setState({ + loading_status: 'loading', + }); + + let data = new URLSearchParams(); + data.append('pid', this.props.pid); + data.append('text', this.state.text); + data.append('user_token', this.props.token); + fetch( + API_BASE + '/api.php?action=docomment' + token_param(this.props.token), + { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: data, + }, + ) + .then(get_json) + .then((json) => { + if (json.code !== 0) { + if (json.msg) alert(json.msg); + throw new Error(JSON.stringify(json)); } - } - componentDidMount() { - document.addEventListener('keypress',this.global_keypress_handler_bound); - } - componentWillUnmount() { - document.removeEventListener('keypress',this.global_keypress_handler_bound); - } - on_change(value) { this.setState({ - text: value, + loading_status: 'done', + text: '', + preview: false, }); - } - - on_submit(event) { - if(event) event.preventDefault(); - if(this.state.loading_status==='loading') - return; + this.area_ref.current.clear(); + this.props.on_complete(); + }) + .catch((e) => { + console.error(e); + alert('回复失败'); this.setState({ - loading_status: 'loading', + loading_status: 'done', }); + }); + } - let data=new URLSearchParams(); - data.append('pid',this.props.pid); - data.append('text',this.state.text); - data.append('user_token',this.props.token); - fetch(API_BASE+'/api.php?action=docomment'+token_param(this.props.token), { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: data, - }) - .then(get_json) - .then((json)=>{ - if(json.code!==0) { - if(json.msg) alert(json.msg); - throw new Error(JSON.stringify(json)); - } - - this.setState({ - loading_status: 'done', - text: '', - preview: false, - }); - this.area_ref.current.clear(); - this.props.on_complete(); - }) - .catch((e)=>{ - console.error(e); - alert('回复失败'); - this.setState({ - loading_status: 'done', - }); - }); - } - - toggle_preview() { - this.setState({ - preview: !this.state.preview - }); - } + toggle_preview() { + this.setState({ + preview: !this.state.preview, + }); + } - render() { - return ( - <form onSubmit={this.on_submit.bind(this)} className={'reply-form box'+(this.state.text?' reply-sticky':'')}> - { - this.state.preview ? - <div className='reply-preview'> - <HighlightedMarkdown text={this.state.text} color_picker={this.color_picker} show_pid={()=>{}} /> - </div> : - <SafeTextarea ref={this.area_ref} id={this.props.pid} on_change={this.on_change_bound} on_submit={this.on_submit.bind(this)} /> - } - <button type='button' onClick={()=>{this.toggle_preview()}}> - {this.state.preview? <span className="icon icon-eye-blocked" />: <span className="icon icon-eye" />} - </button> - {this.state.loading_status==='loading' ? - <button disabled="disabled"> - <span className="icon icon-loading" /> - </button> : - <button type="submit"> - <span className="icon icon-send" /> - </button> - } - </form> - ) - } + render() { + return ( + <form + onSubmit={this.on_submit.bind(this)} + className={'reply-form box' + (this.state.text ? ' reply-sticky' : '')} + > + {this.state.preview ? ( + <div className="reply-preview"> + <HighlightedMarkdown + text={this.state.text} + color_picker={this.color_picker} + show_pid={() => {}} + /> + </div> + ) : ( + <SafeTextarea + ref={this.area_ref} + id={this.props.pid} + on_change={this.on_change_bound} + on_submit={this.on_submit.bind(this)} + /> + )} + <button + type="button" + onClick={() => { + this.toggle_preview(); + }} + > + {this.state.preview ? ( + <span className="icon icon-eye-blocked" /> + ) : ( + <span className="icon icon-eye" /> + )} + </button> + {this.state.loading_status === 'loading' ? ( + <button disabled="disabled"> + <span className="icon icon-loading" /> + </button> + ) : ( + <button type="submit"> + <span className="icon icon-send" /> + </button> + )} + </form> + ); + } } export class PostForm extends Component { - constructor(props) { - super(props); - this.state={ - text: '', - loading_status: 'done', - img_tip: null, - preview: false, - }; - this.img_ref=React.createRef(); - this.area_ref=React.createRef(); - this.on_change_bound=this.on_change.bind(this); - this.on_img_change_bound=this.on_img_change.bind(this); - this.color_picker=new ColorPicker(); - } - - componentDidMount() { - if(this.area_ref.current) - this.area_ref.current.focus(); - } + constructor(props) { + super(props); + this.state = { + text: '', + loading_status: 'done', + img_tip: null, + preview: false, + }; + this.img_ref = React.createRef(); + this.area_ref = React.createRef(); + this.on_change_bound = this.on_change.bind(this); + this.on_img_change_bound = this.on_img_change.bind(this); + this.color_picker = new ColorPicker(); + } + + componentDidMount() { + if (this.area_ref.current) this.area_ref.current.focus(); + } + + on_change(value) { + this.setState({ + text: value, + }); + } + + do_post(text, img) { + let data = new URLSearchParams(); + data.append('text', this.state.text); + data.append('type', img ? 'image' : 'text'); + data.append('user_token', this.props.token); + if (img) data.append('data', img); + + fetch(API_BASE + '/api.php?action=dopost' + token_param(this.props.token), { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: data, + }) + .then(get_json) + .then((json) => { + if (json.code !== 0) { + if (json.msg) alert(json.msg); + throw new Error(JSON.stringify(json)); + } - on_change(value) { this.setState({ - text: value, + loading_status: 'done', + text: '', + preview: false, }); - } - - do_post(text,img) { - let data=new URLSearchParams(); - data.append('text',this.state.text); - data.append('type',img ? 'image' : 'text'); - data.append('user_token',this.props.token); - if(img) - data.append('data',img); - - fetch(API_BASE+'/api.php?action=dopost'+token_param(this.props.token), { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: data, - }) - .then(get_json) - .then((json)=>{ - if(json.code!==0) { - if(json.msg) alert(json.msg); - throw new Error(JSON.stringify(json)); - } - - this.setState({ - loading_status: 'done', - text: '', - preview: false, - }); - this.area_ref.current.clear(); - this.props.on_complete(); - }) - .catch((e)=>{ - console.error(e); - alert('发表失败'); - this.setState({ - loading_status: 'done', - }); - }); - } - - proc_img(file) { - return new Promise((resolve,reject)=>{ - function return_url(url) { - const idx=url.indexOf(';base64,'); - if(idx===-1) - throw new Error('img not base64 encoded'); - - return url.substr(idx+8); - } - - let reader=new FileReader(); - function on_got_img(url) { - const image = new Image(); - image.onload=(()=>{ - let width=image.width; - let height=image.height; - let compressed=false; - - if(width>MAX_IMG_DIAM) { - height=height*MAX_IMG_DIAM/width; - width=MAX_IMG_DIAM; - compressed=true; - } - if(height>MAX_IMG_DIAM) { - width=width*MAX_IMG_DIAM/height; - height=MAX_IMG_DIAM; - compressed=true; - } - if(height*width>MAX_IMG_PX) { - let rate=Math.sqrt(height*width/MAX_IMG_PX); - height/=rate; - width/=rate; - compressed=true; - } - console.log('chosen img size',width,height); - - let canvas=document.createElement('canvas'); - let ctx=canvas.getContext('2d'); - canvas.width=width; - canvas.height=height; - ctx.drawImage(image,0,0,width,height); - - let quality_l=.1,quality_r=.9,quality,new_url; - while(quality_r-quality_l>=.03) { - quality=(quality_r+quality_l)/2; - new_url=canvas.toDataURL('image/jpeg',quality); - console.log(quality_l,quality_r,'trying quality',quality,'size',new_url.length); - if(new_url.length<=MAX_IMG_FILESIZE) - quality_l=quality; - else - quality_r=quality; - } - if(quality_l>=.101) { - console.log('chosen img quality',quality); - resolve({ - img: return_url(new_url), - quality: quality, - width: Math.round(width), - height: Math.round(height), - compressed: compressed, - }); - } else { - reject('图片过大,无法上传'); - } - }); - image.src=url; - } - reader.onload=(event)=>{ - fixOrientation(event.target.result,{},(fixed_dataurl)=>{ - on_got_img(fixed_dataurl); - }); - }; - reader.readAsDataURL(file); + this.area_ref.current.clear(); + this.props.on_complete(); + }) + .catch((e) => { + console.error(e); + alert('发表失败'); + this.setState({ + loading_status: 'done', }); - } - - on_img_change() { - if(this.img_ref.current && this.img_ref.current.files.length) - this.setState({ - img_tip: '(正在处理图片……)' - },()=>{ - this.proc_img(this.img_ref.current.files[0]) - .then((d)=>{ - this.setState({ - img_tip: `(${d.compressed?'压缩到':'尺寸'} ${d.width}*${d.height} / `+ - `质量 ${Math.floor(d.quality*100)}% / ${Math.floor(d.img.length/BASE64_RATE/1000)}KB)`, - }); - }) - .catch((e)=>{ - this.setState({ - img_tip: `图片无效:${e}`, - }); - }); - }); - else - this.setState({ - img_tip: null, + }); + } + + proc_img(file) { + return new Promise((resolve, reject) => { + function return_url(url) { + const idx = url.indexOf(';base64,'); + if (idx === -1) throw new Error('img not base64 encoded'); + + return url.substr(idx + 8); + } + + let reader = new FileReader(); + function on_got_img(url) { + const image = new Image(); + image.onload = () => { + let width = image.width; + let height = image.height; + let compressed = false; + + if (width > MAX_IMG_DIAM) { + height = (height * MAX_IMG_DIAM) / width; + width = MAX_IMG_DIAM; + compressed = true; + } + if (height > MAX_IMG_DIAM) { + width = (width * MAX_IMG_DIAM) / height; + height = MAX_IMG_DIAM; + compressed = true; + } + if (height * width > MAX_IMG_PX) { + let rate = Math.sqrt((height * width) / MAX_IMG_PX); + height /= rate; + width /= rate; + compressed = true; + } + console.log('chosen img size', width, height); + + let canvas = document.createElement('canvas'); + let ctx = canvas.getContext('2d'); + canvas.width = width; + canvas.height = height; + ctx.drawImage(image, 0, 0, width, height); + + let quality_l = 0.1, + quality_r = 0.9, + quality, + new_url; + while (quality_r - quality_l >= 0.03) { + quality = (quality_r + quality_l) / 2; + new_url = canvas.toDataURL('image/jpeg', quality); + console.log( + quality_l, + quality_r, + 'trying quality', + quality, + 'size', + new_url.length, + ); + if (new_url.length <= MAX_IMG_FILESIZE) quality_l = quality; + else quality_r = quality; + } + if (quality_l >= 0.101) { + console.log('chosen img quality', quality); + resolve({ + img: return_url(new_url), + quality: quality, + width: Math.round(width), + height: Math.round(height), + compressed: compressed, }); - } - - on_submit(event) { - if(event) event.preventDefault(); - if(this.state.loading_status==='loading') - return; - if(this.img_ref.current.files.length) { - this.setState({ - loading_status: 'processing', - }); - this.proc_img(this.img_ref.current.files[0]) - .then((d)=>{ - this.setState({ - loading_status: 'loading', - }); - this.do_post(this.state.text,d.img); - }) - .catch((e)=>{ - alert(e); - }); - } else { - this.setState({ - loading_status: 'loading', + } else { + reject('图片过大,无法上传'); + } + }; + image.src = url; + } + reader.onload = (event) => { + fixOrientation(event.target.result, {}, (fixed_dataurl) => { + on_got_img(fixed_dataurl); + }); + }; + reader.readAsDataURL(file); + }); + } + + on_img_change() { + if (this.img_ref.current && this.img_ref.current.files.length) + this.setState( + { + img_tip: '(正在处理图片……)', + }, + () => { + this.proc_img(this.img_ref.current.files[0]) + .then((d) => { + this.setState({ + img_tip: + `(${d.compressed ? '压缩到' : '尺寸'} ${d.width}*${ + d.height + } / ` + + `质量 ${Math.floor(d.quality * 100)}% / ${Math.floor( + d.img.length / BASE64_RATE / 1000, + )}KB)`, + }); + }) + .catch((e) => { + this.setState({ + img_tip: `图片无效:${e}`, + }); }); - this.do_post(this.state.text,null); - } - } - - toggle_preview() { - this.setState({ - preview: !this.state.preview + }, + ); + else + this.setState({ + img_tip: null, + }); + } + + on_submit(event) { + if (event) event.preventDefault(); + if (this.state.loading_status === 'loading') return; + if (this.img_ref.current.files.length) { + this.setState({ + loading_status: 'processing', + }); + this.proc_img(this.img_ref.current.files[0]) + .then((d) => { + this.setState({ + loading_status: 'loading', + }); + this.do_post(this.state.text, d.img); + }) + .catch((e) => { + alert(e); }); + } else { + this.setState({ + loading_status: 'loading', + }); + this.do_post(this.state.text, null); } + } - render() { - return ( - <form onSubmit={this.on_submit.bind(this)} className="post-form box"> - <div className="post-form-bar"> - <label> - 图片 - <input ref={this.img_ref} type="file" accept="image/*" disabled={this.state.loading_status!=='done'} - onChange={this.on_img_change_bound} - /> - </label> - - { - this.state.preview ? - <button type='button' onClick={()=>{this.toggle_preview()}}> - <span className="icon icon-eye-blocked" /> -  编辑 - </button> : - <button type='button' onClick={()=>{this.toggle_preview()}}> - <span className="icon icon-eye" /> -  预览 - </button> - } + toggle_preview() { + this.setState({ + preview: !this.state.preview, + }); + } - { - this.state.loading_status!=='done' ? - <button disabled="disabled"> - <span className="icon icon-loading" /> -  {this.state.loading_status==='processing' ? '处理' : '上传'} - </button> : - <button type="submit"> - <span className="icon icon-send" /> -  发表 - </button> - } - </div> - {!!this.state.img_tip && - <p className="post-form-img-tip"> - <a onClick={()=>{this.img_ref.current.value=""; this.on_img_change();}}>删除图片</a> - {this.state.img_tip} - </p> - } - { - this.state.preview ? - <div className='post-preview'> - <HighlightedMarkdown text={this.state.text} color_picker={this.color_picker} show_pid={()=>{}} /> - </div> : - <SafeTextarea ref={this.area_ref} id="new_post" on_change={this.on_change_bound} on_submit={this.on_submit.bind(this)} /> - } - <p><small> - 请遵守<a href="https://thuhole.com/policy.html" target="_blank">树洞管理规范(试行)</a>,文明发言 - </small></p> - </form> - ) - } -} \ No newline at end of file + render() { + return ( + <form onSubmit={this.on_submit.bind(this)} className="post-form box"> + <div className="post-form-bar"> + <label> + 图片 + <input + ref={this.img_ref} + type="file" + accept="image/*" + disabled={this.state.loading_status !== 'done'} + onChange={this.on_img_change_bound} + /> + </label> + + {this.state.preview ? ( + <button + type="button" + onClick={() => { + this.toggle_preview(); + }} + > + <span className="icon icon-eye-blocked" /> +  编辑 + </button> + ) : ( + <button + type="button" + onClick={() => { + this.toggle_preview(); + }} + > + <span className="icon icon-eye" /> +  预览 + </button> + )} + + {this.state.loading_status !== 'done' ? ( + <button disabled="disabled"> + <span className="icon icon-loading" /> +   + {this.state.loading_status === 'processing' ? '处理' : '上传'} + </button> + ) : ( + <button type="submit"> + <span className="icon icon-send" /> +  发表 + </button> + )} + </div> + {!!this.state.img_tip && ( + <p className="post-form-img-tip"> + <a + onClick={() => { + this.img_ref.current.value = ''; + this.on_img_change(); + }} + > + 删除图片 + </a> + {this.state.img_tip} + </p> + )} + {this.state.preview ? ( + <div className="post-preview"> + <HighlightedMarkdown + text={this.state.text} + color_picker={this.color_picker} + show_pid={() => {}} + /> + </div> + ) : ( + <SafeTextarea + ref={this.area_ref} + id="new_post" + on_change={this.on_change_bound} + on_submit={this.on_submit.bind(this)} + /> + )} + <p> + <small> + 请遵守 + <a href="https://thuhole.com/policy.html" target="_blank"> + 树洞管理规范(试行) + </a> + ,文明发言 + </small> + </p> + </form> + ); + } +} diff --git a/src/cache.js b/src/cache.js index 79d87b0..c38a116 100644 --- a/src/cache.js +++ b/src/cache.js @@ -1,172 +1,173 @@ -const HOLE_CACHE_DB_NAME='hole_cache_db'; -const CACHE_DB_VER=1; -const MAINTENANCE_STEP=150; -const MAINTENANCE_COUNT=1000; +const HOLE_CACHE_DB_NAME = 'hole_cache_db'; +const CACHE_DB_VER = 1; +const MAINTENANCE_STEP = 150; +const MAINTENANCE_COUNT = 1000; -const ENC_KEY=42; +const ENC_KEY = 42; class Cache { - constructor() { - this.db=null; - this.added_items_since_maintenance=0; - this.encrypt=this.encrypt.bind(this); - this.decrypt=this.decrypt.bind(this); - const open_req=indexedDB.open(HOLE_CACHE_DB_NAME,CACHE_DB_VER); - open_req.onerror=console.error.bind(console); - open_req.onupgradeneeded=(event)=>{ - console.log('comment cache db upgrade'); - const db=event.target.result; - const store=db.createObjectStore('comment',{ - keyPath: 'pid', - }); - store.createIndex('last_access','last_access',{unique: false}); - }; - open_req.onsuccess=(event)=>{ - console.log('comment cache db loaded'); - this.db=event.target.result; - setTimeout(this.maintenance.bind(this),1); - }; - } + constructor() { + this.db = null; + this.added_items_since_maintenance = 0; + this.encrypt = this.encrypt.bind(this); + this.decrypt = this.decrypt.bind(this); + const open_req = indexedDB.open(HOLE_CACHE_DB_NAME, CACHE_DB_VER); + open_req.onerror = console.error.bind(console); + open_req.onupgradeneeded = (event) => { + console.log('comment cache db upgrade'); + const db = event.target.result; + const store = db.createObjectStore('comment', { + keyPath: 'pid', + }); + store.createIndex('last_access', 'last_access', { unique: false }); + }; + open_req.onsuccess = (event) => { + console.log('comment cache db loaded'); + this.db = event.target.result; + setTimeout(this.maintenance.bind(this), 1); + }; + } - // use window.hole_cache.encrypt() only after cache is loaded! - encrypt(pid,data) { - let s=JSON.stringify(data); - let o=''; - for(let i=0,key=(ENC_KEY^pid)%128;i<s.length;i++) { - let c=s.charCodeAt(i); - let new_key=(key^(c/2))%128; - o+=String.fromCharCode(key^s.charCodeAt(i)); - key=new_key; - } - return o; + // use window.hole_cache.encrypt() only after cache is loaded! + encrypt(pid, data) { + let s = JSON.stringify(data); + let o = ''; + for (let i = 0, key = (ENC_KEY ^ pid) % 128; i < s.length; i++) { + let c = s.charCodeAt(i); + let new_key = (key ^ (c / 2)) % 128; + o += String.fromCharCode(key ^ s.charCodeAt(i)); + key = new_key; } + return o; + } - // use window.hole_cache.decrypt() only after cache is loaded! - decrypt(pid,s) { - let o=''; - if(typeof(s)!==typeof('str')) - return null; + // use window.hole_cache.decrypt() only after cache is loaded! + decrypt(pid, s) { + let o = ''; + if (typeof s !== typeof 'str') return null; - for(let i=0,key=(ENC_KEY^pid)%128;i<s.length;i++) { - let c=key^s.charCodeAt(i); - o+=String.fromCharCode(c); - key=(key^(c/2))%128; - } - - try { - return JSON.parse(o); - } catch(e) { - console.error('decrypt failed'); - console.trace(e); - return null; - } + for (let i = 0, key = (ENC_KEY ^ pid) % 128; i < s.length; i++) { + let c = key ^ s.charCodeAt(i); + o += String.fromCharCode(c); + key = (key ^ (c / 2)) % 128; } - get(pid,target_version) { - pid=parseInt(pid); - return new Promise((resolve,reject)=>{ - if(!this.db) - return resolve(null); - const tx=this.db.transaction(['comment'],'readwrite'); - const store=tx.objectStore('comment'); - const get_req=store.get(pid); - get_req.onsuccess=()=>{ - let res=get_req.result; - if(!res || !res.data_str) { - //console.log('comment cache miss '+pid); - resolve(null); - } else if(target_version===res.version) { // hit - console.log('comment cache hit',pid); - res.last_access=(+new Date()); - store.put(res); - let data=this.decrypt(pid,res.data_str); - resolve(data); // obj or null - } else { // expired - console.log('comment cache expired',pid,': ver',res.version,'target',target_version); - store.delete(pid); - resolve(null); - } - }; - get_req.onerror=(e)=>{ - console.warn('comment cache indexeddb open failed'); - console.error(e); - resolve(null); - }; - }); + try { + return JSON.parse(o); + } catch (e) { + console.error('decrypt failed'); + console.trace(e); + return null; } + } - put(pid,target_version,data) { - pid=parseInt(pid); - return new Promise((resolve,reject)=>{ - if(!this.db) - return resolve(); - const tx=this.db.transaction(['comment'],'readwrite'); - const store=tx.objectStore('comment'); - store.put({ - pid: pid, - version: target_version, - data_str: this.encrypt(pid,data), - last_access: +new Date(), - }); - if(++this.added_items_since_maintenance===MAINTENANCE_STEP) - setTimeout(this.maintenance.bind(this),1); - }); - } + get(pid, target_version) { + pid = parseInt(pid); + return new Promise((resolve, reject) => { + if (!this.db) return resolve(null); + const tx = this.db.transaction(['comment'], 'readwrite'); + const store = tx.objectStore('comment'); + const get_req = store.get(pid); + get_req.onsuccess = () => { + let res = get_req.result; + if (!res || !res.data_str) { + //console.log('comment cache miss '+pid); + resolve(null); + } else if (target_version === res.version) { + // hit + console.log('comment cache hit', pid); + res.last_access = +new Date(); + store.put(res); + let data = this.decrypt(pid, res.data_str); + resolve(data); // obj or null + } else { + // expired + console.log( + 'comment cache expired', + pid, + ': ver', + res.version, + 'target', + target_version, + ); + store.delete(pid); + resolve(null); + } + }; + get_req.onerror = (e) => { + console.warn('comment cache indexeddb open failed'); + console.error(e); + resolve(null); + }; + }); + } - delete(pid) { - pid=parseInt(pid); - return new Promise((resolve,reject)=>{ - if(!this.db) - return resolve(); - const tx=this.db.transaction(['comment'],'readwrite'); - const store=tx.objectStore('comment'); - let req=store.delete(pid); - //console.log('comment cache delete',pid); - req.onerror=()=>{ - console.warn('comment cache delete failed ',pid); - return resolve(); - }; - req.onsuccess=()=>resolve(); - }); - } + put(pid, target_version, data) { + pid = parseInt(pid); + return new Promise((resolve, reject) => { + if (!this.db) return resolve(); + const tx = this.db.transaction(['comment'], 'readwrite'); + const store = tx.objectStore('comment'); + store.put({ + pid: pid, + version: target_version, + data_str: this.encrypt(pid, data), + last_access: +new Date(), + }); + if (++this.added_items_since_maintenance === MAINTENANCE_STEP) + setTimeout(this.maintenance.bind(this), 1); + }); + } - maintenance() { - if(!this.db) - return; - const tx=this.db.transaction(['comment'],'readwrite'); - const store=tx.objectStore('comment'); - let count_req=store.count(); - count_req.onsuccess=()=>{ - let count=count_req.result; - if(count>MAINTENANCE_COUNT) { - console.log('comment cache db maintenance',count); - store.index('last_access').openKeyCursor().onsuccess=(e)=>{ - let cur=e.target.result; - if(cur) { - //console.log('maintenance: delete',cur); - store.delete(cur.primaryKey); - if(--count>MAINTENANCE_COUNT) - cur.continue(); - } - }; - } else { - console.log('comment cache db no need to maintenance',count); - } - this.added_items_since_maintenance=0; + delete(pid) { + pid = parseInt(pid); + return new Promise((resolve, reject) => { + if (!this.db) return resolve(); + const tx = this.db.transaction(['comment'], 'readwrite'); + const store = tx.objectStore('comment'); + let req = store.delete(pid); + //console.log('comment cache delete',pid); + req.onerror = () => { + console.warn('comment cache delete failed ', pid); + return resolve(); + }; + req.onsuccess = () => resolve(); + }); + } + + maintenance() { + if (!this.db) return; + const tx = this.db.transaction(['comment'], 'readwrite'); + const store = tx.objectStore('comment'); + let count_req = store.count(); + count_req.onsuccess = () => { + let count = count_req.result; + if (count > MAINTENANCE_COUNT) { + console.log('comment cache db maintenance', count); + store.index('last_access').openKeyCursor().onsuccess = (e) => { + let cur = e.target.result; + if (cur) { + //console.log('maintenance: delete',cur); + store.delete(cur.primaryKey); + if (--count > MAINTENANCE_COUNT) cur.continue(); + } }; - count_req.onerror=console.error.bind(console); - } + } else { + console.log('comment cache db no need to maintenance', count); + } + this.added_items_since_maintenance = 0; + }; + count_req.onerror = console.error.bind(console); + } - clear() { - if(!this.db) - return; - indexedDB.deleteDatabase(HOLE_CACHE_DB_NAME); - console.log('delete comment cache db'); - } -}; + clear() { + if (!this.db) return; + indexedDB.deleteDatabase(HOLE_CACHE_DB_NAME); + console.log('delete comment cache db'); + } +} export function cache() { - if(!window.hole_cache) - window.hole_cache=new Cache(); - return window.hole_cache; -} \ No newline at end of file + if (!window.hole_cache) window.hole_cache = new Cache(); + return window.hole_cache; +} diff --git a/src/color_picker.js b/src/color_picker.js index 7fa51d7..afcf240 100644 --- a/src/color_picker.js +++ b/src/color_picker.js @@ -1,26 +1,25 @@ // https://martin.ankerl.com/2009/12/09/how-to-create-random-colors-programmatically/ -const golden_ratio_conjugate=0.618033988749895; +const golden_ratio_conjugate = 0.618033988749895; export class ColorPicker { - constructor() { - this.names={}; - this.current_h=Math.random(); - } + constructor() { + this.names = {}; + this.current_h = Math.random(); + } - get(name) { - name=name.toLowerCase(); - if(name==='洞主') - return ['hsl(0,0%,97%)','hsl(0,0%,16%)']; + get(name) { + name = name.toLowerCase(); + if (name === '洞主') return ['hsl(0,0%,97%)', 'hsl(0,0%,16%)']; - if(!this.names[name]) { - this.current_h+=golden_ratio_conjugate; - this.current_h%=1; - this.names[name]=[ - `hsl(${this.current_h*360}, 50%, 90%)`, - `hsl(${this.current_h*360}, 60%, 20%)`, - ]; - } - return this.names[name]; + if (!this.names[name]) { + this.current_h += golden_ratio_conjugate; + this.current_h %= 1; + this.names[name] = [ + `hsl(${this.current_h * 360}, 50%, 90%)`, + `hsl(${this.current_h * 360}, 60%, 20%)`, + ]; } -} \ No newline at end of file + return this.names[name]; + } +} diff --git a/src/flows_api.js b/src/flows_api.js index f897d64..2e8d3ed 100644 --- a/src/flows_api.js +++ b/src/flows_api.js @@ -1,183 +1,182 @@ -import {get_json, API_VERSION_PARAM} from './infrastructure/functions'; -import {THUHOLE_API_ROOT} from './infrastructure/const'; -import {API_BASE} from './Common'; -import {cache} from './cache'; +import { get_json, API_VERSION_PARAM } from './infrastructure/functions'; +import { THUHOLE_API_ROOT } from './infrastructure/const'; +import { API_BASE } from './Common'; +import { cache } from './cache'; -export {THUHOLE_API_ROOT, API_VERSION_PARAM}; +export { THUHOLE_API_ROOT, API_VERSION_PARAM }; export function token_param(token) { - return API_VERSION_PARAM()+(token ? ('&user_token='+token) : ''); + return API_VERSION_PARAM() + (token ? '&user_token=' + token : ''); } -export {get_json}; - -const SEARCH_PAGESIZE=50; - -export const API={ - load_replies: (pid,token,color_picker,cache_version)=>{ - pid=parseInt(pid); - return fetch( - API_BASE+'/api.php?action=getcomment'+ - '&pid='+pid+ - token_param(token) - ) - .then(get_json) - .then((json)=>{ - if(json.code!==0) { - if(json.msg) throw new Error(json.msg); - else throw new Error(JSON.stringify(json)); - } - - cache().delete(pid).then(()=>{ - cache().put(pid,cache_version,json); - }); - - // also change load_replies_with_cache! - json.data=json.data - .sort((a,b)=>{ - return parseInt(a.cid,10)-parseInt(b.cid,10); - }) - .map((info)=>{ - info._display_color=color_picker.get(info.name); - info.variant={}; - return info; - }); - - return json; +export { get_json }; + +const SEARCH_PAGESIZE = 50; + +export const API = { + load_replies: (pid, token, color_picker, cache_version) => { + pid = parseInt(pid); + return fetch( + API_BASE + + '/api.php?action=getcomment' + + '&pid=' + + pid + + token_param(token), + ) + .then(get_json) + .then((json) => { + if (json.code !== 0) { + if (json.msg) throw new Error(json.msg); + else throw new Error(JSON.stringify(json)); + } + + cache() + .delete(pid) + .then(() => { + cache().put(pid, cache_version, json); + }); + + // also change load_replies_with_cache! + json.data = json.data + .sort((a, b) => { + return parseInt(a.cid, 10) - parseInt(b.cid, 10); + }) + .map((info) => { + info._display_color = color_picker.get(info.name); + info.variant = {}; + return info; + }); + + return json; + }); + }, + + load_replies_with_cache: (pid, token, color_picker, cache_version) => { + pid = parseInt(pid); + return cache() + .get(pid, cache_version) + .then((json) => { + if (json) { + // also change load_replies! + json.data = json.data + .sort((a, b) => { + return parseInt(a.cid, 10) - parseInt(b.cid, 10); + }) + .map((info) => { + info._display_color = color_picker.get(info.name); + info.variant = {}; + return info; }); - }, - - load_replies_with_cache: (pid,token,color_picker,cache_version)=> { - pid=parseInt(pid); - return cache().get(pid,cache_version) - .then((json)=>{ - if(json) { - // also change load_replies! - json.data=json.data - .sort((a,b)=>{ - return parseInt(a.cid,10)-parseInt(b.cid,10); - }) - .map((info)=>{ - info._display_color=color_picker.get(info.name); - info.variant={}; - return info; - }); - - return json; - } - else - return API.load_replies(pid,token,color_picker,cache_version); - }); - }, - - set_attention: (pid,attention,token)=>{ - let data=new URLSearchParams(); - data.append('user_token',token); - data.append('pid',pid); - data.append('switch',attention ? '1' : '0'); - return fetch(API_BASE+'/api.php?action=attention'+token_param(token), { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: data, - }) - .then(get_json) - .then((json)=>{ - cache().delete(pid); - if(json.code!==0) { - if(json.msg && json.msg==='已经关注过了') {} - else { - if(json.msg) alert(json.msg); - throw new Error(JSON.stringify(json)); - } - } - return json; - }); - }, - - report: (pid,reason,token)=>{ - let data=new URLSearchParams(); - data.append('user_token',token); - data.append('pid',pid); - data.append('reason',reason); - return fetch(API_BASE+'/api.php?action=report'+token_param(token), { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: data, - }) - .then(get_json) - .then((json)=>{ - if(json.code!==0) { - if(json.msg) alert(json.msg); - throw new Error(JSON.stringify(json)); - } - return json; - }); - }, - - get_list: (page,token)=>{ - return fetch( - API_BASE+'/api.php?action=getlist'+ - '&p='+page+ - token_param(token) - ) - .then(get_json) - .then((json)=>{ - if(json.code!==0) - throw new Error(JSON.stringify(json)); - return json; - }); - }, - - get_search: (page,keyword,token)=>{ - return fetch( - API_BASE+'/api.php?action=search'+ - '&pagesize='+SEARCH_PAGESIZE+ - '&page='+page+ - '&keywords='+encodeURIComponent(keyword)+ - token_param(token) - ) - .then(get_json) - .then((json)=>{ - if(json.code!==0) { - if(json.msg) throw new Error(json.msg); - throw new Error(JSON.stringify(json)); - } - return json; - }); - }, - - get_single: (pid,token)=>{ - return fetch( - API_BASE+'/api.php?action=getone'+ - '&pid='+pid+ - token_param(token) - ) - .then(get_json) - .then((json)=>{ - if(json.code!==0) { - if(json.msg) throw new Error(json.msg); - else throw new Error(JSON.stringify(json)); - } - return json; - }); - }, - - get_attention: (token)=>{ - return fetch( - API_BASE+'/api.php?action=getattention'+ - token_param(token) - ) - .then(get_json) - .then((json)=>{ - if(json.code!==0) { - if(json.msg) throw new Error(json.msg); - throw new Error(JSON.stringify(json)); - } - return json; - }); - }, -}; \ No newline at end of file + + return json; + } else return API.load_replies(pid, token, color_picker, cache_version); + }); + }, + + set_attention: (pid, attention, token) => { + let data = new URLSearchParams(); + data.append('user_token', token); + data.append('pid', pid); + data.append('switch', attention ? '1' : '0'); + return fetch(API_BASE + '/api.php?action=attention' + token_param(token), { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: data, + }) + .then(get_json) + .then((json) => { + cache().delete(pid); + if (json.code !== 0) { + if (json.msg && json.msg === '已经关注过了') { + } else { + if (json.msg) alert(json.msg); + throw new Error(JSON.stringify(json)); + } + } + return json; + }); + }, + + report: (pid, reason, token) => { + let data = new URLSearchParams(); + data.append('user_token', token); + data.append('pid', pid); + data.append('reason', reason); + return fetch(API_BASE + '/api.php?action=report' + token_param(token), { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: data, + }) + .then(get_json) + .then((json) => { + if (json.code !== 0) { + if (json.msg) alert(json.msg); + throw new Error(JSON.stringify(json)); + } + return json; + }); + }, + + get_list: (page, token) => { + return fetch( + API_BASE + '/api.php?action=getlist' + '&p=' + page + token_param(token), + ) + .then(get_json) + .then((json) => { + if (json.code !== 0) throw new Error(JSON.stringify(json)); + return json; + }); + }, + + get_search: (page, keyword, token) => { + return fetch( + API_BASE + + '/api.php?action=search' + + '&pagesize=' + + SEARCH_PAGESIZE + + '&page=' + + page + + '&keywords=' + + encodeURIComponent(keyword) + + token_param(token), + ) + .then(get_json) + .then((json) => { + if (json.code !== 0) { + if (json.msg) throw new Error(json.msg); + throw new Error(JSON.stringify(json)); + } + return json; + }); + }, + + get_single: (pid, token) => { + return fetch( + API_BASE + '/api.php?action=getone' + '&pid=' + pid + token_param(token), + ) + .then(get_json) + .then((json) => { + if (json.code !== 0) { + if (json.msg) throw new Error(json.msg); + else throw new Error(JSON.stringify(json)); + } + return json; + }); + }, + + get_attention: (token) => { + return fetch(API_BASE + '/api.php?action=getattention' + token_param(token)) + .then(get_json) + .then((json) => { + if (json.code !== 0) { + if (json.msg) throw new Error(json.msg); + throw new Error(JSON.stringify(json)); + } + return json; + }); + }, +}; diff --git a/src/registerServiceWorker.js b/src/registerServiceWorker.js index ca5a0b6..b8ef96e 100644 --- a/src/registerServiceWorker.js +++ b/src/registerServiceWorker.js @@ -14,8 +14,8 @@ const isLocalhost = Boolean( window.location.hostname === '[::1]' || // 127.0.0.1/8 is considered localhost for IPv4. window.location.hostname.match( - /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ - ) + /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/, + ), ); export default function register() { @@ -23,10 +23,10 @@ export default function register() { // The URL constructor is available in all browsers that support SW. // const publicUrl = new URL(process.env.PUBLIC_URL, window.location); // if (publicUrl.origin !== window.location.origin) { - // Our service worker won't work if PUBLIC_URL is on a different origin - // from what our page is served on. This might happen if a CDN is used to - // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 - // return; + // Our service worker won't work if PUBLIC_URL is on a different origin + // from what our page is served on. This might happen if a CDN is used to + // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 + // return; // } window.addEventListener('load', () => { @@ -41,7 +41,7 @@ export default function register() { navigator.serviceWorker.ready.then(() => { console.log( 'This web app is being served cache-first by a service ' + - 'worker. To learn more, visit https://goo.gl/SC7cgQ' + 'worker. To learn more, visit https://goo.gl/SC7cgQ', ); }); } else { @@ -55,7 +55,7 @@ export default function register() { function registerValidSW(swUrl) { navigator.serviceWorker .register(swUrl) - .then(registration => { + .then((registration) => { registration.onupdatefound = () => { const installingWorker = registration.installing; installingWorker.onstatechange = () => { @@ -76,7 +76,7 @@ function registerValidSW(swUrl) { }; }; }) - .catch(error => { + .catch((error) => { console.error('Error during service worker registration:', error); }); } @@ -84,14 +84,14 @@ function registerValidSW(swUrl) { function checkValidServiceWorker(swUrl) { // Check if the service worker can be found. If it can't reload the page. fetch(swUrl) - .then(response => { + .then((response) => { // Ensure service worker exists, and that we really are getting a JS file. if ( response.status === 404 || response.headers.get('content-type').indexOf('javascript') === -1 ) { // No service worker found. Probably a different app. Reload the page. - navigator.serviceWorker.ready.then(registration => { + navigator.serviceWorker.ready.then((registration) => { registration.unregister().then(() => { window.location.reload(); }); @@ -103,14 +103,14 @@ function checkValidServiceWorker(swUrl) { }) .catch(() => { console.log( - 'No internet connection found. App is running in offline mode.' + 'No internet connection found. App is running in offline mode.', ); }); } export function unregister() { if ('serviceWorker' in navigator) { - navigator.serviceWorker.ready.then(registration => { + navigator.serviceWorker.ready.then((registration) => { registration.unregister(); }); } diff --git a/src/text_splitter.js b/src/text_splitter.js index 7b4fdea..64cedcd 100644 --- a/src/text_splitter.js +++ b/src/text_splitter.js @@ -1,34 +1,34 @@ // regexp should match the WHOLE segmented part // export const PID_RE=/(^|[^\d\u20e3\ufe0e\ufe0f])([2-9]\d{4,5}|1\d{4,6})(?![\d\u20e3\ufe0e\ufe0f])/g; -export const PID_RE=/(^|[^\d\u20e3\ufe0e\ufe0f])(#\d{1,7})(?![\d\u20e3\ufe0e\ufe0f])/g; +export const PID_RE = /(^|[^\d\u20e3\ufe0e\ufe0f])(#\d{1,7})(?![\d\u20e3\ufe0e\ufe0f])/g; // TODO: fix this re // export const URL_PID_RE=/((?:https?:\/\/)?thuhole\.com\/?#(?:#|%23)([2-9]\d{4,5}|1\d{4,6}))(?!\d|\u20e3|\ufe0e|\ufe0f)/g; -export const URL_PID_RE=/((?:https?:\/\/)?thuhole\.com\/?#(?:#|%23)(\d{1,7}))(?!\d|\u20e3|\ufe0e|\ufe0f)/g; -export const NICKNAME_RE=/(^|[^A-Za-z])((?:(?:Angry|Baby|Crazy|Diligent|Excited|Fat|Greedy|Hungry|Interesting|Jolly|Kind|Little|Magic|Naïve|Old|PKU|Quiet|Rich|Superman|Tough|Undefined|Valuable|Wifeless|Xiangbuchulai|Young|Zombie)\s)?(?:Alice|Bob|Carol|Dave|Eve|Francis|Grace|Hans|Isabella|Jason|Kate|Louis|Margaret|Nathan|Olivia|Paul|Queen|Richard|Susan|Thomas|Uma|Vivian|Winnie|Xander|Yasmine|Zach)|You Win(?: \d+)?|洞主)(?![A-Za-z])/gi; -export const URL_RE=/(^|[^.@a-zA-Z0-9_])((?:https?:\/\/)?(?:(?:[\w-]+\.)+[a-zA-Z]{2,3}|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?::\d{1,5})?(?:\/[\w~!@#$%^&*()\-_=+[\]{};:,./?|]*)?)(?![a-zA-Z0-9])/gi; +export const URL_PID_RE = /((?:https?:\/\/)?thuhole\.com\/?#(?:#|%23)(\d{1,7}))(?!\d|\u20e3|\ufe0e|\ufe0f)/g; +export const NICKNAME_RE = /(^|[^A-Za-z])((?:(?:Angry|Baby|Crazy|Diligent|Excited|Fat|Greedy|Hungry|Interesting|Jolly|Kind|Little|Magic|Naïve|Old|PKU|Quiet|Rich|Superman|Tough|Undefined|Valuable|Wifeless|Xiangbuchulai|Young|Zombie)\s)?(?:Alice|Bob|Carol|Dave|Eve|Francis|Grace|Hans|Isabella|Jason|Kate|Louis|Margaret|Nathan|Olivia|Paul|Queen|Richard|Susan|Thomas|Uma|Vivian|Winnie|Xander|Yasmine|Zach)|You Win(?: \d+)?|洞主)(?![A-Za-z])/gi; +export const URL_RE = /(^|[^.@a-zA-Z0-9_])((?:https?:\/\/)?(?:(?:[\w-]+\.)+[a-zA-Z]{2,3}|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?::\d{1,5})?(?:\/[\w~!@#$%^&*()\-_=+[\]{};:,./?|]*)?)(?![a-zA-Z0-9])/gi; -export function split_text(txt,rules) { - // rules: [['name',/regex/],...] - // return: [['name','part'],[null,'part'],...] +export function split_text(txt, rules) { + // rules: [['name',/regex/],...] + // return: [['name','part'],[null,'part'],...] - txt=[[null,txt]]; - rules.forEach((rule)=>{ - let [name,regex]=rule; - txt=[].concat.apply([],txt.map((part)=>{ - let [rule,content]=part; - if(rule) // already tagged by previous rules - return [part]; - else { - return content - .split(regex) - .map((seg)=>( - regex.test(seg) ? [name,seg] : [null,seg] - )) - .filter(([name,seg])=>( - name!==null || seg - )); - } - })); - }); - return txt; + txt = [[null, txt]]; + rules.forEach((rule) => { + let [name, regex] = rule; + txt = [].concat.apply( + [], + txt.map((part) => { + let [rule, content] = part; + if (rule) + // already tagged by previous rules + return [part]; + else { + return content + .split(regex) + .map((seg) => (regex.test(seg) ? [name, seg] : [null, seg])) + .filter(([name, seg]) => name !== null || seg); + } + }), + ); + }); + return txt; }