a demonstration replicated social networking web app built with anproto wiredove.net/
social ed25519 protocols

add extensive moderation support

+792 -47
+71 -1
adder.js
··· 230 230 button.type = 'button' 231 231 button.className = 'new-posts-button' 232 232 button.addEventListener('click', async () => { 233 + if (state.statusMode) { return } 234 + const scrollEl = document.scrollingElement || document.documentElement || document.body 235 + if (scrollEl) { 236 + scrollEl.scrollTo({ top: 0, behavior: 'smooth' }) 237 + } else { 238 + window.scrollTo({ top: 0, behavior: 'smooth' }) 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 + if (state.statusMessage) { 252 + state.bannerButton.textContent = state.statusMessage 253 + state.bannerButton.disabled = true 254 + state.banner.style.display = 'block' 255 + return 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 + state.bannerButton.disabled = false 250 264 state.banner.style.display = 'block' 251 265 } 252 266 ··· 324 338 return true 325 339 } 326 340 341 + const getStatusState = () => { 342 + const controller = getController() 343 + const src = window.location.hash.substring(1) 344 + let state = controller.getFeed(src) 345 + if (state) { return state } 346 + if (!window.__statusFeedState) { 347 + const container = document.getElementById('scroller') 348 + if (!container) { return null } 349 + const fallback = { 350 + src: '__status__', 351 + container, 352 + entries: [], 353 + cursor: 0, 354 + seen: new Set(), 355 + rendered: new Set(), 356 + pending: [], 357 + pageSize: 0, 358 + latestVisibleTs: 0, 359 + oldestVisibleTs: 0, 360 + banner: null, 361 + bannerButton: null, 362 + statusMessage: '', 363 + statusMode: false, 364 + statusTimer: null 365 + } 366 + ensureBanner(fallback) 367 + window.__statusFeedState = fallback 368 + } 369 + return window.__statusFeedState 370 + } 371 + 372 + window.__feedStatus = (message, options = {}) => { 373 + const state = getStatusState() 374 + if (!state) { return false } 375 + const { timeout = 2500, sticky = false } = options 376 + if (state.statusTimer) { 377 + clearTimeout(state.statusTimer) 378 + state.statusTimer = null 379 + } 380 + state.statusMessage = message || '' 381 + state.statusMode = Boolean(message) 382 + updateBanner(state) 383 + if (state.statusMode && !sticky) { 384 + state.statusTimer = setTimeout(() => { 385 + state.statusMessage = '' 386 + state.statusMode = false 387 + state.statusTimer = null 388 + updateBanner(state) 389 + }, timeout) 390 + } 391 + return true 392 + } 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 - bannerButton: null 416 + bannerButton: null, 417 + statusMessage: '', 418 + statusMode: false, 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 + 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 + if (pubkey && pubkey.length === 44 && await isBlockedAuthor(pubkey)) { 36 + return roomReady 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 + const author = blob.substring(0, 44) 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 - "apds": "https://esm.sh/gh/evbogue/apds@78abedd/apds.js", 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 + import { apds } from 'apds' 2 + 3 + const MOD_KEY = 'moderation' 4 + const DEFAULT_STATE = { 5 + mutedAuthors: [], 6 + hiddenHashes: [], 7 + mutedWords: [], 8 + blockedAuthors: [] 9 + } 10 + 11 + let cachedState = null 12 + let cachedAt = 0 13 + const CACHE_TTL_MS = 2000 14 + 15 + const uniq = (list) => Array.from(new Set(list)) 16 + 17 + const cleanList = (list) => uniq( 18 + (Array.isArray(list) ? list : []) 19 + .map(item => (typeof item === 'string' ? item.trim() : '')) 20 + .filter(Boolean) 21 + ) 22 + 23 + const normalizeState = (state) => { 24 + const base = state && typeof state === 'object' ? state : {} 25 + return { 26 + mutedAuthors: cleanList(base.mutedAuthors), 27 + hiddenHashes: cleanList(base.hiddenHashes), 28 + mutedWords: cleanList(base.mutedWords).map(word => word.toLowerCase()), 29 + blockedAuthors: cleanList(base.blockedAuthors) 30 + } 31 + } 32 + 33 + const parseState = (raw) => { 34 + if (!raw) { return normalizeState(DEFAULT_STATE) } 35 + try { 36 + return normalizeState(JSON.parse(raw)) 37 + } catch { 38 + return normalizeState(DEFAULT_STATE) 39 + } 40 + } 41 + 42 + export const splitTextList = (text) => { 43 + if (!text) { return [] } 44 + return text 45 + .split(/\r?\n/) 46 + .map(line => line.trim()) 47 + .filter(Boolean) 48 + } 49 + 50 + export const getModerationState = async () => { 51 + const now = Date.now() 52 + if (cachedState && now - cachedAt < CACHE_TTL_MS) { 53 + return cachedState 54 + } 55 + const stored = await apds.get(MOD_KEY) 56 + cachedState = parseState(stored) 57 + cachedAt = now 58 + return cachedState 59 + } 60 + 61 + export const saveModerationState = async (nextState) => { 62 + const normalized = normalizeState(nextState) 63 + cachedState = normalized 64 + cachedAt = Date.now() 65 + await apds.put(MOD_KEY, JSON.stringify(normalized)) 66 + return normalized 67 + } 68 + 69 + const updateState = async (updateFn) => { 70 + const current = await getModerationState() 71 + return saveModerationState(updateFn(current)) 72 + } 73 + 74 + export const addMutedAuthor = async (author) => { 75 + if (!author) { return getModerationState() } 76 + return updateState(state => ({ 77 + ...state, 78 + mutedAuthors: uniq([...state.mutedAuthors, author]) 79 + })) 80 + } 81 + 82 + export const removeMutedAuthor = async (author) => { 83 + if (!author) { return getModerationState() } 84 + return updateState(state => ({ 85 + ...state, 86 + mutedAuthors: state.mutedAuthors.filter(item => item !== author) 87 + })) 88 + } 89 + 90 + export const addBlockedAuthor = async (author) => { 91 + if (!author) { return getModerationState() } 92 + const next = await updateState(state => ({ 93 + ...state, 94 + blockedAuthors: uniq([...state.blockedAuthors, author]) 95 + })) 96 + let purge = null 97 + if (typeof apds.purgeAuthor === 'function') { 98 + purge = await apds.purgeAuthor(author) 99 + } 100 + return { state: next, purge } 101 + } 102 + 103 + export const removeBlockedAuthor = async (author) => { 104 + if (!author) { return getModerationState() } 105 + return updateState(state => ({ 106 + ...state, 107 + blockedAuthors: state.blockedAuthors.filter(item => item !== author) 108 + })) 109 + } 110 + 111 + export const addHiddenHash = async (hash) => { 112 + if (!hash) { return getModerationState() } 113 + return updateState(state => ({ 114 + ...state, 115 + hiddenHashes: uniq([...state.hiddenHashes, hash]) 116 + })) 117 + } 118 + 119 + export const removeHiddenHash = async (hash) => { 120 + if (!hash) { return getModerationState() } 121 + return updateState(state => ({ 122 + ...state, 123 + hiddenHashes: state.hiddenHashes.filter(item => item !== hash) 124 + })) 125 + } 126 + 127 + export const shouldHideMessage = async ({ author, hash, body }) => { 128 + const state = await getModerationState() 129 + if (author && state.blockedAuthors.includes(author)) { 130 + return { hidden: true, reason: 'Blocked author', code: 'blocked-author' } 131 + } 132 + if (author && state.mutedAuthors.includes(author)) { 133 + return { hidden: true, reason: 'Muted author', code: 'muted-author' } 134 + } 135 + if (hash && state.hiddenHashes.includes(hash)) { 136 + return { hidden: true, reason: 'Hidden message', code: 'hidden-hash' } 137 + } 138 + if (body && state.mutedWords.length) { 139 + const lowered = body.toLowerCase() 140 + for (const word of state.mutedWords) { 141 + if (word && lowered.includes(word)) { 142 + return { hidden: true, reason: 'Filtered keyword', code: 'muted-word', word } 143 + } 144 + } 145 + } 146 + return { hidden: false } 147 + } 148 + 149 + export const isBlockedAuthor = async (author) => { 150 + if (!author || author.length !== 44) { return false } 151 + const state = await getModerationState() 152 + return state.blockedAuthors.includes(author) 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 + 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 - render.meta = async (blob, opened, hash, div) => { 666 + const findMessageTarget = (hash) => { 667 + if (!hash) { return null } 668 + const wrapper = document.getElementById(hash) 669 + if (!wrapper) { return null } 670 + return wrapper.querySelector('.message') || wrapper.querySelector('.message-shell') 671 + } 672 + 673 + const applyModerationStub = async ({ target, hash, author, moderation, blob, opened }) => { 674 + if (!target || !moderation || !moderation.hidden) { return } 675 + const stub = h('div', {classList: 'message moderation-hidden'}) 676 + const title = h('span', {classList: 'moderation-hidden-title'}, ['Hidden by local moderation']) 677 + const actions = h('span', {classList: 'moderation-hidden-actions'}) 678 + const showOnce = h('button', { 679 + onclick: async () => { 680 + await render.meta(blob, opened, hash, stub, { forceShow: true }) 681 + } 682 + }, ['Show once']) 683 + actions.appendChild(showOnce) 684 + 685 + if (moderation.code === 'muted-author') { 686 + const unmute = h('button', { 687 + onclick: async () => { 688 + await removeMutedAuthor(author) 689 + await render.meta(blob, opened, hash, stub) 690 + } 691 + }, ['Unmute']) 692 + actions.appendChild(unmute) 693 + } else if (moderation.code === 'hidden-hash') { 694 + const unhide = h('button', { 695 + onclick: async () => { 696 + await removeHiddenHash(hash) 697 + await render.meta(blob, opened, hash, stub) 698 + } 699 + }, ['Unhide']) 700 + actions.appendChild(unhide) 701 + } else if (moderation.code === 'muted-word') { 702 + actions.appendChild(h('a', {href: '#settings'}, ['Edit filters'])) 703 + } 704 + 705 + stub.appendChild(title) 706 + stub.appendChild(actions) 707 + target.replaceWith(stub) 708 + } 709 + 710 + const buildModerationControls = ({ author, hash, blob, opened }) => { 711 + const hide = h('a', { 712 + classList: 'material-symbols-outlined', 713 + title: 'Hide message', 714 + onclick: async (e) => { 715 + e.preventDefault() 716 + await addHiddenHash(hash) 717 + const target = findMessageTarget(hash) 718 + if (target) { 719 + await applyModerationStub({ 720 + target, 721 + hash, 722 + author, 723 + moderation: { hidden: true, reason: 'Hidden message', code: 'hidden-hash' }, 724 + blob, 725 + opened 726 + }) 727 + } else { 728 + location.reload() 729 + } 730 + } 731 + }, ['Visibility_Off']) 732 + 733 + const block = h('a', { 734 + classList: 'material-symbols-outlined', 735 + title: 'Block author', 736 + onclick: async (e) => { 737 + e.preventDefault() 738 + if (!confirm('Block this author and purge their local data?')) { return } 739 + window.__feedStatus?.('Blocking author…', { sticky: true }) 740 + const result = await addBlockedAuthor(author) 741 + const removed = result?.purge?.removed ?? 0 742 + const blobs = result?.purge?.blobs ?? 0 743 + if (result?.purge) { 744 + window.__feedStatus?.(`Blocked author. Removed ${removed} post${removed === 1 ? '' : 's'}, ${blobs} blob${blobs === 1 ? '' : 's'}.`) 745 + } else { 746 + window.__feedStatus?.('Blocked author.') 747 + } 748 + const wrappers = Array.from(document.querySelectorAll('.message-wrapper')) 749 + .filter(node => node.dataset?.author === author) 750 + if (wrappers.length) { 751 + wrappers.forEach(node => node.remove()) 752 + } else { 753 + const wrapper = document.getElementById(hash) 754 + if (wrapper) { 755 + wrapper.remove() 756 + } else { 757 + location.reload() 758 + } 759 + } 760 + } 761 + }, ['Block']) 762 + 763 + const mute = h('a', { 764 + classList: 'material-symbols-outlined', 765 + title: 'Mute author', 766 + onclick: async (e) => { 767 + e.preventDefault() 768 + await addMutedAuthor(author) 769 + const target = findMessageTarget(hash) 770 + if (target) { 771 + await applyModerationStub({ 772 + target, 773 + hash, 774 + author, 775 + moderation: { hidden: true, reason: 'Muted author', code: 'muted-author' }, 776 + blob, 777 + opened 778 + }) 779 + } else { 780 + location.reload() 781 + } 782 + } 783 + }, ['Person_Off']) 784 + 785 + return h('span', {classList: 'message-actions-mod'}, [hide, mute, block]) 786 + } 787 + 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 + 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 + let yaml = null 680 805 if (contentBlob) { 681 - const yaml = await apds.parseYaml(contentBlob) 682 - if (yaml && yaml.edit) { 683 - queueEditRefresh(yaml.edit) 684 - syncPrevious(yaml) 806 + yaml = await apds.parseYaml(contentBlob) 807 + } 685 808 686 - const ts = h('a', {href: '#' + hash}, [humanTime]) 687 - observeTimestamp(ts, timestamp) 809 + if (!options.forceShow) { 810 + const moderation = await shouldHideMessage({ 811 + author, 812 + hash, 813 + body: yaml?.body || '' 814 + }) 815 + if (moderation.hidden) { 816 + if (moderation.code === 'blocked-author') { 817 + const wrapper = document.getElementById(hash) 818 + if (wrapper) { wrapper.remove() } 819 + return 820 + } 821 + await applyModerationStub({ 822 + target: div, 823 + hash, 824 + author, 825 + moderation, 826 + blob, 827 + opened 828 + }) 829 + return 830 + } 831 + } 688 832 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 - const { raw, rawDiv } = buildRawControls(blob, opened, contentBlob) 691 - const right = buildRightMeta({ author, hash, blob, qrTarget, raw, ts }) 833 + if (yaml && yaml.edit) { 834 + queueEditRefresh(yaml.edit) 835 + syncPrevious(yaml) 692 836 693 - img.className = 'avatar' 694 - img.id = 'image' + contentHash 695 - img.style = 'float: left;' 837 + const ts = h('a', {href: '#' + hash}, [humanTime]) 838 + observeTimestamp(ts, timestamp) 696 839 697 - const summary = buildEditSummaryLine({ 698 - name: yaml.name, 699 - editHash: yaml.edit, 700 - author, 701 - nameId: 'name' + contentHash, 702 - }) 703 - updateEditSnippet(yaml.edit, summary) 704 - const summaryRow = buildEditSummaryRow({ 705 - avatarLink: h('a', {href: '#' + author}, [img]), 706 - summary 707 - }) 708 - const meta = buildEditMessageShell({ 709 - id: div.id, 710 - right, 711 - summaryRow, 712 - rawDiv, 713 - qrTarget 714 - }) 715 - if (div.dataset.ts) { 716 - meta.dataset.ts = div.dataset.ts 717 - } 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 + const { raw, rawDiv } = buildRawControls(blob, opened, contentBlob) 842 + const right = buildRightMeta({ author, hash, blob, qrTarget, raw, ts }) 718 843 719 - div.replaceWith(meta) 720 - await applyProfile(contentHash, yaml) 721 - return 844 + img.className = 'avatar' 845 + img.id = 'image' + contentHash 846 + img.style = 'float: left;' 847 + 848 + const summary = buildEditSummaryLine({ 849 + name: yaml.name, 850 + editHash: yaml.edit, 851 + author, 852 + nameId: 'name' + contentHash, 853 + }) 854 + updateEditSnippet(yaml.edit, summary) 855 + const summaryRow = buildEditSummaryRow({ 856 + avatarLink: h('a', {href: '#' + author}, [img]), 857 + summary 858 + }) 859 + const meta = buildEditMessageShell({ 860 + id: div.id, 861 + right, 862 + summaryRow, 863 + rawDiv, 864 + qrTarget 865 + }) 866 + meta.dataset.author = author 867 + if (div.dataset.ts) { 868 + meta.dataset.ts = div.dataset.ts 722 869 } 870 + 871 + div.replaceWith(meta) 872 + await applyProfile(contentHash, yaml) 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 + 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 + moderationControls, 777 930 editControls 778 931 ]) 779 932 ··· 942 1095 } 943 1096 944 1097 render.blob = async (blob, meta = {}) => { 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 - await render.meta(blob, opened, hash, div) 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 + const authorKey = blob?.substring(0, 44) 1147 + if (authorKey && await isBlockedAuthor(authorKey)) { 1148 + return 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 - noteSeen(blob.substring(0, 44)) 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 - 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 + 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 - noteInterest(pubkey) 108 + if (await isBlockedAuthor(pubkey)) { continue } 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 - noteInterest(src) 123 + if (await isBlockedAuthor(src)) { return } 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 - noteInterest(src.substring(0, 44)) 144 + const author = src.substring(0, 44) 145 + if (await isBlockedAuthor(author)) { return } 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 + 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 + const url = new URL(r.url) 11 + if (url.pathname.startsWith('/apds')) { 12 + const relPath = url.pathname.replace(/^\/apds/, '') || '/' 13 + const filePath = '/home/ev/apds' + relPath 14 + try { 15 + const data = await Deno.readFile(filePath) 16 + let type = contentType(filePath) 17 + if (!type) { 18 + if (filePath.endsWith('.js') || filePath.endsWith('.mjs')) { 19 + type = 'text/javascript' 20 + } else if (filePath.endsWith('.json')) { 21 + type = 'application/json' 22 + } 23 + } 24 + if (!type) { type = 'application/octet-stream' } 25 + return new Response(data, { 26 + status: 200, 27 + headers: { 'content-type': type } 28 + }) 29 + } catch (err) { 30 + if (err && err.name === 'NotFound') { 31 + return new Response('Not found', { status: 404 }) 32 + } 33 + return new Response('Error', { status: 500 }) 34 + } 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 + 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 + const moderationPanel = async () => { 154 + const container = h('div', {classList: 'moderation-panel'}) 155 + 156 + const fetchAuthorLabel = async (author) => { 157 + if (!author) { return 'Unknown author' } 158 + let label = author.substring(0, 10) 159 + try { 160 + const query = await apds.query(author) 161 + const entry = Array.isArray(query) ? query[0] : query 162 + if (entry && entry.opened) { 163 + const content = await apds.get(entry.opened.substring(13)) 164 + if (content) { 165 + const yaml = await apds.parseYaml(content) 166 + if (yaml && yaml.name) { 167 + label = `${yaml.name} (${author.substring(0, 6)})` 168 + } 169 + } 170 + } 171 + } catch {} 172 + return label 173 + } 174 + 175 + const fetchHiddenLabel = async (hash) => { 176 + if (!hash) { return 'Unknown message' } 177 + let label = hash.substring(0, 10) 178 + try { 179 + const blob = await apds.get(hash) 180 + let author = blob ? blob.substring(0, 44) : null 181 + let opened = null 182 + if (blob) { 183 + opened = await apds.open(blob) 184 + } 185 + const authorLabel = author ? await fetchAuthorLabel(author) : 'Unknown author' 186 + let snippet = '' 187 + if (opened) { 188 + const content = await apds.get(opened.substring(13)) 189 + if (content) { 190 + const yaml = await apds.parseYaml(content) 191 + if (yaml && yaml.body) { 192 + snippet = yaml.body.replace(/\s+/g, ' ').trim().substring(0, 32) 193 + } 194 + } 195 + } 196 + if (snippet) { 197 + label = `${authorLabel} · ${snippet}` 198 + } else { 199 + label = `${authorLabel} · ${hash.substring(0, 10)}` 200 + } 201 + } catch {} 202 + return label 203 + } 204 + 205 + const createTag = ({ label, onRemove }) => { 206 + const text = h('span', {classList: 'moderation-tag-label'}, [label]) 207 + const remove = h('button', { 208 + classList: 'moderation-tag-remove', 209 + onclick: onRemove 210 + }, ['×']) 211 + return h('span', {classList: 'moderation-tag'}, [text, remove]) 212 + } 213 + 214 + const buildSection = async ({ 215 + title, 216 + placeholder, 217 + items, 218 + onAdd, 219 + onRemove, 220 + labelForItem 221 + }) => { 222 + const input = h('input', { 223 + classList: 'moderation-input', 224 + placeholder 225 + }) 226 + const addButton = h('button', { 227 + onclick: async () => { 228 + const value = input.value.trim() 229 + if (!value) { return } 230 + input.value = '' 231 + await onAdd(value) 232 + await renderPanel() 233 + } 234 + }, ['Add']) 235 + 236 + const list = h('div', {classList: 'moderation-tags'}) 237 + for (const item of items) { 238 + const tag = createTag({ 239 + label: item, 240 + onRemove: async () => { 241 + await onRemove(item) 242 + await renderPanel() 243 + } 244 + }) 245 + list.appendChild(tag) 246 + if (labelForItem) { 247 + labelForItem(item).then((label) => { 248 + const labelEl = tag.querySelector('.moderation-tag-label') 249 + if (labelEl) { labelEl.textContent = label } 250 + }) 251 + } 252 + } 253 + 254 + return h('div', {classList: 'moderation-section'}, [ 255 + h('div', {classList: 'moderation-section-title'}, [title]), 256 + h('div', {classList: 'moderation-row'}, [input, addButton]), 257 + list 258 + ]) 259 + } 260 + 261 + const renderPanel = async () => { 262 + const state = await getModerationState() 263 + while (container.firstChild) { container.firstChild.remove() } 264 + 265 + container.appendChild(h('p', {classList: 'moderation-note'}, [ 266 + 'Local-only: saved in your browser and never broadcast.' 267 + ])) 268 + 269 + container.appendChild(await buildSection({ 270 + title: 'Muted authors', 271 + placeholder: 'Add author pubkey', 272 + items: state.mutedAuthors, 273 + onAdd: async (value) => { 274 + await saveModerationState({ 275 + ...state, 276 + mutedAuthors: splitTextList([...state.mutedAuthors, value].join('\n')) 277 + }) 278 + }, 279 + onRemove: async (value) => { 280 + await saveModerationState({ 281 + ...state, 282 + mutedAuthors: state.mutedAuthors.filter(item => item !== value) 283 + }) 284 + }, 285 + labelForItem: fetchAuthorLabel 286 + })) 287 + 288 + container.appendChild(await buildSection({ 289 + title: 'Blocked authors', 290 + placeholder: 'Add author pubkey', 291 + items: state.blockedAuthors, 292 + onAdd: async (value) => { 293 + window.__feedStatus?.('Blocking author…', { sticky: true }) 294 + const result = await addBlockedAuthor(value) 295 + const removed = result?.purge?.removed ?? 0 296 + const blobs = result?.purge?.blobs ?? 0 297 + if (result?.purge) { 298 + window.__feedStatus?.(`Blocked author. Removed ${removed} post${removed === 1 ? '' : 's'}, ${blobs} blob${blobs === 1 ? '' : 's'}.`) 299 + } else { 300 + window.__feedStatus?.('Blocked author.') 301 + } 302 + setTimeout(() => { 303 + location.reload() 304 + }, 600) 305 + }, 306 + onRemove: async (value) => { 307 + await removeBlockedAuthor(value) 308 + }, 309 + labelForItem: fetchAuthorLabel 310 + })) 311 + 312 + container.appendChild(await buildSection({ 313 + title: 'Hidden posts', 314 + placeholder: 'Add message hash', 315 + items: state.hiddenHashes, 316 + onAdd: async (value) => { 317 + await saveModerationState({ 318 + ...state, 319 + hiddenHashes: splitTextList([...state.hiddenHashes, value].join('\n')) 320 + }) 321 + }, 322 + onRemove: async (value) => { 323 + await saveModerationState({ 324 + ...state, 325 + hiddenHashes: state.hiddenHashes.filter(item => item !== value) 326 + }) 327 + }, 328 + labelForItem: fetchHiddenLabel 329 + })) 330 + 331 + container.appendChild(await buildSection({ 332 + title: 'Filtered keywords', 333 + placeholder: 'Add keyword', 334 + items: state.mutedWords, 335 + onAdd: async (value) => { 336 + await saveModerationState({ 337 + ...state, 338 + mutedWords: splitTextList([...state.mutedWords, value].join('\n')) 339 + }) 340 + }, 341 + onRemove: async (value) => { 342 + await saveModerationState({ 343 + ...state, 344 + mutedWords: state.mutedWords.filter(item => item !== value) 345 + }) 346 + } 347 + })) 348 + } 349 + 350 + await renderPanel() 351 + return container 352 + } 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 + h('hr'), 397 + h('p', ['Moderation']), 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 + .message-actions-mod { 345 + display: inline-flex; 346 + align-items: center; 347 + gap: 4px; 348 + position: absolute; 349 + right: 0.75em; 350 + bottom: 0.6em; 351 + } 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 + } 389 + 390 + .moderation-panel { 391 + margin-top: 6px; 392 + } 393 + 394 + .moderation-note { 395 + color: #777; 396 + font-size: 0.9em; 397 + } 398 + 399 + .moderation-section { 400 + margin-top: 10px; 401 + } 402 + 403 + .moderation-section-title { 404 + font-weight: 600; 405 + margin-bottom: 6px; 406 + } 407 + 408 + .moderation-row { 409 + display: flex; 410 + align-items: center; 411 + gap: 6px; 412 + flex-wrap: wrap; 413 + } 414 + 415 + .moderation-row .moderation-input { 416 + flex: 1 1 220px; 417 + min-width: 160px; 418 + } 419 + 420 + .moderation-tags { 421 + margin-top: 6px; 422 + display: flex; 423 + align-items: center; 424 + gap: 6px; 425 + flex-wrap: wrap; 426 + } 427 + 428 + .moderation-tag { 429 + display: inline-flex; 430 + align-items: center; 431 + gap: 6px; 432 + padding: 2px 6px; 433 + border-radius: 12px; 434 + background: #f0f0f0; 435 + border: 1px solid #e4e4e4; 436 + font-size: 0.9em; 437 + } 438 + 439 + .moderation-tag-remove { 440 + padding: 0 6px; 441 + line-height: 1.2; 442 + } 443 + 444 + .moderation-actions { 445 + margin-top: 6px; 446 + display: flex; 447 + align-items: center; 448 + gap: 6px; 449 + flex-wrap: wrap; 450 + } 451 + 452 + .message.moderation-hidden { 453 + background: #f7f5f5; 454 + border: 1px dashed #e4e4e4; 455 + color: #666; 456 + } 457 + 458 + .moderation-hidden-title { 459 + font-weight: 600; 460 + } 461 + 462 + .moderation-hidden-actions { 463 + display: inline-flex; 464 + align-items: center; 465 + gap: 6px; 466 + margin-left: 8px; 467 + } 468 + 469 + @media (prefers-color-scheme: dark) { 470 + .message.moderation-hidden { 471 + background: #1d1f21; 472 + border-color: #2b2d30; 473 + color: #c8c9cc; 474 + } 475 + 476 + .moderation-note { 477 + color: #a5a7ab; 478 + } 479 + 480 + .moderation-tag { 481 + background: #242629; 482 + border-color: #2f3236; 483 + color: #c8c9cc; 484 + } 379 485 } 380 486 381 487 @media (prefers-color-scheme: dark) {
+12 -2
sync.js
··· 1 1 import { apds } from 'apds' 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 + const filtered = [] 111 + for (const pubkey of pubkeys) { 112 + if (!pubkey || pubkey.length !== 44) { continue } 113 + if (await isBlockedAuthor(pubkey)) { continue } 114 + filtered.push(pubkey) 115 + } 116 + pubkeys = filtered 109 117 lastRefresh = nowMs() 110 118 needsRebuild = true 111 119 await bootstrapActivity() 112 120 } 113 121 114 - export const noteSeen = (pubkey) => { 122 + export const noteSeen = async (pubkey) => { 115 123 if (!pubkey || pubkey.length !== 44) { return } 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 - export const noteInterest = (pubkey) => { 130 + export const noteInterest = async (pubkey) => { 122 131 if (!pubkey || pubkey.length !== 44) { return } 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 + 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 + const author = msg.substring(0, 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 + const moderation = await getModerationState() 194 + const blocked = new Set(moderation.blockedAuthors || []) 190 195 for (const pub of p) { 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)