fedipod-bb 0.2.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/README.md +195 -0
- package/bin/fedipod-bb.mjs +113 -0
- package/fep-draft.md +110 -0
- package/package.json +26 -0
- package/site/bb.js +1644 -0
- package/site/index.html +301 -0
- package/site/markdown.mjs +0 -0
- package/site/masto.mjs +179 -0
- package/site/mine.mjs +48 -0
- package/site/oidc-session.mjs +6 -0
- package/site/pod.mjs +394 -0
- package/site/private.mjs +98 -0
- package/site/read.mjs +294 -0
- package/site/seen.mjs +64 -0
- package/src/access.mjs +79 -0
- package/src/credential.mjs +37 -0
- package/src/forum-agent.mjs +991 -0
- package/src/forum-intake.mjs +112 -0
- package/src/index.mjs +9 -0
- package/src/moderation.mjs +303 -0
- package/src/provision.mjs +37 -0
- package/src/publish.mjs +244 -0
- package/src/run.mjs +52 -0
- package/src/settings.mjs +186 -0
- package/src/topics.mjs +165 -0
- package/src/urls.mjs +90 -0
- package/src/wire.mjs +114 -0
package/site/index.html
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; connect-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; form-action 'self'">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7
|
+
<title>FediPod-BB</title>
|
|
8
|
+
<link rel="icon" href="/icons/forum.svg" type="image/svg+xml">
|
|
9
|
+
<link rel="stylesheet" href="/bb/tokens.css">
|
|
10
|
+
<style>
|
|
11
|
+
/* The site's shared tokens are in tokens.css (web/admin/tokens.css): the
|
|
12
|
+
palette, the 20px base and the family. Only this page's layout is here. */
|
|
13
|
+
/* The page is at least the window, less a rem, so the footer sits at the
|
|
14
|
+
bottom of a short page and below the content of a long one. */
|
|
15
|
+
body { font: 1rem/1.55 var(--font); background: var(--bg); color: var(--fg); max-width: 58rem;
|
|
16
|
+
margin: 1.5rem auto; padding: 0 1rem 1rem; min-height: calc(100vh - 1rem);
|
|
17
|
+
display: flex; flex-direction: column; box-sizing: border-box; }
|
|
18
|
+
main { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
|
|
19
|
+
/* Reading is a column; an index is a table and wants the width. */
|
|
20
|
+
main > h1, article.post, section.reply, .topline { max-width: 44rem; }
|
|
21
|
+
a { color: var(--accent); text-decoration: none; }
|
|
22
|
+
a:hover { text-decoration: underline; }
|
|
23
|
+
h1, h2 { color: var(--heading); }
|
|
24
|
+
header.top { display: flex; gap: 1rem; align-items: baseline; flex-wrap: wrap; border-bottom: 1px solid var(--line); padding-bottom: .6rem; margin-bottom: .35rem; }
|
|
25
|
+
header.top h1 { font-size: 1.4rem; margin: 0; }
|
|
26
|
+
header.top a { color: var(--heading); text-decoration: none; }
|
|
27
|
+
#mods-line { margin: .15rem 0 .9rem; display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
|
|
28
|
+
#mods-acts { margin-left: auto; display: flex; gap: .4rem; align-items: center; }
|
|
29
|
+
#mods-acts button { flex: 0 0 auto; font-size: 16px; font-weight: 600; background: var(--bg); color: var(--accent);
|
|
30
|
+
border: 1px solid var(--field-border); border-radius: 1rem; padding: .25rem .9rem; cursor: pointer; }
|
|
31
|
+
#mods-acts button:hover { background: var(--field-bg); }
|
|
32
|
+
/* The box search opens into keeps to the row rather than pushing the rest
|
|
33
|
+
of it onto a second line. */
|
|
34
|
+
#mods-acts input[type=text] { width: 11rem; max-width: 30vw; }
|
|
35
|
+
#mods-line .lead { color: var(--heading); font-weight: 600; }
|
|
36
|
+
#mods-line a { color: var(--accent); text-decoration: underline; }
|
|
37
|
+
header.top .who { margin-left: auto; display: flex; gap: .6rem; align-items: baseline; }
|
|
38
|
+
header.top .who #who-name { color: var(--heading); }
|
|
39
|
+
header.top .who button { font-weight: 600; background: var(--btn); color: var(--btn-text);
|
|
40
|
+
border: 1px solid var(--btn); border-radius: .4rem; padding: .3rem .7rem; cursor: pointer; }
|
|
41
|
+
header.top .who button:hover { background: var(--btn-hover); border-color: var(--btn-hover); }
|
|
42
|
+
nav.crumbs { color: var(--sub); margin: 0 0 1rem; }
|
|
43
|
+
nav.crumbs a { color: var(--accent); }
|
|
44
|
+
.dim { color: var(--sub); }
|
|
45
|
+
.hint { color: var(--sub); }
|
|
46
|
+
.topline { display: flex; gap: 1rem; align-items: baseline; justify-content: space-between; flex-wrap: wrap; }
|
|
47
|
+
.topline h1 { margin: 0; }
|
|
48
|
+
/* The same button as the ones on a post: what it opens is the same box. */
|
|
49
|
+
.topline button, p.chips button.new { flex: 0 0 auto; font-size: 16px; font-weight: 600;
|
|
50
|
+
padding: .3rem .8rem; border-radius: .3rem; background: transparent; color: var(--accent);
|
|
51
|
+
border: 1px solid var(--line); }
|
|
52
|
+
.topline button:hover, p.chips button.new:hover { background: var(--field-bg); }
|
|
53
|
+
/* The row's own controls sit together at its right end, whatever the
|
|
54
|
+
categories in front of them do. */
|
|
55
|
+
p.chips .rowend { margin-left: auto; display: flex; gap: .4rem; align-items: center; }
|
|
56
|
+
p.chips .rowend button.new { margin-left: 0; }
|
|
57
|
+
p.chips button.new { margin-left: auto; }
|
|
58
|
+
input[type=file] { font: inherit; font-size: 16px; }
|
|
59
|
+
.filelabel { font-weight: 600; }
|
|
60
|
+
p.empty.row { display: flex; gap: .8rem; align-items: center; flex-wrap: wrap; }
|
|
61
|
+
p.empty.row button.new { margin-left: 0; }
|
|
62
|
+
/* The category chips are links: each one is an address for that view. */
|
|
63
|
+
a.chip { font-size: 16px; font-weight: 600; padding: .25rem .8rem; border-radius: 1rem;
|
|
64
|
+
border: 1px solid var(--line); color: var(--accent); text-decoration: none; }
|
|
65
|
+
a.chip:hover { background: var(--field-bg); text-decoration: none; }
|
|
66
|
+
a.chip.on { background: var(--btn); border-color: var(--btn); color: var(--btn-text); }
|
|
67
|
+
/* The index scrolls, not the page: the window keeps its header, its
|
|
68
|
+
categories and its footer while a long list moves under them. */
|
|
69
|
+
.scroll { overflow: auto; flex: 1 1 auto; min-height: 0; }
|
|
70
|
+
table.index thead th { position: sticky; top: 0; background: var(--bg); z-index: 1; }
|
|
71
|
+
table.index { border-collapse: collapse; width: 100%; font-size: 1rem; table-layout: auto; }
|
|
72
|
+
/* A rem of air at both ends of every row, so no word sits on an edge. */
|
|
73
|
+
/* A heading that sorts is a button, and looks like the heading it is. */
|
|
74
|
+
table.index th button.sort { font: inherit; font-size: inherit; font-weight: 600; color: var(--sub);
|
|
75
|
+
background: none; border: none; padding: 0; cursor: pointer; }
|
|
76
|
+
table.index th button.sort:hover { color: var(--accent); text-decoration: underline; }
|
|
77
|
+
table.index th { text-align: left; font-weight: 600; color: var(--sub); padding: .4rem .7rem;
|
|
78
|
+
border-bottom: 1px solid var(--line); white-space: nowrap; }
|
|
79
|
+
table.index td { padding: .55rem .7rem; border-bottom: 1px solid var(--line); vertical-align: top; }
|
|
80
|
+
table.index th:first-child, table.index td:first-child { padding-left: 1rem; }
|
|
81
|
+
table.index th:last-child, table.index td:last-child { padding-right: 1rem; }
|
|
82
|
+
/* Every other row on a ground of its own, so a long line is easy to follow
|
|
83
|
+
across to its date. */
|
|
84
|
+
table.index tbody tr:nth-child(even) { background: var(--stripe); }
|
|
85
|
+
/* A pinned topic is marked with an emoji: a star across the whole forum, a
|
|
86
|
+
pin within one category. The label is there for anyone not reading by eye. */
|
|
87
|
+
.pin { font-size: 1.05rem; line-height: 1; }
|
|
88
|
+
|
|
89
|
+
/* The topic takes the room; the rest are as wide as their words and no wider. */
|
|
90
|
+
table.index td:first-child, table.index th:first-child { width: 100%; font-weight: 600; }
|
|
91
|
+
table.index td:not(:first-child), table.index th:not(:first-child) { white-space: nowrap; }
|
|
92
|
+
table.index td:last-child, table.index th:last-child { text-align: right; }
|
|
93
|
+
/* A handle can run as long as the name of the topic itself. The column it is
|
|
94
|
+
in keeps to its width, the name takes the rest, and the whole handle is
|
|
95
|
+
still there to read on the cell. */
|
|
96
|
+
table.index td .who { display: inline-block; max-width: 22ch; overflow: hidden; text-overflow: ellipsis;
|
|
97
|
+
white-space: nowrap; vertical-align: bottom; }
|
|
98
|
+
ul.list { list-style: none; padding: 0; margin: 0; }
|
|
99
|
+
ul.list li { padding: .8rem 0; border-bottom: 1px solid var(--line); }
|
|
100
|
+
/* A list of things to change is not a list of things to read: no rules
|
|
101
|
+
between the rows, and each row stays one row. */
|
|
102
|
+
ul.list.bare li { border-bottom: none; padding: .35rem 0; }
|
|
103
|
+
/* A settings row stays one row: the field gives up width, the buttons and
|
|
104
|
+
the handle keep theirs. */
|
|
105
|
+
ul.list.bare .row, section.reply .row.oneline { margin: .2rem 0; flex-wrap: nowrap; align-items: center; }
|
|
106
|
+
ul.list.bare .row input[type=text], section.reply .row.oneline input[type=text] {
|
|
107
|
+
width: auto; flex: 1 1 8rem; min-width: 5rem; }
|
|
108
|
+
ul.list.bare .row .dim, section.reply .row.oneline .dim { white-space: nowrap; }
|
|
109
|
+
ul.list.bare .row button, section.reply .row.oneline button { flex: 0 0 auto; }
|
|
110
|
+
ul.list li .title { font-weight: 700; font-size: 1.15rem; color: var(--heading); }
|
|
111
|
+
|
|
112
|
+
ul.list li .meta { color: var(--sub); }
|
|
113
|
+
article.post { padding: 1rem 1.1rem; margin: .8rem 0; border: 1px solid var(--line); border-radius: .4rem; background: var(--bg); }
|
|
114
|
+
article.post .who { color: var(--sub); font-size: 16px; display: flex; gap: .6rem; flex-wrap: wrap; justify-content: space-between; align-items: baseline; }
|
|
115
|
+
article.post .who .when { margin-left: auto; display: flex; gap: .6rem; }
|
|
116
|
+
article.post .acts { display: flex; gap: .5rem; margin-top: .7rem; justify-content: flex-end; }
|
|
117
|
+
p.chips { display: flex; gap: .4rem; align-items: center; flex-wrap: wrap; }
|
|
118
|
+
button.chip { font: inherit; font-size: 16px; padding: .25rem .8rem; border-radius: 1rem;
|
|
119
|
+
border: 1px solid var(--line); background: transparent; color: var(--accent); font-weight: 600; }
|
|
120
|
+
button.chip:hover { background: var(--field-bg); }
|
|
121
|
+
button.chip.on { background: var(--btn); border-color: var(--btn); color: var(--btn-text); }
|
|
122
|
+
.acts.mod { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; margin: .6rem 0; }
|
|
123
|
+
.acts.mod button { font-size: 16px; font-weight: 600; padding: .3rem .8rem; border-radius: .3rem;
|
|
124
|
+
background: transparent; color: var(--accent); border: 1px solid var(--line); }
|
|
125
|
+
.acts.mod button:hover { background: var(--field-bg); }
|
|
126
|
+
/* The two that cannot be undone by pressing them again: closing a topic and
|
|
127
|
+
deleting one. Marked so a moderator's eye catches them before the click,
|
|
128
|
+
not after — they sit together at the end of the row for the same reason. */
|
|
129
|
+
.acts.mod button.grave { border-color: var(--danger); color: var(--danger); }
|
|
130
|
+
.acts.mod button.grave:hover { background: var(--danger); color: var(--btn-text); border-color: var(--danger); }
|
|
131
|
+
article.post .acts button { font-size: 16px; font-weight: 600; padding: .3rem .8rem; border-radius: .3rem;
|
|
132
|
+
background: transparent; color: var(--accent); border: 1px solid var(--line); }
|
|
133
|
+
article.post .acts button:hover { background: var(--field-bg); }
|
|
134
|
+
/* The vote you cast, told apart from the one you did not by more than
|
|
135
|
+
colour: it is filled, and its border is the accent rather than the
|
|
136
|
+
divider line. */
|
|
137
|
+
article.post .acts button.voted { background: var(--field-bg); border-color: var(--accent); color: var(--fg); }
|
|
138
|
+
.votes { color: var(--fg); font-weight: 600; }
|
|
139
|
+
article.post .who b { color: var(--fg); }
|
|
140
|
+
/* A post's title and its words are one thing: half a line between them,
|
|
141
|
+
and the browser's own heading margins do not get a say. */
|
|
142
|
+
article.post h2 { font-size: 1.25rem; margin: 1rem 0 0; }
|
|
143
|
+
article.post h2 + .body { margin-top: .3rem; }
|
|
144
|
+
article.post .body { margin-top: .4rem; overflow-wrap: anywhere; }
|
|
145
|
+
article.post .body > :first-child { margin-top: 0; }
|
|
146
|
+
article.post .body > :last-child { margin-bottom: 0; }
|
|
147
|
+
article.post.waiting { background: var(--surface); border: 1px solid var(--line); border-radius: .4rem; padding: 1rem; }
|
|
148
|
+
article.post.gone .body { color: var(--sub); font-style: italic; }
|
|
149
|
+
article.post.picked { border-color: var(--accent); border-width: 2px; }
|
|
150
|
+
/* A reply sits under what it answers, with a line back to it. */
|
|
151
|
+
.nest { margin-left: calc(var(--depth) * 1.4rem); border-left: 2px solid var(--line); padding-left: .8rem; }
|
|
152
|
+
@media (max-width: 40rem) { .nest { margin-left: calc(var(--depth) * .5rem); } }
|
|
153
|
+
article.post .body img { max-width: 100%; height: auto; border-radius: .3rem; margin: .4rem 0; }
|
|
154
|
+
article.post .body pre { overflow-x: auto; background: var(--field-bg); padding: .7rem .8rem; border-radius: .3rem; }
|
|
155
|
+
article.post .body code { background: var(--field-bg); padding: .05em .35em; border-radius: .2rem; }
|
|
156
|
+
article.post .body blockquote { margin: .5rem 0; padding-left: .8rem; border-left: 3px solid var(--line); color: var(--sub); }
|
|
157
|
+
ul.list li span.title { font-weight: 700; font-size: 1.15rem; color: var(--heading); }
|
|
158
|
+
/* A box in front of the page can be moved: its heading is the handle, so
|
|
159
|
+
a reader who needs to see what is behind it drags it aside. */
|
|
160
|
+
dialog h2 { cursor: grab; touch-action: none; }
|
|
161
|
+
dialog h2:active { cursor: grabbing; }
|
|
162
|
+
dialog.moved { margin: 0; position: fixed; }
|
|
163
|
+
dialog { border: 2px solid var(--dialog-edge); border-radius: .5rem; padding: 0; background: var(--bg); color: var(--fg);
|
|
164
|
+
width: min(38rem, calc(100vw - 2rem)); box-shadow: 0 .6rem 2rem rgba(0, 0, 0, .35); }
|
|
165
|
+
dialog::backdrop { background: rgba(0, 0, 0, .45); }
|
|
166
|
+
/* What came of pressing a button in a panel, where the button is. */
|
|
167
|
+
#panel-err.said, p.said { color: var(--ok); }
|
|
168
|
+
/* The settings panels put a label, its field and the buttons side by side,
|
|
169
|
+
which needs more width than a reply box does. */
|
|
170
|
+
#panel { width: min(48rem, calc(100vw - 2rem)); }
|
|
171
|
+
section.reply { padding: 1.1rem 1.2rem; background: var(--surface); }
|
|
172
|
+
section.reply h2 { margin-top: 0; }
|
|
173
|
+
ul.list li article.post { margin: .6rem 0 .2rem; }
|
|
174
|
+
ul.list li article.post .body { max-height: 12rem; overflow: hidden; }
|
|
175
|
+
section.reply h2 { font-size: 1.2rem; margin: 0 0 .5rem; }
|
|
176
|
+
textarea, input[type=text] { font: inherit; padding: .55rem .6rem; box-sizing: border-box; border: 1px solid var(--field-border); border-radius: .3rem; background: var(--field-bg); color: var(--field-text); }
|
|
177
|
+
textarea { width: 100%; min-height: 6rem; }
|
|
178
|
+
input[type=text] { width: 18rem; max-width: 100%; }
|
|
179
|
+
::placeholder { color: var(--sub); opacity: 1; }
|
|
180
|
+
textarea:focus, input:focus, button:focus { outline: 2px solid var(--focus); outline-offset: 2px; }
|
|
181
|
+
button { font: inherit; font-weight: 600; padding: .6rem 1.3rem; border-radius: .4rem; border: 1px solid var(--btn); background: var(--btn); color: var(--btn-text); cursor: pointer; }
|
|
182
|
+
button:hover { background: var(--btn-hover); border-color: var(--btn-hover); }
|
|
183
|
+
button.primary { font-weight: 700; }
|
|
184
|
+
button.link { background: none; border: none; color: var(--accent); font-weight: normal; padding: 0 .2rem; }
|
|
185
|
+
button.plain { background: transparent; color: var(--accent); border-color: var(--line); }
|
|
186
|
+
button.plain:hover { background: var(--field-bg); }
|
|
187
|
+
button[disabled] { opacity: .5; cursor: not-allowed; }
|
|
188
|
+
a.big { display: inline-block; font-weight: 700; padding: .7rem 1.3rem; border-radius: .4rem; background: var(--btn); color: var(--btn-text); text-decoration: none; }
|
|
189
|
+
a.big:hover { background: var(--btn-hover); }
|
|
190
|
+
.row { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; margin: .6rem 0; }
|
|
191
|
+
#signin-handle, #fedi-handle { flex: 1 1 20rem; width: auto; }
|
|
192
|
+
/* What has arrived since this reader last opened the topic. A WORD, not a
|
|
193
|
+
colour: a reader who cannot tell the green apart still reads "New". */
|
|
194
|
+
.badge { display: inline-block; font-size: 16px; font-weight: 700; line-height: 1.3;
|
|
195
|
+
padding: .1rem .55rem; margin-left: .5rem; border-radius: 1rem;
|
|
196
|
+
background: var(--btn); color: var(--btn-text); }
|
|
197
|
+
/* Off the page until a keyboard reaches it, then the first thing on it. */
|
|
198
|
+
.skip { position: absolute; left: -9999px; }
|
|
199
|
+
.skip:focus { left: 1rem; top: 1rem; z-index: 10; background: var(--btn); color: var(--btn-text);
|
|
200
|
+
padding: .6rem 1rem; border-radius: .4rem; text-decoration: none; }
|
|
201
|
+
/* Said to a screen reader and not shown: state the page already makes plain
|
|
202
|
+
some other way — what is loading, how much arrived, that a link was copied. */
|
|
203
|
+
.vh { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden;
|
|
204
|
+
clip-path: inset(50%); white-space: nowrap; border: 0; }
|
|
205
|
+
section.reply label[for=reply-text] { display: block; margin: .7rem 0 .3rem; }
|
|
206
|
+
.err { color: var(--err); }
|
|
207
|
+
.err:empty { display: none; }
|
|
208
|
+
[hidden] { display: none !important; }
|
|
209
|
+
code { background: var(--field-bg); color: var(--field-text); padding: .05em .35em; border-radius: .2rem; }
|
|
210
|
+
p.empty { color: var(--sub); }
|
|
211
|
+
/* At the foot of the page, centred, clear of whatever is above it. */
|
|
212
|
+
footer.foot { margin: 1rem auto 0; color: var(--sub); font-size: 16px; text-align: center; }
|
|
213
|
+
</style>
|
|
214
|
+
</head>
|
|
215
|
+
<body>
|
|
216
|
+
<a class="skip" href="#main">Skip to content</a>
|
|
217
|
+
<header class="top">
|
|
218
|
+
<h1><a id="forum-link" href="#/">Forum</a></h1>
|
|
219
|
+
<span class="who">
|
|
220
|
+
<span id="who-name"></span>
|
|
221
|
+
<button id="who-act">Sign in</button>
|
|
222
|
+
</span>
|
|
223
|
+
</header>
|
|
224
|
+
<p class="dim" id="mods-line"><span id="mods-who"></span><span id="mods-acts"></span></p>
|
|
225
|
+
<nav class="crumbs" id="crumbs" aria-label="Breadcrumb"></nav>
|
|
226
|
+
<p class="vh" id="say" role="status" aria-live="polite"></p>
|
|
227
|
+
<main id="main" tabindex="-1">
|
|
228
|
+
<p class="dim">Loading…</p>
|
|
229
|
+
</main>
|
|
230
|
+
<p class="row" id="reply-row" hidden><button id="reply-open" class="primary">Reply</button></p>
|
|
231
|
+
<dialog id="panel" aria-labelledby="panel-title">
|
|
232
|
+
<section class="reply">
|
|
233
|
+
<h2 id="panel-title">Settings</h2>
|
|
234
|
+
<div id="panel-body"></div>
|
|
235
|
+
<p class="err" id="panel-err" role="alert"></p>
|
|
236
|
+
<p class="row"><button id="panel-close" class="plain">Close</button></p>
|
|
237
|
+
</section>
|
|
238
|
+
</dialog>
|
|
239
|
+
<!-- Signing in on its own, for any action that needs an account rather than
|
|
240
|
+
only for writing. Whatever the reader was trying to do is remembered and
|
|
241
|
+
carried out once they are back. -->
|
|
242
|
+
<dialog id="signin-dlg" aria-labelledby="signin-title">
|
|
243
|
+
<section class="reply">
|
|
244
|
+
<h2 id="signin-title">Sign in</h2>
|
|
245
|
+
<p class="hint" id="signin-why" role="status"></p>
|
|
246
|
+
<label for="signin-handle">Your handle or WebID</label>
|
|
247
|
+
<div class="row">
|
|
248
|
+
<input type="text" id="signin-handle" placeholder="@you@server or @you@pod or your WebID" autocomplete="off">
|
|
249
|
+
<button id="signin-go" class="primary">Sign in</button>
|
|
250
|
+
</div>
|
|
251
|
+
<p class="hint" id="signin-note" role="status"></p>
|
|
252
|
+
<p class="hint">You can participate if you have a pod or a Fediverse account.
|
|
253
|
+
If you have only one or neither, a FediPod account will give you both.</p>
|
|
254
|
+
<p class="row"><a class="big" href="/new-account">Get a FediPod account</a></p>
|
|
255
|
+
<div class="row"><button id="signin-cancel" class="plain">Cancel</button></div>
|
|
256
|
+
</section>
|
|
257
|
+
</dialog>
|
|
258
|
+
<dialog id="reply-dlg" aria-labelledby="reply-title">
|
|
259
|
+
<section class="reply" id="reply">
|
|
260
|
+
<h2 id="reply-title">Reply</h2>
|
|
261
|
+
<div id="reply-signed-out">
|
|
262
|
+
<label for="fedi-handle">Your handle or WebID</label>
|
|
263
|
+
<div class="row">
|
|
264
|
+
<input type="text" id="fedi-handle" placeholder="@you@server or @you@pod or your WebID" autocomplete="off">
|
|
265
|
+
<button id="fedi-login">Sign in</button>
|
|
266
|
+
</div>
|
|
267
|
+
<p class="hint" id="fedi-note" role="status"></p>
|
|
268
|
+
<p class="hint">You can participate if you have a pod or a Fediverse account.
|
|
269
|
+
If you have only one or neither, a FediPod account will give you both.</p>
|
|
270
|
+
<p class="row"><a id="fedipod-signup" class="big" href="/new-account">Get a FediPod account</a></p>
|
|
271
|
+
</div>
|
|
272
|
+
<div id="reply-signed-in" hidden>
|
|
273
|
+
<p class="hint">Posting as <b id="reply-as"></b> <button id="masto-logout" class="link">Sign out</button></p>
|
|
274
|
+
<div class="row" id="topic-title-row" hidden>
|
|
275
|
+
<label for="topic-title">Topic name</label>
|
|
276
|
+
<input type="text" id="topic-title" placeholder="What this topic is about" autocomplete="off">
|
|
277
|
+
</div>
|
|
278
|
+
<label for="reply-text" id="reply-text-label">Your reply</label>
|
|
279
|
+
<textarea id="reply-text" placeholder="Write your reply"></textarea>
|
|
280
|
+
<p class="row">
|
|
281
|
+
<label class="filelabel" for="reply-image">Add an image</label>
|
|
282
|
+
<input type="file" id="reply-image" accept="image/*">
|
|
283
|
+
<span class="hint" id="reply-image-said"></span>
|
|
284
|
+
</p>
|
|
285
|
+
<div class="row">
|
|
286
|
+
<button id="reply-cancel" class="plain">Cancel</button>
|
|
287
|
+
<button id="reply-send" class="primary">Post reply</button>
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
<p class="hint" id="reply-private" hidden></p>
|
|
291
|
+
<p class="err" id="reply-err" role="alert"></p>
|
|
292
|
+
</section>
|
|
293
|
+
</dialog>
|
|
294
|
+
<footer class="foot">
|
|
295
|
+
Powered by <a href="https://github.com/jeff-zucker/FediPod" rel="noopener">FediPod-BB</a>.
|
|
296
|
+
This site adheres to the <a href="https://solidproject.org/code_of_conduct" rel="noopener">Solid Code of Conduct</a>;
|
|
297
|
+
please use the report button as needed.
|
|
298
|
+
</footer>
|
|
299
|
+
<script type="module" src="/bb/bb.js"></script>
|
|
300
|
+
</body>
|
|
301
|
+
</html>
|
|
Binary file
|
package/site/masto.mjs
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// masto.mjs — replying from a Mastodon account, in the browser: the same
|
|
2
|
+
// OAuth steps the FediPod agent takes for a connected account (register an
|
|
3
|
+
// app on the reader's server, send them to approve it, exchange the code),
|
|
4
|
+
// then posting through that server's API. The token stays in this browser
|
|
5
|
+
// and goes nowhere else.
|
|
6
|
+
|
|
7
|
+
const SCOPES = 'read:accounts read:search write:statuses';
|
|
8
|
+
const CLIENT_NAME = 'FediPod-BB';
|
|
9
|
+
const STATE_TTL_MS = 10 * 60_000;
|
|
10
|
+
|
|
11
|
+
export const cleanHost = (s) => {
|
|
12
|
+
const h = String(s || '').trim().replace(/^https?:\/\//u, '').replace(/\/.*$/u, '').toLowerCase();
|
|
13
|
+
return /^[a-z0-9.-]+\.[a-z0-9-]+(:\d+)?$/u.test(h) ? h : null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// The server in a Fediverse handle: @you@host, you@host, or the address of a
|
|
17
|
+
// profile page there.
|
|
18
|
+
export const hostOfHandle = (s) => {
|
|
19
|
+
const t = String(s || '').trim();
|
|
20
|
+
const m = /^@?[^@\s/]+@([^@\s/]+)$/u.exec(t);
|
|
21
|
+
return cleanHost(m ? m[1] : t);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// The actor id behind a Fediverse handle, asked of the handle's own server:
|
|
25
|
+
// WebFinger is answered from any origin (RFC 7033), so the page may ask it
|
|
26
|
+
// directly. An address typed in full is already an actor id.
|
|
27
|
+
export async function actorOfHandle(input, f = globalThis.fetch.bind(globalThis)) {
|
|
28
|
+
const typed = String(input || '').trim();
|
|
29
|
+
if (/^https?:\/\//u.test(typed)) return typed;
|
|
30
|
+
const host = hostOfHandle(typed);
|
|
31
|
+
const name = typed.replace(/^@/u, '').split('@')[0];
|
|
32
|
+
if (!host || !name || name === host) throw new Error('a handle looks like @you@their.server');
|
|
33
|
+
const at = `@${name}@${host}`;
|
|
34
|
+
const r = await f(`https://${host}/.well-known/webfinger?resource=${encodeURIComponent(`acct:${name}@${host}`)}`,
|
|
35
|
+
{ headers: { accept: 'application/jrd+json, application/json' } }).catch(() => null);
|
|
36
|
+
if (!r?.ok) throw new Error(`${host} does not know ${at}`);
|
|
37
|
+
const jrd = await r.json().catch(() => null);
|
|
38
|
+
const self = (jrd?.links || []).find(l => l.rel === 'self' && /activity\+json|ld\+json/u.test(l.type || ''));
|
|
39
|
+
if (!self?.href) throw new Error(`${host} did not say where ${at} lives`);
|
|
40
|
+
return self.href;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// What a reader's server speaks — asked of the Gateway, not of the server:
|
|
44
|
+
// a page may not probe a stranger's server from the browser. 'mastodon-api'
|
|
45
|
+
// (Mastodon, Pleroma, Akkoma, GoToSocial, a FediPod agent) signs in here;
|
|
46
|
+
// 'lemmy' takes part by its community address; 'fedipod' is an account at
|
|
47
|
+
// this Gateway, whose client is the FediPod app.
|
|
48
|
+
export async function serverKind(host, front, f = globalThis.fetch.bind(globalThis), handle = '') {
|
|
49
|
+
try {
|
|
50
|
+
const q = `host=${encodeURIComponent(host)}${handle ? `&handle=${encodeURIComponent(handle)}` : ''}`;
|
|
51
|
+
const r = await f(`${front}/api/server?${q}`, { headers: { accept: 'application/json' } });
|
|
52
|
+
if (!r.ok) return { kind: 'unknown', host };
|
|
53
|
+
return await r.json();
|
|
54
|
+
} catch { return { kind: 'unknown', host }; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class MastoLogin {
|
|
58
|
+
// `storage` is localStorage-shaped; `fetch` is the page's.
|
|
59
|
+
constructor({ fetch: f = globalThis.fetch.bind(globalThis), storage, redirectUri }) {
|
|
60
|
+
this.fetch = f;
|
|
61
|
+
this.storage = storage;
|
|
62
|
+
this.redirectUri = redirectUri;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_read(k) { try { return JSON.parse(this.storage.getItem('bb:' + k) || 'null'); } catch { return null; } }
|
|
66
|
+
_write(k, v) { this.storage.setItem('bb:' + k, JSON.stringify(v)); }
|
|
67
|
+
_drop(k) { this.storage.removeItem('bb:' + k); }
|
|
68
|
+
|
|
69
|
+
account() { return this._read('account'); }
|
|
70
|
+
signOut() { this._drop('account'); }
|
|
71
|
+
|
|
72
|
+
async _json(res, host) {
|
|
73
|
+
const body = await res.json().catch(() => ({}));
|
|
74
|
+
if (!res.ok) throw new Error(body.error_description || body.error || `${host} answered ${res.status}`);
|
|
75
|
+
return body;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async appFor(host) {
|
|
79
|
+
const apps = this._read('apps') || {};
|
|
80
|
+
const known = apps[host];
|
|
81
|
+
if (known?.redirectUri === this.redirectUri && known.clientId) return known;
|
|
82
|
+
const res = await this.fetch(`https://${host}/api/v1/apps`, {
|
|
83
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
84
|
+
body: JSON.stringify({ client_name: CLIENT_NAME, redirect_uris: this.redirectUri, scopes: SCOPES, website: 'https://github.com/jeff-zucker/FediPod' }),
|
|
85
|
+
});
|
|
86
|
+
const body = await this._json(res, host);
|
|
87
|
+
if (!body.client_id || !body.client_secret) throw new Error(`${host} registered no client`);
|
|
88
|
+
const app = { host, redirectUri: this.redirectUri, clientId: body.client_id, clientSecret: body.client_secret };
|
|
89
|
+
this._write('apps', { ...apps, [host]: app });
|
|
90
|
+
return app;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Where to send the reader. The state is single-use and short-lived, so a
|
|
94
|
+
// code handed back to this page by anyone else binds nothing.
|
|
95
|
+
async begin(hostInput) {
|
|
96
|
+
const host = cleanHost(hostInput);
|
|
97
|
+
if (!host) throw new Error('that is not a server address');
|
|
98
|
+
const app = await this.appFor(host);
|
|
99
|
+
const bytes = new Uint8Array(24);
|
|
100
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
101
|
+
const state = [...bytes].map(b => b.toString(16).padStart(2, '0')).join('');
|
|
102
|
+
this._write('pending', { state, host, at: Date.now() });
|
|
103
|
+
const u = new URL(`https://${host}/oauth/authorize`);
|
|
104
|
+
u.searchParams.set('client_id', app.clientId);
|
|
105
|
+
u.searchParams.set('redirect_uri', this.redirectUri);
|
|
106
|
+
u.searchParams.set('response_type', 'code');
|
|
107
|
+
u.searchParams.set('scope', SCOPES);
|
|
108
|
+
u.searchParams.set('state', state);
|
|
109
|
+
return u.href;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The code comes back: exchange it, ask who this is, and only then keep it.
|
|
113
|
+
async complete({ state, code }) {
|
|
114
|
+
const pend = this._read('pending');
|
|
115
|
+
this._drop('pending');
|
|
116
|
+
if (!pend || pend.state !== state || Date.now() - pend.at > STATE_TTL_MS) throw new Error('that sign-in has expired — start again');
|
|
117
|
+
const { host } = pend;
|
|
118
|
+
const app = await this.appFor(host);
|
|
119
|
+
const res = await this.fetch(`https://${host}/oauth/token`, {
|
|
120
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
121
|
+
body: JSON.stringify({ grant_type: 'authorization_code', code, redirect_uri: this.redirectUri,
|
|
122
|
+
client_id: app.clientId, client_secret: app.clientSecret, scope: SCOPES }),
|
|
123
|
+
});
|
|
124
|
+
const tok = await this._json(res, host);
|
|
125
|
+
if (!tok.access_token) throw new Error(`${host} returned no token`);
|
|
126
|
+
const me = await this._json(await this.fetch(`https://${host}/api/v1/accounts/verify_credentials`,
|
|
127
|
+
{ headers: { authorization: `Bearer ${tok.access_token}` } }), host);
|
|
128
|
+
const account = { host, token: tok.access_token, handle: `@${me.username}@${host}`, name: me.display_name || me.username, url: me.url || null };
|
|
129
|
+
this._write('account', account);
|
|
130
|
+
return account;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// The id a post has on the reader's own server, found by its address.
|
|
134
|
+
async statusIdFor(url) {
|
|
135
|
+
const a = this.account();
|
|
136
|
+
if (!a) throw new Error('not signed in');
|
|
137
|
+
const u = new URL(`https://${a.host}/api/v2/search`);
|
|
138
|
+
u.searchParams.set('q', url); u.searchParams.set('resolve', 'true'); u.searchParams.set('type', 'statuses'); u.searchParams.set('limit', '1');
|
|
139
|
+
const body = await this._json(await this.fetch(u.href, { headers: { authorization: `Bearer ${a.token}` } }), a.host);
|
|
140
|
+
return body.statuses?.[0]?.id || null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Post from the reader's account: the category is named so it receives
|
|
144
|
+
// the post; a reply names the post it answers by its id on that server.
|
|
145
|
+
async edit({ url, text }) {
|
|
146
|
+
const a = this.account();
|
|
147
|
+
if (!a) throw new Error('not signed in');
|
|
148
|
+
const id = await this.statusIdFor(url);
|
|
149
|
+
if (!id) throw new Error(`${a.host} could not find that post`);
|
|
150
|
+
return this._json(await this.fetch(`https://${a.host}/api/v1/statuses/${id}`, {
|
|
151
|
+
method: 'PUT', headers: { 'content-type': 'application/json', authorization: `Bearer ${a.token}` },
|
|
152
|
+
body: JSON.stringify({ status: text }),
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async remove({ url }) {
|
|
157
|
+
const a = this.account();
|
|
158
|
+
if (!a) throw new Error('not signed in');
|
|
159
|
+
const id = await this.statusIdFor(url);
|
|
160
|
+
if (!id) throw new Error(`${a.host} could not find that post`);
|
|
161
|
+
return this._json(await this.fetch(`https://${a.host}/api/v1/statuses/${id}`, {
|
|
162
|
+
method: 'DELETE', headers: { authorization: `Bearer ${a.token}` },
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async post({ text, mention, inReplyToUrl = null }) {
|
|
167
|
+
const a = this.account();
|
|
168
|
+
if (!a) throw new Error('not signed in');
|
|
169
|
+
const status = mention && !text.includes(mention) ? `${mention} ${text}` : text;
|
|
170
|
+
const inReplyTo = inReplyToUrl ? await this.statusIdFor(inReplyToUrl) : null;
|
|
171
|
+
if (inReplyToUrl && !inReplyTo) throw new Error(`${a.host} could not find the post you are answering`);
|
|
172
|
+
const body = { status, visibility: 'public', ...(inReplyTo ? { in_reply_to_id: inReplyTo } : {}) };
|
|
173
|
+
const made = await this._json(await this.fetch(`https://${a.host}/api/v1/statuses`, {
|
|
174
|
+
method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${a.token}` },
|
|
175
|
+
body: JSON.stringify(body),
|
|
176
|
+
}), a.host);
|
|
177
|
+
return { id: made.id, url: made.url || made.uri || null, uri: made.uri || null };
|
|
178
|
+
}
|
|
179
|
+
}
|
package/site/mine.mjs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// mine.mjs — what a reader keeps for themselves: the categories they have
|
|
2
|
+
// joined and the posts they saved. Both live in this browser under the
|
|
3
|
+
// account that is signed in, because a public forum has nowhere to write
|
|
4
|
+
// "Mei saved this" and no business writing it. Nothing here is sent
|
|
5
|
+
// anywhere; a reader on another machine starts with an empty list.
|
|
6
|
+
|
|
7
|
+
const KEY = (who, what) => `bb:${what}:${who || 'anon'}`;
|
|
8
|
+
|
|
9
|
+
const read = (storage, who, what) => {
|
|
10
|
+
try { const v = JSON.parse(storage.getItem(KEY(who, what)) || '[]'); return Array.isArray(v) ? v : []; }
|
|
11
|
+
catch { return []; }
|
|
12
|
+
};
|
|
13
|
+
const write = (storage, who, what, list) => {
|
|
14
|
+
try { storage.setItem(KEY(who, what), JSON.stringify(list.slice(0, 500))); } catch { /* full or blocked */ }
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function mine({ storage, who }) {
|
|
18
|
+
return {
|
|
19
|
+
// Categories this reader has joined from here. The forum's own followers
|
|
20
|
+
// list is the truth; this is what this browser asked for, so the page can
|
|
21
|
+
// show Join or Leave without reading a collection per category.
|
|
22
|
+
joined: () => read(storage, who, 'joined'),
|
|
23
|
+
isJoined: (id) => read(storage, who, 'joined').includes(id),
|
|
24
|
+
join: (id) => write(storage, who, 'joined', [...new Set([...read(storage, who, 'joined'), id])]),
|
|
25
|
+
leave: (id) => write(storage, who, 'joined', read(storage, who, 'joined').filter(x => x !== id)),
|
|
26
|
+
|
|
27
|
+
// Which way this reader voted on a post. The forum's count is the truth;
|
|
28
|
+
// this is only so the buttons can show which one is theirs. A post is
|
|
29
|
+
// remembered in one list or the other, never both.
|
|
30
|
+
votedOn: (id) => (read(storage, who, 'liked').includes(id) ? 'up'
|
|
31
|
+
: read(storage, who, 'disliked').includes(id) ? 'down' : 'none'),
|
|
32
|
+
isLiked: (id) => read(storage, who, 'liked').includes(id),
|
|
33
|
+
vote: (id, way) => {
|
|
34
|
+
write(storage, who, 'liked', way === 'up'
|
|
35
|
+
? [...new Set([...read(storage, who, 'liked'), id])]
|
|
36
|
+
: read(storage, who, 'liked').filter(x => x !== id));
|
|
37
|
+
write(storage, who, 'disliked', way === 'down'
|
|
38
|
+
? [...new Set([...read(storage, who, 'disliked'), id])]
|
|
39
|
+
: read(storage, who, 'disliked').filter(x => x !== id));
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
// Posts kept to come back to.
|
|
43
|
+
saved: () => read(storage, who, 'saved'),
|
|
44
|
+
isSaved: (id) => read(storage, who, 'saved').some(x => x.id === id),
|
|
45
|
+
save: (entry) => write(storage, who, 'saved', [entry, ...read(storage, who, 'saved').filter(x => x.id !== entry.id)]),
|
|
46
|
+
unsave: (id) => write(storage, who, 'saved', read(storage, who, 'saved').filter(x => x.id !== id)),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// oidc-session.mjs — signing in to a pod: the session library bound to the
|
|
2
|
+
// FediPod app's database and client name, so the app and the forum on one
|
|
3
|
+
// site share one session.
|
|
4
|
+
import { solidOidcSession } from 'fediverse-session/oidc-session.mjs';
|
|
5
|
+
|
|
6
|
+
export const { beginLogin, completeLogin, getSession, signOut } = solidOidcSession({ dbName: 'fedipod-oidc', clientName: 'FediPod' });
|