import React, { PureComponent } from 'react'; import copy from 'copy-to-clipboard'; import { ColorPicker } from './color_picker'; import { split_text, NICKNAME_RE, PID_RE, URL_RE, URL_PID_RE, } from './text_splitter'; import { format_time, build_highlight_re, Time, TitleLine, ClickHandler, ColoredSpan, HighlightedMarkdown, } from './Common'; import './Flows.css'; import LazyLoad, { forceCheck } from './react-lazyload/src'; import { TokenCtx, ReplyForm } from './UserAction'; import { API } from './flows_api'; const IMAGE_BASE = 'https://thimg.yecdn.com/'; const IMAGE_BAK_BASE = 'https://img2.thuhole.com/'; const CLICKABLE_TAGS = { a: true, audio: true }; const PREVIEW_REPLY_COUNT = 10; // const QUOTE_BLACKLIST=['23333','233333','66666','666666','10086','10000','100000','99999','999999','55555','555555']; const QUOTE_BLACKLIST = []; window.LATEST_POST_ID = parseInt(localStorage['_LATEST_POST_ID'], 10) || 0; const DZ_NAME = '洞主'; function load_single_meta(show_sidebar, token) { return async (pid, replace = false) => { let color_picker = new ColorPicker(); let title_elem = '树洞 #' + pid; show_sidebar( title_elem,
正在加载 #{pid}
, replace ? 'replace' : 'push', ); try { let single = await API.get_single(pid, token); single.data.variant = {}; let { data: replies } = await API.load_replies_with_cache( pid, token, color_picker, parseInt(single.data.reply), ); show_sidebar( title_elem, , 'replace', ); } catch (e) { console.error(e); show_sidebar( title_elem,

load_single_meta(show_sidebar, token)(pid, true)}> 重新加载

{'' + e}

, 'replace', ); } }; } class Reply extends PureComponent { constructor(props) { super(props); } render() { const author = this.props.info.name, replyText = this.props.info.text; return (
{!!this.props.do_filter_name && ( { this.props.do_filter_name(this.props.info.name); }} > )}   {( {this.props.info.name} )}
); } } class FlowItem extends PureComponent { constructor(props) { super(props); } copy_link(event) { event.preventDefault(); copy( `${event.target.href}${ this.props.info.cw ? ' 【' + this.props.info.cw + '】' : '' }\n` + `${this.props.info.text}${ this.props.info.type === 'image' ? ' [图片]' : this.props.info.type === 'audio' ? ' [语音]' : '' }\n` + `(${format_time(new Date(this.props.info.timestamp * 1000))} ${ this.props.info.likenum }关注 ${this.props.info.reply}回复)\n` + this.props.replies .map((r) => (r.cw ? '【' + r.cw + '】' : '') + r.text) .join('\n'), ); } render() { let props = this.props; return (
{!!props.is_quote && (
{/*
*/} {/* 提到*/} {/*
*/}
)}
{!!window.LATEST_POST_ID && parseInt(props.info.pid, 10) > window.LATEST_POST_ID && (
)} {!!this.props.attention && !this.props.cached && (
)}
{!!this.props.do_filter_name && ( { this.props.do_filter_name(DZ_NAME); }} > )} {!!parseInt(props.info.likenum, 10) && ( {props.info.likenum}  )} {!!parseInt(props.info.reply, 10) && ( {props.info.reply}  )} #{props.info.pid}   {props.info.cw !== null && ( {props.info.cw} )}
{props.info.type === 'image' && (

{props.img_clickable ? ( { if (e.target.src === IMAGE_BASE + props.info.url) { e.target.src = IMAGE_BAK_BASE + props.info.url; } }} alt={IMAGE_BASE + props.info.url} /> ) : ( { if (e.target.src === IMAGE_BASE + props.info.url) { e.target.src = IMAGE_BAK_BASE + props.info.url; } }} alt={IMAGE_BASE + props.info.url} /> )}

)} {/*{props.info.type==='audio' && }*/}
{!!(props.attention && props.info.variant.latest_reply) && (

最新回复{' '}

)}
); } } class FlowSidebar extends PureComponent { constructor(props) { super(props); this.state = { attention: props.attention, info: props.info, replies: props.replies, loading_status: 'done', error_msg: null, filter_name: null, rev: false, }; this.color_picker = props.color_picker; this.syncState = props.sync_state || (() => {}); this.reply_ref = React.createRef(); } set_variant(cid, variant) { this.setState( (prev) => { if (cid) return { replies: prev.replies.map((reply) => { if (reply.cid === cid) return Object.assign({}, reply, { variant: Object.assign({}, reply.variant, variant), }); else return reply; }), }; else return { info: Object.assign({}, prev.info, { variant: Object.assign({}, prev.info.variant, variant), }), }; }, function () { this.syncState({ info: this.state.info, replies: this.state.replies, }); }, ); } load_replies(update_count = true) { this.setState({ loading_status: 'loading', error_msg: null, }); API.load_replies( this.state.info.pid, this.props.token, this.color_picker, null, ) .then((json) => { this.setState( (prev, props) => ({ replies: json.data, info: update_count ? Object.assign({}, prev.info, { reply: '' + json.data.length, }) : prev.info, attention: !!json.attention, loading_status: 'done', error_msg: null, }), () => { this.syncState({ replies: this.state.replies, attention: this.state.attention, info: this.state.info, }); if (this.state.replies.length) this.set_variant(null, { latest_reply: Math.max.apply( null, this.state.replies.map((r) => parseInt(r.timestamp)), ), }); }, ); }) .catch((e) => { console.error(e); this.setState({ replies: [], loading_status: 'done', error_msg: '' + e, }); }); } toggle_attention() { this.setState({ loading_status: 'loading', }); const next_attention = !this.state.attention; API.set_attention(this.state.info.pid, next_attention, this.props.token) .then((json) => { this.setState({ loading_status: 'done', attention: next_attention, }); this.syncState({ attention: next_attention, }); }) .catch((e) => { this.setState({ loading_status: 'done', }); alert('设置关注失败'); console.error(e); }); } report() { let reason = prompt(`举报 #${this.state.info.pid} 的理由:`); if (reason !== null) { API.report(this.state.info.pid, reason, this.props.token) .then((json) => { alert('举报成功'); }) .catch((e) => { alert('举报失败'); console.error(e); }); } } set_filter_name(name) { this.setState((prevState) => ({ filter_name: name === prevState.filter_name ? null : name, })); } toggle_rev() { this.setState((prevState) => ({ rev: !prevState.rev }), forceCheck); } show_reply_bar(name, event) { if (this.reply_ref.current && !event.target.closest('a, .clickable')) { let text = this.reply_ref.current.get(); if ( /^\s*(?:Re (?:|洞主|(?:[A-Z][a-z]+ )?(?:[A-Z][a-z]+)|You Win(?: \d+)?):)?\s*$/.test( text, ) ) { // text is nearly empty so we can replace it let should_text = 'Re ' + name + ': '; if (should_text === this.reply_ref.current.get()) this.reply_ref.current.set(''); else this.reply_ref.current.set(should_text); } } } render() { if (this.state.loading_status === 'loading') return

加载中……

; let show_pid = load_single_meta(this.props.show_sidebar, this.props.token); let replies_to_show = this.state.filter_name ? this.state.replies.filter((r) => r.name === this.state.filter_name) : this.state.replies.slice(); if (this.state.rev) replies_to_show.reverse(); // may not need key, for performance // key for lazyload elem // let view_mode_key = // (this.state.rev ? 'y-' : 'n-') + (this.state.filter_name || 'null'); let replies_cnt = { [DZ_NAME]: 1 }; replies_to_show.forEach((r) => { if (replies_cnt[r.name] === undefined) replies_cnt[r.name] = 0; replies_cnt[r.name]++; }); // hide main thread when filtered let main_thread_elem = this.state.filter_name && this.state.filter_name !== DZ_NAME ? null : ( { this.show_reply_bar('', e); }} > { this.set_variant(null, variant); }} do_filter_name={ replies_cnt[DZ_NAME] > 1 ? this.set_filter_name.bind(this) : null } /> ); return (
{!!this.props.token && (    )} {(this.state.replies.length >= 1 || this.state.rev) && (    )} {!!this.props.token && (    { this.toggle_attention(); }} > {this.state.attention ? ( ) : ( )} )}
{!!this.state.filter_name && (

{ this.set_filter_name(null); }} > 还原  当前只看  {this.state.filter_name}

)} {!this.state.rev && main_thread_elem} {!!this.state.error_msg && (

回复加载失败

{this.state.error_msg}

)} {this.props.deletion_detect && parseInt(this.state.info.reply) > this.state.replies.length && !!this.state.replies.length && (
{parseInt(this.state.info.reply) - this.state.replies.length}{' '} 条回复被删除
)} {replies_to_show.map((reply, i) => ( { this.show_reply_bar(reply.name, e); }} > { this.set_variant(reply.cid, variant); }} do_filter_name={ replies_cnt[reply.name] > 1 ? this.set_filter_name.bind(this) : null } /> ))} {this.state.rev && main_thread_elem} {this.props.token ? ( ) : (
登录后可以回复树洞
)}
); } } class FlowItemRow extends PureComponent { constructor(props) { super(props); this.needFold = props.info.cw && (props.search_param === '热榜' || !props.search_param) && window.config.fold && props.mode !== 'attention' && props.mode !== 'attention_finished'; this.state = { replies: [], reply_status: 'done', reply_error: null, info: Object.assign({}, props.info, { variant: {} }), hidden: window.config.block_words.some((word) => props.info.text.includes(word), ) || this.needFold, attention: props.attention_override === null ? false : props.attention_override, cached: true, // default no display anything }; this.color_picker = new ColorPicker(); } componentDidMount() { if (parseInt(this.state.info.reply, 10)) { this.load_replies(null, /*update_count=*/ false); } } // reveal() { // this.setState({ hidden: false }); // } load_replies(callback, update_count = true) { console.log('fetching reply', this.state.info.pid); this.setState({ reply_status: 'loading', reply_error: null, }); API.load_replies_with_cache( this.state.info.pid, this.props.token, this.color_picker, parseInt(this.state.info.reply), ) .then(({ data: json, cached }) => { this.setState( (prev, props) => ({ replies: json.data, info: Object.assign({}, prev.info, { reply: update_count ? '' + json.data.length : prev.info.reply, variant: json.data.length ? { latest_reply: Math.max.apply( null, json.data.map((r) => parseInt(r.timestamp)), ), } : {}, }), attention: !!json.attention, reply_status: 'done', reply_error: null, cached, }), callback, ); }) .catch((e) => { console.error(e); this.setState( { replies: [], reply_status: 'failed', reply_error: '' + e, }, callback, ); }); } show_sidebar() { this.props.show_sidebar( '树洞 #' + this.state.info.pid, , ); } render() { let show_pid = load_single_meta(this.props.show_sidebar, this.props.token, [ this.state.info.pid, ]); let hl_rules = [ ['url_pid', URL_PID_RE], ['url', URL_RE], ['pid', PID_RE], ['nickname', NICKNAME_RE], ]; if (this.props.search_param) { hl_rules.push([ 'search', !!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 quote_id = null; if (!this.props.is_quote) for (let [mode, content] of parts) { content = content.length > 0 ? content.substring(1) : content; if ( mode === 'pid' && QUOTE_BLACKLIST.indexOf(content) === -1 && parseInt(content) < parseInt(this.state.info.pid) ) if (quote_id === null) quote_id = parseInt(content); else { quote_id = null; break; } } let res = (
{ if (!CLICKABLE_TAGS[event.target.tagName.toLowerCase()]) this.show_sidebar(); }} >
{this.state.reply_status === 'loading' && (
加载中
)} {this.state.reply_status === 'failed' && ( )} {this.state.replies.slice(0, PREVIEW_REPLY_COUNT).map((reply) => ( ))} {this.state.replies.length > PREVIEW_REPLY_COUNT && (
还有 {this.state.replies.length - PREVIEW_REPLY_COUNT} 条
)}
); if (this.state.hidden) { return (
{ if (!CLICKABLE_TAGS[event.target.tagName.toLowerCase()]) this.show_sidebar(); }} >
{!!this.props.is_quote && (
{/*
*/} {/* 提到*/} {/*
*/}
)}
{!!this.props.do_filter_name && ( { this.props.do_filter_name(DZ_NAME); }} > )} #{this.props.info.pid}   {this.props.info.cw !== null && ( {this.props.info.cw} )}
); } return quote_id ? (
{res}
) : ( res ); } } class FlowItemQuote extends PureComponent { constructor(props) { super(props); this.state = { loading_status: 'empty', error_msg: null, info: null, }; } componentDidMount() { this.load(); } load() { this.setState( { loading_status: 'loading', }, () => { API.get_single(this.props.pid, this.props.token) .then((json) => { this.setState({ loading_status: 'done', info: json.data, }); }) .catch((err) => { if (('' + err).indexOf('没有这条树洞') !== -1) this.setState({ loading_status: 'empty', }); else this.setState({ loading_status: 'error', error_msg: '' + err, }); }); }, ); } render() { if (this.state.loading_status === 'empty') return null; else if (this.state.loading_status === 'loading') return (
提到了 #{this.props.pid}
); else if (this.state.loading_status === 'error') return (

重新加载

{this.state.error_msg}

); // 'done' else return ( ); } } function FlowChunk(props) { return ( {({ value: token }) => (
{!!props.title && } {props.list.map((info, ind) => (
{!!( props.deletion_detect && props.mode === 'list' && ind && props.list[ind - 1].pid - info.pid > 1 ) && (
{props.list[ind - 1].pid - info.pid - 1} 条被删除
)}
))}
)}
); } export class Flow extends PureComponent { constructor(props) { super(props); this.state = { mode: props.mode, search_param: props.search_text, loaded_pages: 0, chunks: { title: '', data: [], }, loading_status: 'done', error_msg: null, }; this.on_scroll_bound = this.on_scroll.bind(this); window.LATEST_POST_ID = parseInt(localStorage['_LATEST_POST_ID'], 10) || 0; } load_page(page) { const failed = (err) => { console.error(err); console.log(err.to_string); this.setState((prev, props) => ({ loaded_pages: prev.loaded_pages - 1, loading_status: 'failed', error_msg: prev.loaded_pages>1 ? '找不到更多了' : '' + err, })); }; if (page > this.state.loaded_pages + 1) throw new Error('bad page'); if (page === this.state.loaded_pages + 1) { console.log('fetching page', page); if (this.state.mode === 'list') { API.get_list(page, this.props.token) .then((json) => { if (page === 1 && json.data.length) { // update latest_post_id let max_id = -1; json.data.forEach((x) => { if (parseInt(x.pid, 10) > max_id) max_id = parseInt(x.pid, 10); }); localStorage['_LATEST_POST_ID'] = '' + max_id; } this.setState((prev, props) => ({ chunks: { title: 'News Feed', data: prev.chunks.data.concat( json.data.filter( (x) => prev.chunks.data.length === 0 || !prev.chunks.data .slice(-100) .some((p) => p.pid === x.pid), ), ), }, loading_status: 'done', })); }) .catch(failed); } else if (this.state.mode === 'search') { API.get_search(page, this.state.search_param, this.props.token) .then((json) => { const finished = json.data.length === 0; this.setState((prev, props) => ({ chunks: { title: 'Result for "' + this.state.search_param + '"', data: prev.chunks.data.concat( json.data.filter( (x) => prev.chunks.data.length === 0 || !prev.chunks.data .slice(-100) .some((p) => p.pid === x.pid), ), ), }, mode: finished ? 'search_finished' : 'search', loading_status: 'done', })); }) .catch(failed); } else if (this.state.mode === 'single') { const pid = parseInt(this.state.search_param.substr(1), 10); API.get_single(pid, this.props.token) .then((json) => { this.setState({ chunks: { title: 'PID = ' + pid, data: [json.data], }, mode: 'single_finished', loading_status: 'done', }); }) .catch(failed); } else if (this.state.mode === 'attention') { 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) .then((json) => { this.setState({ chunks: { title: `${ 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', loading_status: 'done', }); }) .catch(failed); } else { console.log('nothing to load'); return; } this.setState((prev, props) => ({ loaded_pages: prev.loaded_pages + 1, loading_status: 'loading', error_msg: null, })); } } on_scroll(event) { if (event.target === document) { const avail = document.body.scrollHeight - window.scrollY - window.innerHeight; if (avail < window.innerHeight && this.state.loading_status === 'done') this.load_page(this.state.loaded_pages + 1); } } componentDidMount() { this.load_page(1); window.addEventListener('scroll', this.on_scroll_bound); window.addEventListener('resize', this.on_scroll_bound); } componentWillUnmount() { window.removeEventListener('scroll', this.on_scroll_bound); window.removeEventListener('resize', this.on_scroll_bound); } render() { const should_deletion_detect = localStorage['DELETION_DETECT'] === 'on'; return (
{this.state.loading_status === 'failed' && ( )}  Loading... ) : ( '🄯 2020 copyleft: hole_thu' ) } />
); } }