1901 lines
57 KiB
JavaScript
1901 lines
57 KiB
JavaScript
import React, { PureComponent, useState } from 'react';
|
||
import copy from 'copy-to-clipboard';
|
||
import { ColorPicker } from './color_picker';
|
||
import { split_text, PID_RE } from './text_splitter';
|
||
import {
|
||
format_time,
|
||
Time,
|
||
TitleLine,
|
||
ClickHandler,
|
||
ColoredSpan,
|
||
HighlightedMarkdown,
|
||
get_push_subscription,
|
||
} from './Common';
|
||
import './Flows.css';
|
||
import LazyLoad, { forceCheck } from 'react-lazyload';
|
||
import { TokenCtx, ReplyForm } from './UserAction';
|
||
import { API, parse_replies } from './flows_api';
|
||
import { cache } from './cache';
|
||
import { save_attentions } from './Attention';
|
||
import Poll from 'react-polls';
|
||
|
||
/*
|
||
const IMAGE_BASE = 'https://thimg.yecdn.com/';
|
||
const IMAGE_BAK_BASE = 'https://img2.thuhole.com/';
|
||
*/
|
||
|
||
const CLICKABLE_TAGS = { a: true, audio: true, button: 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 get_search_text(post) {
|
||
return [post.text]
|
||
.concat((post.comments || []).map((comment) => comment.text))
|
||
.join('\n');
|
||
}
|
||
|
||
function check_block(info) {
|
||
return (
|
||
(((window.config.block_tmp || !window.config.show_all_rooms) &&
|
||
info.is_tmp) ||
|
||
window.config.block_words_v4.some((word) => info.text.includes(word)) ||
|
||
(info.cw &&
|
||
window.config.block_words_v4
|
||
.concat(window.config.block_cw)
|
||
.some((word) => info.cw.includes(word)))) &&
|
||
!info.can_del
|
||
);
|
||
}
|
||
|
||
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,
|
||
<div className="box box-tip">正在加载 #{pid}</div>,
|
||
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,
|
||
<FlowSidebar
|
||
key={+new Date()}
|
||
info={single.data}
|
||
replies={replies.data}
|
||
attention={replies.attention}
|
||
token={token}
|
||
show_sidebar={show_sidebar}
|
||
color_picker={color_picker}
|
||
deletion_detect={localStorage['DELETION_DETECT'] === 'on'}
|
||
/>,
|
||
'replace',
|
||
);
|
||
} catch (e) {
|
||
console.error(e);
|
||
show_sidebar(
|
||
title_elem,
|
||
<div className="box box-tip">
|
||
<p>
|
||
<a
|
||
href="###"
|
||
onClick={() => load_single_meta(show_sidebar, token)(pid, true)}
|
||
>
|
||
重新加载
|
||
</a>
|
||
</p>
|
||
<p>{'' + e}</p>
|
||
</div>,
|
||
'replace',
|
||
);
|
||
}
|
||
};
|
||
}
|
||
|
||
class Reply extends PureComponent {
|
||
render() {
|
||
const {
|
||
info,
|
||
color_picker,
|
||
show_pid,
|
||
do_filter_name,
|
||
do_delete,
|
||
do_report,
|
||
do_block,
|
||
search_param,
|
||
} = this.props;
|
||
const author = info.name,
|
||
replyText = info.text;
|
||
this.color_picker = new ColorPicker();
|
||
return (
|
||
!check_block(info) && (
|
||
<div
|
||
className={'flow-reply box'}
|
||
style={
|
||
info._display_color
|
||
? {
|
||
'--box-bgcolor-light': info._display_color[0],
|
||
'--box-bgcolor-dark': info._display_color[1],
|
||
}
|
||
: null
|
||
}
|
||
>
|
||
<div className="box-header">
|
||
{!!do_filter_name && (
|
||
<span
|
||
className="reply-header-badge clickable"
|
||
onClick={() => {
|
||
do_filter_name(info.name);
|
||
}}
|
||
>
|
||
<span className="icon icon-locate" />
|
||
</span>
|
||
)}
|
||
|
||
{<span className="box-header-name">{info.name}</span>}
|
||
{info.author_title && (
|
||
<span className="box-header-name author-title">{`"${info.author_title}"`}</span>
|
||
)}
|
||
{info.is_tmp && (
|
||
<span className="box-header-name tmp-title">临时账号</span>
|
||
)}
|
||
{!!do_delete && !!info.can_del && (
|
||
<span
|
||
className="clickable icon icon-delete op-btn"
|
||
onClick={() => {
|
||
do_delete('cid', info.cid);
|
||
}}
|
||
/>
|
||
)}
|
||
{!!do_block && (
|
||
<span
|
||
className="clickable icon icon-block op-btn"
|
||
onClick={do_block}
|
||
/>
|
||
)}
|
||
{!!do_report && (
|
||
<span
|
||
className="clickable icon icon-flag op-btn"
|
||
onClick={do_report}
|
||
/>
|
||
)}
|
||
{info.blocked_count && (
|
||
<span className="danger-info"> {info.blocked_count} </span>
|
||
)}
|
||
<Time stamp={info.timestamp} short={!do_report} />
|
||
|
||
</div>
|
||
<div className="box-content">
|
||
<HighlightedMarkdown
|
||
author={author}
|
||
text={replyText}
|
||
color_picker={color_picker}
|
||
show_pid={show_pid}
|
||
search_param={search_param}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
);
|
||
}
|
||
}
|
||
|
||
class FlowItem extends PureComponent {
|
||
constructor(props) {
|
||
super(props);
|
||
this.state = {
|
||
cw: props.info.cw || '',
|
||
};
|
||
}
|
||
|
||
copy_link(event) {
|
||
event.preventDefault();
|
||
copy(
|
||
`${event.target.href}${
|
||
this.props.info.cw ? ' 【' + this.props.info.cw + '】' : ''
|
||
}\n\n${this.props.info.text}\n` +
|
||
`(${format_time(new Date(this.props.info.timestamp * 1000))} ${
|
||
this.props.info.likenum
|
||
}关注 ${this.props.info.reply}回复)\n\n` +
|
||
this.props.replies.map((r) => `【${r.name}】\n${r.text}\n`).join('\n'),
|
||
);
|
||
}
|
||
|
||
on_cw_change(event) {
|
||
this.setState({
|
||
cw: event.target.value,
|
||
});
|
||
}
|
||
|
||
render() {
|
||
const {
|
||
info,
|
||
is_quote,
|
||
cached,
|
||
attention,
|
||
do_filter_name,
|
||
do_delete,
|
||
do_edit_cw,
|
||
img_clickable,
|
||
color_picker,
|
||
show_pid,
|
||
do_vote,
|
||
do_block,
|
||
do_react,
|
||
search_param,
|
||
} = this.props;
|
||
const { cw } = this.state;
|
||
const _display_color = color_picker.get(DZ_NAME, info.is_tmp);
|
||
return (
|
||
<div className={'flow-item' + (is_quote ? ' flow-item-quote' : '')}>
|
||
{!!is_quote && (
|
||
<div className="quote-tip black-outline">
|
||
<div>
|
||
<span className="icon icon-quote" />
|
||
</div>
|
||
{/*<div>*/}
|
||
{/* <small>提到</small>*/}
|
||
{/*</div>*/}
|
||
</div>
|
||
)}
|
||
<div
|
||
className="box box-post"
|
||
style={{
|
||
'--box-bgcolor-light': _display_color[0],
|
||
'--box-bgcolor-dark': _display_color[1],
|
||
}}
|
||
>
|
||
<div className="box-post-main">
|
||
{!!window.LATEST_POST_ID &&
|
||
parseInt(info.pid, 10) > window.LATEST_POST_ID && (
|
||
<div className="flow-item-dot" />
|
||
)}
|
||
{!!attention && !cached && <div className="flow-item-dot" />}
|
||
<div className="box-header">
|
||
{!!do_filter_name && (
|
||
<span
|
||
className="reply-header-badge clickable"
|
||
onClick={() => {
|
||
do_filter_name(DZ_NAME);
|
||
}}
|
||
>
|
||
<span className="icon icon-locate" />
|
||
</span>
|
||
)}
|
||
{!do_react && (
|
||
<span className="box-header-badge">
|
||
<span className="icon icon-up-and-down" />
|
||
<span style={{ verticalAlign: 'super' }}>
|
||
{info.up_votes - info.down_votes}
|
||
</span>
|
||
</span>
|
||
)}
|
||
{!!parseInt(info.likenum, 10) && (
|
||
<span className="box-header-badge">
|
||
{info.likenum}
|
||
<span
|
||
className={'icon icon-' + (attention ? 'star-ok' : 'star')}
|
||
/>
|
||
</span>
|
||
)}
|
||
{!!parseInt(info.reply, 10) && (
|
||
<span className="box-header-badge">
|
||
{info.reply}
|
||
<span className="icon icon-comment" />
|
||
</span>
|
||
)}
|
||
<code className="box-id">
|
||
<a href={'##' + info.pid} onClick={this.copy_link.bind(this)}>
|
||
#{info.pid}
|
||
{info.room_id !== parseInt(window.config.room) && (
|
||
<> @{info.room_id}</>
|
||
)}
|
||
</a>
|
||
</code>
|
||
|
||
{info.author_title && (
|
||
<span className="box-header-name author-title">{`"${info.author_title}"`}</span>
|
||
)}
|
||
{info.is_tmp && (
|
||
<span className="box-header-name tmp-title">临时账号</span>
|
||
)}
|
||
{info.is_reported && <span className="danger-info"> R </span>}
|
||
{!!do_delete && !!info.can_del && (
|
||
<span
|
||
className="clickable icon icon-delete op-btn"
|
||
onClick={() => {
|
||
do_delete('pid', info.pid);
|
||
}}
|
||
/>
|
||
)}
|
||
{!!do_block && (
|
||
<span
|
||
className="clickable icon icon-block op-btn"
|
||
onClick={do_block}
|
||
/>
|
||
)}
|
||
{info.dangerous_user && (
|
||
<span className="danger-info"> {info.dangerous_user} </span>
|
||
)}
|
||
{info.blocked_count && (
|
||
<span className="danger-info"> {info.blocked_count} </span>
|
||
)}
|
||
{info.cw !== null && (!do_edit_cw || !info.can_del) && (
|
||
<span className="box-header-cw">{info.cw}</span>
|
||
)}
|
||
{!!do_edit_cw && !!info.can_del && (
|
||
<div className="box-header-cw-edit clickable">
|
||
<input
|
||
type="text"
|
||
value={cw}
|
||
maxLength="32"
|
||
placeholder="编辑折叠警告"
|
||
onChange={this.on_cw_change.bind(this)}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => do_edit_cw(cw, info.pid)}
|
||
>
|
||
更新
|
||
</button>
|
||
</div>
|
||
)}
|
||
{!info.allow_search && <span className="icon icon-lock" />}
|
||
<Time stamp={info.timestamp} short={!img_clickable} />
|
||
</div>
|
||
{!!info.hot_score && (
|
||
<span className="box-header">hot score: {info.hot_score}</span>
|
||
)}
|
||
<div className="box-content">
|
||
<HighlightedMarkdown
|
||
text={info.text}
|
||
color_picker={color_picker}
|
||
show_pid={show_pid}
|
||
search_param={search_param}
|
||
/>
|
||
</div>
|
||
{info.poll && (
|
||
<div className="box-poll">
|
||
<Poll
|
||
key={info.poll.answers.map((a) => a.votes).join('_')}
|
||
question={''}
|
||
answers={info.poll.answers}
|
||
onVote={do_vote}
|
||
customStyles={{ theme: 'cyan' }}
|
||
noStorage={true}
|
||
vote={
|
||
localStorage['VOTE_RECORD:' + info.pid] || info.poll.vote
|
||
}
|
||
/>
|
||
</div>
|
||
)}
|
||
{!!(attention && info.variant.latest_reply) && (
|
||
<p className="box-footer">
|
||
最新回复{' '}
|
||
<Time stamp={info.variant.latest_reply} short={false} />
|
||
</p>
|
||
)}
|
||
</div>
|
||
{do_react && (
|
||
<div className="box-post-vote">
|
||
<span
|
||
className="clickable"
|
||
onClick={() => do_react(info.reaction_status === 1 ? 0 : 1)}
|
||
>
|
||
<svg
|
||
className={
|
||
info.reaction_status === 1
|
||
? 'vote-icon-active'
|
||
: 'vote-icon-inactive'
|
||
}
|
||
width="24"
|
||
height="24"
|
||
viewBox="0 0 36 36"
|
||
>
|
||
<path d="M2 25h32L18 9 2 25Z" />
|
||
</svg>
|
||
</span>
|
||
<div className="vote-num">{info.up_votes}</div>
|
||
<hr />
|
||
<span
|
||
className="clickable"
|
||
onClick={() =>
|
||
do_react && do_react(info.reaction_status === -1 ? 0 : -1)
|
||
}
|
||
>
|
||
<svg
|
||
className={
|
||
info.reaction_status === -1
|
||
? 'vote-icon-active'
|
||
: 'vote-icon-inactive'
|
||
}
|
||
width="24"
|
||
height="24"
|
||
viewBox="0 0 36 36"
|
||
>
|
||
<path d="M2 11h32L18 27 2 11Z"></path>
|
||
</svg>
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
}
|
||
|
||
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,
|
||
});
|
||
});
|
||
}
|
||
|
||
set_reaction(reaction_status) {
|
||
console.log(reaction_status);
|
||
const prev_info = this.state.info;
|
||
const pid = prev_info.pid;
|
||
API.set_reaction(pid, reaction_status, this.props.token).then((json) => {
|
||
this.setState({
|
||
info: Object.assign({}, prev_info, json.data),
|
||
});
|
||
// TODO: save to localStore
|
||
this.syncState({
|
||
info: Object.assign({}, prev_info, json.data),
|
||
});
|
||
});
|
||
}
|
||
|
||
toggle_attention() {
|
||
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({
|
||
attention: json.attention,
|
||
info: Object.assign({}, prev_info, {
|
||
likenum: '' + json.likenum,
|
||
}),
|
||
});
|
||
|
||
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);
|
||
});
|
||
}
|
||
|
||
toggle_notification() {
|
||
console.log('set notifi');
|
||
get_push_subscription().then((sc) => {
|
||
if (!sc) {
|
||
alert('浏览器不支持消息推送,或无法连接到推送服务');
|
||
return;
|
||
}
|
||
let sc_data = JSON.parse(JSON.stringify(sc));
|
||
API.set_notification(
|
||
this.state.info.pid,
|
||
true,
|
||
sc_data.endpoint,
|
||
sc_data.keys.auth,
|
||
sc_data.keys.p256dh,
|
||
this.props.token,
|
||
).then((json) => {
|
||
// TODO
|
||
});
|
||
});
|
||
}
|
||
|
||
do_vote(vote) {
|
||
this.setState({
|
||
loading_status: 'loading',
|
||
error_msg: null,
|
||
});
|
||
API.add_vote(vote, this.state.info.pid, this.props.token)
|
||
.then((json) => {
|
||
if (json.code !== 0) return;
|
||
localStorage['VOTE_RECORD:' + this.state.info.pid] = vote;
|
||
this.setState(
|
||
(prev, props) => ({
|
||
info: Object.assign({}, prev.info, { poll: json.data }),
|
||
loading_status: 'done',
|
||
}),
|
||
() => {
|
||
this.syncState({
|
||
info: this.state.info,
|
||
});
|
||
},
|
||
);
|
||
})
|
||
.catch((e) => {
|
||
console.error(e);
|
||
this.setState({
|
||
loading_status: 'done',
|
||
error_msg: '' + e,
|
||
});
|
||
});
|
||
}
|
||
|
||
report(event, text = '') {
|
||
console.log(text);
|
||
let reason = prompt(`举报 #${this.state.info.pid} 的理由:`, text);
|
||
if (!reason) return;
|
||
let should_hide = window.confirm('是否认为此洞应该被删除或隐藏?');
|
||
if (reason !== null) {
|
||
API.report(this.state.info.pid, reason, should_hide, this.props.token)
|
||
.then((json) => {
|
||
alert('举报成功');
|
||
})
|
||
.catch((e) => {
|
||
alert('举报失败\n' + e);
|
||
console.error(e);
|
||
});
|
||
}
|
||
}
|
||
|
||
block(name, type, id, on_complete) {
|
||
if (window.confirm(`确定拉黑${name}吗?后续将不会收到其发布的任何内容`)) {
|
||
API.block(type, id, this.props.token)
|
||
.then((json) => {
|
||
let data = json.data;
|
||
alert(`操作成功,当前拉黑数 ${data.curr}`);
|
||
!!on_complete && on_complete();
|
||
})
|
||
.catch((e) => {
|
||
alert('拉黑失败\n' + e);
|
||
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) => {
|
||
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;
|
||
}
|
||
|
||
do_edit_cw(cw, id) {
|
||
API.update_cw(cw, id, this.props.token)
|
||
.then((json) => {
|
||
this.setState(
|
||
{
|
||
info: Object.assign({}, this.state.info, { cw: cw }),
|
||
},
|
||
() => {
|
||
this.syncState({
|
||
info: this.state.info,
|
||
});
|
||
},
|
||
);
|
||
alert('已更新');
|
||
})
|
||
.catch((e) => {
|
||
alert('更新失败\n' + e);
|
||
console.error(e);
|
||
});
|
||
}
|
||
|
||
render() {
|
||
if (this.state.loading_status === 'loading')
|
||
return <p className="box box-tip">加载中……</p>;
|
||
|
||
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 : (
|
||
<ClickHandler
|
||
callback={(e) => {
|
||
this.show_reply_bar('', e);
|
||
}}
|
||
>
|
||
<FlowItem
|
||
info={this.state.info}
|
||
attention={this.state.attention}
|
||
img_clickable={true}
|
||
color_picker={this.color_picker}
|
||
show_pid={show_pid}
|
||
search_param={this.props.search_param}
|
||
replies={this.state.replies}
|
||
set_variant={(variant) => {
|
||
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.do_edit_cw.bind(this)}
|
||
do_vote={this.do_vote.bind(this)}
|
||
do_block={() => {
|
||
this.block('洞主', 'post', this.state.info.pid, () => {
|
||
window.location.reload();
|
||
});
|
||
}}
|
||
do_react={this.set_reaction.bind(this)}
|
||
/>
|
||
</ClickHandler>
|
||
);
|
||
|
||
return (
|
||
<div className="flow-item-row sidebar-flow-item">
|
||
<div className="box box-tip">
|
||
{!!this.props.token && (
|
||
<span>
|
||
<a href="###" onClick={this.report.bind(this)}>
|
||
<span className="icon icon-flag" />
|
||
<label>举报</label>
|
||
</a>
|
||
|
||
</span>
|
||
)}
|
||
<a href="###" onClick={this.load_replies.bind(this)}>
|
||
<span className="icon icon-refresh" />
|
||
<label>刷新</label>
|
||
</a>
|
||
{(this.state.replies.length >= 1 || this.state.rev) && (
|
||
<span>
|
||
|
||
<a href="###" onClick={this.toggle_rev.bind(this)}>
|
||
<span className="icon icon-order-rev" />
|
||
<label>{this.state.rev ? '还原' : '逆序'}</label>
|
||
</a>
|
||
</span>
|
||
)}
|
||
{!!this.props.token && (
|
||
<span>
|
||
|
||
<a href="###" onClick={() => this.toggle_attention()}>
|
||
{this.state.attention ? (
|
||
<span>
|
||
<span className="icon icon-star-ok" />
|
||
<label>已关注</label>
|
||
</span>
|
||
) : (
|
||
<span>
|
||
<span className="icon icon-star" />
|
||
<label>未关注</label>
|
||
</span>
|
||
)}
|
||
</a>
|
||
</span>
|
||
)}
|
||
{!!this.props.token && !!this.state.attention && (
|
||
<span style={{ display: 'none' }}>
|
||
|
||
<a href="###" onClick={() => this.toggle_notification()}>
|
||
<span>
|
||
<span className="icon icon-bell" />
|
||
<label>提醒</label>
|
||
</span>
|
||
</a>
|
||
</span>
|
||
)}
|
||
</div>
|
||
{!!this.state.filter_name && (
|
||
<div className="box box-tip flow-item filter-name-bar">
|
||
<p>
|
||
<span style={{ float: 'left' }}>
|
||
<a
|
||
href="###"
|
||
onClick={() => {
|
||
this.set_filter_name(null);
|
||
}}
|
||
>
|
||
还原
|
||
</a>
|
||
</span>
|
||
<span className="icon icon-locate" />
|
||
当前只看
|
||
<ColoredSpan
|
||
colors={this.color_picker.get(this.state.filter_name)}
|
||
>
|
||
{this.state.filter_name}
|
||
</ColoredSpan>
|
||
</p>
|
||
</div>
|
||
)}
|
||
{!this.state.rev && main_thread_elem}
|
||
{!!this.state.error_msg && (
|
||
<div className="box box-tip flow-item">
|
||
<p>加载失败</p>
|
||
<p>{this.state.error_msg}</p>
|
||
</div>
|
||
)}
|
||
{this.props.deletion_detect &&
|
||
parseInt(this.state.info.reply) > this.state.replies.length &&
|
||
!!this.state.replies.length && (
|
||
<div className="box box-tip flow-item box-danger">
|
||
{parseInt(this.state.info.reply) - this.state.replies.length}{' '}
|
||
条回复被删除
|
||
</div>
|
||
)}
|
||
{replies_to_show.map(
|
||
(reply, i) =>
|
||
(reply.can_del || !reply.is_blocked) && (
|
||
<LazyLoad
|
||
key={i}
|
||
offset={1500}
|
||
height="5em"
|
||
overflow={true}
|
||
once={true}
|
||
>
|
||
<ClickHandler
|
||
callback={(e) => {
|
||
this.show_reply_bar(reply.name, e);
|
||
}}
|
||
>
|
||
<Reply
|
||
info={reply}
|
||
color_picker={this.color_picker}
|
||
show_pid={show_pid}
|
||
search_param={this.props.search_param}
|
||
set_variant={(variant) => {
|
||
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),
|
||
)}
|
||
do_block={() => {
|
||
this.block(
|
||
reply.name,
|
||
'comment',
|
||
reply.cid,
|
||
this.load_replies.bind(this),
|
||
);
|
||
}}
|
||
do_report={(e) => {
|
||
this.report(
|
||
e,
|
||
`评论区${reply.name},评论id ${reply.cid}`,
|
||
);
|
||
}}
|
||
/>
|
||
</ClickHandler>
|
||
</LazyLoad>
|
||
),
|
||
)}
|
||
{this.state.rev && main_thread_elem}
|
||
{this.props.token ? (
|
||
<ReplyForm
|
||
pid={this.state.info.pid}
|
||
token={this.props.token}
|
||
area_ref={this.reply_ref}
|
||
on_complete={this.load_replies.bind(this)}
|
||
/>
|
||
) : (
|
||
<div className="box box-tip flow-item">登录后可以回复树洞</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
}
|
||
|
||
class FlowItemRow extends PureComponent {
|
||
constructor(props) {
|
||
super(props);
|
||
this.needFold =
|
||
props.info.cw &&
|
||
window.config.whitelist_cw.indexOf('*') === -1 &&
|
||
window.config.whitelist_cw.indexOf(props.info.cw) === -1 &&
|
||
(props.mode === 'list' || this.props.is_quote);
|
||
this.has_block_words = check_block(props.info);
|
||
this.too_many_downvotes =
|
||
props.info.down_votes > 10 &&
|
||
props.info.down_votes > 2 * props.info.up_votes;
|
||
this.color_picker = new ColorPicker();
|
||
this.state = {
|
||
replies: props.info.comments
|
||
? parse_replies(props.info.comments, this.color_picker)
|
||
: [],
|
||
reply_status: 'done',
|
||
reply_error: null,
|
||
info: Object.assign({}, props.info, { variant: {} }),
|
||
attention: props.info.attention,
|
||
cached: true, // default no display anything
|
||
};
|
||
}
|
||
|
||
componentDidMount() {
|
||
// cache from getlist, so always to this to update attention
|
||
if (!this.props.info.comments) {
|
||
//if (true || parseInt(this.state.info.reply, 10)) {
|
||
this.load_replies(null, /*update_count=*/ false);
|
||
}
|
||
}
|
||
|
||
// reveal() {
|
||
// this.setState({ hidden: false });
|
||
// }
|
||
|
||
do_vote(vote) {
|
||
const { info } = this.state;
|
||
let current_poll = info.poll;
|
||
current_poll.vote = vote;
|
||
current_poll.answers.forEach((ans) => {
|
||
if (ans.option === vote) ans.votes += 1;
|
||
});
|
||
|
||
this.setState(
|
||
{
|
||
info: Object.assign({}, info, { poll: current_poll }),
|
||
},
|
||
() => {
|
||
API.add_vote(vote, info.pid, this.props.token)
|
||
.then((json) => {
|
||
if (json.code !== 0) return;
|
||
localStorage['VOTE_RECORD:' + info.pid] = vote;
|
||
this.setState((prev, props) => ({
|
||
info: Object.assign({}, prev.info, { poll: json.data }),
|
||
}));
|
||
})
|
||
.catch((e) => {
|
||
console.error(e);
|
||
alert('投票失败');
|
||
});
|
||
},
|
||
);
|
||
}
|
||
|
||
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,
|
||
<FlowSidebar
|
||
key={+new Date()}
|
||
info={this.state.info}
|
||
search_param={this.props.search_param}
|
||
replies={this.state.replies}
|
||
attention={this.state.attention}
|
||
sync_state={this.setState.bind(this)}
|
||
token={this.props.token}
|
||
show_sidebar={this.props.show_sidebar}
|
||
color_picker={this.color_picker}
|
||
deletion_detect={this.props.deletion_detect}
|
||
/>,
|
||
);
|
||
}
|
||
|
||
render() {
|
||
const { show_sidebar, token, search_param, is_quote, mode, submode } =
|
||
this.props;
|
||
let show_pid = load_single_meta(show_sidebar, token, [this.state.info.pid]);
|
||
|
||
let hl_rules = [['pid', PID_RE]];
|
||
let parts = split_text(this.state.info.text, hl_rules);
|
||
|
||
//console.log('hl:', parts,this.state.info.pid);
|
||
|
||
let quote_id = null;
|
||
if (!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;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (mode === 'list' && submode === 1) {
|
||
this.state.replies.reverse();
|
||
}
|
||
let replies_to_show = this.state.replies.slice(0, PREVIEW_REPLY_COUNT);
|
||
|
||
let res = (
|
||
<div
|
||
className={
|
||
'flow-item-row flow-item-row-with-prompt' +
|
||
(is_quote ? ' flow-item-row-quote' : '')
|
||
}
|
||
onClick={(event) => {
|
||
if (!CLICKABLE_TAGS[event.target.tagName.toLowerCase()])
|
||
this.show_sidebar();
|
||
}}
|
||
>
|
||
<FlowItem
|
||
info={this.state.info}
|
||
attention={this.state.attention}
|
||
img_clickable={false}
|
||
is_quote={this.props.is_quote}
|
||
color_picker={this.color_picker}
|
||
show_pid={show_pid}
|
||
replies={this.state.replies}
|
||
cached={this.state.cached}
|
||
search_param={search_param}
|
||
do_vote={this.do_vote.bind(this)}
|
||
/>
|
||
<div className="flow-reply-row">
|
||
{this.state.reply_status === 'loading' && (
|
||
<div className="box box-tip">加载中</div>
|
||
)}
|
||
{this.state.reply_status === 'failed' && (
|
||
<div className="box box-tip">
|
||
<p>
|
||
<a
|
||
href="###"
|
||
onClick={() => {
|
||
this.load_replies();
|
||
}}
|
||
>
|
||
重新加载评论
|
||
</a>
|
||
</p>
|
||
<p>{this.state.reply_error}</p>
|
||
</div>
|
||
)}
|
||
{replies_to_show.map(
|
||
(reply) =>
|
||
(reply.can_del || !reply.is_blocked) && (
|
||
<Reply
|
||
key={reply.cid}
|
||
info={reply}
|
||
color_picker={this.color_picker}
|
||
show_pid={show_pid}
|
||
search_param={search_param}
|
||
/>
|
||
),
|
||
)}
|
||
{this.state.replies.length > PREVIEW_REPLY_COUNT && (
|
||
<div className="box box-tip">
|
||
还有 {this.state.replies.length - PREVIEW_REPLY_COUNT} 条
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
if (this.needFold || this.has_block_words || this.too_many_downvotes) {
|
||
return (
|
||
(!this.has_block_words || mode !== 'list') && (
|
||
<div
|
||
className={
|
||
'flow-item-row flow-item-row-with-prompt' +
|
||
(is_quote ? ' flow-item-row-quote' : '')
|
||
}
|
||
onClick={(event) => {
|
||
if (!CLICKABLE_TAGS[event.target.tagName.toLowerCase()])
|
||
this.show_sidebar();
|
||
}}
|
||
>
|
||
<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>
|
||
|
||
{this.props.info.author_title && (
|
||
<span className="box-header-name author-title">{`"${this.props.info.author_title}"`}</span>
|
||
)}
|
||
{this.props.info.is_tmp && (
|
||
<span className="box-header-name tmp-title">临时账号</span>
|
||
)}
|
||
{this.props.info.cw !== null && (
|
||
<span className="box-header-cw">{this.props.info.cw}</span>
|
||
)}
|
||
<Time stamp={this.props.info.timestamp} short={true} />
|
||
<span className="box-header-badge">
|
||
{this.needFold && ' 已折叠'}
|
||
{this.has_block_words && ' 已屏蔽'}
|
||
{this.too_many_downvotes && ' 争议较大'}
|
||
</span>
|
||
<div style={{ clear: 'both' }} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
);
|
||
}
|
||
|
||
return quote_id ? (
|
||
<div>
|
||
{res}
|
||
<FlowItemQuote
|
||
pid={quote_id}
|
||
show_sidebar={this.props.show_sidebar}
|
||
token={this.props.token}
|
||
deletion_detect={this.props.deletion_detect}
|
||
/>
|
||
</div>
|
||
) : (
|
||
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 (
|
||
<div className="aux-margin">
|
||
<div className="box box-tip">
|
||
<span className="icon icon-loading" />
|
||
提到了 #{this.props.pid}
|
||
</div>
|
||
</div>
|
||
);
|
||
else if (this.state.loading_status === 'error')
|
||
return (
|
||
<div className="aux-margin">
|
||
<div className="box box-tip">
|
||
<p>
|
||
<a href="###" onClick={this.load.bind(this)}>
|
||
重新加载
|
||
</a>
|
||
</p>
|
||
<p>{this.state.error_msg}</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
// 'done'
|
||
else
|
||
return (
|
||
<FlowItemRow
|
||
info={this.state.info}
|
||
mode={this.props.mode}
|
||
show_sidebar={this.props.show_sidebar}
|
||
token={this.props.token}
|
||
is_quote={true}
|
||
deletion_detect={this.props.deletion_detect}
|
||
/>
|
||
);
|
||
}
|
||
}
|
||
|
||
function FlowChunk(props) {
|
||
return (
|
||
<TokenCtx.Consumer>
|
||
{({ value: token }) => (
|
||
<div className="flow-chunk">
|
||
{!!props.title && <TitleLine text={props.title} />}
|
||
{props.list.map(
|
||
(info, ind) =>
|
||
(info.can_del || !info.is_blocked) && (
|
||
<LazyLoad
|
||
key={info.key || info.pid}
|
||
offset={500}
|
||
height="15em"
|
||
hiddenIfInvisible={false}
|
||
>
|
||
<div>
|
||
{!!(
|
||
props.deletion_detect &&
|
||
props.mode === 'list' &&
|
||
ind &&
|
||
props.list[ind - 1].pid - info.pid > 1
|
||
) && (
|
||
<div className="flow-item-row">
|
||
<div className="box box-tip flow-item box-danger">
|
||
{props.list[ind - 1].pid - info.pid - 1} 条被删除
|
||
</div>
|
||
</div>
|
||
)}
|
||
<FlowItemRow
|
||
info={info}
|
||
mode={props.mode}
|
||
submode={props.submode}
|
||
show_sidebar={props.show_sidebar}
|
||
token={token}
|
||
deletion_detect={props.deletion_detect}
|
||
search_param={props.search_param}
|
||
/>
|
||
</div>
|
||
</LazyLoad>
|
||
),
|
||
)}
|
||
</div>
|
||
)}
|
||
</TokenCtx.Consumer>
|
||
);
|
||
}
|
||
|
||
function Announcement(props) {
|
||
const [show, setShow] = useState(true);
|
||
function do_close() {
|
||
window.LAST_ANN = props.text;
|
||
localStorage['LAST_ANN'] = props.text;
|
||
setShow(false);
|
||
}
|
||
return (
|
||
show && (
|
||
<div className="flow-item-row">
|
||
<div className="flow-item">
|
||
<div className="box">
|
||
<div className="box-header announcement-header">
|
||
<a href="###" className="no-underline" onClick={do_close}>
|
||
|
||
<span className="icon icon-close" />
|
||
|
||
</a>
|
||
</div>
|
||
<div className="box-content">
|
||
<HighlightedMarkdown
|
||
text={props.text}
|
||
show_pid={props.show_pid}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
);
|
||
}
|
||
|
||
export class Flow extends PureComponent {
|
||
constructor(props) {
|
||
super(props);
|
||
let submode = window[props.mode.toUpperCase() + '_SUBMODE_BACKUP'];
|
||
if (submode === undefined) {
|
||
submode =
|
||
(props.mode === 'list' && window.config.by_c) || props.mode === 'search'
|
||
? 1
|
||
: 0;
|
||
}
|
||
this.state = {
|
||
submode: submode,
|
||
announcement: window.ANN,
|
||
};
|
||
}
|
||
|
||
get_submode_names(mode) {
|
||
switch (mode) {
|
||
case 'list':
|
||
return ['最新', '回复', '热门', '随机', '典藏'];
|
||
case 'attention':
|
||
return ['线上关注', '本地收藏'];
|
||
case 'search':
|
||
return ['Tag搜索', '全文搜索', '头衔'];
|
||
default:
|
||
return [];
|
||
}
|
||
}
|
||
|
||
set_submode(submode) {
|
||
window[this.props.mode.toUpperCase() + '_SUBMODE_BACKUP'] = submode;
|
||
this.setState({
|
||
submode: submode,
|
||
});
|
||
}
|
||
|
||
update_announcement(text) {
|
||
if (text !== this.state.announcement) {
|
||
window.ANN = text;
|
||
localStorage['ANN'] = text;
|
||
this.setState({
|
||
announcement: text,
|
||
});
|
||
}
|
||
}
|
||
|
||
render() {
|
||
const { submode, announcement } = this.state;
|
||
const { mode, show_sidebar, search_text, token } = this.props;
|
||
const submode_names = this.get_submode_names(mode);
|
||
return (
|
||
<>
|
||
{announcement && window.LAST_ANN !== announcement && (
|
||
<Announcement
|
||
text={announcement}
|
||
show_pid={load_single_meta(show_sidebar, token)}
|
||
/>
|
||
)}
|
||
<div className="aux-margin flow-submode-choice">
|
||
{submode_names.map((name, idx) => (
|
||
<a
|
||
href="###"
|
||
key={idx}
|
||
className={submode === idx ? 'choiced' : ''}
|
||
onClick={this.set_submode.bind(this, idx)}
|
||
>
|
||
{name}
|
||
</a>
|
||
))}
|
||
</div>
|
||
|
||
<SubFlow
|
||
key={submode}
|
||
show_sidebar={show_sidebar}
|
||
update_announcement={(text) => this.update_announcement(text)}
|
||
mode={mode}
|
||
submode={submode}
|
||
search_text={search_text}
|
||
token={token}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
}
|
||
|
||
class SubFlow extends PureComponent {
|
||
constructor(props) {
|
||
super(props);
|
||
this.state = {
|
||
mode: props.mode,
|
||
search_param: props.search_text,
|
||
loaded_pages: 0,
|
||
chunks: {
|
||
title: '',
|
||
data: [],
|
||
},
|
||
local_attention_text: null,
|
||
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);
|
||
this.setState((prev, props) => ({
|
||
loaded_pages: prev.loaded_pages - 1,
|
||
loading_status: 'failed',
|
||
error_msg: '' + err,
|
||
}));
|
||
};
|
||
|
||
if (page > this.state.loaded_pages + 1) throw new Error('bad page');
|
||
if (page === this.state.loaded_pages + 1) {
|
||
const { mode, search_param } = this.state;
|
||
const { token, submode, update_announcement } = this.props;
|
||
console.log('fetching page', page);
|
||
cache();
|
||
if (mode === 'list') {
|
||
API.get_list(page, token, 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;
|
||
}
|
||
|
||
if (page === 1) {
|
||
window.IS_ADMIN = json.is_admin;
|
||
window.IS_CANDIDATE = json.is_candidate;
|
||
window.TITLE = json.custom_title;
|
||
window.AUTO_BLCOK = json.auto_block_rank;
|
||
if (
|
||
json.custom_title &&
|
||
window.TITLE_SECRET !== json.title_secret
|
||
) {
|
||
window.TITLE_SECRET = json.title_secret;
|
||
localStorage['TITLE_SECRET'] = json.title_secret;
|
||
}
|
||
}
|
||
|
||
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',
|
||
}),
|
||
() => {
|
||
update_announcement(json.announcement);
|
||
},
|
||
);
|
||
})
|
||
.catch(failed);
|
||
} else if (mode === 'search' && search_param) {
|
||
API.get_search(page, search_param, token, submode)
|
||
.then((json) => {
|
||
const finished = json.data.length === 0;
|
||
this.setState((prev, props) => ({
|
||
chunks: {
|
||
title: 'Result for "' + search_param + '"',
|
||
data: prev.chunks.data.concat(json.data),
|
||
},
|
||
mode: finished ? 'search_finished' : 'search',
|
||
loading_status: 'done',
|
||
}));
|
||
})
|
||
.catch(failed);
|
||
} else if (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 (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),
|
||
'i',
|
||
);
|
||
} 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) => {
|
||
const search_text = get_search_text(post).toLowerCase();
|
||
return this.state.search_param
|
||
.split(' ')
|
||
.every((keyword) =>
|
||
search_text.includes(keyword.toLowerCase()),
|
||
);
|
||
}) // Not using regex
|
||
: json.data.filter(
|
||
(post) => !!get_search_text(post).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((a, b) => b - a);
|
||
save_attentions();
|
||
}
|
||
})
|
||
.catch(failed);
|
||
} else if (this.props.submode === 1) {
|
||
const PERPAGE = 50;
|
||
let pids = window.saved_attentions
|
||
.sort((a, b) => b - a)
|
||
.slice((page - 1) * PERPAGE, page * PERPAGE);
|
||
if (pids.length) {
|
||
API.get_multi(pids, this.props.token)
|
||
.then((json) => {
|
||
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: `${
|
||
use_search
|
||
? use_regex
|
||
? `Result for RegEx ${regex_search.toString()} in `
|
||
: `Result for "${this.state.search_param}" in `
|
||
: ''
|
||
}Attention List: Local`,
|
||
data: prev.chunks.data.concat(
|
||
!use_search
|
||
? json.data
|
||
: !use_regex
|
||
? json.data.filter((post) => {
|
||
const search_text =
|
||
get_search_text(post).toLowerCase();
|
||
return this.state.search_param
|
||
.split(' ')
|
||
.every((keyword) =>
|
||
search_text.includes(keyword.toLowerCase()),
|
||
);
|
||
})
|
||
: json.data.filter(
|
||
(post) =>
|
||
!!get_search_text(post).match(regex_search),
|
||
),
|
||
),
|
||
},
|
||
loading_status: 'done',
|
||
}));
|
||
})
|
||
.catch(failed);
|
||
} else {
|
||
console.log('local attention finished');
|
||
this.setState({
|
||
loading_status: 'done',
|
||
mode: 'attention_finished',
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
} 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,
|
||
local_attention_text: JSON.parse(localStorage['saved_attentions'] || '[]')
|
||
.map((pid) => `#${pid}`)
|
||
.join(' '),
|
||
});
|
||
}
|
||
|
||
render() {
|
||
const should_deletion_detect = localStorage['DELETION_DETECT'] === 'on';
|
||
const { mode, chunks, local_attention_text, search_param } = this.state;
|
||
const { submode, show_sidebar } = this.props;
|
||
return (
|
||
<div className="flow-container">
|
||
{mode === 'attention' &&
|
||
submode === 1 &&
|
||
local_attention_text === null && (
|
||
<button
|
||
className="export-btn"
|
||
type="button"
|
||
onClick={this.gen_export.bind(this)}
|
||
>
|
||
导出/导入
|
||
</button>
|
||
)}
|
||
|
||
{local_attention_text && (
|
||
<LocalAttentionEditer init_text={local_attention_text} />
|
||
)}
|
||
|
||
<FlowChunk
|
||
title={chunks.title}
|
||
list={chunks.data}
|
||
mode={mode}
|
||
submode={submode}
|
||
search_param={search_param || null}
|
||
show_sidebar={show_sidebar}
|
||
deletion_detect={should_deletion_detect}
|
||
/>
|
||
{this.state.loading_status === 'failed' && (
|
||
<div className="aux-margin">
|
||
<div className="box box-tip">
|
||
<p>
|
||
<a
|
||
href="###"
|
||
onClick={() => {
|
||
this.load_page(this.state.loaded_pages + 1);
|
||
}}
|
||
>
|
||
重新加载
|
||
</a>
|
||
</p>
|
||
<p>{this.state.error_msg}</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<TitleLine
|
||
text={
|
||
this.state.loading_status === 'loading' ? (
|
||
<span>
|
||
<span className="icon icon-loading" />
|
||
Loading...
|
||
</span>
|
||
) : (
|
||
'🄯 2020 copyleft: hole_thu'
|
||
)
|
||
}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
}
|
||
|
||
function LocalAttentionEditer(props) {
|
||
const [text, setText] = useState(props.init_text);
|
||
const PID_RE = /#(\d+)/g;
|
||
|
||
const save = () => {
|
||
let pids = [...text.matchAll(PID_RE)]
|
||
.map((m) => parseInt(m[1]))
|
||
.sort((a, b) => b - a);
|
||
console.log(pids);
|
||
window.saved_attentions = pids;
|
||
save_attentions();
|
||
setText(pids.map((pid) => `#${pid}`).join(' '));
|
||
};
|
||
|
||
return (
|
||
<div className="box">
|
||
<textarea
|
||
className="export-textarea"
|
||
value={text}
|
||
onChange={(event) => setText(event.target.value)}
|
||
/>
|
||
<button className="export-btn" type="button" onClick={save}>
|
||
保存
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|