Browse Source

format code

dev
thuhole 5 years ago
parent
commit
7636e45c00
  1. 55
      .eslintrc
  2. 7
      .prettierrc.js
  3. 74
      src/App.js
  4. 39
      src/AudioWidget.js
  5. 265
      src/Common.js
  6. 172
      src/Config.js
  7. 775
      src/Flows.js
  8. 32
      src/Markdown.js
  9. 37
      src/Message.js
  10. 51
      src/PressureHelper.js
  11. 39
      src/Sidebar.js
  12. 105
      src/Title.js
  13. 433
      src/UserAction.js
  14. 43
      src/cache.js
  15. 3
      src/color_picker.js
  16. 55
      src/flows_api.js
  17. 18
      src/registerServiceWorker.js
  18. 18
      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',
}

74
src/App.js

@ -11,16 +11,18 @@ 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(
{ default: undefined, light: false, dark: true }[
window.config.color_scheme
],
);
this.state = { this.state = {
sidebar_stack: [[null, null]], // list of [status, content] sidebar_stack: [[null, null]], // list of [status, content]
mode: 'list', // list, single, search, attention mode: 'list', // list, single, search, attention
@ -33,13 +35,17 @@ class App extends Component {
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;
} }
} }
@ -47,16 +53,14 @@ class App extends Component {
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;
@ -66,8 +70,7 @@ class App extends Component {
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,
}; };
@ -84,7 +87,8 @@ 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 || '';
@ -92,37 +96,53 @@ class App extends Component {
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>
); );
} }

39
src/AudioWidget.js

@ -5,14 +5,11 @@ 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();
}); });
}); });
} }
@ -40,11 +37,7 @@ export class AudioWidget extends Component {
this.setState({ this.setState({
state: 'loading', state: 'loading',
}); });
Promise.all([ Promise.all([fetch(this.state.url), load_amrnb()]).then((res) => {
fetch(this.state.url),
load_amrnb(),
])
.then((res)=>{
res[0].blob().then((blob) => { res[0].blob().then((blob) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (event) => { reader.onload = (event) => {
@ -57,13 +50,15 @@ export class AudioWidget extends Component {
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(
new Blob([binary_wave], { type: 'audio/wav' }),
);
window.audio_cache[this.state.url] = objurl; window.audio_cache[this.state.url] = objurl;
this.setState({ this.setState({
state: 'loaded', state: 'loaded',
@ -80,12 +75,18 @@ 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>); );
if (this.state.state === 'loading') return <p>正在下载</p>;
else if (this.state.state === 'decoding') return <p>正在解码</p>;
else if (this.state.state === 'loaded') else if (this.state.state === 'loaded')
return (<p><audio src={this.state.data} controls /></p>); return (
<p>
<audio src={this.state.data} controls />
</p>
);
} }
} }

265
src/Common.js

@ -2,12 +2,18 @@ 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 };
@ -19,19 +25,32 @@ function escape_regex(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;
} }
@ -43,107 +62,172 @@ export class HighlightedText extends PureComponent {
{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
);
} }
} }
} }
@ -165,11 +249,14 @@ export class SafeTextarea extends Component {
} }
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() {
@ -210,8 +297,13 @@ 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}
/>
);
} }
} }
@ -223,28 +315,36 @@ window.addEventListener('beforeinstallprompt', (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;
} }
@ -275,7 +375,9 @@ export class ClickHandler extends PureComponent {
} }
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({
@ -285,8 +387,7 @@ 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,
}); });
@ -300,11 +401,15 @@ export class ClickHandler extends PureComponent {
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>
) );
} }
} }

172
src/Config.js

@ -3,22 +3,30 @@ 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() {
@ -34,8 +42,7 @@ export function load_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);
@ -75,45 +82,71 @@ 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>
); );
} }
@ -135,9 +168,12 @@ 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,18 +181,19 @@ 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>
) );
} }
} }
@ -170,13 +207,16 @@ class ConfigSwitch extends PureComponent {
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>
); );
} }
@ -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>
) );
} }
} }

775
src/Flows.js

File diff suppressed because it is too large Load Diff

32
src/Markdown.js

@ -1,10 +1,10 @@
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,
@ -14,16 +14,20 @@ let md = new MarkdownIt({
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);

37
src/Message.js

@ -17,14 +17,20 @@ 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',
@ -37,16 +43,26 @@ export class MessageViewer extends PureComponent {
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 (
<div className="box box-tip">
<a
onClick={() => {
this.load();
}}
>
重新加载
</a>
</div>
);
else if (this.state.loading_status === 'done') else if (this.state.loading_status === 'done')
return this.state.msg.map((msg) => ( return this.state.msg.map((msg) => (
<div className="box"> <div className="box">
@ -59,7 +75,6 @@ export class MessageViewer extends PureComponent {
</div> </div>
</div> </div>
)); ));
else else return null;
return null;
} }
} }

51
src/PressureHelper.js

@ -3,7 +3,7 @@ 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!
@ -38,13 +38,14 @@ export class PressureHelper extends Component {
componentDidMount() { componentDidMount() {
if (window.config.pressure) { if (window.config.pressure) {
Pressure.set(document.body, { Pressure.set(
document.body,
{
change: (force) => { change: (force) => {
if (!this.state.fired) { if (!this.state.fired) {
if(force>=.999) { if (force >= 0.999) {
this.do_fire(); this.do_fire();
} } else
else
this.setState({ this.setState({
level: force, level: force,
}); });
@ -56,29 +57,32 @@ export class PressureHelper extends Component {
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(() => { this.esc_interval = setInterval(() => {
let new_level=this.state.level+.1; let new_level = this.state.level + 0.1;
if(new_level>=.999) if (new_level >= 0.999) this.do_fire();
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) => {
@ -98,16 +102,19 @@ 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,
}} /> }}
) />
);
} }
} }

39
src/Sidebar.js

@ -12,8 +12,7 @@ export class Sidebar extends PureComponent {
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;
} }
} }
@ -25,18 +24,40 @@ export class Sidebar extends PureComponent {
} }
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>

105
src/Title.js

@ -26,11 +26,14 @@ class ControlBar extends PureComponent {
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' });
}); },
);
} }
} }
@ -46,10 +49,20 @@ class ControlBar extends PureComponent {
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;
} }
@ -77,47 +90,70 @@ class ControlBar extends PureComponent {
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={'icon icon-' + (token ? 'about' : 'login')} />
<span className="control-btn-label">{token ? '账户' : '登录'}</span> <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
token={token}
on_complete={() => {
this.props.show_sidebar(null, null); 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>
) );
} }

433
src/UserAction.js

@ -1,5 +1,10 @@
import React, { Component, PureComponent } from 'react'; import React, { Component, PureComponent } from 'react';
import {API_BASE, SafeTextarea, PromotionBar, HighlightedMarkdown} from './Common'; import {
API_BASE,
SafeTextarea,
PromotionBar,
HighlightedMarkdown,
} from './Common';
import { MessageViewer } from './Message'; import { MessageViewer } from './Message';
import { LoginPopup } from './infrastructure/widgets'; import { LoginPopup } from './infrastructure/widgets';
import { ColorPicker } from './color_picker'; import { ColorPicker } from './color_picker';
@ -7,7 +12,13 @@ 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';
@ -201,26 +212,35 @@ 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
onClick={() => {
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations() navigator.serviceWorker
.getRegistrations()
.then((registrations) => { .then((registrations) => {
for (let registration of registrations) { for (let registration of registrations) {
console.log('unregister', registration); console.log('unregister', registration);
@ -232,42 +252,67 @@ export function InfoSidebar(props) {
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>
@ -284,12 +329,20 @@ class ResetUsertokenWidget extends Component {
} }
do_reset() { do_reset() {
if(window.confirm('您正在重置 UserToken!\n您的账号将会在【所有设备】上注销,您需要手动重新登录!')) { if (
let uid=window.prompt('您正在重置 UserToken!\n请输入您的学号以确认身份:'); window.confirm(
'您正在重置 UserToken!\n您的账号将会在【所有设备】上注销,您需要手动重新登录!',
)
) {
let uid = window.prompt(
'您正在重置 UserToken!\n请输入您的学号以确认身份:',
);
if (uid) if (uid)
this.setState({ 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: {
@ -302,10 +355,8 @@ 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',
@ -316,38 +367,48 @@ class ResetUsertokenWidget extends Component {
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,16 +451,21 @@ 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>
);
} }
} }
@ -399,12 +479,19 @@ export class ReplyForm extends Component {
}; };
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,
);
this.color_picker = new ColorPicker(); 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 (
e.code === 'Enter' &&
!e.ctrlKey &&
!e.altKey &&
['input', 'textarea'].indexOf(e.target.tagName.toLowerCase()) === -1
) {
if (this.area_ref.current) { if (this.area_ref.current) {
e.preventDefault(); e.preventDefault();
this.area_ref.current.focus(); this.area_ref.current.focus();
@ -415,7 +502,10 @@ export class ReplyForm extends Component {
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) {
@ -426,8 +516,7 @@ 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',
}); });
@ -436,13 +525,16 @@ export class ReplyForm extends Component {
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) {
@ -469,33 +561,55 @@ 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>
) );
} }
} }
@ -516,8 +630,7 @@ export class PostForm extends Component {
} }
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) {
@ -531,8 +644,7 @@ export class PostForm extends Component {
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',
@ -569,8 +681,7 @@ export class PostForm extends Component {
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);
} }
@ -578,23 +689,23 @@ export class PostForm extends Component {
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;
@ -607,17 +718,25 @@ export class PostForm extends Component {
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,
new_url;
while (quality_r - quality_l >= 0.03) {
quality = (quality_r + quality_l) / 2; quality = (quality_r + quality_l) / 2;
new_url = canvas.toDataURL('image/jpeg', quality); new_url = canvas.toDataURL('image/jpeg', quality);
console.log(quality_l,quality_r,'trying quality',quality,'size',new_url.length); console.log(
if(new_url.length<=MAX_IMG_FILESIZE) quality_l,
quality_l=quality; quality_r,
else 'trying quality',
quality_r=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),
@ -629,7 +748,7 @@ export class PostForm extends Component {
} else { } else {
reject('图片过大,无法上传'); reject('图片过大,无法上传');
} }
}); };
image.src = url; image.src = url;
} }
reader.onload = (event) => { reader.onload = (event) => {
@ -643,14 +762,21 @@ 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) => {
@ -658,7 +784,8 @@ export class PostForm extends Component {
img_tip: `图片无效:${e}`, img_tip: `图片无效:${e}`,
}); });
}); });
}); },
);
else else
this.setState({ this.setState({
img_tip: null, img_tip: null,
@ -667,8 +794,7 @@ 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',
@ -693,7 +819,7 @@ export class PostForm extends Component {
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>
) );
} }
} }

43
src/cache.js

@ -44,8 +44,7 @@ class Cache {
// 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);
@ -65,8 +64,7 @@ class Cache {
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);
@ -75,14 +73,23 @@ class Cache {
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) {
// hit
console.log('comment cache hit', pid); console.log('comment cache hit', pid);
res.last_access=(+new Date()); 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);
} }
@ -98,8 +105,7 @@ 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({
@ -116,8 +122,7 @@ class Cache {
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);
@ -131,8 +136,7 @@ class Cache {
} }
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();
@ -145,8 +149,7 @@ class Cache {
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 {
@ -158,15 +161,13 @@ class Cache {
} }
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;
} }

3
src/color_picker.js

@ -10,8 +10,7 @@ export class ColorPicker {
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;

55
src/flows_api.js

@ -6,7 +6,7 @@ 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 };
@ -17,9 +17,11 @@ 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) => {
@ -28,7 +30,9 @@ export const API={
else throw new Error(JSON.stringify(json)); else throw new Error(JSON.stringify(json));
} }
cache().delete(pid).then(()=>{ cache()
.delete(pid)
.then(() => {
cache().put(pid, cache_version, json); cache().put(pid, cache_version, json);
}); });
@ -49,7 +53,8 @@ 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()
.get(pid, cache_version)
.then((json) => { .then((json) => {
if (json) { if (json) {
// also change load_replies! // also change load_replies!
@ -64,9 +69,7 @@ export const API={
}); });
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);
}); });
}, },
@ -86,8 +89,8 @@ export const API={
.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));
} }
@ -120,25 +123,26 @@ export const API={
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) => {
@ -152,9 +156,7 @@ export const API={
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) => {
@ -167,10 +169,7 @@ export const API={
}, },
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) {

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();
}); });
} }

18
src/text_splitter.js

@ -14,21 +14,21 @@ export function split_text(txt,rules) {
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(
[],
txt.map((part) => {
let [rule, content] = part; let [rule, content] = part;
if(rule) // already tagged by previous rules 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