tangled
alpha
login
or
join now
evbogue.com
/
wiredove
1
fork
atom
a demonstration replicated social networking web app built with anproto
wiredove.net/
social
ed25519
protocols
1
fork
atom
overview
issues
pulls
pipelines
add extensive moderation support
Everett Bogue
1 month ago
ec7f4d6e
ec050e44
+792
-47
11 changed files
expand all
collapse all
unified
split
adder.js
gossip.js
index.html
moderation.js
render.js
route.js
serve.js
settings.js
style.css
sync.js
websocket.js
+71
-1
adder.js
···
230
230
button.type = 'button'
231
231
button.className = 'new-posts-button'
232
232
button.addEventListener('click', async () => {
233
233
+
if (state.statusMode) { return }
234
234
+
const scrollEl = document.scrollingElement || document.documentElement || document.body
235
235
+
if (scrollEl) {
236
236
+
scrollEl.scrollTo({ top: 0, behavior: 'smooth' })
237
237
+
} else {
238
238
+
window.scrollTo({ top: 0, behavior: 'smooth' })
239
239
+
}
233
240
await flushPending(state)
234
241
})
235
242
banner.appendChild(button)
···
241
248
242
249
const updateBanner = (state) => {
243
250
if (!state.banner || !state.bannerButton) { return }
251
251
+
if (state.statusMessage) {
252
252
+
state.bannerButton.textContent = state.statusMessage
253
253
+
state.bannerButton.disabled = true
254
254
+
state.banner.style.display = 'block'
255
255
+
return
256
256
+
}
244
257
const count = state.pending.length
245
258
if (!count) {
246
259
state.banner.style.display = 'none'
247
260
return
248
261
}
249
262
state.bannerButton.textContent = `Show ${count} new post${count === 1 ? '' : 's'}`
263
263
+
state.bannerButton.disabled = false
250
264
state.banner.style.display = 'block'
251
265
}
252
266
···
324
338
return true
325
339
}
326
340
341
341
+
const getStatusState = () => {
342
342
+
const controller = getController()
343
343
+
const src = window.location.hash.substring(1)
344
344
+
let state = controller.getFeed(src)
345
345
+
if (state) { return state }
346
346
+
if (!window.__statusFeedState) {
347
347
+
const container = document.getElementById('scroller')
348
348
+
if (!container) { return null }
349
349
+
const fallback = {
350
350
+
src: '__status__',
351
351
+
container,
352
352
+
entries: [],
353
353
+
cursor: 0,
354
354
+
seen: new Set(),
355
355
+
rendered: new Set(),
356
356
+
pending: [],
357
357
+
pageSize: 0,
358
358
+
latestVisibleTs: 0,
359
359
+
oldestVisibleTs: 0,
360
360
+
banner: null,
361
361
+
bannerButton: null,
362
362
+
statusMessage: '',
363
363
+
statusMode: false,
364
364
+
statusTimer: null
365
365
+
}
366
366
+
ensureBanner(fallback)
367
367
+
window.__statusFeedState = fallback
368
368
+
}
369
369
+
return window.__statusFeedState
370
370
+
}
371
371
+
372
372
+
window.__feedStatus = (message, options = {}) => {
373
373
+
const state = getStatusState()
374
374
+
if (!state) { return false }
375
375
+
const { timeout = 2500, sticky = false } = options
376
376
+
if (state.statusTimer) {
377
377
+
clearTimeout(state.statusTimer)
378
378
+
state.statusTimer = null
379
379
+
}
380
380
+
state.statusMessage = message || ''
381
381
+
state.statusMode = Boolean(message)
382
382
+
updateBanner(state)
383
383
+
if (state.statusMode && !sticky) {
384
384
+
state.statusTimer = setTimeout(() => {
385
385
+
state.statusMessage = ''
386
386
+
state.statusMode = false
387
387
+
state.statusTimer = null
388
388
+
updateBanner(state)
389
389
+
}, timeout)
390
390
+
}
391
391
+
return true
392
392
+
}
393
393
+
327
394
export const adder = (log, src, div) => {
328
395
if (!div) { return }
329
396
updateScrollDirection()
···
346
413
latestVisibleTs: 0,
347
414
oldestVisibleTs: 0,
348
415
banner: null,
349
349
-
bannerButton: null
416
416
+
bannerButton: null,
417
417
+
statusMessage: '',
418
418
+
statusMode: false,
419
419
+
statusTimer: null
350
420
}
351
421
getController().feeds.set(src, state)
352
422
ensureBanner(state)
+6
gossip.js
···
2
2
import { joinRoom } from './trystero-torrent.min.js'
3
3
import { render } from './render.js'
4
4
import { noteReceived, registerNetworkSenders } from './network_queue.js'
5
5
+
import { isBlockedAuthor } from './moderation.js'
5
6
6
7
export let chan
7
8
···
31
32
})
32
33
33
34
export const makeRoom = async (pubkey) => {
35
35
+
if (pubkey && pubkey.length === 44 && await isBlockedAuthor(pubkey)) {
36
36
+
return roomReady
37
37
+
}
34
38
if (!chan) {
35
39
const room = joinRoom({appId: 'wiredovetestnet', password: 'iajwoiejfaowiejfoiwajfe'}, pubkey)
36
40
const [ sendHash, onHash ] = room.makeAction('hash')
···
51
55
onBlob(async (blob, id) => {
52
56
console.log(`Received: ${blob}`)
53
57
noteReceived(blob)
58
58
+
const author = blob.substring(0, 44)
59
59
+
if (await isBlockedAuthor(author)) { return }
54
60
//await process(blob) <-- trystero and ws should use the same process function
55
61
await apds.make(blob)
56
62
await render.shouldWe(blob)
+1
-1
index.html
···
14
14
<script type='importmap'>
15
15
{
16
16
"imports": {
17
17
-
"apds": "https://esm.sh/gh/evbogue/apds@78abedd/apds.js",
17
17
+
"apds": "https://esm.sh/gh/evbogue/apds@4934fac/apds.js",
18
18
"h": "https://esm.sh/gh/evbogue/apds@78abedd/lib/h.js"
19
19
}
20
20
}
+153
moderation.js
···
1
1
+
import { apds } from 'apds'
2
2
+
3
3
+
const MOD_KEY = 'moderation'
4
4
+
const DEFAULT_STATE = {
5
5
+
mutedAuthors: [],
6
6
+
hiddenHashes: [],
7
7
+
mutedWords: [],
8
8
+
blockedAuthors: []
9
9
+
}
10
10
+
11
11
+
let cachedState = null
12
12
+
let cachedAt = 0
13
13
+
const CACHE_TTL_MS = 2000
14
14
+
15
15
+
const uniq = (list) => Array.from(new Set(list))
16
16
+
17
17
+
const cleanList = (list) => uniq(
18
18
+
(Array.isArray(list) ? list : [])
19
19
+
.map(item => (typeof item === 'string' ? item.trim() : ''))
20
20
+
.filter(Boolean)
21
21
+
)
22
22
+
23
23
+
const normalizeState = (state) => {
24
24
+
const base = state && typeof state === 'object' ? state : {}
25
25
+
return {
26
26
+
mutedAuthors: cleanList(base.mutedAuthors),
27
27
+
hiddenHashes: cleanList(base.hiddenHashes),
28
28
+
mutedWords: cleanList(base.mutedWords).map(word => word.toLowerCase()),
29
29
+
blockedAuthors: cleanList(base.blockedAuthors)
30
30
+
}
31
31
+
}
32
32
+
33
33
+
const parseState = (raw) => {
34
34
+
if (!raw) { return normalizeState(DEFAULT_STATE) }
35
35
+
try {
36
36
+
return normalizeState(JSON.parse(raw))
37
37
+
} catch {
38
38
+
return normalizeState(DEFAULT_STATE)
39
39
+
}
40
40
+
}
41
41
+
42
42
+
export const splitTextList = (text) => {
43
43
+
if (!text) { return [] }
44
44
+
return text
45
45
+
.split(/\r?\n/)
46
46
+
.map(line => line.trim())
47
47
+
.filter(Boolean)
48
48
+
}
49
49
+
50
50
+
export const getModerationState = async () => {
51
51
+
const now = Date.now()
52
52
+
if (cachedState && now - cachedAt < CACHE_TTL_MS) {
53
53
+
return cachedState
54
54
+
}
55
55
+
const stored = await apds.get(MOD_KEY)
56
56
+
cachedState = parseState(stored)
57
57
+
cachedAt = now
58
58
+
return cachedState
59
59
+
}
60
60
+
61
61
+
export const saveModerationState = async (nextState) => {
62
62
+
const normalized = normalizeState(nextState)
63
63
+
cachedState = normalized
64
64
+
cachedAt = Date.now()
65
65
+
await apds.put(MOD_KEY, JSON.stringify(normalized))
66
66
+
return normalized
67
67
+
}
68
68
+
69
69
+
const updateState = async (updateFn) => {
70
70
+
const current = await getModerationState()
71
71
+
return saveModerationState(updateFn(current))
72
72
+
}
73
73
+
74
74
+
export const addMutedAuthor = async (author) => {
75
75
+
if (!author) { return getModerationState() }
76
76
+
return updateState(state => ({
77
77
+
...state,
78
78
+
mutedAuthors: uniq([...state.mutedAuthors, author])
79
79
+
}))
80
80
+
}
81
81
+
82
82
+
export const removeMutedAuthor = async (author) => {
83
83
+
if (!author) { return getModerationState() }
84
84
+
return updateState(state => ({
85
85
+
...state,
86
86
+
mutedAuthors: state.mutedAuthors.filter(item => item !== author)
87
87
+
}))
88
88
+
}
89
89
+
90
90
+
export const addBlockedAuthor = async (author) => {
91
91
+
if (!author) { return getModerationState() }
92
92
+
const next = await updateState(state => ({
93
93
+
...state,
94
94
+
blockedAuthors: uniq([...state.blockedAuthors, author])
95
95
+
}))
96
96
+
let purge = null
97
97
+
if (typeof apds.purgeAuthor === 'function') {
98
98
+
purge = await apds.purgeAuthor(author)
99
99
+
}
100
100
+
return { state: next, purge }
101
101
+
}
102
102
+
103
103
+
export const removeBlockedAuthor = async (author) => {
104
104
+
if (!author) { return getModerationState() }
105
105
+
return updateState(state => ({
106
106
+
...state,
107
107
+
blockedAuthors: state.blockedAuthors.filter(item => item !== author)
108
108
+
}))
109
109
+
}
110
110
+
111
111
+
export const addHiddenHash = async (hash) => {
112
112
+
if (!hash) { return getModerationState() }
113
113
+
return updateState(state => ({
114
114
+
...state,
115
115
+
hiddenHashes: uniq([...state.hiddenHashes, hash])
116
116
+
}))
117
117
+
}
118
118
+
119
119
+
export const removeHiddenHash = async (hash) => {
120
120
+
if (!hash) { return getModerationState() }
121
121
+
return updateState(state => ({
122
122
+
...state,
123
123
+
hiddenHashes: state.hiddenHashes.filter(item => item !== hash)
124
124
+
}))
125
125
+
}
126
126
+
127
127
+
export const shouldHideMessage = async ({ author, hash, body }) => {
128
128
+
const state = await getModerationState()
129
129
+
if (author && state.blockedAuthors.includes(author)) {
130
130
+
return { hidden: true, reason: 'Blocked author', code: 'blocked-author' }
131
131
+
}
132
132
+
if (author && state.mutedAuthors.includes(author)) {
133
133
+
return { hidden: true, reason: 'Muted author', code: 'muted-author' }
134
134
+
}
135
135
+
if (hash && state.hiddenHashes.includes(hash)) {
136
136
+
return { hidden: true, reason: 'Hidden message', code: 'hidden-hash' }
137
137
+
}
138
138
+
if (body && state.mutedWords.length) {
139
139
+
const lowered = body.toLowerCase()
140
140
+
for (const word of state.mutedWords) {
141
141
+
if (word && lowered.includes(word)) {
142
142
+
return { hidden: true, reason: 'Filtered keyword', code: 'muted-word', word }
143
143
+
}
144
144
+
}
145
145
+
}
146
146
+
return { hidden: false }
147
147
+
}
148
148
+
149
149
+
export const isBlockedAuthor = async (author) => {
150
150
+
if (!author || author.length !== 44) { return false }
151
151
+
const state = await getModerationState()
152
152
+
return state.blockedAuthors.includes(author)
153
153
+
}
+197
-40
render.js
···
6
6
import { markdown } from './markdown.js'
7
7
import { noteSeen } from './sync.js'
8
8
import { promptKeypair } from './identify.js'
9
9
+
import { addBlockedAuthor, addHiddenHash, addMutedAuthor, isBlockedAuthor, removeHiddenHash, removeMutedAuthor, shouldHideMessage } from './moderation.js'
9
10
10
11
export const render = {}
11
12
const cache = new Map()
···
662
663
return { wrap, left, right, index }
663
664
}
664
665
665
665
-
render.meta = async (blob, opened, hash, div) => {
666
666
+
const findMessageTarget = (hash) => {
667
667
+
if (!hash) { return null }
668
668
+
const wrapper = document.getElementById(hash)
669
669
+
if (!wrapper) { return null }
670
670
+
return wrapper.querySelector('.message') || wrapper.querySelector('.message-shell')
671
671
+
}
672
672
+
673
673
+
const applyModerationStub = async ({ target, hash, author, moderation, blob, opened }) => {
674
674
+
if (!target || !moderation || !moderation.hidden) { return }
675
675
+
const stub = h('div', {classList: 'message moderation-hidden'})
676
676
+
const title = h('span', {classList: 'moderation-hidden-title'}, ['Hidden by local moderation'])
677
677
+
const actions = h('span', {classList: 'moderation-hidden-actions'})
678
678
+
const showOnce = h('button', {
679
679
+
onclick: async () => {
680
680
+
await render.meta(blob, opened, hash, stub, { forceShow: true })
681
681
+
}
682
682
+
}, ['Show once'])
683
683
+
actions.appendChild(showOnce)
684
684
+
685
685
+
if (moderation.code === 'muted-author') {
686
686
+
const unmute = h('button', {
687
687
+
onclick: async () => {
688
688
+
await removeMutedAuthor(author)
689
689
+
await render.meta(blob, opened, hash, stub)
690
690
+
}
691
691
+
}, ['Unmute'])
692
692
+
actions.appendChild(unmute)
693
693
+
} else if (moderation.code === 'hidden-hash') {
694
694
+
const unhide = h('button', {
695
695
+
onclick: async () => {
696
696
+
await removeHiddenHash(hash)
697
697
+
await render.meta(blob, opened, hash, stub)
698
698
+
}
699
699
+
}, ['Unhide'])
700
700
+
actions.appendChild(unhide)
701
701
+
} else if (moderation.code === 'muted-word') {
702
702
+
actions.appendChild(h('a', {href: '#settings'}, ['Edit filters']))
703
703
+
}
704
704
+
705
705
+
stub.appendChild(title)
706
706
+
stub.appendChild(actions)
707
707
+
target.replaceWith(stub)
708
708
+
}
709
709
+
710
710
+
const buildModerationControls = ({ author, hash, blob, opened }) => {
711
711
+
const hide = h('a', {
712
712
+
classList: 'material-symbols-outlined',
713
713
+
title: 'Hide message',
714
714
+
onclick: async (e) => {
715
715
+
e.preventDefault()
716
716
+
await addHiddenHash(hash)
717
717
+
const target = findMessageTarget(hash)
718
718
+
if (target) {
719
719
+
await applyModerationStub({
720
720
+
target,
721
721
+
hash,
722
722
+
author,
723
723
+
moderation: { hidden: true, reason: 'Hidden message', code: 'hidden-hash' },
724
724
+
blob,
725
725
+
opened
726
726
+
})
727
727
+
} else {
728
728
+
location.reload()
729
729
+
}
730
730
+
}
731
731
+
}, ['Visibility_Off'])
732
732
+
733
733
+
const block = h('a', {
734
734
+
classList: 'material-symbols-outlined',
735
735
+
title: 'Block author',
736
736
+
onclick: async (e) => {
737
737
+
e.preventDefault()
738
738
+
if (!confirm('Block this author and purge their local data?')) { return }
739
739
+
window.__feedStatus?.('Blocking author…', { sticky: true })
740
740
+
const result = await addBlockedAuthor(author)
741
741
+
const removed = result?.purge?.removed ?? 0
742
742
+
const blobs = result?.purge?.blobs ?? 0
743
743
+
if (result?.purge) {
744
744
+
window.__feedStatus?.(`Blocked author. Removed ${removed} post${removed === 1 ? '' : 's'}, ${blobs} blob${blobs === 1 ? '' : 's'}.`)
745
745
+
} else {
746
746
+
window.__feedStatus?.('Blocked author.')
747
747
+
}
748
748
+
const wrappers = Array.from(document.querySelectorAll('.message-wrapper'))
749
749
+
.filter(node => node.dataset?.author === author)
750
750
+
if (wrappers.length) {
751
751
+
wrappers.forEach(node => node.remove())
752
752
+
} else {
753
753
+
const wrapper = document.getElementById(hash)
754
754
+
if (wrapper) {
755
755
+
wrapper.remove()
756
756
+
} else {
757
757
+
location.reload()
758
758
+
}
759
759
+
}
760
760
+
}
761
761
+
}, ['Block'])
762
762
+
763
763
+
const mute = h('a', {
764
764
+
classList: 'material-symbols-outlined',
765
765
+
title: 'Mute author',
766
766
+
onclick: async (e) => {
767
767
+
e.preventDefault()
768
768
+
await addMutedAuthor(author)
769
769
+
const target = findMessageTarget(hash)
770
770
+
if (target) {
771
771
+
await applyModerationStub({
772
772
+
target,
773
773
+
hash,
774
774
+
author,
775
775
+
moderation: { hidden: true, reason: 'Muted author', code: 'muted-author' },
776
776
+
blob,
777
777
+
opened
778
778
+
})
779
779
+
} else {
780
780
+
location.reload()
781
781
+
}
782
782
+
}
783
783
+
}, ['Person_Off'])
784
784
+
785
785
+
return h('span', {classList: 'message-actions-mod'}, [hide, mute, block])
786
786
+
}
787
787
+
788
788
+
render.meta = async (blob, opened, hash, div, options = {}) => {
666
789
const timestamp = opened.substring(0, 13)
667
790
const contentHash = opened.substring(13)
668
791
const author = blob.substring(0, 44)
669
792
const wrapper = document.getElementById(hash)
670
793
if (wrapper) {
671
794
wrapper.dataset.ts = timestamp
795
795
+
wrapper.dataset.author = author
672
796
}
673
797
674
798
const [humanTime, contentBlob, img] = await Promise.all([
···
677
801
apds.visual(author)
678
802
])
679
803
804
804
+
let yaml = null
680
805
if (contentBlob) {
681
681
-
const yaml = await apds.parseYaml(contentBlob)
682
682
-
if (yaml && yaml.edit) {
683
683
-
queueEditRefresh(yaml.edit)
684
684
-
syncPrevious(yaml)
806
806
+
yaml = await apds.parseYaml(contentBlob)
807
807
+
}
685
808
686
686
-
const ts = h('a', {href: '#' + hash}, [humanTime])
687
687
-
observeTimestamp(ts, timestamp)
809
809
+
if (!options.forceShow) {
810
810
+
const moderation = await shouldHideMessage({
811
811
+
author,
812
812
+
hash,
813
813
+
body: yaml?.body || ''
814
814
+
})
815
815
+
if (moderation.hidden) {
816
816
+
if (moderation.code === 'blocked-author') {
817
817
+
const wrapper = document.getElementById(hash)
818
818
+
if (wrapper) { wrapper.remove() }
819
819
+
return
820
820
+
}
821
821
+
await applyModerationStub({
822
822
+
target: div,
823
823
+
hash,
824
824
+
author,
825
825
+
moderation,
826
826
+
blob,
827
827
+
opened
828
828
+
})
829
829
+
return
830
830
+
}
831
831
+
}
688
832
689
689
-
const qrTarget = h('div', {id: 'qr-target' + hash, classList: 'qr-target', style: 'margin: 8px auto 0 auto; text-align: center; width: min(90vw, 400px); max-width: 400px;'})
690
690
-
const { raw, rawDiv } = buildRawControls(blob, opened, contentBlob)
691
691
-
const right = buildRightMeta({ author, hash, blob, qrTarget, raw, ts })
833
833
+
if (yaml && yaml.edit) {
834
834
+
queueEditRefresh(yaml.edit)
835
835
+
syncPrevious(yaml)
692
836
693
693
-
img.className = 'avatar'
694
694
-
img.id = 'image' + contentHash
695
695
-
img.style = 'float: left;'
837
837
+
const ts = h('a', {href: '#' + hash}, [humanTime])
838
838
+
observeTimestamp(ts, timestamp)
696
839
697
697
-
const summary = buildEditSummaryLine({
698
698
-
name: yaml.name,
699
699
-
editHash: yaml.edit,
700
700
-
author,
701
701
-
nameId: 'name' + contentHash,
702
702
-
})
703
703
-
updateEditSnippet(yaml.edit, summary)
704
704
-
const summaryRow = buildEditSummaryRow({
705
705
-
avatarLink: h('a', {href: '#' + author}, [img]),
706
706
-
summary
707
707
-
})
708
708
-
const meta = buildEditMessageShell({
709
709
-
id: div.id,
710
710
-
right,
711
711
-
summaryRow,
712
712
-
rawDiv,
713
713
-
qrTarget
714
714
-
})
715
715
-
if (div.dataset.ts) {
716
716
-
meta.dataset.ts = div.dataset.ts
717
717
-
}
840
840
+
const qrTarget = h('div', {id: 'qr-target' + hash, classList: 'qr-target', style: 'margin: 8px auto 0 auto; text-align: center; width: min(90vw, 400px); max-width: 400px;'})
841
841
+
const { raw, rawDiv } = buildRawControls(blob, opened, contentBlob)
842
842
+
const right = buildRightMeta({ author, hash, blob, qrTarget, raw, ts })
718
843
719
719
-
div.replaceWith(meta)
720
720
-
await applyProfile(contentHash, yaml)
721
721
-
return
844
844
+
img.className = 'avatar'
845
845
+
img.id = 'image' + contentHash
846
846
+
img.style = 'float: left;'
847
847
+
848
848
+
const summary = buildEditSummaryLine({
849
849
+
name: yaml.name,
850
850
+
editHash: yaml.edit,
851
851
+
author,
852
852
+
nameId: 'name' + contentHash,
853
853
+
})
854
854
+
updateEditSnippet(yaml.edit, summary)
855
855
+
const summaryRow = buildEditSummaryRow({
856
856
+
avatarLink: h('a', {href: '#' + author}, [img]),
857
857
+
summary
858
858
+
})
859
859
+
const meta = buildEditMessageShell({
860
860
+
id: div.id,
861
861
+
right,
862
862
+
summaryRow,
863
863
+
rawDiv,
864
864
+
qrTarget
865
865
+
})
866
866
+
meta.dataset.author = author
867
867
+
if (div.dataset.ts) {
868
868
+
meta.dataset.ts = div.dataset.ts
722
869
}
870
870
+
871
871
+
div.replaceWith(meta)
872
872
+
await applyProfile(contentHash, yaml)
873
873
+
return
723
874
}
724
875
725
876
const ts = h('a', {href: '#' + hash}, [humanTime])
···
765
916
}, ['Notes'])
766
917
767
918
const replySlot = h('span', {classList: 'message-actions-reply'})
919
919
+
const moderationControls = buildModerationControls({ author, hash, blob, opened })
768
920
const editControls = h('span', {classList: 'message-actions-edit'}, [
769
921
editButton || '',
770
922
editButton ? ' ' : '',
···
774
926
])
775
927
const actionsRow = h('div', {classList: 'message-actions'}, [
776
928
replySlot,
929
929
+
moderationControls,
777
930
editControls
778
931
])
779
932
···
942
1095
}
943
1096
944
1097
render.blob = async (blob, meta = {}) => {
1098
1098
+
const forceShow = Boolean(meta.forceShow)
945
1099
let hash = meta.hash || null
946
1100
let wrapper = hash ? document.getElementById(hash) : null
947
1101
if (!hash && wrapper) { hash = wrapper.id }
···
971
1125
972
1126
const getimg = document.getElementById('inlineimage' + hash)
973
1127
if (opened && div && !div.childNodes[1]) {
974
974
-
await render.meta(blob, opened, hash, div)
1128
1128
+
await render.meta(blob, opened, hash, div, { forceShow })
975
1129
//await render.comments(hash, blob, div)
976
1130
} else if (div && !div.childNodes[1]) {
977
1131
if (div.className.includes('content')) {
···
989
1143
}
990
1144
991
1145
render.shouldWe = async (blob) => {
1146
1146
+
const authorKey = blob?.substring(0, 44)
1147
1147
+
if (authorKey && await isBlockedAuthor(authorKey)) {
1148
1148
+
return
1149
1149
+
}
992
1150
const [opened, hash] = await Promise.all([
993
1151
apds.open(blob),
994
1152
apds.hash(blob)
···
1016
1174
}
1017
1175
const inDom = document.getElementById(hash)
1018
1176
if (opened && !inDom) {
1019
1019
-
noteSeen(blob.substring(0, 44))
1177
1177
+
await noteSeen(blob.substring(0, 44))
1020
1178
const src = window.location.hash.substring(1)
1021
1179
const al = []
1022
1180
const aliases = localStorage.getItem(src)
···
1036
1194
const ts = parseOpenedTimestamp(opened)
1037
1195
const scroller = document.getElementById('scroller')
1038
1196
// this should detect whether the syncing message is newer or older and place the msg in the right spot
1039
1039
-
const authorKey = blob.substring(0, 44)
1040
1197
const replyTo = getReplyParent(yaml)
1041
1198
if (replyTo) {
1042
1199
indexReply(replyTo, hash, ts, opened)
+8
-3
route.js
···
10
10
import { send } from './send.js'
11
11
import { queueSend } from './network_queue.js'
12
12
import { noteInterest } from './sync.js'
13
13
+
import { isBlockedAuthor } from './moderation.js'
13
14
14
15
const HOME_SEED_COUNT = 3
15
16
const HOME_BACKFILL_DEPTH = 6
···
104
105
console.log(ar)
105
106
let query = []
106
107
for (const pubkey of ar) {
107
107
-
noteInterest(pubkey)
108
108
+
if (await isBlockedAuthor(pubkey)) { continue }
109
109
+
await noteInterest(pubkey)
108
110
await send(pubkey)
109
111
const q = await apds.query(pubkey)
110
112
if (q) {
···
118
120
119
121
else if (src.length === 44) {
120
122
try {
121
121
-
noteInterest(src)
123
123
+
if (await isBlockedAuthor(src)) { return }
124
124
+
await noteInterest(src)
122
125
const log = await apds.query(src)
123
126
scroller.dataset.paginated = 'true'
124
127
adder(log || [], src, scroller)
···
138
141
else if (src.length > 44) {
139
142
const hash = await apds.hash(src)
140
143
const opened = await apds.open(src)
141
141
-
noteInterest(src.substring(0, 44))
144
144
+
const author = src.substring(0, 44)
145
145
+
if (await isBlockedAuthor(author)) { return }
146
146
+
await noteInterest(author)
142
147
if (opened) {
143
148
//await makeRoom(src.substring(0, 44))
144
149
await apds.add(src)
+27
serve.js
···
1
1
import { serveDir } from 'https://deno.land/std@0.224.0/http/file_server.ts'
2
2
+
import { contentType } from 'https://deno.land/std@0.224.0/media_types/mod.ts'
2
3
import { createNotificationsService } from './notifications_server.js'
3
4
4
5
const notifications = await createNotificationsService()
···
6
7
Deno.serve(async (r) => {
7
8
const handled = await notifications.handleRequest(r)
8
9
if (handled) return handled
10
10
+
const url = new URL(r.url)
11
11
+
if (url.pathname.startsWith('/apds')) {
12
12
+
const relPath = url.pathname.replace(/^\/apds/, '') || '/'
13
13
+
const filePath = '/home/ev/apds' + relPath
14
14
+
try {
15
15
+
const data = await Deno.readFile(filePath)
16
16
+
let type = contentType(filePath)
17
17
+
if (!type) {
18
18
+
if (filePath.endsWith('.js') || filePath.endsWith('.mjs')) {
19
19
+
type = 'text/javascript'
20
20
+
} else if (filePath.endsWith('.json')) {
21
21
+
type = 'application/json'
22
22
+
}
23
23
+
}
24
24
+
if (!type) { type = 'application/octet-stream' }
25
25
+
return new Response(data, {
26
26
+
status: 200,
27
27
+
headers: { 'content-type': type }
28
28
+
})
29
29
+
} catch (err) {
30
30
+
if (err && err.name === 'NotFound') {
31
31
+
return new Response('Not found', { status: 404 })
32
32
+
}
33
33
+
return new Response('Error', { status: 500 })
34
34
+
}
35
35
+
}
9
36
return serveDir(r, { quiet: 'True' })
10
37
})
+205
settings.js
···
2
2
import { apds } from 'apds'
3
3
import { nameDiv, avatarSpan } from './profile.js'
4
4
import { clearQueue, getQueueSize, queueSend } from './network_queue.js'
5
5
+
import { addBlockedAuthor, getModerationState, removeBlockedAuthor, saveModerationState, splitTextList } from './moderation.js'
5
6
6
7
const isHash = (value) => typeof value === 'string' && value.length === 44
7
8
···
149
150
}
150
151
}, ['Push everything'])
151
152
153
153
+
const moderationPanel = async () => {
154
154
+
const container = h('div', {classList: 'moderation-panel'})
155
155
+
156
156
+
const fetchAuthorLabel = async (author) => {
157
157
+
if (!author) { return 'Unknown author' }
158
158
+
let label = author.substring(0, 10)
159
159
+
try {
160
160
+
const query = await apds.query(author)
161
161
+
const entry = Array.isArray(query) ? query[0] : query
162
162
+
if (entry && entry.opened) {
163
163
+
const content = await apds.get(entry.opened.substring(13))
164
164
+
if (content) {
165
165
+
const yaml = await apds.parseYaml(content)
166
166
+
if (yaml && yaml.name) {
167
167
+
label = `${yaml.name} (${author.substring(0, 6)})`
168
168
+
}
169
169
+
}
170
170
+
}
171
171
+
} catch {}
172
172
+
return label
173
173
+
}
174
174
+
175
175
+
const fetchHiddenLabel = async (hash) => {
176
176
+
if (!hash) { return 'Unknown message' }
177
177
+
let label = hash.substring(0, 10)
178
178
+
try {
179
179
+
const blob = await apds.get(hash)
180
180
+
let author = blob ? blob.substring(0, 44) : null
181
181
+
let opened = null
182
182
+
if (blob) {
183
183
+
opened = await apds.open(blob)
184
184
+
}
185
185
+
const authorLabel = author ? await fetchAuthorLabel(author) : 'Unknown author'
186
186
+
let snippet = ''
187
187
+
if (opened) {
188
188
+
const content = await apds.get(opened.substring(13))
189
189
+
if (content) {
190
190
+
const yaml = await apds.parseYaml(content)
191
191
+
if (yaml && yaml.body) {
192
192
+
snippet = yaml.body.replace(/\s+/g, ' ').trim().substring(0, 32)
193
193
+
}
194
194
+
}
195
195
+
}
196
196
+
if (snippet) {
197
197
+
label = `${authorLabel} · ${snippet}`
198
198
+
} else {
199
199
+
label = `${authorLabel} · ${hash.substring(0, 10)}`
200
200
+
}
201
201
+
} catch {}
202
202
+
return label
203
203
+
}
204
204
+
205
205
+
const createTag = ({ label, onRemove }) => {
206
206
+
const text = h('span', {classList: 'moderation-tag-label'}, [label])
207
207
+
const remove = h('button', {
208
208
+
classList: 'moderation-tag-remove',
209
209
+
onclick: onRemove
210
210
+
}, ['×'])
211
211
+
return h('span', {classList: 'moderation-tag'}, [text, remove])
212
212
+
}
213
213
+
214
214
+
const buildSection = async ({
215
215
+
title,
216
216
+
placeholder,
217
217
+
items,
218
218
+
onAdd,
219
219
+
onRemove,
220
220
+
labelForItem
221
221
+
}) => {
222
222
+
const input = h('input', {
223
223
+
classList: 'moderation-input',
224
224
+
placeholder
225
225
+
})
226
226
+
const addButton = h('button', {
227
227
+
onclick: async () => {
228
228
+
const value = input.value.trim()
229
229
+
if (!value) { return }
230
230
+
input.value = ''
231
231
+
await onAdd(value)
232
232
+
await renderPanel()
233
233
+
}
234
234
+
}, ['Add'])
235
235
+
236
236
+
const list = h('div', {classList: 'moderation-tags'})
237
237
+
for (const item of items) {
238
238
+
const tag = createTag({
239
239
+
label: item,
240
240
+
onRemove: async () => {
241
241
+
await onRemove(item)
242
242
+
await renderPanel()
243
243
+
}
244
244
+
})
245
245
+
list.appendChild(tag)
246
246
+
if (labelForItem) {
247
247
+
labelForItem(item).then((label) => {
248
248
+
const labelEl = tag.querySelector('.moderation-tag-label')
249
249
+
if (labelEl) { labelEl.textContent = label }
250
250
+
})
251
251
+
}
252
252
+
}
253
253
+
254
254
+
return h('div', {classList: 'moderation-section'}, [
255
255
+
h('div', {classList: 'moderation-section-title'}, [title]),
256
256
+
h('div', {classList: 'moderation-row'}, [input, addButton]),
257
257
+
list
258
258
+
])
259
259
+
}
260
260
+
261
261
+
const renderPanel = async () => {
262
262
+
const state = await getModerationState()
263
263
+
while (container.firstChild) { container.firstChild.remove() }
264
264
+
265
265
+
container.appendChild(h('p', {classList: 'moderation-note'}, [
266
266
+
'Local-only: saved in your browser and never broadcast.'
267
267
+
]))
268
268
+
269
269
+
container.appendChild(await buildSection({
270
270
+
title: 'Muted authors',
271
271
+
placeholder: 'Add author pubkey',
272
272
+
items: state.mutedAuthors,
273
273
+
onAdd: async (value) => {
274
274
+
await saveModerationState({
275
275
+
...state,
276
276
+
mutedAuthors: splitTextList([...state.mutedAuthors, value].join('\n'))
277
277
+
})
278
278
+
},
279
279
+
onRemove: async (value) => {
280
280
+
await saveModerationState({
281
281
+
...state,
282
282
+
mutedAuthors: state.mutedAuthors.filter(item => item !== value)
283
283
+
})
284
284
+
},
285
285
+
labelForItem: fetchAuthorLabel
286
286
+
}))
287
287
+
288
288
+
container.appendChild(await buildSection({
289
289
+
title: 'Blocked authors',
290
290
+
placeholder: 'Add author pubkey',
291
291
+
items: state.blockedAuthors,
292
292
+
onAdd: async (value) => {
293
293
+
window.__feedStatus?.('Blocking author…', { sticky: true })
294
294
+
const result = await addBlockedAuthor(value)
295
295
+
const removed = result?.purge?.removed ?? 0
296
296
+
const blobs = result?.purge?.blobs ?? 0
297
297
+
if (result?.purge) {
298
298
+
window.__feedStatus?.(`Blocked author. Removed ${removed} post${removed === 1 ? '' : 's'}, ${blobs} blob${blobs === 1 ? '' : 's'}.`)
299
299
+
} else {
300
300
+
window.__feedStatus?.('Blocked author.')
301
301
+
}
302
302
+
setTimeout(() => {
303
303
+
location.reload()
304
304
+
}, 600)
305
305
+
},
306
306
+
onRemove: async (value) => {
307
307
+
await removeBlockedAuthor(value)
308
308
+
},
309
309
+
labelForItem: fetchAuthorLabel
310
310
+
}))
311
311
+
312
312
+
container.appendChild(await buildSection({
313
313
+
title: 'Hidden posts',
314
314
+
placeholder: 'Add message hash',
315
315
+
items: state.hiddenHashes,
316
316
+
onAdd: async (value) => {
317
317
+
await saveModerationState({
318
318
+
...state,
319
319
+
hiddenHashes: splitTextList([...state.hiddenHashes, value].join('\n'))
320
320
+
})
321
321
+
},
322
322
+
onRemove: async (value) => {
323
323
+
await saveModerationState({
324
324
+
...state,
325
325
+
hiddenHashes: state.hiddenHashes.filter(item => item !== value)
326
326
+
})
327
327
+
},
328
328
+
labelForItem: fetchHiddenLabel
329
329
+
}))
330
330
+
331
331
+
container.appendChild(await buildSection({
332
332
+
title: 'Filtered keywords',
333
333
+
placeholder: 'Add keyword',
334
334
+
items: state.mutedWords,
335
335
+
onAdd: async (value) => {
336
336
+
await saveModerationState({
337
337
+
...state,
338
338
+
mutedWords: splitTextList([...state.mutedWords, value].join('\n'))
339
339
+
})
340
340
+
},
341
341
+
onRemove: async (value) => {
342
342
+
await saveModerationState({
343
343
+
...state,
344
344
+
mutedWords: state.mutedWords.filter(item => item !== value)
345
345
+
})
346
346
+
}
347
347
+
}))
348
348
+
}
349
349
+
350
350
+
await renderPanel()
351
351
+
return container
352
352
+
}
353
353
+
152
354
153
355
//const didweb = async () => {
154
356
// const input = h('input', {placeholder: 'https://yourwebsite.com/'})
···
191
393
queuePanel,
192
394
pushEverything,
193
395
pullEverything,
396
396
+
h('hr'),
397
397
+
h('p', ['Moderation']),
398
398
+
await moderationPanel(),
194
399
h('hr'),
195
400
h('p', ['Import Keypair']),
196
401
await editKey(),
+106
style.css
···
341
341
justify-content: flex-end;
342
342
}
343
343
344
344
+
.message-actions-mod {
345
345
+
display: inline-flex;
346
346
+
align-items: center;
347
347
+
gap: 4px;
348
348
+
position: absolute;
349
349
+
right: 0.75em;
350
350
+
bottom: 0.6em;
351
351
+
}
352
352
+
344
353
.edit-hint {
345
354
font-family: monospace;
346
355
}
···
376
385
.disabled {
377
386
opacity: 0.5;
378
387
pointer-events: auto;
388
388
+
}
389
389
+
390
390
+
.moderation-panel {
391
391
+
margin-top: 6px;
392
392
+
}
393
393
+
394
394
+
.moderation-note {
395
395
+
color: #777;
396
396
+
font-size: 0.9em;
397
397
+
}
398
398
+
399
399
+
.moderation-section {
400
400
+
margin-top: 10px;
401
401
+
}
402
402
+
403
403
+
.moderation-section-title {
404
404
+
font-weight: 600;
405
405
+
margin-bottom: 6px;
406
406
+
}
407
407
+
408
408
+
.moderation-row {
409
409
+
display: flex;
410
410
+
align-items: center;
411
411
+
gap: 6px;
412
412
+
flex-wrap: wrap;
413
413
+
}
414
414
+
415
415
+
.moderation-row .moderation-input {
416
416
+
flex: 1 1 220px;
417
417
+
min-width: 160px;
418
418
+
}
419
419
+
420
420
+
.moderation-tags {
421
421
+
margin-top: 6px;
422
422
+
display: flex;
423
423
+
align-items: center;
424
424
+
gap: 6px;
425
425
+
flex-wrap: wrap;
426
426
+
}
427
427
+
428
428
+
.moderation-tag {
429
429
+
display: inline-flex;
430
430
+
align-items: center;
431
431
+
gap: 6px;
432
432
+
padding: 2px 6px;
433
433
+
border-radius: 12px;
434
434
+
background: #f0f0f0;
435
435
+
border: 1px solid #e4e4e4;
436
436
+
font-size: 0.9em;
437
437
+
}
438
438
+
439
439
+
.moderation-tag-remove {
440
440
+
padding: 0 6px;
441
441
+
line-height: 1.2;
442
442
+
}
443
443
+
444
444
+
.moderation-actions {
445
445
+
margin-top: 6px;
446
446
+
display: flex;
447
447
+
align-items: center;
448
448
+
gap: 6px;
449
449
+
flex-wrap: wrap;
450
450
+
}
451
451
+
452
452
+
.message.moderation-hidden {
453
453
+
background: #f7f5f5;
454
454
+
border: 1px dashed #e4e4e4;
455
455
+
color: #666;
456
456
+
}
457
457
+
458
458
+
.moderation-hidden-title {
459
459
+
font-weight: 600;
460
460
+
}
461
461
+
462
462
+
.moderation-hidden-actions {
463
463
+
display: inline-flex;
464
464
+
align-items: center;
465
465
+
gap: 6px;
466
466
+
margin-left: 8px;
467
467
+
}
468
468
+
469
469
+
@media (prefers-color-scheme: dark) {
470
470
+
.message.moderation-hidden {
471
471
+
background: #1d1f21;
472
472
+
border-color: #2b2d30;
473
473
+
color: #c8c9cc;
474
474
+
}
475
475
+
476
476
+
.moderation-note {
477
477
+
color: #a5a7ab;
478
478
+
}
479
479
+
480
480
+
.moderation-tag {
481
481
+
background: #242629;
482
482
+
border-color: #2f3236;
483
483
+
color: #c8c9cc;
484
484
+
}
379
485
}
380
486
381
487
@media (prefers-color-scheme: dark) {
+12
-2
sync.js
···
1
1
import { apds } from 'apds'
2
2
+
import { isBlockedAuthor } from './moderation.js'
2
3
3
4
const activity = new Map()
4
5
···
106
107
console.warn('getPubkeys failed', err)
107
108
pubkeys = []
108
109
}
110
110
+
const filtered = []
111
111
+
for (const pubkey of pubkeys) {
112
112
+
if (!pubkey || pubkey.length !== 44) { continue }
113
113
+
if (await isBlockedAuthor(pubkey)) { continue }
114
114
+
filtered.push(pubkey)
115
115
+
}
116
116
+
pubkeys = filtered
109
117
lastRefresh = nowMs()
110
118
needsRebuild = true
111
119
await bootstrapActivity()
112
120
}
113
121
114
114
-
export const noteSeen = (pubkey) => {
122
122
+
export const noteSeen = async (pubkey) => {
115
123
if (!pubkey || pubkey.length !== 44) { return }
124
124
+
if (await isBlockedAuthor(pubkey)) { return }
116
125
const entry = getEntry(pubkey)
117
126
entry.lastSeen = nowMs()
118
127
needsRebuild = true
119
128
}
120
129
121
121
-
export const noteInterest = (pubkey) => {
130
130
+
export const noteInterest = async (pubkey) => {
122
131
if (!pubkey || pubkey.length !== 44) { return }
132
132
+
if (await isBlockedAuthor(pubkey)) { return }
123
133
const entry = getEntry(pubkey)
124
134
entry.lastInterest = nowMs()
125
135
needsRebuild = true
+6
websocket.js
···
1
1
import { apds } from 'apds'
2
2
import { render } from './render.js'
3
3
import { noteReceived, registerNetworkSenders } from './network_queue.js'
4
4
+
import { getModerationState, isBlockedAuthor } from './moderation.js'
4
5
5
6
const pubs = new Set()
6
7
const wsBackoff = new Map()
···
39
40
}
40
41
return
41
42
}
43
43
+
const author = msg.substring(0, 44)
44
44
+
if (await isBlockedAuthor(author)) { return }
42
45
await render.shouldWe(msg)
43
46
await apds.make(msg)
44
47
await apds.add(msg)
···
187
190
console.warn('pubkey failed', err)
188
191
selfPub = null
189
192
}
193
193
+
const moderation = await getModerationState()
194
194
+
const blocked = new Set(moderation.blockedAuthors || [])
190
195
for (const pub of p) {
196
196
+
if (blocked.has(pub)) { continue }
191
197
ws.send(pub)
192
198
if (selfPub && pub === selfPub) {
193
199
const latest = await apds.getLatest(pub)