Browse Source

format code

dev
thuhole 5 years ago
parent
commit
7636e45c00
  1. 55
      .eslintrc
  2. 7
      .prettierrc.js
  3. 142
      src/App.js
  4. 77
      src/AudioWidget.js
  5. 351
      src/Common.js
  6. 228
      src/Config.js
  7. 1061
      src/Flows.js
  8. 34
      src/Markdown.js
  9. 61
      src/Message.js
  10. 99
      src/PressureHelper.js
  11. 53
      src/Sidebar.js
  12. 161
      src/Title.js
  13. 635
      src/UserAction.js
  14. 191
      src/cache.js
  15. 23
      src/color_picker.js
  16. 179
      src/flows_api.js
  17. 18
      src/registerServiceWorker.js
  18. 36
      src/text_splitter.js

55
.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"
}
]
}
}

7
.prettierrc.js

@ -0,0 +1,7 @@
module.exports = {
trailingComma: 'all',
tabWidth: 2,
semi: true,
singleQuote: true,
endOfLine: 'auto',
}

142
src/App.js

@ -1,80 +1,83 @@
import React, {Component} from 'react'; import React, { Component } from 'react';
import {Flow} from './Flows'; import { Flow } from './Flows';
import {Title} from './Title'; import { Title } from './Title';
import {Sidebar} from './Sidebar'; import { Sidebar } from './Sidebar';
import {PressureHelper} from './PressureHelper'; import { PressureHelper } from './PressureHelper';
import {TokenCtx} from './UserAction'; import { TokenCtx } from './UserAction';
import {load_config,bgimg_style} from './Config'; import { load_config, bgimg_style } from './Config';
import {listen_darkmode} from './infrastructure/functions'; import { listen_darkmode } from './infrastructure/functions';
import {LoginPopup, TitleLine} from './infrastructure/widgets'; import { LoginPopup, TitleLine } from './infrastructure/widgets';
const MAX_SIDEBAR_STACK_SIZE=10; const MAX_SIDEBAR_STACK_SIZE = 10;
function DeprecatedAlert(props) { function DeprecatedAlert(props) {
return ( return <div id="global-hint-container" style={{ display: 'none' }} />;
<div id="global-hint-container" style={{display: 'none'}} />
);
} }
class App extends Component { class App extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
load_config(); load_config();
listen_darkmode({default: undefined, light: false, dark: true}[window.config.color_scheme]); listen_darkmode(
this.state={ { default: undefined, light: false, dark: true }[
sidebar_stack: [[null,null]], // list of [status, content] window.config.color_scheme
],
);
this.state = {
sidebar_stack: [[null, null]], // list of [status, content]
mode: 'list', // list, single, search, attention mode: 'list', // list, single, search, attention
search_text: null, search_text: null,
flow_render_key: +new Date(), flow_render_key: +new Date(),
token: localStorage['TOKEN']||null, token: localStorage['TOKEN'] || null,
}; };
this.show_sidebar_bound=this.show_sidebar.bind(this); this.show_sidebar_bound = this.show_sidebar.bind(this);
this.set_mode_bound=this.set_mode.bind(this); this.set_mode_bound = this.set_mode.bind(this);
this.on_pressure_bound=this.on_pressure.bind(this); this.on_pressure_bound = this.on_pressure.bind(this);
// a silly self-deceptive approach to ban guests, enough to fool those muggles // a silly self-deceptive approach to ban guests, enough to fool those muggles
// document cookie 'pku_ip_flag=yes' // document cookie 'pku_ip_flag=yes'
this.inthu_flag=window[atob('ZG9jdW1lbnQ')][atob('Y29va2ll')].indexOf(atob('dGh1X2lwX2ZsYWc9eWVz'))!==-1; this.inthu_flag =
window[atob('ZG9jdW1lbnQ')][atob('Y29va2ll')].indexOf(
atob('dGh1X2lwX2ZsYWc9eWVz'),
) !== -1;
} }
static is_darkmode() { static is_darkmode() {
if(window.config.color_scheme==='dark') return true; if (window.config.color_scheme === 'dark') return true;
if(window.config.color_scheme==='light') return false; if (window.config.color_scheme === 'light') return false;
else { // 'default' else {
// 'default'
return window.matchMedia('(prefers-color-scheme: dark)').matches; return window.matchMedia('(prefers-color-scheme: dark)').matches;
} }
} }
on_pressure() { on_pressure() {
if(this.state.sidebar_stack.length>1) if (this.state.sidebar_stack.length > 1)
this.show_sidebar(null,null,'clear'); this.show_sidebar(null, null, 'clear');
else else this.set_mode('list', null);
this.set_mode('list',null);
} }
show_sidebar(title,content,mode='push') { show_sidebar(title, content, mode = 'push') {
this.setState((prevState)=>{ this.setState((prevState) => {
let ns=prevState.sidebar_stack.slice(); let ns = prevState.sidebar_stack.slice();
if(mode==='push') { if (mode === 'push') {
if(ns.length>MAX_SIDEBAR_STACK_SIZE) if (ns.length > MAX_SIDEBAR_STACK_SIZE) ns.splice(1, 1);
ns.splice(1,1); ns = ns.concat([[title, content]]);
ns=ns.concat([[title,content]]); } else if (mode === 'pop') {
} else if(mode==='pop') { if (ns.length === 1) return;
if(ns.length===1) return;
ns.pop(); ns.pop();
} else if(mode==='replace') { } else if (mode === 'replace') {
ns.pop(); ns.pop();
ns=ns.concat([[title,content]]); ns = ns.concat([[title, content]]);
} else if(mode==='clear') { } else if (mode === 'clear') {
ns=[[null,null]]; ns = [[null, null]];
} else } else throw new Error('bad show_sidebar mode');
throw new Error('bad show_sidebar mode');
return { return {
sidebar_stack: ns, sidebar_stack: ns,
}; };
}); });
} }
set_mode(mode,search_text) { set_mode(mode, search_text) {
this.setState({ this.setState({
mode: mode, mode: mode,
search_text: search_text, search_text: search_text,
@ -84,45 +87,62 @@ class App extends Component {
render() { render() {
return ( return (
<TokenCtx.Provider value={{ <TokenCtx.Provider
value={{
value: this.state.token, value: this.state.token,
set_value: (x)=>{ set_value: (x) => {
localStorage['TOKEN']=x||''; localStorage['TOKEN'] = x || '';
this.setState({ this.setState({
token: x, token: x,
}); });
}, },
}}> }}
>
<PressureHelper callback={this.on_pressure_bound} /> <PressureHelper callback={this.on_pressure_bound} />
<div className="bg-img" style={bgimg_style()} /> <div className="bg-img" style={bgimg_style()} />
<Title show_sidebar={this.show_sidebar_bound} set_mode={this.set_mode_bound} /> <Title
<TokenCtx.Consumer>{(token)=>( show_sidebar={this.show_sidebar_bound}
set_mode={this.set_mode_bound}
/>
<TokenCtx.Consumer>
{(token) => (
<div className="left-container"> <div className="left-container">
<DeprecatedAlert token={token.value} /> <DeprecatedAlert token={token.value} />
{!token.value && {!token.value && (
<div className="flow-item-row aux-margin"> <div className="flow-item-row aux-margin">
<div className="box box-tip"> <div className="box box-tip">
<p> <p>
<LoginPopup token_callback={token.set_value}>{(do_popup)=>( <LoginPopup token_callback={token.set_value}>
{(do_popup) => (
<a onClick={do_popup}> <a onClick={do_popup}>
<span className="icon icon-login" /> <span className="icon icon-login" />
&nbsp;登录到 T大树洞 &nbsp;登录到 T大树洞
</a> </a>
)}</LoginPopup> )}
</LoginPopup>
</p> </p>
</div> </div>
</div> </div>
} )}
{this.inthu_flag||token.value ? {this.inthu_flag || token.value ? (
<Flow key={this.state.flow_render_key} show_sidebar={this.show_sidebar_bound} <Flow
mode={this.state.mode} search_text={this.state.search_text} token={token.value} 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="请登录后查看内容" /> <TitleLine text="请登录后查看内容" />
} )}
<br /> <br />
</div> </div>
)}</TokenCtx.Consumer> )}
<Sidebar show_sidebar={this.show_sidebar_bound} stack={this.state.sidebar_stack} /> </TokenCtx.Consumer>
<Sidebar
show_sidebar={this.show_sidebar_bound}
stack={this.state.sidebar_stack}
/>
</TokenCtx.Provider> </TokenCtx.Provider>
); );
} }

77
src/AudioWidget.js

@ -1,18 +1,15 @@
import React, {Component} from 'react'; import React, { Component } from 'react';
import load from 'load-script'; import load from 'load-script';
window.audio_cache={}; window.audio_cache = {};
function load_amrnb() { function load_amrnb() {
return new Promise((resolve,reject)=>{ return new Promise((resolve, reject) => {
if(window.AMR) if (window.AMR) resolve();
resolve();
else else
load('static/amr_all.min.js', (err)=>{ load('static/amr_all.min.js', (err) => {
if(err) if (err) reject(err);
reject(err); else resolve();
else
resolve();
}); });
}); });
} }
@ -20,7 +17,7 @@ function load_amrnb() {
export class AudioWidget extends Component { export class AudioWidget extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state={ this.state = {
url: this.props.src, url: this.props.src,
state: 'waiting', state: 'waiting',
data: null, data: null,
@ -28,7 +25,7 @@ export class AudioWidget extends Component {
} }
load() { load() {
if(window.audio_cache[this.state.url]) { if (window.audio_cache[this.state.url]) {
this.setState({ this.setState({
state: 'loaded', state: 'loaded',
data: window.audio_cache[this.state.url], data: window.audio_cache[this.state.url],
@ -36,35 +33,33 @@ export class AudioWidget extends Component {
return; return;
} }
console.log('fetching audio',this.state.url); console.log('fetching audio', this.state.url);
this.setState({ this.setState({
state: 'loading', state: 'loading',
}); });
Promise.all([ Promise.all([fetch(this.state.url), load_amrnb()]).then((res) => {
fetch(this.state.url), res[0].blob().then((blob) => {
load_amrnb(), const reader = new FileReader();
]) reader.onload = (event) => {
.then((res)=>{ const raw = new window.AMR().decode(event.target.result);
res[0].blob().then((blob)=>{ if (!raw) {
const reader=new FileReader();
reader.onload=(event)=>{
const raw=new window.AMR().decode(event.target.result);
if(!raw) {
alert('audio decoding failed'); alert('audio decoding failed');
return; return;
} }
const wave=window.PCMData.encode({ const wave = window.PCMData.encode({
sampleRate: 8000, sampleRate: 8000,
channelCount: 1, channelCount: 1,
bytesPerSample: 2, bytesPerSample: 2,
data: raw data: raw,
}); });
const binary_wave=new Uint8Array(wave.length); const binary_wave = new Uint8Array(wave.length);
for(let i=0;i<wave.length;i++) for (let i = 0; i < wave.length; i++)
binary_wave[i]=wave.charCodeAt(i); binary_wave[i] = wave.charCodeAt(i);
const objurl=URL.createObjectURL(new Blob([binary_wave], {type: 'audio/wav'})); const objurl = URL.createObjectURL(
window.audio_cache[this.state.url]=objurl; new Blob([binary_wave], { type: 'audio/wav' }),
);
window.audio_cache[this.state.url] = objurl;
this.setState({ this.setState({
state: 'loaded', state: 'loaded',
data: objurl, data: objurl,
@ -79,13 +74,19 @@ export class AudioWidget extends Component {
} }
render() { render() {
if(this.state.state==='waiting') if (this.state.state === 'waiting')
return (<p><a onClick={this.load.bind(this)}>加载音频</a></p>); return (
if(this.state.state==='loading') <p>
return (<p>正在下载</p>); <a onClick={this.load.bind(this)}>加载音频</a>
else if(this.state.state==='decoding') </p>
return (<p>正在解码</p>); );
else if(this.state.state==='loaded') if (this.state.state === 'loading') return <p>正在下载</p>;
return (<p><audio src={this.state.data} controls /></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>
);
} }
} }

351
src/Common.js

@ -1,179 +1,266 @@
import React, {Component, PureComponent} from 'react'; import React, { Component, PureComponent } from 'react';
import {format_time,Time,TitleLine} from './infrastructure/widgets'; import { format_time, Time, TitleLine } from './infrastructure/widgets';
import {THUHOLE_API_ROOT} from './flows_api'; import { THUHOLE_API_ROOT } from './flows_api';
import HtmlToReact from 'html-to-react' import HtmlToReact from 'html-to-react';
import './Common.css'; 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 // https://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex
function escape_regex(string) { 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') { 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; return txt
? new RegExp(
`(${txt
.split(split)
.filter((x) => !!x)
.map(escape_regex)
.join('|')})`,
option,
)
: /^$/g;
} }
export function ColoredSpan(props) { export function ColoredSpan(props) {
return ( return (
<span className="colored-span" style={{ <span
className="colored-span"
style={{
'--coloredspan-bgcolor-light': props.colors[0], '--coloredspan-bgcolor-light': props.colors[0],
'--coloredspan-bgcolor-dark': props.colors[1], '--coloredspan-bgcolor-dark': props.colors[1],
}}>{props.children}</span> }}
) >
{props.children}
</span>
);
} }
function normalize_url(url) { function normalize_url(url) {
return /^https?:\/\//.test(url) ? url : 'http://'+url; return /^https?:\/\//.test(url) ? url : 'http://' + url;
} }
export class HighlightedText extends PureComponent { export class HighlightedText extends PureComponent {
render() { render() {
return ( return (
<pre> <pre>
{this.props.parts.map((part,idx)=>{ {this.props.parts.map((part, idx) => {
let [rule,p]=part; let [rule, p] = part;
return ( return (
<span key={idx}>{ <span key={idx}>
rule==='url_pid' ? <span className="url-pid-link" title={p}>/##</span> : {rule === 'url_pid' ? (
rule==='url' ? <a href={normalize_url(p)} target="_blank" rel="noopener">{p}</a> : <span className="url-pid-link" title={p}>
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> : </span>
rule==='search' ? <span className="search-query-highlight">{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 p
}</span> )}
</span>
); );
})} })}
</pre> </pre>
) );
} }
} }
// props: text, show_pid, color_picker // props: text, show_pid, color_picker
export class HighlightedMarkdown extends Component { export class HighlightedMarkdown extends Component {
render() { render() {
const props = this.props const props = this.props;
const processDefs = new HtmlToReact.ProcessNodeDefinitions(React) const processDefs = new HtmlToReact.ProcessNodeDefinitions(React);
const processInstructions = [ const processInstructions = [
{ {
shouldProcessNode: (node) => node.name === 'img', // disable images shouldProcessNode: (node) => node.name === 'img', // disable images
processNode (node, children, index) { processNode(node, children, index) {
return (<div key={index}>[图片]</div>) return <div key={index}>[图片]</div>;
} },
}, },
{ {
shouldProcessNode: (node) => (/^h[123456]$/.test(node.name)), shouldProcessNode: (node) => /^h[123456]$/.test(node.name),
processNode (node, children, index) { processNode(node, children, index) {
let currentLevel = +(node.name[1]) let currentLevel = +node.name[1];
if (currentLevel < 3) currentLevel = 3; if (currentLevel < 3) currentLevel = 3;
const HeadingTag = `h${currentLevel}` const HeadingTag = `h${currentLevel}`;
return ( return <HeadingTag key={index}>{children}</HeadingTag>;
<HeadingTag key={index}>{children}</HeadingTag> },
)
}
}, },
{ {
shouldProcessNode: (node) => node.name === 'a', shouldProcessNode: (node) => node.name === 'a',
processNode (node, children, index) { processNode(node, children, index) {
return ( return (
<a href={normalize_url(node.attribs.href)} target="_blank" rel="noopenner noreferrer" className="ext-link" key={index}> <a
href={normalize_url(node.attribs.href)}
target="_blank"
rel="noopenner noreferrer"
className="ext-link"
key={index}
>
{children} {children}
<span className="icon icon-new-tab" /> <span className="icon icon-new-tab" />
</a> </a>
) );
} },
}, },
{ {
shouldProcessNode (node) { shouldProcessNode(node) {
return node.type === 'text' && (!node.parent || !node.parent.attribs || node.parent.attribs['encoding'] != "application/x-tex") // pid, nickname, search return (
node.type === 'text' &&
(!node.parent ||
!node.parent.attribs ||
node.parent.attribs['encoding'] != 'application/x-tex')
); // pid, nickname, search
}, },
processNode (node, children, index) { processNode(node, children, index) {
const originalText = node.data const originalText = node.data;
const splitted = split_text(originalText, [ const splitted = split_text(originalText, [
['url_pid', URL_PID_RE], ['url_pid', URL_PID_RE],
['url',URL_RE], ['url', URL_RE],
['pid',PID_RE], ['pid', PID_RE],
['nickname',NICKNAME_RE], ['nickname', NICKNAME_RE],
]) ]);
return ( return (
<React.Fragment key={index}> <React.Fragment key={index}>
{splitted.map(([rule, p], idx) => { {splitted.map(([rule, p], idx) => {
return (<span key={idx}> return (
{ <span key={idx}>
rule==='url_pid' ? <span className="url-pid-link" title={p}>/##</span> : {rule === 'url_pid' ? (
rule==='url' ? <a href={normalize_url(p)} className="ext-link" target="_blank" rel="noopener noreferrer"> <span className="url-pid-link" title={p}>
/##
</span>
) : rule === 'url' ? (
<a
href={normalize_url(p)}
className="ext-link"
target="_blank"
rel="noopener noreferrer"
>
{p} {p}
<span className="icon icon-new-tab" /> <span className="icon icon-new-tab" />
</a> : </a>
rule==='pid' ? <a href={'#'+p} onClick={(e)=>{e.preventDefault(); props.show_pid(p.substring(1));}}>{p}</a> : ) : rule === 'pid' ? (
rule==='nickname' ? <ColoredSpan colors={props.color_picker.get(p)}>{p}</ColoredSpan> : <a
rule==='search' ? <span className="search-query-highlight">{p}</span> : href={'#' + p}
p} onClick={(e) => {
</span>) 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> </React.Fragment>
) );
} },
}, },
{ {
shouldProcessNode: () => true, shouldProcessNode: () => true,
processNode: processDefs.processDefaultNode processNode: processDefs.processDefaultNode,
} },
] ];
const parser = new HtmlToReact.Parser() const parser = new HtmlToReact.Parser();
if (props.author && props.text.match(/^(?:#+ |>|```|\t|\s*-|\s*\d+\.)/)) { if (props.author && props.text.match(/^(?:#+ |>|```|\t|\s*-|\s*\d+\.)/)) {
const renderedMarkdown = renderMd(props.text) const renderedMarkdown = renderMd(props.text);
return ( return (
<> <>
{props.author} {props.author}
{parser.parseWithInstructions(renderedMarkdown, node => node.type !== 'script', processInstructions) || ''} {parser.parseWithInstructions(
renderedMarkdown,
(node) => node.type !== 'script',
processInstructions,
) || ''}
</> </>
) );
} else { } else {
let rawMd = props.text let rawMd = props.text;
if (props.author) rawMd = props.author + ' ' + rawMd if (props.author) rawMd = props.author + ' ' + rawMd;
const renderedMarkdown = renderMd(rawMd) const renderedMarkdown = renderMd(rawMd);
return (parser.parseWithInstructions(renderedMarkdown, node => node.type !== 'script', processInstructions) || null) return (
parser.parseWithInstructions(
renderedMarkdown,
(node) => node.type !== 'script',
processInstructions,
) || null
);
} }
} }
} }
window.TEXTAREA_BACKUP={}; window.TEXTAREA_BACKUP = {};
export class SafeTextarea extends Component { export class SafeTextarea extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state={ this.state = {
text: '', text: '',
}; };
this.on_change_bound=this.on_change.bind(this); this.on_change_bound = this.on_change.bind(this);
this.on_keydown_bound=this.on_keydown.bind(this); this.on_keydown_bound = this.on_keydown.bind(this);
this.clear=this.clear.bind(this); this.clear = this.clear.bind(this);
this.area_ref=React.createRef(); this.area_ref = React.createRef();
this.change_callback=props.on_change||(()=>{}); this.change_callback = props.on_change || (() => {});
this.submit_callback=props.on_submit||(()=>{}); this.submit_callback = props.on_submit || (() => {});
} }
componentDidMount() { componentDidMount() {
this.setState({ this.setState(
text: window.TEXTAREA_BACKUP[this.props.id]||'' {
},()=>{ text: window.TEXTAREA_BACKUP[this.props.id] || '',
},
() => {
this.change_callback(this.state.text); this.change_callback(this.state.text);
}); },
);
} }
componentWillUnmount() { componentWillUnmount() {
window.TEXTAREA_BACKUP[this.props.id]=this.state.text; window.TEXTAREA_BACKUP[this.props.id] = this.state.text;
this.change_callback(this.state.text); this.change_callback(this.state.text);
} }
@ -184,7 +271,7 @@ export class SafeTextarea extends Component {
this.change_callback(event.target.value); this.change_callback(event.target.value);
} }
on_keydown(event) { on_keydown(event) {
if(event.key==='Enter' && event.ctrlKey && !event.altKey) { if (event.key === 'Enter' && event.ctrlKey && !event.altKey) {
event.preventDefault(); event.preventDefault();
this.submit_callback(); this.submit_callback();
} }
@ -210,41 +297,54 @@ export class SafeTextarea extends Component {
render() { render() {
return ( return (
<textarea ref={this.area_ref} onChange={this.on_change_bound} value={this.state.text} onKeyDown={this.on_keydown_bound} /> <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) => { window.addEventListener('beforeinstallprompt', (e) => {
console.log('pwa: received before install prompt'); console.log('pwa: received before install prompt');
pwa_prompt_event=e; pwa_prompt_event = e;
}); });
export function PromotionBar(props) { export function PromotionBar(props) {
let is_ios=/iPhone|iPad|iPod/i.test(window.navigator.userAgent); 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_installed =
window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone;
if(is_installed) if (is_installed) return null;
return null;
if(is_ios) if (is_ios)
// noinspection JSConstructorReturnsPrimitive // noinspection JSConstructorReturnsPrimitive
return !navigator.standalone ? ( return !navigator.standalone ? (
<div className="box promotion-bar"> <div className="box promotion-bar">
<span className="icon icon-about" />&nbsp; <span className="icon icon-about" />
Safari 把树洞 <b>添加到主屏幕</b> &nbsp; Safari 把树洞 <b>添加到主屏幕</b>
</div> </div>
) : null; ) : null;
else
// noinspection JSConstructorReturnsPrimitive // noinspection JSConstructorReturnsPrimitive
else
return pwa_prompt_event ? ( return pwa_prompt_event ? (
<div className="box promotion-bar"> <div className="box promotion-bar">
<span className="icon icon-about" />&nbsp; <span className="icon icon-about" />
把网页版树洞 <b><a onClick={()=>{ &nbsp; 把网页版树洞{' '}
if(pwa_prompt_event) <b>
pwa_prompt_event.prompt(); <a
}}>安装到桌面</a></b> onClick={() => {
if (pwa_prompt_event) pwa_prompt_event.prompt();
}}
>
安装到桌面
</a>
</b>{' '}
更好用
</div> </div>
) : null; ) : null;
} }
@ -252,32 +352,34 @@ export function PromotionBar(props) {
export class ClickHandler extends PureComponent { export class ClickHandler extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
this.state={ this.state = {
moved: true, moved: true,
init_y: 0, init_y: 0,
init_x: 0, init_x: 0,
}; };
this.on_begin_bound=this.on_begin.bind(this); this.on_begin_bound = this.on_begin.bind(this);
this.on_move_bound=this.on_move.bind(this); this.on_move_bound = this.on_move.bind(this);
this.on_end_bound=this.on_end.bind(this); this.on_end_bound = this.on_end.bind(this);
this.MOVE_THRESHOLD=3; this.MOVE_THRESHOLD = 3;
this.last_fire=0; this.last_fire = 0;
} }
on_begin(e) { on_begin(e) {
//console.log('click',(e.touches?e.touches[0]:e).screenY,(e.touches?e.touches[0]:e).screenX); //console.log('click',(e.touches?e.touches[0]:e).screenY,(e.touches?e.touches[0]:e).screenX);
this.setState({ this.setState({
moved: false, moved: false,
init_y: (e.touches?e.touches[0]:e).screenY, init_y: (e.touches ? e.touches[0] : e).screenY,
init_x: (e.touches?e.touches[0]:e).screenX, init_x: (e.touches ? e.touches[0] : e).screenX,
}); });
} }
on_move(e) { on_move(e) {
if(!this.state.moved) { 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); 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); //console.log('move',mvmt);
if(mvmt>this.MOVE_THRESHOLD) if (mvmt > this.MOVE_THRESHOLD)
this.setState({ this.setState({
moved: true, moved: true,
}); });
@ -285,26 +387,29 @@ export class ClickHandler extends PureComponent {
} }
on_end(event) { on_end(event) {
//console.log('end'); //console.log('end');
if(!this.state.moved) if (!this.state.moved) this.do_callback(event);
this.do_callback(event);
this.setState({ this.setState({
moved: true, moved: true,
}); });
} }
do_callback(event) { do_callback(event) {
if(this.last_fire+100>+new Date()) return; if (this.last_fire + 100 > +new Date()) return;
this.last_fire=+new Date(); this.last_fire = +new Date();
this.props.callback(event); this.props.callback(event);
} }
render() { render() {
return ( return (
<div onTouchStart={this.on_begin_bound} onMouseDown={this.on_begin_bound} <div
onTouchMove={this.on_move_bound} onMouseMove={this.on_move_bound} onTouchStart={this.on_begin_bound}
onClick={this.on_end_bound} > onMouseDown={this.on_begin_bound}
onTouchMove={this.on_move_bound}
onMouseMove={this.on_move_bound}
onClick={this.on_end_bound}
>
{this.props.children} {this.props.children}
</div> </div>
) );
} }
} }

228
src/Config.js

@ -1,57 +1,64 @@
import React, {Component, PureComponent} from 'react'; import React, { Component, PureComponent } from 'react';
import './Config.css'; import './Config.css';
const BUILTIN_IMGS={ 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/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/eriri.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/yurucamp.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': '梦开始的地方', '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={ const DEFAULT_CONFIG = {
background_img: 'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/gbp.jpg', background_img:
'https://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages/static/bg/gbp.jpg',
background_color: '#113366', background_color: '#113366',
pressure: false, pressure: false,
easter_egg: true, easter_egg: true,
color_scheme: 'default', color_scheme: 'default',
fold: true fold: true,
}; };
export function load_config() { export function load_config() {
let config=Object.assign({},DEFAULT_CONFIG); let config = Object.assign({}, DEFAULT_CONFIG);
let loaded_config; let loaded_config;
try { try {
loaded_config=JSON.parse(localStorage['hole_config']||'{}'); loaded_config = JSON.parse(localStorage['hole_config'] || '{}');
} catch(e) { } catch (e) {
alert('设置加载失败,将重置为默认设置!\n'+e); alert('设置加载失败,将重置为默认设置!\n' + e);
delete localStorage['hole_config']; delete localStorage['hole_config'];
loaded_config={}; loaded_config = {};
} }
// unrecognized configs are removed // unrecognized configs are removed
Object.keys(loaded_config).forEach((key)=>{ Object.keys(loaded_config).forEach((key) => {
if(config[key]!==undefined) if (config[key] !== undefined) config[key] = loaded_config[key];
config[key]=loaded_config[key];
}); });
console.log('config loaded',config); console.log('config loaded', config);
window.config=config; window.config = config;
} }
export function save_config() { export function save_config() {
localStorage['hole_config']=JSON.stringify(window.config); localStorage['hole_config'] = JSON.stringify(window.config);
load_config(); load_config();
} }
export function bgimg_style(img,color) { export function bgimg_style(img, color) {
if(img===undefined) img=window.config.background_img; if (img === undefined) img = window.config.background_img;
if(color===undefined) color=window.config.background_color; if (color === undefined) color = window.config.background_color;
return { return {
background: 'transparent center center', background: 'transparent center center',
backgroundImage: img===null ? 'unset' : 'url("'+encodeURI(img)+'")', backgroundImage: img === null ? 'unset' : 'url("' + encodeURI(img) + '")',
backgroundColor: color, backgroundColor: color,
backgroundSize: 'cover', backgroundSize: 'cover',
}; };
@ -60,7 +67,7 @@ export function bgimg_style(img,color) {
class ConfigBackground extends PureComponent { class ConfigBackground extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
this.state={ this.state = {
img: window.config.background_img, img: window.config.background_img,
color: window.config.background_color, color: window.config.background_color,
}; };
@ -74,46 +81,72 @@ class ConfigBackground extends PureComponent {
} }
on_select(e) { on_select(e) {
let value=e.target.value; let value = e.target.value;
this.setState({ this.setState(
img: value==='##other' ? '' : {
value==='##color' ? null : value, img: value === '##other' ? '' : value === '##color' ? null : value,
},this.save_changes.bind(this)); },
this.save_changes.bind(this),
);
} }
on_change_img(e) { on_change_img(e) {
this.setState({ this.setState(
{
img: e.target.value, img: e.target.value,
},this.save_changes.bind(this)); },
this.save_changes.bind(this),
);
} }
on_change_color(e) { on_change_color(e) {
this.setState({ this.setState(
{
color: e.target.value, color: e.target.value,
},this.save_changes.bind(this)); },
this.save_changes.bind(this),
);
} }
render() { render() {
let img_select= this.state.img===null ? '##color' : let img_select =
Object.keys(BUILTIN_IMGS).indexOf(this.state.img)===-1 ? '##other' : this.state.img; this.state.img === null
? '##color'
: Object.keys(BUILTIN_IMGS).indexOf(this.state.img) === -1
? '##other'
: this.state.img;
return ( return (
<div> <div>
<p> <p>
<b>背景图片</b> <b>背景图片</b>
<select value={img_select} onChange={this.on_select.bind(this)}> <select value={img_select} onChange={this.on_select.bind(this)}>
{Object.keys(BUILTIN_IMGS).map((key)=>( {Object.keys(BUILTIN_IMGS).map((key) => (
<option key={key} value={key}>{BUILTIN_IMGS[key]}</option> <option key={key} value={key}>
{BUILTIN_IMGS[key]}
</option>
))} ))}
<option value="##other">输入图片网址</option> <option value="##other">输入图片网址</option>
<option value="##color">纯色背景</option> <option value="##color">纯色背景</option>
</select> </select>
&nbsp; &nbsp;
{img_select==='##other' && {img_select === '##other' && (
<input type="url" placeholder="图片网址" value={this.state.img} onChange={this.on_change_img.bind(this)} /> <input
} type="url"
{img_select==='##color' && placeholder="图片网址"
<input type="color" value={this.state.color} onChange={this.on_change_color.bind(this)} /> 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> </p>
<div className="bg-preview" style={bgimg_style(this.state.img,this.state.color)} /> <div
className="bg-preview"
style={bgimg_style(this.state.img, this.state.color)}
/>
</div> </div>
); );
} }
@ -122,7 +155,7 @@ class ConfigBackground extends PureComponent {
class ConfigColorScheme extends PureComponent { class ConfigColorScheme extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
this.state={ this.state = {
color_scheme: window.config.color_scheme, color_scheme: window.config.color_scheme,
}; };
} }
@ -134,10 +167,13 @@ class ConfigColorScheme extends PureComponent {
} }
on_select(e) { on_select(e) {
let value=e.target.value; let value = e.target.value;
this.setState({ this.setState(
{
color_scheme: value, color_scheme: value,
},this.save_changes.bind(this)); },
this.save_changes.bind(this),
);
} }
render() { render() {
@ -145,38 +181,42 @@ class ConfigColorScheme extends PureComponent {
<div> <div>
<p> <p>
<b>夜间模式</b> <b>夜间模式</b>
<select value={this.state.color_scheme} onChange={this.on_select.bind(this)}> <select
value={this.state.color_scheme}
onChange={this.on_select.bind(this)}
>
<option value="default">跟随系统</option> <option value="default">跟随系统</option>
<option value="light">始终浅色模式</option> <option value="light">始终浅色模式</option>
<option value="dark">始终深色模式</option> <option value="dark">始终深色模式</option>
</select> </select>
&nbsp; <small>#color_scheme</small> &nbsp; <small>#color_scheme</small>
</p> </p>
<p> <p>选择浅色或深色模式深色模式下将会调暗图片亮度</p>
选择浅色或深色模式深色模式下将会调暗图片亮度
</p>
</div> </div>
) );
} }
} }
class ConfigSwitch extends PureComponent { class ConfigSwitch extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
this.state={ this.state = {
switch: window.config[this.props.id], switch: window.config[this.props.id],
}; };
} }
on_change(e) { on_change(e) {
let val=e.target.checked; let val = e.target.checked;
this.setState({ this.setState(
{
switch: val, switch: val,
},()=>{ },
() => {
this.props.callback({ this.props.callback({
[this.props.id]: val, [this.props.id]: val,
}); });
}); },
);
} }
render() { render() {
@ -184,14 +224,17 @@ class ConfigSwitch extends PureComponent {
<div> <div>
<p> <p>
<label> <label>
<input name={'config-'+this.props.id} type="checkbox" checked={this.state.switch} onChange={this.on_change.bind(this)} /> <input
name={'config-' + this.props.id}
type="checkbox"
checked={this.state.switch}
onChange={this.on_change.bind(this)}
/>
<b>{this.props.name}</b> <b>{this.props.name}</b>
&nbsp; <small>#{this.props.id}</small> &nbsp; <small>#{this.props.id}</small>
</label> </label>
</p> </p>
<p> <p>{this.props.description}</p>
{this.props.description}
</p>
</div> </div>
); );
} }
@ -200,20 +243,20 @@ class ConfigSwitch extends PureComponent {
export class ConfigUI extends PureComponent { export class ConfigUI extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
this.save_changes_bound=this.save_changes.bind(this); this.save_changes_bound = this.save_changes.bind(this);
} }
save_changes(chg) { save_changes(chg) {
console.log(chg); console.log(chg);
Object.keys(chg).forEach((key)=>{ Object.keys(chg).forEach((key) => {
window.config[key]=chg[key]; window.config[key] = chg[key];
}); });
save_config(); save_config();
} }
reset_settings() { reset_settings() {
if(window.confirm('重置所有设置?')) { if (window.confirm('重置所有设置?')) {
window.config={}; window.config = {};
save_config(); save_config();
window.location.reload(); window.location.reload();
} }
@ -223,33 +266,62 @@ export class ConfigUI extends PureComponent {
return ( return (
<div> <div>
<div className="box config-ui-header"> <div className="box config-ui-header">
<p>这些功能仍在测试可能不稳定<a onClick={this.reset_settings.bind(this)}>全部重置</a></p> <p>
<p><b>修改设置后 <a onClick={()=>{window.location.reload()}}>刷新页面</a> </b></p> 这些功能仍在测试可能不稳定
<a onClick={this.reset_settings.bind(this)}>全部重置</a>
</p>
<p>
<b>
修改设置后{' '}
<a
onClick={() => {
window.location.reload();
}}
>
刷新页面
</a>{' '}
方可生效
</b>
</p>
</div> </div>
<div className="box"> <div className="box">
<ConfigBackground callback={this.save_changes_bound} /> <ConfigBackground callback={this.save_changes_bound} />
<hr /> <hr />
<ConfigColorScheme callback={this.save_changes_bound} /> <ConfigColorScheme callback={this.save_changes_bound} />
<hr /> <hr />
<ConfigSwitch callback={this.save_changes_bound} id="pressure" name="快速返回" <ConfigSwitch
callback={this.save_changes_bound}
id="pressure"
name="快速返回"
description="短暂按住 Esc 键或重压屏幕(3D Touch)可以快速返回或者刷新树洞" description="短暂按住 Esc 键或重压屏幕(3D Touch)可以快速返回或者刷新树洞"
/> />
<hr /> <hr />
<ConfigSwitch callback={this.save_changes_bound} id="easter_egg" name="允许彩蛋" <ConfigSwitch
callback={this.save_changes_bound}
id="easter_egg"
name="允许彩蛋"
description="在某些情况下显示彩蛋" description="在某些情况下显示彩蛋"
/> />
<hr /> <hr />
<ConfigSwitch callback={this.save_changes_bound} id="fold" name="折叠树洞" <ConfigSwitch
callback={this.save_changes_bound}
id="fold"
name="折叠树洞"
description="在时间线中折叠可能引起不适的树洞" description="在时间线中折叠可能引起不适的树洞"
/> />
<hr /> <hr />
<p> <p>
新功能建议或问题反馈请在&nbsp; 新功能建议或问题反馈请在&nbsp;
<a href="https://github.com/thuhole/thuhole-go-backend/issues" target="_blank">GitHub <span className="icon icon-github" /></a> <a
href="https://github.com/thuhole/thuhole-go-backend/issues"
target="_blank"
>
GitHub <span className="icon icon-github" />
</a>
&nbsp;提出 &nbsp;提出
</p> </p>
</div> </div>
</div> </div>
) );
} }
} }

1061
src/Flows.js

File diff suppressed because it is too large Load Diff

34
src/Markdown.js

@ -1,29 +1,33 @@
import MarkdownIt from 'markdown-it' import MarkdownIt from 'markdown-it';
import MarkdownItKaTeX from 'markdown-it-katex' import MarkdownItKaTeX from 'markdown-it-katex';
import hljs from 'highlight.js' import hljs from 'highlight.js';
import 'highlight.js/styles/atom-one-dark.css' import 'highlight.js/styles/atom-one-dark.css';
import './Markdown.css' import './Markdown.css';
import 'katex/dist/katex.min.css' import 'katex/dist/katex.min.css';
let md = new MarkdownIt({ let md = new MarkdownIt({
html: false, html: false,
linkify: false, linkify: false,
breaks: true, breaks: true,
inline: true, inline: true,
highlight (str, lang) { highlight(str, lang) {
if (lang && hljs.getLanguage(lang)) { if (lang && hljs.getLanguage(lang)) {
try { try {
return '<pre class="hljs"><code>' + return (
'<pre class="hljs"><code>' +
hljs.highlight(lang, str, true).value + hljs.highlight(lang, str, true).value +
'</code></pre>'; '</code></pre>'
);
} catch (__) {} } 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, { }).use(MarkdownItKaTeX, {
"throwOnError" : false, throwOnError: false,
"errorColor" : "#aa0000" errorColor: '#aa0000',
}) });
export default (text) => md.render(text) export default (text) => md.render(text);

61
src/Message.js

@ -1,11 +1,11 @@
import React, {Component, PureComponent} from 'react'; import React, { Component, PureComponent } from 'react';
import {THUHOLE_API_ROOT, get_json, API_VERSION_PARAM} from './flows_api'; import { THUHOLE_API_ROOT, get_json, API_VERSION_PARAM } from './flows_api';
import {Time} from './Common'; import { Time } from './Common';
export class MessageViewer extends PureComponent { export class MessageViewer extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
this.state={ this.state = {
loading_status: 'idle', loading_status: 'idle',
msg: [], msg: [],
}; };
@ -16,39 +16,55 @@ export class MessageViewer extends PureComponent {
} }
load() { load() {
if(this.state.loading_status==='loading') return; if (this.state.loading_status === 'loading') return;
this.setState({ this.setState(
{
loading_status: 'loading', loading_status: 'loading',
},()=>{ },
fetch(THUHOLE_API_ROOT+'api_xmcp/hole/system_msg?user_token='+encodeURIComponent(this.props.token)+API_VERSION_PARAM()) () => {
fetch(
THUHOLE_API_ROOT +
'api_xmcp/hole/system_msg?user_token=' +
encodeURIComponent(this.props.token) +
API_VERSION_PARAM(),
)
.then(get_json) .then(get_json)
.then((json)=>{ .then((json) => {
if(json.error) if (json.error) throw new Error(json.error);
throw new Error(json.error);
else else
this.setState({ this.setState({
loading_status: 'done', loading_status: 'done',
msg: json.result, msg: json.result,
}); });
}) })
.catch((err)=>{ .catch((err) => {
console.error(err); console.error(err);
alert(''+err); alert('' + err);
this.setState({ this.setState({
loading_status: 'failed', loading_status: 'failed',
}); });
})
}); });
},
);
} }
render() { render() {
if(this.state.loading_status==='loading') if (this.state.loading_status === 'loading')
return (<p className="box box-tip">加载中</p>); return <p className="box box-tip">加载中</p>;
else if(this.state.loading_status==='failed') else if (this.state.loading_status === 'failed')
return (<div className="box box-tip"><a onClick={()=>{this.load()}}>重新加载</a></div>); return (
else if(this.state.loading_status==='done') <div className="box box-tip">
return this.state.msg.map((msg)=>( <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">
<div className="box-header"> <div className="box-header">
<Time stamp={msg.timestamp} short={false} /> <Time stamp={msg.timestamp} short={false} />
@ -59,7 +75,6 @@ export class MessageViewer extends PureComponent {
</div> </div>
</div> </div>
)); ));
else else return null;
return null;
} }
} }

99
src/PressureHelper.js

@ -1,91 +1,95 @@
import React, {Component} from 'react'; import React, { Component } from 'react';
import Pressure from 'pressure'; import Pressure from 'pressure';
import './PressureHelper.css'; import './PressureHelper.css';
const THRESHOLD=.4; const THRESHOLD = 0.4;
const MULTIPLIER=25; const MULTIPLIER = 25;
const BORDER_WIDTH=500; // also change css! const BORDER_WIDTH = 500; // also change css!
export class PressureHelper extends Component { export class PressureHelper extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state={ this.state = {
level: 0, level: 0,
fired: false, fired: false,
}; };
this.callback=props.callback; this.callback = props.callback;
this.esc_interval=null; this.esc_interval = null;
} }
do_fire() { do_fire() {
if(this.esc_interval) { if (this.esc_interval) {
clearInterval(this.esc_interval); clearInterval(this.esc_interval);
this.esc_interval=null; this.esc_interval = null;
} }
this.setState({ this.setState({
level: 1, level: 1,
fired: true, fired: true,
}); });
this.callback(); this.callback();
window.setTimeout(()=>{ window.setTimeout(() => {
this.setState({ this.setState({
level: 0, level: 0,
fired: false, fired: false,
}); });
},300); }, 300);
} }
componentDidMount() { componentDidMount() {
if(window.config.pressure) { if (window.config.pressure) {
Pressure.set(document.body, { Pressure.set(
change: (force)=>{ document.body,
if(!this.state.fired) { {
if(force>=.999) { change: (force) => {
if (!this.state.fired) {
if (force >= 0.999) {
this.do_fire(); this.do_fire();
} } else
else
this.setState({ this.setState({
level: force, level: force,
}); });
} }
}, },
end: ()=>{ end: () => {
this.setState({ this.setState({
level: 0, level: 0,
fired: false, fired: false,
}); });
}, },
}, { },
{
polyfill: false, polyfill: false,
only: 'touch', only: 'touch',
preventSelect: false, preventSelect: false,
}); },
);
document.addEventListener('keydown',(e)=>{ document.addEventListener('keydown', (e) => {
if(!e.repeat && e.key==='Escape') { if (!e.repeat && e.key === 'Escape') {
if(this.esc_interval) if (this.esc_interval) clearInterval(this.esc_interval);
clearInterval(this.esc_interval); this.setState(
this.setState({ {
level: THRESHOLD/2, level: THRESHOLD / 2,
},()=>{ },
this.esc_interval=setInterval(()=>{ () => {
let new_level=this.state.level+.1; this.esc_interval = setInterval(() => {
if(new_level>=.999) let new_level = this.state.level + 0.1;
this.do_fire(); if (new_level >= 0.999) this.do_fire();
else else
this.setState({ this.setState({
level: new_level, level: new_level,
}); });
},30); }, 30);
}); },
);
} }
}); });
document.addEventListener('keyup',(e)=>{ document.addEventListener('keyup', (e) => {
if(e.key==='Escape') { if (e.key === 'Escape') {
if(this.esc_interval) { if (this.esc_interval) {
clearInterval(this.esc_interval); clearInterval(this.esc_interval);
this.esc_interval=null; this.esc_interval = null;
} }
this.setState({ this.setState({
level: 0, level: 0,
@ -96,18 +100,21 @@ export class PressureHelper extends Component {
} }
render() { render() {
const pad=MULTIPLIER*(this.state.level-THRESHOLD)-BORDER_WIDTH; const pad = MULTIPLIER * (this.state.level - THRESHOLD) - BORDER_WIDTH;
return ( return (
<div className={ <div
'pressure-box' className={
+(this.state.fired ? ' pressure-box-fired' : '') 'pressure-box' +
+(this.state.level<=.0001 ? ' pressure-box-empty' : '') (this.state.fired ? ' pressure-box-fired' : '') +
} style={{ (this.state.level <= 0.0001 ? ' pressure-box-empty' : '')
}
style={{
left: pad, left: pad,
right: pad, right: pad,
top: pad, top: pad,
bottom: pad, bottom: pad,
}} /> }}
) />
);
} }
} }

53
src/Sidebar.js

@ -1,42 +1,63 @@
import React, {Component, PureComponent} from 'react'; import React, { Component, PureComponent } from 'react';
import './Sidebar.css'; import './Sidebar.css';
export class Sidebar extends PureComponent { export class Sidebar extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
this.sidebar_ref=React.createRef(); this.sidebar_ref = React.createRef();
this.do_close_bound=this.do_close.bind(this); this.do_close_bound = this.do_close.bind(this);
this.do_back_bound=this.do_back.bind(this); this.do_back_bound = this.do_back.bind(this);
} }
componentDidUpdate(nextProps) { componentDidUpdate(nextProps) {
if(this.props.stack!==nextProps.stack) { if (this.props.stack !== nextProps.stack) {
//console.log('sidebar top'); //console.log('sidebar top');
if(this.sidebar_ref.current) if (this.sidebar_ref.current) this.sidebar_ref.current.scrollTop = 0;
this.sidebar_ref.current.scrollTop=0;
} }
} }
do_close() { do_close() {
this.props.show_sidebar(null,null,'clear'); this.props.show_sidebar(null, null, 'clear');
} }
do_back() { do_back() {
this.props.show_sidebar(null,null,'pop'); this.props.show_sidebar(null, null, 'pop');
} }
render() { render() {
let [cur_title,cur_content]=this.props.stack[this.props.stack.length-1]; let [cur_title, cur_content] = this.props.stack[
this.props.stack.length - 1
];
return ( return (
<div className={'sidebar-container '+(cur_title!==null ? 'sidebar-on' : 'sidebar-off')}> <div
<div className="sidebar-shadow" onClick={this.do_back_bound} onTouchEnd={(e)=>{e.preventDefault();e.target.click();}} /> 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"> <div ref={this.sidebar_ref} className="sidebar">
{cur_content} {cur_content}
</div> </div>
<div className="sidebar-title"> <div className="sidebar-title">
<a className="no-underline" onClick={this.do_close_bound}>&nbsp;<span className="icon icon-close" />&nbsp;</a> <a className="no-underline" onClick={this.do_close_bound}>
{this.props.stack.length>2 && &nbsp;
<a className="no-underline" onClick={this.do_back_bound}>&nbsp;<span className="icon icon-back" />&nbsp;</a> <span className="icon icon-close" />
} &nbsp;
</a>
{this.props.stack.length > 2 && (
<a className="no-underline" onClick={this.do_back_bound}>
&nbsp;
<span className="icon icon-back" />
&nbsp;
</a>
)}
{cur_title} {cur_title}
</div> </div>
</div> </div>

161
src/Title.js

@ -1,36 +1,39 @@
import React, {Component, PureComponent} from 'react'; import React, { Component, PureComponent } from 'react';
// import {AppSwitcher} from './infrastructure/widgets'; // import {AppSwitcher} from './infrastructure/widgets';
import {InfoSidebar, PostForm} from './UserAction'; import { InfoSidebar, PostForm } from './UserAction';
import {TokenCtx} from './UserAction'; import { TokenCtx } from './UserAction';
import './Title.css'; import './Title.css';
const flag_re=/^\/\/setflag ([a-zA-Z0-9_]+)=(.*)$/; const flag_re = /^\/\/setflag ([a-zA-Z0-9_]+)=(.*)$/;
class ControlBar extends PureComponent { class ControlBar extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
this.state={ this.state = {
search_text: '', search_text: '',
}; };
this.set_mode=props.set_mode; this.set_mode = props.set_mode;
this.on_change_bound=this.on_change.bind(this); this.on_change_bound = this.on_change.bind(this);
this.on_keypress_bound=this.on_keypress.bind(this); this.on_keypress_bound = this.on_keypress.bind(this);
this.do_refresh_bound=this.do_refresh.bind(this); this.do_refresh_bound = this.do_refresh.bind(this);
this.do_attention_bound=this.do_attention.bind(this); this.do_attention_bound = this.do_attention.bind(this);
} }
componentDidMount() { componentDidMount() {
if(window.location.hash) { if (window.location.hash) {
let text=decodeURIComponent(window.location.hash).substr(1); let text = decodeURIComponent(window.location.hash).substr(1);
if(text.lastIndexOf('?')!==-1) if (text.lastIndexOf('?') !== -1)
text=text.substr(0,text.lastIndexOf('?')); // fuck wechat '#param?nsukey=...' text = text.substr(0, text.lastIndexOf('?')); // fuck wechat '#param?nsukey=...'
this.setState({ this.setState(
{
search_text: text, search_text: text,
}, ()=>{ },
this.on_keypress({key: 'Enter'}); () => {
}); this.on_keypress({ key: 'Enter' });
},
);
} }
} }
@ -41,83 +44,116 @@ class ControlBar extends PureComponent {
} }
on_keypress(event) { on_keypress(event) {
if(event.key==='Enter') { if (event.key === 'Enter') {
let flag_res=flag_re.exec(this.state.search_text); let flag_res = flag_re.exec(this.state.search_text);
if(flag_res) { if (flag_res) {
if(flag_res[2]) { if (flag_res[2]) {
localStorage[flag_res[1]]=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.'); alert(
'Set Flag ' +
flag_res[1] +
'=' +
flag_res[2] +
'\nYou may need to refresh this webpage.',
);
} else { } else {
delete localStorage[flag_res[1]]; delete localStorage[flag_res[1]];
alert('Clear Flag '+flag_res[1]+'\nYou may need to refresh this webpage.'); alert(
'Clear Flag ' +
flag_res[1] +
'\nYou may need to refresh this webpage.',
);
} }
return; return;
} }
const mode=this.state.search_text.startsWith('#') ? 'single' : 'search'; const mode = this.state.search_text.startsWith('#') ? 'single' : 'search';
this.set_mode(mode,this.state.search_text||''); this.set_mode(mode, this.state.search_text || '');
} }
} }
do_refresh() { do_refresh() {
window.scrollTo(0,0); window.scrollTo(0, 0);
this.setState({ this.setState({
search_text: '', search_text: '',
}); });
this.set_mode('list',null); this.set_mode('list', null);
} }
do_attention() { do_attention() {
window.scrollTo(0,0); window.scrollTo(0, 0);
this.setState({ this.setState({
search_text: '', search_text: '',
}); });
this.set_mode('attention',null); this.set_mode('attention', null);
} }
render() { render() {
return ( return (
<TokenCtx.Consumer>{({value: token})=>( <TokenCtx.Consumer>
{({ value: token }) => (
<div className="control-bar"> <div className="control-bar">
<a className="no-underline control-btn" onClick={this.do_refresh_bound}> <a
className="no-underline control-btn"
onClick={this.do_refresh_bound}
>
<span className="icon icon-refresh" /> <span className="icon icon-refresh" />
<span className="control-btn-label">最新</span> <span className="control-btn-label">最新</span>
</a> </a>
{!!token && {!!token && (
<a className="no-underline control-btn" onClick={this.do_attention_bound}> <a
className="no-underline control-btn"
onClick={this.do_attention_bound}
>
<span className="icon icon-attention" /> <span className="icon icon-attention" />
<span className="control-btn-label">关注</span> <span className="control-btn-label">关注</span>
</a> </a>
} )}
<input className="control-search" value={this.state.search_text} placeholder="搜索 或 #树洞号" <input
onChange={this.on_change_bound} onKeyPress={this.on_keypress_bound} 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={()=>{ <a
className="no-underline control-btn"
onClick={() => {
this.props.show_sidebar( this.props.show_sidebar(
'T大树洞', 'T大树洞',
<InfoSidebar show_sidebar={this.props.show_sidebar} /> <InfoSidebar show_sidebar={this.props.show_sidebar} />,
) );
}}> }}
<span className={'icon icon-'+(token ? 'about' : 'login')} /> >
<span className="control-btn-label">{token ? '账户' : '登录'}</span> <span className={'icon icon-' + (token ? 'about' : 'login')} />
<span className="control-btn-label">
{token ? '账户' : '登录'}
</span>
</a> </a>
{!!token && {!!token && (
<a className="no-underline control-btn" onClick={()=>{ <a
className="no-underline control-btn"
onClick={() => {
this.props.show_sidebar( this.props.show_sidebar(
'发表树洞', '发表树洞',
<PostForm token={token} on_complete={()=>{ <PostForm
this.props.show_sidebar(null,null); token={token}
on_complete={() => {
this.props.show_sidebar(null, null);
this.do_refresh(); this.do_refresh();
}} /> }}
) />,
}}> );
}}
>
<span className="icon icon-plus" /> <span className="icon icon-plus" />
<span className="control-btn-label">发表</span> <span className="control-btn-label">发表</span>
</a> </a>
} )}
</div> </div>
)}</TokenCtx.Consumer> )}
) </TokenCtx.Consumer>
);
} }
} }
@ -128,16 +164,23 @@ export function Title(props) {
<div className="aux-margin"> <div className="aux-margin">
<div className="title"> <div className="title">
<p className="centered-line"> <p className="centered-line">
<span onClick={()=>props.show_sidebar( <span
onClick={() =>
props.show_sidebar(
'T大树洞', 'T大树洞',
<InfoSidebar show_sidebar={props.show_sidebar} /> <InfoSidebar show_sidebar={props.show_sidebar} />,
)}> )
}
>
T大树洞 T大树洞
</span> </span>
</p> </p>
</div> </div>
<ControlBar show_sidebar={props.show_sidebar} set_mode={props.set_mode} /> <ControlBar
show_sidebar={props.show_sidebar}
set_mode={props.set_mode}
/>
</div> </div>
</div> </div>
) );
} }

635
src/UserAction.js

@ -1,24 +1,35 @@
import React, {Component, PureComponent} from 'react'; import React, { Component, PureComponent } from 'react';
import {API_BASE, SafeTextarea, PromotionBar, HighlightedMarkdown} from './Common'; import {
import {MessageViewer} from './Message'; API_BASE,
import {LoginPopup} from './infrastructure/widgets'; SafeTextarea,
import {ColorPicker} from './color_picker'; PromotionBar,
import {ConfigUI} from './Config'; 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 fixOrientation from 'fix-orientation';
import copy from 'copy-to-clipboard'; import copy from 'copy-to-clipboard';
import {cache} from './cache'; import { cache } from './cache';
import {API_VERSION_PARAM, THUHOLE_API_ROOT, API, get_json, token_param} from './flows_api'; import {
API_VERSION_PARAM,
THUHOLE_API_ROOT,
API,
get_json,
token_param,
} from './flows_api';
import './UserAction.css'; import './UserAction.css';
const BASE64_RATE=4/3; const BASE64_RATE = 4 / 3;
const MAX_IMG_DIAM=8000; const MAX_IMG_DIAM = 8000;
const MAX_IMG_PX=5000000; const MAX_IMG_PX = 5000000;
const MAX_IMG_FILESIZE=450000*BASE64_RATE; const MAX_IMG_FILESIZE = 450000 * BASE64_RATE;
export const TokenCtx=React.createContext({ export const TokenCtx = React.createContext({
value: null, value: null,
set_value: ()=>{}, set_value: () => {},
}); });
// class LifeInfoBox extends Component { // class LifeInfoBox extends Component {
@ -201,73 +212,107 @@ export function InfoSidebar(props) {
<PromotionBar /> <PromotionBar />
<LoginForm show_sidebar={props.show_sidebar} /> <LoginForm show_sidebar={props.show_sidebar} />
<div className="box list-menu"> <div className="box list-menu">
<a onClick={()=>{props.show_sidebar( <a
'设置', onClick={() => {
<ConfigUI /> props.show_sidebar('设置', <ConfigUI />);
)}}> }}
<span className="icon icon-settings" /><label>设置</label> >
<span className="icon icon-settings" />
<label>设置</label>
</a> </a>
&nbsp;&nbsp; &nbsp;&nbsp;
<a href="https://thuhole.com/policy.html" target="_blank"> <a href="https://thuhole.com/policy.html" target="_blank">
<span className="icon icon-textfile" /><label>树洞规范试行</label> <span className="icon icon-textfile" />
<label>树洞规范试行</label>
</a> </a>
&nbsp;&nbsp; &nbsp;&nbsp;
<a href="https://github.com/thuhole/thuhole-go-backend/issues" target="_blank"> <a
<span className="icon icon-github" /><label>意见反馈</label> href="https://github.com/thuhole/thuhole-go-backend/issues"
target="_blank"
>
<span className="icon icon-github" />
<label>意见反馈</label>
</a> </a>
</div> </div>
<div className="box help-desc-box"> <div className="box help-desc-box">
<p> <p>
<a onClick={()=>{ <a
if('serviceWorker' in navigator) { onClick={() => {
navigator.serviceWorker.getRegistrations() if ('serviceWorker' in navigator) {
.then((registrations)=>{ navigator.serviceWorker
for(let registration of registrations) { .getRegistrations()
console.log('unregister',registration); .then((registrations) => {
for (let registration of registrations) {
console.log('unregister', registration);
registration.unregister(); registration.unregister();
} }
}); });
} }
cache().clear(); cache().clear();
setTimeout(()=>{ setTimeout(() => {
window.location.reload(true); window.location.reload(true);
},200); }, 200);
}}>强制检查更新</a> }}
当前版本{process.env.REACT_APP_BUILD_INFO||'---'} {process.env.NODE_ENV} 会自动在后台检查更新并在下次访问时更新 >
强制检查更新
</a>
当前版本{process.env.REACT_APP_BUILD_INFO || '---'}{' '}
{process.env.NODE_ENV} 会自动在后台检查更新并在下次访问时更新
</p> </p>
</div> </div>
<div className="box help-desc-box"> <div className="box help-desc-box">
<p> <p>联系我们thuhole at protonmail dot com</p>
联系我们thuhole at protonmail dot com
</p>
</div> </div>
<div className="box help-desc-box"> <div className="box help-desc-box">
<p> <p>
T大树洞 网页版 by @thuhole T大树洞 网页版 by @thuhole 基于&nbsp;
基于&nbsp; <a
<a href="https://www.gnu.org/licenses/gpl-3.0.zh-cn.html" target="_blank">GPLv3</a> href="https://www.gnu.org/licenses/gpl-3.0.zh-cn.html"
&nbsp;协议在 <a href="https://github.com/thuhole/webhole" target="_blank">GitHub</a> target="_blank"
>
GPLv3
</a>
&nbsp;协议在{' '}
<a href="https://github.com/thuhole/webhole" target="_blank">
GitHub
</a>{' '}
开源
</p> </p>
<p> <p>
T大树洞 网页版的诞生离不开&nbsp; T大树洞 网页版的诞生离不开&nbsp;
<a href="https://github.com/pkuhelper-web/webhole" target="_blank" rel="noopener">P大树洞网页版 by @xmcp</a> <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://reactjs.org/" target="_blank" rel="noopener">
React
</a>
<a href="https://icomoon.io/#icons" target="_blank" rel="noopener">IcoMoon</a> <a href="https://icomoon.io/#icons" target="_blank" rel="noopener">
IcoMoon
</a>
&nbsp;等开源项目 &nbsp;等开源项目
</p> </p>
<p> <p>
This program is free software: you can redistribute it and/or modify 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 it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or the Free Software Foundation, either version 3 of the License, or (at
(at your option) any later version. your option) any later version.
</p> </p>
<p> <p>
This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful, but
but WITHOUT ANY WARRANTY; without even the implied warranty of WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the&nbsp; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the&nbsp;
<a href="https://www.gnu.org/licenses/gpl-3.0.zh-cn.html" target="_blank">GNU General Public License</a> <a
href="https://www.gnu.org/licenses/gpl-3.0.zh-cn.html"
target="_blank"
>
GNU General Public License
</a>
&nbsp;for more details. &nbsp;for more details.
</p> </p>
</div> </div>
@ -278,19 +323,27 @@ export function InfoSidebar(props) {
class ResetUsertokenWidget extends Component { class ResetUsertokenWidget extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state={ this.state = {
loading_status: 'done', loading_status: 'done',
}; };
} }
do_reset() { do_reset() {
if(window.confirm('您正在重置 UserToken!\n您的账号将会在【所有设备】上注销,您需要手动重新登录!')) { if (
let uid=window.prompt('您正在重置 UserToken!\n请输入您的学号以确认身份:'); window.confirm(
if(uid) '您正在重置 UserToken!\n您的账号将会在【所有设备】上注销,您需要手动重新登录!',
this.setState({ )
) {
let uid = window.prompt(
'您正在重置 UserToken!\n请输入您的学号以确认身份:',
);
if (uid)
this.setState(
{
loading_status: 'loading', loading_status: 'loading',
},()=>{ },
fetch(THUHOLE_API_ROOT+'api_xmcp/hole/reset_usertoken', { () => {
fetch(THUHOLE_API_ROOT + 'api_xmcp/hole/reset_usertoken', {
method: 'post', method: 'post',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -301,53 +354,61 @@ class ResetUsertokenWidget extends Component {
}), }),
}) })
.then(get_json) .then(get_json)
.then((json)=>{ .then((json) => {
if(json.error) if (json.error) throw new Error(json.error);
throw new Error(json.error); else alert('重置成功!您需要在所有设备上重新登录。');
else
alert('重置成功!您需要在所有设备上重新登录。');
this.setState({ this.setState({
loading_status: 'done', loading_status: 'done',
}); });
}) })
.catch((e)=>{ .catch((e) => {
alert('重置失败:'+e); alert('重置失败:' + e);
this.setState({ this.setState({
loading_status: 'done', loading_status: 'done',
}); });
})
}); });
},
);
} }
} }
render() { render() {
if(this.state.loading_status==='done') if (this.state.loading_status === 'done')
return (<a onClick={this.do_reset.bind(this)}>重置</a>); return <a onClick={this.do_reset.bind(this)}>重置</a>;
else if(this.state.loading_status==='loading') else if (this.state.loading_status === 'loading')
return (<a><span className="icon icon-loading" /></a>); return (
<a>
<span className="icon icon-loading" />
</a>
);
} }
} }
export class LoginForm extends Component { export class LoginForm extends Component {
copy_token(token) { copy_token(token) {
if(copy(token)) if (copy(token)) alert('复制成功!\n请一定不要泄露哦');
alert('复制成功!\n请一定不要泄露哦');
} }
render() { render() {
return ( return (
<TokenCtx.Consumer>{(token)=> <TokenCtx.Consumer>
{(token) => (
<div> <div>
{/*{!!token.value &&*/} {/*{!!token.value &&*/}
{/* <LifeInfoBox token={token.value} set_token={token.set_value} />*/} {/* <LifeInfoBox token={token.value} set_token={token.set_value} />*/}
{/*}*/} {/*}*/}
<div className="login-form box"> <div className="login-form box">
{token.value ? {token.value ? (
<div> <div>
<p> <p>
<b>您已登录</b> <b>您已登录</b>
<button type="button" onClick={()=>{token.set_value(null);}}> <button
type="button"
onClick={() => {
token.set_value(null);
}}
>
<span className="icon icon-logout" /> 注销 <span className="icon icon-logout" /> 注销
</button> </button>
<br /> <br />
@ -357,18 +418,32 @@ export class LoginForm extends Component {
{/*T大树洞将会单向加密(i.e. 哈希散列)您的邮箱后再存入数据库,因此您的发帖具有较强的匿名性。具体可见我们的<a href="https://github.com/thuhole/thuhole-go-backend/blob/76f56e6b75257b59e552b6bdba77e114151fcad1/src/db.go#L184">后端开源代码</a>。*/} {/*T大树洞将会单向加密(i.e. 哈希散列)您的邮箱后再存入数据库,因此您的发帖具有较强的匿名性。具体可见我们的<a href="https://github.com/thuhole/thuhole-go-backend/blob/76f56e6b75257b59e552b6bdba77e114151fcad1/src/db.go#L184">后端开源代码</a>。*/}
{/*</p>*/} {/*</p>*/}
<p> <p>
<a onClick={()=>{this.props.show_sidebar( <a
onClick={() => {
this.props.show_sidebar(
'系统消息', '系统消息',
<MessageViewer token={token.value} /> <MessageViewer token={token.value} />,
)}}>查看系统消息</a><br /> );
}}
>
查看系统消息
</a>
<br />
当您发送的内容违规时我们将用系统消息提示您 当您发送的内容违规时我们将用系统消息提示您
</p> </p>
<p> <p>
<a onClick={this.copy_token.bind(this,token.value)}>复制 User Token</a><br /> <a onClick={this.copy_token.bind(this, token.value)}>
复制 User Token 可以在新设备登录切勿告知他人若怀疑被盗号请重新邮箱验证码登录以重置Token{/*,若怀疑被盗号请尽快 <ResetUsertokenWidget token={token.value} />*/} 复制 User Token
</a>
<br />
复制 User Token
可以在新设备登录切勿告知他人若怀疑被盗号请重新邮箱验证码登录以重置Token
{/*,若怀疑被盗号请尽快 <ResetUsertokenWidget token={token.value} />*/}
</p> </p>
</div> : </div>
<LoginPopup token_callback={token.set_value}>{(do_popup)=>( ) : (
<LoginPopup token_callback={token.set_value}>
{(do_popup) => (
<div> <div>
<p> <p>
<button type="button" onClick={do_popup}> <button type="button" onClick={do_popup}>
@ -376,46 +451,61 @@ export class LoginForm extends Component {
&nbsp;登录 &nbsp;登录
</button> </button>
</p> </p>
<p><small> <p>
T大树洞 面向T大学生通过T大邮箱验证您的身份并提供服务 <small>
</small></p> T大树洞
面向T大学生通过T大邮箱验证您的身份并提供服务
</small>
</p>
</div> </div>
)}</LoginPopup> )}
} </LoginPopup>
)}
</div> </div>
</div> </div>
}</TokenCtx.Consumer> )}
) </TokenCtx.Consumer>
);
} }
} }
export class ReplyForm extends Component { export class ReplyForm extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state={ this.state = {
text: '', text: '',
loading_status: 'done', loading_status: 'done',
preview: false, preview: false,
}; };
this.on_change_bound=this.on_change.bind(this); this.on_change_bound = this.on_change.bind(this);
this.area_ref=this.props.area_ref||React.createRef(); this.area_ref = this.props.area_ref || React.createRef();
this.global_keypress_handler_bound=this.global_keypress_handler.bind(this); this.global_keypress_handler_bound = this.global_keypress_handler.bind(
this.color_picker=new ColorPicker(); this,
);
this.color_picker = new ColorPicker();
} }
global_keypress_handler(e) { global_keypress_handler(e) {
if(e.code==='Enter' && !e.ctrlKey && !e.altKey && ['input','textarea'].indexOf(e.target.tagName.toLowerCase())===-1) { if (
if(this.area_ref.current) { e.code === 'Enter' &&
!e.ctrlKey &&
!e.altKey &&
['input', 'textarea'].indexOf(e.target.tagName.toLowerCase()) === -1
) {
if (this.area_ref.current) {
e.preventDefault(); e.preventDefault();
this.area_ref.current.focus(); this.area_ref.current.focus();
} }
} }
} }
componentDidMount() { componentDidMount() {
document.addEventListener('keypress',this.global_keypress_handler_bound); document.addEventListener('keypress', this.global_keypress_handler_bound);
} }
componentWillUnmount() { componentWillUnmount() {
document.removeEventListener('keypress',this.global_keypress_handler_bound); document.removeEventListener(
'keypress',
this.global_keypress_handler_bound,
);
} }
on_change(value) { on_change(value) {
@ -425,28 +515,30 @@ export class ReplyForm extends Component {
} }
on_submit(event) { on_submit(event) {
if(event) event.preventDefault(); if (event) event.preventDefault();
if(this.state.loading_status==='loading') if (this.state.loading_status === 'loading') return;
return;
this.setState({ this.setState({
loading_status: 'loading', loading_status: 'loading',
}); });
let data=new URLSearchParams(); let data = new URLSearchParams();
data.append('pid',this.props.pid); data.append('pid', this.props.pid);
data.append('text',this.state.text); data.append('text', this.state.text);
data.append('user_token',this.props.token); data.append('user_token', this.props.token);
fetch(API_BASE+'/api.php?action=docomment'+token_param(this.props.token), { fetch(
API_BASE + '/api.php?action=docomment' + token_param(this.props.token),
{
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
}, },
body: data, body: data,
}) },
)
.then(get_json) .then(get_json)
.then((json)=>{ .then((json) => {
if(json.code!==0) { if (json.code !== 0) {
if(json.msg) alert(json.msg); if (json.msg) alert(json.msg);
throw new Error(JSON.stringify(json)); throw new Error(JSON.stringify(json));
} }
@ -458,7 +550,7 @@ export class ReplyForm extends Component {
this.area_ref.current.clear(); this.area_ref.current.clear();
this.props.on_complete(); this.props.on_complete();
}) })
.catch((e)=>{ .catch((e) => {
console.error(e); console.error(e);
alert('回复失败'); alert('回复失败');
this.setState({ this.setState({
@ -469,55 +561,76 @@ export class ReplyForm extends Component {
toggle_preview() { toggle_preview() {
this.setState({ this.setState({
preview: !this.state.preview preview: !this.state.preview,
}); });
} }
render() { render() {
return ( return (
<form onSubmit={this.on_submit.bind(this)} className={'reply-form box'+(this.state.text?' reply-sticky':'')}> <form
{ onSubmit={this.on_submit.bind(this)}
this.state.preview ? className={'reply-form box' + (this.state.text ? ' reply-sticky' : '')}
<div className='reply-preview'> >
<HighlightedMarkdown text={this.state.text} color_picker={this.color_picker} show_pid={()=>{}} /> {this.state.preview ? (
</div> : <div className="reply-preview">
<SafeTextarea ref={this.area_ref} id={this.props.pid} on_change={this.on_change_bound} on_submit={this.on_submit.bind(this)} /> <HighlightedMarkdown
} text={this.state.text}
<button type='button' onClick={()=>{this.toggle_preview()}}> color_picker={this.color_picker}
{this.state.preview? <span className="icon icon-eye-blocked" />: <span className="icon icon-eye" />} 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> </button>
{this.state.loading_status==='loading' ? {this.state.loading_status === 'loading' ? (
<button disabled="disabled"> <button disabled="disabled">
<span className="icon icon-loading" /> <span className="icon icon-loading" />
</button> : </button>
) : (
<button type="submit"> <button type="submit">
<span className="icon icon-send" /> <span className="icon icon-send" />
</button> </button>
} )}
</form> </form>
) );
} }
} }
export class PostForm extends Component { export class PostForm extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state={ this.state = {
text: '', text: '',
loading_status: 'done', loading_status: 'done',
img_tip: null, img_tip: null,
preview: false, preview: false,
}; };
this.img_ref=React.createRef(); this.img_ref = React.createRef();
this.area_ref=React.createRef(); this.area_ref = React.createRef();
this.on_change_bound=this.on_change.bind(this); this.on_change_bound = this.on_change.bind(this);
this.on_img_change_bound=this.on_img_change.bind(this); this.on_img_change_bound = this.on_img_change.bind(this);
this.color_picker=new ColorPicker(); this.color_picker = new ColorPicker();
} }
componentDidMount() { componentDidMount() {
if(this.area_ref.current) if (this.area_ref.current) this.area_ref.current.focus();
this.area_ref.current.focus();
} }
on_change(value) { on_change(value) {
@ -526,15 +639,14 @@ export class PostForm extends Component {
}); });
} }
do_post(text,img) { do_post(text, img) {
let data=new URLSearchParams(); let data = new URLSearchParams();
data.append('text',this.state.text); data.append('text', this.state.text);
data.append('type',img ? 'image' : 'text'); data.append('type', img ? 'image' : 'text');
data.append('user_token',this.props.token); data.append('user_token', this.props.token);
if(img) if (img) data.append('data', img);
data.append('data',img);
fetch(API_BASE+'/api.php?action=dopost'+token_param(this.props.token), { fetch(API_BASE + '/api.php?action=dopost' + token_param(this.props.token), {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
@ -542,9 +654,9 @@ export class PostForm extends Component {
body: data, body: data,
}) })
.then(get_json) .then(get_json)
.then((json)=>{ .then((json) => {
if(json.code!==0) { if (json.code !== 0) {
if(json.msg) alert(json.msg); if (json.msg) alert(json.msg);
throw new Error(JSON.stringify(json)); throw new Error(JSON.stringify(json));
} }
@ -556,7 +668,7 @@ export class PostForm extends Component {
this.area_ref.current.clear(); this.area_ref.current.clear();
this.props.on_complete(); this.props.on_complete();
}) })
.catch((e)=>{ .catch((e) => {
console.error(e); console.error(e);
alert('发表失败'); alert('发表失败');
this.setState({ this.setState({
@ -566,59 +678,66 @@ export class PostForm extends Component {
} }
proc_img(file) { proc_img(file) {
return new Promise((resolve,reject)=>{ return new Promise((resolve, reject) => {
function return_url(url) { function return_url(url) {
const idx=url.indexOf(';base64,'); const idx = url.indexOf(';base64,');
if(idx===-1) if (idx === -1) throw new Error('img not base64 encoded');
throw new Error('img not base64 encoded');
return url.substr(idx+8); return url.substr(idx + 8);
} }
let reader=new FileReader(); let reader = new FileReader();
function on_got_img(url) { function on_got_img(url) {
const image = new Image(); const image = new Image();
image.onload=(()=>{ image.onload = () => {
let width=image.width; let width = image.width;
let height=image.height; let height = image.height;
let compressed=false; let compressed = false;
if(width>MAX_IMG_DIAM) { if (width > MAX_IMG_DIAM) {
height=height*MAX_IMG_DIAM/width; height = (height * MAX_IMG_DIAM) / width;
width=MAX_IMG_DIAM; width = MAX_IMG_DIAM;
compressed=true; compressed = true;
} }
if(height>MAX_IMG_DIAM) { if (height > MAX_IMG_DIAM) {
width=width*MAX_IMG_DIAM/height; width = (width * MAX_IMG_DIAM) / height;
height=MAX_IMG_DIAM; height = MAX_IMG_DIAM;
compressed=true; compressed = true;
} }
if(height*width>MAX_IMG_PX) { if (height * width > MAX_IMG_PX) {
let rate=Math.sqrt(height*width/MAX_IMG_PX); let rate = Math.sqrt((height * width) / MAX_IMG_PX);
height/=rate; height /= rate;
width/=rate; width /= rate;
compressed=true; compressed = true;
} }
console.log('chosen img size',width,height); console.log('chosen img size', width, height);
let canvas=document.createElement('canvas'); let canvas = document.createElement('canvas');
let ctx=canvas.getContext('2d'); let ctx = canvas.getContext('2d');
canvas.width=width; canvas.width = width;
canvas.height=height; canvas.height = height;
ctx.drawImage(image,0,0,width,height); ctx.drawImage(image, 0, 0, width, height);
let quality_l=.1,quality_r=.9,quality,new_url; let quality_l = 0.1,
while(quality_r-quality_l>=.03) { quality_r = 0.9,
quality=(quality_r+quality_l)/2; quality,
new_url=canvas.toDataURL('image/jpeg',quality); new_url;
console.log(quality_l,quality_r,'trying quality',quality,'size',new_url.length); while (quality_r - quality_l >= 0.03) {
if(new_url.length<=MAX_IMG_FILESIZE) quality = (quality_r + quality_l) / 2;
quality_l=quality; new_url = canvas.toDataURL('image/jpeg', quality);
else console.log(
quality_r=quality; 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) { if (quality_l >= 0.101) {
console.log('chosen img quality',quality); console.log('chosen img quality', quality);
resolve({ resolve({
img: return_url(new_url), img: return_url(new_url),
quality: quality, quality: quality,
@ -629,11 +748,11 @@ export class PostForm extends Component {
} else { } else {
reject('图片过大,无法上传'); reject('图片过大,无法上传');
} }
}); };
image.src=url; image.src = url;
} }
reader.onload=(event)=>{ reader.onload = (event) => {
fixOrientation(event.target.result,{},(fixed_dataurl)=>{ fixOrientation(event.target.result, {}, (fixed_dataurl) => {
on_got_img(fixed_dataurl); on_got_img(fixed_dataurl);
}); });
}; };
@ -642,23 +761,31 @@ export class PostForm extends Component {
} }
on_img_change() { on_img_change() {
if(this.img_ref.current && this.img_ref.current.files.length) if (this.img_ref.current && this.img_ref.current.files.length)
this.setState({ this.setState(
img_tip: '(正在处理图片……)' {
},()=>{ img_tip: '(正在处理图片……)',
},
() => {
this.proc_img(this.img_ref.current.files[0]) this.proc_img(this.img_ref.current.files[0])
.then((d)=>{ .then((d) => {
this.setState({ this.setState({
img_tip: `${d.compressed?'压缩到':'尺寸'} ${d.width}*${d.height} / `+ img_tip:
`质量 ${Math.floor(d.quality*100)}% / ${Math.floor(d.img.length/BASE64_RATE/1000)}KB)`, `${d.compressed ? '压缩到' : '尺寸'} ${d.width}*${
d.height
} / ` +
`质量 ${Math.floor(d.quality * 100)}% / ${Math.floor(
d.img.length / BASE64_RATE / 1000,
)}KB`,
}); });
}) })
.catch((e)=>{ .catch((e) => {
this.setState({ this.setState({
img_tip: `图片无效:${e}`, img_tip: `图片无效:${e}`,
}); });
}); });
}); },
);
else else
this.setState({ this.setState({
img_tip: null, img_tip: null,
@ -666,34 +793,33 @@ export class PostForm extends Component {
} }
on_submit(event) { on_submit(event) {
if(event) event.preventDefault(); if (event) event.preventDefault();
if(this.state.loading_status==='loading') if (this.state.loading_status === 'loading') return;
return; if (this.img_ref.current.files.length) {
if(this.img_ref.current.files.length) {
this.setState({ this.setState({
loading_status: 'processing', loading_status: 'processing',
}); });
this.proc_img(this.img_ref.current.files[0]) this.proc_img(this.img_ref.current.files[0])
.then((d)=>{ .then((d) => {
this.setState({ this.setState({
loading_status: 'loading', loading_status: 'loading',
}); });
this.do_post(this.state.text,d.img); this.do_post(this.state.text, d.img);
}) })
.catch((e)=>{ .catch((e) => {
alert(e); alert(e);
}); });
} else { } else {
this.setState({ this.setState({
loading_status: 'loading', loading_status: 'loading',
}); });
this.do_post(this.state.text,null); this.do_post(this.state.text, null);
} }
} }
toggle_preview() { toggle_preview() {
this.setState({ this.setState({
preview: !this.state.preview preview: !this.state.preview,
}); });
} }
@ -703,52 +829,89 @@ export class PostForm extends Component {
<div className="post-form-bar"> <div className="post-form-bar">
<label> <label>
图片 图片
<input ref={this.img_ref} type="file" accept="image/*" disabled={this.state.loading_status!=='done'} <input
ref={this.img_ref}
type="file"
accept="image/*"
disabled={this.state.loading_status !== 'done'}
onChange={this.on_img_change_bound} onChange={this.on_img_change_bound}
/> />
</label> </label>
{ {this.state.preview ? (
this.state.preview ? <button
<button type='button' onClick={()=>{this.toggle_preview()}}> type="button"
onClick={() => {
this.toggle_preview();
}}
>
<span className="icon icon-eye-blocked" /> <span className="icon icon-eye-blocked" />
&nbsp;编辑 &nbsp;编辑
</button> : </button>
<button type='button' onClick={()=>{this.toggle_preview()}}> ) : (
<button
type="button"
onClick={() => {
this.toggle_preview();
}}
>
<span className="icon icon-eye" /> <span className="icon icon-eye" />
&nbsp;预览 &nbsp;预览
</button> </button>
} )}
{ {this.state.loading_status !== 'done' ? (
this.state.loading_status!=='done' ?
<button disabled="disabled"> <button disabled="disabled">
<span className="icon icon-loading" /> <span className="icon icon-loading" />
&nbsp;{this.state.loading_status==='processing' ? '处理' : '上传'} &nbsp;
</button> : {this.state.loading_status === 'processing' ? '处理' : '上传'}
</button>
) : (
<button type="submit"> <button type="submit">
<span className="icon icon-send" /> <span className="icon icon-send" />
&nbsp;发表 &nbsp;发表
</button> </button>
} )}
</div> </div>
{!!this.state.img_tip && {!!this.state.img_tip && (
<p className="post-form-img-tip"> <p className="post-form-img-tip">
<a onClick={()=>{this.img_ref.current.value=""; this.on_img_change();}}>删除图片</a> <a
onClick={() => {
this.img_ref.current.value = '';
this.on_img_change();
}}
>
删除图片
</a>
{this.state.img_tip} {this.state.img_tip}
</p> </p>
} )}
{ {this.state.preview ? (
this.state.preview ? <div className="post-preview">
<div className='post-preview'> <HighlightedMarkdown
<HighlightedMarkdown text={this.state.text} color_picker={this.color_picker} show_pid={()=>{}} /> text={this.state.text}
</div> : color_picker={this.color_picker}
<SafeTextarea ref={this.area_ref} id="new_post" on_change={this.on_change_bound} on_submit={this.on_submit.bind(this)} /> show_pid={() => {}}
} />
<p><small> </div>
请遵守<a href="https://thuhole.com/policy.html" target="_blank">树洞管理规范试行</a> ) : (
</small></p> <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> </form>
) );
} }
} }

191
src/cache.js

@ -1,93 +1,100 @@
const HOLE_CACHE_DB_NAME='hole_cache_db'; const HOLE_CACHE_DB_NAME = 'hole_cache_db';
const CACHE_DB_VER=1; const CACHE_DB_VER = 1;
const MAINTENANCE_STEP=150; const MAINTENANCE_STEP = 150;
const MAINTENANCE_COUNT=1000; const MAINTENANCE_COUNT = 1000;
const ENC_KEY=42; const ENC_KEY = 42;
class Cache { class Cache {
constructor() { constructor() {
this.db=null; this.db = null;
this.added_items_since_maintenance=0; this.added_items_since_maintenance = 0;
this.encrypt=this.encrypt.bind(this); this.encrypt = this.encrypt.bind(this);
this.decrypt=this.decrypt.bind(this); this.decrypt = this.decrypt.bind(this);
const open_req=indexedDB.open(HOLE_CACHE_DB_NAME,CACHE_DB_VER); const open_req = indexedDB.open(HOLE_CACHE_DB_NAME, CACHE_DB_VER);
open_req.onerror=console.error.bind(console); open_req.onerror = console.error.bind(console);
open_req.onupgradeneeded=(event)=>{ open_req.onupgradeneeded = (event) => {
console.log('comment cache db upgrade'); console.log('comment cache db upgrade');
const db=event.target.result; const db = event.target.result;
const store=db.createObjectStore('comment',{ const store = db.createObjectStore('comment', {
keyPath: 'pid', keyPath: 'pid',
}); });
store.createIndex('last_access','last_access',{unique: false}); store.createIndex('last_access', 'last_access', { unique: false });
}; };
open_req.onsuccess=(event)=>{ open_req.onsuccess = (event) => {
console.log('comment cache db loaded'); console.log('comment cache db loaded');
this.db=event.target.result; this.db = event.target.result;
setTimeout(this.maintenance.bind(this),1); setTimeout(this.maintenance.bind(this), 1);
}; };
} }
// use window.hole_cache.encrypt() only after cache is loaded! // use window.hole_cache.encrypt() only after cache is loaded!
encrypt(pid,data) { encrypt(pid, data) {
let s=JSON.stringify(data); let s = JSON.stringify(data);
let o=''; let o = '';
for(let i=0,key=(ENC_KEY^pid)%128;i<s.length;i++) { for (let i = 0, key = (ENC_KEY ^ pid) % 128; i < s.length; i++) {
let c=s.charCodeAt(i); let c = s.charCodeAt(i);
let new_key=(key^(c/2))%128; let new_key = (key ^ (c / 2)) % 128;
o+=String.fromCharCode(key^s.charCodeAt(i)); o += String.fromCharCode(key ^ s.charCodeAt(i));
key=new_key; key = new_key;
} }
return o; return o;
} }
// use window.hole_cache.decrypt() only after cache is loaded! // use window.hole_cache.decrypt() only after cache is loaded!
decrypt(pid,s) { decrypt(pid, s) {
let o=''; let o = '';
if(typeof(s)!==typeof('str')) if (typeof s !== typeof 'str') return null;
return null;
for(let i=0,key=(ENC_KEY^pid)%128;i<s.length;i++) { for (let i = 0, key = (ENC_KEY ^ pid) % 128; i < s.length; i++) {
let c=key^s.charCodeAt(i); let c = key ^ s.charCodeAt(i);
o+=String.fromCharCode(c); o += String.fromCharCode(c);
key=(key^(c/2))%128; key = (key ^ (c / 2)) % 128;
} }
try { try {
return JSON.parse(o); return JSON.parse(o);
} catch(e) { } catch (e) {
console.error('decrypt failed'); console.error('decrypt failed');
console.trace(e); console.trace(e);
return null; return null;
} }
} }
get(pid,target_version) { get(pid, target_version) {
pid=parseInt(pid); pid = parseInt(pid);
return new Promise((resolve,reject)=>{ return new Promise((resolve, reject) => {
if(!this.db) if (!this.db) return resolve(null);
return resolve(null); const tx = this.db.transaction(['comment'], 'readwrite');
const tx=this.db.transaction(['comment'],'readwrite'); const store = tx.objectStore('comment');
const store=tx.objectStore('comment'); const get_req = store.get(pid);
const get_req=store.get(pid); get_req.onsuccess = () => {
get_req.onsuccess=()=>{ let res = get_req.result;
let res=get_req.result; if (!res || !res.data_str) {
if(!res || !res.data_str) {
//console.log('comment cache miss '+pid); //console.log('comment cache miss '+pid);
resolve(null); resolve(null);
} else if(target_version===res.version) { // hit } else if (target_version === res.version) {
console.log('comment cache hit',pid); // hit
res.last_access=(+new Date()); console.log('comment cache hit', pid);
res.last_access = +new Date();
store.put(res); store.put(res);
let data=this.decrypt(pid,res.data_str); let data = this.decrypt(pid, res.data_str);
resolve(data); // obj or null resolve(data); // obj or null
} else { // expired } else {
console.log('comment cache expired',pid,': ver',res.version,'target',target_version); // expired
console.log(
'comment cache expired',
pid,
': ver',
res.version,
'target',
target_version,
);
store.delete(pid); store.delete(pid);
resolve(null); resolve(null);
} }
}; };
get_req.onerror=(e)=>{ get_req.onerror = (e) => {
console.warn('comment cache indexeddb open failed'); console.warn('comment cache indexeddb open failed');
console.error(e); console.error(e);
resolve(null); resolve(null);
@ -95,78 +102,72 @@ class Cache {
}); });
} }
put(pid,target_version,data) { put(pid, target_version, data) {
pid=parseInt(pid); pid = parseInt(pid);
return new Promise((resolve,reject)=>{ return new Promise((resolve, reject) => {
if(!this.db) if (!this.db) return resolve();
return resolve(); const tx = this.db.transaction(['comment'], 'readwrite');
const tx=this.db.transaction(['comment'],'readwrite'); const store = tx.objectStore('comment');
const store=tx.objectStore('comment');
store.put({ store.put({
pid: pid, pid: pid,
version: target_version, version: target_version,
data_str: this.encrypt(pid,data), data_str: this.encrypt(pid, data),
last_access: +new Date(), last_access: +new Date(),
}); });
if(++this.added_items_since_maintenance===MAINTENANCE_STEP) if (++this.added_items_since_maintenance === MAINTENANCE_STEP)
setTimeout(this.maintenance.bind(this),1); setTimeout(this.maintenance.bind(this), 1);
}); });
} }
delete(pid) { delete(pid) {
pid=parseInt(pid); pid = parseInt(pid);
return new Promise((resolve,reject)=>{ return new Promise((resolve, reject) => {
if(!this.db) if (!this.db) return resolve();
return resolve(); const tx = this.db.transaction(['comment'], 'readwrite');
const tx=this.db.transaction(['comment'],'readwrite'); const store = tx.objectStore('comment');
const store=tx.objectStore('comment'); let req = store.delete(pid);
let req=store.delete(pid);
//console.log('comment cache delete',pid); //console.log('comment cache delete',pid);
req.onerror=()=>{ req.onerror = () => {
console.warn('comment cache delete failed ',pid); console.warn('comment cache delete failed ', pid);
return resolve(); return resolve();
}; };
req.onsuccess=()=>resolve(); req.onsuccess = () => resolve();
}); });
} }
maintenance() { maintenance() {
if(!this.db) if (!this.db) return;
return; const tx = this.db.transaction(['comment'], 'readwrite');
const tx=this.db.transaction(['comment'],'readwrite'); const store = tx.objectStore('comment');
const store=tx.objectStore('comment'); let count_req = store.count();
let count_req=store.count(); count_req.onsuccess = () => {
count_req.onsuccess=()=>{ let count = count_req.result;
let count=count_req.result; if (count > MAINTENANCE_COUNT) {
if(count>MAINTENANCE_COUNT) { console.log('comment cache db maintenance', count);
console.log('comment cache db maintenance',count); store.index('last_access').openKeyCursor().onsuccess = (e) => {
store.index('last_access').openKeyCursor().onsuccess=(e)=>{ let cur = e.target.result;
let cur=e.target.result; if (cur) {
if(cur) {
//console.log('maintenance: delete',cur); //console.log('maintenance: delete',cur);
store.delete(cur.primaryKey); store.delete(cur.primaryKey);
if(--count>MAINTENANCE_COUNT) if (--count > MAINTENANCE_COUNT) cur.continue();
cur.continue();
} }
}; };
} else { } else {
console.log('comment cache db no need to maintenance',count); console.log('comment cache db no need to maintenance', count);
} }
this.added_items_since_maintenance=0; this.added_items_since_maintenance = 0;
}; };
count_req.onerror=console.error.bind(console); count_req.onerror = console.error.bind(console);
} }
clear() { clear() {
if(!this.db) if (!this.db) return;
return;
indexedDB.deleteDatabase(HOLE_CACHE_DB_NAME); indexedDB.deleteDatabase(HOLE_CACHE_DB_NAME);
console.log('delete comment cache db'); console.log('delete comment cache db');
} }
}; }
export function cache() { export function cache() {
if(!window.hole_cache) if (!window.hole_cache) window.hole_cache = new Cache();
window.hole_cache=new Cache();
return window.hole_cache; return window.hole_cache;
} }

23
src/color_picker.js

@ -1,24 +1,23 @@
// https://martin.ankerl.com/2009/12/09/how-to-create-random-colors-programmatically/ // 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 { export class ColorPicker {
constructor() { constructor() {
this.names={}; this.names = {};
this.current_h=Math.random(); this.current_h = Math.random();
} }
get(name) { get(name) {
name=name.toLowerCase(); name = name.toLowerCase();
if(name==='洞主') if (name === '洞主') return ['hsl(0,0%,97%)', 'hsl(0,0%,16%)'];
return ['hsl(0,0%,97%)','hsl(0,0%,16%)'];
if(!this.names[name]) { if (!this.names[name]) {
this.current_h+=golden_ratio_conjugate; this.current_h += golden_ratio_conjugate;
this.current_h%=1; this.current_h %= 1;
this.names[name]=[ this.names[name] = [
`hsl(${this.current_h*360}, 50%, 90%)`, `hsl(${this.current_h * 360}, 50%, 90%)`,
`hsl(${this.current_h*360}, 60%, 20%)`, `hsl(${this.current_h * 360}, 60%, 20%)`,
]; ];
} }
return this.names[name]; return this.names[name];

179
src/flows_api.js

@ -1,45 +1,49 @@
import {get_json, API_VERSION_PARAM} from './infrastructure/functions'; import { get_json, API_VERSION_PARAM } from './infrastructure/functions';
import {THUHOLE_API_ROOT} from './infrastructure/const'; import { THUHOLE_API_ROOT } from './infrastructure/const';
import {API_BASE} from './Common'; import { API_BASE } from './Common';
import {cache} from './cache'; import { cache } from './cache';
export {THUHOLE_API_ROOT, API_VERSION_PARAM}; export { THUHOLE_API_ROOT, API_VERSION_PARAM };
export function token_param(token) { export function token_param(token) {
return API_VERSION_PARAM()+(token ? ('&user_token='+token) : ''); return API_VERSION_PARAM() + (token ? '&user_token=' + token : '');
} }
export {get_json}; export { get_json };
const SEARCH_PAGESIZE=50; const SEARCH_PAGESIZE = 50;
export const API={ export const API = {
load_replies: (pid,token,color_picker,cache_version)=>{ load_replies: (pid, token, color_picker, cache_version) => {
pid=parseInt(pid); pid = parseInt(pid);
return fetch( return fetch(
API_BASE+'/api.php?action=getcomment'+ API_BASE +
'&pid='+pid+ '/api.php?action=getcomment' +
token_param(token) '&pid=' +
pid +
token_param(token),
) )
.then(get_json) .then(get_json)
.then((json)=>{ .then((json) => {
if(json.code!==0) { if (json.code !== 0) {
if(json.msg) throw new Error(json.msg); if (json.msg) throw new Error(json.msg);
else throw new Error(JSON.stringify(json)); else throw new Error(JSON.stringify(json));
} }
cache().delete(pid).then(()=>{ cache()
cache().put(pid,cache_version,json); .delete(pid)
.then(() => {
cache().put(pid, cache_version, json);
}); });
// also change load_replies_with_cache! // also change load_replies_with_cache!
json.data=json.data json.data = json.data
.sort((a,b)=>{ .sort((a, b) => {
return parseInt(a.cid,10)-parseInt(b.cid,10); return parseInt(a.cid, 10) - parseInt(b.cid, 10);
}) })
.map((info)=>{ .map((info) => {
info._display_color=color_picker.get(info.name); info._display_color = color_picker.get(info.name);
info.variant={}; info.variant = {};
return info; return info;
}); });
@ -47,35 +51,34 @@ export const API={
}); });
}, },
load_replies_with_cache: (pid,token,color_picker,cache_version)=> { load_replies_with_cache: (pid, token, color_picker, cache_version) => {
pid=parseInt(pid); pid = parseInt(pid);
return cache().get(pid,cache_version) return cache()
.then((json)=>{ .get(pid, cache_version)
if(json) { .then((json) => {
if (json) {
// also change load_replies! // also change load_replies!
json.data=json.data json.data = json.data
.sort((a,b)=>{ .sort((a, b) => {
return parseInt(a.cid,10)-parseInt(b.cid,10); return parseInt(a.cid, 10) - parseInt(b.cid, 10);
}) })
.map((info)=>{ .map((info) => {
info._display_color=color_picker.get(info.name); info._display_color = color_picker.get(info.name);
info.variant={}; info.variant = {};
return info; return info;
}); });
return json; return json;
} } else return API.load_replies(pid, token, color_picker, cache_version);
else
return API.load_replies(pid,token,color_picker,cache_version);
}); });
}, },
set_attention: (pid,attention,token)=>{ set_attention: (pid, attention, token) => {
let data=new URLSearchParams(); let data = new URLSearchParams();
data.append('user_token',token); data.append('user_token', token);
data.append('pid',pid); data.append('pid', pid);
data.append('switch',attention ? '1' : '0'); data.append('switch', attention ? '1' : '0');
return fetch(API_BASE+'/api.php?action=attention'+token_param(token), { return fetch(API_BASE + '/api.php?action=attention' + token_param(token), {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
@ -83,12 +86,12 @@ export const API={
body: data, body: data,
}) })
.then(get_json) .then(get_json)
.then((json)=>{ .then((json) => {
cache().delete(pid); cache().delete(pid);
if(json.code!==0) { if (json.code !== 0) {
if(json.msg && json.msg==='已经关注过了') {} if (json.msg && json.msg === '已经关注过了') {
else { } else {
if(json.msg) alert(json.msg); if (json.msg) alert(json.msg);
throw new Error(JSON.stringify(json)); throw new Error(JSON.stringify(json));
} }
} }
@ -96,12 +99,12 @@ export const API={
}); });
}, },
report: (pid,reason,token)=>{ report: (pid, reason, token) => {
let data=new URLSearchParams(); let data = new URLSearchParams();
data.append('user_token',token); data.append('user_token', token);
data.append('pid',pid); data.append('pid', pid);
data.append('reason',reason); data.append('reason', reason);
return fetch(API_BASE+'/api.php?action=report'+token_param(token), { return fetch(API_BASE + '/api.php?action=report' + token_param(token), {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
@ -109,72 +112,68 @@ export const API={
body: data, body: data,
}) })
.then(get_json) .then(get_json)
.then((json)=>{ .then((json) => {
if(json.code!==0) { if (json.code !== 0) {
if(json.msg) alert(json.msg); if (json.msg) alert(json.msg);
throw new Error(JSON.stringify(json)); throw new Error(JSON.stringify(json));
} }
return json; return json;
}); });
}, },
get_list: (page,token)=>{ get_list: (page, token) => {
return fetch( return fetch(
API_BASE+'/api.php?action=getlist'+ API_BASE + '/api.php?action=getlist' + '&p=' + page + token_param(token),
'&p='+page+
token_param(token)
) )
.then(get_json) .then(get_json)
.then((json)=>{ .then((json) => {
if(json.code!==0) if (json.code !== 0) throw new Error(JSON.stringify(json));
throw new Error(JSON.stringify(json));
return json; return json;
}); });
}, },
get_search: (page,keyword,token)=>{ get_search: (page, keyword, token) => {
return fetch( return fetch(
API_BASE+'/api.php?action=search'+ API_BASE +
'&pagesize='+SEARCH_PAGESIZE+ '/api.php?action=search' +
'&page='+page+ '&pagesize=' +
'&keywords='+encodeURIComponent(keyword)+ SEARCH_PAGESIZE +
token_param(token) '&page=' +
page +
'&keywords=' +
encodeURIComponent(keyword) +
token_param(token),
) )
.then(get_json) .then(get_json)
.then((json)=>{ .then((json) => {
if(json.code!==0) { if (json.code !== 0) {
if(json.msg) throw new Error(json.msg); if (json.msg) throw new Error(json.msg);
throw new Error(JSON.stringify(json)); throw new Error(JSON.stringify(json));
} }
return json; return json;
}); });
}, },
get_single: (pid,token)=>{ get_single: (pid, token) => {
return fetch( return fetch(
API_BASE+'/api.php?action=getone'+ API_BASE + '/api.php?action=getone' + '&pid=' + pid + token_param(token),
'&pid='+pid+
token_param(token)
) )
.then(get_json) .then(get_json)
.then((json)=>{ .then((json) => {
if(json.code!==0) { if (json.code !== 0) {
if(json.msg) throw new Error(json.msg); if (json.msg) throw new Error(json.msg);
else throw new Error(JSON.stringify(json)); else throw new Error(JSON.stringify(json));
} }
return json; return json;
}); });
}, },
get_attention: (token)=>{ get_attention: (token) => {
return fetch( return fetch(API_BASE + '/api.php?action=getattention' + token_param(token))
API_BASE+'/api.php?action=getattention'+
token_param(token)
)
.then(get_json) .then(get_json)
.then((json)=>{ .then((json) => {
if(json.code!==0) { if (json.code !== 0) {
if(json.msg) throw new Error(json.msg); if (json.msg) throw new Error(json.msg);
throw new Error(JSON.stringify(json)); throw new Error(JSON.stringify(json));
} }
return json; return json;

18
src/registerServiceWorker.js

@ -14,8 +14,8 @@ const isLocalhost = Boolean(
window.location.hostname === '[::1]' || window.location.hostname === '[::1]' ||
// 127.0.0.1/8 is considered localhost for IPv4. // 127.0.0.1/8 is considered localhost for IPv4.
window.location.hostname.match( 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() { export default function register() {
@ -41,7 +41,7 @@ export default function register() {
navigator.serviceWorker.ready.then(() => { navigator.serviceWorker.ready.then(() => {
console.log( console.log(
'This web app is being served cache-first by a service ' + '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 { } else {
@ -55,7 +55,7 @@ export default function register() {
function registerValidSW(swUrl) { function registerValidSW(swUrl) {
navigator.serviceWorker navigator.serviceWorker
.register(swUrl) .register(swUrl)
.then(registration => { .then((registration) => {
registration.onupdatefound = () => { registration.onupdatefound = () => {
const installingWorker = registration.installing; const installingWorker = registration.installing;
installingWorker.onstatechange = () => { installingWorker.onstatechange = () => {
@ -76,7 +76,7 @@ function registerValidSW(swUrl) {
}; };
}; };
}) })
.catch(error => { .catch((error) => {
console.error('Error during service worker registration:', error); console.error('Error during service worker registration:', error);
}); });
} }
@ -84,14 +84,14 @@ function registerValidSW(swUrl) {
function checkValidServiceWorker(swUrl) { function checkValidServiceWorker(swUrl) {
// Check if the service worker can be found. If it can't reload the page. // Check if the service worker can be found. If it can't reload the page.
fetch(swUrl) fetch(swUrl)
.then(response => { .then((response) => {
// Ensure service worker exists, and that we really are getting a JS file. // Ensure service worker exists, and that we really are getting a JS file.
if ( if (
response.status === 404 || response.status === 404 ||
response.headers.get('content-type').indexOf('javascript') === -1 response.headers.get('content-type').indexOf('javascript') === -1
) { ) {
// No service worker found. Probably a different app. Reload the page. // No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => { navigator.serviceWorker.ready.then((registration) => {
registration.unregister().then(() => { registration.unregister().then(() => {
window.location.reload(); window.location.reload();
}); });
@ -103,14 +103,14 @@ function checkValidServiceWorker(swUrl) {
}) })
.catch(() => { .catch(() => {
console.log( 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() { export function unregister() {
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => { navigator.serviceWorker.ready.then((registration) => {
registration.unregister(); registration.unregister();
}); });
} }

36
src/text_splitter.js

@ -1,34 +1,34 @@
// regexp should match the WHOLE segmented part // 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])([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 // 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)([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 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 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_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) { export function split_text(txt, rules) {
// rules: [['name',/regex/],...] // rules: [['name',/regex/],...]
// return: [['name','part'],[null,'part'],...] // return: [['name','part'],[null,'part'],...]
txt=[[null,txt]]; txt = [[null, txt]];
rules.forEach((rule)=>{ rules.forEach((rule) => {
let [name,regex]=rule; let [name, regex] = rule;
txt=[].concat.apply([],txt.map((part)=>{ txt = [].concat.apply(
let [rule,content]=part; [],
if(rule) // already tagged by previous rules txt.map((part) => {
let [rule, content] = part;
if (rule)
// already tagged by previous rules
return [part]; return [part];
else { else {
return content return content
.split(regex) .split(regex)
.map((seg)=>( .map((seg) => (regex.test(seg) ? [name, seg] : [null, seg]))
regex.test(seg) ? [name,seg] : [null,seg] .filter(([name, seg]) => name !== null || seg);
))
.filter(([name,seg])=>(
name!==null || seg
));
} }
})); }),
);
}); });
return txt; return txt;
} }

Loading…
Cancel
Save