nodebb-plugin-poll 4.0.14 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.editorconfig +7 -7
- package/LICENSE +20 -20
- package/eslint.config.mjs +7 -0
- package/languages/cs/poll.json +47 -47
- package/languages/de/poll.json +1 -1
- package/languages/en_GB/poll.json +1 -0
- package/languages/en_US/poll.json +52 -52
- package/languages/es/poll.json +39 -39
- package/languages/fr/poll.json +1 -1
- package/languages/gl/poll.json +38 -38
- package/languages/he/poll.json +1 -1
- package/languages/it/poll.json +1 -1
- package/languages/pl/poll.json +1 -1
- package/languages/pt-PT/poll.json +41 -41
- package/languages/ro/poll.json +39 -39
- package/languages/ru/poll.json +1 -1
- package/languages/tr/poll.json +47 -47
- package/lib/config.js +12 -1
- package/lib/hooks.js +231 -14
- package/lib/nodebb.js +0 -0
- package/lib/poll.js +147 -0
- package/lib/sockets.js +117 -12
- package/lib/vote.js +4 -0
- package/library.js +1 -1
- package/package-lock.json +1658 -0
- package/package.json +2 -2
- package/plugin.json +2 -0
- package/public/js/admin.js +0 -0
- package/public/js/poll/creator.js +26 -4
- package/public/js/poll/main.js +19 -8
- package/public/js/poll/view.js +31 -18
- package/templates/admin/plugins/poll.tpl +10 -11
- package/templates/poll/creator.tpl +14 -12
- package/templates/poll/manage.tpl +1 -1
- package/templates/poll/view/messages.tpl +2 -2
- package/templates/poll/view/results.tpl +9 -3
- package/templates/poll/view/voting.tpl +5 -5
- package/templates/poll/view.tpl +8 -8
- package/test/helpers.js +49 -0
- package/test/index.js +530 -0
- package/upgrades/fix_incorrect_poll_privilege.js +15 -15
package/languages/ro/poll.json
CHANGED
|
@@ -1,39 +1,39 @@
|
|
|
1
|
-
{
|
|
2
|
-
"poll": "Sondaj",
|
|
3
|
-
"toggles": "Comutare",
|
|
4
|
-
"allow_guests": "Oaspeți au acces la rezultate",
|
|
5
|
-
"limits": "Limite",
|
|
6
|
-
"max_options": "Opțiuni maxime",
|
|
7
|
-
"defaults": "Valori implicite",
|
|
8
|
-
"default_title": "Titlu ",
|
|
9
|
-
"max_votes": "Voturi maximale per utilizator",
|
|
10
|
-
"info_choices": "Un număr mai mare de 1 permite selecții multiple.",
|
|
11
|
-
"settings": "Setări",
|
|
12
|
-
"save": "Salvează",
|
|
13
|
-
"reset": "Resetare",
|
|
14
|
-
|
|
15
|
-
"creator_title": "Crează sondajul",
|
|
16
|
-
"poll_title": "Titlul sondajului",
|
|
17
|
-
"poll_title_placeholder": "Introduceț titlul sondajului",
|
|
18
|
-
"options_title": "Opțiuni",
|
|
19
|
-
"options_add": "Adaugă opțiuni",
|
|
20
|
-
"max_votes_placeholder": "Introduceți numerele",
|
|
21
|
-
"auto_end_title": "Sfârșitul sondajului",
|
|
22
|
-
"auto_end_placeholder": "Introduceți data si ora",
|
|
23
|
-
"auto_end_help": "Lăsați acest câmp liber dacă sondajul nu are termen.",
|
|
24
|
-
|
|
25
|
-
"error.max_options": "Puteți introduce un maxim de %
|
|
26
|
-
"error.no_options": "Introduceți ce-l puțin o opțiune.",
|
|
27
|
-
"error.valid_date": "Vă rog introduceți o dată valabilă.",
|
|
28
|
-
|
|
29
|
-
"error.privilege.create": "You're not allowed to create a poll",
|
|
30
|
-
|
|
31
|
-
"vote": "Votează",
|
|
32
|
-
"to_voting": "Sondajul",
|
|
33
|
-
"to_results": "Rezultate",
|
|
34
|
-
"voting_unavailable_title": "Sondajul nu este disponibil",
|
|
35
|
-
"voting_unavailable_message": "Sondajul a fost încheiat sau anulat. Puteți sa va uitați la rezultate.",
|
|
36
|
-
|
|
37
|
-
"vote_count": "Utilizatorii au votat pentru aceste opțiuni",
|
|
38
|
-
"votes": "Voturi"
|
|
39
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"poll": "Sondaj",
|
|
3
|
+
"toggles": "Comutare",
|
|
4
|
+
"allow_guests": "Oaspeți au acces la rezultate",
|
|
5
|
+
"limits": "Limite",
|
|
6
|
+
"max_options": "Opțiuni maxime",
|
|
7
|
+
"defaults": "Valori implicite",
|
|
8
|
+
"default_title": "Titlu ",
|
|
9
|
+
"max_votes": "Voturi maximale per utilizator",
|
|
10
|
+
"info_choices": "Un număr mai mare de 1 permite selecții multiple.",
|
|
11
|
+
"settings": "Setări",
|
|
12
|
+
"save": "Salvează",
|
|
13
|
+
"reset": "Resetare",
|
|
14
|
+
|
|
15
|
+
"creator_title": "Crează sondajul",
|
|
16
|
+
"poll_title": "Titlul sondajului",
|
|
17
|
+
"poll_title_placeholder": "Introduceț titlul sondajului",
|
|
18
|
+
"options_title": "Opțiuni",
|
|
19
|
+
"options_add": "Adaugă opțiuni",
|
|
20
|
+
"max_votes_placeholder": "Introduceți numerele",
|
|
21
|
+
"auto_end_title": "Sfârșitul sondajului",
|
|
22
|
+
"auto_end_placeholder": "Introduceți data si ora",
|
|
23
|
+
"auto_end_help": "Lăsați acest câmp liber dacă sondajul nu are termen.",
|
|
24
|
+
|
|
25
|
+
"error.max_options": "Puteți introduce un maxim de %1 opțiuni.",
|
|
26
|
+
"error.no_options": "Introduceți ce-l puțin o opțiune.",
|
|
27
|
+
"error.valid_date": "Vă rog introduceți o dată valabilă.",
|
|
28
|
+
|
|
29
|
+
"error.privilege.create": "You're not allowed to create a poll",
|
|
30
|
+
|
|
31
|
+
"vote": "Votează",
|
|
32
|
+
"to_voting": "Sondajul",
|
|
33
|
+
"to_results": "Rezultate",
|
|
34
|
+
"voting_unavailable_title": "Sondajul nu este disponibil",
|
|
35
|
+
"voting_unavailable_message": "Sondajul a fost încheiat sau anulat. Puteți sa va uitați la rezultate.",
|
|
36
|
+
|
|
37
|
+
"vote_count": "Utilizatorii au votat pentru aceste opțiuni",
|
|
38
|
+
"votes": "Voturi"
|
|
39
|
+
}
|
package/languages/ru/poll.json
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"auto_end_placeholder": "Нажмите, чтобы ввести дату и время",
|
|
24
24
|
"auto_end_help": "Если оставить данное поле пустым, то опрос будет всегда открыт.",
|
|
25
25
|
|
|
26
|
-
"error.max_options": "Вы можете создать только %
|
|
26
|
+
"error.max_options": "Вы можете создать только %1 опций.",
|
|
27
27
|
"error.no_options": "Создайте хотя бы один вариант ответа.",
|
|
28
28
|
"error.valid_date": "Введите корректную дату.",
|
|
29
29
|
|
package/languages/tr/poll.json
CHANGED
|
@@ -1,47 +1,47 @@
|
|
|
1
|
-
{
|
|
2
|
-
"poll": "Anket",
|
|
3
|
-
"toggles": "Değişiklikler",
|
|
4
|
-
"allow_guests": "Ziyaretçilerin anket sonucunu görmelerine izin ver",
|
|
5
|
-
"limits": "Sınırlamalar",
|
|
6
|
-
"max_options": "Anketlerdeki maksimum seçenek sayısı",
|
|
7
|
-
"defaults": "Varsayılanlar",
|
|
8
|
-
"default_title": "Varsayılan Anket Başlığı",
|
|
9
|
-
"max_votes": "Kişi başı maksimum oy sayısı",
|
|
10
|
-
"disallow_vote_update": "Kişilerin oylarını değiştirmelerine izin verme",
|
|
11
|
-
"info_choices": "1'den büyük bir sayı çoktan seçmeli anket oluşturacak.",
|
|
12
|
-
"settings": "Ayarlar",
|
|
13
|
-
"save": "Kaydet",
|
|
14
|
-
"reset": "Sıfırla",
|
|
15
|
-
|
|
16
|
-
"creator_title": "Anket Oluştur",
|
|
17
|
-
"poll_title": "Anket Başlığı",
|
|
18
|
-
"poll_title_placeholder": "Anket başlığını buraya girin",
|
|
19
|
-
"options_title": "Seçenekler",
|
|
20
|
-
"options_add": "Seçenek ekle",
|
|
21
|
-
"max_votes_placeholder": "Bir sayı girin",
|
|
22
|
-
"auto_end_title": "Anketi otomatik olarak bitir",
|
|
23
|
-
"auto_end_placeholder": "Tarih ve saat bilgisini buraya girin",
|
|
24
|
-
"auto_end_help": "Burayı boş bırakırsanız anket hiç sonlanmayacak.",
|
|
25
|
-
|
|
26
|
-
"error.max_options": "Sadece %
|
|
27
|
-
"error.no_options": "En az bir seçenek oluşturmalısınız",
|
|
28
|
-
"error.valid_date": "Lütfen geçerli bir tarih girin",
|
|
29
|
-
|
|
30
|
-
"error.not_main": "Anketi sadece ilk iletiye ekleyebilirsiniz.",
|
|
31
|
-
"error.privilege.create": "Anket oluşturmaya yetkiniz yok",
|
|
32
|
-
|
|
33
|
-
"warning.redactor": "Redactor kullanıyorsunuz. Markup'ı manuel olarak <strong>değiştirmeyin</strong>. Bunun yerine anket oluşturucuyu yeniden açın.",
|
|
34
|
-
|
|
35
|
-
"vote": "Oy ver",
|
|
36
|
-
"update_vote": "Oyu güncelle",
|
|
37
|
-
"remove_vote": "Oyu kaldır",
|
|
38
|
-
"to_voting": "Seçeneklere geri dön",
|
|
39
|
-
"to_results": "Sonuçlar",
|
|
40
|
-
"voting_unavailable_title": "Oy vermek artık mümkün değil",
|
|
41
|
-
"voting_unavailable_message": "Bu anket bitti veya silindi. Sonuçları hala görmeniz mümkün",
|
|
42
|
-
"voting_update_disallowed_message": "Daha önce oy verdiniz. Bu anket için oyunuzu değiştiremezsiniz",
|
|
43
|
-
"vote_is_final": "Bu ankette oy değiştirmeniz mümkün değildir, o nedenle seçiminiz son karardır",
|
|
44
|
-
|
|
45
|
-
"vote_count": "kişi bu seçenek için oy kullandı",
|
|
46
|
-
"votes": "oy"
|
|
47
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"poll": "Anket",
|
|
3
|
+
"toggles": "Değişiklikler",
|
|
4
|
+
"allow_guests": "Ziyaretçilerin anket sonucunu görmelerine izin ver",
|
|
5
|
+
"limits": "Sınırlamalar",
|
|
6
|
+
"max_options": "Anketlerdeki maksimum seçenek sayısı",
|
|
7
|
+
"defaults": "Varsayılanlar",
|
|
8
|
+
"default_title": "Varsayılan Anket Başlığı",
|
|
9
|
+
"max_votes": "Kişi başı maksimum oy sayısı",
|
|
10
|
+
"disallow_vote_update": "Kişilerin oylarını değiştirmelerine izin verme",
|
|
11
|
+
"info_choices": "1'den büyük bir sayı çoktan seçmeli anket oluşturacak.",
|
|
12
|
+
"settings": "Ayarlar",
|
|
13
|
+
"save": "Kaydet",
|
|
14
|
+
"reset": "Sıfırla",
|
|
15
|
+
|
|
16
|
+
"creator_title": "Anket Oluştur",
|
|
17
|
+
"poll_title": "Anket Başlığı",
|
|
18
|
+
"poll_title_placeholder": "Anket başlığını buraya girin",
|
|
19
|
+
"options_title": "Seçenekler",
|
|
20
|
+
"options_add": "Seçenek ekle",
|
|
21
|
+
"max_votes_placeholder": "Bir sayı girin",
|
|
22
|
+
"auto_end_title": "Anketi otomatik olarak bitir",
|
|
23
|
+
"auto_end_placeholder": "Tarih ve saat bilgisini buraya girin",
|
|
24
|
+
"auto_end_help": "Burayı boş bırakırsanız anket hiç sonlanmayacak.",
|
|
25
|
+
|
|
26
|
+
"error.max_options": "Sadece %1 seçenek oluşturabilirsiniz.",
|
|
27
|
+
"error.no_options": "En az bir seçenek oluşturmalısınız",
|
|
28
|
+
"error.valid_date": "Lütfen geçerli bir tarih girin",
|
|
29
|
+
|
|
30
|
+
"error.not_main": "Anketi sadece ilk iletiye ekleyebilirsiniz.",
|
|
31
|
+
"error.privilege.create": "Anket oluşturmaya yetkiniz yok",
|
|
32
|
+
|
|
33
|
+
"warning.redactor": "Redactor kullanıyorsunuz. Markup'ı manuel olarak <strong>değiştirmeyin</strong>. Bunun yerine anket oluşturucuyu yeniden açın.",
|
|
34
|
+
|
|
35
|
+
"vote": "Oy ver",
|
|
36
|
+
"update_vote": "Oyu güncelle",
|
|
37
|
+
"remove_vote": "Oyu kaldır",
|
|
38
|
+
"to_voting": "Seçeneklere geri dön",
|
|
39
|
+
"to_results": "Sonuçlar",
|
|
40
|
+
"voting_unavailable_title": "Oy vermek artık mümkün değil",
|
|
41
|
+
"voting_unavailable_message": "Bu anket bitti veya silindi. Sonuçları hala görmeniz mümkün",
|
|
42
|
+
"voting_update_disallowed_message": "Daha önce oy verdiniz. Bu anket için oyunuzu değiştiremezsiniz",
|
|
43
|
+
"vote_is_final": "Bu ankette oy değiştirmeniz mümkün değildir, o nedenle seçiminiz son karardır",
|
|
44
|
+
|
|
45
|
+
"vote_count": "kişi bu seçenek için oy kullandı",
|
|
46
|
+
"votes": "oy"
|
|
47
|
+
}
|
package/lib/config.js
CHANGED
|
@@ -28,9 +28,20 @@ Config.defaults = {
|
|
|
28
28
|
|
|
29
29
|
Config.getSettings = async function () {
|
|
30
30
|
const settings = await meta.settings.get(Config.plugin.id);
|
|
31
|
-
|
|
31
|
+
const merged = {
|
|
32
32
|
...Config.defaults,
|
|
33
33
|
...settings,
|
|
34
34
|
};
|
|
35
|
+
// NodeBB's settings helper stores checkboxes as 'on'/'off'; normalize to a boolean
|
|
36
|
+
// so parseInt(...) === 1 checks don't silently fail.
|
|
37
|
+
merged.allowGuestsToViewResults = ['1', 1, 'on', true].includes(merged.allowGuestsToViewResults);
|
|
38
|
+
// NodeBB's settings helper stores blank number fields as ''; fall back to defaults
|
|
39
|
+
// so an empty ACP field doesn't override the configured default.
|
|
40
|
+
merged.maxOptions = parseInt(merged.maxOptions, 10) || Config.defaults.maxOptions;
|
|
41
|
+
merged.maximumVotesPerUser = parseInt(merged.maximumVotesPerUser, 10) || Config.defaults.maximumVotesPerUser;
|
|
42
|
+
// NodeBB's settings helper stores blank text fields as ''; fall back to the
|
|
43
|
+
// configured default so an empty ACP field doesn't override the built-in default.
|
|
44
|
+
merged.defaultTitle = merged.defaultTitle || Config.defaults.defaultTitle;
|
|
45
|
+
return merged;
|
|
35
46
|
};
|
|
36
47
|
|
package/lib/hooks.js
CHANGED
|
@@ -44,12 +44,19 @@ Hooks.filter.postCreate = async function (hookData) {
|
|
|
44
44
|
// post is going to be saved to db, data is what is submitted by user
|
|
45
45
|
const { post, data } = hookData;
|
|
46
46
|
if (Array.isArray(data?.polls) && data.polls.length) {
|
|
47
|
-
await checkMaxOptions(data.polls);
|
|
48
47
|
const savedPolls = await Poll.add(post, data.polls);
|
|
49
48
|
if (savedPolls.length) {
|
|
50
49
|
post.pollIds = JSON.stringify(savedPolls.map(p => String(p.pollId)));
|
|
51
50
|
}
|
|
52
51
|
}
|
|
52
|
+
|
|
53
|
+
// Federated Question (FEP-9967): build a remote poll from the AP data
|
|
54
|
+
const ap = data?._activitypub;
|
|
55
|
+
if (ap && (Array.isArray(ap.oneOf) || Array.isArray(ap.anyOf))) {
|
|
56
|
+
const saved = await Poll.createFromQuestion(post, ap);
|
|
57
|
+
const existing = post.pollIds ? JSON.parse(post.pollIds) : [];
|
|
58
|
+
post.pollIds = JSON.stringify([...existing, String(saved.pollId)]);
|
|
59
|
+
}
|
|
53
60
|
return hookData;
|
|
54
61
|
};
|
|
55
62
|
|
|
@@ -68,13 +75,39 @@ Hooks.filter.postGetFields = async function (hookData) {
|
|
|
68
75
|
};
|
|
69
76
|
|
|
70
77
|
Hooks.filter.postEdit = async function (hookData) {
|
|
78
|
+
// Federated Question update (FEP-9967): refresh the existing remote poll
|
|
79
|
+
const ap = hookData.data?._activitypub;
|
|
80
|
+
if (ap && (Array.isArray(ap.oneOf) || Array.isArray(ap.anyOf))) {
|
|
81
|
+
const currentPollIds = await Poll.getPollIdsByPid(hookData.data.pid);
|
|
82
|
+
let remotePollId = null;
|
|
83
|
+
for (const pid of currentPollIds) {
|
|
84
|
+
// eslint-disable-next-line no-await-in-loop
|
|
85
|
+
if (await Poll.isRemote(pid)) {
|
|
86
|
+
remotePollId = pid;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (remotePollId) {
|
|
91
|
+
await Poll.updateFromQuestion(remotePollId, ap);
|
|
92
|
+
} else {
|
|
93
|
+
// No existing poll (e.g. the initial Create was missed); create one
|
|
94
|
+
const saved = await Poll.createFromQuestion({
|
|
95
|
+
uid: hookData.data.uid,
|
|
96
|
+
pid: hookData.data.pid,
|
|
97
|
+
timestamp: hookData.data.timestamp,
|
|
98
|
+
edited: hookData.data.edited,
|
|
99
|
+
}, ap);
|
|
100
|
+
hookData.post.pollIds = JSON.stringify([String(saved.pollId)]);
|
|
101
|
+
}
|
|
102
|
+
return hookData;
|
|
103
|
+
}
|
|
104
|
+
|
|
71
105
|
const currentPollIds = await Poll.getPollIdsByPid(hookData.data.pid);
|
|
72
106
|
if (!currentPollIds.length && (!Array.isArray(hookData.data.polls) || !hookData.data.polls.length)) {
|
|
73
107
|
// no polls before or after, nothing to do
|
|
74
108
|
return hookData;
|
|
75
109
|
}
|
|
76
110
|
const pollData = hookData.data.polls || [];
|
|
77
|
-
await checkMaxOptions(pollData);
|
|
78
111
|
const toAdd = pollData.filter(p => !currentPollIds.includes(String(p.pollId)));
|
|
79
112
|
|
|
80
113
|
if (toAdd.length) {
|
|
@@ -91,6 +124,71 @@ Hooks.filter.postEdit = async function (hookData) {
|
|
|
91
124
|
return hookData;
|
|
92
125
|
};
|
|
93
126
|
|
|
127
|
+
// FEP-9967 outbound: transform a local post with a poll into a `Question`
|
|
128
|
+
// object. Fired at the end of `Mocks.notes.public` (core), which has already
|
|
129
|
+
// built a `Note`/`Article`. Only the first poll on a post is federated.
|
|
130
|
+
Hooks.filter.activitypubMocksNote = async function (hookData) {
|
|
131
|
+
const { object, post } = hookData;
|
|
132
|
+
if (!object || !post || !post.pid) {
|
|
133
|
+
return hookData;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const pollIds = await Poll.getPollIdsByPid(post.pid);
|
|
137
|
+
if (!pollIds.length) {
|
|
138
|
+
return hookData;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const pollId = pollIds[0];
|
|
142
|
+
const [info, votersCount] = await Promise.all([
|
|
143
|
+
Poll.getInfo(pollId),
|
|
144
|
+
Poll.getVotersCount(pollId),
|
|
145
|
+
]);
|
|
146
|
+
if (!info || !Array.isArray(info.options) || !info.options.length) {
|
|
147
|
+
return hookData;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const options = info.options
|
|
151
|
+
.filter(opt => opt && typeof opt.title === 'string')
|
|
152
|
+
.map(opt => ({
|
|
153
|
+
type: 'Note',
|
|
154
|
+
name: opt.title,
|
|
155
|
+
replies: { type: 'Collection', totalItems: opt.voteCount || 0 },
|
|
156
|
+
}));
|
|
157
|
+
|
|
158
|
+
const isMulti = parseInt(info.maximumVotesPerUser, 10) > 1;
|
|
159
|
+
const end = parseInt(info.end, 10);
|
|
160
|
+
const ended = end > 0 && Date.now() > end;
|
|
161
|
+
|
|
162
|
+
object.type = 'Question';
|
|
163
|
+
// Embed the poll title in the content so it renders as the question text
|
|
164
|
+
// on Mastodon (which displays `content` above the options).
|
|
165
|
+
if (info.title) {
|
|
166
|
+
object.content = `<p>${info.title}</p>${object.content || ''}`;
|
|
167
|
+
}
|
|
168
|
+
if (isMulti) {
|
|
169
|
+
object.anyOf = options;
|
|
170
|
+
} else {
|
|
171
|
+
object.oneOf = options;
|
|
172
|
+
}
|
|
173
|
+
if (end > 0) {
|
|
174
|
+
object.endTime = new Date(end).toISOString();
|
|
175
|
+
}
|
|
176
|
+
if (ended) {
|
|
177
|
+
object.closed = true;
|
|
178
|
+
}
|
|
179
|
+
if (votersCount > 0) {
|
|
180
|
+
object.votersCount = votersCount;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// A poll post is a Note, not an Article — drop the Article-only fields.
|
|
184
|
+
// `name` (topic title) is kept: harmless and non-conflicting.
|
|
185
|
+
delete object.preview;
|
|
186
|
+
delete object.summary;
|
|
187
|
+
delete object.sensitive;
|
|
188
|
+
|
|
189
|
+
return hookData;
|
|
190
|
+
};
|
|
191
|
+
|
|
94
192
|
Hooks.filter.topicPost = async function (data) {
|
|
95
193
|
if (Array.isArray(data.polls) && data.polls.length) {
|
|
96
194
|
await canCreate(data.cid, data.uid);
|
|
@@ -106,6 +204,137 @@ Hooks.filter.topicReply = async function (data) {
|
|
|
106
204
|
return data;
|
|
107
205
|
};
|
|
108
206
|
|
|
207
|
+
// FEP-9967 inbound: detect a remote vote on a local poll and apply it.
|
|
208
|
+
// A vote is a Create(Note) where the Note has `name` + `inReplyTo` and no
|
|
209
|
+
// `content`, and `inReplyTo` resolves to a local post that has a poll.
|
|
210
|
+
Hooks.filter.activitypubCreate = async function (hookData) {
|
|
211
|
+
const { activity } = hookData;
|
|
212
|
+
if (!activity || activity.type !== 'Create' || !activity.object) {
|
|
213
|
+
return hookData;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const { object } = activity;
|
|
217
|
+
// FEP-9967 vote shape: Note with name + inReplyTo, no content
|
|
218
|
+
if (object.type !== 'Note' || !object.name || !object.inReplyTo || object.content) {
|
|
219
|
+
return hookData;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Resolve inReplyTo to a local post (e.g. https://example.org/post/123)
|
|
223
|
+
const match = object.inReplyTo.match(/\/post\/(\d+)/);
|
|
224
|
+
if (!match) return hookData;
|
|
225
|
+
const pid = parseInt(match[1], 10);
|
|
226
|
+
if (!(await posts.exists(pid))) return hookData;
|
|
227
|
+
|
|
228
|
+
const pollIds = await Poll.getPollIdsByPid(pid);
|
|
229
|
+
if (!pollIds.length) return hookData;
|
|
230
|
+
|
|
231
|
+
const pollId = pollIds[0];
|
|
232
|
+
const info = await Poll.getInfo(pollId);
|
|
233
|
+
if (!info) return hookData;
|
|
234
|
+
|
|
235
|
+
// Poll must be active
|
|
236
|
+
if (await Poll.hasEnded(pollId) || await Poll.isDeleted(pollId)) {
|
|
237
|
+
return hookData;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Match name to a poll option
|
|
241
|
+
const option = info.options.find(opt => opt && opt.title === object.name);
|
|
242
|
+
if (!option) return hookData;
|
|
243
|
+
|
|
244
|
+
const remoteActorUrl = activity.actor;
|
|
245
|
+
if (!remoteActorUrl) return hookData;
|
|
246
|
+
const isMulti = parseInt(info.maximumVotesPerUser, 10) > 1;
|
|
247
|
+
|
|
248
|
+
// Idempotency: check if the actor has already voted
|
|
249
|
+
if (isMulti) {
|
|
250
|
+
const alreadyVoted = await db.sortedSetScore(`poll:${pollId}:options:${option.id}:votes`, remoteActorUrl);
|
|
251
|
+
if (alreadyVoted) return hookData;
|
|
252
|
+
} else {
|
|
253
|
+
const alreadyVoted = await db.sortedSetScore(`poll:${pollId}:voters`, remoteActorUrl);
|
|
254
|
+
if (alreadyVoted) return hookData;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Apply the vote (remote actor URL as the uid — sorted sets handle strings)
|
|
258
|
+
const Vote = require('./vote');
|
|
259
|
+
await Vote.add({
|
|
260
|
+
pollId,
|
|
261
|
+
options: [option.id],
|
|
262
|
+
uid: remoteActorUrl,
|
|
263
|
+
voteAnon: false,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Claim the activity so it is not processed as a regular post
|
|
267
|
+
hookData.claimed = true;
|
|
268
|
+
|
|
269
|
+
// Publish Update(Question) with refreshed results back to the voter
|
|
270
|
+
const activitypub = nodebb.require('./src/activitypub');
|
|
271
|
+
const [votersCount, refreshedOptions] = await Promise.all([
|
|
272
|
+
Poll.getVotersCount(pollId),
|
|
273
|
+
Poll.getOptions(pollId, false),
|
|
274
|
+
]);
|
|
275
|
+
const questionObject = {
|
|
276
|
+
id: object.inReplyTo,
|
|
277
|
+
type: 'Question',
|
|
278
|
+
attributedTo: await posts.getPostField(pid, 'uid'),
|
|
279
|
+
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
|
280
|
+
content: await posts.getPostField(pid, 'content'),
|
|
281
|
+
};
|
|
282
|
+
const isMultiChoice = parseInt(info.maximumVotesPerUser, 10) > 1;
|
|
283
|
+
const optionsPayload = refreshedOptions
|
|
284
|
+
.filter(opt => opt && typeof opt.title === 'string')
|
|
285
|
+
.map(opt => ({
|
|
286
|
+
type: 'Note',
|
|
287
|
+
name: opt.title,
|
|
288
|
+
replies: { type: 'Collection', totalItems: opt.voteCount || 0 },
|
|
289
|
+
}));
|
|
290
|
+
if (isMultiChoice) {
|
|
291
|
+
questionObject.anyOf = optionsPayload;
|
|
292
|
+
} else {
|
|
293
|
+
questionObject.oneOf = optionsPayload;
|
|
294
|
+
}
|
|
295
|
+
if (votersCount > 0) {
|
|
296
|
+
questionObject.votersCount = votersCount;
|
|
297
|
+
}
|
|
298
|
+
const end = parseInt(info.end, 10);
|
|
299
|
+
if (end > 0) {
|
|
300
|
+
questionObject.endTime = new Date(end).toISOString();
|
|
301
|
+
}
|
|
302
|
+
if (end > 0 && Date.now() > end) {
|
|
303
|
+
questionObject.closed = true;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Publish Update(Question) to all recipients (public, followers, voters)
|
|
307
|
+
setImmediate(async () => {
|
|
308
|
+
try {
|
|
309
|
+
const utils = nodebb.require('./src/utils');
|
|
310
|
+
const authorUid = parseInt(await posts.getPostField(pid, 'uid'), 10);
|
|
311
|
+
const remoteVoters = (await db.getSortedSetMembers(`poll:${pollId}:voters`))
|
|
312
|
+
.filter(v => !utils.isNumber(v));
|
|
313
|
+
|
|
314
|
+
const { to, cc, targets } = await activitypub.buildRecipients(
|
|
315
|
+
{ to: ['https://www.w3.org/ns/activitystreams#Public'], cc: remoteVoters },
|
|
316
|
+
{ pid, uid: authorUid }
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
questionObject.to = to;
|
|
320
|
+
questionObject.cc = cc;
|
|
321
|
+
|
|
322
|
+
await activitypub.send('uid', authorUid, Array.from(targets), {
|
|
323
|
+
id: `${object.id || object.inReplyTo}#update/${Date.now()}`,
|
|
324
|
+
type: 'Update',
|
|
325
|
+
actor: questionObject.attributedTo,
|
|
326
|
+
to,
|
|
327
|
+
cc,
|
|
328
|
+
object: questionObject,
|
|
329
|
+
});
|
|
330
|
+
} catch (e) {
|
|
331
|
+
// Non-fatal: log and move on
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
return hookData;
|
|
336
|
+
};
|
|
337
|
+
|
|
109
338
|
Hooks.action.postDelete = async function (data) {
|
|
110
339
|
const pollIds = await Poll.getPollIdsByPid(data.post.pid);
|
|
111
340
|
if (pollIds.length) {
|
|
@@ -136,15 +365,3 @@ async function canCreate(cid, uid) {
|
|
|
136
365
|
}
|
|
137
366
|
}
|
|
138
367
|
|
|
139
|
-
async function checkMaxOptions(polls) {
|
|
140
|
-
if (!Array.isArray(polls) || !polls.length) {
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
const { maxOptions } = await Config.getSettings();
|
|
144
|
-
polls.forEach((poll) => {
|
|
145
|
-
if (poll && poll.options.length > maxOptions) {
|
|
146
|
-
throw new Error(`[[poll:error.max_options, ${maxOptions}]]`);
|
|
147
|
-
}
|
|
148
|
-
});
|
|
149
|
-
}
|
|
150
|
-
|
package/lib/nodebb.js
CHANGED
|
File without changes
|
package/lib/poll.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
3
5
|
const db = nodebb.require('./src/database');
|
|
4
6
|
const privileges = nodebb.require('./src/privileges');
|
|
5
7
|
|
|
8
|
+
const Config = require('./config');
|
|
6
9
|
const Vote = require('./vote');
|
|
7
10
|
|
|
8
11
|
const Poll = exports;
|
|
@@ -10,6 +13,7 @@ const Poll = exports;
|
|
|
10
13
|
Poll.add = async function (postData, polls) {
|
|
11
14
|
// postData is what is being saved to db
|
|
12
15
|
// polls is submited by user
|
|
16
|
+
await validateMaxOptions(polls);
|
|
13
17
|
const savePolls = [];
|
|
14
18
|
for (const pollData of polls) {
|
|
15
19
|
// eslint-disable-next-line no-await-in-loop
|
|
@@ -44,6 +48,92 @@ Poll.add = async function (postData, polls) {
|
|
|
44
48
|
return savePolls;
|
|
45
49
|
};
|
|
46
50
|
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Federated (remote) polls — FEP-9967 `Question` objects
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
// Stable option id derived from the option name (the FEP guarantees name
|
|
56
|
+
// uniqueness within a poll), so ids survive `Update`s even if order changes.
|
|
57
|
+
function hashOptionId(name) {
|
|
58
|
+
return crypto.createHash('sha256').update(String(name)).digest('hex').slice(0, 12);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Parse a Question's `_activitypub` poll data into the plugin's option shape.
|
|
62
|
+
function parseQuestion(ap) {
|
|
63
|
+
const rawOptions = Array.isArray(ap.anyOf) ? ap.anyOf : (Array.isArray(ap.oneOf) ? ap.oneOf : []);
|
|
64
|
+
const isMulti = Array.isArray(ap.anyOf);
|
|
65
|
+
const options = [];
|
|
66
|
+
const remoteVotes = {};
|
|
67
|
+
rawOptions.forEach((opt) => {
|
|
68
|
+
if (!opt || typeof opt.name !== 'string') {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const id = hashOptionId(opt.name);
|
|
72
|
+
options.push({ id, title: opt.name });
|
|
73
|
+
remoteVotes[id] = (opt.replies && opt.replies.totalItems) || 0;
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
options,
|
|
77
|
+
remoteVotes,
|
|
78
|
+
maximumVotesPerUser: isMulti ? Math.max(options.length, 1) : 1,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function tryParseRemoteVotes(json) {
|
|
83
|
+
if (!json) {
|
|
84
|
+
return {};
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const obj = JSON.parse(json);
|
|
88
|
+
return obj && typeof obj === 'object' && !Array.isArray(obj) ? obj : {};
|
|
89
|
+
} catch (err) {
|
|
90
|
+
return {};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Create a poll from a federated Question. Bypasses the local `maxOptions`
|
|
95
|
+
// limit (that cap applies to locally-authored polls, not ingested ones).
|
|
96
|
+
Poll.createFromQuestion = async function (postData, ap) {
|
|
97
|
+
const { options, remoteVotes, maximumVotesPerUser } = parseQuestion(ap);
|
|
98
|
+
const pollId = await db.incrObjectField('global', 'nextPollId');
|
|
99
|
+
const poll = {
|
|
100
|
+
pollId,
|
|
101
|
+
title: 'Poll',
|
|
102
|
+
uid: postData.editor || postData.uid,
|
|
103
|
+
pid: postData.pid,
|
|
104
|
+
deleted: 0,
|
|
105
|
+
end: ap.endTime ? new Date(ap.endTime).getTime() : 0,
|
|
106
|
+
maximumVotesPerUser,
|
|
107
|
+
timestamp: postData.edited || postData.timestamp,
|
|
108
|
+
remote: 1,
|
|
109
|
+
remoteVotes: JSON.stringify(remoteVotes),
|
|
110
|
+
options: JSON.stringify(options),
|
|
111
|
+
...(ap.votersCount !== undefined && { remoteVotersCount: ap.votersCount }),
|
|
112
|
+
};
|
|
113
|
+
await Promise.all([
|
|
114
|
+
db.setObject(`poll:${pollId}`, poll),
|
|
115
|
+
db.sortedSetAdd('polls:createtime', poll.timestamp, pollId),
|
|
116
|
+
]);
|
|
117
|
+
return poll;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Refresh an existing remote poll from an updated Question (FEP-9967 `Update`).
|
|
121
|
+
Poll.updateFromQuestion = async function (pollId, ap) {
|
|
122
|
+
const { options, remoteVotes, maximumVotesPerUser } = parseQuestion(ap);
|
|
123
|
+
await db.setObject(`poll:${pollId}`, {
|
|
124
|
+
options: JSON.stringify(options),
|
|
125
|
+
remoteVotes: JSON.stringify(remoteVotes),
|
|
126
|
+
end: ap.endTime ? new Date(ap.endTime).getTime() : 0,
|
|
127
|
+
maximumVotesPerUser,
|
|
128
|
+
...(ap.votersCount !== undefined && { remoteVotersCount: ap.votersCount }),
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
Poll.isRemote = async function (pollId) {
|
|
133
|
+
const result = await Poll.getField(pollId, 'remote');
|
|
134
|
+
return parseInt(result, 10) === 1;
|
|
135
|
+
};
|
|
136
|
+
|
|
47
137
|
Poll.deletePolls = async function (pollIds) {
|
|
48
138
|
if (!pollIds.length) return;
|
|
49
139
|
const polls = await db.getObjects(pollIds.map(pollId => `poll:${pollId}`));
|
|
@@ -75,6 +165,9 @@ Poll.edit = async function (postData, newPolls) {
|
|
|
75
165
|
const toDelete = currentPollIds.filter(pollId => !newPollIds.includes(String(pollId)));
|
|
76
166
|
const currentPolls = await db.getObjects(currentPollIds.map(pollId => `poll:${pollId}`));
|
|
77
167
|
|
|
168
|
+
await validateMaxOptions(toAdd);
|
|
169
|
+
await validateMaxOptionsForEdit(toUpdate, currentPolls);
|
|
170
|
+
|
|
78
171
|
await Poll.deletePolls(toDelete);
|
|
79
172
|
|
|
80
173
|
const savedPolls = await Poll.add(postData, toAdd);
|
|
@@ -206,6 +299,20 @@ Poll.getInfo = async function (pollId, withVotes = false) {
|
|
|
206
299
|
const end = parseInt(poll.end, 10);
|
|
207
300
|
poll.ended = end > 0 && Date.now() > end;
|
|
208
301
|
poll.options = await loadOptions(pollId, poll.options, withVotes);
|
|
302
|
+
|
|
303
|
+
// Federated (remote) polls: displayed count = ingested baseline + local votes.
|
|
304
|
+
// (v1 reconciliation: a local voter sees their vote reflect immediately; a small
|
|
305
|
+
// over-count is accepted until the next remote Update refreshes the baseline.)
|
|
306
|
+
if (parseInt(poll.remote, 10) === 1) {
|
|
307
|
+
const remoteVotes = tryParseRemoteVotes(poll.remoteVotes);
|
|
308
|
+
poll.options.forEach((option) => {
|
|
309
|
+
if (option) {
|
|
310
|
+
const baseline = remoteVotes[option.id] || 0;
|
|
311
|
+
option.voteCount = baseline + (option.voteCount || 0);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
poll.voteCount = poll.options.reduce((sum, o) => sum + (o ? (o.voteCount || 0) : 0), 0);
|
|
315
|
+
}
|
|
209
316
|
return poll;
|
|
210
317
|
};
|
|
211
318
|
|
|
@@ -245,6 +352,46 @@ function tryParseOptions(optionsJson) {
|
|
|
245
352
|
}
|
|
246
353
|
}
|
|
247
354
|
|
|
355
|
+
async function validateMaxOptions(polls) {
|
|
356
|
+
if (!Array.isArray(polls) || !polls.length) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const settings = await Config.getSettings();
|
|
360
|
+
const { maxOptions } = settings;
|
|
361
|
+
polls.forEach((poll) => {
|
|
362
|
+
if (!poll) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const optionCount = Array.isArray(poll.options) ? poll.options.length : 0;
|
|
366
|
+
if (optionCount > maxOptions) {
|
|
367
|
+
throw new Error(`[[poll:error.max_options, ${maxOptions}]]`);
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function validateMaxOptionsForEdit(updatedPolls, currentPolls) {
|
|
373
|
+
if (!Array.isArray(updatedPolls) || !updatedPolls.length) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const settings = await Config.getSettings();
|
|
377
|
+
const { maxOptions } = settings;
|
|
378
|
+
updatedPolls.forEach((poll) => {
|
|
379
|
+
if (!poll) {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const newOptionCount = Array.isArray(poll.options) ? poll.options.length : 0;
|
|
383
|
+
if (newOptionCount <= maxOptions) {
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const currentPoll = currentPolls.find(p => p && String(p.pollId) === String(poll.pollId));
|
|
387
|
+
const currentOptions = currentPoll ? tryParseOptions(currentPoll.options) : [];
|
|
388
|
+
const currentOptionCount = Array.isArray(currentOptions) ? currentOptions.length : 0;
|
|
389
|
+
if (newOptionCount > currentOptionCount) {
|
|
390
|
+
throw new Error(`[[poll:error.max_options, ${maxOptions}]]`);
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
248
395
|
Poll.getOption = async function (pollId, option, withVotes = false) {
|
|
249
396
|
const [optionsJson, votes, voteCount] = await Promise.all([
|
|
250
397
|
db.getObjectField(`poll:${pollId}`, 'options'),
|