这个事情,我查了下我的git提交的日期,已经时半个月前自学的了,肯定不能保证包教包会,但是留了些代码和一些基础知识,可以给未来的我和有同样需求的象友们提供参考。

这篇心得我也不说面向什么基础的读者了,即使读者是大神,也可能会跟我导师听了我的presentation以后说出一样的话“我听不懂你在说什么”。只能说尽量提供帮助。

需求背景

有些网友很喜欢分享自己的生活。有的喜欢在网上当偶像,今天吃了草莓塔,马上发一个带美图的照片然后就被坏人跟踪。有的在基层默默无闻数年,诶,今天有幸领导带我去参加了一个涉密的会议,发个朋友圈炫耀一下。这也得益于上头天天说教:“你没干坏事要什么隐私啊”结果现在国家安全教育难以下手,留下来的都是爱炫耀的人。
我在长毛象上最主要的痛点,就不是天天发家里的街景图和快递包装了,而是分享YouTube和b站视频。还有微信里面的一些文章。这种行为其实也有危险,即使把链接脱敏, 只要根据你发布的日期和时间,找到你大概在看视频时那段时间的那些评论,就能知道你的视频账号。随后可以根据你的投稿(如果有的话)和社工库里你账号绑定的身份证号,把你给开盒。
以上这种情况,只要晚两个小时再分享就可以多少减轻一点风险。
要做到这个,最没技术含量的办法,也是我最开始的办法,就是晚上再回去把视频翻出来,进行分享,同时要忍住不跟评论互动(不管是人家夸你,你去讨论也好,还是人家骂你,你去跟人讲道理也好)。代码上没技术含量,其实社会工程的技术含量也不小。我没上过培训班,不在乎老师说“你已经初中了,就不要用小学的知识来解题了”。我的处世之道是用的招数越傻越好,傻的满足不了需求了再用聪明的,用这样的方式让所有技术发挥最大作用。

FediPlan

接下来我就去网上找有没有现成的办法推迟发嘟。我是不会为了锻炼而去找英文进行阅读的,不然我写出了这篇文章以后,还是会有人说我抄袭。搜“长毛象 计划发嘟”(菜鸟会搜“求救!!!长毛象怎么计划发嘟”),一般都是搜出怎么注册账号,甚至是怎么养大象之类的。然后用英文搜答案,就能找到了(别问我怎么练的英语,首先要学会照葫芦画瓢,不会ruby,不会javascript怎么办?找到类似的语法,把你想要的变量或词汇填进去!)。
我找到了这一篇 https://fedi.tips/scheduling-posts-on-mastodon/ 。你就能了解到,其实早就在开发了。已经有软件实现了计划发嘟。而官方,确实有一个API叫scheduled_at。我查了官方文档,确实如此。所以,现在所有计划发嘟的软件的原理,就是发嘟请求中带有scheduled_at,实例的服务器接收到了,就会安排定时发嘟。而官方web端和主流应用都没有这个功能。
到现在为止,你可以登录 https://plan.fedilab.app/ 这个网站进行发嘟。需要你跳转到你的实例以后,手动把token粘贴过来进行发嘟。该有的功能它都有,丑是有点丑,但是问题不大。缺点我认为就是发嘟文总会自带User Agent,而长毛象实例的服务器会根据你的UA给你的嘟文标注你用了哪个客户端。
如果这个FediPlan能改UA,那就无敌了,我也不需要写这个官方GitHub管理员都瞧不上的前端代码了。

嘟文发送的基本原理

抛开客户端不谈,只简单讲讲网页端的原理,因为即使开发完了,官方和我的IceCube还是不能计划发嘟。
首先浏览器输入了实例的网址,登录了账号。这时会跳入这个路径 https://cmx.dzm.pp.ua/deck/getting-started (仅限登录成功时)。

此时,浏览器会执行javascript代码。有基础的读者都知道,浏览器是javascript的解释器,只要安全策略允许,浏览器可以根据javascript代码做任何事。前端代码就在浏览器跑起来了。

长毛象的前端是用react写的。react的基础就不赘述了,不懂的懒得看,懂的都远远比我还懂。
只要注意,每一个组件都要首字母大写,然后在render函数内当作一个HTML元素使用。而这个组件,它的值不像HTML的表单一样可以让用户随意改。最基础的,是要编写函数onChange,然后通过onChange这个方法修改这个元素的值。每次onChange修改了元素的值,render函数都会重新运行一次。

Redux的逻辑浅析

长毛象前端嘟文发送,并不像发送表单一样简单。你的嘟文,它的内容、媒体、投票、是否敏感、内容警告信息等一大串属性,最终都是要发给服务器的。而这些属性,都要反映在你的编辑界面里。所以这些state都储存在store里,每次有组件被操作,onChange就要触发dispatch(),而dispatch函数会根据括号里的参数找到action对象。action文件里定义了一大堆大写字母的变量,其实是为了防止调用action.type时遇到拼写错误无法正确执行期望的动作,且不报错的这个情况发生。随后action函数再去找reducer。因为每次发生更改,store里面的数组可能都要从头重新写一遍,所以为了简化代码,才有reducer这么个模块。reducer模块会枚举所有action.type,用大量elseifswitch case罗列所有action.type

也就是说,你每点一次鼠标,每按一次键盘,上图的流程就要跑一圈。
直到最后,你编辑完毕,点下发送,它就会执行submit动作。这个动作直接调用了api()模块,把你的token,加上store里的所有state,发到服务器地址的指定路径下,最后经过nginx和puma的解析,让后端处理这些数据,将它们放入数据库,并发送给你的所有粉丝。

代码魔改

以下是在前端添加计划发嘟功能的代码,请读者谨慎修改!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index 7525be454..88cb684a8 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -81,6 +81,9 @@ export const COMPOSE_CHANGE_MEDIA_ORDER = 'COMPOSE_CHANGE_MEDIA_ORDER';
export const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS';
export const COMPOSE_FOCUS = 'COMPOSE_FOCUS';

+export const COMPOSE_CHANGE_IS_SCHEDULED = 'COMPOSE_CHANGE_IS_SCHEDULED';
+export const COMPOSE_CHANGE_SCHEDULE_TIME = 'COMPOSE_CHANGE_SCHEDULE_TIME';
+
const messages = defineMessages({
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
open: { id: 'compose.published.open', defaultMessage: 'Open' },
@@ -187,6 +190,7 @@ export function submitCompose() {
const status = getState().getIn(['compose', 'text'], '');
const media = getState().getIn(['compose', 'media_attachments']);
const statusId = getState().getIn(['compose', 'id'], null);
+ const is_scheduled = getState().getIn(['compose', 'is_scheduled']);

if ((!status || !status.length) && media.size === 0) {
return;
@@ -227,6 +231,7 @@ export function submitCompose() {
visibility: getState().getIn(['compose', 'privacy']),
poll: getState().getIn(['compose', 'poll'], null),
language: getState().getIn(['compose', 'language']),
+ scheduled_at: is_scheduled ? getState().getIn(['compose', 'scheduled_at']) : null,
},
headers: {
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
@@ -236,9 +241,22 @@ export function submitCompose() {
browserHistory.goBack();
}

+
+ if ('scheduled_at' in response.data) {
+ dispatch(showAlert({
+ message: messages.saved,
+ dismissAfter: 10000,
+ }));
+ dispatch(submitComposeSuccess({ ...response.data.params}));
+ return;
+ }
+
dispatch(insertIntoTagHistory(response.data.tags, status));
dispatch(submitComposeSuccess({ ...response.data }));

+
+
+
// To make the app more responsive, immediately push the status
// into the columns
const insertIfOnline = timelineId => {
@@ -829,3 +847,16 @@ export const changeMediaOrder = (a, b) => ({
a,
b,
});
+
+export function changeIsScheduled() {
+ return {
+ type: COMPOSE_CHANGE_IS_SCHEDULED,
+ };
+}
+
+export function changeScheduleTime(value) {
+ return {
+ type: COMPOSE_CHANGE_SCHEDULE_TIME,
+ value,
+ };
+}
\ No newline at end of file
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.jsx b/app/javascript/mastodon/features/compose/components/compose_form.jsx
index b5e8dabb7..e1e0038eb 100644
--- a/app/javascript/mastodon/features/compose/components/compose_form.jsx
+++ b/app/javascript/mastodon/features/compose/components/compose_form.jsx
@@ -29,6 +29,9 @@ import { PollForm } from "./poll_form";
import { ReplyIndicator } from './reply_indicator';
import { UploadForm } from './upload_form';

+import ScheduleButtonContainer from '../containers/schedule_button_container';
+import { ScheduleForm } from './schedule_form';
+
const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';

const messages = defineMessages({
@@ -69,6 +72,11 @@ class ComposeForm extends ImmutablePureComponent {
singleColumn: PropTypes.bool,
lang: PropTypes.string,
maxChars: PropTypes.number,
+
+ schedule_time: PropTypes.string,
+ schedule_timezone: PropTypes.string,
+ is_scheduled: PropTypes.bool.isRequired,
+ scheduled_at: PropTypes.string,
};

static defaultProps = {
@@ -295,6 +303,7 @@ class ComposeForm extends ImmutablePureComponent {
<PollButtonContainer />
<SpoilerButtonContainer />
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
+ <ScheduleButtonContainer />
<CharacterCounter max={maxChars} text={this.getFulltextForCharacterCounting()} />
</div>

@@ -306,6 +315,7 @@ class ComposeForm extends ImmutablePureComponent {
/>
</div>
</div>
+ <ScheduleForm />
</div>
</div>
</form>
diff --git a/app/javascript/mastodon/features/compose/components/schedule_form.jsx b/app/javascript/mastodon/features/compose/components/schedule_form.jsx
new file mode 100644
index 000000000..435d9427d
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/schedule_form.jsx
@@ -0,0 +1,41 @@
+import PropTypes from 'prop-types';
+import { useCallback } from 'react';
+
+import { defineMessages, useIntl } from 'react-intl';
+
+import classNames from 'classnames';
+
+import { useDispatch, useSelector} from 'react-redux';
+
+import { changeScheduleTime } from 'mastodon/actions/compose';
+
+const messages = defineMessages({
+ schedule_time: { id: 'compose_form.schedule_time', defaultMessage: '计划发文时间(北京时间)' },
+});
+
+export const ScheduleForm = () => {
+ const is_scheduled = useSelector(state => state.getIn(['compose', 'is_scheduled']));
+ const schedule_time = useSelector(state => state.getIn(['compose', 'schedule_time']));
+ const dispatch = useDispatch();
+ const intl = useIntl();
+
+ const handleChange = useCallback(({ target: { value } }) => {
+ dispatch(changeScheduleTime(value));
+ }, [dispatch]);
+
+ if (!is_scheduled) {
+ return null;
+ }
+
+ return (
+ <div>
+ <label>{intl.formatMessage(messages.schedule_time)}</label>
+ <input
+ className='search__input'
+ type='datetime-local'
+ value={schedule_time}
+ onChange={handleChange}
+ />
+ </div>
+ );
+}
\ No newline at end of file
diff --git a/app/javascript/mastodon/features/compose/containers/compose_form_container.js b/app/javascript/mastodon/features/compose/containers/compose_form_container.js
index 76ac65bf3..e97d33695 100644
--- a/app/javascript/mastodon/features/compose/containers/compose_form_container.js
+++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js
@@ -29,6 +29,10 @@ const mapStateToProps = state => ({
isInReply: state.getIn(['compose', 'in_reply_to']) !== null,
lang: state.getIn(['compose', 'language']),
maxChars: state.getIn(['server', 'server', 'configuration', 'statuses', 'max_characters'], 5000),
+ is_scheduled: state.getIn(['compose', 'is_scheduled']),
+ schedule_time: state.getIn(['compose', 'schedule_time']),
+ schedule_timezone: state.getIn(['compose', 'schedule_timezone']),
+ scheduled_at: state.getIn(['compose', 'scheduled_at']),
});

const mapDispatchToProps = (dispatch) => ({
diff --git a/app/javascript/mastodon/features/compose/containers/schedule_button_container.js b/app/javascript/mastodon/features/compose/containers/schedule_button_container.js
new file mode 100644
index 000000000..0ece8ffb8
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/schedule_button_container.js
@@ -0,0 +1,30 @@
+import { injectIntl, defineMessages } from "react-intl";
+
+import { connect } from 'react-redux';
+
+import ScheduleIcon from '@/material-icons/400-20px/schedule.svg?react';
+import { IconButton } from "@/mastodon/components/icon_button";
+
+import { changeIsScheduled } from '../../../actions/compose';
+
+const messages = defineMessages({
+ marked: { id: 'compose_form.schedule.marked', defaultMessage: '本文将在以下时间发布'},
+ unmarked: { id: 'compose_form.schedule.unmarked', defaultMessage: '文本将立即发布'},
+})
+
+const mapStateToProps = (state, { intl }) => ({
+ iconComponent: ScheduleIcon,
+ title: intl.formatMessage(state.getIn(['compose', 'is_scheduled']) ? messages.marked : messages.unmarked),
+ active: state.getIn(['compose', 'is_scheduled']),
+ ariaControls: 'schedule-publish',
+ size: 18,
+ inverted: true,
+});
+
+const mapDispatchToProps = dispatch => ({
+ onClick () {
+ dispatch(changeIsScheduled());
+ },
+});
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(IconButton));
\ No newline at end of file
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index bfa2ec6a0..6b9a2c3ec 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -50,6 +50,8 @@ import {
COMPOSE_CHANGE_MEDIA_ORDER,
COMPOSE_SET_STATUS,
COMPOSE_FOCUS,
+ COMPOSE_CHANGE_IS_SCHEDULED,
+ COMPOSE_CHANGE_SCHEDULE_TIME
} from '../actions/compose';
import { REDRAFT } from '../actions/statuses';
import { STORE_HYDRATE } from '../actions/store';
@@ -94,6 +96,11 @@ const initialState = ImmutableMap({
focusY: 0,
dirty: false,
}),
+
+ schedule_time: null,
+ schedule_timezone: '+08:00',
+ is_scheduled: false,
+ scheduled_at: null,
});

const initialPoll = ImmutableMap({
@@ -127,6 +134,9 @@ function clearAll(state) {
map.update('media_attachments', list => list.clear());
map.set('poll', null);
map.set('idempotencyKey', uuid());
+ map.set('schedule_time', null);
+ map.set('is_scheduled', false);
+ map.set('scheduled_at', null);
});
}

@@ -560,6 +570,18 @@ export default function compose(state = initialState, action) {

return list.splice(indexA, 1).splice(indexB, 0, moveItem);
});
+ case COMPOSE_CHANGE_IS_SCHEDULED:
+ return state.withMutations(map => {
+ map.set('is_scheduled', !state.get('is_scheduled'));
+ map.set('scheduled_at', state.get('schedule_time') + ':00.0' + state.get('schedule_timezone'));
+ map.set('idempotencyKey', uuid());
+ });
+ case COMPOSE_CHANGE_SCHEDULE_TIME:
+ return state.withMutations(map => {
+ map.set('schedule_time', action.value);
+ map.set('scheduled_at', action.value + ':00.0' + state.get('schedule_timezone'));
+ map.set('idempotencyKey', uuid());
+ });
default:
return state;
}
diff --git a/app/javascript/material-icons/400-20px/schedule.svg b/app/javascript/material-icons/400-20px/schedule.svg
new file mode 100644
index 000000000..350607c71
--- /dev/null
+++ b/app/javascript/material-icons/400-20px/schedule.svg
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="20px" height="20px" viewBox="0 0 20 20" version="1.1">
+<g id="surface1">
+<path style=" stroke:none;fill-rule:nonzero;fill-opacity:1;" d="M 13.476562 8.167969 C 15.507812 8.167969 17.382812 9.214844 18.398438 10.917969 C 19.417969 12.609375 19.417969 14.722656 18.398438 16.417969 C 17.382812 18.117188 15.507812 19.167969 13.476562 19.167969 C 10.332031 19.167969 7.785156 16.699219 7.785156 13.667969 C 7.785156 10.632812 10.332031 8.167969 13.476562 8.167969 Z M 7.160156 1.25 L 7.160156 2.058594 L 12.214844 2.058594 L 12.214844 1.25 C 12.214844 1.023438 12.398438 0.832031 12.632812 0.832031 L 13.066406 0.832031 C 13.292969 0.832031 13.484375 1.015625 13.484375 1.25 L 13.484375 2.058594 L 18.125 2.058594 C 18.359375 2.058594 18.542969 2.25 18.542969 2.476562 L 18.542969 8.367188 C 18.542969 8.589844 18.359375 8.785156 18.125 8.785156 L 17.691406 8.785156 C 17.457031 8.785156 17.273438 8.601562 17.273438 8.367188 L 17.273438 6.949219 L 2.101562 6.949219 L 2.101562 17.332031 L 7.375 17.332031 C 7.609375 17.332031 7.792969 17.515625 7.792969 17.75 L 7.792969 18.140625 C 7.792969 18.375 7.609375 18.558594 7.375 18.558594 L 0.832031 18.558594 L 0.832031 2.476562 C 0.832031 2.242188 1.015625 2.058594 1.25 2.058594 L 5.890625 2.058594 L 5.890625 1.25 C 5.890625 1.023438 6.074219 0.832031 6.308594 0.832031 L 6.742188 0.832031 C 6.964844 0.832031 7.160156 1.015625 7.160156 1.25 Z M 13.476562 9.390625 C 11.890625 9.390625 10.433594 10.207031 9.640625 11.535156 C 8.851562 12.859375 8.851562 14.492188 9.640625 15.808594 C 10.433594 17.125 11.890625 17.949219 13.476562 17.949219 C 15.917969 17.949219 17.898438 16.035156 17.898438 13.675781 C 17.898438 11.316406 15.917969 9.390625 13.476562 9.390625 Z M 12.523438 11.226562 L 13.375 11.226562 C 13.609375 11.226562 13.792969 11.410156 13.792969 11.640625 L 13.792969 13.941406 C 13.792969 14.089844 13.875 14.234375 14.007812 14.308594 L 14.976562 14.851562 C 15.074219 14.902344 15.144531 14.996094 15.175781 15.105469 C 15.207031 15.210938 15.191406 15.328125 15.132812 15.425781 L 14.726562 16.117188 L 12.75 15.015625 C 12.617188 14.941406 12.535156 14.800781 12.535156 14.648438 L 12.535156 11.226562 Z M 5.890625 3.273438 L 2.101562 3.273438 L 2.101562 5.714844 L 17.273438 5.714844 L 17.273438 3.273438 L 13.484375 3.273438 L 13.484375 4.082031 C 13.484375 4.316406 13.300781 4.5 13.066406 4.5 L 12.632812 4.5 C 12.398438 4.5 12.214844 4.316406 12.214844 4.082031 L 12.214844 3.273438 L 7.160156 3.273438 L 7.160156 4.082031 C 7.160156 4.316406 6.976562 4.5 6.742188 4.5 L 6.308594 4.5 C 6.074219 4.5 5.890625 4.316406 5.890625 4.082031 Z M 5.890625 3.273438 "/>
+</g>
+</svg>
(END)

长毛象主题的修改

代码因主题而异,由于笔者未介绍过如何安装主题,所以只简略地说一下:
首先在测试环境的网页端或已经上线的网页端,按F12查看元素,大概在右下角可以修改样式。调整某些数值,直到错位解决以后,再在样式的.scss文件搜索这个元素的class名称,对其中的数据进行修改。
总之,这个过程比魔改计划发文更容易依样画葫芦。

结语

这个代码并非完成版。仔细看了就会发现,时区仅限东八区。这是因为我还没设计出选择时区的界面,也不知道该如何实现。下次有时间一定补上。