thuhole 5 years ago
parent
commit
98881eca9a
  1. 4
      .prettierrc.js
  2. 7421
      package-lock.json
  3. 28
      package.json
  4. 16
      public/static/fonts_7/icomoon.css
  5. 18
      src/App.js
  6. 37
      src/Common.js
  7. 19
      src/Config.css
  8. 137
      src/Config.js
  9. 211
      src/Flows.js
  10. 6
      src/Message.js
  11. 44
      src/Sidebar.css
  12. 36
      src/Sidebar.js
  13. 12
      src/Title.js
  14. 2
      src/UserAction.js
  15. 3
      src/cache.js
  16. 206
      src/flows_api.js
  17. 2
      src/infrastructure

4
.prettierrc.js

@ -3,5 +3,5 @@ module.exports = {
tabWidth: 2, tabWidth: 2,
semi: true, semi: true,
singleQuote: true, singleQuote: true,
endOfLine: 'auto', endOfLine: 'auto'
} };

7421
package-lock.json generated

File diff suppressed because it is too large Load Diff

28
package.json

@ -1,9 +1,9 @@
{ {
"name": "webhole", "name": "webhole",
"version": "0.1.0", "version": "0.3.1",
"private": true, "private": true,
"dependencies": { "dependencies": {
"copy-to-clipboard": "^3.0.8", "copy-to-clipboard": "^3.3.1",
"fix-orientation": "^1.1.0", "fix-orientation": "^1.1.0",
"gh-pages": "^3.0.0", "gh-pages": "^3.0.0",
"highlight.js": "^10.1.1", "highlight.js": "^10.1.1",
@ -12,11 +12,11 @@
"markdown-it": "^11.0.0", "markdown-it": "^11.0.0",
"markdown-it-katex": "^2.0.3", "markdown-it-katex": "^2.0.3",
"pressure": "^2.1.2", "pressure": "^2.1.2",
"react": "^16.9.0", "react": "^16.13.1",
"react-dom": "^16.9.0", "react-dom": "^16.13.0",
"react-google-recaptcha-v3": "^1.5.2", "react-google-recaptcha-v3": "^1.5.2",
"react-scripts": "^3.1.1", "react-scripts": "^3.4.1",
"react-timeago": "^4.1.9" "react-timeago": "^4.4.0"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",
@ -24,9 +24,10 @@
"test": "echo 'skipped react-scripts test --env=jsdom'", "test": "echo 'skipped react-scripts test --env=jsdom'",
"predeploy": "", "predeploy": "",
"deploy": "gh-pages -d build -m $(TZ=Asia/Shanghai date +\"%y%m%d%H%M%S\")", "deploy": "gh-pages -d build -m $(TZ=Asia/Shanghai date +\"%y%m%d%H%M%S\")",
"eject": "react-scripts eject" "eject": "react-scripts eject",
"lint": "eslint --fix src/*.js"
}, },
"homepage": "https://://cdn.jsdelivr.net/gh/thuhole/webhole@gh-pages", "homepage": ".",
"browserslist": { "browserslist": {
"production": [ "production": [
">0.2%", ">0.2%",
@ -38,5 +39,16 @@
"last 1 firefox version", "last 1 firefox version",
"last 1 safari version" "last 1 safari version"
] ]
},
"devDependencies": {
"eslint": "^6.8.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-import": "^2.21.2",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^3.1.3",
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-react": "^7.20.0",
"eslint-plugin-standard": "^4.0.1",
"prettier": "^2.0.5"
} }
} }

16
public/static/fonts_7/icomoon.css

@ -1,24 +1,25 @@
@font-face { @font-face {
font-family: 'icomoon'; font-family: 'icomoon';
src: url('icomoon.eot?f9daqg'); src:
src: url('icomoon.eot?f9daqg#iefix') format('embedded-opentype'), url('icomoon.ttf?8qh3rt') format('truetype'),
url('icomoon.ttf?f9daqg') format('truetype'), url('icomoon.woff?8qh3rt') format('woff'),
url('icomoon.woff?f9daqg') format('woff'), url('icomoon.svg?8qh3rt#icomoon') format('svg');
url('icomoon.svg?f9daqg#icomoon') format('svg');
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
font-display: block; font-display: block;
} }
[class^="icon-"], [class*=" icon-"] { .icon {
/* use !important to prevent issues with browser extensions that change fonts */ /* use !important to prevent issues with browser extensions that change fonts */
/*noinspection CssNoGenericFontName*/
font-family: 'icomoon' !important; font-family: 'icomoon' !important;
speak: never; speak: none;
font-style: normal; font-style: normal;
font-weight: normal; font-weight: normal;
font-variant: normal; font-variant: normal;
text-transform: none; text-transform: none;
line-height: 1; line-height: 1;
vertical-align: -.0625em;
/* Better Font Rendering =========== */ /* Better Font Rendering =========== */
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
@ -93,6 +94,7 @@
} }
.icon-order-rev:before { .icon-order-rev:before {
content: "\ea46"; content: "\ea46";
font-size: 1.2em;
} }
.icon-github:before { .icon-github:before {
content: "\eab0"; content: "\eab0";

18
src/App.js

@ -60,15 +60,32 @@ class App extends Component {
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 === 1) {
document.body.style.top = `-${window.scrollY}px`;
document.body.style.position = 'fixed';
document.body.style.width = '100vw'; // Be responsive with fixed position
}
if (ns.length > MAX_SIDEBAR_STACK_SIZE) ns.splice(1, 1); if (ns.length > MAX_SIDEBAR_STACK_SIZE) 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;
if (ns.length === 2) {
const scrollY = document.body.style.top;
document.body.style.position = '';
document.body.style.top = '';
document.body.style.width = '';
window.scrollTo(0, parseInt(scrollY || '0') * -1);
}
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') {
const scrollY = document.body.style.top;
document.body.style.position = '';
document.body.style.top = '';
document.body.style.width = '';
window.scrollTo(0, parseInt(scrollY || '0') * -1);
ns = [[null, null]]; ns = [[null, null]];
} else throw new Error('bad show_sidebar mode'); } else throw new Error('bad show_sidebar mode');
return { return {
@ -103,6 +120,7 @@ class App extends Component {
<Title <Title
show_sidebar={this.show_sidebar_bound} show_sidebar={this.show_sidebar_bound}
set_mode={this.set_mode_bound} set_mode={this.set_mode_bound}
mode={this.state.mode}
/> />
<TokenCtx.Consumer> <TokenCtx.Consumer>
{(token) => ( {(token) => (

37
src/Common.js

@ -24,17 +24,30 @@ 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(
return txt txt,
? new RegExp( split = ' ',
`(${txt option = 'g',
.split(split) isRegex = false,
.filter((x) => !!x) ) {
.map(escape_regex) if (isRegex) {
.join('|')})`, try {
option, return new RegExp('(' + txt.slice(1, -1) + ')', option);
) } catch (e) {
: /^$/g; return /^$/g;
}
} else {
return txt
? new RegExp(
`(${txt
.split(split)
.filter((x) => !!x)
.map(escape_regex)
.join('|')})`,
option,
)
: /^$/g;
}
} }
export function ColoredSpan(props) { export function ColoredSpan(props) {
@ -142,7 +155,7 @@ export class HighlightedMarkdown extends Component {
node.type === 'text' && node.type === 'text' &&
(!node.parent || (!node.parent ||
!node.parent.attribs || !node.parent.attribs ||
node.parent.attribs['encoding'] != 'application/x-tex') node.parent.attribs['encoding'] !== 'application/x-tex')
); // pid, nickname, search ); // pid, nickname, search
}, },
processNode(node, children, index) { processNode(node, children, index) {

19
src/Config.css

@ -4,6 +4,23 @@
position: sticky; position: sticky;
} }
.config-description {
font-size: 0.75em;
}
.config-select {
height: 2em;
}
.config-textarea {
margin-top: 0.5em;
width: 100%;
max-width: 100%;
min-width: 100%;
height: 7em;
min-height: 2em;
}
.bg-preview { .bg-preview {
height: 18em; height: 18em;
width: 32em; width: 32em;
@ -11,4 +28,4 @@
max-width: 100%; max-width: 100%;
margin: .5em auto 1em; margin: .5em auto 1em;
box-shadow: 0 1px 5px rgba(0,0,0,.4); box-shadow: 0 1px 5px rgba(0,0,0,.4);
} }

137
src/Config.js

@ -1,4 +1,4 @@
import React, { Component, PureComponent } from 'react'; import React, { PureComponent } from 'react';
import './Config.css'; import './Config.css';
@ -26,7 +26,7 @@ const DEFAULT_CONFIG = {
pressure: false, pressure: false,
easter_egg: true, easter_egg: true,
color_scheme: 'default', color_scheme: 'default',
fold: true, block_words: [],
}; };
export function load_config() { export function load_config() {
@ -117,7 +117,11 @@ class ConfigBackground extends PureComponent {
<div> <div>
<p> <p>
<b>背景图片</b> <b>背景图片</b>
<select value={img_select} onChange={this.on_select.bind(this)}> <select
className="config-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}> <option key={key} value={key}>
{BUILTIN_IMGS[key]} {BUILTIN_IMGS[key]}
@ -127,6 +131,7 @@ class ConfigBackground extends PureComponent {
<option value="##color">纯色背景</option> <option value="##color">纯色背景</option>
</select> </select>
&nbsp; &nbsp;
<small>#background_img</small>&nbsp;
{img_select === '##other' && ( {img_select === '##other' && (
<input <input
type="url" type="url"
@ -182,6 +187,7 @@ class ConfigColorScheme extends PureComponent {
<p> <p>
<b>夜间模式</b> <b>夜间模式</b>
<select <select
className="config-select"
value={this.state.color_scheme} value={this.state.color_scheme}
onChange={this.on_select.bind(this)} onChange={this.on_select.bind(this)}
> >
@ -189,14 +195,105 @@ class ConfigColorScheme extends PureComponent {
<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 className="config-description">
选择浅色或深色模式深色模式下将会调暗图片亮度
</p> </p>
<p>选择浅色或深色模式深色模式下将会调暗图片亮度</p>
</div> </div>
); );
} }
} }
class ConfigTextArea extends PureComponent {
constructor(props) {
super(props);
this.state = {
[props.id]: window.config[props.id],
};
}
save_changes() {
this.props.callback({
[this.props.id]: this.props.sift(this.state[this.props.id]),
});
}
on_change(e) {
let value = this.props.parse(e.target.value);
this.setState(
{
[this.props.id]: value,
},
this.save_changes.bind(this),
);
}
render() {
return (
<div>
<label>
<p>
<b>{this.props.name}</b>&nbsp;<small>#{this.props.id}</small>
</p>
<p className="config-description">{this.props.description}</p>
<textarea
name={'config-' + this.props.id}
id={`config-textarea-${this.props.id}`}
className="config-textarea"
value={this.props.display(this.state[this.props.id])}
onChange={this.on_change.bind(this)}
/>
</label>
</div>
);
}
}
/* class ConfigBlockWords extends PureComponent {
constructor(props) {
super(props);
this.state = {
block_words: window.config.block_words,
};
}
save_changes() {
this.props.callback({
block_words: this.state.block_words.filter((v) => v),
});
}
on_change(e) {
// Filter out those blank lines
let value = e.target.value.split('\n');
this.setState(
{
block_words: value,
},
this.save_changes.bind(this),
);
}
render() {
return (
<div>
<p>
{' '}
<b>设置屏蔽词 </b>
</p>
<p>
<textarea
className="block-words"
value={this.state.block_words.join('\n')}
onChange={this.on_change.bind(this)}
/>
</p>
</div>
);
}
} */
class ConfigSwitch extends PureComponent { class ConfigSwitch extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
@ -230,11 +327,11 @@ class ConfigSwitch extends PureComponent {
checked={this.state.switch} checked={this.state.switch}
onChange={this.on_change.bind(this)} onChange={this.on_change.bind(this)}
/> />
<b>{this.props.name}</b> &nbsp;<b>{this.props.name}</b>
&nbsp; <small>#{this.props.id}</small> &nbsp;<small>#{this.props.id}</small>
</label> </label>
</p> </p>
<p>{this.props.description}</p> <p className="config-description">{this.props.description}</p>
</div> </div>
); );
} }
@ -285,9 +382,29 @@ export class ConfigUI extends PureComponent {
</p> </p>
</div> </div>
<div className="box"> <div className="box">
<ConfigBackground callback={this.save_changes_bound} /> <ConfigBackground
id="background"
callback={this.save_changes_bound}
/>
<hr /> <hr />
<ConfigColorScheme callback={this.save_changes_bound} /> <ConfigColorScheme
id="color-scheme"
callback={this.save_changes_bound}
/>
<hr />
{/* <ConfigBlockWords
id="block-words"
callback={this.save_changes_bound}
/> */}
<ConfigTextArea
id="block_words"
callback={this.save_changes_bound}
name="设置屏蔽词"
description={'包含屏蔽词的树洞会被折叠,每行写一个屏蔽词'}
display={(array) => array.join('\n')}
sift={(array) => array.filter((v) => v)}
parse={(string) => string.split('\n')}
/>
<hr /> <hr />
<ConfigSwitch <ConfigSwitch
callback={this.save_changes_bound} callback={this.save_changes_bound}

211
src/Flows.js

@ -1,4 +1,4 @@
import React, { Component, PureComponent } from 'react'; import React, { PureComponent } from 'react';
import copy from 'copy-to-clipboard'; import copy from 'copy-to-clipboard';
import { ColorPicker } from './color_picker'; import { ColorPicker } from './color_picker';
import { import {
@ -19,11 +19,11 @@ import {
HighlightedMarkdown, HighlightedMarkdown,
} from './Common'; } from './Common';
import './Flows.css'; import './Flows.css';
import LazyLoad from './react-lazyload/src'; import LazyLoad, { forceCheck } from './react-lazyload/src';
import { AudioWidget } from './AudioWidget'; import { AudioWidget } from './AudioWidget';
import { TokenCtx, ReplyForm } from './UserAction'; import { TokenCtx, ReplyForm } from './UserAction';
import { API, THUHOLE_API_ROOT } from './flows_api'; import { API } from './flows_api';
const IMAGE_BASE = 'https://img.thuhole.com/'; const IMAGE_BASE = 'https://img.thuhole.com/';
const IMAGE_BAK_BASE = 'https://img2.thuhole.com/'; const IMAGE_BAK_BASE = 'https://img2.thuhole.com/';
@ -52,7 +52,7 @@ window.LATEST_POST_ID = parseInt(localStorage['_LATEST_POST_ID'], 10) || 0;
const DZ_NAME = '洞主'; const DZ_NAME = '洞主';
function load_single_meta(show_sidebar, token) { function load_single_meta(show_sidebar, token) {
return (pid, replace = false) => { return async (pid, replace = false) => {
let color_picker = new ColorPicker(); let color_picker = new ColorPicker();
let title_elem = '树洞 #' + pid; let title_elem = '树洞 #' + pid;
show_sidebar( show_sidebar(
@ -60,56 +60,44 @@ function load_single_meta(show_sidebar, token) {
<div className="box box-tip">正在加载 #{pid}</div>, <div className="box box-tip">正在加载 #{pid}</div>,
replace ? 'replace' : 'push', replace ? 'replace' : 'push',
); );
API.get_single(pid, token) try {
.then((single) => { let single = await API.get_single(pid, token);
single.data.variant = {}; single.data.variant = {};
return new Promise((resolve, reject) => { let { data: replies } = await API.load_replies_with_cache(
API.load_replies_with_cache( pid,
pid, token,
token, color_picker,
color_picker, parseInt(single.data.reply),
parseInt(single.data.reply), );
) show_sidebar(
.then((replies) => { title_elem,
resolve([single, replies]); <FlowSidebar
}) key={+new Date()}
.catch(reject); info={single.data}
}); replies={replies.data}
}) attention={replies.attention}
.then((res) => { token={token}
let [single, replies] = res; show_sidebar={show_sidebar}
show_sidebar( color_picker={color_picker}
title_elem, deletion_detect={localStorage['DELETION_DETECT'] === 'on'}
<FlowSidebar />,
key={+new Date()} 'replace',
info={single.data} );
replies={replies.data} } catch (e) {
attention={replies.attention} console.error(e);
token={token} show_sidebar(
show_sidebar={show_sidebar} title_elem,
color_picker={color_picker} <div className="box box-tip">
deletion_detect={localStorage['DELETION_DETECT'] === 'on'} <p>
/>, <a onClick={() => load_single_meta(show_sidebar, token)(pid, true)}>
'replace', 重新加载
); </a>
}) </p>
.catch((e) => { <p>{'' + e}</p>
console.error(e); </div>,
show_sidebar( 'replace',
title_elem, );
<div className="box box-tip"> }
<p>
<a
onClick={() => load_single_meta(show_sidebar, token)(pid, true)}
>
重新加载
</a>
</p>
<p>{'' + e}</p>
</div>,
'replace',
);
});
}; };
} }
@ -213,6 +201,9 @@ class FlowItem extends PureComponent {
parseInt(props.info.pid, 10) > window.LATEST_POST_ID && ( parseInt(props.info.pid, 10) > window.LATEST_POST_ID && (
<div className="flow-item-dot" /> <div className="flow-item-dot" />
)} )}
{!!this.props.attention && !this.props.cached && (
<div className="flow-item-dot" />
)}
<div className="box-header"> <div className="box-header">
{!!this.props.do_filter_name && ( {!!this.props.do_filter_name && (
<span <span
@ -446,9 +437,7 @@ class FlowSidebar extends PureComponent {
} }
toggle_rev() { toggle_rev() {
this.setState((prevState) => ({ this.setState((prevState) => ({ rev: !prevState.rev }), forceCheck);
rev: !prevState.rev,
}));
} }
show_reply_bar(name, event) { show_reply_bar(name, event) {
@ -479,9 +468,10 @@ class FlowSidebar extends PureComponent {
: this.state.replies.slice(); : this.state.replies.slice();
if (this.state.rev) replies_to_show.reverse(); if (this.state.rev) replies_to_show.reverse();
// may not need key, for performance
// key for lazyload elem // key for lazyload elem
let view_mode_key = // let view_mode_key =
(this.state.rev ? 'y-' : 'n-') + (this.state.filter_name || 'null'); // (this.state.rev ? 'y-' : 'n-') + (this.state.filter_name || 'null');
let replies_cnt = { [DZ_NAME]: 1 }; let replies_cnt = { [DZ_NAME]: 1 };
replies_to_show.forEach((r) => { replies_to_show.forEach((r) => {
@ -600,9 +590,9 @@ class FlowSidebar extends PureComponent {
条回复被删除 条回复被删除
</div> </div>
)} )}
{replies_to_show.map((reply) => ( {replies_to_show.map((reply, i) => (
<LazyLoad <LazyLoad
key={reply.cid + view_mode_key} key={i}
offset={1500} offset={1500}
height="5em" height="5em"
overflow={true} overflow={true}
@ -630,7 +620,7 @@ class FlowSidebar extends PureComponent {
</LazyLoad> </LazyLoad>
))} ))}
{this.state.rev && main_thread_elem} {this.state.rev && main_thread_elem}
{!!this.props.token ? ( {this.props.token ? (
<ReplyForm <ReplyForm
pid={this.state.info.pid} pid={this.state.info.pid}
token={this.props.token} token={this.props.token}
@ -653,8 +643,12 @@ class FlowItemRow extends PureComponent {
reply_status: 'done', reply_status: 'done',
reply_error: null, reply_error: null,
info: Object.assign({}, props.info, { variant: {} }), info: Object.assign({}, props.info, { variant: {} }),
hidden: window.config.block_words.some((word) =>
props.info.text.includes(word),
),
attention: attention:
props.attention_override === null ? false : props.attention_override, props.attention_override === null ? false : props.attention_override,
cached: true, // default no display anything
}; };
this.color_picker = new ColorPicker(); this.color_picker = new ColorPicker();
} }
@ -665,6 +659,10 @@ class FlowItemRow extends PureComponent {
} }
} }
reveal() {
this.setState({ hidden: false });
}
load_replies(callback, update_count = true) { load_replies(callback, update_count = true) {
console.log('fetching reply', this.state.info.pid); console.log('fetching reply', this.state.info.pid);
this.setState({ this.setState({
@ -677,7 +675,7 @@ class FlowItemRow extends PureComponent {
this.color_picker, this.color_picker,
parseInt(this.state.info.reply), parseInt(this.state.info.reply),
) )
.then((json) => { .then(({ data: json, cached }) => {
this.setState( this.setState(
(prev, props) => ({ (prev, props) => ({
replies: json.data, replies: json.data,
@ -695,6 +693,7 @@ class FlowItemRow extends PureComponent {
attention: !!json.attention, attention: !!json.attention,
reply_status: 'done', reply_status: 'done',
reply_error: null, reply_error: null,
cached,
}), }),
callback, callback,
); );
@ -740,11 +739,14 @@ class FlowItemRow extends PureComponent {
['pid', PID_RE], ['pid', PID_RE],
['nickname', NICKNAME_RE], ['nickname', NICKNAME_RE],
]; ];
if (this.props.search_param) if (this.props.search_param) {
hl_rules.push([ hl_rules.push([
'search', 'search',
build_highlight_re(this.props.search_param, ' ', 'gi'), !!this.props.search_param.match(/\/.+\//)
? build_highlight_re(this.props.search_param, ' ', 'gi', true) // Use regex
: build_highlight_re(this.props.search_param, ' ', 'gi'), // Don't use regex
]); ]);
}
let parts = split_text(this.state.info.text, hl_rules); let parts = split_text(this.state.info.text, hl_rules);
let quote_id = null; let quote_id = null;
@ -787,6 +789,7 @@ class FlowItemRow extends PureComponent {
color_picker={this.color_picker} color_picker={this.color_picker}
show_pid={show_pid} show_pid={show_pid}
replies={this.state.replies} replies={this.state.replies}
cached={this.state.cached}
fold={needFold} fold={needFold}
/> />
{!needFold && ( {!needFold && (
@ -826,6 +829,54 @@ class FlowItemRow extends PureComponent {
</div> </div>
); );
if (this.state.hidden) {
return (
<div
className="flow-item-row flow-item-row-with-prompt"
onClick={() => this.reveal()}
>
<div
className={
'flow-item' + (this.props.is_quote ? ' flow-item-quote' : '')
}
>
{!!this.props.is_quote && (
<div className="quote-tip black-outline">
<div>
<span className="icon icon-quote" />
</div>
<div>
<small>提到</small>
</div>
</div>
)}
<div className="box">
<div className="box-header">
{!!this.props.do_filter_name && (
<span
className="reply-header-badge clickable"
onClick={() => {
this.props.do_filter_name(DZ_NAME);
}}
>
<span className="icon icon-locate" />
</span>
)}
<code className="box-id">#{this.props.info.pid}</code>
&nbsp;
{this.props.info.tag !== null && (
<span className="box-header-tag">{this.props.info.tag}</span>
)}
<Time stamp={this.props.info.timestamp} />
<span className="box-header-badge">已隐藏</span>
<div style={{ clear: 'both' }} />
</div>
</div>
</div>
</div>
);
}
return !needFold && quote_id ? ( return !needFold && quote_id ? (
<div> <div>
{res} {res}
@ -1061,12 +1112,38 @@ export class Flow extends PureComponent {
}) })
.catch(failed); .catch(failed);
} else if (this.state.mode === 'attention') { } else if (this.state.mode === 'attention') {
let use_search = !!this.state.search_param;
let use_regex = use_search && !!this.state.search_param.match(/\/.+\//);
let regex_search = /.+/;
if (use_regex) {
try {
regex_search = new RegExp(this.state.search_param.slice(1, -1));
} catch (e) {
alert(`请检查正则表达式合法性!\n${e}`);
regex_search = /.+/;
}
}
console.log(use_search, use_regex);
API.get_attention(this.props.token) API.get_attention(this.props.token)
.then((json) => { .then((json) => {
this.setState({ this.setState({
chunks: { chunks: {
title: 'Attention List', title: `${
data: json.data, use_search
? use_regex
? `Result for RegEx ${regex_search.toString()} in `
: `Result for "${this.state.search_param}" in `
: ''
}Attention List`,
data: !use_search
? json.data
: !use_regex
? json.data.filter((post) => {
return this.state.search_param
.split(' ')
.every((keyword) => post.text.includes(keyword));
}) // Not using regex
: json.data.filter((post) => !!post.text.match(regex_search)), // Using regex
}, },
mode: 'attention_finished', mode: 'attention_finished',
loading_status: 'done', loading_status: 'done',

6
src/Message.js

@ -1,4 +1,4 @@
import React, { Component, PureComponent } from 'react'; import React, { 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';
@ -65,10 +65,10 @@ export class MessageViewer extends PureComponent {
); );
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" key={msg.timestamp}>
<div className="box-header"> <div className="box-header">
<Time stamp={msg.timestamp} short={false} /> <Time stamp={msg.timestamp} short={false} />
&nbsp; <b>{msg.title}</b> <b>{msg.title}</b>
</div> </div>
<div className="box-content"> <div className="box-content">
<pre>{msg.content}</pre> <pre>{msg.content}</pre>

44
src/Sidebar.css

@ -31,14 +31,21 @@
user-select: text; user-select: text;
position: fixed; position: fixed;
top: 0; top: 0;
/* think twice before you use 100vh
https://dev.to/peiche/100vh-behavior-on-chrome-2hm8
*/
height: 100%; height: 100%;
background-color: rgba(255,255,255,.7); background-color: rgba(255,255,255,.7);
overflow-y: auto; overflow-y: auto;
padding-top: 3em; padding-top: 3em;
padding-bottom: 1em; /* padding-bottom: 1em; */ /* move to sidebar-content */
backdrop-filter: blur(5px); backdrop-filter: blur(5px);
} }
.sidebar-content {
backdrop-filter: blur(0px); /* fix scroll performance issues */
}
.root-dark-mode .sidebar { .root-dark-mode .sidebar {
background-color: hsla(0,0%,5%,.4); background-color: hsla(0,0%,5%,.4);
} }
@ -118,15 +125,27 @@
pointer-events: initial; pointer-events: initial;
} }
.sidebar, .sidebar-title {
/* move all padding to sidebar-content - the scrolling div (overflow-y: auto) */
/* .sidebar, */
.sidebar-content,
.sidebar-title {
padding-left: 1em; padding-left: 1em;
padding-right: 1em; padding-right: 1em;
} }
.sidebar-content {
padding-bottom: 1em;
}
@media screen and (max-width: 1300px) { @media screen and (max-width: 1300px) {
.sidebar, .sidebar-title { .sidebar, .sidebar-title {
left: calc(100% - 550px); left: calc(100% - 550px);
width: 550px; width: 550px;/*
padding-left: .5em;
padding-right: .5em; */
}
.sidebar-content, .sidebar-title {
padding-left: .5em; padding-left: .5em;
padding-right: .5em; padding-right: .5em;
} }
@ -135,6 +154,10 @@
.sidebar, .sidebar-title { .sidebar, .sidebar-title {
left: 27px; left: 27px;
width: calc(100% - 27px); width: calc(100% - 27px);
/* padding-left: .25em;
padding-right: .25em; */
}
.sidebar-content, .sidebar-title {
padding-left: .25em; padding-left: .25em;
padding-right: .25em; padding-right: .25em;
} }
@ -142,8 +165,19 @@
.sidebar-flow-item { .sidebar-flow-item {
display: block; display: block;
/*overflow-x: hidden;*/
} }
.sidebar-flow-item .box { .sidebar-flow-item .box {
width: 100%; width: 100%;
} }
.sidebar-content-show {
height: 100%;
overflow-y: auto;
}
.sidebar-content-hide{
/* will make lazyload working correctly */
height: 0;
padding: 0;
overflow-y: scroll;
}

36
src/Sidebar.js

@ -1,21 +1,14 @@
import React, { Component, PureComponent } from 'react'; import React, { 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) {
if (this.props.stack !== nextProps.stack) {
//console.log('sidebar top');
if (this.sidebar_ref.current) this.sidebar_ref.current.scrollTop = 0;
}
}
do_close() { do_close() {
this.props.show_sidebar(null, null, 'clear'); this.props.show_sidebar(null, null, 'clear');
} }
@ -24,9 +17,24 @@ export class Sidebar extends PureComponent {
} }
render() { render() {
let [cur_title, cur_content] = this.props.stack[ // hide old contents to remember state
this.props.stack.length - 1 let contents = this.props.stack.map(
]; ({ 1: content }, i) =>
content && (
<div
key={i}
className={
'sidebar-content ' +
(i === this.props.stack.length - 1
? 'sidebar-content-show'
: 'sidebar-content-hide')
}
>
{content}
</div>
),
);
let cur_title = this.props.stack[this.props.stack.length - 1][0];
return ( return (
<div <div
className={ className={
@ -42,9 +50,7 @@ export class Sidebar extends PureComponent {
e.target.click(); e.target.click();
}} }}
/> />
<div ref={this.sidebar_ref} className="sidebar"> <div className="sidebar">{contents}</div>
{cur_content}
</div>
<div className="sidebar-title"> <div className="sidebar-title">
<a className="no-underline" onClick={this.do_close_bound}> <a className="no-underline" onClick={this.do_close_bound}>
&nbsp; &nbsp;

12
src/Title.js

@ -1,4 +1,4 @@
import React, { Component, PureComponent } from 'react'; import React, { 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';
@ -67,7 +67,11 @@ class ControlBar extends PureComponent {
return; return;
} }
const mode = this.state.search_text.startsWith('#') ? 'single' : 'search'; const mode = this.state.search_text.startsWith('#')
? 'single'
: this.props.mode !== 'attention'
? 'search'
: 'attention';
this.set_mode(mode, this.state.search_text || ''); this.set_mode(mode, this.state.search_text || '');
} }
} }
@ -112,7 +116,9 @@ class ControlBar extends PureComponent {
<input <input
className="control-search" className="control-search"
value={this.state.search_text} value={this.state.search_text}
placeholder="搜索 或 #树洞号" placeholder={`${
this.props.mode === 'attention' ? '在关注列表中' : ''
}搜索 #PID`}
onChange={this.on_change_bound} onChange={this.on_change_bound}
onKeyPress={this.on_keypress_bound} onKeyPress={this.on_keypress_bound}
/> />

2
src/UserAction.js

@ -1,4 +1,4 @@
import React, { Component, PureComponent } from 'react'; import React, { Component } from 'react';
import { import {
API_BASE, API_BASE,
SafeTextarea, SafeTextarea,

3
src/cache.js

@ -114,6 +114,7 @@ class Cache {
data_str: this.encrypt(pid, data), data_str: this.encrypt(pid, data),
last_access: +new Date(), last_access: +new Date(),
}); });
console.log('comment cache put', pid);
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);
}); });
@ -126,7 +127,7 @@ class Cache {
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();

206
src/flows_api.js

@ -13,127 +13,98 @@ export { get_json };
const SEARCH_PAGESIZE = 50; const SEARCH_PAGESIZE = 50;
export const API = { const handle_response = async (response, notify = false) => {
load_replies: (pid, token, color_picker, cache_version) => { let json = await get_json(response);
pid = parseInt(pid); if (json.code !== 0) {
return fetch( if (json.msg) {
API_BASE + if (notify) alert(json.msg);
'/api.php?action=getcomment' + else throw new Error(json.msg);
'&pid=' + } else throw new Error(JSON.stringify(json));
pid + }
token_param(token), return json;
) };
.then(get_json)
.then((json) => {
if (json.code !== 0) {
if (json.msg) throw new Error(json.msg);
else throw new Error(JSON.stringify(json));
}
cache()
.delete(pid)
.then(() => {
cache().put(pid, cache_version, json);
});
// also change load_replies_with_cache! const parse_replies = (replies, color_picker) =>
json.data = json.data replies
.sort((a, b) => { .sort((a, b) => parseInt(a.cid, 10) - parseInt(b.cid, 10))
return parseInt(a.cid, 10) - parseInt(b.cid, 10); .map((info) => {
}) info._display_color = color_picker.get(info.name);
.map((info) => { info.variant = {};
info._display_color = color_picker.get(info.name); return info;
info.variant = {}; });
return info;
});
return json; export const API = {
}); load_replies: async (pid, token, color_picker, cache_version) => {
pid = parseInt(pid);
let response = await fetch(
API_BASE + '/api.php?action=getcomment&pid=' + pid + token_param(token),
);
let json = await handle_response(response);
// Why delete then put ??
cache().put(pid, cache_version, json);
json.data = parse_replies(json.data, color_picker);
return json;
}, },
load_replies_with_cache: (pid, token, color_picker, cache_version) => { load_replies_with_cache: async (pid, token, color_picker, cache_version) => {
pid = parseInt(pid); pid = parseInt(pid);
return cache() let json = await cache().get(pid, cache_version);
.get(pid, cache_version) if (json) {
.then((json) => { json.data = parse_replies(json.data, color_picker);
if (json) { return { data: json, cached: true };
// also change load_replies! } else {
json.data = json.data json = await API.load_replies(pid, token, color_picker, cache_version);
.sort((a, b) => { return { data: json, cached: !json };
return parseInt(a.cid, 10) - parseInt(b.cid, 10); }
})
.map((info) => {
info._display_color = color_picker.get(info.name);
info.variant = {};
return info;
});
return json;
} else return API.load_replies(pid, token, color_picker, cache_version);
});
}, },
set_attention: (pid, attention, token) => { set_attention: async (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), { let response = await fetch(
method: 'POST', API_BASE + '/api.php?action=attention' + token_param(token),
headers: { {
'Content-Type': 'application/x-www-form-urlencoded', method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: data,
}, },
body: data, );
}) // Delete cache to update `attention` on next reload
.then(get_json) cache().delete(pid);
.then((json) => { return handle_response(response, true);
cache().delete(pid);
if (json.code !== 0) {
if (json.msg && json.msg === '已经关注过了') {
} else {
if (json.msg) alert(json.msg);
throw new Error(JSON.stringify(json));
}
}
return json;
});
}, },
report: (pid, reason, token) => { report: async (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), { let response = await fetch(
method: 'POST', API_BASE + '/api.php?action=report' + token_param(token),
headers: { {
'Content-Type': 'application/x-www-form-urlencoded', method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: data,
}, },
body: data, );
}) return handle_response(response, true);
.then(get_json)
.then((json) => {
if (json.code !== 0) {
if (json.msg) alert(json.msg);
throw new Error(JSON.stringify(json));
}
return json;
});
}, },
get_list: (page, token) => { get_list: async (page, token) => {
return fetch( let response = await fetch(
API_BASE + '/api.php?action=getlist' + '&p=' + page + token_param(token), API_BASE + '/api.php?action=getlist' + '&p=' + page + token_param(token),
) );
.then(get_json) return handle_response(response);
.then((json) => {
if (json.code !== 0) throw new Error(JSON.stringify(json));
return json;
});
}, },
get_search: (page, keyword, token) => { get_search: async (page, keyword, token) => {
return fetch( let response = await fetch(
API_BASE + API_BASE +
'/api.php?action=search' + '/api.php?action=search' +
'&pagesize=' + '&pagesize=' +
@ -143,40 +114,21 @@ export const API = {
'&keywords=' + '&keywords=' +
encodeURIComponent(keyword) + encodeURIComponent(keyword) +
token_param(token), token_param(token),
) );
.then(get_json) return handle_response(response);
.then((json) => {
if (json.code !== 0) {
if (json.msg) throw new Error(json.msg);
throw new Error(JSON.stringify(json));
}
return json;
});
}, },
get_single: (pid, token) => { get_single: async (pid, token) => {
return fetch( let response = await fetch(
API_BASE + '/api.php?action=getone' + '&pid=' + pid + token_param(token), API_BASE + '/api.php?action=getone' + '&pid=' + pid + token_param(token),
) );
.then(get_json) return handle_response(response);
.then((json) => {
if (json.code !== 0) {
if (json.msg) throw new Error(json.msg);
else throw new Error(JSON.stringify(json));
}
return json;
});
}, },
get_attention: (token) => { get_attention: async (token) => {
return fetch(API_BASE + '/api.php?action=getattention' + token_param(token)) let response = await fetch(
.then(get_json) API_BASE + '/api.php?action=getattention' + token_param(token),
.then((json) => { );
if (json.code !== 0) { return handle_response(response);
if (json.msg) throw new Error(json.msg);
throw new Error(JSON.stringify(json));
}
return json;
});
}, },
}; };

2
src/infrastructure

@ -1 +1 @@
Subproject commit 43b48087b181beef7b6d69163914d6ca799a12b4 Subproject commit e3c39ff4e3efaad45e16796d9eb511345d4cbcf4
Loading…
Cancel
Save