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, TAG_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'; import { cache } from './cache'; import { save_attentions } from './Attention' /* 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 {info, color_picker, show_pid, do_filter_name, do_delete} = this.props; const author = info.name, replyText = info.text; return (
{!!do_filter_name && ( { do_filter_name(info.name); }} > )}   {( {info.name} )} {!!do_delete && !!info.can_del && ( { do_delete('cid', info.cid); }} > )}  
); } } class FlowItem extends PureComponent { constructor(props) { super(props); this.state = { hot_score: props.info.hot_score || 0, cw: props.info.cw || '', } } 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'), ); } on_hot_score_change(event) { this.setState({ hot_score: event.target.value }); } on_cw_change(event) { this.setState({ cw: event.target.value, }); } render() { const { info, is_quote, cached, attention, can_del, do_filter_name, do_delete, do_edit_cw, do_edit_score, timestamp, img_clickable, color_picker, show_pid } = this.props; const { cw, hot_score } = this.state; return (
{!!is_quote && (
{/*
*/} {/* 提到*/} {/*
*/}
)}
{!!window.LATEST_POST_ID && parseInt(info.pid, 10) > window.LATEST_POST_ID && (
)} {!!attention && !cached && (
)}
{!!do_filter_name && ( { do_filter_name(DZ_NAME); }} > )} {!!parseInt(info.likenum, 10) && ( {info.likenum}  )} {!!parseInt(info.reply, 10) && ( {info.reply}  )} #{info.pid} {!!do_delete && !!info.can_del && ( { do_delete('pid', info.pid); }} > )}   {info.cw !== null && (!do_edit_cw || !info.can_del) && ( {info.cw} )} { !!do_edit_cw && !!info.can_del && (
) } { info.allow_search && 📢 }
{info.hot_score !== undefined && (do_edit_score ? ( <> ) : ( hot score: {info.hot_score} ))}
{info.type === 'image' && (

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

)} {/*{info.type==='audio' && }*/}
{!!(attention && 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, likenum: ''+json.likenum, }) : 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 prev_info = this.state.info; const pid = prev_info.pid; API.set_attention(pid, !this.state.attention, this.props.token) .then((json) => { this.setState({ loading_status: 'done', attention: json.attention, info: Object.assign({}, prev_info, { likenum: ''+json.likenum, }), }); console.log(json); let saved_attentions = window.saved_attentions; if (json.attention && !saved_attentions.includes(pid)) { saved_attentions.unshift(pid) } else if (!json.attention && saved_attentions.includes(pid)) { const idx = saved_attentions.indexOf(pid); saved_attentions.splice(idx, 1) } window.saved_attentions = saved_attentions; save_attentions(); this.syncState({ attention: json.attention, info: Object.assign({}, prev_info, { likenum: ''+json.likenum, }), }); }) .catch((e) => { this.setState({ loading_status: 'done', }); alert('设置关注失败\n' + e); 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); } } } make_do_delete(token, on_complete=null) { const do_delete = (type, id) => { console.log('del', type, id, token); let note = prompt(`将删除${type}=${id}, 备注:`, '(无)'); if (note !== null) { API.del(type, id, note, token) .then((json) => { alert('删除成功'); on_complete(); }) .catch((e) => { alert('删除失败\n' + e); console.error(e); }); } } return do_delete; } make_do_edit_cw(token) { const do_edit_cw = (cw, id) => { console.log('edit cw', cw); API.update_cw(cw, id, token) .then((json) => { alert('已更新\n刷新列表显示新版本'); }) .catch((e) => { alert('更新失败\n' + e); console.error(e); }); } return do_edit_cw; } make_do_edit_score(token) { const do_edit_score = (score, id) => { console.log('edit score', score); API.update_score(score, id, token) .then((json) => { console.log('已更新'); }) .catch((e) => { alert('更新失败\n' + e); console.error(e); }); } return do_edit_score; } 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 } do_delete={this.make_do_delete(this.props.token, ()=>{window.location.reload();})} do_edit_cw={this.make_do_edit_cw(this.props.token)} do_edit_score={this.make_do_edit_score(this.props.token)} /> ); 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 } do_delete={this.make_do_delete(this.props.token, this.load_replies.bind(this))} /> ))} {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 && (window.config.whitelist_cw.indexOf('*')==-1 && window.config.whitelist_cw.indexOf(props.info.cw)==-1) && 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_v2.some((word) => props.info.text.includes(word), ) && !props.info.can_del || this.needFold, attention: props.attention_override === null ? false : props.attention_override, cached: true, // default no display anything }; this.color_picker = new ColorPicker(); } componentDidMount() { // cache from getlist, so always to this to update attention if (true || 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 }) => { //console.log('>> update', json, json.attention); 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], ['tag', TAG_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); //console.log('hl:', parts,this.state.info.pid); 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 this.needFold && (
{ 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 = { submode: this.props.mode == 'list' ? (window.config.by_c ? 1 : 0) : 0, subflow_render_key: +new Date(), } } get_submode_names(mode) { switch(mode) { case('list'): return ['最新', '最近回复', '近期热门', '随机']; case('attention'): return ['线上', '本地'] } return [] } set_submode(submode) { this.setState({ submode: submode, subflow_render_key: +new Date(), }); } render() { const { submode } = this.state; const submode_names = this.get_submode_names(this.props.mode) return ( <>
{submode_names.map((name, idx) => ( {name} ))}
) } } class SubFlow extends PureComponent { constructor(props) { super(props); this.state = { mode: props.mode, search_param: props.search_text, loaded_pages: 0, chunks: { title: '', data: [], }, export_text: '', 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); cache(); if (this.state.mode === 'list') { API.get_list(page, this.props.token, this.props.submode) .then((json) => { if (page === 1 && json.data.length) { // update latest_post_id let max_id = window.LATEST_POST_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; } json.data.forEach((x) => { if (x.comments) { let comment_json = { code: 0, attention: x.attention, data: x.comments, }; //console.log('My cache', comment_json, x.pid, x.reply) cache().put(x.pid, parseInt(x.reply, 10), comment_json); } }); 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' && this.state.search_param) { 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) => { let x = json.data; if (x.comments) { let comment_json = { code: 0, attention: x.attention, data: x.comments, }; //console.log('My cache', comment_json, x.pid, x.reply) cache().put(x.pid, parseInt(x.reply, 10), comment_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); if (this.props.submode === 0) { 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', }); if (!use_search) { window.saved_attentions = Array.from( new Set([ ...window.saved_attentions, ...json.data.map(post => post.pid) ]) ).sort().reverse(); save_attentions(); } }) .catch(failed); } else if (this.props.submode === 1) { this.setState({ title: 'Attention List: Local', data: [], export_text: `以下是浏览器本地保存的关注列表,将在下个版本提供直接展示\n\n#${ window.saved_attentions.join('\n#') }` }); } } 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); } trunc_string(s, max_len) { return s.substr(0, max_len) + ( s.length > max_len ? '...' : '' ) } gen_export() { this.setState({ can_export: false, export_text: "以下是你关注的洞及摘要,复制保存到本地吧。\n\n" + this.state.chunks.data.map( p => `#${p.pid}: ${ this.trunc_string(p.text.replaceAll('\n', ' '), 50) }`).join('\n\n') }); } render() { const should_deletion_detect = localStorage['DELETION_DETECT'] === 'on'; return (
{this.state.mode === 'attention_finished' && this.props.submode == 0 && ( )} {this.state.export_text && (